/* =====================================================================
   chat.jsx — 与批注对话
   registerBook（注册当前书）/ listThreads（批量取已有对话）/
   InlineChat（内嵌在气泡里的对话区，SSE 流式渲染）
   依赖：React, bandu.ui（VOICE_LABEL）
   导出：bandu.chat
   ===================================================================== */

window.bandu = window.bandu || {};

(function (bandu) {

const { useState, useRef, useEffect } = React;

// 模型回复只需要少量行内格式。用 React 节点解析而不是 innerHTML，既保留
// **粗体** / `代码`，也保证模型哪怕输出标签或脚本仍只会显示成普通文字。
function ChatMarkdown({ text }) {
  const value = String(text || "");
  const pattern = /(\*\*[^\n]+?\*\*|`[^`\n]+`)/g;
  const nodes = [];
  let cursor = 0;
  let match;
  let key = 0;
  while ((match = pattern.exec(value)) !== null) {
    if (match.index > cursor) nodes.push(value.slice(cursor, match.index));
    const token = match[0];
    if (token.startsWith("**")) nodes.push(<strong key={key++}>{token.slice(2, -2)}</strong>);
    else nodes.push(<code key={key++}>{token.slice(1, -1)}</code>);
    cursor = match.index + token.length;
  }
  if (cursor < value.length) nodes.push(value.slice(cursor));
  return <span className="chat-markdown">{nodes}</span>;
}

async function api(path, body) {
  const res = await fetch(path, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw await httpError(res);
  return res.json();
}

// 服务端的错误都是 {error: "中文人话"}，前端直接显示。
// 401（要口令）广播一个事件：口令框由 App 统一弹，免得每个调用点各写一遍。
// 429/503 带上 Retry-After，好把"等一会儿"说成具体多久。
async function httpError(res) {
  const j = await res.json().catch(() => ({}));
  const err = new Error(j.error || `HTTP ${res.status}`);
  err.status = res.status;
  if (res.status === 401) window.dispatchEvent(new CustomEvent("bandu:gate-needed"));
  if (res.status === 429 || res.status === 503) {
    const wait = Number(res.headers.get("retry-after"));
    if (wait > 0) err.retryAfter = wait;
  }
  return err;
}

// 站点能力：记忆/上传/口令/对话开关。取不到就当"什么都不可用"，页面照常能读。
async function getHealth() {
  const res = await fetch("/api/health");
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

// 补交口令：没带 ?k= 直接进站的人走这条
async function submitGate(code) {
  const res = await fetch("/api/gate", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ code }),
  });
  if (!res.ok) throw await httpError(res);
  return true;
}

// 服务器预注册的公开书。前端据此拿 book_id，不必把整本 JSON 传上去。
async function getCatalog() {
  const res = await fetch("/api/catalog");
  if (!res.ok) throw await httpError(res);
  return (await res.json()).books || [];
}

// 把当前阅读的书注册到本地服务；静态托管（python http.server）下会失败，
// 调用方以 bookId=null 表示"对话服务不可用"，阅读功能不受影响。
async function registerBook(segmentation, commentary) {
  const r = await api("/api/books", { segmentation, commentary });
  return r.book_id;
}

// 先问目录，再考虑上传：读公开书的人（绝大多数）根本不该把整本 JSON 发一遍，
// 那既慢又白白撞上传配额。目录里没有、且站点允许上传，才走注册。
async function resolveBook(segmentation, commentary, { uploadAllowed = true } = {}) {
  const sha = segmentation && segmentation.source_sha256;
  if (sha) {
    const hit = (await getCatalog().catch(() => [])).find((b) => b.source_sha256 === sha);
    if (hit) return hit.book_id;
  }
  if (!uploadAllowed) return null;
  return registerBook(segmentation, commentary);
}

// 进页面时一次取回全书已有对话：comment_id → { thread_id, messages }
async function listThreads(bookId) {
  const res = await fetch(`/api/books/${bookId}/threads`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const j = await res.json();
  const m = new Map();
  for (const t of j.threads || []) m.set(t.comment_id, { thread_id: t.thread_id, messages: t.messages || [] });
  return m;
}

async function openThread(bookId, commentId) {
  return api("/api/threads", { book_id: bookId, comment_id: commentId });
}

// 读 SSE 流：按 data: 事件逐个回调；{error} 事件转 throw
async function readSse(res, onEvent) {
  const reader = res.body.getReader();
  const dec = new TextDecoder();
  let buf = "";
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    buf += dec.decode(value, { stream: true });
    let i;
    while ((i = buf.indexOf("\n\n")) >= 0) {
      const line = buf.slice(0, i).trim();
      buf = buf.slice(i + 2);
      if (!line.startsWith("data:")) continue;
      const msg = JSON.parse(line.slice(5));
      if (msg.error) throw new Error(msg.error);
      onEvent(msg);
    }
  }
}

// POST + 读 SSE 流。fetch 而非 EventSource：EventSource 只支持 GET。
async function streamTurn(threadId, content, voice, onDelta) {
  const res = await fetch(`/api/threads/${threadId}/turns`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(voice ? { content, voice } : { content }),
  });
  // 限流/口令/超长都在这里回 JSON，把服务端那句人话原样带给用户，
  // 别退化成"HTTP 429"——读者看不懂，也不知道要等多久。
  if (!res.ok) throw await httpError(res);
  if (!res.body) throw new Error("浏览器不支持流式响应");
  await readSse(res, (msg) => { if (msg.delta) onDelta(msg.delta); });
}

// 气泡随声音开关/窗口宽度切换而反复挂载卸载，对话状态存组件里会丢。
// 模块级缓存按 bookId:commentId 记住每条批注聊到哪了，重挂载时接着显示。
const threadCache = new Map();

// 建笔记。带 askVoice 时响应是 SSE：先回 {note} 元数据，再流点评正文
async function createNote(bookId, beatId, text, askVoice, onMeta, onDelta) {
  const res = await fetch(`/api/books/${bookId}/notes`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(askVoice ? { beat_id: beatId, text, ask_voice: askVoice } : { beat_id: beatId, text }),
  });
  if (!res.ok) throw await httpError(res);
  if (!/text\/event-stream/.test(res.headers.get("content-type") || "")) {
    onMeta((await res.json()).note);
    return;
  }
  await readSse(res, (msg) => {
    if (msg.note) onMeta(msg.note);
    if (msg.delta) onDelta(msg.delta);
  });
}

// 进页面时一次取回全书笔记：beat_id → 笔记与其对话历史
async function listNotes(bookId) {
  const res = await fetch(`/api/books/${bookId}/notes`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const j = await res.json();
  const m = new Map();
  for (const n of j.notes || []) {
    m.set(n.beat_id, {
      commentId: n.comment_id, threadId: n.thread_id, text: n.text,
      createdAt: n.created_at, messages: n.messages || [],
    });
  }
  return m;
}

async function deleteNote(bookId, beatId) {
  const res = await fetch(`/api/books/${bookId}/notes/${beatId}`, { method: "DELETE" });
  if (!res.ok) throw await httpError(res);
}

// 删笔记后清对话缓存：同 beat 重建会复用同一 comment_id，旧对话不能再冒出来
function forgetThread(bookId, commentId) {
  threadCache.delete(`${bookId}:${commentId}`);
}

// ---- 长期记忆面板用的 api（memory.jsx）：画像 + 记忆的取存 ----

async function getProfile() {
  const res = await fetch("/api/profile");
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return (await res.json()).fields;
}

async function putProfile(fields) {
  const res = await fetch("/api/profile", {
    method: "PUT",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ fields }),
  });
  if (!res.ok) throw await httpError(res);
  return (await res.json()).fields;
}

async function listAllMemories(bookId) {
  const qs = bookId != null ? `?book_id=${bookId}` : "";
  const res = await fetch(`/api/memories${qs}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return (await res.json()).memories;
}

async function addMemory({ text, bookId, category }) {
  const res = await fetch("/api/memories", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ text, book_id: bookId, category }),
  });
  if (!res.ok) throw await httpError(res);
  return (await res.json()).memory;
}

async function removeMemory(id) {
  const res = await fetch(`/api/memories/${id}`, { method: "DELETE" });
  if (!res.ok) throw await httpError(res);
}

// 把 Retry-After 的秒数说成人话：读者要的是"等多久"，不是一个数字
function waitText(sec) {
  if (!sec) return "";
  if (sec < 90) return `约 ${Math.ceil(sec)} 秒后可以再试`;
  if (sec < 3600) return `约 ${Math.ceil(sec / 60)} 分钟后可以再试`;
  return `约 ${Math.ceil(sec / 3600)} 小时后可以再试`;
}

function InlineChat({ bubble, bookId, initial, voiceMode = "author" }) {
  const key = `${bookId}:${bubble.id}`;
  const cached = bookId != null ? threadCache.get(key) : null;
  const [messages, setMessages] = useState(cached ? cached.messages : []);
  const [threadId, setThreadId] = useState(cached ? cached.threadId : null);
  const [input, setInput] = useState("");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState(null);
  const [expanded, setExpanded] = useState(false);
  // ask（横幅）/optional（笔记）模式下本轮 @ 谁；author（气泡）没有这个选择，恒为批注作者
  const [atVoice, setAtVoice] = useState(voiceMode === "optional" ? "none" : "mentor");
  const AT_CYCLE = voiceMode === "optional"
    ? { mentor: "student", student: "none", none: "mentor" }
    : { mentor: "student", student: "mentor" };
  const bodyRef = useRef(null);
  const mounted = useRef(true);
  useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);

  // 批量历史在气泡挂载之后才到；只在这条批注还没开始聊时采纳，
  // 不能覆盖用户已经发出去的内容。
  useEffect(() => {
    if (initial && threadId == null && messages.length === 0) {
      setThreadId(initial.thread_id);
      setMessages(initial.messages);
    }
  }, [initial]);

  useEffect(() => {
    if (bookId != null) threadCache.set(key, { threadId, messages });
  }, [key, threadId, messages, bookId]);

  useEffect(() => {
    const el = bodyRef.current;
    if (el) el.scrollTop = el.scrollHeight;
  }, [messages, expanded, err]);

  const send = async () => {
    const q = input.trim();
    if (!q || busy || bookId == null) return;
    setInput(""); setBusy(true); setErr(null);
    // author：不传 voice，批注作者答；ask/optional：传选中的；none：只记录
    const asked = voiceMode === "author" ? null : atVoice;
    const display = asked || bubble.voice;
    setMessages((m) => asked === "none"
      ? [...m, { role: "user", content: q, voice: null }]
      : [...m, { role: "user", content: q, voice: display }, { role: "assistant", content: "", voice: display }]);
    try {
      // 线程惰性创建：没聊过的批注在第一次发送时才建线程
      let tid = threadId;
      if (tid == null) {
        const t = await openThread(bookId, bubble.id);
        tid = t.thread_id;
        if (mounted.current) setThreadId(tid);
      }
      await streamTurn(tid, q, asked, (delta) => {
        if (!mounted.current) return;
        // 'none' 没建 assistant 占位；万一服务端意外流出 delta，绝不能拿它去改写
        // 刚追加的 user 消息（把角色悄悄改成 assistant、内容拼错）
        if (asked === "none") return;
        setMessages((m) => {
          const next = m.slice();
          const last = next[next.length - 1];
          next[next.length - 1] = { role: "assistant", content: last.content + delta, voice: last.voice };
          return next;
        });
      });
    } catch (e) {
      // 被限流/被门挡住时，把发出去的那条从气泡里收回来：
      // 留着一个"没人回答的问题"比直接说清楚更让人困惑
      if (mounted.current) {
        if (e.status === 429 || e.status === 503 || e.status === 401 || e.status === 413) {
          setMessages((m) => m.slice(0, -(asked === "none" ? 1 : 2)));
          setInput(q);
        }
        setErr([e.message, waitText(e.retryAfter)].filter(Boolean).join("，"));
      }
    }
    if (mounted.current) setBusy(false);
  };

  const label = voiceMode === "author"
    ? (bandu.ui.VOICE_LABEL || {})[bubble.voice] || bubble.voice
    : (bandu.ui.VOICE_LABEL || {})[atVoice];
  const placeholder = bookId === null
    ? "对话服务未连接：npm start 并配置 .env"
    : atVoice === "none" && voiceMode === "optional"
      ? "记笔记…（回车发送）"
      : `问${label}…（回车发送）`;

  return (
    <div className={`ichat ${expanded ? "expanded" : ""}`}>
      {messages.length > 0 && (
        <div className="ichat-bar">
          <span className="ichat-count">{Math.max(messages.filter((m) => m.role === "user").length, 1)} 轮对话</span>
          <button className="ichat-expand" onClick={() => setExpanded((v) => !v)}>
            {expanded ? "收起 ▴" : "展开 ▾"}
          </button>
        </div>
      )}
      {(messages.length > 0 || err) && (
        <div className="ichat-body" ref={bodyRef}>
          {messages.map((m, i) => (
            <div key={i} className={`chat-msg ${m.role}`}>
              {voiceMode !== "author" && m.role === "assistant" && (m.content || (busy && i === messages.length - 1)) && (
                <span className={`chat-tag ${m.voice || "mentor"}`}>
                  {(bandu.ui.VOICE_LABEL || {})[m.voice] || "导师"}
                </span>
              )}
              {m.role === "assistant"
                ? <ChatMarkdown text={m.content || (busy && i === messages.length - 1 ? "…" : "")} />
                : (m.content || (busy && i === messages.length - 1 ? "…" : ""))}
            </div>
          ))}
          {err && <div className="chat-err">{err}</div>}
        </div>
      )}
      <div className="ichat-input">
        {voiceMode !== "author" && (
          <button
            type="button"
            className={`ichat-at ${atVoice}`}
            title="点击切换要 @ 的伴读"
            disabled={bookId === null}
            onClick={() => setAtVoice((v) => AT_CYCLE[v])}
          >
            {atVoice === "none" ? "笔记" : `@${(bandu.ui.VOICE_LABEL || {})[atVoice]}`}
          </button>
        )}
        <textarea
          value={input}
          rows={1}
          disabled={bookId === null}
          placeholder={placeholder}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === "Enter" && !e.shiftKey) {
              // 中文输入法用回车选字，isComposing 时不能当发送处理
              if (e.nativeEvent.isComposing) return;
              e.preventDefault(); send();
            }
          }}
        />
        <button className="btn primary ichat-send" disabled={busy || bookId == null || !input.trim()} onClick={send}>
          {busy ? "…" : "发送"}
        </button>
      </div>
    </div>
  );
}

bandu.chat = {
  registerBook, resolveBook, getCatalog, getHealth, submitGate,
  listThreads, listNotes, createNote, deleteNote, forgetThread, InlineChat,
  getProfile, putProfile, listAllMemories, addMemory, removeMemory,
};

})(window.bandu);
