#!/usr/bin/env python3
"""Vault integrity — structural / graph / convention health for an Obsidian vault.

Read-only. Surfaces the things that quietly rot a PEAK "second brain": broken
wikilinks (split into fixable link-errors vs. not-yet-created gaps), frontmatter
holes, missing folder notes, TODO-convention slips, checkboxes on People notes
(a PEAK violation — People notes are relationship records, not task queues),
duplicate basenames (link ambiguity), and orphans.

Stdlib-only (`python3`), no venv. Finds the vault root the same way `digest.py` /
`vault_stats.py` do (walk up to the folder holding `Areas/` + `AGENTS.md`, or
`$VAULT_DIR`), so it runs from anywhere in the vault.

Usage:
    python3 Agent/scripts/integrity/vault_integrity.py            # human report
    python3 Agent/scripts/integrity/vault_integrity.py --json     # machine-readable
    python3 Agent/scripts/integrity/vault_integrity.py --top 40   # rows per section

Read-only: never writes to the vault. Wikilink parsing accounts for display
aliases (`[[A|B]]`), heading links (`[[A#H]]`), table-escaped pipes (`[[A\\|B]]`),
attachment links/embeds, and skips fenced/inline code so doc examples aren't
counted as real links.
"""
import os
import re
import sys
import json
import argparse
from pathlib import Path
from collections import defaultdict

from alexandria_vault.engine import consume_vault_arg, resolve_vault
from alexandria_vault.vault_core import analyze_links

# `--vault` must land in VAULT_DIR before resolve_vault() runs at main().
consume_vault_arg()

EXCLUDE_DIRS = {".git", ".obsidian", ".trash", ".claude", ".state", ".alexandria", "node_modules", ".venv"}
# Folders that are containers/tooling, not content-note folders (folder-note check).
NON_CONTENT_FOLDERS = {"Files", "inbox", "attachments", "scripts", "config", "tests", "templates"}


def base_noext(rel: str) -> str:
    return os.path.splitext(os.path.basename(rel))[0]


# ---------------------------------------------------------------- collect
def collect(root: Path):
    md_files, attach = [], []
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
        for name in filenames:
            rel = os.path.relpath(os.path.join(dirpath, name), root)
            (md_files if name.endswith(".md") else attach).append(rel)
    return md_files, attach


def split_frontmatter(text):
    """(dict of top-level scalar keys, body, terminated?, present?). Light YAML."""
    if not text.startswith("---"):
        return {}, text, True, False
    end = text.find("\n---", 3)
    if end == -1:
        return {}, text, False, True  # unterminated
    fm = {}
    for line in text[3:end].splitlines():
        m = re.match(r"^([A-Za-z0-9_\-]+):\s?(.*)$", line)
        if m:
            fm[m.group(1)] = m.group(2).strip()
    return fm, text[end + 4:], True, True


# ---------------------------------------------------------------- analyze
def analyze(root: Path):
    # Graph half — broken links, link-errors vs true-missing, dup basenames,
    # orphans — is the absorbed pipeline in vault_core.graph (docs/DEVAULT.md
    # step 2; parity with the original locked by the P2.5 goldens + corpus
    # tests). The convention checks below stay here: they are integrity's own
    # domain, not graph logic.
    g = analyze_links(root)

    md_files, _attach = collect(root)
    notes = {}
    for rel in md_files:
        raw = (root / rel).read_text(encoding="utf-8", errors="replace")
        fm, body, terminated, present = split_frontmatter(raw)
        notes[rel] = dict(fm=fm, body=body, present=present, terminated=terminated)

    # frontmatter
    no_fm, unterminated, no_author, no_type = [], [], [], []
    for rel, d in notes.items():
        if base_noext(rel) == "AGENTS":
            continue
        if not d["present"]:
            no_fm.append(rel); continue
        if not d["terminated"]:
            unterminated.append(rel); continue
        if "author" not in d["fm"]:
            no_author.append(rel)
        if "type" not in d["fm"]:
            no_type.append(rel)

    # folder notes
    missing_folder_notes = []
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
        rel_root = os.path.relpath(dirpath, root)
        if rel_root == ".":
            continue
        folder = os.path.basename(dirpath)
        if folder in NON_CONTENT_FOLDERS or rel_root.startswith("Agent"):
            continue
        if any(f.endswith(".md") for f in filenames) and (folder + ".md") not in filenames:
            missing_folder_notes.append(rel_root)

    # area TODO coverage
    area_root = root / "Areas"
    areas = sorted(d.name for d in area_root.iterdir() if d.is_dir()) if area_root.is_dir() else []
    have = {r.split(os.sep)[1] for r in md_files
            if r.endswith("TODO.md") and r.startswith("Areas" + os.sep) and len(r.split(os.sep)) >= 2}
    areas_without_todo = [a for a in areas if a not in have]

    # todos missing `added`
    todo_item = re.compile(r"^\s*-\s*\[[ x]\]")
    todos_missing_added = defaultdict(int)
    for r in (r for r in md_files if r.endswith("TODO.md")):
        lines = notes[r]["body"].splitlines()
        for i, line in enumerate(lines):
            if todo_item.match(line) and "added" not in "\n".join(lines[i:i + 4]).lower():
                todos_missing_added[r] += 1

    # People-note checkboxes — a PEAK violation (People notes hold no tasks; actions
    # live in an area TODO). Universal convention, so worth flagging on every vault.
    person_checkbox = []
    for rel, d in notes.items():
        if base_noext(rel) == "AGENTS" or rel.startswith("Agent" + os.sep):
            continue
        if (d["fm"].get("type") == "person" or rel.startswith("People" + os.sep)) \
                and base_noext(rel) != "People" and re.search(r"^\s*-\s*\[[ x]\]", d["body"], re.M):
            person_checkbox.append(rel)

    return dict(
        n_notes=g["n_notes"], n_attach=g["n_attach"],
        content_broken=g["content_broken"], link_errors=g["link_errors"],
        true_missing=g["true_missing"],
        no_fm=no_fm, unterminated=unterminated, no_author=no_author, no_type=no_type,
        missing_folder_notes=missing_folder_notes, areas_without_todo=areas_without_todo,
        todos_missing_added=dict(todos_missing_added),
        person_checkbox=person_checkbox, dup_basenames=g["dup_basenames"],
        orphans=g["orphans"],
    )


# ---------------------------------------------------------------- report
def human(a, top):
    L = []
    p = L.append
    p("VAULT INTEGRITY REPORT")
    p(f"Notes: {a['n_notes']}  |  Attachments: {a['n_attach']}")

    cb = a["content_broken"]
    p("\n== 1. GRAPH — broken wikilinks ==")
    p(f"{len(cb)} distinct missing targets / {sum(len(s) for s in cb.values())} refs")
    p(f"\n(a) LINK ERRORS — note exists under a variant name ({len(a['link_errors'])}) — FIXABLE:")
    for t, (ex, n) in sorted(a["link_errors"].items(), key=lambda x: -x[1][1]):
        p(f"   [[{t}]] ({n}x) -> [[{ex}]]")
    p(f"\n(b) TOP not-yet-created (allowed; high count = worth a stub):")
    for t, n in sorted(a["true_missing"].items(), key=lambda x: -x[1])[:top]:
        p(f"   [[{t}]]  <- {n}x")

    p("\n== 2. FRONTMATTER ==")
    p(f"missing entirely: {len(a['no_fm'])} | unterminated: {len(a['unterminated'])} | "
      f"no author: {len(a['no_author'])} | no type: {len(a['no_type'])}")
    for label, items in (("no-fm", a["no_fm"]), ("no-author", a["no_author"]), ("no-type", a["no_type"])):
        for r in items[:top]:
            p(f"   [{label}] {r}")

    p("\n== 3. FOLDER NOTES missing ==")
    for r in sorted(a["missing_folder_notes"])[:top]:
        p(f"   {r}")

    p("\n== 4. AREA TODO coverage ==")
    p(f"   areas without a *TODO.md: {a['areas_without_todo'] or 'none ✓'}")

    p("\n== 5. TODO items missing 'added' date ==")
    p(f"   {sum(a['todos_missing_added'].values())} across {len(a['todos_missing_added'])} files")
    for r, n in sorted(a["todos_missing_added"].items(), key=lambda x: -x[1])[:top]:
        p(f"   {n:3d}  {r}")

    p("\n== 6. PEOPLE-NOTE CHECKBOXES (PEAK: People notes are relationship records, no tasks) ==")
    p(f"   {len(a['person_checkbox'])} People notes carry task checkboxes")
    for r in a["person_checkbox"][:top]:
        p(f"   {r}")

    p("\n== 7. DUPLICATE basenames (link ambiguity) ==")
    for b, paths in sorted(a["dup_basenames"].items()):
        p(f"   {b}: {len(paths)} notes")

    p("\n== 8. ORPHANS (no inbound links; soft) ==")
    p(f"   {len(a['orphans'])} notes")
    return "\n".join(L)


def main():
    ap = argparse.ArgumentParser(description="Vault integrity check (read-only).")
    ap.add_argument("--json", action="store_true", help="machine-readable output")
    ap.add_argument("--top", type=int, default=30, help="rows per section (human mode)")
    args = ap.parse_args()

    a = analyze(resolve_vault())
    if args.json:
        summary = {k: (len(v) if isinstance(v, (list, dict)) else v)
                   for k, v in a.items() if k not in ("content_broken",)}
        summary["broken_targets"] = len(a["content_broken"])
        summary["broken_refs"] = sum(len(s) for s in a["content_broken"].values())
        print(json.dumps(summary, indent=2))
    else:
        print(human(a, args.top))


if __name__ == "__main__":
    main()
