import struct, zlib, sys, os, json

# A MeshCollider collides with triangles, not with its bounding box. Reading the vertices lets the
# caller ask whether a mesh actually reaches the gameplay plane, instead of assuming its box does.
KEEP_VERTICES = False
MAX_VERTICES = 4000
UNITY_NEGATE_X = True


def _rx(a):
    import math
    c, s_ = math.cos(a), math.sin(a); return ((1, 0, 0), (0, c, -s_), (0, s_, c))
def _ry(a):
    import math
    c, s_ = math.cos(a), math.sin(a); return ((c, 0, s_), (0, 1, 0), (-s_, 0, c))
def _rz(a):
    import math
    c, s_ = math.cos(a), math.sin(a); return ((c, -s_, 0), (s_, c, 0), (0, 0, 1))
def _mm(a, b):
    return tuple(tuple(sum(a[i][k]*b[k][j] for k in range(3)) for j in range(3)) for i in range(3))
def _mv(m, v):
    return tuple(sum(m[i][k]*v[k] for k in range(3)) for i in range(3))


def apply_geometric(v, g):
    """Apply a Model node's geometric transform to one raw vertex, the way Unity bakes it.

    FBX geometric rotation is an XYZ Euler (rotate about X, then Y, then Z, i.e. Rz*Ry*Rx), in
    degrees; scaling is applied first and translation last. Verified against Unity's own
    BoxCollider for Wall_7.FBX: raw vertices land a full unit away with the axes cycled, this
    lands on the collider box to the millimetre.
    """
    import math
    sc = g.get('GeometricScaling', (1.0, 1.0, 1.0))
    rot = g.get('GeometricRotation', (0.0, 0.0, 0.0))
    tr = g.get('GeometricTranslation', (0.0, 0.0, 0.0))
    x, y, z = v[0]*sc[0], v[1]*sc[1], v[2]*sc[2]
    if any(abs(r) > 1e-9 for r in rot):
        R = _mm(_rz(math.radians(rot[2])), _mm(_ry(math.radians(rot[1])), _rx(math.radians(rot[0]))))
        x, y, z = _mv(R, (x, y, z))
    x, y, z = x + tr[0], y + tr[1], z + tr[2]
    # Unity converts the right-handed FBX into its left-handed space by negating X — on every
    # import, geometric transform or not. A bounding box cannot see a mirror, so this was invisible
    # to every collider-box check; a pair of quarter-round end caps could see it, and
    # curved the wrong way until it was applied.
    return (-x, y, z) if UNITY_NEGATE_X else (x, y, z)


def read_fbx(path, keep_vertices=False):
    global KEEP_VERTICES
    KEEP_VERTICES = keep_vertices
    data = open(path,'rb').read()
    if not data.startswith(b'Kaydara FBX Binary'):
        return None
    version = struct.unpack_from('<I', data, 23)[0]
    off = 27
    wide = version >= 7500
    meshes = []
    _pending_verts = []   # list of (name, [ (minx,miny,minz),(maxx,maxy,maxz) ])
    # FBX geometric transforms live on the Model node and apply to that node's mesh vertices
    # ONLY. Unity bakes them into the mesh data; reading the raw vertex array skips them and
    # leaves every 3ds Max export cycled by two right angles and displaced.
    geo_list = []
    _geo_cur = {}

    def read_record(off, path_names):
        if wide:
            end, nprops, plen = struct.unpack_from('<QQQ', data, off); off += 24
        else:
            end, nprops, plen = struct.unpack_from('<III', data, off); off += 12
        nlen = data[off]; off += 1
        name = data[off:off+nlen].decode('utf-8','replace'); off += nlen
        if end == 0:
            return 0, None
        props = []
        pend = off + plen
        for _ in range(nprops):
            t = chr(data[off]); off += 1
            if t in 'SR':
                L = struct.unpack_from('<I', data, off)[0]; off += 4
                props.append(data[off:off+L].decode('utf-8', 'replace') if t == 'S' else b''); off += L
                continue
            if t == 'Y': props.append(struct.unpack_from('<h', data, off)[0]); off += 2
            elif t == 'C': props.append(data[off] != 0); off += 1
            elif t == 'I': props.append(struct.unpack_from('<i', data, off)[0]); off += 4
            elif t == 'F': props.append(struct.unpack_from('<f', data, off)[0]); off += 4
            elif t == 'D': props.append(struct.unpack_from('<d', data, off)[0]); off += 8
            elif t == 'L': props.append(struct.unpack_from('<q', data, off)[0]); off += 8
            elif t in 'fdlib':
                alen, enc, clen = struct.unpack_from('<III', data, off); off += 12
                raw = data[off:off+clen]; off += clen
                if enc == 1:
                    raw = zlib.decompress(raw)
                fmt = {'f':'f','d':'d','l':'q','i':'i','b':'b'}[t]
                if t in 'fd' or t in 'li':
                    props.append(('array', fmt, alen, raw))
                else:
                    props.append(('array', fmt, alen, raw))
            elif t in 'SR':
                slen = struct.unpack_from('<I', data, off)[0]; off += 4
                props.append(data[off:off+slen]); off += slen
            else:
                off = pend
                break
        off = pend
        if name == 'Model':
            if _geo_cur:
                geo_list.append(dict(_geo_cur))
            _geo_cur.clear()
            _geo_cur['_model'] = True
        if name == 'P' and props and _geo_cur.get('_model') and isinstance(props[0], str) \
                and props[0] in ('GeometricRotation', 'GeometricTranslation', 'GeometricScaling'):
            vals = [v for v in props[4:] if isinstance(v, (int, float))]
            if len(vals) == 3:
                _geo_cur[props[0]] = tuple(float(v) for v in vals)
        if name == 'Vertices' and props and isinstance(props[0], tuple):
            _, fmt, alen, raw = props[0]
            vals = struct.unpack('<%d%s' % (alen, fmt), raw[:alen*struct.calcsize(fmt)])
            mn = [1e30]*3; mx = [-1e30]*3
            for i in range(0, len(vals)-2, 3):
                for k in range(3):
                    v = vals[i+k]
                    if v < mn[k]: mn[k] = v
                    if v > mx[k]: mx[k] = v
            _pending_verts.append(len(meshes))
            verts = None
            if KEEP_VERTICES:
                # Kept whole: PolygonVertexIndex refers to these by position, so subsampling
                # would silently scramble the faces.
                verts = [(vals[i], vals[i + 1], vals[i + 2])
                         for i in range(0, len(vals) - 2, 3)]
            meshes.append((path_names[-1] if path_names else '', mn, mx, verts, None, None))
        if name == 'PolygonVertexIndex' and KEEP_VERTICES and props and isinstance(props[0], tuple):
            _, fmt, alen, raw = props[0]
            idx = struct.unpack('<%d%s' % (alen, fmt), raw[:alen*struct.calcsize(fmt)])
            if _pending_verts:
                at = _pending_verts[-1]
                if at < len(meshes):
                    e = meshes[at]
                    meshes[at] = (e[0], e[1], e[2], e[3], idx, e[5])
        # nested
        nullsz = 25 if wide else 13
        while off < end - nullsz:
            off, _ = read_record(off, path_names + [name])
        if off < end:
            off = end
        return off, name

    unit_scale = None
    nullsz = 25 if wide else 13
    while off < len(data) - nullsz:
        noff, nm = read_record(off, [])
        if noff == 0 or noff <= off: break
        off = noff
    idx = data.find(b'UnitScaleFactor')
    if idx > 0:
        seg = data[idx:idx+120]
        j = seg.find(b'\x04', 15)   # 'D' type marker is 0x44; properties are S,S,S,S,D
        k = seg.find(b'D', 15)
        if k > 0 and k + 9 <= len(seg):
            try:
                unit_scale = struct.unpack_from('<d', seg, k+1)[0]
            except Exception:
                unit_scale = None
    if _geo_cur:
        geo_list.append(dict(_geo_cur))
    geos = [g for g in geo_list if any(k in g for k in ('GeometricRotation', 'GeometricTranslation', 'GeometricScaling'))]
    # Only Model records that are meshes carry geometry; pair in order when the counts line up,
    # and otherwise only when there is exactly one of each.
    paired = []
    for i, e in enumerate(meshes):
        g = None
        if len(geos) == len(meshes):
            g = geos[i]
        elif len(geos) == 1 and len(meshes) == 1:
            g = geos[0]
        paired.append((e[0], e[1], e[2], e[3], e[4], g))
    meshes = paired
    # Bounds are reported AFTER the geometric transform, so every consumer sees Unity's mesh.
    fixed = []
    for (nm, mn, mx, verts, idx, g) in meshes:
        if not g:
            g = {}   # still run apply_geometric: the X negation applies to every file
        if True:
            corners = [(x, y, z) for x in (mn[0], mx[0]) for y in (mn[1], mx[1]) for z in (mn[2], mx[2])]
            tc = [apply_geometric(c, g) for c in corners]
            mn = [min(c[k] for c in tc) for k in range(3)]
            mx = [max(c[k] for c in tc) for k in range(3)]
            if verts:
                verts = [apply_geometric(v, g) for v in verts]
        fixed.append((nm, mn, mx, verts, idx, g))
    return fixed, unit_scale

if __name__ == '__main__':
    for p in sys.argv[1:]:
        r = read_fbx(p)
        ms, us = (r if r else (None, None))
        if not ms:
            print(os.path.basename(p), 'FAILED'); continue
        mn = [min(m[1][k] for m in ms) for k in range(3)]
        mx = [max(m[2][k] for m in ms) for k in range(3)]
        print(os.path.basename(p), 'meshes=%d' % len(ms), 'unit=', us, 'min=', [round(v,3) for v in mn], 'max=', [round(v,3) for v in mx])
