"""Vehicle spike: /tmp/veh-spike/pub = showroom (browse/vehicles.html + its modules, vendor, blobs) + renders page.
usage: python3 tools/vehicles/build_spike.py "<HH:MM CET>"  then  tools/vehicles/publish_spike.sh"""
import html, io, json, os, shutil, subprocess, sys
from PIL import Image
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
OUT = '/tmp/veh-spike/pub'
ARCHIVE = '/srv/archive/local-forge-build-20261014/forge-assets-jobs'  # on gex44
E = html.escape
stamp = sys.argv[1] if len(sys.argv) > 1 else ''


def job_bytes(job, name):
    """a file of a vehicle job: the job dir, else git, else the gex44 archive of the 14 Oct cleanup (None if nowhere)"""
    p = f'{ROOT}/jobs/{job}/{name}'
    if os.path.exists(p):
        return open(p, 'rb').read()
    for cmd in (['git', '-C', ROOT, 'show', f'HEAD:jobs/{job}/{name}'], ['ssh', 'root@5.9.10.114', 'cat', f'{ARCHIVE}/{job}/{name}']):
        r = subprocess.run(cmd, capture_output=True)
        if r.returncode == 0 and r.stdout:
            return r.stdout
    return None


def job_minutes(job):
    from datetime import datetime
    a, b = job_bytes(job, 'started.txt'), job_bytes(job, 'finished.txt')
    if not (a and b):
        return None
    t = lambda x: datetime.fromisoformat(x.decode().strip().replace('Z', '+00:00'))
    return round((t(b) - t(a)).total_seconds() / 60)


def drawn_by(a):
    """the model that drew it in Blender (card provenance.author), in plain words"""
    a = a or 'GPT-6 Astra'
    return 'GPT-6 Astra' if 'astra' in a.lower() else 'Gemini 4 Argon (Antigravity CLI)' if 'gemini-4' in a.lower() else a


if os.path.exists(OUT):
    shutil.rmtree(OUT)
os.makedirs(f'{OUT}/blobs'); os.makedirs(f'{OUT}/renders')
# the showroom page and every local module it imports (10 Oct 2026: track-belt.mjs was missing -> page script died, empty panels)
import re
local = set(re.findall(r"from '\./([\w./-]+\.mjs)'", open(f'{ROOT}/browse/vehicles.html').read()))
for m in list(local):  # one level of imports inside those modules
    local |= {x for x in re.findall(r"from '\./([\w./-]+\.mjs)'", open(f'{ROOT}/browse/{m}').read())}
for f in ['vehicles.html', 'vehicles.json', *sorted(local)]:
    shutil.copy(f'{ROOT}/browse/{f}', OUT)
shutil.copytree(f'{ROOT}/browse/garage', f'{OUT}/garage')
shutil.copytree(f'{ROOT}/browse/vendor', f'{OUT}/vendor', symlinks=False, ignore=shutil.ignore_patterns('fge-look', 'rapier'))
vs = json.load(open(f'{ROOT}/browse/vehicles.json'))
FAM = {'cars': 'Cars', 'race': 'Race cars', 'motos': 'Motorbikes and scooter', 'bikes': 'Bicycles', 'buses': 'Buses', 'wheeled': 'Military wheeled (Humvees, jeeps, troop trucks)',
       'tanks': 'Tanks', 'rotor': 'Helicopters', 'jets': 'Jets, bombers and AWACS'}
cards = []
for v in vs:
    shutil.copy(f"{ROOT}/store/blobs/{v['glb']}.glb", f'{OUT}/blobs/')
    if v.get('damage_glb'):  # destructible tanks: damaged twins, hit marks, wreck
        shutil.copy(f"{ROOT}/store/blobs/{v['damage_glb']}.glb", f'{OUT}/blobs/')
    job = v.get('job') or v['slug'] + '-v' + str(v['version'])  # card versions without a new job (axes fixes) keep their renders
    for src, dst, w in (('final.png', 'final', 1280), ('paints.png', 'paints', 1280), ('sheet.jpg', 'sheet', 1280),
                          ('states.png', 'states', 1600)):
        # job dir removed by a disk cleanup: the committed render is in git; *.png in jobs/ is gitignored, so the
        # 14 Oct cleanup's archive on gex44 is the last place
        b = job_bytes(job, src) if src != 'states.png' or v.get('damage_glb') else None
        p = io.BytesIO(b) if b else None
        if p:
            im = Image.open(p).convert('RGB'); im = im.resize((w, int(w * im.height / im.width)))
            im.save(f"{OUT}/renders/{v['slug']}-{dst}.jpg", quality=84)
    cards.append(v)
# Gemini 4 twins of Astra's cars (15 Oct 2026, operator: "re-do the cars Astra did with Gemini 4, I want to see the
# difference"): <slug>-g4 cards shown side by side with Astra's car. The family grid shows the one games pick (card
# status approved); 17 Oct the operator swapped the GT racer and stock car to Gemini's
by_slug = {v['slug']: v for v in cards}
twins = [(by_slug[v['slug'][:-3]], v) for v in cards if v['slug'].endswith('-g4') and v['slug'][:-3] in by_slug]


def status(v):
    cls, slug = v['id'].split('/', 1)
    return json.load(open(f"{ROOT}/cards/{cls.replace('.', '/')}/{slug}/v{v['version']}.json"))['status']


in_games = {v['slug'] for pair in twins for v in pair if status(v) in ('approved', 'legacy')}
twin_slugs = {v['slug'] for pair in twins for v in pair if v['slug'] not in in_games}
USE = ' <em class="use">in games</em>'


def pen_facts(v):
    """size, weight, time (and list cost for a Gemini run) of the job(s) behind a card, in plain words"""
    job = v.get('job') or f"{v['slug']}-v{v['version']}"
    mins = [m for m in {job_minutes(f"{v['slug']}-v1"), job_minutes(job)} if m]
    rounds = ' + '.join(f'{m} min' for m in sorted(mins)) + (' (two rounds)' if len(mins) > 1 else '') if mins else ''
    cost = ''
    relay = job_bytes(job, 'agy-relay.log')
    if relay:
        r = subprocess.run(['python3', os.path.expanduser('~/forge-hub/agent/agy4_cost.py'), '/dev/stdin'], input=relay,
                           capture_output=True)
        if r.returncode == 0:
            cost = f" · ${json.loads(r.stdout)['usd_list']:.2f} list"
    return f"{v['tris'] / 1000:.0f}k triangles · {v['bytes'] / 1e6:.1f} MB{' · ' + rounds if rounds else ''}{cost}"


rows = []
if twins:
    rows.append(f'<h2 id="astra-vs-gemini">GPT-6 Astra vs Gemini 4 <span>{len(twins)} cars, same brief and same reference '
                'sheet</span></h2><p class="note">Left: GPT-6 Astra. Right: Gemini 4 Argon (Antigravity CLI) drawing the same car '
                'from Astra\'s first brief. Astra\'s road cars had one revision round, Gemini\'s none. New games pick the car marked '
                '<b>in games</b>; the other stays in the library.</p><div class="pairs">')
    for a, g in twins:
        figs = ''.join(
            f'<figure><a class="shot" href="vehicles.html?v={E(v["slug"])}"><img src="renders/{E(v["slug"])}-final.jpg" '
            f'alt="{E(v["title"])} drawn by {E(drawn_by(v.get("author")))}" loading="lazy"></a><figcaption><b>{E(drawn_by(v.get("author")))}</b>'
            f'{USE if v["slug"] in in_games else ""}<br>{E(pen_facts(v))}</figcaption></figure>' for v in (a, g))
        more = ''.join(f'<img src="renders/{E(v["slug"])}-paints.jpg" alt="six paints, {E(drawn_by(v.get("author")))}" loading="lazy">'
                       for v in (a, g))
        rows.append(f'<article class="pair"><h3>{E(a["title"])}</h3><div class="two">{figs}</div><details><summary>Paints '
                    f'(Astra, then Gemini) and the shared reference sheet</summary>{more}<img src="renders/{E(a["slug"])}-sheet.jpg" '
                    f'alt="reference sheet" loading="lazy"></details></article>')
    rows.append('</div>')
for fam, title in FAM.items():
    fvs = [v for v in cards if v['family'] == fam and v['slug'] not in twin_slugs]
    if not fvs:
        continue
    rows.append(f'<h2>{E(title)} <span>{len(fvs)} models</span></h2><div class="grid">')
    for v in fvs:
        s = v['slug']
        dmg = (v['nodes'].get('damage') or {}) if v.get('damage_glb') else {}
        dtxt = f" · destructible: {len(dmg['parts'])} panels, {len(dmg['states'])} states" if dmg else ''
        dimg = f'<img src="renders/{E(s)}-states.jpg" alt="damage states: healthy, hit, panels off, track and gun out, wreck, turret blown off" loading="lazy">' if dmg and os.path.exists(f'{OUT}/renders/{s}-states.jpg') else ''
        rows.append(f"""<article><a class="shot" href="vehicles.html?v={E(s)}"><img src="renders/{E(s)}-final.jpg" alt="{E(v['title'])}, Blender render" loading="lazy"></a>
<div class="txt"><h3><a href="vehicles.html?v={E(s)}">{E(v['title'])}</a></h3>
<p>{v['size'][2]:.2f} m long · {v['tris'] / 1000:.0f}k triangles · {v['bytes'] / 1e6:.1f} MB · {len(v['paints'])} paints{' · v' + str(v['version']) if v['version'] > 1 else ''}{dtxt}</p>
<p class="by">Drawn by <b>{E(drawn_by(v.get('author')))}</b></p>
<details><summary>{'Damage states, paints' if dimg else 'Paints'} and reference sheet</summary>{dimg}<img src="renders/{E(s)}-paints.jpg" alt="six paints" loading="lazy"><img src="renders/{E(s)}-sheet.jpg" alt="reference sheet" loading="lazy"></details></div></article>""")
    rows.append('</div>')
nd = sum(1 for v in cards if v.get('damage_glb'))
ndmg = (f'{nd} tank{"s are" if nd > 1 else " is"} destructible (WWI to today, more landing): hit marks, bent and missing panels, a thrown track, '
        'a burst gun, a burnt wreck whose turret can be blown off; try the damage buttons in the showroom. ') if nd else ''
page = f"""<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Forge vehicles: civilian and military</title><style>
:root{{--ink:#1d2330;--mute:#5d6676;--line:#dde1e7;--acc:#2456d6}}
*{{box-sizing:border-box}}body{{margin:0;font:16px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:var(--ink);background:#f6f7f9}}
header{{display:flex;align-items:center;gap:28px;height:56px;padding:0 24px;background:#fff;border-bottom:1px solid var(--line);position:sticky;top:0;z-index:2}}
header strong{{font-size:17px}}nav{{display:flex;gap:6px}}
nav a{{color:var(--ink);text-decoration:none;padding:6px 12px;border-radius:999px;transition:background .18s}}
nav a[aria-current=page]{{background:#e8eefc;color:var(--acc)}}@media (hover:hover){{nav a:hover{{background:#eef1f5}}}}
nav a:focus-visible,a:focus-visible,summary:focus-visible{{outline:2px solid var(--acc);outline-offset:2px}}
main{{max-width:1240px;margin:0 auto;padding:28px 24px 64px}}
.lead{{font-size:18px;color:var(--mute);max-width:820px}}
h2{{margin:40px 0 14px;font-size:22px}}h2 span{{font-size:14px;color:var(--mute);font-weight:400;margin-left:8px}}
.grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(360px,100%),1fr));gap:20px}}
article{{background:#fff;border:1px solid var(--line);border-radius:12px;overflow:hidden}}
.shot img{{display:block;width:100%;aspect-ratio:16/9;object-fit:cover;transition:opacity .18s}}@media (hover:hover){{.shot:hover img{{opacity:.92}}}}
.txt{{padding:12px 16px 14px}}h3{{margin:0 0 4px;font-size:17px}}h3 a{{color:var(--ink);text-decoration:none}}@media (hover:hover){{h3 a:hover{{color:var(--acc)}}}}
p{{margin:0;color:var(--mute);font-size:14px}}details{{margin-top:8px;font-size:14px}}summary{{cursor:pointer;color:var(--acc)}}details img{{width:100%;margin-top:8px;border-radius:6px}}
.pairs{{display:grid;gap:20px}}.pair{{padding:14px 16px}}.pair h3{{margin:0 0 10px}}.two{{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(320px,100%),1fr));gap:14px}}figure{{margin:0}}figure img{{border-radius:8px}}figcaption{{font-size:14px;color:var(--mute);margin-top:6px}}figcaption b{{color:var(--ink)}}.use{{font-style:normal;font-size:12px;font-weight:600;color:#1e6b34;background:#e3f4e8;padding:2px 8px;border-radius:999px;margin-left:6px}}.note{{color:var(--mute);max-width:820px;margin:0 0 14px}}
.cta{{display:inline-block;margin-top:16px;background:var(--acc);color:#fff;text-decoration:none;padding:10px 18px;border-radius:8px;transition:background .18s}}@media (hover:hover){{.cta:hover{{background:#1b44ad}}}}
</style></head><body>
<header><strong>Forge vehicles</strong><nav aria-label="Vehicles"><a href="index.html" aria-current="page">Renders</a><a href="vehicles.html">3D showroom</a></nav></header>
<main><h1>Vehicles for our games: civilian and military</h1>
<p class="lead">{len(cards) - len(twins)} realistic models built in Blender from a reference sheet by GPT-6 Astra or Gemini 4 Argon (each card says which), packed for phones (KTX2 + Draco). No national insignia: games add their own factions.
Every one recolours: games pick one of six paints, or any colour. Wheels spin and steer, bikes pedal, turrets turn, rotors spin, control surfaces move.
{ndmg}Updated {E(stamp)}.</p>
<a class="cta" href="vehicles.html">Open the 3D showroom</a>{' <a class="cta" href="#astra-vs-gemini">Astra vs Gemini 4</a>' if twins else ''}
{''.join(rows)}</main></body></html>"""
# publish_spike.sh mirrors with --delete: a page pointing at a render we could not find would wipe the last good copy
# (15 Oct 2026: 82 renders of 41 vehicles went blank that way). No index.html = publish_spike.sh refuses.
lost = sorted({m for m in re.findall(r'src="(renders/[^"]+)"', page) if not os.path.exists(f'{OUT}/{m}')})
if lost:
    sys.exit(f'{len(lost)} renders missing, NOT publishable: {" ".join(lost[:8])}')
open(f'{OUT}/index.html', 'w').write(page)
print(OUT, len(cards), 'vehicles')
