"""Tests for context/commands.py — canonical ⇄ runtime slash command fan-out."""

import shutil
import tomllib

import pytest

import memtomem.context.commands as commands_mod
from memtomem.context.commands import (
    CANONICAL_COMMAND_ROOT,
    COMMAND_GENERATORS,
    CommandParseError,
    CommandSyncResult,
    StrictDropError,
    canonical_command_name,
    diff_commands,
    extract_commands_to_canonical,
    generate_all_commands,
    list_canonical_commands,
    parse_canonical_command,
)
from memtomem.context.detector import detect_command_dirs


SAMPLE_FULL_COMMAND = """---
description: Review a file for issues
argument-hint: [file-path]
allowed-tools: [Read, Grep]
model: sonnet
---

Review the file at $ARGUMENTS for issues.
Report a prioritized punch list.
"""

SAMPLE_MINIMAL_COMMAND = """---
description: Simple prompt
---

Say hi to $ARGUMENTS.
"""


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


def test_module_docstring_does_not_claim_codex_command_fanout() -> None:
    """Codex prompts are reserved/deprecated, not an active command target."""
    doc = commands_mod.__doc__ or ""
    assert "Codex commands are **not** fanned out" in doc
    assert "passes through unchanged\nfor the Codex target" not in doc


class TestParseCanonicalCommand:
    def test_parses_all_fields(self, tmp_path):
        p = _make_canonical_command(tmp_path, "review", SAMPLE_FULL_COMMAND)
        cmd = parse_canonical_command(p)
        assert cmd.name == "review"
        assert cmd.description == "Review a file for issues"
        assert cmd.argument_hint == "[file-path]"
        assert cmd.allowed_tools == ["Read", "Grep"]
        assert cmd.model == "sonnet"
        assert "Review the file at $ARGUMENTS" in cmd.body

    def test_parses_minimal(self, tmp_path):
        p = _make_canonical_command(tmp_path, "hi", SAMPLE_MINIMAL_COMMAND)
        cmd = parse_canonical_command(p)
        assert cmd.name == "hi"
        assert cmd.description == "Simple prompt"
        assert cmd.argument_hint is None
        assert cmd.allowed_tools == []
        assert cmd.model is None
        assert "Say hi to $ARGUMENTS" in cmd.body

    def test_tolerates_missing_frontmatter(self, tmp_path):
        p = tmp_path / "bare.md"
        p.write_text("Just a bare prompt with $ARGUMENTS.", encoding="utf-8")
        cmd = parse_canonical_command(p)
        assert cmd.name == "bare"
        assert cmd.description == ""
        assert "Just a bare prompt" in cmd.body

    @pytest.mark.parametrize(
        "hostile_name",
        [
            "../../evil",
            "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 is interpolated into the output path."""
        p = tmp_path / "hostile.md"
        p.write_text(f"---\nname: {hostile_name}\ndescription: x\n---\n\nbody\n")
        with pytest.raises(CommandParseError, match="invalid command name"):
            parse_canonical_command(p)


class TestListCanonicalCommands:
    def test_empty(self, tmp_path):
        assert list_canonical_commands(tmp_path) == []

    def test_sorted(self, tmp_path):
        _make_canonical_command(tmp_path, "zeta", SAMPLE_MINIMAL_COMMAND)
        _make_canonical_command(tmp_path, "alpha", SAMPLE_MINIMAL_COMMAND)
        names = [p.stem for p, _layout in list_canonical_commands(tmp_path)]
        assert names == ["alpha", "zeta"]


class TestClaudeCommandRendering:
    def test_passes_through_all_fields(self, tmp_path):
        _make_canonical_command(tmp_path, "review", SAMPLE_FULL_COMMAND)
        generate_all_commands(tmp_path, runtimes=["claude_commands"])
        out = (tmp_path / ".claude/commands/review.md").read_text(encoding="utf-8")
        assert "description: Review a file for issues" in out
        assert "argument-hint: [file-path]" in out
        assert "allowed-tools: [Read, Grep]" in out
        assert "model: sonnet" in out
        assert "$ARGUMENTS" in out  # placeholder preserved

    def test_no_dropped_fields(self, tmp_path):
        _make_canonical_command(tmp_path, "review", SAMPLE_FULL_COMMAND)
        result = generate_all_commands(tmp_path, runtimes=["claude_commands"])
        assert result.dropped == []

    def test_frontmatter_omitted_when_all_fields_empty(self, tmp_path):
        body = "Just the prompt with $ARGUMENTS.\n"
        p = tmp_path / CANONICAL_COMMAND_ROOT / "bare.md"
        p.parent.mkdir(parents=True)
        p.write_text(body, encoding="utf-8")  # no frontmatter at all
        generate_all_commands(tmp_path, runtimes=["claude_commands"])
        out = (tmp_path / ".claude/commands/bare.md").read_text(encoding="utf-8")
        assert out.startswith("Just the prompt")
        assert "---" not in out


class TestGeminiCommandRendering:
    def test_writes_valid_toml(self, tmp_path):
        _make_canonical_command(tmp_path, "review", SAMPLE_FULL_COMMAND)
        generate_all_commands(tmp_path, runtimes=["gemini_commands"])
        toml_path = tmp_path / ".gemini/commands/review.toml"
        assert toml_path.is_file()
        parsed = tomllib.loads(toml_path.read_text(encoding="utf-8"))
        assert parsed["description"] == "Review a file for issues"
        assert "prompt" in parsed
        assert "Review the file at {{args}}" in parsed["prompt"]

    def test_rewrites_arguments_placeholder(self, tmp_path):
        _make_canonical_command(tmp_path, "review", SAMPLE_FULL_COMMAND)
        generate_all_commands(tmp_path, runtimes=["gemini_commands"])
        parsed = tomllib.loads(
            (tmp_path / ".gemini/commands/review.toml").read_text(encoding="utf-8")
        )
        assert "$ARGUMENTS" not in parsed["prompt"]
        assert "{{args}}" in parsed["prompt"]

    def test_drops_claude_only_fields(self, tmp_path):
        _make_canonical_command(tmp_path, "review", SAMPLE_FULL_COMMAND)
        result = generate_all_commands(tmp_path, runtimes=["gemini_commands"])
        fields = result.dropped[0][2]
        assert "argument-hint" in fields
        assert "allowed-tools" in fields
        assert "model" in fields

    def test_minimal_command_has_no_dropped_fields(self, tmp_path):
        _make_canonical_command(tmp_path, "hi", SAMPLE_MINIMAL_COMMAND)
        result = generate_all_commands(tmp_path, runtimes=["gemini_commands"])
        assert result.dropped == []


class TestGenerateAllCommands:
    def test_fans_out_to_all_runtimes(self, tmp_path):
        _make_canonical_command(tmp_path, "hi", SAMPLE_MINIMAL_COMMAND)
        result = generate_all_commands(tmp_path)
        assert isinstance(result, CommandSyncResult)
        assert len(result.generated) == 2
        assert (tmp_path / ".claude/commands/hi.md").is_file()
        assert (tmp_path / ".gemini/commands/hi.toml").is_file()

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

    def test_registry_contents(self):
        assert "claude_commands" in COMMAND_GENERATORS
        assert "gemini_commands" in COMMAND_GENERATORS
        assert "codex_commands" not in COMMAND_GENERATORS

    def test_unknown_runtime_skipped(self, tmp_path):
        _make_canonical_command(tmp_path, "hi", SAMPLE_MINIMAL_COMMAND)
        result = generate_all_commands(tmp_path, runtimes=["claude_commands", "nope"])
        assert ("nope", "unknown runtime", "unknown_runtime") in result.skipped


class TestStrictMode:
    def test_strict_raises_on_dropped_fields(self, tmp_path):
        _make_canonical_command(tmp_path, "review", SAMPLE_FULL_COMMAND)
        with pytest.raises(StrictDropError):
            generate_all_commands(tmp_path, runtimes=["gemini_commands"], strict=True)

    def test_strict_passes_with_minimal(self, tmp_path):
        _make_canonical_command(tmp_path, "hi", SAMPLE_MINIMAL_COMMAND)
        result = generate_all_commands(tmp_path, 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_commands),
        # so "alpha-minimal" is processed before "beta-full". gemini_commands
        # is used because claude_commands supports the full command schema
        # and never drops fields.
        _make_canonical_command(tmp_path, "alpha-minimal", SAMPLE_MINIMAL_COMMAND)
        _make_canonical_command(tmp_path, "beta-full", SAMPLE_FULL_COMMAND)

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

        runtime_dir = tmp_path / ".gemini" / "commands"
        assert (runtime_dir / "alpha-minimal.toml").is_file()
        assert not (runtime_dir / "beta-full.toml").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.toml.*.tmp")) == []


class TestDuplicateCommandName:
    """Sibling of ``TestDuplicateAgentName`` (#1247) — same engine, same
    contract: first-seen canonical wins the colliding frontmatter ``name:``,
    later claimants get a typed ``duplicate_name`` skip.
    """

    def _seed_collision(self, project_root):
        _make_canonical_command(
            project_root,
            "a-review",
            "---\nname: review\ndescription: First claimant\n---\n\nBody from A.\n",
        )
        _make_canonical_command(
            project_root,
            "b-review",
            "---\nname: review\ndescription: Second claimant\n---\n\nBody from B.\n",
        )

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

        out = tmp_path / ".claude/commands/review.md"
        assert result.generated == [("claude_commands", 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 == "review"
        assert "a-review.md" in reason and "b-review.md" in reason

    def test_post_sync_diff_reports_in_sync(self, tmp_path):
        self._seed_collision(tmp_path)
        generate_all_commands(tmp_path)

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

    # Command parse failures are narrower than agents': frontmatter-less
    # files are tolerated (stem-named prompt body), so the only
    # ``CommandParseError`` shape is an INVALID effective name. The shadow
    # fixture is a file whose stem is the colliding name but whose
    # frontmatter ``name: -bad`` fails validation.
    _MALFORMED = "---\nname: -bad\ndescription: broken\n---\n\nBody.\n"

    def test_parse_failure_does_not_shadow_parsed_name_in_diff(self, tmp_path):
        """Sibling of the agents shadow pin (#1247 B4 Codex impl round): a
        later malformed canonical whose stem collides with an earlier parsed
        ``name:`` must not flip diff to a permanent parse-error row."""
        _make_canonical_command(
            tmp_path,
            "aaa",
            "---\nname: review\ndescription: Parsed claimant\n---\n\nBody from A.\n",
        )
        _make_canonical_command(tmp_path, "review", self._MALFORMED)
        generate_all_commands(tmp_path)

        rows = [r for r in diff_commands(tmp_path) if r[1] == "review"]
        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):
        _make_canonical_command(tmp_path, "review", self._MALFORMED)
        _make_canonical_command(
            tmp_path,
            "zzz",
            "---\nname: review\ndescription: Parsed claimant\n---\n\nBody from Z.\n",
        )
        generate_all_commands(tmp_path)

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


class TestExtractCommandsToCanonical:
    def test_imports_claude_command(self, tmp_path):
        d = tmp_path / ".claude/commands"
        d.mkdir(parents=True)
        (d / "review.md").write_text(SAMPLE_FULL_COMMAND, encoding="utf-8")
        result = extract_commands_to_canonical(tmp_path)
        assert len(result.imported) == 1
        # New commands land in directory layout per ADR-0008.
        assert (tmp_path / CANONICAL_COMMAND_ROOT / "review" / "command.md").is_file()
        assert result.skipped == []

    def test_imports_gemini_toml_with_placeholder_rewrite(self, tmp_path):
        d = tmp_path / ".gemini/commands"
        d.mkdir(parents=True)
        (d / "review.toml").write_text(
            'description = "Review a file"\nprompt = "Review {{args}} and report issues."\n',
            encoding="utf-8",
        )
        result = extract_commands_to_canonical(tmp_path)
        assert len(result.imported) == 1
        canonical = (tmp_path / CANONICAL_COMMAND_ROOT / "review" / "command.md").read_text(
            encoding="utf-8"
        )
        assert "description: Review a file" in canonical
        # {{args}} rewritten back to $ARGUMENTS
        assert "$ARGUMENTS" in canonical
        assert "{{args}}" not in canonical

    def test_gemini_toml_multiline_description_cannot_inject_frontmatter(self, tmp_path):
        """A TOML multi-line description interpolated raw used to become extra
        frontmatter lines — silently injecting keys (model, allowed-tools)
        into the canonical that then fan out to every runtime (#1229)."""
        d = tmp_path / ".gemini/commands"
        d.mkdir(parents=True)
        (d / "evil.toml").write_text(
            'description = "helper\\nmodel: gpt-4-turbo\\nallowed-tools: [Bash]"\n'
            'prompt = "do it"\n',
            encoding="utf-8",
        )
        result = extract_commands_to_canonical(tmp_path)
        assert len(result.imported) == 1
        path, layout = result.imported[0]
        cmd = parse_canonical_command(path, layout=layout)
        assert cmd.model is None
        assert cmd.allowed_tools == []
        # The injected lines are demoted to plain words inside the description.
        assert "model: gpt-4-turbo" in cmd.description

    def test_gemini_toml_description_cannot_terminate_frontmatter(self, tmp_path):
        """A '---' line inside the description used to close the frontmatter
        early, leaking the rest of the description into the body (#1229)."""
        d = tmp_path / ".gemini/commands"
        d.mkdir(parents=True)
        (d / "tricky.toml").write_text(
            'description = "first\\n---\\nrest"\nprompt = "do it"\n',
            encoding="utf-8",
        )
        result = extract_commands_to_canonical(tmp_path)
        assert len(result.imported) == 1
        path, layout = result.imported[0]
        cmd = parse_canonical_command(path, layout=layout)
        assert cmd.description == "first --- rest"
        assert cmd.body.strip() == "do it"

    def test_claude_wins_over_gemini(self, tmp_path):
        for runtime, filename, content in (
            (".claude/commands", "shared.md", SAMPLE_MINIMAL_COMMAND),
            (".gemini/commands", "shared.toml", 'prompt = "gemini"\n'),
        ):
            d = tmp_path / runtime
            d.mkdir(parents=True)
            (d / filename).write_text(content, encoding="utf-8")
        result = extract_commands_to_canonical(tmp_path)
        assert len(result.imported) == 1
        canonical = (tmp_path / CANONICAL_COMMAND_ROOT / "shared" / "command.md").read_text(
            encoding="utf-8"
        )
        assert "Simple prompt" in canonical  # claude version won
        # Gemini copy was skipped.
        assert len(result.skipped) == 1
        assert result.skipped[0][0] == "shared"
        assert "already imported" in result.skipped[0][1]

    def test_overwrite_flag(self, tmp_path):
        d = tmp_path / ".claude/commands"
        d.mkdir(parents=True)
        new = SAMPLE_MINIMAL_COMMAND.replace("Simple prompt", "UPDATED")
        (d / "hi.md").write_text(new, encoding="utf-8")

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

        result = extract_commands_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_commands_to_canonical(tmp_path, overwrite=True)
        assert len(result.imported) == 1
        assert "UPDATED" in canonical.read_text(encoding="utf-8")

    def test_skips_hostile_runtime_filename(self, tmp_path):
        """#276: runtime filenames are interpolated into canonical paths."""
        d = tmp_path / ".claude/commands"
        d.mkdir(parents=True)
        (d / "-bad.md").write_text(SAMPLE_MINIMAL_COMMAND)
        (d / "ok.md").write_text(SAMPLE_MINIMAL_COMMAND)

        result = extract_commands_to_canonical(tmp_path)
        # ExtractResult.imported is now (path, layout) tuples.
        imported_names = sorted(canonical_command_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

    def test_only_name_filters_to_one(self, tmp_path):
        d = tmp_path / ".claude/commands"
        d.mkdir(parents=True)
        (d / "alpha.md").write_text(SAMPLE_MINIMAL_COMMAND)
        (d / "beta.md").write_text(SAMPLE_MINIMAL_COMMAND)

        result = extract_commands_to_canonical(tmp_path, only_name="alpha")
        # ExtractResult.imported is (path, layout) tuples.
        assert [canonical_command_name(p, layout) for p, layout in result.imported] == ["alpha"]
        assert result.skipped == []
        # Beta was untouched — neither imported nor a canonical written.
        assert not (tmp_path / CANONICAL_COMMAND_ROOT / "beta" / "command.md").exists()

    def test_only_name_no_match_returns_empty(self, tmp_path):
        """Caller-distinguishable signal for single-name miss: imported and
        skipped both empty (route layer turns this into 404)."""
        d = tmp_path / ".claude/commands"
        d.mkdir(parents=True)
        (d / "alpha.md").write_text(SAMPLE_MINIMAL_COMMAND)

        result = extract_commands_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 covers BOTH the Claude (byte passthrough) and
        # Gemini (TOML→Markdown) write branches — neither touches disk.
        claude = tmp_path / ".claude/commands"
        claude.mkdir(parents=True)
        (claude / "review.md").write_text(SAMPLE_FULL_COMMAND, encoding="utf-8")
        gemini = tmp_path / ".gemini/commands"
        gemini.mkdir(parents=True)
        (gemini / "summarize.toml").write_text(
            'description = "Summarize"\nprompt = "Summarize {{args}}."\n',
            encoding="utf-8",
        )

        preview = extract_commands_to_canonical(tmp_path, dry_run=True)
        names = sorted(canonical_command_name(p, layout) for p, layout in preview.imported)
        assert names == ["review", "summarize"]
        assert preview.skipped == []
        # Nothing written by either branch.
        assert not (tmp_path / CANONICAL_COMMAND_ROOT).exists()

        # A real run reproduces the same import set and now writes.
        applied = extract_commands_to_canonical(tmp_path)
        assert len(applied.imported) == 2
        assert (tmp_path / CANONICAL_COMMAND_ROOT / "review" / "command.md").is_file()
        assert (tmp_path / CANONICAL_COMMAND_ROOT / "summarize" / "command.md").is_file()


class TestDiffCommands:
    def test_empty_project(self, tmp_path):
        assert diff_commands(tmp_path) == []

    def test_missing_target(self, tmp_path):
        _make_canonical_command(tmp_path, "hi", SAMPLE_MINIMAL_COMMAND)
        rows = diff_commands(tmp_path)
        assert rows
        assert all(status == "missing target" for _, _, status in rows)

    def test_in_sync_after_generate(self, tmp_path):
        _make_canonical_command(tmp_path, "hi", SAMPLE_MINIMAL_COMMAND)
        generate_all_commands(tmp_path)
        rows = diff_commands(tmp_path)
        assert all(status == "in sync" for _, _, status in rows)

    def test_out_of_sync(self, tmp_path):
        _make_canonical_command(tmp_path, "hi", SAMPLE_MINIMAL_COMMAND)
        generate_all_commands(tmp_path)
        (tmp_path / ".claude/commands/hi.md").write_text("mutated", encoding="utf-8")
        rows = diff_commands(tmp_path)
        status_by_runtime = {r: s for r, _, s in rows}
        assert status_by_runtime["claude_commands"] == "out of sync"
        assert status_by_runtime["gemini_commands"] == "in sync"

    def test_missing_canonical(self, tmp_path):
        d = tmp_path / ".claude/commands"
        d.mkdir(parents=True)
        (d / "runtime-only.md").write_text(SAMPLE_MINIMAL_COMMAND, encoding="utf-8")
        rows = diff_commands(tmp_path)
        assert any(status == "missing canonical" for _, _, status in rows)

    def test_frontmatter_name_wins_over_stem_after_sync(self, tmp_path):
        """Sync targets the parsed frontmatter ``name:``; diff must key
        canonicals the same way, or a stem/name mismatch reports two phantom
        rows per runtime that re-syncing never clears (#1229)."""
        body = "---\nname: run-review\ndescription: Simple prompt\n---\n\nSay hi.\n"
        _make_canonical_command(tmp_path, "review", body)  # stem != frontmatter name
        generate_all_commands(tmp_path)
        rows = diff_commands(tmp_path)
        assert rows
        assert {name for _, name, _ in rows} == {"run-review"}
        assert all(status == "in sync" for _, _, status in rows)

    def test_non_utf8_canonical_byte_does_not_abort_diff(self, tmp_path):
        """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_COMMAND_ROOT
        root.mkdir(parents=True, exist_ok=True)
        (root / "cafe.md").write_bytes(b"---\ndescription: caf\xe9\n---\n\nSay hi.\n")
        generate_all_commands(tmp_path)
        rows = diff_commands(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):
        """A non-UTF-8 byte in a runtime copy is drift, not a diff-wide crash
        (#1229)."""
        _make_canonical_command(tmp_path, "hi", SAMPLE_MINIMAL_COMMAND)
        generate_all_commands(tmp_path)
        (tmp_path / ".claude/commands/hi.md").write_bytes(b"caf\xe9 drift\n")
        rows = diff_commands(tmp_path)
        status_by_runtime = {r: s for r, _, s in rows}
        assert status_by_runtime["claude_commands"] == "out of sync"
        assert status_by_runtime["gemini_commands"] == "in sync"

    def test_invalid_runtime_name_surfaces_as_invalid_name_row(self, tmp_path):
        """Invalid-named runtime commands surface as a dedicated row instead
        of vanishing from diff (#1229)."""
        d = tmp_path / ".claude/commands"
        d.mkdir(parents=True)
        (d / "bad name.md").write_text(SAMPLE_MINIMAL_COMMAND, encoding="utf-8")
        rows = diff_commands(tmp_path)
        assert ("claude_commands", "bad name", "invalid name") in rows

    def test_unparseable_canonical_reports_parse_error_not_missing_target(self, tmp_path):
        """Mirrors diff_agents: parse-error canonicals must not masquerade as
        'missing target' (#1229). Commands tolerate missing frontmatter, so
        the unparseable case here is an invalid effective name."""
        _make_canonical_command(tmp_path, "broken", "---\nname: bad name\n---\n\nbody\n")
        rows = diff_commands(tmp_path)
        statuses = {s for _, n, s in rows if n == "broken"}
        assert statuses == {"parse error"}

    def test_parse_error_row_carries_reason(self, tmp_path):
        """U7 (#1229): mirrors diff_agents — the row reason embeds the
        source path via the exception text."""
        _make_canonical_command(tmp_path, "broken", "---\nname: bad name\n---\n\nbody\n")
        rows = diff_commands(tmp_path)
        row = next(r for r in rows if r[1] == "broken")
        assert row[2] == "parse error"
        assert "invalid command name" in (row.reason or "")
        assert "broken.md" in (row.reason or "")

    def test_whitespace_only_drift_detected_and_converges(self, tmp_path):
        """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_command(tmp_path, "hi", SAMPLE_MINIMAL_COMMAND)
        generate_all_commands(tmp_path)
        target = tmp_path / ".claude/commands/hi.md"
        target.write_bytes(target.read_bytes() + b"\n")

        status_by_runtime = {r: s for r, _, s in diff_commands(tmp_path)}
        assert status_by_runtime["claude_commands"] == "out of sync"
        assert status_by_runtime["gemini_commands"] == "in sync"

        generate_all_commands(tmp_path)
        assert all(s == "in sync" for _, _, s in diff_commands(tmp_path))


class TestDetectCommandDirs:
    def test_empty(self, tmp_path):
        assert detect_command_dirs(tmp_path) == []

    def test_detects_claude(self, tmp_path):
        d = tmp_path / ".claude/commands"
        d.mkdir(parents=True)
        (d / "review.md").write_text(SAMPLE_MINIMAL_COMMAND, encoding="utf-8")
        found = detect_command_dirs(tmp_path)
        assert len(found) == 1
        assert found[0].agent == "claude_commands"
        assert found[0].kind == "command_file"

    def test_detects_gemini_toml(self, tmp_path):
        d = tmp_path / ".gemini/commands"
        d.mkdir(parents=True)
        (d / "review.toml").write_text('prompt = "hi"\n', encoding="utf-8")
        found = detect_command_dirs(tmp_path)
        assert len(found) == 1
        assert found[0].agent == "gemini_commands"
        assert found[0].path.suffix == ".toml"

    def test_ignores_wrong_extension(self, tmp_path):
        # .md inside .gemini/commands is NOT a Gemini command — skip it.
        d = tmp_path / ".gemini/commands"
        d.mkdir(parents=True)
        (d / "stray.md").write_text("not a toml command", encoding="utf-8")
        found = detect_command_dirs(tmp_path)
        assert found == []


class TestOnDrop:
    def test_on_drop_warn_logs(self, tmp_path, caplog):
        _make_canonical_command(tmp_path, "review", SAMPLE_FULL_COMMAND)
        with caplog.at_level("WARNING"):
            result = generate_all_commands(tmp_path, runtimes=["gemini_commands"], on_drop="warn")
        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_command(tmp_path, "review", SAMPLE_FULL_COMMAND)
        with pytest.raises(StrictDropError):
            generate_all_commands(tmp_path, runtimes=["gemini_commands"], on_drop="error")

    def test_on_drop_ignore_is_silent(self, tmp_path, caplog):
        _make_canonical_command(tmp_path, "review", SAMPLE_FULL_COMMAND)
        with caplog.at_level("WARNING"):
            result = generate_all_commands(tmp_path, runtimes=["gemini_commands"], 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_command(tmp_path, "review", SAMPLE_FULL_COMMAND)
        with pytest.raises(StrictDropError):
            generate_all_commands(tmp_path, runtimes=["gemini_commands"], strict=True)


class TestRoundtrip:
    def test_canonical_to_claude_and_back(self, tmp_path):
        _make_canonical_command(tmp_path, "review", SAMPLE_FULL_COMMAND)
        generate_all_commands(tmp_path, runtimes=["claude_commands"])

        shutil.rmtree(tmp_path / CANONICAL_COMMAND_ROOT)
        result = extract_commands_to_canonical(tmp_path)
        assert len(result.imported) == 1
        # New canonical lands in dir layout per ADR-0008.
        reparsed = parse_canonical_command(
            tmp_path / CANONICAL_COMMAND_ROOT / "review" / "command.md", layout="dir"
        )
        assert reparsed.name == "review"
        assert "$ARGUMENTS" in reparsed.body

    def test_canonical_to_gemini_and_back(self, tmp_path):
        # Minimal command so no fields get dropped on the Gemini side.
        _make_canonical_command(tmp_path, "hi", SAMPLE_MINIMAL_COMMAND)
        generate_all_commands(tmp_path, runtimes=["gemini_commands"])

        shutil.rmtree(tmp_path / CANONICAL_COMMAND_ROOT)
        result = extract_commands_to_canonical(tmp_path)
        assert len(result.imported) == 1
        # New canonical lands in dir layout per ADR-0008.
        canonical = (tmp_path / CANONICAL_COMMAND_ROOT / "hi" / "command.md").read_text(
            encoding="utf-8"
        )
        assert "description: Simple prompt" in canonical
        assert "$ARGUMENTS" in canonical
        assert "{{args}}" not in canonical


class TestCrlfParsing:
    """#279: command parser shares agents.py's frontmatter regex — CRLF files
    must parse the same way they do on LF systems."""

    def test_crlf_frontmatter_parses(self, tmp_path):
        p = tmp_path / CANONICAL_COMMAND_ROOT / "crlf.md"
        p.parent.mkdir(parents=True)
        p.write_bytes(SAMPLE_FULL_COMMAND.replace("\n", "\r\n").encode("utf-8"))
        cmd = parse_canonical_command(p)
        assert cmd.name == "crlf"
        assert cmd.description == "Review a file for issues"
        assert cmd.argument_hint == "[file-path]"
        assert cmd.allowed_tools == ["Read", "Grep"]
        assert "$ARGUMENTS" in cmd.body
        assert "\r" not in cmd.body


class TestBomParsing:
    """#1229: the frontmatter regex anchors at position 0, so a BOM-prefixed
    command parsed as frontmatter-less — description/argument-hint/
    allowed-tools/model were SILENTLY dropped (the raw frontmatter became the
    prompt body) while diff still reported "in sync"."""

    def test_bom_frontmatter_parses(self, tmp_path):
        p = tmp_path / CANONICAL_COMMAND_ROOT / "bom.md"
        p.parent.mkdir(parents=True)
        p.write_bytes(b"\xef\xbb\xbf" + SAMPLE_FULL_COMMAND.encode("utf-8"))
        cmd = parse_canonical_command(p)
        assert cmd.name == "bom"
        assert cmd.description == "Review a file for issues"
        assert cmd.argument_hint == "[file-path]"
        assert cmd.allowed_tools == ["Read", "Grep"]
        assert cmd.model == "sonnet"
        assert "﻿" not in cmd.body

    def test_bom_crlf_combo_parses(self, tmp_path):
        p = tmp_path / CANONICAL_COMMAND_ROOT / "bomcrlf.md"
        p.parent.mkdir(parents=True)
        p.write_bytes(b"\xef\xbb\xbf" + SAMPLE_FULL_COMMAND.replace("\n", "\r\n").encode("utf-8"))
        cmd = parse_canonical_command(p)
        assert cmd.description == "Review a file for issues"
        assert "\r" not in cmd.body
        assert "﻿" not in cmd.body

    def test_bom_frontmatterless_body_is_clean(self, tmp_path):
        """The tolerated no-frontmatter branch no longer leaks the BOM into
        the prompt body (``lstrip("\\n")`` never removed it)."""
        p = tmp_path / CANONICAL_COMMAND_ROOT / "plain.md"
        p.parent.mkdir(parents=True)
        p.write_bytes(b"\xef\xbb\xbfSay hi.\n")
        cmd = parse_canonical_command(p)
        assert cmd.name == "plain"
        assert cmd.body == "Say hi.\n"

    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 / CANONICAL_COMMAND_ROOT / "zwnbsp.md"
        p.parent.mkdir(parents=True)
        p.write_text(SAMPLE_MINIMAL_COMMAND + "zero﻿width\n", encoding="utf-8")
        cmd = parse_canonical_command(p)
        assert "zero﻿width" in cmd.body

    def test_bom_canonical_fans_out_metadata_and_stays_in_sync(self, tmp_path):
        """End-to-end: BOM canonical → generate → rendered runtime file is
        BOM-free and carries the real description (pre-fix it was dropped and
        the BOM leaked into the body), and diff is genuinely in sync."""
        p = tmp_path / CANONICAL_COMMAND_ROOT / "bom-e2e.md"
        p.parent.mkdir(parents=True)
        p.write_bytes(b"\xef\xbb\xbf" + SAMPLE_FULL_COMMAND.encode("utf-8"))
        generate_all_commands(tmp_path)

        rendered_path = tmp_path / ".claude/commands/bom-e2e.md"
        assert b"\xef\xbb\xbf" not in rendered_path.read_bytes()
        rendered = parse_canonical_command(rendered_path)
        assert rendered.description == "Review a file for issues"
        assert all(status == "in sync" for _, _, status in diff_commands(tmp_path))

    def test_bom_gemini_toml_imports(self, tmp_path):
        """tomllib rejects a raw BOM — reading Gemini TOML with ``utf-8-sig``
        lets a BOM-prefixed Windows-authored .toml import instead of skipping
        with a TOML parse error."""
        d = tmp_path / ".gemini/commands"
        d.mkdir(parents=True)
        (d / "review.toml").write_bytes(
            b"\xef\xbb\xbf" + b'description = "Review a file"\nprompt = "Review {{args}}."\n'
        )
        result = extract_commands_to_canonical(tmp_path)
        assert len(result.imported) == 1
        canonical = (tmp_path / CANONICAL_COMMAND_ROOT / "review" / "command.md").read_text(
            encoding="utf-8"
        )
        assert "description: Review a file" in canonical
