/**
 * Head style — three appearance sliders, the face's answer to `bodyStyle.ts`.
 *
 *   length −1 round/short … +1 long/narrow
 *   jaw    −1 tapered     … +1 square/heavy
 *   brow   −1 soft/flat   … +1 heavy brow, strong nose
 *
 * Three axes are enough because a PSX head has three things a viewer can read
 * at 240p: the skull's proportion, the shape of the lower face, and the
 * profile. Eye colour and freckles are the texture's job; these move geometry.
 *
 * The sliders sit on top of a per-sex face base — see {@link craniumFor}. That
 * base is where the male and female heads actually differ, so a neutral
 * character of each sex already reads as a different person rather than the
 * same skull scaled by a couple of percent.
 */

import type { BodyType } from "./types.js";

export interface HeadStyle {
  /** −1 round & short … +1 long & narrow */
  length: number;
  /** −1 tapered chin … +1 square, heavy jaw */
  jaw: number;
  /** −1 smooth profile … +1 heavy brow and nose */
  brow: number;
}

export const HEAD_STYLE_DEFAULTS: HeadStyle = Object.freeze({
  length: 0,
  jaw: 0,
  brow: 0,
});

/** Slider metadata for the creator UI. */
export const HEAD_STYLE_SLIDERS = Object.freeze([
  {
    id: "length" as const,
    label: "Face length",
    min: -1,
    max: 1,
    step: 0.01,
    left: "Round",
    right: "Long",
  },
  {
    id: "jaw" as const,
    label: "Jaw",
    min: -1,
    max: 1,
    step: 0.01,
    left: "Tapered",
    right: "Square",
  },
  {
    id: "brow" as const,
    label: "Brow",
    min: -1,
    max: 1,
    step: 0.01,
    left: "Soft",
    right: "Heavy",
  },
]);

function clamp(v: number, lo: number, hi: number): number {
  if (!Number.isFinite(v)) return 0;
  return Math.max(lo, Math.min(hi, v));
}

/** Normalize a partial head bag. Missing keys become neutral. */
export function normalizeHeadStyle(raw: unknown): HeadStyle {
  if (!raw || typeof raw !== "object") {
    return { ...HEAD_STYLE_DEFAULTS };
  }
  const o = raw as Record<string, unknown>;
  // Accept packed wire form { l, j, b } as well as full names.
  const length = o.length ?? o.l;
  const jaw = o.jaw ?? o.j;
  const brow = o.brow ?? o.b;
  return {
    length: clamp(Number(length ?? 0), -1, 1),
    jaw: clamp(Number(jaw ?? 0), -1, 1),
    brow: clamp(Number(brow ?? 0), -1, 1),
  };
}

export function isDefaultHeadStyle(style: HeadStyle): boolean {
  const s = normalizeHeadStyle(style);
  return s.length === 0 && s.jaw === 0 && s.brow === 0;
}

/**
 * Ease unit strength so mid-slider stays mild and the last third pushes into
 * caricature. Same curve as the body sliders, so the two panels feel alike.
 */
function easeExtreme(t: number): number {
  const a = clamp(t, 0, 1);
  return a * 0.35 + a * a * 0.25 + a * a * a * 0.4;
}

function shapedSigned(v: number): number {
  if (v === 0) return 0;
  return Math.sign(v) * easeExtreme(Math.abs(v));
}

/**
 * Skull shape scalars. Widths are fractions of the head's half-width, so
 * `jawW: 0.92` is a jaw ring 92% as wide as the cheekbones; the rest are
 * multipliers on the base head dimensions (≈1 on the male base).
 */
export interface Cranium {
  /** Multipliers on head half-width / half-depth / chin-to-crown height. */
  widthF: number;
  depthF: number;
  faceLengthF: number;

  /** Chin ring: breadth, depth, and how far it juts forward. */
  chinW: number;
  chinD: number;
  chinOut: number;

  /** Jaw-angle ring — the widest point of the lower face. */
  jawW: number;
  jawD: number;

  /** Cheekbone breadth, the head's widest ring. */
  cheekW: number;

  /** Brow ridge size and projection. Near 0 = no ridge; the bar is dropped. */
  browOut: number;

  /** 0 = vertical forehead … 1+ = cranium drifting back over the occiput. */
  foreheadSlope: number;

  /** Nose length (down), projection (forward) and breadth (across). */
  noseLen: number;
  noseOut: number;
  noseW: number;

  /** Ear scale. */
  earSize: number;

  style: HeadStyle;
}

/**
 * The sex face bases.
 *
 * Male numbers reproduce the head `body.ts` drew before the sliders existed, so
 * a default male is unchanged. The female base is the actual edit: cheekbones
 * carry the width while the jaw and chin narrow under them (the tapered read),
 * the brow ridge nearly disappears, the forehead stands up rather than sloping
 * back, and the nose comes down in all three axes.
 */
function faceBase(body: BodyType): Omit<Cranium, "style"> {
  if (body === "female") {
    return {
      widthF: 0.985,
      depthF: 0.965,
      faceLengthF: 0.955,
      chinW: 0.42,
      chinD: 0.58,
      chinOut: 0.28,
      jawW: 0.83,
      jawD: 0.86,
      cheekW: 1.03,
      browOut: 0.4,
      foreheadSlope: 0.55,
      noseLen: 0.86,
      noseOut: 0.84,
      noseW: 0.85,
      earSize: 0.92,
    };
  }
  return {
    widthF: 1,
    depthF: 1,
    faceLengthF: 1,
    chinW: 0.5,
    chinD: 0.62,
    chinOut: 0.3,
    jawW: 0.92,
    jawD: 0.9,
    cheekW: 1,
    browOut: 1,
    foreheadSlope: 1,
    noseLen: 1,
    noseOut: 1,
    noseW: 1,
    earSize: 1,
  };
}

/**
 * Sex face base × slider multipliers.
 *
 * Peak gains are tuned to stay inside a believable adult skull: the head never
 * changes size much (that is the body's `headF`), it changes *proportion*.
 */
export function craniumFor(
  body: BodyType,
  style: HeadStyle | null | undefined = null,
): Cranium {
  const s = normalizeHeadStyle(style);
  const len = shapedSigned(s.length);
  const jaw = shapedSigned(s.jaw);
  const brow = shapedSigned(s.brow);
  const base = faceBase(body);

  return {
    // A long face is also a narrow one — otherwise the head just grows.
    widthF: base.widthF * (1 - len * 0.075),
    depthF: base.depthF * (1 + len * 0.045),
    faceLengthF: base.faceLengthF * (1 + len * 0.17),

    chinW: base.chinW * (1 + jaw * 0.44),
    chinD: base.chinD * (1 + jaw * 0.14),
    chinOut: base.chinOut * (1 + jaw * 0.18),

    jawW: base.jawW * (1 + jaw * 0.19),
    jawD: base.jawD * (1 + jaw * 0.12),

    // Cheeks widen slightly with the jaw and narrow with a long face, so the
    // taper from cheekbone to chin stays legible at both ends of both sliders.
    cheekW: base.cheekW * (1 + jaw * 0.04) * (1 - len * 0.04),

    browOut: Math.max(0, base.browOut * (1 + brow * 0.95)),
    foreheadSlope: Math.max(0, base.foreheadSlope * (1 + brow * 0.45)),

    noseLen: base.noseLen * (1 + brow * 0.24),
    noseOut: base.noseOut * (1 + brow * 0.34),
    noseW: base.noseW * (1 + brow * 0.16),

    earSize: base.earSize,

    style: s,
  };
}
