#!/usr/bin/env node
/**
 * Crop a folder of photos through a template of components, from any machine.
 * TypeScript twin of crop_batch.py — Node 18+ only (built-in fetch/FormData),
 * no packages. Run with:
 *
 *   npx tsx crop_batch.ts --key dtk_live_... --photos ./my-photos \
 *     --template "Banner Hero@HP" "Card Teaser@CATLP" \
 *     --product footwear --category Football \
 *     --campaign "Predator Touch" --season FW26 --phase Launch --select best
 *
 * (or `node --experimental-strip-types crop_batch.ts …` on Node 22.6+).
 *
 * --photos-from "API TEST/your-team/shoot" reads the photos from a SharePoint
 * folder your key is scoped to instead of --photos (one of the two is
 * required); the crops are written back to <folder>/crops/<job_id>/ unless
 * --no-write-back. --base (or CROP_API_BASE) points at another host.
 *
 * --campaign/--season/--phase (with --category) name the files the way our
 * content plan does: global-predator_touch-football-fw26-launch-hp-banner_hero-d.jpg
 * (-t tablet, -m mobile). "@HP" after a component is the touchpoint (HP, CATLP,
 * CAMLP, GLP, WGLP, MGLP, PLP, PDP, APP, CRM). --select best cuts each component
 * only from the photo(s) that fit its shape best. report.json in the zip says
 * which photo was used for what, and why.
 */
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join, extname, basename } from "node:path";

let BASE = process.env.CROP_API_BASE || "https://api.toolkitengine.com";

type Args = {
  key: string; photos: string; photosFrom: string; writeBack: boolean; base: string;
  template: string[]; channel: string; product: string;
  category?: string; shotType?: string; select: string; campaign?: string;
  season?: string; phase: string; market: string; out: string;
};

function parseArgs(argv: string[]): Args {
  const a: Args = { key: "", photos: "", photosFrom: "", writeBack: true, base: BASE,
                    template: [], channel: ".COM", product: "footwear",
                    select: "all", phase: "Launch", market: "global", out: "result.zip" };
  for (let i = 0; i < argv.length; i++) {
    const flag = argv[i];
    const next = () => argv[++i];
    switch (flag) {
      case "--key": a.key = next(); break;
      case "--photos": a.photos = next(); break;
      case "--photos-from": a.photosFrom = next(); break;
      case "--no-write-back": a.writeBack = false; break;
      case "--base": a.base = next(); break;
      case "--template": while (argv[i + 1] && !argv[i + 1].startsWith("--")) a.template.push(next()); break;
      case "--channel": a.channel = next(); break;
      case "--product": a.product = next(); break;
      case "--category": a.category = next(); break;
      case "--shot-type": a.shotType = next(); break;
      case "--select": a.select = next(); break;
      case "--campaign": a.campaign = next(); break;
      case "--season": a.season = next(); break;
      case "--phase": a.phase = next(); break;
      case "--market": a.market = next(); break;
      case "--out": a.out = next(); break;
      default: throw new Error(`unknown flag ${flag}`);
    }
  }
  if (!a.key || !a.template.length) throw new Error("--key and --template are required");
  if (!a.photos === !a.photosFrom) throw new Error("give exactly one of --photos (a local folder) or --photos-from (a SharePoint folder)");
  if (a.campaign && !(a.category && a.season)) throw new Error("--campaign needs --category (Originals, Football or Running) and --season too");
  return a;
}

async function call(key: string, method: string, path: string, body?: BodyInit, headers: Record<string, string> = {}) {
  for (let attempt = 0; attempt < 8; attempt++) {
    const r = await fetch(BASE + path, { method, body, headers: { Authorization: `Bearer ${key}`, ...headers } });
    if (r.status === 429 && attempt < 7) {            // over the per-minute rate: wait as told
      const wait = Number(r.headers.get("Retry-After") || 5);
      console.log(`  rate limited, waiting ${wait}s`);
      await new Promise((res) => setTimeout(res, wait * 1000));
      continue;
    }
    if (!r.ok) throw new Error(`${method} ${path} -> HTTP ${r.status}: ${(await r.text()).slice(0, 400)}`);
    return r;
  }
  throw new Error("gave up after 8 attempts");
}

async function main() {
  const a = parseArgs(process.argv.slice(2));
  BASE = a.base.replace(/\/+$/, "");
  const photos = a.photosFrom ? [] : readdirSync(a.photos)
    .filter((f) => [".jpg", ".jpeg", ".png", ".webp"].includes(extname(f).toLowerCase()))
    .sort()
    .map((f) => join(a.photos, f));
  if (!a.photosFrom && !photos.length) throw new Error(`no photos in ${a.photos}`);

  // "Card Teaser@HP x3": "@PAGE" names the page, " xN" how many (different
  // photos, or tiles for Group Media).
  const template = a.template.map((raw) => {
    let t = raw.trim(), count: number | undefined;
    const m = t.match(/^(.*?)\s+[xX](\d+)$/);
    if (m) { t = m[1]; count = Number(m[2]); }
    const at = t.indexOf("@");
    if (at < 0 && count === undefined) return t;
    const e: Record<string, unknown> = { component: at < 0 ? t : t.slice(0, at) };
    if (at >= 0) e.touchpoint = t.slice(at + 1);
    if (count !== undefined) e.count = count;
    return e;
  });
  const body: Record<string, unknown> = {
    template, channel: a.channel, product: a.product, category: a.category ?? null,
    shot_type: a.shotType ?? null, select: a.select,
  };
  if (a.campaign) {
    body.campaign = { market: a.market, campaign: a.campaign, category: a.category, season: a.season, phase: a.phase };
  }
  if (a.photosFrom) {
    body.photos_from = a.photosFrom;
    body.write_back = a.writeBack;
  }

  const job = await (await call(a.key, "POST", "/crop/v1/jobs", JSON.stringify(body), { "Content-Type": "application/json" })).json();
  const count = a.photosFrom ? `${job.source.files.length} photo(s) in ${a.photosFrom}` : `${photos.length} photo(s)`;
  console.log(`job ${job.job_id}: ${count}, template ${JSON.stringify(a.template)}, select ${a.select}`);

  for (const [i, p] of photos.entries()) {
    const form = new FormData();
    const type = extname(p).toLowerCase() === ".png" ? "image/png" : extname(p).toLowerCase() === ".webp" ? "image/webp" : "image/jpeg";
    form.append("images", new Blob([readFileSync(p)], { type }), basename(p));
    await call(a.key, "POST", job.upload_to, form);
    console.log(`  uploaded ${i + 1}/${photos.length} ${basename(p)}`);
  }

  await call(a.key, "POST", `/crop/v1/jobs/${job.job_id}/start`);
  let st: any;
  for (;;) {
    st = await (await call(a.key, "GET", `/crop/v1/jobs/${job.job_id}`)).json();
    console.log(`  ${st.status}: ${st.done}/${st.photos} photos done, ${st.flagged} crops to check`);
    if (st.status === "done" || st.status === "failed") break;
    await new Promise((res) => setTimeout(res, 3000));
  }
  if (st.status === "failed") throw new Error(`job failed: ${st.error}`);

  const zip = await (await call(a.key, "GET", st.download)).arrayBuffer();
  writeFileSync(a.out, Buffer.from(zip));
  console.log(`saved ${a.out}`);
  if (st.delivered_to) {
    const d = st.delivered_to;
    console.log(`crops on SharePoint: ${d.sharepoint} (${d.uploaded} file(s)${d.failed ? `, ${d.failed} failed` : ""})`);
    if (d.url) console.log(`  ${d.url}`);
  }
}

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