import type { Raster, Rgb } from "../image/Raster.js";
import { bounds, type MeshData, type Vec3Tuple } from "../mesh/MeshBuilder.js";

/**
 * Software rasteriser for mesh review.
 *
 * The creator emits GLB, which means the only way to judge a silhouette used to
 * be opening the web viewer. That is a bad loop for authoring a *base* mesh,
 * where every change is a judgement call about shape. This renders the same
 * `MeshData` the exporter sees, offscreen, to a PNG contact sheet: front /
 * three-quarter / side, with head-unit rules for proportion checks.
 *
 * Orthographic on purpose — perspective foreshortening lies about proportion,
 * and the game's fixed cameras are near-ortho at range anyway.
 */

export interface RenderOptions {
  /** Tile size in pixels. */
  size?: number;
  /** Camera yaw in degrees. 0 = front (looking down −Z), 90 = the figure's left. */
  yaw?: number;
  /** Camera pitch in degrees. Positive looks down on the figure. */
  pitch?: number;
  /** Background fill. */
  background?: Rgb;
  /** Override every vertex colour with flat clay — shape reads without palette noise. */
  clay?: Rgb | null;
  /** Draw triangle edges over the shading. */
  wireframe?: boolean;
  /** Paint back-facing triangles magenta instead of culling (winding check). */
  showBackfaces?: boolean;
  /** Horizontal rules every head-height (figure height / 7.5) plus a centre line. */
  guides?: boolean;
  /** Vertical fraction of the tile the figure fills. */
  fill?: number;
}

const BACKFACE: Rgb = { r: 255, g: 0, b: 190 };
const GUIDE: Rgb = { r: 62, g: 70, b: 84 };
const CENTRE_GUIDE: Rgb = { r: 82, g: 92, b: 110 };
const WIRE: Rgb = { r: 20, g: 22, b: 28 };
const DEFAULT_BG: Rgb = { r: 30, g: 33, b: 40 };

/** Key / fill / rim in view space — a three-point setup so the far side isn't black. */
const LIGHTS: { dir: Vec3Tuple; intensity: number }[] = [
  { dir: [-0.45, 0.55, 0.7], intensity: 0.85 },
  { dir: [0.7, 0.1, 0.35], intensity: 0.3 },
  { dir: [0.1, -0.3, -0.8], intensity: 0.22 },
];
const AMBIENT = 0.22;

const linearToSrgb = (c: number): number => {
  const v = c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
  return Math.max(0, Math.min(255, Math.round(v * 255)));
};

const norm = (v: Vec3Tuple): Vec3Tuple => {
  const l = Math.hypot(v[0], v[1], v[2]) || 1;
  return [v[0] / l, v[1] / l, v[2] / l];
};

const NORMALISED_LIGHTS = LIGHTS.map((l) => ({ dir: norm(l.dir), intensity: l.intensity }));

function rotate(p: Vec3Tuple, yaw: number, pitch: number): Vec3Tuple {
  const cy = Math.cos(yaw);
  const sy = Math.sin(yaw);
  const x = p[0] * cy + p[2] * sy;
  const z = -p[0] * sy + p[2] * cy;
  const cp = Math.cos(pitch);
  const sp = Math.sin(pitch);
  return [x, p[1] * cp - z * sp, p[1] * sp + z * cp];
}

export interface View {
  raster: Raster;
  /** Pixels per world metre — callers overlay their own annotations with it. */
  scale: number;
}

export function renderMesh(mesh: MeshData, options: RenderOptions = {}): View {
  const size = options.size ?? 420;
  const yaw = ((options.yaw ?? 0) * Math.PI) / 180;
  const pitch = ((options.pitch ?? 0) * Math.PI) / 180;
  const bg = options.background ?? DEFAULT_BG;
  const clay = options.clay ?? null;
  const fill = options.fill ?? 0.88;

  const width = size;
  const height = size;
  const data = new Uint8Array(width * height * 4);
  for (let i = 0; i < width * height; i++) {
    data[i * 4] = bg.r;
    data[i * 4 + 1] = bg.g;
    data[i * 4 + 2] = bg.b;
    data[i * 4 + 3] = 255;
  }
  const depth = new Float32Array(width * height).fill(-Infinity);

  const { min, max } = bounds(mesh);
  const modelHeight = Math.max(1e-6, max[1] - min[1]);
  // Fit on height alone so male / female / clothed tiles stay directly comparable.
  const scale = (height * fill) / modelHeight;
  const centreY = (min[1] + max[1]) / 2;
  const originX = width / 2;
  const originY = height / 2;

  const project = (p: Vec3Tuple): { x: number; y: number; z: number } => {
    const r = rotate(p, yaw, pitch);
    return {
      x: originX + r[0] * scale,
      y: originY - (r[1] - centreY) * scale,
      z: r[2],
    };
  };

  if (options.guides !== false) {
    drawGuides(data, width, height, min, max, centreY, scale, originX, originY);
  }

  const vertexCount = mesh.positions.length / 3;
  const screen = new Float32Array(vertexCount * 3);
  const viewNormal = new Float32Array(vertexCount * 3);
  for (let v = 0; v < vertexCount; v++) {
    const p = project([
      mesh.positions[v * 3]!,
      mesh.positions[v * 3 + 1]!,
      mesh.positions[v * 3 + 2]!,
    ]);
    screen[v * 3] = p.x;
    screen[v * 3 + 1] = p.y;
    screen[v * 3 + 2] = p.z;
    const n = rotate(
      [mesh.normals[v * 3] ?? 0, mesh.normals[v * 3 + 1] ?? 1, mesh.normals[v * 3 + 2] ?? 0],
      yaw,
      pitch,
    );
    viewNormal[v * 3] = n[0];
    viewNormal[v * 3 + 1] = n[1];
    viewNormal[v * 3 + 2] = n[2];
  }

  const edges: [number, 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]!;

    const ax = screen[ia * 3]!;
    const ay = screen[ia * 3 + 1]!;
    const az = screen[ia * 3 + 2]!;
    const bx = screen[ib * 3]!;
    const by = screen[ib * 3 + 1]!;
    const bz = screen[ib * 3 + 2]!;
    const cx = screen[ic * 3]!;
    const cy = screen[ic * 3 + 1]!;
    const cz = screen[ic * 3 + 2]!;

    // Screen y is flipped, so a CCW (front-facing) triangle has negative area.
    const area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
    if (area === 0) continue;
    const back = area > 0;
    if (back && !options.showBackfaces) continue;

    const minX = Math.max(0, Math.floor(Math.min(ax, bx, cx)));
    const maxX = Math.min(width - 1, Math.ceil(Math.max(ax, bx, cx)));
    const minY = Math.max(0, Math.floor(Math.min(ay, by, cy)));
    const maxY = Math.min(height - 1, Math.ceil(Math.max(ay, by, cy)));
    if (minX > maxX || minY > maxY) continue;

    const inv = 1 / area;
    for (let y = minY; y <= maxY; y++) {
      for (let x = minX; x <= maxX; x++) {
        const px = x + 0.5;
        const py = y + 0.5;
        let w0 = ((bx - ax) * (py - ay) - (by - ay) * (px - ax)) * inv;
        let w1 = ((cx - bx) * (py - by) - (cy - by) * (px - bx)) * inv;
        let w2 = 1 - w0 - w1;
        if (w0 < 0 || w1 < 0 || w2 < 0) continue;
        // w0 is opposite c, w1 opposite a, w2 opposite b.
        const la = w1;
        const lb = w2;
        const lc = w0;

        const z = la * az + lb * bz + lc * cz;
        const at = y * width + x;
        if (z <= depth[at]!) continue;
        depth[at] = z;

        let shade: Rgb;
        if (back) {
          shade = BACKFACE;
        } else {
          const nx = la * viewNormal[ia * 3]! + lb * viewNormal[ib * 3]! + lc * viewNormal[ic * 3]!;
          const ny =
            la * viewNormal[ia * 3 + 1]! +
            lb * viewNormal[ib * 3 + 1]! +
            lc * viewNormal[ic * 3 + 1]!;
          const nz =
            la * viewNormal[ia * 3 + 2]! +
            lb * viewNormal[ib * 3 + 2]! +
            lc * viewNormal[ic * 3 + 2]!;
          const n = norm([nx, ny, nz]);

          let lit = AMBIENT;
          for (const light of NORMALISED_LIGHTS) {
            const d = n[0] * light.dir[0] + n[1] * light.dir[1] + n[2] * light.dir[2];
            // Half-lambert: PSX-era vertex lighting never went fully black either.
            lit += Math.max(0, d * 0.5 + 0.5) * light.intensity;
          }

          if (clay) {
            shade = {
              r: linearToSrgb(srgbToLinear(clay.r) * lit),
              g: linearToSrgb(srgbToLinear(clay.g) * lit),
              b: linearToSrgb(srgbToLinear(clay.b) * lit),
            };
          } else {
            const cr = la * mesh.colors[ia * 4]! + lb * mesh.colors[ib * 4]! + lc * mesh.colors[ic * 4]!;
            const cg =
              la * mesh.colors[ia * 4 + 1]! +
              lb * mesh.colors[ib * 4 + 1]! +
              lc * mesh.colors[ic * 4 + 1]!;
            const cb =
              la * mesh.colors[ia * 4 + 2]! +
              lb * mesh.colors[ib * 4 + 2]! +
              lc * mesh.colors[ic * 4 + 2]!;
            shade = {
              r: linearToSrgb(cr * lit),
              g: linearToSrgb(cg * lit),
              b: linearToSrgb(cb * lit),
            };
          }
        }

        data[at * 4] = shade.r;
        data[at * 4 + 1] = shade.g;
        data[at * 4 + 2] = shade.b;
      }
    }

    if (options.wireframe && !back) {
      edges.push([ia, ib], [ib, ic], [ic, ia]);
    }
  }

  if (options.wireframe) {
    for (const [a, b] of edges) {
      drawLine(
        data,
        depth,
        width,
        height,
        screen[a * 3]!,
        screen[a * 3 + 1]!,
        screen[a * 3 + 2]!,
        screen[b * 3]!,
        screen[b * 3 + 1]!,
        screen[b * 3 + 2]!,
        WIRE,
      );
    }
  }

  return { raster: { width, height, data }, scale };
}

const srgbToLinear = (channel: number): number => {
  const c = channel / 255;
  return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};

/** Head-unit rules + centre line, drawn under the figure. */
function drawGuides(
  data: Uint8Array,
  width: number,
  height: number,
  min: Vec3Tuple,
  max: Vec3Tuple,
  centreY: number,
  scale: number,
  originX: number,
  originY: number,
): void {
  const figureHeight = max[1] - min[1];
  const headUnit = figureHeight / 7.5;
  const put = (x: number, y: number, c: Rgb) => {
    if (x < 0 || y < 0 || x >= width || y >= height) return;
    const i = (y * width + x) * 4;
    data[i] = c.r;
    data[i + 1] = c.g;
    data[i + 2] = c.b;
  };

  for (let u = 0; u <= 7.5; u += 1) {
    const worldY = max[1] - u * headUnit;
    const y = Math.round(originY - (worldY - centreY) * scale);
    for (let x = 0; x < width; x++) put(x, y, GUIDE);
  }
  for (let y = 0; y < height; y++) put(Math.round(originX), y, CENTRE_GUIDE);
}

/** Depth-tested line for the wireframe overlay. */
function drawLine(
  data: Uint8Array,
  depth: Float32Array,
  width: number,
  height: number,
  x0: number,
  y0: number,
  z0: number,
  x1: number,
  y1: number,
  z1: number,
  color: Rgb,
): void {
  const steps = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0))));
  for (let s = 0; s <= steps; s++) {
    const t = s / steps;
    const x = Math.round(x0 + (x1 - x0) * t);
    const y = Math.round(y0 + (y1 - y0) * t);
    if (x < 0 || y < 0 || x >= width || y >= height) continue;
    const at = y * width + x;
    const z = z0 + (z1 - z0) * t;
    // Small bias so edges win against their own faces but stay hidden behind others.
    if (z < depth[at]! - 1e-4) continue;
    data[at * 4] = color.r;
    data[at * 4 + 1] = color.g;
    data[at * 4 + 2] = color.b;
  }
}

export interface SheetTile {
  mesh: MeshData;
  yaw?: number;
  pitch?: number;
}

/** Horizontal contact sheet — one tile per view, shared scale. */
export function renderSheet(tiles: SheetTile[], options: RenderOptions = {}): Raster {
  const size = options.size ?? 420;
  const views = tiles.map((tile) =>
    renderMesh(tile.mesh, {
      ...options,
      ...(tile.yaw === undefined ? {} : { yaw: tile.yaw }),
      ...(tile.pitch === undefined ? {} : { pitch: tile.pitch }),
    }),
  );

  const width = size * views.length;
  const data = new Uint8Array(width * size * 4);
  for (let i = 0; i < views.length; i++) {
    const src = views[i]!.raster;
    for (let y = 0; y < size; y++) {
      for (let x = 0; x < size; x++) {
        const from = (y * size + x) * 4;
        const to = (y * width + i * size + x) * 4;
        data[to] = src.data[from]!;
        data[to + 1] = src.data[from + 1]!;
        data[to + 2] = src.data[from + 2]!;
        data[to + 3] = 255;
      }
    }
  }
  // Tile separators.
  for (let i = 1; i < views.length; i++) {
    for (let y = 0; y < size; y++) {
      const to = (y * width + i * size) * 4;
      data[to] = 12;
      data[to + 1] = 13;
      data[to + 2] = 16;
    }
  }
  return { width, height: size, data };
}

/** Stack contact sheets vertically (e.g. a male row above a female row). */
export function stackRasters(rows: Raster[]): Raster {
  const width = Math.max(...rows.map((r) => r.width));
  const height = rows.reduce((sum, r) => sum + r.height, 0);
  const data = new Uint8Array(width * height * 4);
  let offsetY = 0;
  for (const row of rows) {
    for (let y = 0; y < row.height; y++) {
      for (let x = 0; x < row.width; x++) {
        const from = (y * row.width + x) * 4;
        const to = ((offsetY + y) * width + x) * 4;
        data[to] = row.data[from]!;
        data[to + 1] = row.data[from + 1]!;
        data[to + 2] = row.data[from + 2]!;
        data[to + 3] = 255;
      }
    }
    offsetY += row.height;
  }
  return { width, height, data };
}
