import { assembleCharacter } from "../../src/creator/assemble";
import { SKIN } from "../../src/creator/body";
import {
  BODY_STYLE_DEFAULTS,
  normalizeBodyStyle,
  type BodyStyle,
} from "../../src/creator/bodyStyle";
import {
  HEAD_STYLE_DEFAULTS,
  normalizeHeadStyle,
  type HeadStyle,
} from "../../src/creator/headStyle";
import { PART_DEFINITIONS } from "../../src/creator/parts";
import { CHARACTER_PRESETS } from "../../src/creator/presets";
import type {
  BodyType,
  CharacterRecipe,
  CreatorPalette,
  PartSlot,
} from "../../src/creator/types";
import type { Rgb } from "../../src/image/Raster";
import { CharacterPreview } from "./preview";

const SLOTS: PartSlot[] = ["hair", "upper", "lower", "feet", "accessory"];
const PALETTE_KEYS: (keyof CreatorPalette)[] = [
  "skin",
  "hair",
  "primary",
  "secondary",
  "accent",
  "metal",
  "leather",
];

const SKIN_OPTIONS: { id: string; name: string; color: Rgb }[] = [
  { id: "fair", name: "Fair", color: SKIN.fair },
  { id: "light", name: "Light", color: SKIN.light },
  { id: "medium", name: "Medium", color: SKIN.medium },
  { id: "tan", name: "Tan", color: SKIN.tan },
  { id: "deep", name: "Deep", color: SKIN.deep },
];

interface CreatorState {
  id: string;
  name: string;
  body: BodyType;
  height: number;
  bodyStyle: BodyStyle;
  headStyle: HeadStyle;
  styleNotes: string;
  palette: CreatorPalette;
  parts: Partial<Record<PartSlot, string>>;
}

function clonePalette(palette: CreatorPalette): CreatorPalette {
  return {
    skin: { ...palette.skin },
    hair: { ...palette.hair },
    primary: { ...palette.primary },
    secondary: { ...palette.secondary },
    accent: { ...palette.accent },
    metal: { ...palette.metal },
    leather: { ...palette.leather },
  };
}

function cloneBodyStyle(style?: BodyStyle | null): BodyStyle {
  return normalizeBodyStyle(style ?? BODY_STYLE_DEFAULTS);
}

function cloneHeadStyle(style?: HeadStyle | null): HeadStyle {
  return normalizeHeadStyle(style ?? HEAD_STYLE_DEFAULTS);
}

function fromPreset(preset: CharacterRecipe): CreatorState {
  return {
    id: preset.id,
    name: preset.name,
    body: preset.body,
    height: preset.height,
    bodyStyle: cloneBodyStyle(preset.bodyStyle),
    headStyle: cloneHeadStyle(preset.headStyle),
    styleNotes: preset.styleNotes ?? "",
    palette: clonePalette(preset.palette),
    parts: { ...preset.parts },
  };
}

function defaultState(): CreatorState {
  const preset = CHARACTER_PRESETS[0]!;
  return fromPreset(preset);
}

function rgbToHex({ r, g, b }: Rgb): string {
  const h = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0");
  return `#${h(r)}${h(g)}${h(b)}`;
}

function hexToRgb(hex: string): Rgb {
  const raw = hex.replace("#", "");
  const full = raw.length === 3 ? raw.split("").map((c) => c + c).join("") : raw;
  const n = Number.parseInt(full, 16);
  return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}

function nearestSkinId(skin: Rgb): string {
  let best = SKIN_OPTIONS[0]!;
  let bestDist = Infinity;
  for (const option of SKIN_OPTIONS) {
    const d =
      (option.color.r - skin.r) ** 2 +
      (option.color.g - skin.g) ** 2 +
      (option.color.b - skin.b) ** 2;
    if (d < bestDist) {
      bestDist = d;
      best = option;
    }
  }
  return best.id;
}

function slugify(value: string): string {
  return value
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "")
    .slice(0, 48) || "character";
}

function partOptions(slot: PartSlot, body: BodyType) {
  return PART_DEFINITIONS.filter((part) => part.slot === slot && (!part.body || part.body === body));
}

function buildRecipe(state: CreatorState): CharacterRecipe {
  const parts: CharacterRecipe["parts"] = {};
  for (const slot of SLOTS) {
    const id = state.parts[slot];
    if (id) parts[slot] = id;
  }
  const bodyStyle = cloneBodyStyle(state.bodyStyle);
  const headStyle = cloneHeadStyle(state.headStyle);
  const styleNotes = state.styleNotes.trim();
  return {
    id: state.id || slugify(state.name),
    name: state.name || "Character",
    body: state.body,
    height: state.height,
    bodyStyle,
    headStyle,
    ...(styleNotes ? { styleNotes } : {}),
    palette: clonePalette(state.palette),
    parts,
  };
}

function formatStyle(n: number): string {
  return (Math.round(n * 100) / 100).toFixed(2);
}

function recipeJson(recipe: CharacterRecipe, stats?: { vertices: number; triangles: number; bones: number; clips: number }): string {
  return JSON.stringify(
    {
      ...recipe,
      ...(stats ? { stats } : {}),
    },
    null,
    2,
  );
}

function downloadBlob(filename: string, blob: Blob): void {
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);
}

// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------

const state = defaultState();
let latestGlb: Uint8Array | null = null;
let latestRecipe: CharacterRecipe | null = null;
let rebuildTimer = 0;
let rebuildToken = 0;

const el = {
  preset: document.querySelector<HTMLSelectElement>("#preset")!,
  name: document.querySelector<HTMLInputElement>("#name")!,
  id: document.querySelector<HTMLInputElement>("#id")!,
  body: document.querySelector<HTMLElement>("#body")!,
  height: document.querySelector<HTMLInputElement>("#height")!,
  heightLabel: document.querySelector<HTMLElement>("#height-label")!,
  mass: document.querySelector<HTMLInputElement>("#mass")!,
  massLabel: document.querySelector<HTMLElement>("#mass-label")!,
  muscle: document.querySelector<HTMLInputElement>("#muscle")!,
  muscleLabel: document.querySelector<HTMLElement>("#muscle-label")!,
  fat: document.querySelector<HTMLInputElement>("#fat")!,
  fatLabel: document.querySelector<HTMLElement>("#fat-label")!,
  bodyStyleReset: document.querySelector<HTMLButtonElement>("#body-style-reset")!,
  faceLength: document.querySelector<HTMLInputElement>("#face-length")!,
  faceLengthLabel: document.querySelector<HTMLElement>("#face-length-label")!,
  faceJaw: document.querySelector<HTMLInputElement>("#face-jaw")!,
  faceJawLabel: document.querySelector<HTMLElement>("#face-jaw-label")!,
  faceBrow: document.querySelector<HTMLInputElement>("#face-brow")!,
  faceBrowLabel: document.querySelector<HTMLElement>("#face-brow-label")!,
  headStyleReset: document.querySelector<HTMLButtonElement>("#head-style-reset")!,
  skinSwatches: document.querySelector<HTMLElement>("#skin-swatches")!,
  parts: document.querySelector<HTMLElement>("#parts")!,
  palette: document.querySelector<HTMLElement>("#palette")!,
  stats: document.querySelector<HTMLElement>("#stats")!,
  recipeJson: document.querySelector<HTMLElement>("#recipe-json")!,
  status: document.querySelector<HTMLElement>("#status")!,
  clip: document.querySelector<HTMLSelectElement>("#clip")!,
  autoRotate: document.querySelector<HTMLInputElement>("#auto-rotate")!,
  downloadGlb: document.querySelector<HTMLButtonElement>("#download-glb")!,
  downloadRecipe: document.querySelector<HTMLButtonElement>("#download-recipe")!,
  copyRecipe: document.querySelector<HTMLButtonElement>("#copy-recipe")!,
  saveAssets: document.querySelector<HTMLButtonElement>("#save-assets")!,
  uvGuide: document.querySelector<HTMLButtonElement>("#uv-guide")!,
  textureGrok: document.querySelector<HTMLButtonElement>("#texture-grok")!,
  textureRegen: document.querySelector<HTMLButtonElement>("#texture-regen")!,
  albedoImport: document.querySelector<HTMLButtonElement>("#albedo-import")!,
  albedoFile: document.querySelector<HTMLInputElement>("#albedo-file")!,
  styleNotes: document.querySelector<HTMLTextAreaElement>("#style-notes")!,
  texStatus: document.querySelector<HTMLElement>("#tex-status")!,
  texPreviews: document.querySelector<HTMLElement>("#tex-previews")!,
  uvGuideImg: document.querySelector<HTMLImageElement>("#uv-guide-img")!,
  uvGuideLink: document.querySelector<HTMLAnchorElement>("#uv-guide-link")!,
  albedoCard: document.querySelector<HTMLElement>("#albedo-card")!,
  albedoImg: document.querySelector<HTMLImageElement>("#albedo-img")!,
  albedoLink: document.querySelector<HTMLAnchorElement>("#albedo-link")!,
  viewport: document.querySelector<HTMLCanvasElement>("#viewport")!,
};

const preview = new CharacterPreview(el.viewport);
preview.setAutoRotate(el.autoRotate.checked);

function setStatus(message: string, kind: "ok" | "err" | "" = ""): void {
  el.status.textContent = message;
  el.status.classList.remove("is-ok", "is-err");
  if (kind === "ok") el.status.classList.add("is-ok");
  if (kind === "err") el.status.classList.add("is-err");
}

function setTexStatus(message: string, kind: "ok" | "err" | "" = ""): void {
  el.texStatus.textContent = message;
  el.texStatus.classList.remove("is-ok", "is-err");
  if (kind === "ok") el.texStatus.classList.add("is-ok");
  if (kind === "err") el.texStatus.classList.add("is-err");
}

function base64ToBytes(b64: string): Uint8Array {
  const binary = atob(b64);
  const out = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
  return out;
}

function setTextureBusy(busy: boolean): void {
  el.uvGuide.disabled = busy;
  el.textureGrok.disabled = busy;
  el.textureRegen.disabled = busy;
  el.albedoImport.disabled = busy;
}

function bytesToBase64(bytes: Uint8Array): string {
  let binary = "";
  const chunk = 0x8000;
  for (let i = 0; i < bytes.length; i += chunk) {
    binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
  }
  return btoa(binary);
}

interface TextureRun {
  mode: "guide" | "texture";
  force?: boolean;
  /** Apply this image as the albedo instead of calling Grok. */
  albedo?: File;
}

async function runTexture({ mode, force = false, albedo }: TextureRun): Promise<void> {
  const recipe = buildRecipe(state);
  setTextureBusy(true);
  setTexStatus(
    albedo
      ? `Applying ${albedo.name}…`
      : mode === "guide"
        ? "Baking UV guide…"
        : force
          ? "Repainting with Grok Imagine, ignoring the cache… (can take ~1 min)"
          : "Texturing with Grok Imagine… (can take ~1 min)",
  );

  try {
    const albedoBase64 = albedo
      ? bytesToBase64(new Uint8Array(await albedo.arrayBuffer()))
      : undefined;
    const response = await fetch("/api/texture", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ recipe, mode, force, albedoBase64 }),
    });
    const body = (await response.json()) as {
      ok?: boolean;
      error?: string;
      glbBase64?: string;
      guideUrl?: string;
      albedoUrl?: string | null;
      textured?: boolean;
      imported?: boolean;
      cached?: boolean;
      costUsd?: number;
      bytes?: number;
    };
    if (!response.ok || !body.ok || !body.glbBase64) {
      throw new Error(body.error ?? `HTTP ${response.status}`);
    }

    latestGlb = base64ToBytes(body.glbBase64);
    latestRecipe = recipe;

    if (body.guideUrl) {
      el.texPreviews.hidden = false;
      el.uvGuideImg.src = body.guideUrl;
      el.uvGuideLink.href = body.guideUrl;
    }
    if (body.albedoUrl) {
      el.albedoCard.hidden = false;
      el.albedoImg.src = body.albedoUrl;
      el.albedoLink.href = body.albedoUrl;
    } else {
      el.albedoCard.hidden = true;
    }

    await preview.setGlb(latestGlb, el.clip.value);

    const bits = [
      mode === "guide"
        ? "UV guide ready"
        : !body.textured
          ? "Done"
          : body.imported
            ? "Albedo imported"
            : force
              ? "Repainted — cache replaced"
              : "Textured GLB ready",
      body.bytes ? `${(body.bytes / 1024).toFixed(0)} KB` : "",
      body.cached ? "cached" : "",
      body.costUsd && body.costUsd > 0 ? `$${body.costUsd.toFixed(3)}` : "",
    ].filter(Boolean);
    setTexStatus(bits.join(" · "), "ok");
    el.stats.textContent = [
      body.textured
        ? body.imported
          ? "Textured (imported albedo)"
          : "Textured (Grok albedo)"
        : "UV guide baked (vertex colour GLB)",
      body.bytes ? `${(body.bytes / 1024).toFixed(1)} KB` : "",
      recipe.body + " @ " + recipe.height.toFixed(2) + " m",
    ]
      .filter(Boolean)
      .join("\n");
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    setTexStatus(message, "err");
  } finally {
    setTextureBusy(false);
  }
}

function syncBodyStyleControls(): void {
  el.mass.value = String(state.bodyStyle.mass);
  el.muscle.value = String(state.bodyStyle.muscle);
  el.fat.value = String(state.bodyStyle.fat);
  el.massLabel.textContent = formatStyle(state.bodyStyle.mass);
  el.muscleLabel.textContent = formatStyle(state.bodyStyle.muscle);
  el.fatLabel.textContent = formatStyle(state.bodyStyle.fat);
}

function syncHeadStyleControls(): void {
  el.faceLength.value = String(state.headStyle.length);
  el.faceJaw.value = String(state.headStyle.jaw);
  el.faceBrow.value = String(state.headStyle.brow);
  el.faceLengthLabel.textContent = formatStyle(state.headStyle.length);
  el.faceJawLabel.textContent = formatStyle(state.headStyle.jaw);
  el.faceBrowLabel.textContent = formatStyle(state.headStyle.brow);
}

function syncFormFromState(): void {
  el.name.value = state.name;
  el.id.value = state.id;
  el.height.value = String(state.height);
  el.heightLabel.textContent = `${state.height.toFixed(2)} m`;
  syncBodyStyleControls();
  syncHeadStyleControls();
  el.styleNotes.value = state.styleNotes;

  for (const btn of el.body.querySelectorAll<HTMLButtonElement>(".seg__btn")) {
    btn.classList.toggle("is-active", btn.dataset.body === state.body);
  }

  const skinId = nearestSkinId(state.palette.skin);
  for (const swatch of el.skinSwatches.querySelectorAll<HTMLButtonElement>(".swatch")) {
    swatch.classList.toggle("is-active", swatch.dataset.skin === skinId);
  }

  for (const key of PALETTE_KEYS) {
    const input = el.palette.querySelector<HTMLInputElement>(`input[data-key="${key}"]`);
    if (input) input.value = rgbToHex(state.palette[key]);
  }

  renderParts();
}

function renderPresets(): void {
  el.preset.innerHTML = "";
  const blank = document.createElement("option");
  blank.value = "";
  blank.textContent = "— custom —";
  el.preset.append(blank);

  for (const preset of CHARACTER_PRESETS) {
    const option = document.createElement("option");
    option.value = preset.id;
    option.textContent = `${preset.name} (${preset.id})`;
    el.preset.append(option);
  }
  el.preset.value = state.id;
}

function renderSkinSwatches(): void {
  el.skinSwatches.innerHTML = "";
  for (const option of SKIN_OPTIONS) {
    const btn = document.createElement("button");
    btn.type = "button";
    btn.className = "swatch";
    btn.title = option.name;
    btn.dataset.skin = option.id;
    btn.style.background = rgbToHex(option.color);
    btn.addEventListener("click", () => {
      state.palette.skin = { ...option.color };
      const skinInput = el.palette.querySelector<HTMLInputElement>(`input[data-key="skin"]`);
      if (skinInput) skinInput.value = rgbToHex(option.color);
      for (const s of el.skinSwatches.querySelectorAll(".swatch")) {
        s.classList.toggle("is-active", s === btn);
      }
      scheduleRebuild();
    });
    el.skinSwatches.append(btn);
  }
}

function renderPalette(): void {
  el.palette.innerHTML = "";
  for (const key of PALETTE_KEYS) {
    const label = document.createElement("label");
    label.className = "color-field";
    const span = document.createElement("span");
    span.textContent = key;
    const input = document.createElement("input");
    input.type = "color";
    input.dataset.key = key;
    input.value = rgbToHex(state.palette[key]);
    input.addEventListener("input", () => {
      state.palette[key] = hexToRgb(input.value);
      if (key === "skin") {
        const skinId = nearestSkinId(state.palette.skin);
        for (const s of el.skinSwatches.querySelectorAll<HTMLButtonElement>(".swatch")) {
          s.classList.toggle("is-active", s.dataset.skin === skinId);
        }
      }
      scheduleRebuild();
    });
    label.append(span, input);
    el.palette.append(label);
  }
}

function renderParts(): void {
  el.parts.innerHTML = "";
  for (const slot of SLOTS) {
    const wrap = document.createElement("div");
    wrap.className = "part-slot";

    const label = document.createElement("span");
    label.className = "part-slot__label";
    label.textContent = slot;
    wrap.append(label);

    const row = document.createElement("div");
    row.className = "chip-row";

    const options = partOptions(slot, state.body);
    const current = state.parts[slot] ?? `${slot}.none`;

    // If current part is invalid for body (e.g. skirt on male), clear it.
    if (!options.some((p) => p.id === current)) {
      state.parts[slot] = options[0]?.id ?? `${slot}.none`;
    }

    for (const part of options) {
      const chip = document.createElement("button");
      chip.type = "button";
      chip.className = "chip";
      chip.textContent = part.name;
      chip.dataset.part = part.id;
      if (part.id === (state.parts[slot] ?? `${slot}.none`)) chip.classList.add("is-active");
      chip.addEventListener("click", () => {
        state.parts[slot] = part.id;
        for (const c of row.querySelectorAll(".chip")) c.classList.remove("is-active");
        chip.classList.add("is-active");
        scheduleRebuild();
      });
      row.append(chip);
    }

    wrap.append(row);
    el.parts.append(wrap);
  }
}

function scheduleRebuild(): void {
  window.clearTimeout(rebuildTimer);
  rebuildTimer = window.setTimeout(() => {
    void rebuild();
  }, 80);
}

async function rebuild(): Promise<void> {
  const token = ++rebuildToken;
  const recipe = buildRecipe(state);
  el.recipeJson.textContent = recipeJson(recipe);
  el.stats.textContent = "Assembling…";

  try {
    const t0 = performance.now();
    const result = assembleCharacter(recipe);
    if (token !== rebuildToken) return;

    latestGlb = result.glb;
    latestRecipe = result.recipe;
    const ms = performance.now() - t0;

    el.recipeJson.textContent = recipeJson(result.recipe, result.stats);
    const bs = result.recipe.bodyStyle ?? BODY_STYLE_DEFAULTS;
    const hs = result.recipe.headStyle ?? HEAD_STYLE_DEFAULTS;
    el.stats.textContent = [
      `${result.stats.vertices} verts · ${result.stats.triangles} tris · ${result.stats.bones} bones`,
      `${(result.glb.byteLength / 1024).toFixed(1)} KB · ${ms.toFixed(0)} ms`,
      `${result.recipe.body} @ ${result.recipe.height.toFixed(2)} m`,
      `mass ${formatStyle(bs.mass)} · muscle ${formatStyle(bs.muscle)} · fat ${formatStyle(bs.fat)}`,
      `face ${formatStyle(hs.length)} · jaw ${formatStyle(hs.jaw)} · brow ${formatStyle(hs.brow)}`,
    ].join("\n");

    await preview.setGlb(result.glb, el.clip.value);
    if (token !== rebuildToken) return;
    setStatus("");
  } catch (error) {
    if (token !== rebuildToken) return;
    const message = error instanceof Error ? error.message : String(error);
    el.stats.textContent = "Assemble failed";
    setStatus(message, "err");
    console.error(error);
  }
}

// Events -------------------------------------------------------------------

el.preset.addEventListener("change", () => {
  const preset = CHARACTER_PRESETS.find((p) => p.id === el.preset.value);
  if (!preset) return;
  Object.assign(state, fromPreset(preset));
  syncFormFromState();
  scheduleRebuild();
});

el.name.addEventListener("input", () => {
  state.name = el.name.value;
  // Keep id in sync while it still looks auto-derived.
  if (!el.id.dataset.manual) {
    state.id = slugify(state.name);
    el.id.value = state.id;
  }
  el.preset.value = CHARACTER_PRESETS.some((p) => p.id === state.id) ? state.id : "";
  scheduleRebuild();
});

el.id.addEventListener("input", () => {
  el.id.dataset.manual = "1";
  state.id = slugify(el.id.value) || el.id.value;
  el.preset.value = CHARACTER_PRESETS.some((p) => p.id === state.id) ? state.id : "";
  scheduleRebuild();
});

el.body.addEventListener("click", (event) => {
  const btn = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-body]");
  if (!btn?.dataset.body) return;
  state.body = btn.dataset.body as BodyType;
  for (const b of el.body.querySelectorAll(".seg__btn")) b.classList.remove("is-active");
  btn.classList.add("is-active");
  renderParts();
  scheduleRebuild();
});

el.height.addEventListener("input", () => {
  state.height = Number(el.height.value);
  el.heightLabel.textContent = `${state.height.toFixed(2)} m`;
  scheduleRebuild();
});

function bindStyleSlider(
  input: HTMLInputElement,
  label: HTMLElement,
  key: keyof BodyStyle,
): void {
  input.addEventListener("input", () => {
    state.bodyStyle[key] = Number(input.value);
    label.textContent = formatStyle(state.bodyStyle[key]);
    scheduleRebuild();
  });
}

bindStyleSlider(el.mass, el.massLabel, "mass");
bindStyleSlider(el.muscle, el.muscleLabel, "muscle");
bindStyleSlider(el.fat, el.fatLabel, "fat");

function bindFaceSlider(
  input: HTMLInputElement,
  label: HTMLElement,
  key: keyof HeadStyle,
): void {
  input.addEventListener("input", () => {
    state.headStyle[key] = Number(input.value);
    label.textContent = formatStyle(state.headStyle[key]);
    scheduleRebuild();
  });
}

bindFaceSlider(el.faceLength, el.faceLengthLabel, "length");
bindFaceSlider(el.faceJaw, el.faceJawLabel, "jaw");
bindFaceSlider(el.faceBrow, el.faceBrowLabel, "brow");

el.bodyStyleReset.addEventListener("click", () => {
  state.bodyStyle = cloneBodyStyle(BODY_STYLE_DEFAULTS);
  syncBodyStyleControls();
  scheduleRebuild();
});

el.headStyleReset.addEventListener("click", () => {
  state.headStyle = cloneHeadStyle(HEAD_STYLE_DEFAULTS);
  syncHeadStyleControls();
  scheduleRebuild();
});

el.clip.addEventListener("change", () => {
  preview.play(el.clip.value);
});

el.autoRotate.addEventListener("change", () => {
  preview.setAutoRotate(el.autoRotate.checked);
});

el.downloadGlb.addEventListener("click", () => {
  if (!latestGlb || !latestRecipe) {
    setStatus("Nothing to download yet", "err");
    return;
  }
  const bytes = new Uint8Array(latestGlb);
  downloadBlob(
    `${latestRecipe.id}.glb`,
    new Blob([bytes], { type: "model/gltf-binary" }),
  );
  setStatus(`Downloaded ${latestRecipe.id}.glb`, "ok");
});

el.downloadRecipe.addEventListener("click", () => {
  if (!latestRecipe) {
    setStatus("Nothing to download yet", "err");
    return;
  }
  const text = recipeJson(latestRecipe);
  downloadBlob(`${latestRecipe.id}.recipe.json`, new Blob([text], { type: "application/json" }));
  setStatus(`Downloaded ${latestRecipe.id}.recipe.json`, "ok");
});

el.copyRecipe.addEventListener("click", async () => {
  if (!latestRecipe) {
    setStatus("Nothing to copy yet", "err");
    return;
  }
  try {
    await navigator.clipboard.writeText(recipeJson(latestRecipe));
    setStatus("Recipe JSON copied", "ok");
  } catch {
    setStatus("Clipboard blocked — select the JSON panel instead", "err");
  }
});

el.saveAssets.addEventListener("click", async () => {
  if (!latestGlb || !latestRecipe) {
    setStatus("Nothing to save yet", "err");
    return;
  }

  setStatus("Saving to assets/characters/…");
  try {
    const response = await fetch("/api/save", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        id: latestRecipe.id,
        recipe: {
          ...latestRecipe,
        },
        glbBase64: bytesToBase64(latestGlb),
      }),
    });
    const body = (await response.json()) as { ok?: boolean; error?: string; bytes?: number; glb?: string };
    if (!response.ok || !body.ok) {
      throw new Error(body.error ?? `HTTP ${response.status}`);
    }
    setStatus(`Saved ${latestRecipe.id}.glb (${((body.bytes ?? 0) / 1024).toFixed(1)} KB)`, "ok");
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    setStatus(`Save failed: ${message}`, "err");
  }
});

el.styleNotes.addEventListener("input", () => {
  state.styleNotes = el.styleNotes.value;
  // Doesn't touch geometry, but keeps the recipe JSON panel truthful.
  scheduleRebuild();
});

el.uvGuide.addEventListener("click", () => {
  void runTexture({ mode: "guide" });
});

el.textureGrok.addEventListener("click", () => {
  void runTexture({ mode: "texture" });
});

el.textureRegen.addEventListener("click", () => {
  void runTexture({ mode: "texture", force: true });
});

el.albedoImport.addEventListener("click", () => {
  el.albedoFile.click();
});

el.albedoFile.addEventListener("change", () => {
  const file = el.albedoFile.files?.[0];
  // Reset first, so picking the same file twice still fires a change event.
  el.albedoFile.value = "";
  if (file) void runTexture({ mode: "texture", albedo: file });
});

// Boot ---------------------------------------------------------------------

renderPresets();
renderSkinSwatches();
renderPalette();
syncFormFromState();
void rebuild();
