"""Spaceship spike: /tmp/space-spike/pub = showroom (browse/spaceships.html + the modules it imports, vendor, spaceships.json,
blobs incl. painted copies) + a renders page with one card per craft, grouped by family.
usage: python3 tools/space/build_spike_space.py "<HH:MM CET>"  then  tools/space/publish_spike_space.sh
Reads browse/spaceships.json (tools/space/manifest_space.py). No cards = it says so and changes nothing. A linked render or
blob that cannot be found = no index.html, so publish_spike_space.sh refuses and the last good copy stays online.
Tests can point it elsewhere with SPACE_SPIKE_ROOT (a repo-shaped tree) and SPACE_SPIKE_OUT."""
import html, io, json, os, re, shutil, subprocess, sys
from datetime import datetime
from PIL import Image
ROOT = os.environ.get('SPACE_SPIKE_ROOT') or os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
OUT = os.environ.get('SPACE_SPIKE_OUT', '/tmp/space-spike/pub')
E = html.escape
stamp = sys.argv[1] if len(sys.argv) > 1 else ''
FAM = {'fighters': 'Fighters (several genres)', 'alien': 'Alien vessels', 'ships': 'Spaceships: shuttles, freighters, gunships', 'pods': 'Escape pods',
       'cruisers': 'Cruisers', 'battleships': 'Battleships', 'motherships': 'Motherships', 'speeders': 'Speeders'}


def job_bytes(job, name):
    """a file of a space job: the job dir, else git (None if nowhere)"""
    p = f'{ROOT}/jobs/{job}/{name}'
    if os.path.exists(p):
        return open(p, 'rb').read()
    r = subprocess.run(['git', '-C', ROOT, 'show', f'HEAD:jobs/{job}/{name}'], capture_output=True)
    return r.stdout if r.returncode == 0 and r.stdout else None


def job_minutes(job):
    """minutes between started.txt and finished.txt of the job (None when either is missing)"""
    a, b = job_bytes(job, 'started.txt'), job_bytes(job, 'finished.txt')
    if not (a and b):
        return None
    try:
        t = lambda x: datetime.fromisoformat(x.decode().strip().replace('Z', '+00:00'))
        return max(1, round((t(b) - t(a)).total_seconds() / 60))
    except ValueError:
        return None


def is_gemini(a):
    return 'gemini-4' in (a or '').lower()


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 is_gemini(a) else a


def list_cost(job):
    """list price of a Gemini job from its relay log (agy4_cost.py), '' when there is no log"""
    relay = job_bytes(job, 'agy-relay.log')
    if not relay:
        return ''
    r = subprocess.run(['python3', os.path.expanduser('~/forge-hub/agent/agy4_cost.py'), '/dev/stdin'], input=relay, capture_output=True)
    return f" · ${json.loads(r.stdout)['usd_list']:.2f} list" if r.returncode == 0 and r.stdout else ''


def local_modules(rel):
    """the local ES modules a page or module imports, recursively; relative paths kept (stage.mjs pulls garage/paint.mjs)"""
    seen, todo = set(), [rel]
    while todo:
        f = todo.pop()
        text = open(f'{ROOT}/browse/{f}').read()
        for m in re.findall(r"""(?:from|import)\s*\(?\s*['"]\./([\w./-]+\.m?js)['"]""", text):
            g = os.path.normpath(os.path.join(os.path.dirname(f), m))
            if g not in seen:
                seen.add(g); todo.append(g)
    return sorted(seen)


manifest = f'{ROOT}/browse/spaceships.json'
cards = json.load(open(manifest)) if os.path.exists(manifest) else []
if not cards:
    print('0 spacecraft cards in browse/spaceships.json (run tools/space/manifest_space.py): nothing built, nothing changed')
    sys.exit(0)

if os.path.exists(OUT):
    shutil.rmtree(OUT)
os.makedirs(f'{OUT}/blobs'); os.makedirs(f'{OUT}/renders')
for f in ['spaceships.html', 'spaceships.json', *local_modules('spaceships.html')]:
    os.makedirs(os.path.dirname(f'{OUT}/{f}') or OUT, exist_ok=True)
    shutil.copy(f'{ROOT}/browse/{f}', f'{OUT}/{f}')
shutil.copytree(f'{ROOT}/browse/garage', f'{OUT}/garage', dirs_exist_ok=True)  # stage.mjs also reads garage/colormap.png when a kit asks for it
shutil.copytree(f'{ROOT}/browse/vendor', f'{OUT}/vendor', symlinks=False, ignore=shutil.ignore_patterns('fge-look', 'rapier'))

lost_blobs = []
for v in cards:
    for sha in filter(None, [v['glb'], v.get('painted_glb')]):
        src = f'{ROOT}/store/blobs/{sha}.glb'
        if os.path.exists(src):
            shutil.copy(src, f'{OUT}/blobs/')
        else:
            lost_blobs.append(f"{v['slug']}:{sha[:10]}")
    for src, dst, w in (('final.png', 'final', 1280), ('paints.png', 'paints', 1280), ('sheet.jpg', 'sheet', 1280),
                        ('painted-before.jpg', 'painted-before', 1600), ('painted-after.jpg', 'painted-after', 1600)):
        b = job_bytes(v['job'], src)
        if b:
            im = Image.open(io.BytesIO(b)).convert('RGB')
            if im.width > w:
                im = im.resize((w, int(w * im.height / im.width)))
            im.save(f"{OUT}/renders/{v['slug']}-{dst}.jpg", quality=86)


def fmt_len(m):
    return f'{m:,.0f}' if m >= 100 else f'{m:.1f}'


def card_html(v):
    s = v['slug']
    mins = job_minutes(v['job'])
    took = f' · took {mins} min' if mins else ''
    cost = list_cost(v['job']) if is_gemini(v.get('author')) else ''
    pair = ''
    if os.path.exists(f'{OUT}/renders/{s}-painted-before.jpg') and os.path.exists(f'{OUT}/renders/{s}-painted-after.jpg'):
        pair = (f'<div class="pair"><figure><figcaption>Before: Blender textures</figcaption><img src="renders/{E(s)}-painted-before.jpg" '
                f'alt="{E(v["title"])} before the repaint, eight angles" loading="lazy"></figure><figure><figcaption>After: repainted copy</figcaption>'
                f'<img src="renders/{E(s)}-painted-after.jpg" alt="{E(v["title"])} after the repaint, eight angles" loading="lazy"></figure></div>')
    return f"""<article><a class="shot" href="spaceships.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="spaceships.html?v={E(s)}">{E(v['title'])}</a></h3>
<p>{fmt_len(v['size'][2])} 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 ''}</p>
<p class="by">Drawn by <b>{E(drawn_by(v.get('author')))}</b>{took}{cost}</p>{pair}
<details><summary>Paints and reference sheet</summary><img src="renders/{E(s)}-paints.jpg" alt="paints" loading="lazy"><img src="renders/{E(s)}-sheet.jpg" alt="reference sheet" loading="lazy"></details></div></article>"""


rows = []
for fam in [*FAM, *sorted({v['family'] for v in cards} - set(FAM))]:
    fvs = [v for v in cards if v['family'] == fam]
    if not fvs:
        continue
    rows.append(f'<h2>{E(FAM.get(fam, fam))} <span>{len(fvs)} {"model" if len(fvs) == 1 else "models"}</span></h2><div class="grid">')
    rows.extend(card_html(v) for v in fvs)
    rows.append('</div>')
npaint = sum(1 for v in cards if v.get('painted_glb'))
page = f"""<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Forge spaceships</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[aria-current=page]:hover{{background:#dde7fb}}}}
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;transition:color .18s}}@media (hover:hover){{h3 a:hover{{color:var(--acc)}}}}
p{{margin:0;color:var(--mute);font-size:14px}}.by b{{color:var(--ink)}}
details{{margin-top:8px;font-size:14px}}summary{{cursor:pointer;color:var(--acc);transition:color .18s}}@media (hover:hover){{summary:hover{{color:#1b44ad}}}}details img{{width:100%;margin-top:8px;border-radius:6px}}
.pair{{display:grid;gap:6px;margin-top:10px}}.pair figure{{margin:0}}.pair figcaption{{font-size:13px;color:var(--mute);margin-bottom:2px}}.pair img{{display:block;width:100%;border-radius:6px;background:#0b1022}}
.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}}}}
.cta:focus-visible{{outline-color:#1b44ad}}
</style></head><body>
<header><strong>Forge spaceships</strong><nav aria-label="Spaceships"><a href="index.html" aria-current="page">Renders</a><a href="spaceships.html">3D showroom</a></nav></header>
<main><h1>Forge spaceships</h1>
<p class="lead">{len(cards)} models built in Blender from a concept sheet by GPT-6 Astra or Gemini 4 Argon (each card says which), packed for phones (KTX2 + Draco). Every one recolours, the moving parts move, and there is no insignia or text, so games add their own factions.{f' {npaint} of them also {"has" if npaint == 1 else "have"} a painted copy, repainted by an image model.' if npaint else ''}
Updated {E(stamp)}.</p>
<a class="cta" href="spaceships.html">Open the 3D showroom</a>
{''.join(rows)}</main></body></html>"""
# publish_spike_space.sh mirrors with --delete: a page pointing at a render we could not find would wipe the last good copy.
# No index.html = publish_spike_space.sh refuses.
lost = sorted({m for m in re.findall(r'src="(renders/[^"]+)"', page) if not os.path.exists(f'{OUT}/{m}')})
if lost or lost_blobs:
    sys.exit(f'NOT publishable: {len(lost)} renders missing ({" ".join(lost[:8])}), {len(lost_blobs)} blobs missing ({" ".join(lost_blobs[:6])}); no index.html written')
open(f'{OUT}/index.html', 'w').write(page)
print(OUT, len(cards), 'spacecraft,', npaint, 'with a painted copy')
