#!/usr/bin/env python3
"""Check every record in the unlocked compartments (charter §14 step 5).

    tools/validate [--data DIR] [ROOT ...]

ROOT is a compartment's plaintext root (e.g. ~/continuity-data/mnt/c1, or an
archive extracted to ~/continuity/c1); its name must be c1..c5. Without
ROOTs, every unlocked compartment under $CONTINUITY_DATA/mnt is checked.

Checks: front matter parses; schema (types, required fields, no unknown
fields); file name = id; the record sits in the compartment and area it
declares; ids are unique; every linked id resolves; supersession links
agree in both directions; attachments exist; nothing looks like a secret;
each compartment's audit log (audit/log.jsonl, ADR-004 §4) is an unbroken
hash chain.

Exit status: 0 = no errors (warnings allowed), 1 = errors, 2 = usage.
Reads only; never changes a record.
"""
import argparse
import hashlib
import json
import re
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import continuity_records as cr  # noqa: E402

LINK_FIELDS = ("superseded_by", "supersedes", "procedure")
LINK_LISTS = ("depends_on", "legal_documents")
# (pattern, what, is_error): key material is an error; a password-like line
# is only a warning, since "PIN: in the paper kit" is a legitimate note.
SECRET_PATTERNS = [
    (re.compile(r"AGE-SECRET-KEY-"), "an age secret key", True),
    (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), "a private key", True),
    (re.compile(r"(?im)^\s*(password|passwd|lozinka|pin|seed|recovery[ _-]?code)s?\s*[:=]\s*\S"), "a password-like line", False),
]


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("roots", nargs="*", type=Path)
    ap.add_argument("--data", type=Path, help="CONTINUITY_DATA (default: env or ~/continuity-data)")
    a = ap.parse_args()

    if a.roots:
        roots = []
        for r in a.roots:
            if r.name not in cr.COMPARTMENTS:
                print(f"validate: {r}: the folder name must be one of {', '.join(cr.COMPARTMENTS)}", file=sys.stderr)
                return 2
            roots.append((r.name, r))
    else:
        roots = cr.vault_roots(a.data)
    if not roots:
        print("validate: no unlocked compartments found (unlock first, or pass their folders)", file=sys.stderr)
        return 2

    records = cr.load_vault(roots)
    present = {c for c, _ in roots}
    files = {c: cr.file_ids(r) for c, r in roots}
    checker = cr.SchemaChecker()
    errors, warnings = [], []

    def err(rec, msg): errors.append(f"{rec.path}: {msg}")
    def warn(rec, msg): warnings.append(f"{rec.path}: {msg}")

    by_id = {}
    for rec in records:
        if rec.parse_error:
            err(rec, rec.parse_error)
            continue
        for e in checker.errors(rec.meta):
            err(rec, e)
        m = rec.meta
        if str(m.get("id")) != rec.path.stem:
            err(rec, f"file name must be the record id ({m.get('id')}.md)")
        if m.get("compartment") != rec.compartment:
            err(rec, f"declares compartment {m.get('compartment')} but is stored in {rec.compartment}")
        if m.get("area") != rec.area_dir:
            err(rec, f"declares area {m.get('area')} but is stored in records/{rec.area_dir}/")
        if rec.id in by_id:
            err(rec, f"duplicate id, also used by {by_id[rec.id].path}")
        else:
            by_id[rec.id] = rec
        if m.get("type") == "procedure":
            steps = cr.procedure_steps(rec.body)
            marked = any(s.destructive for s in steps)
            if not steps:
                err(rec, "a procedure needs numbered steps under '## Steps' (ADR-005 §1)")
            elif m.get("destructive") is True and not marked:
                err(rec, "destructive: true, but no step starts with 'DESTRUCTIVE:'; mark the step that erases or cancels something")
            elif m.get("destructive") is False and marked:
                err(rec, "a step is marked 'DESTRUCTIVE:', so destructive must be true")
        for pattern, what, is_error in SECRET_PATTERNS:
            if pattern.search(rec.body):
                (err if is_error else warn)(rec, f"body seems to contain {what}; secrets belong in the password manager, not in records")

    missing_is_error = present == set(cr.COMPARTMENTS)
    for rec in by_id.values():
        m = rec.meta
        targets = [(f, m.get(f)) for f in LINK_FIELDS if m.get(f)]
        targets += [(f, t) for f in LINK_LISTS for t in (m.get(f) or []) if isinstance(m.get(f), list)]
        targets += [("links", l.get("id")) for l in (m.get("links") or []) if isinstance(l, dict)]
        for fld, t in targets:
            if t == rec.id:
                err(rec, f"{fld} points to the record itself")
            elif t not in by_id:
                msg = f"{fld} points to {t}, which does not exist"
                if missing_is_error:
                    err(rec, msg)
                else:
                    warn(rec, msg + " in the unlocked compartments (it may be in a locked one)")
        for f in m.get("files") or []:
            if f not in files.get(rec.compartment, set()):
                err(rec, f"attachment {f} not found in {rec.compartment}/files/")
        new = by_id.get(m.get("superseded_by"))
        if new is not None and new.meta.get("supersedes") != rec.id:
            warn(rec, f"superseded by {new.id}, but that record's 'supersedes' is {new.meta.get('supersedes')}")
        if m.get("superseded_by") and m.get("valid_until") is None:
            warn(rec, "superseded, but valid_until is not set (docs/data-format.md §5)")
        old = by_id.get(m.get("supersedes"))
        if old is not None and old.meta.get("superseded_by") != rec.id:
            warn(rec, f"supersedes {old.id}, but that record's 'superseded_by' is {old.meta.get('superseded_by')}")

    for comp, root in roots:
        log = root / "audit" / "log.jsonl"
        if not log.is_file():
            continue
        expected = "0" * 64
        for n, line in enumerate(log.read_bytes().split(b"\n"), 1):
            if not line.strip():
                continue
            try:
                prev = json.loads(line).get("prev")
            except ValueError:
                errors.append(f"{log}: line {n} is not valid JSON")
                break
            if prev != expected:
                errors.append(f"{log}: hash chain broken at line {n} (a line was changed, removed or reordered)")
                break
            expected = hashlib.sha256(line).hexdigest()

    for w in warnings:
        print(f"warning: {w}")
    for e in errors:
        print(f"ERROR:   {e}")
    locked = sorted(set(cr.COMPARTMENTS) - present)
    print(f"\n{len(records)} records in {', '.join(sorted(present))}"
          + (f" (locked, not checked: {', '.join(locked)})" if locked else "")
          + f": {len(errors)} error(s), {len(warnings)} warning(s)")
    return 1 if errors else 0


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