"""Turn raw Unity colliders into the four depth-sorted sets `src/work/ArenaData.ts` exports.

    python3 tools/unity_extract.py "<scene>.unity"  > tools/colliders.json
    python3 tools/unity_markers.py "<scene>.unity"  > tools/markers.json
    python3 tools/build_parts.py [--map]            > tools/arena_parts.json

Ships fly at z=0 with a 0.8-radius collider and their Z frozen, so only geometry crossing
z ∈ [-0.8, 0.8] can be hit. Everything else is set dressing at its own depth, in front of the plane
or behind it. `--map` prints an ASCII plan of the result.

Unity renumbered its collision layers between the two project trees — TERRAIN is 7 in
one tree and 10 in the other, and the level lived in the second — so the
number is read by NAME out of the project's own TagManager. It used to be inferred as "whichever
layer holds the most in-plane boxes", which silently picked Default once the extractor started
finding the colliders nested inside prefabs, and quietly dropped every real Terrain box.
"""

import collections
import json
import os
import re
import sys

SC = os.path.dirname(os.path.abspath(__file__))

# Must match unity_extract's slice: the depth band a ship is deemed to occupy, in Unity's Z. The
# arena's doorways are archways cut at z -3..-1, so the play plane is not at zero — see the note in
# unity_extract.py. Classifying around a different centre than the extractor sliced at would drop
# every sliced wall into "foreground" and leave the level with no collision at all.
PLANE_HALF = float(_CFG['planeHalf'])
PLANE_CENTER = float(_CFG['planeZ'])
PLANE_LO = PLANE_CENTER - PLANE_HALF
PLANE_HI = PLANE_CENTER + PLANE_HALF

# The project the scene belongs to; its TagManager names the collision layers.
from import_config import CFG as _CFG, unity_root as _unity_root
UNITY_ROOT = _unity_root()


def layer_index(name):
    """The number Unity gave a named collision layer, or None if the project cannot be read."""
    path = os.path.join(UNITY_ROOT, 'ProjectSettings', 'TagManager.asset')
    try:
        with open(path, encoding='utf-8', errors='ignore') as fh:
            text = fh.read()
    except OSError:
        return None
    block = re.search(r'\n  layers:\n(.*?)(?=\n  m_\w)', text, re.S)
    if not block:
        return None
    for i, line in enumerate(block.group(1).split('\n')):
        entry = line.strip()
        if entry.startswith('- '):
            entry = entry[2:].strip()
        elif entry == '-':
            entry = ''
        else:
            continue
        if entry == name:
            return i
    return None

# An FBX whose bounds are junk — LOD or pivot geometry that swamps the real mesh.
BAD_TAGS = set(_CFG['badTags'])


def qrot(q, v):
    x, y, z, w = q
    vx, vy, vz = v
    tx = 2 * (y * vz - z * vy)
    ty = 2 * (z * vx - x * vz)
    tz = 2 * (x * vy - y * vx)
    return (vx + w * tx + (y * tz - z * ty),
            vy + w * ty + (z * tx - x * tz),
            vz + w * tz + (x * ty - y * tx))


def to_aabb(colliders):
    out = []
    for c in colliders:
        sx, sy, sz = c['size']
        scx, scy, scz = c['scale']
        cx, cy, cz = c['center']
        hx, hy, hz = sx * scx / 2, sy * scy / 2, sz * scz / 2
        q = c['quat']
        p = c['pos']
        wc = qrot(q, (cx * scx, cy * scy, cz * scz))
        ctr = (p[0] + wc[0], p[1] + wc[1], p[2] + wc[2])
        mn = [1e9] * 3
        mx = [-1e9] * 3
        for ix in (-1, 1):
            for iy in (-1, 1):
                for iz in (-1, 1):
                    v = qrot(q, (ix * hx, iy * hy, iz * hz))
                    for k in range(3):
                        w = ctr[k] + v[k]
                        mn[k] = min(mn[k], w)
                        mx[k] = max(mx[k], w)
        out.append(dict(tag=c['tag'], name=c.get('name', ''), layer=c['layer'],
                        trigger=c['trigger'],
                        min=[round(v, 3) for v in mn], max=[round(v, 3) for v in mx]))
    return out


def merge(rects):
    """Fuse rectangles that share an edge, so the wall list stays short.

    A fused rectangle keeps the first source's name and tag; the pieces being merged are runs of
    one object's cross-section, so that is the object either way.
    """
    rs = [dict(r) for r in rects]
    changed = True
    while changed:
        changed = False
        for i in range(len(rs)):
            for j in range(i + 1, len(rs)):
                a, b = rs[i], rs[j]
                if (abs(a['y0'] - b['y0']) < 0.02 and abs(a['y1'] - b['y1']) < 0.02
                        and a['x0'] <= b['x1'] + 0.02 and b['x0'] <= a['x1'] + 0.02):
                    a['x0'] = min(a['x0'], b['x0'])
                    a['x1'] = max(a['x1'], b['x1'])
                    rs.pop(j)
                    changed = True
                    break
                if (abs(a['x0'] - b['x0']) < 0.02 and abs(a['x1'] - b['x1']) < 0.02
                        and a['y0'] <= b['y1'] + 0.02 and b['y0'] <= a['y1'] + 0.02):
                    a['y0'] = min(a['y0'], b['y0'])
                    a['y1'] = max(a['y1'], b['y1'])
                    rs.pop(j)
                    changed = True
                    break
            if changed:
                break
    return rs


def main():
    colliders = json.load(open(os.path.join(SC, 'colliders.json')))
    markers_path = os.path.join(SC, 'markers.json')
    markers = json.load(open(markers_path)) if os.path.exists(markers_path) else []

    boxes = to_aabb(colliders)
    in_plane = [o for o in boxes if o['min'][2] <= PLANE_HI and o['max'][2] >= PLANE_LO]

    terrain_layer = layer_index('Terrain')
    if terrain_layer is None:
        counts = collections.Counter(o['layer'] for o in in_plane if not o['trigger'])
        terrain_layer = counts.most_common(1)[0][0] if counts else 0
        print(f'warning: no TagManager under {UNITY_ROOT}; inferred terrain layer {terrain_layer}',
              file=sys.stderr)
    # Solid is Terrain plus Default — Unity's collision matrix is the default all-on, so a ship
    # hits both, and a level's walls really can be spread across the two (in the origin game a
    # base was enclosed by Default-layer facades, one of which carried its doorway).
    #
    # SHADOWS is never solid. Those are the low-poly stand-ins the original rendered only into its
    # line-of-sight mask; they sit inside the geometry they stand in for, and making them solid
    # seals a base shut. Unity records which copy is which as a per-instance m_Layer override, so
    # this only became visible once those were honoured.
    shadow_layer = layer_index('Shadows')
    solid_layers = {terrain_layer, 0}

    bases = [m for m in markers if m['kind'] == 'Base']
    if bases:
        bx = [b['pos'][0] for b in bases]
        by = [b['pos'][1] for b in bases]
        pad = 14
        X0, X1 = min(bx) - pad, max(bx) + pad
        Y0, Y1 = min(by) - pad, max(by) + pad
    else:
        xs = [o['min'][0] for o in in_plane] + [o['max'][0] for o in in_plane]
        ys = [o['min'][1] for o in in_plane] + [o['max'][1] for o in in_plane]
        X0, X1, Y0, Y1 = min(xs), max(xs), min(ys), max(ys)

    def inside(o, pad=6):
        return (o['max'][0] > X0 - pad and o['min'][0] < X1 + pad
                and o['max'][1] > Y0 - pad and o['min'][1] < Y1 + pad)

    def rect(o, pad=6):
        return dict(x0=round(max(o['min'][0], X0 - pad), 2), y0=round(max(o['min'][1], Y0 - pad), 2),
                    x1=round(min(o['max'][0], X1 + pad), 2), y1=round(min(o['max'][1], Y1 + pad), 2))

    def slab(o):
        r = rect(o, 14)
        r['z0'] = round(o['min'][2], 2)
        r['z1'] = round(min(o['max'][2], 40), 2)
        return r

    walls = []
    for o in boxes:
        if o['trigger'] or o['tag'] in BAD_TAGS or o['layer'] not in solid_layers:
            continue
        if shadow_layer is not None and o['layer'] == shadow_layer:
            continue
        if not (o['min'][2] <= PLANE_HI and o['max'][2] >= PLANE_LO) or not inside(o):
            continue
        r = rect(o)
        if r['x1'] - r['x0'] < 0.15 or r['y1'] - r['y0'] < 0.15:
            continue
        r['tag'] = o['tag'] or 'wall'
        # The Unity GameObject this rectangle came from. Carried all the way into ArenaData so a
        # wall the player hits can be named in the console instead of guessed at.
        r['name'] = o.get('name') or ''
        walls.append(r)
    walls = merge(walls)

    back = [slab(o) for o in boxes
            if not o['trigger'] and o['tag'] not in BAD_TAGS and o['min'][2] > PLANE_HI and inside(o)
            and (o['max'][0] - o['min'][0]) > 0.3 and (o['max'][1] - o['min'][1]) > 0.3]
    fore = [slab(o) for o in boxes
            if not o['trigger'] and o['tag'] not in BAD_TAGS and o['max'][2] < PLANE_LO and inside(o)
            and (o['max'][0] - o['min'][0]) > 0.15 and (o['max'][1] - o['min'][1]) > 0.15]

    # "Over land": the origin game's land check sphere-casts from the player along +Z with
    # radius 0.25 for 3 units, against the TERRAIN layer. So a ship is over land wherever solid
    # terrain sits within 3 units BEHIND the gameplay plane — the raised decks the level is flown
    # over — not where a trigger volume is. The cast is masked to TERRAIN alone, so Default-layer
    # decoration does not count. Reduced here to the XY footprint of every terrain box whose Z span
    # the cast would reach, which is what `Arena.isOverLand` tests against.
    LAND_CAST_RADIUS = 0.25
    LAND_CAST_DISTANCE = 3.0
    # the cast starts at the player, which is at PLANE_CENTER, not at zero
    floors = [rect(o) for o in boxes
              if not o['trigger'] and o['tag'] not in BAD_TAGS and o['layer'] == terrain_layer
              and o['min'][2] <= PLANE_CENTER + LAND_CAST_DISTANCE + LAND_CAST_RADIUS
              and o['max'][2] >= PLANE_CENTER - LAND_CAST_RADIUS
              and inside(o)]
    floors = merge(floors)

    result = dict(walls=walls, plates=[], back=back, fore=fore, floors=floors,
                  bounds=dict(x0=round(X0, 2), y0=round(Y0, 2), x1=round(X1, 2), y1=round(Y1, 2)),
                  terrainLayer=terrain_layer)
    json.dump(result, open(os.path.join(SC, 'arena_parts.json'), 'w'))
    print(f"terrain layer {terrain_layer} | walls {len(walls)} | back {len(back)} | "
          f"fore {len(fore)} | floors {len(floors)} | bases {len(bases)}", file=sys.stderr)

    if '--map' in sys.argv:
        W, H = 118, 46
        grid = [[' '] * W for _ in range(H)]

        def paint(items, ch):
            for o in items:
                for gx in range(W):
                    wx = X0 + (gx + 0.5) * (X1 - X0) / W
                    if not (o['x0'] <= wx <= o['x1']):
                        continue
                    for gy in range(H):
                        wy = Y0 + (gy + 0.5) * (Y1 - Y0) / H
                        if o['y0'] <= wy <= o['y1']:
                            grid[gy][gx] = ch
        paint(back, '-')
        paint(floors, '.')
        paint(walls, '#')
        for i, b in enumerate(bases):
            gx = int((b['pos'][0] - X0) / (X1 - X0) * W)
            gy = int((b['pos'][1] - Y0) / (Y1 - Y0) * H)
            if 0 <= gx < W and 0 <= gy < H:
                grid[gy][gx] = str((i + 1) % 10)
        for row in reversed(grid):
            print(''.join(row), file=sys.stderr)


if __name__ == '__main__':
    main()
