"""Contract check of an Astra vehicle GLB (cars, buses, motorbikes, bicycles) before it becomes a card.
usage: python3 tools/vehicles/check_vehicle.py <job_dir> [<glb>]   -> prints JSON {ok, errors, warnings, facts}; exit 1 on errors.
Checks: required node names, wheels on the right side/end (+X = left, +Z = front), wheel origins at hub height, tyres on
y = 0, Paint material with a factor (recolour contract), Glass/Lamps materials, size against job.json, 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'))
import glb  # noqa: E402


def world_nodes(doc):
    """{name: (world position, node index)} for every node."""
    out = {}
    def walk(i, parent):
        n = doc['nodes'][i]
        m = glb._mul(parent, glb._mat(n))
        out[n.get('name', f'node{i}')] = ([round(m[r][3], 3) for r in range(3)], i)
        for c in n.get('children', []):
            walk(c, m)
    ident = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
    for s in doc.get('scenes', [{}])[doc.get('scene', 0)].get('nodes', []):
        walk(s, ident)
    return out


def _desc(doc, i):
    out, stack = set(), list(doc['nodes'][i].get('children', []))
    while stack:
        k = stack.pop(); out.add(k); stack += doc['nodes'][k].get('children', [])
    return out


def nums(text):
    return [float(x) for x in re.findall(r'(\d+(?:\.\d+)?) m\b', text)]


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)
    fam = j.get('family')
    two = fam in ('motos', 'bikes')
    err, warn = [], []
    four = fam in ('cars', 'buses', 'wheeled')
    need = {'motos': ['Frame', 'Fork', 'Wheel_F', 'Wheel_R', 'Seat_Rider', 'Hand_L', 'Hand_R', 'Foot_L', 'Foot_R'],
            'bikes': ['Frame', 'Fork', 'Wheel_F', 'Wheel_R', 'Seat_Rider', 'Hand_L', 'Hand_R', 'Foot_L', 'Foot_R', 'Crank', 'Pedal_L', 'Pedal_R'],
            'tanks': ['Hull', 'Turret', 'Gun', 'Track_L', 'Track_R', 'Muzzle'],
            'rotor': ['Fuselage', 'Seat_Pilot'],
            'jets': ['Fuselage', 'Gear_Nose', 'Gear_L', 'Gear_R', 'Seat_Pilot'],
            }.get(fam, ['Body', 'Wheel_FL', 'Wheel_FR', 'Wheel_RL', 'Wheel_RR', 'SteeringWheel', 'Seat_Driver'])
    if j.get('layout') == 'turretless':  # WWI types: sponson or nose guns, no turret (tank batch 2)
        need = ['Hull', 'Track_L', 'Track_R']
        if not ({'Gun', 'Muzzle'} <= names or {'Gun_L', 'Gun_R', 'Muzzle_L', 'Muzzle_R'} <= names):
            err.append('no Gun + Muzzle (or Gun_L/Gun_R + Muzzle_L/Muzzle_R)')
    for n in need:  # 11 Oct 2026: the list was written but never tested
        if n not in names: err.append(f'missing node {n}')
    if fam == 'rotor' and not ({'MainRotor', 'TailRotor'} <= names or {'Rotor_Front', 'Rotor_Rear'} <= names):
        err.append('no MainRotor + TailRotor (or Rotor_Front + Rotor_Rear)')
    # moving parts must keep their origin ON their pivot (10 Oct 2026: the Hind's tail rotor and gun sat on the ground
    # origin, so the tail rotor orbited the aircraft and the gun swung through the cockpit). The POSITION accessor min/max
    # of a node's own mesh is in its local space, Draco or not: the origin (0,0,0) must lie inside that box.
    MOVING = r'(MainRotor|TailRotor|Rotor_Front|Rotor_Rear|Rotodome|Turret|Gun|Gun_[LR]|Gun_Hull|Barrel|MG|MG_\d+|Wheel_\w+|Fork|Crank)'
    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.05 * max(h - l for h, l in zip(hi, lo)) + 0.02
        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)')
    if 'awacs' in j['id'] and 'Rotodome' not in names:
        err.append('missing node Rotodome')
    if fam == 'tanks' and 'Turret' in names and 'Gun' in names and nodes['Gun'][1] not in _desc(doc, nodes['Turret'][1]):
        err.append('Gun is not under Turret (turret traverse would leave the gun behind)')
    if two and 'Wheel_F' in names and 'Fork' in names:
        fork_i = nodes['Fork'][1]
        kids = doc['nodes'][fork_i].get('children', [])
        if nodes['Wheel_F'][1] not in kids and j['slug'] != 'cargo-bike':
            warn.append('Wheel_F is not a child of Fork (steering will not carry the wheel)')
    wheels = {n: p for n, (p, _) in nodes.items() if re.fullmatch(r'Wheel_(F|R|FL|FR|RL|RR|RL2|RR2)', n)}
    tw = {n: p for n, (p, _) in nodes.items() if re.fullmatch(r'Wheel_[LR]\d+', n)}
    for n, p in tw.items():
        if n[6] == 'L' and p[0] <= 0: err.append(f'{n} not on +X (left) side (x={p[0]})')
        if n[6] == 'R' and p[0] >= 0: err.append(f'{n} not on -X (right) side (x={p[0]})')
    if fam == 'tanks' and len(tw) < 6: warn.append(f'{len(tw)} track wheels as nodes (Wheel_L1.., Wheel_R1..)')
    for n, p in wheels.items():
        side = n[-1] if len(n) > 7 else ''
        tag = n.split('_')[1]
        if tag.startswith('F') and p[2] <= 0: err.append(f'{n} not at the front (z={p[2]})')
        if tag.startswith('R') and p[2] >= 0: err.append(f'{n} not at the rear (z={p[2]})')
        if not two:
            if tag in ('FL', 'RL', 'RL2') and p[0] <= 0: err.append(f'{n} not on +X (left) side (x={p[0]})')
            if tag in ('FR', 'RR', 'RR2') and p[0] >= 0: err.append(f'{n} not on -X (right) side (x={p[0]})')
        elif abs(p[0]) > 0.05: warn.append(f'{n} off the centre line (x={p[0]})')
        if p[1] < 0.1: err.append(f'{n} origin not at the hub (y={p[1]})')
    if wheels and four:
        zs = [p[2] for p in wheels.values()]
        mid = (max(zs) + min(zs)) / 2
        if abs(mid) > 0.08: warn.append(f'origin not midway between axles (axle midpoint z={mid:.3f})')
    b = m['bounds']
    if abs(b['min'][1]) > 0.03: warn.append(f'lowest point y={b["min"][1]:.3f} (tyres should touch 0)')
    mats = {mt.get('name', ''): mt for mt in doc.get('materials', [])}
    paint = mats.get('Paint')
    if not paint:
        err.append('no material named Paint (recolour contract)')
    else:
        pbr = paint.get('pbrMetallicRoughness', {})
        fac = pbr.get('baseColorFactor', [1, 1, 1, 1])
        if fac[:3] == [1, 1, 1]: warn.append('Paint baseColorFactor is white: default colour baked in the texture?')
    open_cockpit = j.get('open_cockpit') or 'OPEN-COCKPIT' in j.get('nodes', '')  # a formula car has no windows (15 Oct 2026)
    if fam not in ('motos', 'bikes', 'tanks') and not open_cockpit and 'Glass' not in mats: err.append('no material named Glass')
    if 'Lamps' not in mats and fam != 'bikes': warn.append('no material named Lamps')
    if len(mats) > j['mats'] + 1: warn.append(f'{len(mats)} materials (budget {j["mats"]})')
    sz = nums(j['size'])
    L = m['size_xyz'][2]; W = m['size_xyz'][0]; H = m['size_xyz'][1]
    if sz:
        if abs(L - sz[0]) / sz[0] > 0.08:
            (warn if fam == 'rotor' else err).append(f'length {L:.2f} m vs asked {sz[0]} m')  # rotor blades at rest change the box
        hm = re.search(r'(\d+(?:\.\d+)?) m high', j['size'])
        if hm and abs(H - float(hm.group(1))) / float(hm.group(1)) > 0.12: warn.append(f'height {H:.2f} m vs asked {hm.group(1)} m')
    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)')
    paints = []
    if os.path.exists(f'{jd}/nodes.json'):
        try:
            paints = json.load(open(f'{jd}/nodes.json')).get('paints') or []
        except Exception as e:  # noqa: BLE001
            warn.append(f'nodes.json unreadable: {e}')
    if len(paints) < 4: warn.append(f'{len(paints)} paints in nodes.json (asked 6)')
    dmg = None
    if j.get('damage'):
        dmg = check_damage(jd, j, doc, nodes, m)
        if dmg['parts'] < 6: err.append(f"only {dmg['parts']} Part_ nodes (bolt-on items must be their own nodes)")
    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), wheels=wheels, paints=len(paints),
                 empties=sorted(n for n in names if doc['nodes'][nodes[n][1]].get('mesh') is None))
    out = dict(slug=j['slug'], ok=not err, errors=err, warnings=warn, facts=facts)
    if dmg: out['damage'] = dmg
    return out


def check_damage(jd, j, doc, nodes, m):
    """tank-damage/v1: Part_ nodes in the healthy file + <slug>-damage.glb (Hull_Damage, Turret_Damage, Wreck_Hull,
    Wreck_Turret). Returns {ok, parts, twins, errors, warnings, states}: a tank with a broken damage file still gets its
    card (healthy contract), the card lists only the states that really exist."""
    e, w = [], []
    turretless = j.get('layout') == 'turretless'
    parts = sorted(n for n in nodes if n.startswith('Part_') and doc['nodes'][nodes[n][1]].get('mesh') is not None)
    small = 'Small tank' in j.get('nodes', '')
    if len(parts) < (10 if small else 14): w.append(f'{len(parts)} Part_ nodes (asked {10 if small else 14})')
    for n in ('Fx_Engine', 'Fx_Ammo', 'Hatch_Commander'):
        if n not in nodes: w.append(f'missing {n}')
    if 'Barrel' not in nodes and not turretless: w.append('no Barrel node (recoil)')
    f = f"{jd}/{j['slug']}-damage.glb"
    states = ['healthy'] + (['stripped'] if parts else [])
    if not os.path.exists(f):
        return dict(ok=False, parts=len(parts), twins=0, errors=['no damage file'], warnings=w, states=states)
    dd = glb.read(f)[0]; dn = world_nodes(dd); dm = glb.measure(f)
    top = [dd['nodes'][i].get('name') for i in dd.get('scenes', [{}])[dd.get('scene', 0)].get('nodes', [])]
    want_top = ['Hull_Damage', 'Turret_Damage', 'Wreck_Hull'] + ([] if turretless else ['Wreck_Turret'])
    for n in want_top:
        if n not in dn: e.append(f'damage file: missing {n}')
        elif n not in top: w.append(f'damage file: {n} is not a top-level node')
    def near(a, b, tol=0.05): return all(abs(x - y) <= tol for x, y in zip(a, b))
    base_gun = 'Gun' if 'Gun' in nodes else 'Gun_L'
    pairs = [('Gun_Broken', base_gun)] + ([] if turretless else [('Turret_Damage', 'Turret'), ('Wreck_Turret', 'Turret')])
    for a, b in pairs:
        if a in dn and b in nodes and not near(dn[a][0], nodes[b][0]):
            e.append(f'damage file: {a} at {dn[a][0]} but {b} is at {nodes[b][0]} (they must share the pivot)')
    for n in ('Hull_Damage', 'Wreck_Hull'):
        if n in dn and not near(dn[n][0], [0, 0, 0]): e.append(f'damage file: {n} not at the origin ({dn[n][0]})')
    twins = sorted(n for n in dn if n.startswith('Dmg_') and dd['nodes'][dn[n][1]].get('mesh') is not None)
    lone = [t for t in twins if 'Part_' + t[4:] not in nodes]
    if lone: w.append(f'{len(lone)} Dmg_ twins without a Part of the same name: {lone[:4]}')
    off = [t for t in twins if 'Part_' + t[4:] in nodes and not near(dn[t][0], nodes['Part_' + t[4:]][0], 0.08)]
    if off: w.append(f'{len(off)} Dmg_ twins not at their Part origin: {off[:4]}')
    if len(twins) < 6: w.append(f'{len(twins)} Dmg_ twins (asked 11)')
    scars = sorted(n for n in dn if n.startswith('Scar_'))
    miss = [n for n in ['Scar_Hull_Front', 'Scar_Hull_L', 'Scar_Hull_R', 'Scar_Hull_Rear', 'Scar_Turret_Front', 'Scar_Turret_L',
                        'Scar_Turret_R', 'Scar_Turret_Rear', 'Track_L_Broken', 'Track_R_Broken', 'Gun_Broken'] if n not in dn]
    if miss: e.append(f'damage file: missing {", ".join(miss)}')
    if dm['tris'] > 110000 * 1.3: w.append(f"damage file {dm['tris']} tris (budget 110k)")
    if 'Burnt' not in {mt.get('name') for mt in dd.get('materials', [])}: w.append('damage file: no material named Burnt')
    if scars or (twins and not lone): states.append('hit')
    if 'Track_L_Broken' in dn and 'Track_R_Broken' in dn: states.append('immobilised')
    if 'Gun_Broken' in dn: states.append('gun-out')
    if 'Wreck_Hull' in dn and (turretless or 'Wreck_Turret' in dn): states.append('wreck')
    return dict(ok=not e, parts=len(parts), twins=len(twins), scars=len(scars), errors=e, warnings=w, states=states,
                tris=dm['tris'], mb=round(dm['bytes'] / 1e6, 2), gpu_mb=round(dm['gpu_mb_decoded'], 1))


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)
