#!/usr/bin/env python3
"""
Vault verifier — runs the Phase 4 checks from the team-aware vault restructure
(see HQ-Vault/_System/MigrationVerification.md).

Run from repo root:
    python scripts/vault-verify.py

Writes the report to HQ-Vault/_System/MigrationVerification.md and exits 0 if
all checks pass, 1 if any check produces a deviation.
"""

import re
import subprocess
import sys
from pathlib import Path


ROOT = Path("HQ-Vault")
OUTPUT = ROOT / "_System" / "MigrationVerification.md"

TARGET_STRUCTURE = {
    "00_Inbox": [],
    "01_Daily": [],
    "02_Weekly": [],
    "10_RateRight": ["_Brain", "_AgentLog", "Business", "Operations", "Codebase", "Archive"],
    "11_OpsMan_LFCS": ["_Brain", "_AgentLog", "Business", "Operations", "Codebase", "Archive"],
    "12_SiteOps": ["_Brain", "_AgentLog", "Business", "Operations", "Codebase", "Archive"],
    "20_Personal": ["_Brain"],
    "30_Goals": ["_Brain"],
    "40_People": [],
    "90_Reference": [],
    "_Archive": [],
    "_System": ["AgentLog", "Templates"],
}

# Required core files — the minimum scaffold. New content can be added beyond
# this; this list only enforces what MUST exist for the protocol to work.
REQUIRED_FILES = {
    "_System/CLAUDE.md",
    "_System/Templates/Daily.md",
    "_System/Templates/Weekly.md",
    "_System/Templates/Project_Log.md",
    "_System/Templates/Person.md",
    "10_RateRight/_Brain/CLAUDE.md",
    "10_RateRight/_Brain/README.md",
    "10_RateRight/_Brain/_Log.md",
    "11_OpsMan_LFCS/_Brain/CLAUDE.md",
    "11_OpsMan_LFCS/_Brain/README.md",
    "11_OpsMan_LFCS/_Brain/_Log.md",
    "12_SiteOps/_Brain/CLAUDE.md",
    "12_SiteOps/_Brain/README.md",
    "12_SiteOps/_Brain/_Log.md",
    "20_Personal/_Brain/CLAUDE.md",
    "30_Goals/_Brain/CLAUDE.md",
}

MIN_MD_COUNT = 30  # Loose floor — alarms only if we're losing files, not when adding them.

WIKILINK_RE = re.compile(r"\[\[([^\]\|]+)(?:\|[^\]]+)?\]\]")


def strip_codespans(text: str) -> str:
    """Remove fenced code blocks then inline `code` so quoted [[...]] in code don't count as links."""
    text = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
    text = re.sub(r"`[^`\n]*`", "", text)
    return text


def normalise_wikilink_target(raw: str) -> str:
    """Strip the backslash-escaped pipe ('\\|' inside table cells) and section anchors,
    so [[file\\|alias]] inside a table resolves the same as [[file|alias]] in prose."""
    target = raw.replace("\\|", "|")
    # Take the part before `|` (alias separator), then before `#` (section anchor)
    target = target.split("|", 1)[0].split("#", 1)[0]
    return target.strip()


# Wikilinks pointing into these prefixes are pre-migration references to the
# sibling `vault/` directory (CC VPS / pre-hibernation Obsidian vault). They
# resolve in vault/ when Obsidian is opened there, but not from HQ-Vault.
# Treat as external / out-of-scope rather than broken.
EXTERNAL_LINK_PREFIXES = (
    "01-Daily/",          # vault uses hyphen; HQ-Vault uses underscore (01_Daily/)
    "02-Fleet/",
    "03-Projects/",
    "04-Operations/",
    "05-Knowledge/",
    "06-Business/",
    "07-Command-Centre/",
    "08-Infrastructure/",
    "09-Agent-Archive/",
    "09-Agent-Output/",
    "Templates/",         # vault has its own Templates/; HQ-Vault has _System/Templates/
)


def check_folder_structure() -> list[str]:
    deviations = []
    for top, subs in TARGET_STRUCTURE.items():
        p = ROOT / top
        if not p.exists():
            deviations.append(f"MISSING top-level: {top}")
            continue
        for sub in subs:
            if not (p / sub).exists():
                deviations.append(f"MISSING: {top}/{sub}")
    actual_tops = {d.name for d in ROOT.iterdir() if d.is_dir() and not d.name.startswith(".")}
    for extra in sorted(actual_tops - set(TARGET_STRUCTURE)):
        deviations.append(f"UNEXPECTED top-level: {extra}")
    return deviations


def check_required_files() -> list[str]:
    """Return list of REQUIRED files that don't exist."""
    actual = {p.relative_to(ROOT).as_posix() for p in ROOT.rglob("*.md")}
    return sorted(REQUIRED_FILES - actual)


def check_links() -> tuple[list[tuple[str, str]], list[tuple[str, str]]]:
    all_links: list[tuple[str, str]] = []
    for p in ROOT.rglob("*.md"):
        try:
            content = p.read_text(encoding="utf-8", errors="ignore")
        except OSError:
            continue
        cleaned = strip_codespans(content)
        # Match anything inside [[...]] up to the first closing bracket
        for m in re.finditer(r"\[\[([^\]]+)\]\]", cleaned):
            all_links.append((p.relative_to(ROOT).as_posix(), m.group(1).strip()))
    basenames = {p.stem for p in ROOT.rglob("*.md")}
    folders = {d.relative_to(ROOT).as_posix() for d in ROOT.rglob("*") if d.is_dir()}
    folder_basenames = {d.name for d in ROOT.rglob("*") if d.is_dir()}
    broken: list[tuple[str, str]] = []
    external: list[tuple[str, str]] = []
    for src, raw in all_links:
        t = normalise_wikilink_target(raw)
        if not t:
            continue
        # Treat pre-migration vault/ paths as external, not broken
        if any(t.startswith(p) for p in EXTERNAL_LINK_PREFIXES):
            external.append((src, raw))
            continue
        if "/" in t:
            cand = ROOT / t
            if not cand.suffix:
                cand_with_md = cand.with_suffix(".md")
                if cand_with_md.exists() or cand.exists():
                    continue
            elif cand.exists():
                continue
            # Obsidian basename fallback: [[folder/basename]] resolves
            # by basename anywhere in vault, not strictly full path.
            # Strip leading ../ and trailing #anchor (already handled upstream).
            basename = t.rsplit("/", 1)[-1].lstrip(".")
            if basename and (basename in basenames or basename in folders or basename in folder_basenames):
                continue
            broken.append((src, raw))
        elif t in basenames or t in folders or t in folder_basenames:
            continue
        else:
            broken.append((src, raw))
    # Stash external count via a side channel: append to broken list signature
    # (for reporting); but only RETURN broken so it counts toward exit code.
    if external:
        broken[:0] = []  # no-op; kept for clarity
    return all_links, broken, external


def check_frontmatter() -> tuple[list[str], list[tuple[str, str]]]:
    fm_files: list[str] = []
    fm_broken: list[tuple[str, str]] = []
    for p in ROOT.rglob("*.md"):
        try:
            content = p.read_text(encoding="utf-8", errors="ignore")
        except OSError:
            continue
        if content.startswith(("---\n", "---\r\n")):
            rest = content.split("\n", 1)[1] if "\n" in content else ""
            if "\n---" not in rest:
                fm_broken.append((p.relative_to(ROOT).as_posix(), "unterminated frontmatter"))
            else:
                fm_files.append(p.relative_to(ROOT).as_posix())
    return fm_files, fm_broken


def check_per_project() -> dict[str, list[str]]:
    expected_subs = ["_Brain", "_AgentLog", "Business", "Operations", "Codebase", "Archive"]
    expected_brain = ["CLAUDE.md", "README.md", "_Log.md"]
    failures: dict[str, list[str]] = {}
    for proj in ("10_RateRight", "11_OpsMan_LFCS", "12_SiteOps"):
        p = ROOT / proj
        problems: list[str] = []
        for sub in expected_subs:
            if not (p / sub).exists():
                problems.append(f"missing subfolder: {sub}")
        for fname in expected_brain:
            if not (p / "_Brain" / fname).exists():
                problems.append(f"missing _Brain/{fname}")
        if problems:
            failures[proj] = problems
    return failures


def restructure_commits() -> list[str]:
    # text=True alone uses the system default encoding, which is cp1252 on
    # Windows — that breaks any em-dash in commit messages. Force UTF-8.
    out = subprocess.check_output(
        ["git", "log", "--oneline", "-25"],
        text=True,
        encoding="utf-8",
    )
    return [
        line for line in out.splitlines()
        if "restructure:" in line or line.split(maxsplit=1)[1:2] == ["vault: initial rebuild — folders, templates, stubs"]
        or "vault: initial rebuild" in line
        or "daily: log restructure" in line
    ]


def main() -> int:
    lines: list[str] = []
    write = lines.append

    write("---")
    write("title: Migration Verification")
    write("created: 2026-05-01")
    write("type: system")
    write("phase: 4")
    write("---")
    write("")
    write("# Migration Verification")
    write("")
    write("> Run with `python scripts/vault-verify.py` from repo root.")
    write("")

    deviations = check_folder_structure()
    write("## 1. Folder structure check")
    write("")
    if deviations:
        write("**Deviations:**")
        for d in deviations:
            write(f"- {d}")
    else:
        write("**No deviations.** All target folders present, no extras.")
    write("")

    md_files = sorted(ROOT.rglob("*.md"))
    gitkeep_count = len(list(ROOT.rglob(".gitkeep")))
    self_present = OUTPUT in md_files
    effective_md = len(md_files) if self_present else len(md_files) + 1
    write("## 2. File count check")
    write("")
    write(f"- `.md` files: **{len(md_files)}**" + ("" if self_present else " (this report not yet on disk; +1 expected)"))
    write(f"- `.gitkeep` files: **{gitkeep_count}**")
    write("")
    write(f"Floor (alarms only on regression): **{MIN_MD_COUNT}**")
    if effective_md >= MIN_MD_COUNT:
        write(f"**PASS** — count {effective_md} >= floor {MIN_MD_COUNT}.")
    else:
        write(f"**FAIL** — count {effective_md} dropped below floor {MIN_MD_COUNT}. Files lost?")
    write("")

    missing_required = check_required_files()
    write("## 3. Required-file check")
    write("")
    if not missing_required:
        write(f"**PASS** — all {len(REQUIRED_FILES)} required scaffold files present.")
    else:
        write(f"**FAIL** — {len(missing_required)} required file(s) missing:")
        for m in missing_required:
            write(f"- `{m}`")
    write("")

    all_links, broken, external = check_links()
    write("## 4. Link integrity")
    write("")
    if not all_links:
        write("**No `[[wikilinks]]` found in vault.**")
    elif not broken:
        write(f"**PASS** — {len(all_links)} wikilink(s) found ({len(external)} external to sibling vault, all rest resolve).")
    else:
        write(f"**FAIL** — {len(broken)} broken wikilink(s) of {len(all_links)} total ({len(external)} external):")
        for src, tgt in broken:
            write(f"- `{src}` -> `[[{tgt}]]`")
    if external:
        write("")
        write(f"**External wikilinks** ({len(external)}, pointing to sibling `vault/` — pre-migration references, not counted as broken):")
        # Don't dump all external links; just sample
        for src, tgt in external[:5]:
            write(f"- `{src}` -> `[[{tgt}]]`")
        if len(external) > 5:
            write(f"- _(...{len(external)-5} more — see `git grep '\\[\\[02-Fleet'` etc.)_")
    write("")

    fm_files, fm_broken = check_frontmatter()
    write("## 5. Frontmatter integrity")
    write("")
    write(f"- Files with frontmatter: **{len(fm_files)}**")
    if fm_broken:
        write(f"- **Broken frontmatter ({len(fm_broken)}):**")
        for f, reason in fm_broken:
            write(f"  - `{f}` — {reason}")
    else:
        write("- **No frontmatter parse errors.**")
    write("")

    failures = check_per_project()
    write("## 6. Per-project completeness")
    write("")
    if not failures:
        for proj in ("10_RateRight", "11_OpsMan_LFCS", "12_SiteOps"):
            write(f"- **{proj}:** PASS — all 6 subfolders present, _Brain/ has CLAUDE.md + README.md + _Log.md")
        write("")
        write("**Overall: PASS** for all three project folders.")
    else:
        for proj, problems in failures.items():
            write(f"- **{proj}:** FAIL")
            for prob in problems:
                write(f"  - {prob}")
    write("")

    write("## 7. Git log — restructure-related commits")
    write("")
    commits = restructure_commits()
    for c in commits:
        write(f"- `{c}`")
    write("")
    write(f"**Total: {len(commits)}**")
    write("")

    # Structural failures are blocking. Broken wikilinks are reported but
    # do not fail the gate — agents routinely write forward-referencing
    # wikilinks (planned standards, future docs) and blocking on those
    # silently nukes vault commits via auto-sync.sh's gate.
    fail = (
        bool(deviations)
        or bool(missing_required)
        or bool(fm_broken)
        or bool(failures)
        or effective_md < MIN_MD_COUNT
    )
    write("## Summary")
    write("")
    if fail:
        write("**One or more STRUCTURAL checks failed. See sections above.**")
    elif broken:
        write(f"**Structural checks PASSED.** {len(broken)} broken wikilink(s) reported as WARN — see Section 4. Auto-sync proceeds.")
    else:
        write("**All checks passed.** Vault structure matches target spec.")
    write("")

    OUTPUT.write_text("\n".join(lines), encoding="utf-8")
    # Guard against Windows cp1252 stdout choking on unicode chars
    try:
        print("\n".join(lines))
    except UnicodeEncodeError:
        print("\n".join(lines).encode("ascii", "replace").decode("ascii"))
    return 1 if fail else 0


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