// 文件预览:主弹框(图/视频/PDF/文本/markdown/html/docx/xlsx,大文件降级下载)+
// 同时再开一个的小窗预览(mini)。PDF 用 PDF.js canvas 渲染以兼容 App WebView，
// docx/xlsx 走 loadScript 懒加载 vendor。
// 导出 open*/close* 供 files / 媒体 chip / 粘贴文件 / main 的 Esc 关栈调用;
// _categorize 也供 media 段判图/视频。反向依赖 downloadFile(media)、logout(auth)。
import { state } from "./state.js";
import { api } from "./api.js";
import { $ } from "./dom.js";
import { humanSize, escapeHtml } from "./format.js";
import { renderMd, highlightIn } from "./markdown.js";
import { configureHtmlPreviewFrame } from "./preview_content.js";
import { logout } from "./auth.js";
import { downloadFile } from "./media.js";

// ───── file preview ─────
const PREVIEW_TEXT_MAX = 2 * 1024 * 1024;
const PREVIEW_BIN_MAX = 50 * 1024 * 1024;
const PDFJS_SRC = "/static/vendor/pdfjs/pdf.min.js";
const PDFJS_WORKER_SRC = "/static/vendor/pdfjs/pdf.worker.min.js";

const WEB_PREVIEW_PREFIX = "[WebPreview] ";

export function parseWebPreviewResult(text) {
  const source = String(text || "");
  if (!source.startsWith(WEB_PREVIEW_PREFIX)) return null;
  try {
    const value = JSON.parse(source.slice(WEB_PREVIEW_PREFIX.length).split("\n", 1)[0]);
    if (!value || value.type !== "web_preview" || !value.preview_id) return null;
    return value;
  } catch (_) {
    return null;
  }
}

export function renderWebPreviewCardHtml(preview) {
  if (!preview || !preview.preview_id) return "";
  const stopped = preview.status && preview.status !== "active";
  const meta = `${Number(preview.file_count || 0)} 个文件 · ${humanSize(Number(preview.size_bytes || 0))}`;
  return `<section class="web-preview-card${stopped ? " stopped" : ""}" data-preview-id="${escapeHtml(preview.preview_id)}">
    <div class="web-preview-icon" aria-hidden="true">WEB</div>
    <div class="web-preview-info">
      <strong>${escapeHtml(preview.name || "网页项目预览")}</strong>
      <span>${escapeHtml(preview.root || ".")} · ${escapeHtml(meta)}</span>
    </div>
    <div class="web-preview-actions">
      <button type="button" class="web-preview-open"${stopped ? " disabled" : ""}>${stopped ? "已停止" : "打开预览"}</button>
      <button type="button" class="web-preview-stop"${stopped ? " disabled" : ""} title="停止此预览">停止</button>
    </div>
  </section>`;
}

export async function openWebPreview(preview) {
  const previewId = typeof preview === "string" ? preview : preview && preview.preview_id;
  if (!previewId) return;
  const data = await api("POST", `/v1/web-previews/${encodeURIComponent(previewId)}/launch`);
  const current = data.preview || preview || {};
  _fpCurrentRel = null;
  _fpCurrentTaskId = current.task_id || "";
  _fpCurrentLegacy = false;
  _fpCurrentArtifactId = "";
  _fpCurrentWebPreviewId = previewId;
  $("fp-name").textContent = current.name || "网页项目预览";
  $("fp-meta").textContent = `${Number(current.file_count || 0)} 个文件 · ${humanSize(Number(current.size_bytes || 0))}`;
  _resetTextModes("fp");
  const body = $("fp-body");
  _clearZoom(body);
  body.className = "body html-preview";
  body.innerHTML = "";
  const frame = document.createElement("iframe");
  frame.className = "preview-frame preview-html-frame web-project-frame";
  frame.title = current.name || "网页项目预览";
  frame.referrerPolicy = "no-referrer";
  frame.setAttribute("sandbox", "allow-scripts allow-forms allow-modals allow-downloads");
  frame.src = data.url;
  body.appendChild(frame);
  const cf = $("chat-form");
  const inset = (cf && cf.offsetParent) ? cf.offsetHeight : 0;
  $("file-preview-modal").style.setProperty("--preview-bottom-inset", inset + "px");
  $("file-preview-modal").classList.add("show");
  document.body.classList.add("fp-open");
}

export async function stopWebPreview(previewId) {
  const result = await api("DELETE", `/v1/web-previews/${encodeURIComponent(previewId)}`);
  if (_fpCurrentWebPreviewId === previewId) closeFilePreview();
  return result;
}

const _scriptCache = new Map();
function loadScript(src) {
  if (_scriptCache.has(src)) return _scriptCache.get(src);
  const p = new Promise((resolve, reject) => {
    const s = document.createElement("script");
    s.src = src;
    s.onload = () => resolve();
    s.onerror = () => { _scriptCache.delete(src); reject(new Error("load failed: " + src)); };
    document.head.appendChild(s);
  });
  _scriptCache.set(src, p);
  return p;
}

const _previewBlobUrls = new Set();
function _trackBlobUrl(blob, mime) {
  const b = mime ? new Blob([blob], { type: mime }) : blob;
  const url = URL.createObjectURL(b);
  _previewBlobUrls.add(url);
  return url;
}
function _flushBlobUrls() {
  for (const u of _previewBlobUrls) URL.revokeObjectURL(u);
  _previewBlobUrls.clear();
}

const _EXT_GROUPS = {
  image: new Set(["jpg","jpeg","png","gif","webp","bmp","svg","ico"]),
  video: new Set(["mp4","webm","mov","mkv","m4v"]),
  pdf:   new Set(["pdf"]),
  md:    new Set(["md","markdown"]),
  html:  new Set(["html","htm"]),
  text:  new Set([
    "txt","log","json","jsonl","yaml","yml","toml","ini","csv","tsv",
    "py","js","mjs","ts","jsx","tsx","go","rs","java","c","cc","cpp","h","hpp",
    "xml","css","scss","sh","bash","zsh","sql","conf","env",
  ]),
  docx:  new Set(["docx"]),
  xlsx:  new Set(["xlsx","xls"]),
  ppt:   new Set(["pptx","ppt"]),
};
export function _categorize(rel) {
  const m = /\.([a-z0-9]+)$/i.exec(rel);
  const ext = m ? m[1].toLowerCase() : "";
  for (const [cat, set] of Object.entries(_EXT_GROUPS)) if (set.has(ext)) return cat;
  return "fallback";
}

let _fpCurrentRel = null;
let _fpCurrentTaskId = "";
let _fpCurrentLegacy = false;
let _fpCurrentArtifactId = "";
let _fpCurrentWebPreviewId = "";

// Markdown / HTML 共用“预览 / 源文件”切换。HTML 在 opaque-origin sandbox iframe
// 中运行：可执行脚本、加载 HTTPS 资源，但不能读取 zcbot 页面或发起表单/顶层跳转。
const _textPreviewState = { fp: null, mp: null };

function _resetTextModes(prefix) {
  _textPreviewState[prefix] = null;
  const modes = $(`${prefix}-modes`);
  if (modes) modes.hidden = true;
}

async function _loadPdfJs() {
  await loadScript(PDFJS_SRC);
  if (!window.pdfjsLib || !window.pdfjsLib.getDocument) {
    throw new Error("PDF.js 不可用");
  }
  window.pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_SRC;
  return window.pdfjsLib;
}

function _showSource(body, text) {
  body.className = "body";
  body.innerHTML = "";
  const pre = document.createElement("pre");
  pre.className = "preview-text";
  pre.textContent = text;
  body.appendChild(pre);
}

function _showHtml(body, text) {
  body.className = "body html-preview";
  body.innerHTML = "";
  const frame = document.createElement("iframe");
  frame.className = "preview-frame preview-html-frame";
  configureHtmlPreviewFrame(frame, text);
  body.appendChild(frame);
}

function _renderTextMode(prefix, mode) {
  const state = _textPreviewState[prefix];
  if (!state) return;
  state.mode = mode;
  const body = $(`${prefix}-body`);
  const previewBtn = $(`${prefix}-mode-preview`);
  const sourceBtn = $(`${prefix}-mode-source`);
  const showingPreview = mode === "preview";
  previewBtn.classList.toggle("active", showingPreview);
  sourceBtn.classList.toggle("active", !showingPreview);
  previewBtn.setAttribute("aria-pressed", String(showingPreview));
  sourceBtn.setAttribute("aria-pressed", String(!showingPreview));
  if (!showingPreview) {
    _showSource(body, state.text);
  } else if (state.cat === "md") {
    body.className = "body";
    body.innerHTML = `<div class="md-render">${renderMd(state.text)}</div>`;
    highlightIn(body);
  } else {
    _showHtml(body, state.text);
  }
}

function _showRenderableText(prefix, cat, text) {
  _textPreviewState[prefix] = { cat, text, mode: "preview" };
  $(`${prefix}-modes`).hidden = false;
  _renderTextMode(prefix, "preview");
}

// ───── 滚动不穿透 + 图片 Ctrl+滚轮缩放 ─────
// body 元素在多次预览间复用,故 wheel 监听只在 init 时挂一次(_bindBodyWheel),
// 缩放目标用 _zoomState 记录,避免每次预览重复 addEventListener 泄漏。
const _zoomState = new WeakMap(); // bodyEl -> { img, scale, badge, timer }

function _captureBase(z) {
  const img = z.img;
  if (!z.baseW) {
    z.baseW = img.clientWidth || img.naturalWidth || 0;
    z.baseH = img.clientHeight || img.naturalHeight || 0;
  }
  return z.baseW > 0;
}

function _applyZoom(z, hint) {
  const img = z.img;
  if (z.scale === 1) {
    // 复位:还原 CSS 里的 max-width/height:100% 自适应贴合
    img.style.maxWidth = "";
    img.style.maxHeight = "";
    img.style.width = "";
    img.style.height = "";
    img.style.cursor = ""; // 100%:普通箭头(左键不缩放,放大镜会误导)
  } else {
    // 以 scale=1 时的"贴合显示"尺寸为基准,给显式像素尺寸。
    // 不用 CSS zoom:图片是带 max-width/height:100% 的 flex item,zoom 放大后会被
    // 百分比 max 约束重新夹回 → 视觉不变;显式 px + max:none 才真正撑大并让 body 出滚动条。
    if (!_captureBase(z)) return; // 图片还没量到尺寸(未加载完),本次跳过,下次再缩
    img.style.maxWidth = "none";
    img.style.maxHeight = "none";
    img.style.width = Math.round(z.baseW * z.scale) + "px";
    img.style.height = Math.round(z.baseH * z.scale) + "px";
    img.style.cursor = "grab"; // 放大后可左键拖动平移
  }
  z.badge.textContent = hint || (Math.round(z.scale * 100) + "%");
  z.badge.classList.add("show");
  if (z.timer) clearTimeout(z.timer);
  z.timer = setTimeout(() => z.badge.classList.remove("show"), hint ? 1400 : 1000);
}

// 给某 body 内的图片接上 Ctrl+滚轮缩放(badge + 双击复位),记录到 _zoomState。
function _makeImageZoomable(bodyEl, img) {
  _clearZoom(bodyEl);
  const card = bodyEl.closest(".card") || bodyEl;
  const badge = document.createElement("div");
  badge.className = "zoom-badge"; // 挂到 card 而非 body,放大后 body 滚动时徽标不跟着滚走
  card.appendChild(badge);
  const z = { img, scale: 1, badge, timer: null, baseW: 0, baseH: 0 };
  _zoomState.set(bodyEl, z);
  img.draggable = false; // 关掉浏览器原生图片拖拽(否则拖出 ghost image)
  // 图片一加载完就量好贴合尺寸做基准,避免首次缩放时还没渲染量到 0
  if (img.complete) _captureBase(z);
  else img.addEventListener("load", () => _captureBase(z), { once: true });
  img.addEventListener("dblclick", () => {
    if (z.scale === 1) return; // 本来就 100%,无需提示
    z.scale = 1;
    _applyZoom(z, "已复位 · 100%");
  });

  // 放大后左键拖动平移:本质是改 body 的滚动位置(图片此时已撑大、body 可滚)
  let dragging = false, sx = 0, sy = 0, sl = 0, st = 0;
  img.addEventListener("mousedown", (e) => {
    if (e.button !== 0 || z.scale === 1) return; // 仅左键、仅放大态
    e.preventDefault();
    dragging = true;
    sx = e.clientX; sy = e.clientY;
    sl = bodyEl.scrollLeft; st = bodyEl.scrollTop;
    img.style.cursor = "grabbing";
  });
  z._onMove = (e) => {
    if (!dragging) return;
    bodyEl.scrollLeft = sl - (e.clientX - sx);
    bodyEl.scrollTop = st - (e.clientY - sy);
  };
  z._onUp = () => {
    if (!dragging) return;
    dragging = false;
    img.style.cursor = z.scale === 1 ? "" : "grab";
  };
  document.addEventListener("mousemove", z._onMove);
  document.addEventListener("mouseup", z._onUp);
}

function _clearZoom(bodyEl) {
  const z = _zoomState.get(bodyEl);
  if (!z) return;
  if (z.timer) clearTimeout(z.timer);
  if (z._onMove) document.removeEventListener("mousemove", z._onMove);
  if (z._onUp) document.removeEventListener("mouseup", z._onUp);
  z.badge.remove();
  _zoomState.delete(bodyEl);
}

// 只挂一次:Ctrl+滚轮缩当前图片;否则拦住滚动穿透到背景对话列表。
function _bindBodyWheel(bodyEl) {
  bodyEl.addEventListener("wheel", (e) => {
    const z = _zoomState.get(bodyEl);
    if (e.ctrlKey) {
      if (!z) return; // 无可缩放图片:不拦,交还浏览器
      e.preventDefault(); // 有图片:吃掉浏览器页面缩放,改缩图片
      z.scale = Math.min(8, Math.max(0.1, +(z.scale * (e.deltaY < 0 ? 1.1 : 1 / 1.1)).toFixed(3)));
      _applyZoom(z);
      return;
    }
    // PDF 使用 body 内部的独立 viewport 连续滚动。wheel 会继续冒泡到 body，
    // 若仍按 body 自身是否可滚判断并 preventDefault，会把内层的默认滚动一并取消。
    // viewport 已用 overscroll-behavior 隔离边界穿透，因此这里直接交还给它。
    if (e.target instanceof Element && e.target.closest(".pdf-viewport")) return;
    // 非 ctrl:容器不可滚 / 已到边界时阻断,避免冒泡到背景滚动对话列表
    const canScroll = bodyEl.scrollHeight > bodyEl.clientHeight + 1;
    if (!canScroll) { e.preventDefault(); return; }
    const atTop = bodyEl.scrollTop <= 0 && e.deltaY < 0;
    const atBottom = bodyEl.scrollTop + bodyEl.clientHeight >= bodyEl.scrollHeight - 1 && e.deltaY > 0;
    if (atTop || atBottom) e.preventDefault();
  }, { passive: false });
}

function _fileDownloadUrl(rel, taskId = "", legacy = false, artifactId = "") {
  const base = taskId
    ? `/v1/tasks/${encodeURIComponent(taskId)}/files/download`
    : "/v1/files/download";
  return base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : "")
    + (artifactId ? "&artifact_id=" + encodeURIComponent(artifactId) : "");
}

function _pptPreviewUrl(rel, taskId = "", legacy = false, artifactId = "") {
  const base = taskId
    ? `/v1/tasks/${encodeURIComponent(taskId)}/files/preview_pdf`
    : "/v1/files/preview_pdf";
  return base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : "")
    + (artifactId ? "&artifact_id=" + encodeURIComponent(artifactId) : "");
}

export async function openFilePreview(rel, taskId = "", legacy = false, artifactId = "") {
  _fpCurrentWebPreviewId = "";
  _fpCurrentRel = rel;
  _fpCurrentTaskId = taskId;
  _fpCurrentLegacy = legacy;
  _fpCurrentArtifactId = artifactId;
  const name = rel.split("/").pop() || rel;
  $("fp-name").textContent = name;
  $("fp-meta").textContent = "";
  const body = $("fp-body");
  _resetTextModes("fp");
  _clearZoom(body);
  body.className = "body center";
  body.innerHTML = `<div class="ph">加载中…</div>`;
  // 让出聊天输入区高度,弹框不遮挡 chat-form(无活动任务时 cf 隐藏,inset = 0)
  const cf = $("chat-form");
  const inset = (cf && cf.offsetParent) ? cf.offsetHeight : 0;
  $("file-preview-modal").style.setProperty("--preview-bottom-inset", inset + "px");
  $("file-preview-modal").classList.add("show");
  // 遮罩铺满整屏后,把输入区抬到遮罩之上继续可用(CSS body.fp-open #chat-form)
  document.body.classList.add("fp-open");

  const cat = _categorize(rel);
  // pptx/ppt:后端转 PDF 再复用现成 PDF iframe(非下载原文件),首次稍候 + 失败回退下载。
  if (cat === "ppt") { await _showPptAsPdf(rel, $("fp-body"), $("fp-meta"), _showFallback, taskId, legacy, artifactId); return; }
  try {
    const r = await fetch(_fileDownloadUrl(rel, taskId, legacy, artifactId), {
      headers: { "Authorization": "Bearer " + state.token },
    });
    if (!r.ok) throw new Error("HTTP " + r.status);
    const blob = await r.blob();
    $("fp-meta").textContent = humanSize(blob.size);

    if (cat === "text" || cat === "md" || cat === "html") {
      if (blob.size > PREVIEW_TEXT_MAX) {
        _showFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
        return;
      }
      const text = await blob.text();
      if (cat === "md" || cat === "html") _showRenderableText("fp", cat, text);
      else _showText(text);
      return;
    }
    if (blob.size > PREVIEW_BIN_MAX) {
      _showFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
      return;
    }
    if (cat === "image") _showImage(blob);
    else if (cat === "video") _showVideo(blob);
    else if (cat === "pdf") await _showPdf(blob);
    else if (cat === "docx") await _showDocx(blob);
    else if (cat === "xlsx") await _showXlsx(blob);
    else _showFallback("暂不支持在线预览此格式,请下载查看");
  } catch (e) {
    if (e.status === 401) { closeFilePreview(); logout(); return; }
    _showFallback("加载失败:" + e.message);
  }
}

function _showImage(blob) {
  // SVG 在 <img> 里必须 Content-Type=image/svg+xml 才渲染;下载接口靠服务端
  // mimetypes 猜类型,部分部署环境(Linux)未注册 .svg → octet-stream → 不显示。
  // 这里据扩展名强制纠正,与服务端响应头无关。
  const mime = /\.svg$/i.test(_fpCurrentRel || "") ? "image/svg+xml" : "";
  const url = _trackBlobUrl(blob, mime);
  const body = $("fp-body");
  body.className = "body center";
  body.innerHTML = "";
  const img = document.createElement("img");
  img.className = "preview-img";
  img.src = url;
  body.appendChild(img);
  _makeImageZoomable(body, img);
}

function _showVideo(blob) {
  const url = _trackBlobUrl(blob);
  const body = $("fp-body");
  body.className = "body center";
  body.innerHTML = "";
  const v = document.createElement("video");
  v.className = "preview-video";
  v.src = url;
  v.controls = true;
  v.autoplay = true;
  body.appendChild(v);
}

const _pdfPreviewStates = new WeakMap();

function _clearPdfPreview(body) {
  const state = _pdfPreviewStates.get(body);
  if (!state) return;
  state.disposed = true;
  if (state.renderTasks) {
    for (const task of state.renderTasks.values()) task.cancel();
    state.renderTasks.clear();
  }
  if (state.loadingTask) state.loadingTask.destroy();
  if (state.resizeObserver) state.resizeObserver.disconnect();
  if (state.pageObserver) state.pageObserver.disconnect();
  if (state.scrollFrame) cancelAnimationFrame(state.scrollFrame);
  if (state.onResize) window.removeEventListener("resize", state.onResize);
  if (state.pdf) state.pdf.destroy();
  _pdfPreviewStates.delete(body);
}

async function _showPdfIn(body, blob, fallbackFn) {
  _clearPdfPreview(body);
  const state = {
    disposed: false,
    pdf: null,
    loadingTask: null,
    pageNumber: 1,
    zoom: 1,
    renderTasks: new Map(),
    renderSeq: 0,
    pageShells: [],
    pageObserver: null,
    resizeObserver: null,
    onResize: null,
    scrollFrame: null,
  };
  _pdfPreviewStates.set(body, state);
  body.className = "body pdf-preview";
  body.innerHTML = `
    <div class="pdf-toolbar">
      <label>第 <input class="pdf-page" type="number" min="1" value="1" inputmode="numeric" disabled> / <span class="pdf-pages">…</span> 页</label>
      <span class="pdf-toolbar-spacer"></span>
      <button class="small pdf-zoom-out" type="button" aria-label="缩小" disabled>−</button>
      <span class="pdf-zoom">适宽</span>
      <button class="small pdf-zoom-in" type="button" aria-label="放大" disabled>＋</button>
    </div>
    <div class="pdf-viewport"><div class="ph">解析 PDF 中…</div></div>`;

  const viewportHost = body.querySelector(".pdf-viewport");
  const pageInput = body.querySelector(".pdf-page");
  const pagesLabel = body.querySelector(".pdf-pages");
  const zoomLabel = body.querySelector(".pdf-zoom");
  const zoomOut = body.querySelector(".pdf-zoom-out");
  const zoomIn = body.querySelector(".pdf-zoom-in");

  const renderPage = async (pageNumber) => {
    if (state.disposed || !state.pdf) return;
    const shell = state.pageShells[pageNumber - 1];
    if (!shell) return;
    const renderKey = `${state.renderSeq}:${viewportHost.clientWidth}:${state.zoom}`;
    if (shell.dataset.renderKey === renderKey || state.renderTasks.has(pageNumber)) return;
    const page = await state.pdf.getPage(pageNumber);
    if (state.disposed || !shell.isConnected) return;
    const baseViewport = page.getViewport({ scale: 1 });
    const fitScale = Math.max(0.1, (viewportHost.clientWidth - 24) / baseViewport.width);
    const viewport = page.getViewport({ scale: fitScale * state.zoom });
    shell.dataset.baseWidth = String(baseViewport.width);
    shell.dataset.baseHeight = String(baseViewport.height);
    const outputScale = Math.min(window.devicePixelRatio || 1, 2);
    const canvas = document.createElement("canvas");
    const context = canvas.getContext("2d");
    canvas.className = "pdf-canvas";
    canvas.width = Math.floor(viewport.width * outputScale);
    canvas.height = Math.floor(viewport.height * outputScale);
    canvas.style.width = Math.floor(viewport.width) + "px";
    canvas.style.height = Math.floor(viewport.height) + "px";
    shell.style.width = Math.floor(viewport.width) + "px";
    shell.style.height = Math.floor(viewport.height) + "px";
    const renderTask = page.render({
      canvasContext: context,
      viewport,
      transform: outputScale === 1 ? null : [outputScale, 0, 0, outputScale, 0, 0],
    });
    state.renderTasks.set(pageNumber, renderTask);
    try {
      await renderTask.promise;
    } catch (e) {
      if (e && e.name === "RenderingCancelledException") return;
      throw e;
    } finally {
      if (state.renderTasks.get(pageNumber) === renderTask) state.renderTasks.delete(pageNumber);
    }
    if (state.disposed || renderKey !== `${state.renderSeq}:${viewportHost.clientWidth}:${state.zoom}`) return;
    shell.innerHTML = "";
    shell.appendChild(canvas);
    shell.dataset.renderKey = renderKey;
  };

  const goToPage = (pageNumber) => {
    if (!state.pdf) return;
    state.pageNumber = Math.min(state.pdf.numPages, Math.max(1, pageNumber));
    pageInput.value = String(state.pageNumber);
    const shell = state.pageShells[state.pageNumber - 1];
    if (shell) viewportHost.scrollTo({ top: Math.max(0, shell.offsetTop - 12), behavior: "smooth" });
  };
  pageInput.onchange = () => goToPage(parseInt(pageInput.value, 10) || 1);

  const rerenderVisiblePages = () => {
    state.renderSeq += 1;
    for (const task of state.renderTasks.values()) task.cancel();
    state.renderTasks.clear();
    const availableWidth = Math.max(1, viewportHost.clientWidth - 24);
    for (const shell of state.pageShells) {
      shell.dataset.renderKey = "";
      const baseWidth = Number(shell.dataset.baseWidth);
      const baseHeight = Number(shell.dataset.baseHeight);
      const scale = Math.max(0.1, availableWidth / baseWidth) * state.zoom;
      shell.style.width = Math.floor(baseWidth * scale) + "px";
      shell.style.height = Math.floor(baseHeight * scale) + "px";
      const rect = shell.getBoundingClientRect();
      const hostRect = viewportHost.getBoundingClientRect();
      if (rect.bottom >= hostRect.top - hostRect.height && rect.top <= hostRect.bottom + hostRect.height) {
        renderPage(Number(shell.dataset.page)).catch(handleRenderError);
      }
    }
  };

  const handleRenderError = (e) => {
    if (state.disposed || (e && e.name === "RenderingCancelledException")) return;
    _clearPdfPreview(body);
    fallbackFn("PDF 渲染失败:" + e.message);
  };

  zoomOut.onclick = () => {
    state.zoom = Math.max(0.5, +(state.zoom / 1.25).toFixed(2));
    zoomLabel.textContent = state.zoom === 1 ? "适宽" : Math.round(state.zoom * 100) + "%";
    rerenderVisiblePages();
  };
  zoomIn.onclick = () => {
    state.zoom = Math.min(3, +(state.zoom * 1.25).toFixed(2));
    zoomLabel.textContent = state.zoom === 1 ? "适宽" : Math.round(state.zoom * 100) + "%";
    rerenderVisiblePages();
  };

  try {
    const pdfjsLib = await _loadPdfJs();
    const data = new Uint8Array(await blob.arrayBuffer());
    state.loadingTask = pdfjsLib.getDocument({ data });
    const pdf = await state.loadingTask.promise;
    state.loadingTask = null;
    if (state.disposed || _pdfPreviewStates.get(body) !== state) {
      pdf.destroy();
      return;
    }
    state.pdf = pdf;
    pagesLabel.textContent = String(pdf.numPages);
    pageInput.max = String(pdf.numPages);
    pageInput.disabled = false;
    zoomOut.disabled = false;
    zoomIn.disabled = false;
    const firstPage = await pdf.getPage(1);
    const firstViewport = firstPage.getViewport({ scale: 1 });
    const initialScale = Math.max(0.1, (viewportHost.clientWidth - 24) / firstViewport.width);
    viewportHost.innerHTML = '<div class="pdf-pages-list"></div>';
    const pagesList = viewportHost.firstElementChild;
    for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
      const shell = document.createElement("div");
      shell.className = "pdf-page-shell";
      shell.dataset.page = String(pageNumber);
      shell.dataset.baseWidth = String(firstViewport.width);
      shell.dataset.baseHeight = String(firstViewport.height);
      shell.style.width = Math.floor(firstViewport.width * initialScale) + "px";
      shell.style.height = Math.floor(firstViewport.height * initialScale) + "px";
      shell.innerHTML = `<span>第 ${pageNumber} 页</span>`;
      pagesList.appendChild(shell);
      state.pageShells.push(shell);
    }
    state.pageObserver = new IntersectionObserver((entries) => {
      for (const entry of entries) {
        if (entry.isIntersecting) renderPage(Number(entry.target.dataset.page)).catch(handleRenderError);
      }
    }, { root: viewportHost, rootMargin: "100% 0px" });
    for (const shell of state.pageShells) state.pageObserver.observe(shell);

    viewportHost.addEventListener("scroll", () => {
      if (state.scrollFrame) return;
      state.scrollFrame = requestAnimationFrame(() => {
        state.scrollFrame = null;
        const center = viewportHost.scrollTop + viewportHost.clientHeight / 2;
        let current = 1;
        let distance = Infinity;
        for (const shell of state.pageShells) {
          const delta = Math.abs(shell.offsetTop + shell.offsetHeight / 2 - center);
          if (delta < distance) {
            distance = delta;
            current = Number(shell.dataset.page);
          }
          const farAway = shell.offsetTop + shell.offsetHeight < viewportHost.scrollTop - viewportHost.clientHeight * 2
            || shell.offsetTop > viewportHost.scrollTop + viewportHost.clientHeight * 3;
          if (farAway && shell.dataset.renderKey) {
            shell.innerHTML = `<span>第 ${shell.dataset.page} 页</span>`;
            shell.dataset.renderKey = "";
          }
        }
        state.pageNumber = current;
        pageInput.value = String(current);
      });
    }, { passive: true });

    const rerender = () => {
      if (state.disposed) return;
      rerenderVisiblePages();
    };
    if (window.ResizeObserver) {
      state.resizeObserver = new ResizeObserver(rerender);
      state.resizeObserver.observe(viewportHost);
    } else {
      state.onResize = rerender;
      window.addEventListener("resize", rerender);
    }
  } catch (e) {
    if (!state.disposed) {
      _clearPdfPreview(body);
      fallbackFn("PDF 预览失败:" + e.message);
    }
  }
}

async function _showPdf(blob) {
  await _showPdfIn($("fp-body"), blob, _showFallback);
}

// pptx/ppt → 后端转 PDF → PDF.js。main / mini 共用各自 body / meta / fallback。
async function _showPptAsPdf(rel, body, metaEl, fallbackFn, taskId = "", legacy = false, artifactId = "") {
  body.className = "body center";
  body.innerHTML = `<div class="ph"><div class="preview-spinner"></div>由 PPT 转换为 PDF · 首次稍候…</div>`;
  if (metaEl) metaEl.textContent = "";
  let r;
  try {
    r = await fetch(_pptPreviewUrl(rel, taskId, legacy, artifactId), {
      headers: { "Authorization": "Bearer " + state.token },
    });
  } catch (e) {
    fallbackFn("加载失败:" + e.message);
    return;
  }
  if (r.status === 401) { logout(); return; }
  if (!r.ok) {
    let msg = "PPT 在线预览不可用,请下载查看";
    if (r.status === 501) msg = "服务器未装 LibreOffice,无法在线预览 PPT,请下载查看";
    else if (r.status === 500) msg = "PPT 转换失败,请下载原文件查看";
    fallbackFn(msg);
    return;
  }
  const blob = await r.blob();
  if (metaEl) metaEl.textContent = humanSize(blob.size) + " · PDF 预览";
  await _showPdfIn(body, blob, fallbackFn);
}

function _showText(text) { _showSource($("fp-body"), text); }

async function _showDocx(blob) {
  const body = $("fp-body");
  body.className = "body center";
  body.innerHTML = `<div class="ph">解析 docx 中…</div>`;
  try {
    await loadScript("/static/vendor/jszip.min.js");
    await loadScript("/static/vendor/docx-preview.min.js");
  } catch (e) {
    _showFallback("docx 解析库加载失败:" + e.message);
    return;
  }
  if (!window.docx || !window.docx.renderAsync) {
    _showFallback("docx 解析库不可用");
    return;
  }
  body.className = "body";
  body.innerHTML = `<div class="docx-host"></div>`;
  try {
    await window.docx.renderAsync(blob, body.querySelector(".docx-host"), null, {
      inWrapper: false,
      ignoreLastRenderedPageBreak: true,
    });
  } catch (e) {
    _showFallback("docx 渲染失败:" + e.message);
  }
}

async function _showXlsx(blob) {
  const body = $("fp-body");
  body.className = "body center";
  body.innerHTML = `<div class="ph">解析表格中…</div>`;
  try {
    await loadScript("/static/vendor/xlsx.full.min.js");
  } catch (e) {
    _showFallback("xlsx 解析库加载失败:" + e.message);
    return;
  }
  if (!window.XLSX || !window.XLSX.read) {
    _showFallback("xlsx 解析库不可用");
    return;
  }
  let wb;
  try {
    const ab = await blob.arrayBuffer();
    wb = window.XLSX.read(ab, { type: "array" });
  } catch (e) {
    _showFallback("xlsx 解析失败:" + e.message);
    return;
  }
  const names = wb.SheetNames || [];
  if (!names.length) { _showFallback("xlsx 内无 sheet"); return; }
  body.className = "body";
  const tabsHtml = names.map((n, i) =>
    `<button class="small xlsx-tab${i===0?" active":""}" data-i="${i}">${escapeHtml(n)}</button>`
  ).join("");
  body.innerHTML = `<div class="xlsx-tabs">${tabsHtml}</div><div class="xlsx-sheet" id="fp-xlsx-sheet"></div>`;
  const render = (i) => {
    const ws = wb.Sheets[names[i]];
    $("fp-xlsx-sheet").innerHTML = window.XLSX.utils.sheet_to_html(ws);
  };
  body.querySelectorAll(".xlsx-tab").forEach((btn) => {
    btn.onclick = () => {
      body.querySelectorAll(".xlsx-tab").forEach((b) => b.classList.remove("active"));
      btn.classList.add("active");
      render(parseInt(btn.dataset.i));
    };
  });
  render(0);
}

function _showFallback(msg) {
  const body = $("fp-body");
  body.className = "body center";
  body.innerHTML = "";
  const ph = document.createElement("div");
  ph.className = "ph";
  ph.textContent = msg;
  const br = document.createElement("br");
  const dl = document.createElement("button");
  dl.className = "primary";
  dl.textContent = "下载原文件";
  dl.style.marginTop = "12px";
  dl.onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel, _fpCurrentTaskId, _fpCurrentLegacy, _fpCurrentArtifactId); };
  ph.appendChild(document.createElement("br"));
  ph.appendChild(br);
  ph.appendChild(dl);
  body.appendChild(ph);
}

export function closeFilePreview() {
  $("file-preview-modal").classList.remove("show");
  document.body.classList.remove("fp-open");
  $("file-preview-modal").style.removeProperty("--preview-bottom-inset");
  _clearZoom($("fp-body"));
  _clearPdfPreview($("fp-body"));
  _resetTextModes("fp");
  $("fp-body").innerHTML = "";
  _flushBlobUrls();
  _fpCurrentRel = null;
  _fpCurrentTaskId = "";
  _fpCurrentLegacy = false;
  _fpCurrentArtifactId = "";
  _fpCurrentWebPreviewId = "";
}

let _mpCurrentRel = null;
const _miniPreviewBlobUrls = new Set();
function _trackMiniBlobUrl(blob, mime) {
  const b = mime ? new Blob([blob], { type: mime }) : blob;
  const url = URL.createObjectURL(b);
  _miniPreviewBlobUrls.add(url);
  return url;
}
function _flushMiniBlobUrls() {
  for (const u of _miniPreviewBlobUrls) URL.revokeObjectURL(u);
  _miniPreviewBlobUrls.clear();
}
export function openPasteFilePreview(rel) {
  if ($("file-preview-modal").classList.contains("show")) openMiniFilePreview(rel);
  else openFilePreview(rel);
}
async function openMiniFilePreview(rel) {
  _mpCurrentRel = rel;
  const name = rel.split("/").pop() || rel;
  $("mp-name").textContent = name;
  $("mp-meta").textContent = "";
  const body = $("mp-body");
  _resetTextModes("mp");
  _clearZoom(body);
  _clearPdfPreview(body);
  body.className = "body center";
  body.innerHTML = `<div class="ph">加载中…</div>`;
  _flushMiniBlobUrls();
  $("mini-preview-modal").classList.add("show");

  const cat = _categorize(rel);
  if (cat === "ppt") {
    await _showPptAsPdf(rel, $("mp-body"), $("mp-meta"), _showMiniFallback);
    return;
  }
  try {
    const r = await fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
      headers: { "Authorization": "Bearer " + state.token },
    });
    if (!r.ok) throw new Error("HTTP " + r.status);
    const blob = await r.blob();
    $("mp-meta").textContent = humanSize(blob.size);
    if (cat === "text" || cat === "md" || cat === "html") {
      if (blob.size > PREVIEW_TEXT_MAX) {
        _showMiniFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
        return;
      }
      const text = await blob.text();
      if (cat === "md" || cat === "html") _showRenderableText("mp", cat, text);
      else _showSource(body, text);
      return;
    }
    if (blob.size > PREVIEW_BIN_MAX) {
      _showMiniFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
      return;
    }
    body.innerHTML = "";
    if (cat === "image") {
      body.className = "body center";
      const img = document.createElement("img");
      img.className = "preview-img";
      const _mime = /\.svg$/i.test(_mpCurrentRel || "") ? "image/svg+xml" : "";
      img.src = _trackMiniBlobUrl(blob, _mime);
      body.appendChild(img);
      _makeImageZoomable(body, img);
    } else if (cat === "video") {
      body.className = "body center";
      const v = document.createElement("video");
      v.className = "preview-video";
      v.src = _trackMiniBlobUrl(blob);
      v.controls = true;
      body.appendChild(v);
    } else if (cat === "pdf") {
      await _showPdfIn(body, blob, _showMiniFallback);
    } else {
      _showMiniFallback("暂不支持小窗预览此格式,请下载查看");
    }
  } catch (e) {
    if (e.status === 401) { closeMiniPreview(); logout(); return; }
    _showMiniFallback("加载失败:" + e.message);
  }
}
function _showMiniFallback(msg) {
  const body = $("mp-body");
  body.className = "body center";
  body.innerHTML = "";
  const ph = document.createElement("div");
  ph.className = "ph";
  ph.textContent = msg;
  const dl = document.createElement("button");
  dl.className = "primary";
  dl.textContent = "下载原文件";
  dl.style.marginTop = "12px";
  dl.onclick = () => { if (_mpCurrentRel) downloadFile(_mpCurrentRel); };
  ph.appendChild(document.createElement("br"));
  ph.appendChild(dl);
  body.appendChild(ph);
}
export function closeMiniPreview() {
  $("mini-preview-modal").classList.remove("show");
  _clearZoom($("mp-body"));
  _clearPdfPreview($("mp-body"));
  _resetTextModes("mp");
  $("mp-body").innerHTML = "";
  _flushMiniBlobUrls();
  _mpCurrentRel = null;
}
// 消息进入发送流程后，预览不应继续遮住正在生成的回答。主预览与粘贴附件的
// 次级预览可能同时存在，因此必须一起关闭。
export function closeAllPreviews() {
  if ($("file-preview-modal").classList.contains("show")) closeFilePreview();
  if ($("mini-preview-modal").classList.contains("show")) closeMiniPreview();
}
// 删文件时:若该 rel 正在主/小预览中则关掉(供 main 的 deletePastedFile 等调用,
// 不对外暴露内部 current-rel 状态)。
export function closePreviewIfShowing(rel) {
  if (_fpCurrentRel === rel) closeFilePreview();
  if (_mpCurrentRel === rel) closeMiniPreview();
}

_bindBodyWheel($("fp-body"));
_bindBodyWheel($("mp-body"));

$("fp-close").onclick = closeFilePreview;
$("fp-download").onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel, _fpCurrentTaskId, _fpCurrentLegacy, _fpCurrentArtifactId); };
$("fp-mode-preview").onclick = () => _renderTextMode("fp", "preview");
$("fp-mode-source").onclick = () => _renderTextMode("fp", "source");
$("file-preview-modal").addEventListener("click", (e) => {
  if (e.target.id === "file-preview-modal") closeFilePreview();
});
$("mp-close").onclick = closeMiniPreview;
$("mp-download").onclick = () => { if (_mpCurrentRel) downloadFile(_mpCurrentRel); };
$("mp-mode-preview").onclick = () => _renderTextMode("mp", "preview");
$("mp-mode-source").onclick = () => _renderTextMode("mp", "source");
$("mini-preview-modal").addEventListener("click", (e) => {
  if (e.target.id === "mini-preview-modal") closeMiniPreview();
});
