"""Vault graph — wikilink parsing, the note index, and link analysis.

Absorbs the two pre-existing implementations VERBATIM (docs/V3-PLAN.md §2.3
"absorb, don't reinvent"); tests/test_vault_core.py runs the template originals
side-by-side on a fixture and asserts identical output:

- ``note_index`` ports sweep.py ``_note_index`` — the link-repair resolver
  (lowercased stems + aliases; accent/punct-normalized stem → canonical stem,
  ambiguous keys dropped).
- ``analyze_links`` ports vault_integrity.py's graph subset — fence-aware link
  extraction, broken-link classification (link_errors vs true_missing),
  duplicate basenames, orphans.

The originals deliberately disagree on edge cases, and both behaviors are
preserved rather than papered over:
- sweep's ``_normalize`` strips accents ([[Guácimo]] can repair to Guacimo.md);
  integrity's ``norm`` does not (the same link classes as not-yet-created).
- sweep's alias parser splits naively on commas; integrity's is quote-aware
  (a quoted "A, B" alias stays one alias).
- sweep scans raw text (links in code fences count); integrity strips fenced +
  inline code first.

``build_graph``/``parse_wikilinks`` are the net-new spider surface
(docs/V3-P4-RESEARCH.md), built on the integrity-side parser.
"""
import os
import re
import unicodedata
from collections import defaultdict
from pathlib import Path

from . import fs
from . import ignore as _ignore
from . import wikilink as _wl

# ---- sweep.py side (parity: _note_index) ------------------------------------

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


def _normalize(s):
    """Casefold + strip accents + collapse non-alphanumerics (sweep semantics)."""
    s = unicodedata.normalize("NFKD", s or "")
    s = "".join(c for c in s if not unicodedata.combining(c))
    return re.sub(r"[^a-z0-9]+", " ", s.lower()).strip()


def _parse_list(raw):
    """Inline YAML list, naive comma split (sweep semantics)."""
    raw = raw.strip()
    if raw.startswith("["):
        raw = raw[1:-1] if raw.endswith("]") else raw[1:]
    return [x.strip().strip("\"'") for x in raw.split(",") if x.strip().strip("\"'")]


def note_index(vault):
    """(resolve, norm_to_stem) — see sweep.py ``_note_index``. ``resolve`` is what
    Obsidian resolves case-insensitively (stems + aliases); ``norm_to_stem`` maps
    normalized stems to the canonical stem for single-candidate repair."""
    vault = Path(vault)
    jail_root = fs.vault_root(vault)
    resolve, norm_to_stem, collide = set(), {}, set()
    for p in vault.rglob("*.md"):
        # Skip every excluded container, not just .git/.obsidian: .claude/ holds
        # full worktree COPIES of the vault and .alexandria/ holds the software
        # tree in single-folder installs — stems from either shadow real notes.
        if any(part in EXCLUDE_DIRS for part in p.parts):
            continue
        # Out-of-vault symlink — the guard analyze_links below already applies.
        # rglob does not descend a symlinked DIRECTORY, but it does yield a
        # symlinked FILE, and this reads each one's head for aliases.
        if not fs.within_vault(vault, p, root=jail_root):
            continue
        stem = p.stem
        resolve.add(stem.lower())
        ns = _normalize(stem)
        if ns in norm_to_stem and norm_to_stem[ns] != stem:
            collide.add(ns)
        norm_to_stem.setdefault(ns, stem)
        try:
            head = p.read_text(encoding="utf-8")
        except Exception:
            continue
        if head.startswith("---"):
            am = re.search(r"(?m)^aliases:\s*(.+)$", head.split("\n---", 1)[0])
            if am:
                for a in _parse_list(am.group(1)):
                    resolve.add(a.lower())
    for ns in collide:
        norm_to_stem.pop(ns, None)  # >1 candidate → never auto-pick
    return resolve, norm_to_stem


# ---- vault_integrity.py side (parity: the analyze() graph subset) ------------

EXCLUDE_DIRS = {".git", ".obsidian", ".trash", ".claude", ".state", ".alexandria", "node_modules", ".venv"}

# The parser lives in one place now (vault_core.wikilink) — these names stay
# bound here because they are part of this module's long-standing surface.
FENCE = _wl.FENCE
INLINE = _wl.INLINE
WIKILINK = _wl.WIKILINK
PLACEHOLDER_RE = re.compile(r"[<>…]|\.\.\.")
PLACEHOLDER_EXACT = {
    "person", "name", "org", "area", "x", "a", "b", "source note",
    "person note", "their-name", "title", "embed", "wikilink", "<area> todo",
}


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


_split_frontmatter = _wl.split_frontmatter
_links_in = _wl.links_in


def _norm_squash(s):
    """integrity's ``norm``: lowercase, drop every non-alphanumeric (no accent strip)."""
    return re.sub(r"[^a-z0-9]", "", s.lower())


def _collect(root: Path):
    # Corpus policy is shared with the query layer (vault_core.ignore): engine
    # scripts, fixtures and user-ignored paths are in the folder but are not
    # notes. This module used to skip that check, so a fixture note showed up in
    # links_get/graph_get and in the think_deep spider's reachable universe
    # while vaultq_neighbors never saw it.
    patterns = _ignore.ignore_prefixes(root)
    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)
            if _ignore.is_ignored(rel, patterns):
                continue
            p = Path(dirpath) / name
            if p.is_symlink() and not fs._within_root(Path(root), p):
                continue   # a symlink out of the vault is not this vault's content
            (md_files if name.endswith(".md") else attach).append(rel)
    return md_files, attach


_parse_aliases_quote_aware = _wl.parse_aliases


def _analyze(root: Path):
    """The integrity graph pipeline, with per-note outlinks recorded for the spider."""
    root = Path(root)
    md_files, attach = _collect(root)
    by_base = defaultdict(list)
    for rel in md_files:
        by_base[_wl.resolve_key(_base_noext(rel))].append(rel)

    attach_names = set()
    for rel in attach:
        b = os.path.basename(rel)
        attach_names.add(_wl.resolve_key(b))
        attach_names.add(_wl.resolve_key(os.path.splitext(b)[0]))

    notes, fm_links, aliases = {}, {}, defaultdict(list)
    for rel in md_files:
        raw = (root / rel).read_text(encoding="utf-8", errors="replace")
        fm, body, terminated, present = _split_frontmatter(raw)
        notes[rel] = body
        # `area:`/`parent:`/`related:` wikilinks are real edges — they are
        # wikilinks in the file's bytes, which is a format convention and not a
        # property of any editor. Scanning them here is what stopped links_get /
        # graph_get / think_deep being blind to the structural edges of a PEAK
        # vault while vaultq_neighbors saw them (docs/MCP-TOOL-SURFACE.md §7).
        fm_links[rel] = _wl.frontmatter_targets(raw)
        if "aliases" in fm:
            for a in _parse_aliases_quote_aware(fm["aliases"]):
                aliases[_wl.resolve_key(a)].append(rel)

    resolvable = set(by_base) | set(aliases)

    broken = defaultdict(list)
    inbound = defaultdict(set)
    outlinks = defaultdict(list)
    unresolved = defaultdict(list)
    for rel, body in notes.items():
        # frontmatter targets ride the same resolution path as body links; they
        # carry no alias/anchor, so they arrive already cleaned
        body_links = [(raw.strip(), _wl.clean_target(raw)) for _e, raw in _links_in(body)]
        for raw_t, t in body_links + [(t, t) for t in fm_links.get(rel, ())]:
            if not t:
                continue
            key = _wl.resolve_key(t)
            if key in resolvable:
                for tgt in by_base.get(key, []) + aliases.get(key, []):
                    inbound[tgt].add(rel)
                    outlinks[rel].append(tgt)
                continue
            # Attachments are checked only AFTER note resolution fails. Checking
            # first meant a stray `notes.txt` anywhere in the vault silently
            # killed every `[[Notes]]` edge in it, even with `Notes.md` present.
            if key in attach_names or _wl.resolve_key(t) in attach_names:
                continue
            if PLACEHOLDER_RE.search(raw_t) or key in PLACEHOLDER_EXACT:
                continue
            broken[t].append(rel)
            unresolved[rel].append(t)

    guide = lambda r: _base_noext(r) == "AGENTS"  # noqa: E731
    content_broken = {t: [s for s in ss if not guide(s)] for t, ss in broken.items()}
    content_broken = {t: ss for t, ss in content_broken.items() if ss}

    norm_index = defaultdict(list)
    for b in by_base:
        norm_index[_norm_squash(b)].append(b)
    for a in aliases:
        norm_index[_norm_squash(a)].append(a)
    link_errors, true_missing = {}, {}
    for t, ss in content_broken.items():
        n = _norm_squash(t)
        if n and norm_index.get(n):
            link_errors[t] = (norm_index[n][0], len(ss))
        else:
            true_missing[t] = len(ss)

    dup_basenames = {b: p for b, p in by_base.items() if len(p) > 1}

    ignore = ("Daily/", "Events/", "Agent/")
    orphans = []
    for rel in md_files:
        parent = os.path.basename(os.path.dirname(rel))
        if _base_noext(rel) == parent or rel in ("AGENTS.md", "Action Board.md"):
            continue
        if any(rel.startswith(p) for p in ignore) or rel.endswith("TODO.md"):
            continue
        if not inbound.get(rel):
            orphans.append(rel)

    return dict(
        n_notes=len(md_files), n_attach=len(attach),
        content_broken=content_broken, link_errors=link_errors, true_missing=true_missing,
        dup_basenames=dup_basenames, orphans=orphans,
        inbound=inbound, outlinks=outlinks, unresolved=unresolved,
        md_files=md_files,
    )


def analyze_links(root):
    """The vault_integrity graph subset, byte-compatible with the original
    ``analyze()`` on these keys: n_notes, n_attach, content_broken, link_errors,
    true_missing, dup_basenames, orphans."""
    a = _analyze(root)
    return {k: a[k] for k in ("n_notes", "n_attach", "content_broken", "link_errors",
                              "true_missing", "dup_basenames", "orphans")}


def build_graph(root):
    """The spider's view: per-note ``outlinks`` (resolved, vault-relative),
    ``backlinks`` (sorted), and ``unresolved`` (raw broken targets)."""
    a = _analyze(root)
    return {
        "outlinks": {rel: sorted(set(t)) for rel, t in a["outlinks"].items()},
        "backlinks": {rel: sorted(srcs) for rel, srcs in a["inbound"].items()},
        "unresolved": {rel: sorted(set(t)) for rel, t in a["unresolved"].items()},
    }


def parse_wikilinks(text):
    """Wikilinks in one note body (fence/inline-code-stripped): dicts of
    ``target``, ``heading``, ``alias``, ``embed``. See :mod:`vault_core.wikilink`."""
    return _wl.parse_wikilinks(text)


def graph_snapshot(root):
    """The RENDER view of the vault graph — what a graph drawing shows.

    Every note is a node (orphans included, unlike ``build_graph``'s link-bearing
    dicts); edges are merged directed pairs with self-loops dropped, exactly the
    reduction the desktop app's ``buildGraph()`` applies before drawing; and
    ``degree`` counts each note's merged in+out edges — the number that sizes a
    node in both the desktop views (``graphTypes.ts``: "total resolved wikilinks
    in + out") and the ``graph_image`` engine.

    Returns ``{"nodes": [rel, …], "edges": [(src, tgt), …], "degree": {rel: n},
    "unresolved": {rel: [raw target, …]}}`` — nodes sorted, edges in sorted-source
    order, so equal vaults yield byte-equal snapshots.
    """
    a = _analyze(root)
    nodes = sorted(a["md_files"])
    known = set(nodes)
    seen, edges = set(), []
    for src in nodes:
        for tgt in a["outlinks"].get(src, []):
            if tgt == src or tgt not in known or (src, tgt) in seen:
                continue
            seen.add((src, tgt))
            edges.append((src, tgt))
    degree = {n: 0 for n in nodes}
    for s, t in edges:
        degree[s] += 1
        degree[t] += 1
    unresolved = {rel: sorted(set(ts)) for rel, ts in a["unresolved"].items() if ts}
    return {"nodes": nodes, "edges": edges, "degree": degree, "unresolved": unresolved}
