import { describe, expect, it } from "vitest";
import { AnimationClip as ThreeClip, Bone, SkinnedMesh } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { buildDefaultClips } from "../anim/procedural.js";
import { analyzeReference } from "../image/analyze.js";
import { denoise, segmentForeground } from "../image/segment.js";
import { DEFAULT_FIGURE, renderFigure } from "../image/testFixtures.js";
import { buildHumanoidMesh } from "../mesh/humanoid.js";
import { triangleCount, vertexCount } from "../mesh/MeshBuilder.js";
import { buildSkeleton } from "../rig/skeleton.js";
import { computeInverseBindMatrices, computeSkinWeights, skinStatistics } from "../rig/skin.js";
import { exportGlb } from "./glb.js";

/**
 * The exporter is hand-rolled, so "it produced bytes" proves nothing. These
 * tests feed the output back through three's own `GLTFLoader` — the exact
 * parser the runtime uses — and assert on the reconstructed scene graph.
 */

function buildCharacter() {
  const raster = renderFigure(DEFAULT_FIGURE);
  const mask = denoise(segmentForeground(raster));
  const analysis = analyzeReference(raster, mask);

  const skeleton = buildSkeleton(analysis.measurements, { height: 1.7 });
  const mesh = buildHumanoidMesh(skeleton, analysis.measurements, analysis.palette);
  computeSkinWeights(mesh, skeleton);

  const inverseBindMatrices = computeInverseBindMatrices(skeleton);
  const clips = buildDefaultClips();
  const glb = exportGlb(mesh, skeleton, inverseBindMatrices, { name: "TestCharacter", clips });

  return { glb, mesh, skeleton, clips, analysis };
}

/** Parses GLB bytes with the real loader. */
function parseGlb(glb: Uint8Array) {
  const loader = new GLTFLoader();
  const buffer = glb.buffer.slice(glb.byteOffset, glb.byteOffset + glb.byteLength) as ArrayBuffer;

  return new Promise<import("three/examples/jsm/loaders/GLTFLoader.js").GLTF>((resolve, reject) => {
    loader.parse(buffer, "", resolve, reject);
  });
}

describe("GLB container", () => {
  it("writes a valid header", () => {
    const { glb } = buildCharacter();
    const view = new DataView(glb.buffer, glb.byteOffset, glb.byteLength);

    expect(view.getUint32(0, true)).toBe(0x46546c67); // "glTF"
    expect(view.getUint32(4, true)).toBe(2);
    expect(view.getUint32(8, true)).toBe(glb.byteLength);
  });

  it("keeps every chunk 4-byte aligned", () => {
    const { glb } = buildCharacter();
    const view = new DataView(glb.buffer, glb.byteOffset, glb.byteLength);

    let offset = 12;
    while (offset < glb.byteLength) {
      const chunkLength = view.getUint32(offset, true);
      expect(chunkLength % 4).toBe(0);
      offset += 8 + chunkLength;
    }
    // Chunks must tile the file exactly.
    expect(offset).toBe(glb.byteLength);
  });
});

describe("GLTFLoader round trip", () => {
  it("parses without error", async () => {
    const { glb } = buildCharacter();
    const gltf = await parseGlb(glb);
    expect(gltf.scene).toBeDefined();
  });

  it("reconstructs a skinned mesh with the expected geometry", async () => {
    const { glb, mesh } = buildCharacter();
    const gltf = await parseGlb(glb);

    let skinned: SkinnedMesh | null = null;
    gltf.scene.traverse((object) => {
      if ((object as SkinnedMesh).isSkinnedMesh) skinned = object as SkinnedMesh;
    });

    expect(skinned).not.toBeNull();
    const geometry = skinned!.geometry;

    expect(geometry.getAttribute("position").count).toBe(vertexCount(mesh));
    expect(geometry.index!.count / 3).toBe(triangleCount(mesh));

    // Vertex colours carry the palette; without them the character is white.
    expect(geometry.getAttribute("color")).toBeDefined();
    expect(geometry.getAttribute("normal")).toBeDefined();
    expect(geometry.getAttribute("skinIndex")).toBeDefined();
    expect(geometry.getAttribute("skinWeight")).toBeDefined();
  });

  it("binds all 22 canonical bones", async () => {
    const { glb, skeleton } = buildCharacter();
    const gltf = await parseGlb(glb);

    let skinned: SkinnedMesh | null = null;
    gltf.scene.traverse((object) => {
      if ((object as SkinnedMesh).isSkinnedMesh) skinned = object as SkinnedMesh;
    });

    expect(skinned!.skeleton.bones).toHaveLength(skeleton.joints.length);

    const names = skinned!.skeleton.bones.map((bone: Bone) => bone.name);
    expect(names).toContain("Hips");
    expect(names).toContain("Head");
    expect(names).toContain("LeftHand");
    expect(names).toContain("RightToeBase");
  });

  it("preserves the bone hierarchy", async () => {
    const { glb } = buildCharacter();
    const gltf = await parseGlb(glb);

    const bones = new Map<string, Bone>();
    gltf.scene.traverse((object) => {
      if ((object as Bone).isBone) bones.set(object.name, object as Bone);
    });

    // A flat list of bones would still load but would not animate correctly.
    expect(bones.get("LeftForeArm")?.parent?.name).toBe("LeftArm");
    expect(bones.get("LeftLeg")?.parent?.name).toBe("LeftUpLeg");
    expect(bones.get("Spine")?.parent?.name).toBe("Hips");
  });

  it("carries every animation clip with named bone tracks", async () => {
    const { glb, clips } = buildCharacter();
    const gltf = await parseGlb(glb);

    expect(gltf.animations).toHaveLength(clips.length);

    const walk = gltf.animations.find((clip: ThreeClip) => clip.name === "Walk");
    expect(walk).toBeDefined();
    expect(walk!.duration).toBeGreaterThan(0);
    expect(walk!.tracks.length).toBeGreaterThan(0);

    // Tracks must target bone names, which is what makes clips portable
    // between characters produced by the harness.
    const targets = walk!.tracks.map((track) => track.name.split(".")[0]);
    expect(targets).toContain("LeftUpLeg");
    expect(targets).toContain("RightUpLeg");
  });

  it("keeps the character upright and roughly the requested height", async () => {
    const { glb } = buildCharacter();
    const gltf = await parseGlb(glb);

    let skinned: SkinnedMesh | null = null;
    gltf.scene.traverse((object) => {
      if ((object as SkinnedMesh).isSkinnedMesh) skinned = object as SkinnedMesh;
    });

    skinned!.geometry.computeBoundingBox();
    const bbox = skinned!.geometry.boundingBox!;

    // Feet near the origin, crown near the target height.
    expect(bbox.min.y).toBeGreaterThan(-0.05);
    expect(bbox.max.y).toBeGreaterThan(1.5);
    expect(bbox.max.y).toBeLessThan(1.85);
    // Taller than wide — a figure, not a puddle.
    expect(bbox.max.y - bbox.min.y).toBeGreaterThan(bbox.max.x - bbox.min.x);
  });
});

describe("skin weights", () => {
  it("normalises every vertex to exactly one", () => {
    const { mesh } = buildCharacter();
    const stats = skinStatistics(mesh);
    expect(stats.unweighted).toBe(0);
    expect(stats.maxInfluences).toBeLessThanOrEqual(4);
  });

  it("assigns hand vertices to the hand bone, not the torso", () => {
    const { mesh, skeleton } = buildCharacter();
    const handIndex = skeleton.index.get("LeftHand")!;
    const foreArmIndex = skeleton.index.get("LeftForeArm")!;
    const handWorld = skeleton.joints[handIndex]!.world;

    // Find the vertex nearest the left hand joint.
    let nearest = -1;
    let nearestDistance = Infinity;
    for (let v = 0; v < vertexCount(mesh); v++) {
      const distance = Math.hypot(
        mesh.positions[v * 3]! - handWorld[0],
        mesh.positions[v * 3 + 1]! - handWorld[1],
        mesh.positions[v * 3 + 2]! - handWorld[2],
      );
      if (distance < nearestDistance) {
        nearestDistance = distance;
        nearest = v;
      }
    }

    const influences = [0, 1, 2, 3].map((i) => ({
      joint: mesh.joints[nearest * 4 + i]!,
      weight: mesh.weights[nearest * 4 + i]!,
    }));
    const dominant = influences.reduce((best, entry) => (entry.weight > best.weight ? entry : best));

    // Distance-to-joint skinning would let the spine claim this vertex; the
    // segment-based version must not.
    expect([handIndex, foreArmIndex]).toContain(dominant.joint);
  });

  it("keeps left-side vertices off right-side bones", () => {
    const { mesh, skeleton } = buildCharacter();
    const rightBones = new Set(
      skeleton.joints
        .map((joint, index) => ({ joint, index }))
        .filter(({ joint }) => joint.name.startsWith("Right"))
        .map(({ index }) => index),
    );

    let violations = 0;
    for (let v = 0; v < vertexCount(mesh); v++) {
      const x = mesh.positions[v * 3]!;
      if (x < 0.15) continue; // clearly on the left side

      for (let i = 0; i < 4; i++) {
        if (rightBones.has(mesh.joints[v * 4 + i]!) && mesh.weights[v * 4 + i]! > 0.2) {
          violations++;
        }
      }
    }
    expect(violations).toBe(0);
  });
});
