"""
Tests for scanner exclusion pipeline (task 2.5).

Verifies invariant #9: frozen/ignored/append-only files are present in the
tree but absent from the shortlist; .dochygiene/ and excluded dirs are never
scanned.

sys.path manipulation is intentional per task constraints (no shared conftest).
"""

import os
import shutil
import sys
import tempfile
from pathlib import Path

# Prepend scripts/ to sys.path so we can import scanner without a package
_SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
if str(_SCRIPTS_DIR) not in sys.path:
    sys.path.insert(0, str(_SCRIPTS_DIR))

from scanner import Scanner  # noqa: E402


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_tree(tmp: Path, files: dict) -> None:
    """Create files in *tmp* from a dict of rel_path -> content."""
    for rel, content in files.items():
        p = tmp / rel
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(content, encoding="utf-8")


def _run(root: Path, **kwargs) -> dict:
    """Run scanner on *root* with no git and frozen clock."""
    return Scanner(
        root=root,
        git_log_fn=lambda p: [],
        now_fn=lambda: 0.0,
        **kwargs,
    ).run()


# ---------------------------------------------------------------------------
# 2.5a: hygiene: frozen file is excluded
# ---------------------------------------------------------------------------

class TestFrozenFrontmatter:
    def test_frozen_file_absent_from_shortlist(self, tmp_path):
        _make_tree(tmp_path, {
            "frozen_doc.md": "---\nhygiene: frozen\n---\n\n# Frozen\nContent.",
            "live_doc.md": "# Live\nThis is live content.",
        })
        result = _run(tmp_path)
        assert "frozen_doc.md" not in result["shortlist"], (
            "frozen_doc.md should be excluded by hygiene: frozen frontmatter"
        )

    def test_frozen_file_has_no_signals(self, tmp_path):
        _make_tree(tmp_path, {
            "frozen_doc.md": "---\nhygiene: frozen\n---\n\n# Frozen\nContent.",
        })
        result = _run(tmp_path)
        assert "frozen_doc.md" not in result["signals"], (
            "no signals should be computed for a frozen file"
        )

    def test_non_frozen_file_present_in_shortlist(self, tmp_path):
        _make_tree(tmp_path, {
            "frozen_doc.md": "---\nhygiene: frozen\n---\n\n# Frozen",
            "live_doc.md": "# Live\nNormal content.",
        })
        result = _run(tmp_path)
        assert "live_doc.md" in result["shortlist"], (
            "live_doc.md should be shortlisted"
        )

    def test_frozen_only_checked_at_frontmatter_not_content(self, tmp_path):
        """A file mentioning 'frozen' in body but not frontmatter is NOT excluded."""
        _make_tree(tmp_path, {
            "doc.md": "# Not frozen\nThis doc mentions hygiene: frozen in the body.",
        })
        result = _run(tmp_path)
        assert "doc.md" in result["shortlist"]

    def test_fixture_frozen_doc(self):
        """Smoke: use the checked-in fixture tree."""
        fixture_root = Path(__file__).parent / "fixtures" / "scanner" / "frozen"
        if not fixture_root.exists():
            return  # fixture not present, skip
        result = _run(fixture_root)
        assert "frozen_doc.md" not in result["shortlist"]
        assert "live_doc.md" in result["shortlist"]


# ---------------------------------------------------------------------------
# 2.5b: .dochygiene-ignore match is excluded
# ---------------------------------------------------------------------------

class TestIgnorePatterns:
    def test_ignored_file_absent_from_shortlist(self, tmp_path):
        _make_tree(tmp_path, {
            ".dochygiene-ignore": "ignored_doc.md\n",
            "ignored_doc.md": "# Ignored\nThis should be excluded.",
            "normal_doc.md": "# Normal\nThis should be shortlisted.",
        })
        result = _run(tmp_path)
        assert "ignored_doc.md" not in result["shortlist"]

    def test_ignored_file_has_no_signals(self, tmp_path):
        _make_tree(tmp_path, {
            ".dochygiene-ignore": "ignored_doc.md\n",
            "ignored_doc.md": "# Ignored",
        })
        result = _run(tmp_path)
        assert "ignored_doc.md" not in result["signals"]

    def test_non_ignored_file_present(self, tmp_path):
        _make_tree(tmp_path, {
            ".dochygiene-ignore": "ignored_doc.md\n",
            "normal_doc.md": "# Normal",
            "ignored_doc.md": "# Ignored",
        })
        result = _run(tmp_path)
        assert "normal_doc.md" in result["shortlist"]

    def test_glob_pattern_in_ignore(self, tmp_path):
        _make_tree(tmp_path, {
            ".dochygiene-ignore": "temp_*.md\n",
            "temp_notes.md": "# Temp",
            "design.md": "# Design",
        })
        result = _run(tmp_path)
        assert "temp_notes.md" not in result["shortlist"]
        assert "design.md" in result["shortlist"]

    def test_comment_lines_in_ignore_file_skipped(self, tmp_path):
        _make_tree(tmp_path, {
            ".dochygiene-ignore": "# This is a comment\nnotes.md\n",
            "notes.md": "# Notes",
            "design.md": "# Design",
        })
        result = _run(tmp_path)
        assert "notes.md" not in result["shortlist"]
        assert "design.md" in result["shortlist"]

    def test_no_ignore_file_means_nothing_excluded(self, tmp_path):
        _make_tree(tmp_path, {"doc.md": "# Doc"})
        result = _run(tmp_path)
        assert "doc.md" in result["shortlist"]

    def test_fixture_ignored_doc(self):
        """Smoke: use the checked-in fixture tree with a live .dochygiene-ignore."""
        fixture_root = Path(__file__).parent / "fixtures" / "scanner" / "ignored"
        if not fixture_root.exists():
            return
        # Write a .dochygiene-ignore into a temp copy so we don't modify the fixture dir
        import shutil, tempfile
        with tempfile.TemporaryDirectory() as tmp:
            tmp_path = Path(tmp)
            shutil.copytree(fixture_root, tmp_path / "tree")
            tree = tmp_path / "tree"
            (tree / ".dochygiene-ignore").write_text("ignored_doc.md\n")
            result = _run(tree)
            assert "ignored_doc.md" not in result["shortlist"]
            assert "normal_doc.md" in result["shortlist"]


# ---------------------------------------------------------------------------
# 2.5c: append-only log is excluded (positive + negative fixture)
# ---------------------------------------------------------------------------

class TestAppendOnlyExclusion:
    def test_append_only_explicit_marker_excluded(self, tmp_path):
        _make_tree(tmp_path, {
            "changelog.md": "<!-- append-only -->\n\n# Changelog\n\n- Entry 1\n",
            "normal.md": "# Normal doc\nContent here.",
        })
        result = _run(tmp_path)
        assert "changelog.md" not in result["shortlist"], (
            "file with explicit append-only marker should be excluded"
        )

    def test_append_only_explicit_marker_no_signals(self, tmp_path):
        _make_tree(tmp_path, {
            "changelog.md": "<!-- append-only -->\n# Log\n- Item\n",
        })
        result = _run(tmp_path)
        assert "changelog.md" not in result["signals"]

    def test_dated_sections_changelog_excluded(self, tmp_path):
        """Positive fixture: multi-entry dated changelog is append-only."""
        content = (
            "# Changelog\n\n"
            "## 2026-06-18\n\n- Feature added\n\n"
            "## 2026-06-17\n\n- Bug fixed\n\n"
            "## 2026-06-16\n\n- Project started\n"
        )
        _make_tree(tmp_path, {"CHANGELOG.md": content})
        result = _run(tmp_path)
        assert "CHANGELOG.md" not in result["shortlist"], (
            "multi-entry dated changelog should be detected as append-only"
        )

    def test_regular_doc_not_excluded(self, tmp_path):
        """Negative fixture: a regular architecture doc is NOT append-only."""
        content = (
            "# Architecture Overview\n\n"
            "## Components\n\nThe system has three components.\n\n"
            "## Rationale\n\nWe chose this design for clarity.\n\n"
            "## Trade-offs\n\nThe main trade-off is simplicity vs flexibility.\n"
        )
        _make_tree(tmp_path, {"ARCHITECTURE.md": content})
        result = _run(tmp_path)
        assert "ARCHITECTURE.md" in result["shortlist"], (
            "regular architecture doc (negative fixture) should NOT be excluded"
        )

    def test_fixture_positive_changelog(self):
        """Smoke: checked-in changelog fixture is excluded."""
        p = Path(__file__).parent / "fixtures" / "scanner" / "append_only" / "changelog_positive.md"
        if not p.exists():
            return
        import tempfile
        with tempfile.TemporaryDirectory() as tmp:
            tmp_path = Path(tmp)
            shutil.copy(p, tmp_path / p.name)
            result = _run(tmp_path)
            assert p.name not in result["shortlist"]

    def test_fixture_negative_regular_doc(self):
        """Smoke: checked-in regular doc fixture IS shortlisted."""
        p = Path(__file__).parent / "fixtures" / "scanner" / "append_only" / "regular_doc_negative.md"
        if not p.exists():
            return
        import tempfile
        with tempfile.TemporaryDirectory() as tmp:
            tmp_path = Path(tmp)
            shutil.copy(p, tmp_path / p.name)
            result = _run(tmp_path)
            assert p.name in result["shortlist"]


# ---------------------------------------------------------------------------
# 2.5d: excluded dirs are never descended
# ---------------------------------------------------------------------------

class TestExcludedDirs:
    def test_default_excluded_dirs_not_scanned(self, tmp_path):
        # Bare directory-name excludes (matched at any depth).  "golden" is NOT
        # here — it is a path-aware "examples/golden" pair, covered separately.
        bare_excludes = ["build", "vendor", "archive", "graphify-out", "fixtures"]
        for excluded_dir in bare_excludes:
            _make_tree(tmp_path, {
                f"{excluded_dir}/secret.md": f"# In {excluded_dir}\nShould not be scanned.",
            })
        _make_tree(tmp_path, {"live.md": "# Live"})
        result = _run(tmp_path)
        for excluded_dir in bare_excludes:
            for path in result["shortlist"]:
                assert not path.startswith(f"{excluded_dir}/"), (
                    f"file under {excluded_dir}/ should not appear in shortlist"
                )

    def test_dochygiene_self_excluded_always(self, tmp_path):
        """State dir is always self-excluded regardless of configuration."""
        _make_tree(tmp_path, {
            ".dochygiene/state.json": '{"last_check": null}',
            ".dochygiene/state_doc.md": "# This should never be scanned",
            "real_doc.md": "# Real doc",
        })
        result = _run(tmp_path)
        for path in result["shortlist"]:
            assert not path.startswith(".dochygiene/"), (
                ".dochygiene/ contents should never appear in shortlist"
            )
        assert "real_doc.md" in result["shortlist"]

    def test_dochygiene_excluded_even_if_not_in_config(self, tmp_path):
        """Even with an empty excluded_dirs config, .dochygiene/ is self-excluded."""
        _make_tree(tmp_path, {
            ".dochygiene/report.md": "# Should be excluded",
            "live.md": "# Live",
        })
        # Pass an empty excluded_dirs — .dochygiene/ must still be excluded
        result = Scanner(
            root=tmp_path,
            excluded_dirs=[],  # explicitly empty
            git_log_fn=lambda p: [],
            now_fn=lambda: 0.0,
        ).run()
        for path in result["shortlist"]:
            assert not path.startswith(".dochygiene/")
        assert "live.md" in result["shortlist"]

    def test_cc_os_self_excluded_always(self, tmp_path):
        """Canonical .cc-os/ state dir (ADR-027) is always self-excluded."""
        _make_tree(tmp_path, {
            ".cc-os/dochygiene/state.json": '{"last_check": null}',
            ".cc-os/dochygiene/state_doc.md": "# This should never be scanned",
            "real_doc.md": "# Real doc",
        })
        result = _run(tmp_path)
        for path in result["shortlist"]:
            assert not path.startswith(".cc-os/"), (
                ".cc-os/ contents should never appear in shortlist"
            )
        assert "real_doc.md" in result["shortlist"]

    def test_cc_os_excluded_even_if_not_in_config(self, tmp_path):
        """Even with an empty excluded_dirs config, .cc-os/ is self-excluded."""
        _make_tree(tmp_path, {
            ".cc-os/dochygiene/report.md": "# Should be excluded",
            "live.md": "# Live",
        })
        result = Scanner(
            root=tmp_path,
            excluded_dirs=[],  # explicitly empty
            git_log_fn=lambda p: [],
            now_fn=lambda: 0.0,
        ).run()
        for path in result["shortlist"]:
            assert not path.startswith(".cc-os/")
        assert "live.md" in result["shortlist"]

    def test_custom_excluded_dir_not_scanned(self, tmp_path):
        _make_tree(tmp_path, {
            "myexcluded/doc.md": "# Should not appear",
            "live.md": "# Live",
        })
        result = Scanner(
            root=tmp_path,
            excluded_dirs=["myexcluded"],
            git_log_fn=lambda p: [],
            now_fn=lambda: 0.0,
        ).run()
        for path in result["shortlist"]:
            assert not path.startswith("myexcluded/")
        assert "live.md" in result["shortlist"]

    def test_excluded_dir_files_cost_no_files_scanned_count(self, tmp_path):
        """Files inside excluded dirs must not increment files_scanned."""
        _make_tree(tmp_path, {
            "build/output.md": "# Build output",
            "live.md": "# Live",
        })
        result = _run(tmp_path)
        # Only live.md should have been scanned (build/ is pruned before walk)
        assert result["files_scanned"] == 1


# ---------------------------------------------------------------------------
# 2.5e: exclusion precedes signal computation
# ---------------------------------------------------------------------------

class TestExclusionPrecedesSignals:
    def test_frozen_file_with_broken_link_has_no_signals(self, tmp_path):
        """Frozen file with a broken reference should produce NO signals."""
        _make_tree(tmp_path, {
            "frozen.md": (
                "---\nhygiene: frozen\n---\n\n"
                "# Frozen\n[missing](./nonexistent.md)\n"
            ),
        })
        result = _run(tmp_path)
        assert "frozen.md" not in result["shortlist"]
        assert "frozen.md" not in result["signals"]

    def test_ignored_file_with_broken_link_has_no_signals(self, tmp_path):
        _make_tree(tmp_path, {
            ".dochygiene-ignore": "ignored.md\n",
            "ignored.md": "# Ignored\n[missing](./gone.md)\n",
        })
        result = _run(tmp_path)
        assert "ignored.md" not in result["signals"]


# ---------------------------------------------------------------------------
# 2.5f: output shape contains no classifier fields
# ---------------------------------------------------------------------------

class TestOutputShape:
    FORBIDDEN_FIELDS = {"entries", "category", "op", "op_type", "safety_tier",
                        "exact_edit", "token_estimate"}

    def test_no_classifier_fields_in_output(self, tmp_path):
        _make_tree(tmp_path, {"doc.md": "# Doc"})
        result = _run(tmp_path)
        for field in self.FORBIDDEN_FIELDS:
            assert field not in result, f"forbidden field '{field}' found in scanner output"

    def test_scan_metadata_fields_present(self, tmp_path):
        _make_tree(tmp_path, {"doc.md": "# Doc"})
        result = _run(tmp_path)
        assert "project_root" in result
        assert "scope_globs" in result
        assert "excluded_dirs" in result
        assert "files_scanned" in result
        assert "shortlist" in result
        assert "signals" in result

    def test_project_root_is_absolute(self, tmp_path):
        _make_tree(tmp_path, {"doc.md": "# Doc"})
        result = _run(tmp_path)
        assert os.path.isabs(result["project_root"])

    def test_files_scanned_count_is_correct(self, tmp_path):
        _make_tree(tmp_path, {
            "a.md": "# A",
            "b.md": "# B",
            "c.md": "# C",
        })
        result = _run(tmp_path)
        assert result["files_scanned"] == 3


# ---------------------------------------------------------------------------
# 2.5g: test-fixture and golden-example dirs are excluded by default
#
# These cover the real false-positive case: a project-level scan of a repo
# that contains test/golden input trees full of intentionally-stale docs.
#
# Two distinct semantics:
#   * "fixtures"        — bare directory-NAME match at any depth (broad).
#   * "examples/golden" — PATH-AWARE: a 'golden' dir is pruned only when its
#                         immediate parent is 'examples' (depth-independent), so
#                         unrelated projects' 'golden/' dirs are NOT skipped.
# ---------------------------------------------------------------------------

class TestFixtureAndGoldenExclusion:
    """'fixtures' (bare name) and 'examples/golden' (path-aware) are default-excluded."""

    def test_fixtures_subtree_not_scanned_by_default(self, tmp_path):
        """Files under any child dir named 'fixtures' are not scanned."""
        _make_tree(tmp_path, {
            "tests/fixtures/scanner/stale_fixture.md": "# Intentionally stale fixture",
            "tests/fixtures/scanner/another.md": "# Another scanner fixture",
            "live_doc.md": "# Real live doc",
        })
        result = _run(tmp_path)
        for path in result["shortlist"]:
            assert "fixtures/" not in path, (
                f"file under fixtures/ sub-dir should not appear in shortlist, got: {path}"
            )
        assert "live_doc.md" in result["shortlist"], (
            "live_doc.md at project root must still be shortlisted"
        )

    def test_examples_golden_subtree_not_scanned_by_default(self, tmp_path):
        """A 'golden' dir under an 'examples' parent is pruned (nested, monorepo-style)."""
        _make_tree(tmp_path, {
            # Mirrors the real monorepo path: examples/golden nested under a pkg dir.
            "doc-hygiene/examples/golden/classifier/1-orphaned/input/docs/orphan.md": (
                "# Orphaned\nThis deliberately-stale golden input must not be flagged."
            ),
            "live_doc.md": "# Real live doc",
        })
        result = _run(tmp_path)
        for path in result["shortlist"]:
            assert "examples/golden/" not in path, (
                f"file under examples/golden/ should not appear in shortlist, got: {path}"
            )
        assert "live_doc.md" in result["shortlist"]

    def test_bare_golden_dir_without_examples_parent_is_scanned(self, tmp_path):
        """A 'golden/' dir NOT under an 'examples' parent is legitimate and IS scanned.

        This is the false-negative the path-aware narrowing exists to prevent:
        an unrelated project with its own top-level golden/ dir must not be skipped.
        """
        _make_tree(tmp_path, {
            "golden/master_record.md": "# Golden source of truth\nLegit project content.",
            "data/golden/values.md": "# Golden values\nMore legit content.",
            "live.md": "# Live",
        })
        result = _run(tmp_path)
        assert "golden/master_record.md" in result["shortlist"], (
            "a top-level golden/ dir (parent != examples) must be scanned"
        )
        assert "data/golden/values.md" in result["shortlist"], (
            "a golden/ dir under 'data' (parent != examples) must be scanned"
        )

    def test_fixtures_files_cost_no_files_scanned_count(self, tmp_path):
        """Files inside fixtures/ dirs must not increment files_scanned (pruned at walk)."""
        _make_tree(tmp_path, {
            "tests/fixtures/scanner/input.md": "# fixture",
            "live.md": "# Live",
        })
        result = _run(tmp_path)
        # Only live.md should be scanned (fixtures/ is pruned before walk descends)
        assert result["files_scanned"] == 1, (
            f"expected 1 file scanned, got {result['files_scanned']}"
        )

    def test_examples_golden_files_cost_no_files_scanned_count(self, tmp_path):
        """Files inside examples/golden/ must not increment files_scanned (pruned at walk)."""
        _make_tree(tmp_path, {
            "examples/golden/1-case/input/docs/doc.md": "# golden input",
            "live.md": "# Live",
        })
        result = _run(tmp_path)
        assert result["files_scanned"] == 1, (
            f"expected 1 file scanned, got {result['files_scanned']}"
        )

    def test_explicit_excluded_dirs_override_does_not_auto_add_fixtures(self, tmp_path):
        """When excluded_dirs is passed explicitly, fixtures/examples-golden are NOT
        auto-added (they are defaults, not invariant-forced like .dochygiene/)."""
        _make_tree(tmp_path, {
            "fixtures/stale.md": "# stale fixture",
            "examples/golden/g.md": "# golden",
            "live.md": "# Live",
        })
        # Pass an explicit override list that does NOT include them
        result = Scanner(
            root=tmp_path,
            excluded_dirs=["build"],  # custom override — fixtures/golden not listed
            git_log_fn=lambda p: [],
            now_fn=lambda: 0.0,
        ).run()
        # Both should now be scanned (override didn't include them)
        assert "fixtures/stale.md" in result["shortlist"], (
            "with explicit excluded_dirs override omitting 'fixtures', "
            "fixtures/stale.md should be scanned"
        )
        assert "examples/golden/g.md" in result["shortlist"], (
            "with explicit excluded_dirs override omitting 'examples/golden', "
            "examples/golden/g.md should be scanned"
        )
        assert "live.md" in result["shortlist"]

    def test_path_aware_pair_passable_as_explicit_override(self, tmp_path):
        """A 'parent/child' entry passed explicitly applies the path-aware match."""
        _make_tree(tmp_path, {
            "examples/golden/g.md": "# excluded",
            "golden/top.md": "# scanned (no examples parent)",
            "live.md": "# Live",
        })
        result = Scanner(
            root=tmp_path,
            excluded_dirs=["examples/golden"],
            git_log_fn=lambda p: [],
            now_fn=lambda: 0.0,
        ).run()
        assert "examples/golden/g.md" not in result["shortlist"]
        assert "golden/top.md" in result["shortlist"]
        assert "live.md" in result["shortlist"]

    def test_golden_harness_root_scan_is_unaffected(self, tmp_path):
        """The golden test harness points the scanner at input/ as the root.

        os.walk never prunes the root itself (only child dirs), and the
        examples→golden parent/child pair lives in the ANCESTRY above the walk
        root — never re-encountered as a child below input/. So files under the
        harness root are fully scanned even with the path-aware exclude active.
        """
        # Simulate what test_classifier_golden.py does: root = .../1-orphaned/input/
        input_root = tmp_path / "examples" / "golden" / "1-orphaned" / "input"
        _make_tree(tmp_path, {
            "examples/golden/1-orphaned/input/docs/orphan.md": (
                "# Orphaned\n[missing](./gone.md)\n"
            ),
        })
        # Run scanner with root = input/ (exactly as the harness does)
        result = Scanner(
            root=input_root,
            git_log_fn=lambda p: [],
            now_fn=lambda: 0.0,
        ).run()
        # The file must still be scanned (root is never pruned; the examples→golden
        # pair is above the root, not a child below it).
        assert "docs/orphan.md" in result["shortlist"], (
            "golden harness: scanner rooted at input/ must still find docs/orphan.md"
        )
