"""Find library models by meaning when a game's words hit no alias or class word (18 Oct 2026, Part C).

Pure Python, no numpy (the Factory image has none, and gex44 publishes index.json + blobs but no vectors, so there is no
query embedding on the Factory side). BM25 over name, tags, summary and visual_description of the assets the caller already
cleared (reuse.omg, status, setting). Deterministic: same words + same index = same hits (ties break on the asset id).

Laws:
- a score floor: a word that names nothing in the library returns nothing (a GAP stays a GAP, never a lookalike);
- a word too common in the library ("wooden", "white") is not a search word;
- a hit needs the word itself in the asset's name or tags (a plain-words match), or a rare word in its description.

Word map (18 Oct 2026, Part C2): `word_map` = search/word_map.json, built at publish time by tools/build_word_map.py (every
everyday word embedded and matched to the assets by meaning, so "settee" finds the sofas and "lorry" the trucks). A word looks
up the map first, then BM25; map hits lead, ranked by their stored score, then BM25 hits the map did not bring. The map holds
only hits above its own calibrated threshold, so a word naming nothing ("cow") is still absent. No file = BM25 only.

Optional vectors: `search(..., query_vec=..., vectors=...)` blends cosine similarity when a caller has a query embedding and
asset vectors (dict id -> list of floats); without them it is BM25 only. Nothing here calls a network.
"""

from __future__ import annotations

import math
import re
from typing import Any, Iterable

STOP = set("a an and the of with for to in on at by from my our some big small little old new one two three is are it its as "
           "be that this these those you your they their there into over under out up down not no or but if then so very more".split())
# words of a game brief that are never objects of the library
BRIEF_WORDS = set("""game games player players play playing level levels score scores point points win lose winning build make made
create world story style fun fast slow time timer button click tap touch swipe move jump run shoot collect avoid enemy enemies
goal goals mode mission missions quest quests hero boss bosses character characters camera view first third person top down
player's multiplayer single coin coins life lives health bonus power up powerup upgrade upgrades menu screen simple easy hard
cute cool nice beautiful colourful colorful bright dark light day night sunny rainy cozy calm relaxing challenging""".split())
SEM_FLOOR = 2.2        # BM25F score a name/tag hit must reach
DESC_FLOOR = 4.7       # a description-only hit (rare word) must reach this
DESC_MAX_DF = 2        # a description-only hit needs a word in at most this many assets
MAX_DF = 0.04          # a word in more than 4% of the assets is not a search word
PER_TERM = 2           # assets one unmatched word may bring
K1, B = 1.4, 0.6
W_NAME, W_TAG, W_TEXT = 3.0, 2.0, 1.0
BLEND = 0.5            # weight of the vector score when vectors are present


def tokens(text: str) -> list[str]:
    """Same plural stemming as games_library.tokens (kept here so this module stands alone)."""
    out = []
    for t in re.split(r"[^a-z0-9]+", (text or "").lower()):
        if len(t) < 3 or t in STOP:  # "keeper's" leaves a lone "s"
            continue
        if len(t) > 3 and t.endswith("ies"):
            t = t[:-3] + "y"
        elif len(t) > 3 and t.endswith("es") and not t.endswith("ses"):
            t = t[:-2] if t[:-2].endswith(("ch", "sh", "x")) else t[:-1]
        elif len(t) > 3 and t.endswith("s") and not t.endswith("ss"):
            t = t[:-1]
        out.append(t)
    return out


class Corpus:
    """BM25F statistics over a list of assets. Build once per (index, usable set); searches are cheap."""

    def __init__(self, assets: Iterable[dict]):
        self.docs: list[tuple[dict, dict[str, float], set[str], float]] = []
        df: dict[str, int] = {}
        for a in sorted(assets, key=lambda x: x["id"]):
            name = tokens((a.get("name") or "") + " " + a["id"].split("/")[-1].replace("-", " "))
            tags = tokens(" ".join(a.get("tags") or []))
            summ, vis = a.get("summary") or "", a.get("visual_description") or ""
            text = tokens(summ + ("" if vis == summ else " " + vis))
            tf: dict[str, float] = {}
            for w in name:
                tf[w] = tf.get(w, 0) + W_NAME
            for w in tags:
                tf[w] = tf.get(w, 0) + W_TAG
            for w in text:
                tf[w] = tf.get(w, 0) + W_TEXT
            strong = set(name) | set(tags)
            length = sum(tf.values())
            self.docs.append((a, tf, strong, length))
            for w in tf:
                df[w] = df.get(w, 0) + 1
        self.n = len(self.docs)
        self.byid = {d[0]["id"]: d[0] for d in self.docs}
        self.df = df
        self.avg = (sum(d[3] for d in self.docs) / self.n) if self.n else 1.0

    def idf(self, w: str) -> float:
        d = self.df.get(w, 0)
        return math.log(1 + (self.n - d + 0.5) / (d + 0.5))

    def searchable(self, w: str) -> bool:
        """A word the library knows and that is not everywhere."""
        d = self.df.get(w, 0)
        return 0 < d <= max(2, int(MAX_DF * self.n))

    def term_hits(self, w: str) -> list[tuple[float, dict]]:
        """(score, asset) for one stemmed word, best first, floor applied."""
        if not self.searchable(w):
            return []
        idf, hits = self.idf(w), []
        for a, tf, strong, length in self.docs:
            f = tf.get(w, 0.0)
            if not f:
                continue
            s = idf * f * (K1 + 1) / (f + K1 * (1 - B + B * length / self.avg))
            if w not in strong and self.df[w] > DESC_MAX_DF:
                continue
            floor = SEM_FLOOR if w in strong else DESC_FLOOR
            if s >= floor:
                hits.append((s, a))
        hits.sort(key=lambda h: (-h[0], h[1]["id"]))
        return hits


def _cos(u: list[float], v: list[float]) -> float:
    nu = math.sqrt(sum(x * x for x in u)) or 1.0
    nv = math.sqrt(sum(x * x for x in v)) or 1.0
    return sum(x * y for x, y in zip(u, v)) / (nu * nv)


def map_hits(corpus: Corpus, word_map: dict | None, w: str) -> list[tuple[float, dict]]:
    """(score, asset) the word map holds for stemmed word/phrase `w`, only assets the caller cleared, best first."""
    rows = ((word_map or {}).get("words") or {}).get(w) or []
    hits = [(float(sc), corpus.byid[i]) for i, sc in rows if i in corpus.byid]
    hits.sort(key=lambda h: (-h[0], h[1]["id"]))
    return hits


def search_terms(corpus: Corpus, terms: Iterable[str], per_term: int = PER_TERM, limit: int = 8,
                 query_vec: list[float] | None = None, vectors: dict[str, list[float]] | None = None,
                 skip: set[str] | None = None, word_map: dict | None = None) -> list[tuple[str, dict, float]]:
    """[(term, asset, score)] for stemmed `terms` (a term may be a two-word phrase the map knows), in term order.
    `skip` = ids already picked (not repeated). With a `word_map`, a term's map hits come before its BM25 hits."""
    out, seen = [], set(skip or ())
    for w in dict.fromkeys(terms):
        if w in STOP or w in BRIEF_WORDS:
            continue
        mapped = map_hits(corpus, word_map, w)
        known = {a["id"] for _, a in mapped}
        hits = mapped + [h for h in corpus.term_hits(w) if h[1]["id"] not in known]
        if query_vec and vectors:  # blend: re-rank the word's own hits, never add a vector-only lookalike
            hits = sorted(((s + BLEND * 10 * max(0.0, _cos(query_vec, vectors[a["id"]])) if a["id"] in vectors else s, a)
                           for s, a in hits), key=lambda h: (-h[0], h[1]["id"]))
        n = 0
        for s, a in hits:
            if a["id"] in seen:
                continue
            out.append((w, a, round(s, 3)))
            seen.add(a["id"])
            n += 1
            if n >= per_term or len(out) >= limit:
                break
        if len(out) >= limit:
            break
    return out


def with_phrases(toks: list[str]) -> list[str]:
    """Stemmed words plus their two-word neighbours ("rowing boat"), each phrase just before its first word."""
    out = []
    for i, w in enumerate(toks):
        if i + 1 < len(toks) and w and toks[i + 1]:  # "" = a word something else already named: it breaks a phrase
            out.append(w + " " + toks[i + 1])
        if w:
            out.append(w)
    return out


def search(assets: Iterable[dict], text: str, **kw: Any) -> list[tuple[str, dict, float]]:
    """Convenience: every word of `text` is a query word."""
    toks = tokens(text)
    return search_terms(Corpus(assets), with_phrases(toks) if kw.get("word_map") else toks, **kw)


# ======================================================================================================================
# Engine side (everything above is a verbatim copy of omg/factory/library_search.py in forge-assets; a test keeps them equal)
# ======================================================================================================================
import argparse
import json
import os
import sys
import time
import urllib.request
from pathlib import Path

BASE = os.environ.get("FORGE_LIBRARY_BASE", "https://infer.ohmyunicorn.com/library").rstrip("/")
CACHE = Path(os.environ.get("FORGE_LIBRARY_CACHE", "/tmp/forge-library-index.json"))
WORD_CACHE = Path(os.environ.get("FORGE_LIBRARY_WORD_CACHE", "/tmp/forge-library-word-map.json"))
CACHE_TTL_S = 600
OK_STATUS = ("approved", "legacy")


def load_index(fetch=None, base: str = BASE, cache: Path | None = CACHE) -> dict:
    """The published index.json (cached 10 min on disk). fetch(url) -> bytes is injectable for tests."""
    if cache is not None:
        try:
            if time.time() - cache.stat().st_mtime < CACHE_TTL_S:
                return json.loads(cache.read_text())
        except (OSError, ValueError):
            pass
    fetch = fetch or (lambda u: urllib.request.urlopen(urllib.request.Request(u, headers={"User-Agent": "forge-engine"}), timeout=30).read())
    raw = fetch(f"{base}/repo/index.json")
    data = json.loads(raw)
    if cache is not None:
        try:
            cache.write_text(raw.decode() if isinstance(raw, bytes) else raw)
        except OSError:
            pass
    return data


def load_word_map(fetch=None, base: str = BASE, cache: Path | None = WORD_CACHE) -> dict | None:
    """The published search/word_map.json (optional: any failure = BM25 only, as before the map existed). Cached 10 min."""
    if cache is not None:
        try:
            if time.time() - cache.stat().st_mtime < CACHE_TTL_S:
                return json.loads(cache.read_text())
        except (OSError, ValueError):
            pass
    fetch = fetch or (lambda u: urllib.request.urlopen(urllib.request.Request(u, headers={"User-Agent": "forge-engine"}), timeout=30).read())
    try:
        raw = fetch(f"{base}/repo/search/word_map.json")
        data = json.loads(raw)
    except Exception:  # noqa: BLE001
        return None
    if cache is not None:
        try:
            cache.write_text(raw.decode() if isinstance(raw, bytes) else raw)
        except OSError:
            pass
    return data


def usable(a: dict, kind: str | None = None) -> bool:
    """reuse.omu false = opted out; candidates and retired assets never. kind = id prefix filter (e.g. "prop.")."""
    r = a.get("reuse") or {}
    return (r.get("omu", a.get("game_use", False)) and a.get("status") in OK_STATUS and bool(a.get("glb"))
            and (not kind or a["id"].startswith(kind)))


def find(index: dict, query: str, kind: str | None = None, limit: int = 5, word_map: dict | None = None) -> list[tuple[float, dict]]:
    """Assets for a free-text query: every query word is searched, scores add up, a word below the floor adds nothing."""
    corpus = Corpus(a for a in index["assets"] if usable(a, kind))
    scores: dict[str, float] = {}
    byid: dict[str, dict] = {}
    toks = tokens(query)
    # a word that names a library class (e.g. "dragon" = monster.flyer.dragon) gets that class or a GAP, never a lookalike
    named: dict[str, list[str]] = {}
    for kind_, node in (index.get("tree") or {}).items():
        for grp, g in (node.get("groups") or {}).items():
            for cls in g.get("classes") or {}:
                named.setdefault(cls.replace("-", " "), []).append(f"{kind_}.{grp}.{cls}")
    for w in dict.fromkeys(with_phrases(toks) if word_map else toks):
        if w in STOP:
            continue
        if w in named:
            own = [d[0] for d in corpus.docs if d[0]["id"].split("/")[0] in named[w]]
            for a in own:
                scores[a["id"]] = scores.get(a["id"], 0.0) + 10.0
                byid[a["id"]] = a
            continue
        mapped = map_hits(corpus, word_map, w)
        known = {a["id"] for _, a in mapped}
        # a map hit scores its stored cosine on a 0-10 scale beside BM25 (0.7 -> 7); BM25 hits the map did not bring add as before
        for s, a in [(sc * 10, a) for sc, a in mapped] + [h for h in corpus.term_hits(w) if h[1]["id"] not in known]:
            scores[a["id"]] = scores.get(a["id"], 0.0) + s
            byid[a["id"]] = a
    ranked = sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))[:limit]
    return [(round(s, 2), byid[i]) for i, s in ranked]


def describe(score: float, a: dict) -> str:
    sz = a.get("size") or {}
    return (f"{a['id']} | {a.get('name', '')} | {sz.get('w', '?')}x{sz.get('l', '?')}x{sz.get('h', '?')} m (w x l x h) | "
            f"{a.get('tris', '?')} tris | {round((a.get('bytes') or 0) / 1024)} KB | {(a.get('summary') or '')[:110]}")


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(description="Search the Forge 3D library (read-only). Prints id | name | size | cost | summary.")
    ap.add_argument("query", nargs="+", help='one query per argument, e.g. "wardrobe" "street lantern"')
    ap.add_argument("--kind", help='id prefix filter: "prop.", "building.", "vehicle."')
    ap.add_argument("--limit", type=int, default=5)
    args = ap.parse_args(argv)
    try:
        index = load_index()
    except Exception as exc:  # noqa: BLE001
        print(f"library unreachable ({type(exc).__name__}); build the object yourself", file=sys.stderr)
        return 0
    word_map = load_word_map()
    for q in args.query:
        hits = find(index, q, args.kind, args.limit, word_map)
        print(f"## {q}")
        print("\n".join(describe(s, a) for s, a in hits) if hits else "GAP: nothing in the library names this. Build or generate it yourself.")
    return 0


if __name__ == "__main__":
    sys.exit(main())
