#!/usr/bin/env python3
"""Advisory lint: profile-scope hazard patterns on the lines a change adds.

One Hermes process may serve many profiles (multiplex gateway, Desktop/dashboard ``serve``), and
``os.environ`` / module globals hold only the LAUNCH profile's values. Every pattern in
``scripts/ci/profile_scope_patterns.json`` is a call-site shape that turned out to be
profile-sensitive at least once — a child env built from ``os.environ``, a raw ``os.getenv`` of a
platform credential, an RPC decorator that binds the home but not the secret scope, a bare PID
liveness check. The invariant itself is in the root ``AGENTS.md`` (§ Code Shape Rules).

Advisory by construction: it prints ``file:line  <id>/<class>  why`` for every hit and ALWAYS
exits 0, because most patterns have legitimate sites (a standalone ``hermes -p x`` process where
environ IS the profile). The reviewer reads each finding against its ``scope_hint``.

Usage:
    python scripts/check_profile_scope_patterns.py [--base origin/main] [--head HEAD]
    python scripts/check_profile_scope_patterns.py --files tools/bot_relay.py ...   # whole files
    python scripts/check_profile_scope_patterns.py --base origin/main --json out.json
"""
from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
from dataclasses import asdict, dataclass
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
PATTERNS = ROOT / "scripts" / "ci" / "profile_scope_patterns.json"
SUFFIXES = (".py", ".ts", ".tsx")
SKIP_PREFIXES = ("tests/", "website/", "skills/", "optional-skills/", "evals/", "scripts/", ".worktrees/")
_HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")


@dataclass(frozen=True)
class Finding:
    path: str
    line: int
    pattern_id: str
    pattern_class: str
    why: str
    scope_hint: str
    text: str


def load_patterns(path: Path = PATTERNS) -> list[dict]:
    data = json.loads(path.read_text(encoding="utf-8-sig"))
    out = []
    for p in data["patterns"]:
        # ``path_regex`` (optional) restricts a pattern to files whose repo-relative path matches.
        path_rx = re.compile(p["path_regex"]) if p.get("path_regex") else None
        out.append({**p, "_rx": re.compile(p["pattern_regex"], re.M), "_path_rx": path_rx})
    return out


def _lint_path(rel: str) -> bool:
    return rel.endswith(SUFFIXES) and not rel.startswith(SKIP_PREFIXES) and "/tests/" not in rel and "node_modules" not in rel


def scan_text(rel: str, text: str, patterns: list[dict], lines: set[int] | None = None) -> list[Finding]:
    """Findings for *text*; ``lines`` restricts to those 1-based line numbers (None = whole file).
    Multi-line patterns are anchored on the line where the match starts."""
    findings: list[Finding] = []
    src_lines = text.split("\n")
    for p in patterns:
        path_rx = p.get("_path_rx")
        if path_rx is not None and not path_rx.search(rel):
            continue
        for m in p["_rx"].finditer(text):
            line_no = text.count("\n", 0, m.start()) + 1
            if lines is not None and line_no not in lines:
                continue
            findings.append(Finding(rel, line_no, p["id"], p["class"], p["why"], p["scope_hint"],
                                    src_lines[line_no - 1].strip()[:160]))
    return findings


def _git(*args: str) -> str:
    proc = subprocess.run(["git", *args], cwd=ROOT, capture_output=True, text=True, encoding="utf-8", errors="replace")
    return proc.stdout


def added_lines_vs_base(base: str, head: str | None) -> dict[str, set[int]]:
    """``{path: {added line numbers in head}}`` for lint-able files changed between *base* and *head*
    (working tree when *head* is None)."""
    rev = [base, head] if head else [base]
    diff = _git("diff", "--no-color", "-U0", "--diff-filter=AM", *rev, "--")
    out: dict[str, set[int]] = {}
    current: str | None = None
    for raw in diff.split("\n"):
        if raw.startswith("+++ "):
            name = raw[4:]
            current = name[2:] if name.startswith("b/") else None
            if current is not None and not _lint_path(current):
                current = None
            continue
        if current is None:
            continue
        m = _HUNK_RE.match(raw)
        if m:
            start, count = int(m.group(1)), int(m.group(2) or "1")
            out.setdefault(current, set()).update(range(start, start + count))
    return out


def _read(rel: str, head: str | None) -> str | None:
    if head:
        cmd = ["git", "show", f"{head}:{rel}"]
        r = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, encoding="utf-8", errors="replace")
        return r.stdout if r.returncode == 0 else None
    path = ROOT / rel
    return path.read_text(encoding="utf-8-sig", errors="replace") if path.is_file() else None


def run(base: str | None, head: str | None, files: list[str], patterns: list[dict]) -> list[Finding]:
    findings: list[Finding] = []
    if files:
        for f in files:
            path = Path(f)
            text = path.read_text(encoding="utf-8-sig", errors="replace") if path.is_file() else None
            if text is None:
                continue
            resolved = path.resolve()
            rel = str(resolved.relative_to(ROOT)) if resolved.is_relative_to(ROOT) else str(path)
            findings.extend(scan_text(rel, text, patterns))
        return findings
    for rel, lines in sorted(added_lines_vs_base(base or "origin/main", head).items()):
        text = _read(rel, head)
        if text is not None:
            findings.extend(scan_text(rel, text, patterns, lines))
    return findings


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--base", default="origin/main", help="base ref for the added-lines diff")
    ap.add_argument("--head", default=None, help="head ref (default: working tree)")
    ap.add_argument("--files", nargs="*", default=[], help="scan these whole files instead of a diff")
    ap.add_argument("--json", default=None, help="also write findings as JSON to this path")
    args = ap.parse_args(argv)

    patterns = load_patterns()
    findings = run(args.base, args.head, args.files, patterns)
    if args.json:
        Path(args.json).write_text(json.dumps([asdict(f) for f in findings], indent=2) + "\n", encoding="utf-8")
    if not findings:
        print("profile-scope patterns: 0 findings")
        return 0
    print(f"profile-scope patterns: {len(findings)} finding(s) — ADVISORY, read each against its scope hint "
          f"(root AGENTS.md § Code Shape Rules; scripts/ci/profile_scope_patterns.json)")
    for f in findings:
        print(f"{f.path}:{f.line}  {f.pattern_id}/{f.pattern_class}  {f.why}")
        print(f"    | {f.text}")
        print(f"    hint: {f.scope_hint}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
