"""Contract check of a spacecraft/v1 or hover/v1 GLB (jobs written by tools/space/make_jobs_space.py) before it becomes a card.
usage: python3 tools/space/check_space.py <job_dir> [<glb>]   -> prints JSON {ok, errors, warnings, facts}; exit 1 on errors.
Errors stop the card (things a game would break on): no Hull, no Paint material, a moving part whose pivot is not on the
part (it would orbit instead of turning), a Gun not under its Turret, a size far from the brief. Everything else is a warning
the card keeps (missing thrusters or lights, merged turrets, budget)."""
import json, os, re, sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.join(ROOT, 'lib')); sys.path.insert(0, os.path.join(ROOT, 'tools', 'vehicles'))
import glb  # noqa: E402
from check_vehicle import world_nodes, _desc  # noqa: E402

MOVING = (r'(Turret_\d+|Gun_\d+|Canopy|Wing_[LR]|Radiator_[LR]|BayDoor_[LR]|Ring(_\d+)?|Radar|Core|Arm_\w+|Drill|Ramp|Hatch|'
          r'Door_[LR]|Fan_\w+|Pod_\w+|Vane_[LR]|Yoke)')


def expected_moving(text):
    """node names the job's 'Extra moving parts' sentence promised (Arm_1..Arm_6 expanded)"""
    out = []
    for sent in re.findall(r'Extra moving parts?: (.*?\))\. ', text):
        for a, b, c in re.findall(r'\b([A-Z][a-z]+)_(\d+)\.\.\1_(\d+)', sent):
            out += [f'{a}_{k}' for k in range(int(b), int(c) + 1)]
        sent = re.sub(r'\b([A-Z][a-z]+)_\d+\.\.\1_\d+', '', sent)
        out += re.findall(r'\b(?:[A-Z][a-z]+_(?:[A-Z]{1,2}|\d+)|Ramp|Hatch|Ring|Radar|Core|Drill|Yoke|Canopy)\b', sent)
    return sorted(set(out))


def size_asked(text):
    g = lambda rx: (lambda m: float(m.group(1)) if m else None)(re.search(rx, text))
    return dict(l=g(r'([\d.]+) m long'), w=g(r'([\d.]+) m (?:wide|wingspan|fin span)'), h=g(r'([\d.]+) m high'),
                d=g(r'([\d.]+) m diameter'), hover=g(r'hovering ([\d.]+) m'))


def check(jd, f=None):
    j = json.load(open(f'{jd}/job.json'))
    f = f or f"{jd}/{j['slug']}.glb"
    doc = glb.read(f)[0]
    m = glb.measure(f)
    nodes = world_nodes(doc)
    names = set(nodes)
    hover = j.get('contract') == 'hover/v1'
    fam = j.get('family')
    err, warn = [], []
    mesh = lambda n: doc['nodes'][nodes[n][1]].get('mesh') is not None
    if 'Hull' not in names:
        err.append('missing node Hull')
    thr = sorted(n for n in names if re.fullmatch(r'Thruster_\d+', n))
    if not thr:
        warn.append('no Thruster_1..n empties (games cannot place engine flames)')
    for n in thr:
        if mesh(n): warn.append(f'{n} has a mesh (should be an empty)')
    # moving parts keep their origin ON their pivot: the POSITION accessor box of the node's own mesh (local space, Draco or
    # not) must contain (0,0,0) (10 Oct 2026 lesson: the Hind's tail rotor orbited the aircraft)
    for nm, (_, i) in nodes.items():
        nd = doc['nodes'][i]
        if not re.fullmatch(MOVING, nm) or nd.get('mesh') is None:
            continue
        acc = [doc['accessors'][pr['attributes']['POSITION']] for pr in doc['meshes'][nd['mesh']]['primitives'] if 'POSITION' in pr['attributes']]
        lo = [min(a['min'][k] for a in acc) for k in range(3)]; hi = [max(a['max'][k] for a in acc) for k in range(3)]
        pad = 0.08 * max(h - l for h, l in zip(hi, lo)) + 0.05
        if not all(lo[k] - pad <= 0 <= hi[k] + pad for k in range(3)):
            err.append(f'{nm} origin is outside its own geometry (pivot not on the part: it will orbit instead of turning)')
    for g in sorted(n for n in names if re.fullmatch(r'Gun_\d+', n)):
        t = 'Turret_' + g[4:]
        if t in names and nodes[g][1] not in _desc(doc, nodes[t][1]):
            err.append(f'{g} is not under {t} (turret traverse would leave the gun behind)')
    promised = expected_moving(j.get('nodes', ''))
    missing = [n for n in promised if n not in names]
    if missing: warn.append(f'moving parts merged or missing: {", ".join(missing)}')
    turrets = sorted(n for n in names if re.fullmatch(r'Turret_\d+', n))
    if re.search(r'turret', j['name'], re.I) and not turrets:
        warn.append('the brief has turrets but there is no Turret_ node (turrets merged into the hull)')
    mats = {mt.get('name', ''): mt for mt in doc.get('materials', [])}
    if 'Paint' not in mats:
        err.append('no material named Paint (recolour contract)')
    else:
        fac = mats['Paint'].get('pbrMetallicRoughness', {}).get('baseColorFactor', [1, 1, 1, 1])
        if fac[:3] == [1, 1, 1]: warn.append('Paint baseColorFactor is white: default colour baked in the texture?')
    for need in ('Engines', 'Lamps'):
        if need not in mats: warn.append(f'no material named {need}')
    if fam in ('fighters', 'ships', 'speeders') and 'alien' not in j.get('tags', []) and 'Glass' not in mats:
        warn.append('no material named Glass')
    if len(mats) > j['mats'] + 2: warn.append(f'{len(mats)} materials (budget {j["mats"]})')
    seat = ['Seat_Driver', 'Seat_Rider'] if hover else ['Seat_Pilot']
    if fam in ('fighters', 'speeders') and not any(s in names for s in seat):
        warn.append(f'no {seat[0]} empty')
    for n in (['Camera_Chase'] + ([] if hover else ['Light_Nav_L', 'Light_Nav_R'])):
        if n not in names: warn.append(f'no {n}')
    # size against the brief (Z = length, X = width, Y = height)
    W, H, L = m['size_xyz']
    a = size_asked(j['size'])
    def cmp(label, got, want, err_at, warn_at):
        if not want: return
        off = abs(got - want) / want
        if off > err_at: err.append(f'{label} {got:.2f} m vs asked {want} m')
        elif off > warn_at: warn.append(f'{label} {got:.2f} m vs asked {want} m')
    cmp('length', L, a['l'], 0.30, 0.10)
    cmp('width', W, a['w'], 0.45, 0.15)
    cmp('height', H, a['h'], 0.45, 0.20)
    if a['d']:   # a disc (saucer, capsule) is as wide as it is long; a long pod's diameter is its cross-section
        cmp('diameter', max(W, H) if a['l'] else max(W, L), a['d'], 0.30, 0.10)
    b = m['bounds']
    if hover:
        low = b['min'][1]
        if low < 0.05: warn.append(f'lowest point y={low:.2f} m: it should hover ({a["hover"]} m)')
        elif a['hover'] and abs(low - a['hover']) > 0.35: warn.append(f'hovers at {low:.2f} m (asked {a["hover"]} m)')
    else:
        c = [(b['min'][k] + b['max'][k]) / 2 for k in range(3)]
        span = max(m['size_xyz'])
        off = max(abs(x) for x in c) / span
        if off > 0.25: warn.append(f'origin far from the middle of the ship (bbox centre {[round(x, 2) for x in c]})')
    tmax = int(re.findall(r'(\d+)k', j['tris'])[-1]) * 1000
    if m['tris'] > tmax * 1.3: warn.append(f"{m['tris']} tris over budget {j['tris']}")
    big = [t for t in m['textures'] if max(t.get('w', 0), t.get('h', 0)) > 1024]
    if big: warn.append(f'{len(big)} textures above 1024 px (pack caps them)')
    info = {}
    if os.path.exists(f'{jd}/nodes.json'):
        try:
            info = json.load(open(f'{jd}/nodes.json'))
        except Exception as e:  # noqa: BLE001
            warn.append(f'nodes.json unreadable: {e}')
    paints = info.get('paints') or [] if isinstance(info, dict) else []
    if len(paints) < 4: warn.append(f'{len(paints)} paints in nodes.json (asked 6)')
    moving = sorted(n for n in names if re.fullmatch(MOVING, n) and mesh(n))
    if moving and not (isinstance(info, dict) and info.get('axes')): warn.append('no "axes" in nodes.json for the moving parts')
    facts = dict(size=[round(W, 3), round(H, 3), round(L, 3)], tris=m['tris'], materials=sorted(mats), textures=len(m['textures']),
                 gpu_mb=round(m['gpu_mb_decoded'], 1), mb=round(m['bytes'] / 1e6, 2), thrusters=len(thr), turrets=len(turrets),
                 moving=moving, promised=promised, paints=len(paints),
                 empties=sorted(n for n in names if doc['nodes'][nodes[n][1]].get('mesh') is None))
    return dict(slug=j['slug'], ok=not err, errors=err, warnings=warn, facts=facts)


if __name__ == '__main__':
    r = check(os.path.abspath(sys.argv[1]), sys.argv[2] if len(sys.argv) > 2 else None)
    print(json.dumps(r, indent=1))
    sys.exit(0 if r['ok'] else 1)
