import { eulerToQuaternion, type AnimationClip } from "../anim/procedural.js";
import { bounds, vertexCount, type MeshData } from "../mesh/MeshBuilder.js";
import type { Skeleton } from "../rig/skeleton.js";

/**
 * glTF 2.0 / GLB writer.
 *
 * Hand-rolled rather than using three's `GLTFExporter`, which expects a browser
 * (Blob, FileReader) and a live scene graph. The pipeline is a Node process
 * holding plain arrays, so emitting the container directly is both simpler and
 * dependency-free — GLB is a JSON chunk and a binary chunk with 4-byte
 * alignment, and that is the whole format.
 */

const GLB_MAGIC = 0x46546c67; // "glTF"
const GLB_VERSION = 2;
const CHUNK_JSON = 0x4e4f534a;
const CHUNK_BIN = 0x004e4942;

const COMPONENT_FLOAT = 5126;
const COMPONENT_UNSIGNED_SHORT = 5123;
const COMPONENT_UNSIGNED_INT = 5125;

const TARGET_ARRAY_BUFFER = 34962;
const TARGET_ELEMENT_ARRAY_BUFFER = 34963;

interface Accessor {
  bufferView: number;
  componentType: number;
  count: number;
  type: string;
  min?: number[];
  max?: number[];
  normalized?: boolean;
}

interface BufferView {
  buffer: 0;
  byteOffset: number;
  byteLength: number;
  target?: number;
  byteStride?: number;
}

/** Accumulates binary data with the 4-byte alignment glTF requires. */
class BinaryWriter {
  private readonly chunks: Uint8Array[] = [];
  private length = 0;

  readonly bufferViews: BufferView[] = [];
  readonly accessors: Accessor[] = [];

  private align(): void {
    const padding = (4 - (this.length % 4)) % 4;
    if (padding === 0) return;
    this.chunks.push(new Uint8Array(padding));
    this.length += padding;
  }

  private addView(bytes: Uint8Array, target?: number): number {
    this.align();
    const byteOffset = this.length;
    this.chunks.push(bytes);
    this.length += bytes.byteLength;

    this.bufferViews.push({
      buffer: 0,
      byteOffset,
      byteLength: bytes.byteLength,
      ...(target !== undefined ? { target } : {}),
    });
    return this.bufferViews.length - 1;
  }

  addFloats(values: ArrayLike<number>, type: string, target?: number, withBounds = false): number {
    const array = Float32Array.from(values as number[]);
    const view = this.addView(new Uint8Array(array.buffer, array.byteOffset, array.byteLength), target);
    const components = COMPONENTS_PER_TYPE[type]!;

    const accessor: Accessor = {
      bufferView: view,
      componentType: COMPONENT_FLOAT,
      count: array.length / components,
      type,
    };

    if (withBounds) {
      const min = new Array<number>(components).fill(Infinity);
      const max = new Array<number>(components).fill(-Infinity);
      for (let i = 0; i < array.length; i += components) {
        for (let c = 0; c < components; c++) {
          const value = array[i + c]!;
          if (value < min[c]!) min[c] = value;
          if (value > max[c]!) max[c] = value;
        }
      }
      accessor.min = min;
      accessor.max = max;
    }

    this.accessors.push(accessor);
    return this.accessors.length - 1;
  }

  addIndices(values: number[]): number {
    // 16-bit where it fits: a PSX-budget mesh almost always does, and it halves
    // the index buffer.
    const needs32 = values.some((value) => value > 65535);
    const array = needs32 ? Uint32Array.from(values) : Uint16Array.from(values);
    const view = this.addView(
      new Uint8Array(array.buffer, array.byteOffset, array.byteLength),
      TARGET_ELEMENT_ARRAY_BUFFER,
    );

    this.accessors.push({
      bufferView: view,
      componentType: needs32 ? COMPONENT_UNSIGNED_INT : COMPONENT_UNSIGNED_SHORT,
      count: values.length,
      type: "SCALAR",
    });
    return this.accessors.length - 1;
  }

  addJoints(values: number[]): number {
    const array = Uint16Array.from(values);
    const view = this.addView(
      new Uint8Array(array.buffer, array.byteOffset, array.byteLength),
      TARGET_ARRAY_BUFFER,
    );

    this.accessors.push({
      bufferView: view,
      componentType: COMPONENT_UNSIGNED_SHORT,
      count: values.length / 4,
      type: "VEC4",
    });
    return this.accessors.length - 1;
  }

  /** Raw bytes (e.g. embedded PNG) — no accessor, just a bufferView. */
  addRaw(bytes: Uint8Array): number {
    return this.addView(bytes);
  }

  finish(): Uint8Array {
    this.align();
    const out = new Uint8Array(this.length);
    let offset = 0;
    for (const chunk of this.chunks) {
      out.set(chunk, offset);
      offset += chunk.byteLength;
    }
    return out;
  }
}

const COMPONENTS_PER_TYPE: Record<string, number> = {
  SCALAR: 1,
  VEC2: 2,
  VEC3: 3,
  VEC4: 4,
  MAT4: 16,
};

export interface ExportOptions {
  name?: string;
  clips?: AnimationClip[];
  /** Written into the asset's generator string for provenance. */
  generator?: string;
  /**
   * Optional image bytes for baseColorTexture (PNG or JPEG).
   * Requires mesh.uvs to be populated (TEXCOORD_0).
   */
  baseColorImage?: Uint8Array;
  /** MIME type for baseColorImage. Default: sniff from magic bytes, else image/png. */
  baseColorMimeType?: string;
}

export function exportGlb(
  mesh: MeshData,
  skeleton: Skeleton,
  inverseBindMatrices: Float32Array,
  options: ExportOptions = {},
): Uint8Array {
  const writer = new BinaryWriter();
  const name = options.name ?? "Character";
  const clips = options.clips ?? [];
  const hasUvs = mesh.uvs.length >= (mesh.positions.length / 3) * 2 && mesh.uvs.length > 0;
  const imageBytes = options.baseColorImage;
  const hasTexture = Boolean(imageBytes && hasUvs);
  const imageMime =
    options.baseColorMimeType ??
    (imageBytes ? sniffImageMime(imageBytes) : "image/png");

  // --- mesh attributes -----------------------------------------------------
  const positionAccessor = writer.addFloats(mesh.positions, "VEC3", TARGET_ARRAY_BUFFER, true);
  const normalAccessor = writer.addFloats(mesh.normals, "VEC3", TARGET_ARRAY_BUFFER);
  const colorAccessor = writer.addFloats(mesh.colors, "VEC4", TARGET_ARRAY_BUFFER);
  const uvAccessor = hasUvs
    ? writer.addFloats(mesh.uvs, "VEC2", TARGET_ARRAY_BUFFER)
    : undefined;
  const jointsAccessor = writer.addJoints(mesh.joints);
  const weightsAccessor = writer.addFloats(mesh.weights, "VEC4", TARGET_ARRAY_BUFFER);
  const indexAccessor = writer.addIndices(mesh.indices);
  const ibmAccessor = writer.addFloats(inverseBindMatrices, "MAT4");

  let imageBufferView: number | undefined;
  if (hasTexture && imageBytes) {
    imageBufferView = writer.addRaw(imageBytes);
  }

  // --- node graph ----------------------------------------------------------
  // Node 0 is the skinned mesh; joints follow, so joint N is node N+1.
  const JOINT_NODE_OFFSET = 1;

  const nodes: Array<Record<string, unknown>> = [
    { name, mesh: 0, skin: 0 },
  ];

  for (const joint of skeleton.joints) {
    const children = skeleton.joints
      .map((other, index) => ({ other, index }))
      .filter(({ other }) => other.parent === joint.name)
      .map(({ index }) => index + JOINT_NODE_OFFSET);

    nodes.push({
      name: joint.name,
      translation: joint.local,
      ...(children.length > 0 ? { children } : {}),
    });
  }

  const rootJointIndex = skeleton.joints.findIndex((joint) => joint.parent === null);

  // --- animations ----------------------------------------------------------
  const animations = clips.map((clip) => buildAnimation(clip, skeleton, writer, JOINT_NODE_OFFSET));

  const attributes: Record<string, number> = {
    POSITION: positionAccessor,
    NORMAL: normalAccessor,
    COLOR_0: colorAccessor,
    JOINTS_0: jointsAccessor,
    WEIGHTS_0: weightsAccessor,
  };
  if (uvAccessor !== undefined) attributes["TEXCOORD_0"] = uvAccessor;

  const material: Record<string, unknown> = {
    name: hasTexture ? "PSXTextured" : "PSXVertexColor",
    pbrMetallicRoughness: {
      baseColorFactor: [1, 1, 1, 1],
      metallicFactor: 0,
      roughnessFactor: 1,
      ...(hasTexture ? { baseColorTexture: { index: 0 } } : {}),
    },
    doubleSided: false,
  };

  const gltf: Record<string, unknown> = {
    asset: {
      version: "2.0",
      generator: options.generator ?? "psx-adventure-engine harness",
    },
    scene: 0,
    scenes: [{ nodes: [0, rootJointIndex + JOINT_NODE_OFFSET] }],
    nodes,
    meshes: [
      {
        name: `${name}Mesh`,
        primitives: [
          {
            attributes,
            indices: indexAccessor,
            material: 0,
          },
        ],
      },
    ],
    skins: [
      {
        name: `${name}Skin`,
        inverseBindMatrices: ibmAccessor,
        skeleton: rootJointIndex + JOINT_NODE_OFFSET,
        joints: skeleton.joints.map((_, index) => index + JOINT_NODE_OFFSET),
      },
    ],
    materials: [material],
    ...(hasTexture
      ? {
          textures: [{ source: 0, sampler: 0 }],
          images: [{ bufferView: imageBufferView, mimeType: imageMime }],
          // Nearest sampling — PSX chunky texels.
          samplers: [
            {
              magFilter: 9728, // NEAREST
              minFilter: 9728, // NEAREST
              wrapS: 33071, // CLAMP_TO_EDGE
              wrapT: 33071,
            },
          ],
        }
      : {}),
    ...(animations.length > 0 ? { animations } : {}),
    accessors: writer.accessors,
    bufferViews: writer.bufferViews,
    buffers: [{ byteLength: 0 }],
  };

  const binary = writer.finish();
  (gltf["buffers"] as Array<{ byteLength: number }>)[0]!.byteLength = binary.byteLength;

  return packGlb(gltf, binary);
}

function buildAnimation(
  clip: AnimationClip,
  skeleton: Skeleton,
  writer: BinaryWriter,
  jointNodeOffset: number,
): Record<string, unknown> {
  const samplers: Array<Record<string, unknown>> = [];
  const channels: Array<Record<string, unknown>> = [];

  for (const channel of clip.channels) {
    const jointIndex = skeleton.index.get(channel.bone);
    // A clip may reference bones a given rig does not have; skip rather than
    // fail, so clips stay portable across characters (GDD §10.2).
    if (jointIndex === undefined) continue;

    const times = channel.keyframes.map((key) => key.time);
    // Ensure consecutive quats take the *short* arc (dot ≥ 0). LINEAR
    // interpolation of XYZW otherwise spins the long way around.
    const rotations: number[] = [];
    let prev: [number, number, number, number] | null = null;
    for (const key of channel.keyframes) {
      let q = eulerToQuaternion(key.rotation[0], key.rotation[1], key.rotation[2]) as [
        number,
        number,
        number,
        number,
      ];
      if (prev && prev[0] * q[0] + prev[1] * q[1] + prev[2] * q[2] + prev[3] * q[3] < 0) {
        q = [-q[0], -q[1], -q[2], -q[3]];
      }
      rotations.push(q[0], q[1], q[2], q[3]);
      prev = q;
    }

    // Rest rotations are identity, so a clip's quaternion is the final value.
    const input = writer.addFloats(times, "SCALAR", undefined, true);
    const output = writer.addFloats(rotations, "VEC4");

    samplers.push({ input, output, interpolation: "LINEAR" });
    channels.push({
      sampler: samplers.length - 1,
      target: { node: jointIndex + jointNodeOffset, path: "rotation" },
    });
  }

  return { name: clip.name, samplers, channels };
}

function packGlb(gltf: unknown, binary: Uint8Array): Uint8Array {
  const jsonText = JSON.stringify(gltf);
  const jsonBytes = new TextEncoder().encode(jsonText);

  // Both chunks pad to 4 bytes: JSON with spaces, BIN with zeros, per spec.
  const jsonPadding = (4 - (jsonBytes.byteLength % 4)) % 4;
  const binPadding = (4 - (binary.byteLength % 4)) % 4;

  const jsonChunkLength = jsonBytes.byteLength + jsonPadding;
  const binChunkLength = binary.byteLength + binPadding;
  const totalLength = 12 + 8 + jsonChunkLength + 8 + binChunkLength;

  const out = new Uint8Array(totalLength);
  const view = new DataView(out.buffer);
  let offset = 0;

  view.setUint32(offset, GLB_MAGIC, true);
  view.setUint32(offset + 4, GLB_VERSION, true);
  view.setUint32(offset + 8, totalLength, true);
  offset += 12;

  view.setUint32(offset, jsonChunkLength, true);
  view.setUint32(offset + 4, CHUNK_JSON, true);
  offset += 8;
  out.set(jsonBytes, offset);
  // 0x20 is a space; JSON chunks pad with spaces so the text stays valid.
  out.fill(0x20, offset + jsonBytes.byteLength, offset + jsonChunkLength);
  offset += jsonChunkLength;

  view.setUint32(offset, binChunkLength, true);
  view.setUint32(offset + 4, CHUNK_BIN, true);
  offset += 8;
  out.set(binary, offset);

  return out;
}

/** Convenience for the pipeline report. */
export function meshStatistics(mesh: MeshData): { vertices: number; triangles: number } {
  const { min, max } = bounds(mesh);
  void min;
  void max;
  return { vertices: vertexCount(mesh), triangles: mesh.indices.length / 3 };
}

function sniffImageMime(bytes: Uint8Array): string {
  if (bytes.length >= 4 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
    return "image/png";
  }
  if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
    return "image/jpeg";
  }
  if (bytes.length >= 12 && bytes[0] === 0x52 && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42) {
    return "image/webp";
  }
  return "image/png";
}
