"""Tests for recommendation writer — marker-based file updates."""

from pathlib import Path

import pytest

from headroom.learn.models import ProjectInfo, Recommendation, RecommendationTarget
from headroom.learn.writer import (
    _MARKER_END,
    _MARKER_START,
    ClaudeCodeWriter,
    _merge_into_file,
    _parse_prior_recommendations,
    _read_text_tolerant,
    extract_marker_block,
)


def _project(tmp_path: Path) -> ProjectInfo:
    proj_dir = tmp_path / "myproject"
    proj_dir.mkdir()
    data_dir = tmp_path / "data"
    data_dir.mkdir()
    memory_dir = data_dir / "memory"
    memory_dir.mkdir()
    return ProjectInfo(
        name="myproject",
        project_path=proj_dir,
        data_path=data_dir,
    )


def _rec(target: RecommendationTarget, section: str, content: str) -> Recommendation:
    return Recommendation(
        target=target, section=section, content=content, confidence=0.8, evidence_count=5
    )


class TestClaudeCodeWriter:
    def test_dry_run_does_not_write(self, tmp_path):
        proj = _project(tmp_path)
        writer = ClaudeCodeWriter()
        recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]

        result = writer.write(recs, proj, dry_run=True)

        assert result.dry_run is True
        assert len(result.files_written) == 1
        # File should NOT exist (dry run)
        claude_local = proj.project_path / "CLAUDE.local.md"
        assert not claude_local.exists()
        # Default target is the personal CLAUDE.local.md, never the shared CLAUDE.md
        assert result.files_written[0].name == "CLAUDE.local.md"

    def test_apply_writes_claude_local_md(self, tmp_path):
        proj = _project(tmp_path)
        writer = ClaudeCodeWriter()
        recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use `uv run python`")]

        result = writer.write(recs, proj, dry_run=False)

        assert result.dry_run is False
        # Learnings go to the personal, gitignored CLAUDE.local.md by default...
        claude_local = proj.project_path / "CLAUDE.local.md"
        assert claude_local.exists()
        content = claude_local.read_text()
        assert "uv run python" in content
        assert _MARKER_START in content
        assert _MARKER_END in content
        # ...and never touch the team-shared CLAUDE.md.
        assert not (proj.project_path / "CLAUDE.md").exists()

    def test_apply_writes_memory_md(self, tmp_path):
        proj = _project(tmp_path)
        writer = ClaudeCodeWriter()
        recs = [_rec(RecommendationTarget.MEMORY_FILE, "Retry Prevention", "- Don't retry globs")]

        writer.write(recs, proj, dry_run=False)

        memory_md = proj.data_path / "memory" / "MEMORY.md"
        assert memory_md.exists()
        assert "Don't retry globs" in memory_md.read_text()

    def test_hand_written_claude_md_left_untouched(self, tmp_path):
        proj = _project(tmp_path)
        claude_md = proj.project_path / "CLAUDE.md"
        original = "# My Project\n\nExisting instructions here.\n"
        claude_md.write_text(original)

        writer = ClaudeCodeWriter()
        recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]
        writer.write(recs, proj, dry_run=False)

        # A hand-written CLAUDE.md with no headroom block is left exactly as-is.
        assert claude_md.read_text() == original
        # Learnings land in the personal CLAUDE.local.md instead.
        local_content = (proj.project_path / "CLAUDE.local.md").read_text()
        assert "Use uv" in local_content

    def test_carries_forward_prior_sections_not_resurfaced(self, tmp_path):
        """Re-running learn must not drop prior sections that the new run didn't re-surface."""
        proj = _project(tmp_path)
        claude_md = proj.project_path / "CLAUDE.local.md"
        prior_block = (
            f"# My Project\n\n{_MARKER_START}\n"
            "## Headroom Learned Patterns\n"
            "*Auto-generated by `headroom learn` on 2026-01-01 — do not edit manually*\n\n"
            "### Large Files\n"
            "*~15,000 tokens/session saved*\n"
            "- src/App.tsx is huge\n\n"
            "### Build Commands\n"
            "- cargo check from src-tauri/\n\n"
            f"{_MARKER_END}\n"
        )
        claude_md.write_text(prior_block)

        writer = ClaudeCodeWriter()
        recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]
        writer.write(recs, proj, dry_run=False)

        content = claude_md.read_text()
        assert "My Project" in content
        # New section present
        assert "Use uv" in content
        # Prior sections preserved (neither heading re-surfaced by the new run)
        assert "### Large Files" in content
        assert "src/App.tsx is huge" in content
        assert "### Build Commands" in content
        assert "cargo check from src-tauri/" in content
        # Tokens annotation round-tripped
        assert "*~15,000 tokens/session saved*" in content
        # Still exactly one marker pair
        assert content.count(_MARKER_START) == 1
        assert content.count(_MARKER_END) == 1

    def test_new_run_overrides_same_named_prior_section(self, tmp_path):
        """When a section appears in both prior and new, the new run wins."""
        proj = _project(tmp_path)
        claude_md = proj.project_path / "CLAUDE.local.md"
        prior_block = (
            f"{_MARKER_START}\n"
            "## Headroom Learned Patterns\n"
            "*Auto-generated by `headroom learn` on 2026-01-01 — do not edit manually*\n\n"
            "### Environment\n"
            "- old stale environment note\n\n"
            f"{_MARKER_END}\n"
        )
        claude_md.write_text(prior_block)

        writer = ClaudeCodeWriter()
        recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- fresh environment note")]
        writer.write(recs, proj, dry_run=False)

        content = claude_md.read_text()
        assert "fresh environment note" in content
        assert "old stale environment note" not in content
        # Only one Environment section in the final block
        assert content.count("### Environment") == 1

    def test_replacing_existing_block_handles_literal_backslash_escapes(self, tmp_path):
        """LLM text with backslash escapes must not be interpreted as a regex replacement."""
        proj = _project(tmp_path)
        claude_md = proj.project_path / "CLAUDE.md"
        claude_md.write_text(
            f"# Existing\n\n{_MARKER_START}\n"
            "## Headroom Learned Patterns\n\n"
            "### Windows Paths\n"
            "- stale\n\n"
            f"{_MARKER_END}\n"
        )

        full_content = _merge_into_file(
            claude_md,
            [
                _rec(
                    RecommendationTarget.CONTEXT_FILE,
                    "Windows Paths",
                    r"- Keep the literal \u sequence and C:\Users\john.doe\repo path",
                )
            ],
        )

        assert r"\u sequence" in full_content
        assert r"C:\Users\john.doe\repo" in full_content
        assert "stale" not in full_content

    def test_memory_md_carry_forward(self, tmp_path):
        """Carry-forward also works for MEMORY.md."""
        proj = _project(tmp_path)
        memory_md = proj.data_path / "memory" / "MEMORY.md"
        memory_md.write_text(
            f"{_MARKER_START}\n"
            "## Headroom Learned Patterns\n"
            "*Auto-generated by `headroom learn` on 2026-01-01 — do not edit manually*\n\n"
            "### User Workflow Preferences\n"
            "- User rejects sleep-based polling\n\n"
            f"{_MARKER_END}\n"
        )

        writer = ClaudeCodeWriter()
        recs = [
            _rec(RecommendationTarget.MEMORY_FILE, "Related Codebases", "- web app at ~/Code/web")
        ]
        writer.write(recs, proj, dry_run=False)

        content = memory_md.read_text()
        assert "User rejects sleep-based polling" in content
        assert "web app at ~/Code/web" in content

    def test_section_without_tokens_annotation_round_trips(self, tmp_path):
        """Prior sections emitted without a tokens annotation must still carry forward cleanly."""
        proj = _project(tmp_path)
        claude_md = proj.project_path / "CLAUDE.local.md"
        claude_md.write_text(
            f"{_MARKER_START}\n"
            "## Headroom Learned Patterns\n"
            "*Auto-generated by `headroom learn` on 2026-01-01 — do not edit manually*\n\n"
            "### Misc\n"
            "- one-liner pattern\n\n"
            f"{_MARKER_END}\n"
        )

        writer = ClaudeCodeWriter()
        recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Other", "- new one")]
        writer.write(recs, proj, dry_run=False)

        content = claude_md.read_text()
        assert "### Misc" in content
        assert "one-liner pattern" in content
        # No spurious tokens annotation injected for a prior that didn't have one
        misc_idx = content.index("### Misc")
        after_misc = content[misc_idx : misc_idx + 200]
        assert "tokens/session saved" not in after_misc

    def test_appends_to_existing_memory_md(self, tmp_path):
        proj = _project(tmp_path)
        memory_md = proj.data_path / "memory" / "MEMORY.md"
        memory_md.write_text("# Existing Memory\n\nSome facts.\n")

        writer = ClaudeCodeWriter()
        recs = [_rec(RecommendationTarget.MEMORY_FILE, "Retry Prevention", "- New pattern")]
        writer.write(recs, proj, dry_run=False)

        content = memory_md.read_text()
        assert "Existing Memory" in content
        assert "Some facts" in content
        assert "New pattern" in content


def _legacy_block(section: str, body: str) -> str:
    return (
        f"# My Project\n\nExisting instructions.\n\n{_MARKER_START}\n"
        "## Headroom Learned Patterns\n"
        "*Auto-generated by `headroom learn` on 2026-01-01 — do not edit manually*\n\n"
        f"### {section}\n{body}\n\n"
        f"{_MARKER_END}\n"
    )


class TestContextTargetOverride:
    """--target / set_context_target controls where CONTEXT_FILE recs are written."""

    def test_target_override_relative_path(self, tmp_path):
        proj = _project(tmp_path)
        writer = ClaudeCodeWriter()
        writer.set_context_target("CLAUDE.md")
        recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]
        writer.write(recs, proj, dry_run=False)

        # Explicit target opts back into the team-shared CLAUDE.md.
        assert (proj.project_path / "CLAUDE.md").exists()
        assert "Use uv" in (proj.project_path / "CLAUDE.md").read_text()
        assert not (proj.project_path / "CLAUDE.local.md").exists()

    def test_target_override_via_constructor(self, tmp_path):
        proj = _project(tmp_path)
        writer = ClaudeCodeWriter(context_target="docs/LEARNINGS.md")
        recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]
        writer.write(recs, proj, dry_run=False)

        target = proj.project_path / "docs" / "LEARNINGS.md"
        assert target.exists()
        assert "Use uv" in target.read_text()

    def test_target_absolute_path(self, tmp_path):
        proj = _project(tmp_path)
        abs_target = tmp_path / "elsewhere" / "NOTES.md"
        writer = ClaudeCodeWriter(context_target=str(abs_target))
        recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]
        writer.write(recs, proj, dry_run=False)

        assert abs_target.exists()
        assert "Use uv" in abs_target.read_text()


class TestLegacyClaudeMdMigration:
    """A stale headroom block in the shared CLAUDE.md migrates to CLAUDE.local.md."""

    def test_migrates_block_and_strips_legacy(self, tmp_path):
        proj = _project(tmp_path)
        claude_md = proj.project_path / "CLAUDE.md"
        claude_md.write_text(_legacy_block("Build Commands", "- cargo check from src-tauri/"))

        writer = ClaudeCodeWriter()
        recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]
        result = writer.write(recs, proj, dry_run=False)

        # Hand-written content stays in CLAUDE.md; the headroom block is gone.
        legacy = claude_md.read_text()
        assert "Existing instructions." in legacy
        assert _MARKER_START not in legacy
        assert "Build Commands" not in legacy

        # CLAUDE.local.md now owns the migrated section AND the new one.
        local = (proj.project_path / "CLAUDE.local.md").read_text()
        assert "### Build Commands" in local
        assert "cargo check from src-tauri/" in local
        assert "### Environment" in local
        assert "Use uv" in local
        assert local.count(_MARKER_START) == 1

        # The migration is surfaced to the user.
        assert any("CLAUDE.md" in w for w in result.warnings)

    def test_block_only_claude_md_is_removed(self, tmp_path):
        proj = _project(tmp_path)
        claude_md = proj.project_path / "CLAUDE.md"
        # CLAUDE.md holds nothing but the Headroom block (no hand-written content).
        claude_md.write_text(
            f"{_MARKER_START}\n## Headroom Learned Patterns\n\n"
            "### Build Commands\n- cargo check\n\n"
            f"{_MARKER_END}\n"
        )

        writer = ClaudeCodeWriter()
        recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]
        result = writer.write(recs, proj, dry_run=False)

        # The empty husk is deleted rather than left behind as an empty file.
        assert not claude_md.exists()
        local = (proj.project_path / "CLAUDE.local.md").read_text()
        assert "### Build Commands" in local
        assert "### Environment" in local
        assert any("Removed" in w for w in result.warnings)

    def test_dry_run_block_only_claude_md_not_removed(self, tmp_path):
        proj = _project(tmp_path)
        claude_md = proj.project_path / "CLAUDE.md"
        original = (
            f"{_MARKER_START}\n## Headroom Learned Patterns\n\n"
            "### Build Commands\n- cargo check\n\n"
            f"{_MARKER_END}\n"
        )
        claude_md.write_text(original)

        writer = ClaudeCodeWriter()
        recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]
        result = writer.write(recs, proj, dry_run=True)

        # Dry run leaves the file on disk but still previews the removal.
        assert claude_md.read_text() == original
        assert any("Removed" in w for w in result.warnings)

    def test_dry_run_migration_writes_nothing(self, tmp_path):
        proj = _project(tmp_path)
        claude_md = proj.project_path / "CLAUDE.md"
        original = _legacy_block("Build Commands", "- cargo check")
        claude_md.write_text(original)

        writer = ClaudeCodeWriter()
        recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]
        result = writer.write(recs, proj, dry_run=True)

        # Nothing written on disk, but the warning still fires for the preview.
        assert claude_md.read_text() == original
        assert not (proj.project_path / "CLAUDE.local.md").exists()
        assert any("CLAUDE.md" in w for w in result.warnings)

    def test_no_migration_when_local_already_owns_block(self, tmp_path):
        proj = _project(tmp_path)
        claude_md = proj.project_path / "CLAUDE.md"
        legacy = _legacy_block("Build Commands", "- cargo check")
        claude_md.write_text(legacy)
        local_md = proj.project_path / "CLAUDE.local.md"
        local_md.write_text(
            f"{_MARKER_START}\n## Headroom Learned Patterns\n\n"
            "### Environment\n- prior local note\n\n"
            f"{_MARKER_END}\n"
        )

        writer = ClaudeCodeWriter()
        recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- fresh note")]
        result = writer.write(recs, proj, dry_run=False)

        # CLAUDE.md is left untouched (local is already the source of truth).
        assert claude_md.read_text() == legacy
        assert not result.warnings
        local = local_md.read_text()
        assert "fresh note" in local
        assert "prior local note" not in local

    def test_target_override_skips_migration(self, tmp_path):
        proj = _project(tmp_path)
        claude_md = proj.project_path / "CLAUDE.md"
        legacy = _legacy_block("Build Commands", "- cargo check")
        claude_md.write_text(legacy)

        writer = ClaudeCodeWriter()
        writer.set_context_target("CLAUDE.md")
        recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]
        result = writer.write(recs, proj, dry_run=False)

        # Explicit CLAUDE.md target merges in place, no migration warning.
        assert not result.warnings
        content = claude_md.read_text()
        assert "### Environment" in content
        assert "### Build Commands" in content


class TestHomeDirectoryContext:
    """The home directory keeps writing to ~/.claude/CLAUDE.md (personal global memory)."""

    def test_home_dir_writes_global_claude_md(self, tmp_path, monkeypatch):
        fake_home = tmp_path / "home"
        fake_home.mkdir()
        monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home))

        proj = ProjectInfo(
            name="home",
            project_path=fake_home,
            data_path=tmp_path / "data",
        )
        writer = ClaudeCodeWriter()
        recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]
        writer.write(recs, proj, dry_run=False)

        global_md = fake_home / ".claude" / "CLAUDE.md"
        assert global_md.exists()
        assert "Use uv" in global_md.read_text()
        assert not (fake_home / "CLAUDE.local.md").exists()


class TestParsePriorRecommendations:
    """Direct coverage for _parse_prior_recommendations edge cases."""

    def test_no_marker_block_returns_empty(self):
        """A file without any marker block yields no prior recommendations."""
        assert _parse_prior_recommendations("# Project\n\nJust a regular README.\n") == []

    def test_empty_marker_block_yields_no_recs(self):
        """A marker block with nothing between the markers yields no recs."""
        content = f"prefix\n{_MARKER_START}\n{_MARKER_END}\nsuffix\n"
        assert _parse_prior_recommendations(content) == []

    def test_marker_block_with_empty_heading_is_skipped(self):
        """A stray `### ` (empty heading) inside the block is skipped, not raised."""
        # Leading `### ` with no heading text, followed by a real section.
        content = (
            f"{_MARKER_START}\n"
            "## Headroom Learned Patterns\n"
            "### \n"
            "some orphan content\n"
            "\n"
            "### Real Section\n"
            "- real bullet\n"
            "\n"
            f"{_MARKER_END}\n"
        )
        recs = _parse_prior_recommendations(content)
        # Only the real section is parsed; the empty-heading entry is dropped.
        assert len(recs) == 1
        assert recs[0].section == "Real Section"
        assert "real bullet" in recs[0].content


class TestExtractMarkerBlock:
    """Direct coverage for extract_marker_block."""

    def test_returns_raw_block_when_present(self):
        """Marker block is returned verbatim with delimiters, for LLM prompts."""
        content = (
            "# Project README\n\n"
            "Some text.\n\n"
            f"{_MARKER_START}\n"
            "## Headroom Learned Patterns\n"
            "### Environment\n"
            "- Use uv run python\n"
            f"{_MARKER_END}\n"
            "Trailing text.\n"
        )
        block = extract_marker_block(content)
        assert block is not None
        assert block.startswith(_MARKER_START)
        assert block.endswith(_MARKER_END)
        assert "### Environment" in block
        assert "Use uv run python" in block
        assert "Trailing text." not in block

    def test_returns_none_when_absent(self):
        """File without any marker delimiters yields None."""
        assert extract_marker_block("# Project\n\nJust a regular README.\n") is None

    def test_returns_none_when_only_start_marker(self):
        """Partial/malformed block (start only) yields None — writer expects both delimiters."""
        content = f"prefix\n{_MARKER_START}\n### Something\n- content\n"
        assert extract_marker_block(content) is None

    def test_returns_empty_block_when_markers_are_adjacent(self):
        """A block with nothing between the markers is still returned (caller's choice what to do)."""
        content = f"prefix\n{_MARKER_START}\n{_MARKER_END}\nsuffix\n"
        block = extract_marker_block(content)
        assert block is not None
        assert block == f"{_MARKER_START}\n{_MARKER_END}"


class TestEncodingResilience:
    """Regression tests for #1202 — ``learn --apply`` must not crash merging into
    an existing context file that carries a stray non-UTF-8 byte (e.g. a legacy
    cp1252 em-dash ``0x97``)."""

    def test_read_text_tolerant_preserves_valid_utf8(self, tmp_path):
        path = tmp_path / "AGENTS.md"
        path.write_text("Use em-dashes — and arrows →.", encoding="utf-8")
        assert _read_text_tolerant(path) == "Use em-dashes — and arrows →."

    def test_read_text_tolerant_survives_stray_legacy_byte(self, tmp_path):
        # Predominantly valid UTF-8 (genuine em-dash E2 80 94) plus one stray
        # cp1252 em-dash byte (0x97) that strict UTF-8 cannot decode.
        path = tmp_path / "AGENTS.md"
        path.write_bytes("real em-dash — here\n".encode() + b"legacy \x97 byte\n")

        # The old strict read aborts the whole --apply on that single byte.
        with pytest.raises(UnicodeDecodeError):
            path.read_text(encoding="utf-8")

        text = _read_text_tolerant(path)
        # Valid UTF-8 content is preserved (no cp1252 "â€" mojibake) and the
        # stray byte is replaced rather than fatal.
        assert "real em-dash — here" in text
        assert "\x97" not in text
        assert "â€" not in text

    def test_merge_into_file_applies_over_file_with_stray_byte(self, tmp_path):
        path = tmp_path / "AGENTS.md"
        path.write_bytes("# Notes — existing\n".encode() + b"stray \x97 byte\n")
        recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")]

        merged = _merge_into_file(path, recs)

        assert "Use uv" in merged
        assert "Notes — existing" in merged
