/* =====================================================================
   app.jsx — 文件接收与装配
   拖放 / 选择文件 → 按内容判类型 → buildDocument → Reader
   依赖：React, ReactDOM, bandu.doc, bandu.ui
   ===================================================================== */

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

(function (bandu) {

const { useState, useMemo, useEffect, useCallback } = React;
const { buildDocument, computeProgress } = bandu.doc;
const { Reader, VOICE_LABEL } = bandu.ui;
const { MemoryPanel } = bandu;

/* ---- 渐进解锁：localStorage 读写。Safari 隐私模式下 localStorage 会 throw，
   所以全部 try/catch；读不到一律回落默认值，不拦阅读。 ---- */
const PREF_KEY = "bandu:pref:progressive";

function readProgressivePref() {
  try { return localStorage.getItem(PREF_KEY) !== "0"; } catch (e) { return true; }
}
function writeProgressivePref(on) {
  try { localStorage.setItem(PREF_KEY, on ? "1" : "0"); } catch (e) {}
}

// 按书区分进度。source_sha256 解决 book.id 撞车，也让重新分段的同名书各存各的；
// 老数据两个字段都缺时退化为 book.id + beat 数。
function makeProgressKey(segmentation) {
  if (!segmentation || !segmentation.book) return null;
  const book = segmentation.book;
  const head = segmentation.document_id || book.id || "book";
  const sha = String(segmentation.source_sha256 || "").slice(0, 12);
  if (sha) return `bandu:progress:v1:${head}:${sha}`;
  let beats = 0;
  for (const ch of book.chapters || [])
    for (const sec of ch.sections || []) {
      beats += (sec.beats || []).length + (sec.reference_blocks || []).length;
      for (const sub of sec.subsections || []) beats += (sub.beats || []).length;
    }
  return `bandu:progress:v1:${head}:${beats}`;
}

function loadProgress(key) {
  if (!key) return 1;
  try {
    const raw = JSON.parse(localStorage.getItem(key));
    const n = raw && raw.n;
    return Number.isInteger(n) && n >= 1 ? n : 1;
  } catch (e) { return 1; }
}
function saveProgress(key, n, total) {
  if (!key) return;
  try { localStorage.setItem(key, JSON.stringify({ n, total, t: Date.now() })); } catch (e) {}
}

function classify(name, text) {
  if (/\.(md|markdown)$/i.test(name)) return { slot: "source", value: text };
  let json;
  try { json = JSON.parse(text); } catch (e) { return { error: `${name} 不是合法的 JSON` }; }
  if (json.stage === "book_commentary" || (Array.isArray(json.items) && json.items.some((i) => i.commentary || i.commentaries))) {
    return { slot: "commentary", value: json };
  }
  if (json.book && Array.isArray(json.book.chapters)) return { slot: "segmentation", value: json };
  // 报错要说「该怎么办」，不是「你给的东西叫什么」。
  if (json.stage === "commentary_review") return { error: `${name} 是校对报告，这里用不上——需要的是分段和批注两份文件` };
  return { error: `${name} 认不出来：既不是分段文件（*-segmentation.json），也不是批注文件（*.commentary.json）` };
}

// 三个槽位的说明。只有拖进来一部分、还缺东西时才拿出来当进度用。
const SLOTS = [
  { key: "source", title: "正文", required: false, file: "《书名》.md",
    hint: "不给也行，那就用分段里存的正文重建" },
  { key: "segmentation", title: "分段", required: true, file: "*-segmentation.json",
    hint: "把正文切成一个个段落，气泡靠它找到落点" },
  { key: "commentary", title: "批注", required: true, file: "*.commentary.json",
    hint: "气泡里的话，导师与朋友两个声音" },
];

/* ---- 共享口令。门只拦 /api/*：口令没交上也能一直读下去，
   只有要说话时才需要它。所以这里是一层浮层，不是一道拦在正文前的墙。 ---- */
function GatePrompt({ onPass, onClose }) {
  const [code, setCode] = useState("");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState(null);

  const submit = async () => {
    const value = code.trim();
    if (!value || busy) return;
    setBusy(true); setErr(null);
    try {
      await bandu.chat.submitGate(value);
      onPass();
    } catch (e) {
      setErr(e.status === 401 ? "口令不对，再试试" : e.message || "没能验证口令");
      setBusy(false);
    }
  };

  return (
    <div className="gate-mask" onClick={onClose}>
      <div className="gate" onClick={(e) => e.stopPropagation()}>
        <h2>输入口令</h2>
        <p className="gate-lead">正文随便读。要跟批注对话，需要分享链接里的那个口令。</p>
        <input
          className="gate-input"
          type="text"
          value={code}
          autoFocus
          placeholder="口令"
          onChange={(e) => setCode(e.target.value)}
          onKeyDown={(e) => { if (e.key === "Enter") submit(); }}
        />
        {err && <div className="gate-err">{err}</div>}
        <div className="gate-actions">
          <button className="btn" onClick={onClose}>先只读</button>
          <button className="btn primary" onClick={submit} disabled={busy || !code.trim()}>
            {busy ? "验证中…" : "进去"}
          </button>
        </div>
      </div>
    </div>
  );
}

function App() {
  const [source, setSource] = useState(null);
  const [segmentation, setSegmentation] = useState(null);
  const [commentary, setCommentary] = useState(null);
  const [names, setNames] = useState({});
  const [error, setError] = useState(null);
  const [hot, setHot] = useState(false);
  const [voices, setVoices] = useState({ mentor: true, student: true, self: true });

  // 渐进解锁：开关是全局阅读偏好；解锁数带 key，换书时 key 不匹配就
  // 直接回落到新书的存档值，不需要 effect，也就没有闪一帧全文的问题
  const [progressive, setProgressive] = useState(readProgressivePref);
  const [unlockState, setUnlockState] = useState({ key: null, n: 1 });

  // 对话功能：把当前书注册到本地服务。undefined=注册中，null=服务不可用
  const [bookId, setBookId] = useState(undefined);
  // 站点能力（/api/health）：记忆/上传/口令/对话开关。null = 还没问到
  const [caps, setCaps] = useState(null);
  // 需要补交口令时弹的框
  const [gateOpen, setGateOpen] = useState(false);
  // 已有对话的批量历史：comment_id → { thread_id, messages }。取不到就当没有，不拦阅读
  const [threads, setThreads] = useState(null);
  // 自建笔记：beatId → note。null = 未加载/不可用
  const [notes, setNotes] = useState(null);
  // 画像/记忆浮层的开合
  const [memoryOpen, setMemoryOpen] = useState(false);

  // 开局问一次站点能力：静态托管下问不到，当作"什么都不开"，阅读照常
  useEffect(() => {
    let dead = false;
    const params = new URLSearchParams(window.location.search);
    const linkCode = params.get("k");
    // 静态托管没有 Node 层替我们 302：先从地址栏抹掉口令，再交给 API 换 gate cookie。
    // 失败时仍会正常弹输入框，不让错误口令阻塞阅读。
    if (linkCode) {
      params.delete("k");
      const clean = `${window.location.pathname}${params.size ? `?${params}` : ""}${window.location.hash}`;
      window.history.replaceState(null, "", clean);
    }
    const enter = linkCode ? bandu.chat.submitGate(linkCode).catch(() => false) : Promise.resolve(true);
    enter.then(() => bandu.chat.getHealth())
      .then((h) => { if (!dead) { setCaps(h); if (h.gate && !h.gate_passed) setGateOpen(true); } })
      .catch(() => { if (!dead) setCaps({ ok: false, memory: false, upload: false, gate: false, chat: false }); });
    return () => { dead = true; };
  }, []);

  // 任何接口回 401 都说明门没过（cookie 过期、换了浏览器、链接没带 ?k=）
  useEffect(() => {
    const onGate = () => setGateOpen(true);
    window.addEventListener("bandu:gate-needed", onGate);
    return () => window.removeEventListener("bandu:gate-needed", onGate);
  }, []);

  // 口令过了就重新问一次能力位：caps 换了新对象，下面注册书那个 effect
  // 会跟着重跑，刚才被 401 挡掉的历史与笔记这时才补得回来
  const onGatePassed = useCallback(() => {
    setGateOpen(false);
    bandu.chat.getHealth().then(setCaps).catch(() => {});
  }, []);

  useEffect(() => {
    if (!segmentation || !commentary || caps === null) return;
    let dead = false;
    setBookId(undefined); setThreads(null); setNotes(null);
    // 先查目录：公开书早就注册好了，读者不必把整本 JSON 再传一遍
    bandu.chat.resolveBook(segmentation, commentary, { uploadAllowed: caps.upload !== false })
      .then(async (id) => {
        if (id == null) { if (!dead) setBookId(null); return; }
        const [map, noteMap] = await Promise.all([
          bandu.chat.listThreads(id).catch(() => null),
          bandu.chat.listNotes(id).catch(() => new Map()),
        ]);
        if (!dead) { setBookId(id); setThreads(map); setNotes(noteMap); }
      })
      .catch(() => { if (!dead) setBookId(null); }); // 静态托管下无 /api，正常降级
    return () => { dead = true; };
  }, [segmentation, commentary, caps]);

  const ingest = useCallback(async (fileList) => {
    setError(null);
    const errs = [];
    for (const file of Array.from(fileList)) {
      const text = await file.text();
      const res = classify(file.name, text);
      if (res.error) { errs.push(res.error); continue; }
      if (res.slot === "source") setSource(res.value);
      if (res.slot === "segmentation") setSegmentation(res.value);
      if (res.slot === "commentary") setCommentary(res.value);
      setNames((n) => ({ ...n, [res.slot]: file.name }));
    }
    if (errs.length) setError(errs.join("；"));
  }, []);

  // 建笔记时顺手打开「自己」开关：刚写完就看不见自己的笔记太荒谬
  const onNoteCreated = useCallback((beatId, note) => {
    setNotes((m) => { const next = new Map(m || []); next.set(beatId, note); return next; });
    setVoices((v) => (v.self ? v : { ...v, self: true }));
  }, []);
  const onNoteDeleted = useCallback((beatId) => {
    setNotes((m) => { const next = new Map(m || []); next.delete(beatId); return next; });
  }, []);

  const loadSample = useCallback(async ({ silent = false } = {}) => {
    setError(null);
    try {
      const [seg, com] = await Promise.all([
        fetch("../data/chapter1-segmentation.json").then((r) => r.json()),
        fetch("../data/chapter1-segmentation.commentary.2026.08.07.json").then((r) => r.json()),
      ]);
      const src = await fetch("../" + seg.source_path).then((r) => (r.ok ? r.text() : null)).catch(() => null);
      setSegmentation(seg);
      setCommentary(com);
      if (src) setSource(src);
      setNames({
        segmentation: "chapter1-segmentation.json",
        commentary: "chapter1-segmentation.commentary.2026.08.07.json",
        source: src ? seg.source_path.split("/").pop() : undefined,
      });
    } catch (e) {
      // 自动载入失败就安静地退回选文件那一屏；只有用户亲手点了才值得报错
      if (!silent) setError("读取示例失败。file:// 下浏览器会拦截 fetch，请在仓库根目录运行 `python3 -m http.server 8000`，再打开 http://localhost:8000/web/");
    }
  }, []);

  // 首屏直接把书打开：分享出去的链接，点进来就该看见正文，
  // 而不是一个"请拖入文件"的空框。只在挂载时试一次，
  // 之后按「返回首页」仍然回到选文件那一屏。
  useEffect(() => { loadSample({ silent: true }); }, []);

  useEffect(() => {
    const stop = (e) => { e.preventDefault(); };
    const over = (e) => { e.preventDefault(); setHot(true); };
    const leave = () => setHot(false);
    const drop = (e) => {
      e.preventDefault(); setHot(false);
      if (e.dataTransfer && e.dataTransfer.files.length) ingest(e.dataTransfer.files);
    };
    window.addEventListener("dragover", over);
    window.addEventListener("dragleave", leave);
    window.addEventListener("drop", drop);
    window.addEventListener("dragenter", stop);
    return () => {
      window.removeEventListener("dragover", over);
      window.removeEventListener("dragleave", leave);
      window.removeEventListener("drop", drop);
      window.removeEventListener("dragenter", stop);
    };
  }, [ingest]);

  const doc = useMemo(
    () => buildDocument(source, segmentation, commentary),
    [source, segmentation, commentary]
  );

  const progressKey = useMemo(() => makeProgressKey(segmentation), [segmentation]);
  const unlocked = unlockState.key === progressKey ? unlockState.n : loadProgress(progressKey);
  // null = 全量模式（开关关掉），Reader 走原有渲染路径
  const progress = useMemo(
    () => (doc && progressive ? computeProgress(doc, unlocked) : null),
    [doc, progressive, unlocked]
  );

  const onUnlockNext = useCallback(() => {
    if (!progress || progress.done) return;
    const n = Math.min(progress.n + 1, progress.total);
    setUnlockState({ key: progressKey, n });
    saveProgress(progressKey, n, progress.total);
  }, [progress, progressKey]);

  const toggleProgressive = useCallback(() => {
    setProgressive((on) => {
      writeProgressivePref(!on);
      return !on;
    });
  }, []);

  // 「还差什么」：一份都没给时是空的（那时该看见的是两个按钮，不是清单），
  // 给了一部分才列出来。正文是可选的，不算欠账。
  const waiting = useMemo(() => {
    const got = names.segmentation || names.commentary || names.source;
    if (!got) return [];
    return SLOTS.filter((s) => s.required && !names[s.key]);
  }, [names]);

  const reset = () => {
    setSource(null); setSegmentation(null); setCommentary(null); setNames({}); setError(null);
    setBookId(undefined); setThreads(null); setNotes(null); setMemoryOpen(false);
  };

  const pick = (accept) => {
    const input = document.createElement("input");
    input.type = "file";
    input.multiple = true;
    input.accept = accept;
    input.onchange = () => input.files && ingest(input.files);
    input.click();
  };

  // 问不到 /api/health（静态托管、file://）时按"能传"处理：那种场景本来就是本地自己用
  const canUpload = !caps || caps.upload !== false;

  return (
    <React.Fragment>
      <div className="topbar">
        {/* 顶栏在读的时候要让位给正文：入口按钮只在拖放区那一屏给，这里不重复。
            书名本身就是回家的路 —— 点它回到选文件那一屏，跟「返回首页」同一个动作。 */}
        {doc ? (
          <button className="brand as-link" onClick={reset} title="回到开头，换一份读">伴读</button>
        ) : (
          <div className="brand">伴读</div>
        )}
        {doc && (
          <React.Fragment>
            <button className="btn" onClick={reset}>返回首页</button>
            {/* 显示方式开关：标签写当前模式，点一下切到另一种。
                两种模式都是"开着"的状态，样式恒为 on，只有文字在变 */}
            <button
              className="toggle progressive on"
              onClick={toggleProgressive}
              title="点击切换：渐进逐段解锁 / 直接显示全书"
            >
              <span className="dot" />{progressive ? "渐进显示" : "显示全文"}
            </button>
            {/* 对话服务不可用（bookId===null）时画像/记忆也拿不到数据，跟其他 chat 功能一样降级不渲染；
                公开实例把长期记忆关着（BANDU_MEMORY=off），入口也就没有意义 */}
            {bookId != null && caps && caps.memory && (
              <button className="btn" onClick={() => setMemoryOpen(true)}>记忆</button>
            )}
          </React.Fragment>
        )}

        <div className="spacer" />

        {doc && (
          <React.Fragment>
            {["mentor", "student", ...(bookId != null ? ["self"] : [])].map((v) => (
              <button
                key={v}
                className={`toggle ${v} ${voices[v] ? "on" : "off"}`}
                onClick={() => setVoices((s) => ({ ...s, [v]: !s[v] }))}
              >
                <span className="dot" />{VOICE_LABEL[v]}
              </button>
            ))}
            <div className="status">
              {/* 「beats」是流水线的切分单位，读者只关心「有多少段、多少条批注」。
                  渐进模式下报解锁进度，否则「30 段」和眼前只有一段自相矛盾 */}
              {progress && !progress.done ? (
                <React.Fragment>
                  已解锁 <b>{progress.n}</b>/{progress.total} 段 · <b>{progress.unlockedBubbleCount}</b>/{doc.bubbleCount} 条批注
                </React.Fragment>
              ) : (
                <React.Fragment>
                  <b>{doc.beatCount}</b> 段 · <b>{doc.bubbleCount}</b> 条批注
                </React.Fragment>
              )}
              {doc.reconstructed && " · 正文由分段重建"}
              {!commentary && " · 还差批注文件"}
            </div>
          </React.Fragment>
        )}
      </div>

      <div className="stage">
        {error && <div className="err">{error}</div>}

        {!doc ? (
          <div className={`drop ${hot ? "hot" : ""}`}>
            {/* 空手进来的人只有两条路：看看长什么样，或者拿自己的文件。
                先给这两个按钮，别拿文件清单挡在前面 —— 清单是「还差什么」的
                进度反馈，只有已经拖进来一部分、还缺东西时才有意义（见下）。 */}
            <h1>伴读</h1>
            <p className="lead">给一本书配上气泡批注，边读边看。</p>
            <div className="files">
              <button className="btn primary" onClick={() => loadSample()}>加载示例（第 1 章）</button>
              {canUpload && (
                <button className="btn" onClick={() => pick(".md,.markdown,.json")}>用我自己的文件…</button>
              )}
            </div>
            <p className="aside">
              {canUpload
                ? "没试过就先看示例。自己的文件也可以直接拖进这个窗口。"
                : "这个站点只放了一本书，点上面那个按钮开始读。"}
            </p>

            {waiting.length > 0 && (
              <div className="slots">
                <div className="slots-head">还差{waiting.length === 1 ? "最后一份" : `${waiting.length} 份`}：</div>
                {/* 必需的都列出来（缺的要说，已给的打勾），已给的可选文件也列出来 ——
                    否则拖了 .md 进来的人看不到任何「收到了」的回音。 */}
                {SLOTS.filter((s) => s.required || names[s.key]).map(({ key, title, file, hint }) => (
                  <div key={key} className={`slot ${names[key] ? "filled" : ""}`}>
                    <span className="tick">{names[key] ? "✓" : "○"}</span>
                    <span className="what">
                      <span className="line1"><b>{title}</b><code>{file}</code></span>
                      <span className="hint">{hint}</span>
                    </span>
                    <span className="name">{names[key] || ""}</span>
                  </div>
                ))}
              </div>
            )}
          </div>
        ) : (
          <Reader
            doc={doc}
            voices={voices}
            progress={progress}
            onUnlockNext={onUnlockNext}
            chat={{ bookId, threads, notes, onNoteCreated, onNoteDeleted }}
          />
        )}
      </div>

      {memoryOpen && bookId != null && MemoryPanel && (
        <MemoryPanel bookId={bookId} onClose={() => setMemoryOpen(false)} />
      )}

      {gateOpen && (
        <GatePrompt onPass={onGatePassed} onClose={() => setGateOpen(false)} />
      )}
    </React.Fragment>
  );
}

bandu.app = { classify, App };

ReactDOM.createRoot(document.getElementById("root")).render(<App />);

})(window.bandu);
