#!/usr/bin/env python3
from dataclasses import dataclass
import struct
from typing import Any, Tuple
import zlib
import sys
import io
import argparse

@dataclass
class BinaryFormat:
    version: int
    big_endian: bool
    array_encoding: int = 1
    array_original: bool = False

@dataclass
class Value:
    type: str
    value: Any
    original_data: Tuple[int, bytes] = (0, b"")

@dataclass
class Node:
    name: bytes
    values: list[Value]
    children: list["Node"]

@dataclass
class FbxFile:
    root: Node
    version: int
    format: str
    footer: bytes = b""

def pack(stream, fmt, *args):
    stream.write(struct.pack(fmt, *args))

def unpack(stream, fmt):
    size = struct.calcsize(fmt)
    data = stream.read(size)
    return struct.unpack(fmt, data)

primitive_fmt = {
    b"C": "b", b"B": "b",
    b"Y": "h",
    b"I": "l", b"F": "f",
    b"L": "q", b"D": "d",
}

def binary_parse_value(stream, bf):
    endian = "<>"[bf.big_endian]
    type = stream.read(1)
    fmt = primitive_fmt.get(type)
    if fmt:
        value, = unpack(stream, endian + fmt)
        return Value(type, value)
    if type in b"cbilfd":
        arr_fmt = endian + "L" * 3
        fmt = primitive_fmt[type.upper()]
        count, encoding, encoded_size = unpack(stream, arr_fmt)
        original_data = arr_data = stream.read(encoded_size)
        if encoding == 0: pass # Nop
        elif encoding == 1:
            arr_data = zlib.decompress(arr_data)
        else:
            raise ValueError(f"Unknown encoding: {encoding}")
        values = list(v[0] for v in struct.iter_unpack(endian + fmt, arr_data))
        assert len(values) == count
        return Value(type, values, original_data=(encoding, original_data))
    elif type in b"SR":
        length, = unpack(stream, endian + "L")
        return Value(type, stream.read(length))
    else:
        raise ValueError(f"Bad type: '{type}'")

def binary_parse_node(stream, bf):
    pos = stream.tell()
    endian = "<>"[bf.big_endian]
    head_fmt = endian + "LQ"[bf.version >= 7500] * 3 + "B"
    end_offset, num_values, values_len, name_len = unpack(stream, head_fmt)
    if end_offset == 0 and name_len == 0: return None
    name = stream.read(name_len)
    values_end = stream.tell() + values_len
    values = [binary_parse_value(stream, bf) for _ in range(num_values)]
    children = []
    if stream.tell() != values_end:
        assert stream.tell() < values_end
        stream.seek(pos + values_end)
    while stream.tell() < end_offset:
        node = binary_parse_node(stream, bf)
        if not node: break
        children.append(node)
    return Node(name, values, children)

def parse_fbx(stream):
    magic = stream.read(22)
    if magic == b"Kaydara FBX Binary  \x00\x1a":
        big_endian = stream.read(1) != b"\x00"
        endian = "<>"[big_endian]
        version, = unpack(stream, endian + "L")
        bf = BinaryFormat(version, big_endian)
        children = []
        while True:
            node = binary_parse_node(stream, bf)
            if not node: break
            children.append(node)
        footer = stream.read(16)
        root = Node("", [], children)
        format = "binary-be" if big_endian else "binary"
        return FbxFile(root, version, format, footer)
    else:
        # TODO
        raise NotImplementedError()

def binary_dump_value(stream, value: Value, bf: BinaryFormat):
    endian = "<>"[bf.big_endian]
    fmt = primitive_fmt.get(value.type)
    stream.write(value.type)
    if fmt:
        pack(stream, endian + fmt, value.value)
    elif value.type in b"cbilfd":
        fmt = endian + primitive_fmt[value.type.upper()]

        arr_fmt = endian + "L" * 3
        if bf.array_original:
            encoding, arr_data = value.original_data
            pack(stream, arr_fmt, len(value.value), encoding, len(arr_data))
            stream.write(arr_data)
        else:
            with io.BytesIO() as ds:
                for v in value.value:
                    pack(ds, fmt, v)
                arr_data = ds.getvalue()

            count = len(value.value)
            encoding = bf.array_encoding 
            if encoding == 1:
                arr_data = zlib.compress(arr_data)
            encoded_size = len(arr_data)

            pack(stream, arr_fmt, count, encoding, encoded_size)
            stream.write(arr_data)

    elif value.type in b"SR":
        pack(stream, endian + "L", len(value.value))
        stream.write(value.value)
    else:
        raise ValueError(f"Bad type: '{value.type}'")

def binary_dump_node(stream, node: Node, bf: BinaryFormat):
    endian = "<>"[bf.big_endian]
    head_size = 25 if bf.version >= 7500 else 13
    head_null = b"\x00" * head_size
    off_start = stream.tell()
    stream.write(head_null)
    stream.write(node.name)
    off_value_start = stream.tell()
    for value in node.values:
        binary_dump_value(stream, value, bf)
    values_size = stream.tell() - off_value_start
    for child in node.children:
        binary_dump_node(stream, child, bf)
    if node.children or node.name in { b"References", b"AnimationStack", b"AnimationLayer" }:
        stream.write(head_null)
    off_end = stream.tell()
    head_fmt = endian + "LQ"[bf.version >= 7500] * 3 + "B"
    stream.seek(off_start)
    pack(stream, head_fmt, off_end, len(node.values), values_size, len(node.name))
    stream.seek(off_end)

def binary_dump_root(stream, root: Node, bf: BinaryFormat, footer: bytes):
    head_size = 25 if bf.version >= 7500 else 13
    head_null = b"\x00" * head_size
    endian = "<>"[bf.big_endian]

    stream.write(b"Kaydara FBX Binary  \x00\x1a")
    pack(stream, "B", bf.big_endian)
    pack(stream, endian + "L", bf.version)

    for node in root.children:
        binary_dump_node(stream, node, bf)
    stream.write(head_null)

    stream.write(footer)
    stream.write(b"\x00" * 4)

    ofs = stream.tell()
    pad = ((ofs + 15) & ~15) - ofs
    if pad == 0:
        pad = 16

    stream.write(b"\0" * pad)
    pack(stream, endian + "I", bf.version)
    stream.write(b"\0" * 120)
    stream.write(b"\xf8\x5a\x8c\x6a\xde\xf5\xd9\x7e\xec\xe9\x0c\xe3\x75\x8f\x29\x0b")

def ascii_dump_value(stream, value: Value, indent: str):
    if value.type in b"CBYILFD":
        stream.write(str(value.value))
    elif value.type in b"SR":
        s = str(value.value)[2:-1]
        stream.write(f"\"{s}\"")
    elif value.type in b"cbilfd":
        stream.write(f"* {len(value.value)} {{")
        first = True
        for v in value.value:
            stream.write(" " if first else ", ")
            stream.write(str(v))
            first = False
        stream.write(" }")
    else:
        raise ValueError(f"Bad value type: '{value.type}'")

def ascii_dump_node(stream, node: Node, indent: str):
    name = node.name.decode("utf-8")
    stream.write(f"{indent}{name}:")
    first = True
    for value in node.values:
        stream.write(" " if first else ", ")
        first = False
        ascii_dump_value(stream, value, indent + "    ")
    if node.children:
        stream.write(" {\n")
        for node in node.children:
            ascii_dump_node(stream, node, indent + "    ")
        stream.write(indent + "}\n")
    else:
        stream.write("\n")

def ascii_dump_root(stream, root: Node, version: int):
    v0 = version // 1000 % 10
    v1 = version // 100 % 10
    v2 = version // 10 % 10
    stream.write(f"; FBX {v0}.{v1}.{v2} project file\n")
    stream.write("----------------------------------------------------\n")
    for child in root.children:
        ascii_dump_node(stream, child, "")

if __name__ == "__main__":
    parser = argparse.ArgumentParser(usage="transmute_fbx.py src -o dst -v 7400 -f binary-be")
    parser.add_argument("src", help="Source file to read")
    parser.add_argument("--output", "-o", required=True, help="Output filename")
    parser.add_argument("--version", "-v", help="File version")
    parser.add_argument("--format", "-f", help="File format")
    argv = parser.parse_args()

    with open(argv.src, "rb") as f:
        fbx = parse_fbx(f)

    format = argv.format
    if not format:
        format = fbx.format
    version = argv.version
    if not version:
        version = fbx.version
    
    with open(argv.output, "wt" if format == "ascii" else "wb") as f:
        if format == "ascii":
            ascii_dump_root(f, fbx.root, version)
        else:
            if format == "binary-be":
                bf = BinaryFormat(version, True, 0)
            elif format == "binary":
                bf = BinaryFormat(version, False, 1)
            else:
                raise ValueError(f"Unknown format: {format}")
            binary_dump_root(f, fbx.root, bf, fbx.footer)
