/**
 * Simplified web chat for Forge — no extension required.
 *
 * Talks to the chat-standalone server through /api/chat-proxy. Full
 * featureset (per-tab connector hints, browser-side scripts, unread
 * badges, fork UI) stays in the browser extension; this page is a
 * fallback so users can chat from any device with a browser.
 *
 * Routes used:
 *   GET    /api/chat-proxy/sessions
 *   GET    /api/chat-proxy/sessions/main
 *   POST   /api/chat-proxy/sessions          (new temp session)
 *   GET    /api/chat-proxy/sessions/:id       (messages for a session)
 *   DELETE /api/chat-proxy/sessions/:id/messages  (clear)
 *   POST   /api/chat-proxy/sessions/:id/messages  (send user turn)
 *   GET    /api/chat-proxy/sessions/:id/events    (SSE)
 *   DELETE /api/chat-proxy/sessions/:id           (delete — refused for main)
 */

'use client';

import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import MarkdownContent from '@/components/MarkdownContent';
import WatchesPanel from '@/components/WatchesPanel';
import type { ContentBlock, Message, Session } from '@/lib/chat/types';

const PROXY = '/api/chat-proxy';

// The global body font is monospace (see globals.css). The chat page
// is a reading surface — override to a UI-friendly system stack and
// let MarkdownContent's <code> elements switch back to mono inline.
const SANS_FONT =
  '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif';

interface MemoryStatus {
  backend?: 'temper' | 'local';
  pinnedCount?: number;
  blocksCount?: number;
  hitsCount?: number;
}

interface ChatSession extends Session {
  meta?: { kind?: 'main' | 'temp'; [k: string]: unknown };
}

export default function ChatPage() {
  const [sessions, setSessions] = useState<ChatSession[]>([]);
  // Inline rename state — only one row edits at a time. editingId is the
  // session being renamed, editingTitle is the current input value.
  const [editingId, setEditingId] = useState<string | null>(null);
  const [editingTitle, setEditingTitle] = useState('');
  const [activeId, setActiveId] = useState<string>('');
  const [messages, setMessages] = useState<Message[]>([]);
  // Background-watch progress chips (watch_id → {text, ts}). Ambient,
  // updated in place; pruned when stale. Not part of the message thread.
  const [watchChips, setWatchChips] = useState<Record<string, { text: string; ts: number }>>({});
  const [input, setInput] = useState('');
  const [streaming, setStreaming] = useState(false);
  // True from Stop-click → next iteration boundary on the backend. Drives
  // an immediate banner so Stop feels responsive when a tool call is mid-flight.
  const [stopRequested, setStopRequested] = useState(false);
  const [partial, setPartial] = useState('');
  const [memory, setMemory] = useState<MemoryStatus | null>(null);
  const [memoryOpen, setMemoryOpen] = useState(false);
  const [error, setError] = useState('');

  const eventSrcRef = useRef<EventSource | null>(null);
  const scrollRef = useRef<HTMLDivElement>(null);
  const composerRef = useRef<HTMLTextAreaElement>(null);

  // ─── Load sessions ────────────────────────────────────────
  const refreshSessions = useCallback(async () => {
    try {
      const [listResp, mainResp] = await Promise.all([
        fetch(`${PROXY}/sessions?limit=200`),
        fetch(`${PROXY}/sessions/main`),
      ]);
      const listJson = (await listResp.json()) as { sessions: ChatSession[] };
      const list = listJson.sessions || [];
      const mainJson = (await mainResp.json()) as { session: ChatSession };
      const mainId = mainJson?.session?.id;
      const ordered = list.slice().sort((a, b) => {
        if (a.id === mainId) return -1;
        if (b.id === mainId) return 1;
        return (b.updated_at || 0) - (a.updated_at || 0);
      });
      setSessions(ordered);
      if (!activeId && mainId) setActiveId(mainId);
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e));
    }
  }, [activeId]);

  useEffect(() => {
    refreshSessions();
  }, [refreshSessions]);

  // ─── Load messages on session change ──────────────────────
  const loadMessages = useCallback(async (id: string) => {
    if (!id) return;
    try {
      const r = await fetch(`${PROXY}/sessions/${id}?limit=1000`);
      const j = (await r.json()) as { messages?: Message[] };
      setMessages(j.messages || []);
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e));
    }
  }, []);

  useEffect(() => {
    if (activeId) loadMessages(activeId);
  }, [activeId, loadMessages]);

  // ─── SSE subscription ─────────────────────────────────────
  useEffect(() => {
    if (!activeId) return;
    eventSrcRef.current?.close();
    const src = new EventSource(`${PROXY}/sessions/${activeId}/events`);
    eventSrcRef.current = src;

    src.onmessage = (ev) => {
      let payload: { type?: string; data?: any; message_id?: string };
      try { payload = JSON.parse(ev.data); } catch { return; }
      const type = payload.type;
      const data = payload.data || {};
      if (type === 'text_delta') {
        setPartial((p) => p + (data.delta || ''));
      } else if (type === 'message_saved') {
        loadMessages(activeId);
        setPartial('');
      } else if (type === 'memory_status') {
        setMemory({
          backend: data.backend,
          pinnedCount: data.pinnedCount,
          blocksCount: data.blocksCount,
          hitsCount: data.hitsCount,
        });
      } else if (type === 'turn_done') {
        setStreaming(false);
        setStopRequested(false);
        setPartial('');
        loadMessages(activeId);
        refreshSessions();
      } else if (type === 'watch_status') {
        // Ambient background-watch progress — a chip that updates in
        // place, NOT a chat message (doesn't touch the thread / LLM).
        // A terminal status (done/non-active) drops the chip at once.
        const wid = String(data.watch_id || '');
        if (wid) {
          const terminal = !!data.done || (data.state && data.state !== 'active');
          setWatchChips((c) => {
            if (terminal) { const n = { ...c }; delete n[wid]; return n; }
            return { ...c, [wid]: { text: String(data.text || ''), ts: Date.now() } };
          });
        }
      } else if (type === 'error') {
        setStreaming(false);
        setStopRequested(false);
        setError(String(data.error || 'unknown error'));
      }
    };
    return () => {
      src.close();
    };
  }, [activeId, loadMessages, refreshSessions]);

  // ─── Auto-scroll on new content ───────────────────────────
  useEffect(() => {
    const el = scrollRef.current;
    if (el) el.scrollTop = el.scrollHeight;
  }, [messages, partial]);

  // ─── Prune stale watch chips (no update in >150s = done/gone) ──
  useEffect(() => {
    const t = setInterval(() => {
      setWatchChips((c) => {
        const now = Date.now();
        const next: typeof c = {};
        let changed = false;
        for (const [k, v] of Object.entries(c)) {
          if (now - v.ts < 150_000) next[k] = v; else changed = true;
        }
        return changed ? next : c;
      });
    }, 30_000);
    return () => clearInterval(t);
  }, []);

  // ─── Auto-resize composer ─────────────────────────────────
  useEffect(() => {
    const el = composerRef.current;
    if (!el) return;
    el.style.height = 'auto';
    el.style.height = Math.min(el.scrollHeight, 200) + 'px';
  }, [input]);

  // ─── Actions ──────────────────────────────────────────────
  async function send(textArg?: string) {
    const text = (textArg ?? input).trim();
    if (!text || !activeId || streaming) return;
    if (textArg === undefined) setInput('');
    setStreaming(true);
    setError('');
    setPartial('');
    try {
      const r = await fetch(`${PROXY}/sessions/${activeId}/messages`, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ text }),
      });
      if (!r.ok) {
        const j = await r.json().catch(() => ({}));
        throw new Error(j.error || `HTTP ${r.status}`);
      }
    } catch (e) {
      setStreaming(false);
      setError(e instanceof Error ? e.message : String(e));
    }
  }

  // Stop the in-flight tool-call loop. The backend breaks at the next
  // iteration boundary and persists a "⏹ Stopped" message; the turn_done
  // SSE event flips `streaming` off here.
  async function stop() {
    if (!activeId) return;
    // Flip the banner instantly so the user sees feedback. The backend
    // returns immediately but the loop only checks the flag between
    // iterations, so the actual "⏹ Stopped" sentinel can lag a long-
    // running tool call by tens of seconds. turn_done clears this.
    setStopRequested(true);
    try {
      await fetch(`${PROXY}/sessions/${activeId}/abort`, { method: 'POST' });
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e));
    }
  }

  // Inject supplementary info into the RUNNING turn — the agent picks it
  // up on its next iteration. If the turn just ended (409), fall back to
  // sending it as a normal new message so the text is never lost.
  async function addNote() {
    const text = input.trim();
    if (!text || !activeId) return;
    setInput('');
    // Optimistic display: the server only persists the note on the next
    // iteration (after the current tool call finishes), which can take
    // tens of seconds. Show the user's message in the thread NOW so
    // sending feels responsive — turn_done reloads from DB and the real
    // persisted version replaces this entry.
    const optimisticId = `optimistic-note-${Date.now()}`;
    setMessages((ms) => [...ms, {
      id: optimisticId,
      session_id: activeId,
      role: 'user',
      blocks: [{ type: 'text', text } as ContentBlock],
      ts: Date.now(),
    } as Message]);
    try {
      const r = await fetch(`${PROXY}/sessions/${activeId}/note`, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ text }),
      });
      if (r.ok) return; // the loop surfaces the note as a user message
      if (r.status === 409) { await send(text); return; } // turn ended → normal send
      const j = await r.json().catch(() => ({}));
      throw new Error(j.error || `HTTP ${r.status}`);
    } catch (e) {
      // Roll back the optimistic on failure
      setMessages((ms) => ms.filter((m) => m.id !== optimisticId));
      setError(e instanceof Error ? e.message : String(e));
    }
  }

  async function newSession() {
    // Optional title via prompt — leave blank for the default
    // "Temp · <id>" label. The user can also rename later by clicking
    // the pencil icon on the sidebar row (or double-clicking the row).
    const raw = window.prompt('Name this conversation (leave blank for default):', '');
    if (raw === null) return; // user hit Cancel
    const title = raw.trim() || undefined;
    try {
      const r = await fetch(`${PROXY}/sessions`, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ meta: { kind: 'temp' }, ...(title ? { title } : {}) }),
      });
      const j = (await r.json()) as { session?: ChatSession };
      if (j?.session?.id) {
        await refreshSessions();
        setActiveId(j.session.id);
      }
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e));
    }
  }

  // PATCH /sessions/:id with a new title. Used by both the sidebar's
  // pencil-icon inline editor and the double-click handler. Empty input
  // is a no-op (cancel) — the backend's updateSession uses ?? semantics
  // so passing null doesn't clear, and the "Main conversation" / "Temp
  // · xxx" defaults are computed from null title at render time anyway.
  async function renameSession(id: string, title: string) {
    const clean = title.trim();
    if (!clean) return;
    try {
      const r = await fetch(`${PROXY}/sessions/${id}`, {
        method: 'PATCH',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ title: clean }),
      });
      if (!r.ok) {
        const j = await r.json().catch(() => ({}));
        setError(j.error || `rename failed (HTTP ${r.status})`);
        return;
      }
      await refreshSessions();
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e));
    }
  }

  async function clearMessages() {
    if (!activeId) return;
    if (!confirm('Clear all messages in this session?')) return;
    await fetch(`${PROXY}/sessions/${activeId}/messages`, { method: 'DELETE' });
    setMessages([]);
    setPartial('');
  }

  async function deleteSession(id: string) {
    if (!confirm('Delete this session permanently?')) return;
    const r = await fetch(`${PROXY}/sessions/${id}`, { method: 'DELETE' });
    if (!r.ok) {
      const j = await r.json().catch(() => ({}));
      alert(j.error || 'Delete failed (main session cannot be deleted)');
      return;
    }
    if (id === activeId) setActiveId('');
    await refreshSessions();
  }

  // ─── Render ───────────────────────────────────────────────
  const activeSession = useMemo(
    () => sessions.find((s) => s.id === activeId),
    [sessions, activeId],
  );

  return (
    <div
      className="flex h-screen text-[var(--text-primary)] bg-[var(--bg-primary)]"
      style={{ fontFamily: SANS_FONT }}
    >
      {/* ─── Sidebar ─────────────────────────────────────── */}
      <aside className="w-64 shrink-0 border-r border-[var(--border)] flex flex-col bg-[var(--bg-secondary)]/40">
        <div className="px-4 py-3 border-b border-[var(--border)] flex items-center justify-between">
          <a
            href="/"
            className="text-sm font-semibold text-[var(--text-primary)] hover:text-[var(--accent)] transition-colors"
            title="Back to Dashboard"
          >
            ← Forge
          </a>
          <button
            onClick={newSession}
            className="text-xs px-2.5 py-1 bg-[var(--accent)] text-white rounded-md hover:opacity-90 transition-opacity"
          >
            + New
          </button>
        </div>

        <div className="flex-1 overflow-y-auto py-2 px-2">
          {sessions.length === 0 && (
            <div className="text-xs text-[var(--text-secondary)] italic px-2 py-3">No sessions yet.</div>
          )}
          {sessions.map((s) => {
            const isMain = s.meta?.kind === 'main';
            const isActive = s.id === activeId;
            const isEditing = editingId === s.id;
            const displayTitle = s.title || (isMain ? 'Main conversation' : `Temp · ${s.id.slice(0, 6)}`);
            const beginEdit = () => {
              setEditingId(s.id);
              setEditingTitle(s.title || '');
            };
            const commitEdit = async () => {
              const next = editingTitle.trim();
              setEditingId(null);
              if (next && next !== (s.title || '')) {
                await renameSession(s.id, next);
              }
            };
            const cancelEdit = () => {
              setEditingId(null);
              setEditingTitle('');
            };
            return (
              <div
                key={s.id}
                className={`group flex items-center gap-2 px-3 py-2 rounded-md text-sm mb-1 transition-colors ${
                  isActive
                    ? 'bg-[var(--accent)]/15 text-[var(--text-primary)]'
                    : 'text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)]'
                } ${isEditing ? '' : 'cursor-pointer'}`}
                onClick={() => { if (!isEditing) setActiveId(s.id); }}
                onDoubleClick={(e) => { e.stopPropagation(); if (!isEditing) beginEdit(); }}
              >
                <span
                  className={`inline-block w-2 h-2 rounded-full shrink-0 ${
                    isMain ? 'bg-[var(--accent)]' : 'bg-[var(--text-secondary)]/40'
                  }`}
                />
                {isEditing ? (
                  <input
                    autoFocus
                    type="text"
                    value={editingTitle}
                    onChange={(e) => setEditingTitle(e.target.value)}
                    onClick={(e) => e.stopPropagation()}
                    onKeyDown={(e) => {
                      if (e.key === 'Enter') { e.preventDefault(); commitEdit(); }
                      else if (e.key === 'Escape') { e.preventDefault(); cancelEdit(); }
                    }}
                    onBlur={commitEdit}
                    placeholder={displayTitle}
                    className="flex-1 min-w-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded px-1.5 py-0.5 text-sm text-[var(--text-primary)] focus:outline-none"
                  />
                ) : (
                  <>
                    <div className="truncate flex-1" title="Double-click to rename">
                      {displayTitle}
                    </div>
                    <button
                      onClick={(e) => { e.stopPropagation(); beginEdit(); }}
                      className="opacity-0 group-hover:opacity-100 text-[var(--text-secondary)] hover:text-[var(--accent)] text-xs leading-none px-1"
                      title="Rename"
                    >
                      ✎
                    </button>
                    {!isMain && (
                      <button
                        onClick={(e) => { e.stopPropagation(); deleteSession(s.id); }}
                        className="opacity-0 group-hover:opacity-100 text-[var(--text-secondary)] hover:text-red-400 text-base leading-none px-1"
                        title="Delete session"
                      >
                        ×
                      </button>
                    )}
                  </>
                )}
              </div>
            );
          })}
        </div>

        <WatchesPanel />

        <div className="px-4 py-3 border-t border-[var(--border)] text-xs text-[var(--text-secondary)] space-y-1">
          <button
            type="button"
            onClick={() => setMemoryOpen(true)}
            className="flex items-center gap-2 w-full text-left hover:text-[var(--text-primary)] transition-colors"
          >
            <span>Memory</span>
            <span
              className={`px-1.5 py-[1px] rounded text-[10px] uppercase tracking-wide border ${
                memory?.backend === 'temper'
                  ? 'border-green-500/60 text-green-400'
                  : memory?.backend === 'local'
                    ? 'border-[var(--accent)] text-[var(--accent)]'
                    : 'border-[var(--border)]'
              }`}
            >
              {memory?.backend ?? '…'}
            </span>
            <span className="ml-auto text-[10px] opacity-60">view →</span>
          </button>
          {memory && (
            <div className="text-[11px]">
              {memory.pinnedCount ?? 0} pinned · {memory.blocksCount ?? 0} blocks
            </div>
          )}
          <div className="text-[10px] italic pt-1 leading-snug">
            Simplified web chat — full UX lives in the browser extension.
          </div>
        </div>
      </aside>

      {memoryOpen && <MemoryDrawer onClose={() => setMemoryOpen(false)} />}

      {/* ─── Main pane ───────────────────────────────────── */}
      <main className="flex-1 flex flex-col min-w-0">
        <header className="border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
          <div className="min-w-0">
            <div className="text-sm font-medium truncate">
              {activeSession?.title ||
                (activeSession?.meta?.kind === 'main' ? 'Main conversation' : activeSession?.id) ||
                'No session'}
            </div>
            {activeSession && (
              <div className="text-[11px] text-[var(--text-secondary)]">
                {activeSession.provider || 'auto'} · {activeSession.model || 'default'}
              </div>
            )}
          </div>
          <button
            onClick={clearMessages}
            disabled={!activeId}
            className="text-xs px-3 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded-md hover:border-red-500/60 hover:text-red-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
          >
            Clear
          </button>
        </header>

        <div ref={scrollRef} className="flex-1 overflow-y-auto">
          <div className="max-w-3xl mx-auto px-6 py-6 space-y-6">
            {messages.length === 0 && !partial && !streaming && (
              <div className="text-center text-sm text-[var(--text-secondary)] mt-12">
                <div className="text-base mb-1">Start a conversation</div>
                <div className="text-xs">Type a message below. Markdown supported.</div>
              </div>
            )}
            {messages.map((m) => (
              <MessageView key={m.id} m={m} />
            ))}
            {partial && (
              <RoleBlock role="assistant">
                <MarkdownContent content={partial} linkify />
                <span className="inline-block w-2 h-3 ml-0.5 align-text-bottom bg-[var(--accent)] animate-pulse" />
              </RoleBlock>
            )}
            {streaming && !partial && (
              <RoleBlock role="assistant">
                <div className="text-sm text-[var(--text-secondary)] italic">thinking…</div>
              </RoleBlock>
            )}
            {error && (
              <div className="text-sm text-red-400 border border-red-500/30 bg-red-500/5 rounded-md p-3">
                {error}
              </div>
            )}
          </div>
        </div>

        {Object.keys(watchChips).length > 0 && (
          <div className="px-6 pt-2">
            <div className="max-w-3xl mx-auto flex flex-wrap gap-2">
              {Object.entries(watchChips).map(([id, w]) => (
                <span
                  key={id}
                  title={id}
                  className="inline-flex items-center gap-1.5 text-xs rounded-full border border-[var(--border)] bg-[var(--bg-secondary)] px-3 py-1 text-[var(--text-secondary)]"
                >
                  <span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
                  {w.text}
                </span>
              ))}
            </div>
          </div>
        )}

        {stopRequested && (
          <div className="border-t border-amber-500/40 bg-amber-500/10 text-amber-300 px-6 py-2 text-xs">
            ⏳ Stop requested — waiting for the current step to finish, then aborting…
          </div>
        )}

        <form
          className="border-t border-[var(--border)] px-6 py-4"
          onSubmit={(e) => { e.preventDefault(); if (streaming) addNote(); else send(); }}
        >
          <div className="max-w-3xl mx-auto flex items-end gap-3">
            <textarea
              ref={composerRef}
              value={input}
              onChange={(e) => setInput(e.target.value)}
              onKeyDown={(e) => {
                // Skip Enter while an IME composition is active — otherwise
                // pinyin/kana commit-with-Enter sends the message by mistake.
                // isComposing covers modern browsers; keyCode===229 is the
                // legacy fallback some IMEs still emit.
                if (e.nativeEvent.isComposing || e.keyCode === 229) return;
                if (e.key !== 'Enter') return;
                // Shift+Enter → newline (let the textarea handle it; never Send/Stop).
                if (e.shiftKey) return;
                // Plain Enter → Send. During streaming this routes the text
                // into the running turn (note). Either way, Enter NEVER stops.
                e.preventDefault();
                if (streaming) addNote(); else send();
              }}
              disabled={!activeId}
              placeholder={
                !activeId
                  ? 'Pick or create a session'
                  : streaming
                    ? 'Message…  (lands on the running task’s next step)'
                    : 'Message…  (Enter to send · Shift+Enter for newline)'
              }
              rows={1}
              style={{ fontFamily: SANS_FONT }}
              className="flex-1 resize-none bg-[var(--bg-tertiary)] border border-[var(--border)] rounded-lg px-4 py-3 text-sm text-[var(--text-primary)] leading-relaxed focus:outline-none focus:border-[var(--accent)] focus:ring-1 focus:ring-[var(--accent)]/40 disabled:opacity-50"
            />
            {/* Stop sits next to Send (mirrors the extension layout).
                While a turn is running, Send routes the text into THAT
                turn (a normal POST would spawn a second concurrent loop
                on the same session). Stop is type="button" so Enter on
                the textarea NEVER triggers it — only an explicit click
                here aborts. */}
            {streaming && (
              <button
                type="button"
                onClick={stop}
                disabled={!activeId || stopRequested}
                title={stopRequested ? 'Stop pending — waiting for current step to finish' : 'Stop the running turn'}
                className="px-4 py-2.5 text-sm font-medium border border-red-500/60 text-red-400 rounded-lg hover:bg-red-500/10 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
              >
                ■ Stop
              </button>
            )}
            <button
              type="submit"
              disabled={!input.trim() || !activeId}
              title={streaming ? 'Send — the running task reads it on its next step' : undefined}
              className="px-4 py-2.5 text-sm font-medium bg-[var(--accent)] text-white rounded-lg hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed transition-opacity"
            >
              Send
            </button>
          </div>
        </form>
      </main>
    </div>
  );
}

// ─── Message renderers ────────────────────────────────────

function fmtTs(ts?: number): string {
  if (!ts) return '';
  const d = new Date(ts);
  if (Number.isNaN(d.getTime())) return '';
  const now = new Date();
  const sameDay = d.toDateString() === now.toDateString();
  const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
  return sameDay ? time : `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${time}`;
}

function RoleBlock({ role, ts, pending, children }: { role: 'user' | 'assistant'; ts?: number; pending?: boolean; children: React.ReactNode }) {
  const isUser = role === 'user';
  // User → right-aligned with bubble; assistant → left-aligned (avatar +
  // expanded content for markdown / tool cards). Pending optimistic notes
  // get a pink wash that clears the moment the server's persisted version
  // replaces them on the loop's next iteration.
  if (isUser) {
    return (
      <div className="flex justify-end">
        <div className="max-w-[80%]">
          <div className="flex items-baseline gap-2 mb-1 justify-end">
            {pending ? (
              <span className="text-[10px] uppercase tracking-wide" style={{ color: '#f472b6' }}>pending</span>
            ) : null}
            {ts ? <span className="text-[10px] text-[var(--text-secondary)] opacity-60">{fmtTs(ts)}</span> : null}
            <span className="text-[11px] uppercase tracking-wide text-[var(--text-secondary)]">you</span>
          </div>
          <div
            className="space-y-2 rounded-lg px-3 py-2 border"
            style={
              // Pending: subtle pink tint over the dark theme bg + a
              // visible pink border. Text stays var(--text-primary) so
              // markdown rendering is readable. The tint vanishes once
              // the server-persisted message replaces the optimistic.
              pending
                ? { background: 'rgba(236, 72, 153, 0.10)', borderColor: 'rgba(236, 72, 153, 0.55)' }
                : { background: 'var(--bg-tertiary)', borderColor: 'var(--border)' }
            }
          >
            {children}
          </div>
        </div>
      </div>
    );
  }
  return (
    <div className="flex gap-4">
      <div
        className="shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold bg-[var(--bg-tertiary)] text-[var(--text-secondary)] border border-[var(--border)]"
        aria-hidden
      >
        AI
      </div>
      <div className="flex-1 min-w-0">
        <div className="flex items-baseline gap-2 mb-1">
          <span className="text-[11px] uppercase tracking-wide text-[var(--text-secondary)]">assistant</span>
          {ts ? <span className="text-[10px] text-[var(--text-secondary)] opacity-60">{fmtTs(ts)}</span> : null}
        </div>
        <div className="space-y-2">{children}</div>
      </div>
    </div>
  );
}

// Memoized — keystroke in the composer re-renders the page, and without
// memo every prior message re-runs its markdown parse → typing in a long
// chat became visibly laggy.
const MessageView = memo(function MessageView({ m }: { m: Message }) {
  const pending = typeof m.id === 'string' && m.id.startsWith('optimistic-note-');
  return (
    <RoleBlock role={m.role} ts={m.ts} pending={pending}>
      {m.blocks.map((b, i) => (
        <BlockView key={i} b={b} role={m.role} />
      ))}
      {m.error && (
        <div className="text-xs text-red-400 border border-red-500/30 bg-red-500/5 rounded-md p-2 mt-1">
          {m.error}
        </div>
      )}
    </RoleBlock>
  );
});

const BlockView = memo(function BlockView({ b, role }: { b: ContentBlock; role?: 'user' | 'assistant' }) {
  if (b.type === 'text') {
    return <MarkdownContent content={b.text} linkify={role === 'assistant'} />;
  }
  if (b.type === 'tool_use') {
    return <ToolUseBlockView name={b.name} input={b.input} />;
  }
  if (b.type === 'tool_result') {
    const txt = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
    return <ToolResultBlockView content={txt} isError={!!b.is_error} />;
  }
  return null;
});

function ToolUseBlockView({ name, input }: { name: string; input: unknown }) {
  const [open, setOpen] = useState(false);
  const preview = JSON.stringify(input);
  return (
    <div className="rounded-md border border-[var(--border)] bg-[var(--bg-tertiary)]/50 text-xs">
      <button
        type="button"
        onClick={() => setOpen((v) => !v)}
        className="w-full px-3 py-1.5 flex items-center gap-2 text-left hover:bg-[var(--bg-tertiary)] transition-colors"
      >
        <span className="text-[10px]">{open ? '▾' : '▸'}</span>
        <span className="text-[var(--accent)] font-mono">→ {name}</span>
        {!open && (
          <span className="text-[var(--text-secondary)] font-mono truncate flex-1">{preview}</span>
        )}
      </button>
      {open && (
        <pre className="px-3 pb-2 pt-1 text-[11px] font-mono text-[var(--text-secondary)] whitespace-pre-wrap break-words border-t border-[var(--border)] bg-[var(--bg-tertiary)]/30">
          {tryPrettyJson(preview)}
        </pre>
      )}
    </div>
  );
}

function ToolResultBlockView({ content, isError }: { content: string; isError: boolean }) {
  const [open, setOpen] = useState(false);
  const truncated = content.length > 400 ? content.slice(0, 400) + '…' : content;
  return (
    <div
      className={`rounded-md border text-xs ${
        isError
          ? 'border-red-500/40 bg-red-500/5'
          : 'border-[var(--border)] bg-[var(--bg-tertiary)]/30'
      }`}
    >
      <button
        type="button"
        onClick={() => setOpen((v) => !v)}
        className="w-full px-3 py-1.5 flex items-center gap-2 text-left hover:bg-[var(--bg-tertiary)] transition-colors"
      >
        <span className="text-[10px]">{open ? '▾' : '▸'}</span>
        <span className={isError ? 'text-red-400 font-mono' : 'text-[var(--text-secondary)] font-mono'}>
          {isError ? 'error result' : 'tool result'}
        </span>
        {!open && (
          <span className="text-[var(--text-secondary)] font-mono truncate flex-1">
            {truncated.replace(/\s+/g, ' ').slice(0, 100)}
          </span>
        )}
      </button>
      {open && (
        <pre
          className={`px-3 pb-2 pt-1 text-[11px] font-mono whitespace-pre-wrap break-words border-t ${
            isError
              ? 'border-red-500/30 text-red-400'
              : 'border-[var(--border)] text-[var(--text-secondary)]'
          }`}
        >
          {content.length > 4000 ? content.slice(0, 4000) + '\n…(truncated)' : content}
        </pre>
      )}
    </div>
  );
}

function tryPrettyJson(s: string): string {
  try {
    return JSON.stringify(JSON.parse(s), null, 2);
  } catch {
    return s;
  }
}

// ─── Memory drawer ───────────────────────────────────────────
//
// Inspector for whatever the active memory backend (Temper / Local)
// has. Read-only: search + list + click-to-expand JSON. The internal
// summarizer bookkeeping (cursor / health) is hidden by default to
// reduce noise; toggle reveals it.

const INTERNAL_PREFIXES = ['forge.summarizer.cursor:', 'forge.summarizer.health:'];

interface MemoryBlockRow {
  key: string;
  value: unknown;
  pinned?: boolean;
  description?: string;
  scope?: string;
}

interface MemoryHitRow {
  id: string;
  kind: string;
  fact?: string;
  score?: number;
  valid_at?: string | null;
}

interface MemoryBlocksResponse {
  backend: 'temper' | 'local';
  enabled: boolean;
  blocks: MemoryBlockRow[];
  hits: MemoryHitRow[];
  query?: string;
}

function MemoryDrawer({ onClose }: { onClose: () => void }) {
  const [data, setData] = useState<MemoryBlocksResponse | null>(null);
  const [loading, setLoading] = useState(false);
  const [err, setErr] = useState('');
  const [q, setQ] = useState('');
  const [showInternal, setShowInternal] = useState(false);
  const [expanded, setExpanded] = useState<Record<string, boolean>>({});

  const fetchBlocks = useCallback(async (query: string) => {
    setLoading(true);
    setErr('');
    try {
      const url = query
        ? `/api/memory/blocks?q=${encodeURIComponent(query)}&limit=300`
        : `/api/memory/blocks?limit=300`;
      const r = await fetch(url);
      if (!r.ok) throw new Error(`${r.status}`);
      const j = (await r.json()) as MemoryBlocksResponse;
      setData(j);
    } catch (e) {
      setErr(e instanceof Error ? e.message : String(e));
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => { fetchBlocks(''); }, [fetchBlocks]);

  // Debounce search input → API
  useEffect(() => {
    const t = setTimeout(() => fetchBlocks(q.trim()), 250);
    return () => clearTimeout(t);
  }, [q, fetchBlocks]);

  const visibleBlocks = useMemo(() => {
    const all = data?.blocks ?? [];
    if (showInternal) return all;
    return all.filter((b) => !INTERNAL_PREFIXES.some((p) => b.key.startsWith(p)));
  }, [data, showInternal]);

  const visibleHits = data?.hits ?? [];

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
      <div
        className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg w-[720px] max-w-[95vw] max-h-[85vh] flex flex-col shadow-xl"
        onClick={(e) => e.stopPropagation()}
        style={{ fontFamily: SANS_FONT }}
      >
        <div className="px-4 py-3 border-b border-[var(--border)] flex items-center gap-3">
          <h2 className="text-sm font-bold text-[var(--text-primary)]">Memory</h2>
          {data && (
            <span
              className={`px-1.5 py-[1px] rounded text-[10px] uppercase tracking-wide border ${
                data.backend === 'temper'
                  ? 'border-green-500/60 text-green-400'
                  : 'border-[var(--accent)] text-[var(--accent)]'
              }`}
            >
              {data.backend}
            </span>
          )}
          <input
            value={q}
            onChange={(e) => setQ(e.target.value)}
            placeholder="search…"
            className="flex-1 bg-[var(--bg-primary)] border border-[var(--border)] rounded px-2 py-1 text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
          />
          <label className="flex items-center gap-1 text-[10px] text-[var(--text-secondary)]">
            <input
              type="checkbox"
              checked={showInternal}
              onChange={(e) => setShowInternal(e.target.checked)}
              className="accent-[var(--accent)]"
            />
            show internal
          </label>
          <button onClick={onClose} className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]">Close</button>
        </div>

        <div className="flex-1 overflow-y-auto">
          {loading && <div className="px-4 py-6 text-xs text-[var(--text-secondary)]">Loading…</div>}
          {err && <div className="px-4 py-6 text-xs text-red-400">Error: {err}</div>}

          {visibleHits.length > 0 && (
            <div className="px-4 py-2 border-b border-[var(--border)]">
              <div className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)] mb-1">
                Search hits ({visibleHits.length})
              </div>
              <div className="space-y-1">
                {visibleHits.slice(0, 20).map((h) => (
                  <div key={h.id} className="text-[11px] text-[var(--text-primary)]">
                    <span className="text-[var(--text-secondary)] font-mono mr-2">{h.id}</span>
                    {h.fact || '(no fact)'}
                  </div>
                ))}
              </div>
            </div>
          )}

          <div className="px-2 py-1 text-[10px] uppercase tracking-wide text-[var(--text-secondary)] sticky top-0 bg-[var(--bg-secondary)]">
            Blocks ({visibleBlocks.length}{data && data.blocks.length !== visibleBlocks.length ? ` of ${data.blocks.length}` : ''})
          </div>
          {visibleBlocks.length === 0 && !loading && (
            <div className="px-4 py-6 text-xs text-[var(--text-secondary)] italic">No blocks{q ? ' match' : ''}.</div>
          )}
          {visibleBlocks.map((b) => {
            const isOpen = !!expanded[b.key];
            const valStr = typeof b.value === 'string' ? b.value : JSON.stringify(b.value);
            const preview = valStr.length > 140 ? valStr.slice(0, 140) + '…' : valStr;
            return (
              <div key={b.key} className="border-b border-[var(--border)]">
                <button
                  type="button"
                  onClick={() => setExpanded((s) => ({ ...s, [b.key]: !isOpen }))}
                  className="w-full text-left px-3 py-2 hover:bg-[var(--bg-primary)] transition-colors"
                >
                  <div className="flex items-baseline gap-2">
                    <span className="text-[11px] font-mono text-[var(--accent)] truncate flex-1">{b.key}</span>
                    {b.pinned && <span className="text-[9px] text-yellow-400">📌</span>}
                  </div>
                  <div className="text-[11px] text-[var(--text-secondary)] mt-0.5 truncate">
                    {preview}
                  </div>
                  {b.description && (
                    <div className="text-[10px] text-gray-500 italic mt-0.5 truncate">{b.description}</div>
                  )}
                </button>
                {isOpen && (
                  <pre className="px-3 pb-3 text-[10px] font-mono whitespace-pre-wrap break-words text-[var(--text-secondary)]">
                    {tryPrettyJson(valStr)}
                  </pre>
                )}
              </div>
            );
          })}
        </div>

        <div className="px-4 py-2 border-t border-[var(--border)] text-[10px] text-[var(--text-secondary)]">
          Read-only. Edit / delete via Settings → Memory (Temper UI for KG).
        </div>
      </div>
    </div>
  );
}
