import { Command } from "commander";
import fs from "fs/promises";
import path from "path";
import crypto from "node:crypto";
import { spawn } from "node:child_process";
import { addEntity, getEntity, updateEntity, deleteEntity, listEntities } from "../lib/registry.js";
import { slugify, generateId } from "../lib/ids.js";
import { ARTIFACT_KINDS, artifactKindDir, artifactsDir, projectRefsDir, resolveArtifactKindDirs, projectDir, layoutMode, workspaceDir } from "../lib/paths.js";
import { existsSync } from "node:fs";
import { out, ok, err } from "../lib/output.js";
import { raiseError } from "../lib/errors/index.js";
import { readLog, readGenerations, logUserPrompt, logUserAsset, logGeneration, type UserPromptEntry, type UserAssetEntry } from "../lib/gen-log.js";
import { transcribe, DEFAULT_MODEL, WHISPER_MODEL, type TranscribeLanguage, type TranscribeBackend } from "../lib/transcribe.js";
import { scoreScenario, type Scenario } from "../lib/score.js";
import { probeFile, walkMediaFiles, classifyFile, diffManifestVsProbe, ensureFfprobe } from "../lib/ffprobe.js";
import { extractFrame, audioStats, contactSheet } from "../lib/ffmpeg-recipes.js";

async function safeJson(fp: string) {
  try { return JSON.parse(await fs.readFile(fp, "utf-8")); } catch { return null; }
}

// "kbo-broadcast-001" → "Kbo Broadcast 001". Used to default --name from --id
// on `project create` (#031). Keeps `ralphy project create --id foo-001` viable
// without forcing the user to redundantly retype `--name "Foo 001"`.
function titleCaseFromId(id: string): string {
  return id
    .split(/[-_]+/)
    .filter(Boolean)
    .map((part) => (part.length === 0 ? part : part[0].toUpperCase() + part.slice(1)))
    .join(" ");
}

async function getProjectStatus(id: string) {
  const dir = projectDir(id);
  const hasScenario = !!(await safeJson(path.join(dir, "scenario.json")));
  const hasPrompts = !!(await safeJson(path.join(dir, "prompts.json")));
  const hasManifest = !!(await safeJson(path.join(dir, "asset-manifest.json")));
  const hasRender = await fs.access(path.join(dir, "render", "final.mp4")).then(() => true).catch(() => false);
  if (hasRender) return "done";
  if (hasManifest) return "assets";
  if (hasPrompts) return "prompts";
  if (hasScenario) return "scenario";
  return "draft";
}

export function projectCmd() {
  const cmd = new Command("project").description("Manage video projects");

  cmd
    .command("create")
    .description("Create a new project")
    .option("--name <name>", "Project name (default: title-cased --id)")
    .option("--brand <id>", "Brand ID")
    .option("--persona <id>", "Persona ID")
    .option("--template <id>", "Template ID")
    .option("--brief <text>", "Project brief")
    .option("--platform <platform>", "Target platform", "tiktok")
    .option("--aspect-ratio <ratio>", "Aspect ratio", "9:16")
    .option("--duration <seconds>", "Target duration in seconds", parseInt)
    .option("--id <id>", "Custom project ID")
    .option(
      "--kind <kind>",
      "Project shape: video (default — scenes + scenario) | image-pack (just artifacts/images + selected + artifacts/refs, no scenario.json)",
      "video",
    )
    .action(async (opts) => {
      // #031: --name is now optional. Default to title-cased --id, or to a
      // generated id slug if neither is provided. Either --name or --id must
      // disambiguate the project, but no longer both.
      if (!opts.name && !opts.id) {
        raiseError("E_VALIDATION_FAILED", {
          target: "--name | --id",
          detail: "at least one of --name or --id is required",
        });
      }
      const kind = String(opts.kind || "video");
      if (kind !== "video" && kind !== "image-pack") {
        raiseError("E_VALIDATION_FAILED", {
          target: "--kind",
          detail: `unknown --kind '${kind}'. Allowed: video | image-pack`,
        });
      }
      const id = opts.id || slugify(opts.name) || generateId("proj");
      const name: string = opts.name || titleCaseFromId(id);
      const dir = projectDir(id);
      await fs.mkdir(dir, { recursive: true });
      // #105: one artifacts/<kind>/ tree per project (refs is a kind).
      for (const k of ARTIFACT_KINDS) {
        await fs.mkdir(artifactKindDir(id, k), { recursive: true });
      }
      if (kind === "image-pack") {
        // #049: image-pack shape — no scenes / scenario scaffold. `selected/`
        // (the cherry-picked subset for handoff) stays a project-root sibling.
        await fs.mkdir(path.join(dir, "selected"), { recursive: true });
      } else {
        await fs.mkdir(path.join(dir, "render"), { recursive: true });
      }

      const data: Record<string, unknown> = {
        name,
        kind,
        platform: opts.platform,
        aspectRatio: opts.aspectRatio,
        status: "draft",
        createdAt: new Date().toISOString(),
      };
      if (opts.brand) data.brand = opts.brand;
      if (opts.persona) data.persona = opts.persona;
      if (opts.template) data.template = opts.template;
      if (opts.brief) data.brief = opts.brief;
      if (opts.duration) data.duration = opts.duration;

      const project = await addEntity("projects", id, data);
      ok(`Project created: ${id}`);
      out(project);
    });

  cmd
    .command("list")
    .description("List all projects")
    .option("--status <status>", "Filter by status")
    .option("--brand <id>", "Filter by brand")
    .action(async (opts: any) => {
      let projects = await listEntities("projects");

      // Enrich with actual status from filesystem
      projects = await Promise.all(
        projects.map(async (p: any) => ({
          ...p,
          status: await getProjectStatus(p.id),
        }))
      );

      if (opts.status) projects = projects.filter((p: any) => p.status === opts.status);
      if (opts.brand) projects = projects.filter((p: any) => p.brand === opts.brand);

      const rows = projects.map((p: any) => ({
        id: p.id,
        name: p.name,
        status: p.status,
        brand: p.brand || "—",
        persona: p.persona || "—",
        platform: p.platform || "—",
      }));

      const ui = await import("../lib/ui.js");
      if (!ui.isPrettyMode()) {
        out(rows);
        return;
      }
      const { c, icons, section, table } = ui;
      const statusColor = (s: string): string => {
        if (s === "done") return c.ok(s);
        if (s === "render" || s === "assets") return c.warn(s);
        if (s === "prompts" || s === "scenario") return c.info(s);
        return c.muted(s);
      };
      section(`Projects  ${c.muted(`(${rows.length} total)`)}`);
      table(rows, [
        { key: "id", header: "id", format: (v) => c.cmd(String(v)) },
        { key: "name", header: "name", format: (v) => c.bold(String(v ?? "")) },
        { key: "status", header: "status", format: (v) => statusColor(String(v ?? "draft")) },
        { key: "platform", header: "platform" },
        { key: "brand", header: "brand", format: (v) => (v === "—" ? c.muted("—") : c.value(String(v))) },
        { key: "persona", header: "persona", format: (v) => (v === "—" ? c.muted("—") : c.value(String(v))) },
      ]);
      console.log();
      console.log(`  ${icons.bullet} ${c.cmd("ralphy project show <id>")}              full details`);
      console.log(`  ${icons.bullet} ${c.cmd("ralphy project show <id> --tree")}       directory tree`);
      console.log(`  ${icons.bullet} ${c.cmd("ralphy project verify <id>")}            ffprobe + manifest sanity`);
      console.log();
    });

  cmd
    .command("show <id>")
    .description("Show project details")
    .option("--scenario", "Show scenario JSON")
    .option("--assets", "Show asset manifest")
    .option("--prompts", "Show prompts")
    .option("--status", "Show pipeline status only")
    .option("--tree", "Print the project directory tree (file paths + sizes, max depth 4). appstore postmortem asked for this.")
    .action(async (id: string, opts: any) => {
      const project = await getEntity("projects", id);
      if (!project) raiseError("E_NOT_FOUND", { kind: "Project", id });

      const dir = projectDir(id);

      if (opts.tree) {
        // Walk the project tree up to depth 4, emit { path, size_bytes } per file.
        async function walk(d: string, depth: number): Promise<Array<{ path: string; bytes?: number }>> {
          if (depth > 4) return [];
          const entries: Array<{ path: string; bytes?: number }> = [];
          let items: import("fs").Dirent[] = [];
          try {
            items = await fs.readdir(d, { withFileTypes: true });
          } catch {
            return [];
          }
          for (const it of items) {
            const full = path.join(d, it.name);
            const rel = path.relative(dir, full);
            if (it.isDirectory()) {
              entries.push({ path: rel + "/" });
              entries.push(...(await walk(full, depth + 1)));
            } else if (it.isFile()) {
              try {
                const st = await fs.stat(full);
                entries.push({ path: rel, bytes: st.size });
              } catch {
                entries.push({ path: rel });
              }
            }
          }
          return entries;
        }
        const tree = await walk(dir, 1);
        const totalBytes = tree.reduce((s, e) => s + (e.bytes ?? 0), 0);
        out({ project: id, root: dir, fileCount: tree.filter((e) => !e.path.endsWith("/")).length, totalBytes, tree });
        return;
      }

      if (opts.scenario) {
        const scenario = await safeJson(path.join(dir, "scenario.json"));
        if (!scenario) raiseError("E_FILE_UNREADABLE", { path: "scenario.json" });
        out(scenario);
        return;
      }
      if (opts.prompts) {
        const prompts = await safeJson(path.join(dir, "prompts.json"));
        if (!prompts) raiseError("E_FILE_UNREADABLE", { path: "prompts.json" });
        out(prompts);
        return;
      }
      if (opts.assets) {
        const manifest = await safeJson(path.join(dir, "asset-manifest.json"));
        if (!manifest) raiseError("E_FILE_UNREADABLE", { path: "asset-manifest.json" });
        out(manifest);
        return;
      }

      const status = await getProjectStatus(id);
      if (opts.status) {
        const scenario = !!(await safeJson(path.join(dir, "scenario.json")));
        const prompts = !!(await safeJson(path.join(dir, "prompts.json")));
        const manifest = !!(await safeJson(path.join(dir, "asset-manifest.json")));
        const render = await fs.access(path.join(dir, "render", "final.mp4")).then(() => true).catch(() => false);
        out({ id, status, steps: { scenario, prompts, assets: manifest, render } });
        return;
      }

      out({ ...project, status });
    });

  cmd
    .command("update <id>")
    .description("Update project")
    .option("--name <name>")
    .option("--brand <id>")
    .option("--persona <id>")
    .option("--brief <text>")
    .action(async (id: string, opts: any) => {
      const updates: Record<string, unknown> = {};
      if (opts.name) updates.name = opts.name;
      if (opts.brand) updates.brand = opts.brand;
      if (opts.persona) updates.persona = opts.persona;
      if (opts.brief) updates.brief = opts.brief;
      const project = await updateEntity("projects", id, updates);
      if (!project) raiseError("E_NOT_FOUND", { kind: "Project", id });
      ok(`Project updated: ${id}`);
      out(project);
    });

  cmd
    .command("delete <id>")
    .description("Delete a project")
    .option("--keep-render", "Keep the final rendered video")
    .action(async (id: string, opts: any) => {
      const dir = projectDir(id);
      try {
        if (opts.keepRender) {
          // Delete everything except render/
          for (const entry of await fs.readdir(dir)) {
            if (entry !== "render") {
              await fs.rm(path.join(dir, entry), { recursive: true, force: true });
            }
          }
        } else {
          await fs.rm(dir, { recursive: true, force: true });
        }
      } catch { /* dir may not exist */ }
      await deleteEntity("projects", id);
      ok(`Project deleted: ${id}`);
      out({ deleted: id });
    });

  cmd
    .command("log <id>")
    .description("Tail project logs (generations / user-prompts / user-assets)")
    .option("--type <type>", "Log type: generations | user-prompts | user-assets | all", "generations")
    .option("--limit <n>", "Max entries (newest last)", (v) => parseInt(v, 10), 50)
    .action(async (id: string, opts: any) => {
      const project = await getEntity("projects", id);
      if (!project) raiseError("E_NOT_FOUND", { kind: "Project", id });

      const types: Array<"generations" | "user-prompts" | "user-assets"> =
        opts.type === "all" ? ["user-prompts", "user-assets", "generations"] : [opts.type];

      const combined: any[] = [];
      for (const t of types) {
        // Use the normalizer for generations so legacy rows (top-level slot, costUsd,
        // missing model) are coerced to canonical before display. #032
        const entries = t === "generations" ? await readGenerations(id) : await readLog(id, t);
        for (const e of entries) combined.push({ _type: t, ...(e as object) });
      }
      combined.sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)));
      const sliced = combined.slice(-opts.limit);
      out(sliced);
    });

  cmd
    .command("timeline <id>")
    .description("Merged project timeline (user requests + assets + generations) as pretty chronological log")
    .action(async (id: string) => {
      const project = await getEntity("projects", id);
      if (!project) raiseError("E_NOT_FOUND", { kind: "Project", id });
      const [prompts, assets, gens] = await Promise.all([
        readLog<UserPromptEntry>(id, "user-prompts"),
        readLog<UserAssetEntry>(id, "user-assets"),
        readGenerations(id), // canonicalizes legacy rows transparently (#032)
      ]);
      type Row = { timestamp: string; kind: string; summary: string };
      const rows: Row[] = [];
      for (const p of prompts) rows.push({
        timestamp: p.timestamp,
        kind: "user:prompt" + (p.stage ? `[${p.stage}]` : ""),
        summary: p.text.replace(/\s+/g, " ").slice(0, 120),
      });
      for (const a of assets) rows.push({
        timestamp: a.timestamp,
        kind: "user:asset[" + a.kind + "]",
        summary: (a.purpose ? `${a.purpose} — ` : "") + (a.dest || a.source).slice(-80),
      });
      for (const g of gens) rows.push({
        timestamp: g.timestamp,
        kind: `gen:${g.kind}[${g.provider}]`,
        summary: `${g.endpoint} ${g.status === "ok" ? "✓" : "✗"}${g.note ? " — " + g.note : ""}`,
      });
      rows.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
      out(rows);
    });

  cmd
    .command("log-prompt [id]")
    .description("Append a user-prompt entry to project logs. Accept project id positionally OR via --project (#031).")
    .option("--project <id>", "Project id (alternative to the positional <id>)")
    .requiredOption("--text <text>", "Prompt text")
    .option("--stage <stage>", "Stage label (brief | feedback | ...)")
    .option("--note <note>", "Free-form note")
    .action(async (idArg: string | undefined, opts: any) => {
      const id = idArg ?? (opts.project as string | undefined);
      if (!id) raiseError("E_VALIDATION_FAILED", { target: "project id", detail: "pass it positionally or via --project" });
      const project = await getEntity("projects", id);
      if (!project) raiseError("E_NOT_FOUND", { kind: "Project", id });
      await logUserPrompt(id, { text: opts.text, stage: opts.stage, note: opts.note });
      ok(`Prompt logged for ${id}`);
      out({ project: id, logged: "user-prompt" });
    });

  cmd
    .command("log-asset [id]")
    .description(
      "Append a user-asset entry to project logs. Accept project id positionally OR via --project (#031). With --copy-from <src>, copies the file into <project>/artifacts/refs/ first (auto-detects disposable macOS NSIRD / /tmp paths and rescues them before they evaporate). Sanitizes U+202F NARROW NO-BREAK SPACE in filenames.",
    )
    .option("--project <id>", "Project id (alternative to the positional <id>)")
    .requiredOption("--kind <kind>", "screenshot | photo | video | audio | doc | ref-url | other")
    .requiredOption("--source <source>", "Original path or URL")
    .option("--dest <dest>", "Stored path inside project (used as-is if no --copy-from)")
    .option(
      "--copy-from <src>",
      "Local file to copy into <project>/artifacts/refs/ before logging. NSIRD / NSTemporaryDirectory paths get rescued before macOS auto-deletes them (skater + appstore postmortems).",
    )
    .option("--purpose <purpose>", "character-ref | product-ref | brand-screenshot | ...")
    .option("--note <note>", "Free-form note")
    .action(async (idArg: string | undefined, opts: any) => {
      const id = idArg ?? (opts.project as string | undefined);
      if (!id) raiseError("E_VALIDATION_FAILED", { target: "project id", detail: "pass it positionally or via --project" });
      const project = await getEntity("projects", id);
      if (!project) raiseError("E_NOT_FOUND", { kind: "Project", id });

      // Disposable-path detector (issue #038). macOS screenshot temp paths
      // auto-delete within minutes; warn loudly when the user logs one
      // without --copy-from so they at least know the file is on borrowed time.
      const looksDisposable = (p: string): boolean => {
        if (!p) return false;
        return (
          p.includes("/var/folders/") ||
          p.includes("NSIRD_") ||
          p.includes("/TemporaryItems/") ||
          p.startsWith("/tmp/") ||
          /\/Screenshot[^/]*\.png$/i.test(p) ||
          /\/Снимок экрана[^/]*\.png$/i.test(p)
        );
      };

      let dest = opts.dest as string | undefined;
      let originalPath: string | undefined;
      let localPath: string | undefined;

      if (opts.copyFrom) {
        const src = path.resolve(opts.copyFrom);
        originalPath = src;
        // Sanitize the basename: replace U+202F NARROW NO-BREAK SPACE / U+00A0 NBSP /
        // U+200B ZERO-WIDTH SPACE / U+2007 FIGURE SPACE with a regular hyphen.
        // macOS NSIRD paths contain these (appstore postmortem hit ENOENT on `ls`
        // showed the file but `cp` failed because of invisible U+202F between words).
        const rawBase = path.basename(src);
        const sanitized = rawBase
          .replace(/[   ​]/g, "-")
          .replace(/\s+/g, "-");
        const refsDir = projectRefsDir(id);
        await fs.mkdir(refsDir, { recursive: true });

        // Idempotency: if a file with the same name already exists in refs/
        // AND has the same sha256, skip the copy (AGENTS.md invariant #14 —
        // never overwrite existing refs/ files without explicit consent).
        // If the name collides but the sha differs, pick the next free
        // `<stem>-N<ext>` slot — never overwrite.
        const sha = async (p: string): Promise<string> => {
          const buf = await fs.readFile(p);
          return crypto.createHash("sha256").update(buf).digest("hex");
        };

        let srcSha = "";
        try {
          srcSha = await sha(src);
        } catch (e) {
          err(`Failed to read ${src}: ${(e as Error).message}`);
        }

        let candidate = path.join(refsDir, sanitized);
        let copied = false;
        let skippedSameSha = false;
        let collided = false;
        try {
          const stat = await fs.stat(candidate).catch(() => null);
          if (stat && stat.isFile()) {
            const existingSha = await sha(candidate);
            if (existingSha === srcSha) {
              skippedSameSha = true;
            } else {
              collided = true;
              const ext = path.extname(sanitized);
              const stem = sanitized.slice(0, sanitized.length - ext.length);
              let n = 2;
              while (true) {
                const next = path.join(refsDir, `${stem}-${n}${ext}`);
                const exists = await fs.stat(next).catch(() => null);
                if (!exists) { candidate = next; break; }
                if (exists.isFile()) {
                  const existSha = await sha(next);
                  if (existSha === srcSha) {
                    candidate = next;
                    skippedSameSha = true;
                    break;
                  }
                }
                n += 1;
                if (n > 9999) {
                  err(`Too many filename collisions for ${sanitized} in refs/`);
                  break;
                }
              }
            }
          }
          if (!skippedSameSha) {
            await fs.copyFile(src, candidate);
            copied = true;
          }
          dest = candidate;
          localPath = candidate;

          if (looksDisposable(src)) {
            // eslint-disable-next-line no-console
            console.error(
              `ralphy: rescued disposable path → ${candidate} (source was under ${src.split("/").slice(0, 5).join("/")}/...)`,
            );
          }
          if (sanitized !== rawBase) {
            // eslint-disable-next-line no-console
            console.error(
              `ralphy: filename sanitized: "${rawBase}" → "${sanitized}"`,
            );
          }
          if (skippedSameSha) {
            // eslint-disable-next-line no-console
            console.error(
              `ralphy: copy skipped (same sha256 already at ${candidate})`,
            );
          } else if (copied && collided) {
            // eslint-disable-next-line no-console
            console.error(
              `ralphy: name collision (different sha256), wrote ${candidate}`,
            );
          }
        } catch (e) {
          err(`Failed to copy ${src} → ${candidate}: ${(e as Error).message}`);
        }
      } else if (looksDisposable(opts.source)) {
        // Warn when the user logs a path that macOS will eat. The asset is
        // load-bearing for the art-director stage; losing it is silent data loss.
        // (issue #038)
        // eslint-disable-next-line no-console
        console.error(
          `ralphy: warning — "${opts.source}" looks like a disposable / temp path (macOS NSIRD, /tmp, or "Screenshot ...png"). Pass --copy-from <src> to stash it in <project>/artifacts/refs/ before it auto-deletes. (issue #038)`,
        );
      }

      await logUserAsset(id, {
        kind: opts.kind,
        source: opts.source,
        dest,
        originalPath,
        localPath,
        purpose: opts.purpose,
        note: opts.note,
      });
      ok(`Asset logged for ${id}${dest ? ` (saved at ${dest})` : ""}`);
      out({
        project: id,
        logged: "user-asset",
        kind: opts.kind,
        dest,
        originalPath,
        localPath,
      });
    });

  cmd
    .command("score <id>")
    .description("Run virality rubric over scenario.json (Hard fails + warnings, no LLM)")
    .option("--strict", "Exit with code 1 if any failure")
    .action(async (id: string, opts: any) => {
      const project = await getEntity("projects", id);
      if (!project) raiseError("E_NOT_FOUND", { kind: "Project", id });
      const scenario = (await safeJson(
        path.join(projectDir(id), "scenario.json")
      )) as Scenario | null;
      if (!scenario) err(`No scenario.json found for ${id}`);

      const result = scoreScenario(scenario as Scenario);
      out({
        project: id,
        passed: result.passed,
        failures: result.failures,
        warnings: result.warnings,
      });
      if (opts.strict && !result.passed) {
        process.exit(1);
      }
    });

  cmd
    .command("transcribe <id>")
    .description("Transcribe an audio file → captions.json (Caption[]). Default backend: ElevenLabs Scribe v1 (word-level).")
    .requiredOption("--audio <path>", "Path to audio file (mp3/m4a/wav, ≤25MB)")
    .option("--language <lang>", "ru | en | auto", "ru")
    .option("--backend <backend>", "elevenlabs | openrouter | gemini", "elevenlabs")
    .option("--model <model>", "(advanced; only honored for backend=openrouter)", DEFAULT_MODEL)
    .option("--out <path>", "Output JSON path (default: <project>/captions.json)")
    .action(async (id: string, opts: any) => {
      const project = await getEntity("projects", id);
      if (!project) raiseError("E_NOT_FOUND", { kind: "Project", id });

      const audioPath = path.resolve(opts.audio);
      const projDir = projectDir(id);
      const outPath = opts.out
        ? path.resolve(opts.out)
        : path.join(projDir, "captions.json");

      const language = (opts.language || "ru") as TranscribeLanguage;
      const backend = (opts.backend || "elevenlabs") as TranscribeBackend;

      const t0 = Date.now();
      try {
        const result = await transcribe({
          audioPath,
          language,
          backend,
          model: opts.model,
        });
        await fs.mkdir(path.dirname(outPath), { recursive: true });
        await fs.writeFile(outPath, JSON.stringify(result.captions, null, 2) + "\n");

        await logGeneration(id, {
          provider: result.backend === "elevenlabs" ? "elevenlabs" : "openrouter",
          model: result.model,
          endpoint: result.model,
          kind: "text",
          input: { project: id, audio: audioPath, language, backend: result.backend },
          output: { local: outPath },
          status: "ok",
          latency_ms: result.durationMs,
          cost_usd: result.costUsd,
          note: `transcribed ${result.captions.length} captions, lang=${result.language}, audio=${result.audioDurationSec.toFixed(1)}s`,
        });

        ok(`Transcribed ${result.captions.length} captions → ${outPath}`);
        out({
          project: id,
          captions: result.captions.length,
          language: result.language,
          backend: result.backend,
          model: result.model,
          durationMs: result.durationMs,
          audioDurationSec: result.audioDurationSec,
          costUsd: result.costUsd,
          out: outPath,
        });
      } catch (e: any) {
        await logGeneration(id, {
          provider: backend === "elevenlabs" ? "elevenlabs" : "openrouter",
          model: backend === "openrouter" ? WHISPER_MODEL : `transcribe/${backend}`,
          endpoint: backend === "openrouter" ? WHISPER_MODEL : `transcribe/${backend}`,
          kind: "text",
          input: { project: id, audio: audioPath, language, backend },
          status: "error",
          error: e?.message || String(e),
          latency_ms: Date.now() - t0,
        });
        err(`Transcription failed: ${e?.message || e}`);
      }
    });

  cmd
    .command("clone <id>")
    .description("Clone a project")
    .requiredOption("--name <name>", "New project name")
    .action(async (id: string, opts: any) => {
      const src = projectDir(id);
      const newId = slugify(opts.name) || generateId("proj");
      const dst = projectDir(newId);
      await fs.cp(src, dst, { recursive: true });

      const project = await getEntity("projects", id);
      await addEntity("projects", newId, { ...(project || {}), name: opts.name, id: newId, createdAt: new Date().toISOString() });
      ok(`Project cloned: ${id} → ${newId}`);
      out({ id: newId, clonedFrom: id });
    });

  // ── move (#108) ────────────────────────────────────────────────────────
  cmd
    .command("move <id> <workspace>")
    .description(
      "Move a project into another workspace's projects/ and update its registry entry. Precondition: no background job (ralphy generate / render) may be mid-flight on the project — its file paths go stale on move.",
    )
    .action(async (id: string, targetWs: string) => {
      if (layoutMode() === "legacy") {
        // #106 fail-fast: explicit guard so the refusal is immediate and
        // carries the catalog payload (path helpers would throw anyway).
        raiseError("E_LEGACY_LAYOUT", { verb: "project move" });
      }
      const project = await getEntity("projects", id);
      const src = projectDir(id);
      if (!project && !existsSync(src)) {
        raiseError("E_NOT_FOUND", { kind: "Project", id });
      }
      if (!existsSync(workspaceDir(targetWs))) {
        raiseError("E_NOT_FOUND", { kind: "Workspace", id: targetWs });
      }
      const dst = path.join(workspaceDir(targetWs), "projects", id);
      if (src === dst) {
        out({ id, workspace: targetWs, moved: false, note: "already in target workspace" });
        return;
      }
      if (existsSync(dst)) {
        raiseError("E_ALREADY_EXISTS", { kind: "Project", id: path.join(targetWs, "projects", id) });
      }
      if (existsSync(src)) {
        await fs.mkdir(path.dirname(dst), { recursive: true });
        await fs.rename(src, dst);
      }
      if (project) {
        await updateEntity("projects", id, { workspace: targetWs });
      }
      ok(`Project moved: ${id} → ${targetWs}`);
      out({ id, workspace: targetWs, from: src, to: dst, moved: true });
    });

  // ── assets ─────────────────────────────────────────────────────────────
  // Issue #029. Walks <project>/artifacts/, ffprobe-truths every media file,
  // emits a flat array of {slot, path, kind, duration_s, width, height, fps,
  // codecs, size_bytes}. The point: stop every multi-clip project from
  // re-inventing an ad-hoc `ffprobe -show_entries` loop and inheriting wrong
  // duration constants from sibling projects.
  cmd
    .command("assets <id>")
    .description(
      "ffprobe-truth every media file under <project>/artifacts/ and emit a flat array. Honors --kind video|image|audio.",
    )
    .option("--kind <kind>", "Filter by classified kind: video | image | audio")
    .action(async (id: string, opts: { kind?: string }) => {
      const project = await getEntity("projects", id);
      if (!project) raiseError("E_NOT_FOUND", { kind: "Project", id });
      const dir = projectDir(id);

      try {
        ensureFfprobe();
      } catch (e) {
        err(`${(e as Error).message}\n  → Try \`ralphy doctor\` to verify ffmpeg + ffprobe are installed.`);
      }

      const files = await walkMediaFiles(artifactsDir(id));

      // Build a slot lookup from asset-manifest.json (if present) so each row
      // carries the canonical slot name when we have one. We resolve real
      // paths on both sides so symlink-prefixed temp dirs (macOS /tmp vs
      // /private/tmp) still match.
      const manifest = await safeJson(path.join(dir, "asset-manifest.json"));
      const pathToSlot = new Map<string, string>();
      const tryRealpath = async (p: string): Promise<string> => {
        try { return await fs.realpath(p); } catch { return path.resolve(p); }
      };
      if (manifest && typeof manifest === "object") {
        const slots = (manifest as { slots?: Record<string, any>; assets?: Array<any> }).slots;
        if (slots) {
          for (const [slot, meta] of Object.entries(slots)) {
            const p = (meta as { path?: string }).path;
            if (p) pathToSlot.set(await tryRealpath(p), slot);
          }
        }
        // Legacy manifest shape: `assets: [{id, file, ...}]`.
        const legacy = (manifest as { assets?: Array<{ id?: string; file?: string }> }).assets;
        if (Array.isArray(legacy)) {
          for (const a of legacy) {
            if (a.file && a.id) {
              pathToSlot.set(await tryRealpath(path.join(dir, a.file)), a.id);
            }
          }
        }
      }

      const rows: Array<Record<string, unknown>> = [];
      for (const f of files) {
        const kind = classifyFile(f);
        if (opts.kind && kind !== opts.kind) continue;
        const probe = await probeFile(f);
        const slot = pathToSlot.get(await tryRealpath(f));
        rows.push({
          slot: slot ?? null,
          path: path.relative(dir, f),
          absolute_path: f,
          kind,
          duration_s: probe.duration_s ?? null,
          width: probe.width ?? null,
          height: probe.height ?? null,
          fps: probe.fps ?? null,
          codecs: probe.codecs ?? null,
          size_bytes: probe.size_bytes ?? null,
          has_video: probe.has_video ?? null,
          has_audio: probe.has_audio ?? null,
          error: probe.error ?? null,
        });
      }

      await logGeneration(id, {
        provider: "ffmpeg",
        model: "ffprobe/project-assets",
        endpoint: "ffprobe/project-assets",
        kind: "other",
        input: { project: id, filter_kind: opts.kind ?? null, count: rows.length },
        status: "ok",
        cost_usd: 0,
        note: `ffprobe ${rows.length} media files under artifacts/`,
      });

      out(rows);
    });

  // ── verify ─────────────────────────────────────────────────────────────
  // Postmortem-driven (tokyo + kbo + noski): asset-manifest.json claims can
  // drift from on-disk reality (wrong aspect, wrong duration, truncated codec).
  // ffprobes every slot file and compares against the manifest's own claim.
  // Tolerance: 100ms on duration; exact on width / height / size_bytes.
  cmd
    .command("verify <id>")
    .description(
      "ffprobe every slot in asset-manifest.json and flag divergences from claimed duration / dimensions / size (tolerance: 100ms on duration). Exit non-zero on any red.",
    )
    .option("--strict", "Treat warnings (missing optional metadata) as errors too", false)
    .action(async (id: string, opts: { strict?: boolean }) => {
      const dir = projectDir(id);
      try { await fs.access(dir); } catch { raiseError("E_NOT_FOUND", { kind: "Project", id }); }

      try {
        ensureFfprobe();
      } catch (e) {
        err(`${(e as Error).message}\n  → Try \`ralphy doctor\` to verify ffmpeg + ffprobe are installed.`);
      }

      const manifestPath = path.join(dir, "asset-manifest.json");
      const manifest = await safeJson(manifestPath);
      if (!manifest) {
        err(`asset-manifest.json missing or invalid at ${manifestPath}`);
      }

      // Normalize to a uniform { slot, claim, path } shape across the two
      // shapes we see in the wild:
      //   1. `slots: { <slot>: { path, kind, durationSec?, width?, height? } }`
      //   2. `assets: [ { id, file, durationSec?, width?, height? } ]`
      type Entry = { slot: string; claim: Record<string, unknown>; localPath: string | null; kind?: string };
      const entries: Entry[] = [];
      const m = manifest as any;
      if (m.slots && typeof m.slots === "object") {
        for (const [slot, meta] of Object.entries(m.slots as Record<string, any>)) {
          entries.push({
            slot,
            claim: meta as Record<string, unknown>,
            localPath: (meta?.path as string | undefined) ?? null,
            kind: meta?.kind as string | undefined,
          });
        }
      } else if (Array.isArray(m.assets)) {
        for (const a of m.assets as Array<Record<string, unknown>>) {
          const file = (a.file as string | undefined) ?? (a.path as string | undefined);
          entries.push({
            slot: (a.id as string | undefined) ?? (file ?? "<unknown>"),
            claim: a,
            localPath: file ? (path.isAbsolute(file) ? file : path.join(dir, file)) : null,
            kind: a.type as string | undefined,
          });
        }
      } else {
        err(`asset-manifest.json has neither .slots nor .assets at ${manifestPath}`);
      }

      type SlotReport = {
        slot: string;
        path: string | null;
        exists: boolean;
        kind?: string;
        probe: Record<string, unknown>;
        divergences: Array<{ field: string; manifest: unknown; ffprobe: unknown; delta?: number }>;
        issues: string[];
      };
      const reports: SlotReport[] = [];
      let red = 0;

      for (const e of entries) {
        const issues: string[] = [];
        const r: SlotReport = {
          slot: e.slot,
          path: e.localPath,
          exists: false,
          kind: e.kind,
          probe: {},
          divergences: [],
          issues,
        };
        if (!e.localPath) {
          issues.push("manifest entry has no `path` / `file` field");
          red += 1;
          reports.push(r);
          continue;
        }
        const ext = path.extname(e.localPath).toLowerCase();
        // Probe only if it's media we understand; otherwise just stat-check.
        const probe = await probeFile(e.localPath);
        r.exists = probe.exists;
        r.probe = {
          duration_s: probe.duration_s ?? null,
          width: probe.width ?? null,
          height: probe.height ?? null,
          fps: probe.fps ?? null,
          codecs: probe.codecs ?? null,
          size_bytes: probe.size_bytes ?? null,
        };
        if (!probe.exists) {
          issues.push(`file missing on disk: ${e.localPath}`);
          red += 1;
          reports.push(r);
          continue;
        }
        if (probe.error) {
          issues.push(probe.error);
          red += 1;
        }

        const div = diffManifestVsProbe(e.claim, probe);
        r.divergences = div;
        if (div.length > 0) red += 1;

        if (opts.strict && probe.exists && !probe.codecs?.length && ext && ext !== ".srt" && ext !== ".vtt") {
          issues.push("strict: file has no decodable codec");
          red += 1;
        }
        reports.push(r);
      }

      await logGeneration(id, {
        provider: "ffmpeg",
        model: "ffprobe/project-verify",
        endpoint: "ffprobe/project-verify",
        kind: "other",
        input: { project: id, strict: !!opts.strict, slotCount: reports.length, redCount: red },
        status: red === 0 ? "ok" : "error",
        cost_usd: 0,
        note: `verify: ${reports.length} slots, ${red} red`,
      });

      out({
        project: id,
        slotCount: reports.length,
        redCount: red,
        verdict: red === 0 ? "ok" : "fail",
        slots: reports,
      });
      if (red > 0) {
        // Non-zero exit so CI / scripts can chain
        process.exitCode = 1;
      }
    });

  // ── thumbnail (#049) ───────────────────────────────────────────────────
  // `ralphy project thumbnail <id> --at <t>` — single-frame extract for QA
  // preview. Replaces the venom-bodywash workaround of 30 raw `ffmpeg -ss`
  // invocations. Writes <project>/compositions/thumbnails/<basename>-<t>.png
  // if --slot/--src given, else <project>/thumb-<t>.png. Numeric-suffix on
  // collision (AGENTS.md #14: no overwrite).
  cmd
    .command("thumbnail <id>")
    .description(
      "Extract a single frame from a project video. Default source: <project>/render/final.mp4.",
    )
    .requiredOption("--at <seconds>", "Timestamp in seconds (float ok)", parseFloat)
    .option(
      "--src <path>",
      "Video to thumbnail (default: <project>/render/final.mp4). Relative paths resolve under the project dir.",
    )
    .option("--out <path>", "Output PNG path (default under <project>/compositions/thumbnails/)")
    .action(async (id: string, opts: any) => {
      const project = await getEntity("projects", id);
      if (!project) raiseError("E_NOT_FOUND", { kind: "Project", id });
      const dir = projectDir(id);
      const src = opts.src
        ? (path.isAbsolute(opts.src) ? opts.src : path.join(dir, opts.src))
        : path.join(dir, "render", "final.mp4");
      const t = Number(opts.at);
      if (!Number.isFinite(t) || t < 0) {
        raiseError("E_VALIDATION_FAILED", {
          target: "--at",
          detail: `must be a non-negative number, got '${opts.at}'`,
        });
      }
      const baseSlug = path
        .basename(src, path.extname(src))
        .replace(/[^a-zA-Z0-9_-]+/g, "-");
      const defaultOut = path.join(
        dir,
        "compositions",
        "thumbnails",
        `${baseSlug}-${t.toString().replace(".", "p")}.png`,
      );
      let dst = opts.out
        ? (path.isAbsolute(opts.out) ? opts.out : path.join(dir, opts.out))
        : defaultOut;
      // Numeric-suffix on collision (AGENTS.md #14). Never overwrite.
      const ext = path.extname(dst);
      const stem = dst.slice(0, dst.length - ext.length);
      let n = 2;
      while (await fs.access(dst).then(() => true).catch(() => false)) {
        dst = `${stem}-${n}${ext}`;
        n += 1;
        if (n > 9999) break;
      }
      await fs.mkdir(path.dirname(dst), { recursive: true });
      await extractFrame({ src, atSec: t, dst, projectId: id, note: `thumbnail @${t}s` });
      out({ project: id, src, atSec: t, out: dst });
    });

  // ── audio-stats (#049) ─────────────────────────────────────────────────
  // `ralphy project audio-stats <id>` — LUFS / peak / mean per audio file
  // under <project>/artifacts/. Replaces venom-bodywash's 10 raw
  // `ffmpeg -af volumedetect` invocations. JSON output, gen-log row per
  // file.
  cmd
    .command("audio-stats <id>")
    .description(
      "Loudness table (mean/peak dBFS + integrated LUFS + true peak + LRA) for every audio file under <project>/artifacts/.",
    )
    .option("--src <path>", "Single file to probe instead of the artifacts/ walk")
    .action(async (id: string, opts: any) => {
      const project = await getEntity("projects", id);
      if (!project) raiseError("E_NOT_FOUND", { kind: "Project", id });
      const dir = projectDir(id);
      let files: string[];
      if (opts.src) {
        const p = path.isAbsolute(opts.src) ? opts.src : path.join(dir, opts.src);
        files = [p];
      } else {
        try {
          ensureFfprobe();
        } catch (e) {
          err(`${(e as Error).message}\n  → Try \`ralphy doctor\` to verify ffmpeg is installed.`);
        }
        const all = await walkMediaFiles(artifactsDir(id));
        files = all.filter((f) => classifyFile(f) === "audio");
      }
      const rows: Array<Record<string, unknown>> = [];
      for (const f of files) {
        try {
          const stats = await audioStats({ src: f, projectId: id, note: "project audio-stats" });
          rows.push({ ...stats, path: path.relative(dir, f), absolute_path: f });
        } catch (e) {
          rows.push({ path: path.relative(dir, f), absolute_path: f, error: (e as Error).message });
        }
      }
      out({ project: id, count: rows.length, files: rows });
    });

  // ── contact-sheet (#049) ───────────────────────────────────────────────
  // `ralphy project contact-sheet <id> --slots 'pattern' --cols 5` — montage
  // images into an N-column grid PNG. Replaces 6 raw ffmpeg hstack invocations
  // from ralphy-carousel-001.
  cmd
    .command("contact-sheet <id>")
    .description(
      "Grid montage of images. --slots accepts a glob over <project>/artifacts/images/ (e.g. 'zine-*'). Default cols=5.",
    )
    .option(
      "--slots <pattern>",
      "Glob pattern matched against filenames under <project>/artifacts/images/ (default: '*' = all images)",
      "*",
    )
    .option("--cols <n>", "Grid columns (default 5)", (v) => parseInt(v, 10), 5)
    .option("--tile-w <n>", "Tile width (default 480)", (v) => parseInt(v, 10), 480)
    .option("--tile-h <n>", "Tile height (default 270)", (v) => parseInt(v, 10), 270)
    .option("--name <name>", "Output basename (default: contact-<timestamp>)")
    .option("--out <path>", "Override output path entirely")
    .action(async (id: string, opts: any) => {
      const project = await getEntity("projects", id);
      if (!project) raiseError("E_NOT_FOUND", { kind: "Project", id });
      const dir = projectDir(id);
      const imagesDirs = resolveArtifactKindDirs(id, "images");
      const entryToDir = new Map<string, string>();
      for (const d of imagesDirs) {
        try {
          for (const f of await fs.readdir(d)) {
            if (!entryToDir.has(f)) entryToDir.set(f, d);
          }
        } catch { /* missing dir contributes nothing */ }
      }
      if (entryToDir.size === 0) {
        raiseError("E_FILE_UNREADABLE", { path: imagesDirs[0] });
      }
      const entries: string[] = [...entryToDir.keys()];
      // Tiny inline glob — only `*` and `?` are honored, keeps the surface small.
      const pattern = String(opts.slots || "*");
      const rx = new RegExp(
        "^" +
          pattern
            .replace(/[.+^${}()|[\]\\]/g, "\\$&")
            .replace(/\*/g, ".*")
            .replace(/\?/g, ".") +
          "$",
      );
      const srcs = entries
        .filter((f) => /\.(png|jpg|jpeg|webp)$/i.test(f))
        .filter((f) => rx.test(f) || rx.test(path.basename(f, path.extname(f))))
        .map((f) => path.join(entryToDir.get(f)!, f))
        .sort((a, b) => path.basename(a).localeCompare(path.basename(b)));
      if (srcs.length === 0) {
        raiseError("E_VALIDATION_FAILED", {
          target: "--slots",
          detail: `no images matched '${pattern}' under ${imagesDirs.join(" / ")}`,
        });
      }
      const cols = Number(opts.cols) > 0 ? Number(opts.cols) : 5;
      const tileW = Number(opts.tileW) > 0 ? Number(opts.tileW) : 480;
      const tileH = Number(opts.tileH) > 0 ? Number(opts.tileH) : 270;
      const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
      const name = String(opts.name || `contact-${stamp}`);
      const dst = opts.out
        ? (path.isAbsolute(opts.out) ? opts.out : path.join(dir, opts.out))
        : path.join(dir, "compositions", "contact", `${name}.png`);
      await contactSheet({ srcs, dst, cols, tileW, tileH, projectId: id, note: `contact-sheet --slots ${pattern}` });
      out({ project: id, slotPattern: pattern, tileCount: srcs.length, cols, rows: Math.ceil(srcs.length / cols), out: dst });
    });

  // ── zip (#049) ─────────────────────────────────────────────────────────
  // `ralphy project zip <id> [--selected|--all]` — handoff bundle. Replaces
  // the appstore-takeaminute hand-assembled 32-PNG + curated-8-PNG zips. Uses
  // the system `zip` binary (always present on macOS / linux). gen-log row.
  cmd
    .command("zip <id>")
    .description(
      "Zip a project's deliverables into <cwd>/<id>.zip. --selected = <project>/selected/ only. --all = everything except logs/cache.",
    )
    .option("--selected", "Zip only <project>/selected/ (cherry-picked deliverables)")
    .option("--all", "Zip everything except logs/ and node_modules / cache")
    .option("--out <path>", "Output path (default: <cwd>/<id>.zip)")
    .action(async (id: string, opts: any) => {
      const project = await getEntity("projects", id);
      if (!project) raiseError("E_NOT_FOUND", { kind: "Project", id });
      if (!opts.selected && !opts.all) {
        raiseError("E_VALIDATION_FAILED", {
          target: "--selected | --all",
          detail: "pass --selected (cherry-picked deliverables) or --all (full project minus logs/cache)",
        });
      }
      const dir = projectDir(id);
      const t0 = Date.now();
      const dst = opts.out
        ? path.resolve(opts.out)
        : path.resolve(process.cwd(), `${id}.zip`);
      // Numeric-suffix on collision — never overwrite an existing zip.
      let finalDst = dst;
      const ext = path.extname(finalDst);
      const stem = finalDst.slice(0, finalDst.length - ext.length);
      let n = 2;
      while (await fs.access(finalDst).then(() => true).catch(() => false)) {
        finalDst = `${stem}-${n}${ext}`;
        n += 1;
        if (n > 9999) break;
      }
      await fs.mkdir(path.dirname(finalDst), { recursive: true });
      const args = ["-r", finalDst];
      if (opts.selected) {
        const sel = path.join(dir, "selected");
        try {
          await fs.access(sel);
        } catch {
          raiseError("E_FILE_UNREADABLE", { path: sel });
        }
        args.push("selected");
      } else {
        // --all: every top-level entry except logs/ and the .ralph cache.
        const top = await fs.readdir(dir);
        for (const e of top) {
          if (e === "logs" || e === ".ralph" || e === "node_modules") continue;
          args.push(e);
        }
      }
      const r = await new Promise<{ exitCode: number; stderr: string }>((resolve) => {
        const proc = spawn("zip", args, { cwd: dir });
        let stderr = "";
        proc.stderr.on("data", (d) => (stderr += d.toString()));
        proc.on("error", (e) => resolve({ exitCode: 1, stderr: e.message }));
        proc.on("close", (code) => resolve({ exitCode: code ?? 1, stderr }));
      });
      if (r.exitCode !== 0) {
        raiseError("E_INTERNAL", { detail: `zip failed (exit ${r.exitCode}): ${r.stderr.slice(0, 300)}` });
      }
      const size = (await fs.stat(finalDst)).size;
      await logGeneration(id, {
        provider: "other",
        model: "zip/project",
        endpoint: "zip/project",
        kind: "other",
        input: { project: id, mode: opts.selected ? "selected" : "all" },
        output: { local: finalDst, bytes: size },
        status: "ok",
        latency_ms: Date.now() - t0,
        cost_usd: 0,
        note: `project zip (${opts.selected ? "selected" : "all"})`,
      });
      out({ project: id, mode: opts.selected ? "selected" : "all", out: finalDst, bytes: size });
    });

  return cmd;
}
