"""
Tests for scripts/calibrate_helpers.py

Covers task 5.4 of the lifecycle-aware-doc-hygiene change:
- ClusterSampler: group unmatched paths by path-shape, capped sample per
  cluster
- RuleReportBuilder: 5-element rule-report data (glob verbatim, matched
  sample+total, near-miss boundary, lifetime+tier, why)
- RuleQualityChecker: class-not-path lint (instance-unique identifiers /
  no-wildcard-single-match) and prefer-the-narrower-glob tie-break

sys.path is patched here (no shared conftest), matching the existing
tests/test_rulebook.py pattern.
"""

from __future__ import annotations

import sys
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 calibrate_helpers import (  # noqa: E402
    ClusterSampler,
    RuleQualityChecker,
    RuleReportBuilder,
)


# ---------------------------------------------------------------------------
# ClusterSampler
# ---------------------------------------------------------------------------


class TestClusterSampler:
    def test_groups_by_directory_and_filename_shape(self):
        paths = [
            "autoresearch/run-12345678/notes.md",
            "autoresearch/run-87654321/notes.md",
            "autoresearch/run-11112222/notes.md",
            "docs/HANDOFF-2026-07-01.md",
            "docs/HANDOFF-2026-07-02.md",
        ]
        sampler = ClusterSampler()
        clusters = sampler.cluster(paths)

        # autoresearch/run-# dirs form one cluster (same dir-shape prefix
        # pattern doesn't collapse dir segments, but filenames do) —
        # here dir segments differ (run-12345678 vs run-87654321), so they
        # are distinct directories; but the *filenames* are identical
        # ("notes.md") which is itself a valid single-file-per-dir cluster
        # test. Assert basic invariants instead of exact grouping count.
        assert len(clusters) >= 1
        total_paths = sum(c.total for c in [c for c in clusters])
        # every path is accounted for
        seen = set()
        for c in clusters:
            seen.update(c.paths)
        assert seen == set(paths)

    def test_same_dir_and_shape_groups_together(self):
        paths = [
            "logs/log-00000001.txt",
            "logs/log-00000002.txt",
            "logs/log-00000003.txt",
        ]
        sampler = ClusterSampler()
        clusters = sampler.cluster(paths)
        assert len(clusters) == 1
        assert clusters[0].total == 3
        assert set(clusters[0].paths) == set(paths)

    def test_sample_is_capped(self):
        paths = [f"logs/log-{i:08d}.txt" for i in range(20)]
        sampler = ClusterSampler(sample_cap=5)
        clusters = sampler.cluster(paths)
        assert len(clusters) == 1
        assert clusters[0].total == 20
        assert len(clusters[0].sample) == 5

    def test_different_directories_are_different_clusters(self):
        paths = ["a/file.md", "b/file.md"]
        sampler = ClusterSampler()
        clusters = sampler.cluster(paths)
        assert len(clusters) == 2

    def test_hex_run_collapses_to_shape_class(self):
        paths = [
            "cache/deadbeefcafe.json",
            "cache/0123456789ab.json",
        ]
        sampler = ClusterSampler()
        clusters = sampler.cluster(paths)
        # both filenames collapse to the same hex-shape class -> one cluster
        assert len(clusters) == 1
        assert clusters[0].total == 2

    def test_to_dict_is_json_serializable(self):
        import json

        paths = ["docs/PRD.md"]
        sampler = ClusterSampler()
        dicts = sampler.cluster_to_dicts(paths)
        json.dumps(dicts)  # must not raise
        assert dicts[0]["total"] == 1
        assert dicts[0]["sample"] == ["docs/PRD.md"]

    def test_empty_input_yields_no_clusters(self):
        sampler = ClusterSampler()
        assert sampler.cluster([]) == []


# ---------------------------------------------------------------------------
# RuleReportBuilder
# ---------------------------------------------------------------------------


class TestRuleReportBuilder:
    def test_glob_is_verbatim(self):
        all_paths = ["docs/HANDOFF-1.md", "docs/HANDOFF-2.md"]
        rule = {"glob": "docs/HANDOFF-*.md", "lifetime": "temporary", "tier": "auto"}
        entry = RuleReportBuilder().build(rule, all_paths)
        assert entry.glob == "docs/HANDOFF-*.md"

    def test_matches_sample_and_total(self):
        all_paths = [f"docs/HANDOFF-{i}.md" for i in range(10)]
        rule = {"glob": "docs/HANDOFF-*.md", "lifetime": "temporary", "tier": "auto"}
        entry = RuleReportBuilder(sample_cap=3).build(rule, all_paths)
        assert entry.matched_total == 10
        assert len(entry.matched_sample) == 3

    def test_near_miss_boundary_detects_sibling_paths(self):
        # A too-narrow glob silently misses a sibling path under the same
        # parent directory — this is exactly the #45 boundary bug
        # (autoresearch/classic-*/ missing autoresearch/improve-*/).
        all_paths = [
            "autoresearch/classic-260710-1057/report.md",
            "autoresearch/improve-260710-1057/report.md",
        ]
        rule = {"glob": "autoresearch/classic-*/report.md", "lifetime": "temporary", "tier": "confirm"}
        entry = RuleReportBuilder().build(rule, all_paths)
        assert entry.matched_total == 1
        assert "autoresearch/improve-260710-1057/report.md" in entry.near_miss

    def test_no_near_miss_when_glob_covers_all_similar_paths(self):
        all_paths = [
            "autoresearch/classic-1/report.md",
            "autoresearch/classic-2/report.md",
        ]
        rule = {"glob": "autoresearch/*/report.md", "lifetime": "temporary", "tier": "confirm"}
        entry = RuleReportBuilder().build(rule, all_paths)
        assert entry.matched_total == 2
        assert entry.near_miss == []

    def test_lifetime_and_tier_carried_through(self):
        rule = {"glob": "x/*.md", "lifetime": "delete-once-served", "tier": "confirm"}
        entry = RuleReportBuilder().build(rule, ["x/a.md"])
        assert entry.lifetime == "delete-once-served"
        assert entry.tier == "confirm"

    def test_build_all_is_json_serializable(self):
        import json

        rules = [
            {"glob": "docs/HANDOFF-*.md", "lifetime": "temporary", "tier": "auto"},
        ]
        dicts = RuleReportBuilder().build_all(rules, ["docs/HANDOFF-1.md"])
        json.dumps(dicts)
        assert dicts[0]["matches"]["total"] == 1


# ---------------------------------------------------------------------------
# RuleQualityChecker
# ---------------------------------------------------------------------------


class TestRuleQualityCheckerClassNotPath:
    def test_convention_recurring_name_passes(self):
        checker = RuleQualityChecker()
        finding = checker.class_not_path("docs/HANDOFF-*.md")
        assert finding.passed is True

    def test_recurring_filename_glob_with_wildcard_passes(self):
        # "**/PRD.md" is structurally able to match PRD.md in any directory
        # -- the wildcard makes it a class, not a single-instance path, even
        # though the literal basename "PRD.md" is itself a fixed name.
        checker = RuleQualityChecker()
        finding = checker.class_not_path(
            "**/PRD.md", all_paths=["docs/PRD.md", "other/PRD.md"]
        )
        assert finding.passed is True

    def test_long_digit_run_fails(self):
        checker = RuleQualityChecker()
        finding = checker.class_not_path("logs/log-20260714153045.txt")
        assert finding.passed is False
        assert "digit" in finding.reason.lower()

    def test_hex_hash_fails(self):
        checker = RuleQualityChecker()
        finding = checker.class_not_path("cache/deadbeefcafe1234.bin")
        assert finding.passed is False
        assert "hash" in finding.reason.lower() or "hex" in finding.reason.lower()

    def test_one_current_match_is_fine_if_glob_has_wildcard(self):
        checker = RuleQualityChecker()
        finding = checker.class_not_path("docs/HANDOFF-*.md", all_paths=["docs/HANDOFF-1.md"])
        assert finding.passed is True

    def test_wildcard_free_glob_matching_only_one_path_ever_fails(self):
        checker = RuleQualityChecker()
        finding = checker.class_not_path(
            "docs/migration-report.md", all_paths=["docs/migration-report.md"]
        )
        assert finding.passed is False
        assert "generalization" in finding.reason.lower()

    def test_wildcard_free_glob_with_multiple_matches_passes(self):
        # a bare name matching >1 existing path is a recurring convention
        # even with no wildcard char (unusual but should not be flagged)
        checker = RuleQualityChecker()
        finding = checker.class_not_path(
            "PRD.md", all_paths=["a/PRD.md"]
        )
        # single match with a bare literal glob (no wildcard) still fails,
        # since "PRD.md" as an exact-path glob wouldn't even match "a/PRD.md"
        # (fnmatch requires the full string) -- so total matches = 0 -> not
        # flagged for "matches <=1 existing path" since it's 0 (never seen).
        # This asserts the checker doesn't crash and returns a finding.
        assert finding.check == "class_not_path"


class TestRuleQualityCheckerPreferNarrower:
    def test_narrower_glob_by_match_count_wins(self):
        checker = RuleQualityChecker()
        all_paths = [
            "autoresearch/classic-1/report.md",
            "autoresearch/classic-2/report.md",
            "autoresearch/improve-1/report.md",
        ]
        result = checker.prefer_narrower(
            "autoresearch/classic-*/report.md", "autoresearch/*/report.md", all_paths
        )
        assert result["narrower"] == "autoresearch/classic-*/report.md"
        assert result["match_counts"]["autoresearch/classic-*/report.md"] == 2
        assert result["match_counts"]["autoresearch/*/report.md"] == 3

    def test_tie_break_prefers_longer_pattern(self):
        checker = RuleQualityChecker()
        all_paths = ["a/b.md"]
        result = checker.prefer_narrower("a/*.md", "a/b.md", all_paths)
        # both match exactly 1 path -> tie -> longer raw pattern wins
        assert result["narrower"] == "a/b.md"

    def test_result_is_json_serializable(self):
        import json

        checker = RuleQualityChecker()
        result = checker.prefer_narrower("a/*.md", "a/*.txt", ["a/b.md"])
        json.dumps(result)
