"""Tests for trw_mcp.bootstrap — PRD-INFRA-006."""

from __future__ import annotations

import importlib.metadata
import json
from datetime import datetime
from pathlib import Path

import pytest
import tomllib

from trw_mcp.bootstrap import (
    _DATA_DIR,
    detect_ide,
    detect_installed_clis,
    init_project,
    resolve_ide_targets,
    update_project,
)
from trw_mcp.bootstrap._codex import (
    generate_codex_agents,
    generate_codex_config,
    generate_codex_hooks,
    install_codex_skills,
    merge_codex_config,
)
from trw_mcp.bootstrap._utils import _result_action_key, _write_version_yaml
from trw_mcp.models.config import TRWConfig
from trw_mcp.state.persistence import FileStateReader


@pytest.fixture()
def fake_git_repo(tmp_path: Path) -> Path:
    """Create a minimal fake git repo directory."""
    (tmp_path / ".git").mkdir()
    return tmp_path


# ── Structure Tests ──────────────────────────────────────────────────────


@pytest.mark.unit
class TestInitProjectStructure:
    """Test that init_project creates all expected directories and files."""

    def test_creates_trw_directories(self, fake_git_repo: Path) -> None:
        result = init_project(fake_git_repo)
        assert not result["errors"]

        expected_dirs = [
            ".trw/frameworks",
            ".trw/context",
            ".trw/templates",
            ".trw/learnings/entries",
            ".trw/scripts",
            ".claude/hooks",
        ]
        for d in expected_dirs:
            assert (fake_git_repo / d).is_dir(), f"Missing directory: {d}"

    def test_creates_framework_files(self, fake_git_repo: Path) -> None:
        result = init_project(fake_git_repo)
        assert not result["errors"]

        expected_files = [
            ".trw/frameworks/FRAMEWORK.md",
            ".trw/context/behavioral_protocol.yaml",
            ".trw/context/messages.yaml",
            ".trw/templates/claude_md.md",
            ".trw/config.yaml",
            ".trw/learnings/index.yaml",
            ".trw/.gitignore",
            ".claude/settings.json",
            ".mcp.json",
            "CLAUDE.md",
            "REVIEW.md",
        ]
        for f in expected_files:
            assert (fake_git_repo / f).is_file(), f"Missing file: {f}"

    def test_creates_all_expected_files(self, fake_git_repo: Path) -> None:
        """All files reported as created on first run."""
        result = init_project(fake_git_repo)
        assert not result["errors"]
        assert len(result["created"]) > 0
        assert len(result["skipped"]) == 0


# ── Idempotency Tests ───────────────────────────────────────────────────


@pytest.mark.unit
class TestIdempotency:
    """Test that re-running without --force skips existing files."""

    def test_second_run_skips_existing(self, fake_git_repo: Path) -> None:
        init_project(fake_git_repo)
        result2 = init_project(fake_git_repo)

        assert not result2["errors"]
        # All files should be skipped, no new creates (dirs already exist too)
        assert len(result2["skipped"]) > 0
        # Dirs don't report as created when they already exist.
        # .mcp.json (merge) and installer-meta.yaml (always-write) are expected.
        file_creates = [c for c in result2["created"] if not c.endswith("/")]
        expected_always_write = {".mcp.json", "installer-meta.yaml", "managed-artifacts.yaml", "VERSION.yaml"}
        unexpected = [c for c in file_creates if not any(e in c for e in expected_always_write)]
        assert len(unexpected) == 0, f"Unexpected creates: {unexpected}"

    def test_force_overwrites(self, fake_git_repo: Path) -> None:
        init_project(fake_git_repo)

        # Modify a file
        claude_md = fake_git_repo / "CLAUDE.md"
        claude_md.write_text("modified content", encoding="utf-8")

        result2 = init_project(fake_git_repo, force=True)
        assert not result2["errors"]

        # File should be re-created
        restored = claude_md.read_text(encoding="utf-8")
        assert restored != "modified content"
        assert "trw_session_start" in restored


# ── Validation Tests ─────────────────────────────────────────────────────


@pytest.mark.unit
class TestValidation:
    """Test error handling and validation."""

    def test_not_git_repo_errors(self, tmp_path: Path) -> None:
        """Should error when target is not a git repository."""
        result = init_project(tmp_path)
        assert len(result["errors"]) == 1
        assert ".git/ not found" in result["errors"][0]
        assert len(result["created"]) == 0


# ── Content Tests ────────────────────────────────────────────────────────


@pytest.mark.unit
class TestContent:
    """Test content correctness of generated files."""

    def test_mcp_json_valid(self, fake_git_repo: Path) -> None:
        init_project(fake_git_repo)
        mcp_json = fake_git_repo / ".mcp.json"
        data = json.loads(mcp_json.read_text(encoding="utf-8"))

        assert "mcpServers" in data
        assert "trw" in data["mcpServers"]
        assert "command" in data["mcpServers"]["trw"]
        assert "args" in data["mcpServers"]["trw"]

    def test_claude_md_has_protocol(self, fake_git_repo: Path) -> None:
        init_project(fake_git_repo)
        content = (fake_git_repo / "CLAUDE.md").read_text(encoding="utf-8")

        assert "trw_session_start" in content
        assert "trw_deliver" in content

    def test_framework_md_is_v24(self, fake_git_repo: Path) -> None:
        init_project(fake_git_repo)
        path = fake_git_repo / ".trw" / "frameworks" / "FRAMEWORK.md"
        content = path.read_text(encoding="utf-8")
        assert TRWConfig().framework_version in content

    def test_config_yaml_has_defaults(self, fake_git_repo: Path) -> None:
        init_project(fake_git_repo)
        content = (fake_git_repo / ".trw" / "config.yaml").read_text(encoding="utf-8")
        assert "task_root: docs" in content
        assert "debug: false" in content

    def test_learnings_index_initialized(self, fake_git_repo: Path) -> None:
        init_project(fake_git_repo)
        path = fake_git_repo / ".trw" / "learnings" / "index.yaml"
        content = path.read_text(encoding="utf-8")
        assert "entries: []" in content

    def test_gitignore_has_expected_patterns(self, fake_git_repo: Path) -> None:
        init_project(fake_git_repo)
        content = (fake_git_repo / ".trw" / ".gitignore").read_text(encoding="utf-8")
        assert "context/" in content
        assert "logs/" in content
        assert "reflections/" in content
        assert "*.jsonl" in content

    def test_behavioral_protocol_copied(self, fake_git_repo: Path) -> None:
        init_project(fake_git_repo)
        path = fake_git_repo / ".trw" / "context" / "behavioral_protocol.yaml"
        content = path.read_text(encoding="utf-8")
        assert "directives:" in content

    def test_settings_json_copied(self, fake_git_repo: Path) -> None:
        init_project(fake_git_repo)
        path = fake_git_repo / ".claude" / "settings.json"
        assert "hooks" in json.loads(path.read_text(encoding="utf-8"))


# ── Hooks Tests ──────────────────────────────────────────────────────────


@pytest.mark.unit
class TestHooks:
    """Test hook script copying."""

    EXPECTED_HOOKS = [
        "lib-ide-adapter.sh",
        "lib-trw.sh",
        "post-tool-event.sh",
        "pre-compact.sh",
        "pre-tool-deliver-gate.sh",
        "session-end.sh",
        "session-start.sh",
        "stop-ceremony.sh",
        "subagent-start.sh",
        "subagent-stop.sh",
        "task-completed.sh",
        "teammate-idle.sh",
        "user-prompt-submit.sh",
        "validate-prd-write.sh",
    ]

    def test_all_hooks_copied(self, fake_git_repo: Path) -> None:
        init_project(fake_git_repo)
        hooks_dir = fake_git_repo / ".claude" / "hooks"

        copied = sorted(f.name for f in hooks_dir.iterdir() if f.suffix == ".sh")
        assert copied == self.EXPECTED_HOOKS

    def test_hooks_no_phase_check(self, fake_git_repo: Path) -> None:
        """post-phase-check.sh should NOT be deployed (tool removed)."""
        init_project(fake_git_repo)
        assert not (fake_git_repo / ".claude" / "hooks" / "post-phase-check.sh").exists()

    def test_hooks_not_empty(self, fake_git_repo: Path) -> None:
        init_project(fake_git_repo)
        hooks_dir = fake_git_repo / ".claude" / "hooks"

        for hook in self.EXPECTED_HOOKS:
            hook_file = hooks_dir / hook
            assert hook_file.stat().st_size > 0, f"Hook {hook} is empty"


# ── Skills Tests ────────────────────────────────────────────────────────


@pytest.mark.unit
class TestSkills:
    """Test skill directory deployment."""

    EXPECTED_SKILLS = [
        "trw-audit",
        "trw-ceremony-guide",
        "trw-commit",
        "trw-deliver",
        "trw-dry-check",
        "trw-email-template",
        "trw-exec-plan",
        "trw-framework-check",
        "trw-learn",
        "trw-memory-audit",
        "trw-memory-optimize",
        "trw-prd-groom",
        "trw-prd-new",
        "trw-prd-ready",
        "trw-prd-review",
        "trw-project-health",
        "trw-security-check",
        "trw-simplify",
        "trw-sprint-finish",
        "trw-sprint-init",
        "trw-sprint-team",
        "trw-team-playbook",
        "trw-test-strategy",
    ]

    def test_init_deploys_skills(self, fake_git_repo: Path) -> None:
        """After init_project(), .claude/skills/ has 23 subdirectories each with SKILL.md."""
        result = init_project(fake_git_repo)
        assert not result["errors"]

        skills_dir = fake_git_repo / ".claude" / "skills"
        deployed = sorted(d.name for d in skills_dir.iterdir() if d.is_dir())
        assert deployed == self.EXPECTED_SKILLS

        for skill in self.EXPECTED_SKILLS:
            skill_md = skills_dir / skill / "SKILL.md"
            assert skill_md.is_file(), f"Missing SKILL.md in {skill}"
            assert skill_md.stat().st_size > 0, f"SKILL.md is empty in {skill}"

    def test_init_force_overwrites_skills(self, fake_git_repo: Path) -> None:
        """Write dummy SKILL.md, run init_project(force=True), verify content changed."""
        init_project(fake_git_repo)

        # Write dummy content to one skill
        dummy_path = fake_git_repo / ".claude" / "skills" / "trw-deliver" / "SKILL.md"
        dummy_path.write_text("dummy content", encoding="utf-8")

        result = init_project(fake_git_repo, force=True)
        assert not result["errors"]

        restored = dummy_path.read_text(encoding="utf-8")
        assert restored != "dummy content"

    def test_init_skills_idempotent(self, fake_git_repo: Path) -> None:
        """Run init_project() twice — no errors and same file count."""
        result1 = init_project(fake_git_repo)
        assert not result1["errors"]

        result2 = init_project(fake_git_repo)
        assert not result2["errors"]

        # All skill files should be skipped on second run
        skills_dir = fake_git_repo / ".claude" / "skills"
        deployed = sorted(d.name for d in skills_dir.iterdir() if d.is_dir())
        assert deployed == self.EXPECTED_SKILLS


# ── Simplify Skill Content Tests ────────────────────────────────────────


@pytest.mark.unit
class TestSimplifySkillContent:
    """Test that bundled simplify skill contains only generic, portable content."""

    BUNDLED_SKILL_DIR = _DATA_DIR / "skills" / "trw-simplify"
    BUNDLED_SKILL = BUNDLED_SKILL_DIR / "SKILL.md"
    TRW_SPECIFIC_TERMS = ["lock_for_rmw", "TRWConfig", "trw://config", "FastMCP"]

    def test_bundled_simplify_skill_exists(self) -> None:
        """Bundled simplify SKILL.md exists at the expected path."""
        assert self.BUNDLED_SKILL.exists()

    def test_bundled_simplify_skill_is_generic(self) -> None:
        """Bundled simplify SKILL.md contains no trw-mcp-specific terms."""
        content = self.BUNDLED_SKILL.read_text(encoding="utf-8")
        for term in self.TRW_SPECIFIC_TERMS:
            assert term not in content, f"Found trw-mcp-specific term '{term}' in bundled skill"

    def test_bundled_simplify_has_preservation_rules(self) -> None:
        """Bundled simplify SKILL.md contains all 10 Preservation Rules."""
        content = self.BUNDLED_SKILL.read_text(encoding="utf-8")
        assert "Preservation Rules" in content
        for i in range(1, 11):
            assert f"{i}. **DO NOT" in content, f"Missing preservation rule {i}"

    def test_bundled_simplify_no_conventions_file(self) -> None:
        """Bundled simplify skill directory contains no conventions.md."""
        conventions_path = self.BUNDLED_SKILL_DIR / "conventions.md"
        assert not conventions_path.exists(), "conventions.md should not be bundled"


# ── Agents Tests ────────────────────────────────────────────────────────


@pytest.mark.unit
class TestAgents:
    """Test agent file deployment.

    Adding a new agent? See TestAgentDefinitions docstring in test_agent_teams.py
    for the full update sequence (7 locations).
    """

    EXPECTED_AGENTS = [
        "trw-auditor.md",
        "trw-implementer.md",
        "trw-prd-groomer.md",
        "trw-researcher.md",
        "trw-reviewer.md",
    ]

    def test_init_deploys_agents(self, fake_git_repo: Path) -> None:
        """After init_project(), .claude/agents/ has agent .md files."""
        result = init_project(fake_git_repo)
        assert not result["errors"]

        agents_dir = fake_git_repo / ".claude" / "agents"
        deployed = sorted(f.name for f in agents_dir.iterdir() if f.suffix == ".md")
        assert deployed == self.EXPECTED_AGENTS

        for agent in self.EXPECTED_AGENTS:
            agent_file = agents_dir / agent
            assert agent_file.stat().st_size > 0, f"Agent {agent} is empty"


# ── Bootstrap Config Flags — PRD-INFRA-011-FR06 ────────────────────────


@pytest.mark.unit
class TestBootstrapConfigFlags:
    """Tests for source_package and test_path bootstrap flags — PRD-INFRA-011-FR06."""

    def test_source_package_in_config(self, fake_git_repo: Path) -> None:
        """source_package='myapp' → config.yaml has source_package_name: myapp."""
        init_project(fake_git_repo, source_package="myapp")
        content = (fake_git_repo / ".trw" / "config.yaml").read_text(encoding="utf-8")
        assert "source_package_name: myapp" in content

    def test_test_path_in_config(self, fake_git_repo: Path) -> None:
        """test_path='tests' → config.yaml has tests_relative_path: tests."""
        init_project(fake_git_repo, test_path="tests")
        content = (fake_git_repo / ".trw" / "config.yaml").read_text(encoding="utf-8")
        assert "tests_relative_path: tests" in content

    def test_both_flags_in_config(self, fake_git_repo: Path) -> None:
        """Both flags → config.yaml has both fields."""
        init_project(fake_git_repo, source_package="myapp", test_path="tests")
        content = (fake_git_repo / ".trw" / "config.yaml").read_text(encoding="utf-8")
        assert "source_package_name: myapp" in content
        assert "tests_relative_path: tests" in content

    def test_default_no_extra_fields(self, fake_git_repo: Path) -> None:
        """No args → config.yaml does NOT have source_package_name or tests_relative_path."""
        init_project(fake_git_repo)
        content = (fake_git_repo / ".trw" / "config.yaml").read_text(encoding="utf-8")
        assert "source_package_name" not in content
        assert "tests_relative_path" not in content


# ── Update Project Tests ─────────────────────────────────────────────────


@pytest.fixture()
def initialized_repo(fake_git_repo: Path) -> Path:
    """Create a repo with TRW already initialized."""
    result = init_project(fake_git_repo)
    assert not result["errors"]
    return fake_git_repo


@pytest.mark.unit
class TestUpdateProjectBasics:
    """Test update_project basic behavior."""

    def test_requires_trw_installed(self, tmp_path: Path) -> None:
        """update_project errors if .trw/ does not exist."""
        result = update_project(tmp_path)
        assert len(result["errors"]) == 1
        assert ".trw/ not found" in result["errors"][0]

    def test_no_errors_on_initialized_repo(self, initialized_repo: Path) -> None:
        """update_project succeeds on an initialized repo."""
        result = update_project(initialized_repo)
        assert not result["errors"]

    def test_reports_updated_files(self, initialized_repo: Path) -> None:
        """update_project reports framework files as updated."""
        result = update_project(initialized_repo)
        assert len(result["updated"]) > 0
        # Should have updated hooks, skills, agents, framework files
        updated_str = "\n".join(result["updated"])
        assert "FRAMEWORK.md" in updated_str
        assert "hooks" in updated_str

    def test_reports_preserved_files(self, initialized_repo: Path) -> None:
        """update_project reports user files as preserved."""
        result = update_project(initialized_repo)
        preserved_str = "\n".join(result["preserved"])
        assert "config.yaml" in preserved_str


@pytest.mark.unit
class TestUpdatePreservesUserFiles:
    """Test that update_project never overwrites user-customized files."""

    def test_preserves_config_yaml(self, initialized_repo: Path) -> None:
        """User's config.yaml is never overwritten."""
        config_path = initialized_repo / ".trw" / "config.yaml"
        config_path.write_text("custom_setting: true\n", encoding="utf-8")

        update_project(initialized_repo)

        content = config_path.read_text(encoding="utf-8")
        assert "custom_setting: true" in content

    def test_preserves_learnings(self, initialized_repo: Path) -> None:
        """User's learnings index is never overwritten."""
        index_path = initialized_repo / ".trw" / "learnings" / "index.yaml"
        index_path.write_text("entries:\n- id: L001\n", encoding="utf-8")

        update_project(initialized_repo)

        content = index_path.read_text(encoding="utf-8")
        assert "L001" in content

    def test_preserves_mcp_json(self, initialized_repo: Path) -> None:
        """User's .mcp.json is never overwritten."""
        mcp_path = initialized_repo / ".mcp.json"
        mcp_path.write_text('{"custom": true}\n', encoding="utf-8")

        update_project(initialized_repo)

        content = mcp_path.read_text(encoding="utf-8")
        assert '"custom": true' in content


@pytest.mark.unit
class TestUpdateOverwritesFrameworkFiles:
    """Test that update_project overwrites framework-managed files."""

    def test_updates_framework_md(self, initialized_repo: Path) -> None:
        """FRAMEWORK.md is overwritten with latest version."""
        fw_path = initialized_repo / ".trw" / "frameworks" / "FRAMEWORK.md"
        fw_path.write_text("old framework content", encoding="utf-8")

        update_project(initialized_repo)

        content = fw_path.read_text(encoding="utf-8")
        assert content != "old framework content"
        assert TRWConfig().framework_version in content

    def test_updates_hooks(self, initialized_repo: Path) -> None:
        """Hook scripts are overwritten with latest versions."""
        hook_path = initialized_repo / ".claude" / "hooks" / "session-start.sh"
        hook_path.write_text("old hook", encoding="utf-8")

        update_project(initialized_repo)

        content = hook_path.read_text(encoding="utf-8")
        assert content != "old hook"

    def test_updates_skills(self, initialized_repo: Path) -> None:
        """Skill files are overwritten with latest versions."""
        skill_path = initialized_repo / ".claude" / "skills" / "trw-deliver" / "SKILL.md"
        skill_path.write_text("old skill", encoding="utf-8")

        update_project(initialized_repo)

        content = skill_path.read_text(encoding="utf-8")
        assert content != "old skill"

    def test_updates_agents(self, initialized_repo: Path) -> None:
        """Agent files are overwritten with latest versions."""
        agent_path = initialized_repo / ".claude" / "agents" / "trw-implementer.md"
        agent_path.write_text("old agent", encoding="utf-8")

        update_project(initialized_repo)

        content = agent_path.read_text(encoding="utf-8")
        assert content != "old agent"


@pytest.mark.unit
class TestUpdateClaudeMdSmartMerge:
    """Test that update_project smart-merges CLAUDE.md."""

    def test_preserves_user_sections(self, initialized_repo: Path) -> None:
        """User content above TRW markers is preserved."""
        claude_md = initialized_repo / "CLAUDE.md"
        content = claude_md.read_text(encoding="utf-8")

        # Add user content before the TRW section
        user_section = "## My Custom Section\n\nThis is user content.\n\n"
        content = content.replace("<!-- TRW AUTO-GENERATED", user_section + "<!-- TRW AUTO-GENERATED")
        claude_md.write_text(content, encoding="utf-8")

        update_project(initialized_repo)

        updated = claude_md.read_text(encoding="utf-8")
        assert "My Custom Section" in updated
        assert "This is user content." in updated
        assert "trw_session_start" in updated  # TRW section still present

    def test_updates_trw_section(self, initialized_repo: Path) -> None:
        """TRW auto-generated section is updated."""
        claude_md = initialized_repo / "CLAUDE.md"

        update_project(initialized_repo)

        content = claude_md.read_text(encoding="utf-8")
        assert "<!-- trw:start -->" in content
        assert "<!-- trw:end -->" in content
        assert "trw_session_start" in content

    def test_appends_trw_section_if_missing(self, initialized_repo: Path) -> None:
        """If CLAUDE.md has no TRW markers, append the section."""
        claude_md = initialized_repo / "CLAUDE.md"
        claude_md.write_text("# My Project\n\nNo TRW section here.\n", encoding="utf-8")

        update_project(initialized_repo)

        content = claude_md.read_text(encoding="utf-8")
        assert "# My Project" in content
        assert "<!-- trw:start -->" in content
        assert "trw_session_start" in content

    def test_creates_claude_md_if_missing(self, initialized_repo: Path) -> None:
        """If CLAUDE.md doesn't exist, create it from template."""
        claude_md = initialized_repo / "CLAUDE.md"
        claude_md.unlink()

        result = update_project(initialized_repo)
        assert not result["errors"]
        assert claude_md.exists()
        assert "trw_session_start" in claude_md.read_text(encoding="utf-8")


@pytest.mark.unit
class TestUpdateRemovesStaleArtifacts:
    """Test that update_project cleans up renamed/removed artifacts."""

    def test_removes_stale_managed_hook(self, initialized_repo: Path) -> None:
        """Hook listed in manifest but no longer bundled is removed."""
        # init_project writes manifest; add a fake entry to simulate stale
        manifest_path = initialized_repo / ".trw" / "managed-artifacts.yaml"
        from trw_mcp.state.persistence import FileStateReader, FileStateWriter

        reader = FileStateReader()
        manifest = reader.read_yaml(manifest_path)
        assert isinstance(manifest, dict)
        hooks_list = list(manifest.get("hooks", []))
        hooks_list.append("old-removed-hook.sh")
        manifest["hooks"] = hooks_list
        FileStateWriter().write_yaml(manifest_path, manifest)

        stale_hook = initialized_repo / ".claude" / "hooks" / "old-removed-hook.sh"
        stale_hook.write_text("#!/bin/sh\nexit 0", encoding="utf-8")

        result = update_project(initialized_repo)

        assert not stale_hook.exists()
        assert any("removed:" in u and "old-removed-hook" in u for u in result["updated"])

    def test_removes_stale_managed_skill(self, initialized_repo: Path) -> None:
        """Skill listed in manifest but no longer bundled is removed."""
        manifest_path = initialized_repo / ".trw" / "managed-artifacts.yaml"
        from trw_mcp.state.persistence import FileStateReader, FileStateWriter

        reader = FileStateReader()
        manifest = reader.read_yaml(manifest_path)
        assert isinstance(manifest, dict)
        skills_list = list(manifest.get("skills", []))
        skills_list.append("trw-old-skill")
        manifest["skills"] = skills_list
        FileStateWriter().write_yaml(manifest_path, manifest)

        stale_skill = initialized_repo / ".claude" / "skills" / "trw-old-skill"
        stale_skill.mkdir(parents=True, exist_ok=True)
        (stale_skill / "SKILL.md").write_text("old", encoding="utf-8")

        update_project(initialized_repo)

        assert not stale_skill.exists()

    def test_removes_stale_managed_agent(self, initialized_repo: Path) -> None:
        """Agent listed in manifest but no longer bundled is removed."""
        manifest_path = initialized_repo / ".trw" / "managed-artifacts.yaml"
        from trw_mcp.state.persistence import FileStateReader, FileStateWriter

        reader = FileStateReader()
        manifest = reader.read_yaml(manifest_path)
        assert isinstance(manifest, dict)
        agents_list = list(manifest.get("agents", []))
        agents_list.append("trw-old-agent.md")
        manifest["agents"] = agents_list
        FileStateWriter().write_yaml(manifest_path, manifest)

        stale_agent = initialized_repo / ".claude" / "agents" / "trw-old-agent.md"
        stale_agent.write_text("old agent", encoding="utf-8")

        update_project(initialized_repo)

        assert not stale_agent.exists()

    def test_does_not_remove_non_md_agents(self, initialized_repo: Path) -> None:
        """Non-.md files in agents directory are not touched."""
        other_file = initialized_repo / ".claude" / "agents" / "notes.txt"
        other_file.write_text("user notes", encoding="utf-8")

        update_project(initialized_repo)

        assert other_file.exists()

    def test_custom_skill_survives_update(self, initialized_repo: Path) -> None:
        """Custom skills NOT in manifest are never deleted by update."""
        custom_skill = initialized_repo / ".claude" / "skills" / "my-deploy"
        custom_skill.mkdir(parents=True, exist_ok=True)
        (custom_skill / "SKILL.md").write_text("custom", encoding="utf-8")

        update_project(initialized_repo)

        assert custom_skill.exists()
        assert (custom_skill / "SKILL.md").read_text(encoding="utf-8") == "custom"

    def test_custom_agent_survives_update(self, initialized_repo: Path) -> None:
        """Custom agents NOT in manifest are never deleted by update."""
        custom_agent = initialized_repo / ".claude" / "agents" / "my-reviewer.md"
        custom_agent.write_text("custom agent", encoding="utf-8")

        update_project(initialized_repo)

        assert custom_agent.exists()
        assert custom_agent.read_text(encoding="utf-8") == "custom agent"

    def test_no_cleanup_without_manifest(self, fake_git_repo: Path) -> None:
        """First update without manifest writes manifest but skips cleanup."""
        # Manually init without manifest (simulate pre-manifest install)
        init_project(fake_git_repo)
        manifest_path = fake_git_repo / ".trw" / "managed-artifacts.yaml"
        manifest_path.unlink()  # Remove manifest written by init

        # Add a custom skill that should survive
        custom_skill = fake_git_repo / ".claude" / "skills" / "my-custom"
        custom_skill.mkdir(parents=True, exist_ok=True)
        (custom_skill / "SKILL.md").write_text("custom", encoding="utf-8")

        result = update_project(fake_git_repo)
        assert not result["errors"]

        # Custom skill survives (no cleanup without prior manifest)
        assert custom_skill.exists()
        # Manifest is now written for future updates
        assert manifest_path.exists()


@pytest.mark.unit
class TestUpdateCreatesNewArtifacts:
    """Test that update_project creates new artifacts from newer versions."""

    def test_creates_new_skill(self, initialized_repo: Path) -> None:
        """New skills in bundled data are deployed."""
        # All skills should exist after update
        result = update_project(initialized_repo)
        assert not result["errors"]

        skills_dir = initialized_repo / ".claude" / "skills"
        deployed = sorted(d.name for d in skills_dir.iterdir() if d.is_dir())
        # Should have all expected skills
        assert "trw-deliver" in deployed
        assert "trw-learn" in deployed
        assert "trw-project-health" in deployed

    def test_creates_new_agent(self, initialized_repo: Path) -> None:
        """New agents in bundled data are deployed."""
        result = update_project(initialized_repo)
        assert not result["errors"]

        agents_dir = initialized_repo / ".claude" / "agents"
        deployed = sorted(f.name for f in agents_dir.iterdir() if f.suffix == ".md")
        assert "trw-implementer.md" in deployed
        assert "trw-auditor.md" in deployed


@pytest.mark.unit
class TestUpdateWarningsAndVersionCheck:
    """Test update_project warnings, version check, and restart guidance."""

    def test_includes_restart_warning(self, initialized_repo: Path) -> None:
        """update_project always warns about restarting sessions."""
        result = update_project(initialized_repo)
        assert "warnings" in result
        assert any("Restart" in w for w in result["warnings"])

    def test_includes_version_check(self, initialized_repo: Path) -> None:
        """update_project checks installed package version."""
        result = update_project(initialized_repo)
        # Should have either a version match (preserved) or mismatch (warning)
        version_related = [p for p in result["preserved"] if "trw-mcp package" in p] + [
            w for w in result["warnings"] if "trw-mcp" in w and "differs" in w
        ]
        assert len(version_related) > 0

    def test_warnings_key_always_present(self, initialized_repo: Path) -> None:
        """update_project result always includes 'warnings' key."""
        result = update_project(initialized_repo)
        assert "warnings" in result
        assert isinstance(result["warnings"], list)


# ── .mcp.json Smart Merge Tests ───────────────────────────────────────────


@pytest.mark.unit
class TestMcpJsonMerge:
    """Test that .mcp.json merge preserves user servers and ensures trw entry."""

    def test_merge_preserves_user_servers(self, initialized_repo: Path) -> None:
        """Existing user-configured MCP servers survive update."""
        mcp_path = initialized_repo / ".mcp.json"
        mcp_path.write_text(
            json.dumps(
                {
                    "mcpServers": {
                        "trw": {"command": "trw-mcp", "args": ["--debug"]},
                        "my-tool": {"command": "my-tool-server", "args": []},
                    }
                },
                indent=2,
            ),
            encoding="utf-8",
        )

        update_project(initialized_repo)

        data = json.loads(mcp_path.read_text(encoding="utf-8"))
        assert "my-tool" in data["mcpServers"]
        assert data["mcpServers"]["my-tool"]["command"] == "my-tool-server"

    def test_merge_restores_trw_key(self, initialized_repo: Path) -> None:
        """Missing 'trw' key is added back during update."""
        mcp_path = initialized_repo / ".mcp.json"
        mcp_path.write_text(
            json.dumps(
                {
                    "mcpServers": {
                        "my-tool": {"command": "my-tool-server", "args": []},
                    }
                },
                indent=2,
            ),
            encoding="utf-8",
        )

        result = update_project(initialized_repo)

        data = json.loads(mcp_path.read_text(encoding="utf-8"))
        assert "trw" in data["mcpServers"]
        assert "command" in data["mcpServers"]["trw"]
        # Should be reported as an update
        assert any("trw entry" in u for u in result["updated"])

    def test_merge_updates_trw_command(self, initialized_repo: Path) -> None:
        """Stale trw command path is refreshed."""
        mcp_path = initialized_repo / ".mcp.json"
        mcp_path.write_text(
            json.dumps(
                {
                    "mcpServers": {
                        "trw": {"command": "/old/path/trw-mcp", "args": []},
                    }
                },
                indent=2,
            ),
            encoding="utf-8",
        )

        update_project(initialized_repo)

        data = json.loads(mcp_path.read_text(encoding="utf-8"))
        assert data["mcpServers"]["trw"]["command"] != "/old/path/trw-mcp"

    def test_merge_creates_if_missing(self, fake_git_repo: Path) -> None:
        """Fresh .mcp.json generated when init runs on a new project."""
        result = init_project(fake_git_repo)
        assert not result["errors"]

        mcp_path = fake_git_repo / ".mcp.json"
        assert mcp_path.exists()
        data = json.loads(mcp_path.read_text(encoding="utf-8"))
        assert "trw" in data["mcpServers"]


# ── Installer Metadata Tests ──────────────────────────────────────────────


@pytest.mark.unit
class TestInstallerMetadata:
    """Test .trw/installer-meta.yaml creation and updates."""

    def test_metadata_written_on_init(self, fake_git_repo: Path) -> None:
        """installer-meta.yaml created during init_project."""
        init_project(fake_git_repo)
        meta_path = fake_git_repo / ".trw" / "installer-meta.yaml"
        assert meta_path.exists()

        from trw_mcp.state.persistence import FileStateReader

        reader = FileStateReader()
        data = reader.read_yaml(meta_path)
        assert data["installed_by"] == "trw-mcp init-project"
        assert "framework_version" in data
        assert "package_version" in data
        assert data["hooks_count"] > 0

    def test_metadata_updated_on_update(self, initialized_repo: Path) -> None:
        """installer-meta.yaml refreshed during update_project."""
        update_project(initialized_repo)
        meta_path = initialized_repo / ".trw" / "installer-meta.yaml"
        assert meta_path.exists()

        from trw_mcp.state.persistence import FileStateReader

        reader = FileStateReader()
        data = reader.read_yaml(meta_path)
        assert data["installed_by"] == "trw-mcp update-project"
        assert data["skills_count"] > 0
        assert data["agents_count"] > 0


# ── Dry-Run Tests ─────────────────────────────────────────────────────────


@pytest.mark.unit
class TestDryRun:
    """Test --dry-run mode doesn't modify files."""

    def test_dry_run_no_file_changes(self, initialized_repo: Path) -> None:
        """--dry-run doesn't modify any files."""
        framework_md = initialized_repo / ".trw" / "frameworks" / "FRAMEWORK.md"

        # Modify a file to create a diff
        framework_md.write_text("old content", encoding="utf-8")
        old_content = framework_md.read_text(encoding="utf-8")

        result = update_project(initialized_repo, dry_run=True)
        assert not result["errors"]

        # File should not be changed
        assert framework_md.read_text(encoding="utf-8") == old_content

    def test_dry_run_reports_would_update(self, initialized_repo: Path) -> None:
        """Dry run result lists expected changes."""
        # Modify a framework file to create a diff
        framework_md = initialized_repo / ".trw" / "frameworks" / "FRAMEWORK.md"
        framework_md.write_text("old content", encoding="utf-8")

        result = update_project(initialized_repo, dry_run=True)

        # Should report would-update items
        assert any("would" in u for u in result["updated"])
        # Should include dry-run warning
        assert any("DRY RUN" in w for w in result["warnings"])

    def test_dry_run_reports_missing_trw_entry(self, initialized_repo: Path) -> None:
        """Dry run detects missing trw key in .mcp.json."""
        mcp_path = initialized_repo / ".mcp.json"
        mcp_path.write_text(
            json.dumps(
                {
                    "mcpServers": {"other": {"command": "x", "args": []}},
                },
                indent=2,
            ),
            encoding="utf-8",
        )

        result = update_project(initialized_repo, dry_run=True)

        assert any("trw entry" in u for u in result["updated"])

    def test_dry_run_no_installer_metadata(self, initialized_repo: Path) -> None:
        """Dry run doesn't write installer metadata."""
        meta_path = initialized_repo / ".trw" / "installer-meta.yaml"
        meta_existed = meta_path.exists()

        update_project(initialized_repo, dry_run=True)

        if not meta_existed:
            assert not meta_path.exists()


# ── Default Config Tests ──────────────────────────────────────────────────


@pytest.mark.unit
class TestDefaultConfig:
    """Test _default_config() matches TRWConfig defaults."""

    def test_default_config_matches_trwconfig(self) -> None:
        """_default_config() claude_md_max_lines matches TRWConfig default."""
        from trw_mcp.bootstrap import _default_config
        from trw_mcp.models.config import TRWConfig

        config_text = _default_config()
        default_model = TRWConfig()
        assert f"claude_md_max_lines: {default_model.claude_md_max_lines}" in config_text

    def test_default_config_includes_runs_root(self) -> None:
        """_default_config() includes runs_root with the default value."""
        from trw_mcp.bootstrap import _default_config

        config_text = _default_config()
        assert "runs_root: .trw/runs" in config_text

    def test_default_config_custom_runs_root(self) -> None:
        """_default_config(runs_root=...) emits the custom value."""
        from trw_mcp.bootstrap import _default_config

        config_text = _default_config(runs_root="docs/runs")
        assert "runs_root: docs/runs" in config_text
        assert ".trw/runs" not in config_text


# ── Verification Tests ───────────────────────────────────────────────────


@pytest.mark.unit
class TestVerifyInstallation:
    """Test post-update health verification."""

    def test_verify_passes_healthy(self, initialized_repo: Path) -> None:
        """Verification passes on a clean install — no health warnings."""
        result = update_project(initialized_repo)
        # Filter to only health-check warnings (not restart/version warnings)
        health_warnings = [
            w for w in result["warnings"] if "executable" in w or "missing" in w.lower() or "not valid" in w
        ]
        assert len(health_warnings) == 0, f"Unexpected health warnings: {health_warnings}"


# ── Manifest Tests ──────────────────────────────────────────────────────


@pytest.mark.unit
class TestManagedArtifactsManifest:
    """Test .trw/managed-artifacts.yaml creation and stale-cleanup behavior."""

    def test_manifest_written_on_init(self, fake_git_repo: Path) -> None:
        """init_project writes the managed-artifacts manifest."""
        init_project(fake_git_repo)
        manifest_path = fake_git_repo / ".trw" / "managed-artifacts.yaml"
        assert manifest_path.exists()

        from trw_mcp.state.persistence import FileStateReader

        data = FileStateReader().read_yaml(manifest_path)
        assert isinstance(data, dict)
        assert data["version"] == 2
        skills = data.get("skills", [])
        assert isinstance(skills, list)
        assert "trw-deliver" in skills
        assert "trw-learn" in skills

    def test_manifest_written_on_update(self, initialized_repo: Path) -> None:
        """update_project refreshes the managed-artifacts manifest."""
        update_project(initialized_repo)
        manifest_path = initialized_repo / ".trw" / "managed-artifacts.yaml"
        assert manifest_path.exists()

        from trw_mcp.state.persistence import FileStateReader

        data = FileStateReader().read_yaml(manifest_path)
        assert isinstance(data, dict)
        agents = data.get("agents", [])
        assert isinstance(agents, list)
        assert "trw-auditor.md" in agents
        assert "trw-implementer.md" in agents

    def test_manifest_lists_all_bundled_artifacts(self, fake_git_repo: Path) -> None:
        """Manifest includes all bundled skills, agents, and hooks."""
        init_project(fake_git_repo)
        manifest_path = fake_git_repo / ".trw" / "managed-artifacts.yaml"

        from trw_mcp.state.persistence import FileStateReader

        data = FileStateReader().read_yaml(manifest_path)
        assert isinstance(data, dict)
        skills = data.get("skills", [])
        agents = data.get("agents", [])
        hooks = data.get("hooks", [])
        assert isinstance(skills, list)
        assert isinstance(agents, list)
        assert isinstance(hooks, list)

        # These asserts are for TRW bundled SKILLS & AGENTS, if these numbers are being changed, 
        # ensure the change is for a skill/agent that should be released and distributed with the TRW Framework
        # or if the skill/agent/change is for an internal monorepo skill
        assert len(skills) == 23
        assert len(agents) == 5
        assert len(hooks) > 0


# ── _write_version_yaml Tests ─────────────────────────────────────────────


@pytest.mark.unit
class TestWriteVersionYaml:
    """Unit tests for _write_version_yaml — VERSION.yaml generation from metadata."""

    def _make_init_result(self) -> dict[str, list[str]]:
        """Return a result dict matching init_project's shape (no 'updated' key)."""
        return {"created": [], "skipped": [], "errors": []}

    def _make_update_result(self) -> dict[str, list[str]]:
        """Return a result dict matching update_project's shape (has 'updated' key)."""
        return {"created": [], "updated": [], "skipped": [], "errors": [], "preserved": []}

    def test_writes_all_expected_keys(self, fake_git_repo: Path) -> None:
        """Generated VERSION.yaml contains all four expected metadata keys."""
        (fake_git_repo / ".trw" / "frameworks").mkdir(parents=True)
        result = self._make_init_result()
        _write_version_yaml(fake_git_repo, result)

        version_path = fake_git_repo / ".trw" / "frameworks" / "VERSION.yaml"
        assert version_path.is_file()

        data = FileStateReader().read_yaml(version_path)
        assert isinstance(data, dict)
        assert "framework_version" in data
        assert "aaref_version" in data
        assert "trw_mcp_version" in data
        assert "deployed_at" in data

    def test_framework_version_matches_config(self, fake_git_repo: Path) -> None:
        """framework_version in VERSION.yaml matches TRWConfig default."""
        (fake_git_repo / ".trw" / "frameworks").mkdir(parents=True)
        result = self._make_init_result()
        _write_version_yaml(fake_git_repo, result)

        version_path = fake_git_repo / ".trw" / "frameworks" / "VERSION.yaml"
        data = FileStateReader().read_yaml(version_path)
        assert isinstance(data, dict)
        assert data["framework_version"] == TRWConfig().framework_version

    def test_trw_mcp_version_matches_metadata(self, fake_git_repo: Path) -> None:
        """trw_mcp_version in VERSION.yaml matches installed package metadata."""
        (fake_git_repo / ".trw" / "frameworks").mkdir(parents=True)
        result = self._make_init_result()
        _write_version_yaml(fake_git_repo, result)

        version_path = fake_git_repo / ".trw" / "frameworks" / "VERSION.yaml"
        data = FileStateReader().read_yaml(version_path)
        assert isinstance(data, dict)
        assert data["trw_mcp_version"] == importlib.metadata.version("trw-mcp")

    def test_deployed_at_is_valid_iso(self, fake_git_repo: Path) -> None:
        """deployed_at field parses as a valid ISO-8601 datetime without error."""
        (fake_git_repo / ".trw" / "frameworks").mkdir(parents=True)
        result = self._make_init_result()
        _write_version_yaml(fake_git_repo, result)

        version_path = fake_git_repo / ".trw" / "frameworks" / "VERSION.yaml"
        data = FileStateReader().read_yaml(version_path)
        assert isinstance(data, dict)
        deployed_at = data["deployed_at"]
        # fromisoformat raises ValueError on invalid input — that's the assertion
        parsed = datetime.fromisoformat(str(deployed_at))
        assert parsed is not None

    def test_appends_to_created_for_init_result(self, fake_git_repo: Path) -> None:
        """On an init-style result (no 'updated' key), path is appended to result['created']."""
        (fake_git_repo / ".trw" / "frameworks").mkdir(parents=True)
        result = self._make_init_result()
        _write_version_yaml(fake_git_repo, result)

        version_path = str(fake_git_repo / ".trw" / "frameworks" / "VERSION.yaml")
        assert version_path in result["created"]
        assert result["errors"] == []

    def test_appends_to_updated_for_update_result(self, fake_git_repo: Path) -> None:
        """On an update-style result (has 'updated' key), path is appended to result['updated']."""
        (fake_git_repo / ".trw" / "frameworks").mkdir(parents=True)
        result = self._make_update_result()
        _write_version_yaml(fake_git_repo, result)

        version_path = str(fake_git_repo / ".trw" / "frameworks" / "VERSION.yaml")
        assert version_path in result["updated"]
        # Should NOT also appear in created
        assert version_path not in result["created"]
        assert result["errors"] == []

    def test_oserror_captured_in_errors(self, fake_git_repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        """OSError from FileStateWriter.write_yaml is captured in result['errors']."""
        (fake_git_repo / ".trw" / "frameworks").mkdir(parents=True)
        result = self._make_init_result()

        from trw_mcp.state import persistence as persistence_mod

        def _raise_os_error(self: object, path: Path, data: object) -> None:
            raise OSError("disk full")

        monkeypatch.setattr(persistence_mod.FileStateWriter, "write_yaml", _raise_os_error)
        _write_version_yaml(fake_git_repo, result)

        assert len(result["errors"]) == 1
        assert "disk full" in result["errors"][0]
        assert result["created"] == []


# ── _result_action_key Tests ─────────────────────────────────────────────


@pytest.mark.unit
class TestResultActionKey:
    """Unit tests for _result_action_key — action-key selection helper."""

    def test_returns_created_when_no_updated_key(self) -> None:
        """Returns 'created' when result dict has no 'updated' key (init flow)."""
        result: dict[str, list[str]] = {"created": [], "errors": []}
        assert _result_action_key(result) == "created"

    def test_returns_updated_when_updated_key_exists(self) -> None:
        """Returns 'updated' when result dict contains an 'updated' key (update flow)."""
        result: dict[str, list[str]] = {"created": [], "updated": [], "errors": []}
        assert _result_action_key(result) == "updated"


# ── _run_claude_md_sync Tests ────────────────────────────────────────────


class TestRunClaudeMdSync:
    """Tests for _run_claude_md_sync — fail-open + stdout suppression."""

    @staticmethod
    def _failing_llm_client() -> None:
        """Simulate LLMClient raising TypeError (anthropic SDK with no API key)."""
        raise TypeError("Could not resolve authentication")

    def test_auth_error_captured_as_warning(
        self,
        fake_git_repo: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Auth failures from LLMClient are captured as warnings, not errors."""
        from trw_mcp.bootstrap._update_project import _run_claude_md_sync

        # Provide a fake API key so the early-return guard is bypassed and
        # _run_claude_md_sync proceeds to call LLMClient().
        monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key-auth-error")

        # Patch at the source module since _run_claude_md_sync imports locally
        monkeypatch.setattr(
            "trw_mcp.state.llm_helpers.LLMClient",
            self._failing_llm_client,
        )

        result: dict[str, list[str]] = {
            "updated": [],
            "created": [],
            "preserved": [],
            "errors": [],
            "warnings": [],
        }
        init_project(fake_git_repo)

        _run_claude_md_sync(fake_git_repo, result)

        # The TypeError from LLMClient is caught by the except-Exception handler
        # and recorded as a warning (format: "CLAUDE.md sync skipped: <exc>").
        assert any("CLAUDE.md sync skipped" in w for w in result["warnings"])
        assert result["errors"] == []

    def test_auth_error_does_not_leak_to_stdout(
        self,
        fake_git_repo: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Auth errors must NOT leak to stdout (would corrupt installer progress pipe)."""
        from trw_mcp.bootstrap._update_project import _run_claude_md_sync

        monkeypatch.setattr(
            "trw_mcp.state.llm_helpers.LLMClient",
            self._failing_llm_client,
        )

        result: dict[str, list[str]] = {
            "updated": [],
            "created": [],
            "preserved": [],
            "errors": [],
            "warnings": [],
        }
        init_project(fake_git_repo)

        _run_claude_md_sync(fake_git_repo, result)

        captured = capsys.readouterr()
        # Filter out structlog lines (structured observability is expected);
        # the test intent is that raw tracebacks / SDK errors don't leak.
        non_structlog_lines = [
            line
            for line in captured.out.splitlines()
            if not (
                "[warning " in line
                or "[info " in line
                or "[debug " in line
                or "[error " in line
            )
        ]
        plain_output = "\n".join(non_structlog_lines)
        assert "authentication" not in plain_output.lower()
        assert "TypeError" not in plain_output

    def test_timeout_captured_as_warning(
        self,
        fake_git_repo: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Sync operations that exceed the timeout are captured as warnings."""
        import time

        from trw_mcp.bootstrap._update_project import _run_claude_md_sync

        # Provide a fake API key so the early-return guard is bypassed and
        # _run_claude_md_sync proceeds to call LLMClient().
        monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key-timeout")

        def _slow_llm_client() -> None:
            time.sleep(10)  # Will exceed the 1-second timeout below

        monkeypatch.setattr(
            "trw_mcp.state.llm_helpers.LLMClient",
            _slow_llm_client,
        )

        result: dict[str, list[str]] = {
            "updated": [],
            "created": [],
            "preserved": [],
            "errors": [],
            "warnings": [],
        }
        init_project(fake_git_repo)

        _run_claude_md_sync(fake_git_repo, result, timeout=1)

        assert any("timed out" in w for w in result["warnings"])
        assert result["errors"] == []


class TestClaudeMdSyncTimeoutFix:
    """Tests for _run_claude_md_sync ThreadPoolExecutor timeout handling.

    The fix changed ``with ThreadPoolExecutor() as pool:`` to an explicit
    ``pool = ThreadPoolExecutor()`` + ``pool.shutdown(wait=False,
    cancel_futures=True)`` in a finally block, preventing the context-manager
    ``__exit__`` from blocking when a worker thread (e.g. LLMClient) hangs.
    """

    def test_sync_timeout_returns_promptly(
        self,
        fake_git_repo: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """When sync times out, the function returns within timeout + buffer — not indefinitely."""
        import time as time_mod

        from trw_mcp.bootstrap._update_project import _run_claude_md_sync

        # Provide a fake API key so the early-return guard is bypassed and
        # _run_claude_md_sync proceeds to call LLMClient().
        monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key-timeout-fix")

        def _hanging_llm(*_args: object, **_kwargs: object) -> None:
            time_mod.sleep(300)  # Simulate LLMClient network hang

        monkeypatch.setattr(
            "trw_mcp.state.llm_helpers.LLMClient",
            _hanging_llm,
        )

        init_project(fake_git_repo)
        result: dict[str, list[str]] = {
            "updated": [],
            "created": [],
            "preserved": [],
            "errors": [],
            "warnings": [],
        }

        start = time_mod.monotonic()
        _run_claude_md_sync(fake_git_repo, result, timeout=2)
        elapsed = time_mod.monotonic() - start

        # Must complete well under 10s — the old code would block for 300s
        assert elapsed < 10, (
            f"_run_claude_md_sync blocked for {elapsed:.1f}s; "
            f"expected <10s (timeout was 2s)"
        )
        assert any("timed out" in w for w in result["warnings"])

    def test_sync_success_adds_updated_entry(
        self,
        fake_git_repo: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Successful sync adds a descriptive entry to result['updated']."""
        from unittest.mock import MagicMock

        from trw_mcp.bootstrap._update_project import _run_claude_md_sync

        # Provide a fake API key so the early-return guard is bypassed and
        # _run_claude_md_sync proceeds to call LLMClient().
        monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key-success")

        monkeypatch.setattr(
            "trw_mcp.state.claude_md.execute_claude_md_sync",
            lambda **kwargs: {"learnings_promoted": 3},
        )
        monkeypatch.setattr(
            "trw_mcp.state.llm_helpers.LLMClient",
            lambda: MagicMock(),
        )

        init_project(fake_git_repo)
        result: dict[str, list[str]] = {
            "updated": [],
            "created": [],
            "preserved": [],
            "errors": [],
            "warnings": [],
        }

        _run_claude_md_sync(fake_git_repo, result, timeout=10)

        assert any("synced" in u for u in result["updated"])
        # Verify the learnings count is included in the message
        assert any("3" in u for u in result["updated"])

    def test_sync_generic_exception_adds_warning(
        self,
        fake_git_repo: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """A generic exception during sync adds a warning, doesn't crash."""
        from trw_mcp.bootstrap._update_project import _run_claude_md_sync

        # init_project must run BEFORE we break get_config
        init_project(fake_git_repo)

        def _broken_sync(**_kwargs: object) -> dict[str, object]:
            raise RuntimeError("sync broken")

        monkeypatch.setattr(
            "trw_mcp.state.claude_md.execute_claude_md_sync",
            _broken_sync,
        )

        result: dict[str, list[str]] = {
            "updated": [],
            "created": [],
            "preserved": [],
            "errors": [],
            "warnings": [],
        }

        _run_claude_md_sync(fake_git_repo, result, timeout=5)

        assert any("skipped" in w for w in result["warnings"])
        assert result["errors"] == []


# ── Root FRAMEWORK.md Tests (PRD-FIX-025) ──────────────────────────────


@pytest.mark.unit
class TestRootFrameworkMd:
    """Test that init/update deploy FRAMEWORK.md to the project root."""

    def test_init_creates_root_framework_md(self, fake_git_repo: Path) -> None:
        """init_project creates FRAMEWORK.md at the project root."""
        result = init_project(fake_git_repo)
        assert not result["errors"]

        root_fw = fake_git_repo / "FRAMEWORK.md"
        assert root_fw.is_file()
        content = root_fw.read_text(encoding="utf-8")
        assert TRWConfig().framework_version in content

    def test_init_root_matches_cached(self, fake_git_repo: Path) -> None:
        """Root FRAMEWORK.md matches .trw/frameworks/FRAMEWORK.md after init."""
        init_project(fake_git_repo)

        root_fw = fake_git_repo / "FRAMEWORK.md"
        cached_fw = fake_git_repo / ".trw" / "frameworks" / "FRAMEWORK.md"
        assert root_fw.read_text(encoding="utf-8") == cached_fw.read_text(encoding="utf-8")

    def test_update_overwrites_stale_root_framework_md(self, initialized_repo: Path) -> None:
        """update_project overwrites a stale root FRAMEWORK.md."""
        root_fw = initialized_repo / "FRAMEWORK.md"
        root_fw.write_text("old stale content v16.0", encoding="utf-8")

        result = update_project(initialized_repo)
        assert not result["errors"]

        content = root_fw.read_text(encoding="utf-8")
        assert content != "old stale content v16.0"
        assert TRWConfig().framework_version in content

    def test_update_root_matches_cached(self, initialized_repo: Path) -> None:
        """After update, root FRAMEWORK.md matches cached version."""
        update_project(initialized_repo)

        root_fw = initialized_repo / "FRAMEWORK.md"
        cached_fw = initialized_repo / ".trw" / "frameworks" / "FRAMEWORK.md"
        assert root_fw.read_text(encoding="utf-8") == cached_fw.read_text(encoding="utf-8")


# ── Prefix-Scoped Cleanup Tests (PRD-INFRA-013) ────────────────────────


@pytest.mark.unit
class TestUpdatePrefixScopedCleanup:
    """Test that _remove_stale_artifacts only removes trw- prefixed items."""

    def test_custom_skill_without_trw_prefix_survives(self, initialized_repo: Path) -> None:
        """Custom skill without trw- prefix survives update_project()."""
        # Add a non-trw-prefixed skill to the manifest (simulate pre-migration)
        manifest_path = initialized_repo / ".trw" / "managed-artifacts.yaml"
        from trw_mcp.state.persistence import FileStateReader, FileStateWriter

        reader = FileStateReader()
        manifest = reader.read_yaml(manifest_path)
        assert isinstance(manifest, dict)
        skills_list = list(manifest.get("skills", []))
        skills_list.append("my-custom-skill")
        manifest["skills"] = skills_list
        FileStateWriter().write_yaml(manifest_path, manifest)

        custom_skill = initialized_repo / ".claude" / "skills" / "my-custom-skill"
        custom_skill.mkdir(parents=True, exist_ok=True)
        (custom_skill / "SKILL.md").write_text("custom content", encoding="utf-8")

        update_project(initialized_repo)

        # Non-trw-prefixed skill should survive even if not in current bundle
        assert custom_skill.exists()
        assert (custom_skill / "SKILL.md").read_text(encoding="utf-8") == "custom content"

    def test_custom_agent_without_trw_prefix_survives(self, initialized_repo: Path) -> None:
        """Custom agent without trw- prefix survives update_project()."""
        manifest_path = initialized_repo / ".trw" / "managed-artifacts.yaml"
        from trw_mcp.state.persistence import FileStateReader, FileStateWriter

        reader = FileStateReader()
        manifest = reader.read_yaml(manifest_path)
        assert isinstance(manifest, dict)
        agents_list = list(manifest.get("agents", []))
        agents_list.append("my-custom-agent.md")
        manifest["agents"] = agents_list
        FileStateWriter().write_yaml(manifest_path, manifest)

        custom_agent = initialized_repo / ".claude" / "agents" / "my-custom-agent.md"
        custom_agent.write_text("custom agent content", encoding="utf-8")

        update_project(initialized_repo)

        # Non-trw-prefixed agent should survive even if not in current bundle
        assert custom_agent.exists()
        assert custom_agent.read_text(encoding="utf-8") == "custom agent content"

    def test_stale_trw_skill_is_removed(self, initialized_repo: Path) -> None:
        """Stale trw-prefixed skill IS removed by update_project()."""
        manifest_path = initialized_repo / ".trw" / "managed-artifacts.yaml"
        from trw_mcp.state.persistence import FileStateReader, FileStateWriter

        reader = FileStateReader()
        manifest = reader.read_yaml(manifest_path)
        assert isinstance(manifest, dict)
        skills_list = list(manifest.get("skills", []))
        skills_list.append("trw-deprecated-skill")
        manifest["skills"] = skills_list
        FileStateWriter().write_yaml(manifest_path, manifest)

        stale_skill = initialized_repo / ".claude" / "skills" / "trw-deprecated-skill"
        stale_skill.mkdir(parents=True, exist_ok=True)
        (stale_skill / "SKILL.md").write_text("deprecated", encoding="utf-8")

        update_project(initialized_repo)

        assert not stale_skill.exists()

    def test_stale_trw_agent_is_removed(self, initialized_repo: Path) -> None:
        """Stale trw-prefixed agent IS removed by update_project()."""
        manifest_path = initialized_repo / ".trw" / "managed-artifacts.yaml"
        from trw_mcp.state.persistence import FileStateReader, FileStateWriter

        reader = FileStateReader()
        manifest = reader.read_yaml(manifest_path)
        assert isinstance(manifest, dict)
        agents_list = list(manifest.get("agents", []))
        agents_list.append("trw-deprecated-agent.md")
        manifest["agents"] = agents_list
        FileStateWriter().write_yaml(manifest_path, manifest)

        stale_agent = initialized_repo / ".claude" / "agents" / "trw-deprecated-agent.md"
        stale_agent.write_text("deprecated agent", encoding="utf-8")

        update_project(initialized_repo)

        assert not stale_agent.exists()


# ── Context Cleanup Tests (PRD-FIX-031) ──────────────────────────────────


@pytest.mark.unit
class TestContextCleanup:
    """Test _cleanup_context_transients via update_project — PRD-FIX-031."""

    def test_removes_transient_files(self, initialized_repo: Path) -> None:
        """Transient files are removed; allowlisted files are preserved."""
        context = initialized_repo / ".trw" / "context"
        # Allowlisted (should survive)
        (context / "analytics.yaml").write_text("data: 1", encoding="utf-8")
        (context / "build-status.yaml").write_text("ok", encoding="utf-8")
        (context / "pre_compact_state.json").write_text("{}", encoding="utf-8")
        (context / "hooks-reference.yaml").write_text("ref", encoding="utf-8")
        # Transient (should be removed)
        (context / "tc_block_abc").write_text("", encoding="utf-8")
        (context / "sprint-34-findings.yaml").write_text("", encoding="utf-8")
        (context / "velocity.yaml").write_text("", encoding="utf-8")
        (context / "tool-telemetry.jsonl").write_text("", encoding="utf-8")

        result = update_project(initialized_repo)

        # Allowlisted files still present
        assert (context / "analytics.yaml").exists()
        assert (context / "behavioral_protocol.yaml").exists()
        assert (context / "messages.yaml").exists()
        assert (context / "build-status.yaml").exists()
        assert (context / "pre_compact_state.json").exists()
        assert (context / "hooks-reference.yaml").exists()
        # Transient files removed
        assert not (context / "tc_block_abc").exists()
        assert not (context / "sprint-34-findings.yaml").exists()
        assert not (context / "velocity.yaml").exists()
        assert not (context / "tool-telemetry.jsonl").exists()

    def test_result_cleaned_key_populated(self, initialized_repo: Path) -> None:
        """result['cleaned'] contains paths of removed files."""
        context = initialized_repo / ".trw" / "context"
        (context / "velocity.yaml").write_text("stale", encoding="utf-8")
        (context / "tc_block_x").write_text("", encoding="utf-8")

        result = update_project(initialized_repo)

        assert len(result["cleaned"]) >= 2
        cleaned_names = [Path(p).name for p in result["cleaned"]]
        assert "velocity.yaml" in cleaned_names
        assert "tc_block_x" in cleaned_names

    def test_dry_run_reports_without_deleting(self, initialized_repo: Path) -> None:
        """dry_run=True reports would-be removals without deleting files."""
        context = initialized_repo / ".trw" / "context"
        (context / "velocity.yaml").write_text("stale", encoding="utf-8")
        (context / "idle_block_lead").write_text("", encoding="utf-8")

        result = update_project(initialized_repo, dry_run=True)

        # Files still exist
        assert (context / "velocity.yaml").exists()
        assert (context / "idle_block_lead").exists()
        # Cleaned entries have "would remove:" prefix
        assert len(result["cleaned"]) == 2
        for entry in result["cleaned"]:
            assert entry.startswith("would remove: ")

    def test_noop_when_only_allowlisted(self, initialized_repo: Path) -> None:
        """No files removed when only allowlisted files are present."""
        result = update_project(initialized_repo)

        # Only allowlisted files should be in context dir (behavioral_protocol, messages)
        assert result["cleaned"] == []

    def test_result_cleaned_key_always_present(self, initialized_repo: Path) -> None:
        """result dict always has 'cleaned' key, even when nothing is removed."""
        result = update_project(initialized_repo)

        assert "cleaned" in result
        assert isinstance(result["cleaned"], list)


# ── PRD-FIX-032: Prefix Migration Predecessor Cleanup ─────────────────


@pytest.mark.unit
class TestPrefixMigration:
    """Tests for _migrate_prefix_predecessors via update_project."""

    def test_migrate_removes_predecessor_when_successor_present(self, initialized_repo: Path) -> None:
        """Old non-prefixed skill dir is removed when trw- successor is installed."""
        skills_dir = initialized_repo / ".claude" / "skills"
        # Create predecessor and successor skill dirs
        (skills_dir / "commit").mkdir(parents=True, exist_ok=True)
        (skills_dir / "commit" / "SKILL.md").write_text("old", encoding="utf-8")
        (skills_dir / "trw-commit").mkdir(parents=True, exist_ok=True)
        (skills_dir / "trw-commit" / "SKILL.md").write_text("new", encoding="utf-8")

        result = update_project(initialized_repo)

        assert not (skills_dir / "commit").exists()
        migrated_entries = [e for e in result["updated"] if "migrated:" in e and "commit" in e]
        assert len(migrated_entries) >= 1

    def test_migrate_skips_predecessor_when_successor_absent(self, initialized_repo: Path) -> None:
        """Old skill dir remains when trw- successor is NOT installed."""
        skills_dir = initialized_repo / ".claude" / "skills"
        # Create only predecessor — no successor
        (skills_dir / "commit").mkdir(parents=True, exist_ok=True)
        (skills_dir / "commit" / "SKILL.md").write_text("old", encoding="utf-8")

        result = update_project(initialized_repo)

        # Note: update_project installs trw-commit from bundled data, so the
        # successor will now exist.  We test with a name NOT in bundled data
        # to isolate this behavior.  But "commit" IS in PREDECESSOR_MAP and
        # trw-commit IS bundled, so the predecessor gets removed.  Instead,
        # let's verify the function logic directly: if we remove trw-commit
        # after install, predecessor stays.
        # This test verifies update_project doesn't crash and produces results.
        assert "errors" in result

    def test_migrate_skips_when_no_successor(self, fake_git_repo: Path) -> None:
        """Predecessor survives when its successor directory is absent."""
        (fake_git_repo / ".trw").mkdir(parents=True)
        skills_dir = fake_git_repo / ".claude" / "skills"
        skills_dir.mkdir(parents=True, exist_ok=True)
        # Create only predecessor, no trw- successor
        (skills_dir / "commit").mkdir(parents=True, exist_ok=True)
        (skills_dir / "commit" / "SKILL.md").write_text("old", encoding="utf-8")

        from trw_mcp.bootstrap import _migrate_prefix_predecessors

        result: dict[str, list[str]] = {"updated": [], "errors": []}
        _migrate_prefix_predecessors(fake_git_repo, result)

        assert (skills_dir / "commit").exists()
        assert not any("commit" in e for e in result["updated"])

    def test_migrate_removes_agent_predecessor(self, initialized_repo: Path) -> None:
        """Old non-prefixed agent .md file is removed when trw- successor exists."""
        agents_dir = initialized_repo / ".claude" / "agents"
        # Create predecessor and successor agent files
        (agents_dir / "implementer.md").write_text("old", encoding="utf-8")
        # trw-implementer.md is already deployed by init_project

        result = update_project(initialized_repo)

        assert not (agents_dir / "implementer.md").exists()
        migrated_entries = [e for e in result["updated"] if "migrated:" in e and "implementer.md" in e]
        assert len(migrated_entries) >= 1

    def test_migrate_idempotent(self, initialized_repo: Path) -> None:
        """Second update_project run is a no-op on already-cleaned dirs."""
        skills_dir = initialized_repo / ".claude" / "skills"
        (skills_dir / "commit").mkdir(parents=True, exist_ok=True)
        (skills_dir / "commit" / "SKILL.md").write_text("old", encoding="utf-8")
        (skills_dir / "trw-commit").mkdir(parents=True, exist_ok=True)
        (skills_dir / "trw-commit" / "SKILL.md").write_text("new", encoding="utf-8")

        # First run removes predecessor
        result1 = update_project(initialized_repo)
        assert not (skills_dir / "commit").exists()

        # Second run is a no-op — no migrated entries for commit
        result2 = update_project(initialized_repo)
        migrated_commit = [e for e in result2["updated"] if "migrated:" in e and "commit" in e]
        assert migrated_commit == []

    def test_genuine_custom_skill_not_removed(self, initialized_repo: Path) -> None:
        """A custom skill not in PREDECESSOR_MAP survives update_project."""
        skills_dir = initialized_repo / ".claude" / "skills"
        custom_skill = skills_dir / "my-custom-tool"
        custom_skill.mkdir(parents=True, exist_ok=True)
        (custom_skill / "SKILL.md").write_text("custom", encoding="utf-8")

        result = update_project(initialized_repo)

        assert custom_skill.exists()
        assert (custom_skill / "SKILL.md").read_text(encoding="utf-8") == "custom"
        # Not in any migrated entries
        migrated = [e for e in result["updated"] if "my-custom-tool" in e]
        assert migrated == []


# --- FR08: IDE Detection (PRD-CORE-074) ---


@pytest.mark.unit
class TestIDEDetection:
    """Tests for detect_ide, detect_installed_clis, and resolve_ide_targets."""

    def test_fr08_detect_claude_code(self, tmp_path: Path) -> None:
        (tmp_path / ".claude").mkdir()
        result = detect_ide(tmp_path)
        assert result == ["claude-code"]

    def test_fr08_detect_cursor(self, tmp_path: Path) -> None:
        (tmp_path / ".cursor").mkdir()
        result = detect_ide(tmp_path)
        assert result == ["cursor"]

    def test_fr08_detect_opencode_dir(self, tmp_path: Path) -> None:
        (tmp_path / ".opencode").mkdir()
        result = detect_ide(tmp_path)
        assert result == ["opencode"]

    def test_fr08_detect_opencode_json(self, tmp_path: Path) -> None:
        (tmp_path / "opencode.json").write_text("{}", encoding="utf-8")
        result = detect_ide(tmp_path)
        assert result == ["opencode"]

    def test_fr08_detect_codex_dir(self, tmp_path: Path) -> None:
        (tmp_path / ".codex").mkdir()
        result = detect_ide(tmp_path)
        assert result == ["codex"]

    def test_fr08_detect_codex_config(self, tmp_path: Path) -> None:
        (tmp_path / ".codex").mkdir()
        (tmp_path / ".codex" / "config.toml").write_text("", encoding="utf-8")
        result = detect_ide(tmp_path)
        assert result == ["codex"]

    def test_fr08_detect_multiple(self, tmp_path: Path) -> None:
        (tmp_path / ".claude").mkdir()
        (tmp_path / ".opencode").mkdir()
        result = detect_ide(tmp_path)
        assert "claude-code" in result
        assert "opencode" in result

    def test_fr08_detect_none(self, tmp_path: Path) -> None:
        result = detect_ide(tmp_path)
        assert result == []

    def test_fr08_resolve_override(self, tmp_path: Path) -> None:
        result = resolve_ide_targets(tmp_path, ide_override="opencode")
        assert result == ["opencode"]

    def test_fr08_resolve_all(self, tmp_path: Path) -> None:
        result = resolve_ide_targets(tmp_path, ide_override="all")
        assert "claude-code" in result
        assert "opencode" in result
        assert "cursor" in result
        assert "codex" in result

    def test_fr08_resolve_default_claude(self, tmp_path: Path) -> None:
        # No IDE detected → default to claude-code
        result = resolve_ide_targets(tmp_path)
        assert result == ["claude-code"]

    def test_fr08_resolve_auto_detect(self, tmp_path: Path) -> None:
        (tmp_path / ".opencode").mkdir()
        result = resolve_ide_targets(tmp_path)
        assert result == ["opencode"]

    def test_fr08_resolve_auto_detect_codex(self, tmp_path: Path) -> None:
        (tmp_path / ".codex").mkdir()
        result = resolve_ide_targets(tmp_path)
        assert result == ["codex"]

    def test_fr08_detect_installed_clis_returns_list(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """detect_installed_clis returns only CLIs found on PATH."""
        import shutil as _shutil

        def fake_which(cmd: str) -> str | None:
            return {
                "claude": "/usr/bin/claude",
                "codex": "/usr/bin/codex",
            }.get(cmd)

        monkeypatch.setattr(_shutil, "which", fake_which)
        # Also patch the shutil reference inside the bootstrap module
        monkeypatch.setattr("trw_mcp.bootstrap._utils.shutil.which", fake_which)
        result = detect_installed_clis()
        assert result == ["claude-code", "codex"]

    def test_fr08_detect_installed_clis_empty(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """detect_installed_clis returns empty list when no CLIs found."""
        monkeypatch.setattr("trw_mcp.bootstrap._utils.shutil.which", lambda _cmd: None)
        result = detect_installed_clis()
        assert result == []


# ---------------------------------------------------------------------------
# FR11 + FR16 — OpenCode Bootstrap (PRD-CORE-074)
# ---------------------------------------------------------------------------

from trw_mcp.bootstrap._opencode import (
    _parse_jsonc,
    generate_agents_md,
    generate_opencode_config,
    merge_opencode_json,
)


class TestOpenCodeBootstrap:
    """FR11: OpenCode Bootstrap Configuration."""

    def test_fr11_opencode_json_created(self, tmp_path: Path) -> None:
        result = generate_opencode_config(tmp_path)
        assert "opencode.json" in result["created"]
        config = json.loads((tmp_path / "opencode.json").read_text())
        assert "trw" in config["mcp"]

    def test_fr11_opencode_json_permissions(self, tmp_path: Path) -> None:
        generate_opencode_config(tmp_path)
        config = json.loads((tmp_path / "opencode.json").read_text())
        assert config["permission"]["bash"] == "ask"
        assert config["permission"]["write"] == "ask"
        assert config["permission"]["edit"] == "ask"

    def test_fr11_opencode_json_mcp_local(self, tmp_path: Path) -> None:
        generate_opencode_config(tmp_path)
        config = json.loads((tmp_path / "opencode.json").read_text())
        assert config["mcp"]["trw"]["type"] == "local"
        assert config["mcp"]["trw"]["command"] == ["trw-mcp", "--debug"]
        assert "args" not in config["mcp"]["trw"]

    def test_fr11_agents_md_created(self, tmp_path: Path) -> None:
        result = generate_agents_md(tmp_path, "## TRW Section\nContent here")
        assert "AGENTS.md" in result["created"]
        content = (tmp_path / "AGENTS.md").read_text()
        assert "<!-- trw:start -->" in content
        assert "<!-- trw:end -->" in content

    def test_fr11_agents_md_same_markers(self, tmp_path: Path) -> None:
        generate_agents_md(tmp_path, "Test content")
        content = (tmp_path / "AGENTS.md").read_text()
        assert "<!-- trw:start -->" in content
        assert "<!-- trw:end -->" in content

    def test_fr11_agents_md_updates_existing(self, tmp_path: Path) -> None:
        # Write initial
        generate_agents_md(tmp_path, "Version 1")
        # Update
        generate_agents_md(tmp_path, "Version 2")
        content = (tmp_path / "AGENTS.md").read_text()
        assert "Version 2" in content
        assert "Version 1" not in content

    def test_fr11_agents_md_preserves_user_content(self, tmp_path: Path) -> None:
        # Write file with user content + markers
        (tmp_path / "AGENTS.md").write_text(
            "# My Project\n\nUser content here\n\n"
            "<!-- TRW AUTO-GENERATED — do not edit between markers -->\n"
            "<!-- trw:start -->\nOld TRW\n<!-- trw:end -->\n\n"
            "More user content\n"
        )
        generate_agents_md(tmp_path, "New TRW content")
        content = (tmp_path / "AGENTS.md").read_text()
        assert "User content here" in content
        assert "More user content" in content
        assert "New TRW content" in content
        assert "Old TRW" not in content


class TestOpenCodeJsonMerge:
    """FR16: opencode.json Smart Merge."""

    def test_fr16_merge_preserves_other_servers(self) -> None:
        existing: dict[str, object] = {"mcp": {"other-server": {"type": "remote", "url": "http://x"}}}
        trw: dict[str, object] = {"type": "local", "command": ["trw-mcp"]}
        result = merge_opencode_json(existing, trw)
        assert "other-server" in result["mcp"]
        assert "trw" in result["mcp"]

    def test_fr16_merge_preserves_user_permissions(self) -> None:
        existing: dict[str, object] = {"permission": {"bash": "never"}, "mcp": {}}
        trw: dict[str, object] = {"type": "local", "command": ["trw-mcp"]}
        result = merge_opencode_json(existing, trw)
        assert result["permission"]["bash"] == "never"

    def test_fr16_merge_preserves_model(self) -> None:
        existing: dict[str, object] = {
            "model": "ollama/qwen3-coder-next",
            "mcp": {},
        }
        trw: dict[str, object] = {"type": "local", "command": ["trw-mcp"]}
        result = merge_opencode_json(existing, trw)
        assert result["model"] == "ollama/qwen3-coder-next"

    def test_fr16_merge_adds_trw_entry(self) -> None:
        existing: dict[str, object] = {"mcp": {}}
        trw: dict[str, object] = {"type": "local", "command": ["trw-mcp"]}
        result = merge_opencode_json(existing, trw)
        assert result["mcp"]["trw"] == trw

    def test_fr16_merge_updates_existing_trw(self) -> None:
        existing: dict[str, object] = {"mcp": {"trw": {"type": "local", "command": ["old"]}}}
        trw: dict[str, object] = {
            "type": "local",
            "command": ["trw-mcp", "--debug"],
        }
        result = merge_opencode_json(existing, trw)
        assert result["mcp"]["trw"]["command"] == ["trw-mcp", "--debug"]

    def test_fr16_fresh_install_full_template(self, tmp_path: Path) -> None:
        result = generate_opencode_config(tmp_path)
        config = json.loads((tmp_path / "opencode.json").read_text())
        assert "permission" in config
        assert "mcp" in config
        assert "trw" in config["mcp"]

    def test_fr16_jsonc_line_comments(self) -> None:
        jsonc = '{\n  // This is a comment\n  "key": "value"\n}'
        result = _parse_jsonc(jsonc)
        assert result["key"] == "value"

    def test_fr16_jsonc_block_comments(self) -> None:
        jsonc = '{\n  /* block\n  comment */\n  "key": "value"\n}'
        result = _parse_jsonc(jsonc)
        assert result["key"] == "value"

    def test_fr16_smart_merge_existing_file(self, tmp_path: Path) -> None:
        # Write existing opencode.json with another server
        (tmp_path / "opencode.json").write_text(
            json.dumps(
                {
                    "model": "ollama/qwen3-coder-next",
                    "mcp": {"other": {"type": "remote", "url": "http://x"}},
                }
            )
        )
        result = generate_opencode_config(tmp_path)
        assert "opencode.json" in result["updated"]
        config = json.loads((tmp_path / "opencode.json").read_text())
        assert config["model"] == "ollama/qwen3-coder-next"
        assert "other" in config["mcp"]
        assert "trw" in config["mcp"]


class TestCodexBootstrap:
    """Codex bootstrap configuration and smart-merge behavior."""

    def test_codex_config_created(self, tmp_path: Path) -> None:
        result = generate_codex_config(tmp_path)
        assert ".codex/config.toml" in result["created"]
        config = tomllib.loads((tmp_path / ".codex" / "config.toml").read_text(encoding="utf-8"))
        assert config["features"]["codex_hooks"] is True
        assert config["mcp_servers"]["trw"]["enabled"] is True
        assert config["mcp_servers"]["openaiDeveloperDocs"]["enabled"] is True
        assert "trw_session_start" in config["mcp_servers"]["trw"]["enabled_tools"]
        assert "trw_build_check" in config["mcp_servers"]["trw"]["enabled_tools"]
        assert "trw_checkpoint" in config["mcp_servers"]["trw"]["enabled_tools"]
        assert all(not entry["path"].endswith("/SKILL.md") for entry in config["skills"]["config"])
        assert any(entry["path"] == ".agents/skills/trw-deliver" for entry in config["skills"]["config"])

    def test_codex_hooks_json_created(self, tmp_path: Path) -> None:
        result = generate_codex_hooks(tmp_path)
        assert ".codex/hooks.json" in result["created"]
        hooks = json.loads((tmp_path / ".codex" / "hooks.json").read_text(encoding="utf-8"))
        assert "UserPromptSubmit" in hooks["hooks"]
        assert "PreToolUse" in hooks["hooks"]
        assert "PostToolUse" in hooks["hooks"]
        assert "Stop" in hooks["hooks"]

    def test_codex_hooks_merge_preserves_user_handlers(self, tmp_path: Path) -> None:
        codex_dir = tmp_path / ".codex"
        codex_dir.mkdir()
        (codex_dir / "hooks.json").write_text(
            json.dumps(
                {
                    "hooks": {
                        "UserPromptSubmit": [
                            {
                                "description": "user custom",
                                "hooks": [{"type": "command", "command": "echo custom"}],
                            }
                        ],
                        "Notification": [
                            {
                                "description": "notify",
                                "hooks": [{"type": "command", "command": "echo notify"}],
                            }
                        ],
                    }
                },
                indent=2,
            )
            + "\n",
            encoding="utf-8",
        )

        result = generate_codex_hooks(tmp_path)
        assert ".codex/hooks.json" in result["updated"]
        hooks = json.loads((codex_dir / "hooks.json").read_text(encoding="utf-8"))
        assert hooks["hooks"]["Notification"][0]["description"] == "notify"
        assert hooks["hooks"]["UserPromptSubmit"][0]["description"] == "user custom"
        assert any(
            entry.get("description", "").startswith("TRW managed:")
            for entry in hooks["hooks"]["UserPromptSubmit"]
        )

    def test_codex_agents_created(self, tmp_path: Path) -> None:
        result = generate_codex_agents(tmp_path)
        assert ".codex/agents/trw-explorer.toml" in result["created"]
        assert ".codex/agents/trw-implementer.toml" in result["created"]
        explorer = (tmp_path / ".codex" / "agents" / "trw-explorer.toml").read_text(encoding="utf-8")
        assert 'model = "gpt-5.4-mini"' in explorer

    def test_codex_skills_installed(self, tmp_path: Path) -> None:
        result = install_codex_skills(tmp_path)
        assert any(path.endswith("/SKILL.md") for path in result["created"])
        skill_path = tmp_path / ".agents" / "skills" / "trw-deliver" / "SKILL.md"
        assert skill_path.exists()
        content = skill_path.read_text(encoding="utf-8")
        assert "Codex-specific skill" in content
        assert "AGENTS.md" in content
        assert "allowed-tools:" not in content
        assert "disable-model-invocation:" not in content
        assert "user-invocable:" not in content
        assert "model: claude-" not in content

    def test_codex_merge_preserves_user_settings(self) -> None:
        merged = merge_codex_config(
            {
                "model": "gpt-5.4-mini",
                "model_reasoning_effort": "high",
                "sandbox_mode": "read-only",
                "approval_policy": "never",
                "features": {"some_feature": False},
                "mcp_servers": {"custom": {"command": "custom-mcp", "enabled": False}},
            }
        )
        assert merged["model"] == "gpt-5.4-mini"
        assert merged["model_reasoning_effort"] == "high"
        assert merged["sandbox_mode"] == "read-only"
        assert merged["approval_policy"] == "never"
        assert merged["features"]["codex_hooks"] is True
        assert merged["features"]["some_feature"] is False
        assert "custom" in merged["mcp_servers"]
        assert merged["mcp_servers"]["custom"]["enabled"] is False
        assert merged["mcp_servers"]["trw"]["enabled"] is True
        assert "trw_session_start" in merged["mcp_servers"]["trw"]["enabled_tools"]

    def test_codex_config_smart_merge_existing_file(self, tmp_path: Path) -> None:
        codex_dir = tmp_path / ".codex"
        codex_dir.mkdir()
        (codex_dir / "config.toml").write_text(
            """
model = "gpt-5.4-mini"
project_doc_fallback_filenames = ["README.md"]

[features]
legacy_toggle = false

[mcp_servers.custom]
command = "custom-server"
enabled = false

[mcp_servers.trw]
enabled_tools = ["legacy_helper"]
disabled_tools = ["trw_old_tool"]

[mcp_servers.trw.tools.custom_helper]
enabled = true

[skills]
config = [
  { path = ".agents/skills/trw-deliver/SKILL.md", enabled = true },
]
""".strip()
            + "\n",
            encoding="utf-8",
        )

        result = generate_codex_config(tmp_path)
        assert ".codex/config.toml" in result["updated"]
        config = tomllib.loads((codex_dir / "config.toml").read_text(encoding="utf-8"))
        assert config["model"] == "gpt-5.4-mini"
        assert config["features"]["legacy_toggle"] is False
        assert config["features"]["codex_hooks"] is True
        assert config["mcp_servers"]["custom"]["enabled"] is False
        assert config["mcp_servers"]["trw"]["enabled"] is True
        assert "trw_session_start" in config["mcp_servers"]["trw"]["enabled_tools"]
        assert "legacy_helper" in config["mcp_servers"]["trw"]["enabled_tools"]
        assert "custom_helper" in config["mcp_servers"]["trw"]["enabled_tools"]
        assert "trw_old_tool" in config["mcp_servers"]["trw"]["disabled_tools"]
        assert "README.md" in config["project_doc_fallback_filenames"]
        assert ".codex/INSTRUCTIONS.md" in config["project_doc_fallback_filenames"]
        skill_paths = [entry["path"] for entry in config["skills"]["config"]]
        assert ".agents/skills/trw-deliver" in skill_paths
        assert ".agents/skills/trw-deliver/SKILL.md" not in skill_paths

    def test_codex_config_reinstall_is_idempotent(self, tmp_path: Path) -> None:
        first = generate_codex_config(tmp_path)
        assert ".codex/config.toml" in first["created"]

        second = generate_codex_config(tmp_path)
        assert ".codex/config.toml" in second["updated"]

        config = tomllib.loads((tmp_path / ".codex" / "config.toml").read_text(encoding="utf-8"))
        fallback_files = config["project_doc_fallback_filenames"]
        assert fallback_files.count(".codex/INSTRUCTIONS.md") == 1

        trw_tools = config["mcp_servers"]["trw"]["enabled_tools"]
        assert "trw_session_start" in trw_tools
        assert trw_tools.count("trw_session_start") == 1

        skill_paths = [entry["path"] for entry in config["skills"]["config"]]
        assert len(skill_paths) == len(set(skill_paths))


# ── FR15: Update-Project Multi-IDE Support ───────────────────────────────


@pytest.mark.unit
class TestUpdateProjectMultiIDE:
    """FR15: Update-project supports multiple IDEs (PRD-CORE-074)."""

    def test_fr15_update_detects_opencode_by_dir(self, tmp_path: Path) -> None:
        """With .opencode/ present, update generates opencode.json."""
        from unittest.mock import patch

        # Set up a minimal existing TRW project
        (tmp_path / ".trw").mkdir()
        (tmp_path / ".trw" / "config.yaml").write_text("task_root: docs\n")
        (tmp_path / ".opencode").mkdir()

        # Patch heavy update internals so we focus on opencode branch
        with (
            patch("trw_mcp.bootstrap._update_project._update_framework_files"),
            patch("trw_mcp.bootstrap._update_project._update_mcp_config"),
            patch("trw_mcp.bootstrap._update_project._cleanup_stale_artifacts"),
            patch("trw_mcp.bootstrap._update_project._check_package_version"),
            patch("trw_mcp.bootstrap._update_project._write_installer_metadata"),
            patch("trw_mcp.bootstrap._update_project._write_version_yaml"),
            patch("trw_mcp.bootstrap._update_project._verify_installation"),
            patch("trw_mcp.bootstrap._update_project._run_claude_md_sync"),
            patch("trw_mcp.bootstrap._update_project._ensure_dir"),
        ):
            result = update_project(tmp_path)

        # opencode.json should be created (detected via .opencode/ dir)
        assert (tmp_path / "opencode.json").exists()
        config = json.loads((tmp_path / "opencode.json").read_text())
        assert "mcp" in config
        assert "trw" in config["mcp"]

    def test_fr15_update_detects_opencode_by_json(self, tmp_path: Path) -> None:
        """With opencode.json present, update performs smart-merge."""
        from unittest.mock import patch

        (tmp_path / ".trw").mkdir()
        (tmp_path / ".trw" / "config.yaml").write_text("task_root: docs\n")
        # Create existing opencode.json (triggers detection)
        (tmp_path / "opencode.json").write_text(json.dumps({"model": "custom-model", "mcp": {}}))

        with (
            patch("trw_mcp.bootstrap._update_project._update_framework_files"),
            patch("trw_mcp.bootstrap._update_project._update_mcp_config"),
            patch("trw_mcp.bootstrap._update_project._cleanup_stale_artifacts"),
            patch("trw_mcp.bootstrap._update_project._check_package_version"),
            patch("trw_mcp.bootstrap._update_project._write_installer_metadata"),
            patch("trw_mcp.bootstrap._update_project._write_version_yaml"),
            patch("trw_mcp.bootstrap._update_project._verify_installation"),
            patch("trw_mcp.bootstrap._update_project._run_claude_md_sync"),
            patch("trw_mcp.bootstrap._update_project._ensure_dir"),
        ):
            result = update_project(tmp_path)

        config = json.loads((tmp_path / "opencode.json").read_text())
        # Preserved user key
        assert config.get("model") == "custom-model"
        # TRW entry injected
        assert "trw" in config["mcp"]

    def test_fr15_update_detects_codex_by_dir(self, tmp_path: Path) -> None:
        """With .codex/ present, update generates Codex artifacts."""
        from unittest.mock import patch

        (tmp_path / ".trw").mkdir()
        (tmp_path / ".trw" / "config.yaml").write_text("task_root: docs\n", encoding="utf-8")
        (tmp_path / ".codex").mkdir()

        with (
            patch("trw_mcp.bootstrap._update_project._update_framework_files"),
            patch("trw_mcp.bootstrap._update_project._update_mcp_config"),
            patch("trw_mcp.bootstrap._update_project._cleanup_stale_artifacts"),
            patch("trw_mcp.bootstrap._update_project._check_package_version"),
            patch("trw_mcp.bootstrap._update_project._write_installer_metadata"),
            patch("trw_mcp.bootstrap._update_project._write_version_yaml"),
            patch("trw_mcp.bootstrap._update_project._verify_installation"),
            patch("trw_mcp.bootstrap._update_project._run_claude_md_sync"),
            patch("trw_mcp.bootstrap._update_project._ensure_dir"),
        ):
            update_project(tmp_path)

        config = tomllib.loads((tmp_path / ".codex" / "config.toml").read_text(encoding="utf-8"))
        assert config["features"]["codex_hooks"] is True
        assert config["mcp_servers"]["trw"]["enabled"] is True
        assert (tmp_path / ".codex" / "hooks.json").exists()
        assert (tmp_path / ".codex" / "agents" / "trw-reviewer.toml").exists()
        assert (tmp_path / ".agents" / "skills" / "trw-deliver" / "SKILL.md").exists()
        assert (tmp_path / "AGENTS.md").exists()

    def test_fr15_update_no_opencode_skips(self, tmp_path: Path) -> None:
        """Without opencode indicators, update does not create opencode.json."""
        from unittest.mock import patch

        (tmp_path / ".trw").mkdir()
        (tmp_path / ".trw" / "config.yaml").write_text("task_root: docs\n")
        # Only Claude Code present
        (tmp_path / ".claude").mkdir()

        with (
            patch("trw_mcp.bootstrap._update_project._update_framework_files"),
            patch("trw_mcp.bootstrap._update_project._update_mcp_config"),
            patch("trw_mcp.bootstrap._update_project._cleanup_stale_artifacts"),
            patch("trw_mcp.bootstrap._update_project._check_package_version"),
            patch("trw_mcp.bootstrap._update_project._write_installer_metadata"),
            patch("trw_mcp.bootstrap._update_project._write_version_yaml"),
            patch("trw_mcp.bootstrap._update_project._verify_installation"),
            patch("trw_mcp.bootstrap._update_project._run_claude_md_sync"),
            patch("trw_mcp.bootstrap._update_project._ensure_dir"),
        ):
            result = update_project(tmp_path)

        assert not (tmp_path / "opencode.json").exists()

    def test_fr15_update_ide_override_opencode(self, tmp_path: Path) -> None:
        """update_project(ide='opencode') creates opencode.json even without detection."""
        from unittest.mock import patch

        (tmp_path / ".trw").mkdir()
        (tmp_path / ".trw" / "config.yaml").write_text("task_root: docs\n")
        # No .opencode/ dir, but explicit override

        with (
            patch("trw_mcp.bootstrap._update_project._update_framework_files"),
            patch("trw_mcp.bootstrap._update_project._update_mcp_config"),
            patch("trw_mcp.bootstrap._update_project._cleanup_stale_artifacts"),
            patch("trw_mcp.bootstrap._update_project._check_package_version"),
            patch("trw_mcp.bootstrap._update_project._write_installer_metadata"),
            patch("trw_mcp.bootstrap._update_project._write_version_yaml"),
            patch("trw_mcp.bootstrap._update_project._verify_installation"),
            patch("trw_mcp.bootstrap._update_project._run_claude_md_sync"),
            patch("trw_mcp.bootstrap._update_project._ensure_dir"),
        ):
            result = update_project(tmp_path, ide="opencode")

        assert (tmp_path / "opencode.json").exists()

    def test_fr15_init_with_ide_opencode(self, tmp_path: Path) -> None:
        """init_project(ide='opencode') creates opencode.json and per-client instructions."""
        (tmp_path / ".git").mkdir()

        result = init_project(tmp_path, ide="opencode")

        assert not result["errors"], result["errors"]
        # opencode.json created
        assert (tmp_path / "opencode.json").exists()
        config = json.loads((tmp_path / "opencode.json").read_text())
        assert "mcp" in config
        assert "trw" in config["mcp"]
        # Per-client instruction file created (PRD-CORE-115)
        assert (tmp_path / ".opencode" / "INSTRUCTIONS.md").exists()
        instructions = (tmp_path / ".opencode" / "INSTRUCTIONS.md").read_text()
        assert "trw_session_start" in instructions
        assert "trw_deliver" in instructions

    def test_fr15_init_creates_both_ide_all(self, tmp_path: Path) -> None:
        """init_project(ide='all') creates Claude, OpenCode, and Codex artifacts."""
        (tmp_path / ".git").mkdir()

        result = init_project(tmp_path, ide="all")

        assert not result["errors"], result["errors"]
        # Claude Code artifacts
        assert (tmp_path / ".claude").is_dir()
        assert (tmp_path / "CLAUDE.md").exists()
        # OpenCode artifacts
        assert (tmp_path / "opencode.json").exists()
        # Codex artifacts
        assert (tmp_path / ".codex" / "config.toml").exists()
        assert (tmp_path / ".codex" / "hooks.json").exists()
        assert (tmp_path / ".codex" / "agents" / "trw-implementer.toml").exists()
        assert (tmp_path / ".agents" / "skills" / "trw-deliver" / "SKILL.md").exists()
        # Per-client instruction files (PRD-CORE-115)
        assert (tmp_path / ".opencode" / "INSTRUCTIONS.md").exists()
        assert (tmp_path / ".codex" / "INSTRUCTIONS.md").exists()

    def test_fr15_init_default_no_opencode_artifacts(self, tmp_path: Path) -> None:
        """init_project() without --ide does not create opencode artifacts by default."""
        (tmp_path / ".git").mkdir()
        # No opencode indicators — default auto-detect should fall back to claude-code

        result = init_project(tmp_path)

        assert not result["errors"], result["errors"]
        # opencode artifacts should NOT exist (no .opencode/ dir present)
        assert not (tmp_path / "opencode.json").exists()
        assert not (tmp_path / "AGENTS.md").exists()

    def test_fr15_opencode_instructions_contains_trw_section(self, tmp_path: Path) -> None:
        """Per-client instructions generated by init_project contain TRW tool reference."""
        (tmp_path / ".git").mkdir()

        result = init_project(tmp_path, ide="opencode")

        assert not result["errors"], result["errors"]
        instructions_content = (tmp_path / ".opencode" / "INSTRUCTIONS.md").read_text()
        # The per-client instructions should contain tool names
        assert "trw_session_start" in instructions_content or "TRW" in instructions_content

    def test_fr15_init_with_ide_codex(self, tmp_path: Path) -> None:
        """init_project(ide='codex') creates Codex config, hooks, agents, skills, and per-client instructions."""
        (tmp_path / ".git").mkdir()

        result = init_project(tmp_path, ide="codex")

        assert not result["errors"], result["errors"]
        config = tomllib.loads((tmp_path / ".codex" / "config.toml").read_text(encoding="utf-8"))
        assert config["features"]["codex_hooks"] is True
        assert config["mcp_servers"]["trw"]["enabled"] is True
        assert config["mcp_servers"]["openaiDeveloperDocs"]["enabled"] is True
        assert (tmp_path / ".codex" / "hooks.json").exists()
        assert (tmp_path / ".codex" / "agents" / "trw-docs-researcher.toml").exists()
        assert (tmp_path / ".agents" / "skills" / "trw-deliver" / "SKILL.md").exists()
        # Per-client instruction file (PRD-CORE-115)
        assert (tmp_path / ".codex" / "INSTRUCTIONS.md").exists()
        instructions_content = (tmp_path / ".codex" / "INSTRUCTIONS.md").read_text(encoding="utf-8")
        assert "trw_deliver" in instructions_content

    def test_fr15_update_opencode_also_creates_agents_md(self, tmp_path: Path) -> None:
        """update_project with opencode detected also creates/updates AGENTS.md."""
        from unittest.mock import patch

        (tmp_path / ".trw").mkdir()
        (tmp_path / ".trw" / "config.yaml").write_text("task_root: docs\n")
        (tmp_path / ".opencode").mkdir()

        with (
            patch("trw_mcp.bootstrap._update_project._update_framework_files"),
            patch("trw_mcp.bootstrap._update_project._update_mcp_config"),
            patch("trw_mcp.bootstrap._update_project._cleanup_stale_artifacts"),
            patch("trw_mcp.bootstrap._update_project._check_package_version"),
            patch("trw_mcp.bootstrap._update_project._write_installer_metadata"),
            patch("trw_mcp.bootstrap._update_project._write_version_yaml"),
            patch("trw_mcp.bootstrap._update_project._verify_installation"),
            patch("trw_mcp.bootstrap._update_project._run_claude_md_sync"),
            patch("trw_mcp.bootstrap._update_project._ensure_dir"),
        ):
            result = update_project(tmp_path)

        assert (tmp_path / "AGENTS.md").exists()
        content = (tmp_path / "AGENTS.md").read_text()
        assert "<!-- trw:start -->" in content


# ── FR09: A/B Test Infrastructure (CORE-074) ─────────────────────────────


@pytest.mark.unit
class TestEnforcementVariant:
    """FR09: A/B test infrastructure for ceremony enforcement variants."""

    def test_fr09_default_baseline(self) -> None:
        """Default enforcement_variant is 'baseline'."""
        from trw_mcp.models.config import TRWConfig

        config = TRWConfig()
        assert config.enforcement_variant == "baseline"

    def test_fr09_variant_configurable(self) -> None:
        """enforcement_variant accepts valid values."""
        from trw_mcp.models.config import TRWConfig

        config = TRWConfig(enforcement_variant="nudge")
        assert config.enforcement_variant == "nudge"

    def test_fr09_all_valid_variants(self) -> None:
        """enforcement_variant accepts all documented variant values."""
        from trw_mcp.models.config import TRWConfig

        for variant in ("baseline", "nudge", "nudge-only", "mcp-only", "none"):
            config = TRWConfig(enforcement_variant=variant)
            assert config.enforcement_variant == variant


# ── FR05+FR06+FR07: Cursor IDE Bootstrap (CORE-074) ──────────────────────


@pytest.mark.integration
class TestCursorBootstrap:
    """FR05+FR06+FR07: Cursor IDE bootstrap — hooks, rules, mcp config."""

    def test_fr05_cursor_hooks_created(self, tmp_path: Path) -> None:
        """FR05: generate_cursor_hooks creates .cursor/hooks.json with TRW hooks."""
        from trw_mcp.bootstrap._cursor import generate_cursor_hooks

        result = generate_cursor_hooks(tmp_path)

        assert ".cursor/hooks.json" in result["created"]
        config = json.loads((tmp_path / ".cursor" / "hooks.json").read_text())
        assert len(config["hooks"]) == 4
        events = {h["event"] for h in config["hooks"]}
        assert "beforeMCPExecution" in events
        assert "beforeSubmitPrompt" in events
        assert "afterFileEdit" in events
        assert "stop" in events

    def test_fr05_cursor_hooks_all_have_trw_descriptions(self, tmp_path: Path) -> None:
        """FR05: All generated hooks have descriptions starting with 'TRW'."""
        from trw_mcp.bootstrap._cursor import generate_cursor_hooks

        generate_cursor_hooks(tmp_path)
        config = json.loads((tmp_path / ".cursor" / "hooks.json").read_text())
        for hook in config["hooks"]:
            assert hook["description"].startswith("TRW"), (
                f"Hook {hook['event']} description does not start with 'TRW': {hook['description']}"
            )

    def test_fr05_cursor_hooks_smart_merge_preserves_user_hooks(self, tmp_path: Path) -> None:
        """FR05: Smart merge preserves existing user hooks when file already exists."""
        from trw_mcp.bootstrap._cursor import generate_cursor_hooks

        cursor_dir = tmp_path / ".cursor"
        cursor_dir.mkdir()
        existing = {"hooks": [{"event": "custom", "command": "echo hi", "description": "User hook"}]}
        (cursor_dir / "hooks.json").write_text(json.dumps(existing))

        result = generate_cursor_hooks(tmp_path)

        assert ".cursor/hooks.json" in result["updated"]
        config = json.loads((tmp_path / ".cursor" / "hooks.json").read_text())
        # User hook preserved + 4 TRW hooks = 5 total
        assert len(config["hooks"]) == 5
        descriptions = [h["description"] for h in config["hooks"]]
        assert "User hook" in descriptions

    def test_fr05_cursor_hooks_smart_merge_replaces_trw_hooks(self, tmp_path: Path) -> None:
        """FR05: Smart merge replaces stale TRW hooks without duplicating them."""
        from trw_mcp.bootstrap._cursor import generate_cursor_hooks

        cursor_dir = tmp_path / ".cursor"
        cursor_dir.mkdir()
        existing = {
            "hooks": [
                {"event": "old", "command": "echo old", "description": "TRW old hook"},
                {"event": "custom", "command": "echo hi", "description": "User hook"},
            ]
        }
        (cursor_dir / "hooks.json").write_text(json.dumps(existing))

        generate_cursor_hooks(tmp_path)
        config = json.loads((tmp_path / ".cursor" / "hooks.json").read_text())
        # Old TRW hook removed, 4 new TRW hooks + user hook = 5
        assert len(config["hooks"]) == 5
        # Stale TRW hook gone
        old_events = [h["event"] for h in config["hooks"]]
        assert "old" not in old_events

    def test_fr05_cursor_hooks_force_overwrites(self, tmp_path: Path) -> None:
        """FR05: force=True overwrites existing hooks without merging."""
        from trw_mcp.bootstrap._cursor import generate_cursor_hooks

        cursor_dir = tmp_path / ".cursor"
        cursor_dir.mkdir()
        existing = {"hooks": [{"event": "custom", "command": "echo hi", "description": "User hook"}]}
        (cursor_dir / "hooks.json").write_text(json.dumps(existing))

        result = generate_cursor_hooks(tmp_path, force=True)

        assert ".cursor/hooks.json" in result["created"]
        config = json.loads((tmp_path / ".cursor" / "hooks.json").read_text())
        # Only TRW hooks — user hook not preserved
        assert len(config["hooks"]) == 4

    def test_fr05_cursor_hooks_malformed_json_fallback(self, tmp_path: Path) -> None:
        """FR05: Malformed existing JSON is gracefully overwritten."""
        from trw_mcp.bootstrap._cursor import generate_cursor_hooks

        cursor_dir = tmp_path / ".cursor"
        cursor_dir.mkdir()
        (cursor_dir / "hooks.json").write_text("not valid json {{")

        result = generate_cursor_hooks(tmp_path)

        assert ".cursor/hooks.json" in result["updated"]
        config = json.loads((tmp_path / ".cursor" / "hooks.json").read_text())
        assert len(config["hooks"]) == 4

    def test_fr06_cursor_rules_created(self, tmp_path: Path) -> None:
        """FR06: generate_cursor_rules creates .cursor/rules/trw-ceremony.mdc."""
        from trw_mcp.bootstrap._cursor import generate_cursor_rules

        result = generate_cursor_rules(tmp_path, "## TRW Protocol\nContent here")

        assert ".cursor/rules/trw-ceremony.mdc" in result["created"]
        rules_file = tmp_path / ".cursor" / "rules" / "trw-ceremony.mdc"
        assert rules_file.exists()
        content = rules_file.read_text()
        assert "alwaysApply: true" in content
        assert "TRW Protocol" in content
        assert "Content here" in content

    def test_fr06_cursor_rules_frontmatter_valid(self, tmp_path: Path) -> None:
        """FR06: Generated rules file has valid MDC frontmatter."""
        from trw_mcp.bootstrap._cursor import generate_cursor_rules

        generate_cursor_rules(tmp_path, "## TRW\nBody")
        content = (tmp_path / ".cursor" / "rules" / "trw-ceremony.mdc").read_text()
        assert content.startswith("---\n")
        assert "alwaysApply: true" in content
        assert "globs: []" in content
        assert "description:" in content

    def test_fr06_cursor_rules_under_500_lines(self, tmp_path: Path) -> None:
        """FR06: Generated rules file stays under 500 lines."""
        from trw_mcp.bootstrap._cursor import generate_cursor_rules

        generate_cursor_rules(tmp_path, "Short content")
        content = (tmp_path / ".cursor" / "rules" / "trw-ceremony.mdc").read_text()
        assert len(content.splitlines()) < 500

    def test_fr06_cursor_rules_update_on_existing(self, tmp_path: Path) -> None:
        """FR06: Calling generate_cursor_rules on an existing file reports 'updated'."""
        from trw_mcp.bootstrap._cursor import generate_cursor_rules

        generate_cursor_rules(tmp_path, "First content")
        result = generate_cursor_rules(tmp_path, "Updated content")

        assert ".cursor/rules/trw-ceremony.mdc" in result["updated"]
        content = (tmp_path / ".cursor" / "rules" / "trw-ceremony.mdc").read_text()
        assert "Updated content" in content

    def test_fr07_cursor_mcp_created(self, tmp_path: Path) -> None:
        """FR07: generate_cursor_mcp_config creates .cursor/mcp.json with TRW entry."""
        from trw_mcp.bootstrap._cursor import generate_cursor_mcp_config

        result = generate_cursor_mcp_config(tmp_path)

        assert ".cursor/mcp.json" in result["created"]
        mcp_file = tmp_path / ".cursor" / "mcp.json"
        assert mcp_file.exists()
        config = json.loads(mcp_file.read_text())
        assert "mcpServers" in config
        assert "trw" in config["mcpServers"]

    def test_fr07_cursor_mcp_entry_has_command(self, tmp_path: Path) -> None:
        """FR07: TRW MCP entry has a command field."""
        from trw_mcp.bootstrap._cursor import generate_cursor_mcp_config

        generate_cursor_mcp_config(tmp_path)
        config = json.loads((tmp_path / ".cursor" / "mcp.json").read_text())
        trw_entry = config["mcpServers"]["trw"]
        assert "command" in trw_entry

    def test_fr07_cursor_mcp_smart_merge_preserves_user_servers(self, tmp_path: Path) -> None:
        """FR07: Smart merge preserves existing MCP servers in .cursor/mcp.json."""
        from trw_mcp.bootstrap._cursor import generate_cursor_mcp_config

        cursor_dir = tmp_path / ".cursor"
        cursor_dir.mkdir()
        existing = {"mcpServers": {"other-server": {"command": "other-mcp"}}}
        (cursor_dir / "mcp.json").write_text(json.dumps(existing))

        result = generate_cursor_mcp_config(tmp_path)

        assert ".cursor/mcp.json" in result["updated"]
        config = json.loads((tmp_path / ".cursor" / "mcp.json").read_text())
        assert "other-server" in config["mcpServers"]
        assert "trw" in config["mcpServers"]

    def test_fr07_cursor_mcp_smart_merge_updates_trw_entry(self, tmp_path: Path) -> None:
        """FR07: Smart merge updates the trw entry even if it already exists."""
        from trw_mcp.bootstrap._cursor import generate_cursor_mcp_config

        cursor_dir = tmp_path / ".cursor"
        cursor_dir.mkdir()
        existing = {
            "mcpServers": {
                "trw": {"command": "old-command"},
                "other": {"command": "other-mcp"},
            }
        }
        (cursor_dir / "mcp.json").write_text(json.dumps(existing))

        generate_cursor_mcp_config(tmp_path)
        config = json.loads((tmp_path / ".cursor" / "mcp.json").read_text())
        # other server preserved
        assert "other" in config["mcpServers"]
        # trw entry refreshed
        assert config["mcpServers"]["trw"]["command"] != "old-command"

    def test_fr07_cursor_mcp_force_overwrites(self, tmp_path: Path) -> None:
        """FR07: force=True writes a fresh mcp.json without merging."""
        from trw_mcp.bootstrap._cursor import generate_cursor_mcp_config

        cursor_dir = tmp_path / ".cursor"
        cursor_dir.mkdir()
        existing = {"mcpServers": {"other": {"command": "other-mcp"}}}
        (cursor_dir / "mcp.json").write_text(json.dumps(existing))

        result = generate_cursor_mcp_config(tmp_path, force=True)

        assert ".cursor/mcp.json" in result["created"]
        config = json.loads((tmp_path / ".cursor" / "mcp.json").read_text())
        # Only TRW — user server removed
        assert "other" not in config["mcpServers"]
        assert "trw" in config["mcpServers"]

    def test_fr07_cursor_mcp_malformed_json_fallback(self, tmp_path: Path) -> None:
        """FR07: Malformed existing JSON is gracefully overwritten."""
        from trw_mcp.bootstrap._cursor import generate_cursor_mcp_config

        cursor_dir = tmp_path / ".cursor"
        cursor_dir.mkdir()
        (cursor_dir / "mcp.json").write_text("{{not valid json")

        result = generate_cursor_mcp_config(tmp_path)

        assert ".cursor/mcp.json" in result["updated"]
        config = json.loads((tmp_path / ".cursor" / "mcp.json").read_text())
        assert "trw" in config["mcpServers"]

    def test_fr05_fr06_fr07_cursor_dir_auto_created(self, tmp_path: Path) -> None:
        """FR05+FR06+FR07: .cursor/ directory and subdirs are created automatically."""
        import shutil as _shutil

        from trw_mcp.bootstrap._cursor import (
            generate_cursor_hooks,
            generate_cursor_mcp_config,
            generate_cursor_rules,
        )

        # FR05: .cursor/ created by generate_cursor_hooks
        assert not (tmp_path / ".cursor").exists()
        generate_cursor_hooks(tmp_path)
        assert (tmp_path / ".cursor").is_dir()
        assert (tmp_path / ".cursor" / "hooks.json").exists()

        # FR07: .cursor/ created (or reused) by generate_cursor_mcp_config
        _shutil.rmtree(tmp_path / ".cursor")
        assert not (tmp_path / ".cursor").exists()
        generate_cursor_mcp_config(tmp_path)
        assert (tmp_path / ".cursor").is_dir()
        assert (tmp_path / ".cursor" / "mcp.json").exists()

        # FR06: .cursor/rules/ subdir auto-created by generate_cursor_rules
        _shutil.rmtree(tmp_path / ".cursor")
        generate_cursor_rules(tmp_path, "content")
        assert (tmp_path / ".cursor" / "rules").is_dir()
        assert (tmp_path / ".cursor" / "rules" / "trw-ceremony.mdc").exists()

    def test_fr05_fr07_init_project_cursor_ide(self, tmp_path: Path) -> None:
        """FR05+FR07: init_project(ide='cursor') creates .cursor/ artifacts."""
        (tmp_path / ".git").mkdir()

        result = init_project(tmp_path, ide="cursor")

        assert not result["errors"], result["errors"]
        assert (tmp_path / ".cursor" / "hooks.json").exists()
        assert (tmp_path / ".cursor" / "mcp.json").exists()
        assert (tmp_path / ".cursor" / "rules" / "trw-ceremony.mdc").exists()

    def test_fr05_fr07_init_project_ide_all_includes_cursor(self, tmp_path: Path) -> None:
        """FR05+FR07: init_project(ide='all') creates Cursor artifacts alongside others."""
        (tmp_path / ".git").mkdir()

        result = init_project(tmp_path, ide="all")

        assert not result["errors"], result["errors"]
        # Cursor artifacts
        assert (tmp_path / ".cursor" / "hooks.json").exists()
        assert (tmp_path / ".cursor" / "mcp.json").exists()
        # Claude Code artifacts still present
        assert (tmp_path / ".claude").is_dir()
        assert (tmp_path / "CLAUDE.md").exists()

    def test_fr05_fr07_update_project_cursor_ide(self, tmp_path: Path) -> None:
        """FR05+FR07: update_project with cursor detected updates .cursor/ artifacts."""
        from unittest.mock import patch

        (tmp_path / ".trw").mkdir()
        (tmp_path / ".trw" / "config.yaml").write_text("task_root: docs\n")
        (tmp_path / ".cursor").mkdir()  # Presence triggers cursor detection

        with (
            patch("trw_mcp.bootstrap._update_project._update_framework_files"),
            patch("trw_mcp.bootstrap._update_project._update_mcp_config"),
            patch("trw_mcp.bootstrap._update_project._cleanup_stale_artifacts"),
            patch("trw_mcp.bootstrap._update_project._check_package_version"),
            patch("trw_mcp.bootstrap._update_project._write_installer_metadata"),
            patch("trw_mcp.bootstrap._update_project._write_version_yaml"),
            patch("trw_mcp.bootstrap._update_project._verify_installation"),
            patch("trw_mcp.bootstrap._update_project._run_claude_md_sync"),
            patch("trw_mcp.bootstrap._update_project._ensure_dir"),
        ):
            result = update_project(tmp_path)

        assert (tmp_path / ".cursor" / "hooks.json").exists()
        assert (tmp_path / ".cursor" / "mcp.json").exists()
        assert (tmp_path / ".cursor" / "rules" / "trw-ceremony.mdc").exists()
