#!/usr/bin/env python3
"""Mechanical evidence check for an llm-wiki-loop vault.

Ported and adapted from Astro-Han/karpathy-llm-wiki (MIT license,
https://github.com/Astro-Han/karpathy-llm-wiki). Layout changed to the
canonical llm-wiki-loop standard:
    wiki/<layer>/*.md         articles (concepts|topics|references)
    raw/notes|data/*.md       immutable sources
    index.md                  index (skipped)
    log.md                    log (skipped)

Report-only; never modifies files. Three sweeps:

1. Fidelity - extract candidate literals (specific numbers, ISO dates,
   direct quotes) from each wiki article and verify that each candidate
   appears verbatim in the body of the raw files linked by that
   article's Raw field. Misses are listed as suspects. Derived values,
   product names, and deliberate paraphrases will show up as suspects;
   judging them is the reader's job, not this script's.
2. Evidence errors - articles that cannot be verified at all: a missing
   Raw field on a non-archive article, Raw links that do not resolve,
   or Raw links that escape raw/ (evidence must live in immutable raw/).
3. Inventory - raw files that no article's Raw field references,
   excluding files whose ingest was logged as "no material".

Coverage boundary (closed candidate set, frozen): candidates are
- quotes of 15+ characters (double-quoted spans and body blockquotes)
- ISO dates (YYYY-MM-DD, YYYY-MM)
- specific numbers: thousands-grouped (10,000), dotted (2.1.80, 3.14),
  suffixed (42K, 99.9%), or 4+ digits (2026)
Small plain integers ("42", "500") and exotic forms (signs, currencies,
spelled-out dates) are deliberately not checked; they belong to the
compile-time locate-before-write rule and to judgment review. New prose
forms extend this list in the docstring, not the regexes.

Exit code: 0 always unless --strict is given (1 on faults). Report is primary interface.

Usage: check_evidence.py [--strict] [--strict-all] [project-root] [article.md ...]
  --strict            exit 1 if evidence errors or drift detected
  --strict-all        also fail on fidelity suspects (alias: --include-suspects)
Defaults: project-root is the current directory (or its nearest ancestor
containing wiki/ and raw/); all wiki/**/*.md articles except index.md and
log.md are checked. Article paths may be absolute or relative to the
project root.
"""

from __future__ import annotations

import hashlib
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path

NUMBER_TOKEN_RE = re.compile(
    r"(?:\d{1,3}(?:,\d{3})+(?:\.\d+)*(?:\s*[KMB%](?![A-Za-z]))?"
    r"|\d+(?:\.\d+)*(?:\s*[KMB%](?![A-Za-z]))?)(?![A-Za-z])"
)
SUFFIX_RE = re.compile(r"[KMB%]$")
DATE_RE = re.compile(r"\d{4}-\d{2}(?:-\d{2})?")
QUOTE_RES = [re.compile(r'"([^"\n]*)"'), re.compile(r"\u201c([^\u201d\n]*)\u201d")]
METADATA_RE = re.compile(
    r"^>\s*(Sources?|Raw|Collected|Published|Updated|Archived|Triggers|Fingerprint|Monitored):",
    re.IGNORECASE,
)
STATUS_LINE_RE = re.compile(r"^>\s*(\*\*)?Status:", re.IGNORECASE)
LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]*)\)")
INLINE_CODE_RE = re.compile(r"`[^`\n]*`")
RAW_LINK_RE = re.compile(r"\(([^)]+\.md)[^)]*\)")
FINGERPRINT_RE = re.compile(r"^>\s*Fingerprint:\s*(\S+)", re.IGNORECASE)
MONITORED_RE = re.compile(r"^>\s*Monitored:\s*(.+)$", re.IGNORECASE)
NO_MATERIAL_HEADING_RE = re.compile(
    r"^## \[[^\]]*\]\s*ingest\s*\|\s*no material:\s*(\S+)", re.IGNORECASE
)
ARCHIVED_RE = re.compile(r"^>\s*Archived:", re.IGNORECASE)
FENCE_OPEN_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
FENCE_CLOSE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})[ \t]*$")
WS_RE = re.compile(r"\s+")

SKIP_FILES = {"index.md", "log.md"}

WIKI_DIR_NAME = "wiki"
RAW_DIR_NAME = "raw"


@dataclass(frozen=True)
class Document:
    title: str | None
    header: tuple[str, ...]
    body: tuple[str, ...]


@dataclass(frozen=True)
class Candidate:
    kind: str
    value: str


def normalize(text: str) -> str:
    return WS_RE.sub(" ", text).strip()


def fence_opener(line: str) -> tuple[str, int] | None:
    m = FENCE_OPEN_RE.match(line)
    if not m:
        return None
    marker, info = m.groups()
    if marker[0] == "`" and "`" in info:
        return None
    return marker[0], len(marker)


def is_fence_closer(line: str, char: str, length: int) -> bool:
    m = FENCE_CLOSE_RE.match(line)
    return bool(m and m.group(1)[0] == char and len(m.group(1)) >= length)


def parse_document(text: str) -> Document:
    """Return the visible title, metadata header, and body.

    The metadata header is only the contiguous blockquote immediately
    after the first H1 outside a fence. A fence is a body boundary; its
    removal must not promote a later blockquote into the header.
    """
    title = None
    header = []
    preamble = []
    body = []
    state = "before_title"
    fence_char = None
    fence_len = 0

    for line in text.splitlines():
        if fence_char:
            if is_fence_closer(line, fence_char, fence_len):
                fence_char = None
            continue
        opener = fence_opener(line)
        if opener:
            fence_char, fence_len = opener
            if state == "after_title":
                state = "body"
            continue
        if state == "before_title":
            if line.startswith("# "):
                title = line
                state = "after_title"
            else:
                preamble.append(line)
        elif state == "after_title":
            if not line.strip():
                continue
            if line.strip().startswith(">"):
                header.append(line)
                state = "header"
            else:
                body.append(line)
                state = "body"
        elif state == "header":
            if line.strip().startswith(">"):
                header.append(line)
            else:
                body.append(line)
                state = "body"
        else:
            body.append(line)

    return Document(title, tuple(header), tuple(preamble + body))


def strip_fences(text: str) -> str:
    """Remove Standard Markdown fenced code blocks."""
    out = []
    fence_char = None
    fence_len = 0
    for line in text.splitlines():
        if fence_char:
            if is_fence_closer(line, fence_char, fence_len):
                fence_char = None
            continue
        opener = fence_opener(line)
        if opener:
            fence_char, fence_len = opener
            continue
        out.append(line)
    return "\n".join(out)


def strip_noise(text: str) -> str:
    text = INLINE_CODE_RE.sub(" ", text)
    text = LINK_RE.sub(r"\1", text)
    return text


def keep_number(token: str) -> bool:
    token = token.strip()
    if SUFFIX_RE.search(token) or "," in token or "." in token:
        return True
    return len(token) >= 4


def extract_numeric_date_candidates(line: str) -> list[Candidate]:
    line = strip_noise(line)
    date_matches = list(DATE_RE.finditer(line))
    candidates = [Candidate("date", m.group(0)) for m in date_matches]
    number_text = list(line)
    for match in date_matches:
        number_text[match.start() : match.end()] = " " * (match.end() - match.start())
    candidates.extend(
        Candidate("number", m.group(0))
        for m in NUMBER_TOKEN_RE.finditer("".join(number_text))
        if keep_number(m.group(0))
    )
    return candidates


def extract_candidates(text: str) -> list[Candidate]:
    document = parse_document(text)
    lines = ([document.title] if document.title else []) + [
        line for line in document.header if not METADATA_RE.match(line.strip())
    ] + list(document.body)
    candidates: list[Candidate] = []
    skip_status_block = False
    blockquote: list[str] = []
    paragraph: list[str] = []

    def flush_blockquote():
        if blockquote:
            joined = normalize(" ".join(blockquote))
            if len(joined) >= 15:
                candidates.append(Candidate("quote", joined))
            blockquote.clear()

    def flush_paragraph():
        if paragraph:
            joined = normalize(" ".join(paragraph))
            for quote_re in QUOTE_RES:
                candidates.extend(
                    Candidate("quote", m.group(1))
                    for m in quote_re.finditer(joined)
                    if len(m.group(1).strip()) >= 15
                )
            paragraph.clear()

    for line in lines:
        stripped = line.strip()
        if STATUS_LINE_RE.match(stripped):
            flush_blockquote()
            flush_paragraph()
            skip_status_block = True
            continue
        if skip_status_block:
            if stripped.startswith(">"):
                continue
            skip_status_block = False
        if stripped.startswith(">"):
            flush_paragraph()
            content = strip_noise(stripped.lstrip(">").strip())
            blockquote.append(content)
            candidates.extend(extract_numeric_date_candidates(content))
            continue
        flush_blockquote()
        if not stripped:
            flush_paragraph()
            continue
        line = strip_noise(line)
        candidates.extend(extract_numeric_date_candidates(line))
        paragraph.append(line)
    flush_blockquote()
    flush_paragraph()
    seen = set()
    unique = []
    for candidate in candidates:
        value = candidate.value.strip().strip(".,;:()[]")
        candidate = Candidate(candidate.kind, value)
        if value and candidate not in seen:
            seen.add(candidate)
            unique.append(candidate)
    return unique


def raw_links_of(article_text: str) -> list[str]:
    """Raw links come only from the metadata header; identical lines in
    the body or in code fences are content, not fields."""
    links = []
    for line in parse_document(article_text).header:
        if re.match(r"^>\s*Raw:", line.strip(), re.IGNORECASE):
            links.extend(RAW_LINK_RE.findall(line))
    return links


def contains(haystack: str, candidate: Candidate) -> bool:
    if candidate.kind == "quote":
        return candidate.value in haystack
    # Values must stand on their own, while sentence punctuation remains
    # valid. A month may not pass as the prefix of a full ISO date.
    right = r"(?!-\d{2})" if candidate.kind == "date" and len(candidate.value) == 7 else ""
    pattern = (
        r"(?<![\d.,])" + re.escape(candidate.value) + right + r"(?![A-Za-z0-9]|[.,]\d|%)"
    )
    return re.search(pattern, haystack) is not None


def source_content(path: Path) -> str:
    """Raw file body with the metadata header removed. Collection
    metadata (Source/Collected/Published) is bookkeeping, not evidence;
    letting it match candidates would false-pass dates and years."""
    document = parse_document(path.read_text(encoding="utf-8-sig"))
    return normalize("\n".join(document.body))


def check_article(article: Path, root: Path) -> tuple[list[str], list[str]]:
    """Return (fidelity suspects, evidence errors) for one article."""
    text = article.read_text(encoding="utf-8-sig")
    links = raw_links_of(text)
    if not links:
        if any(ARCHIVED_RE.match(line.strip()) for line in parse_document(text).header):
            return [], []
        return [], ["article has no Raw field"]
    raw_root = (root / RAW_DIR_NAME).resolve()
    raws = []
    errors = []
    for link in links:
        target = (article.parent / link).resolve()
        try:
            is_rel = target.is_relative_to(raw_root)
        except (AttributeError, ValueError):
            try:
                target.relative_to(raw_root)
                is_rel = True
            except ValueError:
                is_rel = False

        if not is_rel:
            errors.append(f"Raw link escapes raw/: {link}")
        elif not target.is_file():
            errors.append(f"unresolvable Raw link: {link}")
        else:
            raws.append(source_content(target))
    misses = []
    if raws:
        for candidate in extract_candidates(text):
            candidate = Candidate(candidate.kind, normalize(candidate.value))
            if not any(contains(raw, candidate) for raw in raws):
                misses.append(candidate.value)
    return misses, errors


def iter_articles(wiki_dir: Path):
    for path in sorted(wiki_dir.rglob("*.md")):
        if path.relative_to(wiki_dir).as_posix() not in SKIP_FILES:
            yield path


def no_material_paths(log_file: Path) -> set[str]:
    if not log_file.is_file():
        return set()
    paths = set()
    text = strip_fences(log_file.read_text(encoding="utf-8-sig"))
    for line in text.splitlines():
        m = NO_MATERIAL_HEADING_RE.match(line)
        if m:
            paths.add(m.group(1).strip("`;'\", "))
    return paths


def referenced_raws(root: Path) -> set[str]:
    referenced: set[str] = set()
    for article in iter_articles(root / WIKI_DIR_NAME):
        for link in raw_links_of(article.read_text(encoding="utf-8-sig")):
            try:
                target = (article.parent / link).resolve()
                referenced.add(target.as_posix())
            except Exception:
                continue
    return referenced


def unreferenced_raws(root: Path) -> list[str]:
    raw_dir = root / RAW_DIR_NAME
    if not raw_dir.is_dir():
        return []
    referenced = referenced_raws(root)
    # Normalize disposed paths to posix for cross-platform comparison
    disposed_raw = no_material_paths(root / "log.md")
    disposed = {Path(p).as_posix() for p in disposed_raw}
    missing = []
    for path in sorted(raw_dir.rglob("*.md")):
        try:
            resolved_posix = path.resolve().as_posix()
        except Exception:
            resolved_posix = path.as_posix()
        rel_posix = path.relative_to(root).as_posix()
        # Also check disposed with relative posix and with resolved posix
        if resolved_posix not in referenced and rel_posix not in disposed and resolved_posix not in disposed:
            missing.append(rel_posix)
    return missing


def parse_fingerprint_info(article_text: str) -> tuple[str | None, list[str]]:
    """Extract (Fingerprint, [Monitored paths]) from metadata header."""
    fingerprint = None
    monitored = []
    for line in parse_document(article_text).header:
        f_match = FINGERPRINT_RE.match(line.strip())
        if f_match:
            fingerprint = f_match.group(1).strip()
        m_match = MONITORED_RE.match(line.strip())
        if m_match:
            raw_paths = m_match.group(1).strip()
            paths = [p.strip().strip("`\"'") for p in re.split(r"[,;]", raw_paths) if p.strip()]
            monitored.extend(paths)
    return fingerprint, monitored


def check_code_drift(article: Path, root: Path, fingerprint: str, monitored_paths: list[str]) -> list[str]:
    """Check if any monitored files have drifted since fingerprint.
    
    Supports:
      - git: <hash> with Monitored: file1, file2
      - sha256: <hash> with single file (legacy single-file mode)
      - sha256 per-file: Monitored: path:sha256:<hex>, path2:sha256:<hex>
    Returns list of drift warning messages (empty if fresh).
    """
    if not fingerprint or not monitored_paths:
        return []

    drift_messages = []

    if fingerprint.startswith("git:"):
        commit_hash = fingerprint[4:].strip()
        if not commit_hash:
            return ["empty git commit hash in Fingerprint"]

        try:
            res = subprocess.run(
                ["git", "rev-parse", "--is-inside-work-tree"],
                cwd=root,
                capture_output=True,
                text=True,
                timeout=5,
            )
            if res.returncode != 0:
                return []
        except Exception:
            return []

        # Normalize monitored paths to posix-relative form for git (git expects posix)
        normalized_paths = [Path(p).as_posix() for p in monitored_paths]
        # Filter out per-file hash suffix if present (git mode ignores it)
        clean_paths = []
        for p in normalized_paths:
            # If entry is like "src/foo.ts:sha256:abc", extract path part before :sha256:
            m = re.match(r"^(.*?)\s*:\s*sha256:[a-fA-F0-9]{5,}\s*$", p, re.IGNORECASE)
            if m:
                clean_paths.append(m.group(1).strip())
            else:
                # also strip possible inline hash after colon
                clean_paths.append(p.split(":sha256:")[0].strip() if ":sha256:" in p.lower() else p)
        try:
            diff_cmd = ["git", "diff", "--name-only", commit_hash, "--"] + clean_paths
            diff_res = subprocess.run(
                diff_cmd,
                cwd=root,
                capture_output=True,
                text=True,
                timeout=10,
            )
            if diff_res.returncode != 0:
                err_text = diff_res.stderr.strip()
                if "unknown revision" in err_text.lower() or "bad revision" in err_text.lower():
                    return [f"invalid or unknown git commit hash: {commit_hash}"]
                return [f"git diff failed: {err_text}"]

            changed_files = [line.strip() for line in diff_res.stdout.splitlines() if line.strip()]
            for f in changed_files:
                drift_messages.append(f"{f} modified since {fingerprint}")
        except Exception as e:
            drift_messages.append(f"drift check failed: {e}")

    elif fingerprint.startswith("sha256:"):
        # Per-file hash support: Monitored entries may be "path:sha256:hex"
        # Build mapping of path -> expected hash
        fallback_hash = fingerprint[7:].strip().lower()
        # Validate fallback is hex-ish (allow short for tests)
        path_to_hash: dict[str, str] = {}
        clean_monitored: list[str] = []
        for entry in monitored_paths:
            entry_stripped = entry.strip()
            # Detect per-file syntax: <path>:sha256:<hex>
            per_file_match = re.match(r"^(.*?)\s*:\s*sha256:([a-fA-F0-9]{8,})\s*$", entry_stripped, re.IGNORECASE)
            if per_file_match:
                p = per_file_match.group(1).strip().strip("`\"'")
                h = per_file_match.group(2).strip().lower()
                # Normalize path to posix
                p_norm = Path(p).as_posix()
                path_to_hash[p_norm] = h
                clean_monitored.append(p_norm)
            else:
                # Legacy mode: use fingerprint hash for all
                p_norm = Path(entry_stripped.strip("`\"'")).as_posix()
                # If multiple files share single hash, warn that per-file is preferred but still check
                path_to_hash[p_norm] = fallback_hash
                clean_monitored.append(p_norm)

        # If multiple files with identical fallback hash and no per-file hashes, emit guidance on first drift
        # (still check, but user can migrate to per-file syntax)

        for rel_path in clean_monitored:
            expected_hash = path_to_hash.get(rel_path, fallback_hash)
            # Validate hash shape
            if not re.fullmatch(r"[a-f0-9]{8,}", expected_hash or ""):
                drift_messages.append(f"invalid SHA-256 hash for {rel_path}: {expected_hash}")
                continue
            target = (root / rel_path).resolve()
            if not target.is_file():
                drift_messages.append(f"monitored file not found: {rel_path}")
                continue
            try:
                hasher = hashlib.sha256()
                with open(target, "rb") as f:
                    while chunk := f.read(65536):
                        hasher.update(chunk)
                current_hash = hasher.hexdigest().lower()
                if current_hash != expected_hash:
                    drift_messages.append(
                        f"{rel_path} SHA-256 changed (current: {current_hash[:8]}... vs expected: {expected_hash[:8]}...)"
                    )
            except Exception as e:
                drift_messages.append(f"failed to read {rel_path}: {e}")

    return drift_messages


def check_drift_for_article(article: Path, root: Path) -> list[str]:
    text = article.read_text(encoding="utf-8-sig")
    fp, monitored = parse_fingerprint_info(text)
    if fp and monitored:
        return check_code_drift(article, root, fp, monitored)
    return []


def find_project_root(start: Path) -> Path:
    """Nearest ancestor of start containing wiki/ and raw/; else start."""
    for candidate in [start] + list(start.parents):
        if (candidate / "wiki").is_dir() and (candidate / "raw").is_dir():
            return candidate
    return start


def main(argv: list[str]) -> int:
    # Parse strict flags (2-tier): --strict (errors+drift) and --strict-all / --include-suspects
    strict_mode = False
    include_suspects = False
    filtered: list[str] = []
    for arg in argv[1:]:
        if arg == "--strict":
            strict_mode = True
        elif arg in ("--strict-all", "--strict--all", "--include-suspects", "--include_suspects"):
            strict_mode = True
            include_suspects = True
        elif arg.startswith("--"):
            # Unknown flag - keep for error handling below
            if arg in ("--help", "-h"):
                print(__doc__)
                return 0
            print(f"unknown option: {arg}", file=sys.stderr)
            return 1
        else:
            filtered.append(arg)

    root = Path(filtered[0]).resolve() if len(filtered) >= 1 else find_project_root(Path.cwd())
    wiki_dir = root / WIKI_DIR_NAME
    if not wiki_dir.is_dir():
        print(f"no {WIKI_DIR_NAME}/ directory under {root}")
        return 1

    articles = []
    for arg in filtered[1:]:
        path = Path(arg)
        if not path.is_absolute():
            path = root / path
        try:
            if path.resolve().relative_to(wiki_dir).as_posix() in SKIP_FILES:
                print(f"warning: {arg} is an index/log file, skipping", file=sys.stderr)
                continue
        except ValueError:
            pass
        if not path.is_file():
            print(f"warning: article not found: {arg}", file=sys.stderr)
            continue
        articles.append(path)
    if not filtered[1:]:
        articles = list(iter_articles(wiki_dir))

    results = {}
    drift_results = {}
    total_fingerprinted = 0
    for article in articles:
        results[article] = check_article(article, root)
        text = article.read_text(encoding="utf-8-sig")
        fp, _ = parse_fingerprint_info(text)
        if fp:
            total_fingerprinted += 1
            drifts = check_drift_for_article(article, root)
            if drifts:
                drift_results[article] = drifts

    def label(article: Path) -> Path:
        try:
            return article.resolve().relative_to(root)
        except ValueError:
            return article

    print("# Evidence check\n")
    print("## Fidelity suspects")
    suspect_count = 0
    for article, (misses, _) in results.items():
        if misses:
            print(f"\n{label(article)}")
            for miss in misses:
                print(f"- {miss}")
                suspect_count += 1
    if suspect_count == 0:
        print("\n(none)")

    print("\n## Evidence errors")
    error_count = 0
    for article, (_, errors) in results.items():
        if errors:
            print(f"\n{label(article)}")
            for error in errors:
                print(f"- {error}")
                error_count += 1
    if error_count == 0:
        print("(none)")

    print("\n## Unreferenced raw files")
    orphans = unreferenced_raws(root)
    for path in orphans:
        print(f"- {path}")
    if not orphans:
        print("(none)")

    print("\n## Code freshness (Drift detection)")
    drift_count = 0
    if drift_results:
        for article, drifts in drift_results.items():
            print(f"\n{label(article)}")
            for drift in drifts:
                print(f"- ⚠ {drift}")
                drift_count += 1
    elif total_fingerprinted > 0:
        print(f"(all {total_fingerprinted} fingerprinted article(s) are fresh)")
    else:
        print("(none)")

    print(
        f"\n## Summary\n{suspect_count} fidelity suspect(s), "
        f"{error_count} evidence error(s), {len(orphans)} unreferenced raw file(s), "
        f"{drift_count} drifted article(s)"
    )
    if strict_mode:
        has_fault = error_count > 0 or drift_count > 0
        has_suspect_fault = include_suspects and suspect_count > 0
        if has_fault or has_suspect_fault:
            return 1
    return 0


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

