/* global React, ReactDOM */
const { useState, useEffect, useMemo, useRef } = React;

/* ============ CUTMAN — Creative Ops ============
   Fight-camp control room for modular ad creatives.
   Level Up design system (shared with Creative Capture):
   near-black canvas, warm ink, lime accent, red = warn,
   amber = holding/testing. Keys keep their historic names
   (gold = the accent) so component code never changes.
================================================= */

/* Design tokens — see docs/DESIGN-PRINCIPLES.md. Level Up near-black base,
   ONE gold accent used only for meaning, hairline borders, tonal-step depth.
   Numerals are tabular (JetBrains Mono) so metrics column-align. */
const NUM = "'JetBrains Mono', ui-monospace, SFMono-Regular, monospace";
const C = {
  bg: "#0a0a0b",
  surface: "#121316",
  raised: "#191b1f",
  line: "rgba(236,233,221,0.10)",   // hairline
  line2: "rgba(236,233,221,0.16)",  // slightly stronger hairline
  ink: "#ece9dd",
  dim: "#a7a49b",
  faint: "#6f6d66",
  gold: "#d9a441",                  // the single accent — meaning only
  goldSoft: "rgba(217,164,65,0.12)",
  red: "#ff5c5c",
  redSoft: "rgba(255,92,92,0.12)",
  blue: "#ffb547",                  // hold / testing (amber)
  green: "#7FB069",
};

const STORE_KEY = "cutman:v1";

/* Hybrid store — the normalized tables (/api/cycles, /api/modules,
   /api/assemblies, /api/assembly-metrics) are the source of truth for
   entities; /api/ops-state keeps taxonomy, the angle bench and history.
   localStorage caches the combined snapshot as the offline read fallback. */
async function apiGet(path) {
  const r = await fetch(path);
  if (!r.ok) throw new Error(path + " " + r.status);
  return r.json();
}
async function apiPost(path, body) {
  const r = await fetch(path, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!r.ok) throw new Error(path + " " + r.status);
  return r.json();
}

/* Contract vocabularies (docs/NAMING.md) — the DB enforces the lowercase
   forms with check constraints; the UI speaks Title case. Fixed on purpose:
   every layer (capture, Cutman, Meta, readback) speaks the same words. */
const MODULE_STATUSES = ["Idea", "Queued", "Shot", "Edited", "Live", "Retired"];
const VERDICTS = ["Testing", "Scale", "Iterate", "Kill", "Retired"];

const lc = (s) => (s || "").toLowerCase();
const tc = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
const nowIso = () => new Date().toISOString();
const angleCodeOf = (angleId, angles, fallback) =>
  angles.find((a) => a.id === angleId)?.code || fallback || null;

/* Row mappers — client camelCase ⇄ DB snake_case. angle_code is denormalized
   onto rows at save time so readback joins never depend on workspace ids. */
const cycleToRow = (c, angles) => ({
  id: c.id, angle_id: c.angleId || null,
  angle_code: angleCodeOf(c.angleId, angles),
  name: c.name, question: c.question || null, answer: c.answer || null,
  status: lc(c.status) || "planned",
  start_date: c.startDate || null, end_date: c.endDate || null,
  created_at: c.createdAt || null, updated_at: nowIso(),
});
const rowToCycle = (r) => ({
  id: r.id, angleId: r.angle_id || "", name: r.name, question: r.question || "",
  answer: r.answer || "", status: tc(r.status), startDate: r.start_date || "",
  endDate: r.end_date || "", createdAt: r.created_at,
});

const moduleToRow = (m, angles) => ({
  id: m.id, code: m.code, slot_id: m.slotId, name: m.name,
  key_line: m.keyLine || null, tags: m.tags || {}, link: m.link || null,
  source: m.source || null, status: lc(m.status) || "idea", notes: m.notes || null,
  angle_id: m.angleId || null,
  angle_code: angleCodeOf(m.angleId, angles, m.angleCode),
  idea_id: m.ideaId || null,
  created_at: m.createdAt || null, updated_at: nowIso(),
});
const rowToModule = (r) => ({
  id: r.id, code: r.code, slotId: r.slot_id, name: r.name, keyLine: r.key_line || "",
  tags: r.tags || {}, link: r.link || "", source: r.source || "", status: tc(r.status),
  notes: r.notes || "", angleId: r.angle_id || "", angleCode: r.angle_code || "",
  ideaId: r.idea_id || null, createdAt: r.created_at,
});

const assemblyToRow = (a, angles) => ({
  id: a.id, code: a.code, module_ids: a.moduleIds || [],
  angle_id: a.angleId || null,
  angle_code: angleCodeOf(a.angleId, angles, a.angleCode),
  cycle_id: a.cycleId || null, campaign: a.campaign || null, platform: a.platform || null,
  status: lc(a.status) || "draft", verdict: lc(a.verdict) || "testing",
  launch_date: a.launchDate || null, retired_at: a.retiredAt || null, notes: a.notes || null,
  brand: a.brand || "LUFG", market: a.market || null, round: a.round || null,
  ratio: a.ratio || null, version: a.version || 1,
  kind: a.kind || null, asset_link: a.assetLink || null,
  created_at: a.createdAt || null, updated_at: nowIso(),
});
const rowToAssembly = (r) => ({
  id: r.id, code: r.code, name: r.code, moduleIds: r.module_ids || [],
  angleId: r.angle_id || null, angleCode: r.angle_code || "", cycleId: r.cycle_id || null,
  campaign: r.campaign || "", platform: r.platform || "",
  status: tc(r.status), verdict: tc(r.verdict),
  launchDate: r.launch_date || null, retiredAt: r.retired_at || null, notes: r.notes || "",
  brand: r.brand || "LUFG", market: r.market || "", round: r.round || "",
  ratio: r.ratio || "", version: r.version || 1,
  kind: r.kind || "", assetLink: r.asset_link || "", createdAt: r.created_at,
});

const metricToRow = (x) => ({
  id: x.id, assembly_id: x.assemblyId, date: x.date,
  spend: x.spend === "" || x.spend == null ? null : +x.spend,
  hook_rate: x.hookRate === "" || x.hookRate == null ? null : +x.hookRate,
  ctr: x.ctr === "" || x.ctr == null ? null : +x.ctr,
  leads: x.leads === "" || x.leads == null ? null : +x.leads,
  conversions: x.conversions === "" || x.conversions == null ? null : +x.conversions,
  notes: x.notes || null, created_at: x.createdAt || null,
});
const rowToMetric = (r) => ({
  id: r.id, assemblyId: r.assembly_id, date: r.date,
  spend: r.spend == null ? "" : r.spend, hookRate: r.hook_rate == null ? "" : r.hook_rate,
  ctr: r.ctr == null ? "" : r.ctr, leads: r.leads == null ? "" : r.leads,
  conversions: r.conversions == null ? "" : r.conversions,
  notes: r.notes || "", createdAt: r.created_at,
});

const uid = () => Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-4);
const today = () => new Date().toISOString().slice(0, 10);

const DEFAULT_TAXONOMY = {
  fyStartMonth: 7,
  slots: [
    { id: "hook", code: "H", name: "Hook", order: 1 },
    { id: "body", code: "B", name: "Body / Sell", order: 2 },
    { id: "proof", code: "P", name: "Proof", order: 3 },
    { id: "offer", code: "O", name: "Offer + CTA", order: 4 },
  ],
  attrs: {
    Angle: ["Belief-shift", "Authority", "Contrarian", "Transformation", "Before/After", "Narrative", "Aspirational", "Path to Victory"],
    Format: ["Talking head", "B-roll overlay", "Static", "Testimonial", "Training footage", "Athlete footage"],
    Awareness: ["Cold", "Warm", "Hot"],
    Persona: ["Hobbyist", "Competitor", "Coach"],
  },
  moduleStatuses: MODULE_STATUSES,  // legacy key — options now come from the contract constants
  verdicts: VERDICTS,
};

const SEED_MODULES = [
  {
    slotId: "hook", name: "AI will not replace fighters", tags: { Angle: "Contrarian", Format: "Talking head", Awareness: "Cold" },
    keyLine: "AI will never replace fighters — but fighters using it will replace the ones who don't.",
  },
  {
    slotId: "body", name: "GSP — fighting is a game", tags: { Angle: "Belief-shift", Format: "Talking head", Awareness: "Warm" },
    keyLine: "GSP: fighting is a game — and if you don't think it's a game, you're wrong.",
  },
  {
    slotId: "body", name: "GSP — programming your autopilot", tags: { Angle: "Authority", Format: "B-roll overlay", Awareness: "Warm" },
    keyLine: "In a fight you do what you're programmed to do. Training programs patterns into your operating system.",
  },
];

const ANGLE_STATUSES = ["Discovery", "Active", "Proven", "Retired"];
const CYCLE_STATUSES = ["Planned", "Active", "Closed"];

function seedMarketing(taxonomy, mods) {
  const now = new Date().toISOString();
  const a1 = {
    id: uid(), name: "Build the Version of You That Wins", status: "Active", code: "BVW",
    thesis: "Aspirational identity: there is a winning version of you, and systematic skill development builds it.",
    why: "Won the long-term trajectory in angle discovery testing. Primary angle for Q1 FY27 — dig deep, refine, learn which customer resonates hardest.",
    createdAt: now,
  };
  const a2 = {
    id: uid(), name: "AI Will Not Replace Fighters", status: "Proven", code: "AIF",
    thesis: "Contrarian authority: AI won't replace fighters — fighters who use it will replace the ones who don't.",
    why: "Pulled ahead early in discovery. Held as secondary angle and cold-hook source.",
    createdAt: now,
  };
  const cycles = [
    { id: uid(), angleId: a1.id, name: "FY27 Q1 · Cycle 1 — July", status: "Active", startDate: "2026-07-06", endDate: "2026-07-31",
      question: "Which hook iteration of the angle wins? Wave 1: static hook variations. Wave 2: creative formats on the winner (static / video / app screen-recording overlay). Waves 3–4: merge + retarget quiz leads at 100.", answer: "", createdAt: now },
    { id: uid(), angleId: a1.id, name: "FY27 Q1 · Cycle 2 — August", status: "Planned", startDate: "2026-08-01", endDate: "2026-08-31",
      question: "Deepen Cycle 1 winners: iteration × format × audience. Which ad set and audience responds best?", answer: "", createdAt: now },
    { id: uid(), angleId: a1.id, name: "FY27 Q1 · Cycle 3 — September", status: "Planned", startDate: "2026-09-01", endDate: "2026-09-30",
      question: "Merge best hook × best format × best iteration. Lock the quarter verdict and stage the Q2 angle.", answer: "", createdAt: now },
  ];
  const hookSlot = taxonomy.slots.find((s) => s.id === "hook") || taxonomy.slots[0];
  let n = mods.filter((m) => m.slotId === hookSlot.id).length;
  const mk = (name) => ({
    id: uid(), code: hookSlot.code + String(++n).padStart(2, "0"), slotId: hookSlot.id, name, keyLine: name,
    tags: { Angle: "Aspirational", Format: "Static", Awareness: "Cold" }, link: "",
    source: "Q1 FY27 Cycle 1 · Wave 1 hook variations", status: "Idea", notes: "", angleId: a1.id, createdAt: now,
  });
  const hookMods = [mk("Build the version of you that wins"), mk("Move well today"), mk("You can have more winning days"), mk("Don't accept average")];
  return { angles: [a1, a2], cycles, hookMods };
}

/* ---------- FY period math ---------- */
function periodRange(kind, fyStartMonth) {
  const now = new Date();
  const y = now.getFullYear(), m = now.getMonth(); // 0-based
  const fy0 = fyStartMonth - 1;
  const start = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
  let s, e = new Date(y, m, now.getDate(), 23, 59, 59);
  if (kind === "week") {
    s = start(new Date(now)); s.setDate(s.getDate() - 6);
  } else if (kind === "month") {
    s = new Date(y, m, 1);
  } else if (kind === "quarter" || kind === "half" || kind === "year") {
    let monthsSince = (m - fy0 + 12) % 12;
    const fyYear = m >= fy0 ? y : y - 1;
    if (kind === "year") s = new Date(fyYear, fy0, 1);
    if (kind === "half") s = new Date(fyYear, fy0 + (monthsSince >= 6 ? 6 : 0), 1);
    if (kind === "quarter") s = new Date(fyYear, fy0 + Math.floor(monthsSince / 3) * 3, 1);
  } else { s = new Date(2000, 0, 1); }
  return { s, e };
}
function fyLabel(kind, fyStartMonth) {
  const now = new Date();
  const fy0 = fyStartMonth - 1;
  const m = now.getMonth();
  const monthsSince = (m - fy0 + 12) % 12;
  const fyYear = m >= fy0 ? now.getFullYear() : now.getFullYear() - 1;
  const fyTag = `FY${String(fyYear + 1).slice(2)}`;
  if (kind === "week") return "Last 7 days";
  if (kind === "month") return now.toLocaleString("en-AU", { month: "long", year: "numeric" });
  if (kind === "quarter") return `${fyTag} Q${Math.floor(monthsSince / 3) + 1}`;
  if (kind === "half") return `${fyTag} H${monthsSince >= 6 ? 2 : 1}`;
  if (kind === "year") return fyTag;
  return "All time";
}

/* ---------- tiny UI primitives ---------- */
const Label = ({ children }) => (
  <div style={{ fontSize: 10.5, letterSpacing: "0.14em", textTransform: "uppercase", color: C.faint, fontWeight: 600, marginBottom: 6 }}>{children}</div>
);
const Input = (p) => (
  <input {...p} style={{ background: C.raised, border: `1px solid ${C.line}`, color: C.ink, borderRadius: 8, padding: "10px 12px", fontSize: 15, width: "100%", outline: "none", ...p.style }} />
);
const TArea = (p) => (
  <textarea {...p} style={{ background: C.raised, border: `1px solid ${C.line}`, color: C.ink, borderRadius: 8, padding: "10px 12px", fontSize: 15, width: "100%", outline: "none", minHeight: 64, ...p.style }} />
);
const Sel = ({ value, onChange, options, placeholder }) => (
  <select value={value || ""} onChange={onChange}
    style={{ background: C.raised, border: `1px solid ${C.line}`, color: value ? C.ink : C.faint, borderRadius: 8, padding: "10px 12px", fontSize: 15, width: "100%", outline: "none", appearance: "none" }}>
    <option value="">{placeholder || "—"}</option>
    {options.map((o) => (typeof o === "string" ? <option key={o} value={o}>{o}</option> : <option key={o.value} value={o.value}>{o.label}</option>))}
  </select>
);
const Btn = ({ children, onClick, kind = "solid", small, style, disabled }) => {
  const base = {
    borderRadius: 8, fontWeight: 600, cursor: disabled ? "default" : "pointer", border: "1px solid transparent",
    padding: small ? "7px 12px" : "11px 16px", fontSize: small ? 13 : 15, opacity: disabled ? 0.45 : 1,
    fontFamily: "inherit",
  };
  const kinds = {
    solid: { background: C.gold, color: "#0a0a0b" },
    ghost: { background: "transparent", color: C.dim, border: `1px solid ${C.line}` },
    danger: { background: "transparent", color: C.red, border: `1px solid ${C.red}44` },
  };
  return <button disabled={disabled} onClick={onClick} style={{ ...base, ...kinds[kind], ...style }}>{children}</button>;
};
const Chip = ({ children, tone }) => {
  const tones = {
    gold: { background: C.goldSoft, color: C.gold },
    red: { background: C.redSoft, color: C.red },
    blue: { background: "rgba(255,181,71,0.14)", color: C.blue },
    green: { background: "rgba(127,176,105,0.15)", color: C.green },
    dim: { background: C.raised, color: C.dim },
  };
  return <span style={{ ...( tones[tone] || tones.dim), borderRadius: 6, padding: "3px 8px", fontSize: 11.5, fontWeight: 600, letterSpacing: "0.02em", whiteSpace: "nowrap" }}>{children}</span>;
};
const Plate = ({ code, big }) => (
  <span style={{
    fontFamily: NUM, fontSize: big ? 16 : 12, fontWeight: 600,
    color: C.ink, background: C.raised, border: `1px solid ${C.line2}`, borderRadius: 6,
    padding: big ? "5px 10px" : "2px 7px", letterSpacing: "0.04em", whiteSpace: "nowrap",
  }}>{code}</span>
);
const Card = ({ children, style, onClick }) => (
  <div onClick={onClick} style={{ background: C.surface, border: `1px solid ${C.line}`, borderRadius: 12, padding: 14, ...style }}>{children}</div>
);
const verdictTone = (v) => (v === "Scale" ? "gold" : v === "Kill" ? "red" : v === "Retired" ? "dim" : "blue");

/* ---------- stroke icons (no emoji chrome — law 9) ---------- */
const Ic = ({ d, size = 20 }) => (
  <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">{d}</svg>
);
const IcIdeate = (p) => <Ic {...p} d={<><path d="M9 18h6" /><path d="M10 21h4" /><path d="M12 3a6 6 0 0 0-4 10.5c.6.6 1 1.5 1 2.5h6c0-1 .4-1.9 1-2.5A6 6 0 0 0 12 3z" /></>} />;
const IcResearch = (p) => <Ic {...p} d={<><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.5" y2="16.5" /></>} />;
const IcBuild = (p) => <Ic {...p} d={<><path d="M12 2 2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5" /><path d="M2 12l10 5 10-5" /></>} />;
const IcPerform = (p) => <Ic {...p} d={<><polyline points="3 17 9 11 13 15 21 7" /><polyline points="14 7 21 7 21 14" /></>} />;
const IcMic = (p) => <Ic {...p} d={<><rect x="9" y="3" width="6" height="12" rx="3" /><path d="M5 11a7 7 0 0 0 14 0" /><line x1="12" y1="18" x2="12" y2="22" /></>} />;
const IcFolder = (p) => <Ic {...p} d={<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />} />;
const IcFile = (p) => <Ic {...p} d={<><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /></>} />;
const IcFilm = (p) => <Ic {...p} d={<><rect x="2" y="4" width="20" height="16" rx="2" /><line x1="7" y1="4" x2="7" y2="20" /><line x1="17" y1="4" x2="17" y2="20" /><line x1="2" y1="12" x2="22" y2="12" /></>} />;
const IcImage = (p) => <Ic {...p} d={<><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><polyline points="21 15 16 10 5 21" /></>} />;
const IcGear = (p) => <Ic {...p} d={<><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.09a1.7 1.7 0 0 0-1-1.55 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.09a1.7 1.7 0 0 0 1.55-1 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34h.01a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1 1.55h.01a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v.01a1.7 1.7 0 0 0 1.55 1H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.55 1z" /></>} />;

/* Segmented control — quiet sub-navigation within a tab */
const Seg = ({ value, onChange, options }) => (
  <div style={{ display: "inline-flex", background: C.surface, border: `1px solid ${C.line}`, borderRadius: 9, padding: 3, gap: 2, marginBottom: 16 }}>
    {options.map(([k, l]) => (
      <button key={k} onClick={() => onChange(k)} style={{
        background: value === k ? C.raised : "transparent", color: value === k ? C.ink : C.faint,
        border: value === k ? `1px solid ${C.line2}` : "1px solid transparent", borderRadius: 7,
        padding: "6px 14px", fontSize: 12.5, fontWeight: 600, cursor: "pointer", fontFamily: "inherit",
        transition: "all 0.15s ease",
      }}>{l}</button>
    ))}
  </div>
);

/* =================================================== */
function App() {
  const [loaded, setLoaded] = useState(false);
  const [tab, setTab] = useState("performance");
  const [tax, setTax] = useState(DEFAULT_TAXONOMY);
  const [modules, setModules] = useState([]);
  const [assemblies, setAssemblies] = useState([]);
  const [angles, setAngles] = useState([]);
  const [cycles, setCycles] = useState([]);
  const [metrics, setMetrics] = useState([]);
  const [history, setHistory] = useState([]);
  const [registry, setRegistry] = useState([]); // angles table — the 3-letter code registry
  const [metaStatus, setMetaStatus] = useState(null); // /api/meta-status — token health
  const [taxo, setTaxo] = useState(null); // /api/taxonomy — levers/hooks/formats/layouts/ctas
  const [capture, setCapture] = useState(null); // null | { idea? } — the capture sheet (mic button)
  const [ideasBump, setIdeasBump] = useState(0); // bumps when an idea saves → Ideate refetches
  const [buildSeg, setBuildSeg] = useState("assemble");
  const [perfSeg, setPerfSeg] = useState("overview");
  const [toast, setToast] = useState(null);
  const blobTimer = useRef(null);
  const entTimer = useRef(null);
  // Last state arrays pushed to the tables — reference compare marks dirty sets.
  // null = never pushed, so a legacy-blob or seeded workspace migrates on first sync.
  const lastPushed = useRef({ cycles: null, modules: null, assemblies: null, metrics: null, angles: null });
  const holdBlobSave = useRef(false); // set when the blob was unreadable at load — skips the first auto-save

  useEffect(() => {
    const l = document.createElement("link");
    l.rel = "stylesheet";
    l.href = "https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700;800;900&family=JetBrains+Mono:wght@600;700&display=swap";
    document.head.appendChild(l);
  }, []);

  /* load — registry, then blob (tax/angles/history), then the normalized tables */
  useEffect(() => {
    (async () => {
      // Angle-code registry (best-effort — the ad-name generator needs it)
      let reg = [];
      try { reg = (await apiGet('/api/list-angles')).angles || []; } catch (e) { /* offline */ }
      setRegistry(reg);

      // Meta token health (best-effort — drives the replace-token banner)
      apiGet('/api/meta-status').then(setMetaStatus).catch(() => {});

      // Live taxonomy for the capture form (best-effort; Sels stay empty offline)
      apiGet('/api/taxonomy').then(setTaxo).catch(() => {});

      // Flush any ideas queued while offline (capture must never lose a thought)
      try {
        const pending = JSON.parse(localStorage.getItem('mad:pendingIdeas') || '[]');
        if (pending.length) {
          const still = [];
          for (const row of pending) {
            try { await apiPost('/api/save-idea', row); } catch (e) { still.push(row); }
          }
          localStorage.setItem('mad:pendingIdeas', JSON.stringify(still));
        }
      } catch (e) {}

      // Shared blob: taxonomy, angle bench, history (+ entities, pre-migration)
      let blob = null, blobFailed = false;
      try {
        const d = await apiGet('/api/ops-state');
        if (d && d.state && Object.keys(d.state).length > 0) blob = d.state;
      } catch (e) { blobFailed = true; /* offline — cache fallback below */ }

      // Normalized tables: source of truth for cycles/modules/assemblies/metrics
      let rows = null;
      try {
        const [cy, mo, asm, me] = await Promise.all([
          apiGet('/api/cycles'), apiGet('/api/modules'),
          apiGet('/api/assemblies'), apiGet('/api/assembly-metrics'),
        ]);
        rows = {
          cycles: (cy.cycles || []).map(rowToCycle),
          modules: (mo.modules || []).map(rowToModule),
          assemblies: (asm.assemblies || []).map(rowToAssembly),
          metrics: (me.metrics || []).map(rowToMetric),
        };
      } catch (e) { /* offline — cache fallback below */ }

      // Offline fallback: last combined snapshot cached locally
      let cache = null;
      if (!blob || !rows) {
        try { const v = localStorage.getItem(STORE_KEY); if (v) cache = JSON.parse(v); } catch (e) {}
      }

      const meta = blob || cache; // tax + angles + history live here
      const mergedTax = { ...DEFAULT_TAXONOMY, ...(meta && meta.tax), attrs: (meta && meta.tax && meta.tax.attrs) || DEFAULT_TAXONOMY.attrs };
      setTax(mergedTax);

      // Workspace angles carry a registry code — auto-match by name where missing
      const withCode = (a) => a.code ? a : { ...a, code: reg.find((r) => (r.name || "").toLowerCase() === (a.name || "").toLowerCase())?.code || "" };

      const hasRows = rows && (rows.cycles.length || rows.modules.length || rows.assemblies.length || rows.metrics.length) > 0;

      if (hasRows) {
        // Tables are live — already pushed, nothing to migrate
        const ang = ((meta && meta.angles) || []).map(withCode);
        setAngles(ang); setHistory((meta && meta.history) || []);
        setCycles(rows.cycles); setModules(rows.modules);
        setAssemblies(rows.assemblies); setMetrics(rows.metrics);
        lastPushed.current = { cycles: rows.cycles, modules: rows.modules, assemblies: rows.assemblies, metrics: rows.metrics, angles: ang };
        // Blob unreadable but tables have data → don't overwrite the server
        // blob with an empty bench on the automatic post-load save
        if (blobFailed && !meta) holdBlobSave.current = true;
      } else if (meta && ((meta.modules && meta.modules.length) || (meta.angles && meta.angles.length))) {
        // Legacy blob workspace — hydrate from it; lastPushed stays null, so the
        // first sync pass performs the one-time migration into the tables.
        let mods = meta.modules || [];
        let hist = meta.history || [];
        let ang, cyc;
        if (meta.angles && meta.cycles) {
          ang = meta.angles; cyc = meta.cycles;
        } else {
          const mk = seedMarketing(mergedTax, mods);
          mods = mods
            .map((m) => (m.name && m.name.startsWith("AI will not replace") ? { ...m, angleId: mk.angles[1].id } : m))
            .concat(mk.hookMods);
          hist = [...hist, { ts: new Date().toISOString(), type: "system", detail: "Marketing layer added — Q1 FY27 angle, cycle plan and hook wave seeded" }];
          ang = mk.angles; cyc = mk.cycles;
        }
        ang = ang.map(withCode);
        setAngles(ang); setCycles(cyc); setModules(mods);
        setAssemblies(meta.assemblies || []); setMetrics(meta.metrics || []);
        setHistory([...hist, { ts: new Date().toISOString(), type: "system", detail: "Workspace migrated from shared blob to normalized tables" }]);
      } else {
        // First run: seed modules + marketing layer; first sync pushes it up
        const seeded = SEED_MODULES.map((m) => ({
          id: uid(), code: "X00", status: "Edited", link: "", source: "", notes: "", createdAt: new Date().toISOString(), ...m,
        }));
        const counts = {};
        seeded.forEach((m) => {
          const slot = DEFAULT_TAXONOMY.slots.find((s) => s.id === m.slotId);
          counts[m.slotId] = (counts[m.slotId] || 0) + 1;
          m.code = slot.code + String(counts[m.slotId]).padStart(2, "0");
        });
        const mk = seedMarketing(DEFAULT_TAXONOMY, seeded);
        const mods = seeded
          .map((m) => (m.name.startsWith("AI will not replace") ? { ...m, angleId: mk.angles[1].id } : m))
          .concat(mk.hookMods);
        setModules(mods); setAngles(mk.angles.map(withCode)); setCycles(mk.cycles);
        setHistory([{ ts: new Date().toISOString(), type: "system", detail: "Workspace created — starter modules, Q1 FY27 angle and cycle plan seeded" }]);
      }
      setLoaded(true);
    })();
  }, []);

  /* cache the combined snapshot locally — the offline read fallback */
  const cacheAll = () => {
    try { localStorage.setItem(STORE_KEY, JSON.stringify({ tax, angles, history, modules, assemblies, cycles, metrics })); } catch (e) {}
  };

  /* debounced save — blob channel: taxonomy, angle bench, history.
     Until the entity sync has landed in the tables at least once (migration
     or first seed), the blob keeps carrying the entities too, so a failed
     table push can never strand data in one device's localStorage. */
  useEffect(() => {
    if (!loaded) return;
    clearTimeout(blobTimer.current);
    blobTimer.current = setTimeout(async () => {
      cacheAll();
      if (holdBlobSave.current) { holdBlobSave.current = false; return; }
      const inTables = lastPushed.current.modules !== null;
      try {
        await apiPost('/api/ops-state', {
          state: {
            schemaVersion: 2, tax, angles, history,
            ...(inTables ? {} : { modules, assemblies, cycles, metrics }),
          },
        });
      } catch (e) { setToast("Save failed — check connection"); }
    }, 400);
  }, [tax, angles, history, loaded]);

  /* debounced sync — entity channel: bulk-upsert dirty tables.
     Order matters: assemblies before metrics (FK). An angle edit re-pushes
     dependent tables so the denormalized angle_code stays fresh. */
  useEffect(() => {
    if (!loaded) return;
    clearTimeout(entTimer.current);
    entTimer.current = setTimeout(async () => {
      cacheAll();
      const last = lastPushed.current;
      const anglesChanged = last.angles !== angles;
      try {
        if (last.cycles !== cycles || anglesChanged) {
          if (cycles.length) await apiPost('/api/cycles', cycles.map((c) => cycleToRow(c, angles)));
          last.cycles = cycles;
        }
        if (last.modules !== modules || anglesChanged) {
          if (modules.length) await apiPost('/api/modules', modules.map((m) => moduleToRow(m, angles)));
          last.modules = modules;
        }
        if (last.assemblies !== assemblies || anglesChanged) {
          if (assemblies.length) await apiPost('/api/assemblies', assemblies.map((a) => assemblyToRow(a, angles)));
          last.assemblies = assemblies;
        }
        if (last.metrics !== metrics) {
          if (metrics.length) await apiPost('/api/assembly-metrics', metrics.map(metricToRow));
          last.metrics = metrics;
        }
        last.angles = angles;
      } catch (e) { setToast("Sync failed — check connection"); }
    }, 600);
  }, [cycles, modules, assemblies, metrics, angles, loaded]);

  const log = (type, detail) => setHistory((h) => [...h, { ts: new Date().toISOString(), type, detail }]);
  const flash = (msg) => { setToast(msg); setTimeout(() => setToast(null), 2200); };

  /* re-pull metrics from the tables (after a Meta sync) without re-pushing them */
  const refreshMetrics = async () => {
    const d = await apiGet('/api/assembly-metrics');
    const arr = (d.metrics || []).map(rowToMetric);
    lastPushed.current.metrics = arr;
    setMetrics(arr);
  };

  /* Periodic silent Meta sync — the numbers refresh themselves. Runs once when
     the token is known-good, then every 20 min while the app is open. Idempotent
     server-side; failures are swallowed (the manual Sync Meta button remains). */
  useEffect(() => {
    if (!loaded || !metaStatus || !metaStatus.configured || !metaStatus.valid) return;
    let alive = true;
    const run = async () => {
      try { await apiPost('/api/meta-sync', {}); if (alive) await refreshMetrics(); } catch (e) {}
    };
    run();
    const t = setInterval(run, 20 * 60 * 1000);
    return () => { alive = false; clearInterval(t); };
  }, [loaded, metaStatus && metaStatus.configured && metaStatus.valid]);

  function nextCode(taxonomy, mods, slotId) {
    const slot = taxonomy.slots.find((s) => s.id === slotId);
    const n = mods.filter((m) => m.slotId === slotId).length + 1;
    return (slot?.code || "X") + String(n).padStart(2, "0");
  }

  if (!loaded) return (
    <div style={{ background: C.bg, minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", color: C.dim, fontFamily: "'Archivo', sans-serif" }}>
      Loading your corner…
    </div>
  );

  // ONE app, five destinations, the mic in the centre (the entry point).
  // Old tabs live on inside their hubs: Angle → Performance, Library/Track →
  // Build, More → the gear in the header. Nothing removed, everything homed.
  const goTrack = () => { setTab("build"); setBuildSeg("track"); };

  return (
    <div style={{ background: C.bg, minHeight: "100vh", color: C.ink, fontFamily: "'Archivo', -apple-system, sans-serif", paddingBottom: "calc(92px + env(safe-area-inset-bottom, 0px))" }}>
      {/* header — the wordmark whispers; the gear reaches settings/export */}
      <div style={{ padding: "calc(14px + env(safe-area-inset-top, 0px)) 16px 10px", borderBottom: `1px solid ${C.line}`, position: "sticky", top: 0, background: C.bg, zIndex: 20 }}>
        <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
          <div style={{ fontFamily: "'Archivo', sans-serif", fontWeight: 900, fontSize: 17, letterSpacing: "0.01em", color: C.ink }}>
            MAD<span style={{ color: C.gold }}>VERTISING</span>
          </div>
          <div style={{ flex: 1 }} />
          <button onClick={() => setTab(tab === "more" ? "performance" : "more")} aria-label="Settings"
            style={{ background: "transparent", border: "none", color: tab === "more" ? C.ink : C.faint, cursor: "pointer", padding: 8 }}>
            <IcGear size={18} />
          </button>
        </div>
      </div>

      {/* Meta token banner — driven by the real expiry from /api/meta-status */}
      {metaStatus && metaStatus.configured && metaStatus.warn && (
        <div style={{ background: C.redSoft, borderBottom: `1px solid ${C.red}66`, color: C.red, padding: "10px 16px", fontSize: 13, fontWeight: 600, textAlign: "center" }}>
          {metaStatus.valid
            ? `⚠ Meta access token expires in ${metaStatus.days_left} day${metaStatus.days_left === 1 ? "" : "s"} (${(metaStatus.expires_at || "").slice(0, 10)}) — generate a new token and update META_ACCESS_TOKEN in Cloudflare Pages → Settings → Variables.`
            : "⚠ Meta access token is invalid or expired — update META_ACCESS_TOKEN in Cloudflare Pages → Settings → Variables."}
        </div>
      )}

      <div style={{ padding: 16, maxWidth: 720, margin: "0 auto" }}>
        {tab === "ideate" && <Ideate taxo={taxo} registry={registry} bump={ideasBump} onOpen={(idea) => setCapture({ idea })} onNew={() => setCapture({})} />}
        {tab === "research" && <Research angles={angles} log={log} flash={flash} />}
        {tab === "build" && (
          <div>
            <Seg value={buildSeg} onChange={setBuildSeg} options={[["assemble", "Assemble"], ["modules", "Modules"], ["track", "Track"]]} />
            {buildSeg === "assemble" && <Build tax={tax} angles={angles} cycles={cycles} modules={modules} assemblies={assemblies} setAssemblies={setAssemblies} log={log} flash={flash} goTrack={goTrack} />}
            {buildSeg === "modules" && <Library tax={tax} angles={angles} modules={modules} setModules={setModules} nextCode={(s) => nextCode(tax, modules, s)} log={log} flash={flash} />}
            {buildSeg === "track" && <Track tax={tax} angles={angles} cycles={cycles} modules={modules} assemblies={assemblies} setAssemblies={setAssemblies} metrics={metrics} setMetrics={setMetrics} metaStatus={metaStatus} refreshMetrics={refreshMetrics} log={log} flash={flash} />}
          </div>
        )}
        {tab === "performance" && (
          <div>
            <Seg value={perfSeg} onChange={setPerfSeg} options={[["overview", "Overview"], ["angles", "Angles"]]} />
            {perfSeg === "overview" && <Performance tax={tax} angles={angles} cycles={cycles} modules={modules} assemblies={assemblies} metrics={metrics} metaStatus={metaStatus} refreshMetrics={refreshMetrics} log={log} flash={flash} goTrack={goTrack} />}
            {perfSeg === "angles" && <Marketing tax={tax} registry={registry} angles={angles} setAngles={setAngles} cycles={cycles} setCycles={setCycles} assemblies={assemblies} metrics={metrics} log={log} flash={flash} />}
          </div>
        )}
        {tab === "more" && <More tax={tax} setTax={setTax} angles={angles} cycles={cycles} modules={modules} assemblies={assemblies} metrics={metrics} history={history} log={log} flash={flash} />}
      </div>

      {/* capture sheet — reachable from anywhere via the centre mic */}
      {capture && (
        <CaptureSheet
          idea={capture.idea || null}
          taxo={taxo}
          registry={registry}
          flash={flash}
          onClose={() => setCapture(null)}
          onSaved={() => { setCapture(null); setIdeasBump((n) => n + 1); }}
        />
      )}

      {toast && (
        <div style={{ position: "fixed", bottom: "calc(104px + env(safe-area-inset-bottom, 0px))", left: "50%", transform: "translateX(-50%)", background: C.raised, border: `1px solid ${C.line2}`, color: C.ink, padding: "10px 18px", borderRadius: 10, fontSize: 14, zIndex: 50, whiteSpace: "nowrap", animation: "rise 0.2s ease-out" }}>{toast}</div>
      )}

      {/* bottom nav — 4 quiet destinations, one gold action */}
      <div style={{ position: "fixed", bottom: 0, left: 0, right: 0, background: C.surface, borderTop: `1px solid ${C.line}`, display: "flex", alignItems: "stretch", zIndex: 30, paddingBottom: "env(safe-area-inset-bottom, 0px)" }}>
        {[
          { id: "ideate", label: "Ideate", icon: IcIdeate },
          { id: "research", label: "Research", icon: IcResearch },
          { id: "mic" },
          { id: "build", label: "Build", icon: IcBuild },
          { id: "performance", label: "Perform", icon: IcPerform },
        ].map((t) =>
          t.id === "mic" ? (
            <div key="mic" style={{ flex: 1, display: "flex", justifyContent: "center" }}>
              <button onClick={() => setCapture({})} aria-label="Capture an idea" style={{
                width: 54, height: 54, borderRadius: "50%", background: C.gold, color: "#0a0a0b",
                border: "none", cursor: "pointer", marginTop: -16, display: "flex", alignItems: "center",
                justifyContent: "center", transition: "transform 0.15s ease",
              }}><IcMic size={24} /></button>
            </div>
          ) : (
            <button key={t.id} onClick={() => setTab(t.id)} style={{
              flex: 1, padding: "10px 0 8px", background: "transparent", border: "none", cursor: "pointer",
              color: tab === t.id ? C.gold : C.faint, fontFamily: "inherit",
              display: "flex", flexDirection: "column", alignItems: "center", gap: 3,
            }}>
              <t.icon size={20} />
              <span style={{ fontSize: 10, letterSpacing: "0.1em", textTransform: "uppercase", fontWeight: 600 }}>{t.label}</span>
            </button>
          )
        )}
      </div>
    </div>
  );
}

/* ================= IDEATE ================= */
/* The creative creation system's front door: every idea, born by voice or
   typed, classified to the taxonomy, filed to Supabase. The mic in the nav
   opens CaptureSheet from anywhere; this tab is the library of results. */
const IDEA_STATUSES = ["idea", "queued", "shot", "edited", "live", "retired"];

function Ideate({ taxo, registry, bump, onOpen, onNew }) {
  const [ideas, setIdeas] = useState(null);
  const [q, setQ] = useState("");
  const [filter, setFilter] = useState("all");

  useEffect(() => {
    let live = true;
    apiGet("/api/list-ideas?limit=300&status=" + IDEA_STATUSES.join(","))
      .then((d) => { if (live) setIdeas(d.ideas || []); })
      .catch(() => { if (live) setIdeas([]); });
    return () => { live = false; };
  }, [bump]);

  const shown = (ideas || []).filter((i) => {
    if (filter !== "all" && i.status !== filter) return false;
    if (q.trim()) {
      const hay = [i.title, i.raw, i.hook_text, i.angle_code, i.notes].join(" ").toLowerCase();
      if (!hay.includes(q.trim().toLowerCase())) return false;
    }
    return true;
  });

  return (
    <div>
      <div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
        <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search ideas…" />
        <Btn small kind="ghost" onClick={onNew} style={{ whiteSpace: "nowrap" }}>+ Type one</Btn>
      </div>
      <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 14 }}>
        {["all", ...IDEA_STATUSES].map((s) => (
          <button key={s} onClick={() => setFilter(s)} style={{
            background: filter === s ? C.raised : "transparent", color: filter === s ? C.ink : C.faint,
            border: `1px solid ${filter === s ? C.line2 : C.line}`, borderRadius: 999, padding: "5px 12px",
            fontSize: 12, fontWeight: 600, cursor: "pointer", fontFamily: "inherit", textTransform: "capitalize",
          }}>{s}</button>
        ))}
      </div>

      {ideas === null && <div style={{ color: C.dim, textAlign: "center", padding: 32 }}>Loading ideas…</div>}
      {ideas !== null && shown.length === 0 && (
        <Card style={{ textAlign: "center", color: C.dim, padding: 32 }}>
          {ideas.length === 0 ? "No ideas yet. Tap the mic and speak one." : "No matches."}
        </Card>
      )}
      <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
        {shown.map((i) => (
          <Card key={i.id} onClick={() => onOpen(i)} style={{ cursor: "pointer", padding: 13 }}>
            <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
              <div style={{ fontWeight: 600, fontSize: 14.5, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                {i.title || (i.raw || "").slice(0, 60) || "Untitled"}
              </div>
              {i.angle_code && <Chip tone="dim">{i.angle_code}</Chip>}
              <Chip tone={i.status === "live" ? "gold" : i.status === "queued" ? "blue" : "dim"}>{i.status}</Chip>
            </div>
            {i.hook_text && <div style={{ fontSize: 13, color: C.dim, fontStyle: "italic", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>&ldquo;{i.hook_text}&rdquo;</div>}
            {Array.isArray(i.images) && i.images.length > 0 && (
              <div style={{ display: "flex", gap: 6, marginTop: 8 }}>
                {i.images.slice(0, 4).map((p) => (
                  <img key={p} src={`/api/idea-image?path=${encodeURIComponent(p)}`} loading="lazy"
                    style={{ width: 42, height: 42, objectFit: "cover", borderRadius: 6, border: `1px solid ${C.line}` }} />
                ))}
              </div>
            )}
          </Card>
        ))}
      </div>
    </div>
  );
}

/* ================= CAPTURE SHEET ================= */
/* Voice → Whisper → classify → taxonomy-tagged idea. Opens from the centre
   mic on any tab. Offline-safe: a failed save queues locally and flushes on
   the next app load — a thought is never lost. */
function CaptureSheet({ idea, taxo, registry, flash, onClose, onSaved }) {
  const editing = !!(idea && idea.id);
  const [f, setF] = useState(() => ({
    raw: idea?.raw || "", title: idea?.title || "", hookText: idea?.hook_text || "",
    angle: idea?.angle_code || "", lever: idea?.lever || "", hook: idea?.hook || "",
    format: idea?.format_hint || "", layout: idea?.layout_hint || "", cta: idea?.cta_hint || "",
    notes: idea?.notes || "", status: idea?.status || "idea",
    images: Array.isArray(idea?.images) ? [...idea.images] : [],
  }));
  const [rec, setRec] = useState("idle"); // idle | recording | processing
  const [classifying, setClassifying] = useState(false);
  const [saving, setSaving] = useState(false);
  const recRef = useRef(null);
  const set = (k, v) => setF((p) => ({ ...p, [k]: v }));

  const toggleRec = async () => {
    if (rec === "recording") { recRef.current?.stop(); return; }
    if (rec === "processing") return;
    if (!navigator.mediaDevices?.getUserMedia) return flash("Mic unavailable in this browser");
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      const mime = ["audio/mp4", "audio/webm;codecs=opus", "audio/webm", "audio/ogg"].find((m) => window.MediaRecorder && MediaRecorder.isTypeSupported(m)) || "";
      const mr = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
      const chunks = [];
      mr.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
      mr.onstop = async () => {
        stream.getTracks().forEach((t) => t.stop());
        setRec("processing");
        try {
          const type = mr.mimeType || "audio/webm";
          const ext = type.includes("mp4") ? "m4a" : type.includes("ogg") ? "ogg" : "webm";
          const fd = new FormData();
          fd.append("audio", new Blob(chunks, { type }), `capture_${Date.now()}.${ext}`);
          const res = await fetch("/api/transcribe", { method: "POST", body: fd });
          if (!res.ok) throw new Error("transcribe " + res.status);
          const d = await res.json();
          if (d.text) setF((p) => ({ ...p, raw: (p.raw ? p.raw + " " : "") + d.text.trim() }));
        } catch (e) { flash("Transcription failed — type it instead"); }
        setRec("idle");
      };
      mr.start();
      recRef.current = mr;
      setRec("recording");
    } catch (e) { flash("Mic permission denied"); }
  };

  const classify = async () => {
    if (!f.raw.trim()) return flash("Nothing to classify yet");
    setClassifying(true);
    try {
      const d = await apiPost("/api/classify", { text: f.raw });
      setF((p) => ({
        ...p,
        title: p.title || d.title || "", angle: p.angle || d.angle || "",
        hookText: p.hookText || d.hookText || "", lever: p.lever || d.lever || "",
        hook: p.hook || d.hook || "", format: p.format || d.format || "",
        layout: p.layout || d.layout || "", cta: p.cta || d.cta || "",
        notes: p.notes || d.notes || "",
      }));
    } catch (e) { flash("Auto-classify unavailable — fill manually"); }
    setClassifying(false);
  };

  const onImage = (e) => {
    const file = e.target.files?.[0];
    e.target.value = "";
    if (!file) return;
    if (file.size > 8 * 1024 * 1024) return flash("Image too large (max 8MB)");
    const r = new FileReader();
    r.onload = async () => {
      try {
        const d = await apiPost("/api/upload-idea-image", { image: r.result });
        if (d.path) setF((p) => ({ ...p, images: [...p.images, d.path] }));
      } catch (err) { flash("Image upload failed"); }
    };
    r.readAsDataURL(file);
  };

  const save = async () => {
    if (!f.raw.trim() && !f.title.trim()) return flash("Speak, type, or title the idea first");
    setSaving(true);
    const row = {
      ...(editing ? { id: idea.id } : {}),
      raw: f.raw.trim() || null, title: f.title.trim() || null,
      hook_text: f.hookText.trim() || null, notes: f.notes.trim() || null,
      angle_code: f.angle || null, lever: f.lever || null, hook: f.hook || null,
      format_hint: f.format || null, layout_hint: f.layout || null, cta_hint: f.cta || null,
      status: f.status, images: f.images,
    };
    try {
      await apiPost("/api/save-idea", row);
      flash(editing ? "Idea updated" : "Idea captured");
      onSaved();
    } catch (e) {
      try {
        const pend = JSON.parse(localStorage.getItem("mad:pendingIdeas") || "[]");
        pend.push(row);
        localStorage.setItem("mad:pendingIdeas", JSON.stringify(pend));
        flash("Offline — saved locally, will sync");
        onSaved();
      } catch (e2) { flash("Save failed"); }
    }
    setSaving(false);
  };

  const tx = (kind) => (taxo && Array.isArray(taxo[kind]) ? taxo[kind] : []);

  return (
    <div onClick={onClose} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", zIndex: 60, display: "flex", alignItems: "flex-end", justifyContent: "center" }}>
      <div onClick={(e) => e.stopPropagation()} style={{ background: C.surface, borderTop: `1px solid ${C.line2}`, borderRadius: "16px 16px 0 0", width: "100%", maxWidth: 720, maxHeight: "88vh", animation: "sheet 0.22s ease-out", overflow: "auto", padding: "16px 16px calc(16px + env(safe-area-inset-bottom, 0px))" }}>
        <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 14 }}>
          <div style={{ fontFamily: "'Archivo', sans-serif", fontWeight: 700, fontSize: 16, flex: 1 }}>{editing ? "Edit idea" : "Capture an idea"}</div>
          <Btn small kind="ghost" onClick={onClose}>Close</Btn>
        </div>

        {/* voice — the primary affordance when capturing fresh */}
        {!editing && (
          <div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 12 }}>
            <button onClick={toggleRec} aria-label="Record" style={{
              width: 56, height: 56, borderRadius: "50%", border: "none", cursor: "pointer",
              background: rec === "recording" ? C.red : C.gold, color: "#0a0a0b",
              display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
              transition: "background 0.2s ease",
            }}><IcMic size={24} /></button>
            <div style={{ fontSize: 13, color: C.dim }}>
              {rec === "recording" ? "Recording — tap to stop" : rec === "processing" ? "Transcribing…" : "Tap to speak the idea"}
            </div>
          </div>
        )}

        <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
          <div><Label>Raw idea / transcript</Label><TArea value={f.raw} onChange={(e) => set("raw", e.target.value)} placeholder="Speak or type the idea…" /></div>
          <Btn small kind="ghost" onClick={classify} disabled={classifying}>{classifying ? "Classifying…" : "Classify with AI"}</Btn>
          <div><Label>Title</Label><Input value={f.title} onChange={(e) => set("title", e.target.value)} placeholder="One short line" /></div>
          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
            <div><Label>Angle</Label><Sel value={f.angle} onChange={(e) => set("angle", e.target.value)} placeholder="—" options={registry.map((r) => ({ value: r.code, label: `${r.code} — ${r.name}` }))} /></div>
            <div><Label>Lever</Label><Sel value={f.lever} onChange={(e) => set("lever", e.target.value)} options={tx("levers")} /></div>
            <div><Label>Hook style</Label><Sel value={f.hook} onChange={(e) => set("hook", e.target.value)} options={tx("hooks")} /></div>
            <div><Label>Format</Label><Sel value={f.format} onChange={(e) => set("format", e.target.value)} options={tx("formats")} /></div>
            <div><Label>Layout</Label><Sel value={f.layout} onChange={(e) => set("layout", e.target.value)} options={tx("layouts")} /></div>
            <div><Label>CTA</Label><Sel value={f.cta} onChange={(e) => set("cta", e.target.value)} options={tx("ctas")} /></div>
          </div>
          <div><Label>Hook text — the 3-second opener</Label><TArea value={f.hookText} onChange={(e) => set("hookText", e.target.value)} style={{ minHeight: 48 }} /></div>
          <div><Label>Notes</Label><TArea value={f.notes} onChange={(e) => set("notes", e.target.value)} style={{ minHeight: 44 }} /></div>
          <div>
            <Label>Status</Label>
            <div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
              {IDEA_STATUSES.map((s) => (
                <button key={s} onClick={() => set("status", s)} style={{
                  background: f.status === s ? C.goldSoft : "transparent", color: f.status === s ? C.gold : C.faint,
                  border: `1px solid ${f.status === s ? C.gold + "66" : C.line}`, borderRadius: 999,
                  padding: "5px 12px", fontSize: 12, fontWeight: 600, cursor: "pointer", fontFamily: "inherit",
                }}>{s}</button>
              ))}
            </div>
          </div>
          <div>
            <Label>Reference images</Label>
            <div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
              {f.images.map((p, i) => (
                <span key={p} style={{ position: "relative" }}>
                  <img src={`/api/idea-image?path=${encodeURIComponent(p)}`} style={{ width: 56, height: 56, objectFit: "cover", borderRadius: 8, border: `1px solid ${C.line}` }} />
                  <button onClick={() => setF((pr) => ({ ...pr, images: pr.images.filter((_, j) => j !== i) }))}
                    style={{ position: "absolute", top: -6, right: -6, background: C.raised, color: C.red, border: `1px solid ${C.line2}`, borderRadius: "50%", width: 20, height: 20, fontSize: 11, cursor: "pointer", lineHeight: 1 }}>×</button>
                </span>
              ))}
              <label style={{ width: 56, height: 56, border: `1px dashed ${C.line2}`, borderRadius: 8, display: "flex", alignItems: "center", justifyContent: "center", color: C.faint, cursor: "pointer", fontSize: 22 }}>
                +<input type="file" accept="image/*" onChange={onImage} style={{ display: "none" }} />
              </label>
            </div>
          </div>
          <Btn onClick={save} disabled={saving} style={{ width: "100%" }}>{saving ? "Saving…" : editing ? "Save changes" : "File to library"}</Btn>
        </div>
      </div>
    </div>
  );
}

/* ================= RESEARCH ================= */
/* Competitor intel from the Meta Ad Library. Longevity is the signal —
   an ad running for weeks is one that's working. Study it, then pull the
   hook straight into the Ideate library to feed the creation loop. */
const RESEARCH_COUNTRIES = [
  { value: "GB", label: "United Kingdom (best coverage)" },
  { value: "AU", label: "Australia" },
  { value: "US", label: "United States" },
  { value: "NZ", label: "New Zealand" },
];
const RESEARCH_STATUS = [
  { value: "active", label: "Active only" },
  { value: "all", label: "All (incl. stopped)" },
  { value: "inactive", label: "Stopped only" },
];

function Research({ angles, log, flash }) {
  const [f, setF] = useState(() => {
    let saved = {};
    try { saved = JSON.parse(localStorage.getItem("cutman:research:defaults") || "{}"); } catch (e) {}
    return { q: saved.q || "", pageIds: "", country: saved.country || "GB", status: "active" };
  });
  const [loading, setLoading] = useState(false);
  const [results, setResults] = useState(null);
  const [note, setNote] = useState("");
  const [expanded, setExpanded] = useState({});
  const [saved, setSaved] = useState({});
  const set = (k, v) => setF((p) => ({ ...p, [k]: v }));

  const search = async () => {
    if (!f.q.trim() && !f.pageIds.trim()) return flash("Enter search terms or advertiser Page ids");
    setLoading(true); setResults(null); setNote("");
    try {
      const params = new URLSearchParams({ country: f.country, status: f.status, limit: "30" });
      if (f.q.trim()) params.set("q", f.q.trim());
      if (f.pageIds.trim()) params.set("page_ids", f.pageIds.trim());
      const d = await apiGet(`/api/ad-library?${params.toString()}`);
      setResults(d.results || []);
      if (d.note) setNote(d.note);
      try { localStorage.setItem("cutman:research:defaults", JSON.stringify({ q: f.q, country: f.country })); } catch (e) {}
      log("research", `Ad Library: "${f.q || f.pageIds}" (${f.country}) → ${(d.results || []).length} ads`);
    } catch (e) {
      flash("Research failed — " + (e.message || "check META_AD_LIBRARY_TOKEN"));
    }
    setLoading(false);
  };

  // Close the loop: a competitor hook becomes a queued idea in Ideate
  const saveAsIdea = async (r) => {
    const body = (r.bodies[0] || r.titles[0] || "").trim();
    if (!body) return flash("No copy on this ad to save");
    try {
      await apiPost("/api/save-idea", {
        raw: body,
        title: body.slice(0, 70),
        hook_text: r.titles[0] || body.slice(0, 90),
        notes: `Competitor research — ${r.page_name}${r.days_running != null ? ` · running ${r.days_running}d` : ""}`,
        status: "idea",
      });
      setSaved((s) => ({ ...s, [r.id]: true }));
      log("research", `Saved competitor hook from ${r.page_name} to Ideate`);
      flash("Saved to Ideate as an idea");
    } catch (e) {
      flash("Save failed — check connection");
    }
  };

  return (
    <div>
      <div style={{ fontSize: 10.5, letterSpacing: "0.14em", textTransform: "uppercase", color: C.faint, fontWeight: 600, marginBottom: 4 }}>COMPETITOR RESEARCH</div>
      <div style={{ color: C.dim, fontSize: 13.5, marginBottom: 16 }}>Meta Ad Library. Long-running ads are the ones that work — study them, then pull the hook into Ideate.</div>

      <Card style={{ marginBottom: 14 }}>
        <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
          <div><Label>Search terms</Label><Input value={f.q} onChange={(e) => set("q", e.target.value)} placeholder="fight training, MMA coaching, jiu jitsu…" onKeyDown={(e) => { if (e.key === "Enter") search(); }} /></div>
          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
            <div><Label>Country</Label><Sel value={f.country} onChange={(e) => set("country", e.target.value)} options={RESEARCH_COUNTRIES} /></div>
            <div><Label>Status</Label><Sel value={f.status} onChange={(e) => set("status", e.target.value)} options={RESEARCH_STATUS} /></div>
          </div>
          <details>
            <summary style={{ fontSize: 12, color: C.faint, cursor: "pointer", fontFamily: "'JetBrains Mono', monospace", letterSpacing: "0.1em", textTransform: "uppercase" }}>Or search a specific advertiser</summary>
            <div style={{ marginTop: 8 }}><Label>Advertiser Page ids (comma-separated)</Label><Input value={f.pageIds} onChange={(e) => set("pageIds", e.target.value)} placeholder="1004040636131255" /></div>
          </details>
          <Btn onClick={search} disabled={loading} style={{ width: "100%" }}>{loading ? "Searching…" : "Search Ad Library"}</Btn>
        </div>
      </Card>

      {note && <Card style={{ marginBottom: 14, borderLeft: `3px solid ${C.blue}`, color: C.dim, fontSize: 13 }}>{note}</Card>}

      {results && results.length > 0 && (
        <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
          {results.map((r) => {
            const isOpen = expanded[r.id];
            const body = r.bodies[0] || "";
            const long = body.length > 220;
            return (
              <Card key={r.id}>
                <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
                  <div style={{ fontWeight: 700, fontSize: 15, flex: 1 }}>{r.page_name}</div>
                  {r.days_running != null && (
                    <Chip tone={r.days_running >= 30 ? "gold" : r.days_running >= 7 ? "green" : "dim"}>
                      {r.days_running}d running{r.days_running >= 30 ? " · winner?" : ""}
                    </Chip>
                  )}
                </div>
                {r.titles[0] && <div style={{ fontWeight: 600, fontSize: 14, color: C.ink, marginBottom: 4 }}>{r.titles[0]}</div>}
                {body && (
                  <div style={{ fontSize: 13.5, color: C.dim, marginBottom: 8, whiteSpace: "pre-wrap" }}>
                    {isOpen || !long ? body : body.slice(0, 220) + "…"}
                    {long && <button onClick={() => setExpanded((s) => ({ ...s, [r.id]: !isOpen }))} style={{ background: "none", border: "none", color: C.gold, cursor: "pointer", fontSize: 12.5, fontWeight: 600, padding: "0 0 0 6px" }}>{isOpen ? "less" : "more"}</button>}
                  </div>
                )}
                <div style={{ display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center", marginBottom: 10 }}>
                  {(r.platforms || []).map((p) => <Chip key={p} tone="dim">{p}</Chip>)}
                  {r.captions[0] && <span style={{ fontSize: 12, color: C.faint }}>{r.captions[0]}</span>}
                </div>
                <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
                  {r.snapshot_url && <a href={r.snapshot_url} target="_blank" rel="noreferrer" style={{ color: C.gold, fontSize: 13, fontWeight: 600 }}>View creative ↗</a>}
                  <div style={{ flex: 1 }} />
                  <Btn small kind="ghost" disabled={saved[r.id]} onClick={() => saveAsIdea(r)}>{saved[r.id] ? "✓ In Ideate" : "→ Save as idea"}</Btn>
                </div>
              </Card>
            );
          })}
        </div>
      )}

      {results && results.length === 0 && !note && (
        <Card style={{ textAlign: "center", color: C.dim, padding: 28 }}>No ads found. Try broader terms or country=GB for the widest commercial coverage.</Card>
      )}
    </div>
  );
}

/* ================= MARKETING / ANGLE ================= */
function Marketing({ tax, registry, angles, setAngles, cycles, setCycles, assemblies, metrics, log, flash }) {
  const [angleForm, setAngleForm] = useState(null);
  const [cycleForm, setCycleForm] = useState(null);
  const [closing, setClosing] = useState(null); // { cycleId, answer }

  const active = angles.find((a) => a.status === "Active");
  const others = angles.filter((a) => a.id !== active?.id);

  const angleStats = (id) => {
    const asms = assemblies.filter((a) => a.angleId === id);
    const rows = metrics.filter((r) => asms.some((a) => a.id === r.assemblyId));
    const spend = rows.reduce((t, r) => t + (+r.spend || 0), 0);
    const conv = rows.reduce((t, r) => t + (+r.conversions || 0), 0);
    const leads = rows.reduce((t, r) => t + (+r.leads || 0), 0);
    const ctr = rows.length ? rows.reduce((t, r) => t + (+r.ctr || 0), 0) / rows.length : null;
    return { n: asms.length, spend, conv, leads, ctr, cpl: leads > 0 ? spend / leads : null };
  };

  const saveAngle = () => {
    const f = angleForm;
    if (!f.name.trim()) return flash("Name the angle");
    if (f.id) { setAngles((as) => as.map((x) => (x.id === f.id ? f : x))); log("angle", `Updated angle "${f.name}"`); }
    else {
      const a = { ...f, id: uid(), createdAt: new Date().toISOString() };
      setAngles((as) => [...as, a]); log("angle", `New angle "${a.name}" (${a.status})`);
    }
    setAngleForm(null); flash("Angle saved");
  };

  // Remove an angle from the workspace bench. The registry (Supabase angles
  // table) is untouched — codes stay immutable there; this only drops it from
  // this workspace's list. Linked creatives keep their angle_code (readback
  // intact) but lose the bench link.
  const deleteAngle = () => {
    const f = angleForm;
    if (!f.id) return;
    const linked = assemblies.filter((a) => a.angleId === f.id).length;
    const warn = linked
      ? `Remove "${f.name}" from the bench? ${linked} creative${linked === 1 ? "" : "s"} link to it — they keep their ${f.code || "angle"} code for readback but lose the bench link.`
      : `Remove "${f.name}" from the bench?`;
    if (typeof window !== "undefined" && !window.confirm(warn)) return;
    setAngles((as) => as.filter((x) => x.id !== f.id));
    log("angle", `Removed angle "${f.name}" from the bench`);
    setAngleForm(null); flash("Angle removed");
  };

  const saveCycle = () => {
    const f = cycleForm;
    if (!f.name.trim() || !f.angleId) return flash("Cycle needs a name and an angle");
    if (f.id) { setCycles((cs) => cs.map((x) => (x.id === f.id ? f : x))); log("cycle", `Updated ${f.name}`); }
    else {
      const c = { ...f, id: uid(), createdAt: new Date().toISOString() };
      setCycles((cs) => [...cs, c]); log("cycle", `New cycle ${c.name}`);
    }
    setCycleForm(null); flash("Cycle saved");
  };

  const closeCycle = () => {
    if (!closing.answer.trim()) return flash("Record what the market said first");
    const c = cycles.find((x) => x.id === closing.cycleId);
    setCycles((cs) => cs.map((x) => (x.id === closing.cycleId ? { ...x, status: "Closed", answer: closing.answer } : x)));
    log("cycle", `Closed ${c?.name} — market verdict recorded`);
    setClosing(null); flash("Cycle closed — verdict on record");
  };

  const blankAngle = { id: null, name: "", thesis: "", why: "", status: "Discovery", code: "" };
  const blankCycle = { id: null, angleId: active?.id || angles[0]?.id || "", name: "", question: "", startDate: today(), endDate: "", status: "Planned", answer: "" };
  const sortedCycles = [...cycles].sort((a, b) => (a.startDate || "").localeCompare(b.startDate || ""));
  const convo = cycles.filter((c) => c.status === "Closed" && c.answer)
    .sort((a, b) => (b.endDate || b.startDate || "").localeCompare(a.endDate || a.startDate || ""));

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
      {/* active angle hero */}
      {active ? (
        <Card style={{ border: `1px solid ${C.gold}66`, padding: 18 }}>
          <Label>Active angle · what we&rsquo;re saying to the market</Label>
          <div style={{ display: "flex", alignItems: "center", gap: 10, margin: "4px 0 8px", flexWrap: "wrap" }}>
            <div style={{ fontFamily: "'Archivo', sans-serif", fontWeight: 700, fontSize: 28, letterSpacing: "0.02em", color: C.gold }}>
              {active.name.toUpperCase()}
            </div>
            {active.code ? <Plate code={active.code} /> : <Chip tone="red">no registry code — ad naming blocked</Chip>}
          </div>
          {active.thesis && <div style={{ fontSize: 14.5, color: C.ink, marginBottom: 8 }}>{active.thesis}</div>}
          {active.why && <div style={{ fontSize: 13, color: C.dim, marginBottom: 10 }}><b style={{ color: C.faint }}>Why this, why now:</b> {active.why}</div>}
          {(() => { const s = angleStats(active.id); return (
            <div style={{ display: "flex", gap: 14, fontSize: 13, color: C.dim, flexWrap: "wrap", marginBottom: 12 }}>
              <span><b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{s.n}</b> creatives</span>
              <span>Spend <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>${s.spend.toFixed(0)}</b></span>
              <span>CPL <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{s.cpl != null ? "$" + s.cpl.toFixed(2) : "—"}</b></span>
              <span>Leads <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{s.leads}</b></span>
              <span>Avg CTR <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{s.ctr != null ? s.ctr.toFixed(2) + "%" : "—"}</b></span>
              <span>Conv <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{s.conv}</b></span>
            </div>
          ); })()}
          <Btn small kind="ghost" onClick={() => setAngleForm({ ...active })}>Edit angle</Btn>
        </Card>
      ) : (
        <Card style={{ textAlign: "center", color: C.dim, padding: 24 }}>No active angle. Set one — it&rsquo;s the message everything else tests.</Card>
      )}

      {angleForm && (
        <Card style={{ border: `1px solid ${C.gold}55` }}>
          <div style={{ fontSize: 10.5, letterSpacing: "0.14em", textTransform: "uppercase", color: C.faint, fontWeight: 600, marginBottom: 12 }}>{angleForm.id ? "EDIT ANGLE" : "NEW ANGLE"}</div>
          <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
            <div><Label>Name</Label><Input value={angleForm.name} onChange={(e) => setAngleForm({ ...angleForm, name: e.target.value })} placeholder="Build the Version of You That Wins" /></div>
            <div><Label>Thesis — what we&rsquo;re saying</Label><TArea value={angleForm.thesis} onChange={(e) => setAngleForm({ ...angleForm, thesis: e.target.value })} /></div>
            <div><Label>Why — positioning rationale</Label><TArea value={angleForm.why} onChange={(e) => setAngleForm({ ...angleForm, why: e.target.value })} /></div>
            <div><Label>Status</Label><Sel value={angleForm.status} onChange={(e) => setAngleForm({ ...angleForm, status: e.target.value })} options={ANGLE_STATUSES} /></div>
            <div><Label>Registry code — 3-letter, from the angles table (NAMING.md)</Label>
              <Sel value={angleForm.code || ""} onChange={(e) => setAngleForm({ ...angleForm, code: e.target.value })} placeholder="No code — ad naming blocked"
                options={registry.map((r) => ({ value: r.code, label: `${r.code} — ${r.name}` }))} />
              <div style={{ fontSize: 12, color: C.faint, marginTop: 6 }}>New angle without a code? Insert a row in the Supabase angles table first — codes are the registry, never hardcoded.</div>
            </div>
            <div style={{ display: "flex", gap: 8 }}>
              <Btn onClick={saveAngle} style={{ flex: 1 }}>Save angle</Btn>
              {angleForm.id && <Btn kind="danger" onClick={deleteAngle}>Delete</Btn>}
              <Btn kind="ghost" onClick={() => setAngleForm(null)}>Cancel</Btn>
            </div>
          </div>
        </Card>
      )}

      {/* other angles */}
      <div>
        <div style={{ display: "flex", alignItems: "center", marginBottom: 6 }}>
          <Label>Angle bench — staged for future quarters</Label>
          <div style={{ flex: 1 }} />
          <Btn small kind="ghost" onClick={() => setAngleForm({ ...blankAngle })}>+ Angle</Btn>
        </div>
        <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
          {others.map((a) => {
            const s = angleStats(a.id);
            return (
              <Card key={a.id} style={{ padding: 12, cursor: "pointer" }} onClick={() => setAngleForm({ ...a })}>
                <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                  <div style={{ fontWeight: 600, fontSize: 15, flex: 1 }}>{a.name}</div>
                  <Chip tone={a.status === "Proven" ? "green" : a.status === "Retired" ? "red" : "dim"}>{a.status}</Chip>
                </div>
                {a.thesis && <div style={{ fontSize: 13, color: C.dim, marginTop: 4 }}>{a.thesis}</div>}
                {s.n > 0 && <div style={{ fontSize: 12.5, color: C.faint, marginTop: 6 }}>{s.n} creatives · ${s.spend.toFixed(0)} spend · {s.ctr != null ? s.ctr.toFixed(2) + "% CTR" : "no metrics"}</div>}
              </Card>
            );
          })}
          {others.length === 0 && <Card style={{ color: C.faint, fontSize: 13, textAlign: "center" }}>Never enter a new quarter without the next angle staged here.</Card>}
        </div>
      </div>

      {/* cycles */}
      <div>
        <div style={{ display: "flex", alignItems: "center", marginBottom: 6 }}>
          <Label>Cycles — the questions we&rsquo;re asking</Label>
          <div style={{ flex: 1 }} />
          <Btn small kind="ghost" onClick={() => setCycleForm({ ...blankCycle })}>+ Cycle</Btn>
        </div>
        <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
          {sortedCycles.map((c) => {
            const ang = angles.find((a) => a.id === c.angleId);
            return (
              <Card key={c.id} style={{ padding: 12, borderLeft: `3px solid ${c.status === "Active" ? C.gold : c.status === "Closed" ? C.green : C.line}` }}>
                <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                  <div style={{ fontWeight: 600, fontSize: 14.5, flex: 1 }}>{c.name}</div>
                  <Chip tone={c.status === "Active" ? "gold" : c.status === "Closed" ? "green" : "dim"}>{c.status}</Chip>
                </div>
                {ang && <div style={{ fontSize: 12, color: C.faint, marginTop: 3 }}>{ang.name} · {c.startDate || "?"} → {c.endDate || "?"}</div>}
                {c.question && <div style={{ fontSize: 13, color: C.dim, marginTop: 6 }}><b style={{ color: C.faint }}>Q:</b> {c.question}</div>}
                {c.answer && <div style={{ fontSize: 13, color: C.ink, marginTop: 6 }}><b style={{ color: C.green }}>Market said:</b> {c.answer}</div>}
                <div style={{ display: "flex", gap: 8, marginTop: 10, flexWrap: "wrap" }}>
                  {c.status === "Planned" && <Btn small onClick={() => { setCycles((cs) => cs.map((x) => (x.id === c.id ? { ...x, status: "Active" } : x))); log("cycle", `${c.name} activated`); }}>Activate</Btn>}
                  {c.status === "Active" && <Btn small onClick={() => setClosing({ cycleId: c.id, answer: "" })}>Close cycle</Btn>}
                  <Btn small kind="ghost" onClick={() => setCycleForm({ ...c })}>Edit</Btn>
                </div>
                {closing?.cycleId === c.id && (
                  <div style={{ marginTop: 10 }}>
                    <Label>What did the market say? (the cycle&rsquo;s verdict)</Label>
                    <TArea value={closing.answer} onChange={(e) => setClosing({ ...closing, answer: e.target.value })}
                      placeholder="e.g. 'Move well today' won hook rate by 2x on cold; video overlay beat static on CTR; competitors persona converted best." />
                    <div style={{ display: "flex", gap: 8, marginTop: 8 }}>
                      <Btn small onClick={closeCycle} style={{ flex: 1 }}>Record verdict & close</Btn>
                      <Btn small kind="ghost" onClick={() => setClosing(null)}>Cancel</Btn>
                    </div>
                  </div>
                )}
              </Card>
            );
          })}
        </div>
      </div>

      {cycleForm && (
        <Card style={{ border: `1px solid ${C.gold}55` }}>
          <div style={{ fontSize: 10.5, letterSpacing: "0.14em", textTransform: "uppercase", color: C.faint, fontWeight: 600, marginBottom: 12 }}>{cycleForm.id ? "EDIT CYCLE" : "NEW CYCLE"}</div>
          <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
            <div><Label>Name</Label><Input value={cycleForm.name} onChange={(e) => setCycleForm({ ...cycleForm, name: e.target.value })} placeholder="FY27 Q2 · Cycle 1 — October" /></div>
            <div><Label>Angle</Label><Sel value={cycleForm.angleId} onChange={(e) => setCycleForm({ ...cycleForm, angleId: e.target.value })} options={angles.map((a) => ({ value: a.id, label: a.name }))} /></div>
            <div><Label>The question this cycle answers</Label><TArea value={cycleForm.question} onChange={(e) => setCycleForm({ ...cycleForm, question: e.target.value })} /></div>
            <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
              <div><Label>Start</Label><Input type="date" value={cycleForm.startDate} onChange={(e) => setCycleForm({ ...cycleForm, startDate: e.target.value })} /></div>
              <div><Label>End</Label><Input type="date" value={cycleForm.endDate} onChange={(e) => setCycleForm({ ...cycleForm, endDate: e.target.value })} /></div>
            </div>
            <div><Label>Status</Label><Sel value={cycleForm.status} onChange={(e) => setCycleForm({ ...cycleForm, status: e.target.value })} options={CYCLE_STATUSES} /></div>
            <div style={{ display: "flex", gap: 8 }}>
              <Btn onClick={saveCycle} style={{ flex: 1 }}>Save cycle</Btn>
              <Btn kind="ghost" onClick={() => setCycleForm(null)}>Cancel</Btn>
            </div>
          </div>
        </Card>
      )}

      {/* conversation log */}
      {convo.length > 0 && (
        <div>
          <Label>Conversation log — what the market has said so far</Label>
          <div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 6 }}>
            {convo.map((c) => (
              <Card key={c.id} style={{ padding: 12 }}>
                <div style={{ fontSize: 12, color: C.faint, marginBottom: 4 }}>{c.name}</div>
                <div style={{ fontSize: 13.5, color: C.ink }}>{c.answer}</div>
              </Card>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

/* ================= LIBRARY ================= */
function Library({ tax, angles, modules, setModules, nextCode, log, flash }) {
  const [filterSlot, setFilterSlot] = useState("");
  const [editing, setEditing] = useState(null); // module object or "new"
  const [queued, setQueued] = useState(null); // capture-tool ideas awaiting intake
  const slots = [...tax.slots].sort((a, b) => a.order - b.order);

  useEffect(() => {
    (async () => {
      try { setQueued((await apiGet('/api/list-ideas?status=queued')).ideas || []); }
      catch (e) { setQueued([]); /* offline — intake hidden */ }
    })();
  }, []);

  // A queued idea is importable until some module carries its idea_id
  const importedIds = new Set(modules.map((m) => m.ideaId).filter(Boolean));
  const importable = (queued || []).filter((i) => !importedIds.has(i.id));

  const importIdea = (idea) => {
    const matched = angles.find((a) => a.code && a.code === idea.angle_code);
    setEditing({
      id: null, slotId: slots[0]?.id,
      name: idea.title || (idea.hook_text || idea.raw || "").slice(0, 80),
      tags: {}, link: "", keyLine: idea.hook_text || "",
      source: "Capture idea", status: "Queued", notes: idea.notes || "",
      angleId: matched?.id || "", angleCode: idea.angle_code || "", ideaId: idea.id,
    });
  };

  const blank = { id: null, slotId: slots[0]?.id, name: "", tags: {}, link: "", keyLine: "", source: "", status: MODULE_STATUSES[0], notes: "", angleId: "", angleCode: "", ideaId: null };

  const save = (m) => {
    if (!m.name.trim()) return flash("Give it a name first");
    if (m.id) {
      setModules((ms) => ms.map((x) => (x.id === m.id ? m : x)));
      log("module", `Updated ${m.code} · ${m.name}`);
    } else {
      const created = { ...m, id: uid(), code: nextCode(m.slotId), createdAt: new Date().toISOString() };
      setModules((ms) => [...ms, created]);
      log("module", `Added ${created.code} · ${created.name}${created.ideaId ? " — imported from capture" : ""}`);
    }
    setEditing(null); flash("Module saved");
  };

  const shown = modules.filter((m) => !filterSlot || m.slotId === filterSlot);

  return (
    <div>
      <div style={{ display: "flex", gap: 8, alignItems: "center", marginBottom: 14 }}>
        <div style={{ flex: 1 }}>
          <Sel value={filterSlot} onChange={(e) => setFilterSlot(e.target.value)} placeholder="All slots"
            options={slots.map((s) => ({ value: s.id, label: s.name }))} />
        </div>
        <Btn onClick={() => setEditing({ ...blank })}>+ Module</Btn>
      </div>

      {editing && <ModuleForm tax={tax} angles={angles} slots={slots} m={editing} setM={setEditing} onSave={save} onCancel={() => setEditing(null)} />}

      {importable.length > 0 && !editing && (
        <Card style={{ marginBottom: 14, border: `1px solid ${C.blue}66` }}>
          <Label>Queued ideas from capture — pull into the library</Label>
          <div style={{ display: "flex", flexDirection: "column", gap: 10, marginTop: 8 }}>
            {importable.map((i) => (
              <div key={i.id} style={{ display: "flex", alignItems: "center", gap: 10 }}>
                <div style={{ flex: 1, minWidth: 0 }}>
                  <div style={{ fontSize: 14, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                    {i.title || (i.hook_text || i.raw || "").slice(0, 60) || "Untitled idea"}
                  </div>
                  <div style={{ fontSize: 12, color: C.faint, marginTop: 2 }}>
                    {[i.angle_code, i.lever, i.format_hint].filter(Boolean).join(" · ") || "unclassified"}
                  </div>
                </div>
                <Btn small kind="ghost" onClick={() => importIdea(i)}>Import</Btn>
              </div>
            ))}
          </div>
          <div style={{ fontSize: 12, color: C.faint, marginTop: 10 }}>Importing links the module to its idea (idea_id) — the idea stays queued in capture until it's shot.</div>
        </Card>
      )}

      {shown.length === 0 && !editing && (
        <Card style={{ textAlign: "center", color: C.dim, padding: 28 }}>
          No modules {filterSlot ? "in this slot" : "yet"}. Add your first clip — a hook, a GSP body cut, a testimonial.
        </Card>
      )}

      <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
        {shown.map((m) => {
          const slot = slots.find((s) => s.id === m.slotId);
          return (
            <Card key={m.id} onClick={() => setEditing({ ...m })} style={{ cursor: "pointer" }}>
              <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 6 }}>
                <Plate code={m.code} />
                <div style={{ fontWeight: 600, fontSize: 15.5, flex: 1 }}>{m.name}</div>
                <Chip tone={m.status === "Live" ? "gold" : m.status === "Retired" ? "red" : "dim"}>{m.status}</Chip>
              </div>
              {m.keyLine && <div style={{ color: C.dim, fontSize: 13.5, marginBottom: 8, fontStyle: "italic" }}>&ldquo;{m.keyLine}&rdquo;</div>}
              <div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
                <Chip tone="blue">{slot?.name}</Chip>
                {m.angleId && angles.find((a) => a.id === m.angleId) && <Chip tone="gold">{angles.find((a) => a.id === m.angleId).name}</Chip>}
                {Object.entries(m.tags || {}).filter(([, v]) => v).map(([k, v]) => <Chip key={k}>{v}</Chip>)}
                {m.link && <Chip tone="green">linked ↗</Chip>}
              </div>
            </Card>
          );
        })}
      </div>
    </div>
  );
}

function ModuleForm({ tax, angles, slots, m, setM, onSave, onCancel }) {
  const set = (k, v) => setM({ ...m, [k]: v });
  return (
    <Card style={{ marginBottom: 14, border: `1px solid ${C.gold}55` }}>
      <div style={{ fontSize: 10.5, letterSpacing: "0.14em", textTransform: "uppercase", color: C.faint, fontWeight: 600, marginBottom: 12, letterSpacing: "0.03em" }}>
        {m.id ? `EDIT ${m.code}` : "NEW MODULE"}
      </div>
      <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
        <div><Label>Name</Label><Input value={m.name} onChange={(e) => set("name", e.target.value)} placeholder="GSP — fighting is a game" /></div>
        <div><Label>Slot</Label><Sel value={m.slotId} onChange={(e) => set("slotId", e.target.value)} options={slots.map((s) => ({ value: s.id, label: s.name }))} /></div>
        <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
          {Object.keys(tax.attrs).map((cat) => (
            <div key={cat}><Label>{cat}</Label>
              <Sel value={m.tags?.[cat]} onChange={(e) => setM({ ...m, tags: { ...m.tags, [cat]: e.target.value } })} options={tax.attrs[cat]} placeholder="—" />
            </div>
          ))}
          <div><Label>Status</Label><Sel value={m.status} onChange={(e) => set("status", e.target.value)} options={MODULE_STATUSES} /></div>
          <div><Label>Source</Label><Input value={m.source} onChange={(e) => set("source", e.target.value)} placeholder="YT long-form 12 Jul" /></div>
          <div style={{ gridColumn: "1 / -1" }}><Label>Marketing angle</Label>
            <Sel value={m.angleId} onChange={(e) => set("angleId", e.target.value)} placeholder="No angle"
              options={angles.map((a) => ({ value: a.id, label: a.name }))} />
          </div>
        </div>
        <div><Label>Asset link (Drive etc.)</Label><Input value={m.link} onChange={(e) => set("link", e.target.value)} placeholder="https://drive.google.com/…" /></div>
        <div><Label>Key line / transcript</Label><TArea value={m.keyLine} onChange={(e) => set("keyLine", e.target.value)} placeholder="The line that carries it" /></div>
        <div><Label>Notes</Label><TArea value={m.notes} onChange={(e) => set("notes", e.target.value)} style={{ minHeight: 44 }} /></div>
        <div style={{ display: "flex", gap: 8 }}>
          <Btn onClick={() => onSave(m)} style={{ flex: 1 }}>Save module</Btn>
          <Btn kind="ghost" onClick={onCancel}>Cancel</Btn>
        </div>
      </div>
    </Card>
  );
}

/* ================= BUILD ================= */
function Build({ tax, angles, cycles, modules, assemblies, setAssemblies, log, flash, goTrack }) {
  const slots = [...tax.slots].sort((a, b) => a.order - b.order);
  const [picks, setPicks] = useState({});
  const activeAngle = angles.find((a) => a.status === "Active");
  const [ctx, setCtx] = useState({
    angleId: activeAngle?.id || "",
    cycleId: cycles.find((c) => c.status === "Active" && c.angleId === activeAngle?.id)?.id || "",
  });
  const ctxCycles = cycles.filter((c) => !ctx.angleId || c.angleId === ctx.angleId);
  const chosen = slots.map((s) => ({ slot: s, mod: modules.find((m) => m.id === picks[s.id]) })).filter((x) => x.mod);
  const code = chosen.map((x) => x.mod.code).join("-");
  const tested = assemblies.some((a) => a.code === code && code);

  const create = () => {
    if (chosen.length < 2) return flash("Pick at least two modules");
    const a = {
      id: uid(), code, name: code,
      moduleIds: chosen.map((x) => x.mod.id),
      angleId: ctx.angleId || null, cycleId: ctx.cycleId || null,
      campaign: "", platform: "", launchDate: null, status: "Draft",
      retiredAt: null, verdict: "Testing", notes: "", createdAt: new Date().toISOString(),
      brand: "LUFG", market: "", round: "", ratio: "", version: 1, angleCode: "",
    };
    setAssemblies((as) => [...as, a]);
    log("assembly", `Built ${code}`);
    setPicks({}); flash(`${code} created — find it in Track`);
    goTrack();
  };

  return (
    <div>
      <div style={{ fontSize: 10.5, letterSpacing: "0.14em", textTransform: "uppercase", color: C.faint, fontWeight: 600, marginBottom: 4 }}>ASSEMBLE A CREATIVE</div>
      <div style={{ color: C.dim, fontSize: 13.5, marginBottom: 16 }}>One module per slot. The plate below is your ad name in Meta — that&rsquo;s the join key.</div>

      <Card style={{ marginBottom: 12 }}>
        <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
          <div><Label>Angle</Label>
            <Sel value={ctx.angleId} onChange={(e) => setCtx({ angleId: e.target.value, cycleId: "" })} placeholder="No angle"
              options={angles.map((a) => ({ value: a.id, label: a.name }))} />
          </div>
          <div><Label>Cycle</Label>
            <Sel value={ctx.cycleId} onChange={(e) => setCtx({ ...ctx, cycleId: e.target.value })} placeholder="No cycle"
              options={ctxCycles.map((c) => ({ value: c.id, label: c.name }))} />
          </div>
        </div>
      </Card>

      <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
        {slots.map((s) => {
          const opts = modules.filter((m) => m.slotId === s.id && m.status !== "Retired");
          const pick = modules.find((m) => m.id === picks[s.id]);
          return (
            <Card key={s.id}>
              <Label>{s.name}</Label>
              <Sel value={picks[s.id] || ""} onChange={(e) => setPicks({ ...picks, [s.id]: e.target.value })}
                placeholder={opts.length ? `Choose ${s.name.toLowerCase()}…` : "No modules in this slot yet"}
                options={opts.map((m) => ({ value: m.id, label: `${m.code} · ${m.name}` }))} />
              {pick && (
                <div style={{ marginTop: 8, display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center" }}>
                  {Object.values(pick.tags || {}).filter(Boolean).map((v, i) => <Chip key={i}>{v}</Chip>)}
                  {pick.link ? <a href={pick.link} target="_blank" rel="noreferrer" style={{ color: C.green, fontSize: 12.5, fontWeight: 600 }}>asset ↗</a> : <Chip tone="red">no asset link</Chip>}
                </div>
              )}
            </Card>
          );
        })}
      </div>

      <Card style={{ marginTop: 16, border: `1px solid ${C.gold}55`, textAlign: "center", padding: 18 }}>
        <Label>Fight card</Label>
        <div style={{ margin: "6px 0 12px" }}>{code ? <Plate code={code} big /> : <span style={{ color: C.faint }}>Pick modules to generate the plate</span>}</div>
        {tested && <div style={{ color: C.red, fontSize: 13, marginBottom: 10, fontWeight: 600 }}>⚠ This exact combination has already been built</div>}
        <Btn onClick={create} disabled={chosen.length < 2} style={{ width: "100%" }}>Create assembly</Btn>
      </Card>

      <StageToMeta chosen={chosen} code={code} ctx={ctx} angles={angles} assemblies={assemblies} setAssemblies={setAssemblies} log={log} flash={flash} />
    </div>
  );
}

/* ============ STAGE TO META ============
   Runs the pipeline UP TO the human gate: image → creative → ad,
   named to taxonomy, tagged with ?src=, left PAUSED in the target
   ad set. Launching is a human decision in Ads Manager — this
   panel (and the endpoint behind it) never activates anything. */
function StageToMeta({ chosen, code, ctx, angles, assemblies, setAssemblies, log, flash }) {
  const angleCode = angles.find((a) => a.id === ctx.angleId)?.code || "";
  const [f, setF] = useState(() => {
    let saved = {};
    try { saved = JSON.parse(localStorage.getItem("cutman:stage:defaults") || "{}"); } catch (e) {}
    return {
      adsetId: saved.adsetId || "", market: saved.market || "US", round: "R1", version: 1,
      title: "", body: "", description: "", landingBase: saved.landingBase || "",
      srcCode: "", descriptor: "", image: null, imageName: "",
    };
  });
  const [staging, setStaging] = useState(false);
  const [result, setResult] = useState(null);
  const set = (k, v) => setF((p) => ({ ...p, [k]: v }));
  const srcDefault = [f.market, f.round, angleCode].filter(Boolean).join("_").toLowerCase();

  const onFile = (e) => {
    const file = e.target.files && e.target.files[0];
    if (!file) return;
    if (file.size > 8 * 1024 * 1024) return flash("Image too large (max 8MB)");
    const r = new FileReader();
    r.onload = () => setF((p) => ({ ...p, image: r.result, imageName: file.name }));
    r.readAsDataURL(file);
  };

  // The descriptor is the creative's fingerprint in the ad name (NAMING.md):
  // the PLATE for modular creatives, a typed name (FighterProfile) for
  // single statics. Either way it becomes the assembly's code, which is
  // what meta-sync matches in ad names — both kinds read back identically.
  const descriptor = (f.descriptor.trim() || code || "").trim();

  const stage = async () => {
    if (!descriptor) return flash("Give the creative a fingerprint — pick modules (plate) or type a static descriptor like FighterProfile");
    if (!angleCode) return flash("Angle needs a registry code — set it on the Angle tab");
    if (!/^\d{5,}$/.test(f.adsetId.trim())) return flash("Ad set id required (numeric, from Ads Manager)");
    if (!f.image) return flash("Attach the 4x5 image");
    if (!f.title.trim() || !f.body.trim()) return flash("Title and body are required");
    if (!/^https:\/\//.test(f.landingBase.trim())) return flash("Landing URL must start with https://");
    setStaging(true); setResult(null);
    try {
      // The assembly is the system's record of this creative — make sure it
      // exists AND is saved server-side before the staged_ads row points at it.
      // code = the fingerprint (plate OR static descriptor), so readback joins work for both.
      let asm = assemblies.find((a) => a.code === descriptor);
      if (!asm) {
        asm = {
          id: uid(), code: descriptor, name: descriptor, moduleIds: chosen.map((x) => x.mod.id),
          angleId: ctx.angleId || null, cycleId: ctx.cycleId || null,
          campaign: "", platform: "Meta", launchDate: null, status: "Draft",
          retiredAt: null, verdict: "Testing", notes: "", createdAt: new Date().toISOString(),
          brand: "LUFG", market: f.market.toUpperCase(), round: f.round, ratio: "4x5",
          version: f.version || 1, angleCode,
        };
        setAssemblies((as) => [...as, asm]);
        log("assembly", `Built ${descriptor} (via staging)`);
      }
      await apiPost('/api/assemblies', [assemblyToRow(asm, angles)]);

      const res = await fetch('/api/stage-to-meta', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          angle_code: angleCode, market: f.market.trim().toUpperCase(), round: f.round,
          adset_id: f.adsetId.trim(), image_4x5: f.image,
          body: f.body.trim(), title: f.title.trim(), description: f.description.trim(),
          landing_base_url: f.landingBase.trim(),
          src_code: (f.srcCode.trim() || srcDefault),
          descriptor, version: f.version || 1, assembly_id: asm.id,
        }),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.detail || data.error || `stage-to-meta ${res.status}`);
      try { localStorage.setItem("cutman:stage:defaults", JSON.stringify({ adsetId: f.adsetId, market: f.market, landingBase: f.landingBase })); } catch (e) {}
      setResult(data);
      log("assembly", `Staged ${data.taxonomy_name} → Meta ad ${data.ad_id}, PAUSED at the gate`);
      flash("Staged to Meta — PAUSED, awaiting your launch");
    } catch (e) {
      flash("Staging failed — " + (e.message || "check configuration"));
    }
    setStaging(false);
  };

  return (
    <Card style={{ marginTop: 12, border: `1px solid ${C.line}` }}>
      <Label>Stage to Meta — stops at the gate, never launches</Label>
      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, margin: "10px 0" }}>
        <div><Label>Ad set id (existing)</Label><Input value={f.adsetId} onChange={(e) => set("adsetId", e.target.value)} placeholder="From Ads Manager" /></div>
        <div><Label>Market</Label><Input value={f.market} onChange={(e) => set("market", e.target.value.toUpperCase())} placeholder="US" /></div>
        <div><Label>Round</Label><Sel value={f.round} onChange={(e) => set("round", e.target.value)} options={ROUNDS} /></div>
        <div><Label>Version</Label><Input type="number" min="1" value={f.version} onChange={(e) => set("version", Math.max(1, parseInt(e.target.value, 10) || 1))} /></div>
        <div style={{ gridColumn: "1 / -1" }}><Label>Landing URL (src is appended)</Label><Input value={f.landingBase} onChange={(e) => set("landingBase", e.target.value)} placeholder="https://levelupfightgame.com/archetype" /></div>
        <div style={{ gridColumn: "1 / -1" }}><Label>src code</Label><Input value={f.srcCode} onChange={(e) => set("srcCode", e.target.value)} placeholder={srcDefault || "us_r1_bvw"} /></div>
        <div style={{ gridColumn: "1 / -1" }}><Label>Descriptor — plate auto-fills from modules; type one for a single static</Label><Input value={f.descriptor} onChange={(e) => set("descriptor", e.target.value)} placeholder={code || "FighterProfile"} /></div>
        <div style={{ gridColumn: "1 / -1" }}><Label>Headline (title)</Label><Input value={f.title} onChange={(e) => set("title", e.target.value)} placeholder="Build the version of you that wins" /></div>
        <div style={{ gridColumn: "1 / -1" }}><Label>Primary text (body)</Label><TArea value={f.body} onChange={(e) => set("body", e.target.value)} /></div>
        <div style={{ gridColumn: "1 / -1" }}><Label>Link description (optional)</Label><Input value={f.description} onChange={(e) => set("description", e.target.value)} /></div>
        <div style={{ gridColumn: "1 / -1" }}>
          <Label>Creative image — single 4x5 static</Label>
          <label style={{ display: "flex", alignItems: "center", gap: 10, background: C.raised, border: `1px solid ${C.line}`, borderRadius: 8, padding: "10px 12px", cursor: "pointer" }}>
            <input type="file" accept="image/*" onChange={onFile} style={{ display: "none" }} />
            <span style={{ color: f.image ? C.green : C.dim, fontSize: 14 }}>{f.image ? `✓ ${f.imageName}` : "Choose image…"}</span>
          </label>
        </div>
      </div>
      {descriptor && angleCode ? (
        <div style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: 12, color: C.gold, background: "#12140a", border: `1px solid ${C.gold}44`, borderRadius: 6, padding: "7px 10px", marginBottom: 10, wordBreak: "break-all" }}>
          {["LUFG", f.market || "?", f.round, angleCode, descriptor, `v${f.version || 1}`].join(" | ")}
        </div>
      ) : (
        <div style={{ color: C.faint, fontSize: 12.5, marginBottom: 10 }}>Pick modules or type a descriptor (and set the angle&rsquo;s registry code) to preview the ad name.</div>
      )}
      <Btn onClick={stage} disabled={staging} style={{ width: "100%" }}>{staging ? "Staging…" : "Stage to Meta (PAUSED)"}</Btn>
      <div style={{ fontSize: 12, color: C.faint, marginTop: 8 }}>
        Uploads the image, builds the creative, creates the ad in the target ad set — named to taxonomy, funnel-tagged, and left <b style={{ color: C.ink }}>PAUSED</b>. You launch it in Ads Manager. Always.
      </div>
      {result && (
        <div style={{ marginTop: 12, borderTop: `1px solid ${C.line}`, paddingTop: 12 }}>
          <div style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: 12.5, color: C.gold, marginBottom: 6, wordBreak: "break-all" }}>{result.taxonomy_name}</div>
          <div style={{ fontSize: 13, color: C.dim, marginBottom: 8 }}>Ad <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{result.ad_id}</b> staged · status <Chip tone="blue">PAUSED</Chip></div>
          {result.warning && <div style={{ color: C.red, fontSize: 12.5, marginBottom: 8 }}>⚠ {result.warning}</div>}
          <a href={result.preview_url} target="_blank" rel="noreferrer" style={{ color: C.gold, fontSize: 13.5, fontWeight: 600 }}>Review & launch in Ads Manager ↗</a>
        </div>
      )}
    </Card>
  );
}

/* ============ META AD NAME (NAMING.md) ============
   BRAND | MARKET | ROUND | ANGLE | PLATE | RATIO | vN
   e.g.  LUFG | US | R1 | BVW | H02-B01-O01 | 4x5 | v1
   Tokens persist on the assembly; the plate is the join key. */
const ROUNDS = ["R1", "R2", "R3", "SCL"];
const RATIOS = ["4x5", "1x1", "9x16", "16x9"];
// Creative types — a creative can be built from modules OR made entirely
// outside the module generator (a static or video from Photoshop/Premiere/etc.).
const CREATIVE_KINDS = ["Static", "Video", "Carousel", "Other"];

function AdName({ a, angles, upd, flash }) {
  const angleCode = angles.find((x) => x.id === a.angleId)?.code || a.angleCode || "";
  const copy = async (text, label) => {
    try { await navigator.clipboard.writeText(text); flash(label + " copied"); }
    catch { flash("Copy blocked — select the name text manually"); }
  };
  const missing = [
    !a.brand && "brand",
    !a.market && "market",
    !a.round && "round",
    !angleCode && "angle code (assign one to the angle in the Angle tab)",
    !a.ratio && "ratio",
  ].filter(Boolean);
  const v = a.version || 1;
  const adName = [a.brand, a.market, a.round, angleCode, a.code, a.ratio, `v${v}`].join(" | ");
  const fileName = `${a.brand}_${a.market}-${a.round}_${angleCode}_${a.code}_${a.ratio}_v${v}.png`;
  return (
    <Card style={{ border: `1px solid ${C.gold}44`, background: C.raised }}>
      <Label>Meta ad name — brand | market | round | angle | plate | ratio | vN</Label>
      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, margin: "8px 0 10px" }}>
        <div><Label>Brand</Label><Input value={a.brand || ""} onChange={(e) => upd(a.id, { brand: e.target.value.toUpperCase() })} placeholder="LUFG" /></div>
        <div><Label>Market</Label><Input value={a.market || ""} onChange={(e) => upd(a.id, { market: e.target.value.toUpperCase() })} placeholder="US / AU / NZ" /></div>
        <div><Label>Round</Label><Sel value={a.round || ""} onChange={(e) => upd(a.id, { round: e.target.value })} placeholder="—" options={ROUNDS} /></div>
        <div><Label>Ratio</Label><Sel value={a.ratio || ""} onChange={(e) => upd(a.id, { ratio: e.target.value })} placeholder="—" options={RATIOS} /></div>
        <div><Label>Version — bump instead of duplicating, never "- Copy"</Label>
          <Input type="number" min="1" value={a.version || 1} onChange={(e) => upd(a.id, { version: Math.max(1, parseInt(e.target.value, 10) || 1) })} />
        </div>
      </div>
      {missing.length > 0 ? (
        <div style={{ color: C.red, fontSize: 13, fontWeight: 600 }}>⚠ Name blocked — missing {missing.join(", ")}</div>
      ) : (
        <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
          <div style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: 13, fontWeight: 600, color: C.gold, background: "#12140a", border: `1px solid ${C.gold}44`, borderRadius: 6, padding: "8px 10px", wordBreak: "break-all" }}>{adName}</div>
          <div style={{ display: "flex", gap: 8 }}>
            <Btn small onClick={() => copy(adName, "Ad name")} style={{ flex: 1 }}>Copy ad name</Btn>
            <Btn small kind="ghost" onClick={() => copy(fileName, "Export filename")} style={{ flex: 1 }}>Copy filename</Btn>
          </div>
        </div>
      )}
    </Card>
  );
}

/* ============ DRIVE PICKER ============
   Browse the shared Google Drive creatives folder and pick a file — its
   webViewLink becomes the assembly's asset link. Read-only; the service
   account only sees folders shared with it. */
function DrivePicker({ onPick, onClose }) {
  const [stack, setStack] = useState([{ id: "", name: "Creatives" }]); // breadcrumb ("" = default folder)
  const [files, setFiles] = useState(null);
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(true);
  const current = stack[stack.length - 1];

  useEffect(() => {
    let live = true;
    (async () => {
      setLoading(true); setError(""); setFiles(null);
      try {
        const qs = current.id ? `?folder=${encodeURIComponent(current.id)}` : "";
        const d = await apiGet(`/api/drive-list${qs}`);
        if (live) setFiles(d.files || []);
      } catch (e) {
        if (live) setError(/501/.test(e.message || "") ? "Drive not connected yet — add GOOGLE_SERVICE_ACCOUNT_JSON in Cloudflare and share the creatives folder with the service-account email." : "Couldn't load Drive — " + (e.message || "check configuration"));
      }
      if (live) setLoading(false);
    })();
    return () => { live = false; };
  }, [stack.length]); // reloads when navigating into/out of a folder

  const folders = (files || []).filter((f) => f.isFolder);
  const items = (files || []).filter((f) => !f.isFolder);

  return (
    <div onClick={onClose} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", zIndex: 60, display: "flex", alignItems: "flex-end", justifyContent: "center" }}>
      <div onClick={(e) => e.stopPropagation()} style={{ background: C.surface, borderTop: `1px solid ${C.line}`, borderRadius: "16px 16px 0 0", width: "100%", maxWidth: 720, maxHeight: "80vh", animation: "sheet 0.22s ease-out", overflow: "auto", padding: 16 }}>
        <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
          <div style={{ fontFamily: "'Archivo', sans-serif", fontWeight: 700, fontSize: 16, flex: 1 }}>Pick from Drive</div>
          <Btn small kind="ghost" onClick={onClose}>Close</Btn>
        </div>
        {/* breadcrumb */}
        <div style={{ display: "flex", gap: 4, flexWrap: "wrap", marginBottom: 10, fontSize: 12.5 }}>
          {stack.map((c, i) => (
            <span key={i}>
              <button onClick={() => setStack((s) => s.slice(0, i + 1))} style={{ background: "none", border: "none", color: i === stack.length - 1 ? C.gold : C.dim, cursor: "pointer", padding: 0, fontWeight: 600 }}>{c.name}</button>
              {i < stack.length - 1 && <span style={{ color: C.faint }}> / </span>}
            </span>
          ))}
        </div>

        {loading && <div style={{ color: C.dim, padding: 20, textAlign: "center" }}>Loading Drive…</div>}
        {error && <Card style={{ color: C.red, fontSize: 13, borderLeft: `3px solid ${C.red}` }}>{error}</Card>}
        {!loading && !error && files && files.length === 0 && <div style={{ color: C.faint, padding: 20, textAlign: "center" }}>Empty folder.</div>}

        {!loading && !error && (
          <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
            {folders.map((f) => (
              <button key={f.id} onClick={() => setStack((s) => [...s, { id: f.id, name: f.name }])} style={{ display: "flex", alignItems: "center", gap: 10, background: C.raised, border: `1px solid ${C.line}`, borderRadius: 8, padding: "10px 12px", cursor: "pointer", textAlign: "left", fontFamily: "inherit" }}>
                <span style={{ color: C.faint, display: "flex" }}><IcFolder size={18} /></span>
                <span style={{ color: C.ink, fontSize: 14, fontWeight: 600, flex: 1 }}>{f.name}</span>
                <span style={{ color: C.faint, fontSize: 18 }}>›</span>
              </button>
            ))}
            {items.map((f) => (
              <button key={f.id} onClick={() => onPick(f.link)} disabled={!f.link} style={{ display: "flex", alignItems: "center", gap: 10, background: C.raised, border: `1px solid ${C.line}`, borderRadius: 8, padding: "8px 10px", cursor: f.link ? "pointer" : "default", textAlign: "left", fontFamily: "inherit", opacity: f.link ? 1 : 0.5 }}>
                {f.hasThumb
                  ? <img src={`/api/drive-thumb?id=${encodeURIComponent(f.id)}`} loading="lazy" style={{ width: 40, height: 40, objectFit: "cover", borderRadius: 6, flexShrink: 0 }} onError={(e) => { e.target.style.display = "none"; }} />
                  : <span style={{ width: 40, height: 40, display: "flex", alignItems: "center", justifyContent: "center", background: C.bg, borderRadius: 6, color: C.faint, flexShrink: 0 }}>{/video/.test(f.mimeType) ? <IcFilm size={17} /> : /image/.test(f.mimeType) ? <IcImage size={17} /> : <IcFile size={17} />}</span>}
                <span style={{ flex: 1, minWidth: 0 }}>
                  <span style={{ color: C.ink, fontSize: 14, fontWeight: 600, display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{f.name}</span>
                  <span style={{ color: C.faint, fontSize: 11 }}>{(f.mimeType || "").split(".").pop().split("/").pop()}{f.modified ? " · " + f.modified.slice(0, 10) : ""}</span>
                </span>
                <span style={{ color: C.gold, fontSize: 12, fontWeight: 600 }}>Pick</span>
              </button>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

/* ================= TRACK ================= */
function Track({ tax, angles, cycles, modules, assemblies, setAssemblies, metrics, setMetrics, metaStatus, refreshMetrics, log, flash }) {
  const [open, setOpen] = useState(null);
  const [mForm, setMForm] = useState(null);
  const [syncing, setSyncing] = useState(false);
  const [picker, setPicker] = useState(null); // { onPick } — the Drive file picker target

  const syncMeta = async () => {
    setSyncing(true);
    try {
      const res = await apiPost('/api/meta-sync', {});
      await refreshMetrics();
      flash(`Meta sync: ${res.synced_rows} daily rows, ${res.matched_assemblies.length} creatives matched`);
      log("metric", `Meta sync — ${res.synced_rows} rows${res.matched_assemblies.length ? " for " + res.matched_assemblies.join(", ") : " (no plate matches in ad names yet)"}`);
    } catch (e) { flash("Meta sync failed — check META_ACCESS_TOKEN / META_AD_ACCOUNT_ID"); }
    setSyncing(false);
  };

  const upd = (id, patch, note) => {
    setAssemblies((as) => as.map((a) => (a.id === id ? { ...a, ...patch } : a)));
    if (note) log("assembly", note);
  };

  const life = (a) => {
    const rows = metrics.filter((x) => x.assemblyId === a.id);
    const spend = rows.reduce((t, r) => t + (+r.spend || 0), 0);
    const conv = rows.reduce((t, r) => t + (+r.conversions || 0), 0);
    const leads = rows.reduce((t, r) => t + (+r.leads || 0), 0);
    const ctr = rows.length ? rows.reduce((t, r) => t + (+r.ctr || 0), 0) / rows.length : null;
    const hook = rows.length ? rows.reduce((t, r) => t + (+r.hookRate || 0), 0) / rows.length : null;
    const cpl = leads > 0 ? spend / leads : null; // primary decision metric
    const cpa = conv > 0 ? spend / conv : null;
    const days = a.launchDate ? Math.max(1, Math.round(((a.retiredAt ? new Date(a.retiredAt) : new Date()) - new Date(a.launchDate)) / 86400000)) : null;
    return { spend, conv, leads, ctr, hook, cpl, cpa, days, n: rows.length };
  };

  const saveMetric = () => {
    if (!mForm.assemblyId) return;
    setMetrics((ms) => [...ms, { ...mForm, id: uid() }]);
    log("metric", `Logged metrics for ${assemblies.find((a) => a.id === mForm.assemblyId)?.code} (${mForm.date})`);
    setMForm(null); flash("Metrics logged");
  };

  const sorted = [...assemblies].sort((a, b) => (a.status === "Live" ? -1 : 1) - (b.status === "Live" ? -1 : 1) || new Date(b.createdAt) - new Date(a.createdAt));

  // Register a creative that already exists in Meta (made outside the system):
  // its ad-name descriptor becomes the assembly's code/fingerprint, so
  // meta-sync starts attributing its history (90-day window) immediately.
  const [regForm, setRegForm] = useState(null);
  const registerCreative = () => {
    const f = regForm;
    const descriptor = f.descriptor.trim();
    if (!descriptor) return flash("Descriptor required — the name token in the ad (e.g. FighterProfile)");
    if (assemblies.some((a) => a.code === descriptor)) { setRegForm(null); return flash("Already registered"); }
    const a = {
      id: uid(), code: descriptor, name: descriptor, moduleIds: [],
      angleId: f.angleId || null, cycleId: null, campaign: "", platform: "Meta",
      launchDate: today(), status: "Live", retiredAt: null, verdict: "Testing",
      notes: "External creative — made outside the module generator", createdAt: new Date().toISOString(),
      brand: "LUFG", market: f.market.toUpperCase(), round: f.round, ratio: f.ratio, version: 1,
      kind: f.kind || "Static", assetLink: f.assetLink.trim(),
      angleCode: angles.find((x) => x.id === f.angleId)?.code || "",
    };
    setAssemblies((as) => [...as, a]);
    log("assembly", `Registered external creative ${descriptor} (${f.kind}) — Meta sync will attribute it`);
    setRegForm(null);
    flash(`${descriptor} registered — run Sync Meta to pull its history`);
  };

  return (
    <div>
      {picker && <DrivePicker onPick={(link) => { picker.onPick(link); setPicker(null); flash("Asset linked from Drive"); }} onClose={() => setPicker(null)} />}
      <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 12, flexWrap: "wrap" }}>
        <div style={{ fontSize: 12.5, color: C.faint, flex: 1, minWidth: 180 }}>
          Meta pulls daily spend/CTR/leads per fingerprint — don&rsquo;t hand-log the same days for synced creatives.
        </div>
        <Btn small kind="ghost" onClick={() => setRegForm(regForm ? null : { descriptor: "", angleId: angles.find((x) => x.status === "Active")?.id || "", market: "US", round: "R1", ratio: "4x5", kind: "Static", assetLink: "" })}>+ Register creative</Btn>
        {metaStatus && metaStatus.configured && <Btn small kind="ghost" onClick={syncMeta} disabled={syncing}>{syncing ? "Syncing…" : "Sync Meta"}</Btn>}
      </div>
      {regForm && (
        <Card style={{ marginBottom: 12, border: `1px solid ${C.gold}44` }}>
          <Label>Register an external creative — a static or video made outside the module generator</Label>
          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, margin: "8px 0" }}>
            <div style={{ gridColumn: "1 / -1" }}><Label>Descriptor (exactly as in the ad name — the fingerprint)</Label><Input value={regForm.descriptor} onChange={(e) => setRegForm({ ...regForm, descriptor: e.target.value })} placeholder="FighterProfile" /></div>
            <div><Label>Type</Label><Sel value={regForm.kind} onChange={(e) => setRegForm({ ...regForm, kind: e.target.value })} options={CREATIVE_KINDS} /></div>
            <div><Label>Angle</Label><Sel value={regForm.angleId} onChange={(e) => setRegForm({ ...regForm, angleId: e.target.value })} placeholder="No angle" options={angles.map((x) => ({ value: x.id, label: x.name }))} /></div>
            <div><Label>Market</Label><Input value={regForm.market} onChange={(e) => setRegForm({ ...regForm, market: e.target.value.toUpperCase() })} /></div>
            <div><Label>Round</Label><Sel value={regForm.round} onChange={(e) => setRegForm({ ...regForm, round: e.target.value })} options={ROUNDS} /></div>
            <div style={{ gridColumn: "1 / -1" }}><Label>Asset link — where the file lives</Label>
              <div style={{ display: "flex", gap: 8 }}>
                <Input value={regForm.assetLink} onChange={(e) => setRegForm({ ...regForm, assetLink: e.target.value })} placeholder="https://drive.google.com/…" />
                <Btn small kind="ghost" onClick={() => setPicker({ onPick: (link) => setRegForm((p) => ({ ...p, assetLink: link })) })}>Drive</Btn>
              </div>
            </div>
          </div>
          <div style={{ fontSize: 12, color: C.faint, marginBottom: 10, lineHeight: 1.6 }}>
            One creative = one registration, even across 3 aspect ratios. As long as every ratio&rsquo;s Meta ad name carries this same descriptor, all of them roll up here automatically. Ratio is a per-ad token, not a separate creative.
          </div>
          <div style={{ display: "flex", gap: 8 }}>
            <Btn small onClick={registerCreative} style={{ flex: 1 }}>Register</Btn>
            <Btn small kind="ghost" onClick={() => setRegForm(null)}>Cancel</Btn>
          </div>
        </Card>
      )}
      {assemblies.length === 0 && <Card style={{ textAlign: "center", color: C.dim, padding: 28 }}>Nothing in the field yet. Build an assembly first.</Card>}
      <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
        {sorted.map((a) => {
          const L = life(a);
          const isOpen = open === a.id;
          return (
            <Card key={a.id}>
              <div style={{ display: "flex", alignItems: "center", gap: 10, cursor: "pointer" }} onClick={() => setOpen(isOpen ? null : a.id)}>
                <Plate code={a.code} />
                {a.kind && a.moduleIds.length === 0 && <Chip tone="dim">{a.kind}</Chip>}
                <div style={{ flex: 1 }} />
                <Chip tone={a.status === "Live" ? "gold" : a.status === "Retired" ? "dim" : "blue"}>{a.status}</Chip>
                <Chip tone={verdictTone(a.verdict)}>{a.verdict}</Chip>
              </div>

              {/* lifetime strip */}
              <div style={{ display: "flex", gap: 14, marginTop: 10, flexWrap: "wrap", fontSize: 13, color: C.dim }}>
                <span>Spend <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>${L.spend.toFixed(0)}</b></span>
                <span>CPL <b style={{ color: C.gold, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{L.cpl != null ? "$" + L.cpl.toFixed(2) : "—"}</b></span>
                <span>Leads <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{L.leads}</b></span>
                <span>CTR <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{L.ctr != null ? L.ctr.toFixed(2) + "%" : "—"}</b></span>
                <span>Hook <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{L.hook != null ? L.hook.toFixed(1) + "%" : "—"}</b></span>
                <span>CPA <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{L.cpa != null ? "$" + L.cpa.toFixed(0) : "—"}</b></span>
                <span>Conv <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{L.conv}</b></span>
                {L.days && <span>{L.days}d live</span>}
              </div>

              {isOpen && (
                <div style={{ marginTop: 14, borderTop: `1px solid ${C.line}`, paddingTop: 14, display: "flex", flexDirection: "column", gap: 10 }}>
                  {/* modules inside, or the external-creative marker */}
                  <div style={{ display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center" }}>
                    {a.moduleIds.length > 0
                      ? a.moduleIds.map((id) => {
                          const m = modules.find((x) => x.id === id);
                          return m ? <Chip key={id} tone="blue">{m.code} {m.name}</Chip> : null;
                        })
                      : <Chip tone="dim">{a.kind || "External"} — no modules</Chip>}
                    {a.assetLink && <a href={a.assetLink} target="_blank" rel="noreferrer" style={{ color: C.green, fontSize: 12.5, fontWeight: 600 }}>asset ↗</a>}
                  </div>
                  <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
                    <div><Label>Type</Label><Sel value={a.kind || ""} onChange={(e) => upd(a.id, { kind: e.target.value })} placeholder="—" options={CREATIVE_KINDS} /></div>
                    <div><Label>Asset link (where the file lives)</Label>
                      <div style={{ display: "flex", gap: 8 }}>
                        <Input value={a.assetLink || ""} onChange={(e) => upd(a.id, { assetLink: e.target.value })} placeholder="https://drive.google.com/…" />
                        <Btn small kind="ghost" onClick={() => setPicker({ onPick: (link) => upd(a.id, { assetLink: link }) })}>Drive</Btn>
                      </div>
                    </div>
                  </div>
                  <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
                    <div><Label>Angle</Label>
                      <Sel value={a.angleId || ""} onChange={(e) => upd(a.id, { angleId: e.target.value || null })} placeholder="No angle"
                        options={angles.map((x) => ({ value: x.id, label: x.name }))} />
                    </div>
                    <div><Label>Cycle</Label>
                      <Sel value={a.cycleId || ""} onChange={(e) => upd(a.id, { cycleId: e.target.value || null })} placeholder="No cycle"
                        options={cycles.filter((c) => !a.angleId || c.angleId === a.angleId).map((c) => ({ value: c.id, label: c.name }))} />
                    </div>
                    <div><Label>Campaign</Label><Input value={a.campaign} onChange={(e) => upd(a.id, { campaign: e.target.value })} placeholder="Cold prospecting" /></div>
                    <div><Label>Platform</Label><Input value={a.platform} onChange={(e) => upd(a.id, { platform: e.target.value })} placeholder="Meta / IG" /></div>
                  </div>
                  <div><Label>Verdict</Label><Sel value={a.verdict} onChange={(e) => upd(a.id, { verdict: e.target.value }, `${a.code} verdict → ${e.target.value}`)} options={VERDICTS} /></div>
                  <div><Label>Notes</Label><TArea value={a.notes} onChange={(e) => upd(a.id, { notes: e.target.value })} style={{ minHeight: 44 }} /></div>

                  <AdName a={a} angles={angles} upd={upd} flash={flash} />

                  <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
                    {a.status === "Draft" && <Btn small onClick={() => upd(a.id, { status: "Live", launchDate: today() }, `${a.code} launched`)}>Mark live</Btn>}
                    {a.status === "Live" && <Btn small kind="danger" onClick={() => upd(a.id, { status: "Retired", retiredAt: today() }, `${a.code} retired after run`)}>Retire</Btn>}
                    <Btn small kind="ghost" onClick={() => setMForm({ assemblyId: a.id, date: today(), spend: "", hookRate: "", ctr: "", leads: "", conversions: "", notes: "" })}>+ Log metrics</Btn>
                  </div>

                  {mForm?.assemblyId === a.id && (
                    <Card style={{ border: `1px solid ${C.gold}55` }}>
                      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
                        <div><Label>Date</Label><Input type="date" value={mForm.date} onChange={(e) => setMForm({ ...mForm, date: e.target.value })} /></div>
                        <div><Label>Spend $</Label><Input type="number" value={mForm.spend} onChange={(e) => setMForm({ ...mForm, spend: e.target.value })} /></div>
                        <div><Label>Hook rate %</Label><Input type="number" value={mForm.hookRate} onChange={(e) => setMForm({ ...mForm, hookRate: e.target.value })} /></div>
                        <div><Label>CTR %</Label><Input type="number" value={mForm.ctr} onChange={(e) => setMForm({ ...mForm, ctr: e.target.value })} /></div>
                        <div><Label>Leads</Label><Input type="number" value={mForm.leads} onChange={(e) => setMForm({ ...mForm, leads: e.target.value })} /></div>
                        <div><Label>Conversions</Label><Input type="number" value={mForm.conversions} onChange={(e) => setMForm({ ...mForm, conversions: e.target.value })} /></div>
                        <div><Label>Notes</Label><Input value={mForm.notes} onChange={(e) => setMForm({ ...mForm, notes: e.target.value })} /></div>
                      </div>
                      <div style={{ display: "flex", gap: 8, marginTop: 12 }}>
                        <Btn small onClick={saveMetric} style={{ flex: 1 }}>Save metrics</Btn>
                        <Btn small kind="ghost" onClick={() => setMForm(null)}>Cancel</Btn>
                      </div>
                    </Card>
                  )}

                  {/* metric history */}
                  {metrics.filter((x) => x.assemblyId === a.id).slice().reverse().map((r) => (
                    <div key={r.id} style={{ fontSize: 12.5, color: C.dim, display: "flex", gap: 12, flexWrap: "wrap" }}>
                      <span style={{ color: C.faint }}>{r.date}</span>
                      <span>${(+r.spend || 0).toFixed(0)}</span>
                      <span>hook {r.hookRate || "—"}%</span>
                      <span>ctr {r.ctr || "—"}%</span>
                      <span>{r.leads || 0} leads</span>
                      <span>{r.conversions || 0} conv</span>
                    </div>
                  ))}
                </div>
              )}
            </Card>
          );
        })}
      </div>
    </div>
  );
}

/* ================= PERFORMANCE ================= */
/* The homescreen — the numbers a general checks each morning. Defaults to
   THIS testing cycle (aligned to the marketing cycle) and surfaces scale/kill
   decisions; toggle to Historical for the deep period breakdowns. CPL — cost
   per MMA lead — is the spine, ahead of conversions. */
const avg = (arr) => (arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : null);

function Performance({ tax, angles, cycles, modules, assemblies, metrics, metaStatus, refreshMetrics, log, flash, goTrack }) {
  const [scope, setScope] = useState("cycle"); // cycle | historical
  const [syncing, setSyncing] = useState(false);

  const activeAngle = angles.find((a) => a.status === "Active");
  const activeCycle = cycles.find((c) => c.status === "Active");

  const inCycleWindow = (dateStr) => {
    if (!activeCycle) return true;
    if (activeCycle.startDate && dateStr < activeCycle.startDate) return false;
    if (activeCycle.endDate && dateStr > activeCycle.endDate) return false;
    return true;
  };

  // Per-creative rollup for the current cycle
  const rows = useMemo(() => {
    const map = {};
    metrics.filter((r) => inCycleWindow(r.date)).forEach((r) => {
      const g = (map[r.assemblyId] = map[r.assemblyId] || { spend: 0, leads: 0, conv: 0, ctrs: [], days: new Set() });
      g.spend += +r.spend || 0; g.leads += +r.leads || 0; g.conv += +r.conversions || 0;
      if (r.ctr) g.ctrs.push(+r.ctr);
      if (r.date) g.days.add(r.date);
    });
    let list = Object.entries(map).map(([id, v]) => {
      const asm = assemblies.find((a) => a.id === id);
      if (!asm) return null;
      return { asm, spend: v.spend, leads: v.leads, conv: v.conv, ctr: avg(v.ctrs), cpl: v.leads > 0 ? v.spend / v.leads : null, days: v.days.size };
    }).filter(Boolean);
    // Prefer creatives explicitly linked to the active cycle; if none are linked
    // yet (day 1), fall back to everything with metrics in the window.
    if (activeCycle) {
      const linked = list.filter((r) => r.asm.cycleId === activeCycle.id);
      if (linked.length) list = linked;
    }
    return list;
  }, [metrics, assemblies, activeCycle]);

  const totSpend = rows.reduce((t, r) => t + r.spend, 0);
  const totLeads = rows.reduce((t, r) => t + r.leads, 0);
  const blendedCpl = totLeads > 0 ? totSpend / totLeads : null;

  // Scale/kill flag — a SUGGESTION the human acts on. Unit is the blended CPL,
  // so there are no arbitrary dollar thresholds. An explicit verdict wins.
  const flagOf = (r) => {
    const v = (r.asm.verdict || "").toLowerCase();
    if (v === "scale") return "scale";
    if (v === "kill") return "kill";
    if (!blendedCpl) return "test";                                   // nobody has leads yet
    if (r.leads === 0 && r.spend >= blendedCpl) return "kill";        // spent a lead's worth, got none
    if (r.cpl != null && r.leads >= 2 && r.cpl <= 0.85 * blendedCpl) return "scale";
    if (r.cpl != null && r.cpl >= 1.5 * blendedCpl) return "kill";
    return "test";
  };

  const ranked = rows.map((r) => ({ ...r, flag: flagOf(r) }))
    .sort((a, b) => (a.cpl == null ? Infinity : a.cpl) - (b.cpl == null ? Infinity : b.cpl));
  const nScale = ranked.filter((r) => r.flag === "scale").length;
  const nKill = ranked.filter((r) => r.flag === "kill").length;

  const flagChip = { scale: "gold", kill: "red", test: "blue" };
  const flagLabel = { scale: "SCALE", kill: "KILL", test: "TESTING" };

  const sync = async () => {
    setSyncing(true);
    try {
      const res = await apiPost("/api/meta-sync", {});
      await refreshMetrics();
      flash(`Meta sync: ${res.synced_rows} rows, ${res.matched_assemblies.length} creatives matched`);
      log("metric", `Meta sync from Performance — ${res.synced_rows} rows`);
    } catch (e) { flash("Meta sync failed — check META_ACCESS_TOKEN / META_AD_ACCOUNT_ID"); }
    setSyncing(false);
  };

  const num = (v) => ({ fontSize: 23, fontWeight: 600, fontFamily: NUM, fontVariantNumeric: "tabular-nums", letterSpacing: "-0.01em", color: v });

  return (
    <div>
      {/* scope toggle */}
      <div style={{ display: "flex", gap: 6, marginBottom: 14 }}>
        {[["cycle", "This cycle"], ["historical", "Historical"]].map(([k, l]) => (
          <button key={k} onClick={() => setScope(k)} style={{
            background: scope === k ? C.goldSoft : C.raised, color: scope === k ? C.gold : C.dim,
            border: `1px solid ${scope === k ? C.gold + "66" : C.line}`, borderRadius: 8, padding: "7px 14px",
            fontSize: 13, fontWeight: 600, cursor: "pointer", fontFamily: "inherit",
          }}>{l}</button>
        ))}
        <div style={{ flex: 1 }} />
        {metaStatus && metaStatus.configured && (
          <Btn small kind="ghost" onClick={sync} disabled={syncing}>{syncing ? "Syncing…" : "Sync Meta"}</Btn>
        )}
      </div>

      {scope === "historical" ? (
        <Reports tax={tax} angles={angles} modules={modules} assemblies={assemblies} metrics={metrics} />
      ) : (
        <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
          {/* active angle + cycle context */}
          <Card style={{ border: `1px solid ${C.gold}44` }}>
            <Label>What we&rsquo;re testing right now</Label>
            <div style={{ fontFamily: "'Archivo', sans-serif", fontWeight: 700, fontSize: 21, color: C.ink, letterSpacing: "-0.01em", margin: "6px 0 3px" }}>
              {activeAngle ? activeAngle.name : "No active angle"}{activeAngle && activeAngle.code ? <span style={{ color: C.faint, fontWeight: 500 }}> · {activeAngle.code}</span> : ""}
            </div>
            <div style={{ fontSize: 13, color: C.dim }}>{activeCycle ? activeCycle.name : "No active cycle — set one on the Angle tab"}</div>
            {activeCycle && activeCycle.question && <div style={{ fontSize: 13, color: C.dim, marginTop: 6 }}><b style={{ color: C.faint }}>Q:</b> {activeCycle.question}</div>}
          </Card>

          {/* the 5 numbers */}
          <Card>
            <div style={{ display: "flex", gap: 20, flexWrap: "wrap" }}>
              <div><Label>Spend</Label><div style={num(C.ink)}>${totSpend.toFixed(0)}</div></div>
              <div><Label>CPL</Label><div style={num(C.gold)}>{blendedCpl != null ? "$" + blendedCpl.toFixed(2) : "—"}</div></div>
              <div><Label>Leads</Label><div style={num(C.ink)}>{totLeads}</div></div>
              <div><Label>Creatives</Label><div style={num(C.ink)}>{ranked.length}</div></div>
              <div><Label>Flags</Label><div style={{ fontSize: 15, fontWeight: 700, fontFamily: "'Archivo', sans-serif", display: "flex", gap: 8, alignItems: "baseline" }}>
                <span style={{ color: C.gold }}>{nScale}▲</span><span style={{ color: C.red }}>{nKill}▼</span>
              </div></div>
            </div>
          </Card>

          {ranked.length === 0 ? (
            <Card style={{ textAlign: "center", color: C.dim, padding: 24 }}>
              <div style={{ fontWeight: 700, color: C.ink, marginBottom: 8 }}>No connected ad data yet.</div>
              <div style={{ fontSize: 13.5, lineHeight: 1.6 }}>
                To monitor an ad here: it must be an assembly whose code appears in the Meta ad name.<br />
                <b style={{ color: C.ink }}>New ads</b> → stage them in <b>Build</b> (born named to contract).<br />
                <b style={{ color: C.ink }}>Existing ads</b> → <b>Track → + Register creative</b>, then tap <b>Sync Meta</b> above.
              </div>
              <div style={{ marginTop: 12 }}><Btn small kind="ghost" onClick={goTrack}>Go to Track →</Btn></div>
            </Card>
          ) : (
            <div>
              <Label>Creatives · best CPL first</Label>
              <div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 6 }}>
                {ranked.map((r) => {
                  const better = blendedCpl && r.cpl != null && r.cpl < blendedCpl;
                  return (
                    <Card key={r.asm.id} style={{ padding: 12, borderLeft: `3px solid ${r.flag === "scale" ? C.gold : r.flag === "kill" ? C.red : C.line}` }}>
                      <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                        <Plate code={r.asm.code} />
                        {r.asm.kind && r.asm.moduleIds.length === 0 && <Chip tone="dim">{r.asm.kind}</Chip>}
                        <div style={{ fontSize: 12, color: C.faint }}>{[r.asm.market, r.asm.round].filter(Boolean).join(" · ")}</div>
                        <div style={{ flex: 1 }} />
                        <Chip tone={flagChip[r.flag]}>{flagLabel[r.flag]}</Chip>
                      </div>
                      <div style={{ display: "flex", gap: 14, marginTop: 8, fontSize: 13, color: C.dim, flexWrap: "wrap" }}>
                        <span>CPL <b style={{ color: r.cpl == null ? C.faint : better ? C.gold : C.ink }}>{r.cpl != null ? "$" + r.cpl.toFixed(2) : "—"}</b></span>
                        <span>Leads <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{r.leads}</b></span>
                        <span>Spend <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>${r.spend.toFixed(0)}</b></span>
                        <span>CTR <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{r.ctr != null ? r.ctr.toFixed(2) + "%" : "—"}</b></span>
                        {r.days > 0 && <span>{r.days}d data</span>}
                      </div>
                    </Card>
                  );
                })}
              </div>
              <div style={{ fontSize: 12, color: C.faint, marginTop: 10, lineHeight: 1.6 }}>
                Flags are suggestions off blended CPL (${blendedCpl != null ? blendedCpl.toFixed(2) : "—"}) — you decide. Act in Ads Manager, then log the verdict in <button onClick={goTrack} style={{ background: "none", border: "none", color: C.gold, cursor: "pointer", padding: 0, fontWeight: 600, fontSize: 12 }}>Track</button>.
              </div>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

/* ================= REPORTS ================= */
function Reports({ tax, angles, modules, assemblies, metrics }) {
  const [period, setPeriod] = useState("month");
  const { s, e } = periodRange(period, tax.fyStartMonth);
  const inRange = metrics.filter((m) => { const d = new Date(m.date); return d >= s && d <= e; });

  const byAssembly = useMemo(() => {
    const map = {};
    inRange.forEach((r) => {
      const a = (map[r.assemblyId] = map[r.assemblyId] || { spend: 0, conv: 0, leads: 0, ctrs: [], hooks: [] });
      a.spend += +r.spend || 0; a.conv += +r.conversions || 0; a.leads += +r.leads || 0;
      if (r.ctr) a.ctrs.push(+r.ctr); if (r.hookRate) a.hooks.push(+r.hookRate);
    });
    return Object.entries(map).map(([id, v]) => {
      const asm = assemblies.find((a) => a.id === id);
      return asm && {
        asm, spend: v.spend, conv: v.conv, leads: v.leads,
        ctr: v.ctrs.length ? v.ctrs.reduce((a, b) => a + b) / v.ctrs.length : 0,
        hook: v.hooks.length ? v.hooks.reduce((a, b) => a + b) / v.hooks.length : 0,
        cpl: v.leads > 0 ? v.spend / v.leads : null,
        cpa: v.conv > 0 ? v.spend / v.conv : null,
      };
    }).filter(Boolean).sort((a, b) => b.ctr - a.ctr);
  }, [inRange, assemblies]);

  const moduleStats = useMemo(() => {
    const map = {};
    byAssembly.forEach((row, idx) => {
      const topHalf = idx < Math.ceil(byAssembly.length / 2);
      row.asm.moduleIds.forEach((id) => {
        const m = (map[id] = map[id] || { runs: 0, top: 0, ctrs: [], scaleWins: 0 });
        m.runs++; if (topHalf) m.top++; m.ctrs.push(row.ctr);
        if (row.asm.verdict === "Scale") m.scaleWins++;
      });
    });
    return Object.entries(map).map(([id, v]) => ({
      mod: modules.find((m) => m.id === id), ...v,
      avgCtr: v.ctrs.length ? v.ctrs.reduce((a, b) => a + b) / v.ctrs.length : 0,
    })).filter((x) => x.mod).sort((a, b) => b.avgCtr - a.avgCtr);
  }, [byAssembly, modules]);

  const byAngle = useMemo(() => {
    const map = {};
    byAssembly.forEach((r) => {
      const key = r.asm.angleId || "none";
      const g = (map[key] = map[key] || { spend: 0, conv: 0, leads: 0, ctrs: [], n: 0 });
      g.spend += r.spend; g.conv += r.conv; g.leads += r.leads; g.ctrs.push(r.ctr); g.n++;
    });
    return Object.entries(map).map(([id, g]) => ({
      angle: angles.find((a) => a.id === id),
      ...g, ctr: g.ctrs.length ? g.ctrs.reduce((a, b) => a + b, 0) / g.ctrs.length : 0,
    })).sort((a, b) => b.ctr - a.ctr);
  }, [byAssembly, angles]);

  const totSpend = byAssembly.reduce((t, r) => t + r.spend, 0);
  const totConv = byAssembly.reduce((t, r) => t + r.conv, 0);
  const totLeads = byAssembly.reduce((t, r) => t + r.leads, 0);

  return (
    <div>
      <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 16 }}>
        {["week", "month", "quarter", "half", "year"].map((k) => (
          <button key={k} onClick={() => setPeriod(k)} style={{
            background: period === k ? C.goldSoft : C.raised, color: period === k ? C.gold : C.dim,
            border: `1px solid ${period === k ? C.gold + "66" : C.line}`, borderRadius: 8, padding: "7px 12px",
            fontSize: 13, fontWeight: 600, cursor: "pointer", fontFamily: "inherit",
          }}>{fyLabel(k, tax.fyStartMonth)}</button>
        ))}
      </div>

      {byAssembly.length === 0 ? (
        <Card style={{ textAlign: "center", color: C.dim, padding: 28 }}>No metrics logged in this period yet. Log weekly numbers in Track and this becomes your scoreboard.</Card>
      ) : (
        <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
          <Card>
            <div style={{ display: "flex", gap: 20, flexWrap: "wrap" }}>
              <div><Label>Spend</Label><div style={{ fontSize: 22, fontWeight: 700, fontFamily: "'Archivo', sans-serif" }}>${totSpend.toFixed(0)}</div></div>
              <div><Label>Blended CPL</Label><div style={{ fontSize: 22, fontWeight: 700, fontFamily: "'Archivo', sans-serif", color: C.gold }}>{totLeads ? "$" + (totSpend / totLeads).toFixed(2) : "—"}</div></div>
              <div><Label>Leads</Label><div style={{ fontSize: 22, fontWeight: 700, fontFamily: "'Archivo', sans-serif" }}>{totLeads}</div></div>
              <div><Label>Conversions</Label><div style={{ fontSize: 22, fontWeight: 700, fontFamily: "'Archivo', sans-serif" }}>{totConv}</div></div>
              <div><Label>Blended CPA</Label><div style={{ fontSize: 22, fontWeight: 700, fontFamily: "'Archivo', sans-serif" }}>{totConv ? "$" + (totSpend / totConv).toFixed(0) : "—"}</div></div>
              <div><Label>Creatives tested</Label><div style={{ fontSize: 22, fontWeight: 700, fontFamily: "'Archivo', sans-serif" }}>{byAssembly.length}</div></div>
            </div>
          </Card>

          {byAngle.some((x) => x.angle) && (
            <div>
              <Label>Angles — the conversation scoreboard</Label>
              <div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 6 }}>
                {byAngle.filter((x) => x.angle).map((r) => (
                  <Card key={r.angle.id} style={{ padding: 12 }}>
                    <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                      <div style={{ fontFamily: "'Archivo', sans-serif", fontWeight: 700, fontSize: 17, flex: 1, letterSpacing: "0.02em" }}>{r.angle.name}</div>
                      <Chip tone={r.angle.status === "Active" ? "gold" : "dim"}>{r.angle.status}</Chip>
                    </div>
                    <div style={{ display: "flex", gap: 14, marginTop: 8, fontSize: 13, color: C.dim, flexWrap: "wrap" }}>
                      <span>CPL <b style={{ color: C.gold, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{r.leads > 0 ? "$" + (r.spend / r.leads).toFixed(2) : "—"}</b></span>
                      <span>Leads <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{r.leads}</b></span>
                      <span>Avg CTR <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{r.ctr.toFixed(2)}%</b></span>
                      <span>Spend <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>${r.spend.toFixed(0)}</b></span>
                      <span>Conv <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{r.conv}</b></span>
                      <span><b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{r.n}</b> creatives tested</span>
                    </div>
                  </Card>
                ))}
              </div>
            </div>
          )}

          <div>
            <Label>Creatives · best → worst by CTR</Label>
            <div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 6 }}>
              {byAssembly.map((r, i) => (
                <Card key={r.asm.id} style={{ padding: 12, borderLeft: `3px solid ${i === 0 ? C.gold : i === byAssembly.length - 1 && byAssembly.length > 1 ? C.red : C.line}` }}>
                  <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                    <Plate code={r.asm.code} />
                    <div style={{ flex: 1 }} />
                    <Chip tone={verdictTone(r.asm.verdict)}>{r.asm.verdict}</Chip>
                  </div>
                  <div style={{ display: "flex", gap: 14, marginTop: 8, fontSize: 13, color: C.dim, flexWrap: "wrap" }}>
                    <span>CPL <b style={{ color: C.gold, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{r.cpl ? "$" + r.cpl.toFixed(2) : "—"}</b></span>
                    <span>CTR <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{r.ctr.toFixed(2)}%</b></span>
                    <span>Hook <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{r.hook.toFixed(1)}%</b></span>
                    <span>Spend <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>${r.spend.toFixed(0)}</b></span>
                    <span>CPA <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{r.cpa ? "$" + r.cpa.toFixed(0) : "—"}</b></span>
                  </div>
                </Card>
              ))}
            </div>
          </div>

          <div>
            <Label>Module effectiveness — what to shoot more of</Label>
            <div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 6 }}>
              {moduleStats.map((r) => (
                <Card key={r.mod.id} style={{ padding: 12 }}>
                  <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                    <Plate code={r.mod.code} />
                    <div style={{ fontSize: 14, fontWeight: 600, flex: 1 }}>{r.mod.name}</div>
                  </div>
                  <div style={{ display: "flex", gap: 14, marginTop: 8, fontSize: 13, color: C.dim, flexWrap: "wrap" }}>
                    <span>Avg CTR <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{r.avgCtr.toFixed(2)}%</b></span>
                    <span>In <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{r.runs}</b> creatives</span>
                    <span>Top-half <b style={{ color: C.ink, fontFamily: NUM, fontWeight: 600, fontSize: "0.95em" }}>{Math.round((r.top / r.runs) * 100)}%</b></span>
                    {r.scaleWins > 0 && <Chip tone="gold">{r.scaleWins} scale winner{r.scaleWins > 1 ? "s" : ""}</Chip>}
                  </div>
                </Card>
              ))}
            </div>
          </div>

          <div style={{ fontSize: 12.5, color: C.faint }}>
            Close the loop: what are you killing, scaling, or shooting next because of this report? If the answer is nothing, the report failed — not the month.
          </div>
        </div>
      )}
    </div>
  );
}

/* ================= MORE (Export + Settings) ================= */
function More({ tax, setTax, angles, cycles, modules, assemblies, metrics, history, log, flash }) {
  const [section, setSection] = useState("export");
  const [newVals, setNewVals] = useState({});

  const copy = async (text, label) => {
    try {
      await navigator.clipboard.writeText(text);
      flash(label + " copied");
    } catch {
      flash("Copy blocked — long-press the preview to select");
    }
  };

  const fullExport = () => JSON.stringify({
    exportedAt: new Date().toISOString(),
    schemaVersion: 1,
    taxonomy: tax, angles, cycles, modules, assemblies, metrics, history,
  }, null, 2);

  const llmExport = () => {
    const lines = [];
    lines.push(`# Cutman creative-ops export — ${new Date().toDateString()}`);
    lines.push(`\n## Marketing angles (${angles.length})`);
    angles.forEach((a) => lines.push(`- "${a.name}" [${a.status}] — thesis: ${a.thesis || "—"} | why: ${a.why || "—"}`));
    lines.push(`\n## Cycles (${cycles.length})`);
    cycles.forEach((c) => {
      const ang = angles.find((x) => x.id === c.angleId);
      lines.push(`- ${c.name} [${c.status}] angle "${ang?.name || "—"}" — question: ${c.question || "—"}${c.answer ? ` | market said: ${c.answer}` : ""}`);
    });
    lines.push(`\n## Modules (${modules.length})`);
    modules.forEach((m) => {
      const slot = tax.slots.find((s) => s.id === m.slotId);
      lines.push(`- ${m.code} [${slot?.name}] "${m.name}" — tags: ${Object.values(m.tags || {}).filter(Boolean).join(", ") || "none"}; status: ${m.status}${m.keyLine ? `; key line: "${m.keyLine}"` : ""}`);
    });
    lines.push(`\n## Assemblies (${assemblies.length})`);
    assemblies.forEach((a) => {
      const rows = metrics.filter((x) => x.assemblyId === a.id);
      const spend = rows.reduce((t, r) => t + (+r.spend || 0), 0);
      const conv = rows.reduce((t, r) => t + (+r.conversions || 0), 0);
      const leads = rows.reduce((t, r) => t + (+r.leads || 0), 0);
      const cpl = leads > 0 ? "$" + (spend / leads).toFixed(2) : "—";
      const ctr = rows.length ? (rows.reduce((t, r) => t + (+r.ctr || 0), 0) / rows.length).toFixed(2) : "—";
      const ang = angles.find((x) => x.id === a.angleId);
      const cyc = cycles.find((x) => x.id === a.cycleId);
      lines.push(`- ${a.code} | angle "${ang?.name || "—"}" | cycle "${cyc?.name || "—"}" | status ${a.status} | verdict ${a.verdict} | campaign "${a.campaign || "—"}" | launched ${a.launchDate || "—"} | lifetime: $${spend.toFixed(0)} spend, ${cpl} CPL, ${leads} leads, ${ctr}% CTR, ${conv} conv, ${rows.length} metric snapshots`);
    });
    lines.push(`\n## Metric log (${metrics.length} rows)`);
    metrics.forEach((r) => {
      const a = assemblies.find((x) => x.id === r.assemblyId);
      lines.push(`- ${r.date} ${a?.code || "?"}: $${r.spend || 0} spend, ${r.hookRate || "—"}% hook, ${r.ctr || "—"}% CTR, ${r.leads || 0} leads, ${r.conversions || 0} conv${r.notes ? ` — ${r.notes}` : ""}`);
    });
    lines.push(`\n## History (${history.length} events)`);
    history.slice(-100).forEach((h) => lines.push(`- ${h.ts.slice(0, 16)} [${h.type}] ${h.detail}`));
    lines.push(`\n## Task\nAnalyse this marketing + creative testing history as one conversation with the market. Identify: (1) how each angle is performing across its iterations, hooks and formats, (2) which modules and tag combinations correlate with winners, (3) fatigue patterns, (4) gaps in the library, (5) what the closed-cycle verdicts suggest the next cycle's question should be, (6) the three highest-leverage things to shoot or test next.`);
    return lines.join("\n");
  };

  /* taxonomy editing helpers */
  const addAttr = (cat) => {
    const v = (newVals[cat] || "").trim();
    if (!v) return;
    setTax({ ...tax, attrs: { ...tax.attrs, [cat]: [...tax.attrs[cat], v] } });
    setNewVals({ ...newVals, [cat]: "" });
    log("taxonomy", `Added ${cat}: ${v}`);
  };
  const rmAttr = (cat, v) => {
    setTax({ ...tax, attrs: { ...tax.attrs, [cat]: tax.attrs[cat].filter((x) => x !== v) } });
    log("taxonomy", `Removed ${cat}: ${v}`);
  };
  const addSlot = () => {
    const name = (newVals.slotName || "").trim();
    const code = (newVals.slotCode || "").trim().toUpperCase().slice(0, 2);
    if (!name || !code) return flash("Slot needs a name and a code letter");
    setTax({ ...tax, slots: [...tax.slots, { id: uid(), code, name, order: tax.slots.length + 1 }] });
    setNewVals({ ...newVals, slotName: "", slotCode: "" });
    log("taxonomy", `Added slot ${name} (${code})`);
  };

  return (
    <div>
      <div style={{ display: "flex", gap: 6, marginBottom: 16 }}>
        {[["export", "Export"], ["settings", "Taxonomy & settings"], ["history", "History"]].map(([k, l]) => (
          <button key={k} onClick={() => setSection(k)} style={{
            background: section === k ? C.goldSoft : C.raised, color: section === k ? C.gold : C.dim,
            border: `1px solid ${section === k ? C.gold + "66" : C.line}`, borderRadius: 8, padding: "7px 12px",
            fontSize: 13, fontWeight: 600, cursor: "pointer", fontFamily: "inherit",
          }}>{l}</button>
        ))}
      </div>

      {section === "export" && (
        <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
          <Card>
            <div style={{ fontWeight: 600, marginBottom: 6 }}>LLM insight brief</div>
            <div style={{ color: C.dim, fontSize: 13.5, marginBottom: 12 }}>Full history formatted for pasting straight into Claude for analysis — module win rates, fatigue, gaps, next shots.</div>
            <Btn onClick={() => copy(llmExport(), "LLM brief")} style={{ width: "100%" }}>Copy LLM brief</Btn>
          </Card>
          <Card>
            <div style={{ fontWeight: 600, marginBottom: 6 }}>Full JSON export</div>
            <div style={{ color: C.dim, fontSize: 13.5, marginBottom: 12 }}>Complete structured dump — also your migration file when this graduates to the production app.</div>
            <Btn kind="ghost" onClick={() => copy(fullExport(), "JSON export")} style={{ width: "100%" }}>Copy full JSON</Btn>
          </Card>
          <Card>
            <Label>Preview (long-press to select manually)</Label>
            <pre style={{ fontSize: 11, color: C.dim, whiteSpace: "pre-wrap", maxHeight: 260, overflow: "auto", margin: 0, fontFamily: "'JetBrains Mono', monospace" }}>{llmExport().slice(0, 3000)}</pre>
          </Card>
        </div>
      )}

      {section === "settings" && (
        <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
          {Object.keys(tax.attrs).map((cat) => (
            <Card key={cat}>
              <Label>{cat}</Label>
              <div style={{ display: "flex", gap: 6, flexWrap: "wrap", margin: "8px 0 10px" }}>
                {tax.attrs[cat].map((v) => (
                  <span key={v} style={{ display: "inline-flex", alignItems: "center", gap: 6, background: C.raised, borderRadius: 6, padding: "4px 8px", fontSize: 12.5 }}>
                    {v}
                    <button onClick={() => rmAttr(cat, v)} style={{ background: "none", border: "none", color: C.red, cursor: "pointer", fontSize: 13, padding: 0 }}>×</button>
                  </span>
                ))}
              </div>
              <div style={{ display: "flex", gap: 8 }}>
                <Input value={newVals[cat] || ""} onChange={(e) => setNewVals({ ...newVals, [cat]: e.target.value })} placeholder={`New ${cat.toLowerCase()}…`} />
                <Btn small kind="ghost" onClick={() => addAttr(cat)}>Add</Btn>
              </div>
            </Card>
          ))}

          <Card>
            <Label>Slots</Label>
            <div style={{ display: "flex", gap: 6, flexWrap: "wrap", margin: "8px 0 10px" }}>
              {tax.slots.map((s) => <Chip key={s.id} tone="blue">{s.code} · {s.name}</Chip>)}
            </div>
            <div style={{ display: "flex", gap: 8 }}>
              <Input value={newVals.slotName || ""} onChange={(e) => setNewVals({ ...newVals, slotName: e.target.value })} placeholder="Slot name (e.g. Re-hook)" />
              <Input value={newVals.slotCode || ""} onChange={(e) => setNewVals({ ...newVals, slotCode: e.target.value })} placeholder="R" style={{ width: 64 }} />
              <Btn small kind="ghost" onClick={addSlot}>Add</Btn>
            </div>
            <div style={{ fontSize: 12, color: C.faint, marginTop: 8 }}>Existing slots stay put so historical codes never break.</div>
          </Card>

          <Card>
            <Label>Statuses & verdicts — the naming contract</Label>
            <div style={{ display: "flex", gap: 6, flexWrap: "wrap", margin: "8px 0 8px" }}>
              {MODULE_STATUSES.map((v) => <Chip key={v}>{v}</Chip>)}
            </div>
            <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 10 }}>
              {VERDICTS.map((v) => <Chip key={v} tone={verdictTone(v)}>{v}</Chip>)}
            </div>
            <div style={{ fontSize: 12, color: C.faint }}>
              Fixed vocabulary from docs/NAMING.md, enforced by the database — every layer
              (capture, Cutman, Meta, readback) speaks it. Change the contract, not the app.
            </div>
          </Card>

          <Card>
            <Label>Financial year starts in month</Label>
            <Sel value={String(tax.fyStartMonth)} onChange={(e) => setTax({ ...tax, fyStartMonth: +e.target.value })}
              options={Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i, 1).toLocaleString("en-AU", { month: "long" }) }))} />
          </Card>
        </div>
      )}

      {section === "history" && (
        <Card>
          <Label>Append-only record · {history.length} events</Label>
          <div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8, maxHeight: 420, overflow: "auto" }}>
            {[...history].reverse().map((h, i) => (
              <div key={i} style={{ fontSize: 13, color: C.dim, display: "flex", gap: 10 }}>
                <span style={{ color: C.faint, fontFamily: "'JetBrains Mono', monospace", fontSize: 11.5, whiteSpace: "nowrap" }}>{h.ts.slice(5, 16).replace("T", " ")}</span>
                <span><Chip tone={h.type === "metric" ? "green" : h.type === "assembly" ? "gold" : "dim"}>{h.type}</Chip> {h.detail}</span>
              </div>
            ))}
            {history.length === 0 && <div style={{ color: C.faint, fontSize: 13 }}>Every add, edit, launch, verdict, and metric log lands here permanently.</div>}
          </div>
        </Card>
      )}
    </div>
  );
}


ReactDOM.createRoot(document.getElementById("root")).render(<App />);
