"""
Tests for scanner signal computation (task 2.6).

One fixture per signal type — asserts the expected signal appears on the right
path with NO classification attached (no category, op, op_type, safety_tier).

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

import os
import sys
import time
from pathlib import Path

_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, SignalComputer  # noqa: E402


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

FORBIDDEN_SIGNAL_FIELDS = {"category", "op", "op_type", "safety_tier",
                            "exact_edit", "token_estimate", "class", "subtype"}


def _make_tree(tmp: Path, files: dict) -> None:
    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, git_log_fn=None, now_fn=None, **kwargs) -> dict:
    return Scanner(
        root=root,
        git_log_fn=git_log_fn or (lambda p: []),
        now_fn=now_fn or (lambda: 0.0),
        **kwargs,
    ).run()


def _assert_signal_present(signals_list: list, name: str) -> dict:
    """Assert a signal with the given name exists and return it."""
    matching = [s for s in signals_list if s.get("name") == name]
    assert matching, (
        f"expected signal '{name}' not found. Got: {[s.get('name') for s in signals_list]}"
    )
    return matching[0]


def _assert_no_classifier_fields(signal: dict) -> None:
    """Assert a signal dict contains only 'name' and 'detail' — no classifier fields."""
    for field in FORBIDDEN_SIGNAL_FIELDS:
        assert field not in signal, (
            f"classifier field '{field}' found in signal: {signal}"
        )
    assert "name" in signal, "signal must have a 'name' field"
    assert "detail" in signal, "signal must have a 'detail' field"


# ---------------------------------------------------------------------------
# Signal: broken_reference
# ---------------------------------------------------------------------------

class TestBrokenReferenceSignal:
    def test_broken_link_emits_signal(self, tmp_path):
        _make_tree(tmp_path, {
            "doc.md": "# Doc\n[missing file](./nonexistent.md)\n",
        })
        result = _run(tmp_path)
        assert "doc.md" in result["shortlist"]
        sigs = result["signals"].get("doc.md", [])
        sig = _assert_signal_present(sigs, "broken_reference")
        _assert_no_classifier_fields(sig)
        assert "nonexistent.md" in sig["detail"]

    def test_valid_link_no_broken_reference_signal(self, tmp_path):
        _make_tree(tmp_path, {
            "doc.md": "# Doc\n[existing](./existing.md)\n",
            "existing.md": "# Existing",
        })
        result = _run(tmp_path)
        sigs = result["signals"].get("doc.md", [])
        names = [s["name"] for s in sigs]
        assert "broken_reference" not in names

    def test_external_url_no_broken_reference_signal(self, tmp_path):
        _make_tree(tmp_path, {
            "doc.md": "# Doc\n[external](https://example.com/page)\n",
        })
        result = _run(tmp_path)
        sigs = result["signals"].get("doc.md", [])
        names = [s["name"] for s in sigs]
        assert "broken_reference" not in names

    def test_fragment_only_link_no_broken_reference(self, tmp_path):
        """Links with only an anchor (#section) have no file path to check."""
        _make_tree(tmp_path, {
            "doc.md": "# Doc\n[Section](#section)\n",
        })
        result = _run(tmp_path)
        sigs = result["signals"].get("doc.md", [])
        names = [s["name"] for s in sigs]
        assert "broken_reference" not in names

    def test_fixture_broken_ref(self):
        """Smoke: checked-in fixture has a broken reference signal."""
        fixture_root = Path(__file__).parent / "fixtures" / "scanner" / "signals"
        if not (fixture_root / "broken_ref.md").exists():
            return
        result = _run(fixture_root)
        sigs = result["signals"].get("broken_ref.md", [])
        sig = _assert_signal_present(sigs, "broken_reference")
        _assert_no_classifier_fields(sig)


# ---------------------------------------------------------------------------
# Signal: version_skew
# ---------------------------------------------------------------------------

class TestVersionSkewSignal:
    def test_version_skew_emits_signal(self, tmp_path):
        """Doc declares an old version; pyproject.toml has a newer one."""
        _make_tree(tmp_path, {
            "pyproject.toml": '[project]\nname = "myapp"\nversion = "2.0.0"\n',
            "docs/api.md": "# API\nversion: 1.0.0\n\nThis doc describes v1.0.0.",
        })
        result = _run(tmp_path)
        sigs = result["signals"].get("docs/api.md", [])
        sig = _assert_signal_present(sigs, "version_skew")
        _assert_no_classifier_fields(sig)
        assert "1.0.0" in sig["detail"]
        assert "2.0.0" in sig["detail"]

    def test_matching_version_no_signal(self, tmp_path):
        _make_tree(tmp_path, {
            "pyproject.toml": '[project]\nname = "myapp"\nversion = "1.0.0"\n',
            "doc.md": "# Doc\nversion: 1.0.0\nCurrent version docs.",
        })
        result = _run(tmp_path)
        sigs = result["signals"].get("doc.md", [])
        names = [s["name"] for s in sigs]
        assert "version_skew" not in names

    def test_no_pyproject_no_signal(self, tmp_path):
        """Without a version source, no version_skew signal."""
        _make_tree(tmp_path, {
            "doc.md": "# Doc\nversion: 1.0.0\nContent.",
        })
        result = _run(tmp_path)
        sigs = result["signals"].get("doc.md", [])
        names = [s["name"] for s in sigs]
        assert "version_skew" not in names


# ---------------------------------------------------------------------------
# Signal: edit_recency_vs_churn
# ---------------------------------------------------------------------------

class TestEditRecencyVsChurnSignal:
    def test_recently_modified_high_churn_emits_signal(self, tmp_path):
        """File modified 1 day ago with 10 commits → signal fires."""
        _make_tree(tmp_path, {"doc.md": "# Active doc\nFrequently updated."})
        doc = tmp_path / "doc.md"
        # Set mtime to 1 day ago
        recent_mtime = time.time() - 86400
        os.utime(doc, (recent_mtime, recent_mtime))

        fake_log = lambda p: [f"abc{i}def commit {i}" for i in range(10)]
        now_fn = lambda: time.time()

        result = Scanner(
            root=tmp_path,
            git_log_fn=fake_log,
            now_fn=now_fn,
        ).run()

        sigs = result["signals"].get("doc.md", [])
        sig = _assert_signal_present(sigs, "edit_recency_vs_churn")
        _assert_no_classifier_fields(sig)
        assert "10" in sig["detail"]

    def test_old_file_high_churn_no_signal(self, tmp_path):
        """File modified 30 days ago (not recent) → no signal even with high churn."""
        _make_tree(tmp_path, {"doc.md": "# Old doc"})
        doc = tmp_path / "doc.md"
        old_mtime = time.time() - (30 * 86400)
        os.utime(doc, (old_mtime, old_mtime))

        fake_log = lambda p: [f"abc{i} commit" for i in range(10)]
        now_fn = lambda: time.time()

        result = Scanner(
            root=tmp_path,
            git_log_fn=fake_log,
            now_fn=now_fn,
        ).run()

        sigs = result["signals"].get("doc.md", [])
        names = [s["name"] for s in sigs]
        assert "edit_recency_vs_churn" not in names

    def test_recent_low_churn_no_signal(self, tmp_path):
        """File modified recently but only 2 commits → no signal."""
        _make_tree(tmp_path, {"doc.md": "# New stable doc"})
        doc = tmp_path / "doc.md"
        recent_mtime = time.time() - 3600  # 1 hour ago
        os.utime(doc, (recent_mtime, recent_mtime))

        fake_log = lambda p: ["abc1 commit", "abc2 commit"]
        now_fn = lambda: time.time()

        result = Scanner(
            root=tmp_path,
            git_log_fn=fake_log,
            now_fn=now_fn,
        ).run()

        sigs = result["signals"].get("doc.md", [])
        names = [s["name"] for s in sigs]
        assert "edit_recency_vs_churn" not in names

    def test_no_git_log_fn_no_signal(self, tmp_path):
        """When git_log_fn is None, the signal is skipped entirely."""
        _make_tree(tmp_path, {"doc.md": "# Doc"})
        doc = tmp_path / "doc.md"
        recent_mtime = time.time() - 3600
        os.utime(doc, (recent_mtime, recent_mtime))

        result = Scanner(
            root=tmp_path,
            git_log_fn=None,  # explicitly disabled
            now_fn=lambda: time.time(),
        ).run()

        sigs = result["signals"].get("doc.md", [])
        names = [s["name"] for s in sigs]
        assert "edit_recency_vs_churn" not in names


# ---------------------------------------------------------------------------
# Signal: stale_name_location
# ---------------------------------------------------------------------------

class TestStaleNameLocationSignal:
    def test_old_prefix_in_name_emits_signal(self, tmp_path):
        _make_tree(tmp_path, {"old_notes.md": "# Old notes about the previous design."})
        result = _run(tmp_path)
        sigs = result["signals"].get("old_notes.md", [])
        sig = _assert_signal_present(sigs, "stale_name_location")
        _assert_no_classifier_fields(sig)
        assert "old" in sig["detail"].lower()

    def test_deprecated_in_name_emits_signal(self, tmp_path):
        _make_tree(tmp_path, {"deprecated_api.md": "# Deprecated API"})
        result = _run(tmp_path)
        sigs = result["signals"].get("deprecated_api.md", [])
        sig = _assert_signal_present(sigs, "stale_name_location")
        _assert_no_classifier_fields(sig)

    def test_normal_name_no_signal(self, tmp_path):
        _make_tree(tmp_path, {"architecture.md": "# Architecture"})
        result = _run(tmp_path)
        sigs = result["signals"].get("architecture.md", [])
        names = [s["name"] for s in sigs]
        assert "stale_name_location" not in names

    def test_fixture_stale_name(self):
        """Smoke: checked-in fixture (old_notes.md) emits stale_name_location."""
        fixture_root = Path(__file__).parent / "fixtures" / "scanner" / "signals"
        if not (fixture_root / "old_notes.md").exists():
            return
        result = _run(fixture_root)
        sigs = result["signals"].get("old_notes.md", [])
        sig = _assert_signal_present(sigs, "stale_name_location")
        _assert_no_classifier_fields(sig)


# ---------------------------------------------------------------------------
# Signal: archive_to_live_ratio
# ---------------------------------------------------------------------------

class TestArchiveToLiveRatioSignal:
    def test_mostly_resolved_content_emits_signal(self, tmp_path):
        content = (
            "# Project History\n\n"
            "## Resolved Issues\n\n"
            "Resolved: A resolved: B resolved: C resolved: D resolved: E\n"
            "resolved: F resolved: G resolved: H resolved: I resolved: J\n\n"
            "## Completed Work\n\n"
            "done: 1 done: 2 done: 3 done: 4 done: 5\n"
            "done: 6 done: 7 done: 8 done: 9 done: 10\n\n"
            "## Current\n\nOne line of live content.\n"
        )
        _make_tree(tmp_path, {"history.md": content})
        result = _run(tmp_path)
        sigs = result["signals"].get("history.md", [])
        sig = _assert_signal_present(sigs, "archive_to_live_ratio")
        _assert_no_classifier_fields(sig)

    def test_mostly_live_content_no_signal(self, tmp_path):
        content = (
            "# Architecture\n\n"
            "## Components\n\nThe main components are A, B, and C.\n\n"
            "## Design Decisions\n\nWe chose X over Y because of Z.\n\n"
            "## Future Work\n\nNext steps: D and E.\n"
        )
        _make_tree(tmp_path, {"arch.md": content})
        result = _run(tmp_path)
        sigs = result["signals"].get("arch.md", [])
        names = [s["name"] for s in sigs]
        assert "archive_to_live_ratio" not in names

    def test_fixture_archive_ratio(self):
        """Smoke: checked-in archive_ratio fixture emits the signal."""
        fixture_root = Path(__file__).parent / "fixtures" / "scanner" / "signals"
        if not (fixture_root / "archive_ratio.md").exists():
            return
        result = _run(fixture_root)
        sigs = result["signals"].get("archive_ratio.md", [])
        sig = _assert_signal_present(sigs, "archive_to_live_ratio")
        _assert_no_classifier_fields(sig)


# ---------------------------------------------------------------------------
# Signal: frontmatter_marker
# ---------------------------------------------------------------------------

class TestFrontmatterMarkerSignal:
    def test_draft_status_emits_signal(self, tmp_path):
        _make_tree(tmp_path, {
            "doc.md": "---\nstatus: draft\n---\n\n# Draft Doc\nContent.",
        })
        result = _run(tmp_path)
        sigs = result["signals"].get("doc.md", [])
        sig = _assert_signal_present(sigs, "frontmatter_marker")
        _assert_no_classifier_fields(sig)
        assert "draft" in sig["detail"]

    def test_provisional_status_emits_signal(self, tmp_path):
        _make_tree(tmp_path, {
            "doc.md": "---\nstatus: provisional\n---\n\n# Provisional",
        })
        result = _run(tmp_path)
        sigs = result["signals"].get("doc.md", [])
        sig = _assert_signal_present(sigs, "frontmatter_marker")
        _assert_no_classifier_fields(sig)

    def test_wip_status_emits_signal(self, tmp_path):
        _make_tree(tmp_path, {
            "doc.md": "---\nstatus: wip\n---\n\n# WIP",
        })
        result = _run(tmp_path)
        sigs = result["signals"].get("doc.md", [])
        sig = _assert_signal_present(sigs, "frontmatter_marker")
        _assert_no_classifier_fields(sig)

    def test_no_special_frontmatter_no_signal(self, tmp_path):
        _make_tree(tmp_path, {
            "doc.md": "---\ntitle: My Doc\n---\n\n# My Doc\nContent.",
        })
        result = _run(tmp_path)
        sigs = result["signals"].get("doc.md", [])
        names = [s["name"] for s in sigs]
        assert "frontmatter_marker" not in names

    def test_frozen_frontmatter_excluded_not_signaled(self, tmp_path):
        """hygiene: frozen causes exclusion — no frontmatter_marker should appear."""
        _make_tree(tmp_path, {
            "doc.md": "---\nhygiene: frozen\n---\n\n# Frozen",
        })
        result = _run(tmp_path)
        assert "doc.md" not in result["shortlist"]
        assert "doc.md" not in result["signals"]

    def test_fixture_frontmatter_marker(self):
        """Smoke: checked-in fixture emits frontmatter_marker."""
        fixture_root = Path(__file__).parent / "fixtures" / "scanner" / "signals"
        if not (fixture_root / "frontmatter_marker.md").exists():
            return
        result = _run(fixture_root)
        sigs = result["signals"].get("frontmatter_marker.md", [])
        sig = _assert_signal_present(sigs, "frontmatter_marker")
        _assert_no_classifier_fields(sig)


# ---------------------------------------------------------------------------
# Cross-cutting: signals carry no classifier fields
# ---------------------------------------------------------------------------

class TestSignalsNeverClassify:
    def test_all_signals_in_output_lack_classifier_fields(self, tmp_path):
        """Integration check: run over a tree with all signal types and verify output."""
        _make_tree(tmp_path, {
            "pyproject.toml": '[project]\nname = "test"\nversion = "2.0.0"\n',
            "broken.md": "# Doc\n[missing](./gone.md)\n",
            "old_stuff.md": "# Old stuff",
            "draft.md": "---\nstatus: draft\n---\n# Draft",
            "api.md": "# API\nversion: 1.0.0\n",
        })
        result = _run(tmp_path)
        for path, sigs in result["signals"].items():
            for sig in sigs:
                _assert_no_classifier_fields(sig)
                assert isinstance(sig.get("name"), str), f"signal name must be a string: {sig}"
                assert isinstance(sig.get("detail"), str), f"signal detail must be a string: {sig}"

    def test_output_never_contains_entries_field(self, tmp_path):
        _make_tree(tmp_path, {"doc.md": "# Doc"})
        result = _run(tmp_path)
        assert "entries" not in result
