/* DITTO AGENT — the conversational version of the whole platform.
   Chat with Ditto end-to-end: URL → concepts/scripts → ads / reels / catalogue shots /
   landing pages / campaign kits, with a world-class critique loop where the USER approves
   every improvement round (and can steer it with their own suggestions).

   Architecture: the backend `agent/` endpoint is a pure planner — one step per call,
   returning {say, action, options}. This component runs the action (all generation plumbing
   already lives in window.DittoAPI), appends a TOOL RESULT message, and asks the planner for
   the next step, until the agent yields to the user with tappable quick-reply options.

   NOTE ON STRUCTURE: every widget component lives at MODULE scope. Defining them inside
   AgentChatView would recreate the component types on each keystroke, remounting every bubble
   (videos restart, layout jumps, the input flickers). */

/* Stage narration per action — what's ACTUALLY happening, not "generating…". The ticker
   advances through the stages and holds on the last one until the call returns. */
const AGENT_STAGES = {
  analyze_reference: ['Studying your reference frame by frame', 'Reverse-engineering the craft — light, camera, copy', 'Mapping every trick to a studio tool', 'Writing your step-by-step recipe'],
  plan: ['Reading the conversation…', 'Weighing the next step…', 'Planning…'],
  scrape_url: ['Opening the product page…', 'Reading title, price & reviews…', 'Validating the product photos…', 'Grabbing the brand logo…'],
  ad_concepts: ['Studying the product signals…', 'Brainstorming campaign angles…', 'Writing 3 distinct concepts…'],
  reel_scripts: ['Studying the product signals…', 'Timing hooks and beats…', 'Writing 3 shot-by-shot scripts…'],
  generate_ad: ['Briefing the art director…', 'Composing the layout…', 'Placing your product, pixel-faithful…', 'Painting light & shadow…', 'Setting the headline typography…', 'Final render…'],
  critique: ['Putting on the creative-director hat…', 'Checking hierarchy & readability…', 'Hunting for artifacts…', 'Scoring against world-class…'],
  magic_edit: ['Parsing your instruction…', 'Masking the right region…', 'Repainting…'],
  instant_reel: ['Storyboarding the motion…', 'Sending to Ditto Motion…', 'Rendering frames (this takes 1–3 min)…', 'Adding native sound…'],
  catalogue_photos: ['Building the set…', 'Lighting the product…', 'Shooting…'],
  landing_page: ['Reading the product page…', 'Writing conversion copy…', 'Designing the sections…', 'Rendering the page…'],
  campaign_kit: ['Building the landing page…', 'Filming the 8s motion hero (1–3 min)…', 'Shooting the catalogue…', 'Designing ad statics…', 'Writing the copy deck…', 'Packing the kit…'],
  long_video: ['Writing the continuous story script…', 'Rendering the scene frames…', 'Filming segment by segment — each continues the exact last frame (3–10 min)…', 'Stitching the unbroken take…'],
  spin_360: ['Ordering your product angles…', 'Interpolating the rotation between real photos…', 'Rendering the turntable (2–8 min)…', 'Closing the loop…'],
};

const AGENT_VEO = { fast: 'veo-3.1-fast-generate-preview', lite: 'veo-3.1-lite-generate-preview', pro: 'veo-3.1-generate-preview' };

/* ---- premium widgets (module scope — stable identity across renders) ---- */

function AgentAvatar() {
  return (
    <div style={{ width: 26, height: 26, borderRadius: '50%', flex: 'none', display: 'grid', placeItems: 'center', background: 'linear-gradient(135deg, var(--accent), #b18cff)', color: '#fff', fontSize: 13, marginTop: 2 }}>✦</div>
  );
}

const agentScoreColor = (s) => (s >= 8 ? '#0a7d43' : s >= 6 ? '#a15c00' : '#b3261e');

/* MENTOR: the recreate-it-here recipe card — summary, what's in the reference, numbered steps. */
function AgentRecipeCard({ d }) {
  return (
    <div className="card" style={{ padding: 14, maxWidth: 560 }}>
      <div className="eyebrow" style={{ marginBottom: 6 }}>📚 How to make this — step by step</div>
      {d.summary && <p style={{ fontSize: 12.5, lineHeight: 1.55, margin: '0 0 10px' }}>{d.summary}</p>}
      {(d.elements || []).length > 0 && (
        <div className="row" style={{ gap: 5, flexWrap: 'wrap', marginBottom: 10 }}>
          {d.elements.map((e, i) => <span key={i} className="tag" style={{ fontSize: 10 }}>{e}</span>)}
        </div>
      )}
      <ol style={{ margin: 0, paddingLeft: 18, display: 'flex', flexDirection: 'column', gap: 7 }}>
        {(d.recipe || []).map((st, i) => <li key={i} style={{ fontSize: 12.5, lineHeight: 1.5 }}>{st}</li>)}
      </ol>
      <div className="dim" style={{ fontSize: 10.5, marginTop: 10 }}>Answer the questions below (or just say "create it") — I can run this whole recipe for you right here.</div>
    </div>
  );
}

function AgentProductCard({ d }) {
  return (
    <div className="card" style={{ padding: 12, display: 'grid', gridTemplateColumns: d.img ? '72px 1fr' : '1fr', gap: 12, alignItems: 'center', maxWidth: 520 }}>
      {d.img && <img src={d.img} alt="" style={{ width: 72, height: 72, objectFit: 'cover', borderRadius: 10, border: '1px solid var(--line-2)' }} />}
      <div className="col" style={{ gap: 4, minWidth: 0 }}>
        <strong style={{ fontSize: 13, lineHeight: 1.35, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{d.title || 'Product'}</strong>
        <div className="row" style={{ gap: 8, alignItems: 'baseline', flexWrap: 'wrap' }}>
          {d.price && <b style={{ fontSize: 14 }}>{(d.currency === 'INR' ? '₹' : (d.currency || '')) + d.price}</b>}
          {d.mrp && <span className="dim" style={{ fontSize: 11.5, textDecoration: 'line-through' }}>{(d.currency === 'INR' ? '₹' : '') + d.mrp}</span>}
          {d.rating && <span style={{ fontSize: 11.5, color: '#a15c00' }}>★ {d.rating} ({d.rating_count || '?'})</span>}
        </div>
        <div className="row" style={{ gap: 6, flexWrap: 'wrap' }}>
          {d.offer && <span className="mono" style={{ fontSize: 10, background: 'var(--accent-glow)', color: 'var(--accent)', padding: '2px 8px', borderRadius: 'var(--r-full)', fontWeight: 700 }}>{d.offer}</span>}
          <span className="mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>photo imported ✓{d.gotLogo ? ' · logo ✓' : ''}</span>
        </div>
      </div>
    </div>
  );
}

function AgentScoreCard({ d }) {
  return (
    <div className="card" style={{ padding: 14, maxWidth: 520 }}>
      <div className="row" style={{ gap: 14, alignItems: 'center' }}>
        <div style={{ width: 54, height: 54, borderRadius: '50%', display: 'grid', placeItems: 'center', flex: 'none', border: '3px solid ' + agentScoreColor(d.score || 0), color: agentScoreColor(d.score || 0), fontWeight: 800, fontSize: 17, fontFamily: 'var(--font-display)' }}>
          {d.score != null ? d.score : '–'}
        </div>
        <div className="col" style={{ gap: 2 }}>
          <span className="mono" style={{ fontSize: 9.5, letterSpacing: '0.08em', color: 'var(--ink-3)' }}>CREATIVE DIRECTOR · SCORE / 10</span>
          <span style={{ fontSize: 12.5, lineHeight: 1.4 }}>{d.verdict}</span>
        </div>
      </div>
      {(d.issues || []).length > 0 && (
        <div className="col" style={{ gap: 4, marginTop: 10, paddingTop: 10, borderTop: '1px dashed var(--line-2)' }}>
          {d.issues.map((x, i) => <span key={i} style={{ fontSize: 11.5, color: 'var(--ink-2)' }}>▸ {x}</span>)}
        </div>
      )}
    </div>
  );
}

function AgentConceptCard({ d, onSend }) {
  return (
    <div className="card" style={{ padding: 12, maxWidth: 520 }}>
      <div className="row" style={{ gap: 8, alignItems: 'baseline' }}>
        <span className="mono" style={{ fontSize: 10, color: 'var(--accent)', fontWeight: 700 }}>#{d.n}</span>
        <strong style={{ fontSize: 12.5 }}>{d.name}</strong>
        {d.cta && <span className="mono" style={{ fontSize: 9.5, marginLeft: 'auto', color: 'var(--ink-3)' }}>CTA · {d.cta}</span>}
      </div>
      {d.headline && <div style={{ fontFamily: 'var(--font-display)', fontSize: 14, margin: '3px 0' }}>“{d.headline}”</div>}
      {(d.scene || d.hook) && <div style={{ fontFamily: 'var(--font-serif)', fontStyle: 'italic', fontSize: 11.5, color: 'var(--ink-2)' }}>{d.scene || d.hook}</div>}
      {(d.beats || []).length > 0 && <div className="col" style={{ gap: 2, marginTop: 5 }}>{d.beats.map((b2, i) => <span key={i} className="mono" style={{ fontSize: 10.5, color: 'var(--ink-2)' }}>{b2}</span>)}</div>}
      <button className="btn btn-ghost btn-sm" style={{ marginTop: 8, fontSize: 11.5 }} onClick={() => onSend('Use ' + (d.beats ? 'script' : 'concept') + ' #' + d.n + ' — ' + d.name)}>Use this →</button>
    </div>
  );
}

function AgentStoryScript({ d }) {
  return (
    <div className="card" style={{ padding: 14, maxWidth: 560 }}>
      <div className="row" style={{ alignItems: 'baseline', gap: 8, marginBottom: 6 }}>
        <span className="mono" style={{ fontSize: 9.5, letterSpacing: '0.06em', color: 'var(--ink-3)', textTransform: 'uppercase' }}>📜 story script · one continuous take</span>
        {d.music && <span className="dim" style={{ fontSize: 11, marginLeft: 'auto' }}>🎵 {d.music}</span>}
      </div>
      {d.style && <p style={{ fontFamily: 'var(--font-serif)', fontStyle: 'italic', fontSize: 12, color: 'var(--ink-2)', margin: '0 0 8px', lineHeight: 1.5 }}>{d.style}</p>}
      <div className="col" style={{ gap: 7 }}>
        {(d.scenes || []).map((sc, i) => (
          <div key={i} style={{ fontSize: 12, lineHeight: 1.5 }}>
            <span className="mono" style={{ fontSize: 9.5, color: 'var(--accent)', fontWeight: 700 }}>{(i * 8) + '–' + ((i + 1) * 8) + 's'}</span> <b>{sc.title}</b>
            <br />{sc.visual || sc.caption || ''}
            <span className="dim"> · 🎥 {sc.motion || 'subtle motion'}</span>
            {sc.narration && <div style={{ marginTop: 3, padding: '3px 8px', background: 'var(--accent-glow)', border: '1px solid var(--accent)', borderRadius: 6, fontSize: 11.5 }}>🎙 <i>“{sc.narration}”</i></div>}
          </div>
        ))}
      </div>
    </div>
  );
}

function AgentImagePick({ d, onConfirm }) {
  const [sel, setSel] = useState(() => new Set([0]));
  const [applied, setApplied] = useState(false);
  const [busyPick, setBusyPick] = useState(false);
  const toggle = (i) => { if (applied) return; setSel(s => { const n = new Set(s); n.has(i) ? n.delete(i) : n.add(i); return n; }); };
  const confirm = async () => {
    const picked = (d.images || []).filter((_, i) => sel.has(i));
    if (!picked.length || busyPick) return;
    setBusyPick(true);
    const ok = await onConfirm(picked);
    setBusyPick(false);
    if (ok) setApplied(true);
  };
  return (
    <div className="card" style={{ padding: 12, maxWidth: 520 }}>
      <div className="mono" style={{ fontSize: 9.5, letterSpacing: '0.06em', color: 'var(--ink-3)', marginBottom: 8, textTransform: 'uppercase' }}>
        {applied ? sel.size + ' photo' + (sel.size > 1 ? 's' : '') + ' in use — the rest are discarded ✓' : 'Pick the product photos to use'}
      </div>
      <div className="row" style={{ gap: 8, flexWrap: 'wrap' }}>
        {(d.images || []).slice(0, 8).map((src, i) => (
          <button key={i} onClick={() => toggle(i)} style={{
            width: 64, height: 64, padding: 0, position: 'relative', borderRadius: 10, overflow: 'hidden',
            border: sel.has(i) ? '2px solid var(--accent)' : '1px solid var(--line-2)',
            opacity: applied && !sel.has(i) ? 0.25 : sel.has(i) ? 1 : 0.75,
            cursor: applied ? 'default' : 'pointer', background: 'var(--surface-2)',
          }}>
            <img src={src} alt="" referrerPolicy="no-referrer" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
            {sel.has(i) && <span style={{ position: 'absolute', top: 3, right: 3, width: 16, height: 16, borderRadius: '50%', background: 'var(--accent)', color: '#fff', fontSize: 10, display: 'grid', placeItems: 'center', fontWeight: 700 }}>✓</span>}
          </button>
        ))}
      </div>
      {!applied && (
        <button className="btn btn-accent btn-sm" disabled={!sel.size || busyPick} onClick={confirm} style={{ marginTop: 10 }}>
          {busyPick ? 'Importing…' : 'Use ' + sel.size + ' selected photo' + (sel.size > 1 ? 's' : '')}
        </button>
      )}
    </div>
  );
}

/* Generated audio + a one-click MP3 download. The model hands back WAV/PCM, which Safari and most
   phones won't save sensibly, so the bytes are transcoded server-side on demand (ffmpeg) rather
   than at generation time — most clips are never downloaded. */
function AgentAudio({ src, label }) {
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState('');
  const grabMp3 = async () => {
    setBusy(true); setErr('');
    try {
      const r = await window.DittoAPI.audioToMp3({ audio: src });
      if (r && r.audio_base64) {
        const a = document.createElement('a');
        a.href = 'data:audio/mpeg;base64,' + r.audio_base64;
        a.download = 'ditto-audio-' + Date.now() + '.mp3';
        document.body.appendChild(a); a.click(); a.remove();
      } else setErr(r && r.reason === 'ffmpeg_unavailable' ? 'MP3 needs ffmpeg on the server.' : 'Could not convert to MP3.');
    } catch (e) { setErr('Could not convert to MP3.'); }
    setBusy(false);
  };
  return (
    <div className="card" style={{ padding: 10 }}>
      <audio controls src={src} style={{ height: 34, maxWidth: 320, width: '100%' }} />
      <div className="row" style={{ gap: 6, marginTop: 8, flexWrap: 'wrap' }}>
        <button className="btn btn-ghost btn-sm" disabled={busy} onClick={grabMp3}>
          <Icon name="download" size={12} />{busy ? 'Converting…' : 'Download MP3'}</button>
        <a className="btn btn-quiet btn-sm" href={src} download="ditto-audio.wav" style={{ fontSize: 11 }}>Original</a>
      </div>
      <div className="dim" style={{ fontSize: 10.5, marginTop: 4 }}>{err || label}</div>
    </div>
  );
}

function AgentBubble({ m, isLast, busy, onSend, onEditor, onPickImages }) {
  if (m.hidden) return null;
  if (m.role === 'widget') {
    return (
      <div className="row" style={{ gap: 8, alignItems: 'flex-start' }}>
        <div style={{ width: 26, flex: 'none' }} />
        <div style={{ maxWidth: 'min(560px, 84%)' }}>
          {m.kind === 'product' && <AgentProductCard d={m.data} />}
          {m.kind === 'recipe' && <AgentRecipeCard d={m.data} />}
          {m.kind === 'imagepick' && <AgentImagePick d={m.data} onConfirm={onPickImages} />}
          {m.kind === 'storyscript' && <AgentStoryScript d={m.data} />}
          {m.kind === 'critique' && <AgentScoreCard d={m.data} />}
          {(m.kind === 'concept' || m.kind === 'script') && <AgentConceptCard d={m.data} onSend={onSend} />}
          {m.kind === 'image' && (
            <div className="card" style={{ padding: 10 }}>
              <div className="mono" style={{ fontSize: 9.5, letterSpacing: '0.06em', color: 'var(--ink-3)', marginBottom: 6, textTransform: 'uppercase' }}>{m.label}</div>
              <img src={m.src} alt="" style={{ maxWidth: '100%', borderRadius: 10, display: 'block' }} />
              {m.editable && (
                <div className="row" style={{ gap: 6, marginTop: 8 }}>
                  <button className="btn btn-ghost btn-sm" onClick={() => onEditor(m.src)}><Icon name="layers" size={12} />Open in editor</button>
                  <a className="btn btn-ghost btn-sm" href={m.src} download="ditto-agent.png"><Icon name="download" size={12} />Download</a>
                </div>
              )}
            </div>
          )}
          {m.kind === 'audio' && <AgentAudio src={m.src} label={m.label} />}
          {m.kind === 'video' && (
            <div className="card" style={{ padding: 10 }}>
              <div className="mono" style={{ fontSize: 9.5, letterSpacing: '0.06em', color: 'var(--ink-3)', marginBottom: 6, textTransform: 'uppercase' }}>{m.label}</div>
              <video src={m.src} controls loop muted playsInline preload="metadata" style={{ maxWidth: '100%', maxHeight: 420, borderRadius: 10, display: 'block' }} />
              <div className="row" style={{ gap: 6, marginTop: 8 }}>
                <a className="btn btn-ghost btn-sm" href={m.src} download="ditto-reel.mp4"><Icon name="download" size={12} />Download</a>
              </div>
            </div>
          )}
          {m.kind === 'page' && (
            <div className="card" style={{ padding: 10 }}>
              <div className="row" style={{ alignItems: 'center', marginBottom: 8, gap: 8 }}>
                <span className="mono" style={{ fontSize: 9.5, letterSpacing: '0.06em', color: 'var(--ink-3)', textTransform: 'uppercase' }}>{m.label}</span>
                <a className="btn btn-ghost btn-sm" style={{ marginLeft: 'auto', fontSize: 11.5 }} href={m.href} target="_blank" rel="noreferrer">Open full ↗</a>
              </div>
              <iframe src={m.href} title="Landing page preview" sandbox="allow-scripts"
                style={{ width: '100%', height: 460, border: '1px solid var(--line-2)', borderRadius: 10, background: '#fff', display: 'block' }} />
              {m.text && <div className="dim" style={{ fontSize: 11.5, marginTop: 6 }}>“{m.text}”</div>}
            </div>
          )}
          {m.kind === 'link' && (
            <div className="card row" style={{ padding: 12, gap: 10, alignItems: 'center' }}>
              <span style={{ fontSize: 12.5 }}>{m.label}</span>
              <a className="btn btn-accent btn-sm" style={{ marginLeft: 'auto' }} href={m.href} {...(m.download ? { download: m.download } : { target: '_blank', rel: 'noreferrer' })}>{m.download ? 'Download' : 'Open'} →</a>
            </div>
          )}
        </div>
      </div>
    );
  }
  const isUser = m.role === 'user';
  return (
    <div className="col" style={{ gap: 6 }}>
      <div className="row" style={{ justifyContent: isUser ? 'flex-end' : 'flex-start', gap: 8, alignItems: 'flex-start' }}>
        {!isUser && <AgentAvatar />}
        <div style={{
          maxWidth: 'min(600px, 80%)', padding: m.imgs ? 10 : '9px 14px', borderRadius: 14,
          borderTopLeftRadius: isUser ? 14 : 4, borderTopRightRadius: isUser ? 4 : 14,
          background: isUser ? 'var(--accent)' : 'var(--surface)',
          color: isUser ? '#fff' : 'var(--ink-1)',
          border: isUser ? 'none' : '1px solid var(--line-2)',
          fontSize: 13.5, lineHeight: 1.55, whiteSpace: 'pre-wrap', overflowWrap: 'break-word',
        }}>
          {m.text}
          {(m.imgs || []).map((u, i) => <img key={i} src={u} alt="" style={{ maxWidth: 200, borderRadius: 10, display: 'block', marginTop: m.text ? 8 : 0 }} />)}
        </div>
      </div>
      {!isUser && isLast && !busy && (m.options || []).length > 0 && (
        <div className="row" style={{ gap: 6, flexWrap: 'wrap', paddingLeft: 34 }}>
          {m.options.map((o) => (
            <button key={o} className="btn btn-sm" onClick={() => onSend(o.replace(/^[^\w₹]+\s*/, ''))} style={{
              borderRadius: 'var(--r-full)', fontSize: 12, padding: '5px 13px',
              background: 'var(--surface)', border: '1px solid var(--accent)', color: 'var(--accent)', fontWeight: 600,
            }}>{o}</button>
          ))}
        </div>
      )}
    </div>
  );
}

function AgentChatView({ features, setView, onEdit, spendCredits }) {
  const api = window.DittoAPI;
  const [msgs, setMsgs] = useState([{
    role: 'agent',
    text: "Hi, I'm Ditto — your creative agent. Give me a product URL or a photo and tell me what you need. I'll plan it, build it, score it like a creative director, and improve it with you until it's world-class.",
    options: ['🚀 Ad from a product URL', '🎬 Make an instant reel', '📸 Catalogue shots', '📦 Full campaign kit'],
  }]);
  const [input, setInput] = useState('');
  const [busy, setBusy] = useState(false);
  const [stage, setStage] = useState(null);            // {name, t0}
  const [tick, setTick] = useState(0);
  const assets = useRef({ product: null, logo: null, lastImage: null, landingHtml: null, landingSpec: null });
  const scroller = useRef(null);

  /* Autoscroll ONLY when the user is already near the bottom — a long render must not yank
     them back down while they scroll up to review earlier results. */
  useEffect(() => {
    const el = scroller.current;
    if (!el) return;
    const near = el.scrollHeight - el.scrollTop - el.clientHeight < 220;
    if (near) el.scrollTop = el.scrollHeight;
  }, [msgs, busy]);
  useEffect(() => {
    if (!stage) return;
    const id = setInterval(() => setTick(t => t + 1), 3200);
    return () => clearInterval(id);
  }, [stage && stage.name]);

  const beginStage = (name) => { setStage({ name, t0: Date.now() }); setTick(0); };
  const push = (m) => setMsgs(s => [...s, m]);
  const b64 = (dataUri) => (dataUri && dataUri.split(',')[1]) || dataUri;

  /* ---- action executors: each returns a compact TOOL RESULT text (+ pushes widgets) ---- */

  const importFirstImage = async (res) => {
    const got = { logo: false };
    if (res.logo && !assets.current.logo) {
      const uri = await api.fetchImageDataUri({ src: res.logo });
      if (uri) { assets.current.logo = uri; got.logo = true; }
    }
    return got;
  };

  const runScrape = async (p) => {
    const res = await api.scrapeUrl({ url: p.url });
    if (res.status !== 'ok') return 'Could not read that page (' + (res.status || 'error') + '). Ask the user for a photo + brief instead.';
    const got = await importFirstImage(res);   // logo only — product photos go through the picker
    assets.current.facts = { title: res.title || '', brand: res.brand || '' };   // durable across the history window
    push({ role: 'widget', kind: 'product', data: { ...res, img: (res.images || [])[0], gotLogo: got.logo || !!assets.current.logo } });
    const nImgs = (res.images || []).length;
    if (nImgs) push({ role: 'widget', kind: 'imagepick', data: { images: res.images }, label: 'photo picker' });
    return 'Page read OK. title="' + (res.title || '') + '" · brand=' + (res.brand || '?') + ' · price=' + (res.price || '?') + ' ' + (res.currency || '') +
      ' · mrp=' + (res.mrp || '-') + ' · rating=' + (res.rating || '-') + ' (' + (res.rating_count || '-') + ') · offer="' + (res.offer || '') + '"' +
      ' · suggested brief="' + (res.description || '') + '" · backdrop="' + (res.background_prompt || '') + '"' +
      ' · logo imported: ' + (assets.current.logo ? 'YES' : 'NO') +
      (nImgs
        ? ' · ' + nImgs + ' product photo candidates shown with a PICKER — WAIT for the user to confirm which photos to use (action=null, ask them to pick) before ANY generation.'
        : ' · no product photos found — ask the user to upload one.');
  };

  const runConcepts = async (p) => {
    const r = await api.scrapeConcepts({ url: p.url, answers: p });
    (r.concepts || []).forEach((c, i) => push({ role: 'widget', kind: 'concept', data: { ...c, n: i + 1 } }));
    return 'Concepts shown to the user:\n' + (r.concepts || []).map((c, i) =>
      (i + 1) + '. ' + c.name + ' — headline "' + c.headline + '" · brief: ' + c.description).join('\n');
  };

  const runReelScripts = async (p) => {
    const r = await api.scrapeReelScripts({ url: p.url, answers: p });
    (r.scripts || []).forEach((s, i) => push({ role: 'widget', kind: 'script', data: { ...s, n: i + 1 } }));
    return 'Reel scripts shown to the user:\n' + (r.scripts || []).map((s, i) =>
      (i + 1) + '. ' + s.name + ' — hook: ' + s.hook + ' · VO: "' + (s.voiceover || '') + '"').join('\n');
  };

  const runGenerateAd = async (p) => {
    if (!assets.current.product) return 'NO PRODUCT PHOTO — ask the user for a photo or a product URL.';
    spendCredits && spendCredits();
    // statics flow: headline/CTA are BAKED into the image (a chat needs a finished visual, and
    // the critique judges what it sees) — smart-ads' layered copy would arrive invisible here.
    const desc = (p.description || '') + (p.brand_name ? ' Brand: ' + p.brand_name + '.' : '') +
      (p.style_directive ? '\nCREATIVE DIRECTION: ' + p.style_directive : '');
    const res = await api.generateStatics({
      productDataURL: assets.current.product, description: desc,
      format: p.format || 'square', platforms: 'instagram',
      logoDataURL: assets.current.logo || null,
    });
    const img = res && res.ad_image_base64;
    if (!img || res.status !== 'ok') return 'Ad generation failed (' + ((res && res.reason) || 'error') + '). Try a simpler description.';
    const uri = 'data:image/png;base64,' + img;
    assets.current.lastImage = uri;
    push({ role: 'widget', kind: 'image', src: uri, label: 'Generated ad', editable: true });
    return 'Ad generated OK and displayed to the user (headline/CTA are baked into the image). Now run critique.';
  };

  const runCritique = async (p) => {
    if (!assets.current.lastImage) return 'No generated image to critique yet.';
    const r = await api.agentCritique({ imageDataURL: assets.current.lastImage, goal: p.goal || '', platform: p.platform || '' });
    if (r.status !== 'ok') return 'Critique unavailable — present the current version.';
    push({ role: 'widget', kind: 'critique', data: r });
    return 'CRITIQUE: score=' + r.score + '/10 · verdict="' + r.verdict + '" · issues: ' + (r.issues || []).join('; ') +
      ' · improved_prompt="' + (r.improved_prompt || '') + '". Present this to the user and ASK before regenerating.';
  };

  const runMagicEdit = async (p) => {
    if (!assets.current.lastImage) return 'No generated image to edit yet.';
    const r = await api.magicEdit({ imageDataURL: assets.current.lastImage, instruction: p.instruction || '' });
    if (!r || !r.image_base64) return 'Edit failed — try different wording.';
    const uri = 'data:image/png;base64,' + r.image_base64;
    assets.current.lastImage = uri;
    push({ role: 'widget', kind: 'image', src: uri, label: 'Edited · ' + (p.instruction || '').slice(0, 60), editable: true });
    return 'Edit applied and shown to the user.';
  };

  const runInstantReelChat = async (p) => {
    if (!assets.current.product) return 'NO PRODUCT PHOTO — ask the user for a photo or a product URL.';
    // the imported brand logo is BAKED into the seed frame (videostudio's compositeLogo) — never
    // ask the video model to "add a logo" in text: it hallucinates a random one.
    const seed = (assets.current.logo && typeof compositeLogo === 'function')
      ? await compositeLogo(assets.current.product, assets.current.logo)
      : assets.current.product;
    const logoLine = assets.current.logo
      ? ' A brand logo is overlaid in the top-right corner of the frame — it must stay static, crisp and completely unchanged for the entire clip; never animate, redraw or replace it.'
      : '';
    const sc = [{ image_base64: b64(seed), title: 'Product', caption: '' }];
    const aspect = p.aspect === '16:9' ? '16:9' : '9:16';
    const veoModel = AGENT_VEO[p.model] || AGENT_VEO.fast;
    spendCredits && spendCredits();
    // ONE VOICE: narrator VO becomes a TTS bed mixed at stitch — Veo renders silent then;
    // only model-mode uses native audio (the person speaks on camera).
    const wantsBed = !!(p.voiceover || '').trim() && (p.speaker || 'narrator') !== 'model';
    const r = await api.generateVideo({ scenes: sc, brief: (p.directive || 'A scroll-stopping product reel.') + (p.style_directive ? ' CREATIVE DIRECTION: ' + p.style_directive : '') + logoLine, mode: 'single', aspect, audio: !wantsBed, veoModel, voiceover: p.speaker === 'model' ? (p.voiceover || '') : '', speaker: p.speaker || 'narrator' });
    if (!r || !r.segments || !r.segments.length) return 'Video engine unavailable right now.';
    const t0 = Date.now();
    const ops = r.segments;   // poll expects the {idx, op, caption} segment objects
    let overloaded = false, peopleBlock = false;
    while (Date.now() - t0 < 8 * 60 * 1000) {
      await new Promise(res2 => setTimeout(res2, 8000));
      const pr = await api.pollVideo({ ops });
      if (pr && pr.overloaded) overloaded = true;
      if (pr && pr.blocked_people) peopleBlock = true;
      const done = ((pr && pr.clips) || []).filter(c => c.video_base64);
      if (done.length) {
        let src = 'data:' + (done[0].mime || 'video/mp4') + ';base64,' + done[0].video_base64;
        if (wantsBed) {   // mix the single narrator voice over the silent clip
          try {
            const tts = await api.generateVoiceover({ text: p.voiceover });
            if (tts && tts.audio_base64) {
              const sv = await api.stitchVideoServer({ clips: [done[0]], transition: 'none', aspect, voiceoverAudio: tts.audio_base64, muteClips: true });
              if (sv && sv.status === 'ok' && sv.video_base64) src = 'data:' + (sv.mime || 'video/mp4') + ';base64,' + sv.video_base64;
            }
          } catch (e) { }
        }
        push({ role: 'widget', kind: 'video', src, label: 'Instant reel · ' + aspect + (assets.current.logo ? ' · logo baked in' : '') });
        return 'Reel rendered and shown to the user' + (assets.current.logo ? ' (brand logo baked into the frame)' : '') + '.';
      }
      if (pr && pr.done && !done.length) break;   // finished with no clip (e.g. model overload)
    }
    if (peopleBlock) {
      return 'VIDEO BLOCKED by the safety filter: the directive referenced a real person’s name or likeness. ' +
        'INFORM the user, then call instant_reel again with a directive that contains NO real people’s names ' +
        '(describe "a young model" instead). Never reference celebrities in directives.';
    }
    if (overloaded) {
      const next = p.model === 'pro' ? 'fast' : p.model === 'lite' ? 'pro' : 'lite';
      return 'VIDEO MODEL OVERLOADED — Google error 14 ("experiencing high demand") on the ' + (p.model || 'fast') +
        ' model, nothing rendered. INFORM the user of this exact error and ASK whether to retry with the ' + next +
        ' model (offer options like ["Retry with ' + next + '", "Wait a few minutes"]). Do not retry without consent.';
    }
    return 'Reel render produced no clip (timeout or content filter). Tell the user and suggest trying again or using Video Studio.';
  };

  const runCatalogue = async (p) => {
    if (!assets.current.product) return 'NO PRODUCT PHOTO — ask the user for a photo or a product URL.';
    const themes = (p.themes || ['hero']).slice(0, 3);
    const out = [];
    for (const th of themes) {
      spendCredits && spendCredits();
      try {
        const r = await api.enhanceCatalogue({ productDataURLs: (assets.current.photos || [assets.current.product]).slice(0, 4), theme: th, brief: p.brief || '', offer: p.offer || '', brand: p.brand || '' });
        if (r && r.image_base64) {
          const uri = 'data:image/png;base64,' + r.image_base64;
          assets.current.lastImage = uri;
          push({ role: 'widget', kind: 'image', src: uri, label: 'Catalogue · ' + th, editable: true });
          out.push(th + ': OK');
        } else out.push(th + ': failed');
      } catch (e) { out.push(th + ': failed'); }
    }
    return 'Catalogue shots — ' + out.join(' · ') + '. Shown to the user.';
  };

  const runLanding = async (p) => {
    // HARD GUARD: landing/kit consume product photos — never run them on an unconfirmed set.
    if (!assets.current.photoUrls || !assets.current.photoUrls.length) {
      return 'PHOTO SELECTION REQUIRED — run scrape_url first and WAIT for the user to confirm ' +
        'which product photos to use (the picker), then call this action again.';
    }
    spendCredits && spendCredits();
    const r = await api.generateLanding({ url: p.url, tone: p.tone || 'balanced', images: assets.current.photoUrls || undefined });
    if (!r || !r.spec || (r.status !== 'ok' && r.reason !== 'copy_fallback')) return 'Landing generation failed — check the URL.';
    const html = window.renderLandingHTML ? window.renderLandingHTML(r.spec) : r.html;
    if (!html) return 'Landing rendered no HTML.';
    assets.current.landingHtml = html; assets.current.landingSpec = r.spec;
    const blob = new Blob([html], { type: 'text/html' });
    push({ role: 'widget', kind: 'page', href: URL.createObjectURL(blob), label: '🌐 Landing page — live preview', text: (r.spec.hero && r.spec.hero.headline) || '' });
    return 'Landing page generated (headline: "' + ((r.spec.hero || {}).headline || '') + '")' +
      (assets.current.photoUrls ? ' using ONLY the ' + assets.current.photoUrls.length + ' photo(s) the user selected' : '') +
      '. An inline preview is shown in the chat.';
  };

  /* Kit's motion catalogue: stage a keyframe -> one Veo job -> web-optimized mp4. Mirrors the
     studio's buildMotionHero; failure never blocks the kit (it ships without the film). */
  const buildMotionHeroChat = async (spc) => {
    const heroSrc = spc.hero_image || (spc.images || [])[0];
    if (!heroSrc) return null;
    try {
      const title = (spc.product || {}).title || 'the product';
      const args = { scene: 'A premium, softly-lit scene that flatters ' + title + ', gentle ambient motion', aspect: '16:9', brief: title };
      if (heroSrc.startsWith('data:')) args.productDataURLs = [heroSrc]; else args.imageUrls = [heroSrc];
      const f = await api.motionScene(args);
      if (!f || !f.image_base64) return null;
      const start = await api.generateVideo({ scenes: [{ image_base64: f.image_base64, visual: args.scene, caption: '' }], brief: args.scene, tagline: '', mode: 'single', aspect: '16:9', audio: false });
      if (!start || !start.segments || !start.segments.length) return null;
      let clip = null;
      for (let i = 0; i < 40 && !clip; i++) {
        await new Promise(r2 => setTimeout(r2, 7000));
        const pr = await api.pollVideo({ ops: start.segments }).catch(() => null);
        if (pr && pr.clips && pr.clips.length) clip = pr.clips[0];
        else if (pr && pr.done) break;
      }
      if (!clip) return null;
      const raw = 'data:' + (clip.mime || 'video/mp4') + ';base64,' + clip.video_base64;
      const opt = await api.optimizeVideo({ videoSrc: raw, maxWidth: 960, crf: 28 }).catch(() => null);
      return (opt && opt.status === 'ok' && opt.video_base64) ? 'data:video/mp4;base64,' + opt.video_base64 : raw;
    } catch (e) { return null; }
  };

  const runKit = async (p) => {
    const landing = await runLanding(p);
    if (!assets.current.landingSpec) return landing;
    // the 8s motion catalogue film — part of every kit (page hero video + kit asset)
    const motionSrc = await buildMotionHeroChat(assets.current.landingSpec);
    if (motionSrc) {
      assets.current.landingSpec = { ...assets.current.landingSpec, hero_video: motionSrc };
      if (window.renderLandingHTML) assets.current.landingHtml = window.renderLandingHTML(assets.current.landingSpec);
      push({ role: 'widget', kind: 'video', src: motionSrc, label: 'Motion catalogue · 8s hero film' });
    }
    const r = await api.landingKit({ spec: assets.current.landingSpec, html: assets.current.landingHtml, motionSrc: motionSrc || undefined });
    if (!r || !r.zip_base64) return landing + ' Kit assembly failed — the landing page alone is ready.';
    const a = r.assets || {};
    (a.shots || []).forEach((s2, i) => s2 && s2.image_base64 && push({ role: 'widget', kind: 'image', src: 'data:image/png;base64,' + s2.image_base64, label: 'Kit shot · ' + (s2.theme || i + 1), editable: true }));
    (a.ads || []).forEach((s2, i) => { const ib = (s2 && s2.image_base64) || (typeof s2 === 'string' ? s2 : null); if (ib) push({ role: 'widget', kind: 'image', src: 'data:image/png;base64,' + ib, label: 'Kit ad ' + (i + 1), editable: true }); });
    push({ role: 'widget', kind: 'link', href: 'data:application/zip;base64,' + r.zip_base64, download: 'campaign-kit.zip', label: '📦 Download the full campaign kit (.zip)' });
    return 'Campaign kit ready: ' + ((a.shots || []).length) + ' shots, ' + ((a.ads || []).length) + ' ads, ' +
      (motionSrc ? 'the 8s motion catalogue film, ' : '(motion film failed — kit shipped without it) ') +
      'landing page and copy. All shown to the user.';
  };

  /* One continuous long video: contiguous storyboard -> full script shown in chat -> sequential
     chained render (shared chainRenderVideo, Option A with the native-extend probe) -> hard-concat
     stitch. The storyboard, every segment and the final video are all recorded server-side. */
  const runLongVideo = async (p) => {
    if (!assets.current.product) return 'NO PRODUCT PHOTO — ask the user for a photo or a product URL.';
    const seconds = Math.min(40, Math.max(16, parseInt(p.seconds, 10) || 24));
    const nScenes = Math.min(6, Math.round(seconds / 8) + 1);
    const aspect = p.aspect === '9:16' ? '9:16' : '16:9';
    spendCredits && spendCredits();
    const sb = await api.generateStoryboard({ productDataURL: assets.current.product, productsDataURLs: (assets.current.photos || []).slice(0, 4), logoDataURL: assets.current.logo || null,
      brief: (p.brief || '') + (p.style_directive ? '\nCREATIVE DIRECTION: ' + p.style_directive : ''), tagline: p.tagline || '', scenes: nScenes, aspect, continuous: true });
    if (!sb || !sb.scenes || !sb.scenes.length) return 'Storyboard failed — try a simpler brief.';
    push({ role: 'widget', kind: 'storyscript', data: { style: sb.style, music: sb.music, scenes: sb.scenes.map(sc => ({ title: sc.title, visual: sc.visual, motion: sc.motion, caption: sc.caption, narration: sc.narration })) }, label: 'story script' });
    const frames = sb.scenes.filter(sc => sc.image_base64);
    if (frames.length < 2) return 'Story script shown, but scene frames failed to render — cannot film.';
    const res = await chainRenderVideo({ frames, brief: p.brief || '', tagline: p.tagline || '', aspect,
      voiceover: p.voiceover || '', speaker: p.speaker || 'narrator',
      veoModel: AGENT_VEO[p.model] || AGENT_VEO.fast, onStage: () => {} });
    if (!res.clips.length) return 'Story script shown, but the render failed (' + (res.error || 'no clips') + '). Suggest retrying.';
    let final = null;
    try {
      // ONE narrator voice across the WHOLE video: the per-beat narrations join into one script,
      // rendered as a single TTS track (segments are silent in narrator mode).
      let voB64 = null;
      if ((p.speaker || 'narrator') !== 'model') {
        const script = (p.voiceover || '').trim() || sb.scenes.map(sc => (sc.narration || '').trim()).filter(Boolean).join(' ');
        if (script) { try { const tts = await api.generateVoiceover({ text: script }); voB64 = (tts && tts.audio_base64) || null; } catch (e) { } }
      }
      const sv = await api.stitchVideoServer({ clips: res.clips, transition: 'none', aspect, voiceoverAudio: voB64, muteClips: !!voB64 });
      if (sv && sv.status === 'ok' && sv.video_base64) final = 'data:' + (sv.mime || 'video/mp4') + ';base64,' + sv.video_base64;
    } catch (e) { }
    if (!final) final = 'data:' + (res.clips[res.clips.length - 1].mime || 'video/mp4') + ';base64,' + res.clips[res.clips.length - 1].video_base64;
    push({ role: 'widget', kind: 'video', src: final, label: 'Continuous video · ~' + (res.clips.length * 8) + 's · ' + (res.mode === 'extend' ? 'native extend' : 'last-frame chained') });
    return 'Continuous ' + (res.clips.length * 8) + 's video rendered (' + res.mode + ' mode)' +
      (res.error ? ' — note: ' + res.error + ', shipped what finished' : '') + '. Script + video shown to the user and saved to their library.';
  };

  /* 360° spin from the user's confirmed photo set (2-4 real angles) — the same multi-keyframe
     interpolation loop Video Studio uses, with the delta guard bypassed. */
  const runSpin360 = async (p) => {
    const photos = assets.current.photos || [];
    if (photos.length < 2) return 'NEED 2+ PRODUCT PHOTOS from different angles — ask the user to select at least two in the picker (or upload more).';
    const b64s = photos.slice(0, 4).map(u => b64(u));
    const secs = parseInt(p.seconds, 10) || 0;   // 0 = one full rotation (N angles × 8s)
    const segCount = secs ? Math.max(1, Math.min(6, Math.round(secs / 8))) : b64s.length;
    const frames = Array.from({ length: segCount + 1 }, (_, i) => ({ image_base64: b64s[i % b64s.length], caption: '' }));
    const aspect = p.aspect === '9:16' ? '9:16' : '16:9';
    spendCredits && spendCredits();
    let sheet = '';
    try { const ps = await api.productSheet({ images: b64s }); sheet = (ps && ps.sheet) || ''; } catch (e) { }
    const r = await api.generateVideo({ scenes: frames,
      brief: 'A seamless 360° turntable rotation of the EXACT product in the keyframes: product centered and identical throughout, no cuts, no text.' +
        (p.treatment ? ' TREATMENT — ' + p.treatment + '.' : ' Constant-speed smooth orbit, consistent studio lighting and background.') +
        ' Rotate the PRODUCT ITSELF in 3D between keyframes — NEVER cross-fade, dissolve, ghost or morph between the images; every intermediate frame must be a physically valid viewing angle of the same solid object. If a person is visible, their ENTIRE body rotates as one rigid unit with the turntable — head, neck, shoulders and torso locked together, the head never turning independently or facing a different direction than the body.' +
        (sheet ? ' PRODUCT SHEET — the COMPLETE allowlist of marks on this product; reproduce these faithfully, add nothing, remove nothing, and every side the camera reveals must match it exactly: ' + sheet : ''),
      mode: 'all', aspect, audio: false, veoModel: AGENT_VEO[p.model] || AGENT_VEO.fast, forceInterpolate: true, refs: b64s });
    if (!r || !r.segments || !r.segments.length) return 'Video engine unavailable right now.';
    const byIdx = {};
    const t0 = Date.now();
    while (Date.now() - t0 < 12 * 60 * 1000) {
      await new Promise(r2 => setTimeout(r2, 8000));
      const pr = await api.pollVideo({ ops: r.segments }).catch(() => null);
      ((pr && pr.clips) || []).forEach(c => { byIdx[c.idx] = c; });
      if (pr && pr.done) break;
    }
    const ordered = Object.keys(byIdx).map(k => byIdx[k]).sort((a2, b2) => a2.idx - b2.idx);
    if (!ordered.length) return 'Spin render produced no clips — suggest retrying.';
    // brand-mark check against the real photos; chat has no re-roll loop, so be honest instead
    let brandNote = '';
    try {
      for (const c of ordered) {
        const q = await api.qcClip({ videoBase64: c.video_base64, refs: b64s });
        if (q && q.ok === false && (q.artifacts || []).length) { brandNote = ' ⚠ QC flagged: ' + q.artifacts[0] + ' — offer the user a regenerate.'; break; }
      }
    } catch (e) { }
    let final = null;
    try {
      const sv = await api.stitchVideoServer({ clips: ordered, transition: 'none', aspect, logo: assets.current.logo ? (assets.current.logo.split(',')[1] || assets.current.logo) : null });
      if (sv && sv.status === 'ok' && sv.video_base64) final = 'data:' + (sv.mime || 'video/mp4') + ';base64,' + sv.video_base64;
    } catch (e) { }
    if (!final) final = 'data:' + (ordered[0].mime || 'video/mp4') + ';base64,' + ordered[0].video_base64;
    push({ role: 'widget', kind: 'video', src: final, label: '360° spin · ' + ordered.length * 8 + 's · ' + b64s.length + ' angles' });
    return '360° spin rendered from ' + b64s.length + ' angles (' + ordered.length + ' segments) and shown to the user. Saved to their library.' + brandNote;
  };

  const runAnalyzeReference = async (p) => {
    const vid = assets.current.refVideo, img = assets.current.refImage;
    if (!vid && !img) return 'NO REFERENCE ATTACHED — ask the user to attach a reference ad (the 🎬 button next to the message box, image or video).';
    const r = await api.agentRecipe({ imageDataURL: vid ? null : img, videoDataURL: vid || null, note: p.note || '', hasProduct: !!assets.current.product });
    if (!r || r.status !== 'ok') return 'Could not analyze the reference (' + ((r && r.reason) || 'error') + ') — apologize and ask for a clearer file (videos under ~18MB).';
    push({ role: 'widget', kind: 'recipe', data: r });
    return 'RECIPE CARD SHOWN to the user.\nSummary: ' + r.summary +
      '\nSteps:\n' + (r.recipe || []).map((x, i) => (i + 1) + '. ' + x).join('\n') +
      '\nNow ask the user these clarifying questions ONE at a time (as tappable options): ' + (r.questions || []).join(' | ') +
      '\nALWAYS include the option "🚀 Create it for me now". When they confirm, run action `' + r.action + '` with params ' + r.params_json + ' (merged with their answers).';
  };

  const EXEC = {
    scrape_url: runScrape, ad_concepts: runConcepts, reel_scripts: runReelScripts,
    generate_ad: runGenerateAd, critique: runCritique, magic_edit: runMagicEdit,
    instant_reel: runInstantReelChat, catalogue_photos: runCatalogue, long_video: runLongVideo, spin_360: runSpin360, analyze_reference: runAnalyzeReference,
    landing_page: runLanding, campaign_kit: runKit,
  };

  /* ---- the agent loop: plan → execute → feed back, until the agent yields ---- */
  const historyText = (m) => {
    if (m.role === 'widget') return '[shown to user: ' + (m.label || m.kind) + ']';
    return m.text || '';
  };

  const runAgent = async (history) => {
    setBusy(true);
    let hist = history.slice();
    for (let step = 0; step < 10; step++) {
      beginStage('plan');
      let r;
      try {
        r = await api.agentStep({
          messages: hist.filter(m => historyText(m)).map(m => ({ role: m.role === 'widget' ? 'tool' : m.role, text: historyText(m) })),
          context: {
            product: !!assets.current.product, logo: !!assets.current.logo, last_image: !!assets.current.lastImage,
            ref_image: !!assets.current.refImage, ref_video: !!assets.current.refVideo,
            product_title: (assets.current.facts || {}).title || '', brand: (assets.current.facts || {}).brand || '',
          },
        });
      } catch (e) { push({ role: 'agent', text: (e && e.message) || 'I lost the thread — try again?' }); break; }
      if (r.say) { const m = { role: 'agent', text: r.say, options: !r.action ? (r.options || []) : [] }; push(m); hist.push(m); }
      if (!r.action) break;
      const fn = EXEC[r.action.name];
      beginStage(r.action.name);
      let resultText;
      try { resultText = fn ? await fn(r.action.params || {}) : 'Unknown action "' + r.action.name + '" — pick another.'; }
      catch (e) { resultText = 'Action failed: ' + ((e && e.message) || 'unknown error').slice(0, 140); }
      const tm = { role: 'tool', text: resultText, hidden: true };
      push(tm);        // kept in state (invisible) so the planner remembers it on future turns
      hist.push(tm);
    }
    setStage(null); setBusy(false);
  };

  const send = (text) => {
    const t = (text != null ? text : input).trim();
    if (!t || busy) return;
    setInput('');
    const m = { role: 'user', text: t };
    push(m);
    runAgent([...msgs, m]);
  };

  /* Picker confirm: the chosen photos become THE product set for every downstream flow
     (ad = first photo, catalogue = up to 4); everything unpicked is discarded. */
  const confirmImages = async (urls) => {
    const uris = [];
    for (const src of urls.slice(0, 4)) {
      const u = await api.fetchImageDataUri({ src });
      if (u) uris.push(u);
    }
    if (!uris.length) { push({ role: 'agent', text: 'I could not load those photos — try picking different ones.' }); return false; }
    assets.current.product = uris[0];
    assets.current.photos = uris;
    assets.current.photoUrls = urls.slice(0, 4);   // original urls — landing/kit generation filters to these
    const m = { role: 'user', text: 'I selected ' + uris.length + ' product photo' + (uris.length > 1 ? 's' : '') + ' to use. Discard the rest.' };
    push(m);
    runAgent([...msgs, m]);
    return true;
  };

  /* ── Direct chat (model playground): raw passthrough to the model the user picked — no Ditto
     system prompts, no rewriting. Separate message list so the agent conversation is untouched. ── */
  const canAgent = !features || features.indexOf('agent') >= 0;
  const [chatMode, setChatMode] = useState(canAgent ? 'agent' : 'direct');   // 'agent' | 'direct'
  const [playModels, setPlayModels] = useState(null);      // catalogue with availability
  const [playModel, setPlayModel] = useState('');          // selected model id
  const [dcMsgs, setDcMsgs] = useState([]);                // direct-chat transcript
  const [dcBusy, setDcBusy] = useState(false);
  const [dcImgs, setDcImgs] = useState([]);                // attachments for the NEXT message
  const [cat, setCat] = useState(null);                    // live image/audio/video model catalogue
  const [catQ, setCatQ] = useState('');                    // dropdown search
  const [catOpen, setCatOpen] = useState(false);
  const [rawModel, setRawModel] = useState(null);          // {slug,name,mod} chosen from the dropdown
  useEffect(() => {
    if (chatMode === 'direct' && playModels === null && api.playModels) {
      api.playModels().then(r => {
        const models = (r && r.models) || [];
        setPlayModels(models);
        const first = models.find(m => m.available);
        if (first && !playModel) setPlayModel(first.id);
      });
    }
  }, [chatMode]);   // eslint-disable-line
  useEffect(() => {
    if (chatMode === 'direct' && cat === null && api.playCatalogue) api.playCatalogue().then(setCat);
  }, [chatMode]);   // eslint-disable-line
  const dcPush = (m) => setDcMsgs(prev => [...prev, m]);
  const sendDirect = async (text) => {
    const t = (text != null ? text : input).trim();
    if (!t || dcBusy) return;
    if (!playModel && !rawModel) { dcPush({ role: 'agent', text: 'Pick a model above first.' }); return; }
    setInput('');
    const entry = rawModel
      ? { label: rawModel.name, caps: [rawModel.mod], raw: rawModel.slug }
      : (playModels && playModels.find(m => m.id === playModel));
    const userMsg = { role: 'user', text: t, imgs: dcImgs.slice() };
    dcPush(userMsg);
    // FULL CONVERSATION CONTEXT: every earlier turn goes back to the model — including what it
    // GENERATED (images ride as real image parts so "make it blue" refers to the right picture;
    // video/audio ride as notes). Transient status lines are skipped, and only the most recent
    // images are inlined so the payload stays sane on long chats.
    const HIST_MAX = 40, IMG_MAX = 6;
    const raw = [...dcMsgs, userMsg].filter(m =>
      (m.role === 'user' || m.role === 'agent' || m.role === 'widget') &&
      !(m.role === 'agent' && /^(🎬|⚠|\(the model returned)/.test(m.text || '')));
    const recent = raw.slice(-HIST_MAX);
    let imgBudget = IMG_MAX;
    const hist = [];
    for (let k = recent.length - 1; k >= 0; k--) {      // walk backwards: newest images win the budget
      const m = recent[k];
      if (m.role === 'widget') {
        if (m.kind === 'image' && m.src) {
          const keep = imgBudget > 0; if (keep) imgBudget--;
          hist.unshift({ role: 'assistant', text: '[generated image' + (m.label ? ': ' + m.label : '') + ']',
            images: keep ? [m.src] : [] });
        } else if (m.kind === 'video' || m.kind === 'audio') {
          hist.unshift({ role: 'assistant', text: '[generated ' + m.kind + (m.label ? ': ' + m.label : '') + ']', images: [] });
        }
        continue;
      }
      const ims = (m.imgs || []).filter(() => imgBudget-- > 0);
      hist.unshift({ role: m.role === 'agent' ? 'assistant' : 'user', text: m.text || '', images: ims });
    }
    const history = hist;
    setDcImgs([]);
    setDcBusy(true);
    try {
      if (entry && (entry.caps.indexOf('video') >= 0 || entry.raw === 'native:veo')) {
        // Veo passthrough: prompt AS-IS (+ first attachment as the seed frame), then poll
        dcPush({ role: 'agent', text: '🎬 Generating with ' + ((entry && entry.label) || 'the video model') + '… (1–3 min)' });
        const vslug = (entry && entry.raw && entry.raw !== 'native:veo') ? entry.raw
          : (entry && entry.slug && entry.slug !== 'native:veo') ? entry.slug : '';
        // SEED FROM THE CONVERSATION: use this message's attachment, else the most recent image in
        // the chat (an earlier upload OR an image the model just generated) — otherwise the video
        // model invents a product instead of animating the user's own.
        const seed = userMsg.imgs[0]
          || [...dcMsgs].reverse().reduce((acc, m) =>
               acc || (m.role === 'widget' && m.kind === 'image' && m.src) || (m.imgs || [])[0] || null, null);
        if (!userMsg.imgs[0] && seed) dcPush({ role: 'agent', text: '📎 Using the product image from earlier in this chat as the first frame.' });
        const r = await api.playVideo({ model: vslug, prompt: t, imageDataURL: seed || null, aspect: '16:9' });
        if (!r || r.status !== 'pending') { dcPush({ role: 'agent', text: '⚠ ' + ((r && r.error) || 'The video model did not start.') }); }
        else if (r.video_id) {          // OpenRouter video API (Kling/Seedance/Sora/Hailuo/Wan…)
          const t0 = Date.now(); let out = null;
          while (Date.now() - t0 < 12 * 60 * 1000 && !out) {
            await new Promise(res => setTimeout(res, 8000));
            const p = await api.playVideoPoll({ videoId: r.video_id, prompt: t });
            if (p && p.status === 'ok' && (p.video_url || p.video_base64)) {
              // signed proxy url on our own domain — the player streams it, no base64 in the JSON
              out = p.video_url ? ((p.video_url.indexOf('http') === 0 ? '' : (window.DITTO_API_BASE || '')) + p.video_url)
                : ('data:' + (p.mime || 'video/mp4') + ';base64,' + p.video_base64);
            }
            else if (p && p.status === 'error') { dcPush({ role: 'agent', text: '⚠ ' + (p.error || 'generation failed') }); break; }
          }
          if (out) dcPush({ role: 'widget', kind: 'video', src: out, label: (entry && entry.label) || 'Generated video' });
          else if (Date.now() - t0 >= 12 * 60 * 1000) dcPush({ role: 'agent', text: '⚠ Timed out waiting for the video.' });
        }
        else {
          const t0 = Date.now();
          let done = null;
          while (Date.now() - t0 < 8 * 60 * 1000 && !done) {
            await new Promise(res => setTimeout(res, 8000));
            const pr = await api.pollVideo({ ops: r.segments }).catch(() => null);
            done = (((pr && pr.clips) || []).filter(c => c.video_base64))[0] || null;
            if (pr && pr.done && !done) break;
          }
          if (done) dcPush({ role: 'widget', kind: 'video', src: 'data:' + (done.mime || 'video/mp4') + ';base64,' + done.video_base64, label: (entry && entry.label) || 'Generated video' });
          else dcPush({ role: 'agent', text: '⚠ No clip was produced (timeout or content filter).' });
        }
      } else {
        const r = await api.playChat({ model: (entry && entry.raw) || playModel, messages: history });
        if (!r || r.status !== 'ok') dcPush({ role: 'agent', text: '⚠ ' + ((r && r.error) || 'The model call failed.') });
        else {
          if (r.text) dcPush({ role: 'agent', text: r.text });
          (r.images || []).forEach(u => dcPush({ role: 'widget', kind: 'image', src: u, label: (entry && entry.label) || 'Generated image', editable: true }));
          if (r.audio) dcPush({ role: 'widget', kind: 'audio', src: r.audio, label: (entry && entry.label) || 'Generated audio' });
          if (!r.text && !(r.images || []).length && !r.audio) dcPush({ role: 'agent', text: '(the model returned an empty response)' });
        }
      }
    } catch (e) { dcPush({ role: 'agent', text: '⚠ ' + ((e && e.message) || 'failed').slice(0, 140) }); }
    setDcBusy(false);
  };
  /* A VIDEO attachment: no OpenRouter video model accepts video input, so we grab its FIRST FRAME
     and use that as the seed image — the useful interpretation of "continue/restyle this clip". */
  const frameFromVideo = (file) => new Promise(res => {
    const url = URL.createObjectURL(file);
    const v = document.createElement('video');
    v.muted = true; v.playsInline = true; v.src = url;
    v.onloadeddata = () => { v.currentTime = Math.min(0.1, (v.duration || 1) / 2); };
    v.onseeked = () => {
      const c = document.createElement('canvas');
      c.width = v.videoWidth || 1280; c.height = v.videoHeight || 720;
      c.getContext('2d').drawImage(v, 0, 0, c.width, c.height);
      URL.revokeObjectURL(url);
      res(c.toDataURL('image/png'));
    };
    v.onerror = () => { URL.revokeObjectURL(url); res(null); };
  });

  const attachDirect = (e) => {
    const vids = Array.from(e.target.files || []).filter(f => (f.type || '').indexOf('video/') === 0);
    if (vids.length) {
      frameFromVideo(vids[0]).then(uri => {
        if (uri) { setDcImgs(prev => [...prev, uri].slice(0, 4)); dcPush({ role: 'agent', text: '🎞 Grabbed the first frame of your clip — it will seed the generation.' }); }
        else dcPush({ role: 'agent', text: '⚠ Could not read that video file.' });
      });
      e.target.value = '';
      return;
    }
    const files = Array.from(e.target.files || []).filter(f => (f.type || '').indexOf('image/') === 0).slice(0, 4);
    Promise.all(files.map(f => new Promise(res => { const rd = new FileReader(); rd.onload = ev => res(ev.target.result); rd.readAsDataURL(f); })))
      .then(uris => setDcImgs(prev => [...prev, ...uris.filter(Boolean)].slice(0, 4)));
    e.target.value = '';
  };

  const attachRef = (e) => {
    const f = e.target.files && e.target.files[0];
    if (!f) return;
    if (f.size > 18 * 1024 * 1024) { e.target.value = ''; push({ role: 'agent', text: 'That reference is over ~18MB — please trim/compress it and try again.' }); return; }
    const isVideo = (f.type || '').indexOf('video') === 0;
    const rd = new FileReader();
    rd.onload = (ev) => {
      if (isVideo) { assets.current.refVideo = ev.target.result; assets.current.refImage = null; }
      else { assets.current.refImage = ev.target.result; assets.current.refVideo = null; }
      const m = { role: 'user', text: 'Here is a reference ' + (isVideo ? 'video' : 'image') + ' — teach me step by step how to recreate this here.', imgs: isVideo ? [] : [ev.target.result] };
      push(m);
      runAgent([...msgs, m]);
    };
    rd.readAsDataURL(f);
    e.target.value = '';
  };

  const attach = (e) => {
    const files = Array.from(e.target.files || []).filter(f => f && (f.type || '').indexOf('image/') === 0).slice(0, 4);
    if (!files.length) { e.target.value = ''; return; }
    // multi-upload: read them ALL, use every one (no picker) — first is the hero, the rest are extra angles
    Promise.all(files.map(f => new Promise(res => { const r = new FileReader(); r.onload = ev => res(ev.target.result); r.readAsDataURL(f); })))
      .then(uris => {
        const list = uris.filter(Boolean);
        assets.current.product = list[0];
        assets.current.photos = list;
        const m = { role: 'user', text: list.length > 1 ? 'Here are ' + list.length + ' photos of my product (front/back/sides) — use all of them.' : 'Here is my product photo.', imgs: list };
        push(m);
        runAgent([...msgs, m]);
      });
    e.target.value = '';
  };

  const openInEditor = (uri) => {
    if (!onEdit) return;
    const D = window.DITTO_DATA || {};
    const fmt = { id: 'sq', label: 'Square', ratio: '1:1', w: 1080, h: 1080 };
    const tpl = { id: 'blank', brand: '', head: '', sub: '', cta: '', badge: '', price: '', palette: (D.PAL && D.PAL.ink) || {}, format: fmt, layout: 'centered', media: 'image', plats: ['instagram'] };
    onEdit({ blank: true, openImage: uri, tpl, override: { format: fmt }, inp: {} });
  };

  const seq = stage ? (AGENT_STAGES[stage.name] || AGENT_STAGES.plan) : [];
  const stageText = seq.length ? seq[Math.min(tick, seq.length - 1)] : '';
  const secs = stage ? Math.floor((Date.now() - stage.t0) / 1000) : 0;
  const lastAgentIdx = (() => { let k = -1; msgs.forEach((m, i) => { if (m.role === 'agent') k = i; }); return k; })();

  return (
    <div style={{ maxWidth: 900, margin: '0 auto', padding: '20px 24px 28px', display: 'flex', flexDirection: 'column', height: 'calc(100vh - 8px)' }}>
      <div className="row" style={{ alignItems: 'center', gap: 10, marginBottom: 6, flexWrap: 'wrap' }}>
        <div className="row" style={{ gap: 3, background: 'var(--surface-2)', borderRadius: 'var(--r-full)', padding: 3 }}>
          {canAgent && <button className="btn btn-sm" onClick={() => setChatMode('agent')} style={{ background: chatMode === 'agent' ? 'var(--surface)' : 'transparent', fontSize: 12, fontWeight: chatMode === 'agent' ? 700 : 400 }}>✦ Ditto Agent</button>}
          <button className="btn btn-sm" onClick={() => setChatMode('direct')} style={{ background: chatMode === 'direct' ? 'var(--surface)' : 'transparent', fontSize: 12, fontWeight: chatMode === 'direct' ? 700 : 400 }}>🧪 Playground</button>
        </div>
        {chatMode !== 'direct' && <span className="dim" style={{ fontSize: 12 }}>the whole studio, in one chat — built to world-class, not just built</span>}
        <button className="btn btn-ghost btn-sm" style={{ marginLeft: 'auto', fontSize: 11.5 }} disabled={busy}
          onClick={() => send('Teach me how to use this platform — what can it do and where do I start?')}>📚 Learn the platform</button>
      </div>
      {chatMode === 'direct' && (
        <div className="row" style={{ gap: 6, flexWrap: 'wrap', padding: '8px 4px', borderBottom: '1px solid var(--line)' }}>
          {playModels === null && <span className="dim dp-sheen" style={{ fontSize: 12 }}>Loading models…</span>}
          <div style={{ position: 'relative' }}>
            <button className="btn btn-sm" onClick={() => setCatOpen(!catOpen)} title="All image / voice / video models"
              style={{ borderRadius: 'var(--r-full)', fontSize: 11.5, padding: '4px 12px', border: '1px dashed var(--accent)',
                background: rawModel ? 'var(--accent)' : 'transparent', color: rawModel ? '#fff' : 'var(--accent)' }}>
              {rawModel ? (rawModel.mod === 'image' ? '🖼 ' : rawModel.mod === 'audio' ? '🔊 ' : '🎬 ') + rawModel.name.slice(0, 26) : '🔍 All image / voice / video models'}
            </button>
            {catOpen && (
              <div className="card" style={{ position: 'absolute', top: '110%', left: 0, zIndex: 60, width: 'min(420px, 88vw)', padding: 10, boxShadow: 'var(--sh-pop)', maxHeight: 380, overflowY: 'auto' }}>
                <input className="field" autoFocus value={catQ} onChange={e => setCatQ(e.target.value)} placeholder="Search models…" style={{ width: '100%', fontSize: 12, marginBottom: 8 }} />
                {cat === null && <span className="dim dp-sheen" style={{ fontSize: 11.5 }}>Loading…</span>}
                {cat && cat.status !== 'ok' && <span className="dim" style={{ fontSize: 11.5 }}>{cat.error || 'Catalogue unavailable — add OPENROUTER_API_KEY.'}</span>}
                {cat && cat.status === 'ok' && ['image', 'audio', 'video'].map(mod => {
                  const rows = (cat.groups[mod] || []).filter(r => !catQ.trim() || (r.name + ' ' + r.slug).toLowerCase().indexOf(catQ.toLowerCase()) >= 0);
                  if (!rows.length) return null;
                  return (
                    <div key={mod} className="col" style={{ gap: 2, marginBottom: 8 }}>
                      <span className="eyebrow" style={{ fontSize: 9.5 }}>{mod === 'image' ? '🖼 IMAGE' : mod === 'audio' ? '🔊 VOICE / AUDIO' : '🎬 VIDEO'} · {rows.length}</span>
                      {rows.map(r => (
                        <button key={r.slug} className="btn btn-quiet btn-sm" style={{ justifyContent: 'flex-start', fontSize: 11.5, textAlign: 'left' }}
                          onClick={() => { setRawModel({ slug: r.slug, name: r.name, mod }); setPlayModel(''); setCatOpen(false); }}>
                          {r.name}<span className="dim mono" style={{ fontSize: 9.5, marginLeft: 6 }}>{r.slug}</span>
                        </button>
                      ))}
                    </div>
                  );
                })}
              </div>
            )}
          </div>
          {(playModels || []).map(m => (
            <button key={m.id} className="btn btn-sm" disabled={!m.available} title={m.available ? (m.slug || m.label) : (m.reason || 'unavailable')}
              onClick={() => { setPlayModel(m.id); setRawModel(null); }}
              style={{ borderRadius: 'var(--r-full)', fontSize: 11.5, padding: '4px 12px', opacity: m.available ? 1 : 0.45,
                background: (playModel === m.id && !rawModel) ? 'var(--accent)' : 'var(--surface-2)',
                color: playModel === m.id ? '#fff' : 'var(--ink-2)', border: '1px solid var(--line-2)' }}>
              {m.label}{m.caps.indexOf('image') >= 0 ? ' 🖼' : m.caps.indexOf('video') >= 0 ? ' 🎬' : ''}
            </button>
          ))}
        </div>
      )}
      <div ref={scroller} className="col" style={{ flex: 1, overflowY: 'auto', gap: 10, padding: '12px 4px', overscrollBehavior: 'contain' }}>
        {(chatMode === 'direct' ? dcMsgs : msgs).map((m, i) => <AgentBubble key={i} m={m} isLast={chatMode === 'agent' && i === lastAgentIdx} busy={chatMode === 'direct' ? dcBusy : busy} onSend={chatMode === 'direct' ? sendDirect : send} onEditor={openInEditor} onPickImages={confirmImages} />)}
        {(chatMode === 'direct' ? dcBusy : busy) && (
          <div className="row" style={{ gap: 8, alignItems: 'flex-start' }}>
            <AgentAvatar />
            <div className="row" style={{ gap: 10, padding: '9px 14px', borderRadius: 14, borderTopLeftRadius: 4, background: 'var(--surface)', border: '1px solid var(--line-2)', fontSize: 12.5, color: 'var(--ink-2)', alignItems: 'center' }}>
              <span className="dp-ring" data-on="" />
              <span className="dp-sheen">{chatMode === 'direct' ? 'Talking to the model…' : (stageText || 'Working…')}</span>
              {secs > 3 && <span className="mono" style={{ fontSize: 10.5, color: 'var(--ink-3)' }}>{Math.floor(secs / 60) ? Math.floor(secs / 60) + 'm ' : ''}{secs % 60}s</span>}
            </div>
          </div>
        )}
      </div>
      <div className="row" style={{ gap: 8, paddingTop: 8, borderTop: '1px solid var(--line)' }}>
        <label className="btn btn-ghost" title={chatMode === 'direct' ? 'Attach images for the next message' : 'Attach product photos (one or several)'} style={{ padding: '0 12px', cursor: 'pointer', position: 'relative' }}>
          <Icon name="image" size={15} />
          {chatMode === 'direct' && dcImgs.length > 0 && <span style={{ position: 'absolute', top: 2, right: 2, background: 'var(--accent)', color: '#fff', borderRadius: '50%', width: 15, height: 15, fontSize: 9.5, display: 'grid', placeItems: 'center', fontWeight: 700 }}>{dcImgs.length}</span>}
          <input type="file" accept={chatMode === 'direct' ? 'image/*,video/*' : 'image/*'} multiple onChange={chatMode === 'direct' ? attachDirect : attach} style={{ display: 'none' }} />
        </label>
        <label className="btn btn-ghost" title="Attach a reference ad (image or video) — Ditto teaches you how to recreate it" style={{ padding: '0 12px', cursor: 'pointer' }}>
          <Icon name="video" size={15} />
          <input type="file" accept="image/*,video/*" onChange={attachRef} style={{ display: 'none' }} />
        </label>
        <textarea className="field" rows={1} placeholder="Tell Ditto what to create… (paste a product URL to go end-to-end)"
          value={input} onChange={e => setInput(e.target.value)}
          onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); (chatMode === 'direct' ? sendDirect : send)(); } }}
          style={{ flex: 1, resize: 'none', fontSize: 13.5, lineHeight: 1.5 }} />
        <button className="btn btn-accent" disabled={(chatMode === 'direct' ? dcBusy : busy) || !input.trim()} onClick={() => (chatMode === 'direct' ? sendDirect : send)()} style={{ padding: '0 18px' }}>
          {(chatMode === 'direct' ? dcBusy : busy) ? '…' : 'Send'}
        </button>
      </div>
    </div>
  );
}

Object.assign(window, { AgentChatView });
