// Chat page — multi-conversation history + per-turn pinned inspector.
// Layout: [ConversationsSidebar] · [ConversationPane] · [ResultInspector]
//                                    (50/50 split of the remaining width)

const { useState: useCS, useEffect: useCE, useMemo: useCM, useRef: useCR, useLayoutEffect: useCL } = React;

// ──────────────────────────────────────────────────────────────────────────
// Store helpers — backend is authoritative for every conversation's turns.
// localStorage only remembers which conversation the user was last viewing,
// so a page reload lands them back on the same chat.
// ──────────────────────────────────────────────────────────────────────────
const CHAT_CURRENT_LS_KEY = 'parcle.chat.currentId';
const CHAT_DRAFT_TURN_ID  = '__draft__';
// Client-only prefix for the optimistic conversation entry that appears in
// the sidebar between user submit and the backend's `final` frame. Replaced
// with the real conversation_id once `final` arrives.
const CHAT_TEMP_CONV_PREFIX = '__pending__';
const isTempConvId = (id) => typeof id === 'string' && id.startsWith(CHAT_TEMP_CONV_PREFIX);

function deriveChatTitle(text) {
  const t = (text || '').trim().replace(/\s+/g, ' ');
  if (!t) return 'New chat';
  return t.length > 40 ? t.slice(0, 40) + '…' : t;
}

// Title derived from the conversation's first user query — the backend does
// not store titles, so we compute one on the fly for display only. For an
// optimistic temp conversation that has no turns yet, fall back to the
// pending query stashed at submit time so the sidebar entry has a title
// from the moment the user hits Send.
function conversationTitle(conv) {
  if (!conv) return 'New chat';
  if (conv.turns && conv.turns.length > 0) return deriveChatTitle(conv.turns[0].query);
  if (conv._pendingQuery) return deriveChatTitle(conv._pendingQuery);
  return 'New chat';
}

// ──────────────────────────────────────────────────────────────────────────
// Typewriter: on text prop change, backspaces current char-by-char,
// then types the new target. Interruptible.
// ──────────────────────────────────────────────────────────────────────────
// Render markdown → sanitized HTML. Uses `marked` (GFM) + DOMPurify from CDN.
// Falls back to escaped plain text with <br/> if either lib hasn't loaded.
function escapeHtmlForMd(s) {
  return String(s || '')
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}
function Markdown({ text }) {
  const html = useCM(() => {
    const src = text == null ? '' : String(text);
    if (!src) return '';
    const m = window.marked, d = window.DOMPurify;
    if (!m || !d) return escapeHtmlForMd(src).replace(/\n/g, '<br/>');
    const raw = m.parse(src, { gfm: true, breaks: true });
    return d.sanitize(raw, { ADD_ATTR: ['target', 'rel'] });
  }, [text]);
  return <div className="md" dangerouslySetInnerHTML={{ __html: html }}/>;
}

function TypewriterLabel({ text, deleteMs = 20, typeMs = 28, cursor = true }) {
  const [displayed, setDisplayed] = useCS('');
  const stateRef = useCR({ cur: '' });
  useCE(() => {
    const target = text || '';
    let cancelled = false;
    let lastTs = 0;
    const tick = (ts) => {
      if (cancelled) return;
      const cur = stateRef.current.cur;
      if (cur === target) return;
      if (!lastTs) lastTs = ts;
      const needsDelete = !target.startsWith(cur);
      const delay = needsDelete ? deleteMs : typeMs;
      if (ts - lastTs >= delay) {
        const next = needsDelete ? cur.slice(0, -1) : target.slice(0, cur.length + 1);
        stateRef.current.cur = next;
        setDisplayed(next);
        lastTs = ts;
      }
      requestAnimationFrame(tick);
    };
    const id = requestAnimationFrame(tick);
    return () => { cancelled = true; cancelAnimationFrame(id); };
  }, [text, deleteMs, typeMs]);
  return (
    <span style={{ whiteSpace: 'pre-wrap' }}>
      {displayed}
      {cursor && <span className="tw-cursor"/>}
    </span>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// ChatPage — state container
//
// Server-truth `conversations` is fetched once at mount from
// /skill/conversations. Each conversation has shape {conversation_id, turns}
// with turns = [{query, response: AskResponse}].
//
// In-flight streaming turns live in `drafts` (client-only), keyed by
// conversation_id. Multiple streams can run concurrently — submitting a
// question and then switching to another chat does NOT cancel the stream;
// it keeps running in the background and its draft updates the originating
// conversation's entry in the sidebar / pane on return. When /ask/progress
// fires `final`, we append {query, response} into `conversations` and drop
// the conv's slot from `drafts`. Only the currently-selected conversation
// id is persisted to localStorage.
// ──────────────────────────────────────────────────────────────────────────
function ChatPage() {
  const [conversations, setConversations] = useCS([]);
  const [loading, setLoading] = useCS(true);
  const [loadError, setLoadError] = useCS(null);

  // `currentId === null` means "new-chat draft" — no backend row yet.
  const [currentId, setCurrentId] = useCS(() => {
    try { return localStorage.getItem(CHAT_CURRENT_LS_KEY) || null; } catch (e) { return null; }
  });

  // Per-conversation in-flight draft turns, keyed by conv id (real or temp).
  // The slot for a given conv is created in send(), updated by onStep, and
  // removed in onFinal / .catch. Multiple slots may be present at once if
  // the user fires-and-switches.
  const [drafts, setDrafts] = useCS({});

  const [pinnedTurnId, setPinnedTurnId] = useCS(null);
  const [draft, setDraft] = useCS('');
  const [sidebarOpen, setSidebarOpen] = useCS(() => {
    const v = localStorage.getItem('parcle.chat.sidebar');
    return v == null ? true : v === 'true';
  });
  // Per-conversation AbortControllers. Refs because controllers don't need
  // to drive renders — only stop() / deleteConv() / unmount read them.
  const abortsRef = useCR({});

  // Helpers — write the draft slot for a given conv. Pass `null` to drop.
  const setDraftFor = (convId, next) => {
    setDrafts(prev => {
      if (next == null) {
        if (!(convId in prev)) return prev;
        const { [convId]: _drop, ...rest } = prev;
        return rest;
      }
      return { ...prev, [convId]: next };
    });
  };
  const patchDraftFor = (convId, patch) => {
    setDrafts(prev => {
      const cur = prev[convId];
      if (!cur) return prev;
      const inc = typeof patch === 'function' ? patch(cur) : patch;
      // A null/empty patch means "no change" — bail without allocating a new
      // draft object so we don't trigger a needless re-render.
      if (!inc || Object.keys(inc).length === 0) return prev;
      return { ...prev, [convId]: { ...cur, ...inc } };
    });
  };

  // Initial (and only) fetch of the backend conversation list.
  useCE(() => {
    let cancelled = false;
    listConversationsCached()
      .then(list => {
        if (cancelled) return;
        setConversations(Array.isArray(list) ? list : []);
        setLoading(false);
      })
      .catch(e => {
        if (cancelled) return;
        setLoadError((e && e.message) || String(e));
        setLoading(false);
      });
    return () => { cancelled = true; };
  }, []);

  // Persist only the selected conversation id. Temp ids are client-only
  // and meaningless on next load — never persist them.
  useCE(() => {
    try {
      if (currentId && !isTempConvId(currentId)) localStorage.setItem(CHAT_CURRENT_LS_KEY, currentId);
      else localStorage.removeItem(CHAT_CURRENT_LS_KEY);
    } catch (e) { /* quota / disabled */ }
  }, [currentId]);

  useCE(() => { localStorage.setItem('parcle.chat.sidebar', String(sidebarOpen)); }, [sidebarOpen]);
  // On unmount, abort every in-flight stream.
  useCE(() => () => {
    Object.values(abortsRef.current).forEach(c => { try { c.abort(); } catch (e) { /* already settled */ } });
  }, []);

  // If the cached `currentId` no longer matches any server conversation
  // (deleted from another tab, LRU-evicted whole, etc.), fall back to the
  // new-chat draft rather than leaving the pane in an empty state pointing
  // at a non-existent id. Skip temp placeholders — they vanish briefly
  // during the onFinal swap (temp removed → real added → currentId
  // updated), and if those updates land in separate renders this effect
  // would otherwise flash-reset currentId to null and the subsequent
  // functional updater (`prev === targetConvId ? backendId : prev`) would
  // see `prev === null` and dump the user on New Chat.
  useCE(() => {
    if (loading) return;
    if (currentId && !isTempConvId(currentId) && !conversations.find(c => c.conversation_id === currentId)) {
      setCurrentId(null);
    }
  }, [loading, conversations, currentId]);

  const current = currentId ? conversations.find(c => c.conversation_id === currentId) : null;
  const currentDraft = currentId ? (drafts[currentId] || null) : null;

  // Render-ready turn list: server turns, then the in-flight draft for the
  // current conversation (if any). Turn `id`s are stable within a
  // conversation render — backend LRU eviction shifts indices, but the
  // pinned-turn healing effect below catches that.
  const viewTurns = useCM(() => {
    const items = [];
    if (current) {
      current.turns.forEach((t, i) => {
        items.push({
          id: `${current.conversation_id}#${i}`,
          status: 'done',
          query: t.query,
          response: t.response,
          liveSteps: [],
          liveSources: [],
          statusLabel: '',
        });
      });
    }
    if (currentDraft) {
      items.push({
        id: CHAT_DRAFT_TURN_ID,
        status: currentDraft.status,
        query: currentDraft.query,
        response: currentDraft.response || null,
        liveSteps: currentDraft.liveSteps || [],
        liveSources: currentDraft.liveSources || [],
        statusLabel: currentDraft.statusLabel || '',
      });
    }
    return items;
  }, [current, currentDraft]);

  const streaming = !!(currentDraft && currentDraft.status === 'streaming');
  const latestTurn = viewTurns[viewTurns.length - 1] || null;

  // Heal pinned id if the turn vanished (conversation switched, LRU-evicted,
  // or deleted). Fall back to latest.
  useCE(() => {
    if (!pinnedTurnId) return;
    if (!viewTurns.find(t => t.id === pinnedTurnId)) setPinnedTurnId(null);
  }, [viewTurns, pinnedTurnId]);

  const effectiveTurn = pinnedTurnId
    ? (viewTurns.find(t => t.id === pinnedTurnId) || latestTurn)
    : latestTurn;
  const effectiveIndex = effectiveTurn ? viewTurns.findIndex(t => t.id === effectiveTurn.id) : -1;
  const pinnedToLatest = !pinnedTurnId || (latestTurn != null && pinnedTurnId === latestTurn.id);

  const send = (raw) => {
    const text = (raw != null ? raw : draft).trim();
    if (!text || streaming) return;

    // Snapshot the conversation target at send time. For a brand-new chat
    // (currentId === null) we eagerly create a temp conversation entry so
    // the sidebar shows the new chat immediately and survives the user
    // switching away mid-stream. The temp id is replaced with the real
    // backend id in onFinal.
    let targetConvId = currentId;
    const startedFromNewChat = !targetConvId;
    if (startedFromNewChat) {
      targetConvId = `${CHAT_TEMP_CONV_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
      setConversations(list => [
        { conversation_id: targetConvId, turns: [], _pendingQuery: text },
        ...list,
      ]);
      setCurrentId(targetConvId);
    }

    setDraftFor(targetConvId, {
      convId: targetConvId,
      query: text,
      status: 'streaming',
      statusLabel: 'Thinking…',
      liveSteps: [],
      liveSources: [],
      response: null,
      startedAt: Date.now(),
    });
    setDraft('');
    // Intentionally keep pinnedTurnId — user may be reading a past turn.

    // `settled` guards against onFinal / onError / .catch all racing each
    // other on the same turn (e.g. abort fires on the same tick the final
    // SSE frame is received). First terminal callback wins; others no-op.
    let settled = false;
    const markSettled = () => { const first = !settled; settled = true; return first; };

    const ctrl = new AbortController();
    abortsRef.current[targetConvId] = ctrl;
    // Temp ids are client-only sentinels — translate to null on the wire.
    const apiConvId = isTempConvId(targetConvId) ? null : targetConvId;
    askStream(
      { query: text, conversationId: apiConvId, signal: ctrl.signal },
      {
        onStep: (s) => {
          if (settled) return;
          // New protocol (api_contract_ask.md "Step"): the SSE `step` event
          // payload is `{id, label, step: <full Step matching final schema>}`.
          // Pull the embedded Step object so live rendering can match the
          // final visual; fall back to a synthesized Step if a legacy
          // `{id, label}` envelope arrives.
          const stepObj = (s && s.step && typeof s.step === 'object')
            ? s.step
            : {
                id: (s && s.id) || 'processing',
                title: (s && s.label) || 'Working',
                status: 'done',
                message: null,
                data: {},
                thought: null,
              };
          const label = (s && s.label) || stepObj.title || 'Working';
          patchDraftFor(targetConvId, d => ({
            statusLabel: label,
            liveSteps: [...d.liveSteps, stepObj],
          }));
        },
        onKnowledge: (k) => {
          if (settled) return;
          // New protocol (api_contract_ask.md "knowledge"): each event is a
          // SourceRef the agent declared mid-turn. Accumulate into liveSources
          // for real-time "Knowledge used" rendering. Backend already dedups by
          // (type, id); we dedup again as a safety net per the contract.
          if (!k || !k.type || k.id === null || k.id === undefined) return;
          patchDraftFor(targetConvId, d => {
            const existing = d.liveSources || [];
            if (existing.some(s => s.type === k.type && s.id === k.id)) return null;
            return { liveSources: [...existing, { type: k.type, id: k.id, name: k.name || k.id }] };
          });
        },
        onFinal: (r) => {
          if (!markSettled()) return;
          if (abortsRef.current[targetConvId] === ctrl) delete abortsRef.current[targetConvId];
          // Per-turn graph upsert (api_contract_ask.md "AskGraphDelta") —
          // merges into the cached snapshot so the Learning stream / ticker
          // reflect this turn's learnings immediately.
          // Skip on failure status: the contract permits graph_delta on any
          // status, but a failed turn's "learnings" should not pollute the
          // user-visible learning stream.
          if (r && r.status !== 'failure' && r.graph_delta &&
              typeof applyAskGraphDelta === 'function') {
            applyAskGraphDelta(r.graph_delta);
          }
          const backendId = (r && r.conversation_id) || (isTempConvId(targetConvId) ? null : targetConvId);
          if (!backendId) {
            // Defensive: backend should always return an id. If it doesn't
            // we can't file this turn under a real conversation, but we can
            // still bake the question into whatever placeholder the user is
            // looking at as a failure turn — same shape as the .catch path —
            // so the conversation entry isn't left as a permanent ghost.
            const label = 'Missing conversation_id in response';
            setConversations(list => list.map(c => {
              if (c.conversation_id !== targetConvId) return c;
              const failedResp = {
                status: 'failure',
                message: label,
                steps: [],
                result: { payloads: [], sources: [], confidence: null },
              };
              const { _pendingQuery, ...rest } = c;
              return { ...rest, turns: [...(c.turns || []), { query: text, response: failedResp }] };
            }));
            setDraftFor(targetConvId, null);
            return;
          }
          setConversations(list => {
            // Drop the temp placeholder (if any) before merging into the real conv.
            const cleaned = isTempConvId(targetConvId)
              ? list.filter(c => c.conversation_id !== targetConvId)
              : list;
            const idx = cleaned.findIndex(c => c.conversation_id === backendId);
            if (idx >= 0) {
              const c = cleaned[idx];
              const next = cleaned.slice();
              next[idx] = { ...c, turns: [...c.turns, { query: text, response: r }] };
              return next;
            }
            return [
              { conversation_id: backendId, turns: [{ query: text, response: r }] },
              ...cleaned,
            ];
          });
          // Move the user to the real id only if they're still parked on the
          // temp placeholder — don't yank them away from a manual switch.
          setCurrentId(prev => prev === targetConvId ? backendId : prev);
          setDraftFor(targetConvId, null);
        },
        onError: (e) => {
          // Non-terminal: an SSE `error` frame surfaces a step-level error
          // label but the stream itself can still continue and produce a
          // `final` (or end without one, in which case .catch settles).
          // Intentionally does NOT call markSettled — if a recovery `final`
          // arrives later, onFinal must still win.
          if (settled) return;
          patchDraftFor(targetConvId, { statusLabel: (e && e.label) || 'Something went wrong' });
        },
      }
    ).catch(err => {
      if (!markSettled()) return;
      if (abortsRef.current[targetConvId] === ctrl) delete abortsRef.current[targetConvId];
      const aborted = err && err.name === 'AbortError';
      const label = aborted ? 'Stopped' : ((err && err.message) || 'Request failed');
      // Bake the user's query into the conversation's turns as a stopped /
      // failed turn, then drop the draft. Without this the question only
      // lives in the draft slot, which gets cleared by deleteConv (or by a
      // re-send to the same conv after the previous attempt stopped) —
      // silently losing the question. Persisting it as a real turn means
      // the originating conversation always retains the user's question
      // (with a Stopped/failure indicator) regardless of what they do next.
      setConversations(list => list.map(c => {
        if (c.conversation_id !== targetConvId) return c;
        const stoppedResp = {
          status: 'failure',
          message: label,
          steps: [],
          result: { payloads: [], sources: [], confidence: null },
        };
        const { _pendingQuery, ...rest } = c;
        return { ...rest, turns: [...(c.turns || []), { query: text, response: stoppedResp }] };
      }));
      setDraftFor(targetConvId, null);
    });
  };

  // Stop only the current conversation's in-flight stream. Other
  // background streams (other convs the user fired-and-switched on) keep
  // running.
  const stop = () => {
    if (!currentId) return;
    const ctrl = abortsRef.current[currentId];
    if (ctrl) ctrl.abort();
  };

  const newChat = () => {
    // Streams are per-conversation now — navigating to New Chat does NOT
    // abort anything. In-flight streams keep running in the background and
    // will commit their final response into their originating conversation
    // regardless of what the user is currently viewing.
    if (typeof resetChatMockIdx === 'function') resetChatMockIdx();
    setDraft('');
    setPinnedTurnId(null);
    setCurrentId(null);
  };

  const switchTo = (id) => {
    if (id === currentId) return;
    if (!conversations.find(c => c.conversation_id === id)) return;
    // Per-conversation streams keep running in the background — do NOT
    // abort. Each conv's draft slot in `drafts` survives the switch and
    // becomes visible again when the user returns; if the stream finishes
    // while the user is elsewhere, onFinal commits its real turn into the
    // originating conversation and the user sees the answer when they
    // navigate back.
    setCurrentId(id);
    setPinnedTurnId(null);
    setDraft('');
  };

  const deleteConv = async (id) => {
    // Abort the deleted conversation's stream (if any) and drop the ref
    // entry immediately. Other convs' streams are unaffected. The async
    // .catch from the in-flight stream will still fire and try to bake a
    // stopped turn into `conversations`, but by then the conv is gone from
    // the list so the map() is a no-op.
    const ctrl = abortsRef.current[id];
    if (ctrl) {
      delete abortsRef.current[id];
      ctrl.abort();
    }
    // Temp placeholders only exist client-side — skip the round-trip.
    if (!isTempConvId(id)) {
      try {
        await deleteConversation(id);
      } catch (e) {
        if (e && e.status === 409) {
          window.alert('Conversation is currently being processed. Try again in a moment.');
          return;
        }
        if (e && e.status !== 404) {
          window.alert(`Delete failed: ${(e && e.message) || e}`);
          return;
        }
        // 404 → already gone server-side; fall through to local cleanup.
      }
    }
    // Functional updaters so we don't clobber state the user changed during
    // the round-trip (e.g. switched to a different conversation while this
    // delete was in flight).
    setConversations(list => list.filter(c => c.conversation_id !== id));
    setCurrentId(prev => prev === id ? null : prev);
    setDraftFor(id, null);
    setPinnedTurnId(prev => {
      if (!prev) return prev;
      // Pin sentinel always references the currently-viewed conv's draft;
      // if we're deleting that conv, currentId changes too and the healing
      // effect on viewTurns will clear the pin on next render.
      if (prev === CHAT_DRAFT_TURN_ID) return prev;
      return prev.startsWith(id + '#') ? null : prev;
    });
  };

  const pinTurn = (id) => {
    if (latestTurn && id === latestTurn.id) setPinnedTurnId(null);
    else setPinnedTurnId(id);
  };

  const jumpToLatest = () => setPinnedTurnId(null);

  // Synthesized conversation shape for the pane header so it can stay
  // agnostic about whether we're on a real chat or the new-chat draft.
  const headerConv = current
    ? {
        id: current.conversation_id,
        title: conversationTitle(current),
        // Hide the client-only temp id from the pane subtitle — until the
        // backend assigns a real id, present the chat as "new conversation".
        conversationId: isTempConvId(current.conversation_id) ? null : current.conversation_id,
      }
    : { id: null, title: 'New chat', conversationId: null };

  if (loading) {
    return (
      <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-tertiary)' }}>
        <span className="t-body">Loading conversations…</span>
      </div>
    );
  }
  if (loadError) {
    return (
      <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 8, padding: 24 }}>
        <div className="t-h3" style={{ color: 'var(--error)' }}>Failed to load conversations</div>
        <div className="mono-sm mono text-tertiary" style={{ textAlign: 'center', maxWidth: 480 }}>{loadError}</div>
      </div>
    );
  }

  return (
    <div style={{ display: 'flex', height: '100%', minHeight: 0 }}>
      <ConversationsSidebar
        conversations={conversations}
        currentId={currentId}
        open={sidebarOpen}
        setOpen={setSidebarOpen}
        onSwitch={switchTo}
        onNew={newChat}
        onDelete={deleteConv}
      />
      <div style={{ flex: '1 1 50%', minWidth: 0, minHeight: 0, borderRight: '1px solid var(--border-subtle)', display: 'flex', flexDirection: 'column' }}>
        <ConversationPane
          turns={viewTurns}
          effectiveTurnId={effectiveTurn ? effectiveTurn.id : null}
          latestTurnId={latestTurn ? latestTurn.id : null}
          onPin={pinTurn}
          draft={draft}
          setDraft={setDraft}
          onSend={send}
          onStop={stop}
          onNewChat={newChat}
          streaming={streaming}
          conversation={headerConv}
        />
      </div>
      <div style={{ flex: '1 1 50%', minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
        <ResultInspector
          turn={effectiveTurn}
          turnIndex={effectiveIndex}
          totalTurns={viewTurns.length}
          pinnedToLatest={pinnedToLatest}
          onJumpLatest={jumpToLatest}
        />
      </div>
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// ConversationsSidebar — flat list (backend gives no timestamps to group by,
// and no title field to edit; titles are derived from the first user query).
// ──────────────────────────────────────────────────────────────────────────
Object.assign(window, { ChatPage, TypewriterLabel });
