"""games_library: the Forge Asset Library in OhMyGames builds (7 Oct 2026)."""

import hashlib
import io
import json
import tarfile
from types import SimpleNamespace as N

import pytest

from forge_corp.api.routes import games_library as gl


def _card(title, pitch, stage="none", view="third", lane="custom"):
    return N(title=title, pitch=pitch, genre="", core_loop="", first_version="", mood=[], world=N(view=view, setting="", look=""),
             lane=lane, stage=stage)


def _asset(root, cls, slug, size=100, status="legacy", game_use=True, kit="village-kit"):
    blob = f"{cls}/{slug}".encode() * size
    h = hashlib.sha256(blob).hexdigest()
    (root / "blobs" / f"{h}.glb").write_bytes(blob)
    card = root / "repo" / "cards" / slug / "v1.json"
    card.parent.mkdir(parents=True, exist_ok=True)
    card.write_text(json.dumps({"bounds": {"footprint": {"minX": -1, "maxX": 1, "minZ": -1, "maxZ": 1}, "collider": {"parts": [
        {"type": "box", "x": 0, "y": 0.5, "z": 0, "hx": 1, "hy": 0.5, "hz": 1}]}}, "sockets": [], "placement": {"role": "prop"}}))
    return {"id": f"{cls}/{slug}", "name": slug.replace("-", " "), "summary": slug, "tags": slug.split("-"), "status": status,
            "game_use": game_use, "kit": kit, "glb": h, "bytes": len(blob), "release": "sha256:" + h, "scale": 1.0, "tris": 10,
            "size": {"w": 2.0, "l": 2.0, "h": 1.0}, "card": f"cards/{slug}/v1.json"}


TREE = {"building": {"groups": {"landmark": {"classes": ["lighthouse", "windmill"]}, "civic": {"classes": ["market"]},
                      "medieval": {"classes": ["house"]}, "dwelling": {"classes": ["house"]}}},
        "prop": {"groups": {"container": {"classes": ["crate", "barrel"]}, "street": {"classes": ["lamp", "bench"]},
                            "furniture": {"classes": ["lamp", "sofa"]}, "farm": {"classes": ["well"]}}},
        "vehicle": {"groups": {"sea": {"classes": ["rowboat"]}}}}


@pytest.fixture
def root(tmp_path):
    (tmp_path / "blobs").mkdir()
    kit = tmp_path / "repo" / "omg" / "kit"
    kit.mkdir(parents=True)
    for name, files in (("basis", ("basis_transcoder.js", "basis_transcoder.wasm")), ("draco", ("draco_decoder.js",))):
        d = tmp_path / "repo" / "omg" / "public" / name; d.mkdir(parents=True)
        for f in files:
            (d / f).write_text(f)
    for f in ("index.mjs", "index.d.ts", "LIBRARY.md"):
        (kit / f).write_text("// " + f)
    house = _asset(tmp_path, "building.dwelling.house", "breton-stone-house", status="approved", kit="library-author")
    house.update(ktx2=True, codec="draco")
    assets = [house, _asset(tmp_path, "building.landmark.lighthouse", "harbour-lighthouse", status="approved"),
              _asset(tmp_path, "prop.container.crate", "harbour-crate"), _asset(tmp_path, "prop.container.crate", "harbour-crate-stack"),
              _asset(tmp_path, "prop.container.barrel", "harbour-barrel"), _asset(tmp_path, "prop.street.lamp", "harbour-lamp-post"),
              _asset(tmp_path, "prop.furniture.lamp", "store-floor-lamp"), _asset(tmp_path, "prop.furniture.sofa", "store-sofa"),
              _asset(tmp_path, "prop.furniture.sofa", "ikea-sofa", game_use=False, status="candidate"),
              _asset(tmp_path, "vehicle.sea.rowboat", "harbour-rowboat")]
    idx = {"built": "2026-10-07T13:00:00Z", "tree": TREE, "aliases": {"lamp post": "prop.street.lamp", "cask": "prop.container.barrel"},
           "assets": assets}
    (tmp_path / "repo" / "index.json").write_text(json.dumps(idx))
    gl._INDEX.update(mtime=None, data=None)
    return tmp_path


def _members(att):
    import base64
    data = base64.b64decode(att["url"].split(",", 1)[1])
    with tarfile.open(fileobj=io.BytesIO(data)) as tf:
        return {m.name: tf.extractfile(m).read() for m in tf.getmembers() if m.isfile()}


def test_harbour_words_get_the_lighthouse_crates_and_barrels(root):
    out = gl.library_attachments(_card("Harbour Run", "Haul crates and barrels past the lighthouse"), 2, "", root)
    assert out is not None
    atts, contract, meta = out
    ids = {r for r in json.loads(_members(atts[1])["library/manifest.json"])["assets"] for r in [r["id"]]}
    assert {"building.landmark.lighthouse/harbour-lighthouse", "prop.container.crate/harbour-crate", "prop.container.barrel/harbour-barrel"} <= ids
    assert "att3" in contract and "att4" in contract and "harbour-lighthouse" in contract
    assert atts[0]["filename"] == "library-kit.tar.gz" and atts[1]["filename"] == "library-models.tar.gz"
    assert meta["count"] == len(ids) and meta["lock"].startswith("sha256:")


def test_pack_holds_models_lock_and_kit(root):
    atts, _, meta = gl.library_attachments(_card("Harbour", "a lighthouse"), 0, "", root)
    files = _members(atts[1])
    lock = json.loads(files["library/assets.lock.json"])
    assert meta["lock"] == "sha256:" + hashlib.sha256(files["library/assets.lock.json"]).hexdigest()
    assert all(f"library/models/{a['glb'][:16]}.glb" in files for a in lock["assets"])
    assert set(_members(atts[0])) == {"index.mjs", "index.d.ts", "LIBRARY.md"}


def test_same_words_same_picks(root):
    a = gl.library_attachments(_card("Harbour", "crates and a lighthouse"), 0, "", root)[2]
    b = gl.library_attachments(_card("Harbour", "crates and a lighthouse"), 0, "", root)[2]
    assert a["releases"] == b["releases"]


def test_no_library_words_no_library(root):
    assert gl.library_attachments(_card("Space Race", "race spaceships in an asteroid field"), 0, "", root) is None


def test_everyday_words_are_not_objects(root):
    # "as well" is not a well, nothing else named: no library
    assert gl.library_attachments(_card("Puzzle", "a calm puzzle game as well"), 0, "", root) is None


def test_street_lamp_beats_floor_lamp_outdoors(root):
    picks, classes, _ = gl.pick(gl.load_index(root), "a village street with a lamp and a bench")
    assert "prop.street.lamp" in classes and "prop.furniture.lamp" not in classes


def test_alias_reaches_its_class(root):
    picks, classes, _ = gl.pick(gl.load_index(root), "a cellar full of casks")
    assert "prop.container.barrel" in classes


def test_vehicles_and_uncleared_assets_never_attached(root):
    picks, _, _ = gl.pick(gl.load_index(root), "a rowboat by the sofa")
    ids = [p["id"] for p in picks]
    assert not any(i.startswith("vehicle.") for i in ids) and "prop.furniture.sofa/ikea-sofa" not in ids


def test_gap_is_reported_not_filled(root):
    _, _, gaps = gl.pick(gl.load_index(root), "a windmill on the hill")
    assert gaps == ["building.landmark.windmill"]


def test_room_stage_board_and_side_view_get_nothing(root):
    words = ("Harbour", "crates, barrels and a lighthouse")
    assert gl.library_attachments(_card(*words, stage="room"), 0, "", root) is None
    assert gl.library_attachments(_card(*words, stage="board"), 0, "", root) is None
    assert gl.library_attachments(_card(*words, view="side"), 0, "", root) is None
    assert gl.library_attachments(_card(*words, lane="quick"), 0, "", root) is None


def test_a_room_game_whose_room_never_came_gets_the_library(root):
    # 18 Oct 2026, Costa Rouge: room stage, room lost; the Cast and the Garage came, the library did not
    words = ("Harbour", "crates, barrels and a lighthouse")
    assert gl.library_attachments(_card(*words, stage="room"), 0, "", root, roomless=True) is not None
    assert gl.library_attachments(_card(*words, stage="board"), 0, "", root, roomless=True) is None


def test_missing_library_builds_as_before(tmp_path):
    gl._INDEX.update(mtime=None, data=None)
    assert gl.library_attachments(_card("Harbour", "a lighthouse"), 0, "", tmp_path) is None


def test_tampered_blob_means_no_library(root):
    idx = json.loads((root / "repo" / "index.json").read_text())
    h = next(a["glb"] for a in idx["assets"] if a["id"].endswith("harbour-lighthouse"))
    (root / "blobs" / f"{h}.glb").write_bytes(b"not the file the card pins")
    assert gl.library_attachments(_card("Harbour", "a lighthouse"), 0, "", root) is None


def test_caps(root, monkeypatch):
    monkeypatch.setattr(gl, "MAX_ASSETS", 2)
    picks, _, _ = gl.pick(gl.load_index(root), "crates, barrels, a lighthouse, a lamp post and a bench")
    assert len(picks) == 2
    monkeypatch.setattr(gl, "MAX_ASSETS", 30)
    monkeypatch.setattr(gl, "MAX_BYTES", 1)
    assert gl.pick(gl.load_index(root), "crates and barrels")[0] == []


def test_ktx2_models_bring_their_decoders(root):
    atts, contract, _ = gl.library_attachments(_card("Quay", "a stone house by the lighthouse"), 0, "", root)
    files = _members(atts[1]); man = json.loads(files["library/manifest.json"])
    assert man["ktx2"] and man["draco"]
    assert {"library/basis/basis_transcoder.js", "library/basis/basis_transcoder.wasm", "library/draco/draco_decoder.js"} <= set(files)
    assert "renderer" in contract


def test_plain_models_ship_no_decoders(root):
    atts, _, _ = gl.library_attachments(_card("Harbour", "a lighthouse"), 0, "", root)
    files = _members(atts[1]); man = json.loads(files["library/manifest.json"])
    assert not man["ktx2"] and not any(f.startswith("library/basis/") for f in files)


def test_a_filled_class_beats_an_empty_namesake(root):
    picks, classes, gaps = gl.pick(gl.load_index(root), "a stone house on the quay")
    assert "building.dwelling.house" in classes and "building.medieval.house" not in gaps


# ---- road vehicles (10 Oct 2026): Astra cars, bikes, buses as street life; Garage-kit models and boats stay out ----
def _vehicle(root, cls, slug, kit="library-author"):
    a = _asset(root, cls, slug, status="approved", kit=kit)
    card = root / "repo" / a["card"]
    c = json.loads(card.read_text())
    c.update(paint={"material": "Paint", "default": [0.5, 0.0, 0.0], "paints": [[0.5, 0, 0], [0, 0, 0.5]], "names": ["red", "blue"]},
             nodes={"wheels": ["Wheel_FL", "Wheel_FR", "Wheel_RL", "Wheel_RR"], "steer": ["Wheel_FL", "Wheel_FR"]})
    card.write_text(json.dumps(c))
    return a


@pytest.fixture
def street(root):
    idx = json.loads((root / "repo" / "index.json").read_text())
    idx["tree"]["vehicle"]["groups"].update({"car": {"classes": ["sedan", "hatchback", "sports"]}, "service": {"classes": ["bus", "taxi"]},
                                             "two-wheel": {"classes": ["bicycle"]}})
    idx["aliases"].update({"car": "vehicle.car", "bike": "vehicle.two-wheel.bicycle"})
    idx["assets"] += [_vehicle(root, "vehicle.car.sedan", "family-sedan"), _vehicle(root, "vehicle.car.hatchback", "euro-hatchback"),
                      _vehicle(root, "vehicle.service.bus", "city-bus"), _vehicle(root, "vehicle.two-wheel.bicycle", "city-bike"),
                      _vehicle(root, "vehicle.car.sedan", "kenney-sedan", kit="garage"), _vehicle(root, "vehicle.service.taxi", "kenney-taxi", kit="garage")]
    (root / "repo" / "index.json").write_text(json.dumps(idx))
    gl._INDEX.update(mtime=None, data=None)
    return root


def test_a_city_gets_cars_buses_and_bikes_with_their_paints(street):
    atts, contract, meta = gl.library_attachments(_card("Rush Hour", "deliver pizzas across the city before the traffic jams"), 0, "", street)
    rows = {r["id"]: r for r in json.loads(_members(atts[1])["library/manifest.json"])["assets"]}
    assert {"vehicle.car.sedan/family-sedan", "vehicle.service.bus/city-bus", "vehicle.two-wheel.bicycle/city-bike"} <= set(rows)
    sedan = rows["vehicle.car.sedan/family-sedan"]
    assert sedan["paint"]["material"] == "Paint" and len(sedan["paint"]["paints"]) == 2 and sedan["nodes"]["wheels"][0] == "Wheel_FL"


def test_garage_kit_vehicles_and_boats_never_come_from_the_library(street):
    picks, _, _ = gl.pick(gl.load_index(street), "a taxi and a sedan car by the rowboat in the city")
    ids = [p["id"] for p in picks]
    assert "vehicle.car.sedan/family-sedan" in ids
    assert not any("kenney" in i for i in ids) and "vehicle.sea.rowboat/harbour-rowboat" not in ids


def test_older_worlds_get_no_modern_vehicles(street):
    picks, _, _ = gl.pick(gl.load_index(street), "a medieval city with a car of hay and a bike")
    assert not any(p["id"].startswith("vehicle.") for p in picks)


def test_a_named_bike_comes_without_a_city(street):
    picks, _, _ = gl.pick(gl.load_index(street), "ride a bike past the lighthouse")
    assert "vehicle.two-wheel.bicycle/city-bike" in [p["id"] for p in picks]


def test_sports_and_classic_are_not_vehicle_words(street):
    _, classes, gaps = gl.pick(gl.load_index(street), "a classic sports game with a lighthouse")
    assert not any(c.startswith("vehicle.") for c in classes + gaps)


def test_vehicle_classes_ship_at_most_two_models(street):
    for i in range(4):
        idx = json.loads((street / "repo" / "index.json").read_text())
        idx["assets"].append(_vehicle(street, "vehicle.car.sedan", f"extra-sedan-{i}"))
        (street / "repo" / "index.json").write_text(json.dumps(idx))
    gl._INDEX.update(mtime=None, data=None)
    picks, _, _ = gl.pick(gl.load_index(street), "a sedan in the car park")
    assert sum(p["id"].startswith("vehicle.car.sedan/") for p in picks) == 2


# ---- military (10 Oct 2026 ~12:00 CET): war settings and named phrases bring the military motor pool ----
@pytest.fixture
def army(street):
    idx = json.loads((street / "repo" / "index.json").read_text())
    idx["tree"]["vehicle"]["groups"].update({"military-land": {"classes": ["tank", "jeep", "truck"]}, "rotor": {"classes": ["helicopter", "attack-helicopter"]},
                                             "military-air": {"classes": ["fighter", "bomber"]}})
    idx["assets"] += [_vehicle(street, "vehicle.military-land.tank", "abrams-tank"), _vehicle(street, "vehicle.military-land.jeep", "humvee-troop"),
                      _vehicle(street, "vehicle.rotor.attack-helicopter", "apache-helicopter"), _vehicle(street, "vehicle.military-air.fighter", "f16-fighter"),
                      _vehicle(street, "vehicle.military-air.bomber", "b52-bomber")]
    (street / "repo" / "index.json").write_text(json.dumps(idx))
    gl._INDEX.update(mtime=None, data=None)
    return street


def test_a_war_setting_brings_tanks_jeeps_helicopters_and_jets(army):
    ids = [p["id"] for p in gl.pick(gl.load_index(army), "hold the front line against the invasion")[0]]
    assert {"vehicle.military-land.tank/abrams-tank", "vehicle.military-land.jeep/humvee-troop", "vehicle.rotor.attack-helicopter/apache-helicopter",
            "vehicle.military-air.fighter/f16-fighter"} <= set(ids)
    assert "vehicle.military-air.bomber/b52-bomber" not in ids  # bombers only when named


def test_a_fish_tank_and_street_fighter_are_not_military(army):
    for words in ("feed the fish in the fish tank", "a street fighter brawler", "a jeep in the city"):
        ids = [p["id"] for p in gl.pick(gl.load_index(army), words)[0]]
        assert not any(i.startswith(("vehicle.military", "vehicle.rotor")) for i in ids), words


def test_named_phrases_reach_military_classes(army):
    _, classes, _ = gl.pick(gl.load_index(army), "a dogfight between fighter jets over a battle tank")
    assert {"vehicle.military-air.fighter", "vehicle.military-land.tank"} <= set(classes)


def test_a_medieval_war_gets_no_modern_military(army):
    ids = [p["id"] for p in gl.pick(gl.load_index(army), "a medieval war between knights and an army of orcs")[0]]
    assert not any(i.startswith("vehicle.") for i in ids)


def test_a_setting_brings_one_model_per_vehicle_class(army):
    for i in range(3):
        idx = json.loads((army / "repo" / "index.json").read_text())
        idx["assets"].append(_vehicle(army, "vehicle.military-land.tank", f"extra-tank-{i}"))
        (army / "repo" / "index.json").write_text(json.dumps(idx))
    gl._INDEX.update(mtime=None, data=None)
    ids = [p["id"] for p in gl.pick(gl.load_index(army), "hold the front line against the invasion")[0]]
    assert sum(i.startswith("vehicle.military-land.tank/") for i in ids) == 1
    named = [p["id"] for p in gl.pick(gl.load_index(army), "a battle tank duel at the front line")[0]]
    assert sum(i.startswith("vehicle.military-land.tank/") for i in named) == 2  # named by the words: two models


def test_ww2_settings_get_only_ww2_era_military(army):
    idx = json.loads((army / "repo" / "index.json").read_text())
    sher = _vehicle(army, "vehicle.military-land.tank", "sherman-tank"); sher["tags"] = ["era-ww2", "ww2"]; sher["bytes"] = 10 ** 6
    idx["assets"].append(sher); (army / "repo" / "index.json").write_text(json.dumps(idx)); gl._INDEX.update(mtime=None, data=None)
    ids = [p["id"] for p in gl.pick(gl.load_index(army), "a WW2 tank battle in Normandy, hold the front line")[0]]
    assert "vehicle.military-land.tank/sherman-tank" in ids and "vehicle.military-land.tank/abrams-tank" not in ids
    assert not any(i.startswith(("vehicle.military-air", "vehicle.rotor")) for i in ids)  # no WWII aircraft in the library yet
    modern = [p["id"] for p in gl.pick(gl.load_index(army), "hold the front line against the invasion")[0]]
    assert "vehicle.military-land.tank/abrams-tank" in modern and "vehicle.military-land.tank/sherman-tank" not in modern


def test_a_battlefield_gets_its_tanks_before_second_models(army, monkeypatch):
    # every vehicle model weighs 1 MB here; named jets, trucks and Humvees must not crowd the first tank out of an 8 MB cap
    idx = json.loads((army / "repo" / "index.json").read_text())
    idx["tree"]["vehicle"]["groups"]["military-land"]["classes"] += ["truck"]
    for cls, slug in (("vehicle.military-air.fighter", "rafale-fighter"), ("vehicle.military-land.truck", "tactical-truck"),
                      ("vehicle.military-land.truck", "ural-truck"), ("vehicle.military-land.jeep", "humvee-armored"),
                      ("vehicle.rotor.helicopter", "chinook-helicopter"), ("vehicle.rotor.helicopter", "blackhawk-helicopter")):
        idx["assets"].append(_vehicle(army, cls, slug))
    for a in idx["assets"]:
        if a["id"].startswith("vehicle."): a["bytes"] = 1_000_000
    (army / "repo" / "index.json").write_text(json.dumps(idx)); gl._INDEX.update(mtime=None, data=None)
    words = "Hold the front line: command your tanks, Humvees and troop trucks, call in helicopters and fighter jets against enemy tank columns"
    ids = [p["id"] for p in gl.pick(gl.load_index(army), words)[0]]
    assert any(i.startswith("vehicle.military-land.tank/") for i in ids), ids
    assert any(i.startswith("vehicle.military-air.fighter/") for i in ids) and any(i.startswith("vehicle.rotor.") for i in ids)
    assert sum(i.startswith("vehicle.") for i in ids) <= 8


def test_tank_outside_a_battlefield_is_still_not_a_vehicle(army):
    ids = [p["id"] for p in gl.pick(gl.load_index(army), "clean the fish tank and feed the fish")[0]]
    assert not any(i.startswith("vehicle.") for i in ids)


def _tank(root, slug, era, damage=True, size=100):
    a = _vehicle(root, "vehicle.military-land.tank", slug)
    a["tags"] = [f"era-{era}", "tank"]
    if damage:
        blob = f"damage/{slug}".encode() * size
        h = hashlib.sha256(blob).hexdigest()
        (root / "blobs" / f"{h}.glb").write_bytes(blob)
        a.update(damage_glb=h, damage_bytes=len(blob))
        card = root / "repo" / a["card"]
        c = json.loads(card.read_text())
        c["nodes"]["damage"] = {"contract": "tank-damage/v1", "parts": [{"node": "Part_Skirt_L1", "twin": "Dmg_Skirt_L1", "zone": "left"}],
                                "scars": {"hull-front": "Scar_Hull_Front"}, "broken": {"Track_L": "Track_L_Broken"},
                                "wreck": {"hull": "Wreck_Hull", "turret": "Wreck_Turret"}, "attach": {"Turret_Damage": "Turret"}}
        card.write_text(json.dumps(c))
    return a


@pytest.fixture
def tanks(army):
    idx = json.loads((army / "repo" / "index.json").read_text())
    idx["assets"] = [a for a in idx["assets"] if not a["id"].startswith("vehicle.military-land.tank/")]
    idx["assets"] += [_tank(army, "renault-ft-tank", "ww1", size=50), _tank(army, "leopard2-tank", "modern", size=300),
                      _tank(army, "t55-tank", "coldwar", size=200), _tank(army, "sherman-tank", "ww2", size=150)]
    (army / "repo" / "index.json").write_text(json.dumps(idx))
    gl._INDEX.update(mtime=None, data=None)
    return army


def _tank_ids(root, words):
    return [p["id"].split("/")[1] for p in gl.pick(gl.load_index(root), words)[0] if p["id"].startswith("vehicle.military-land.tank/")]


def test_each_war_gets_the_tanks_of_its_era(tanks):
    assert _tank_ids(tanks, "a battle tank duel at the front line")[0] == "leopard2-tank"  # modern first, though the 1917 tank is smallest
    assert _tank_ids(tanks, "a WW2 tank battle in Normandy, hold the front line") == ["sherman-tank"]
    assert _tank_ids(tanks, "WW1 trench warfare: tanks cross no man's land in the great war") == ["renault-ft-tank"]
    assert _tank_ids(tanks, "a cold war tank battle in the fulda gap")[0] == "t55-tank"


def test_a_destructible_tank_ships_its_damage_file_and_the_contract_says_how(tanks):
    att, contract, meta = gl.library_attachments(_card("Steel Front", "a battle tank war at the front line"), 0, root=tanks)
    files = _members(att[1])
    man = json.loads(files["library/manifest.json"])
    row = next(r for r in man["assets"] if r["id"].endswith("/leopard2-tank"))
    assert row["damage"].startswith("models/") and ("library/" + row["damage"]) in files
    assert row["nodes"]["damage"]["broken"] == {"Track_L": "Track_L_Broken"}
    lock = json.loads(files["library/assets.lock.json"])
    assert any(x.get("damage_glb") for x in lock["assets"])
    assert "lib.damage(object)" in contract and "d.wreck" in contract and meta["destructible"] >= 1
    assert meta["bytes"] == sum(len(v) for k, v in files.items() if k.startswith("library/models/"))


def test_no_destructible_tank_no_damage_line(army):
    _, contract, _ = gl.library_attachments(_card("Steel Front", "a battle tank war at the front line"), 0, root=army)
    assert "lib.damage" not in contract


def test_damage_files_count_against_the_byte_cap(tanks, monkeypatch):
    monkeypatch.setattr(gl, "MAX_BYTES", 8_000)  # the leopard alone (3.7 KB) fits; with its damage file (6 KB) it does not
    assert "leopard2-tank" not in _tank_ids(tanks, "a battle tank duel at the front line")


def test_a_tampered_damage_file_means_no_library(tanks):
    idx = json.loads((tanks / "repo" / "index.json").read_text())
    leo = next(a for a in idx["assets"] if a["id"].endswith("/leopard2-tank"))
    (tanks / "blobs" / f"{leo['damage_glb']}.glb").write_bytes(b"changed")
    assert gl.library_attachments(_card("Steel Front", "a battle tank war at the front line"), 0, root=tanks) is None


def test_inside_an_era_named_models_and_nations_come_first(tanks):
    idx = json.loads((tanks / "repo" / "index.json").read_text())
    chi = _tank(tanks, "chi-ha-tank", "ww2", size=20); chi["tags"] += ["japan", "chi", "ha"]
    tiger = _tank(tanks, "tiger-heavy-tank", "ww2", size=400); tiger["tags"] += ["germany", "tiger"]
    idx["assets"] += [chi, tiger]
    for a in idx["assets"]:
        if a["id"].endswith("/sherman-tank"): a["tags"] += ["usa", "sherman"]
    (tanks / "repo" / "index.json").write_text(json.dumps(idx)); gl._INDEX.update(mtime=None, data=None)
    assert _tank_ids(tanks, "a WW2 tank battle in Normandy, hold the front line")[0] == "sherman-tank"  # the Chi-Ha is lighter: Pacific only
    assert _tank_ids(tanks, "WW2: German panzers attack, hold the front line")[0] == "tiger-heavy-tank"
    assert _tank_ids(tanks, "WW2 in the Pacific: island tank battle at the front line")[0] == "chi-ha-tank"
    assert _tank_ids(tanks, "a WW2 duel: one Tiger tank against the front line")[0] == "tiger-heavy-tank"


def test_a_named_battlefield_brings_the_nations_that_fought_there(tanks):
    idx = json.loads((tanks / "repo" / "index.json").read_text())
    is3 = _tank(tanks, "is3-heavy-tank", "ww2", size=10); is3["tags"] += ["ussr"]
    idx["assets"].append(is3)
    for a in idx["assets"]:
        if a["id"].endswith("/sherman-tank"): a["tags"] += ["usa", "sherman"]
    (tanks / "repo" / "index.json").write_text(json.dumps(idx)); gl._INDEX.update(mtime=None, data=None)
    assert _tank_ids(tanks, "a WW2 tank battle in Normandy, hold the front line")[0] == "sherman-tank"
    assert _tank_ids(tanks, "WW2: the tank battle of Kursk on the front line")[0] == "is3-heavy-tank"


# ---- driven cars (15 Oct 2026, operator: Rocket Rivals' racers were blunt Garage models while realistic cars stood parked) ----
@pytest.fixture
def grid(street):
    idx = json.loads((street / "repo" / "index.json").read_text())
    idx["tree"]["vehicle"]["groups"]["car"]["classes"] += ["race"]
    idx["aliases"].update({"race car": "vehicle.car.race"})
    race = []
    for slug, tags in (("gt-racer", ["gt", "circuit"]), ("stock-car", ["stock-car", "oval"]), ("le-mans-prototype", ["prototype", "circuit"]),
                       ("formula-racer", ["formula", "circuit"]), ("rally-car", ["rally", "gravel"]), ("future-racer", ["futuristic", "rocket"])):
        a = _vehicle(street, "vehicle.car.race", slug)
        a["tags"] = tags
        race.append(a)
    idx["assets"] += race + [_vehicle(street, "vehicle.car.race", "kenney-race", kit="garage")]
    (street / "repo" / "index.json").write_text(json.dumps(idx))
    gl._INDEX.update(mtime=None, data=None)
    return street


def _race_ids(picks):
    return [p["id"].split("/")[1] for p in picks if p["id"].startswith("vehicle.car.race/")]


def test_a_driving_race_gets_up_to_four_race_cars_first(grid):
    picks, _, _ = gl.pick(gl.load_index(grid), "Rocket Rivals: a stadium car racing game, three laps against nine rivals", driving=True)
    race = _race_ids(picks)
    assert len(race) == gl.DRIVEN_PER_CLASS and "kenney-race" not in race
    assert picks[0]["id"].startswith("vehicle.car.race/"), "the grid comes before parked cars"
    assert race[0] == "future-racer", "a model the words name (rocket) leads"


def test_race_words_bring_no_grid_unless_the_game_drives(grid):
    picks, _, _ = gl.pick(gl.load_index(grid), "a car racing game, three laps", driving=False)
    assert _race_ids(picks) == []
    picks, _, _ = gl.pick(gl.load_index(grid), "a sedan car parked by the lighthouse", driving=True)
    assert _race_ids(picks) == [], "driving without a race brings no race cars"


def test_a_rally_names_the_rally_car(grid):
    picks, _, _ = gl.pick(gl.load_index(grid), "a gravel rally with cars in the forest", driving=True)
    assert _race_ids(picks)[0] == "rally-car"


def test_driven_cars_get_the_drive_contract_and_meta(grid):
    card = _card("Rocket Rivals", "a stadium car racing game, three laps against nine rival cars")
    atts, contract, meta = gl.library_attachments(card, 2, "", grid, driving=True)
    assert meta["driven"] and all(i.startswith("vehicle.car.") for i in meta["driven"])
    assert "DRIVEN CARS COME FROM THE LIBRARY" in contract and "lib.vehicle(id" in contract
    assert all(i in contract for i in meta["driven"])
    assert meta["driven"][0].startswith("vehicle.car.race/") and "the grid is the race cars" in contract
    _, contract2, meta2 = gl.library_attachments(card, 2, "", grid, driving=False)
    assert "driven" not in meta2 and "DRIVEN CARS" not in contract2


def test_military_vehicles_are_never_driven_cars(army):
    card = _card("Frontline", "a war game with tanks, jeeps and a car")
    _, contract, meta = gl.library_attachments(card, 0, "", army, driving=True)
    assert all(i.startswith("vehicle.car.") for i in meta.get("driven", []))
    assert not any(i.startswith("vehicle.military") for i in meta.get("driven", []))


def test_a_driving_game_gets_road_cars_without_naming_one(grid):
    # 18 Oct 2026, Costa Rouge: "vole une voiture" (French) named no car class; the game drove blocky Garage cars
    text = "Costa Rouge: marche, vole une voiture, roule vers les marqueurs de mission, sème la police; port avec grues"
    picks, _, _ = gl.pick(gl.load_index(grid), text, driving=True)
    cars = [p["id"] for p in picks if p["id"].startswith("vehicle.car.")]
    assert {"vehicle.car.sedan/family-sedan", "vehicle.car.hatchback/euro-hatchback"} <= set(cars)
    assert _race_ids(picks) == [] and picks[0]["id"].startswith("vehicle.car."), "road cars first, never a race car"
    picks, _, _ = gl.pick(gl.load_index(grid), text, driving=False)
    assert not any(p["id"].startswith("vehicle.car.") for p in picks), "a game that drives nothing parks no unnamed car"
    picks, _, _ = gl.pick(gl.load_index(grid), "a war: drive the army truck past enemy troops across the desert", driving=True)
    assert not any(p["id"].startswith("vehicle.car.") for p in picks), "a battlefield keeps its bytes for military vehicles"
    picks, _, _ = gl.pick(gl.load_index(grid), "a space shooter in an asteroid field", driving=True)
    assert not any(p["id"].startswith("vehicle.car.") for p in picks)


def test_the_word_car_never_parks_a_race_car(grid):
    for driving in (False, True):
        picks, _, _ = gl.pick(gl.load_index(grid), "deliver pizzas by car across the city", driving=driving)
        assert _race_ids(picks) == [] and any(p["id"].startswith("vehicle.car.") for p in picks)
    picks, _, _ = gl.pick(gl.load_index(grid), "a race car parked by the lighthouse")
    assert _race_ids(picks), "named by its own alias"


# ---- spacecraft (17 Oct 2026, operator: "publish the spaceships"): only space or hover settings get them ----
def _ship(root, cls, slug, tags=()):
    a = _vehicle(root, cls, slug)  # tags like the real cards: slug words + space, sci-fi, vehicle, the class
    a["tags"] = sorted(set(a["tags"]) | {"space", "sci-fi", "vehicle", cls.split(".")[-1]} | set(tags))
    return a


@pytest.fixture
def space(army):
    idx = json.loads((army / "repo" / "index.json").read_text())
    idx["tree"]["vehicle"]["groups"]["space"] = {"classes": ["fighter", "bomber", "shuttle", "freighter", "cruiser", "battleship",
                                                             "mothership", "pod", "saucer", "speeder", "station"]}
    idx["assets"] += [_ship(army, "vehicle.space.fighter", "starfighter-opera"), _ship(army, "vehicle.space.fighter", "rocket-fighter-retro", ["retro"]),
                      _ship(army, "vehicle.space.fighter", "alien-bio-fighter", ["alien"]), _ship(army, "vehicle.space.cruiser", "light-cruiser"),
                      _ship(army, "vehicle.space.cruiser", "alien-crystal-vessel", ["alien"]), _ship(army, "vehicle.space.freighter", "cargo-hauler"),
                      _ship(army, "vehicle.space.shuttle", "crew-shuttle"), _ship(army, "vehicle.space.battleship", "dreadnought"),
                      _ship(army, "vehicle.space.mothership", "carrier-mothership"), _ship(army, "vehicle.space.pod", "escape-pod-capsule"),
                      _ship(army, "vehicle.space.saucer", "alien-saucer", ["alien"]), _ship(army, "vehicle.space.speeder", "speeder-bike")]
    (army / "repo" / "index.json").write_text(json.dumps(idx))
    gl._INDEX.update(mtime=None, data=None)
    return army


def _ids(root, words):
    return [p["id"] for p in gl.pick(gl.load_index(root), words)[0]]


def test_a_space_setting_gets_a_small_fleet_and_no_ground_vehicles(space):
    ids = _ids(space, "a space shooter: defend the galaxy against waves of pirates")
    assert {"vehicle.space.fighter/starfighter-opera", "vehicle.space.cruiser/light-cruiser", "vehicle.space.freighter/cargo-hauler",
            "vehicle.space.shuttle/crew-shuttle"} <= set(ids)
    assert not any(i.startswith(("vehicle.car", "vehicle.military", "vehicle.rotor", "vehicle.service", "vehicle.two-wheel")) for i in ids)
    assert not any("alien" in i for i in ids)  # no alien words: human ships only (the alien ones sort last)
    assert "vehicle.space.battleship/dreadnought" not in ids  # capital ships come by name


def test_cities_battlefields_and_castles_never_get_spacecraft(space):
    for words in ("deliver pizzas across the city", "hold the front line against the invasion", "a medieval war between knights",
                  "a street fighter brawler", "a cargo truck on the highway", "explore the cave as a lone explorer"):
        assert not any(i.startswith("vehicle.space.") for i in _ids(space, words)), words


def test_named_space_classes_come_in_space(space):
    ids = _ids(space, "a space battle: the dreadnought battleship and the mothership launch fighters, eject in an escape pod")
    assert {"vehicle.space.battleship/dreadnought", "vehicle.space.mothership/carrier-mothership",
            "vehicle.space.pod/escape-pod-capsule"} <= set(ids)
    assert sum(i.startswith("vehicle.space.fighter/") for i in ids) == 2  # named: two models


def test_alien_words_put_the_alien_vessels_first(space):
    ids = _ids(space, "an alien invasion fleet in orbit: shoot down the alien fighters and their cruisers")
    fighters = [i for i in ids if i.startswith("vehicle.space.fighter/")]
    assert fighters[0] == "vehicle.space.fighter/alien-bio-fighter" and len(fighters) == 2  # the alien one first, then a human one
    assert not any(i.startswith(("vehicle.military", "vehicle.rotor")) for i in ids)  # "invasion" in space brings no F-16
    assert "vehicle.space.cruiser/alien-crystal-vessel" in ids and "vehicle.space.saucer/alien-saucer" in ids


def test_a_genre_word_picks_its_fighter(space):
    ids = _ids(space, "a retro space shooter with a fighter")
    assert [i for i in ids if i.startswith("vehicle.space.fighter/")][0] == "vehicle.space.fighter/rocket-fighter-retro"


def test_hover_words_bring_speeders_without_space(space):
    ids = _ids(space, "race speeder bikes through a desert canyon")
    assert "vehicle.space.speeder/speeder-bike" in ids and not any(i.startswith("vehicle.space.fighter") for i in ids)


def test_spacecraft_contract_line(space):
    atts, contract, meta = gl.library_attachments(_card("Star Patrol", "a space shooter in the asteroid belt"), 0, "", space)
    assert "SPACECRAFT" in contract and "L12" in contract and "s.thrusters" in contract
    assert "vehicle.space.fighter/starfighter-opera" in contract
    _, plain, _ = gl.library_attachments(_card("Harbour", "a harbour with a lighthouse and crates"), 0, "", space)
    assert "SPACECRAFT" not in plain


def test_speeder_bikes_are_not_bicycles(space):
    ids = _ids(space, "race speeder bikes through a desert canyon")
    assert "vehicle.space.speeder/speeder-bike" in ids and not any(i.startswith("vehicle.two-wheel.") for i in ids)


def test_an_alien_story_gets_its_saucer_before_the_generic_fleet(space, monkeypatch):
    monkeypatch.setattr(gl, "MAX_BYTES", 7500)  # room for two of the test ships: the saucer must be one of them
    ids = _ids(space, "an alien attack on the space colony")
    assert "vehicle.space.saucer/alien-saucer" in ids and not any(i.startswith("vehicle.space.freighter") for i in ids)


def test_hover_cars_bring_speeders_and_no_road_cars(space):
    ids = _ids(space, "hover cars race through the neon city")
    assert "vehicle.space.speeder/speeder-bike" in ids and not any(i.startswith(("vehicle.car", "vehicle.service")) for i in ids)

def test_reuse_optout_is_stronger_than_game_clearance(root):
    idx = json.loads((root / 'repo/index.json').read_text())
    sofas = [a for a in idx['assets'] if a['id'].startswith('prop.furniture.sofa/') and a['game_use']]
    assert sofas
    assert any(a in gl.pick(idx, 'black sofa')[0] for a in sofas)
    for a in sofas:
        a['reuse'] = {'omg': False, 'omu': True, 'reason': 'owner opted out of games'}
    picks, _, _ = gl.pick(idx, 'black sofa')
    assert not any(a in picks for a in sofas)
    assert not any(gl._usable(a) for a in sofas)
    with pytest.raises(PermissionError):
        gl.build_pack(root, sofas)
