"""
Tests for scripts/rulebook.py

Covers tasks 1.1-1.2 of the lifecycle-aware-doc-hygiene change:
- envelope parsing, hard-fail on unparseable JSON / unknown schema_version
- skip-and-warn on invalid rules and rules missing confirmed_by
- glob compilation via glob.translate(recursive=True, include_hidden=True),
  with a Python >=3.13 version check
- add-only merge + two-axis precedence (project file > project dir >
  global file > global dir; ties by longest glob, then last-defined)
- keep-shadowing neutralization
- unmatched path -> None
- file-level vs directory-level match distinction
- IGNORE-sentinel rules (no lifetime field -> zero-emission prune)

sys.path is patched here (no shared conftest) so the test file is
self-contained, as required by the file-ownership constraints.
"""

from __future__ import annotations

import json
import sys
from pathlib import Path

import pytest

_SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
if str(_SCRIPTS_DIR) not in sys.path:
    sys.path.insert(0, str(_SCRIPTS_DIR))

from rulebook import (  # noqa: E402
    Rulebook,
    RulebookLoadError,
    load_rulebook,
)


def _write(tmp_path: Path, name: str, payload: dict) -> Path:
    p = tmp_path / name
    p.write_text(json.dumps(payload), encoding="utf-8")
    return p


def _envelope(*rules: dict) -> dict:
    return {"schema_version": 1, "rules": list(rules)}


def _rule(glob, lifetime="keep", confirmed_by="human", **extra) -> dict:
    r = {
        "glob": glob,
        "confirmed_by": confirmed_by,
        "confirmed_on": "2026-07-14",
        "source": "test",
    }
    if lifetime is not None:
        r["lifetime"] = lifetime
    r.update(extra)
    return r


# ===========================================================================
# Task 1.1 — envelope parsing / hard-fail / skip-and-warn / glob compilation
# ===========================================================================

class TestEnvelopeParsing:
    def test_loads_valid_global_only(self, tmp_path):
        global_path = _write(
            tmp_path, "rulebook.json", _envelope(_rule("foo.md"))
        )
        rb = load_rulebook(global_path=global_path, project_path=None)
        assert isinstance(rb, Rulebook)

    def test_missing_project_file_is_fine(self, tmp_path):
        global_path = _write(
            tmp_path, "rulebook.json", _envelope(_rule("foo.md"))
        )
        project_path = tmp_path / "does-not-exist.json"
        rb = load_rulebook(global_path=global_path, project_path=project_path)
        assert isinstance(rb, Rulebook)

    def test_unparseable_json_hard_fails(self, tmp_path):
        global_path = tmp_path / "rulebook.json"
        global_path.write_text("{not valid json", encoding="utf-8")
        with pytest.raises(RulebookLoadError):
            load_rulebook(global_path=global_path, project_path=None)

    def test_unknown_schema_version_hard_fails(self, tmp_path):
        global_path = _write(
            tmp_path,
            "rulebook.json",
            {"schema_version": 99, "rules": [_rule("foo.md")]},
        )
        with pytest.raises(RulebookLoadError):
            load_rulebook(global_path=global_path, project_path=None)

    def test_project_file_unparseable_json_hard_fails(self, tmp_path):
        global_path = _write(
            tmp_path, "rulebook.json", _envelope(_rule("foo.md"))
        )
        project_path = tmp_path / "proj.json"
        project_path.write_text("nope not json", encoding="utf-8")
        with pytest.raises(RulebookLoadError):
            load_rulebook(global_path=global_path, project_path=project_path)


class TestSkipAndWarnValidation:
    def test_rule_missing_confirmed_by_is_skipped_not_fatal(self, tmp_path):
        bad = {
            "glob": "unconfirmed.md",
            "lifetime": "keep",
            "confirmed_on": "2026-07-14",
            "source": "test",
        }
        assert "confirmed_by" not in bad
        good_rules = [_rule(f"good-{i}.md") for i in range(9)]
        global_path = _write(
            tmp_path, "rulebook.json", _envelope(bad, *good_rules)
        )
        rb = load_rulebook(global_path=global_path, project_path=None)

        # the invalid rule never contributes a match
        assert rb.query("unconfirmed.md", is_dir=False) is None
        # a warning is surfaced
        assert any("confirmed_by" in w for w in rb.warnings)
        # the other nine rules still function
        for i in range(9):
            match = rb.query(f"good-{i}.md", is_dir=False)
            assert match is not None

    def test_invalid_lifetime_value_is_skipped(self, tmp_path):
        bad = _rule("bad.md", lifetime="not-a-real-lifetime")
        global_path = _write(tmp_path, "rulebook.json", _envelope(bad))
        rb = load_rulebook(global_path=global_path, project_path=None)
        assert rb.query("bad.md", is_dir=False) is None
        assert rb.warnings

    def test_rule_missing_glob_is_skipped(self, tmp_path):
        bad = {
            "lifetime": "keep",
            "confirmed_by": "human",
            "confirmed_on": "2026-07-14",
            "source": "test",
        }
        global_path = _write(tmp_path, "rulebook.json", _envelope(bad))
        rb = load_rulebook(global_path=global_path, project_path=None)
        assert rb.warnings

    def test_propagate_ignore_field_is_rejected(self, tmp_path):
        bad = _rule("bad.md", propagate_ignore=True)
        global_path = _write(tmp_path, "rulebook.json", _envelope(bad))
        rb = load_rulebook(global_path=global_path, project_path=None)
        assert rb.query("bad.md", is_dir=False) is None
        assert any("propagate_ignore" in w for w in rb.warnings)

    def test_defaults_retain_recent_and_max_age_days(self, tmp_path):
        rule = _rule("stuff/**", lifetime="temporary")
        global_path = _write(tmp_path, "rulebook.json", _envelope(rule))
        rb = load_rulebook(global_path=global_path, project_path=None)
        match = rb.query("stuff", is_dir=True)
        assert match is not None
        assert match.rule["retain_recent"] == 3
        assert match.rule["max_age_days"] == 3

    def test_overridden_retain_recent_and_max_age_days(self, tmp_path):
        rule = _rule(
            "stuff/**", lifetime="temporary", retain_recent=5, max_age_days=10
        )
        global_path = _write(tmp_path, "rulebook.json", _envelope(rule))
        rb = load_rulebook(global_path=global_path, project_path=None)
        match = rb.query("stuff", is_dir=True)
        assert match.rule["retain_recent"] == 5
        assert match.rule["max_age_days"] == 10


class TestGlobCompilationVersionCheck:
    def test_hard_fails_clearly_below_python_313(self, tmp_path, monkeypatch):
        import rulebook as rb_module

        monkeypatch.setattr(rb_module.sys, "version_info", (3, 12, 0))
        global_path = _write(
            tmp_path, "rulebook.json", _envelope(_rule("foo.md"))
        )
        with pytest.raises(RulebookLoadError, match=r"(?i)python.*3\.13"):
            load_rulebook(global_path=global_path, project_path=None)

    def test_compiles_recursive_double_star(self, tmp_path):
        rule = _rule("autoresearch/*/**", lifetime="temporary")
        global_path = _write(tmp_path, "rulebook.json", _envelope(rule))
        rb = load_rulebook(global_path=global_path, project_path=None)
        match = rb.query("autoresearch/run-2026-07-01", is_dir=True)
        assert match is not None
        assert match.level == "directory"

    def test_matches_hidden_dotfile_paths(self, tmp_path):
        rule = _rule(".dochygiene/**")
        global_path = _write(tmp_path, "rulebook.json", _envelope(rule))
        rb = load_rulebook(global_path=global_path, project_path=None)
        match = rb.query(".dochygiene", is_dir=True)
        assert match is not None


# ===========================================================================
# Task 1.2 — add-only merge, two-axis precedence, keep-shadowing,
# unmatched -> None, file-vs-directory distinction, IGNORE sentinels
# ===========================================================================

class TestPrecedenceAndMerge:
    def test_unmatched_path_returns_none(self, tmp_path):
        global_path = _write(
            tmp_path, "rulebook.json", _envelope(_rule("foo.md"))
        )
        rb = load_rulebook(global_path=global_path, project_path=None)
        assert rb.query("totally/unrelated/path.md", is_dir=False) is None

    def test_file_level_vs_directory_level_distinction(self, tmp_path):
        rules = [
            _rule("HANDOFF-2026-07-01.md", lifetime="delete-once-served"),
            _rule("autoresearch/*/**", lifetime="temporary"),
        ]
        global_path = _write(tmp_path, "rulebook.json", _envelope(*rules))
        rb = load_rulebook(global_path=global_path, project_path=None)

        file_match = rb.query("HANDOFF-2026-07-01.md", is_dir=False)
        assert file_match.level == "file"

        dir_match = rb.query("autoresearch/run-1", is_dir=True)
        assert dir_match.level == "directory"

    def test_project_file_rule_outranks_global_directory_rule(self, tmp_path):
        global_path = _write(
            tmp_path,
            "rulebook.json",
            _envelope(_rule("notes/**", lifetime="temporary")),
        )
        project_path = _write(
            tmp_path,
            "proj.json",
            _envelope(_rule("notes/keep-me.md", lifetime="keep")),
        )
        rb = load_rulebook(global_path=global_path, project_path=project_path)
        match = rb.query("notes/keep-me.md", is_dir=False)
        assert match is not None
        assert match.rule["lifetime"] == "keep"
        assert match.level == "file"

    def test_project_directory_rule_outranks_global_file_rule(self, tmp_path):
        # Same file path matched by BOTH a global file-rule and a project
        # directory-rule whose subtree covers it; project-dir (tier 1)
        # beats global-file (tier 2) per the two-axis precedence order.
        global_path = _write(
            tmp_path,
            "rulebook.json",
            _envelope(_rule("notes/x.md", lifetime="delete-once-served")),
        )
        project_path = _write(
            tmp_path,
            "proj.json",
            _envelope(_rule("notes/**", lifetime="temporary")),
        )
        rb = load_rulebook(global_path=global_path, project_path=project_path)
        match = rb.query("notes/x.md", is_dir=False)
        assert match is not None
        assert match.rule["lifetime"] == "temporary"
        assert match.origin == "project"
        assert match.level == "directory"

    def test_project_file_rule_outranks_matching_global_directory_rule(
        self, tmp_path
    ):
        # A project file-rule and a global directory-rule both cover the
        # same file path; project-file (tier 0) must win over
        # global-directory (tier 3) even though the directory-rule's
        # subtree also matches.
        global_path = _write(
            tmp_path,
            "rulebook.json",
            _envelope(_rule("notes/**", lifetime="temporary")),
        )
        project_path = _write(
            tmp_path,
            "proj.json",
            _envelope(_rule("notes/keep-me.md", lifetime="keep")),
        )
        rb = load_rulebook(global_path=global_path, project_path=project_path)
        match = rb.query("notes/keep-me.md", is_dir=False)
        assert match is not None
        assert match.rule["lifetime"] == "keep"
        assert match.origin == "project"
        assert match.level == "file"

    def test_ties_broken_by_longest_glob_then_last_defined(self, tmp_path):
        # Both directory-rules' subtrees genuinely cover "a/b/c.md" (real
        # tie within the same (global, directory) tier) — the longer
        # pattern "a/b/**" must win over "a/**".
        rules = [
            _rule("a/**", lifetime="temporary", note="short"),
            _rule("a/b/**", lifetime="keep", note="longer"),
        ]
        global_path = _write(tmp_path, "rulebook.json", _envelope(*rules))
        rb = load_rulebook(global_path=global_path, project_path=None)
        match = rb.query("a/b/c.md", is_dir=False)
        assert match is not None
        assert match.rule["note"] == "longer"

    def test_ties_equal_length_last_defined_wins(self, tmp_path):
        rules = [
            _rule("dup/**", lifetime="temporary", note="first"),
            _rule("dup/**", lifetime="keep", note="second"),
        ]
        global_path = _write(tmp_path, "rulebook.json", _envelope(*rules))
        rb = load_rulebook(global_path=global_path, project_path=None)
        match = rb.query("dup", is_dir=True)
        assert match.rule["note"] == "second"

    def test_add_only_merge_never_deletes_global_rules(self, tmp_path):
        global_path = _write(
            tmp_path,
            "rulebook.json",
            _envelope(_rule("a.md"), _rule("b.md")),
        )
        project_path = _write(
            tmp_path, "proj.json", _envelope(_rule("c.md"))
        )
        rb = load_rulebook(global_path=global_path, project_path=project_path)
        assert rb.query("a.md", is_dir=False) is not None
        assert rb.query("b.md", is_dir=False) is not None
        assert rb.query("c.md", is_dir=False) is not None

    def test_keep_shadowing_neutralizes_global_delete_rule(self, tmp_path):
        global_path = _write(
            tmp_path,
            "rulebook.json",
            _envelope(_rule("autoresearch/*/**", lifetime="temporary")),
        )
        project_path = _write(
            tmp_path,
            "proj.json",
            _envelope(_rule("autoresearch/special/**", lifetime="keep")),
        )
        rb = load_rulebook(global_path=global_path, project_path=project_path)
        shadowed = rb.query("autoresearch/special", is_dir=True)
        assert shadowed.rule["lifetime"] == "keep"
        # an unrelated run dir under the same global rule is still temporary
        other = rb.query("autoresearch/other-run", is_dir=True)
        assert other.rule["lifetime"] == "temporary"


class TestIgnoreSentinel:
    def test_ignore_rule_has_no_lifetime_and_is_flagged(self, tmp_path):
        ignore_rule = _rule("graphify-out/**", lifetime=None)
        global_path = _write(tmp_path, "rulebook.json", _envelope(ignore_rule))
        rb = load_rulebook(global_path=global_path, project_path=None)
        match = rb.query("graphify-out", is_dir=True)
        assert match is not None
        assert match.is_ignore is True
        assert "lifetime" not in match.rule or match.rule.get("lifetime") is None

    def test_ignore_rule_distinct_from_keep(self, tmp_path):
        rules = [
            _rule("graphify-out/**", lifetime=None),
            _rule(".dochygiene/**", lifetime="keep"),
        ]
        global_path = _write(tmp_path, "rulebook.json", _envelope(*rules))
        rb = load_rulebook(global_path=global_path, project_path=None)
        ignore_match = rb.query("graphify-out", is_dir=True)
        keep_match = rb.query(".dochygiene", is_dir=True)
        assert ignore_match.is_ignore is True
        assert keep_match.is_ignore is False
        assert keep_match.rule["lifetime"] == "keep"
