#!/usr/bin/env python3
"""Rule checker for the RULEFOLDER governance. Exit code 1 on any violation.

Checks (all run when no flag is given):
  --fragments   every change fragment has valid front matter
  --parity      every GUI route in code is in FEATURE-REGISTRY.md and mapped to an API
                endpoint (or an explicit, reasoned exception); every API endpoint in code is
                registered and documented in DOC/API.md
  --changelog   CHANGELOG.md matches the fragments
  --staged      (pre-commit) if code is staged, a fragment must be staged too

Usage:
  python RULEFOLDER/tools/check_rules.py
  python RULEFOLDER/tools/check_rules.py --staged        # used by .githooks/pre-commit
"""
import argparse
import re
import subprocess
import sys

from _fragments import (
    ROOT, REGISTRY, API_DOC, ROUTES_DIR, TYPES, SCOPES, REQUIRED, ID_RE,
    load_fragments, as_list, read_team,
)
import build_changelog

ROUTE_RE = re.compile(r'@(\w+)_bp\.route\(\s*"([^"]+)"(?:\s*,\s*methods=\[([^\]]*)\])?')
METHOD_PATH_RE = re.compile(r"\b(GET|POST|PUT|PATCH|DELETE)\s+(/[^\s,;)`]*)")
CODE_PREFIXES = ("app/", "Dockerfile", "docker-compose.yml", "entrypoint.sh", "requirements.txt", "DOC/")

problems = []


def fail(msg):
    problems.append(msg)


def norm(path):
    """Normalise a route path so code, registry and docs compare equal.

    <int:channel_id> / <video_id> / {id} / {video_id}  ->  {}
    """
    p = re.sub(r"<[^>]+>", "{}", path)
    p = re.sub(r"\{[^}]*\}", "{}", p)
    p = p.rstrip("/") or "/"
    return p


def routes_in_code():
    """Return (gui: {path: (file, line)}, api: {path: (file, line)})."""
    gui, api = {}, {}
    for f in sorted(ROUTES_DIR.glob("*.py")):
        for n, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1):
            m = ROUTE_RE.search(line)
            if not m:
                continue
            bp, path = m.group(1), m.group(2)
            if bp == "api":
                api[norm("/api/v1" + path)] = (f.name, n)
            else:
                gui[norm(path)] = (f.name, n)
    return gui, api


def registry_rows():
    """Parse FEATURE-REGISTRY.md table rows -> list of dicts."""
    rows = []
    if not REGISTRY.exists():
        fail("FEATURE-REGISTRY.md is missing")
        return rows
    for line in REGISTRY.read_text(encoding="utf-8").splitlines():
        if not line.startswith("|"):
            continue
        cells = [c.strip() for c in line.strip().strip("|").split("|")]
        if len(cells) < 4 or cells[0] in ("Feature", "") or set(cells[0]) <= {"-", ":"}:
            continue
        feature, gui_cell, api_cell = cells[0], cells[1], cells[2]
        gui_paths = {norm(p) for _, p in METHOD_PATH_RE.findall(gui_cell)}
        api_paths = {norm(p) for _, p in METHOD_PATH_RE.findall(api_cell)}
        exception = api_cell.strip().lower().startswith("n/a")
        rows.append({
            "feature": feature, "gui": gui_paths, "api": api_paths,
            "exception": exception, "api_cell": api_cell,
        })
    return rows


def check_parity():
    gui_code, api_code = routes_in_code()
    rows = registry_rows()
    reg_gui = set().union(*(r["gui"] for r in rows)) if rows else set()
    reg_api = set().union(*(r["api"] for r in rows)) if rows else set()

    for path, (f, n) in sorted(gui_code.items()):
        if path not in reg_gui:
            fail(f"PARITY  unregistered GUI route {path}  ({f}:{n}) — add a row to FEATURE-REGISTRY.md")
    for path, (f, n) in sorted(api_code.items()):
        if path not in reg_api:
            fail(f"PARITY  unregistered API endpoint {path}  ({f}:{n}) — add it to FEATURE-REGISTRY.md")

    for r in rows:
        if r["gui"] and not r["api"] and not r["exception"]:
            fail(f"PARITY  feature '{r['feature']}' has a GUI but no API endpoint and no 'n/a: reason'")
        if r["exception"] and len(r["api_cell"]) < 12:
            fail(f"PARITY  feature '{r['feature']}': exception needs a reason ('n/a: why')")
        for p in r["api"]:
            if p not in api_code:
                fail(f"PARITY  feature '{r['feature']}' lists API {p} which does not exist in code")
        for p in r["gui"]:
            if p not in gui_code:
                fail(f"PARITY  feature '{r['feature']}' lists GUI {p} which does not exist in code")

    if API_DOC.exists():
        doc_paths = {norm(p) for p in re.findall(r"/api/v1/[A-Za-z0-9_\-/{}<>:]*", API_DOC.read_text(encoding="utf-8"))}
        for path, (f, n) in sorted(api_code.items()):
            if path not in doc_paths:
                fail(f"DOC     API endpoint {path} ({f}:{n}) is not documented in DOC/API.md")
    else:
        fail("DOC     DOC/API.md is missing")


def check_fragments():
    _, names = read_team()
    seen = set()
    for fr in load_fragments():
        m, p = fr["meta"], fr["path"].name
        if not m:
            fail(f"FRAG    {p}: no front matter")
            continue
        done = str(m.get("status")) == "done"
        for k in REQUIRED:
            if k == "security_impact" and not done:
                continue  # a colleague's in-progress fragment may still be empty here
            if k not in m or m[k] in ("", None, []):
                fail(f"FRAG    {p}: missing required field '{k}'")
        cid = str(m.get("id", ""))
        if not ID_RE.match(cid):
            fail(f"FRAG    {p}: bad id '{cid}'")
        if cid in seen:
            fail(f"FRAG    {p}: duplicate id {cid}")
        seen.add(cid)
        if cid and not p.startswith(cid):
            fail(f"FRAG    {p}: file name must start with its id {cid}")
        if m.get("type") not in TYPES:
            fail(f"FRAG    {p}: type must be one of {TYPES}")
        for s in as_list(m.get("scope")):
            if s not in SCOPES:
                fail(f"FRAG    {p}: scope '{s}' not in {SCOPES}")
        if names and m.get("author") not in names:
            fail(f"FRAG    {p}: author '{m.get('author')}' not in TEAM.md")
        if str(m.get("status")) == "done":
            if as_list(m.get("gui_routes")) and not as_list(m.get("api_endpoints")) and not m.get("parity_exception"):
                fail(f"FRAG    {p}: GUI routes changed but no api_endpoints and no parity_exception")
            if as_list(m.get("api_endpoints")) and m.get("api_doc_updated") is not True:
                fail(f"FRAG    {p}: api_endpoints listed but api_doc_updated is not 'yes'")
            body = fr["body"]
            for section in ("## What", "## Security review", "## Verification"):
                if section not in body:
                    fail(f"FRAG    {p}: missing section '{section}'")


def check_changelog():
    from _fragments import CHANGELOG
    text = build_changelog.render(load_fragments())
    current = CHANGELOG.read_text(encoding="utf-8") if CHANGELOG.exists() else ""
    if current != text:
        fail("LOG     CHANGELOG.md is stale — run: python RULEFOLDER/tools/build_changelog.py")


def check_staged():
    try:
        out = subprocess.check_output(["git", "diff", "--cached", "--name-only"], cwd=ROOT, text=True)
    except Exception as e:
        fail(f"STAGED  cannot read git index: {e}")
        return
    staged = [s.strip().replace("\\", "/") for s in out.splitlines() if s.strip()]
    code = [s for s in staged if s.startswith(CODE_PREFIXES)]
    frags = [s for s in staged if s.startswith("RULEFOLDER/changes/") and not s.split("/")[-1].startswith("_")]
    if code and not frags:
        fail("STAGED  code is staged but no change fragment is staged. "
             "Run: python RULEFOLDER/tools/new_change.py \"title\" --type ... --scope ...")
    if frags:
        ids = []
        for s in frags:
            m = re.match(r"(CHG-\d{3}|CHG-\d{8}-[A-Z]{2,3}-\d{2})", s.split("/")[-1])
            if m:
                ids.append(m.group(1))
        if ids:
            print("Staged change IDs:", ", ".join(ids), "— commit message must start with one of them.")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--fragments", action="store_true")
    ap.add_argument("--parity", action="store_true")
    ap.add_argument("--changelog", action="store_true")
    ap.add_argument("--staged", action="store_true")
    a = ap.parse_args()
    run_all = not (a.fragments or a.parity or a.changelog or a.staged)

    if a.fragments or run_all:
        check_fragments()
    if a.parity or run_all:
        check_parity()
    if a.changelog or run_all:
        check_changelog()
    if a.staged:
        check_staged()
        check_fragments()
        check_changelog()

    if problems:
        print(f"{len(problems)} rule violation(s):")
        for p in problems:
            print("  -", p)
        sys.exit(1)
    print("All rule checks passed.")


if __name__ == "__main__":
    main()
