import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { buildDefaultClips } from "../anim/procedural.js";
import { assembleCharacter } from "../creator/assemble.js";
import { PART_CATALOGUE } from "../creator/parts.js";
import type { AssembledCharacter, CharacterRecipe } from "../creator/types.js";
import { exportGlb } from "../export/glb.js";
import type { Rgb } from "../image/Raster.js";
import { GrokBridge, GrokUnavailableError } from "../grok/GrokBridge.js";
import { computeInverseBindMatrices, computeSkinWeights } from "../rig/skin.js";
import { bakeUvGuide } from "./bakeUvGuide.js";
import { unwrapCharacterAtlas } from "./unwrap.js";

export interface TextureCharacterOptions {
  /** Output directory for guide / albedo / glb sidecars. */
  outDir: string;
  /** UV guide resolution. */
  guideSize?: number;
  /** Skip Grok and only write the UV guide (offline / CI). */
  guideOnly?: boolean;
  /**
   * Repaint even if this character's prompt is already cached, replacing the
   * cached image. Costs a fresh generation every time.
   */
  force?: boolean;
  /**
   * Use these image bytes as the albedo instead of generating one.
   *
   * The escape hatch from the Grok bridge: bake the UV guide, paint it in
   * anything — Photoshop, Aseprite, some other model — and hand the result
   * back. Grok is never invoked, so this path needs no CLI, no auth and no
   * money, and it is the supported way to use a different image generator.
   *
   * PNG or JPEG; JPEG is re-encoded so the GLB's mime type stays honest.
   */
  importAlbedo?: Uint8Array;
  /** Optional override for Grok bridge. */
  grok?: GrokBridge;
}

export interface TextureCharacterResult {
  assembled: AssembledCharacter;
  guidePath: string;
  albedoPath: string | null;
  glb: Uint8Array;
  textured: boolean;
  cached: boolean;
  /** The albedo came from {@link TextureCharacterOptions.importAlbedo}. */
  imported: boolean;
  costUsd: number;
}

/**
 * Full path: assemble → cylindrical UV unwrap → bake guide → Grok Imagine
 * edit → re-export GLB with baseColorTexture.
 */
export async function textureCharacter(
  recipe: CharacterRecipe,
  options: TextureCharacterOptions,
): Promise<TextureCharacterResult> {
  const outDir = resolve(options.outDir);
  await mkdir(outDir, { recursive: true });

  const assembled = assembleCharacter(recipe);
  const { mesh, skeleton } = assembled;

  // Unwrap may duplicate verts for the front|back fold — re-skin after.
  unwrapCharacterAtlas(mesh, skeleton);
  computeSkinWeights(mesh, skeleton);
  const inverseBindMatrices = computeInverseBindMatrices(skeleton);
  const clips = buildDefaultClips();

  const guidePng = bakeUvGuide(mesh, { size: options.guideSize ?? 512, wireframe: true });
  const guidePath = join(outDir, `${recipe.id}.uv-guide.png`);
  await writeFile(guidePath, guidePng);

  if (options.guideOnly) {
    // White vertex colours so untextured path still works; no map.
    const glb = exportGlb(mesh, skeleton, inverseBindMatrices, {
      name: recipe.name,
      clips,
      generator: "psx-adventure-engine character creator (uv-guide)",
    });
    return {
      assembled: { ...assembled, mesh, glb },
      guidePath,
      albedoPath: null,
      glb,
      textured: false,
      cached: false,
      imported: false,
      costUsd: 0,
    };
  }

  // Either the caller brings its own albedo, or Grok paints one.
  let rawAlbedo: Buffer;
  let cached = false;
  let costUsd = 0;
  const imported = Boolean(options.importAlbedo?.length);

  if (options.importAlbedo?.length) {
    rawAlbedo = Buffer.from(options.importAlbedo);
  } else {
    const grok = options.grok ?? new GrokBridge({ outputDir: join(outDir, ".grok-texture-cache") });
    const available = await grok.isAvailable();
    if (!available) {
      throw new GrokUnavailableError("grok");
    }

    const prompt = texturePrompt(recipe);
    const result = await grok.generate({
      prompt,
      aspectRatio: "1:1",
      sourceImages: [guidePath],
      ...(options.force ? { force: true } : {}),
    });

    rawAlbedo = await readFile(result.path);
    cached = result.cached;
    costUsd = result.costUsd;
  }

  // Imagine (or the user's paint tool) may hand back JPEG; re-encode to PNG so
  // the GLB mime type stays honest.
  const albedoPng = await ensurePng(rawAlbedo);
  const albedoPath = join(outDir, `${recipe.id}.albedo.png`);
  await writeFile(albedoPath, albedoPng);

  // Texture carries colour — neutralise vertex colours so they don't tint the map.
  for (let i = 0; i < mesh.colors.length; i += 4) {
    mesh.colors[i] = 1;
    mesh.colors[i + 1] = 1;
    mesh.colors[i + 2] = 1;
    mesh.colors[i + 3] = 1;
  }

  const glb = exportGlb(mesh, skeleton, inverseBindMatrices, {
    name: recipe.name,
    clips,
    generator: "psx-adventure-engine character creator (textured)",
    baseColorImage: new Uint8Array(albedoPng),
    baseColorMimeType: "image/png",
  });

  return {
    assembled: { ...assembled, mesh, glb },
    guidePath,
    albedoPath,
    glb,
    textured: true,
    cached,
    imported,
    costUsd,
  };
}

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

/** "a, b and c" — reads better to the model than a bare comma list. */
function sentenceList(items: string[]): string {
  if (items.length <= 1) return items[0] ?? "";
  return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
}

/**
 * Prompt for the albedo pass.
 *
 * Three things it has to get right, in order: that this is an atlas and not a
 * portrait, what the figure is actually wearing, and what the wearer wants it
 * to look like. The middle one is derived from the equipped parts rather than
 * written down — an earlier version named "jacket, cargo pants and boots"
 * literally, which is the survivor's kit, so every other character was being
 * asked for someone else's outfit.
 *
 * `recipe.styleNotes` is the steering wheel and deliberately outranks the
 * palette: asking for a charcoal suit should win over an olive `primary`.
 */
export function texturePrompt(recipe: CharacterRecipe): string {
  const worn = Object.entries(recipe.parts)
    .filter(([, id]) => id && !String(id).endsWith(".none"))
    .map(([slot, id]) => {
      const key = String(id);
      const def = PART_CATALOGUE.get(key);
      // Hair styles are named bare ("Short", "Bun"), which reads as a garment
      // in a list of clothes unless it is spelled out.
      const name = `${(def?.name ?? key).toLowerCase()}${slot === "hair" ? " hair" : ""}`;
      const override = recipe.partColors?.[key];
      return override ? `${name} (${hex(override)})` : name;
    });

  const garments = worn.length ? sentenceList(worn) : "bare skin";
  const raw = recipe.styleNotes?.trim();
  // Terminate it, or the next sentence runs straight on from the direction.
  const notes = raw ? (/[.!?]$/.test(raw) ? raw : `${raw}.`) : undefined;

  return [
    "CRITICAL: This is a 3D model UV texture atlas / unwrap sheet, NOT a character portrait or turnaround.",
    "The white wireframe lines mark mesh islands. Paint albedo colours INTO those islands only.",
    "Do NOT redraw the character as a front-view illustration. Do NOT invent new silhouette shapes.",
    "Keep the dark charcoal background pure black/empty outside the islands.",
    "Preserve island shapes exactly — only recolour the filled regions.",
    "Style: PSX / early PlayStation survival-horror game texture — flat, limited muted palette, soft dirt, no photoreal skin pores, no text.",
    `Character kit: ${recipe.name}, ${recipe.body} body, wearing ${garments}.`,
    `Base palette — skin ${hex(recipe.palette.skin)}, hair ${hex(recipe.palette.hair)}, primary ${hex(recipe.palette.primary)}, secondary ${hex(recipe.palette.secondary)}, accent ${hex(recipe.palette.accent)}, metal ${hex(recipe.palette.metal)}, leather ${hex(recipe.palette.leather)}.`,
    // Placed after the palette so the model reads it as the overriding note.
    ...(notes
      ? [
          `WARDROBE DIRECTION (authoritative — follow this over the base palette where they disagree): ${notes}`,
          "Keep the garment cut implied by the mesh islands; change only the colour, material and detailing to match that direction.",
        ]
      : []),
    "Atlas layout: LEFT half = full-body FRONT (head at top, feet at bottom); RIGHT half = full-body BACK (mirrored, same height).",
    `The face and the ${garments} should read as a clear standing figure on each half.`,
    "Paint face features on the upper centre of the LEFT half. Output a square texture atlas.",
  ].join(" ");
}

/** Re-encode JPEG/WebP outputs from Imagine as PNG for GLB embedding. */
async function ensurePng(bytes: Buffer): Promise<Buffer> {
  const isPng = bytes.length >= 4 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47;
  if (isPng) return bytes;

  const isJpeg = bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8;
  if (isJpeg) {
    const jpeg = await import("jpeg-js");
    const { PNG } = await import("pngjs");
    const decoded = jpeg.decode(bytes, { useTArray: true, maxMemoryUsageInMB: 256 });
    const png = new PNG({ width: decoded.width, height: decoded.height });
    png.data = Buffer.from(decoded.data);
    return PNG.sync.write(png);
  }

  // Unknown — pass through and let mime sniff handle it.
  return bytes;
}
