#!/usr/bin/env python3
"""Validate Blacksite documentation authority coverage and lifecycle banners."""

from __future__ import annotations

import argparse
import fnmatch
import json
import re
import subprocess
import sys
import tomllib
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any


AUTHORITY_PATH = Path("docs/authority.toml")
BANNER_MARKERS = {
    "active-plan": "**active plan",
    "evidence": "**evidence record",
    "historical": "**historical",
    "superseded": "**superseded",
}
LINK_RE = re.compile(r"\[[^\]]+\]\(([^)]+)\)")


@dataclass(frozen=True)
class Rule:
    id: str
    classification: str
    priority: int
    path: str | None = None
    prefix: str | None = None
    role: str | None = None
    replacement: str | None = None
    topics: tuple[str, ...] = ()

    def matches(self, candidate: str) -> bool:
        if self.path is not None:
            return candidate == self.path
        if self.prefix is not None:
            return candidate.startswith(self.prefix)
        return False


def repository_root() -> Path:
    current = Path(__file__).resolve()
    for parent in current.parents:
        if (parent / AUTHORITY_PATH).is_file():
            return parent
    raise RuntimeError(f"cannot locate repository root containing {AUTHORITY_PATH}")


def load_authority(root: Path) -> tuple[dict[str, Any], list[Rule]]:
    raw = tomllib.loads((root / AUTHORITY_PATH).read_text(encoding="utf-8"))
    rules: list[Rule] = []
    for item in raw.get("rules", []):
        selectors = [name for name in ("path", "prefix") if item.get(name) is not None]
        if len(selectors) != 1:
            raise ValueError(f"rule {item.get('id', '<unknown>')} must set exactly one path or prefix")
        rules.append(
            Rule(
                id=item["id"],
                classification=item["classification"],
                priority=int(item.get("priority", 0)),
                path=item.get("path"),
                prefix=item.get("prefix"),
                role=item.get("role"),
                replacement=item.get("replacement"),
                topics=tuple(item.get("topics", [])),
            )
        )
    return raw, rules


def discover_documents(root: Path, authority: dict[str, Any]) -> list[str]:
    discovery = authority.get("discovery", {})
    found: set[str] = set()
    for relative in discovery.get("paths", []):
        path = root / relative
        if path.is_file() and path.suffix == ".md":
            found.add(path.relative_to(root).as_posix())
    for relative in discovery.get("trees", []):
        tree = root / relative
        if tree.is_dir():
            found.update(path.relative_to(root).as_posix() for path in tree.rglob("*.md"))
    return sorted(found)


def resolve_rule(path: str, rules: list[Rule]) -> tuple[Rule | None, str | None]:
    matches = [rule for rule in rules if rule.matches(path)]
    if not matches:
        return None, None
    highest = max(rule.priority for rule in matches)
    winners = [rule for rule in matches if rule.priority == highest]
    classifications = {rule.classification for rule in winners}
    if len(classifications) != 1:
        names = ", ".join(rule.id for rule in winners)
        return None, f"equal-priority rules disagree: {names}"
    winners.sort(key=lambda rule: (rule.path is not None, len(rule.path or rule.prefix or "")), reverse=True)
    return winners[0], None


def normalized_link_target(root: Path, document: Path, raw_target: str) -> str | None:
    target = raw_target.split("#", 1)[0].strip().strip("<>")
    if not target or "://" in target or target.startswith("mailto:"):
        return None
    resolved = (document.parent / target).resolve()
    try:
        return resolved.relative_to(root.resolve()).as_posix()
    except ValueError:
        return None


def path_matches(path: str, pattern: str) -> bool:
    if pattern.endswith("/**"):
        return path.startswith(pattern[:-2])
    return fnmatch.fnmatchcase(path, pattern)


def validate_links(root: Path, relative: str) -> list[str]:
    path = root / relative
    text = path.read_text(encoding="utf-8")
    errors: list[str] = []
    for raw_target in LINK_RE.findall(text):
        target = raw_target.split("#", 1)[0].strip().strip("<>")
        if not target or "://" in target or target.startswith(("mailto:", "#")):
            continue
        normalized = normalized_link_target(root, path, raw_target)
        if normalized is None:
            errors.append(f"link escapes the repository: {raw_target}")
            continue
        if not (root / normalized).exists():
            errors.append(f"broken relative link: {raw_target}")
    return errors


def changed_documents(root: Path) -> set[str]:
    changed: set[str] = set()
    commands = [
        ["git", "diff", "--name-only", "--relative", "HEAD"],
        ["git", "ls-files", "--others", "--exclude-standard"],
    ]
    for command in commands:
        result = subprocess.run(command, cwd=root, check=False, capture_output=True, text=True)
        if result.returncode != 0:
            raise RuntimeError(f"failed to query changed files: {' '.join(command)}")
        changed.update(line.strip() for line in result.stdout.splitlines() if line.strip())
    return changed


def validate_banner(root: Path, relative: str, rule: Rule) -> list[str]:
    if rule.classification == "current":
        return []
    path = root / relative
    text = path.read_text(encoding="utf-8")
    head = "\n".join(text.splitlines()[:180])
    lowered = head.lower()
    marker = BANNER_MARKERS[rule.classification]
    if marker not in lowered:
        # ADR 0019 predates Markdown heading style but has an explicit first-line status.
        if not (rule.classification == "superseded" and lowered.startswith("status: superseded")):
            return [f"missing {rule.classification} banner near the start of the document"]

    if rule.classification not in {"evidence", "historical", "superseded"}:
        return []
    links = {
        normalized
        for target in LINK_RE.findall(head)
        if (normalized := normalized_link_target(root, path, target)) is not None
    }
    if not links:
        return ["lifecycle banner must link to current canonical guidance"]
    if rule.replacement and rule.replacement not in links:
        return [f"lifecycle banner must link to configured replacement {rule.replacement}"]
    return []


def audit(root: Path, *, changed_only: bool = False, topic: str | None = None) -> dict[str, Any]:
    authority, rules = load_authority(root)
    allowed = set(authority.get("classifications", []))
    allowed_roles = set(authority.get("roles", []))
    errors: list[str] = []
    warnings: list[str] = []

    if authority.get("version") != 1:
        errors.append("docs/authority.toml: unsupported or missing version")
    if allowed != {"current", "active-plan", "evidence", "historical", "superseded"}:
        errors.append("docs/authority.toml: classifications must be the five workflow classes")

    for rule in rules:
        if rule.classification not in allowed:
            errors.append(f"rule {rule.id}: unknown classification {rule.classification}")
        if not rule.role or rule.role not in allowed_roles:
            errors.append(f"rule {rule.id}: unknown or missing role {rule.role}")
        if rule.path and not (root / rule.path).is_file():
            errors.append(f"rule {rule.id}: exact path does not exist: {rule.path}")
        if rule.replacement and not (root / rule.replacement).is_file():
            errors.append(f"rule {rule.id}: replacement does not exist: {rule.replacement}")

    counts: Counter[str] = Counter()
    documents: list[dict[str, str]] = []
    resolved: list[tuple[str, Rule]] = []
    for relative in discover_documents(root, authority):
        rule, conflict = resolve_rule(relative, rules)
        if conflict:
            errors.append(f"{relative}: {conflict}")
            continue
        if rule is None:
            errors.append(f"{relative}: unclassified")
            continue
        resolved.append((relative, rule))
        counts[rule.classification] += 1
        documents.append(
            {
                "path": relative,
                "classification": rule.classification,
                "rule": rule.id,
                "role": rule.role or "",
                "topics": ",".join(rule.topics),
            }
        )

    canonical_owners: dict[str, list[str]] = {}
    for relative, rule in resolved:
        if rule.role == "canonical" and rule.classification == "current":
            for owned_topic in rule.topics:
                canonical_owners.setdefault(owned_topic, []).append(relative)
    for owned_topic, owners in sorted(canonical_owners.items()):
        if len(owners) > 1:
            errors.append(f"topic {owned_topic}: multiple canonical owners: {', '.join(owners)}")

    selected = resolved
    if changed_only:
        changed = changed_documents(root)
        if AUTHORITY_PATH.as_posix() not in changed:
            selected = [(path, rule) for path, rule in selected if path in changed]
    if topic:
        selected = [(path, rule) for path, rule in selected if topic in rule.topics]
        if not selected:
            errors.append(f"topic {topic}: no classified documents")

    stale_terms = authority.get("stale_terms", [])
    for relative, rule in selected:
        for error in validate_banner(root, relative, rule):
            errors.append(f"{relative}: {error}")
        for error in validate_links(root, relative):
            errors.append(f"{relative}: {error}")

        if rule.classification != "current":
            continue
        text = (root / relative).read_text(encoding="utf-8")
        lowered = text.lower()
        for stale in stale_terms:
            if any(path_matches(relative, allowed_path) for allowed_path in stale.get("allowed_paths", [])):
                continue
            pattern = stale["pattern"]
            if pattern.lower() in lowered:
                errors.append(
                    f"{relative}: stale term {pattern!r}; use {stale.get('replacement', 'current terminology')}"
                )
        if rule.role == "canonical":
            if re.search(r"(?im)^\s*- \[ \]", text):
                warnings.append(f"{relative}: unchecked checklist in canonical documentation; review status semantics")
            if re.search(r"(?i)\bnot implemented\b|\((?:still\s+)?planned\)", text):
                warnings.append(f"{relative}: possible future-status language in canonical documentation; review semantically")

    return {
        "ok": not errors,
        "authority": AUTHORITY_PATH.as_posix(),
        "documents": documents,
        "counts": dict(sorted(counts.items())),
        "errors": errors,
        "warnings": warnings,
        "selected": len(selected),
    }


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--json", action="store_true", help="emit the complete machine-readable report")
    parser.add_argument("--list", action="store_true", help="list every classified document")
    parser.add_argument("--changed", action="store_true", help="check lifecycle, links, and terms only in changed documents")
    parser.add_argument("--topic", help="check lifecycle, links, and terms only for one configured topic")
    args = parser.parse_args()

    try:
        result = audit(repository_root(), changed_only=args.changed, topic=args.topic)
    except (OSError, RuntimeError, ValueError, tomllib.TOMLDecodeError) as error:
        print(f"FAIL docs-authority — {error}", file=sys.stderr)
        return 1

    if args.json:
        print(json.dumps(result, indent=2, sort_keys=True))
    else:
        status = "PASS" if result["ok"] else "FAIL"
        counts = ", ".join(f"{name}={count}" for name, count in result["counts"].items())
        print(
            f"{status} docs-authority — {len(result['documents'])} classified, "
            f"{result['selected']} checked — {counts}"
        )
        if args.list:
            for document in result["documents"]:
                print(f"{document['classification']:11} {document['path']} ({document['rule']})")
        for error in result["errors"]:
            print(f"ERROR {error}", file=sys.stderr)
        for warning in result["warnings"]:
            print(f"WARN {warning}", file=sys.stderr)
    return 0 if result["ok"] else 1


if __name__ == "__main__":
    raise SystemExit(main())
