"""Tests for agent.coding_context — RuntimeMode seam, resolver, toolset, git probe."""

import json
import os
import subprocess
import shutil
from pathlib import Path

import pytest

from agent import coding_context as cc


def test_coding_guidance_advertises_persistent_terminal_state():
    assert "Terminal state persists across calls" in cc.CODING_AGENT_GUIDANCE
    assert "Activate a virtualenv" in cc.CODING_AGENT_GUIDANCE
    assert "instead of re-sourcing it before every test command" in cc.CODING_AGENT_GUIDANCE


def _git_init(path):
    env = {
        "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
        "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
        "HOME": str(path),
    }
    # Commit a source file so the fixture is a real *code* workspace: a bare git
    # repo with no code no longer flips into the coding posture (see
    # _detect_profile_name / _has_code_files), so "a code repo" needs code.
    (Path(path) / "main.py").write_text("print('hi')\n")
    for args in (
        ["init", "-q", "-b", "main"],
        ["add", "-A"],
        ["commit", "-q", "-m", "init commit"],
    ):
        subprocess.run([shutil.which("git"), "-C", str(path), *args], check=True, env=env)


# ── resolver ──────────────────────────────────────────────────────────────

class TestIsCodingContext:



    def test_auto_bare_git_repo_without_code_stays_general(self, tmp_path):
        # A git repo of only prose (notes/writing/research — a big non-coding use
        # case) is NOT a code workspace: .git alone must not flip the posture.
        cfg = {"agent": {"coding_context": "auto"}}
        env = {
            "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
            "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t", "HOME": str(tmp_path),
        }
        (tmp_path / "notes.md").write_text("# my novel\n")
        for args in (["init", "-q", "-b", "main"], ["add", "-A"], ["commit", "-q", "-m", "notes"]):
            subprocess.run([shutil.which("git"), "-C", str(tmp_path), *args], check=True, env=env)

        assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is False
        # …but adding a manifest or source file makes it a code workspace.
        (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
        assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True


    def test_default_mode_is_auto(self, tmp_path):
        # Unknown/missing value normalizes to auto.
        _git_init(tmp_path)
        assert cc.is_coding_context(platform="cli", cwd=tmp_path, config={}) is True


# ── toolset substitution ────────────────────────────────────────────────────

class TestCodingSelection:
    def test_selects_coding_under_focus(self, tmp_path):
        _git_init(tmp_path)
        cfg = {"agent": {"coding_context": "focus"}}
        out = cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg)
        assert out is not None
        assert out[0] == cc.CODING_TOOLSET

    def test_auto_is_prompt_only(self, tmp_path):
        # Default posture must never override the user's configured toolsets —
        # off-by-default toolsets are already off, and explicit opt-ins
        # (image-gen, spotify, …) survive entering a code workspace.
        _git_init(tmp_path)
        cfg = {"agent": {"coding_context": "auto"}}
        assert cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg) is None
        # …while the prompt posture is still active.
        assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True






# ── git/workspace probe ─────────────────────────────────────────────────────

class TestWorkspaceBlock:
    def test_empty_outside_repo(self, tmp_path):
        assert cc.build_coding_workspace_block(tmp_path) == ""

    def test_reports_branch_and_clean_status(self, tmp_path):
        _git_init(tmp_path)
        block = cc.build_coding_workspace_block(tmp_path)
        assert "Workspace" in block
        assert f"Root: {tmp_path.resolve()}" in block or "Root:" in block
        assert "Branch: main" in block
        assert "Status: clean" in block
        assert "init commit" in block

    def test_reports_dirty_counts(self, tmp_path):
        _git_init(tmp_path)
        (tmp_path / "untracked.txt").write_text("hi")
        block = cc.build_coding_workspace_block(tmp_path)
        assert "untracked" in block
        assert "clean" not in block.split("Status:")[1].splitlines()[0]


# ── project facts (verify-loop detection) ───────────────────────────────────

class TestProjectFacts:




    def test_worktree_detected_without_primary_path(self, tmp_path):
        # A linked worktree should be detected, but the output must NOT contain
        # the absolute path to the primary tree — exposing that path causes the
        # model to sometimes run commands in the wrong directory.
        main_tree = tmp_path / "main"
        main_tree.mkdir()
        _git_init(main_tree)
        worktree = tmp_path / "worktree"
        subprocess.run(
            ["git", "-C", str(main_tree), "worktree", "add", "-b", "wt-branch", str(worktree)],
            check=True,
            env={"PATH": os.environ.get("PATH", ""), "HOME": str(tmp_path),
                 "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
                 "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"},
        )
        block = cc.build_coding_workspace_block(worktree)
        assert "Worktree: linked" in block
        # The primary tree path must NOT appear anywhere in the output.
        assert str(main_tree.resolve()) not in block
        assert str(main_tree) not in block
        # The worktree root IS the reported root.
        assert f"Root: {worktree.resolve()}" in block or "Root:" in block



    def test_detect_project_facts_structured(self, tmp_path):
        (tmp_path / "package.json").write_text(
            json.dumps({"scripts": {"test": "vitest", "dev": "vite"}})
        )
        (tmp_path / "pnpm-lock.yaml").write_text("")
        facts = cc.detect_project_facts(tmp_path)
        assert facts.manifests == ["package.json"]
        assert facts.package_managers == ["pnpm"]
        assert facts.verify_commands == ["pnpm run test"]  # dev excluded
        assert facts.context_files == []

    def test_project_facts_for_matches_prompt_block(self, tmp_path):
        # Invariant: the structured facts the UI consumes must not drift from the
        # commands the prompt snapshot renders — one detector feeds both.
        _git_init(tmp_path)
        (tmp_path / "package.json").write_text(
            json.dumps({"scripts": {"test": "vitest", "lint": "eslint ."}})
        )
        (tmp_path / "pnpm-lock.yaml").write_text("")
        facts = cc.project_facts_for(tmp_path)
        assert facts is not None
        verify_line = cc.build_coding_workspace_block(tmp_path).split("Verify:")[1].splitlines()[0]
        assert facts["verifyCommands"]
        for cmd in facts["verifyCommands"]:
            assert cmd in verify_line



# ── $HOME dotfiles guard ────────────────────────────────────────────────────

class TestHomeDotfilesGuard:
    def test_dotfiles_repo_at_home_is_not_coding(self, tmp_path, monkeypatch):
        home = tmp_path / "home"
        home.mkdir()
        _git_init(home)
        monkeypatch.setattr(Path, "home", lambda: home)
        cfg = {"agent": {"coding_context": "auto"}}
        assert cc.is_coding_context(platform="cli", cwd=home, config=cfg) is False
        # …and a plain subdirectory of the dotfiles repo stays general too.
        docs = home / "Documents"
        docs.mkdir()
        assert cc.is_coding_context(platform="cli", cwd=docs, config=cfg) is False


    def test_real_project_under_dotfiles_home_still_detects(self, tmp_path, monkeypatch):
        home = tmp_path / "home"
        home.mkdir()
        _git_init(home)
        monkeypatch.setattr(Path, "home", lambda: home)
        proj = home / "www" / "app"
        proj.mkdir(parents=True)
        (proj / "package.json").write_text("{}")
        cfg = {"agent": {"coding_context": "auto"}}
        assert cc.is_coding_context(platform="cli", cwd=proj, config=cfg) is True



# ── prompt assembly integration ─────────────────────────────────────────────

class TestStatusParsing:
    def test_parse_status_counts_and_branch(self):
        porcelain = (
            "# branch.head feature\n"
            "# branch.upstream origin/feature\n"
            "# branch.ab +2 -1\n"
            "1 M. N... 100644 100644 100644 aaa bbb staged.py\n"
            "1 .M N... 100644 100644 100644 ccc ddd modified.py\n"
            "? new.py\n"
            "u UU N... 1 2 3 abc def conflict.py\n"
        )
        branch, counts = cc._parse_status(porcelain)
        assert branch["head"] == "feature"
        assert branch["upstream"] == "origin/feature"
        assert branch["ahead"] == "2" and branch["behind"] == "1"
        assert counts["staged"] == 1
        assert counts["modified"] == 1
        assert counts["untracked"] == 1
        assert counts["conflicts"] == 1


# ── RuntimeMode seam ────────────────────────────────────────────────────────

class TestRuntimeMode:
    def test_resolves_coding_in_repo(self, tmp_path):
        _git_init(tmp_path)
        mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={})
        assert mode.is_coding is True
        assert mode.kind == "coding"
        assert mode.profile is cc.CODING_PROFILE

    def test_resolves_general_outside_workspace(self, tmp_path):
        mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={})
        assert mode.is_coding is False
        assert mode.kind == "general"
        # General posture pins no toolset and injects no blocks.
        assert mode.toolset_selection() is None
        assert mode.system_blocks() == []





    def test_no_instructions_block_when_unset(self, tmp_path):
        _git_init(tmp_path)
        mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={"agent": {"coding_context": "on"}})
        assert not any("Operator instructions" in b for b in mode.system_blocks())



# ── edit-format steering (per-model harness tuning) ──────────────────────────

class TestEditFormatSteering:
    def test_family_detection(self):
        assert cc._model_family("openai/gpt-5.4") == "patch"
        assert cc._model_family("openai/codex-mini") == "patch"
        assert cc._model_family("anthropic/claude-opus-4.8") == "replace"
        assert cc._model_family("anthropic/claude-sonnet-4") == "replace"
        # Gemini + open-weight coding models (RL'd on str_replace-style
        # editors) steer to replace, not neutral.
        for m in (
            "google/gemini-3-pro", "deepseek-v3.2", "qwen3-coder",
            "moonshot/kimi-k2", "zai/glm-4.6", "nousresearch/hermes-4-405b",
        ):
            assert cc._model_family(m) == "replace"
        # Unknown family and no model both fall through to neutral wording.
        assert cc._model_family("acme/foo-1") is None
        assert cc._model_family(None) is None
        assert cc._model_family("") is None

    def test_openai_family_gets_v4a_nudge(self, tmp_path):
        _git_init(tmp_path)
        mode = cc.resolve_runtime_mode(
            platform="cli", cwd=tmp_path,
            config={"agent": {"coding_context": "on"}}, model="openai/gpt-5.4",
        )
        brief = mode.system_blocks()[0]
        assert "mode='patch'" in brief
        assert "V4A" in brief
        assert "write_file" in brief  # new files authored, not patched
        # Codex-family harnesses ship apply_patch (V4A) as the ONLY editor and
        # instruct it even for single-file edits — never nudge replace mode.
        assert "single-file" in brief
        assert "mode='replace'" not in brief






# ── profile registry ────────────────────────────────────────────────────────

class TestProfiles:


    def test_coding_profile_shape(self):
        # The coding profile declares the seams other domains read.
        assert cc.CODING_PROFILE.toolset == cc.CODING_TOOLSET
        assert cc.CODING_PROFILE.guidance
        assert cc.CODING_PROFILE.model_hint == "coding"
        # General is inert.
        assert cc.GENERAL_PROFILE.toolset is None
        assert cc.GENERAL_PROFILE.guidance == ""

    def test_skill_demotion_gated_on_focus(self, tmp_path):
        # Names-only demotion is opt-in via focus mode — the default (auto)
        # and forced (on) postures leave the skill index untouched. Under
        # focus, clearly-non-coding categories are demoted (never hidden) and
        # coding-adjacent ones keep full entries (deny-list semantics).
        _git_init(tmp_path)
        for raw in ("auto", "on"):
            mode = cc.resolve_runtime_mode(
                platform="cli", cwd=tmp_path, config={"agent": {"coding_context": raw}}
            )
            assert mode.is_coding is True
            assert mode.compact_skill_categories() == frozenset()
        focus = cc.resolve_runtime_mode(
            platform="cli", cwd=tmp_path,
            config={"agent": {"coding_context": "focus"}},
        )
        assert focus.is_coding is True
        compact = focus.compact_skill_categories()
        assert "social-media" in compact and "smart-home" in compact
        for kept in ("github", "devops", "software-development", "data-science"):
            assert kept not in compact
        # General posture demotes nothing.
        general = cc.resolve_runtime_mode(platform="telegram", cwd=tmp_path, config={})
        assert general.compact_skill_categories() == frozenset()


# ── detection signals ───────────────────────────────────────────────────────

class TestDetection:
    @pytest.mark.parametrize("marker", ["pyproject.toml", "package.json", "go.mod", "AGENTS.md"])
    def test_project_manifest_triggers_without_git(self, tmp_path, marker):
        (tmp_path / marker).write_text("x")
        cfg = {"agent": {"coding_context": "auto"}}
        assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True

    def test_marker_in_parent_counts_from_subdir(self, tmp_path):
        (tmp_path / "pyproject.toml").write_text("x")
        sub = tmp_path / "src" / "pkg"
        sub.mkdir(parents=True)
        cfg = {"agent": {"coding_context": "auto"}}
        assert cc.is_coding_context(platform="cli", cwd=sub, config=cfg) is True

    def test_bare_dir_is_not_coding(self, tmp_path):
        cfg = {"agent": {"coding_context": "auto"}}
        assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is False
