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

/* ---- Inline markdown renderer ----------------------------------------------
   Handles **bold**, *italic*, `inline code`, and [text](url) without pulling
   in a dependency (the page already loads React + Babel from unpkg; adding
   marked.js would double our CDN bloom). Block-level features (headings,
   lists, fenced code) are intentionally NOT handled here — assistant replies
   from the gather-requirements turn are short prose, and the rare bullet
   lists already arrive via msg.bullets / msg.codeBlock structured fields.
*/
const INLINE_MD_RE = /(`[^`\n]+`)|(\[[^\]]+\]\([^)\s]+\))|(\*\*[^*\n]+\*\*)|(\*[^*\n]+\*)/g;

function renderInlineMd(text) {
  if (!text) return text;
  const parts = [];
  let last = 0;
  let m;
  INLINE_MD_RE.lastIndex = 0;
  while ((m = INLINE_MD_RE.exec(text)) !== null) {
    if (m.index > last) parts.push(text.slice(last, m.index));
    const seg = m[0];
    const key = `md${m.index}`;
    if (seg.startsWith('`')) {
      parts.push(<code key={key} style={{fontFamily:'var(--font-mono)', fontSize:'0.92em', background:'var(--bg-2)', padding:'1px 4px', borderRadius:3}}>{seg.slice(1, -1)}</code>);
    } else if (seg.startsWith('[')) {
      const lm = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(seg);
      if (lm) {
        parts.push(<a key={key} href={lm[2]} target="_blank" rel="noopener noreferrer" style={{color:'var(--accent)', textDecoration:'underline'}}>{lm[1]}</a>);
      } else {
        parts.push(seg);
      }
    } else if (seg.startsWith('**')) {
      parts.push(<strong key={key}>{seg.slice(2, -2)}</strong>);
    } else if (seg.startsWith('*')) {
      parts.push(<em key={key}>{seg.slice(1, -1)}</em>);
    }
    last = m.index + seg.length;
  }
  if (last < text.length) parts.push(text.slice(last));
  return parts.length > 0 ? parts : text;
}

function MarkdownText({ children }) {
  const text = String(children ?? '');
  if (!text) return null;
  const lines = text.split('\n');
  return (
    <>
      {lines.map((line, i) => (
        <React.Fragment key={i}>
          {renderInlineMd(line)}
          {i < lines.length - 1 && <br/>}
        </React.Fragment>
      ))}
    </>
  );
}

/* ---- ThreadRail (Phase 2) -------------------------------------------------
   Slim left rail showing recent conversations as a quick-switcher. Does NOT
   duplicate Dashboard's filtered list or History's full pagination — caps
   at 10 rows and links out to History for the long tail. Collapses to a
   44px icon bar on narrow viewports, mirroring the right results-rail's
   own collapse pattern.
*/
function relTime(iso) {
  if (!iso) return '';
  const ms = Date.now() - new Date(iso).getTime();
  if (Number.isNaN(ms)) return '';
  if (ms < 60_000) return 'just now';
  if (ms < 3600_000) return `${Math.floor(ms / 60_000)}m`;
  if (ms < 86_400_000) return `${Math.floor(ms / 3600_000)}h`;
  if (ms < 7 * 86_400_000) return `${Math.floor(ms / 86_400_000)}d`;
  return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}

function ThreadRail({ activeConvoId, collapsed, onToggleCollapsed, onPickConvo, onNewChat, onViewHistory }) {
  const [rows, setRows] = useState([]);
  const [loading, setLoading] = useState(false);
  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    // listConversations is cached (2-min TTL) so this is cheap on tab switches.
    window.API.listConversations(10).then(res => {
      if (cancelled) return;
      const items = Array.isArray(res?.items) ? res.items : [];
      // Sort by updatedAt desc — matches Dashboard's recency grouping.
      items.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''));
      setRows(items.slice(0, 10));
    }).catch(() => {
      if (!cancelled) setRows([]);
    }).finally(() => {
      if (!cancelled) setLoading(false);
    });
    return () => { cancelled = true; };
  }, [activeConvoId]); // re-fetch when user lands on a new convo

  if (collapsed) {
    return (
      <aside style={{borderRight:'1px solid var(--line)', background:'var(--bg-1)', display:'flex', flexDirection:'column', alignItems:'center', gap:8, padding:'10px 0'}}>
        <button onClick={onToggleCollapsed} title="Expand threads"
                style={{width:28, height:28, display:'inline-flex', alignItems:'center', justifyContent:'center', background:'transparent', border:'1px solid var(--line)', borderRadius:4, cursor:'pointer'}}>
          <Icon name="list" size={12}/>
        </button>
        <button onClick={onNewChat} title="New chat"
                style={{width:28, height:28, display:'inline-flex', alignItems:'center', justifyContent:'center', background:'var(--bg-3)', border:'1px solid var(--line)', borderRadius:4, cursor:'pointer'}}>
          <Icon name="plus" size={12}/>
        </button>
      </aside>
    );
  }

  return (
    <aside style={{borderRight:'1px solid var(--line)', background:'var(--bg-1)', display:'flex', flexDirection:'column', minHeight:0}}>
      <div style={{padding:'10px 12px', borderBottom:'1px solid var(--line)', background:'var(--bg-3)', display:'flex', alignItems:'center', gap:8}}>
        <div className="label" style={{flex:1, minWidth:0}}>Threads</div>
        <button onClick={onToggleCollapsed} title="Collapse"
                style={{width:20, height:20, display:'inline-flex', alignItems:'center', justifyContent:'center', background:'transparent', color:'var(--ink-3)', border:'1px solid var(--line)', borderRadius:3, cursor:'pointer', padding:0}}>
          <Icon name="chev" size={10}/>
        </button>
      </div>
      <button onClick={onNewChat}
              style={{margin:10, padding:'7px 10px', display:'inline-flex', alignItems:'center', gap:6, background:'var(--bg-3)', color:'var(--ink-1)', border:'1px solid var(--line)', borderRadius:4, cursor:'pointer', fontSize:12, fontWeight:500}}>
        <Icon name="plus" size={11}/>New chat
      </button>
      <div style={{flex:1, overflow:'auto', padding:'0 6px'}}>
        {loading && rows.length === 0 && (
          <div style={{padding:'8px 10px', fontSize:11, color:'var(--ink-3)'}}>Loading…</div>
        )}
        {!loading && rows.length === 0 && (
          <div style={{padding:'8px 10px', fontSize:11, color:'var(--ink-3)'}}>No threads yet.</div>
        )}
        {rows.map(r => {
          const isActive = r.id === activeConvoId;
          return (
            <button key={r.id}
                    onClick={() => onPickConvo(r.id)}
                    title={r.title || r.id}
                    style={{
                      display:'block', width:'100%', textAlign:'left',
                      padding:'6px 8px', marginBottom:2,
                      background: isActive ? 'var(--bg-3)' : 'transparent',
                      color:'var(--ink-1)',
                      border: isActive ? '1px solid var(--line)' : '1px solid transparent',
                      borderRadius:4, cursor:'pointer',
                    }}>
              <div style={{fontSize:12, fontWeight: isActive ? 500 : 400, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', minWidth:0}}>
                {r.title || `convo · ${(r.id || '').slice(0,8)}`}
              </div>
              <div style={{fontSize:10.5, color:'var(--ink-3)', fontFamily:'var(--font-mono)', letterSpacing:'0.04em', marginTop:1}}>
                {relTime(r.updatedAt || r.createdAt)}
              </div>
            </button>
          );
        })}
      </div>
      <button onClick={onViewHistory}
              style={{margin:'6px 10px 10px', padding:'6px 8px', display:'inline-flex', alignItems:'center', justifyContent:'space-between', background:'transparent', color:'var(--ink-3)', border:'1px solid var(--line)', borderRadius:4, cursor:'pointer', fontSize:11, fontFamily:'var(--font-mono)', letterSpacing:'0.04em'}}>
        VIEW ALL IN HISTORY
        <Icon name="chev" size={10}/>
      </button>
    </aside>
  );
}

/* =========================================================================
   NEW CONVERSATION — wider chat · reflowable · pinned architecture card
                  inline pipeline that collapses · suggest chips · tab badges
   ========================================================================= */
function shortId(id) {
  if (!id) return '';
  const s = String(id);
  return s.length <= 12 ? s : `${s.slice(0, 4)}…${s.slice(-4)}`;
}

function archSummary(architecture) {
  const nodes = architecture?.services?.length ?? 0;
  const edges = architecture?.connections?.length ?? 0;
  const region = architecture?.services?.find(s => s.region)?.region || 'eu-west-1';
  return `${nodes} node${nodes === 1 ? '' : 's'} · ${edges} edge${edges === 1 ? '' : 's'} · ${region}`;
}

// Backend's RateLimitError surfaces as a 429 with "Monthly limit ... exceeded".
// Catch both on shape and on message so we don't leak the raw error to chat.
function isQuotaError(err) {
  if (!err) return false;
  if (err.status === 429 || err.statusCode === 429) return true;
  return /monthly limit|rate limit|quota/i.test(err.message || String(err));
}

function NewAnalysis() {
  const { tier, pipelineState, setPipelineState, setRoute, refreshUsage, routeParams } = useApp();
  const { PIPELINE_STEPS, SAMPLE_TF } = window.APP_DATA;
  const reopenConvoId = routeParams?.conversationId || null;

  const [inputMode, setInputMode] = useState('text');
  const [textVal, setTextVal] = useState('');
  const [iacVal, setIacVal] = useState(SAMPLE_TF);
  const [messages, setMessages] = useState([]);
  const [showQuotaBlock, setShowQuotaBlock] = useState(false);
  const [railOpen, setRailOpen] = useState(true);
  const [railTab, setRailTab] = useState('score');
  const [archVersion, setArchVersion] = useState('v1');
  const [versions, setVersions] = useState([
    { id: 'v1', label: 'Initial', at: 'just now', current: true },
  ]);
  // Live results from the most recent run.
  const [analysisId, setAnalysisId] = useState(null);
  const [architecture, setArchitecture] = useState(null);
  const [review, setReview] = useState(null);
  const [costEstimate, setCostEstimate] = useState(null);
  const [diagram, setDiagram] = useState(null);
  const [editMode, setEditMode] = useState(false);
  const [conversationId, setConversationId] = useState(null);
  const [chatBusy, setChatBusy] = useState(false);
  const [archTitle, setArchTitle] = useState(null);
  const [idsOpen, setIdsOpen] = useState(false);
  // Latest assistant.complete frame received over WebSocket. Carries the
  // structured `decision` object the assistant produced — used by the
  // proposal-gate card to render the run-confirmation UI. When VITE_WS_URL
  // is empty this stays null and the REST response remains the only path.
  const [lastAssistantComplete, setLastAssistantComplete] = useState(null);
  // In-flight streaming reply (Phase 1d). assistant.token frames append to
  // this; assistant.complete clears it because the REST response will
  // append the final aggregated message at that point. Carrying `turnId`
  // lets reconnects ignore frames from a stale turn.
  const [streamingTurn, setStreamingTurn] = useState(null); // { turnId, text }
  // AbortController for the in-flight chat POST. Stop-button click triggers
  // .abort() — backend Lambda continues but the frontend drops the response.
  const chatAbortRef = useRef(null);
  // Pipeline event bus — receives step.* / run.* frames from useChatSocket
  // and exposes a Promise-returning subscribe helper that followPipeline
  // races against the 2s polling. WS fires within ~50ms of each Lambda's
  // emitCompleted; polling tick lag is up to 2s. Whichever wins resolves
  // the step, the loser becomes a no-op.
  const pipelineEventBusRef = useRef({
    // events: Map<`${runId}#${eventType}`, {detail}>
    events: new Map(),
    // listeners: Map<`${runId}#${eventType}`, Array<resolve>>
    listeners: new Map(),
  });
  function publishPipelineEvent(detail) {
    const bus = pipelineEventBusRef.current;
    if (!detail?.runId) return;
    const stepName = detail.step; // 'parse' | 'diagram' | 'analysis' | undefined
    const key = stepName
      ? `${detail.runId}#${detail.eventType}#${stepName}`
      : `${detail.runId}#${detail.eventType}`;
    bus.events.set(key, detail);
    const queue = bus.listeners.get(key);
    if (queue && queue.length > 0) {
      queue.forEach(resolve => resolve(detail));
      bus.listeners.set(key, []);
    }
  }
  function awaitPipelineEvent(runId, eventType, stepName, timeoutMs) {
    const bus = pipelineEventBusRef.current;
    const key = stepName ? `${runId}#${eventType}#${stepName}` : `${runId}#${eventType}`;
    const existing = bus.events.get(key);
    if (existing) return Promise.resolve(existing);
    return new Promise((resolve) => {
      const queue = bus.listeners.get(key) || [];
      queue.push(resolve);
      bus.listeners.set(key, queue);
      if (timeoutMs) {
        setTimeout(() => resolve(null), timeoutMs);
      }
    });
  }

  window.CAC_WS.useChatSocket(conversationId, (ev) => {
    if (ev?.type === 'assistant.token') {
      setStreamingTurn(prev => {
        if (prev && prev.turnId !== ev.turnId) return prev; // ignore stale
        return { turnId: ev.turnId, text: ev.replySoFar || ((prev?.text || '') + (ev.delta || '')) };
      });
    } else if (ev?.type === 'assistant.complete') {
      setLastAssistantComplete(ev);
      setStreamingTurn(null);
    } else if (ev?.eventType) {
      // Pipeline events from the WS broadcaster: step.started / step.completed
      // / step.failed / run.completed / run.failed. The broadcaster forwards
      // event.detail (not the EventBridge wrapper) so the discriminant is
      // `eventType`, not `type`.
      publishPipelineEvent(ev);
    }
  });
  const chatRef = useRef(null);
  const idsRef = useRef(null);
  // Tracks which version's snapshot is currently loaded into the artifact panel.
  // Used to suppress the version-switch fetch right after a fresh run produces
  // a new version (data is already live from waitForDiagram/getReview).
  // Must match initial archVersion — otherwise the version-switch effect fetches v1 the moment a fresh conversation gets an id, before the pipeline has produced it.
  const loadedVersionRef = useRef('v1');
  const [versionSwitching, setVersionSwitching] = useState(false);

  useEffect(() => {
    if (!idsOpen) return;
    const onDocClick = (e) => {
      if (idsRef.current && !idsRef.current.contains(e.target)) setIdsOpen(false);
    };
    document.addEventListener('mousedown', onDocClick);
    return () => document.removeEventListener('mousedown', onDocClick);
  }, [idsOpen]);

  // Seed greeting (skip when reopening a previous conversation — the load effect
  // below will hydrate messages from the stored conversation instead).
  useEffect(() => {
    if (messages.length === 0 && !reopenConvoId) {
      setMessages([
        { role: 'assistant', kind: 'greet', content: "Describe an AWS architecture, paste Terraform, or ask a question. I'll diagram it, review it against Well-Architected, and quote it — then we can iterate." },
      ]);
    }
  }, []);

  // Reopen a previous conversation in the chat view (Re-run from detail page).
  // Hydrates messages + the latest version's architecture/diagram/review/cost
  // so the user lands inside the existing conversation and can iterate or re-run.
  useEffect(() => {
    if (!reopenConvoId) return;
    let cancelled = false;
    (async () => {
      try {
        const convo = await window.API.getConversation(reopenConvoId);
        if (cancelled) return;
        setConversationId(reopenConvoId);
        setArchTitle(convo?.header?.title || null);

        const loaded = (convo?.messages || []).map(m => ({
          role: m.role,
          kind: 'text',
          content: m.content,
          ...(Array.isArray(m.suggestedChips) && m.suggestedChips.length > 0 ? { suggests: m.suggestedChips } : {}),
        }));

        const v = convo?.header?.currentVersion;
        if (v && v >= 1) {
          const snap = await window.API.getConversationVersion(reopenConvoId, v);
          if (cancelled) return;
          setArchitecture(snap.version?.architecture || null);
          setAnalysisId(snap.version?.runId || null);
          if (snap.wafReview) {
            setReview({ scorecard: snap.wafReview.scorecard, recommendations: snap.wafReview.recommendations });
          }
          if (snap.costEstimate) setCostEstimate(snap.costEstimate);
          if (snap.diagram) setDiagram(snap.diagram);

          const versionRows = (convo.versions || []).map((vs, i) => ({
            id: `v${vs.v}`,
            label: i === 0 ? 'Initial' : 'Revision',
            at: vs.createdAt ? new Date(vs.createdAt).toLocaleString() : 'earlier',
            current: vs.v === v,
          }));
          if (versionRows.length) {
            setVersions(versionRows);
            setArchVersion(`v${v}`);
            loadedVersionRef.current = `v${v}`;
          }

          loaded.push({
            role: 'assistant', kind: 'pipeline', state: 'done', totalMs: 0,
            stepStates: PIPELINE_STEPS.map(() => ({ status: 'complete', durationMs: 0 })),
          });
          setPipelineState('done');
        }

        if (loaded.length > 0) setMessages(loaded);
      } catch (err) {
        if (!cancelled) {
          setMessages(m => m.length === 0
            ? [{ role: 'assistant', kind: 'text', content: `Could not reopen conversation: ${err?.message || err}` }]
            : m);
        }
      }
    })();
    return () => { cancelled = true; };
  }, [reopenConvoId]);

  // Scroll-stick: auto-follow only when the user is already within ~100px of
  // the bottom. If they've scrolled up to read history, new messages don't
  // yank them back — instead the floating pill below appears and they jump
  // on click. Standard pattern across Claude/ChatGPT/Gemini chats.
  const stickedRef = useRef(true);
  const [showNewPill, setShowNewPill] = useState(false);
  useEffect(() => {
    const el = chatRef.current;
    if (!el) return undefined;
    const onScroll = () => {
      const atBottom = el.scrollHeight - (el.scrollTop + el.clientHeight) < 100;
      stickedRef.current = atBottom;
      if (atBottom) setShowNewPill(false);
    };
    el.addEventListener('scroll', onScroll, { passive: true });
    return () => el.removeEventListener('scroll', onScroll);
  }, []);
  useEffect(() => {
    const el = chatRef.current;
    if (!el) return;
    if (stickedRef.current) {
      el.scrollTop = el.scrollHeight;
    } else {
      setShowNewPill(true);
    }
  }, [messages]);
  const jumpToBottom = () => {
    const el = chatRef.current;
    if (!el) return;
    el.scrollTop = el.scrollHeight;
    stickedRef.current = true;
    setShowNewPill(false);
  };

  // Switch the artifact panel (diagram, scorecard, cost) when the user clicks
  // an older version dot. Skips when the selected version is already loaded —
  // either because we just produced it live, or we just fetched it.
  useEffect(() => {
    if (!conversationId || !archVersion) return;
    if (loadedVersionRef.current === archVersion) return;
    const versionNum = parseInt(archVersion.slice(1), 10);
    if (!Number.isFinite(versionNum) || versionNum < 1) return;
    let cancelled = false;
    setVersionSwitching(true);
    (async () => {
      try {
        const snap = await window.API.getConversationVersion(conversationId, versionNum);
        if (cancelled) return;
        setArchitecture(snap?.version?.architecture || null);
        setAnalysisId(snap?.version?.runId || null);
        setReview(snap?.wafReview
          ? { scorecard: snap.wafReview.scorecard, recommendations: snap.wafReview.recommendations }
          : null);
        setCostEstimate(snap?.costEstimate || null);
        setDiagram(snap?.diagram || null);
        loadedVersionRef.current = archVersion;
      } catch (err) {
        if (!cancelled) {
          setMessages(m => [...m, { role: 'assistant', kind: 'text', content: `Could not load ${archVersion}: ${err?.message || err}` }]);
        }
      } finally {
        if (!cancelled) setVersionSwitching(false);
      }
    })();
    return () => { cancelled = true; };
  }, [archVersion, conversationId]);

  // Mirror tweak state into latest pipeline turn (e.g. user clicks Retry).
  // Only the state field is mirrored — per-step state lives on the message.
  useEffect(() => {
    setMessages(ms => {
      let found = false;
      const next = [...ms].reverse().map(m => {
        if (!found && m.kind === 'pipeline') { found = true; return { ...m, state: pipelineState }; }
        return m;
      }).reverse();
      return next;
    });
  }, [pipelineState]);

  function archHasRun() { return messages.some(m => m.kind === 'pipeline'); }

  // Update the most-recent pipeline message immutably.
  function updateLatestPipeline(updater) {
    setMessages(ms => {
      const out = [...ms];
      for (let i = out.length - 1; i >= 0; i--) {
        if (out[i].kind === 'pipeline') { out[i] = updater(out[i]); break; }
      }
      return out;
    });
  }
  function patchStep(idx, patch) {
    updateLatestPipeline(msg => ({
      ...msg,
      stepStates: (msg.stepStates || PIPELINE_STEPS.map(() => ({ status: 'pending' })))
        .map((s, i) => i === idx ? { ...s, ...patch } : s),
    }));
  }

  async function followPipeline(runId, isRevision) {
    setPipelineState('running');
    setDiagram(null);
    setAnalysisId(runId);
    const pipelineStartedAt = Date.now();
    const wsLive = window.CAC_WS.enabled();

    // WS-fast-path: when the WS broadcaster delivers `step.started`, patch
    // the UI immediately (no 2s polling lag). The artifact fetch still goes
    // through the REST API — WS only carries event metadata.
    awaitPipelineEvent(runId, 'step.started', 'parse').then(() => {
      patchStep(0, { status: 'inprogress', startedAt: Date.now() });
    });
    awaitPipelineEvent(runId, 'step.started', 'diagram').then(() => {
      patchStep(1, { status: 'inprogress', startedAt: Date.now() });
    });
    awaitPipelineEvent(runId, 'step.started', 'analysis').then(() => {
      const now = Date.now();
      // Cost is bundled with analysis on the backend (no separate emit),
      // so both UI rows light up on the same event.
      patchStep(2, { status: 'inprogress', startedAt: now });
      patchStep(3, { status: 'inprogress', startedAt: now });
    });

    const parseStart = Date.now();
    patchStep(0, { status: 'inprogress', startedAt: parseStart });

    // Race WS step.completed vs polling. On WS-fired completion, do a single
    // getAnalysis() to fetch the parsed architecture. The polling path
    // handles the same call internally; whichever wins, we get the data.
    const parsePoll = window.API.waitForParse(runId);
    const parseWs = wsLive
      ? awaitPipelineEvent(runId, 'step.completed', 'parse').then(async (ev) => {
          if (!ev) return null;
          return window.API.getAnalysis(runId);
        })
      : Promise.resolve(null);

    let parsed;
    try {
      parsed = await Promise.race([
        parsePoll,
        // WS race only takes precedence if it resolves with truthy data.
        parseWs.then(d => d || new Promise(() => {})),
      ]);
    } catch (err) {
      const msg = err?.message || String(err);
      patchStep(0, { status: 'failed', durationMs: Date.now() - parseStart, error: msg });
      updateLatestPipeline(m => ({ ...m, totalMs: Date.now() - pipelineStartedAt }));
      setPipelineState('failed');
      if (isQuotaError(err)) {
        setShowQuotaBlock(true);
        setMessages(m => [...m, { role: 'assistant', kind: 'text', content: "You've hit the Free-tier limit for this month. The Pro plan is launching soon — leave your email to be notified the moment it opens up." }]);
      } else {
        setMessages(m => [...m, { role: 'assistant', kind: 'text', content: `Pipeline failed: ${msg}` }]);
      }
      return;
    }
    patchStep(0, { status: 'complete', durationMs: Date.now() - parseStart });
    setArchitecture(parsed.architecture || null);

    const diagStart = Date.now();
    const reviewStart = Date.now();
    patchStep(1, { status: 'inprogress', startedAt: diagStart });
    patchStep(2, { status: 'inprogress', startedAt: reviewStart });
    patchStep(3, { status: 'inprogress', startedAt: reviewStart });

    // Diagram: race WS-event-triggered fetch vs polling.
    const diagWs = wsLive
      ? awaitPipelineEvent(runId, 'step.completed', 'diagram').then(async (ev) => {
          if (!ev) return null;
          // The diagram artifact lives behind a different endpoint than
          // getAnalysis. Reuse waitForDiagram's first iteration which fetches
          // and seeds the cache — but with a tiny interval so we don't wait.
          try {
            return await window.API.getDiagram(runId);
          } catch {
            return null;
          }
        })
      : Promise.resolve(null);

    const diagPromise = Promise.race([
      window.API.waitForDiagram(runId),
      diagWs.then(d => d || new Promise(() => {})),
    ]).then(
      d => { patchStep(1, { status: 'complete', durationMs: Date.now() - diagStart }); setDiagram(d); return d; },
      err => { patchStep(1, { status: 'failed', durationMs: Date.now() - diagStart, error: err?.message || String(err) }); return null; },
    );

    // Review/cost: race WS analysis completion vs the existing getReview
    // call. getReview is a one-shot blocking call (not a poll loop), so
    // racing buys nothing latency-wise; we keep this WS branch so the UI
    // step rows flip to 'complete' the instant the analysis Lambda emits,
    // even if getReview's response is still in flight.
    if (wsLive) {
      awaitPipelineEvent(runId, 'step.completed', 'analysis').then((ev) => {
        if (!ev) return;
        const ms = Date.now() - reviewStart;
        patchStep(2, { status: 'complete', durationMs: ms });
        patchStep(3, { status: 'complete', durationMs: ms });
      });
    }

    const reviewPromise = window.API.getReview(runId).then(
      rev => {
        const ms = Date.now() - reviewStart;
        patchStep(2, { status: 'complete', durationMs: ms });
        patchStep(3, { status: 'complete', durationMs: ms });
        setReview(rev.scorecard ? rev : { scorecard: rev });
        setCostEstimate(rev.costEstimate || null);
        return rev;
      },
      err => {
        const ms = Date.now() - reviewStart;
        const msg = err?.message || String(err);
        patchStep(2, { status: 'failed', durationMs: ms, error: msg });
        patchStep(3, { status: 'failed', durationMs: ms, error: msg });
        return null;
      },
    );

    const [, rev] = await Promise.all([diagPromise, reviewPromise]);

    updateLatestPipeline(m => ({ ...m, totalMs: Date.now() - pipelineStartedAt }));
    setPipelineState('done');
    const newV = `v${versions.length + 1}`;
    setVersions(vs => vs.map(v => ({ ...v, current: false })).concat({ id: newV, label: isRevision ? 'Revision' : 'Initial', at: 'just now', current: true }));
    // Bypass the version-switch effect for the freshly produced version — its
    // diagram/review/cost are already in state from the live pipeline calls.
    loadedVersionRef.current = newV;
    setArchVersion(newV);

    const findings = window.scorecardToFindings(rev?.scorecard);
    const high = findings.filter(f => f.sev === 'HIGH');
    setMessages(m => [...m, {
      role: 'assistant', kind: 'summary',
      content: high.length
        ? `Here's the architecture — anything you'd like to change? ${high.length} HIGH finding${high.length === 1 ? '' : 's'} on the scorecard if you want to dig in.`
        : "Here's the architecture — anything you'd like to change?",
      suggests: ['Show me the cost breakdown', high.length ? 'List the HIGH findings' : 'Walk me through the design', 'Make it multi-AZ'].filter(Boolean),
    }]);
  }

  async function sendChat(raw) {
    if (!raw.trim() || chatBusy) return;
    setMessages(m => [...m, { role: 'user', kind: 'text', content: raw }]);
    setTextVal('');
    setChatBusy(true);

    try {
      let cid = conversationId;
      if (!cid) {
        const c = await window.API.createConversation();
        cid = c?.conversationId || c?.id || c?.convoId;
        if (!cid) throw new Error('Could not create conversation');
        setConversationId(cid);
      }
      // Fresh AbortController per send so a Stop click cleanly cancels
      // this turn's fetch without leaking signal references between turns.
      chatAbortRef.current = new AbortController();
      const resp = await window.API.sendChatMessage(cid, raw, { signal: chatAbortRef.current.signal });
      const reply = resp?.reply || resp?.assistantMessage?.content || '';
      const chips = resp?.assistantMessage?.suggestedChips;

      const pendingRunId = resp?.pendingRunId;
      if (pendingRunId) {
        const isRevision = versions.length > 1 || archHasRun();
        if (resp.title) setArchTitle(resp.title);
        // Phase 1c real proposal-gate: backend wrote a *pending* run but did
        // NOT start the SFN. Render the proposal card with an active "Run
        // analysis" button — the user explicitly approves before the
        // pipeline fires (and before usage is incremented).
        // Prefer the REST response decision (always present); fall back to
        // the WS frame for older backends. Avoids a race where the proposal
        // card renders with just a title when WS hasn't arrived yet.
        const wsDecision =
          lastAssistantComplete?.pendingRunId === pendingRunId
            ? lastAssistantComplete.decision
            : null;
        const decision = resp?.decision || wsDecision || {};
        setMessages(m => [...m,
          { role: 'assistant', kind: 'text', content: reply },
          { role: 'assistant', kind: 'proposal', title: resp.title || null,
            narrative: decision.narrative || null,
            inputType: decision.inputType || null,
            intent: decision.intent || null,
            pendingRunId, isRevision, status: 'pending' },
        ]);
      } else {
        setMessages(m => [...m, {
          role: 'assistant', kind: 'text', content: reply,
          ...(chips && chips.length && { suggests: chips }),
        }]);
      }
    } catch (err) {
      // User-initiated Stop — drop silently; the streaming bubble was
      // already cleared by stopChat().
      if (err?.name === 'AbortError') {
        // nothing — finally clears chatBusy.
      } else if (isQuotaError(err)) {
        setShowQuotaBlock(true);
        setMessages(m => [...m, { role: 'assistant', kind: 'text', content: "You've hit the Free-tier limit for this month. The Pro plan is launching soon — leave your email to be notified the moment it opens up." }]);
      } else {
        setMessages(m => [...m, { role: 'assistant', kind: 'text', content: `Chat failed: ${err.message || err}` }]);
      }
    } finally {
      chatAbortRef.current = null;
      setChatBusy(false);
    }
  }

  // Stop click: abort the REST POST + drop the streaming bubble. The
  // server-side Lambda finishes (Bedrock calls can't be cancelled), but
  // the user gets immediate UI responsiveness.
  function stopChat() {
    if (chatAbortRef.current) {
      try { chatAbortRef.current.abort(); } catch { /* noop */ }
    }
    setStreamingTurn(null);
  }

  function submitFromInput() {
    if (tier === 'free' && showQuotaBlock) return;
    sendChat(inputMode === 'text' ? textVal : iacVal);
  }

  // Phase 1c real gate: user clicked "Run analysis" on the proposal card.
  // Approve the pending run (server-side: usage check + SFN start) and
  // begin the pipeline-progress UI. Updates the matching proposal-card
  // message to status 'approved' so the button hides + becomes a quiet
  // breadcrumb. Idempotent — a double-click returns alreadyApproved.
  async function approveProposal(proposalMsg) {
    if (!conversationId || !proposalMsg?.pendingRunId) return;
    setMessages(m => m.map(x =>
      x === proposalMsg ? { ...x, status: 'approving' } : x
    ));
    try {
      await window.API.approveRun(conversationId, proposalMsg.pendingRunId);
      setMessages(m => {
        const next = m.map(x =>
          x === proposalMsg ? { ...x, status: 'approved' } : x
        );
        next.push({
          role: 'assistant', kind: 'pipeline', state: 'running',
          startedAt: Date.now(),
          stepStates: window.APP_DATA.PIPELINE_STEPS.map(() => ({ status: 'pending' })),
        });
        return next;
      });
      refreshUsage?.();
      await followPipeline(proposalMsg.pendingRunId, proposalMsg.isRevision);
    } catch (err) {
      setMessages(m => m.map(x =>
        x === proposalMsg ? { ...x, status: 'pending' } : x
      ));
      if (isQuotaError(err)) {
        setShowQuotaBlock(true);
        setMessages(m => [...m, { role: 'assistant', kind: 'text', content: "You've hit the Free-tier limit for this month. The Pro plan is launching soon — leave your email to be notified the moment it opens up." }]);
      } else {
        setMessages(m => [...m, { role: 'assistant', kind: 'text', content: `Could not start the pipeline: ${err.message || err}` }]);
      }
    }
  }

  const anyPipelineRun = messages.some(m => m.kind === 'pipeline');
  const diagramVisible = anyPipelineRun || pipelineState === 'done' || pipelineState === 'running' || pipelineState === 'failed';
  const showResults = pipelineState === 'done';

  // Reflowable widths: chat flexes between 360 and 520, rail is fixed.
  // Using minmax on the chat column keeps the center diagram from collapsing
  // on narrow viewports (~920px) while still letting chat breathe when wide.
  // Phase 2 adds a 220px thread rail on the left (220 wide, 44 collapsed),
  // making the page a 4-col grid. Chat shrinks its min from 360→300 to
  // absorb the rail's footprint without squeezing the diagram canvas.
  const [threadRailCollapsed, setThreadRailCollapsed] = useState(false);
  const threadRailWidth = threadRailCollapsed ? '44px' : '220px';
  const chatWidth = 'minmax(300px, 480px)';
  const railWidth = showResults ? (railOpen ? 'minmax(300px, 380px)' : '44px') : '0px';

  return (
    <div className="page full" style={{display:'grid', gridTemplateColumns: `${threadRailWidth} ${chatWidth} minmax(0, 1fr) ${railWidth}`, height:'calc(100vh - 52px)', transition:'grid-template-columns .22s cubic-bezier(.4,0,.2,1)'}}>
      {/* LEFT-LEFT — thread rail (Phase 2). Quick-switch between recent
          conversations without leaving the chat. Defers the long tail to
          History; defers the rich filtered view to Dashboard. */}
      <ThreadRail
        activeConvoId={conversationId}
        collapsed={threadRailCollapsed}
        onToggleCollapsed={() => setThreadRailCollapsed(c => !c)}
        onPickConvo={(id) => {
          if (id === conversationId) return;
          setRoute('new', { conversationId: id });
        }}
        onNewChat={() => setRoute('new')}
        onViewHistory={() => setRoute('history')}
      />
      {/* Diagram editor full-screen overlay */}
      {editMode && diagram?.drawioUrl && (
        <window.DiagramEditor
          analysisId={analysisId}
          drawioUrl={diagram.drawioUrl}
          architecture={architecture}
          onCancel={() => setEditMode(false)}
          onConfirm={(updatedArchitecture) => {
            setEditMode(false);
            setArchitecture(updatedArchitecture);
            setDiagram(null);
            setReview(null);
            setCostEstimate(null);
            window.API.waitForDiagram(analysisId).then(d => setDiagram(d)).catch(() => {});
          }}
        />
      )}
      {/* LEFT — chat */}
      <aside style={{borderRight:'1px solid var(--line)', background:'var(--bg-1)', display:'flex', flexDirection:'column', minHeight:0}}>
        <div style={{padding:'10px 14px', borderBottom:'1px solid var(--line)', display:'flex', alignItems:'center', gap:10, background:'var(--bg-3)'}}>
          <div className="label">Conversation</div>
          {(conversationId || analysisId) && (
            <div ref={idsRef} style={{position:'relative', display:'inline-flex'}}>
              <button
                type="button"
                onClick={() => setIdsOpen(o => !o)}
                title="View IDs"
                aria-label="View IDs"
                aria-expanded={idsOpen}
                style={{display:'inline-flex', alignItems:'center', justifyContent:'center', width:20, height:20, borderRadius:999, border:'1px solid var(--line)', background:'transparent', color:'var(--ink-2)', cursor:'pointer', padding:0}}
              >
                <Icon name="info" size={12}/>
              </button>
              {idsOpen && (
                <div role="dialog" style={{position:'absolute', top:'calc(100% + 6px)', left:0, zIndex:50, background:'var(--bg-1)', border:'1px solid var(--line)', borderRadius:8, padding:'10px 12px', minWidth:280, boxShadow:'0 6px 18px rgba(0,0,0,0.12)'}}>
                  <div style={{fontSize:10.5, letterSpacing:'.04em', textTransform:'uppercase', color:'var(--ink-3)', marginBottom:4}}>Conversation ID</div>
                  <div style={{fontSize:12, fontFamily:'var(--font-mono)', wordBreak:'break-all', color:'var(--ink-1)', marginBottom:10}}>{conversationId || '—'}</div>
                  <div style={{fontSize:10.5, letterSpacing:'.04em', textTransform:'uppercase', color:'var(--ink-3)', marginBottom:4}}>Architecture ID</div>
                  <div style={{fontSize:12, fontFamily:'var(--font-mono)', wordBreak:'break-all', color:'var(--ink-1)'}}>{analysisId || '—'}</div>
                </div>
              )}
            </div>
          )}
        </div>

        {/* Pinned architecture card (appears once a run has completed) */}
        {anyPipelineRun && (
          <div style={{padding:'10px 14px 0'}}>
            <div className="arch-card">
              <div style={{display:'flex', alignItems:'center', gap:8, marginBottom:6}}>
                <Icon name="grid" size={12} className="diag-edge"/>
                <div style={{fontSize:12.5, fontWeight:500, flex:1, minWidth:0, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap'}} title={archTitle || ''}>{archTitle || 'Architecture'}</div>
                <span className="pill ghost">{archVersion}</span>
              </div>
              <div style={{fontSize:11, color:'var(--ink-3)', fontFamily:'var(--font-mono)', marginBottom:8}}>{archSummary(architecture)}</div>
              <div style={{display:'flex', alignItems:'center', gap:6, flexWrap:'wrap', opacity: versionSwitching ? 0.55 : 1, transition:'opacity .15s'}}>
                {versions.map((v, i) => {
                  const isLatest = i === versions.length - 1;
                  const selected = archVersion === v.id;
                  return (
                    <button key={v.id} onClick={() => setArchVersion(v.id)} disabled={versionSwitching}
                      title={`${v.label} · ${v.at}${isLatest ? ' · latest' : ''}`}
                      style={{display:'inline-flex', alignItems:'center', gap:6, padding:'3px 8px', fontSize:11, fontFamily:'var(--font-mono)', border:'1px solid '+(selected?'var(--line-strong)':'transparent'), borderRadius:999, background: selected?'var(--bg-1)':'transparent', color: selected?'var(--ink-0)':'var(--ink-2)', cursor: versionSwitching ? 'wait' : 'pointer'}}>
                      <span className={`ver-dot ${selected?'current':''}`}/>{v.id}{isLatest && !selected ? <span style={{fontSize:9.5, color:'var(--ink-3)', marginLeft:2}}>latest</span> : null}
                    </button>
                  );
                })}
                <button className="btn sm ghost" style={{fontSize:11, color:'var(--ink-3)', marginLeft:'auto'}} title="Fork this version">
                  <Icon name="retry" size={10}/>fork
                </button>
              </div>
            </div>
          </div>
        )}

        <div style={{flex:1, position:'relative', minHeight:0}}>
          <div ref={chatRef} style={{position:'absolute', inset:0, overflow:'auto', padding:'8px 14px 14px'}}>
            {(() => {
              // Find the index of the last assistant text message so only that
              // row shows the Regenerate affordance. Pipeline + iac rows don't
              // count — regenerating a pipeline turn means re-running the
              // pipeline, which is a different action altogether.
              const lastAssistantIdx = messages
                .map((mm, idx) => ({ idx, mm }))
                .filter(({ mm }) => mm.role === 'assistant' && mm.kind !== 'pipeline' && mm.kind !== 'greet')
                .map(({ idx }) => idx)
                .pop();
              const regenerateLast = () => {
                // Re-send the last user message — simplest semantics that
                // doesn't require an idempotency key. Backend produces a new
                // assistant turn that's appended after the existing one.
                for (let i = messages.length - 1; i >= 0; i--) {
                  if (messages[i].role === 'user') {
                    sendChat(messages[i].content);
                    return;
                  }
                }
              };
              return messages.map((m, i) => (
                <ChatTurn key={i} msg={m} steps={PIPELINE_STEPS}
                          isLatestPipeline={m.kind === 'pipeline' && i === messages.map(x=>x.kind).lastIndexOf('pipeline')}
                          runId={analysisId}
                          onRetry={() => setPipelineState('running')}
                          onSuggest={(q) => sendChat(q)}
                          onPickTab={(t) => { setRailOpen(true); setRailTab(t); }}
                          isLastAssistant={i === lastAssistantIdx}
                          onRegenerate={regenerateLast}
                          onApproveProposal={approveProposal}
                          onEditProposal={() => {
                            // "Edit" on a proposal card = focus the composer
                            // so the user can type corrections. The pending
                            // run record sits dormant — it has no SFN
                            // execution and no usage charge, so no cleanup
                            // needed. A new send creates a fresh run.
                            document.querySelector('textarea[data-chat-composer]')?.focus();
                          }}/>
              ));
            })()}
            {/* Streaming bubble — shows the in-flight assistant reply as
                tokens arrive. Cleared by assistant.complete which is
                immediately followed by the REST response appending the
                final message, so there's no visual hop. */}
            {chatBusy && streamingTurn && (
              <div className="chat-msg assistant">
                <div className="who"><Icon name="sparkle" size={13}/></div>
                <div className="content">
                  <div style={{color:'var(--ink-1)', whiteSpace:'pre-wrap', lineHeight:1.55}}>
                    <MarkdownText>{streamingTurn.text}</MarkdownText>
                    <span className="stream-cursor" aria-hidden="true"
                          style={{display:'inline-block', width:7, height:13, marginLeft:2,
                                  background:'var(--ink-2)', verticalAlign:'text-bottom',
                                  animation:'cac-stream-blink 1s steps(2) infinite'}}/>
                  </div>
                </div>
              </div>
            )}
            {messages.length <= 1 && (
              <div style={{padding:'12px 0 0'}}>
                <div className="label" style={{marginBottom:8}}>— try —</div>
                {[
                  '3-tier web app on ECS Fargate with RDS Postgres and CloudFront',
                  'Serverless REST API with API Gateway, Lambda, and DynamoDB',
                  'Event-driven pipeline with SQS, SNS, and Lambda consumers',
                ].map((s, j) => (
                  <button key={j} className="suggest-chip" onClick={() => sendChat(s)} style={{display:'flex', width:'100%', justifyContent:'flex-start', padding:'6px 8px', marginBottom:2, textAlign:'left', whiteSpace:'normal'}}>
                    <span className="arr">▸</span>{s}
                  </button>
                ))}
              </div>
            )}
          </div>
          {showNewPill && (
            <button
              onClick={jumpToBottom}
              style={{
                position:'absolute', left:'50%', bottom:12, transform:'translateX(-50%)',
                padding:'6px 12px', fontSize:11.5, fontFamily:'var(--font-mono)', letterSpacing:'0.04em',
                background:'var(--bg-3)', color:'var(--ink-1)',
                border:'1px solid var(--line)', borderRadius:999,
                boxShadow:'0 2px 8px rgba(0,0,0,0.25)', cursor:'pointer', zIndex:5,
              }}>
              ↓ New messages
            </button>
          )}
        </div>

        {/* Input dock */}
        <div style={{borderTop:'1px solid var(--line)', background:'var(--bg-3)', padding:10}}>
          <div style={{display:'flex', gap:4, marginBottom:8, alignItems:'center'}}>
            <button className={`btn sm ${inputMode==='text'?'primary':'ghost'}`} onClick={() => setInputMode('text')}><Icon name="sparkle" size={11}/>Chat</button>
            <button className={`btn sm ${inputMode==='iac'?'primary':'ghost'}`} onClick={() => setInputMode('iac')}><Icon name="code" size={11}/>Paste IaC</button>
            <div style={{flex:1}}/>
            {tier === 'free' && <button className="btn sm ghost" onClick={() => setShowQuotaBlock(s => !s)} style={{color:'var(--ink-3)', fontSize:11}}>sim quota</button>}
            <span className="pill ghost" style={{color:'var(--ink-3)'}}>{inputMode==='text' ? `${textVal.length} ch` : `${iacVal.split('\n').length} lines`}</span>
          </div>
          {inputMode === 'text' ? (
            <ChatComposer
              value={textVal}
              onChange={setTextVal}
              onSubmit={submitFromInput}
              onStop={stopChat}
              busy={chatBusy}
              disabled={chatBusy || pipelineState === 'running' || showQuotaBlock || !textVal.trim()}
              placeholder={messages.length > 1 ? 'Ask a follow-up, or describe a change…' : 'Describe your AWS architecture, or ask anything…'}
            />
          ) : (
            <>
              <textarea className="field mono" rows={6} style={{fontSize:11.5}} value={iacVal} onChange={e => setIacVal(e.target.value)}/>
              <div style={{display:'flex', marginTop:8, gap:8, alignItems:'center'}}>
                <span style={{fontSize:11.5, color:'var(--ink-3)', fontFamily:'var(--font-mono)'}}>region · eu-west-1</span>
                <div style={{flex:1}}/>
                <button className="btn primary sm" onClick={submitFromInput} disabled={chatBusy || pipelineState === 'running' || showQuotaBlock}><Icon name="send" size={11}/>Send</button>
              </div>
            </>
          )}
          {showQuotaBlock && (
            <div style={{marginTop:8, padding:'8px 10px', border:'1px solid color-mix(in srgb, var(--err) 30%, transparent)', background:'var(--err-soft)', borderRadius:4, fontSize:12}}>
              <div style={{color:'var(--err)', fontWeight:600, display:'flex', alignItems:'center', gap:6}}><Icon name="warn" size={11}/>Free-tier quota reached</div>
              <div style={{marginTop:6}}><UpgradeCta className="btn primary" size="sm"/></div>
            </div>
          )}
          <div style={{marginTop:6, fontSize:10.5, color:'var(--ink-3)', fontFamily:'var(--font-mono)', textAlign:'center'}}>enter ↵ · shift+enter newline</div>
        </div>
      </aside>

      {/* CENTER — diagram canvas */}
      <section style={{minWidth:0, minHeight:0, position:'relative', overflow:'hidden', display:'flex', flexDirection:'column'}}>
        {!diagramVisible
          ? <WaitingCanvas />
          : (
          <>
            <div style={{position:'absolute', top:10, left:12, zIndex:2, display:'flex', alignItems:'center', gap:8, fontFamily:'var(--font-mono)', fontSize:10.5, color:'var(--ink-3)', textTransform:'uppercase', letterSpacing:'0.08em', pointerEvents:'none'}}>
              <span>architecture</span>
              {analysisId && <><span>·</span><span>{analysisId}</span></>}
              {architecture?.services && <><span>·</span><span>{architecture.services.length} services · {architecture.connections?.length || 0} edges</span></>}
            </div>
            <div style={{flex:1, minHeight:0, position:'relative'}}>
              <DiagramImage bare url={diagram?.pngUrl} svgUrl={diagram?.svgUrl} drawioUrl={diagram?.drawioUrl} height="100%"
                onEdit={diagram?.status === 'completed' ? () => setEditMode(true) : undefined}
              />
            </div>
          </>
        )}
      </section>

      {/* RIGHT — results rail (hidden until pipeline completes) */}
      {showResults && (
        <aside className={`rail ${railOpen ? '' : 'collapsed'}`}>
          {railOpen
            ? <ResultsRail tab={railTab} setTab={setRailTab} onCollapse={() => setRailOpen(false)} showResults={showResults} review={review} costEstimate={costEstimate}/>
            : <CollapsedResultsRail onExpand={(t) => { setRailOpen(true); if (t) setRailTab(t); }} active={railTab}/>
          }
        </aside>
      )}
    </div>
  );
}

/* =========================================================================
   Chat composer — single-line by default, grows with content up to 200px
   ========================================================================= */
function ChatComposer({ value, onChange, onSubmit, onStop, busy, disabled, placeholder }) {
  return (
    <div className="composer">
      <div className="composer-input">
        <textarea
          rows={2}
          value={value}
          onChange={e => onChange(e.target.value)}
          onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSubmit(); } }}
          placeholder={placeholder}
          data-chat-composer
        />
      </div>
      {busy && onStop ? (
        <button
          className="composer-send"
          onClick={onStop}
          aria-label="Stop generating"
          style={{background:'var(--bg-2)', color:'var(--ink-1)'}}>
          <span style={{display:'inline-block', width:10, height:10, background:'currentColor', borderRadius:1}}/>
          Stop
        </button>
      ) : (
        <button
          className="composer-send"
          onClick={onSubmit}
          disabled={disabled}
          aria-label="Send message">
          <Icon name="send" size={12}/>Send
        </button>
      )}
    </div>
  );
}

/* =========================================================================
   Chat turn renderer
   ========================================================================= */
function ChatTurn({ msg, steps, isLatestPipeline, runId, onRetry, onSuggest, onPickTab, onRegenerate, onApproveProposal, onEditProposal, isLastAssistant }) {
  const [pipeOpen, setPipeOpen] = useState(true);
  const [copied, setCopied] = useState(false);
  const handleCopy = async () => {
    try {
      await navigator.clipboard.writeText(msg.content || '');
      setCopied(true);
      setTimeout(() => setCopied(false), 1200);
    } catch {
      // Older browsers: silently no-op rather than fall back to a deprecated execCommand.
    }
  };

  // Auto-collapse pipeline as soon as it finishes — the collapsed summary still
  // shows step count + total duration, so users keep the at-a-glance info.
  useEffect(() => {
    if (msg.kind === 'pipeline' && msg.state === 'done') {
      setPipeOpen(false);
    }
  }, [msg.state]);

  if (msg.kind === 'pipeline') {
    const isDone = msg.state === 'done';
    const isRunning = msg.state === 'running';
    const stepStates = msg.stepStates || steps.map(() => ({ status: 'pending' }));
    const fmt = (ms) => ms == null ? '—' : ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(2)}s`;
    const completeCount = stepStates.filter(s => s.status === 'complete').length;
    const totalLabel = msg.totalMs != null ? fmt(msg.totalMs) : '';

    if (!pipeOpen && isDone) {
      return (
        <div className="chat-msg assistant">
          <div className="who"><Icon name="cpu" size={13}/></div>
          <div className="content" style={{paddingTop:6}}>
            <button className="pipe-summary" onClick={() => setPipeOpen(true)}>
              <Icon name="check" size={11} className="diag-edge" style={{color:'var(--ok)'}}/>
              {completeCount} step{completeCount === 1 ? '' : 's'}{totalLabel && ` · ${totalLabel}`}
              <span style={{color:'var(--ink-3)'}}>· expand</span>
              <Icon name="chev" size={10} className="diag-edge"/>
            </button>
          </div>
        </div>
      );
    }

    return (
      <div className="chat-msg assistant">
        <div className="who"><Icon name="cpu" size={13}/></div>
        <div className="content" style={{paddingTop:0}}>
          <div className="meta" style={{display:'flex', alignItems:'center', gap:8}}>
            <span>PIPELINE</span>
            {isDone && <button onClick={() => setPipeOpen(false)} style={{marginLeft:'auto', fontSize:10, color:'var(--ink-3)', fontFamily:'var(--font-mono)', letterSpacing:'0.06em'}}>COLLAPSE ↑</button>}
          </div>
          <div className="ai-border" style={{border:'1px solid var(--line)', borderRadius:6, background:'var(--bg-2)', overflow:'hidden', marginTop:6}}>
            <div style={{display:'flex', alignItems:'center', gap:8, padding:'6px 12px', background:'var(--bg-1)', borderBottom:'1px solid var(--line)'}}>
              <span className="label" style={{margin:0}}>{runId ? `run · ${shortId(runId)}` : 'run'}</span>
              <div style={{flex:1}}/>
              {isRunning && <span className="pill accent"><span className="spinner" style={{width:8, height:8}}/>running</span>}
              {isDone    && <span className="pill ok"><Icon name="check" size={10}/>{totalLabel || 'done'}</span>}
              {msg.state === 'failed' && <span className="pill err"><Icon name="x" size={10}/>failed</span>}
            </div>
            <div style={{padding:'4px 12px 8px'}}>
              <div className="timeline">
                {steps.map((step, i) => {
                  const ss = stepStates[i] || { status: 'pending' };
                  const s = ss.status;
                  const dur = s === 'complete' || s === 'failed' ? fmt(ss.durationMs)
                            : s === 'inprogress' ? 'live' : '—';
                  return (
                    <div key={step.key} className="step-row" style={{padding:'6px 0'}}>
                      <div className={`step-icon ${s}`}>
                        {s === 'complete' && <Icon name="check" size={12}/>}
                        {s === 'failed'   && <Icon name="x" size={12}/>}
                        {s === 'inprogress' && <span className="spinner" />}
                        {s === 'pending'  && <span style={{fontFamily:'var(--font-mono)'}}>{i+1}</span>}
                      </div>
                      <div style={{minWidth:0}}>
                        <div className="step-head"><span className="title" style={{fontSize:12.5}}>{step.title}</span>
                          <span className="dur">{dur}</span>
                        </div>
                        {s === 'failed' && (
                          <>
                            <div className="step-logs" style={{maxHeight:52}}>
                              <div className="line err"><span className="t">{fmt(ss.durationMs)}</span>{ss.error || 'step failed'}</div>
                            </div>
                            <button className="btn sm" style={{marginTop:6}} onClick={onRetry}><Icon name="retry" size={10}/>Retry</button>
                          </>
                        )}
                      </div>
                    </div>
                  );
                })}
              </div>
            </div>
          </div>
        </div>
      </div>
    );
  }

  if (msg.kind === 'proposal') {
    // Structured proposal card — surfaces the assistant's run decision as a
    // bordered summary instead of a silent state transition. The pipeline
    // has already started server-side when this renders (the real "gate"
    // would require a backend defer-and-approve flow — see todo.md). For
    // now this provides the visual moment and a stable anchor for the
    // future approve action.
    const i = msg.intent || {};
    const counts = [
      i.services_named?.length ? `${i.services_named.length} named` : null,
      i.services_implied?.length ? `${i.services_implied.length} implied` : null,
    ].filter(Boolean).join(' · ');
    const tagPills = [
      i.scale_tier && `scale: ${i.scale_tier}`,
      i.region_scope && `region: ${i.region_scope}`,
      i.vpc_required ? 'VPC' : null,
      i.multi_az ? 'multi-AZ' : null,
      ...(i.compliance || []),
    ].filter(Boolean);
    return (
      <div className="chat-msg assistant">
        <div className="who"><Icon name="sparkle" size={13}/></div>
        <div className="content" style={{paddingTop:0}}>
          <div className="meta" style={{marginBottom:6}}>PROPOSAL</div>
          <div className="ai-border" style={{border:'1px solid var(--line)', borderRadius:6, background:'var(--bg-2)', overflow:'hidden'}}>
            <div style={{padding:'8px 12px', borderBottom:'1px solid var(--line)', background:'var(--bg-1)', display:'flex', alignItems:'center', gap:8}}>
              <span style={{fontSize:13, fontWeight:600, color:'var(--ink-1)'}}>{msg.title || 'Architecture proposal'}</span>
              {msg.inputType && (
                <span className="pill" style={{fontFamily:'var(--font-mono)', fontSize:10, color:'var(--ink-3)'}}>
                  {msg.inputType === 'iac' ? 'IaC' : 'text'}
                </span>
              )}
              <div style={{flex:1}}/>
              {msg.isRevision && <span className="pill" style={{fontSize:10, color:'var(--ink-3)'}}>revision</span>}
            </div>
            {(msg.narrative || counts || tagPills.length > 0) && (
              <div style={{padding:'10px 12px'}}>
                {msg.narrative && (
                  <div style={{color:'var(--ink-1)', fontSize:12.5, lineHeight:1.55, marginBottom: counts || tagPills.length ? 8 : 0}}>
                    {msg.narrative}
                  </div>
                )}
                {counts && (
                  <div style={{fontFamily:'var(--font-mono)', fontSize:10.5, color:'var(--ink-3)', letterSpacing:'0.04em'}}>
                    services · {counts}
                  </div>
                )}
                {tagPills.length > 0 && (
                  <div style={{display:'flex', flexWrap:'wrap', gap:4, marginTop:6}}>
                    {tagPills.map((t, k) => (
                      <span key={k} className="pill" style={{fontSize:10, color:'var(--ink-2)'}}>{t}</span>
                    ))}
                  </div>
                )}
              </div>
            )}
            {/* Gate buttons — hidden once approved (the pipeline card below
                takes over as the live indicator). 'approving' shows a brief
                in-flight state to absorb the approve-endpoint RTT. */}
            {msg.status !== 'approved' && (
              <div style={{padding:'8px 12px', borderTop:'1px solid var(--line)', background:'var(--bg-1)', display:'flex', gap:6, alignItems:'center'}}>
                <button
                  className="btn sm primary"
                  disabled={msg.status === 'approving'}
                  onClick={() => onApproveProposal?.(msg)}
                  style={{display:'inline-flex', alignItems:'center', gap:4}}>
                  {msg.status === 'approving' ? (
                    <><span className="spinner" style={{width:10, height:10}}/>starting…</>
                  ) : (
                    <><Icon name="play" size={11}/>Run analysis</>
                  )}
                </button>
                <button
                  className="btn sm ghost"
                  disabled={msg.status === 'approving'}
                  onClick={() => onEditProposal?.(msg)}>
                  Edit
                </button>
                <div style={{flex:1}}/>
                <span style={{fontSize:10.5, fontFamily:'var(--font-mono)', color:'var(--ink-3)', letterSpacing:'0.04em'}}>
                  awaiting approval
                </span>
              </div>
            )}
          </div>
        </div>
      </div>
    );
  }

  if (msg.kind === 'iac') {
    return (
      <div className="chat-msg user">
        <div className="who">You</div>
        <div className="content">
          <div className="meta">TERRAFORM INPUT</div>
          <pre style={{fontFamily:'var(--font-mono)', fontSize:11.5, background:'var(--bg-2)', border:'1px solid var(--line)', borderRadius:4, padding:10, maxHeight:240, overflow:'auto', whiteSpace:'pre', margin:'4px 0 0'}}>{msg.content}</pre>
        </div>
      </div>
    );
  }

  return (
    <div className={`chat-msg ${msg.role}`}>
      <div className="who">{msg.role === 'user' ? 'You' : <Icon name="sparkle" size={13}/>}</div>
      <div className="content">
        {msg.role === 'user' && <div className="meta">YOU</div>}
        <div style={{color:'var(--ink-1)', whiteSpace:'pre-wrap', lineHeight:1.55}}>
          {msg.role === 'assistant' ? <MarkdownText>{msg.content}</MarkdownText> : msg.content}
        </div>
        {msg.bullets && (
          <ul style={{margin:'8px 0 0', paddingLeft:18, color:'var(--ink-1)', fontSize:13, lineHeight:1.6}}>
            {msg.bullets.map((b, i) => <li key={i}>{b}</li>)}
          </ul>
        )}
        {msg.codeBlock && (
          <pre className="ai-border" style={{margin:'10px 0 0', padding:'10px 12px', fontFamily:'var(--font-mono)', fontSize:11.5, background:'var(--bg-sunken)', border:'1px solid var(--line)', borderRadius:4, whiteSpace:'pre', overflow:'auto'}}>
            {msg.codeBlock.split('\n').map((ln, j) => (
              <div key={j} style={{color: ln.startsWith('+') ? 'var(--ok)' : ln.startsWith('-') ? 'var(--err)' : 'var(--ink-1)'}}>{ln}</div>
            ))}
          </pre>
        )}
        {/* Action buttons (Open scorecard/cost/recs) used to live here, but
            they duplicated both the right-rail tabs and the suggest chips
            below. Removed 2026-05-17 — the rail handles navigation; the
            chips drive conversational follow-ups. */}
        {msg.suggests && msg.suggests.length > 0 && (
          <div style={{display:'flex', gap:2, marginTop:8, flexWrap:'wrap'}}>
            {msg.suggests.map((s, i) => (
              <button key={i} className="suggest-chip" onClick={() => onSuggest(s)}>
                <span className="arr">↳</span>{s}
              </button>
            ))}
          </div>
        )}
        {msg.role === 'assistant' && msg.kind !== 'greet' && (
          <div className="chat-actions" style={{display:'flex', gap:6, marginTop:6, opacity:0, transition:'opacity 120ms'}}>
            <button onClick={handleCopy} className="chat-action-btn" title="Copy"
                    style={{fontSize:10.5, fontFamily:'var(--font-mono)', letterSpacing:'0.04em',
                            background:'transparent', color:'var(--ink-3)', border:'1px solid var(--line)',
                            borderRadius:3, padding:'2px 6px', cursor:'pointer'}}>
              {copied ? '✓ COPIED' : '⧉ COPY'}
            </button>
            {isLastAssistant && onRegenerate && (
              <button onClick={onRegenerate} className="chat-action-btn" title="Regenerate"
                      style={{fontSize:10.5, fontFamily:'var(--font-mono)', letterSpacing:'0.04em',
                              background:'transparent', color:'var(--ink-3)', border:'1px solid var(--line)',
                              borderRadius:3, padding:'2px 6px', cursor:'pointer'}}>
                ↻ REGENERATE
              </button>
            )}
          </div>
        )}
      </div>
    </div>
  );
}

/* =========================================================================
   Results rail
   ========================================================================= */
function CollapsedResultsRail({ onExpand, active }) {
  const items = [
    { id: 'score', icon: 'shield',  label: 'Scorecard' },
    { id: 'cost',  icon: 'dollar',  label: 'Cost' },
    { id: 'recs',  icon: 'sparkle', label: 'Recommendations' },
  ];
  return (
    <div className="rail-collapsed-strip">
      <button title="Expand" onClick={() => onExpand()} style={{color:'var(--ink-1)'}}>
        <Icon name="chev" size={14} style={{transform:'rotate(90deg)'}}/>
      </button>
      <div style={{height:1, width:20, background:'var(--line)'}}/>
      {items.map(it => (
        <button key={it.id} title={it.label} className={active === it.id ? 'active' : ''} onClick={() => onExpand(it.id)}>
          <Icon name={it.icon} size={14}/>
        </button>
      ))}
      <div className="rail-collapsed-label">results</div>
    </div>
  );
}

function ResultsRail({ tab, setTab, onCollapse, showResults, review, costEstimate }) {
  const { tier, setRoute } = useApp();
  const pillars = window.scorecardToPillars(review?.scorecard);
  const findings = window.scorecardToFindings(review?.scorecard);
  const lines = window.costToLines(costEstimate);
  const overall = review?.scorecard?.overallScore ?? (pillars.length ? Math.round(pillars.reduce((s, p) => s + p.score, 0) / pillars.length) : 0);
  const total = lines.reduce((s, l) => s + (l.mo || 0), 0);
  const hiddenTotal = lines.filter(l => l.hidden).reduce((s, l) => s + (l.mo || 0), 0);
  const highCount = findings.filter(f => f.sev === 'HIGH').length;
  const canCost = tier !== 'free';
  const canRecs = tier === 'pro' || tier === 'team' || tier === 'enterprise' || tier === 'admin';

  const tabs = [
    { id: 'score', icon: 'shield',  label: 'Scorecard' },
    { id: 'cost',  icon: 'dollar',  label: 'Cost',            locked: !canCost },
    { id: 'recs',  icon: 'sparkle', label: 'Recommendations', locked: !canRecs },
  ];

  return (
    <>
      <div style={{display:'flex', alignItems:'center', padding:'10px 10px 10px 14px', borderBottom:'1px solid var(--line)', gap:8, background:'var(--bg-3)'}}>
        <div className="label">Results</div>
        <div style={{flex:1}}/>
        <button className="btn sm ghost" onClick={onCollapse} title="Collapse">
          <Icon name="chev" size={13} style={{transform:'rotate(-90deg)'}}/>
        </button>
      </div>
      <div style={{display:'flex', borderBottom:'1px solid var(--line)', background:'var(--bg-1)'}}>
        {tabs.map(t => (
          <button key={t.id} onClick={() => setTab(t.id)} style={{
            flex:1, padding:'10px 6px', display:'flex', flexDirection:'column', alignItems:'center', gap:4,
            borderBottom: tab === t.id ? '2px solid var(--ink-0)' : '2px solid transparent',
            marginBottom:-1, color: tab === t.id ? 'var(--ink-0)' : 'var(--ink-2)', fontSize:11,
          }}>
            <div style={{display:'flex', alignItems:'center', gap:5}}>
              <Icon name={t.icon} size={12}/>
              {t.locked && <Icon name="lock" size={9} className="diag-edge"/>}
            </div>
            <div style={{display:'flex', alignItems:'center', gap:5}}>
              <span>{t.label}</span>
            </div>
          </button>
        ))}
      </div>
      <div style={{flex:1, overflow:'auto', padding:14}}>
        {!showResults && <RailPending/>}
        {showResults && tab === 'score' && <RailScore pillars={pillars} findings={findings} overall={overall}/>}
        {showResults && tab === 'cost' && (canCost ? <RailCost lines={lines} total={total} hiddenTotal={hiddenTotal} savings={costEstimate?.savingsSuggestions || []}/> : <RailGated title="Cost is a Pro feature" sub="Upgrade for line-item pricing and hidden cost detection."/>)}
        {showResults && tab === 'recs' && (canRecs ? <RailRecs recs={review?.recommendations || []}/> : <RailGated title="Recommendations are Pro" sub="Ranked fixes with IaC diffs."/>)}
      </div>
    </>
  );
}

function RailPending() {
  return (
    <div style={{padding:'24px 4px', color:'var(--ink-3)', fontSize:12.5}}>
      <div className="label">— nothing to show yet —</div>
    </div>
  );
}

function RailGated({ title, sub }) {
  return (
    <div style={{padding:'20px 12px', textAlign:'center'}}>
      <div style={{width:36, height:36, borderRadius:6, background:'var(--ink-0)', color:'var(--bg-2)', display:'grid', placeItems:'center', margin:'0 auto 12px'}}><Icon name="lock" size={15}/></div>
      <div style={{fontSize:13.5, fontWeight:600, marginBottom:4}}>{title}</div>
      <div style={{fontSize:12.5, color:'var(--ink-2)', marginBottom:14, lineHeight:1.5}}>{sub}</div>
      <UpgradeCta className="btn primary"/>
    </div>
  );
}

function RailScore({ pillars, findings, overall }) {
  const [expanded, setExpanded] = useState('rel');
  return (
    <div>
      <div style={{display:'flex', alignItems:'center', gap:14, padding:'4px 0 16px', borderBottom:'1px dashed var(--line)', marginBottom:10}}>
        <ScoreRing value={overall} size={76} stroke={7}/>
        <div>
          <div className="label">Overall WAF</div>
          <div style={{fontSize:22, fontFamily:'var(--font-display)', fontWeight:600, letterSpacing:'-0.02em', lineHeight:1}}>{overall}<span style={{fontSize:12, color:'var(--ink-3)', fontFamily:'var(--font-mono)', fontWeight:400, marginLeft:4}}>/ 100</span></div>
          <div style={{fontSize:11.5, color:'var(--ink-3)', marginTop:4, fontFamily:'var(--font-mono)'}}>142 · <span style={{color:'var(--err)'}}>5 fail</span> · <span style={{color:'var(--warn)'}}>14 warn</span></div>
        </div>
      </div>
      {pillars.map(p => (
        <div key={p.key}>
          <div className="pillar-row" onClick={() => setExpanded(e => e === p.key ? null : p.key)} style={{gridTemplateColumns:'1fr auto'}}>
            <div className="name" style={{fontSize:12.5}}>
              <Icon name="chev" size={10} style={{transform: expanded === p.key ? 'rotate(0deg)' : 'rotate(-90deg)', transition:'transform .12s'}} className="diag-edge"/>
              {p.name}
            </div>
            <div style={{display:'flex', alignItems:'center', gap:8}}>
              <div style={{width:72, height:3, background:'var(--bg-sunken)', borderRadius:2, overflow:'hidden'}}>
                <span style={{display:'block', height:'100%', width: `${p.score}%`, background: p.score >= 80 ? 'var(--ok)' : p.score >= 60 ? 'var(--warn)' : 'var(--err)'}}/>
              </div>
              <span className="mono" style={{fontSize:11.5, minWidth:30, textAlign:'right'}}>{p.score}</span>
            </div>
          </div>
          {expanded === p.key && (
            <div style={{padding:'4px 0 12px'}}>
              {findings.filter(f => f.pillar === p.key).length === 0 ? (
                <div style={{fontSize:11.5, color:'var(--ink-3)', padding:'6px 18px'}}>No findings.</div>
              ) : findings.filter(f => f.pillar === p.key).map((f, i) => (
                <div key={i} style={{padding:'8px 10px', border:'1px solid var(--line)', background:'var(--bg-2)', borderRadius:4, marginBottom:6, marginLeft:18}}>
                  <div style={{display:'flex', alignItems:'center', gap:6, marginBottom:4}}>
                    <span className={`pill ${f.sev==='HIGH'?'err':f.sev==='MED'?'warn':'ghost'}`}>{f.sev}</span>
                    <span style={{fontFamily:'var(--font-mono)', fontSize:11, color:'var(--ink-1)'}}>{f.rule}</span>
                    <div style={{flex:1}}/>
                    <span style={{fontFamily:'var(--font-mono)', fontSize:10, color:'var(--ink-3)'}}>{f.ref}</span>
                  </div>
                  <div style={{fontSize:12, color:'var(--ink-2)', lineHeight:1.5}}>{f.desc}</div>
                </div>
              ))}
            </div>
          )}
        </div>
      ))}
    </div>
  );
}

function RailCost({ lines, total, hiddenTotal, savings = [] }) {
  return (
    <div>
      <div style={{padding:'4px 0 14px', borderBottom:'1px dashed var(--line)', marginBottom:10}}>
        <div className="label">Estimated cost</div>
        <div style={{fontSize:26, fontFamily:'var(--font-display)', fontWeight:600, letterSpacing:'-0.02em', lineHeight:1.1}}>€{total.toFixed(2)}<span style={{fontSize:12, color:'var(--ink-3)', fontFamily:'var(--font-mono)', fontWeight:400, marginLeft:6}}>/ mo</span></div>
        <div style={{display:'flex', gap:14, marginTop:10, fontSize:11.5}}>
          <div><span style={{color:'var(--warn)'}}>€{hiddenTotal.toFixed(0)}</span> <span style={{color:'var(--ink-3)'}}>hidden</span></div>
          <div><span style={{color:'var(--ok)'}}>{savings.length}</span> <span style={{color:'var(--ink-3)'}}>savings</span></div>
          <div><span style={{color:'var(--ink-3)'}}>{lines.filter(l => l.free).length} free</span></div>
          <div><span style={{color:'var(--ink-3)'}}>{lines.filter(l => l.unavailable).length} unavailable</span></div>
        </div>
      </div>
      {lines.map((l, i) => (
        <div key={i} style={{display:'grid', gridTemplateColumns:'1fr auto', padding:'8px 0', borderBottom:'1px dashed var(--line)', gap:8, alignItems:'center'}}>
          <div style={{minWidth:0}}>
            <div style={{fontSize:12.5, fontWeight:500, display:'flex', alignItems:'center', gap:6}}>
              {l.resource}
              {l.hidden && <span className="pill warn">hidden</span>}
              {l.free && <span className="pill ghost">free</span>}
              {l.unavailable && <span className="pill ghost">unavailable</span>}
            </div>
            <div style={{fontSize:11, color:'var(--ink-3)', fontFamily:'var(--font-mono)'}}>{l.detail}</div>
          </div>
          <div className="mono" style={{fontWeight: l.hidden ? 600 : 500, color: l.hidden ? 'var(--warn)' : (l.unavailable || l.free) ? 'var(--ink-3)' : undefined, fontSize:12, textAlign:'right'}}>{l.unavailable ? '—' : `€${l.mo.toFixed(2)}`}</div>
        </div>
      ))}
      <div style={{display:'flex', justifyContent:'space-between', padding:'10px 0 0', fontWeight:600, fontSize:13}}>
        <span>Total</span><span className="mono">€{total.toFixed(2)}</span>
      </div>
      {savings.length > 0 && (
        <div style={{marginTop:18, paddingTop:14, borderTop:'1px solid var(--line)'}}>
          <div className="label" style={{marginBottom:8, display:'flex', alignItems:'center', gap:8}}>
            <span>Savings opportunities</span>
            <span className="pill ok">{savings.length}</span>
          </div>
          <ul style={{margin:0, padding:'0 0 0 18px', display:'flex', flexDirection:'column', gap:6}}>
            {savings.map((s, i) => (
              <li key={i} style={{fontSize:11.5, lineHeight:1.5, color:'var(--ink-2)'}}>{s}</li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
}

function RailRecs({ recs }) {
  if (!recs || recs.length === 0) {
    return <div style={{padding:'24px 4px', color:'var(--ink-3)', fontSize:12.5}}>No recommendations yet — they appear after the review step finishes.</div>;
  }
  return (
    <div style={{display:'flex', flexDirection:'column', gap:10}}>
      {recs.map((r, i) => (
        <div key={i} className="ai-border" style={{border:'1px solid var(--line)', borderRadius:6, padding:'10px 12px', background:'var(--bg-2)', fontSize:12.5, lineHeight:1.5, color:'var(--ink-1)'}}>
          {r}
        </div>
      ))}
    </div>
  );
}

function WaitingCanvas() {
  return (
    <div style={{height:'100%', display:'grid', placeItems:'center', padding:40,
      background: `repeating-linear-gradient(0deg, transparent 0 39px, var(--grid) 39px 40px), repeating-linear-gradient(90deg, transparent 0 39px, var(--grid) 39px 40px), var(--bg-0)`}}>
    </div>
  );
}

window.NewAnalysis = NewAnalysis;
