#!/usr/bin/env python3
"""check_routing.py — keep the routing VIEWS in sync with routing.json.

routing.json is the canonical route manifest. SKILL.md ("Where to Write"),
references/routing-map.md, and references/api-reference.md are hand-maintained
human views of it, guarded until now only by a prose "routing.json wins"
convention. This check makes that convention mechanically true:

  CHECK A (validity, all three docs): every concrete vault path mentioned in the
          docs must match some route OR retired pattern in routing.json. Catches
          a doc pointing at a path the manifest does not define (typo, stale row).

  CHECK B (coverage, routing-map.md): every route in routing.json must be named
          by at least one path in routing-map.md, which claims to be the complete
          canonical map. Catches a route added to the manifest but never documented.

Exit: 0 = in sync, 1 = drift found, 2 = could not read inputs. No network, no vault.
Run it in CI / from the eval harness; it is pure local file parsing.
"""

from __future__ import annotations

import re
import sys
from pathlib import Path

for _stream in (sys.stdout, sys.stderr):
    try:
        _stream.reconfigure(encoding="utf-8", errors="replace")  # type: ignore[union-attr]
    except Exception:
        pass

SCRIPT_DIR = Path(__file__).resolve().parent
SKILL_DIR = SCRIPT_DIR.parent
ROUTING_JSON = SCRIPT_DIR / "routing.json"
DOCS = {
    "SKILL.md": SKILL_DIR / "SKILL.md",
    "routing-map.md": SKILL_DIR / "references" / "routing-map.md",
    "api-reference.md": SKILL_DIR / "references" / "api-reference.md",
}
CANONICAL_MAP = "routing-map.md"

ROOTS = ("_agent", "inbox", "journal", "projects", "areas", "resources", "decisions")
FILE_SUFFIXES = (".md", ".lock", ".json")

# Order matters: substitute the most specific placeholder first.
DATE_SUBS = [
    ("YYYY-MM-DD", "2026-01-02"),
    ("YYYY-Www", "2026-W01"),
    ("YYYY-MM", "2026-01"),
    ("YYYY-Qn", "2026-Q1"),
    ("HHMM", "0900"),
    ("YYYY", "2026"),
]

# Named placeholders whose route constrains the value to a fixed vocabulary, so a
# generic "sample" would not match the pattern.
NAMED_SUBS = {
    "<lifecycle>": "active",
    "<domain>": "business",
}


def load_routing():
    import json
    data = json.loads(ROUTING_JSON.read_text(encoding="utf-8"))
    routes = [(r["id"], re.compile(r["pattern"]), stem(r["pattern"])) for r in data.get("routes", [])]
    retired = [re.compile(r["pattern"]) for r in data.get("retired", [])]
    return routes, retired


def stem(pattern: str) -> str:
    """Literal directory prefix of a regex pattern, e.g. ^projects/active/[^/]+\\.md$ -> projects/active/."""
    body = pattern[1:] if pattern.startswith("^") else pattern
    literal = []
    for ch in body:
        if ch in "\\([{+*?.|^$":
            break
        literal.append(ch)
    text = "".join(literal)
    return text[: text.rfind("/") + 1] if "/" in text else ""


def strip_fenced_blocks(text: str) -> str:
    out, in_fence = [], False
    for line in text.splitlines():
        if line.lstrip().startswith("```"):
            in_fence = not in_fence
            continue
        if not in_fence:
            out.append(line)
    return "\n".join(out)


def expand_braces(token: str) -> list[str]:
    """Expand a single brace group: a/{x,y}/b -> [a/x/b, a/y/b]. Recurses for nested groups."""
    match = re.search(r"\{([^{}]*)\}", token)
    if not match:
        return [token]
    pre, post = token[: match.start()], token[match.end():]
    results = []
    for part in match.group(1).split(","):
        results.extend(expand_braces(pre + part.strip() + post))
    return results


def concretize(token: str) -> str:
    for placeholder, sample in DATE_SUBS:
        token = token.replace(placeholder, sample)
    for placeholder, sample in NAMED_SUBS.items():
        token = token.replace(placeholder, sample)
    return re.sub(r"<[^>]+>", "sample", token)


def looks_like_path(token: str) -> bool:
    if " " in token or "::" in token or token.count("`"):
        return False
    # README signposts can live under any folder, including placeholder folders.
    if token == "README.md" or token.endswith("/README.md"):
        return True
    # Otherwise must sit under a known vault root directory (root + "/"), which
    # excludes shorthand like `inbox.md` and non-paths like `application/vnd...`.
    if not any(token.startswith(root + "/") for root in ROOTS):
        return False
    return token.endswith("/") or token.endswith(FILE_SUFFIXES) or "<" in token or "{" in token


def extract_tokens(text: str) -> set[str]:
    text = strip_fenced_blocks(text)
    tokens: set[str] = set()
    for raw in re.findall(r"`([^`]+)`", text):
        raw = raw.strip()
        for piece in expand_braces(raw):
            if looks_like_path(piece):
                tokens.add(piece)
    return tokens


def classify(tokens: set[str]):
    files, dirs = set(), set()
    for token in tokens:
        if token.endswith("/"):
            dirs.add(token)
        else:
            files.add(concretize(token))
    return files, dirs


def main() -> int:
    try:
        routes, retired = load_routing()
    except Exception as exc:
        print(f"check-routing: cannot read routing.json ({exc})", file=sys.stderr)
        return 2

    problems: list[str] = []
    doc_tokens: dict[str, tuple[set[str], set[str]]] = {}
    for name, path in DOCS.items():
        try:
            files, dirs = classify(extract_tokens(path.read_text(encoding="utf-8")))
        except Exception as exc:
            print(f"check-routing: cannot read {name} ({exc})", file=sys.stderr)
            return 2
        doc_tokens[name] = (files, dirs)

    route_stems = {st for _, _, st in routes if st}

    # CHECK A — every documented path is valid under the manifest.
    for name, (files, dirs) in doc_tokens.items():
        for f in sorted(files):
            if not (any(rx.match(f) for _, rx, _ in routes) or any(rx.match(f) for rx in retired)):
                problems.append(f"[A] {name}: path `{f}` matches no route or retired pattern in routing.json")
        for d in sorted(dirs):
            ok = (any(rx.match(d) for rx in retired)
                  or any(st == d or st.startswith(d) or d.startswith(st) for st in route_stems))
            if not ok:
                problems.append(f"[A] {name}: folder `{d}` matches no route stem or retired pattern")

    # CHECK B — every route appears in the canonical map.
    map_files, map_dirs = doc_tokens[CANONICAL_MAP]
    for rid, rx, st in routes:
        covered = any(rx.match(f) for f in map_files) or (st and st in map_dirs)
        if not covered:
            problems.append(f"[B] route '{rid}' ({rx.pattern}) is not documented in {CANONICAL_MAP}")

    if problems:
        print(f"check-routing: {len(problems)} drift issue(s) found\n")
        for p in problems:
            print(f"  - {p}")
        return 1
    print(f"check-routing: in sync — {len(routes)} routes documented and all doc paths are valid.")
    return 0


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