#!/usr/bin/env python3
"""
vaultgraph — the canonical vault parse + graph core (vaultq library).

The one place the reference vault parses notes and builds the wikilink graph, so
its consumers share a single implementation instead of each duplicating parsers:

  - ../vault-review/spider.py        graph-traversal / ingest-cost profiler
  - ../vault-review/link_suggest.py  missing-link suggester
  - ../vault-review/brain_boost.py   with/without-vault eval
  - the coming vaultq verbs          resolve / find / neighbors / route / load

Absorbed ~verbatim from the reference vault (per this repo's "absorb, don't
reinvent" doctrine) and generalized: no owner identity, generic quarantine
fallback, Config-sentinel-aware root finder. Read-only;
stdlib-only python3; runs anywhere. Same wikilink-parse conventions as the
integrity check (display aliases, heading anchors, escaped pipes, frontmatter
aliases, code fences skipped) and the machine-snapshot quarantine (Obsidian's
`userIgnoreFilters`, read at runtime; frozen fallback below).

Engine reconciliation (M6): this converges with
`~/Code/alexandria/alexandria_vault/vault_core/graph.py` (the "share" path).
NOTE the two `build_graph` shapes differ *by design* — this module's is the
profiler-rich variant `(notes, by_name, inbound, read_s)`, a superset of the
engine's `build_graph → {outlinks, backlinks, unresolved}` (already carried here
by `notes[rel]["out"]` + `inbound`). `parse_wikilinks` matches the engine's
contract shape so the coming verbs and the M6 absorption line up.
"""
import os
import re
import json
import time
from pathlib import Path
from collections import defaultdict

from ...tokens import CHARS_PER_TOKEN as _CHARS_PER_TOKEN
from .. import ignore as _ignore
from .. import wikilink as _wl
from ..fs import _within_root

EXCLUDE_DIRS = {".git", ".obsidian", ".trash", ".claude",
                ".state", ".alexandria", "node_modules", ".venv"}  # .state: parity w/ vault_core.graph
FIXTURE_PREFIX = os.path.join("Agent", "scripts", "tests", "fixtures")

# Machine-snapshot / tooling quarantine (root AGENTS.md § Machine snapshots):
# recurring generated artifacts are not brain content. The authoritative list is
# Obsidian's own ignore list (`userIgnoreFilters` in `.obsidian/app.json` — path
# prefixes, kept current by the vault-snapshot-system registration flow), so the
# graph these tools measure is the graph Obsidian shows; the frozen copy below is
# the fallback for checkouts without the Obsidian config.
QUARANTINE_FALLBACK = (
    "Agent/scripts/",
    ".state/",
)

# Rough English prose ratio (chars per token) — used by bfs' token accounting.
# Re-exported, not redefined: the ratio now lives in :mod:`alexandria_vault.tokens`
# so a budget quoted here, by `load`, by the model loop, and by a paged tool
# result all mean the same thing. Kept as a module attribute because callers
# (and tests) read `vaultgraph.CHARS_PER_TOKEN`.
CHARS_PER_TOKEN = _CHARS_PER_TOKEN


def quarantine_prefixes(root: Path):
    """Deprecated alias — corpus policy lives in :mod:`vault_core.ignore` now, so
    vault_core.graph and this module cannot disagree about what counts as a note."""
    return _ignore.ignore_prefixes(root)


def is_quarantined(rel: str, prefixes) -> bool:
    return _ignore.is_ignored(rel, prefixes)


def find_vault_root() -> Path:
    env = os.environ.get("VAULT_DIR")
    if env:
        return Path(env).expanduser()
    here = Path(__file__).resolve()
    for parent in here.parents:
        if (parent / "Areas").is_dir() and (
                (parent / "AGENTS.md").is_file()
                or (parent / "Agent" / "config.json").is_file()):
            return parent
    return Path.cwd()


# The parser is shared with vault_core.graph (vault_core.wikilink) — these two
# modules kept byte-identical copies of these regexes and then drifted on
# frontmatter, which is exactly the class of bug one module prevents.
FENCE = _wl.FENCE
INLINE = _wl.INLINE
WIKILINK = _wl.WIKILINK


def links_in(body):
    """Resolved-target strings for every wikilink in a body (fence/inline-code
    stripped; display alias, escaped pipe, and heading anchor dropped)."""
    return _wl.targets_in(body)


def split_frontmatter(text):
    """(frontmatter dict of top-level scalar keys, body). Light YAML — no nesting.

    This module's 2-tuple shape is load-bearing for the query verbs; the shared
    parser returns the richer 4-tuple, so drop the flags here."""
    fm, body, _terminated, _present = _wl.split_frontmatter(text)
    return fm, body


def parse_aliases(fm):
    """Frontmatter `aliases:` → list. Quote-aware inline list (a quoted "A, B"
    stays one alias); bare scalar → single alias. Takes the parsed frontmatter
    DICT (vault_core.graph passes the raw value instead)."""
    return _wl.parse_aliases(fm.get("aliases", ""))


def build_graph(root: Path):
    """Read every note once (timed) and resolve outgoing wikilinks.

    Returns ``(notes, by_name, inbound, read_s)``:
      - ``notes``   ``{rel: {words, chars, targets, out}}`` — ``out`` is the list
        of resolved vault-relative link targets.
      - ``by_name`` ``{lowercased basename/alias: rel}`` (first writer wins).
      - ``inbound`` ``defaultdict(set)`` ``{rel: {rels linking to it}}``.
      - ``read_s``  wall-clock of the disk read+parse pass.
    """
    t0 = time.perf_counter()
    notes = {}                      # rel -> dict(words, chars, targets, out)
    by_name = {}                    # lowercase basename/alias -> rel
    md_files = []
    quarantine = quarantine_prefixes(root)
    for dirpath, dirnames, filenames in os.walk(root):   # followlinks=False: a
        dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]   # symlinked
        for name in filenames:                                         # dir is never
            if not name.endswith(".md"):                               # descended
                continue
            rel = os.path.relpath(os.path.join(dirpath, name), root)
            if is_quarantined(rel, quarantine):
                continue
            # A symlinked note whose real target leaves the vault is not this
            # vault's content. fs.glob/tree already drop these, so links_get and
            # the research frontier never saw them — the query verbs did, and
            # then read them (docs/MCP-TOOL-SURFACE.md §7).
            p = Path(dirpath) / name
            if p.is_symlink() and not _within_root(Path(root), p):
                continue
            md_files.append(rel)

    for rel in md_files:
        raw = (root / rel).read_text(encoding="utf-8", errors="replace")
        fm, body = split_frontmatter(raw)
        base = os.path.splitext(os.path.basename(rel))[0]
        # frontmatter wikilinks (area:/parent:/related: properties) are real
        # graph edges — vault_core.graph reads them the same way now, through
        # the one shared parser
        notes[rel] = {
            "words": len(raw.split()),
            "chars": len(raw),
            "targets": _wl.frontmatter_targets(raw) + links_in(body),
        }
        by_name.setdefault(_wl.resolve_key(base), rel)
        for a in parse_aliases(fm):
            by_name.setdefault(_wl.resolve_key(a), rel)

    # resolve targets -> rel paths (drop unresolved / attachment links)
    for rel, d in notes.items():
        seen, resolved = set(), []
        for t in d["targets"]:
            # path-qualified targets ([[Areas/X/Foo]]) resolve by basename, matching
            # vault_core.graph._analyze; a flat by_name.get(t.lower()) would drop the edge
            key = _wl.resolve_key(t)
            hit = by_name.get(key)
            if hit and hit != rel and hit not in seen:
                seen.add(hit)
                resolved.append(hit)
        d["out"] = resolved
    read_s = time.perf_counter() - t0

    inbound = defaultdict(set)
    for rel, d in notes.items():
        for tgt in d["out"]:
            inbound[tgt].add(rel)
    return notes, by_name, inbound, read_s


def bfs(notes, seed_rel):
    """BFS over the agent view (follow outgoing links) from ``seed_rel``.
    Returns ``(hops, visited)`` — ``hops`` is a per-depth list of
    ``{hop, new_notes, cum_notes, cum_words, cum_tokens}``, ``visited`` the
    reachable-set."""
    hops = []
    visited = {seed_rel}
    frontier = [seed_rel]
    depth = 0
    cum_words = cum_chars = 0
    while frontier:
        new_words = sum(notes[r]["words"] for r in frontier)
        new_chars = sum(notes[r]["chars"] for r in frontier)
        cum_words += new_words
        cum_chars += new_chars
        hops.append({
            "hop": depth,
            "new_notes": len(frontier),
            "cum_notes": len(visited),
            "cum_words": cum_words,
            "cum_tokens": int(cum_chars / CHARS_PER_TOKEN),
        })
        nxt = []
        for r in frontier:
            for tgt in notes[r]["out"]:
                if tgt not in visited:
                    visited.add(tgt)
                    nxt.append(tgt)
        frontier = nxt
        depth += 1
    return hops, visited


def parse_wikilinks(text):
    """Structured wikilinks in one note body — engine-contract shape (mirrors
    `vault_core/graph.parse_wikilinks`): dicts of ``target``, ``heading``,
    ``alias``, ``embed``. Fence/inline-code stripped first (as in ``links_in``).

    Companion to ``links_in`` (which returns bare resolved-target strings): this
    keeps the embed flag, display alias, and heading anchor for the verbs that
    need them (neighbors/route)."""
    b = INLINE.sub(" ", FENCE.sub(" ", text))
    out = []
    for embed, inner in WIKILINK.findall(b):
        raw = inner.strip().replace("\\|", "|")
        target, alias = (raw.split("|", 1) + [None])[:2]
        target, heading = (target.split("#", 1) + [None])[:2]
        target = (target or "").strip()
        if not target:
            continue
        out.append({
            "target": target,
            "heading": heading.strip() if heading else None,
            "alias": alias.strip() if alias else None,
            "embed": embed == "!",
        })
    return out
