import { access, readFile, stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Box3, Vector3 } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer";
import { dedup, meshopt, prune, resample } from "@gltf-transform/functions";
import { createGameAssetIO, convertDocumentTexturesToKtx2 } from "./lib/ktx2.mjs";

const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const assetDirectory = path.join(repositoryRoot, "game_assets", "models", "sketchfab-opensource");
const sourcePath = path.join(assetDirectory, "animated_triceratops_skeleton.glb");
const legacyPath = path.join(assetDirectory, "gravehorn-triceratops.glb");
const optimizedPath = path.join(assetDirectory, "gravehorn-triceratops-uastc.glb");
const requiredClips = [
  "Armature|RiseUp",
  "Armature|Roar",
  "Armature|Walk",
  "Armature|Fall",
  "Gravehorn|Idle",
];
const unusedClips = new Set(["Armature|IdleGround", "Armature|RoarToWalk"]);

function sortedNames(properties) {
  return properties.map((property) => property.getName()).sort();
}

function addUprightIdle(document) {
  const walk = document.getRoot().listAnimations().find((animation) => animation.getName() === "Armature|Walk");
  const buffer = document.getRoot().listBuffers()[0];
  if (!walk || !buffer) throw new Error("Gravehorn source is missing its Walk animation or binary buffer.");

  const idle = document.createAnimation("Gravehorn|Idle");
  for (const [index, sourceChannel] of walk.listChannels().entries()) {
    const sourceSampler = sourceChannel.getSampler();
    const sourceOutput = sourceSampler?.getOutput();
    const sourceValues = sourceOutput?.getArray();
    const targetNode = sourceChannel.getTargetNode();
    const targetPath = sourceChannel.getTargetPath();
    if (!sourceSampler || !sourceOutput || !sourceValues || !targetNode || !targetPath) {
      throw new Error(`Gravehorn Walk channel ${index} is incomplete.`);
    }
    if (sourceSampler.getInterpolation() === "CUBICSPLINE") {
      throw new Error("Gravehorn upright idle builder does not support cubic animation tracks.");
    }

    const elementSize = sourceOutput.getElementSize();
    const values = new sourceValues.constructor(elementSize * 2);
    values.set(sourceValues.subarray(0, elementSize), 0);
    values.set(sourceValues.subarray(0, elementSize), elementSize);
    const input = document.createAccessor(`gravehorn_idle_time_${index}`)
      .setType("SCALAR")
      .setArray(new Float32Array([0, 1]))
      .setBuffer(buffer);
    const output = document.createAccessor(`gravehorn_idle_value_${index}`)
      .setType(sourceOutput.getType())
      .setNormalized(sourceOutput.getNormalized())
      .setArray(values)
      .setBuffer(buffer);
    const sampler = document.createAnimationSampler(`gravehorn_idle_sampler_${index}`)
      .setInput(input)
      .setOutput(output)
      .setInterpolation("LINEAR");
    const channel = document.createAnimationChannel(`gravehorn_idle_channel_${index}`)
      .setSampler(sampler)
      .setTargetNode(targetNode)
      .setTargetPath(targetPath);
    idle.addSampler(sampler).addChannel(channel);
  }
}

async function runtimeBounds(assetPath) {
  const data = await readFile(assetPath);
  const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
  globalThis.self ??= globalThis;
  globalThis.createImageBitmap ??= async () => ({ width: 1, height: 1, close() {} });
  const gltf = await new GLTFLoader().setMeshoptDecoder(MeshoptDecoder).parseAsync(arrayBuffer, "");
  gltf.scene.updateMatrixWorld(true);
  const bounds = new Box3().setFromObject(gltf.scene);
  return {
    min: bounds.min.toArray(),
    max: bounds.max.toArray(),
    center: bounds.getCenter(new Vector3()).toArray(),
  };
}

function validateStructure(document, { optimized }) {
  const root = document.getRoot();
  const material = root.listMaterials()[0];
  const clips = sortedNames(root.listAnimations());
  const textureMimeTypes = root.listTextures().map((texture) => texture.getMimeType());
  const extensionNames = new Set(root.listExtensionsUsed().map((extension) => extension.extensionName));

  if (JSON.stringify(clips) !== JSON.stringify([...requiredClips].sort())) {
    throw new Error(`Gravehorn animation clips changed: ${clips.join(", ")}.`);
  }
  if (root.listMeshes().length !== 1 || root.listMaterials().length !== 1 || root.listTextures().length !== 3 || root.listSkins().length !== 1) {
    throw new Error("Gravehorn must contain one mesh, one material, three textures, and one skin.");
  }
  if (root.listSkins()[0].listJoints().length !== 140) {
    throw new Error(`Gravehorn joint count changed: ${root.listSkins()[0].listJoints().length}.`);
  }
  if (material.getDoubleSided()) {
    throw new Error("Gravehorn material must keep backface culling enabled.");
  }
  if (!extensionNames.has("EXT_meshopt_compression")) {
    throw new Error("Gravehorn is missing Meshopt compression.");
  }
  if (optimized && (textureMimeTypes.some((mimeType) => mimeType !== "image/ktx2") || !extensionNames.has("KHR_texture_basisu"))) {
    throw new Error("Optimized Gravehorn asset must contain only KTX2 textures.");
  }
  if (!optimized && textureMimeTypes.some((mimeType) => mimeType === "image/ktx2")) {
    throw new Error("Legacy Gravehorn fallback must retain standard textures.");
  }
}

await access(sourcePath);
await MeshoptDecoder.ready;
await MeshoptEncoder.ready;
const io = await createGameAssetIO();
const document = await io.read(sourcePath);
const root = document.getRoot();
const scene = root.listScenes()[0].setName("gravehorn_triceratops");
const sceneRoot = scene.listChildren()[0];
const sourceBounds = await runtimeBounds(sourcePath);
const sourceTranslation = sceneRoot.getTranslation();

sceneRoot.setTranslation([
  sourceTranslation[0] - sourceBounds.center[0],
  sourceTranslation[1] - sourceBounds.min[1],
  sourceTranslation[2] - sourceBounds.center[2],
]);
root.listMeshes()[0].setName("gravehorn_triceratops");
root.listMaterials()[0]
  .setName("gravehorn_bone")
  .setDoubleSided(false)
  .setEmissiveFactor([0.1, 0.06, 0.02]);
addUprightIdle(document);
for (const animation of root.listAnimations()) {
  if (!unusedClips.has(animation.getName())) continue;
  for (const channel of animation.listChannels()) channel.dispose();
  for (const sampler of animation.listSamplers()) sampler.dispose();
  animation.dispose();
}

await document.transform(
  resample({ tolerance: 1e-4 }),
  dedup({ keepUniqueNames: true }),
  prune({ keepLeaves: true, keepSolidTextures: true }),
  meshopt({ encoder: MeshoptEncoder, level: "high" }),
);

await io.write(legacyPath, document);
const legacyDocument = await io.read(legacyPath);
validateStructure(legacyDocument, { optimized: false });
const legacyBounds = await runtimeBounds(legacyPath);
if (Math.abs(legacyBounds.min[1]) > 0.015 || Math.abs(legacyBounds.center[0]) > 0.015 || Math.abs(legacyBounds.center[2]) > 0.015) {
  throw new Error(`Gravehorn pivot is not grounded and centered: minY=${legacyBounds.min[1]}, centerX=${legacyBounds.center[0]}, centerZ=${legacyBounds.center[2]}.`);
}

const optimizedDocument = await io.read(legacyPath);
await convertDocumentTexturesToKtx2(optimizedDocument, "iwt-gravehorn-");
await io.write(optimizedPath, optimizedDocument);
validateStructure(await io.read(optimizedPath), { optimized: true });

const sourceSize = (await stat(sourcePath)).size;
const legacySize = (await stat(legacyPath)).size;
const optimizedSize = (await stat(optimizedPath)).size;
console.log(`Built ${path.relative(repositoryRoot, legacyPath)} (${sourceSize} -> ${legacySize} bytes).`);
console.log(`Built ${path.relative(repositoryRoot, optimizedPath)} (${optimizedSize} bytes, KTX2/UASTC).`);
