"""Tests for ClaudeIntegration."""

import json
import os
from pathlib import Path
from unittest.mock import patch

import yaml

from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
from specify_cli.integrations.base import IntegrationBase, SkillsIntegration
from specify_cli.integrations.claude import ARGUMENT_HINTS, FORK_CONTEXT_COMMANDS
from specify_cli.integrations.manifest import IntegrationManifest


class TestClaudeIntegration:
    def test_registered(self):
        assert "claude" in INTEGRATION_REGISTRY
        assert get_integration("claude") is not None

    def test_is_base_integration(self):
        assert isinstance(get_integration("claude"), IntegrationBase)

    def test_config_uses_skills(self):
        integration = get_integration("claude")
        assert integration.config["folder"] == ".claude/"
        assert integration.config["commands_subdir"] == "skills"

    def test_registrar_config_uses_skill_layout(self):
        integration = get_integration("claude")
        assert integration.registrar_config["dir"] == ".claude/skills"
        assert integration.registrar_config["format"] == "markdown"
        assert integration.registrar_config["args"] == "$ARGUMENTS"
        assert integration.registrar_config["extension"] == "/SKILL.md"

    def test_setup_creates_skill_files(self, tmp_path):
        integration = get_integration("claude")
        manifest = IntegrationManifest("claude", tmp_path)
        created = integration.setup(tmp_path, manifest, script_type="sh")

        skill_files = [path for path in created if path.name == "SKILL.md"]
        assert skill_files

        skills_dir = tmp_path / ".claude" / "skills"
        assert skills_dir.is_dir()

        plan_skill = skills_dir / "speckit-plan" / "SKILL.md"
        assert plan_skill.exists()

        content = plan_skill.read_text(encoding="utf-8")
        assert "{SCRIPT}" not in content
        assert "{ARGS}" not in content
        assert "__AGENT__" not in content
        assert "__SPECKIT_COMMAND_" not in content, "unprocessed __SPECKIT_COMMAND_*__"
        assert "/speckit." not in content, "skills agent must use /speckit-<name> not /speckit.<name>"

        parts = content.split("---", 2)
        parsed = yaml.safe_load(parts[1])
        assert parsed["name"] == "speckit-plan"
        assert parsed["user-invocable"] is True
        assert parsed["disable-model-invocation"] is False
        assert parsed["metadata"]["source"] == "templates/commands/plan.md"

    def test_render_skill_unicode(self):
        """Test rendering a skill preserves non-ASCII characters."""
        integration = get_integration("claude")
        rendered = integration._render_skill(
            "constitution",
            {"description": "Prüfe Konformität der Implementierung"},
            "Body",
        )
        assert "Prüfe Konformität" in rendered

    def test_setup_does_not_write_context_section(self, tmp_path):
        """The CLI no longer manages the agent context file — that is owned by
        the opt-in agent-context extension. Setup must not create or touch it."""
        integration = get_integration("claude")
        manifest = IntegrationManifest("claude", tmp_path)
        integration.setup(tmp_path, manifest, script_type="sh")

        for path in tmp_path.rglob("*"):
            if path.is_file():
                text = path.read_text(encoding="utf-8", errors="ignore")
                assert "<!-- SPECKIT START -->" not in text

    def test_teardown_does_not_touch_existing_context_file(self, tmp_path):
        """A user-authored context file is left intact on teardown."""
        integration = get_integration("claude")
        ctx_path = tmp_path / "CLAUDE.md"
        original = "# CLAUDE.md\n\nUser content.\n"
        ctx_path.write_text(original, encoding="utf-8")

        manifest = IntegrationManifest("claude", tmp_path)
        integration.setup(tmp_path, manifest, script_type="sh")
        integration.teardown(tmp_path, manifest)

        assert ctx_path.read_text(encoding="utf-8") == original

    def test_integration_flag_creates_skill_files_cli(self, tmp_path):
        from typer.testing import CliRunner
        from specify_cli import app

        project = tmp_path / "claude-promote"
        project.mkdir()
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            runner = CliRunner()
            result = runner.invoke(
                app,
                [
                    "init",
                    "--here",
                    "--integration",
                    "claude",
                    "--script",
                    "sh",
                    "--ignore-agent-tools",
                ],
                catch_exceptions=False,
            )
        finally:
            os.chdir(old_cwd)

        assert result.exit_code == 0, result.output
        assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
        assert not (project / ".claude" / "commands").exists()

        init_options = json.loads(
            (project / ".specify" / "init-options.json").read_text(encoding="utf-8")
        )
        assert init_options["ai"] == "claude"
        assert init_options["ai_skills"] is True
        assert init_options["integration"] == "claude"

    def test_integration_flag_creates_skill_files(self, tmp_path):
        from typer.testing import CliRunner
        from specify_cli import app

        project = tmp_path / "claude-integration"
        project.mkdir()
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            runner = CliRunner()
            result = runner.invoke(
                app,
                [
                    "init",
                    "--here",
                    "--integration",
                    "claude",
                    "--script",
                    "sh",
                    "--ignore-agent-tools",
                ],
                catch_exceptions=False,
            )
        finally:
            os.chdir(old_cwd)

        assert result.exit_code == 0, result.output
        assert (project / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists()
        assert (project / ".specify" / "integrations" / "claude.manifest.json").exists()

    def test_interactive_claude_selection_uses_integration_path(self, tmp_path):
        from typer.testing import CliRunner
        from specify_cli import app

        project = tmp_path / "claude-interactive"
        project.mkdir()
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            runner = CliRunner()
            with (
                patch("specify_cli.commands.init._stdin_is_interactive", return_value=True),
                patch("specify_cli.commands.init.select_with_arrows", return_value="claude"),
            ):
                result = runner.invoke(
                    app,
                    [
                        "init",
                        "--here",
                        "--script",
                        "sh",
                        "--ignore-agent-tools",
                    ],
                    catch_exceptions=False,
                )
        finally:
            os.chdir(old_cwd)

        assert result.exit_code == 0, result.output
        assert (project / ".specify" / "integration.json").exists()
        assert (project / ".specify" / "integrations" / "claude.manifest.json").exists()

        skill_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
        assert skill_file.exists()
        skill_content = skill_file.read_text(encoding="utf-8")
        assert "user-invocable: true" in skill_content
        assert "disable-model-invocation: false" in skill_content

        init_options = json.loads(
            (project / ".specify" / "init-options.json").read_text(encoding="utf-8")
        )
        assert init_options["ai"] == "claude"
        assert init_options["ai_skills"] is True
        assert init_options["integration"] == "claude"

    def test_claude_init_remains_usable_when_converter_fails(self, tmp_path):
        """Claude init should succeed even without install_skills."""
        from typer.testing import CliRunner
        from specify_cli import app

        runner = CliRunner()
        target = tmp_path / "fail-proj"

        result = runner.invoke(
            app,
            ["init", str(target), "--integration", "claude", "--script", "sh", "--ignore-agent-tools"],
        )

        assert result.exit_code == 0
        assert (target / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists()

    def test_claude_hooks_render_skill_invocation(self, tmp_path):
        from specify_cli.extensions import HookExecutor

        project = tmp_path / "claude-hooks"
        project.mkdir()
        init_options = project / ".specify" / "init-options.json"
        init_options.parent.mkdir(parents=True, exist_ok=True)
        init_options.write_text(json.dumps({"ai": "claude", "ai_skills": True}))

        hook_executor = HookExecutor(project)
        message = hook_executor.format_hook_message(
            "before_plan",
            [
                {
                    "extension": "test-ext",
                    "command": "speckit.plan",
                    "optional": False,
                }
            ],
        )

        assert "Executing: `/speckit-plan`" in message
        assert "EXECUTE_COMMAND: speckit.plan" in message
        assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message

    def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path):
        from specify_cli import save_init_options
        from specify_cli.presets import PresetManager

        project = tmp_path / "claude-preset-skill"
        project.mkdir()
        save_init_options(project, {"ai": "claude", "ai_skills": True, "script": "sh"})

        skills_dir = project / ".claude" / "skills"
        skills_dir.mkdir(parents=True, exist_ok=True)

        preset_dir = tmp_path / "claude-skill-command"
        preset_dir.mkdir()
        (preset_dir / "commands").mkdir()
        (preset_dir / "commands" / "speckit.research.md").write_text(
            "---\n"
            "description: Research workflow\n"
            "---\n\n"
            "preset:claude-skill-command\n"
        )
        manifest_data = {
            "schema_version": "1.0",
            "preset": {
                "id": "claude-skill-command",
                "name": "Claude Skill Command",
                "version": "1.0.0",
                "description": "Test",
            },
            "requires": {"speckit_version": ">=0.1.0"},
            "provides": {
                "templates": [
                    {
                        "type": "command",
                        "name": "speckit.research",
                        "file": "commands/speckit.research.md",
                    }
                ]
            },
        }
        with open(preset_dir / "preset.yml", "w") as f:
            yaml.dump(manifest_data, f)

        manager = PresetManager(project)
        manager.install_from_directory(preset_dir, "0.1.5")

        skill_file = skills_dir / "speckit-research" / "SKILL.md"
        assert skill_file.exists()
        content = skill_file.read_text(encoding="utf-8")
        assert "preset:claude-skill-command" in content
        assert "name: speckit-research" in content
        assert "user-invocable: true" in content
        assert "disable-model-invocation: false" in content

        metadata = manager.registry.get("claude-skill-command")
        assert "speckit-research" in metadata.get("registered_skills", [])


class TestClaudeArgumentHints:
    """Verify that argument-hint frontmatter is injected for Claude skills."""

    def test_converge_has_no_argument_hint(self):
        """Converge should not advertise unsupported feature-name arguments."""
        assert "converge" not in ARGUMENT_HINTS

    def test_all_skills_have_hints(self, tmp_path):
        """Every skill with a configured hint must contain an argument-hint line."""
        i = get_integration("claude")
        m = IntegrationManifest("claude", tmp_path)
        created = i.setup(tmp_path, m, script_type="sh")
        skill_files = [f for f in created if f.name == "SKILL.md"]
        assert len(skill_files) > 0
        for f in skill_files:
            stem = f.parent.name
            if stem.startswith("speckit-"):
                stem = stem[len("speckit-"):]
            content = f.read_text(encoding="utf-8")
            if stem in ARGUMENT_HINTS:
                assert "argument-hint:" in content, (
                    f"{f.parent.name}/SKILL.md is missing argument-hint frontmatter"
                )
            else:
                assert "argument-hint:" not in content, (
                    f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
                )

    def test_hints_match_expected_values(self, tmp_path):
        """Each skill's argument-hint must match the expected text."""
        i = get_integration("claude")
        m = IntegrationManifest("claude", tmp_path)
        created = i.setup(tmp_path, m, script_type="sh")
        skill_files = [f for f in created if f.name == "SKILL.md"]
        for f in skill_files:
            # Extract stem: speckit-plan -> plan
            stem = f.parent.name
            if stem.startswith("speckit-"):
                stem = stem[len("speckit-"):]
            expected_hint = ARGUMENT_HINTS.get(stem)
            content = f.read_text(encoding="utf-8")
            if expected_hint is None:
                assert "argument-hint:" not in content, (
                    f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
                )
            else:
                assert f'argument-hint: "{expected_hint}"' in content, (
                    f"{f.parent.name}/SKILL.md: expected hint '{expected_hint}' not found"
                )

    def test_hint_is_inside_frontmatter(self, tmp_path):
        """argument-hint must appear between the --- delimiters, not in the body."""
        i = get_integration("claude")
        m = IntegrationManifest("claude", tmp_path)
        created = i.setup(tmp_path, m, script_type="sh")
        skill_files = [f for f in created if f.name == "SKILL.md"]
        for f in skill_files:
            content = f.read_text(encoding="utf-8")
            parts = content.split("---", 2)
            assert len(parts) >= 3, f"No frontmatter in {f.parent.name}/SKILL.md"
            frontmatter = parts[1]
            body = parts[2]
            stem = f.parent.name
            if stem.startswith("speckit-"):
                stem = stem[len("speckit-"):]
            if stem in ARGUMENT_HINTS:
                assert "argument-hint:" in frontmatter, (
                    f"{f.parent.name}/SKILL.md: argument-hint not in frontmatter section"
                )
                assert "argument-hint:" not in body, (
                    f"{f.parent.name}/SKILL.md: argument-hint leaked into body"
                )
            else:
                assert "argument-hint:" not in content, (
                    f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
                )

    def test_hint_appears_after_description(self, tmp_path):
        """argument-hint must immediately follow the description line."""
        i = get_integration("claude")
        m = IntegrationManifest("claude", tmp_path)
        created = i.setup(tmp_path, m, script_type="sh")
        skill_files = [f for f in created if f.name == "SKILL.md"]
        for f in skill_files:
            content = f.read_text(encoding="utf-8")
            lines = content.splitlines()
            stem = f.parent.name
            if stem.startswith("speckit-"):
                stem = stem[len("speckit-"):]
            if stem not in ARGUMENT_HINTS:
                assert "argument-hint:" not in content, (
                    f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
                )
                continue
            found_description = False
            for idx, line in enumerate(lines):
                if line.startswith("description:"):
                    found_description = True
                    assert idx + 1 < len(lines), (
                        f"{f.parent.name}/SKILL.md: description is last line"
                    )
                    assert lines[idx + 1].startswith("argument-hint:"), (
                        f"{f.parent.name}/SKILL.md: argument-hint does not follow description"
                    )
                    break
            assert found_description, (
                f"{f.parent.name}/SKILL.md: no description: line found in output"
            )

    def test_inject_argument_hint_only_in_frontmatter(self):
        """inject_argument_hint must not modify description: lines in the body."""
        from specify_cli.integrations.claude import ClaudeIntegration

        content = (
            "---\n"
            "description: My command\n"
            "---\n"
            "\n"
            "description: this is body text\n"
        )
        result = ClaudeIntegration.inject_argument_hint(content, "Test hint")
        lines = result.splitlines()
        hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
        assert hint_count == 1, (
            f"Expected exactly 1 argument-hint line, found {hint_count}"
        )

    def test_inject_argument_hint_skips_if_already_present(self):
        """inject_argument_hint must not duplicate if argument-hint already exists."""
        from specify_cli.integrations.claude import ClaudeIntegration

        content = (
            "---\n"
            "description: My command\n"
            'argument-hint: "Existing hint"\n'
            "---\n"
            "\n"
            "Body text\n"
        )
        result = ClaudeIntegration.inject_argument_hint(content, "New hint")
        assert result == content, "Content should be unchanged when hint already exists"
        lines = result.splitlines()
        hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
        assert hint_count == 1


class TestClaudeDisableModelInvocation:
    """Verify disable-model-invocation is false for Claude skills."""

    def test_setup_sets_disable_model_invocation_false(self, tmp_path):
        """Generated SKILL.md files must have disable-model-invocation: false."""
        i = get_integration("claude")
        m = IntegrationManifest("claude", tmp_path)
        created = i.setup(tmp_path, m, script_type="sh")
        skill_files = [f for f in created if f.name == "SKILL.md"]
        assert len(skill_files) > 0
        for f in skill_files:
            content = f.read_text(encoding="utf-8")
            parts = content.split("---", 2)
            parsed = yaml.safe_load(parts[1])
            assert parsed["disable-model-invocation"] is False, (
                f"{f.parent.name}: expected disable-model-invocation: false"
            )

    def test_disable_model_invocation_not_true(self, tmp_path):
        """No Claude skill should have disable-model-invocation: true."""
        i = get_integration("claude")
        m = IntegrationManifest("claude", tmp_path)
        created = i.setup(tmp_path, m, script_type="sh")
        for f in created:
            if f.name != "SKILL.md":
                continue
            content = f.read_text(encoding="utf-8")
            assert "disable-model-invocation: true" not in content, (
                f"{f.parent.name}: must not have disable-model-invocation: true"
            )

    def test_non_claude_agents_lack_disable_model_invocation(self, tmp_path):
        """Non-Claude skill agents should not get disable-model-invocation."""
        from specify_cli.agents import CommandRegistrar

        fm = CommandRegistrar.build_skill_frontmatter(
            "codex", "speckit-plan", "desc", "templates/commands/plan.md"
        )
        assert "disable-model-invocation" not in fm
        assert "user-invocable" not in fm

    def test_skills_default_post_process_preserves_content_without_hooks(self, tmp_path):
        """SkillsIntegration agents without an override preserve non-hook content."""
        # ``agy`` is a plain SkillsIntegration with no post-process override,
        # so it stands in for the base-class default behavior.
        agy = get_integration("agy")
        if agy is None:
            return  # agy not registered in this build
        content = "---\nname: test\n---\nBody"
        assert agy.post_process_skill_content(content) == content


class TestClaudeForkContext:
    """Verify context: fork is injected only for commands listed in FORK_CONTEXT_COMMANDS."""

    def test_no_commands_fork_by_default(self):
        """FORK_CONTEXT_COMMANDS is empty: no command opts into context: fork.

        ``analyze`` was removed (#3185) because its verbose report defeated the
        purpose of forking and compounded context overhead across repeated runs.
        """
        assert FORK_CONTEXT_COMMANDS == {}

    def test_analyze_skill_does_not_fork(self, tmp_path):
        """speckit-analyze must run in the main session, not a forked subagent (#3185)."""
        i = get_integration("claude")
        m = IntegrationManifest("claude", tmp_path)
        i.setup(tmp_path, m, script_type="sh")
        analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md"
        assert analyze_skill.exists()
        content = analyze_skill.read_text(encoding="utf-8")
        parts = content.split("---", 2)
        parsed = yaml.safe_load(parts[1])
        assert "context" not in parsed
        assert "agent" not in parsed

    def test_no_skills_fork(self, tmp_path):
        """Skills not in FORK_CONTEXT_COMMANDS must not get context: fork."""
        i = get_integration("claude")
        m = IntegrationManifest("claude", tmp_path)
        created = i.setup(tmp_path, m, script_type="sh")
        skill_files = [f for f in created if f.name == "SKILL.md"]
        for f in skill_files:
            stem = f.parent.name
            if stem.startswith("speckit-"):
                stem = stem[len("speckit-"):]
            if stem in FORK_CONTEXT_COMMANDS:
                continue
            content = f.read_text(encoding="utf-8")
            parts = content.split("---", 2)
            parsed = yaml.safe_load(parts[1])
            assert "context" not in parsed, (
                f"{f.parent.name}: must not have context frontmatter"
            )
            assert "agent" not in parsed, (
                f"{f.parent.name}: must not have agent frontmatter"
            )

    def test_post_process_no_fork_for_skills(self):
        """With FORK_CONTEXT_COMMANDS empty, post_process must not add context/agent."""
        i = get_integration("claude")
        for name in ("speckit-analyze", "speckit-plan"):
            content = f'---\nname: "{name}"\ndescription: "x"\n---\n\nBody\n'
            result = i.post_process_skill_content(content)
            parsed = yaml.safe_load(result.split("---", 2)[1])
            assert "context" not in parsed
            assert "agent" not in parsed

    def test_fork_mechanism_injects_when_configured(self, monkeypatch):
        """The injection mechanism still works for any command added to
        FORK_CONTEXT_COMMANDS, even though none ships enabled by default."""
        import specify_cli.integrations.claude as claude_mod

        monkeypatch.setitem(
            claude_mod.FORK_CONTEXT_COMMANDS,
            "analyze",
            {"context": "fork", "agent": "general-purpose"},
        )
        i = get_integration("claude")
        content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n'
        result = i.post_process_skill_content(content)
        parts = result.split("---", 2)
        parsed = yaml.safe_load(parts[1])
        assert parsed.get("context") == "fork"
        assert parsed.get("agent") == "general-purpose"
        # Flags must land in the frontmatter, not the body.
        assert "context: fork" in parts[1]
        assert "context: fork" not in parts[2]
        # Re-running must not duplicate the injected keys.
        twice = i.post_process_skill_content(result)
        assert result == twice
        assert twice.count("context: fork") == 1
        assert twice.count("agent: general-purpose") == 1


class TestClaudeHookCommandNote:
    """Verify dot-to-hyphen normalization note is injected in hook sections."""

    def test_hook_note_injected_in_skills_with_hooks(self, tmp_path):
        """Skills that have hook sections should get the normalization note."""
        i = get_integration("claude")
        m = IntegrationManifest("claude", tmp_path)
        i.setup(tmp_path, m, script_type="sh")
        specify_skill = tmp_path / ".claude/skills/speckit-specify/SKILL.md"
        assert specify_skill.exists()
        content = specify_skill.read_text(encoding="utf-8")
        # specify.md has hook sections
        assert "replace dots" in content, (
            "speckit-specify should have dot-to-hyphen hook note"
        )

    def test_hook_note_not_in_skills_without_hooks(self, tmp_path):
        """Skills without hook sections should not get the note."""
        content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
        result = SkillsIntegration._inject_hook_command_note(content)
        assert "replace dots" not in result

    def test_hook_note_idempotent(self, tmp_path):
        """Injecting the note twice should not duplicate it."""
        content = (
            "---\nname: test\n---\n\n"
            "- For each executable hook, output the following based on its flag:\n"
        )
        once = SkillsIntegration._inject_hook_command_note(content)
        twice = SkillsIntegration._inject_hook_command_note(once)
        assert once == twice, "Hook note injection should be idempotent"

    def test_hook_note_fills_missing_repeated_instructions(self, tmp_path):
        """Already-noted hook sections should not suppress later sections."""
        from specify_cli.integrations.base import _HOOK_COMMAND_NOTE

        content = (
            "---\nname: test\n---\n\n"
            f"{_HOOK_COMMAND_NOTE}"
            "- For each executable hook, output the following based on its flag:\n"
            "\n"
            "  - For each executable hook, output the following based on its flag:\n"
        )
        result = SkillsIntegration._inject_hook_command_note(content)
        assert result.count("replace dots (`.`) with hyphens") == 2

    def test_hook_note_not_suppressed_by_unrelated_phrase(self, tmp_path):
        """Unrelated text should not trip the hook-note idempotence guard."""
        content = (
            "---\nname: test\n---\n\n"
            "This paragraph says replace dots in a different context.\n"
            "- For each executable hook, output the following based on its flag:\n"
        )
        result = SkillsIntegration._inject_hook_command_note(content)
        assert "This paragraph says replace dots in a different context." in result
        assert result.count("replace dots (`.`) with hyphens") == 1

    def test_hook_note_preserves_indentation(self, tmp_path):
        """The injected note should match the indentation of the target line."""
        content = (
            "---\nname: test\n---\n\n"
            "   - For each executable hook, output the following\n"
        )
        result = SkillsIntegration._inject_hook_command_note(content)
        lines = result.splitlines()
        note_line = [line for line in lines if "replace dots" in line][0]
        assert note_line.startswith("   "), "Note should preserve indentation"

    def test_post_process_injects_all_claude_flags(self):
        """post_process_skill_content should inject all Claude-specific fields."""
        i = get_integration("claude")
        content = (
            "---\nname: test\ndescription: test\n---\n\n"
            "- For each executable hook, output the following\n"
        )
        result = i.post_process_skill_content(content)
        assert "user-invocable: true" in result
        assert "disable-model-invocation: false" in result
        assert "replace dots" in result


class TestSpeckitManifestRecordsSkippedFiles:
    """Regression test for issue #2107.

    ``install_shared_infra`` must record every shared-infrastructure file
    under ``.specify/`` in ``speckit.manifest.json``, including files that
    were *skipped* because they already existed on disk and ``force=False``.

    Before the fix, the skip branches in the scripts and templates loops
    appended to ``skipped_files`` without calling ``manifest.record_existing``.
    So when ``install_shared_infra`` ran with a fresh (or lost) manifest
    against an already-populated ``.specify/`` tree, every file went down the
    skip path, ``planned_copies`` and ``planned_templates`` stayed empty, and
    ``manifest.save()`` wrote an empty ``files`` field — leaving the
    integration believing nothing was installed.

    Reproduction (without the fix) using ``install_shared_infra`` directly:

        install_shared_infra(p, "sh", ..., force=False)   # 1st run → 10 files
        (p / ".specify/integrations/speckit.manifest.json").unlink()
        install_shared_infra(p, "sh", ..., force=False)   # 2nd run → 0 files
                                                          # ^^ BUG: empty
    """

    def _read_manifest_files(self, project_path: Path) -> dict:
        manifest_path = (
            project_path / ".specify" / "integrations" / "speckit.manifest.json"
        )
        assert manifest_path.exists(), (
            f"speckit.manifest.json not written at {manifest_path}"
        )
        data = json.loads(manifest_path.read_text(encoding="utf-8"))
        # ``IntegrationManifest.save`` serialises a ``files`` dict — assert
        # the schema explicitly so a regression to a different key (e.g.
        # the internal ``_files`` attribute name) fails loudly instead of
        # being masked by a silent fallback.
        assert isinstance(data, dict), (
            f"manifest root is not a dict, got {type(data).__name__}"
        )
        assert "files" in data, (
            f"manifest missing 'files' key, got keys: {sorted(data.keys())}"
        )
        files = data["files"]
        assert isinstance(files, dict), (
            f"manifest 'files' is not a dict, got {type(files).__name__}"
        )
        return files

    def test_install_shared_infra_records_skipped_files(self, tmp_path):
        """With ``force=False`` and ``.specify/`` already populated, the
        manifest must still record every file — the skip branches are not
        allowed to drop files from the manifest."""
        from rich.console import Console
        from specify_cli.shared_infra import install_shared_infra

        # Resolve the project's own packaged sources by walking up from this
        # test file to the repo root (which contains ``scripts/`` and
        # ``templates/`` that ``shared_scripts_source`` looks for).
        repo_root = Path(__file__).resolve().parents[2]
        console = Console(quiet=True)

        # First run — fresh project, manifest gets populated normally.
        install_shared_infra(
            tmp_path,
            "sh",
            version="0.0.0",
            core_pack=None,
            repo_root=repo_root,
            console=console,
            force=False,
        )
        first_files = self._read_manifest_files(tmp_path)
        assert first_files, "first install produced an empty manifest"

        # Simulate a lost manifest while ``.specify/`` is still on disk
        # (e.g. the manifest was deleted, corrupted, or the layout was
        # extracted out-of-band).
        manifest_path = (
            tmp_path / ".specify" / "integrations" / "speckit.manifest.json"
        )
        manifest_path.unlink()

        # Second run — every file already exists, so every iteration takes
        # the skip branch. With the fix, those files are still recorded.
        install_shared_infra(
            tmp_path,
            "sh",
            version="0.0.0",
            core_pack=None,
            repo_root=repo_root,
            console=console,
            force=False,
        )
        second_files = self._read_manifest_files(tmp_path)
        assert second_files, (
            "speckit.manifest.json files dict is empty after install with "
            "skipped files (issue #2107) — every file went down the skip "
            "branch but none were recorded"
        )

        # The recovered manifest must cover everything the first run tracked.
        missing = set(first_files) - set(second_files)
        assert not missing, (
            f"these files were tracked on the first install but missing after "
            f"the skipped-files re-install: {sorted(missing)[:5]}"
        )

    def test_install_shared_infra_handles_directory_at_script_destination(
        self, tmp_path
    ):
        """A non-file (directory) at a script's destination must NOT crash
        ``install_shared_infra`` and must NOT be recorded in the manifest —
        the path still appears in the user-visible skipped-paths warning.
        """
        from io import StringIO
        from rich.console import Console
        from specify_cli.shared_infra import install_shared_infra

        repo_root = Path(__file__).resolve().parents[2]
        output = StringIO()
        console = Console(file=output, force_terminal=False, width=200)

        # Pre-create the .specify/scripts/bash tree, then plant a directory
        # where a script file is expected so the skip branch hits a
        # non-regular-file path.
        bash_dir = tmp_path / ".specify" / "scripts" / "bash"
        bash_dir.mkdir(parents=True)
        (bash_dir / "common.sh").mkdir()  # collision: dir where file expected

        # Must not crash.
        install_shared_infra(
            tmp_path,
            "sh",
            version="0.0.0",
            core_pack=None,
            repo_root=repo_root,
            console=console,
            force=False,
        )

        files = self._read_manifest_files(tmp_path)
        assert ".specify/scripts/bash/common.sh" not in files, (
            "directory at script dst must not be recorded in the manifest"
        )
        text = output.getvalue()
        assert "common.sh" in text, (
            "directory-at-script-dst path must surface in the skipped warning"
        )

    def test_install_shared_infra_handles_directory_at_template_destination(
        self, tmp_path
    ):
        """Symmetric coverage for the templates loop: a directory at a
        template's destination must NOT crash install nor be recorded."""
        from io import StringIO
        from rich.console import Console
        from specify_cli.shared_infra import install_shared_infra

        repo_root = Path(__file__).resolve().parents[2]
        output = StringIO()
        console = Console(file=output, force_terminal=False, width=200)

        templates_dir = tmp_path / ".specify" / "templates"
        templates_dir.mkdir(parents=True)

        src_templates = repo_root / "templates"
        real_template = next(
            (
                p.name
                for p in src_templates.iterdir()
                if p.is_file()
                and not p.name.startswith(".")
                and p.name != "vscode-settings.json"
            ),
            None,
        )
        assert real_template, (
            "no real template found in repo to collide against"
        )
        (templates_dir / real_template).mkdir()  # collision

        install_shared_infra(
            tmp_path,
            "sh",
            version="0.0.0",
            core_pack=None,
            repo_root=repo_root,
            console=console,
            force=False,
        )

        files = self._read_manifest_files(tmp_path)
        template_rel = f".specify/templates/{real_template}"
        assert template_rel not in files, (
            "directory at template dst must not be recorded in manifest"
        )
        text = output.getvalue()
        assert real_template in text, (
            "directory-at-template-dst path must surface in the skipped warning"
        )
