#!/usr/bin/env python3
"""
find — ranked keyword search over the vault (vaultq verb; M3).

Replaces `grep -rl` as the model's default search: one call returns ranked hits
with paths + snippets + scores instead of a wall of matching lines. Searches
titles, aliases, and bodies; understands the vault's shape (type/area/status/tag
filters, date-led filenames); prefers title hits over body hits and rarer terms
over ubiquitous ones (df-weighted).

Scoring (deterministic, explainable):
  - title/alias tier: exact title/alias = top; all query tokens in the title =
    strong; some tokens = proportional.
  - body tier: per-token occurrences (capped) x idf, + a phrase bonus when the
    exact query string appears.
  - light recency tilt (filename date if `YYYY-MM-DD `-led, else file mtime).
  - scores are normalized to the top hit (top = 1.0) — relative rank, not an
    absolute quality scale.

Read-only; stdlib-only; no API calls. Full-corpus pass ≈ the spider's cold-read
(~0.4s today); the M1 SQLite index makes it sub-50ms without changing this
interface.

    python -m alexandria_vault.vault_core.query.vaultq find "pilot pricing" --limit 10
    python -m alexandria_vault.vault_core.query.vaultq find "Alex" --type meeting-note
    python -m alexandria_vault.vault_core.query.vaultq find "audit" --area Acme --since 2026-06-01
"""
import os
import re
import sys
import math
import json
import argparse
from datetime import date, datetime
from pathlib import Path
from collections import Counter

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

# Re-exported, not redefined: `passages` has to locate the very tokens that
# ranked a note, so the two must tokenize identically by construction.
TOKEN = _passages.TOKEN
FILENAME_DATE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})\b")
TF_CAP = 5                  # per-token occurrence cap (one note can't win on spam)
W_TITLE_EXACT = 100.0
W_TITLE_ALL = 30.0
W_TITLE_TOKEN = 8.0
W_PHRASE = 10.0
RECENCY = ((30, 3.0), (90, 2.0), (365, 1.0))   # days-ago ceiling -> bonus


def tokens(s):
    return TOKEN.findall(s.casefold())


def parse_inline_list(raw):
    """Frontmatter inline list (`[a, b]` / bare scalar) → list. Same quote-aware
    grammar as aliases — reuse the shared parser."""
    return parse_aliases({"aliases": raw or ""})


def note_date(rel, path):
    """Date-led filename wins (the vault's dated-record convention); else mtime."""
    m = FILENAME_DATE.match(os.path.basename(rel))
    if m:
        try:
            return date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
        except ValueError:
            pass
    try:
        return datetime.fromtimestamp(path.stat().st_mtime).date()
    except OSError:
        return None


def area_of(rel):
    """The vault region a note belongs to: Areas/<Area>/… → the area name;
    a top-level pillar (People/Knowledge/Events/Daily/Archive/Agent) → itself."""
    parts = rel.replace(os.sep, "/").split("/")
    if parts[0] == "Areas" and len(parts) > 1:
        return parts[1]
    return parts[0] if len(parts) > 1 else ""


def scan(root):
    """One pass → per-note record: title, aliases, fm fields, body, date."""
    root = Path(root)
    quarantine = quarantine_prefixes(root)
    jail_root = vault_root(root)
    out = []
    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
            # A symlinked note whose real target leaves the vault is not this
            # vault's content — same guard build_graph and analyze_links already
            # apply. It was missing HERE, so `find` (and `route`/`dupes`, which
            # scan through this) read and returned out-of-vault files while
            # read_file and vault_search refused them (v2.110.1).
            if not within_vault(root, os.path.join(dirpath, fname), root=jail_root):
                continue
            p = root / rel
            raw = p.read_text(encoding="utf-8", errors="replace")
            fm, body = split_frontmatter(raw)
            title = os.path.splitext(fname)[0]
            out.append({
                "rel": rel, "title": title,
                "aliases": parse_aliases(fm),
                "type": fm.get("type", ""), "status": fm.get("status", ""),
                "tags": [t.casefold() for t in parse_inline_list(fm.get("tags", ""))],
                "area": area_of(rel),
                "body": body, "date": note_date(rel, p),
            })
    return out


def snippet_for(body, qtokens, query_norm, width=200):
    """The best matching line (most distinct query tokens; phrase line wins),
    trimmed. Falls back to the first non-empty line."""
    best, best_key = None, (-1, -1)
    for line in body.splitlines():
        low = line.casefold()
        hits = sum(1 for t in qtokens if t in low)
        if not hits:
            continue
        key = (1 if query_norm in low else 0, hits)
        if key > best_key:
            best_key, best = key, line.strip()
    if best is None:
        best = next((ln.strip() for ln in body.splitlines() if ln.strip()), "")
    return best[:width]


def find(root, query, type_=None, area=None, status=None, tag=None,
         since=None, limit=20, passages=0, require_all=False):
    """Ranked search. Returns {query, total_hits, hits: [{rel, title, type,
    area, date, score, why, snippet}]}. Scores normalized to the top hit.

    ``passages=N`` additionally attaches up to N scored spans per hit —
    ``{text, line_start, line_end, score, terms}``, file-absolute line numbers
    ready for ``read_file(start=, end=)``. Costs one re-read per returned hit
    (not per note scanned), so it is priced by ``limit``, and is off by default
    because the one-line ``snippet`` is enough to triage a filename match.
    """
    notes = scan(root)
    qnorm = " ".join(tokens(query))
    qtokens = list(dict.fromkeys(tokens(query)))   # unique, order kept
    if not qtokens:
        return {"query": query, "total_hits": 0, "hits": []}

    # document frequency per query token (over bodies+titles) for idf weighting
    df = Counter()
    for n in notes:
        hay = (n["title"] + " " + " ".join(n["aliases"])).casefold() \
              + " " + n["body"].casefold()
        for t in qtokens:
            if t in hay:
                df[t] += 1
    N = max(1, len(notes))
    idf = {t: math.log(N / (1 + df[t])) + 1.0 for t in qtokens}

    try:
        since_d = date.fromisoformat(since) if since else None
    except ValueError:
        raise ValueError(f"since must be an ISO date (YYYY-MM-DD), got {since!r}")
    today = date.today()
    rows = []
    for n in notes:
        if type_ and n["type"] != type_:
            continue
        if area and n["area"].casefold() != area.casefold():
            continue
        if status and n["status"] != status:
            continue
        if tag and tag.casefold() not in n["tags"]:
            continue
        if since_d and (n["date"] is None or n["date"] < since_d):
            continue

        title_norms = [" ".join(tokens(x)) for x in [n["title"]] + n["aliases"]]
        ttokens = set(tokens(n["title"] + " " + " ".join(n["aliases"])))
        low = n["body"].casefold()
        # AND mode (2026-08-22): default scoring is OR — any term earns a place,
        # so a multi-word query almost never returns zero hits and the verb
        # cannot certify ABSENCE (a nonsense query scored 150+ in the reference
        # vault). With require_all every term must appear; the output carries
        # mode:"all". rg -F stays the right tool for exact strings and regex.
        if require_all and not all(t in ttokens or t in low for t in qtokens):
            continue
        why = []
        score = 0.0

        if qnorm and qnorm in title_norms:
            score += W_TITLE_EXACT
            why.append("title-exact")
        elif all(t in ttokens for t in qtokens):
            score += W_TITLE_ALL
            why.append("title-all")
        else:
            k = sum(1 for t in qtokens if t in ttokens)
            if k:
                score += W_TITLE_TOKEN * k
                why.append(f"title:{k}tok")

        body_pts = sum(min(low.count(t), TF_CAP) * idf[t] for t in qtokens)
        if body_pts:
            score += body_pts
            why.append(f"body:{round(body_pts, 1)}")
        if len(qtokens) > 1 and qnorm in " ".join(tokens(n["body"])):
            score += W_PHRASE
            why.append("phrase")

        if score <= 0:
            continue
        if n["date"]:
            days = (today - n["date"]).days
            for ceil, bonus in RECENCY:
                if days <= ceil:
                    score += bonus
                    why.append(f"recent≤{ceil}d")
                    break

        rows.append({"rel": n["rel"], "title": n["title"], "type": n["type"],
                     "area": n["area"],
                     "date": n["date"].isoformat() if n["date"] else None,
                     "score": score, "why": "+".join(why),
                     "snippet": snippet_for(n["body"], qtokens, qnorm)})

    rows.sort(key=lambda r: (-r["score"], r["rel"]))
    total = len(rows)
    rows = rows[:limit]
    top = rows[0]["score"] if rows else 1.0
    for r in rows:
        r["score"] = round(r["score"] / top, 3)
    if passages:
        # Re-read the surviving hits: `scan` keeps only the body, and a passage's
        # line numbers have to count from the first byte of the FILE (frontmatter
        # included) or they address the wrong lines through read_file.
        for r in rows:
            raw = (Path(root) / r["rel"]).read_text(encoding="utf-8",
                                                    errors="replace")
            r["passages"] = _passages.passages(raw, query, idf=idf,
                                               limit=int(passages))
    out = {"query": query, "total_hits": total, "hits": rows}
    if require_all:
        out["mode"] = "all"
    return out


def print_pretty(out):
    print(f'{out["total_hits"]} hit(s) for {out["query"]!r}'
          + (f' (showing {len(out["hits"])})' if out["total_hits"] > len(out["hits"]) else ""))
    for h in out["hits"]:
        d = f' · {h["date"]}' if h["date"] else ""
        print(f'  {h["score"]:5.2f}  [[{h["title"]}]]  ({h["type"] or "?"} · {h["area"]}{d})')
        print(f'         {h["why"]}  |  {h["snippet"][:120]}')
        for p in h.get("passages", []):
            where = f' · {p["section"]}' if p["section"] else ""
            print(f'         └ L{p["line_start"]}-{p["line_end"]}{where}')
            for line in p["text"].splitlines()[:4]:
                print(f'           {line[:110]}')


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("query")
    ap.add_argument("--type", dest="type_")
    ap.add_argument("--area")
    ap.add_argument("--status")
    ap.add_argument("--tag")
    ap.add_argument("--since", help="YYYY-MM-DD — only notes dated on/after")
    ap.add_argument("--limit", type=int, default=20)
    ap.add_argument("--pretty", action="store_true")
    args = ap.parse_args()
    out = find(find_vault_root(), args.query, type_=args.type_, area=args.area,
               status=args.status, tag=args.tag, since=args.since,
               limit=args.limit)
    print_pretty(out) if args.pretty else print(json.dumps(out, indent=1))


if __name__ == "__main__":
    main()
