"""
rulebook.py — lifecycle rulebook loader for os-doc-hygiene.

Stdlib-only. Loads the global rulebook (`plugins/os-doc-hygiene/rulebook.json`)
and an optional per-project override (`<repo-root>/.dochygiene-rules.json`),
both under the envelope `{"schema_version": 1, "rules": [...]}`. Merges
add-only (project rules are appended to, never replace, global rules) and
exposes a single query surface: given a repo-root-relative path (and whether
it is a directory), return `None` (unmatched) or the winning `RuleMatch`.

No knowledge of git, classification, or the report schema — this module only
resolves "which rule, if any, governs this path" (design.md Decision 1).
"""

from __future__ import annotations

import glob
import json
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional

_MIN_PYTHON = (3, 13)

_VALID_LIFETIMES = {"keep", "temporary", "delete-once-served"}

# Fields recognized by the schema (lifecycle-spec.md §2). Any field on a
# rule outside this set (e.g. the removed `propagate_ignore`) is rejected.
_KNOWN_FIELDS = {
    "glob",
    "lifetime",
    "extract",
    "served_when",
    "served_when_path",
    "retain_recent",
    "max_age_days",
    "confirm",
    "confirmed_by",
    "confirmed_on",
    "source",
    "note",
}

_DEFAULT_RETAIN_RECENT = 3
_DEFAULT_MAX_AGE_DAYS = 3

_SUPPORTED_SCHEMA_VERSIONS = {1}


class RulebookLoadError(Exception):
    """Hard-fail: unparseable JSON, unknown schema_version, or unsupported
    Python version for the mandated glob dialect."""


@dataclass(frozen=True)
class RuleMatch:
    """The winning rule for a queried path."""

    rule: dict
    level: str  # "file" | "directory"
    is_ignore: bool
    origin: str  # "project" | "global"


@dataclass
class _CompiledRule:
    raw: dict
    origin: str  # "project" | "global"
    level: str  # "file" | "directory"
    is_ignore: bool
    pattern_for_matching: str
    matcher: Any  # compiled regex (re.Pattern) — exact match on this level
    subtree_matcher: Optional[Any]  # directory rules only: matches paths
    # anywhere beneath the directory (files or nested dirs), used when a
    # directory-rule competes for precedence over a *file* path.
    define_order: int


def _check_python_version() -> None:
    if sys.version_info[:2] < _MIN_PYTHON:
        raise RulebookLoadError(
            "rulebook.py requires Python >= 3.13 for glob.translate("
            "recursive=True, include_hidden=True); running under "
            f"{sys.version_info[0]}.{sys.version_info[1]}."
        )


def _load_json_envelope(path: Path) -> dict:
    try:
        text = path.read_text(encoding="utf-8")
    except FileNotFoundError:
        return {"schema_version": 1, "rules": []}
    try:
        data = json.loads(text)
    except json.JSONDecodeError as exc:
        raise RulebookLoadError(f"{path}: unparseable JSON: {exc}") from exc

    if not isinstance(data, dict) or "schema_version" not in data:
        raise RulebookLoadError(
            f"{path}: missing or malformed envelope (expected "
            '{"schema_version": 1, "rules": [...]})'
        )

    schema_version = data.get("schema_version")
    if schema_version not in _SUPPORTED_SCHEMA_VERSIONS:
        raise RulebookLoadError(
            f"{path}: unrecognized schema_version {schema_version!r} "
            f"(supported: {sorted(_SUPPORTED_SCHEMA_VERSIONS)})"
        )

    rules = data.get("rules", [])
    if not isinstance(rules, list):
        raise RulebookLoadError(f"{path}: 'rules' must be a list")

    return data


def _is_directory_glob(pattern: str) -> bool:
    return pattern.endswith("/**") or pattern.endswith("/")


def _base_pattern_for_directory(pattern: str) -> str:
    if pattern.endswith("/**"):
        return pattern[: -len("/**")]
    if pattern.endswith("/"):
        return pattern[: -len("/")]
    return pattern


def _validate_and_compile_rule(
    raw: dict, origin: str, define_order: int, warnings: list
) -> Optional[_CompiledRule]:
    label = raw.get("glob", "<no glob>")

    unknown_fields = set(raw.keys()) - _KNOWN_FIELDS
    if unknown_fields:
        warnings.append(
            f"rule {label!r} ({origin}): unrecognized field(s) "
            f"{sorted(unknown_fields)} — rule skipped and marked inactive"
        )
        return None

    glob_pattern = raw.get("glob")
    if not glob_pattern or not isinstance(glob_pattern, str):
        warnings.append(
            f"rule {label!r} ({origin}): missing or invalid 'glob' field — "
            "rule skipped and marked inactive"
        )
        return None

    lifetime = raw.get("lifetime")
    is_ignore = lifetime is None
    if lifetime is not None and lifetime not in _VALID_LIFETIMES:
        warnings.append(
            f"rule {glob_pattern!r} ({origin}): invalid lifetime "
            f"{lifetime!r} — rule skipped and marked inactive"
        )
        return None

    if not raw.get("confirmed_by"):
        warnings.append(
            f"rule {glob_pattern!r} ({origin}): missing 'confirmed_by' — "
            "rule loaded but marked inactive, never contributes a match"
        )
        return None

    level = "directory" if _is_directory_glob(glob_pattern) else "file"
    match_pattern = (
        _base_pattern_for_directory(glob_pattern)
        if level == "directory"
        else glob_pattern
    )

    import re

    try:
        regex = glob.translate(
            match_pattern, recursive=True, include_hidden=True
        )
        matcher = re.compile(regex)
    except Exception as exc:  # pragma: no cover - defensive
        warnings.append(
            f"rule {glob_pattern!r} ({origin}): failed to compile glob: "
            f"{exc} — rule skipped and marked inactive"
        )
        return None

    subtree_matcher = None
    if level == "directory":
        # The un-stripped pattern (e.g. "notes/**") matches paths *beneath*
        # the directory too — needed so this directory-rule can compete for
        # precedence against file-rules on a file path (design.md Decision 1
        # two-axis precedence is path-based, not query-level-based: a file
        # path can be governed by a directory-rule that covers it).
        try:
            subtree_regex = glob.translate(
                glob_pattern, recursive=True, include_hidden=True
            )
            subtree_matcher = re.compile(subtree_regex)
        except Exception as exc:  # pragma: no cover - defensive
            warnings.append(
                f"rule {glob_pattern!r} ({origin}): failed to compile "
                f"subtree glob: {exc} — rule skipped and marked inactive"
            )
            return None

    resolved = dict(raw)
    if lifetime == "temporary":
        resolved.setdefault("retain_recent", _DEFAULT_RETAIN_RECENT)
        resolved.setdefault("max_age_days", _DEFAULT_MAX_AGE_DAYS)

    return _CompiledRule(
        raw=resolved,
        origin=origin,
        level=level,
        is_ignore=is_ignore,
        pattern_for_matching=match_pattern,
        matcher=matcher,
        subtree_matcher=subtree_matcher,
        define_order=define_order,
    )


# Precedence tiers, lowest wins first (spec: project file > project dir >
# global file > global dir).
_TIER = {
    ("project", "file"): 0,
    ("project", "directory"): 1,
    ("global", "file"): 2,
    ("global", "directory"): 3,
}


class Rulebook:
    """Merged, compiled rulebook exposing a single query surface."""

    def __init__(self, compiled_rules: list, warnings: list):
        self._rules = compiled_rules
        self.warnings = warnings

    def query(self, path: str, is_dir: bool = False) -> Optional[RuleMatch]:
        """Return the winning rule for *path*, or None if unmatched.

        Precedence is resolved across *all* rules that cover this path,
        regardless of whether the winning rule is a file-rule or a
        directory-rule: a directory-rule's subtree covers files beneath it,
        so it can compete for (and win, per its tier) precedence on a file
        path just as a file-rule can. The returned RuleMatch.level reflects
        the winning rule's own declared type, not the caller's *is_dir*.
        """
        norm_path = path.strip("/")

        candidates = []
        for cr in self._rules:
            if cr.level == "file":
                # A file-rule's pattern is matched exactly; it only ever
                # governs the exact path it names.
                if cr.matcher.fullmatch(norm_path):
                    candidates.append(cr)
            else:  # directory-rule
                if is_dir:
                    # Directory query: does this rule's own subtree glob
                    # match the directory path itself?
                    if cr.matcher.fullmatch(norm_path):
                        candidates.append(cr)
                else:
                    # File query: does this directory-rule's subtree cover
                    # this file path?
                    if cr.subtree_matcher.fullmatch(norm_path):
                        candidates.append(cr)

        if not candidates:
            return None

        def sort_key(cr: _CompiledRule):
            tier = _TIER[(cr.origin, cr.level)]
            # Longer pattern wins ties -> negate for ascending sort.
            # Last-defined wins ties -> negate define_order.
            return (tier, -len(cr.pattern_for_matching), -cr.define_order)

        winner = min(candidates, key=sort_key)
        return RuleMatch(
            rule=winner.raw,
            level=winner.level,
            is_ignore=winner.is_ignore,
            origin=winner.origin,
        )


def default_global_rulebook_path() -> Path:
    """The global rulebook path resolved relative to this script's own
    location: `<plugin-root>/rulebook.json` (this file lives in
    `<plugin-root>/scripts/`)."""
    return Path(__file__).resolve().parent.parent / "rulebook.json"


def load_rulebook(
    global_path: Optional[Path] = None, project_path: Optional[Path] = None
) -> Rulebook:
    """Load and merge the global rulebook with an optional project override.

    Hard-fails (raises RulebookLoadError) on unparseable JSON, an
    unrecognized schema_version, or an unsupported Python version.
    Skips and warns on a per-rule basis for invalid rules or rules missing
    `confirmed_by`.
    """
    _check_python_version()

    global_path = Path(global_path) if global_path is not None else default_global_rulebook_path()
    global_data = _load_json_envelope(global_path)

    project_data: dict
    if project_path is not None:
        project_path = Path(project_path)
        project_data = _load_json_envelope(project_path)
    else:
        project_data = {"schema_version": 1, "rules": []}

    warnings: list = []
    compiled: list = []
    define_order = 0

    for raw in global_data.get("rules", []):
        cr = _validate_and_compile_rule(raw, "global", define_order, warnings)
        define_order += 1
        if cr is not None:
            compiled.append(cr)

    for raw in project_data.get("rules", []):
        cr = _validate_and_compile_rule(raw, "project", define_order, warnings)
        define_order += 1
        if cr is not None:
            compiled.append(cr)

    return Rulebook(compiled_rules=compiled, warnings=warnings)
