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

/**
 * Front | back orthographic body sheet with **triangle-consistent** side
 * assignment (no UV tris spanning the fold).
 *
 *   ┌────────────┬────────────┐
 *   │   FRONT    │    BACK    │
 *   │ head → feet│ head → feet│  same metric scale + shared height
 *   └────────────┴────────────┘
 *
 * Vertices used by both front and back faces are duplicated so each half
 * gets a clean silhouette. That stops the horizontal "rubber band" edges
 * that appeared when one corner of a tri went left and another went right.
 *
 * `u` is not raw X, though — see {@link sheetX}. A straight orthographic
 * projection starves the sides of every round form of texels, which is what
 * smeared the side of the head.
 */

const MARGIN = 0.04;
const GAP = 0.03;

/** Ring buckets per body region. Comfortably above the highest ring count. */
const RING_BINS = 32;

/**
 * The ellipse one loft ring traces, so a vertex on it can be placed by angle.
 *
 * A ring's vertices sit at `x = cx + rx·cos(a)`, `z = cz + rz·sin(a)`. Project
 * that flat down X and texel density is `dx/da = −rx·sin(a)`, which falls to
 * **zero** at `a = 0, π` — exactly the left and right silhouette. Those
 * triangles collapse to slivers and a few texels get stretched across the whole
 * side of the form. Measured on a male head: 18% of its surface under a quarter
 * of median density, the worst of it at 4%.
 *
 * Recovering `a` and spacing `u` by it instead holds density constant around
 * the ring. Centre and radii are per ring rather than per region, so the chin
 * and the cheekbone each get their own width, and offset regions like the arms
 * work without assuming they straddle x = 0.
 *
 * Rings come from bucketing the loft parameter the body tags onto every vertex.
 * That beats bucketing by height: feet are lofted heel-to-toe, so their rings
 * are vertical planes.
 *
 * Untagged geometry — every garment — is left alone. Extending the fix there
 * means tagging clothing the way `coverage.ts` tags skin.
 */
interface RingFrame {
  /** Centre and half-extent of the ring, across (x) and through (z). */
  cx: number;
  rx: number;
  cz: number;
  rz: number;
}

/** Per-ring frames, indexed by original vertex. `null` = untagged, leave alone. */
function ringFrames(mesh: MeshData, n0: number): (RingFrame | null)[] {
  const frames: (RingFrame | null)[] = new Array(n0).fill(null);

  const groups = new Map<number, number[]>();
  for (let i = 0; i < n0; i++) {
    const part = mesh.parts[i] ?? 0;
    if (part === 0) continue;
    let group = groups.get(part);
    if (!group) {
      group = [];
      groups.set(part, group);
    }
    group.push(i);
  }

  const xLo = new Float64Array(RING_BINS);
  const xHi = new Float64Array(RING_BINS);
  const zLo = new Float64Array(RING_BINS);
  const zHi = new Float64Array(RING_BINS);

  for (const indices of groups.values()) {
    xLo.fill(Number.POSITIVE_INFINITY);
    xHi.fill(Number.NEGATIVE_INFINITY);
    zLo.fill(Number.POSITIVE_INFINITY);
    zHi.fill(Number.NEGATIVE_INFINITY);

    const binOf = (i: number) => {
      const t = mesh.ts[i] ?? 0;
      return Math.min(RING_BINS - 1, Math.max(0, Math.floor(t * RING_BINS)));
    };

    for (const i of indices) {
      const bin = binOf(i);
      const x = mesh.positions[i * 3]!;
      const z = mesh.positions[i * 3 + 2]!;
      if (x < xLo[bin]!) xLo[bin] = x;
      if (x > xHi[bin]!) xHi[bin] = x;
      if (z < zLo[bin]!) zLo[bin] = z;
      if (z > zHi[bin]!) zHi[bin] = z;
    }

    for (const i of indices) {
      const bin = binOf(i);
      const rx = (xHi[bin]! - xLo[bin]!) * 0.5;
      const rz = (zHi[bin]! - zLo[bin]!) * 0.5;
      // A ring with no width or no depth has no angle to measure.
      if (!(rx > 1e-6) || !(rz > 1e-6)) continue;
      frames[i] = {
        cx: (xLo[bin]! + xHi[bin]!) * 0.5,
        rx,
        cz: (zLo[bin]! + zHi[bin]!) * 0.5,
        rz,
      };
    }
  }

  return frames;
}

/**
 * Where this vertex sits along its sheet, in units of the ring's half-width.
 *
 * `±1` is the silhouette, `0` is dead centre of the sheet. Values outside
 * `[-1, 1]` are legitimate and are the point of the whole exercise — see below.
 */
function sheetX(frame: RingFrame, x: number, z: number, back: boolean): number {
  const xRel = (x - frame.cx) / frame.rx;
  const zRel = (z - frame.cz) / frame.rz;

  // Is this vertex actually *on* the ring the frame describes?
  //
  // Rings are found by bucketing the loft parameter, and the nose and ears are
  // tagged with a single constant one, so they land in a skull ring's bucket and
  // inherit a frame they were never part of. An ear sits at the frame's x
  // extreme but nowhere near its z centre, and the angle that falls out of that
  // flings it clear of the head — it showed up in the atlas as a bar wider than
  // the figure, right where a texturing model would read a hat brim.
  //
  // A vertex genuinely on the ellipse has a normalised radius near 1: exactly
  // 1.08 for an eight-sided ring, at most about 1.12 for a six-sided one, since
  // `rx`/`rz` are the sampled extents rather than the true semi-axes. Anything
  // outside that band is not on this ring, so it keeps its flat projection.
  const r = Math.hypot(xRel, zRel);
  if (r < 0.85 || r > 1.2) return x;

  // Normalise the ellipse to a circle, then take the angle: 0 = the +x
  // silhouette, π/2 = facing the camera, π = the −x silhouette.
  let a = Math.atan2(zRel, xRel);
  if (back) a = -a;

  // `a` came back on (−π, π]. A vertex on the far side of the seam from its own
  // sheet has to be read on the *near* branch, or it lands on the wrong edge.
  if (a < -Math.PI / 2) a += 2 * Math.PI;

  // A sheet legitimately overruns its silhouette by half a column — 22.5° at
  // eight sides — to give the straddling quad some width. Past that is noise.
  const OVERSHOOT = Math.PI / 4;
  a = Math.max(-OVERSHOOT, Math.min(Math.PI + OVERSHOOT, a));

  // Linear in angle, so texel density is constant around the ring.
  return frame.cx + frame.rx * (1 - (2 * a) / Math.PI);
}

export function unwrapCharacterAtlas(mesh: MeshData, skeleton: Skeleton): void {
  const n0 = vertexCount(mesh);
  if (n0 === 0) return;

  // Before any duplication: the region/ring tags only cover the original
  // vertices, and `duplicateVertex` does not carry them across.
  const frames = ringFrames(mesh, n0);

  // --- classify each triangle as front or back -----------------------------
  const triSide: ("front" | "back")[] = [];
  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]!;
    const nz =
      (mesh.normals[ia * 3 + 2]! + mesh.normals[ib * 3 + 2]! + mesh.normals[ic * 3 + 2]!) / 3;
    triSide.push(nz >= 0 ? "front" : "back");
  }

  // Which sides need each original vertex?
  const needFront = new Uint8Array(n0);
  const needBack = new Uint8Array(n0);
  for (let t = 0; t < triSide.length; t++) {
    const ia = mesh.indices[t * 3]!;
    const ib = mesh.indices[t * 3 + 1]!;
    const ic = mesh.indices[t * 3 + 2]!;
    if (triSide[t] === "front") {
      needFront[ia] = 1;
      needFront[ib] = 1;
      needFront[ic] = 1;
    } else {
      needBack[ia] = 1;
      needBack[ib] = 1;
      needBack[ic] = 1;
    }
  }

  // Map original vert → back-side duplicate (if it also needs a front UV).
  const backDup = new Int32Array(n0).fill(-1);
  for (let i = 0; i < n0; i++) {
    if (needFront[i] && needBack[i]) {
      backDup[i] = duplicateVertex(mesh, i);
    } else if (!needFront[i] && needBack[i]) {
      // Only used on the back — keep original index, mark as back-only.
      backDup[i] = i;
    }
  }

  // Rewrite indices: back tris use the back duplicate when present.
  for (let t = 0; t < triSide.length; t++) {
    if (triSide[t] !== "back") continue;
    for (let k = 0; k < 3; k++) {
      const src = mesh.indices[t * 3 + k]!;
      const mapped = backDup[src] ?? -1;
      if (mapped >= 0) mesh.indices[t * 3 + k] = mapped;
    }
  }

  const n = vertexCount(mesh);
  // side[i] = 0 front, 1 back
  const side = new Uint8Array(n);
  for (let i = 0; i < n0; i++) {
    if (needFront[i] && needBack[i]) {
      side[i] = 0; // original stays front
      side[backDup[i]!] = 1;
    } else if (needBack[i]) {
      side[i] = 1;
    } else {
      side[i] = 0;
    }
  }
  // Any brand-new verts only from dup are already set; leftover defaults front.

  // A duplicate carries its source's ring frame; positions were copied verbatim.
  const origOf = new Int32Array(n);
  for (let i = 0; i < n; i++) origOf[i] = i;
  for (let i = 0; i < n0; i++) {
    const dup = backDup[i]!;
    if (dup >= 0 && dup !== i) origOf[dup] = i;
  }

  // --- place every vertex along its sheet ----------------------------------
  // Has to happen before the bounds are taken: a column straddling the
  // silhouette is deliberately pushed outside `±rx`, so the sheet is a little
  // wider than the figure's outline and the packing must allow for it.
  const xUv = new Float64Array(n);
  for (let i = 0; i < n; i++) {
    const frame = frames[origOf[i]!] ?? null;
    const x = mesh.positions[i * 3]!;
    xUv[i] = frame
      ? sheetX(frame, x, mesh.positions[i * 3 + 2]!, side[i] === 1)
      : x;
  }

  const { min, max } = bounds(mesh);
  const padM = Math.max(skeleton.height * 0.015, 0.008);
  let xLo = Number.POSITIVE_INFINITY;
  let xHi = Number.NEGATIVE_INFINITY;
  for (let i = 0; i < n; i++) {
    if (xUv[i]! < xLo) xLo = xUv[i]!;
    if (xUv[i]! > xHi) xHi = xUv[i]!;
  }
  const minX = xLo - padM;
  const maxX = xHi + padM;
  const minY = min[1]! - padM * 0.35;
  const maxY = max[1]! + padM * 0.35;
  const width = Math.max(maxX - minX, 1e-4);
  const height = Math.max(maxY - minY, 1e-4);

  // --- pack with uniform scale ---------------------------------------------
  const halfW = 0.5 - MARGIN - GAP * 0.5;
  const fullH = 1 - MARGIN * 2;
  const scale = Math.min(halfW / width, fullH / height);
  const contentW = width * scale;
  const contentH = height * scale;

  const frontU0 = MARGIN + (halfW - contentW) * 0.5;
  const backU0 = 0.5 + GAP * 0.5 + (halfW - contentW) * 0.5;
  const v0 = MARGIN + (fullH - contentH) * 0.5;

  mesh.uvs = new Array(n * 2);
  for (let i = 0; i < n; i++) {
    const x = xUv[i]!;
    const y = mesh.positions[i * 3 + 1]!;
    const tx = (x - minX) / width;
    // ty: 0 = feet, 1 = head in model space.
    const ty = (y - minY) / height;

    let u: number;
    if (side[i]) {
      u = backU0 + (1 - tx) * contentW; // mirrored back
    } else {
      u = frontU0 + tx * contentW;
    }
    // glTF UV origin is the **upper-left** of the texture image, with V
    // increasing downward. Put the head at low V (top of the sheet) and feet
    // at high V (bottom) so albedo maps aren't upside-down on the mesh.
    const v = v0 + (1 - ty) * contentH;
    mesh.uvs[i * 2] = clamp01(u);
    mesh.uvs[i * 2 + 1] = clamp01(v);
  }
}

/** @deprecated */
export function unwrapCylindrical(mesh: MeshData, _margin?: number): void {
  const { min, max } = bounds(mesh);
  const height = Math.max(max[1]! - min[1]!, 1);
  unwrapCharacterAtlas(mesh, { height, joints: [], index: new Map() } as unknown as Skeleton);
}

function duplicateVertex(mesh: MeshData, index: number): number {
  const ni = vertexCount(mesh);
  const i = index;
  mesh.positions.push(
    mesh.positions[i * 3]!,
    mesh.positions[i * 3 + 1]!,
    mesh.positions[i * 3 + 2]!,
  );
  mesh.normals.push(mesh.normals[i * 3]!, mesh.normals[i * 3 + 1]!, mesh.normals[i * 3 + 2]!);
  mesh.colors.push(
    mesh.colors[i * 4]!,
    mesh.colors[i * 4 + 1]!,
    mesh.colors[i * 4 + 2]!,
    mesh.colors[i * 4 + 3]!,
  );
  if (mesh.joints.length >= (i + 1) * 4) {
    mesh.joints.push(
      mesh.joints[i * 4]!,
      mesh.joints[i * 4 + 1]!,
      mesh.joints[i * 4 + 2]!,
      mesh.joints[i * 4 + 3]!,
    );
    mesh.weights.push(
      mesh.weights[i * 4]!,
      mesh.weights[i * 4 + 1]!,
      mesh.weights[i * 4 + 2]!,
      mesh.weights[i * 4 + 3]!,
    );
  }
  return ni;
}

function clamp01(x: number): number {
  return Math.max(0, Math.min(1, x));
}
