#!/usr/bin/env node
// =============================================================
// gen-assets.mjs — multi-source game-art pipeline.
//
// Generates cohesive game art and chroma-keys sprites to
// transparent PNGs. It tries SOURCES IN ORDER and automatically
// falls back to the next one on any failure, so it NEVER depends
// on a single provider (or any paid credits):
//
//   1. fal.ai  (nano-banana-pro)  — primary, best quality.
//                                    Needs FAL_KEY (repo .env.local).
//   2. Pollinations.ai            — free, keyless custom AI art.
//   3. Kenney CC0 packs           — free, game-ready sprites.
//
// Usage:  node gen-assets.mjs <assetsDir> <config.json>
//   config.json = [
//     { "name": "bg",  "prompt": "...", "aspect_ratio": "9:16",
//       "category": "background" },
//     { "name": "hero","prompt": "... isolated on solid #FF00FF ...",
//       "aspect_ratio": "1:1", "category": "character",
//       "fallbackPrompt": "short prompt for free providers" },
//     ...
//   ]
//
// Per-asset fields:
//   name            output key (writes <name>.png)
//   prompt          full prompt (fal / Pollinations)
//   fallbackPrompt  optional shorter prompt for free providers
//   aspect_ratio    "1:1" | "9:16" | "16:9" ...
//   category        "character"|"enemy"|"item"|"hazard"|
//                   "background"|"tile"  (guides Pollinations/Kenney)
//
// Sprites: any prompt mentioning "isolated on solid #FF00FF
// background" (or "magenta background") is generated on magenta
// and chroma-keyed to transparent + trimmed. Kenney sprites already
// ship transparent, so they skip chroma-key.
//
// Writes assets/manifest.json with the SOURCE per asset.
// =============================================================
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

// Resolve sharp PORTABLY from the member's project (cwd) / this tool's folder.
// sharp is OPTIONAL — it's only used to chroma-key + trim sprites; without it
// we still generate art, just without auto-transparency.
function portableRequire(pkg) {
  const bases = [process.cwd(), path.join(process.cwd(), 'node_modules'), __dirname, import.meta.url];
  for (const b of bases) {
    try {
      const req = createRequire(typeof b === 'string' ? pathToFileURL(path.join(b, 'noop.js')) : b);
      return req(pkg);
    } catch (e) { /* try next */ }
  }
  return null;
}
let sharp = portableRequire('sharp');
if (!sharp) {
  console.error('Image library "sharp" not found. Install the kit tools once:\n  (in this skill\'s tools/ folder)  npm install');
  process.exit(1);
}

const FAL_ENDPOINT = 'https://fal.run/fal-ai/nano-banana-pro';

// FAL key: env first, then a .env.local in the member's project (cwd).
const ENV_PATH = path.join(process.cwd(), '.env.local');

// ---------------------------------------------------------------
// helpers
// ---------------------------------------------------------------
function readFalKey() {
  if (process.env.FAL_KEY) return process.env.FAL_KEY;
  try {
    const txt = fs.readFileSync(ENV_PATH, 'utf8');
    for (const line of txt.split('\n')) {
      const m = line.match(/^\s*FAL_KEY\s*=\s*(.+)\s*$/);
      if (m) return m[1].replace(/^["']|["']$/g, '').trim();
    }
  } catch (e) { /* no env file */ }
  return null;
}

function aspectToWH(aspect, base = 1024) {
  const m = String(aspect || '1:1').match(/^(\d+)\s*:\s*(\d+)$/);
  if (!m) return { width: base, height: base };
  const aw = +m[1], ah = +m[2];
  if (aw >= ah) return { width: base, height: Math.round((base * ah) / aw) };
  return { width: Math.round((base * aw) / ah), height: base };
}

function isSpritePrompt(prompt) {
  return /isolated on solid #?FF00FF/i.test(prompt) || /magenta background/i.test(prompt);
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// A buffer is a real raster image if sharp can read it and it has
// real dimensions + non-trivial size (guards against HTML/JSON errors).
async function isRealImage(buf) {
  // Floor guards against HTML/JSON error blobs; real (even small,
  // optimized) sprites clear ~400 bytes. The dimension check is the
  // authoritative validity test.
  if (!buf || buf.length < 400) return false;
  // PNG / JPEG magic bytes
  const png = buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47;
  const jpg = buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff;
  if (!png && !jpg) return false;
  try {
    const meta = await sharp(buf).metadata();
    return meta.width > 16 && meta.height > 16;
  } catch (e) {
    return false;
  }
}

async function downloadBuffer(url, timeoutMs = 60000) {
  if (url.startsWith('data:')) {
    const b64 = url.split(',')[1];
    return Buffer.from(b64, 'base64');
  }
  const r = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
  if (!r.ok) throw new Error('download failed HTTP ' + r.status);
  return Buffer.from(await r.arrayBuffer());
}

// Chroma-key magenta (#FF00FF-ish) -> transparent, then trim.
// AI models rarely paint pure #FF00FF — they drift toward hot pink
// (~230,75,220). We detect "high red + high blue + low green" with a
// generous green ceiling, fully clear the matches, AND neutralize the
// RGB of any cleared/edge pixel so no pink halo shows on a light bg.
async function chromaKeyMagenta(buf) {
  const img = sharp(buf).ensureAlpha();
  const { data, info } = await img.raw().toBuffer({ resolveWithObject: true });
  const { width, height, channels } = info;
  const out = Buffer.from(data);
  for (let i = 0; i < out.length; i += channels) {
    const r = out[i], g = out[i + 1], b = out[i + 2];
    const magenta = r > 150 && b > 130 && g < 125 && (r - g) > 60 && (b - g) > 50;
    if (magenta) {
      out[i + 3] = 0;                 // fully transparent
      out[i] = out[i + 1] = out[i + 2] = 0; // kill RGB so no halo bleeds
    } else if (g < 150 && r > 130 && b > 110 && (r - g) > 40 && (b - g) > 30) {
      // pink fringe ring: soften alpha and desaturate the magenta cast
      out[i + 3] = Math.min(out[i + 3], 110);
      out[i] = Math.round((r + g) / 2);
      out[i + 2] = Math.round((b + g) / 2);
    }
  }
  return sharp(out, { raw: { width, height, channels } })
    .png()
    .trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: 10 })
    .toBuffer();
}

// Down-res + compress so web games stay light. 2K source art is far
// larger than any canvas needs. Backgrounds cap by height, sprites by
// their longest edge; both keep aspect ratio and never upscale.
async function optimize(buf, isSprite) {
  const meta = await sharp(buf).metadata();
  const cap = isSprite ? 512 : 1280; // sprite longest-edge / bg height
  let pipe = sharp(buf);
  if (isSprite) {
    const longest = Math.max(meta.width || 0, meta.height || 0);
    if (longest > cap) pipe = pipe.resize({ width: meta.width >= meta.height ? cap : null, height: meta.height > meta.width ? cap : null, fit: 'inside', withoutEnlargement: true });
    return pipe.png({ compressionLevel: 9, palette: false }).toBuffer();
  }
  if ((meta.height || 0) > cap) pipe = pipe.resize({ height: cap, withoutEnlargement: true });
  return pipe.png({ compressionLevel: 9, quality: 90 }).toBuffer();
}

// ---------------------------------------------------------------
// SOURCE 1 — fal.ai (primary)
// ---------------------------------------------------------------
async function falGenerate(prompt, falKey, aspectRatio) {
  if (!falKey) throw new Error('no FAL_KEY');
  const res = await fetch(FAL_ENDPOINT, {
    method: 'POST',
    headers: { Authorization: 'Key ' + falKey, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      prompt,
      aspect_ratio: aspectRatio || '1:1',
      resolution: '2K',
      num_images: 1,
    }),
    signal: AbortSignal.timeout(120000),
  });
  const text = await res.text();
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
  let json;
  try { json = JSON.parse(text); } catch (e) { throw new Error('non-JSON response'); }
  const url = json?.images?.[0]?.url || json?.image?.url;
  if (!url) throw new Error('no image URL in response');
  return downloadBuffer(url, 60000);
}

// ---------------------------------------------------------------
// SOURCE 2 — Pollinations.ai (free, keyless)
// Returns the image directly. The anon tier allows only ~1 queued
// request per IP, so we serialize calls and retry with backoff on
// the 402 "Queue full" / transient responses.
// ---------------------------------------------------------------
async function pollinationsGenerate(prompt, aspectRatio, seed) {
  const { width, height } = aspectToWH(aspectRatio, 1024);
  const sd = seed != null ? seed : Math.floor(Math.random() * 1e6);
  const base = 'https://image.pollinations.ai/prompt/' + encodeURIComponent(prompt);
  const url = `${base}?width=${width}&height=${height}&nologo=true&seed=${sd}`;

  let lastErr = 'unknown';
  for (let attempt = 1; attempt <= 5; attempt++) {
    try {
      const r = await fetch(url, { signal: AbortSignal.timeout(150000) });
      const ct = r.headers.get('content-type') || '';
      if (r.ok && ct.startsWith('image')) {
        const buf = Buffer.from(await r.arrayBuffer());
        if (await isRealImage(buf)) return buf;
        lastErr = 'returned non-image bytes';
      } else if (r.status === 402 || r.status === 429 || r.status >= 500) {
        // transient: rate-limit / queue full / server hiccup -> back off
        lastErr = `HTTP ${r.status} (transient)`;
      } else {
        lastErr = `HTTP ${r.status}`;
        // hard error (e.g. 400 bad prompt) -> don't keep hammering
        if (r.status < 500 && r.status !== 402 && r.status !== 429) break;
      }
    } catch (e) {
      lastErr = e.message;
    }
    if (attempt < 5) await sleep(8000 * attempt); // 8s,16s,24s,32s backoff
  }
  throw new Error(lastErr);
}

// ---------------------------------------------------------------
// SOURCE 3 — Kenney CC0 packs (final fallback)
// Maps an asset category to a Kenney pack + a list of candidate
// sprite filenames (matched loosely). Packs are downloaded once and
// cached in the OS temp dir, then the chosen PNG is extracted.
// Kenney sprites already ship on transparent backgrounds.
// ---------------------------------------------------------------
// slug -> direct .zip url (kenney.nl hosts versioned, hashed zips).
// Verified reachable (HTTP 200) at build time.
const KENNEY_PACKS = {
  'animal-pack': 'https://kenney.nl/media/pages/assets/animal-pack/480cf9f223-1677669996/kenney_animal-pack.zip',
  'fish-pack': 'https://kenney.nl/media/pages/assets/fish-pack/07ae98c5b6-1747237960/kenney_fish-pack_2.zip',
  'shape-characters': 'https://kenney.nl/media/pages/assets/shape-characters/c016420b08-1698339465/kenney_shape-characters.zip',
};

// category -> ordered list of {pack, match} candidates. Matchers target
// real filenames inside the packs (animal-pack uses PNG/Round/<animal>.png,
// fish-pack uses PNG/Default/fish_<color>.png, shape-characters uses
// PNG/Default/<color>_body_circle.png). First match wins; loose fallbacks last.
const KENNEY_MAP = {
  character: [
    { pack: 'animal-pack', match: /Round\/panda\.png$/i },
    { pack: 'animal-pack', match: /Round\/(rabbit|pig|monkey)\.png$/i },
    { pack: 'animal-pack', match: /Round\/[a-z]+\.png$/i },
    { pack: 'shape-characters', match: /Default\/[a-z]+_body_circle\.png$/i },
  ],
  enemy: [
    { pack: 'animal-pack', match: /Round\/(snake|hippo|giraffe)\.png$/i },
    { pack: 'animal-pack', match: /Round\/[a-z]+\.png$/i },
  ],
  item: [
    { pack: 'fish-pack', match: /Default\/fish_orange\.png$/i },
    { pack: 'fish-pack', match: /Default\/fish_[a-z]+\.png$/i },
    { pack: 'animal-pack', match: /Round\/(parrot|penguin)\.png$/i },
  ],
  hazard: [
    { pack: 'animal-pack', match: /Round\/snake\.png$/i },
    { pack: 'fish-pack', match: /Default\/fish_red\.png$/i },
    { pack: 'shape-characters', match: /Default\/red_body_circle\.png$/i },
  ],
  background: [], // Kenney has no single-image bg; falls through to solid fill.
  tile: [
    { pack: 'shape-characters', match: /Default\/[a-z]+_body_square\.png$/i },
  ],
};

const KENNEY_CACHE = path.join(os.tmpdir(), 'ccc-kenney-cache');

async function ensureKenneyPack(slug) {
  fs.mkdirSync(KENNEY_CACHE, { recursive: true });
  const zipPath = path.join(KENNEY_CACHE, slug + '.zip');
  if (!fs.existsSync(zipPath) || fs.statSync(zipPath).size < 10000) {
    const url = KENNEY_PACKS[slug];
    if (!url) throw new Error('unknown Kenney pack: ' + slug);
    const buf = await downloadBuffer(url, 120000);
    fs.writeFileSync(zipPath, buf);
  }
  return zipPath;
}

function unzipList(zipPath) {
  const out = execFileSync('unzip', ['-Z1', zipPath], { encoding: 'utf8', maxBuffer: 1 << 24 });
  return out.split('\n').map((s) => s.trim()).filter(Boolean);
}

function unzipExtract(zipPath, entry) {
  // -p writes the single entry to stdout
  return execFileSync('unzip', ['-p', zipPath, entry], { maxBuffer: 1 << 26 });
}

async function kenneyGenerate(category) {
  const candidates = KENNEY_MAP[category] || KENNEY_MAP.item;
  for (const { pack, match } of candidates) {
    let zipPath;
    try { zipPath = await ensureKenneyPack(pack); } catch (e) { continue; }
    let entries;
    try { entries = unzipList(zipPath); } catch (e) { continue; }
    const hit = entries.find((e) => match.test(e));
    if (!hit) continue;
    try {
      const buf = unzipExtract(zipPath, hit);
      if (await isRealImage(buf)) {
        return { buf, detail: `${pack}:${path.basename(hit)}` };
      }
    } catch (e) { /* try next candidate */ }
  }
  throw new Error('no Kenney match for category ' + category);
}

// ---------------------------------------------------------------
// SOURCE 4 — procedural fallback (offline, zero-dependency)
// Kenney has no single-image scene/background, so when every AI
// source is exhausted we still produce REAL themed art instead of a
// flat fill: a soft vertical gradient + a moon + a scatter of stars,
// derived from the prompt's palette. Sprites get a clean rounded
// silhouette so the game is never a blank rectangle.
// ---------------------------------------------------------------
function pickPalette(prompt) {
  const p = (prompt || '').toLowerCase();
  if (/night|moon|star|dark/.test(p)) return { top: [22, 30, 54], bottom: [58, 44, 36], accent: [255, 222, 150] };
  if (/teal|amber|kitchen|cozy/.test(p)) return { top: [26, 54, 58], bottom: [70, 50, 30], accent: [255, 209, 102] };
  return { top: [30, 34, 52], bottom: [52, 38, 60], accent: [255, 220, 160] };
}

async function proceduralBackground(prompt, aspectRatio) {
  const { width, height } = aspectToWH(aspectRatio, 1024);
  const pal = pickPalette(prompt);
  const stars = Array.from({ length: 60 }, () => {
    const x = Math.floor(Math.random() * width);
    const y = Math.floor(Math.random() * height * 0.55);
    const r = (Math.random() * 1.6 + 0.6).toFixed(1);
    const o = (Math.random() * 0.6 + 0.3).toFixed(2);
    return `<circle cx="${x}" cy="${y}" r="${r}" fill="#fff" opacity="${o}"/>`;
  }).join('');
  const moonX = Math.floor(width * 0.74), moonY = Math.floor(height * 0.18), moonR = Math.floor(width * 0.09);
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
    <defs>
      <linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
        <stop offset="0%" stop-color="rgb(${pal.top.join(',')})"/>
        <stop offset="100%" stop-color="rgb(${pal.bottom.join(',')})"/>
      </linearGradient>
      <radialGradient id="moon" cx="50%" cy="50%" r="50%">
        <stop offset="0%" stop-color="rgb(${pal.accent.join(',')})"/>
        <stop offset="100%" stop-color="rgba(${pal.accent.join(',')},0)"/>
      </radialGradient>
    </defs>
    <rect width="100%" height="100%" fill="url(#g)"/>
    ${stars}
    <circle cx="${moonX}" cy="${moonY}" r="${moonR * 2.2}" fill="url(#moon)" opacity="0.5"/>
    <circle cx="${moonX}" cy="${moonY}" r="${moonR}" fill="rgb(${pal.accent.join(',')})" opacity="0.95"/>
    <rect x="0" y="${Math.floor(height * 0.82)}" width="100%" height="${Math.ceil(height * 0.18)}" fill="rgba(40,28,20,0.55)"/>
  </svg>`;
  return sharp(Buffer.from(svg)).png().toBuffer();
}

// ---------------------------------------------------------------
// orchestration: try sources in order, fall back automatically
// ---------------------------------------------------------------
async function generateAsset(item, falKey, seed, tier) {
  const { name, prompt, category } = item;
  const sprite = isSpritePrompt(prompt);
  const aspect = item.aspect_ratio || (sprite ? '1:1' : '9:16');
  // free providers do better with shorter prompts; allow override.
  const freePrompt = item.fallbackPrompt || prompt;

  const errors = {};

  // --- SOURCE 1: fal.ai (PREMIUM tier only — bespoke custom art) ---
  // Free tier skips straight to the free providers below, so a member
  // needs zero setup and zero cost. Premium = a FAL_KEY + ASSET_TIER=premium.
  if (tier === 'premium') {
    try {
      const raw = await falGenerate(prompt, falKey, aspect);
      return { buf: raw, source: 'fal.ai', sprite, detail: 'nano-banana-pro' };
    } catch (e) { errors.fal = e.message; }
  }

  // --- SOURCE 2: Pollinations.ai ---
  try {
    const raw = await pollinationsGenerate(freePrompt, aspect, seed);
    return { buf: raw, source: 'pollinations', sprite, detail: 'image.pollinations.ai' };
  } catch (e) { errors.pollinations = e.message; }

  // --- SOURCE 3: Kenney CC0 ---
  // Only sensible for sprite-like categories; backgrounds have no
  // single-image Kenney source and fall through to a solid fill below.
  if (sprite || (category && category !== 'background')) {
    try {
      const { buf, detail } = await kenneyGenerate(category || (sprite ? 'character' : 'item'));
      // Kenney art is already transparent -> mark as non-sprite for
      // chroma-key purposes (so we don't accidentally key it).
      return { buf, source: 'kenney', sprite: false, detail, kenney: true };
    } catch (e) { errors.kenney = e.message; }
  }

  // --- SOURCE 4: procedural (offline last resort) ---
  // Backgrounds (and any non-sprite) get real themed art rather than
  // a flat fill, so the pipeline ALWAYS yields a usable asset.
  if (!sprite) {
    try {
      const raw = await proceduralBackground(prompt, aspect);
      return { buf: raw, source: 'procedural', sprite: false, detail: 'themed gradient + moon + stars' };
    } catch (e) { errors.procedural = e.message; }
  }

  const detail = Object.entries(errors).map(([k, v]) => `${k}: ${v}`).join(' | ');
  throw new Error('all sources failed -> ' + detail);
}

// ---------------------------------------------------------------
// main
// ---------------------------------------------------------------
async function main() {
  const [, , assetsDir, configPath] = process.argv;
  if (!assetsDir || !configPath) {
    console.error('Usage: node gen-assets.mjs <assetsDir> <config.json>');
    process.exit(1);
  }
  const falKey = readFalKey();
  // TIERED: free by default (zero setup/cost). Premium uses fal.ai for
  // bespoke art — opt in with ASSET_TIER=premium AND a FAL_KEY.
  const tier = ((process.env.ASSET_TIER || 'free').toLowerCase() === 'premium' && falKey) ? 'premium' : 'free';
  console.log(tier === 'premium'
    ? '(PREMIUM tier — fal.ai custom art, auto free-fallback if it fails)'
    : '(FREE tier — Pollinations + Kenney CC0, zero cost. Add a FAL_KEY and ASSET_TIER=premium for bespoke fal.ai art.)');
  const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
  fs.mkdirSync(assetsDir, { recursive: true });

  const results = [];
  let seedBase = 1000;
  for (const item of config) {
    const { name } = item;
    const sprite = isSpritePrompt(item.prompt);
    process.stdout.write(`Generating "${name}" (${sprite ? 'sprite' : 'image'}, cat=${item.category || 'auto'})... `);
    try {
      const gen = await generateAsset(item, falKey, seedBase++, tier);
      let buf = gen.buf;
      if (gen.sprite) {
        buf = await chromaKeyMagenta(buf);
      } else {
        buf = await sharp(buf).png().toBuffer();
      }
      // Down-res + compress for web. Kenney sprites are already small
      // and transparent; everything else (fal/pollinations/procedural)
      // benefits. Treat any transparent asset as a sprite for sizing.
      const treatAsSprite = gen.sprite || gen.kenney;
      buf = await optimize(buf, treatAsSprite);
      const outPath = path.join(assetsDir, name + '.png');
      fs.writeFileSync(outPath, buf);
      const kb = (buf.length / 1024).toFixed(0);
      console.log(`OK via ${gen.source.toUpperCase()} -> ${name}.png (${kb} KB)${gen.sprite ? ' [chroma-keyed]' : gen.kenney ? ' [transparent]' : ''}  {${gen.detail}}`);
      results.push({ name, file: outPath, bytes: buf.length, source: gen.source, detail: gen.detail, category: item.category || null });
    } catch (e) {
      console.log('FAILED');
      console.error(`  all sources failed for "${name}": ${e.message}`);
      process.exitCode = 1;
    }
  }

  // ---- manifest ----
  // Keep `assets` as a flat name array (the game's main.js reads this
  // to know which keys to load). Add `sources` map + detailed `items`.
  const manifestPath = path.join(assetsDir, 'manifest.json');
  const names = results.map((r) => r.name);
  const sources = {};
  results.forEach((r) => { sources[r.name] = r.source; });
  fs.writeFileSync(manifestPath, JSON.stringify({
    generatedAt: new Date().toISOString(),
    assets: names,
    sources,
    items: results.map((r) => ({
      name: r.name, source: r.source, detail: r.detail,
      category: r.category, bytes: r.bytes,
    })),
  }, null, 2));
  console.log('\nManifest ->', manifestPath);

  console.log('\nSummary (source per asset):');
  for (const r of results) {
    console.log(`  ${r.name.padEnd(8)} ${r.source.padEnd(12)} ${(r.bytes / 1024).toFixed(0)} KB  ${r.detail}`);
  }
  if (results.length === 0) process.exit(1);
}

main().catch((e) => { console.error(e); process.exit(1); });
