import { describe, expect, it } from "vitest";
import { buildDefaultClips } from "../anim/procedural.js";
import { assembleCharacter } from "../creator/assemble.js";
import { CHARACTER_PRESETS } from "../creator/presets.js";
import { exportGlb } from "../export/glb.js";
import { vertexCount } from "../mesh/MeshBuilder.js";
import { computeInverseBindMatrices, computeSkinWeights } from "../rig/skin.js";
import { bakeUvGuide } from "./bakeUvGuide.js";
import { PART } from "../creator/coverage.js";
import type { MeshData } from "../mesh/MeshBuilder.js";
import { unwrapCharacterAtlas } from "./unwrap.js";

/**
 * Texel density per triangle: UV area over world area, so 1.0 after
 * normalising to the median is "average sized in the atlas".
 */
function headDensities(mesh: MeshData): number[] {
  const P = (i: number) =>
    [mesh.positions[i * 3]!, mesh.positions[i * 3 + 1]!, mesh.positions[i * 3 + 2]!] as const;
  const UV = (i: number) => [mesh.uvs[i * 2]!, mesh.uvs[i * 2 + 1]!] as const;

  const out: number[] = [];
  for (let t = 0; t < mesh.indices.length; t += 3) {
    const ia = mesh.indices[t]!;
    const ib = mesh.indices[t + 1]!;
    const ic = mesh.indices[t + 2]!;
    if ((mesh.parts[ia] ?? 0) !== PART.HEAD) continue;

    const [ax, ay, az] = P(ia);
    const [bx, by, bz] = P(ib);
    const [cx, cy, cz] = P(ic);
    const ux = bx - ax;
    const uy = by - ay;
    const uz = bz - az;
    const vx = cx - ax;
    const vy = cy - ay;
    const vz = cz - az;
    const world =
      0.5 * Math.hypot(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx);
    if (world < 1e-9) continue;

    const [au, av] = UV(ia);
    const [bu, bv] = UV(ib);
    const [cu, cv] = UV(ic);
    const uv = Math.abs((bu - au) * (cv - av) - (bv - av) * (cu - au)) * 0.5;
    out.push(uv / world);
  }
  return out;
}

describe("UV unwrap + guide", () => {
  it("assigns UVs in 0..1 for every vertex via multi-island atlas", () => {
    const result = assembleCharacter(CHARACTER_PRESETS[0]!);
    unwrapCharacterAtlas(result.mesh, result.skeleton);
    computeSkinWeights(result.mesh, result.skeleton);
    const n = vertexCount(result.mesh);
    expect(result.mesh.uvs.length).toBe(n * 2);
    for (let i = 0; i < result.mesh.uvs.length; i++) {
      const v = result.mesh.uvs[i]!;
      expect(v).toBeGreaterThanOrEqual(0);
      expect(v).toBeLessThanOrEqual(1);
    }
  });

  it("bakes a non-empty PNG guide with front and back body halves", () => {
    const result = assembleCharacter(CHARACTER_PRESETS[0]!);
    unwrapCharacterAtlas(result.mesh, result.skeleton);
    const png = bakeUvGuide(result.mesh, { size: 256 });
    expect(png.byteLength).toBeGreaterThan(500);
    expect(png[0]).toBe(0x89);
    expect(png[1]).toBe(0x50);

    // Front half (u < 0.5) and back half (u > 0.5) should both be used.
    let front = 0;
    let back = 0;
    for (let i = 0; i < result.mesh.uvs.length; i += 2) {
      if (result.mesh.uvs[i]! < 0.5) front++;
      else back++;
    }
    expect(front).toBeGreaterThan(50);
    expect(back).toBeGreaterThan(50);
  });

  it("keeps texel density even around the head", () => {
    // A flat X projection starves the sides of every round form: on a loft ring
    // `x = c + R·cos(a)`, so density `dx/da` collapses to zero exactly at the
    // left and right silhouette. That is what smeared the side of the head —
    // 18% of its surface was running under a quarter of median density.
    // Placing `u` by angle instead holds it near uniform.
    const result = assembleCharacter(CHARACTER_PRESETS[0]!);
    unwrapCharacterAtlas(result.mesh, result.skeleton);

    const densities = headDensities(result.mesh).sort((a, b) => a - b);
    expect(densities.length).toBeGreaterThan(50);
    const median = densities[Math.floor(densities.length / 2)]!;
    expect(median).toBeGreaterThan(0);

    const starved = densities.filter((d) => d / median < 0.25).length;
    // Some residue is unavoidable in a front|back atlas: the ears and the
    // nose's side strips are surfaces perpendicular to X, so they have no
    // extent to project however `u` is chosen. They are small.
    expect(starved / densities.length).toBeLessThan(0.15);

    const p10 = densities[Math.floor(densities.length * 0.1)]!;
    expect(p10 / median).toBeGreaterThan(0.15);
  });

  it("exports a GLB with TEXCOORD_0 and embedded PNG image chunk", () => {
    const result = assembleCharacter(CHARACTER_PRESETS.find((p) => p.id === "survivor")!);
    unwrapCharacterAtlas(result.mesh, result.skeleton);
    computeSkinWeights(result.mesh, result.skeleton);
    const ibm = computeInverseBindMatrices(result.skeleton);
    const guide = bakeUvGuide(result.mesh, { size: 128 });

    const glb = exportGlb(result.mesh, result.skeleton, ibm, {
      name: "TexTest",
      clips: buildDefaultClips(),
      baseColorImage: new Uint8Array(guide),
      baseColorMimeType: "image/png",
    });

    const view = new DataView(glb.buffer, glb.byteOffset, glb.byteLength);
    expect(view.getUint32(0, true)).toBe(0x46546c67);
    const jsonLen = view.getUint32(12, true);
    const jsonBytes = glb.subarray(20, 20 + jsonLen);
    const jsonText = new TextDecoder().decode(jsonBytes).replace(/\0/g, "").trim();
    const gltf = JSON.parse(jsonText) as {
      meshes: Array<{ primitives: Array<{ attributes: Record<string, number> }> }>;
      images?: Array<{ mimeType: string }>;
      textures?: unknown[];
      materials: Array<{ pbrMetallicRoughness?: { baseColorTexture?: { index: number } } }>;
    };

    expect(gltf.meshes[0]!.primitives[0]!.attributes["TEXCOORD_0"]).toBeDefined();
    expect(gltf.images?.[0]?.mimeType).toBe("image/png");
    expect(gltf.textures?.length).toBe(1);
    expect(gltf.materials[0]!.pbrMetallicRoughness?.baseColorTexture?.index).toBe(0);
  });
});
