#!/usr/bin/env python3
"""
resolve — entity → canonical note (vaultq verb; M2).

The dedup / entity guard: given a name, alias, email, or phone, find the vault
note it refers to. The single most-reused retrieval primitive — every ingestion
resolves people/orgs against it *before* creating a note, so a lazy dump doesn't
fragment into duplicates.

Normalization is baked in (the stuff grep gets wrong):
  - names/aliases  case-folded; exact, then token-subset (a lone first name),
                   then a stdlib difflib fuzzy fallback for typos/variants
  - email          case-insensitive exact; company domain as a weak signal
  - phone          all formatting stripped, matched on the contiguous-digit tail
                   — formatted phones defeat naive contiguous-digit search

Read-only; stdlib-only; no API calls; deterministic. Reuses vaultgraph (M0) for
the walk + frontmatter parse. A full frontmatter pass is sub-second at
typical vault sizes; an index can drop under this interface later unchanged.

    python -m alexandria_vault.vault_core.query.resolve "Alex Chen" --pretty
    python -m alexandria_vault.vault_core.query.resolve "+1 415 555 0100"
    python -m alexandria_vault.vault_core.query.vaultq resolve "alex@example.com"
"""
import os
import re
import sys
import json
import argparse
import difflib
from pathlib import Path

from .vaultgraph import (find_vault_root, split_frontmatter, parse_aliases,  # noqa: E402
                        EXCLUDE_DIRS, FIXTURE_PREFIX, quarantine_prefixes,
                        is_quarantined)
from ..fs import vault_root, within_vault  # noqa: E402

# --kind → the type tokens it admits (best-effort; org spans several).
KIND_TYPES = {
    "person": {"person"},
    "area": {"area"},
    "org": {"wiki-article", "entity", "partner", "vendor"},
    "any": None,
}

EXACT = 1.0
# Partial/fuzzy matches on a *canonical-entity* note (the note that IS the entity)
# rank decisively above the same match on a dated record that merely mentions the
# name in its title — the entity guard wants the entity, not the meeting about it.
ENTITY_TYPES = {"person", "area", "entity", "wiki-article", "partner",
                "vendor", "concept"}
PARTIAL = 0.85          # floor to be "confident enough" for a medium match
PARTIAL_ENTITY = 0.9    # query tokens ⊆ an entity note's name (e.g. a first name)
PARTIAL_OTHER = 0.55    # …⊆ a dated-record title that only mentions the name
FUZZY_MIN = 0.82        # difflib ratio floor for a typo/variant candidate
DOMAIN = 0.3            # email-domain-only (weak — many share a company domain)
DOMINANCE = 0.15        # a non-exact top must beat #2 by this to auto-resolve

_DOMAIN_RE = re.compile(r"^[a-z0-9][a-z0-9.\-]*\.[a-z]{2,}$", re.I)


def norm_name(s):
    return " ".join(s.casefold().split())


def name_tokens(s):
    return set(re.findall(r"[a-z0-9]+", s.casefold()))


def norm_email(s):
    s = (s or "").strip().lower()
    return s if "@" in s else None


def email_domain(s):
    return s.split("@", 1)[1] if s and "@" in s else None


def norm_phone(s):
    """(full_digits, tail7) or (None, None) if fewer than 7 digits."""
    d = re.sub(r"\D", "", s or "")
    return (d, d[-7:]) if len(d) >= 7 else (None, None)


def _is_email(q):
    return "@" in q


def _is_phone(q):
    return not re.search(r"[a-zA-Z]", q) and len(re.sub(r"\D", "", q)) >= 7


def _is_domain(q):
    return "@" not in q and bool(_DOMAIN_RE.match(q.strip()))


def build_entity_index(root):
    """One frontmatter pass → lookup tables + per-note metadata."""
    root = Path(root)
    quarantine = quarantine_prefixes(root)
    jail_root = vault_root(root)
    names = {}      # norm name  -> set(rel)   (basename + aliases + name field)
    toks = []       # (rel, token_set, norm_primary) for partial/fuzzy
    emails = {}     # norm email -> set(rel)
    domains = {}    # domain     -> set(rel)
    phones = {}     # tail7      -> set(rel)
    meta = {}       # rel        -> {title, type, email, phone, aliases}
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
        for fname in filenames:
            if not fname.endswith(".md"):
                continue
            rel = os.path.relpath(os.path.join(dirpath, fname), root)
            if rel.startswith(FIXTURE_PREFIX) or is_quarantined(rel, quarantine):
                continue
            # Out-of-vault symlink — see find.scan. Entity data (names, emails,
            # phones) is exactly what must not be harvested from outside.
            if not within_vault(root, os.path.join(dirpath, fname), root=jail_root):
                continue
            raw = (root / rel).read_text(encoding="utf-8", errors="replace")
            fm, _ = split_frontmatter(raw)
            title = os.path.splitext(os.path.basename(rel))[0]
            aliases = parse_aliases(fm)
            nm = fm.get("name", "").strip()
            for n in [title] + aliases + ([nm] if nm else []):
                names.setdefault(norm_name(n), set()).add(rel)
            primary = nm or title
            toks.append((rel, name_tokens(primary), norm_name(primary)))
            em = norm_email(fm.get("email", ""))
            if em:
                emails.setdefault(em, set()).add(rel)
                dom = email_domain(em)
                if dom:
                    domains.setdefault(dom, set()).add(rel)
            _, tail = norm_phone(fm.get("phone", ""))
            if tail:
                phones.setdefault(tail, set()).add(rel)
            meta[rel] = {"title": title, "type": fm.get("type", ""),
                         "email": fm.get("email", ""),
                         "phone": fm.get("phone", ""), "aliases": aliases}
    return dict(names=names, toks=toks, emails=emails, domains=domains,
                phones=phones, meta=meta)


def _score(idx, query):
    """{rel: (score, how)} — best matcher per rel."""
    cand = {}

    def bump(rel, score, how):
        if rel not in cand or score > cand[rel][0]:
            cand[rel] = (score, how)

    q = query.strip()
    qnorm = norm_name(q)
    qtokens = name_tokens(q)

    if _is_email(q):
        em = norm_email(q)
        for rel in idx["emails"].get(em, ()):
            bump(rel, EXACT, "email")
        for rel in idx["domains"].get(email_domain(em) or "", ()):
            bump(rel, DOMAIN, f"domain:{email_domain(em)}")
    elif _is_domain(q):
        for rel in idx["domains"].get(q.lower(), ()):
            bump(rel, DOMAIN, f"domain:{q.lower()}")

    if _is_phone(q):
        _, tail = norm_phone(q)
        for rel in idx["phones"].get(tail, ()):
            bump(rel, EXACT, "phone")

    for rel in idx["names"].get(qnorm, ()):
        bump(rel, EXACT, "name")

    if qtokens:
        for rel, ntoks, nnorm in idx["toks"]:
            if qnorm == nnorm:
                continue                       # already exact above
            is_entity = idx["meta"][rel]["type"] in ENTITY_TYPES
            if qtokens <= ntoks:               # e.g. a lone first name
                bump(rel, PARTIAL_ENTITY if is_entity else PARTIAL_OTHER, "partial")
            else:
                r = difflib.SequenceMatcher(None, qnorm, nnorm).ratio()
                if r >= FUZZY_MIN:
                    bump(rel, round(r * (0.95 if is_entity else 0.7), 3), "fuzzy")
    return cand


def resolve(root, query, kind="any", limit=8):
    """Resolve `query` to a canonical note. Returns
    {query, kind, match: {rel,title,type,score,how,confidence}|None, candidates:[…]}.
    `match` is set only on a confident, unambiguous hit (a unique exact, or a
    dominant partial/fuzzy); a collision or weak field leaves it None with the
    shortlist in `candidates` for the caller to judge."""
    idx = build_entity_index(root)
    allow = KIND_TYPES.get(kind)
    meta = idx["meta"]
    rows = []
    for rel, (score, how) in _score(idx, query).items():
        t = meta[rel]["type"]
        if allow is not None and t not in allow:
            continue
        rows.append({"rel": rel, "title": meta[rel]["title"], "type": t,
                     "score": round(score, 3), "how": how})
    rows.sort(key=lambda r: (-r["score"], r["rel"]))

    match = None
    if rows:
        top = rows[0]
        exact = [r for r in rows if r["score"] >= EXACT]
        if len(exact) == 1 and top["score"] >= EXACT:
            match = {**top, "confidence": "high"}
        elif not exact and top["score"] >= PARTIAL and (
                len(rows) == 1 or top["score"] - rows[1]["score"] >= DOMINANCE):
            match = {**top, "confidence": "medium"}
    return {"query": query, "kind": kind, "match": match,
            "candidates": rows[:limit]}


def print_pretty(out):
    q, m = out["query"], out["match"]
    if m:
        print(f'✓ {q!r} → [[{m["title"]}]]  '
              f'({m["type"] or "?"} · {m["confidence"]} · via {m["how"]})')
    elif out["candidates"]:
        print(f'? {q!r} → no confident match; {len(out["candidates"])} candidate(s):')
    else:
        print(f'✗ {q!r} → no match (new entity)')
    for c in out["candidates"]:
        print(f'   {c["score"]:.2f}  [[{c["title"]}]]  '
              f'({c["type"] or "?"} · via {c["how"]})  {c["rel"]}')


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("query")
    ap.add_argument("--kind", choices=list(KIND_TYPES), default="any")
    ap.add_argument("--limit", type=int, default=8)
    ap.add_argument("--pretty", action="store_true", help="human output")
    args = ap.parse_args()
    out = resolve(find_vault_root(), args.query, kind=args.kind, limit=args.limit)
    print_pretty(out) if args.pretty else print(json.dumps(out, indent=1))


if __name__ == "__main__":
    main()
