"""Tests for context/agents.py — canonical ⇄ runtime sub-agent fan-out."""

import shutil
import tomllib

import pytest

from memtomem.context.agents import (
    AGENT_GENERATORS,
    CANONICAL_AGENT_ROOT,
    AgentParseError,
    StrictDropError,
    _runtime_agent_names,
    _toml_escape_basic_string,
    _toml_escape_multiline_string,
    canonical_agent_name,
    diff_agents,
    extract_agents_to_canonical,
    generate_all_agents,
    list_canonical_agents,
    parse_canonical_agent,
)
from memtomem.context.detector import detect_agent_dirs
from .helpers import set_home

SAMPLE_FULL_AGENT = """---
name: code-reviewer
description: Reviews staged code for quality
tools: [Read, Grep, Glob]
model: sonnet
skills: [code-review]
isolation: worktree
kind: reviewer
temperature: 0.2
---

You are a meticulous code reviewer.
Respond with a prioritized punch list.
"""

SAMPLE_MINIMAL_AGENT = """---
name: helper
description: Generic helper
---

Help with things.
"""


@pytest.fixture
def codex_home(tmp_path, monkeypatch):
    """Redirect HOME as a defensive guard.

    Codex agents are now project-scope (``<project>/.codex/agents/``), so this
    fixture's primary job — keeping writes off the real ``~/.codex/agents/`` —
    is largely moot for the agents pipeline. It's kept on a few tests so that
    any code path that still calls ``Path.home()`` (e.g. user-scope settings,
    or a future regression) cannot leak onto the real host home.
    """
    fake_home = tmp_path / "home"
    fake_home.mkdir()
    set_home(monkeypatch, fake_home)
    return fake_home


def _make_canonical_agent(project_root, name, body=SAMPLE_FULL_AGENT):
    root = project_root / CANONICAL_AGENT_ROOT
    root.mkdir(parents=True, exist_ok=True)
    path = root / f"{name}.md"
    path.write_text(body, encoding="utf-8")
    return path


class TestParseCanonicalAgent:
    def test_parses_all_fields(self, tmp_path):
        p = _make_canonical_agent(tmp_path, "code-reviewer", SAMPLE_FULL_AGENT)
        agent = parse_canonical_agent(p)
        assert agent.name == "code-reviewer"
        assert "quality" in agent.description
        assert agent.tools == ["Read", "Grep", "Glob"]
        assert agent.model == "sonnet"
        assert agent.skills == ["code-review"]
        assert agent.isolation == "worktree"
        assert agent.kind == "reviewer"
        assert agent.temperature == 0.2
        assert "meticulous code reviewer" in agent.body

    def test_parses_minimal(self, tmp_path):
        p = _make_canonical_agent(tmp_path, "helper", SAMPLE_MINIMAL_AGENT)
        agent = parse_canonical_agent(p)
        assert agent.name == "helper"
        assert agent.description == "Generic helper"
        assert agent.tools == []
        assert agent.model is None
        assert agent.skills == []
        assert agent.isolation is None
        assert agent.kind is None
        assert agent.temperature is None

    def test_missing_frontmatter_raises(self, tmp_path):
        p = tmp_path / "bad.md"
        p.write_text("no frontmatter at all", encoding="utf-8")
        with pytest.raises(AgentParseError):
            parse_canonical_agent(p)

    @pytest.mark.parametrize(
        "hostile_name",
        [
            "../../evil",
            "../escape",
            "a/b",
            "a\\b",
            ".",
            "..",
            "-x",
            "A" * 65,
            "name with space",
        ],
    )
    def test_rejects_hostile_name_in_frontmatter(self, tmp_path, hostile_name):
        """#276: ``name:`` frontmatter must not land us outside the target dir."""
        p = tmp_path / "hostile.md"
        p.write_text(f"---\nname: {hostile_name}\ndescription: x\n---\n\nbody\n")
        with pytest.raises(AgentParseError, match="invalid agent name"):
            parse_canonical_agent(p)

    def test_block_list_syntax(self, tmp_path):
        p = tmp_path / "blocky.md"
        p.write_text(
            "---\n"
            "name: blocky\n"
            "description: Uses block list for tools\n"
            "tools:\n"
            "  - Read\n"
            "  - Grep\n"
            "---\n\n"
            "body\n",
            encoding="utf-8",
        )
        agent = parse_canonical_agent(p)
        assert agent.tools == ["Read", "Grep"]


class TestListCanonicalAgents:
    def test_empty(self, tmp_path):
        assert list_canonical_agents(tmp_path) == []

    def test_sorted(self, tmp_path):
        _make_canonical_agent(tmp_path, "zeta", SAMPLE_MINIMAL_AGENT.replace("helper", "zeta"))
        _make_canonical_agent(tmp_path, "alpha", SAMPLE_MINIMAL_AGENT.replace("helper", "alpha"))
        names = [p.stem for p, _layout in list_canonical_agents(tmp_path)]
        assert names == ["alpha", "zeta"]


class TestClaudeRendering:
    def test_passes_through_all_fields(self, tmp_path):
        _make_canonical_agent(tmp_path, "full", SAMPLE_FULL_AGENT)
        generate_all_agents(tmp_path, runtimes=["claude_agents"])
        out = (tmp_path / ".claude/agents/code-reviewer.md").read_text(encoding="utf-8")
        assert "name: code-reviewer" in out
        assert "tools: [Read, Grep, Glob]" in out
        assert "model: sonnet" in out
        assert "skills: [code-review]" in out
        assert "isolation: worktree" in out
        assert "meticulous code reviewer" in out

    def test_drops_gemini_fields(self, tmp_path):
        _make_canonical_agent(tmp_path, "full", SAMPLE_FULL_AGENT)
        result = generate_all_agents(tmp_path, runtimes=["claude_agents"])
        assert result.dropped
        runtime, agent_name, fields = result.dropped[0]
        assert runtime == "claude_agents"
        assert "kind" in fields
        assert "temperature" in fields


class TestGeminiRendering:
    def test_passes_through_gemini_fields(self, tmp_path):
        _make_canonical_agent(tmp_path, "full", SAMPLE_FULL_AGENT)
        generate_all_agents(tmp_path, runtimes=["gemini_agents"])
        out = (tmp_path / ".gemini/agents/code-reviewer.md").read_text(encoding="utf-8")
        assert "kind: reviewer" in out
        assert "temperature: 0.2" in out
        assert "tools: [Read, Grep, Glob]" in out
        assert "skills:" not in out  # dropped
        assert "isolation:" not in out  # dropped

    def test_drops_claude_fields(self, tmp_path):
        _make_canonical_agent(tmp_path, "full", SAMPLE_FULL_AGENT)
        result = generate_all_agents(tmp_path, runtimes=["gemini_agents"])
        assert result.dropped
        fields = result.dropped[0][2]
        assert "skills" in fields
        assert "isolation" in fields


class TestCodexRendering:
    def test_writes_valid_toml(self, tmp_path, codex_home):
        _make_canonical_agent(tmp_path, "full", SAMPLE_FULL_AGENT)
        generate_all_agents(tmp_path, runtimes=["codex_agents"])
        toml_path = tmp_path / ".codex/agents/code-reviewer.toml"
        assert toml_path.is_file()
        # Defensive: nothing should have leaked into HOME.
        assert not (codex_home / ".codex/agents/code-reviewer.toml").exists()
        parsed = tomllib.loads(toml_path.read_text(encoding="utf-8"))
        assert parsed["name"] == "code-reviewer"
        assert "quality" in parsed["description"]
        assert "meticulous" in parsed["developer_instructions"]
        assert parsed["model"] == "sonnet"

    def test_drops_tools_and_claude_specific(self, tmp_path, codex_home):
        _make_canonical_agent(tmp_path, "full", SAMPLE_FULL_AGENT)
        result = generate_all_agents(tmp_path, runtimes=["codex_agents"])
        fields = result.dropped[0][2]
        assert "tools" in fields
        assert "skills" in fields
        assert "isolation" in fields
        assert "kind" in fields
        assert "temperature" in fields

    def test_minimal_agent_has_no_dropped_fields(self, tmp_path, codex_home):
        _make_canonical_agent(tmp_path, "helper", SAMPLE_MINIMAL_AGENT)
        result = generate_all_agents(tmp_path, runtimes=["codex_agents"])
        assert result.dropped == []
        toml_path = tmp_path / ".codex/agents/helper.toml"
        parsed = tomllib.loads(toml_path.read_text(encoding="utf-8"))
        assert parsed["name"] == "helper"
        assert parsed["description"] == "Generic helper"

    def test_multiline_body_roundtrips_through_tomllib(self, tmp_path, codex_home):
        _make_canonical_agent(tmp_path, "helper", SAMPLE_MINIMAL_AGENT)
        generate_all_agents(tmp_path, runtimes=["codex_agents"])
        toml_path = tmp_path / ".codex/agents/helper.toml"
        parsed = tomllib.loads(toml_path.read_text(encoding="utf-8"))
        assert "Help with things." in parsed["developer_instructions"]


class TestGenerateAllAgents:
    def test_fans_out_to_all_runtimes(self, tmp_path, codex_home):
        _make_canonical_agent(tmp_path, "helper", SAMPLE_MINIMAL_AGENT)
        result = generate_all_agents(tmp_path)
        assert len(result.generated) == 4
        assert (tmp_path / ".claude/agents/helper.md").is_file()
        assert (tmp_path / ".gemini/agents/helper.md").is_file()
        assert (tmp_path / ".codex/agents/helper.toml").is_file()
        assert (tmp_path / ".kimi/agents/helper.yaml").is_file()
        # Defensive: nothing leaked into HOME.
        assert not (codex_home / ".codex/agents").exists()

    def test_no_canonical_no_op(self, tmp_path):
        result = generate_all_agents(tmp_path)
        assert result.generated == []
        assert result.skipped == [("<all>", "no canonical agents", "no_canonical_root")]

    def test_registry_contents(self):
        assert "claude_agents" in AGENT_GENERATORS
        assert "gemini_agents" in AGENT_GENERATORS
        assert "codex_agents" in AGENT_GENERATORS
        assert "kimi_agents" in AGENT_GENERATORS

    def test_unknown_runtime_skipped(self, tmp_path):
        _make_canonical_agent(tmp_path, "helper", SAMPLE_MINIMAL_AGENT)
        result = generate_all_agents(tmp_path, runtimes=["claude_agents", "nope"])
        assert ("nope", "unknown runtime", "unknown_runtime") in result.skipped


class TestStrictMode:
    def test_strict_raises_on_dropped_fields(self, tmp_path):
        _make_canonical_agent(tmp_path, "full", SAMPLE_FULL_AGENT)
        with pytest.raises(StrictDropError):
            generate_all_agents(tmp_path, runtimes=["claude_agents"], strict=True)

    def test_strict_passes_with_minimal_agent(self, tmp_path):
        _make_canonical_agent(tmp_path, "helper", SAMPLE_MINIMAL_AGENT)
        result = generate_all_agents(
            tmp_path,
            runtimes=["claude_agents", "gemini_agents"],
            strict=True,
        )
        assert len(result.generated) == 2

    def test_strict_drop_preserves_earlier_writes(self, tmp_path):
        # Pre-existing partial-write boundary documented on issue #900:
        # Phase 2 raises StrictDropError for the first dropping canonical,
        # but earlier canonicals in pending order have already been written.
        # Canonicals iterate in sorted-name order (list_canonical_agents),
        # so "alpha-minimal" is processed before "beta-full".
        _make_canonical_agent(
            tmp_path,
            "alpha-minimal",
            SAMPLE_MINIMAL_AGENT.replace("helper", "alpha-minimal"),
        )
        _make_canonical_agent(
            tmp_path,
            "beta-full",
            SAMPLE_FULL_AGENT.replace("code-reviewer", "beta-full"),
        )

        with pytest.raises(StrictDropError):
            generate_all_agents(tmp_path, runtimes=["claude_agents"], on_drop="error")

        runtime_dir = tmp_path / ".claude" / "agents"
        assert (runtime_dir / "alpha-minimal.md").is_file()
        assert not (runtime_dir / "beta-full.md").exists()
        # atomic_write_* uses tempfile.mkstemp with prefix=f".{path.name}.";
        # since the raise fires before atomic_write_text for beta-full,
        # no temp file should exist for it.
        assert list(runtime_dir.glob(".beta-full.md.*.tmp")) == []


class TestDuplicateAgentName:
    """Two canonicals (different stems) declaring the same frontmatter
    ``name:`` (#1247): the engine keeps the first-seen canonical (sorted
    order) and emits a typed ``duplicate_name`` skip for the later claimant
    instead of silently last-writer-winning the shared runtime file.
    """

    def _seed_collision(self, project_root):
        body = SAMPLE_MINIMAL_AGENT.replace("helper", "code-reviewer")
        winner = _make_canonical_agent(
            project_root, "a-reviewer", body.replace("Help with things.", "Body from A.")
        )
        loser = _make_canonical_agent(
            project_root, "b-reviewer", body.replace("Help with things.", "Body from B.")
        )
        return winner, loser

    def test_first_seen_wins_single_write(self, tmp_path):
        self._seed_collision(tmp_path)
        result = generate_all_agents(tmp_path, runtimes=["claude_agents"])

        out = tmp_path / ".claude/agents/code-reviewer.md"
        # Exactly one generated tuple — pre-fix both canonicals queued the
        # same out_path and both writes were reported.
        assert result.generated == [("claude_agents", out)]
        text = out.read_text(encoding="utf-8")
        assert "Body from A." in text
        assert "Body from B." not in text

        dup_rows = [row for row in result.skipped if row[2] == "duplicate_name"]
        assert len(dup_rows) == 1
        name, reason, _code = dup_rows[0]
        assert name == "code-reviewer"
        assert "a-reviewer.md" in reason and "b-reviewer.md" in reason

    def test_post_sync_diff_reports_in_sync(self, tmp_path, codex_home):
        """diff keys canonicals by parsed name with the SAME first-seen
        precedence as sync — last-wins there would diff the runtime file
        against the canonical sync never wrote, reporting permanent
        phantom drift the user cannot clear."""
        self._seed_collision(tmp_path)
        generate_all_agents(tmp_path)

        rows = [r for r in diff_agents(tmp_path) if r[1] == "code-reviewer"]
        assert rows, "expected diff rows for the colliding name"
        assert {r[2] for r in rows} == {"in sync"}

    def test_parse_failure_does_not_shadow_parsed_name_in_diff(self, tmp_path, codex_home):
        """Codex impl round (#1247 B4): a later malformed canonical whose
        FALLBACK name (stem) collides with an earlier parsed ``name:`` must
        not overwrite the parsed entry with a parse-error row. Sync writes
        that name fine — the malformed file never claims a name there — so
        the shadowed row would be permanent phantom "parse error" drift no
        sync can clear. The malformed file stays loud via the warning log."""
        _make_canonical_agent(tmp_path, "aaa", SAMPLE_MINIMAL_AGENT.replace("helper", "shared"))
        _make_canonical_agent(tmp_path, "shared", "no frontmatter at all\n")
        generate_all_agents(tmp_path)

        rows = [r for r in diff_agents(tmp_path) if r[1] == "shared"]
        assert rows, "expected diff rows for the shared name"
        assert {r[2] for r in rows} == {"in sync"}

    def test_parsed_name_recovers_parse_failure_fallback_in_diff(self, tmp_path, codex_home):
        """Reverse order of the shadow case: the malformed file registers its
        fallback name FIRST, then a later canonical parses to the same name.
        The parsed entry wins (pre-existing overwrite-None behavior, now
        load-bearing for the sync alignment): sync writes the name from the
        parsed file, so diff must compare against it — not report a stale
        parse-error row."""
        _make_canonical_agent(tmp_path, "shared", "no frontmatter at all\n")
        _make_canonical_agent(tmp_path, "zzz", SAMPLE_MINIMAL_AGENT.replace("helper", "shared"))
        generate_all_agents(tmp_path)

        rows = [r for r in diff_agents(tmp_path) if r[1] == "shared"]
        assert rows, "expected diff rows for the shared name"
        assert {r[2] for r in rows} == {"in sync"}


class TestExtractAgentsToCanonical:
    def test_imports_claude_agent(self, tmp_path):
        claude_dir = tmp_path / ".claude/agents"
        claude_dir.mkdir(parents=True)
        (claude_dir / "helper.md").write_text(SAMPLE_MINIMAL_AGENT, encoding="utf-8")
        result = extract_agents_to_canonical(tmp_path)
        assert len(result.imported) == 1
        # New agents land in directory layout per ADR-0008.
        assert (tmp_path / CANONICAL_AGENT_ROOT / "helper" / "agent.md").is_file()
        assert result.skipped == []

    def test_dedup_across_runtimes(self, tmp_path):
        for runtime in (".claude/agents", ".gemini/agents"):
            d = tmp_path / runtime
            d.mkdir(parents=True)
            (d / "helper.md").write_text(SAMPLE_MINIMAL_AGENT, encoding="utf-8")
        result = extract_agents_to_canonical(tmp_path)
        assert len(result.imported) == 1
        # Gemini copy was skipped because Claude already imported it.
        assert len(result.skipped) == 1
        assert result.skipped[0][0] == "helper"
        assert "already imported" in result.skipped[0][1]

    def test_overwrite_flag(self, tmp_path):
        claude_dir = tmp_path / ".claude/agents"
        claude_dir.mkdir(parents=True)
        new_content = SAMPLE_MINIMAL_AGENT.replace("Generic helper", "UPDATED")
        (claude_dir / "helper.md").write_text(new_content, encoding="utf-8")

        canonical = tmp_path / CANONICAL_AGENT_ROOT / "helper.md"
        canonical.parent.mkdir(parents=True)
        canonical.write_text("old", encoding="utf-8")

        result = extract_agents_to_canonical(tmp_path)
        assert result.imported == []
        assert len(result.skipped) == 1
        assert "canonical exists" in result.skipped[0][1]
        assert canonical.read_text(encoding="utf-8") == "old"

        result = extract_agents_to_canonical(tmp_path, overwrite=True)
        assert len(result.imported) == 1
        assert "UPDATED" in canonical.read_text(encoding="utf-8")

    def test_ignores_codex_toml(self, tmp_path):
        # Even when a Codex TOML exists, extract does not try to import it.
        # The TOML format is too lossy to round-trip without reconstructing
        # fields we dropped on the way out (tools, skills, isolation, etc.).
        (tmp_path / ".codex/agents").mkdir(parents=True)
        (tmp_path / ".codex/agents/helper.toml").write_text('name = "helper"\n', encoding="utf-8")
        result = extract_agents_to_canonical(tmp_path)
        assert result.imported == []

    def test_skips_hostile_runtime_filename(self, tmp_path):
        """#276: a runtime directory containing ``-x.md`` (leading dash) round-trips
        into a canonical filename that validate_name rejects, so we skip rather
        than produce a canonical file we'd refuse to read back."""
        claude_dir = tmp_path / ".claude/agents"
        claude_dir.mkdir(parents=True)
        # File name with leading dash — unusual but filesystem-legal.
        (claude_dir / "-bad.md").write_text(SAMPLE_MINIMAL_AGENT)
        (claude_dir / "ok.md").write_text(SAMPLE_MINIMAL_AGENT)

        result = extract_agents_to_canonical(tmp_path)

        # Only "ok" imported; "-bad" skipped with invalid-name reason.
        # ExtractResult.imported is now (path, layout) tuples; new agents
        # land in dir layout per ADR-0008.
        imported_names = sorted(canonical_agent_name(p, layout) for p, layout in result.imported)
        assert imported_names == ["ok"]
        skipped_names = sorted(name for name, _, _ in result.skipped)
        assert "-bad" in skipped_names
        reason_by_name = {name: reason for name, reason, _code in result.skipped}
        assert "invalid name" in reason_by_name["-bad"]

    def test_only_name_filters_to_one(self, tmp_path):
        d = tmp_path / ".claude/agents"
        d.mkdir(parents=True)
        (d / "alpha.md").write_text(SAMPLE_MINIMAL_AGENT, encoding="utf-8")
        (d / "beta.md").write_text(SAMPLE_MINIMAL_AGENT, encoding="utf-8")

        result = extract_agents_to_canonical(tmp_path, only_name="alpha")
        # ExtractResult.imported is (path, layout) tuples.
        assert [canonical_agent_name(p, layout) for p, layout in result.imported] == ["alpha"]
        assert result.skipped == []
        assert not (tmp_path / CANONICAL_AGENT_ROOT / "beta" / "agent.md").exists()

    def test_only_name_no_match_returns_empty(self, tmp_path):
        d = tmp_path / ".claude/agents"
        d.mkdir(parents=True)
        (d / "alpha.md").write_text(SAMPLE_MINIMAL_AGENT, encoding="utf-8")

        result = extract_agents_to_canonical(tmp_path, only_name="ghost")
        assert result.imported == []
        assert result.skipped == []

    def test_dry_run_reports_without_writing(self, tmp_path):
        # rank-10: dry_run lists the would-import target but never writes,
        # and a real run afterwards still imports normally.
        claude_dir = tmp_path / ".claude/agents"
        claude_dir.mkdir(parents=True)
        (claude_dir / "helper.md").write_text(SAMPLE_MINIMAL_AGENT, encoding="utf-8")

        preview = extract_agents_to_canonical(tmp_path, dry_run=True)
        assert [canonical_agent_name(p, layout) for p, layout in preview.imported] == ["helper"]
        assert preview.skipped == []
        assert not (tmp_path / CANONICAL_AGENT_ROOT).exists()

        applied = extract_agents_to_canonical(tmp_path)
        assert len(applied.imported) == 1
        assert (tmp_path / CANONICAL_AGENT_ROOT / "helper" / "agent.md").is_file()


class TestDiffAgents:
    def test_empty_project(self, tmp_path, codex_home):
        assert diff_agents(tmp_path) == []

    def test_missing_target_for_all(self, tmp_path, codex_home):
        _make_canonical_agent(tmp_path, "helper", SAMPLE_MINIMAL_AGENT)
        rows = diff_agents(tmp_path)
        statuses = {status for _, _, status in rows}
        assert statuses == {"missing target"}

    def test_in_sync_after_generate(self, tmp_path, codex_home):
        _make_canonical_agent(tmp_path, "helper", SAMPLE_MINIMAL_AGENT)
        generate_all_agents(tmp_path)
        rows = diff_agents(tmp_path)
        assert all(status == "in sync" for _, _, status in rows)

    def test_out_of_sync(self, tmp_path, codex_home):
        _make_canonical_agent(tmp_path, "helper", SAMPLE_MINIMAL_AGENT)
        generate_all_agents(tmp_path)
        (tmp_path / ".claude/agents/helper.md").write_text("mutated", encoding="utf-8")
        rows = diff_agents(tmp_path)
        status_by_runtime = {r: s for r, _, s in rows}
        assert status_by_runtime["claude_agents"] == "out of sync"
        assert status_by_runtime["gemini_agents"] == "in sync"
        assert status_by_runtime["codex_agents"] == "in sync"

    def test_missing_canonical(self, tmp_path, codex_home):
        claude_dir = tmp_path / ".claude/agents"
        claude_dir.mkdir(parents=True)
        (claude_dir / "runtime-only.md").write_text(SAMPLE_MINIMAL_AGENT, encoding="utf-8")
        rows = diff_agents(tmp_path)
        assert any(status == "missing canonical" for _, _, status in rows)

    def test_frontmatter_name_wins_over_stem_after_sync(self, tmp_path, codex_home):
        """Sync targets the parsed frontmatter ``name:``; diff must key
        canonicals the same way, or a stem/name mismatch reports two phantom
        rows per runtime ('missing target' + 'missing canonical') that
        re-syncing never clears (#1229)."""
        body = SAMPLE_MINIMAL_AGENT.replace("helper", "code-reviewer")
        _make_canonical_agent(tmp_path, "reviewer", body)  # stem != frontmatter name
        generate_all_agents(tmp_path)
        rows = diff_agents(tmp_path)
        assert rows
        assert {name for _, name, _ in rows} == {"code-reviewer"}
        assert all(status == "in sync" for _, _, status in rows)

    def test_non_utf8_canonical_byte_does_not_abort_diff(self, tmp_path, codex_home):
        """A stray latin-1 byte must not abort the whole diff with an uncaught
        UnicodeDecodeError — sync decodes the same file with errors='replace'
        and succeeds, so diff must agree with what sync wrote (#1229)."""
        root = tmp_path / CANONICAL_AGENT_ROOT
        root.mkdir(parents=True, exist_ok=True)
        (root / "cafe.md").write_bytes(b"---\nname: cafe\ndescription: caf\xe9\n---\n\nBody.\n")
        generate_all_agents(tmp_path)
        rows = diff_agents(tmp_path)
        assert {status for _, name, status in rows if name == "cafe"} == {"in sync"}

    def test_non_utf8_runtime_byte_reports_drift_not_crash(self, tmp_path, codex_home):
        """A non-UTF-8 byte in a runtime copy is drift, not a diff-wide crash
        (#1229)."""
        _make_canonical_agent(tmp_path, "helper", SAMPLE_MINIMAL_AGENT)
        generate_all_agents(tmp_path)
        (tmp_path / ".claude/agents/helper.md").write_bytes(
            b"---\nname: helper\ndescription: caf\xe9 drift\n---\n\nBody.\n"
        )
        rows = diff_agents(tmp_path)
        status_by_runtime = {r: s for r, _, s in rows}
        assert status_by_runtime["claude_agents"] == "out of sync"
        assert status_by_runtime["gemini_agents"] == "in sync"

    def test_invalid_runtime_name_surfaces_as_invalid_name_row(self, tmp_path, codex_home):
        """An invalid-named runtime file used to vanish from diff entirely
        (log-only) — the dashboard read fully in-sync while an unmanaged
        runtime artifact existed (#1229)."""
        claude_dir = tmp_path / ".claude/agents"
        claude_dir.mkdir(parents=True)
        (claude_dir / "-bad.md").write_text(SAMPLE_MINIMAL_AGENT, encoding="utf-8")
        rows = diff_agents(tmp_path)
        assert ("claude_agents", "-bad", "invalid name") in rows
        # Other runtimes don't have the file — no phantom rows there.
        assert not any(r == "gemini_agents" and s == "invalid name" for r, _, s in rows)

    def test_unparseable_canonical_reports_parse_error_not_missing_target(
        self, tmp_path, codex_home
    ):
        """A canonical whose effective name is invalid (or that cannot be
        parsed) used to report 'missing target' — implying sync would create
        the runtime file, which it never can; the row was permanent drift no
        sync clears (#1229)."""
        _make_canonical_agent(tmp_path, "broken", "no frontmatter at all\n")
        rows = diff_agents(tmp_path)
        statuses = {s for _, n, s in rows if n == "broken"}
        assert statuses == {"parse error"}

    def test_invalid_canonical_stem_collision_prefers_parse_error(self, tmp_path, codex_home):
        """When a canonical's invalid stem collides with an invalid runtime
        name, the canonical 'parse error' row wins — no duplicate
        (runtime, name) rows."""
        _make_canonical_agent(tmp_path, "-bad", "no frontmatter at all\n")
        claude_dir = tmp_path / ".claude/agents"
        claude_dir.mkdir(parents=True)
        (claude_dir / "-bad.md").write_text(SAMPLE_MINIMAL_AGENT, encoding="utf-8")
        rows = diff_agents(tmp_path)
        claude_rows = [(n, s) for r, n, s in rows if r == "claude_agents" and n == "-bad"]
        assert claude_rows == [("-bad", "parse error")]

    def test_parse_error_row_carries_reason_with_source_path(self, tmp_path, codex_home):
        """U7 (#1229): the 'parse error' row carries the exception text —
        which embeds the source path — instead of a bare status; healthy
        rows keep reason None (pin-and-invert)."""
        _make_canonical_agent(tmp_path, "broken", "no frontmatter at all\n")
        _make_canonical_agent(tmp_path, "healthy", SAMPLE_MINIMAL_AGENT)
        rows = diff_agents(tmp_path)
        broken = [r for r in rows if r[1] == "broken"]
        assert broken
        for row in broken:
            assert row[2] == "parse error"
            assert "frontmatter" in (row.reason or "")
            assert "broken.md" in (row.reason or "")
        healthy = [r for r in rows if r[1] == "helper"]
        assert healthy
        assert all(getattr(r, "reason", None) is None for r in healthy)

    def test_invalid_name_row_carries_validation_reason(self, tmp_path, codex_home):
        claude_dir = tmp_path / ".claude/agents"
        claude_dir.mkdir(parents=True)
        (claude_dir / "-bad.md").write_text(SAMPLE_MINIMAL_AGENT, encoding="utf-8")
        rows = diff_agents(tmp_path)
        row = next(r for r in rows if r[2] == "invalid name")
        assert "invalid agent name" in (row.reason or "")

    def test_diff_rows_still_unpack_as_three_tuples(self, tmp_path, codex_home):
        """API-compat pin: DiffRow iterates as the historical 3-tuple, and
        equality against plain tuples holds — external positional consumers
        of the public diff_* functions must not break."""
        _make_canonical_agent(tmp_path, "broken", "no frontmatter at all\n")
        rows = diff_agents(tmp_path)
        for runtime, name, status in rows:  # raises ValueError if widened
            assert isinstance(runtime, str) and isinstance(name, str) and isinstance(status, str)
        assert ("claude_agents", "broken", "parse error") in rows

    def test_whitespace_only_drift_detected_and_converges(self, tmp_path, codex_home):
        """Whitespace-only drift is real drift: sync writes render output
        byte-exact, so diff must not ``.strip()`` it away — pre-fix a padded
        runtime file showed "in sync" while sync would rewrite it (#1229).
        One sync converges the drift back to "in sync"."""
        _make_canonical_agent(tmp_path, "helper", SAMPLE_MINIMAL_AGENT)
        generate_all_agents(tmp_path)
        target = tmp_path / ".claude/agents/helper.md"
        target.write_bytes(target.read_bytes() + b"\n")

        status_by_runtime = {r: s for r, _, s in diff_agents(tmp_path)}
        assert status_by_runtime["claude_agents"] == "out of sync"
        assert status_by_runtime["gemini_agents"] == "in sync"

        generate_all_agents(tmp_path)
        assert all(s == "in sync" for _, _, s in diff_agents(tmp_path))


class TestRuntimeAgentNames:
    """Pin the runtime-name lookup contract directly so a future revert of the
    Codex project-scope path can't sneak past via the end-to-end fan-out
    tests alone."""

    def test_codex_reads_project_scope_toml(self, tmp_path, codex_home):
        rt_dir = tmp_path / ".codex/agents"
        rt_dir.mkdir(parents=True)
        (rt_dir / "helper.toml").write_text('name = "helper"\n', encoding="utf-8")
        (rt_dir / "reviewer.toml").write_text('name = "reviewer"\n', encoding="utf-8")
        # Markdown with the wrong suffix must be ignored.
        (rt_dir / "ignored.md").write_text("# wrong suffix\n", encoding="utf-8")

        # User-scope copies must not leak in.
        (codex_home / ".codex/agents").mkdir(parents=True)
        (codex_home / ".codex/agents" / "user-scope.toml").write_text(
            'name = "user-scope"\n', encoding="utf-8"
        )

        names = _runtime_agent_names("codex_agents", tmp_path)
        assert names == {"helper", "reviewer"}

    def test_claude_reads_project_scope_md(self, tmp_path):
        rt_dir = tmp_path / ".claude/agents"
        rt_dir.mkdir(parents=True)
        (rt_dir / "alice.md").write_text(SAMPLE_MINIMAL_AGENT, encoding="utf-8")
        (rt_dir / "wrong-suffix.toml").write_text("# ignored\n", encoding="utf-8")

        assert _runtime_agent_names("claude_agents", tmp_path) == {"alice"}

    def test_gemini_reads_project_scope_md(self, tmp_path):
        rt_dir = tmp_path / ".gemini/agents"
        rt_dir.mkdir(parents=True)
        (rt_dir / "bob.md").write_text(SAMPLE_MINIMAL_AGENT, encoding="utf-8")

        assert _runtime_agent_names("gemini_agents", tmp_path) == {"bob"}

    def test_unknown_runtime_returns_empty(self, tmp_path):
        assert _runtime_agent_names("nope_agents", tmp_path) == set()

    def test_missing_dir_returns_empty(self, tmp_path):
        # No `.codex/agents` directory at all.
        assert _runtime_agent_names("codex_agents", tmp_path) == set()


class TestDetectAgentDirs:
    def test_empty(self, tmp_path):
        assert detect_agent_dirs(tmp_path) == []

    def test_detects_claude(self, tmp_path):
        d = tmp_path / ".claude/agents"
        d.mkdir(parents=True)
        (d / "reviewer.md").write_text(SAMPLE_FULL_AGENT, encoding="utf-8")
        found = detect_agent_dirs(tmp_path)
        assert len(found) == 1
        assert found[0].agent == "claude_agents"
        assert found[0].kind == "agent_file"
        assert found[0].path.name == "reviewer.md"

    def test_detects_gemini(self, tmp_path):
        d = tmp_path / ".gemini/agents"
        d.mkdir(parents=True)
        (d / "tester.md").write_text(SAMPLE_MINIMAL_AGENT, encoding="utf-8")
        found = detect_agent_dirs(tmp_path)
        assert found[0].agent == "gemini_agents"

    def test_detects_codex_project_scope(self, tmp_path):
        d = tmp_path / ".codex/agents"
        d.mkdir(parents=True)
        (d / "helper.toml").write_text('name = "helper"\n', encoding="utf-8")
        found = detect_agent_dirs(tmp_path)
        assert any(f.agent == "codex_agents" and f.path.name == "helper.toml" for f in found)

    def test_codex_user_scope_not_in_project_detect(self, tmp_path, codex_home):
        """Codex user-scope (~/.codex/agents/) is intentionally outside the
        project detect surface — only the project-scope path is scanned."""
        (codex_home / ".codex/agents").mkdir(parents=True)
        (codex_home / ".codex/agents/helper.toml").write_text('name = "helper"\n', encoding="utf-8")
        found = detect_agent_dirs(tmp_path)
        assert found == []


class TestOnDrop:
    def test_on_drop_warn_logs(self, tmp_path, caplog):
        _make_canonical_agent(tmp_path, "full", SAMPLE_FULL_AGENT)
        with caplog.at_level("WARNING"):
            result = generate_all_agents(tmp_path, runtimes=["claude_agents"], on_drop="warn")
        # Claude drops kind + temperature — should still generate.
        assert len(result.generated) == 1
        assert result.dropped
        assert any("dropped" in r.message for r in caplog.records)

    def test_on_drop_error_raises(self, tmp_path):
        _make_canonical_agent(tmp_path, "full", SAMPLE_FULL_AGENT)
        with pytest.raises(StrictDropError):
            generate_all_agents(tmp_path, runtimes=["claude_agents"], on_drop="error")

    def test_on_drop_ignore_is_silent(self, tmp_path, caplog):
        _make_canonical_agent(tmp_path, "full", SAMPLE_FULL_AGENT)
        with caplog.at_level("WARNING"):
            result = generate_all_agents(tmp_path, runtimes=["claude_agents"], on_drop="ignore")
        assert len(result.generated) == 1
        assert result.dropped
        assert not any("dropped" in r.message for r in caplog.records)

    def test_strict_flag_still_works(self, tmp_path):
        """Legacy ``strict=True`` behaves like ``on_drop='error'``."""
        _make_canonical_agent(tmp_path, "full", SAMPLE_FULL_AGENT)
        with pytest.raises(StrictDropError):
            generate_all_agents(tmp_path, runtimes=["claude_agents"], strict=True)

    def test_on_drop_overrides_strict(self, tmp_path, caplog):
        """When both are supplied, ``on_drop`` wins."""
        _make_canonical_agent(tmp_path, "full", SAMPLE_FULL_AGENT)
        with caplog.at_level("WARNING"):
            result = generate_all_agents(
                tmp_path, runtimes=["claude_agents"], strict=True, on_drop="warn"
            )
        assert len(result.generated) == 1  # warn does not abort


class TestRoundtrip:
    def test_canonical_to_claude_and_back(self, tmp_path):
        _make_canonical_agent(tmp_path, "helper", SAMPLE_MINIMAL_AGENT)
        generate_all_agents(tmp_path, runtimes=["claude_agents"])

        shutil.rmtree(tmp_path / CANONICAL_AGENT_ROOT)
        result = extract_agents_to_canonical(tmp_path)
        assert len(result.imported) == 1
        # New canonical lands in dir layout per ADR-0008.
        reparsed = parse_canonical_agent(
            tmp_path / CANONICAL_AGENT_ROOT / "helper" / "agent.md", layout="dir"
        )
        assert reparsed.name == "helper"
        assert "Help with things." in reparsed.body


class TestCrlfParsing:
    """#279: frontmatter regex anchors on ``\\n`` — CRLF files must still parse."""

    def test_crlf_frontmatter_parses(self, tmp_path):
        p = tmp_path / "crlf.md"
        p.write_bytes(SAMPLE_FULL_AGENT.replace("\n", "\r\n").encode("utf-8"))
        agent = parse_canonical_agent(p)
        assert agent.name == "code-reviewer"
        assert agent.tools == ["Read", "Grep", "Glob"]
        assert agent.model == "sonnet"
        # Body is LF-normalized after parse; no raw \r should leak through.
        assert "\r" not in agent.body

    def test_crlf_roundtrip_is_idempotent(self, tmp_path):
        """Canonical (CRLF on disk) → runtime → canonical must be byte-stable."""
        p = tmp_path / CANONICAL_AGENT_ROOT / "helper.md"
        p.parent.mkdir(parents=True)
        p.write_bytes(SAMPLE_MINIMAL_AGENT.replace("\n", "\r\n").encode("utf-8"))

        generate_all_agents(tmp_path, runtimes=["claude_agents"])
        rendered_first = (tmp_path / ".claude/agents/helper.md").read_bytes()

        # Re-parse the canonical (still CRLF on disk) and regenerate.
        generate_all_agents(tmp_path, runtimes=["claude_agents"])
        rendered_second = (tmp_path / ".claude/agents/helper.md").read_bytes()

        assert rendered_first == rendered_second
        # And the rendered runtime file uses LF, not CRLF.
        assert b"\r\n" not in rendered_first


class TestBomParsing:
    """#1229: the frontmatter regex anchors at position 0, so a leading UTF-8
    BOM made BOM-prefixed canonical agents raise ``AgentParseError("missing
    YAML frontmatter")`` — Windows-authored files must parse like clean ones."""

    def test_bom_frontmatter_parses(self, tmp_path):
        p = tmp_path / "bom.md"
        p.write_bytes(b"\xef\xbb\xbf" + SAMPLE_FULL_AGENT.encode("utf-8"))
        agent = parse_canonical_agent(p)
        assert agent.name == "code-reviewer"
        assert agent.model == "sonnet"
        assert "﻿" not in agent.body

    def test_mid_file_feff_preserved(self, tmp_path):
        """Only the leading BOM is normalized — a mid-file U+FEFF is a
        legitimate zero-width no-break space."""
        p = tmp_path / "zwnbsp.md"
        p.write_text(SAMPLE_MINIMAL_AGENT + "zero﻿width\n", encoding="utf-8")
        agent = parse_canonical_agent(p)
        assert "zero﻿width" in agent.body

    def test_bom_crlf_roundtrip_is_idempotent(self, tmp_path):
        """BOM+CRLF canonical → runtime must be byte-stable across re-syncs
        and render BOM-free, LF-only runtime files."""
        p = tmp_path / CANONICAL_AGENT_ROOT / "helper.md"
        p.parent.mkdir(parents=True)
        p.write_bytes(b"\xef\xbb\xbf" + SAMPLE_MINIMAL_AGENT.replace("\n", "\r\n").encode("utf-8"))

        generate_all_agents(tmp_path, runtimes=["claude_agents"])
        rendered_first = (tmp_path / ".claude/agents/helper.md").read_bytes()
        generate_all_agents(tmp_path, runtimes=["claude_agents"])
        rendered_second = (tmp_path / ".claude/agents/helper.md").read_bytes()

        assert rendered_first == rendered_second
        assert b"\xef\xbb\xbf" not in rendered_first
        assert b"\r\n" not in rendered_first


class TestUnknownFrontmatterKeys:
    def test_unknown_key_warns_with_path(self, tmp_path, caplog):
        body = (
            "---\n"
            "name: odd\n"
            "description: has a future key\n"
            "future_field: value\n"
            "another_unknown: [a, b]\n"
            "---\n\n"
            "body\n"
        )
        p = tmp_path / "odd.md"
        p.write_text(body)
        with caplog.at_level("WARNING"):
            agent = parse_canonical_agent(p)
        # Known fields still parse; unknowns are dropped (not stored on SubAgent).
        assert agent.name == "odd"
        messages = [r.getMessage() for r in caplog.records]
        assert any("unknown frontmatter keys" in m for m in messages)
        assert any(str(p) in m for m in messages)
        # Alphabetized for stable output.
        assert any("another_unknown" in m and "future_field" in m for m in messages)

    def test_known_keys_do_not_warn(self, tmp_path, caplog):
        _make_canonical_agent(tmp_path, "full", SAMPLE_FULL_AGENT)
        with caplog.at_level("WARNING"):
            parse_canonical_agent(tmp_path / CANONICAL_AGENT_ROOT / "full.md")
        assert not any("unknown frontmatter" in r.getMessage() for r in caplog.records)


class TestCodexTomlControlChars:
    """#279: Codex TOML must parse back under ``tomllib.loads`` even when the
    canonical body contains raw C0 control characters."""

    def test_body_with_c0_controls_parses(self, tmp_path, codex_home):
        """Bodies routed into a multiline TOML string must escape C0 controls
        that the TOML spec still requires escaping even inside triple quotes
        (sub-0x20 chars aside from ``\\n``/``\\t``). ``\\r`` is pre-normalized
        by ``Path.read_text`` (universal newlines), so it can't reach here on
        the read path — it's exercised directly in :class:`TestTomlEscape`."""
        body = (
            "---\n"
            "name: odd-body\n"
            "description: has control chars\n"
            "---\n\n"
            "line with NUL: \x00 and ESC: \x1b and BS: \x08\n"
        )
        p = tmp_path / CANONICAL_AGENT_ROOT / "odd-body.md"
        p.parent.mkdir(parents=True)
        p.write_text(body)

        generate_all_agents(tmp_path, runtimes=["codex_agents"])
        toml_path = tmp_path / ".codex/agents/odd-body.toml"
        parsed = tomllib.loads(toml_path.read_text())
        # Control chars survive the round-trip through tomllib.
        assert "\x00" in parsed["developer_instructions"]
        assert "\x1b" in parsed["developer_instructions"]
        assert "\x08" in parsed["developer_instructions"]

    def test_multiline_body_with_triple_quote_is_escaped(self, tmp_path, codex_home):
        body = (
            "---\n"
            "name: triple\n"
            "description: body contains triple quote\n"
            '---\n\nHere is a stray triple: """ inside text\nSecond line.\n'
        )
        p = tmp_path / CANONICAL_AGENT_ROOT / "triple.md"
        p.parent.mkdir(parents=True)
        p.write_text(body)

        generate_all_agents(tmp_path, runtimes=["codex_agents"])
        toml_path = tmp_path / ".codex/agents/triple.toml"
        parsed = tomllib.loads(toml_path.read_text())
        assert '"""' in parsed["developer_instructions"]


class TestTomlEscape:
    """Direct unit tests for the TOML escape helpers — documents which chars
    get which escape form so future edits don't silently narrow the set."""

    def test_basic_string_named_escapes(self):
        s = 'back\\slash, quote", bs\b, tab\t, lf\n, ff\f, cr\r'
        escaped = _toml_escape_basic_string(s)
        assert "\\\\" in escaped
        assert '\\"' in escaped
        assert "\\b" in escaped
        assert "\\t" in escaped
        assert "\\n" in escaped
        assert "\\f" in escaped
        assert "\\r" in escaped
        # Round-trips through tomllib.
        parsed = tomllib.loads(f'v = "{escaped}"')
        assert parsed["v"] == s

    def test_basic_string_other_c0_uses_uxxxx(self):
        s = "nul\x00, soh\x01, esc\x1b, del\x7f"
        escaped = _toml_escape_basic_string(s)
        assert "\\u0000" in escaped
        assert "\\u0001" in escaped
        assert "\\u001b" in escaped
        assert "\\u007f" in escaped
        parsed = tomllib.loads(f'v = "{escaped}"')
        assert parsed["v"] == s

    def test_multiline_keeps_newline_and_tab_literal(self):
        s = "line1\nline2\twith tab"
        escaped = _toml_escape_multiline_string(s)
        # Newline/tab stay literal in triple-quoted form.
        assert "\n" in escaped
        assert "\t" in escaped
        assert "\\n" not in escaped
        assert "\\t" not in escaped

    def test_multiline_still_escapes_cr_and_c0(self):
        s = "cr\r middle nul\x00 end\x1b"
        escaped = _toml_escape_multiline_string(s)
        assert "\\r" in escaped
        assert "\\u0000" in escaped
        assert "\\u001b" in escaped
        parsed = tomllib.loads(f'v = """\n{escaped}"""')
        assert parsed["v"] == s

    def test_multiline_breaks_triple_quote(self):
        s = 'prefix """ suffix'
        escaped = _toml_escape_multiline_string(s)
        # Raw ``"""`` would close the string — must be broken.
        assert '"""' not in escaped
        parsed = tomllib.loads(f'v = """\n{escaped}"""')
        assert '"""' in parsed["v"]
