"""Tests for .opencode/scripts/spec-status.py — spec phase oracle.

All gh/git calls are mocked via monkeypatch on the module's ``run_cmd``
helper. Filesystem checks (``docs/spec/*.md``) use ``tmp_path`` with
``monkeypatch`` on ``ss.SPEC_DIR`` and ``ss.META_FILE`` so each test sees
an isolated spec directory.

``get_repo_full_name`` is cached via ``functools.cache`` and called by
Phase 8 (``gh issue view --repo``). The ``_clear_repo_cache`` autouse
fixture clears the cache before and after each test; ``mock_run_cmd``
automatically includes a ``git remote get-url origin`` mock
(``GIT_REMOTE_MOCK``) so ``get_repo_full_name()`` works without extra
boilerplate.
"""

import importlib.util
import sys
from pathlib import Path

import pytest

SCRIPT_PATH = Path(__file__).resolve().parent.parent / ".opencode" / "scripts" / "spec-status.py"
spec = importlib.util.spec_from_file_location("spec_status", SCRIPT_PATH)
ss = importlib.util.module_from_spec(spec)
sys.modules["spec_status"] = ss
spec.loader.exec_module(ss)

GIT_REMOTE_MOCK: tuple[tuple[str, ...], tuple[int, str, str]] = (
    ("git", "remote"),
    (0, "https://github.com/slaid098/opencode-config.git\n", ""),
)


@pytest.fixture(autouse=True)
def _clear_repo_cache():
    """Clear ``functools.cache`` on ``get_repo_full_name`` before/after each test."""
    ss.get_repo_full_name.cache_clear()
    yield
    ss.get_repo_full_name.cache_clear()


def mock_run_cmd(responses: dict[tuple, tuple[int, str, str]]):
    """Factory: mock run_cmd matching by command prefix.

    Keys are tuples of leading args (e.g. ("gh", "issue", "view")),
    values are (returncode, stdout, stderr).

    Automatically includes a ``git remote get-url origin`` mock
    (``GIT_REMOTE_MOCK``) so ``get_repo_full_name()`` works without extra
    boilerplate in every test.
    """
    merged = {GIT_REMOTE_MOCK[0]: GIT_REMOTE_MOCK[1], **responses}

    def _mock(args: list[str]) -> tuple[int, str, str]:
        for prefix, result in merged.items():
            if tuple(args[: len(prefix)]) == tuple(prefix):
                return result
        return (1, "", f"unmocked call: {args}")

    return _mock


def _set_spec_dir(monkeypatch, tmp_path: Path) -> Path:
    """Point ``ss.SPEC_DIR`` and ``ss.META_FILE`` at ``tmp_path/spec``.

    Creates the directory so tests can drop files into it. Returns the
    spec dir path.
    """
    spec_dir = tmp_path / "spec"
    spec_dir.mkdir(parents=True, exist_ok=True)
    monkeypatch.setattr(ss, "SPEC_DIR", spec_dir)
    monkeypatch.setattr(ss, "META_FILE", spec_dir / "meta.md")
    return spec_dir


def _write_meta(spec_dir: Path, **fields) -> None:
    """Write a ``meta.md`` frontmatter file with the given key/value pairs."""
    lines = ["---"]
    for k, v in fields.items():
        value = ("true" if v else "false") if isinstance(v, bool) else str(v)
        lines.append(f"{k}: {value}")
    lines.extend(["---", ""])
    (spec_dir / "meta.md").write_text("\n".join(lines))


# ── parse_frontmatter ────────────────────────────────────────────────────────


def test_parse_frontmatter_empty():
    assert ss.parse_frontmatter("") == {}


def test_parse_frontmatter_no_delimiters():
    assert ss.parse_frontmatter("just text\nno frontmatter") == {}


def test_parse_frontmatter_basic():
    content = "---\nproject: foo\ntype: backend\n---\nbody"
    assert ss.parse_frontmatter(content) == {"project": "foo", "type": "backend"}


def test_parse_frontmatter_quoted_values():
    content = '---\nproject: "foo bar"\ntype: "backend"\n---\n'
    fm = ss.parse_frontmatter(content)
    assert fm["project"] == "foo bar"
    assert fm["type"] == "backend"


def test_parse_frontmatter_single_quoted_values():
    content = "---\nproject: 'foo bar'\ntype: 'backend'\n---\n"
    fm = ss.parse_frontmatter(content)
    assert fm["project"] == "foo bar"
    assert fm["type"] == "backend"


def test_parse_frontmatter_crlf():
    """CRLF line endings are supported (Windows-style meta.md)."""
    content = "---\r\nproject: foo\r\ntype: backend\r\n---\r\n"
    assert ss.parse_frontmatter(content) == {"project": "foo", "type": "backend"}


def test_parse_frontmatter_missing_trailing_newline():
    """Frontmatter without trailing newline (Write tool) is still parsed."""
    content = "---\nproject: foo\ntype: backend\n---"
    assert ss.parse_frontmatter(content) == {"project": "foo", "type": "backend"}


def test_parse_frontmatter_unbalanced_quote_preserved():
    """Unbalanced quotes are kept verbatim (no strip)."""
    content = '---\nproject: "foo\n---\n'
    assert ss.parse_frontmatter(content) == {"project": '"foo'}


def test_parse_frontmatter_multiline_body():
    content = "---\nproject: foo\n---\nline1\nline2\nline3\n"
    fm = ss.parse_frontmatter(content)
    assert fm == {"project": "foo"}


# ── parse_remote_url ─────────────────────────────────────────────────────────


def test_parse_remote_url_https():
    assert ss.parse_remote_url("https://github.com/slaid098/opencode-config.git") == (
        "github.com",
        "slaid098",
        "opencode-config",
    )


def test_parse_remote_url_https_no_git_suffix():
    assert ss.parse_remote_url("https://github.com/slaid098/opencode-config") == (
        "github.com",
        "slaid098",
        "opencode-config",
    )


def test_parse_remote_url_ssh():
    assert ss.parse_remote_url("git@github.com:slaid098/opencode-config.git") == (
        "github.com",
        "slaid098",
        "opencode-config",
    )


def test_parse_remote_url_ssh_no_git_suffix():
    assert ss.parse_remote_url("git@github.com:slaid098/opencode-config") == (
        "github.com",
        "slaid098",
        "opencode-config",
    )


def test_parse_remote_url_invalid():
    with pytest.raises(ValueError, match="Cannot parse remote URL"):
        ss.parse_remote_url("not-a-valid-url")


def test_parse_remote_url_https_with_userinfo():
    """URL с git insteadOf userinfo (x-access-token:TOKEN@host) — host без userinfo.

    Regression for PR#53/ADR-022: ``git config url.insteadOf`` rewrites
    ``https://github.com/`` to ``https://x-access-token:TOKEN@github.com/``,
    so ``git remote get-url origin`` returns the rewritten URL. The old regex
    greedily matched ``x-access-token:TOKEN@github.com`` as host, breaking
    ``get_repo_full_name`` (used in Phase 8 ``gh issue view --repo``).
    """
    assert ss.parse_remote_url(
        "https://x-access-token:github_pat_TOKEN@github.com/slaid098/opencode-config.git"
    ) == ("github.com", "slaid098", "opencode-config")


def test_parse_remote_url_https_without_userinfo():
    """Обычный HTTPS URL без userinfo работает (regression guard)."""
    assert ss.parse_remote_url("https://github.com/slaid098/opencode-config.git") == (
        "github.com",
        "slaid098",
        "opencode-config",
    )


# ── get_repo_full_name ───────────────────────────────────────────────────────


def test_get_repo_full_name_current_repo(monkeypatch):
    ss.get_repo_full_name.cache_clear()
    monkeypatch.setattr(
        ss,
        "run_cmd",
        mock_run_cmd(
            {("git", "remote"): (0, "https://github.com/slaid098/opencode-config.git\n", "")}
        ),
    )
    assert ss.get_repo_full_name() == "slaid098/opencode-config"
    ss.get_repo_full_name.cache_clear()


def test_get_repo_full_name_cached(monkeypatch):
    ss.get_repo_full_name.cache_clear()
    call_count = [0]

    def _counting_mock(args: list[str]) -> tuple[int, str, str]:
        if tuple(args[:2]) == ("git", "remote"):
            call_count[0] += 1
            return (0, "https://github.com/slaid098/opencode-config.git\n", "")
        return (1, "", f"unmocked: {args}")

    monkeypatch.setattr(ss, "run_cmd", _counting_mock)
    assert ss.get_repo_full_name() == "slaid098/opencode-config"
    assert ss.get_repo_full_name() == "slaid098/opencode-config"
    assert call_count[0] == 1
    ss.get_repo_full_name.cache_clear()


def test_get_repo_full_name_remote_error(monkeypatch):
    ss.get_repo_full_name.cache_clear()
    monkeypatch.setattr(
        ss, "run_cmd", mock_run_cmd({("git", "remote"): (1, "", "not a git repository")})
    )
    with pytest.raises(RuntimeError, match="Cannot get git remote URL"):
        ss.get_repo_full_name()
    ss.get_repo_full_name.cache_clear()


def test_get_repo_full_name_with_userinfo(monkeypatch):
    """get_repo_full_name работает когда remote URL содержит userinfo.

    Regression for PR#53/ADR-022: ``git remote get-url origin`` returns
    ``https://x-access-token:TOKEN@github.com/...`` after insteadOf. The
    fix strips userinfo so ``get_repo_full_name()`` returns the correct
    ``org/repo`` (used in Phase 8 ``gh issue view --repo``).
    """
    ss.get_repo_full_name.cache_clear()
    monkeypatch.setattr(
        ss,
        "run_cmd",
        mock_run_cmd(
            {
                ("git", "remote"): (
                    0,
                    "https://x-access-token:github_pat_TOKEN@github.com/slaid098/opencode-config.git\n",
                    "",
                ),
            }
        ),
    )
    assert ss.get_repo_full_name() == "slaid098/opencode-config"
    ss.get_repo_full_name.cache_clear()


# ── _resolve_repo_root ───────────────────────────────────────────────────────


def test_resolve_repo_root_via_git(monkeypatch, tmp_path):
    fake_root = tmp_path / "some-repo"
    fake_root.mkdir()

    class _FakeResult:
        returncode = 0
        stdout = f"{fake_root}\n"
        stderr = ""

    def fake_run(args, **kwargs):
        if tuple(args[:3]) == ("git", "rev-parse", "--show-toplevel"):
            return _FakeResult()
        raise AssertionError(f"unmocked: {args}")

    monkeypatch.setattr(ss.subprocess, "run", fake_run)
    root = ss._resolve_repo_root()
    assert root == fake_root.resolve()


def test_resolve_repo_root_fallback_to_file(monkeypatch):
    class _FakeResult:
        returncode = 1
        stdout = ""
        stderr = "not a git repo"

    def fake_run(args, **kwargs):
        if tuple(args[:3]) == ("git", "rev-parse", "--show-toplevel"):
            return _FakeResult()
        raise AssertionError(f"unmocked: {args}")

    monkeypatch.setattr(ss.subprocess, "run", fake_run)
    root = ss._resolve_repo_root()
    expected = Path(ss.__file__).resolve().parent.parent.parent
    assert root == expected


# ── file helpers ─────────────────────────────────────────────────────────────


def test_file_filled_missing(tmp_path):
    assert not ss.file_filled(tmp_path / "nope.md")


def test_file_filled_empty(tmp_path):
    f = tmp_path / "empty.md"
    f.write_text("   \n\n")
    assert not ss.file_filled(f)


def test_file_filled_ok(tmp_path):
    f = tmp_path / "ok.md"
    f.write_text("content")
    assert ss.file_filled(f)


def test_has_bullet_items_missing(tmp_path):
    assert not ss.has_bullet_items(tmp_path / "nope.md")


def test_has_bullet_items_no_bullet(tmp_path):
    f = tmp_path / "no_bullet.md"
    f.write_text("just text\nno bullets here")
    assert not ss.has_bullet_items(f)


def test_has_bullet_items_dash(tmp_path):
    f = tmp_path / "dash.md"
    f.write_text("- item one\n- item two\n")
    assert ss.has_bullet_items(f)


def test_has_bullet_items_star(tmp_path):
    f = tmp_path / "star.md"
    f.write_text("* item one\n")
    assert ss.has_bullet_items(f)


# ── read_meta (BOM + combined) ──────────────────────────────────────────────


def test_read_meta_strips_bom(monkeypatch, tmp_path):
    """BOM (utf-8-sig) is stripped so frontmatter parsing still works."""
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    meta = spec_dir / "meta.md"
    meta.write_bytes(b"\xef\xbb\xbf---\nproject: foo\ntype: backend\n---\n")
    _content, fm = ss.read_meta()
    assert fm == {"project": "foo", "type": "backend"}


def test_read_meta_combined_bom_crlf_quotes(monkeypatch, tmp_path):
    """BOM + CRLF + quoted values parsed together (regression combo)."""
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    meta = spec_dir / "meta.md"
    meta.write_bytes(b"\xef\xbb\xbf---\r\nproject: \"foo bar\"\r\ntype: 'backend'\r\n---\r\n")
    _content, fm = ss.read_meta()
    assert fm == {"project": "foo bar", "type": "backend"}


# ── check_detect (Phase 0) ───────────────────────────────────────────────────


def test_check_detect_no_meta(monkeypatch, tmp_path):
    _set_spec_dir(monkeypatch, tmp_path)
    # Ensure meta.md does not exist
    assert not (tmp_path / "spec" / "meta.md").exists()
    result = ss.check_detect()
    assert result.status == ss.PhaseStatus.NOT_DONE
    assert "meta.md" in result.detail


def test_check_detect_empty_meta(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    (spec_dir / "meta.md").write_text("")
    result = ss.check_detect()
    assert result.status == ss.PhaseStatus.NOT_DONE


def test_check_detect_no_frontmatter(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    (spec_dir / "meta.md").write_text("just some text\nwithout frontmatter\n")
    result = ss.check_detect()
    assert result.status == ss.PhaseStatus.NOT_DONE


def test_check_detect_no_project_key(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, type="backend", created="2026-07-20")
    result = ss.check_detect()
    assert result.status == ss.PhaseStatus.NOT_DONE
    assert "project" in result.detail


def test_check_detect_ok(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="foo", type="backend")
    result = ss.check_detect()
    assert result.status == ss.PhaseStatus.DONE


# ── check_project_type (Phase 1) ─────────────────────────────────────────────


def test_check_project_type_empty_type(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="foo", type="")
    result = ss.check_project_type()
    assert result.status == ss.PhaseStatus.NOT_DONE


def test_check_project_type_invalid_type(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="foo", type="unknown-type")
    result = ss.check_project_type()
    assert result.status == ss.PhaseStatus.NOT_DONE
    assert "unknown-type" in result.detail


def test_check_project_type_empty_project(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="", type="backend")
    result = ss.check_project_type()
    assert result.status == ss.PhaseStatus.NOT_DONE


def test_check_project_type_no_context_md(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="foo", type="backend")
    # No context.md
    result = ss.check_project_type()
    assert result.status == ss.PhaseStatus.NOT_DONE
    assert "context.md" in result.detail


def test_check_project_type_empty_context_md(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="foo", type="backend")
    (spec_dir / "context.md").write_text("   \n")
    result = ss.check_project_type()
    assert result.status == ss.PhaseStatus.NOT_DONE


def test_check_project_type_ok(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="foo", type="backend")
    (spec_dir / "context.md").write_text("A backend project for foo\n")
    result = ss.check_project_type()
    assert result.status == ss.PhaseStatus.DONE
    assert "backend" in result.detail


# ── check_stack (Phase 2) ────────────────────────────────────────────────────


def test_check_stack_no_stack_md(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="foo", type="backend")
    result = ss.check_stack()
    assert result.status == ss.PhaseStatus.NOT_DONE
    assert "stack.md" in result.detail


def test_check_stack_missing_items(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="foo", type="backend")
    (spec_dir / "stack.md").write_text("- only fastapi\n- nothing else\n")
    result = ss.check_stack()
    assert result.status == ss.PhaseStatus.NOT_DONE
    assert "tortoise" in result.detail


@pytest.mark.parametrize(
    "ptype",
    [
        "backend",
        "fullstack",
        "mcp-server",
        "cli",
        "bot",
        "worker",
    ],
)
def test_check_stack_ok_all_types(monkeypatch, tmp_path, ptype):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="foo", type=ptype)
    required = ss.STACK_REQUIRED[ptype]
    (spec_dir / "stack.md").write_text("\n".join(f"- {item}" for item in required) + "\n")
    result = ss.check_stack()
    assert result.status == ss.PhaseStatus.DONE, f"{ptype}: {result.detail}"


def test_check_stack_invalid_type(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="foo", type="unknown")
    result = ss.check_stack()
    assert result.status == ss.PhaseStatus.NOT_DONE
    assert "невалиден" in result.detail


def test_check_stack_case_insensitive(monkeypatch, tmp_path):
    """STACK_REQUIRED substring match is case-insensitive."""
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="foo", type="backend")
    (spec_dir / "stack.md").write_text(
        "- FastAPI\n- Tortoise ORM\n- UV\n- Pytest\n- Ruff\n- Mypy\n- Loguru\n- Pydantic\n"
    )
    result = ss.check_stack()
    assert result.status == ss.PhaseStatus.DONE


def test_check_stack_word_boundary_uv_not_in_uvicorn(monkeypatch, tmp_path):
    """Word-boundary regex: ``uv`` does NOT match ``uvicorn`` (issue #266).

    stack.md contains ``uvicorn`` but no standalone ``uv`` → NOT_DONE with
    ``uv`` in the missing list. Previously the substring match (``"uv" in
    "uvicorn"``) gave a false positive.
    """
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="foo", type="backend")
    # ``uvicorn`` contains the substring ``uv`` but \buv\b does not match it.
    (spec_dir / "stack.md").write_text(
        "- fastapi\n- tortoise\n- uvicorn\n- pytest\n- ruff\n- mypy\n- loguru\n- pydantic\n"
    )
    result = ss.check_stack()
    assert result.status == ss.PhaseStatus.NOT_DONE, (
        f"uvicorn should NOT satisfy 'uv' (word-boundary), got: {result.detail}"
    )
    assert "uv" in result.detail


# ── check_modules (Phase 3) ──────────────────────────────────────────────────


def test_check_modules_no_file(monkeypatch, tmp_path):
    _set_spec_dir(monkeypatch, tmp_path)
    result = ss.check_modules()
    assert result.status == ss.PhaseStatus.NOT_DONE


def test_check_modules_empty(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    (spec_dir / "modules.md").write_text("  \n\n")
    result = ss.check_modules()
    assert result.status == ss.PhaseStatus.NOT_DONE


def test_check_modules_no_bullet(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    (spec_dir / "modules.md").write_text("just prose\nno bullets")
    result = ss.check_modules()
    assert result.status == ss.PhaseStatus.NOT_DONE


def test_check_modules_ok(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    (spec_dir / "modules.md").write_text("- auth\n- users\n- posts\n")
    result = ss.check_modules()
    assert result.status == ss.PhaseStatus.DONE
    assert "3" in result.detail


# ── check_db_schema (Phase 4) ────────────────────────────────────────────────


def test_check_db_schema_no_db_true(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="foo", type="cli", no_db="true")
    # db-schema.md intentionally absent
    result = ss.check_db_schema()
    assert result.status == ss.PhaseStatus.DONE
    assert "no_db" in result.detail


def test_check_db_schema_no_file(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="foo", type="backend")
    result = ss.check_db_schema()
    assert result.status == ss.PhaseStatus.NOT_DONE


def test_check_db_schema_empty(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="foo", type="backend")
    (spec_dir / "db-schema.md").write_text("   \n\n")
    result = ss.check_db_schema()
    assert result.status == ss.PhaseStatus.NOT_DONE


def test_check_db_schema_ok(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="foo", type="backend")
    (spec_dir / "db-schema.md").write_text("User: id, name\nPost: id, title\n")
    result = ss.check_db_schema()
    assert result.status == ss.PhaseStatus.DONE


# ── check_infra (Phase 5) ────────────────────────────────────────────────────


def test_check_infra_no_file(monkeypatch, tmp_path):
    _set_spec_dir(monkeypatch, tmp_path)
    result = ss.check_infra()
    assert result.status == ss.PhaseStatus.NOT_DONE


def test_check_infra_empty(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    (spec_dir / "infra.md").write_text("  \n\n")
    result = ss.check_infra()
    assert result.status == ss.PhaseStatus.NOT_DONE


def test_check_infra_short(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    (spec_dir / "infra.md").write_text("ab")
    result = ss.check_infra()
    assert result.status == ss.PhaseStatus.NOT_DONE
    assert "<3" in result.detail


def test_check_infra_ok(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    (spec_dir / "infra.md").write_text("Docker compose, Prefect\n")
    result = ss.check_infra()
    assert result.status == ss.PhaseStatus.DONE


# ── check_roadmap (Phase 6) ──────────────────────────────────────────────────


def test_check_roadmap_no_file(monkeypatch, tmp_path):
    _set_spec_dir(monkeypatch, tmp_path)
    result = ss.check_roadmap()
    assert result.status == ss.PhaseStatus.NOT_DONE


def test_check_roadmap_empty(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    (spec_dir / "roadmap.md").write_text("  \n\n")
    result = ss.check_roadmap()
    assert result.status == ss.PhaseStatus.NOT_DONE


def test_check_roadmap_no_bullet(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    (spec_dir / "roadmap.md").write_text("just prose\nno bullets")
    result = ss.check_roadmap()
    assert result.status == ss.PhaseStatus.NOT_DONE


def test_check_roadmap_ok(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    (spec_dir / "roadmap.md").write_text("- scaffolding\n- auth module\n- users module\n")
    result = ss.check_roadmap()
    assert result.status == ss.PhaseStatus.DONE
    assert "3" in result.detail


# ── check_confirm (Phase 7) ──────────────────────────────────────────────────


def test_check_confirm_true(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="foo", type="backend", confirmed="true")
    result = ss.check_confirm()
    assert result.status == ss.PhaseStatus.DONE


def test_check_confirm_false(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="foo", type="backend", confirmed="false")
    result = ss.check_confirm()
    assert result.status == ss.PhaseStatus.NOT_DONE


def test_check_confirm_missing_key(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="foo", type="backend")
    result = ss.check_confirm()
    assert result.status == ss.PhaseStatus.NOT_DONE


# ── check_execute (Phase 8) ──────────────────────────────────────────────────


def _setup_execute_spec(spec_dir: Path, *, executed: str = "true") -> None:
    """Write a spec where Phases 0-7 are DONE and roadmap has #N references."""
    _write_meta(
        spec_dir,
        project="foo",
        type="backend",
        confirmed="true",
        executed=executed,
    )
    (spec_dir / "roadmap.md").write_text("- #1 scaffolding\n- #2 auth\n- #3 users\n")


def test_check_execute_not_executed(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="foo", type="backend", executed="false")
    result = ss.check_execute()
    assert result.status == ss.PhaseStatus.NOT_DONE
    assert "executed" in result.detail


def test_check_execute_no_roadmap_file(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="foo", type="backend", executed="true")
    # No roadmap.md
    result = ss.check_execute()
    assert result.status == ss.PhaseStatus.NOT_DONE
    assert "roadmap.md" in result.detail


def test_check_execute_no_issue_refs(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _write_meta(spec_dir, project="foo", type="backend", executed="true")
    (spec_dir / "roadmap.md").write_text("- scaffolding\n- auth\n")  # No #N refs
    result = ss.check_execute()
    assert result.status == ss.PhaseStatus.NOT_DONE
    assert "#N" in result.detail


def test_check_execute_issues_found(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _setup_execute_spec(spec_dir)
    monkeypatch.setattr(
        ss,
        "run_cmd",
        mock_run_cmd({("gh", "issue", "view"): (0, "issue body", "")}),
    )
    result = ss.check_execute()
    assert result.status == ss.PhaseStatus.DONE
    assert "3" in result.detail


def test_check_execute_issues_missing(monkeypatch, tmp_path):
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _setup_execute_spec(spec_dir)
    monkeypatch.setattr(
        ss,
        "run_cmd",
        mock_run_cmd({("gh", "issue", "view"): (1, "", "not found")}),
    )
    result = ss.check_execute()
    assert result.status == ss.PhaseStatus.NOT_DONE
    assert "#1" in result.detail
    assert "#2" in result.detail
    assert "#3" in result.detail


def test_check_execute_no_remote(monkeypatch, tmp_path):
    """git remote fails → AMBIGUOUS."""
    spec_dir = _set_spec_dir(monkeypatch, tmp_path)
    _setup_execute_spec(spec_dir)
    ss.get_repo_full_name.cache_clear()
    monkeypatch.setattr(
        ss,
        "run_cmd",
        mock_run_cmd({("git", "remote"): (1, "", "not a git repository")}),
    )
    result = ss.check_execute()
    assert result.status == ss.PhaseStatus.AMBIGUOUS
    assert "git remote error" in result.detail
    ss.get_repo_full_name.cache_clear()


# ── find_current_phase ───────────────────────────────────────────────────────


def test_find_current_phase_all_done():
    results = [ss.PhaseResult(ss.PhaseStatus.DONE, "")] * 9
    assert ss.find_current_phase(results) is None


def test_find_current_phase_first_not_done():
    results = [ss.PhaseResult(ss.PhaseStatus.NOT_DONE, "")] + [
        ss.PhaseResult(ss.PhaseStatus.DONE, "")
    ] * 8
    assert ss.find_current_phase(results) == 0


def test_find_current_phase_middle():
    results = (
        [ss.PhaseResult(ss.PhaseStatus.DONE, "")] * 3
        + [ss.PhaseResult(ss.PhaseStatus.NOT_DONE, "")]
        + [ss.PhaseResult(ss.PhaseStatus.DONE, "")] * 5
    )
    assert ss.find_current_phase(results) == 3


# ── format_output ────────────────────────────────────────────────────────────


def test_format_output_complete(monkeypatch):
    results = [ss.PhaseResult(ss.PhaseStatus.DONE, "ok")] * 9
    fm = {"project": "test-project"}
    monkeypatch.setattr("sys.argv", ["spec-status.py"])
    output = ss.format_output(results, fm)
    assert "Status: COMPLETE" in output
    assert "Spec: test-project" in output


def test_format_output_next_action(monkeypatch):
    results = (
        [ss.PhaseResult(ss.PhaseStatus.DONE, "ok")] * 3
        + [ss.PhaseResult(ss.PhaseStatus.NOT_DONE, "missing")]
        + [ss.PhaseResult(ss.PhaseStatus.NOT_DONE, "")] * 5
    )
    fm = {"project": "test-project"}
    monkeypatch.setattr("sys.argv", ["spec-status.py"])
    output = ss.format_output(results, fm)
    assert "NEXT:" in output
    assert "Phase 3" in output


def test_format_output_ambiguous(monkeypatch):
    results = (
        [ss.PhaseResult(ss.PhaseStatus.DONE, "ok")] * 2
        + [ss.PhaseResult(ss.PhaseStatus.AMBIGUOUS, "remote error")]
        + [ss.PhaseResult(ss.PhaseStatus.NOT_DONE, "")] * 6
    )
    fm = {"project": "test-project"}
    monkeypatch.setattr("sys.argv", ["spec-status.py"])
    output = ss.format_output(results, fm)
    assert "AMBIGUOUS" in output
    assert "уточните статус вручную" in output


def test_format_output_unnamed_project(monkeypatch):
    results = [ss.PhaseResult(ss.PhaseStatus.NOT_DONE, "")] * 9
    fm = {}
    monkeypatch.setattr("sys.argv", ["spec-status.py"])
    output = ss.format_output(results, fm)
    assert "Spec: <unnamed>" in output


def test_format_output_validate_shows_done_details(monkeypatch):
    """--validate flag shows full detail even for DONE phases."""
    results = [ss.PhaseResult(ss.PhaseStatus.DONE, "specific detail")] * 9
    fm = {"project": "test-project"}
    monkeypatch.setattr("sys.argv", ["spec-status.py", "--validate"])
    output = ss.format_output(results, fm)
    # In non-validate mode DONE phases show "done" only; --validate shows detail.
    assert "specific detail" in output


# ── main ─────────────────────────────────────────────────────────────────────


def test_main_no_spec(monkeypatch, tmp_path, capsys):
    """Running main() with no docs/spec/ → 9 ❌ lines + NEXT, exit 0."""
    _set_spec_dir(monkeypatch, tmp_path)
    monkeypatch.setattr("sys.argv", ["spec-status.py"])
    ss.main()
    captured = capsys.readouterr()
    assert "Spec: <unnamed>" in captured.out
    assert "NEXT:" in captured.out
    assert "DETECT" in captured.out
