import fs from "node:fs";
import path from "node:path";
import {
  APP_ROOT,
  DATA_ROOT,
  createAsset,
  createMovesetMedia,
  listAssets,
  listCharacters,
  listMovesetEntries,
  listMovesetMedia,
} from "./db.js";

export const CONCEPT_SECTION_KEY = "concept_gallery";
export const SPRITE_PACKET_VERSION = 1;
export const SPRITE_UI_TARGETS = [
  ["portrait", "Portrait", "Bust portrait for profile and roster surfaces."],
  ["select_art", "Select Art", "Character-select hero art."],
  ["unlock_art", "Unlock Art", "Unlock screen reveal art."],
  ["stock_icon", "Stock Icon", "Small life-counter icon."],
];
export const SPRITE_MOVE_GROUPS = [
  ["Ground", [
    ["neutral_a", "Neutral A", "attack"],
    ["side_a", "Side A", "attack"],
    ["up_a", "Up A", "attack"],
    ["down_a", "Down A", "attack"],
    ["dash_attack", "Dash Attack", "attack"],
  ]],
  ["Specials", [
    ["neutral_b", "Neutral B", "special"],
    ["side_b", "Side B", "special"],
    ["up_b", "Up B", "special"],
    ["down_b", "Down B", "special"],
  ]],
  ["Aerials", [
    ["neutral_air", "Neutral Air", "air"],
    ["forward_air", "Forward Air", "air"],
    ["back_air", "Back Air", "air"],
    ["up_air", "Up Air", "air"],
    ["down_air", "Down Air", "air"],
  ]],
  ["Throws", [
    ["grab", "Grab", "throw"],
    ["pummel", "Pummel", "throw"],
    ["forward_throw", "Forward Throw", "throw"],
    ["back_throw", "Back Throw", "throw"],
    ["up_throw", "Up Throw", "throw"],
    ["down_throw", "Down Throw", "throw"],
  ]],
];
export const SPRITE_MOVE_TARGETS = SPRITE_MOVE_GROUPS.flatMap(([, sections]) =>
  sections.map(([section_key, label, section_type]) => ({ section_key, label, section_type })),
);
export const SPRITE_MOVE_KEY_SET = new Set(SPRITE_MOVE_TARGETS.map((row) => row.section_key));

const IMAGE_EXTENSIONS = new Set([".png", ".webp", ".jpg", ".jpeg"]);
const REFERENCE_SECTION_KEYS = new Set([CONCEPT_SECTION_KEY, "skins"]);
const REFERENCE_SLOTS = new Set(["reference_sheet", "gallery_support", "portrait", "select_art", "unlock_art", "stock_icon"]);
const REFERENCE_QUALITY_BUCKETS = new Map([
  ["reference", "reference_sheet"],
  ["hero-candidates", "hero_candidate"],
  ["portrait", "portrait"],
  ["supporting", "gallery_support"],
  ["motion", "motion_reference"],
  ["misc", "misc_reference"],
]);
const PROTOTYPE_SLUGS = new Set(["orange-pill", "blue-pill"]);
const REQUIRED_TEXT_FIELDS = [
  ["personality", "Personality"],
  ["body_type", "Body Type"],
  ["outfit", "Outfit"],
  ["proportions", "Proportions"],
  ["signature_props", "Signature Props / Effects"],
  ["palette", "Palette Guidance"],
];

function ensureDir(targetPath) {
  fs.mkdirSync(targetPath, { recursive: true });
}

function loadJson(filePath) {
  if (!fs.existsSync(filePath)) {
    return null;
  }
  try {
    return JSON.parse(fs.readFileSync(filePath, "utf8"));
  } catch {
    return null;
  }
}

function writeJson(filePath, payload) {
  ensureDir(path.dirname(filePath));
  fs.writeFileSync(filePath, JSON.stringify(payload, null, 2) + "\n");
}

function slugify(value) {
  return String(value ?? "")
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "")
    .slice(0, 80);
}

function text(value) {
  return String(value ?? "").trim();
}

function sourceAbsolutePath(relativeUploadPath) {
  const rawPath = String(relativeUploadPath || "");
  if (!rawPath) {
    return "";
  }
  if (path.isAbsolute(rawPath)) {
    return rawPath;
  }
  const candidates = [
    path.join(DATA_ROOT, rawPath),
    path.join(APP_ROOT, rawPath),
  ];
  return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
}

function existsFile(filePath) {
  return Boolean(filePath) && fs.existsSync(filePath) && fs.statSync(filePath).isFile();
}

function fileKindFromPath(filePath) {
  return path.extname(String(filePath || "")).toLowerCase();
}

function isImageFile(filePath, mimeType = "") {
  return String(mimeType || "").startsWith("image/") || IMAGE_EXTENSIONS.has(fileKindFromPath(filePath));
}

function asTagList(value) {
  return String(value || "")
    .split(",")
    .map((entry) => entry.trim())
    .filter(Boolean);
}

function packetRoot(baseRoot = "") {
  const root = text(baseRoot) || path.join(APP_ROOT, "storage", "sprite-pipeline");
  return path.resolve(root);
}

function characterPacketDir(rootDir, characterSlug) {
  return path.join(rootDir, "characters", characterSlug);
}

function packetPath(rootDir, characterSlug) {
  return path.join(characterPacketDir(rootDir, characterSlug), "packet.json");
}

function validationPath(rootDir, characterSlug) {
  return path.join(characterPacketDir(rootDir, characterSlug), "validation-report.json");
}

function importReportPath(rootDir, characterSlug) {
  return path.join(characterPacketDir(rootDir, characterSlug), "import-report.json");
}

function referencePriority(kind) {
  const ranking = {
    reference_sheet: 0,
    portrait: 1,
    hero_candidate: 2,
    concept_gallery: 3,
    skins: 4,
    select_art: 5,
    unlock_art: 6,
    stock_icon: 7,
    gallery_support: 8,
    motion_reference: 9,
    misc_reference: 10,
  };
  return ranking[kind] ?? 99;
}

function referenceKindForAsset(asset) {
  if (REFERENCE_SLOTS.has(asset.slot)) {
    return asset.slot;
  }
  return REFERENCE_QUALITY_BUCKETS.get(text(asset.quality_bucket)) || "";
}

function looksLikeFullBody(reference) {
  const haystack = [
    reference.kind,
    reference.label,
    reference.original_filename,
    reference.source_path,
  ].map((value) => text(value).toLowerCase()).join(" ");

  const keywords = [
    "full body",
    "fullbody",
    "full-body",
    "standing",
    "reference sheet",
    "refsheet",
    "turnaround",
    "pose",
    "hero",
  ];
  return keywords.some((keyword) => haystack.includes(keyword));
}

function allCharacters({ includePrototypes = false } = {}) {
  const rows = listCharacters();
  if (includePrototypes) {
    return rows;
  }
  return rows.filter((row) => !PROTOTYPE_SLUGS.has(String(row.slug || "")));
}

function resolveCharacters({ character, all = false, includePrototypes = false } = {}) {
  const rows = allCharacters({ includePrototypes });
  if (character) {
    const wanted = slugify(character);
    const matches = rows.filter((row) => row.slug === wanted || slugify(row.display_name) === wanted);
    if (!matches.length) {
      throw new Error(`Character not found: ${character}`);
    }
    return matches;
  }
  if (!all) {
    throw new Error("Pass --character <slug> or --all.");
  }
  return rows;
}

function collectReferenceCandidates(characterSlug) {
  const references = [];

  for (const media of listMovesetMedia()) {
    if (media.character_slug !== characterSlug || !REFERENCE_SECTION_KEYS.has(media.section_key)) {
      continue;
    }
    const sourcePath = sourceAbsolutePath(media.relative_upload_path);
    if (!existsFile(sourcePath) || !isImageFile(sourcePath, media.mime_type)) {
      continue;
    }
    references.push({
      id: `media:${media.section_key}:${media.id}`,
      label: text(media.label || media.original_filename || media.section_key) || media.section_key,
      kind: media.section_key,
      source_type: "moveset_media",
      mime_type: media.mime_type,
      original_filename: media.original_filename,
      source_path: sourcePath,
      relative_upload_path: media.relative_upload_path,
      created_at: media.created_at,
      updated_at: media.updated_at,
      is_image: true,
    });
  }

  for (const asset of listAssets()) {
    const referenceKind = referenceKindForAsset(asset);
    if (asset.entity_type !== "character" || asset.entity_slug !== characterSlug || !referenceKind) {
      continue;
    }
    const sourcePath = sourceAbsolutePath(asset.relative_upload_path);
    if (!existsFile(sourcePath) || !isImageFile(sourcePath, asset.mime_type)) {
      continue;
    }
    references.push({
      id: `asset:${referenceKind}:${asset.id}`,
      label: text(asset.label || asset.original_filename || asset.slot || asset.quality_bucket) || referenceKind,
      kind: referenceKind,
      source_type: "asset",
      mime_type: asset.mime_type,
      original_filename: asset.original_filename,
      source_path: sourcePath,
      relative_upload_path: asset.relative_upload_path,
      quality_bucket: text(asset.quality_bucket),
      created_at: asset.created_at,
      updated_at: asset.updated_at,
      is_image: true,
    });
  }

  return references.sort((a, b) => {
    const priorityDiff = referencePriority(a.kind) - referencePriority(b.kind);
    if (priorityDiff !== 0) {
      return priorityDiff;
    }
    return String(a.label).localeCompare(String(b.label));
  });
}

function notesMapForCharacter(characterSlug) {
  return new Map(
    listMovesetEntries()
      .filter((row) => row.character_slug === characterSlug)
      .map((row) => [row.section_key, row]),
  );
}

function selectedReference(packet, fieldName) {
  const wanted = text(packet?.input?.[fieldName]);
  if (!wanted) {
    return null;
  }
  return (packet.references || []).find((reference) => reference.id === wanted || reference.packet_ref_path === wanted || reference.source_path === wanted) || null;
}

function copyReferenceIfNeeded(reference, refsDir, index, shouldCopy) {
  if (!shouldCopy) {
    return {
      ...reference,
      packet_ref_path: "",
    };
  }
  const ext = path.extname(reference.original_filename || reference.source_path || "") || ".png";
  const baseName = `${String(index + 1).padStart(2, "0")}-${slugify(reference.label || reference.kind || "ref") || `ref-${index + 1}`}${ext}`;
  const destination = path.join(refsDir, baseName);
  ensureDir(refsDir);
  fs.copyFileSync(reference.source_path, destination);
  return {
    ...reference,
    packet_ref_path: destination,
  };
}

function defaultUiTarget(key, label, description) {
  const defaults = {
    portrait: "Clean bust portrait, readable face, neutral-to-confident expression.",
    select_art: "Three-quarter hero pose for the character select screen.",
    unlock_art: "Unlock splash pose, stronger attitude, celebratory presentation.",
    stock_icon: "Small readable stock icon portrait with strong silhouette.",
  };
  return {
    key,
    label,
    description,
    brief: defaults[key] || "",
    output_file: `generated/ui/${key}.png`,
  };
}

function defaultMoveTarget(target, notesSeed = "") {
  return {
    label: target.label,
    section_type: target.section_type,
    notes_seed: notesSeed,
    action: "",
    pose: "",
    feel: "",
    output_file: `generated/keyframes/${target.section_key}.png`,
  };
}

function mergePacket(character, existingPacket, references, options = {}) {
  const notesBySection = notesMapForCharacter(character.slug);
  const refsDir = path.join(options.characterDir, "refs");
  const existingRefs = new Map((existingPacket?.references || []).map((reference) => [reference.id, reference]));
  const copiedReferences = references.map((reference, index) => {
    const merged = {
      ...reference,
      ...(existingRefs.get(reference.id) || {}),
    };
    return copyReferenceIfNeeded(merged, refsDir, index, options.copyRefs);
  });

  const existingInput = existingPacket?.input || {};
  const mainCanon = text(existingInput.main_canon_ref_id) || copiedReferences[0]?.id || "";
  const fullBodyRef = text(existingInput.full_body_ref_id)
    || copiedReferences.find((reference) => looksLikeFullBody(reference))?.id
    || "";

  const uiTargets = Object.fromEntries(
    SPRITE_UI_TARGETS.map(([key, label, description]) => {
      const previous = existingPacket?.ui_targets?.[key] || {};
      return [key, {
        ...defaultUiTarget(key, label, description),
        ...previous,
        key,
        label,
        description,
      }];
    }),
  );

  const moveTargets = Object.fromEntries(
    SPRITE_MOVE_TARGETS.map((target) => {
      const previous = existingPacket?.move_targets?.[target.section_key] || {};
      const notesSeed = text(notesBySection.get(target.section_key)?.notes);
      return [target.section_key, {
        ...defaultMoveTarget(target, notesSeed),
        ...previous,
        label: target.label,
        section_type: target.section_type,
        notes_seed: notesSeed,
      }];
    }),
  );

  return {
    version: SPRITE_PACKET_VERSION,
    generated_at: new Date().toISOString(),
    character: {
      slug: character.slug,
      display_name: character.display_name,
      approval_status: character.approval_status || "unapproved",
      release_bucket: character.release_bucket || "unassigned",
      archetype: character.archetype || "",
      franchise: character.franchise || "",
      roster_group: character.roster_group || "",
      tags: asTagList(character.tags),
    },
    input: {
      main_canon_ref_id: mainCanon,
      full_body_ref_id: fullBodyRef,
      facing_direction: text(existingInput.facing_direction || "right") || "right",
      personality: text(existingInput.personality),
      body_type: text(existingInput.body_type),
      outfit: text(existingInput.outfit),
      proportions: text(existingInput.proportions),
      signature_props: text(existingInput.signature_props),
      palette: text(existingInput.palette),
    },
    references: copiedReferences,
    ui_targets: uiTargets,
    move_targets: moveTargets,
    generation_defaults: {
      sprite_style: text(existingPacket?.generation_defaults?.sprite_style || "2d_sprite_assets"),
      first_pass_scope: text(existingPacket?.generation_defaults?.first_pass_scope || "startup_keyframe"),
      background: text(existingPacket?.generation_defaults?.background || "transparent"),
      character_only: existingPacket?.generation_defaults?.character_only !== false,
      include_effects: existingPacket?.generation_defaults?.include_effects === true,
      size: text(existingPacket?.generation_defaults?.size || "1024x1024"),
      quality: text(existingPacket?.generation_defaults?.quality || "high"),
      output_format: text(existingPacket?.generation_defaults?.output_format || "png"),
      use_case: text(existingPacket?.generation_defaults?.use_case || "game character sprite keyframe"),
      negative: text(existingPacket?.generation_defaults?.negative || "no background, no text, no watermark, no extra characters, no hit sparks, no slash trails, no energy bursts"),
    },
  };
}

export function validatePacket(packet) {
  const errors = [];
  const warnings = [];
  const references = Array.isArray(packet?.references) ? packet.references : [];

  if (!packet?.character?.display_name) {
    errors.push("Character display_name is required.");
  }
  if (!references.length) {
    errors.push("At least one image reference is required.");
  }
  if (references.length > 0 && references.length < 3) {
    warnings.push("Fewer than 3 references are attached. Identity drift risk is higher.");
  }

  const mainCanon = selectedReference(packet, "main_canon_ref_id");
  if (!text(packet?.input?.main_canon_ref_id)) {
    errors.push("Main canon reference must be selected.");
  } else if (!mainCanon) {
    errors.push("Selected main canon reference does not resolve to an attached reference.");
  }

  const fullBody = selectedReference(packet, "full_body_ref_id");
  if (text(packet?.input?.full_body_ref_id) && !fullBody) {
    errors.push("Selected full-body reference does not resolve to an attached reference.");
  }
  if (!text(packet?.input?.full_body_ref_id)) {
    warnings.push("No full-body reference selected.");
  }

  for (const [field, label] of REQUIRED_TEXT_FIELDS) {
    if (!text(packet?.input?.[field])) {
      errors.push(`${label} is required.`);
    }
  }

  const missingMoves = [];
  for (const target of SPRITE_MOVE_TARGETS) {
    const move = packet?.move_targets?.[target.section_key];
    if (!move) {
      missingMoves.push(`${target.label}: missing packet entry`);
      continue;
    }
    const missing = [
      !text(move.action) ? "Action" : "",
      !text(move.pose) ? "Pose" : "",
      !text(move.feel) ? "Feel" : "",
    ].filter(Boolean);
    if (missing.length) {
      missingMoves.push(`${target.label}: missing ${missing.join(", ")}`);
    }
  }
  if (missingMoves.length) {
    errors.push(...missingMoves);
  }

  return {
    generated_at: new Date().toISOString(),
    character_slug: packet?.character?.slug || "",
    reference_count: references.length,
    move_target_count: SPRITE_MOVE_TARGETS.length,
    ready_for_generation: errors.length === 0,
    errors,
    warnings,
  };
}

function quoteShell(value) {
  return `'${String(value).replace(/'/g, `'"'"'`)}'`;
}

function movePrompt(packet, target) {
  const move = packet.move_targets[target.section_key];
  const facing = text(packet.input.facing_direction || "right");
  const identityBits = [
    packet.character.display_name,
    packet.character.archetype ? `archetype ${packet.character.archetype}` : "",
    packet.input.personality ? `personality ${packet.input.personality}` : "",
    packet.input.body_type ? `body type ${packet.input.body_type}` : "",
    packet.input.outfit ? `outfit ${packet.input.outfit}` : "",
    packet.input.proportions ? `proportions ${packet.input.proportions}` : "",
    packet.input.signature_props ? `props and effects ${packet.input.signature_props}` : "",
  ].filter(Boolean).join("; ");

  return {
    prompt: `${packet.character.display_name} ${target.label} startup keyframe`,
    use_case: packet.generation_defaults.use_case,
    scene: "transparent background, no environment, no floor plane",
    subject: `${identityBits}. Move action: ${move.action}. Body pose: ${move.pose}. Attitude and emotion: ${move.feel}.`,
    style: "clean 2D fighting game sprite art, cel-shaded, readable silhouette, game-ready character render",
    composition: `full body, centered, facing ${facing}, generous padding, silhouette clarity, startup pose only`,
    lighting: "flat neutral sprite lighting, crisp readable edges",
    palette: packet.input.palette,
    materials: packet.input.outfit,
    constraints: "character only, transparent background, preserve canon identity, preserve outfit and palette, no text, no watermark, no hit sparks, no slash trails, no energy bursts, no extra characters",
    negative: packet.generation_defaults.negative,
    size: packet.generation_defaults.size,
    quality: packet.generation_defaults.quality,
    background: packet.generation_defaults.background,
    output_format: packet.generation_defaults.output_format,
    out: path.basename(move.output_file),
  };
}

function uiPrompt(packet, key, target) {
  const facing = text(packet.input.facing_direction || "right");
  const summary = [
    packet.character.display_name,
    packet.input.personality ? `personality ${packet.input.personality}` : "",
    packet.input.outfit ? `outfit ${packet.input.outfit}` : "",
    packet.input.signature_props ? `signature props ${packet.input.signature_props}` : "",
  ].filter(Boolean).join("; ");

  return {
    prompt: `${packet.character.display_name} ${target.label}`,
    use_case: "game character portrait art",
    scene: "transparent background, no environment",
    subject: `${summary}. ${target.brief}`,
    style: key === "stock_icon"
      ? "clean 2D icon art, simplified readable portrait, game UI icon"
      : "clean 2D fighting game character art, cel-shaded, polished UI render",
    composition: key === "portrait"
      ? `bust portrait, centered, facing ${facing}, strong expression readability`
      : key === "stock_icon"
        ? "tight head-and-shoulders crop, centered, icon readability first"
        : `full or three-quarter figure, centered, facing ${facing}, UI-ready composition`,
    lighting: "clean neutral lighting for game UI art",
    palette: packet.input.palette,
    materials: packet.input.outfit,
    constraints: "transparent background, no logos, no text, no watermark, preserve canon identity",
    negative: "busy background, extra characters, watermark, logo, text",
    size: packet.generation_defaults.size,
    quality: packet.generation_defaults.quality,
    background: packet.generation_defaults.background,
    output_format: packet.generation_defaults.output_format,
    out: path.basename(target.output_file),
  };
}

function shellScriptHeader() {
  return [
    "#!/bin/bash",
    "set -euo pipefail",
    'CODEX_HOME="${CODEX_HOME:-$HOME/.codex}"',
    'IMAGE_GEN="$CODEX_HOME/skills/imagegen/scripts/image_gen.py"',
    'if [[ ! -f "$IMAGE_GEN" ]]; then',
    '  echo "image_gen.py not found at $IMAGE_GEN" >&2',
    '  exit 1',
    'fi',
    'if [[ -z "${OPENAI_API_KEY:-}" ]]; then',
    '  echo "OPENAI_API_KEY is not set." >&2',
    '  exit 1',
    'fi',
    "",
  ];
}

function buildEditCommand(packet, target, outputDir, canonRefPath, { ui = false, uiKey = "" } = {}) {
  const outputFile = ui ? packet.ui_targets[uiKey].output_file : packet.move_targets[target.section_key].output_file;
  const absoluteOut = path.join(outputDir, path.basename(outputFile));
  const promptData = ui ? uiPrompt(packet, uiKey, packet.ui_targets[uiKey]) : movePrompt(packet, target);
  const args = [
    "python3",
    '"$IMAGE_GEN"',
    "edit",
    "--image",
    quoteShell(canonRefPath),
    "--prompt",
    quoteShell(promptData.prompt),
    "--use-case",
    quoteShell(promptData.use_case),
    "--scene",
    quoteShell(promptData.scene),
    "--subject",
    quoteShell(promptData.subject),
    "--style",
    quoteShell(promptData.style),
    "--composition",
    quoteShell(promptData.composition),
    "--lighting",
    quoteShell(promptData.lighting),
    "--palette",
    quoteShell(promptData.palette),
    "--materials",
    quoteShell(promptData.materials),
    "--constraints",
    quoteShell(`${promptData.constraints}; change only the pose/crop/background while keeping character identity, palette, outfit, and proportions consistent with the input image`),
    "--negative",
    quoteShell(promptData.negative),
    "--quality",
    quoteShell(promptData.quality),
    "--background",
    quoteShell(promptData.background),
    "--output-format",
    quoteShell(promptData.output_format),
    "--input-fidelity",
    quoteShell("high"),
    "--out",
    quoteShell(absoluteOut),
  ];
  return args.join(" ");
}

function mimeTypeForFile(filePath) {
  const ext = path.extname(filePath).toLowerCase();
  return {
    ".png": "image/png",
    ".webp": "image/webp",
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
  }[ext] || "application/octet-stream";
}

function uniqueStoredName(characterSlug, key, sourcePath) {
  const ext = path.extname(sourcePath).toLowerCase() || ".png";
  return `${characterSlug}-${key}-${Date.now()}${ext}`;
}

function copyIntoManagedStore(sourcePath, destinationPieces, key) {
  const destinationDir = path.join(DATA_ROOT, ...destinationPieces);
  ensureDir(destinationDir);
  const characterSlug = destinationPieces[destinationPieces.length - 2] || "asset";
  const storedName = uniqueStoredName(characterSlug, key || destinationPieces[destinationPieces.length - 1] || "file", sourcePath);
  const destinationPath = path.join(destinationDir, storedName);
  fs.copyFileSync(sourcePath, destinationPath);
  return {
    absolute_path: destinationPath,
    relative_upload_path: path.relative(DATA_ROOT, destinationPath),
    stored_filename: storedName,
  };
}

export function exportSpritePackets(options = {}) {
  const rootDir = packetRoot(options.rootDir);
  ensureDir(rootDir);
  const targets = resolveCharacters(options);
  const summaries = [];
  const coverage = {
    with_references: 0,
    with_main_canon: 0,
    with_full_body_ref: 0,
    with_move_note_seed: 0,
  };
  const missing = {
    references: [],
    main_canon: [],
    full_body_ref: [],
    move_note_seed: [],
  };

  for (const character of targets) {
    const characterDir = characterPacketDir(rootDir, character.slug);
    ensureDir(characterDir);
    ensureDir(path.join(characterDir, "generated", "keyframes"));
    ensureDir(path.join(characterDir, "generated", "ui"));
    ensureDir(path.join(characterDir, "jobs"));

    const existingPacket = loadJson(packetPath(rootDir, character.slug));
    const references = collectReferenceCandidates(character.slug);
    const packet = mergePacket(character, existingPacket, references, {
      copyRefs: Boolean(options.copyRefs),
      characterDir,
    });
    const report = validatePacket(packet);
    const hasMoveSeed = Object.values(packet.move_targets || {}).some((move) => text(move.notes_seed));
    if ((packet.references || []).length > 0) {
      coverage.with_references += 1;
    } else {
      missing.references.push(character.slug);
    }
    if (text(packet.input.main_canon_ref_id)) {
      coverage.with_main_canon += 1;
    } else {
      missing.main_canon.push(character.slug);
    }
    if (text(packet.input.full_body_ref_id)) {
      coverage.with_full_body_ref += 1;
    } else {
      missing.full_body_ref.push(character.slug);
    }
    if (hasMoveSeed) {
      coverage.with_move_note_seed += 1;
    } else {
      missing.move_note_seed.push(character.slug);
    }
    writeJson(packetPath(rootDir, character.slug), packet);
    writeJson(validationPath(rootDir, character.slug), report);
    summaries.push({ slug: character.slug, ready: report.ready_for_generation, errors: report.errors.length, warnings: report.warnings.length });
  }

  writeJson(path.join(rootDir, "README.json"), {
    generated_at: new Date().toISOString(),
    character_count: summaries.length,
    ready_count: summaries.filter((row) => row.ready).length,
    coverage,
    missing,
    instructions: [
      "Fill packet.input fields and the action/pose/feel fields in packet.move_targets.",
      "Run validate after edits.",
      "Run build-jobs to create generate/edit command files.",
      "Run import after generated PNGs exist in generated/keyframes and generated/ui.",
    ],
    characters: summaries,
  });

  return {
    root_dir: rootDir,
    packets_written: summaries.length,
    ready_count: summaries.filter((row) => row.ready).length,
    characters: summaries,
  };
}

export function validateSpritePackets(options = {}) {
  const rootDir = packetRoot(options.rootDir);
  const targets = resolveCharacters(options);
  const results = [];

  for (const character of targets) {
    const currentPacket = loadJson(packetPath(rootDir, character.slug));
    if (!currentPacket) {
      results.push({ slug: character.slug, ready_for_generation: false, errors: ["packet.json is missing"], warnings: [] });
      continue;
    }
    const report = validatePacket(currentPacket);
    writeJson(validationPath(rootDir, character.slug), report);
    results.push(report);
  }

  return {
    root_dir: rootDir,
    validated: results.length,
    ready_count: results.filter((row) => row.ready_for_generation).length,
    reports: results,
  };
}

export function buildSpriteJobs(options = {}) {
  const rootDir = packetRoot(options.rootDir);
  const targets = resolveCharacters(options);
  const summaries = [];

  for (const character of targets) {
    const currentPacket = loadJson(packetPath(rootDir, character.slug));
    if (!currentPacket) {
      summaries.push({ slug: character.slug, built: false, reason: "packet-missing" });
      continue;
    }
    const report = validatePacket(currentPacket);
    writeJson(validationPath(rootDir, character.slug), report);
    if (!report.ready_for_generation) {
      summaries.push({ slug: character.slug, built: false, reason: "packet-invalid", errors: report.errors.length });
      continue;
    }

    const characterDir = characterPacketDir(rootDir, character.slug);
    const jobsDir = path.join(characterDir, "jobs");
    const keyframesDir = path.join(characterDir, "generated", "keyframes");
    const uiDir = path.join(characterDir, "generated", "ui");
    ensureDir(jobsDir);
    ensureDir(keyframesDir);
    ensureDir(uiDir);

    const keyframeJobs = SPRITE_MOVE_TARGETS.map((target) => movePrompt(currentPacket, target));
    const uiJobs = Object.entries(currentPacket.ui_targets).map(([key, target]) => uiPrompt(currentPacket, key, target));

    fs.writeFileSync(path.join(jobsDir, "keyframes-generate.jsonl"), keyframeJobs.map((job) => JSON.stringify(job)).join("\n") + "\n");
    fs.writeFileSync(path.join(jobsDir, "ui-generate.jsonl"), uiJobs.map((job) => JSON.stringify(job)).join("\n") + "\n");

    const mainCanon = selectedReference(currentPacket, "main_canon_ref_id");
    if (mainCanon) {
      const canonPath = mainCanon.packet_ref_path || mainCanon.source_path;
      const keyframeCommands = [
        ...shellScriptHeader(),
        ...SPRITE_MOVE_TARGETS.map((target) => buildEditCommand(currentPacket, target, keyframesDir, canonPath)),
        "",
      ];
      const uiCommands = [
        ...shellScriptHeader(),
        ...Object.keys(currentPacket.ui_targets).map((key) => buildEditCommand(currentPacket, { section_key: key }, uiDir, canonPath, { ui: true, uiKey: key })),
        "",
      ];
      const keyframeScriptPath = path.join(jobsDir, "keyframes-edit.sh");
      const uiScriptPath = path.join(jobsDir, "ui-edit.sh");
      fs.writeFileSync(keyframeScriptPath, keyframeCommands.join("\n"));
      fs.writeFileSync(uiScriptPath, uiCommands.join("\n"));
      fs.chmodSync(keyframeScriptPath, 0o755);
      fs.chmodSync(uiScriptPath, 0o755);
    }

    fs.writeFileSync(path.join(jobsDir, "README.md"), [
      `# ${currentPacket.character.display_name} sprite jobs`,
      "",
      "## Generate batch",
      `python3 \"$CODEX_HOME/skills/imagegen/scripts/image_gen.py\" generate-batch --input ${quoteShell(path.join(jobsDir, "keyframes-generate.jsonl"))} --out-dir ${quoteShell(keyframesDir)}`,
      `python3 \"$CODEX_HOME/skills/imagegen/scripts/image_gen.py\" generate-batch --input ${quoteShell(path.join(jobsDir, "ui-generate.jsonl"))} --out-dir ${quoteShell(uiDir)}`,
      "",
      "## Edit from canon reference",
      mainCanon ? `bash ${quoteShell(path.join(jobsDir, "keyframes-edit.sh"))}\nbash ${quoteShell(path.join(jobsDir, "ui-edit.sh"))}` : "No main canon reference selected yet.",
      "",
      "Generated PNGs should land in generated/keyframes and generated/ui, then run the import command.",
      "",
    ].join("\n"));

    summaries.push({ slug: character.slug, built: true, keyframe_jobs: keyframeJobs.length, ui_jobs: uiJobs.length, has_edit_scripts: Boolean(mainCanon) });
  }

  return {
    root_dir: rootDir,
    built_count: summaries.filter((row) => row.built).length,
    characters: summaries,
  };
}

export function importSpriteOutputs(options = {}) {
  const rootDir = packetRoot(options.rootDir);
  const targets = resolveCharacters(options);
  const summaries = [];
  const approveUi = options.approveUi !== false;

  for (const character of targets) {
    const currentPacket = loadJson(packetPath(rootDir, character.slug));
    if (!currentPacket) {
      summaries.push({ slug: character.slug, imported: false, reason: "packet-missing" });
      continue;
    }

    const imported = { ui_assets: 0, move_keyframes: 0, skipped: 0 };
    for (const [key, target] of Object.entries(currentPacket.ui_targets || {})) {
      const sourcePath = path.join(characterPacketDir(rootDir, character.slug), target.output_file);
      if (!existsFile(sourcePath)) {
        imported.skipped += 1;
        continue;
      }
      const stored = copyIntoManagedStore(sourcePath, ["storage", "uploads", "sprite-pipeline", character.slug, "ui"], key);
      createAsset({
        entity_type: "character",
        entity_slug: character.slug,
        slot: key,
        label: `Sprite Pipeline ${target.label}`,
        original_filename: path.basename(sourcePath),
        stored_filename: stored.stored_filename,
        relative_upload_path: stored.relative_upload_path,
        mime_type: mimeTypeForFile(sourcePath),
        source_type: "sprite_pipeline",
        is_public: 0,
        is_approved: approveUi ? 1 : 0,
        notes: "Imported from sprite pipeline UI output.",
      });
      imported.ui_assets += 1;
    }

    for (const target of SPRITE_MOVE_TARGETS) {
      const move = currentPacket.move_targets?.[target.section_key];
      if (!move) {
        imported.skipped += 1;
        continue;
      }
      const sourcePath = path.join(characterPacketDir(rootDir, character.slug), move.output_file);
      if (!existsFile(sourcePath)) {
        imported.skipped += 1;
        continue;
      }
      const stored = copyIntoManagedStore(sourcePath, ["storage", "uploads", "sprite-pipeline", character.slug, "keyframes"], target.section_key);
      createMovesetMedia({
        character_slug: character.slug,
        section_key: target.section_key,
        label: `Sprite Pipeline ${target.label}`,
        original_filename: path.basename(sourcePath),
        stored_filename: stored.stored_filename,
        relative_upload_path: stored.relative_upload_path,
        mime_type: mimeTypeForFile(sourcePath),
      });
      imported.move_keyframes += 1;
    }

    const summary = {
      generated_at: new Date().toISOString(),
      character_slug: character.slug,
      imported,
      approve_ui: approveUi,
    };
    writeJson(importReportPath(rootDir, character.slug), summary);
    summaries.push({ slug: character.slug, imported: true, ...imported });
  }

  return {
    root_dir: rootDir,
    characters: summaries,
    imported_characters: summaries.filter((row) => row.imported).length,
  };
}

export function spriteKeyframeDestination(godotRoot, characterSlug, sectionKey, ext) {
  return path.join(godotRoot, "assets", "content", "characters", characterSlug, "sprites", "keyframes", `${sectionKey}${ext}`);
}

export function latestSpriteKeyframeMedia() {
  const map = new Map();
  for (const media of listMovesetMedia()) {
    if (!SPRITE_MOVE_KEY_SET.has(media.section_key)) {
      continue;
    }
    const key = `${media.character_slug}:${media.section_key}`;
    if (!map.has(key)) {
      map.set(key, media);
    }
  }
  return map;
}
