#!/usr/bin/env python3
"""
route — content → ranked candidate homes + computed confidence (vaultq verb; M4).

The retrieval half of the autofile router: given a piece of content about to be
filed, return the existing notes it plausibly belongs to/with — ranked, classed,
and with a *computed* confidence the autofile gate can read. The model still
judges what each piece IS (meeting vs fact vs todo — the AGENTS routing table's
kind axis); route answers deterministically who/what it's about, where those
live, and how ambiguous that is.

Signals (deterministic, no API calls):
  entity   capitalized names / emails / phones / [[wikilinks]] extracted from
           the input, resolved against the vault (resolve's index; entity notes
           beat mentions). Unresolved entities are surfaced — they're the "new
           person/org detected" signal.
  topic    TF-IDF cosine between the input and every note (idf over the live
           corpus; one-vs-all version of link_suggest's similarity signal).
  Candidates hit by both signals get a corroboration bonus.

Confidence (documented, reproducible):
  dom      top-candidate dominance: 1 for a lone candidate, else (s1-s2)/s1.
  ent      share of extracted entities that resolved uniquely (0.5 if none).
  strength top candidate corroborated (1.0) / single-signal (0.6) / none (0).
  confidence = 0.45*dom + 0.30*ent + 0.25*strength → band high ≥0.7,
  med ≥0.45, low else. Low/med → the review queue; high + owner-source is the
  autofile lane (see the Lazy Ingestion plan).

    python -m alexandria_vault.vault_core.query.vaultq route "Met Alex Chen re the pilot pricing"
    python -m alexandria_vault.vault_core.query.vaultq route "@/path/to/dump.md" --explain
"""
import os
import re
import sys
import math
import json
import argparse
from pathlib import Path
from collections import Counter

from .vaultgraph import find_vault_root, parse_wikilinks  # noqa: E402
from . import resolve as _resolve  # noqa: E402
from . import find as _find  # noqa: E402

STOP = set("""a an and are as at be been but by for from has have i if in into
is it its no not of on or so than that the their there these they this to was
were what when where which who will with would you your met with about re
today yesterday tomorrow""".split())
LEAD_STOP = {"the", "a", "an", "this", "that", "my", "our", "his", "her",
             "met", "with", "from", "dear", "hi", "hello", "on", "in", "at"}
CAP_SEQ = re.compile(r"\b[A-Z][\w'’\-]+(?:\s+(?:of|de|la|van|der|dela)\s+[A-Z][\w'’\-]+|\s+[A-Z][\w'’\-]+)+\b")
EMAIL = re.compile(r"[\w.+\-]+@[\w\-]+\.[\w.\-]+")
PHONE = re.compile(r"(?<![\w])[+(]?[\d][\d\s().\-]{6,}\d")
MAX_ENTITIES = 20
TOP_TERMS = 50
UBIQ_DF = 40            # a term in >40 notes has no discriminative power
NEVER_HOMES = {"Home.md", "Action Board.md", "AGENTS.md"}

W_ENT = 0.65            # a resolved entity is a strong filing target on its own
W_TOP = 0.55
W_BOTH = 0.15           # corroboration bonus
BANDS = ((0.70, "high"), (0.45, "med"), (0.0, "low"))


def _read_input(arg):
    if arg.startswith("@"):
        return Path(arg[1:]).expanduser().read_text(encoding="utf-8",
                                                    errors="replace")
    return arg


def extract_entities(text, idx):
    """Candidate entity strings: [[wikilinks]] > emails/phones > capitalized
    multi-word runs (lead-stopword-stripped) > single capitalized words that
    exactly name a known entity. Deduped, capped."""
    out = []
    for w in parse_wikilinks(text):
        out.append(w["target"])
    out += EMAIL.findall(text)
    out += [m.group(0).strip() for m in PHONE.finditer(text)]
    plain = re.sub(r"\[\[[^\]]+\]\]", " ", text)      # don't re-extract links
    for m in CAP_SEQ.finditer(plain):
        words = m.group(0).split()
        while words and words[0].casefold() in LEAD_STOP:
            words.pop(0)
        if len(words) >= 2:
            out.append(" ".join(words))
    known = idx["names"]
    for w in re.findall(r"\b[A-Z][\w'’\-]{2,}\b", plain):
        if _resolve.norm_name(w) in known:
            out.append(w)
    seen, uniq = set(), []
    for e in out:
        k = _resolve.norm_name(e)
        if k and k not in seen:
            seen.add(k)
            uniq.append(e)
    return uniq[:MAX_ENTITIES]


def _vectorize(notes, text):
    """Input TF-IDF vector + per-note sparse vectors over the input's terms
    (idf from the live corpus; ubiquitous terms skipped)."""
    words = [w for w in re.findall(r"[a-z][a-z0-9\-']{2,}", text.casefold())
             if w not in STOP]
    tf = Counter(words)
    if not tf:
        return {}, {}
    df = Counter()
    lows = {}
    for n in notes:
        low = (n["title"] + " " + n["body"]).casefold()
        lows[n["rel"]] = low
        for t in tf:
            if t in low:
                df[t] += 1
    N = max(1, len(notes))
    qv = {}
    total = sum(tf.values())
    for t, f in tf.items():
        if df[t] == 0 or df[t] > UBIQ_DF:
            continue
        # +1-smoothed idf (same as find.py) — without it, a term present in
        # most of a small corpus zeroes out, which is exactly the twin-note case
        qv[t] = (f / total) * (math.log(N / (1 + df[t])) + 1.0)
    qv = dict(sorted(qv.items(), key=lambda kv: -kv[1])[:TOP_TERMS])
    qn = math.sqrt(sum(v * v for v in qv.values())) or 1.0
    qv = {t: v / qn for t, v in qv.items()}

    nvecs = {}
    for n in notes:
        low = lows[n["rel"]]
        v = {}
        for t, qw in qv.items():
            c = low.count(t)
            if c:
                v[t] = min(c, 5) * (math.log(N / (1 + df[t])) + 1.0)
        if v:
            norm = math.sqrt(sum(x * x for x in v.values())) or 1.0
            nvecs[n["rel"]] = {t: x / norm for t, x in v.items()}
    return qv, nvecs


def _class_of(rel, type_):
    top = rel.replace(os.sep, "/").split("/")[0]
    return {"person": "People", "wiki-article": "Knowledge", "entity": "Entity",
            "concept": "Concept", "area": "Area", "project": "Project",
            "todo-tracker": "TODO", "timeline": "Events",
            "daily-note": "Daily"}.get(
        type_, {"Events": "Events", "Daily": "Daily",
                "People": "People", "Knowledge": "Knowledge"}.get(top, "Area record"))


def route(root, content, limit=10, explain=False, notes=None, idx=None):
    """Rank candidate homes for `content`. Returns {confidence, band, dom, ent,
    strength, candidates:[{rel,title,type,class,score,why}], entities:
    {resolved:[{query,rel,how}], ambiguous:[q], unresolved:[q]}} (+ explain
    extras). Candidates are homes to merge into / link from — kind judgment and
    the final filing plan stay with the model.

    ``notes``/``idx`` inject a prebuilt corpus + entity index. One call builds
    them itself (sub-second, the documented contract); a **bulk** caller filing
    N items must hoist them, or every item re-walks the whole vault — O(N ×
    corpus). :class:`alexandria_vault.ingest.IngestIndex` owns that lifecycle,
    including the overlay that makes a note filed at item 3 visible at item 40.
    """
    if notes is None:
        notes = _find.scan(root)
    notes = [n for n in notes
             if n["rel"] not in NEVER_HOMES and n["type"] != "todo-archive"]
    meta = {n["rel"]: n for n in notes}
    if idx is None:
        idx = _resolve.build_entity_index(root)

    entities = extract_entities(content, idx)
    resolved, ambiguous, unresolved = [], [], []
    ent_hits = {}                                   # rel -> (score, via-query)
    for e in entities:
        scored = [(rel, s, how) for rel, (s, how) in _resolve._score(idx, e).items()
                  if rel in meta]
        scored.sort(key=lambda r: (-r[1], r[0]))
        exact = [r for r in scored if r[1] >= _resolve.EXACT]
        top = scored[0] if scored else None
        ok = None
        if len(exact) == 1:
            ok = exact[0]
        elif not exact and top and top[1] >= _resolve.PARTIAL and (
                len(scored) == 1 or top[1] - scored[1][1] >= _resolve.DOMINANCE):
            ok = top
        if ok:
            rel, s, how = ok
            resolved.append({"query": e, "rel": rel, "how": how})
            if rel not in ent_hits or s > ent_hits[rel][0]:
                ent_hits[rel] = (s, e)
        elif scored:
            ambiguous.append(e)
        else:
            unresolved.append(e)

    qv, nvecs = _vectorize(notes, content)
    # ≥2 shared terms to count as a topical candidate — a single rare-term
    # overlap saturates cosine and isn't topical relatedness (find covers it)
    cos = {rel: round(sum(qv[t] * v[t] for t in v), 3)
           for rel, v in nvecs.items() if len(v) >= 2}

    cands = {}
    for rel, (s, via) in ent_hits.items():
        cands[rel] = {"ent": s, "via": via}
    for rel, c in cos.items():
        if c >= 0.10:
            cands.setdefault(rel, {})["cos"] = c

    rows = []
    for rel, sig in cands.items():
        ent = sig.get("ent", 0.0)
        c = sig.get("cos", 0.0)
        both = bool(ent) and c >= 0.10
        score = min(1.0, W_ENT * ent + W_TOP * c + (W_BOTH if both else 0.0))
        why = []
        if ent:
            why.append(f'entity:{sig["via"]}')
        if c >= 0.10:
            why.append(f"cos:{c}")
        if both:
            why.append("corroborated")
        rows.append({"rel": rel, "title": meta[rel]["title"],
                     "type": meta[rel]["type"],
                     "class": _class_of(rel, meta[rel]["type"]),
                     "score": round(score, 3), "why": "+".join(why),
                     "_both": both})
    rows.sort(key=lambda r: (-r["score"], r["rel"]))

    if rows:
        s1 = rows[0]["score"]
        dom = 1.0 if len(rows) == 1 else max(0.0, (s1 - rows[1]["score"]) / s1)
        strength = 1.0 if rows[0]["_both"] else 0.6
    else:
        dom, strength = 0.0, 0.0
    n_ent = len(entities)
    ent_conf = (len(resolved) / n_ent) if n_ent else 0.5
    confidence = round(0.45 * dom + 0.30 * ent_conf + 0.25 * strength, 3)
    band = next(b for floor, b in BANDS if confidence >= floor)

    for r in rows:
        r.pop("_both")
    out = {"confidence": confidence, "band": band,
           "dom": round(dom, 3), "ent": round(ent_conf, 3),
           "strength": strength,
           "candidates": rows[:limit],
           "entities": {"resolved": resolved, "ambiguous": ambiguous,
                        "unresolved": unresolved}}
    if explain:
        out["explain"] = {"extracted": entities,
                          "top_terms": list(qv)[:15],
                          "topical_hits": sum(1 for c in cos.values() if c >= 0.10)}
    return out


def print_pretty(out):
    e = out["entities"]
    print(f'confidence {out["confidence"]} ({out["band"]})  '
          f'[dom {out["dom"]} · ent {out["ent"]} · strength {out["strength"]}]')
    if e["unresolved"]:
        print(f'  ⚠ new entities (no note): {", ".join(e["unresolved"])}')
    if e["ambiguous"]:
        print(f'  ? ambiguous: {", ".join(e["ambiguous"])}')
    for c in out["candidates"]:
        print(f'  {c["score"]:5.2f}  [[{c["title"]}]]  '
              f'({c["class"]} · {c["type"] or "?"})  {c["why"]}')
    if "explain" in out:
        x = out["explain"]
        print(f'  -- extracted: {x["extracted"]}')
        print(f'  -- top terms: {x["top_terms"]}')
        print(f'  -- topical hits ≥0.10: {x["topical_hits"]}')


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("content", help="text to route, or @/path/to/file")
    ap.add_argument("--limit", type=int, default=10)
    ap.add_argument("--explain", action="store_true")
    ap.add_argument("--pretty", action="store_true")
    args = ap.parse_args()
    out = route(find_vault_root(), _read_input(args.content),
                limit=args.limit, explain=args.explain)
    print_pretty(out) if args.pretty else print(json.dumps(out, indent=1))


if __name__ == "__main__":
    main()
