import { useState, useRef, useEffect } from "react";

import { API_BASE } from "./config";
import { Markdown } from "./Markdown";
import { diffLines, DIFF_LINE_COLOR, keyValueDiffLines } from "./preview/diff-view.js";

export function Chatbot({ visible = true, conversationId, setConversationId, onConversationChanged }) {
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState("");
  const [loading, setLoading] = useState(false);
  const [modelList, setModelList] = useState([]);
  const [selectedModel, setSelectedModel] = useState(""); // "" = auto
  const [autoModel, setAutoModel] = useState(null);
  const [think, setThink] = useState(false); // extended thinking, mirrors Settings
  const [showPicker, setShowPicker] = useState(false);
  const [copiedIdx, setCopiedIdx] = useState(null);
  const [lastStats, setLastStats] = useState(null);
  const [memoryToast, setMemoryToast] = useState(null);
  const [images, setImages] = useState([]); // {name, b64} for vision models
  const [activeTool, setActiveTool] = useState(null); // playbook tool currently running
  const [pendingApproval, setPendingApproval] = useState(null); // [{name, arguments}] awaiting yes/no
  const [approvalToken, setApprovalToken] = useState(null); // single-use token authorizing /chat/approve
  const [editingIdx, setEditingIdx] = useState(null); // user message being edited
  const [editText, setEditText] = useState("");
  const [listening, setListening] = useState(false); // mic dictation active
  const [transcribing, setTranscribing] = useState(false); // local STT running
  const [sttLocal, setSttLocal] = useState(false); // backend Whisper available
  const [speakingIdx, setSpeakingIdx] = useState(null); // message being read aloud
  const recognitionRef = useRef(null);
  const mediaRecRef = useRef(null);

  // Web Speech API — browser-native, no backend/model. Absent on unsupported browsers.
  const SpeechRec = typeof window !== "undefined" && (window.SpeechRecognition || window.webkitSpeechRecognition);
  const ttsSupported = typeof window !== "undefined" && "speechSynthesis" in window;
  const canRecord = typeof navigator !== "undefined" && navigator.mediaDevices && window.MediaRecorder;

  // Prefer local Whisper (on-device) over browser speech (Chrome routes audio to Google).
  useEffect(() => {
    fetch(`${API_BASE}/stt/status`).then(r => r.ok ? r.json() : null)
      .then(d => setSttLocal(Boolean(d && d.available))).catch(() => {});
  }, []);

  const _appendTranscript = (text) => {
    if (text) setInput(prev => (prev ? prev + " " : "") + text);
  };

  // Local path: record audio, POST to /stt (faster-whisper transcribes on-device).
  const startLocalDictation = async () => {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      const rec = new MediaRecorder(stream);
      const chunks = [];
      rec.ondataavailable = e => e.data.size && chunks.push(e.data);
      rec.onstop = async () => {
        stream.getTracks().forEach(t => t.stop());
        setListening(false);
        setTranscribing(true);
        try {
          const b64 = await new Promise((res) => {
            const fr = new FileReader();
            fr.onload = () => res(String(fr.result).split(",")[1]);
            fr.readAsDataURL(new Blob(chunks, { type: rec.mimeType }));
          });
          const r = await fetch(`${API_BASE}/stt`, {
            method: "POST", headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ audio: b64 }),
          });
          if (r.ok) _appendTranscript((await r.json()).text);
        } catch { /* ignore */ } finally {
          setTranscribing(false);
        }
      };
      mediaRecRef.current = rec;
      setListening(true);
      rec.start();
    } catch { setListening(false); }
  };

  const startBrowserDictation = () => {
    const rec = new SpeechRec();
    rec.lang = "en-US";
    rec.interimResults = false;
    rec.onresult = (e) => _appendTranscript(Array.from(e.results).map(r => r[0].transcript).join(" ").trim());
    rec.onend = () => setListening(false);
    rec.onerror = () => setListening(false);
    recognitionRef.current = rec;
    setListening(true);
    rec.start();
  };

  const toggleMic = () => {
    if (transcribing) return;
    if (listening) {
      if (sttLocal && mediaRecRef.current) mediaRecRef.current.stop();
      else recognitionRef.current?.stop();
      return;
    }
    if (sttLocal && canRecord) startLocalDictation();
    else if (SpeechRec) startBrowserDictation();
  };

  // Read a reply aloud (light markdown strip so symbols aren't spoken).
  const speak = (idx, text) => {
    if (!ttsSupported) return;
    window.speechSynthesis.cancel();
    if (speakingIdx === idx) { setSpeakingIdx(null); return; }
    const clean = text.replace(/[*_`#>]/g, "").replace(/\[(.*?)\]\(.*?\)/g, "$1");
    const u = new SpeechSynthesisUtterance(clean);
    u.onend = () => setSpeakingIdx(null);
    setSpeakingIdx(idx);
    window.speechSynthesis.speak(u);
  };

  const abortRef = useRef(null);
  const messagesEndRef = useRef(null);
  const pickerRef = useRef(null);

  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  useEffect(() => {
    if (!conversationId) return;
    if (abortRef.current) abortRef.current.abort();
    let cancelled = false;
    setLastStats(null);
    fetch(`${API_BASE}/conversations/${conversationId}`)
      .then(r => r.ok ? r.json() : null)
      .then(data => {
        if (cancelled) return;
        setMessages(data?.messages?.map(m => ({ role: m.role, content: m.content })) || []);
      })
      .catch(() => { if (!cancelled) setMessages([]); });
    return () => { cancelled = true; };
  }, [conversationId]);

  // Keyed on `visible`, not []: this component stays mounted while other pages
  // show (App hides it with display:none so an in-flight reply survives
  // navigation), so a mount-once fetch left the picker showing whatever was
  // installed when the tab first opened - a model pulled on the Models page
  // didn't appear here until a full browser reload.
  useEffect(() => {
    if (!visible) return;
    Promise.all([
      fetch(`${API_BASE}/models`).then(r => r.ok ? r.json() : null),
      fetch(`${API_BASE}/settings`).then(r => r.ok ? r.json() : null),
    ]).then(([models, settings]) => {
      if (models?.models) setModelList(models.models);
      if (models?.selected) setAutoModel(models.selected);
      if (settings) {
        setSelectedModel(settings.model || "");
        setThink(!!settings.think);
      }
    }).catch(() => {});
   }, [visible]);

  useEffect(() => {
    if (!showPicker) return;
    const handle = (e) => {
      if (pickerRef.current && !pickerRef.current.contains(e.target)) setShowPicker(false);
    };
    document.addEventListener("mousedown", handle);
    return () => document.removeEventListener("mousedown", handle);
  }, [showPicker]);

  // Declared ahead of sendMessage: it is called from there, and a const arrow
  // defined further down is still in the TDZ as far as the linter is concerned.
  const updateAssistant = (index, text) => {
    setMessages(prev => {
      const updated = [...prev];
      updated[index] = { ...updated[index], content: text };
      return updated;
    });
  };

  const setModelChoice = async (model) => {
    setSelectedModel(model);
    setShowPicker(false);
    try {
      await fetch(`${API_BASE}/settings`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ model }),
      });
    } catch { /* persisting the model choice is best-effort */ }
  };

  const toggleThink = async () => {
    const next = !think;
    setThink(next);
    try {
      await fetch(`${API_BASE}/settings`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ think: next }),
      });
    } catch { /* persisting the toggle is best-effort */ }
  };

  const startNewChat = () => {
    if (abortRef.current) abortRef.current.abort();
    setInput("");
    setLoading(false);
    setLastStats(null);
    setConversationId(crypto.randomUUID());
  };

  const onImagePick = (e) => {
    const files = Array.from(e.target.files || []);
    files.forEach(file => {
      const reader = new FileReader();
      reader.onload = () => {
        const b64 = String(reader.result || "").split(",")[1]; // strip data: prefix
        if (b64) setImages(prev => [...prev, { name: file.name, b64 }]);
      };
      reader.readAsDataURL(file);
    });
    e.target.value = "";
  };

  // Shared streaming core: POST /chat/stream and fold tokens into the assistant
  // message at `assistantIndex`. Used by send, regenerate, and edit-and-resend.
  const streamAssistant = async ({ message, history, images: imgs, assistantIndex }) => {
    setLoading(true);

    // Abort previous stream if still open
    if (abortRef.current) abortRef.current.abort();
    const controller = new AbortController();
    abortRef.current = controller;

    try {
      const response = await fetch(`${API_BASE}/chat/stream`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          message,
          conversation_id: conversationId,
          history,
          think,
          ...(imgs && imgs.length ? { images: imgs } : {}),
        }),
        signal: controller.signal,
      });

      if (!response.ok) {
        const text = await response.text();
        updateAssistant(assistantIndex, `Error: ${text}`);
        setLoading(false);
        return;
      }

      const reader = response.body.getReader();
      const decoder = new TextDecoder();

      let buffer = "";
      let pendingEventType = null;

      while (true) {
        const { value, done } = await reader.read();
        if (done) break;

        buffer += decoder.decode(value, { stream: true });

        const lines = buffer.split("\n");
        buffer = lines.pop();

        for (const line of lines) {
          if (line.startsWith("event: ")) {
            pendingEventType = line.slice(7).trim();
            continue;
          }
          if (line.startsWith("data: ")) {
            const payload = line.slice(6);
            if (!payload.trim()) { pendingEventType = null; continue; }

            if (pendingEventType === "meta") {
              try {
                const stats = JSON.parse(payload);
                setLastStats(stats);
                // Tag this message with the model that answered
                if (stats.model) {
                  setMessages(prev => {
                    const updated = [...prev];
                    updated[assistantIndex] = { ...updated[assistantIndex], model: stats.model };
                    return updated;
                  });
                }
              } catch { /* ignore */ }
              pendingEventType = null;
              continue;
            }
            if (pendingEventType === "memory") {
              try {
                const mem = JSON.parse(payload);
                setMemoryToast(mem);
                setTimeout(() => setMemoryToast(null), 5000);
              } catch { /* ignore */ }
              pendingEventType = null;
              continue;
            }
            if (pendingEventType === "status") {
              try { setActiveTool(JSON.parse(payload).tool); } catch { /* ignore */ }
              pendingEventType = null;
              continue;
            }
            if (pendingEventType === "tool_request") {
              try {
                const parsed = JSON.parse(payload);
                setPendingApproval(parsed.actions || []);
                setApprovalToken(parsed.token || null);
              } catch { /* ignore */ }
              pendingEventType = null;
              continue;
            }
            if (pendingEventType === "sources") {
              try {
                const src = JSON.parse(payload).sources;
                setMessages(prev => {
                  const updated = [...prev];
                  updated[assistantIndex] = { ...updated[assistantIndex], sources: src };
                  return updated;
                });
              } catch { /* ignore */ }
              pendingEventType = null;
              continue;
            }
            if (pendingEventType === "done") {
              // Answer is complete; re-enable input while the backend finishes
              // slow post-processing (title, memory) on the still-open stream.
              setLoading(false);
              setActiveTool(null);
              setPendingApproval(null);
              pendingEventType = null;
              continue;
            }
            if (pendingEventType === "title") {
              if (onConversationChanged) onConversationChanged();
              pendingEventType = null;
              continue;
            }
            if (pendingEventType === "error") {
              try {
                const err = JSON.parse(payload);
                updateAssistant(assistantIndex, `Error: ${err.detail || payload}`);
              } catch {
                updateAssistant(assistantIndex, `Error: ${payload}`);
              }
              pendingEventType = null;
              setLoading(false);
              return;
            }
            pendingEventType = null;

            let token = payload;
            try { token = JSON.parse(payload); } catch { /* plain text fallback */ }

            if (activeTool) setActiveTool(null); // tokens started -> tools done
            if (pendingApproval) setPendingApproval(null);
            setMessages(prev => {
              const updated = [...prev];
              updated[assistantIndex] = {
                ...updated[assistantIndex],
                content: (updated[assistantIndex].content || "") + token,
              };
              return updated;
            });
          }
        }
      }
      
    } catch (err) {
      if (err.name !== "AbortError") {
        updateAssistant(assistantIndex, `Connection error: ${err.message}`);
      }
    } finally {
      setLoading(false);
      if (onConversationChanged) onConversationChanged();
    }
  };

  const histBefore = (index) =>
    messages.slice(0, index).filter(m => m.content.trim() !== "")
      .map(m => ({ role: m.role, content: m.content }));

  const sendMessage = async () => {
    if ((!input.trim() && images.length === 0) || loading) return;

    const userMessage = input.trim();
    const outImages = images.map(i => i.b64);
    setInput("");
    setImages([]);

    // Note any attached images so image-only turns aren't blank
    const shownContent = outImages.length
      ? `${userMessage}${userMessage ? "\n\n" : ""}📷 ${outImages.length} image${outImages.length === 1 ? "" : "s"} attached`
      : userMessage;
    const history = histBefore(messages.length);
    const assistantIndex = messages.length + 1;
    setMessages(prev => [...prev, { role: "user", content: shownContent }, { role: "assistant", content: "" }]);
    await streamAssistant({ message: userMessage, history, images: outImages, assistantIndex });
  };

  const regenerate = async () => {
    if (loading) return;
    const lastUserIdx = messages.map(m => m.role).lastIndexOf("user");
    if (lastUserIdx < 0) return;
    const userMessage = messages[lastUserIdx].content;
    const history = histBefore(lastUserIdx);
    const base = messages.slice(0, lastUserIdx + 1); // drop the old assistant reply
    setMessages([...base, { role: "assistant", content: "" }]);
    await streamAssistant({ message: userMessage, history, images: [], assistantIndex: base.length });
  };

  const editAndResend = async (index, newText) => {
    if (loading || !newText.trim()) return;
    const history = histBefore(index);
    const base = [...messages.slice(0, index), { role: "user", content: newText.trim() }];
    setEditingIdx(null);
    setMessages([...base, { role: "assistant", content: "" }]);
    await streamAssistant({ message: newText.trim(), history, images: [], assistantIndex: base.length });
  };

  // Approve or deny the pending action tool(s); the open chat stream resumes.
  const resolveApproval = async (approve) => {
    const req = pendingApproval || [];
    const token = approvalToken;
    setPendingApproval(null);
    setApprovalToken(null);
    const decisions = {};
    req.forEach(a => { decisions[a.name] = approve; });
    try {
      await fetch(`${API_BASE}/chat/approve`, {
        method: "POST", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ conversation_id: conversationId, token, decisions }),
      });
    } catch { /* ignore */ }
  };

  const stopGeneration = () => {
    if (abortRef.current) abortRef.current.abort();
    setLoading(false);
    setActiveTool(null);
    if (pendingApproval) resolveApproval(false); // stopping = deny pending actions
  };

  const handleKeyDown = (e) => {
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      sendMessage();
    }
  };

  return (
    <div style={{ display: "flex", flexDirection: "column", height: "100%", flexGrow: 1 }}>
      {memoryToast && (
        <div style={{
          position: "fixed", bottom: "1.5rem", right: "1.5rem",
          background: "#1a1a2e", border: "1px solid #7c3aed",
          borderRadius: "8px", padding: "0.6rem 1rem",
          fontSize: "0.85rem", color: "#c4b5fd",
          boxShadow: "0 4px 12px rgba(0,0,0,0.5)", zIndex: 9999,
          maxWidth: "320px"
        }}>
          🧠 <strong>Memory saved</strong> [{memoryToast.section}]<br />
          <span style={{ color: "#aaa", fontSize: "0.8rem" }}>{memoryToast.text}</span>
        </div>
      )}
      <div style={{
        flexGrow: 1,
        background: "#161616",
        border: "1px solid #333",
        borderRadius: "12px",
        padding: "1rem",
        display: "flex",
        flexDirection: "column",
        overflow: "hidden"
      }}>
        {/* Header */}
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "0.75rem" }}>
          <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
            <span style={{ fontSize: "0.85rem", color: "#555" }}>
              {messages.length === 0 ? "New conversation" : `${Math.ceil(messages.length / 2)} exchange${messages.length > 2 ? "s" : ""}`}
            </span>
            <div ref={pickerRef} style={{ position: "relative" }}>
              <button
                onClick={() => setShowPicker(p => !p)}
                style={{
                  fontSize: "0.7rem",
                  color: "#7aa",
                  background: showPicker ? "#1e2e2e" : "#1a2a2a",
                  border: "1px solid #2a4a4a",
                  borderRadius: "4px",
                  padding: "0.15rem 0.5rem",
                  cursor: "pointer",
                  letterSpacing: "0.03em",
                  display: "flex",
                  alignItems: "center",
                  gap: "0.3rem",
                }}
              >
                {!selectedModel && <span style={{ color: "#4a6a6a" }}>auto ·</span>}
                {selectedModel || autoModel || "no model"}
                <span style={{ color: "#4a7a7a", fontSize: "0.6rem" }}>▾</span>
              </button>
              {showPicker && (
                <div style={{
                  position: "absolute",
                  top: "calc(100% + 4px)",
                  left: 0,
                  background: "#1a1a1a",
                  border: "1px solid #333",
                  borderRadius: "8px",
                  zIndex: 100,
                  minWidth: "200px",
                  maxHeight: "240px",
                  overflowY: "auto",
                  boxShadow: "0 4px 16px rgba(0,0,0,0.6)",
                }}>
                  <div
                    onClick={() => setModelChoice("")}
                    style={{
                      padding: "0.55rem 0.85rem",
                      cursor: "pointer",
                      fontSize: "0.8rem",
                      color: !selectedModel ? "#7aa" : "#888",
                      background: !selectedModel ? "#1a2a2a" : "transparent",
                      borderBottom: "1px solid #262626",
                      display: "flex",
                      justifyContent: "space-between",
                      alignItems: "center",
                    }}
                  >
                    <span>Auto <span style={{ color: "#445", fontSize: "0.7rem" }}>{autoModel ? `(${autoModel})` : ""}</span></span>
                    {!selectedModel && <span>✓</span>}
                  </div>
                  {modelList.map(m => (
                    <div
                      key={m}
                      onClick={() => setModelChoice(m)}
                      style={{
                        padding: "0.55rem 0.85rem",
                        cursor: "pointer",
                        fontSize: "0.8rem",
                        color: selectedModel === m ? "#7aa" : "#ccc",
                        background: selectedModel === m ? "#1a2a2a" : "transparent",
                        display: "flex",
                        justifyContent: "space-between",
                        alignItems: "center",
                      }}
                    >
                      <span>{m}</span>
                      {selectedModel === m && <span>✓</span>}
                    </div>
                  ))}
                </div>
              )}
            </div>
            <button
              onClick={toggleThink}
              title={think ? "Extended thinking is on — click to turn off" : "Extended thinking is off — click to turn on"}
              style={{
                fontSize: "0.7rem",
                color: think ? "#c4b5fd" : "#888",
                background: think ? "#1e1a2e" : "#1a1a1a",
                border: "1px solid " + (think ? "#7c3aed" : "#2a2a2a"),
                borderRadius: "4px",
                padding: "0.15rem 0.5rem",
                cursor: "pointer",
                letterSpacing: "0.03em",
                display: "flex",
                alignItems: "center",
                gap: "0.3rem",
              }}
            >
              🧠 Think
              <span style={{ fontSize: "0.6rem", color: think ? "#8aff8a" : "#555" }}>
                {think ? "on" : "off"}
              </span>
            </button>
            {lastStats && (
              <span style={{
                fontSize: "0.7rem",
                color: "#888",
                background: "#1a1a1a",
                border: "1px solid #2a2a2a",
                borderRadius: "4px",
                padding: "0.15rem 0.45rem",
              }}>
                {lastStats.tokens} tok · {lastStats.elapsed_s}s
                {lastStats.tokens_per_s > 0 ? ` · ${lastStats.tokens_per_s} t/s` : ""}
              </span>
            )}
          </div>
          <button
            onClick={startNewChat}
            disabled={loading}
            style={{
              padding: "0.4rem 0.85rem",
              background: "#222",
              color: "#aaa",
              border: "1px solid #444",
              borderRadius: "6px",
              cursor: loading ? "default" : "pointer",
              fontSize: "0.8rem",
              opacity: loading ? 0.5 : 1,
            }}
          >
            New Chat
          </button>
        </div>

        <div style={{
          flexGrow: 1,
          overflowY: "auto",
          marginBottom: "1rem",
          paddingRight: "0.5rem"
        }}>
          {messages.length === 0 ? (
            <div style={{ color: "#666", textAlign: "center", paddingTop: "2rem" }}>
              <p>Start a conversation with the chatbot...</p>
            </div>
          ) : (
            messages.map((msg, idx) => (
              <div
                key={idx}
                style={{
                  marginBottom: "1rem",
                  display: "flex",
                  flexDirection: "column",
                  alignItems: msg.role === "user" ? "flex-end" : "flex-start",
                }}
              >
                <div
                  style={{
                    maxWidth: msg.role === "user" ? "70%" : "90%",
                    width: msg.role === "assistant" ? "100%" : undefined,
                    padding: "0.9rem",
                    borderRadius: "10px",
                    background: msg.role === "user" ? "#007acc" : "#262626",
                    color: "#eee",
                    wordWrap: "break-word",
                    boxSizing: "border-box",
                  }}
                >
                  {msg.role === "user" && editingIdx === idx ? (
                    <div style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}>
                      <textarea
                        value={editText}
                        onChange={e => setEditText(e.target.value)}
                        rows={3}
                        style={{ width: "100%", padding: "0.5rem", background: "#0a3a5c", color: "#fff", border: "1px solid #0099ff", borderRadius: "8px", resize: "vertical", boxSizing: "border-box" }}
                      />
                      <div style={{ display: "flex", gap: "0.4rem", justifyContent: "flex-end" }}>
                        <button onClick={() => editAndResend(idx, editText)} disabled={loading || !editText.trim()}
                          style={{ padding: "0.3rem 0.7rem", fontSize: "0.8rem", background: "#0099ff", color: "#fff", border: "none", borderRadius: "6px", cursor: "pointer" }}>
                          Save &amp; resend
                        </button>
                        <button onClick={() => setEditingIdx(null)}
                          style={{ padding: "0.3rem 0.7rem", fontSize: "0.8rem", background: "transparent", color: "#cce", border: "1px solid #0099ff", borderRadius: "6px", cursor: "pointer" }}>
                          Cancel
                        </button>
                      </div>
                    </div>
                  ) : msg.role === "user"
                    ? <span style={{ whiteSpace: "pre-wrap" }}>{msg.content}</span>
                      : msg.content
                      ? <Markdown content={msg.content} />
                      : <span style={{ color: "#aaa" }}>Thinking...</span>
                  }
                </div>
                {msg.sources && msg.sources.length > 0 && (
                  <div style={{ marginTop: "0.35rem", display: "flex", flexWrap: "wrap", gap: "0.35rem", alignItems: "center" }}>
                    <span style={{ fontSize: "0.7rem", color: "#777" }}>Sources:</span>
                    {msg.sources.map((s, i) => (
                      <span key={i} style={{ fontSize: "0.72rem", color: "#8ab4ff", background: "#1a2433", border: "1px solid #2a3a52", borderRadius: "6px", padding: "0.1rem 0.45rem" }}>
                        📄 {s}
                      </span>
                    ))}
                  </div>
                )}
                {msg.content && editingIdx !== idx && (
                  <div style={{ display: "flex", gap: "0.25rem", marginTop: "0.25rem" }}>
                    <button
                      onClick={() => {
                        navigator.clipboard.writeText(msg.content).then(() => {
                          setCopiedIdx(idx);
                          setTimeout(() => setCopiedIdx(null), 1500);
                        });
                      }}
                      style={{ padding: "0.15rem 0.5rem", fontSize: "0.7rem", color: copiedIdx === idx ? "#4caf50" : "#555", background: "transparent", border: "none", cursor: "pointer" }}
                    >
                      {copiedIdx === idx ? "Copied!" : "Copy"}
                    </button>
                    {msg.role === "user" && !loading && (
                      <button
                        onClick={() => { setEditingIdx(idx); setEditText(msg.content); }}
                        style={{ padding: "0.15rem 0.5rem", fontSize: "0.7rem", color: "#555", background: "transparent", border: "none", cursor: "pointer" }}
                      >
                        Edit
                      </button>
                    )}
                    {msg.role === "assistant" && !loading && idx === messages.length - 1 && (
                      <button
                        onClick={regenerate}
                        style={{ padding: "0.15rem 0.5rem", fontSize: "0.7rem", color: "#555", background: "transparent", border: "none", cursor: "pointer" }}
                      >
                        ↻ Regenerate
                      </button>
                    )}
                    {msg.role === "assistant" && ttsSupported && (
                      <button
                        onClick={() => speak(idx, msg.content)}
                        title={speakingIdx === idx ? "Stop" : "Read aloud"}
                        style={{ padding: "0.15rem 0.5rem", fontSize: "0.7rem", color: speakingIdx === idx ? "#4caf50" : "#555", background: "transparent", border: "none", cursor: "pointer" }}
                      >
                        {speakingIdx === idx ? "🔊 Stop" : "🔊 Speak"}
                      </button>
                    )}
                  </div>
                )}
              </div>
            ))
          )}

<div ref={messagesEndRef} />
        </div>

        {pendingApproval && (
          <div style={{ marginBottom: "0.5rem", padding: "0.7rem 0.9rem", background: "#2a2418", border: "1px solid #6a5a2a", borderRadius: "10px" }}>
            <div style={{ color: "#e8c65a", fontSize: "0.9rem", marginBottom: "0.5rem" }}>
              ⚠️ The assistant wants to run:
            </div>
            {pendingApproval.map((a, i) =>
              a.preview
                ? <ActionPreview key={i} action={a} />
                : (
                  <code key={i} style={{ display: "inline-block", color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px", marginRight: "0.35rem", marginBottom: "0.4rem" }}>
                    {a.name}({a.arguments ? Object.values(a.arguments).join(", ") : ""})
                  </code>
                )
            )}
            <div style={{ display: "flex", gap: "0.5rem" }}>
              <button onClick={() => resolveApproval(true)}
                style={{ padding: "0.4rem 1rem", background: "#2a5a2a", color: "#8aff8a", border: "1px solid #3a7a3a", borderRadius: "8px", cursor: "pointer" }}>
                Approve
              </button>
              <button onClick={() => resolveApproval(false)}
                style={{ padding: "0.4rem 1rem", background: "#3a1a1a", color: "#ff8a80", border: "1px solid #5a2a2a", borderRadius: "8px", cursor: "pointer" }}>
                Deny
              </button>
            </div>
          </div>
        )}
        {activeTool && (
          <div style={{ marginBottom: "0.5rem", color: "#8ab4ff", fontSize: "0.9rem" }}>
            🔧 running tool: {activeTool}…
          </div>
        )}
        {images.length > 0 && (
          <div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginBottom: "0.5rem" }}>
            {images.map((img, i) => (
              <span key={i} style={{ display: "inline-flex", alignItems: "center", gap: "0.4rem", padding: "0.35rem 0.6rem", background: "#1a1a1a", border: "1px solid #2a2a2a", borderRadius: "8px", color: "#ccc", fontSize: "0.85rem" }}>
                📷 {img.name}
                <button onClick={() => setImages(prev => prev.filter((_, j) => j !== i))}
                  style={{ background: "none", border: "none", color: "#ff8a80", cursor: "pointer", padding: 0 }}>✕</button>
              </span>
            ))}
          </div>
        )}
        <div style={{ display: "flex", gap: "0.75rem", alignItems: "stretch" }}>
          <label title="Attach image (needs a vision model)"
            style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: "0 0.9rem", background: "#222", border: "1px solid #333", borderRadius: "10px", cursor: loading ? "default" : "pointer", opacity: loading ? 0.6 : 1, color: "#ccc" }}>
            📎
            <input type="file" accept="image/*" multiple onChange={onImagePick} disabled={loading} style={{ display: "none" }} />
          </label>
          {((sttLocal && canRecord) || SpeechRec) && (
            <button onClick={toggleMic}
              title={transcribing ? "Transcribing…" : listening ? "Stop dictation" : sttLocal ? "Dictate (on-device Whisper)" : "Dictate (browser speech)"}
              style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: "0 0.9rem", background: listening ? "#3a1a1a" : "#222", border: "1px solid " + (listening ? "#c0392b" : "#333"), borderRadius: "10px", cursor: transcribing ? "default" : "pointer", color: listening ? "#ff8a80" : "#ccc" }}>
              {transcribing ? "⏳" : listening ? "🔴" : "🎤"}
            </button>
          )}
          <textarea
            value={input}
            onChange={e => setInput(e.target.value)}
            onKeyDown={handleKeyDown}
            placeholder="Type your message... (Shift+Enter for new line)"
            disabled={loading}
            rows={3}
            style={{
              flexGrow: 1,
              padding: "0.9rem",
              background: "#222",
              color: "#eee",
              border: "1px solid #333",
              borderRadius: "10px",
              fontFamily: "system-ui",
              resize: "none",
              opacity: loading ? 0.6 : 1
            }}
          />
          <button
            onClick={loading ? stopGeneration : sendMessage}
            disabled={!loading && !input.trim() && images.length === 0}
            style={{
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              padding: "0 1.5rem",
              background: loading ? "#c0392b" : (!input.trim() && images.length === 0 ? "#555" : "#007acc"),
              color: "#fff",
              border: "none",
              borderRadius: "8px",
              cursor: !loading && !input.trim() && images.length === 0 ? "default" : "pointer",
              opacity: !loading && !input.trim() && images.length === 0 ? 0.6 : 1,
            }}
          >
            {loading ? "Stop" : "Send"}
          </button>
        </div>
      </div>
    </div>
  );
}

// One self-edit tool's approval preview: a real, server-computed diff instead
// of the flat "name(arg, arg)" one-liner used for every other action tool.
// This is what makes "always ask first" mean actual informed consent for
// edit_source/edit_playbook/edit_settings — the human reviews what will
// actually change, not a description of it. See synapse/self_edit.py's
// preview_* functions, which compute exactly what's rendered here.
function ActionPreview({ action }) {
  const { name, preview } = action;
  const banner = (text) => (
    <div style={{
      padding: "0.4rem 0.6rem", marginBottom: "0.4rem", background: "#3a2a10",
      border: "1px solid #8a6a2a", borderRadius: "6px", color: "#ffd580",
      fontSize: "0.8rem", fontWeight: 600,
    }}>
      ⚠ {text}
    </div>
  );
  const diffBox = (lines) => (
    <pre style={{
      background: "#0d0d0d", border: "1px solid #333", borderRadius: "6px",
      padding: "0.5rem 0.7rem", margin: "0 0 0.4rem", fontSize: "0.8rem",
      lineHeight: "1.4", maxHeight: "16rem", overflow: "auto",
    }}>
      {lines.length === 0 && <span style={{ color: "#666" }}>(no changes)</span>}
      {lines.map((l, i) => (
        <div key={i} style={{ color: DIFF_LINE_COLOR[l.kind], whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
          {l.text || " "}
        </div>
      ))}
    </pre>
  );

  if (!preview.ok) {
    return (
      <div style={{ marginBottom: "0.5rem" }}>
        <div style={{ fontSize: "0.85rem", color: "#ccc", marginBottom: "0.3rem" }}>
          <code style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px" }}>{name}</code>
          {" — this will fail:"}
        </div>
        <div style={{ color: "#ff8a80", fontSize: "0.8rem", marginBottom: "0.4rem" }}>{preview.error}</div>
      </div>
    );
  }

  if (name === "edit_source") {
    return (
      <div style={{ marginBottom: "0.5rem" }}>
        <div style={{ fontSize: "0.85rem", color: "#ccc", marginBottom: "0.3rem" }}>
          <code style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px" }}>edit_source</code>
          {" "}{preview.path}{preview.is_new_file ? " (new file)" : ""}
        </div>
        {diffBox(diffLines(preview.diff))}
      </div>
    );
  }

  if (name === "edit_playbook") {
    const keys = ["title", "goal", "instructions", "tags", "tools", "model"];
    const lines = keyValueDiffLines(preview.before, preview.after, keys);
    return (
      <div style={{ marginBottom: "0.5rem" }}>
        <div style={{ fontSize: "0.85rem", color: "#ccc", marginBottom: "0.3rem" }}>
          <code style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px" }}>edit_playbook</code>
          {" "}{preview.is_new ? "(new playbook)" : preview.after?.title}
        </div>
        {preview.becomes_main_playbook && banner("this will become the active system prompt")}
        {diffBox(lines)}
      </div>
    );
  }

  if (name === "edit_settings") {
    const before = {}, after = {};
    for (const [k, v] of Object.entries(preview.applied || {})) {
      before[k] = v.before;
      after[k] = v.after;
    }
    const lines = keyValueDiffLines(before, after, Object.keys(preview.applied || {}));
    return (
      <div style={{ marginBottom: "0.5rem" }}>
        <div style={{ fontSize: "0.85rem", color: "#ccc", marginBottom: "0.3rem" }}>
          <code style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px" }}>edit_settings</code>
        </div>
        {preview.policy_change && banner("this changes the tool-approval policy itself")}
        {preview.system_prompt_change && banner("this changes the fallback system prompt")}
        {diffBox(lines)}
        {preview.ignored_unknown && preview.ignored_unknown.length > 0 && (
          <div style={{ fontSize: "0.75rem", color: "#888" }}>
            ignored (not a real setting): {preview.ignored_unknown.join(", ")}
          </div>
        )}
      </div>
    );
  }

  return null;
}