"""library_search + the by-meaning step of games_library.pick (18 Oct 2026, Part C)."""

import pytest

from forge_corp.api.routes import games_library as gl
from forge_corp.api.routes import library_search as ls


def A(cls, slug, name=None, tags=None, summary="", visual="", omg=True, status="approved"):
    return {"id": f"{cls}/{slug}", "name": name or slug.replace("-", " "), "summary": summary, "visual_description": visual,
            "tags": tags if tags is not None else slug.split("-"), "status": status, "game_use": True, "kit": "village-kit",
            "reuse": {"omg": omg, "omu": True}, "bytes": 1000, "glb": slug, "release": "sha256:" + slug}


TREE = {"prop": {"groups": {"decor": {"classes": ["ornament"]}, "container": {"classes": ["barrel", "crate"]},
                            "street": {"classes": ["bench"]}}},
        "animal": {"groups": {"farm": {"classes": ["cow"]}}}}


def idx(*assets):
    return {"tree": TREE, "aliases": {"cask": "prop.container.barrel"}, "assets": list(assets)}


def ids(picks):
    return [a["id"] for a in picks]


FILLER = [A("prop.container.crate", f"crate-{i}", tags=["crate", "box"]) for i in range(30)]
BIG = [A("prop.container.crate", f"box-{i}", tags=["box"]) for i in range(400)]  # a rare word needs a library-sized corpus
GAZEBO = A("prop.decor.ornament", "garden-gazebo", name="Garden gazebo", tags=["gazebo", "garden", "ornament"],
           summary="Octagonal wooden gazebo with a shingle roof.")


def test_word_without_alias_or_class_finds_the_model_by_name():
    # the old pick() only knew aliases and class names: "gazebo" is neither, so nothing came
    picks, _, _ = gl.pick(idx(GAZEBO, *FILLER), "a quiet garden with a gazebo")
    assert "prop.decor.ornament/garden-gazebo" in ids(picks)
    old, _, _ = gl.pick(idx(GAZEBO, *FILLER), "a quiet garden with a gazebo", semantic=False)
    assert "prop.decor.ornament/garden-gazebo" not in ids(old)


def test_rare_description_word_finds_the_model():
    hall = A("prop.decor.ornament", "fish-hall", tags=["hall"], visual="Open-sided pavilion with a slate roof.")
    picks, _, _ = gl.pick(idx(hall, *FILLER, *BIG), "an open pavilion by the sea")
    assert ids(picks) == ["prop.decor.ornament/fish-hall"]


def test_semantic_picks_come_after_and_never_remove_alias_picks():
    cask = A("prop.container.barrel", "oak-barrel", tags=["barrel"])
    base, _, _ = gl.pick(idx(cask, GAZEBO, *FILLER), "a cellar of casks", semantic=False)
    more, _, _ = gl.pick(idx(cask, GAZEBO, *FILLER), "a cellar of casks and a gazebo")
    assert ids(base) == ["prop.container.barrel/oak-barrel"]
    assert ids(more)[0] == "prop.container.barrel/oak-barrel" and "prop.decor.ornament/garden-gazebo" in ids(more)


def test_no_match_no_pick_floor():
    picks, _, gaps = gl.pick(idx(GAZEBO, *FILLER), "a dragon hunting game in a volcano")
    assert picks == [] and gaps == []


def test_common_words_are_not_search_words():
    # "wooden" sits in every summary: a word everywhere names nothing
    many = [A("prop.container.crate", f"c{i}", tags=["crate"], summary="a wooden thing") for i in range(30)]
    picks, _, _ = gl.pick(idx(*many), "a wooden world")
    assert picks == []


def test_class_with_no_asset_stays_a_gap_not_a_lookalike():
    # "crate" is a class here but holds nothing usable; an item merely mentioning crates in its description must not stand in
    stand_in = A("prop.decor.ornament", "box-shaped-thing", tags=["thing"], summary="looks like a crate, rather like a bench")
    picks, classes, gaps = gl.pick(idx(stand_in), "stack the crate and the bench")
    assert picks == [] and sorted(gaps) == ["prop.container.crate", "prop.street.bench"]


def test_class_of_a_kind_the_library_does_not_bring_is_no_lookalike():
    toy = A("prop.decor.ornament", "toy-cow-shelf", name="Toy cow shelf", tags=["cow", "toy"])
    picks, _, _ = gl.pick(idx(toy, *FILLER), "a farm with a cow")  # animal.farm.cow is the Cast's, not the library's
    assert picks == []


def test_reuse_omg_false_never_picked():
    off = A("prop.decor.ornament", "garden-gazebo", tags=["gazebo"], omg=False)
    picks, _, _ = gl.pick(idx(off, *FILLER), "a gazebo")
    assert picks == []


def test_deterministic():
    i = idx(GAZEBO, *FILLER, A("prop.decor.ornament", "stone-gazebo", tags=["gazebo"]))
    a = ids(gl.pick(i, "gazebo garden gazebo")[0])
    assert a == ids(gl.pick(i, "gazebo garden gazebo")[0])
    assert sorted(a) == ["prop.decor.ornament/garden-gazebo", "prop.decor.ornament/stone-gazebo"]


def test_everyday_ambiguous_word_is_not_searched():
    well = A("prop.decor.ornament", "well-lit-lamp", tags=["well"])
    assert gl.pick(idx(well, *FILLER), "a calm puzzle game as well")[0] == []


def test_semantic_budget():
    many = [A("prop.decor.ornament", f"gazebo-{i:02d}", tags=["gazebo"]) for i in range(10)]
    picks, _, _ = gl.pick(idx(*many, *FILLER), "gazebo")
    assert len(picks) <= gl.SEMANTIC_MAX


def test_tokens_stem_and_drop_one_letter():
    assert ls.tokens("keeper's gazebos and boxes") == ["keeper", "gazebo", "box"]


def test_vectors_only_rerank_never_add():
    c = ls.Corpus([GAZEBO, A("prop.decor.ornament", "stone-gazebo", tags=["gazebo"]), A("prop.decor.ornament", "other", tags=["x"]), *BIG])
    vecs = {"prop.decor.ornament/stone-gazebo": [1.0, 0.0], "prop.decor.ornament/other": [1.0, 0.0],
            "prop.decor.ornament/garden-gazebo": [0.0, 1.0]}
    hits = ls.search_terms(c, ["gazebo"], query_vec=[1.0, 0.0], vectors=vecs)
    assert [a["id"] for _, a, _ in hits][0] == "prop.decor.ornament/stone-gazebo"
    assert "prop.decor.ornament/other" not in [a["id"] for _, a, _ in hits]


# ---- word map (Part C2): meaning lookup without a runtime embedder ------------------------------------------------------

SOFA = A("prop.decor.ornament", "store-sofa-3", name="Store sofa", tags=["sofa"])
BOAT = A("prop.decor.ornament", "harbour-rowboat", name="Harbour rowboat", tags=["rowboat"])
WMAP = {"words": {"settee": [["prop.decor.ornament/store-sofa-3", 0.726]], "rowing boat": [["prop.decor.ornament/harbour-rowboat", 0.744]],
                  "nowhere": [["prop.decor.ornament/gone-model", 0.9]]}}


def test_word_map_finds_a_synonym_bm25_cannot():
    lib = idx(SOFA, BOAT, *FILLER)
    assert "prop.decor.ornament/store-sofa-3" not in ids(gl.pick(lib, "a cosy room with a settee")[0])  # BM25 alone: no shared word
    assert "prop.decor.ornament/store-sofa-3" in ids(gl.pick(lib, "a cosy room with a settee", word_map=WMAP)[0])


def test_word_map_phrase_and_missing_file_and_unknown_asset():
    lib = idx(SOFA, BOAT, *FILLER)
    assert "prop.decor.ornament/harbour-rowboat" in ids(gl.pick(lib, "a pond with a rowing boat", word_map=WMAP)[0])
    assert ids(gl.pick(lib, "a room with a settee", word_map=None)[0]) == ids(gl.pick(lib, "a room with a settee", semantic=False)[0])
    assert ids(gl.pick(lib, "a nowhere", word_map=WMAP)[0]) == ids(gl.pick(lib, "a nowhere", semantic=False)[0])  # map id not in library


def test_word_map_laws_gap_reuse_and_old_picks_stay():
    cow_map = {"words": {"cow": [["prop.decor.ornament/store-sofa-3", 0.9]]}}
    lib = idx(SOFA, *FILLER)
    assert ids(gl.pick(lib, "a farm with a cow", word_map=cow_map)[0]) == ids(gl.pick(lib, "a farm with a cow", semantic=False)[0])  # GAP stays
    closed = idx(A("prop.decor.ornament", "store-sofa-3", omg=False), *FILLER)
    assert "prop.decor.ornament/store-sofa-3" not in ids(gl.pick(closed, "a settee", word_map=WMAP)[0])  # reuse.omg false
    old = ids(gl.pick(lib, "barrel and settee", semantic=False)[0])
    assert set(old) <= set(ids(gl.pick(lib, "barrel and settee", word_map=WMAP)[0]))


def test_word_map_loader_caches_and_reloads(tmp_path):
    (tmp_path / "repo" / "search").mkdir(parents=True)
    p = tmp_path / "repo" / "search" / "word_map.json"
    assert gl.load_word_map(tmp_path) is None
    p.write_text('{"words": {"a": []}}')
    assert gl.load_word_map(tmp_path) == {"words": {"a": []}}
    import os
    p.write_text('{"words": {"b": []}}'); os.utime(p, (1, 1))
    assert gl.load_word_map(tmp_path) == {"words": {"b": []}}
