import { NextResponse } from "next/server";
import { promises as fs } from "fs";
import path from "path";
import os from "os";
import { execFile } from "child_process";
import { promisify } from "util";
import { buildDocumentBridgeContextWithActor } from "@/lib/documents/bridge";
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";

export const dynamic = "force-dynamic";

const execFileAsync = promisify(execFile);

type OutlineItem = {
  title: string;
  level: number; // 1-based
  page: number; // 1-based
};

type RequestPayload = {
  source:
    | { kind: "test"; name: string }
    | { kind: "url"; fileUrl: string; title?: string };
  ollama?: {
    // 兼容 MindmapSidebar 目前的默认值：完整 /api/chat 地址
    baseUrl?: string;
    model?: string;
  };
  options?: {
    maxPages?: number;
    maxCandidates?: number;
    preferProvider?: "online" | "ollama" | "heuristic";
    forceHeuristic?: boolean;
    ollamaTimeoutMs?: number;
    onlineTimeoutMs?: number;
  };
};

const TEST_PDF_DIR = path.join(process.cwd(), "test");

const safeResolveTestPdf = (name: string) => {
  const trimmed = (name ?? "").trim();
  if (!trimmed) return null;
  if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("..")) return null;
  if (!trimmed.toLowerCase().endsWith(".pdf")) return null;
  return path.join(TEST_PDF_DIR, trimmed);
};

const cleanExtractedText = (value: string) => {
  const v = String(value ?? "");
  // 1) 移除 NUL 与不可见控制字符
  // 2) 清理超长英文串（常见于扫描/坏编码提取产生的乱码）
  // 3) 统一空白（保留换行，便于标题/段落检测）
  return v
    .replace(/\u0000/g, "")
    .replace(/[\u0001-\u0008\u000B\u000C\u000E-\u001F]/g, " ")
    .replace(/\uFFFD/g, "") // � 替换字符（坏编码/抽取失败常见）
    .replace(/[\u200B-\u200D\uFEFF]/g, "") // 零宽字符
    .replace(/[A-Za-z]{8,}/g, "")
    .replace(/[ \t\f\v]+/g, " ")
    .replace(/\r\n/g, "\n")
    .replace(/\n{3,}/g, "\n\n")
    .trim();
};

const normalizeLine = (value: string) => cleanExtractedText(value);

const extractHeadingCandidates = (pages: { page: number; text: string }[], maxCandidates: number) => {
  const candidates: { page: number; line: string }[] = [];
  const seen = new Set<string>();

  const isMostlyGarbled = (line: string) => {
    const s = line.replace(/\s+/g, "");
    if (!s) return true;
    const len = s.length;
    if (len < 2) return true;
    const cjk = (s.match(/[\u4e00-\u9fff]/g) ?? []).length;
    const digits = (s.match(/\d/g) ?? []).length;
    const letters = (s.match(/[A-Za-z]/g) ?? []).length;
    // 太像“乱码/噪声”：基本没有中文且长度不短
    if (cjk === 0 && len >= 10) return true;
    // 非中文占比极高且长度较长
    if (len >= 14 && cjk / len < 0.12 && (letters + digits) / len > 0.55) return true;
    // 连续符号堆叠
    if (/^[\W_]{8,}$/.test(s)) return true;
    return false;
  };

  const isJunk = (line: string) => {
    const l = line.trim();
    if (!l) return true;
    if (l.length < 2) return true;
    if (l.length > 80) return true;
    if (/^第?\s*\d+\s*页$/i.test(l)) return true;
    if (/^(http|https):\/\//i.test(l)) return true;
    if (isMostlyGarbled(l)) return true;
    return false;
  };

  const patterns: { re: RegExp; weight: number }[] = [
    { re: /^第[一二三四五六七八九十]+[章节篇]\s*\S+/, weight: 5 },
    { re: /^\d+(\.\d+){0,4}\s+\S+/, weight: 5 },
    { re: /^[一二三四五六七八九十]+[、.．]\s*\S+/, weight: 4 },
    { re: /^[（(][一二三四五六七八九十]+[)）]\s*\S+/, weight: 3 },
    { re: /^（?\d+）\s*\S+/, weight: 2 },
  ];

  const add = (page: number, line: string) => {
    const key = `${page}:${line}`;
    if (seen.has(key)) return;
    seen.add(key);
    candidates.push({ page, line });
  };

  for (const p of pages) {
    const lines = (p.text || "")
      .split(/\r?\n/)
      .map((l) => normalizeLine(l))
      .filter((l) => !isJunk(l));

    // 先按规则挑“像标题”的行
    for (const line of lines) {
      let score = 0;
      for (const pat of patterns) {
        if (pat.re.test(line)) score += pat.weight;
      }
      // 中文文档常见：标题行标点少、较短
      if (line.length <= 30) score += 1;
      if (!/[。？！；，]/.test(line)) score += 1;

      if (score >= 5) {
        add(p.page, line);
      }
      if (candidates.length >= maxCandidates) break;
    }
    if (candidates.length >= maxCandidates) break;

    // 若这一页没有候选，则取前若干行兜底（避免完全无结构）
    const hasCandidateThisPage = candidates.some((c) => c.page === p.page);
    if (!hasCandidateThisPage) {
      for (const line of lines.slice(0, 3)) {
        add(p.page, line);
        if (candidates.length >= maxCandidates) break;
      }
    }
    if (candidates.length >= maxCandidates) break;
  }

  return candidates.slice(0, maxCandidates);
};

type TocEntry = { title: string; page: number; sourcePage: number };

const extractTocEntries = (pages: { page: number; text: string }[], maxPage: number, limit = 120): TocEntry[] => {
  const linesForPage = (text: string) =>
    String(text || "")
      .split(/\r?\n/)
      .map((l) => cleanExtractedText(l))
      .map((l) => l.replace(/[·•●]+/g, "·").trim())
      .filter(Boolean);

  const tocLineToEntry = (line: string, maxP: number): { title: string; page: number } | null => {
    const l = line.replace(/\s+/g, " ").trim();
    if (!l) return null;
    if (l.length < 3 || l.length > 120) return null;
    if (/^目录\s*$/.test(l) || /^目\s*录\s*$/.test(l)) return null;
    if (/^第?\s*\d+\s*页$/i.test(l)) return null;
    if (/(copyright|all rights|isbn)/i.test(l)) return null;
    if (/[�]/.test(l)) return null;

    // 常见目录行：标题 + 引导点线 + 页码
    const m1 = l.match(/^(.+?)\s*(?:\.{2,}|…{2,}|·{2,}|—{2,}|-{2,}|_{2,})\s*(\d{1,3})\s*$/);
    if (m1?.[1] && m1[2]) {
      const title = m1[1].replace(/\s+/g, " ").trim();
      const page = Number(m1[2]);
      if (!title) return null;
      if (!Number.isFinite(page) || page < 1 || page > maxP) return null;       
      return { title, page: Math.floor(page) };
    }

    // 备选：末尾直接跟页码（无引导线）
    const m2 = l.match(/^(.+?)\s+(\d{1,3})\s*$/);
    if (m2?.[1] && m2[2]) {
      const title = m2[1].replace(/\s+/g, " ").trim();
      const page = Number(m2[2]);
      if (!title) return null;
      if (!Number.isFinite(page) || page < 1 || page > maxP) return null;       
      // 过滤像“正文句子末尾刚好有数字”的情况：要求 title 看起来像标题
      const looksLikeHeading =
        /^第[一二三四五六七八九十]+[章节篇]\b/.test(title) ||
        /^\d+(\.\d+){0,4}\s+/.test(title) ||
        /^[一二三四五六七八九十]+[、.．]\s*/.test(title) ||
        /^[（(][一二三四五六七八九十]+[)）]\s*/.test(title);
      if (!looksLikeHeading) return null;
      return { title, page: Math.floor(page) };
    }

    return null;
  };

  const tocScore = (text: string) => {
    const lines = linesForPage(text);
    if (!lines.length) return 0;
    let score = 0;
    if (lines.some((l) => /^(目录|目\s*录)\s*$/.test(l))) score += 8;
    // 目录页通常含有大量“点线+页码”形式
    const leaderHits = lines.filter((l) => /(\.{2,}|…{2,}|·{2,}|—{2,}|-{2,}|_{2,})\s*\d{1,3}\s*$/.test(l)).length;
    score += Math.min(12, leaderHits);
    const numberedHits = lines.filter((l) => /^第[一二三四五六七八九十]+[章节篇]/.test(l) || /^\d+(\.\d+){0,3}\s+/.test(l)).length;
    score += Math.min(6, Math.floor(numberedHits / 2));
    return score;
  };

  const tocPages = [...pages]
    .map((p) => ({ page: p.page, text: p.text, score: tocScore(p.text) }))
    .filter((p) => p.score >= 10)
    .sort((a, b) => b.score - a.score || a.page - b.page)
    .slice(0, 3);

  const out: TocEntry[] = [];
  const seen = new Set<string>();
  for (const p of tocPages) {
    const lines = linesForPage(p.text);
    for (const line of lines) {
      const entry = tocLineToEntry(line, maxPage);
      if (!entry) continue;
      const title = entry.title.replace(/\s+/g, " ").trim();
      if (!title) continue;
      const key = `${entry.page}:${title}`;
      if (seen.has(key)) continue;
      seen.add(key);
      out.push({ title, page: entry.page, sourcePage: p.page });
      if (out.length >= limit) break;
    }
    if (out.length >= limit) break;
  }

  out.sort((a, b) => (a.page - b.page) || a.title.localeCompare(b.title, "zh"));
  return out.slice(0, limit);
};

const inferLevelFromTitle = (title: string) => {
  const t = title.trim();
  const m = t.match(/^(\d+(?:\.\d+){0,4})\s+/);
  if (m?.[1]) {
    return Math.max(1, m[1].split(".").length);
  }
  if (/^第[一二三四五六七八九十]+[章节篇]/.test(t)) return 1;
  if (/^[一二三四五六七八九十]+[、.．]/.test(t)) return 1;
  if (/^[（(][一二三四五六七八九十]+[)）]/.test(t)) return 2;
  if (/^（?\d+）/.test(t)) return 2;
  return 2;
};

const buildOutlineHeuristic = (candidates: { page: number; line: string }[]): OutlineItem[] => {
  const out: OutlineItem[] = [];
  const seen = new Set<string>();
  for (const c of candidates) {
    const title = c.line.trim();
    if (!title) continue;
    const key = `${c.page}:${title}`;
    if (seen.has(key)) continue;
    seen.add(key);
    out.push({
      title,
      level: Math.min(4, Math.max(1, inferLevelFromTitle(title))),
      page: Math.max(1, c.page),
    });
  }
  return out;
};

const extractJsonArray = (value: string): unknown[] | null => {
  const s = value ?? "";
  const start = s.indexOf("[");
  const end = s.lastIndexOf("]");
  if (start === -1 || end === -1 || end <= start) return null;
  try {
    const parsed = JSON.parse(s.slice(start, end + 1));
    return Array.isArray(parsed) ? parsed : null;
  } catch {
    return null;
  }
};

const normalizeOutlineItems = (raw: unknown, maxPage: number): OutlineItem[] => {
  if (!Array.isArray(raw)) return [];
  const items: OutlineItem[] = [];
  const seen = new Set<string>();
  for (const it of raw) {
    if (!it || typeof it !== "object") continue;
    const obj = it as Record<string, unknown>;
    const title = typeof obj.title === "string" ? obj.title.trim() : "";
    const levelNum = typeof obj.level === "number" ? obj.level : Number(obj.level);
    const pageNum = typeof obj.page === "number" ? obj.page : Number(obj.page);
    if (!title) continue;
    const level = Number.isFinite(levelNum) ? Math.max(1, Math.min(6, Math.floor(levelNum))) : 1;
    const page = Number.isFinite(pageNum) ? Math.max(1, Math.min(maxPage, Math.floor(pageNum))) : 1;
    const key = `${page}:${level}:${title}`;
    if (seen.has(key)) continue;
    seen.add(key);
    items.push({ title, level, page });
  }
  // 尽量保证输出按页码递增，避免树构建乱序
  items.sort((a, b) => (a.page - b.page) || (a.level - b.level) || a.title.localeCompare(b.title, "zh"));
  return items;
};

const buildMindmapTree = (rootTitle: string, outline: OutlineItem[], linkForPage: (p: number) => string) => {
  const cryptoAny = crypto as unknown as { randomUUID?: () => string };
  const uid = () => (typeof cryptoAny.randomUUID === "function" ? cryptoAny.randomUUID() : Math.random().toString(36).slice(2));

  const root = { data: { text: rootTitle, uid: uid() }, children: [] as any[] };
  const stack: any[] = [root];

  const pushNode = (item: OutlineItem) => {
    const level = Math.max(1, Math.min(6, item.level));
    while (stack.length > level) stack.pop();
    while (stack.length < level) stack.push(stack[stack.length - 1]);
    const parent = stack[level - 1] ?? root;
    const href = linkForPage(item.page);
    const node = {
      data: {
        text: item.title,
        uid: uid(),
        hyperlink: href,
        // 预留：后续可用于“引用详情/跳转信息”
        refs: [{ kind: "pdf", page: item.page, title: item.title }],
      },
      children: [] as any[],
    };
    parent.children.push(node);
    stack[level] = node;
  };

  outline.forEach(pushNode);
  return root;
};

type AiPoint = { text: string; page?: number };
type AiSection = { title: string; page: number; points?: AiPoint[] | string[] };
type AiChapter = { title: string; page: number; sections?: AiSection[] };
type AiPlan = { title?: string; chapters?: AiChapter[] };

const isChapterTitle = (line: string) => /^第[一二三四五六七八九十]+章/.test(line.trim());
const isSectionTitle = (line: string) => /^第[一二三四五六七八九十]+节/.test(line.trim());
const isPointTitle = (line: string) => {
  const t = line.trim();
  if (!t) return false;
  if (isChapterTitle(t) || isSectionTitle(t)) return false;
  if (/^\d+(\.\d+){2,}\s+/.test(t)) return true; // 1.1.1
  if (/^[一二三四五六七八九十]+[、.．]\s*\S+/.test(t)) return true; // 一、
  if (/^[（(][一二三四五六七八九十]+[)）]\s*\S+/.test(t)) return true; // （一）
  if (/^（?\d+）\s*\S+/.test(t)) return true; // （1）/1）
  return false;
};

const enrichPlanPointsFromCandidates = (
  plan: AiPlan,
  candidates: { page: number; line: string }[],
) => {
  if (!plan.chapters?.length) return plan;

  const cleaned = candidates
    .map((c) => ({ page: c.page, line: cleanExtractedText(c.line) }))
    .map((c) => ({ ...c, line: c.line.trim() }))
    .filter((c) => c.line && c.line.length <= 80)
    .filter((c) => c.line.length >= 6)
    .filter((c) => !/^(目录|目\s*录)$/u.test(c.line))
    .filter((c) => !/[《〈『【（(]\s*$/u.test(c.line))
    .filter((c) => !/^[0-9\s.·•\-–—_]{6,}$/.test(c.line));

  const findPointsInRange = (fromPage: number, toPage: number, exclude: Set<string>) => {
    const points: AiPoint[] = [];
    const seen = new Set<string>();
    for (const c of cleaned) {
      if (c.page < fromPage || c.page > toPage) continue;
      const t = c.line.trim();
      if (!t) continue;
      if (exclude.has(t)) continue;
      if (/[《〈『【（(]\s*$/u.test(t)) continue;
      if (!isPointTitle(t)) continue;
      if (seen.has(t)) continue;
      seen.add(t);
      points.push({ text: t, page: c.page });
      if (points.length >= 6) break;
    }
    return points;
  };

  for (let ci = 0; ci < plan.chapters.length; ci++) {
    const ch = plan.chapters[ci];
    const nextChapterPage = plan.chapters[ci + 1]?.page ?? Number.POSITIVE_INFINITY;
    const sections = ch.sections ?? [];
    for (let si = 0; si < sections.length; si++) {
      const sec = sections[si];
      const nextSectionPage = sections[si + 1]?.page ?? nextChapterPage;
      const fromPage = Math.max(1, sec.page);
      // 控制范围：最多跨 2 页，避免引入别的章节噪声
      const toPage = Math.max(fromPage, Math.min(Number.isFinite(nextSectionPage) ? nextSectionPage : fromPage, fromPage + 2));
      const exclude = new Set<string>([ch.title, sec.title]);
      const points = findPointsInRange(fromPage, toPage, exclude);
      if (points.length) {
        sec.points = points;
        continue;
      }

      // 兜底：同页取 2~3 个候选行做要点
      const fallback = cleaned
        .filter((c) => c.page == fromPage)
        .map((c) => c.line.trim())
        .filter((t) => t && !exclude.has(t))
        .filter((t) => t.length >= 6)
        .filter((t) => !/[《〈『【（(]\s*$/u.test(t))
        .filter((t) => !/^[0-9\s.·•\-–—_]{6,}$/.test(t))
        .slice(0, 3)
        .map((t) => ({ text: t, page: fromPage }));
      if (fallback.length) sec.points = fallback;
    }
  }

  return plan;
};

const enrichPlanPointsFromPages = (plan: AiPlan, pages: { page: number; text: string }[]) => {
  if (!plan.chapters?.length) return plan;

  const getLinesForRange = (fromPage: number, toPage: number) => {
    const out: { page: number; line: string }[] = [];
    for (const p of pages) {
      if (p.page < fromPage || p.page > toPage) continue;
      const lines = String(p.text || "")
        .split(/\r?\n/)
        .map((l) => cleanExtractedText(l))
        .map((l) => l.replace(/[ \t]+/g, " ").trim())
        .filter(Boolean);
      for (const line of lines) out.push({ page: p.page, line });
    }
    return out;
  };

  const isNoiseLine = (line: string) => {
    const t = line.trim();
    if (!t) return true;
    if (t.length < 6) return true;
    if (t.length > 90) return true;
    if (/^(目录|目\s*录)\s*$/.test(t)) return true;
    if (/^第?\s*\d+\s*页$/.test(t)) return true;
    if (/^(http|https):\/\//i.test(t)) return true;
    if (/^[\W_]{8,}$/.test(t)) return true;
    // 孤立的引号/括号开头结尾（常见于页眉/装饰符）
    if (/[《〈『【（(]\s*$/.test(t)) return true;
    // 极低中文占比的长句：更像乱码或页眉/脚注
    const s = t.replace(/\s+/g, "");
    const cjk = (s.match(/[\u4e00-\u9fff]/g) ?? []).length;
    if (s.length >= 18 && cjk / s.length < 0.12) return true;
    return false;
  };

  const scoreLine = (line: string) => {
    const t = line.trim();
    let score = 0;
    if (/[。；：]/.test(t)) score += 2;
    if (/[，、]/.test(t)) score += 1;
    if (isPointTitle(t)) score += 2;
    if (/^(?:-|\*|•|·)\s*\S+/.test(t)) score += 2;
    if (/^[（(][一二三四五六七八九十]+[)）]/.test(t)) score += 2;
    if (/^\d+(\.\d+){1,}\s+/.test(t)) score += 2;
    if (t.length >= 10 && t.length <= 45) score += 1;
    return score;
  };

  for (let ci = 0; ci < plan.chapters.length; ci++) {
    const ch = plan.chapters[ci];
    const nextChapterPage = plan.chapters[ci + 1]?.page ?? Number.POSITIVE_INFINITY;
    const sections = ch.sections ?? [];
    for (let si = 0; si < sections.length; si++) {
      const sec = sections[si];
      const nextSectionPage = sections[si + 1]?.page ?? nextChapterPage;
      const fromPage = Math.max(1, sec.page);
      // 内容抽取范围：最多跨 3 页（更接近“内容”而不只标题）
      const toPage = Math.max(
        fromPage,
        Math.min(Number.isFinite(nextSectionPage) ? nextSectionPage : fromPage, fromPage + 3),
      );
      const exclude = new Set<string>([ch.title, sec.title]);

      const pool = getLinesForRange(fromPage, toPage)
        .map((x) => ({ ...x, line: x.line.trim() }))
        .filter((x) => x.line && !exclude.has(x.line))
        .filter((x) => !isNoiseLine(x.line))
        .filter((x) => !isChapterTitle(x.line) && !isSectionTitle(x.line));

      const seen = new Set<string>();
      const scored = pool
        .filter((x) => {
          if (seen.has(x.line)) return false;
          seen.add(x.line);
          return true;
        })
        .map((x) => ({ ...x, score: scoreLine(x.line) }))
        .sort((a, b) => b.score - a.score || a.page - b.page);

      const picked = scored.slice(0, 6).map((x) => ({ text: x.line, page: x.page }));
      if (picked.length) {
        sec.points = picked;
      }
    }
  }

  return plan;
};

const normalizeAiPlan = (raw: unknown, maxPage: number): AiPlan | null => {
  if (!raw || typeof raw !== "object") return null;
  const obj = raw as Record<string, unknown>;
  const title = typeof obj.title === "string" ? obj.title.trim() : undefined;
  const chaptersRaw = obj.chapters;
  if (!Array.isArray(chaptersRaw)) return null;

  const clampPage = (p: unknown) => {
    const n = typeof p === "number" ? p : Number(p);
    if (!Number.isFinite(n)) return 1;
    return Math.max(1, Math.min(maxPage, Math.floor(n)));
  };

  const chapters: AiChapter[] = [];
  for (const ch of chaptersRaw) {
    if (!ch || typeof ch !== "object") continue;
    const c = ch as Record<string, unknown>;
    const chTitle = typeof c.title === "string" ? c.title.trim() : "";
    if (!chTitle) continue;
    const chPage = clampPage(c.page);
    const sectionsRaw = c.sections;
    const sections: AiSection[] = [];
    if (Array.isArray(sectionsRaw)) {
      for (const sec of sectionsRaw) {
        if (!sec || typeof sec !== "object") continue;
        const s = sec as Record<string, unknown>;
        const secTitle = typeof s.title === "string" ? s.title.trim() : "";
        if (!secTitle) continue;
        const secPage = clampPage(s.page ?? chPage);
        let points: AiPoint[] | undefined;
        if (Array.isArray(s.points)) {
          points = s.points
            .map((p) => {
              if (typeof p === "string") return { text: p.trim(), page: secPage };
              if (p && typeof p === "object") {
                const po = p as Record<string, unknown>;
                const text = typeof po.text === "string" ? po.text.trim() : "";
                if (!text) return null;
                return { text, page: clampPage(po.page ?? secPage) };
              }
              return null;
            })
            .filter(Boolean) as AiPoint[];
        }
        sections.push({ title: secTitle, page: secPage, points });
      }
    }
    chapters.push({ title: chTitle, page: chPage, sections });
  }

  if (!chapters.length) return null;
  return { title, chapters };
};

const stripLeadingNumbering = (title: string) => {
  const t = String(title || "").trim();
  if (!t) return "";
  return (
    t
      // 常见：二、xxx / 2. xxx / 2．xxx
      .replace(/^[一二三四五六七八九十]+[、.．]\s*/u, "")
      .replace(/^\d+(\.\d+){0,3}\s*[、.．]?\s*/u, "")
      // （一）xxx / (1) xxx / （1）xxx
      .replace(/^[（(][一二三四五六七八九十]+[)）]\s*/u, "")
      .replace(/^（?\d+）\s*/u, "")
      .trim()
  );
};

const normalizePlanTitle = (planTitle: string | undefined, fallbackTitle: string) => {
  const t = String(planTitle || "").trim();
  if (!t) return fallbackTitle;
  if (/^(目录|目\s*录)$/u.test(t)) return fallbackTitle;
  if (/^(文档结构|文档大纲|文档目录|大纲|结构)$/u.test(t)) return fallbackTitle;
  if (/document\s+outline/i.test(t)) return fallbackTitle;
  // 纯英文/数字标题通常是网关默认值或无意义占位
  if (/^[A-Za-z0-9 _.-]{6,}$/u.test(t) && !/[\u4e00-\u9fff]/u.test(t)) return fallbackTitle;
  // 太短/过于泛化的标题，直接用文件名/提取标题
  if (t.length <= 2) return fallbackTitle;
  return t;
};

const ensureChapterSectionLabels = (plan: AiPlan) => {
  const chapters = plan.chapters ?? [];
  for (let ci = 0; ci < chapters.length; ci++) {
    const ch = chapters[ci];
    const rawChTitle = String(ch.title || "").trim();
    const chTitleBase = stripLeadingNumbering(rawChTitle) || rawChTitle;
    const hasChapterWord = /章/u.test(rawChTitle) || /^第[一二三四五六七八九十]+章/u.test(rawChTitle);
    ch.title = hasChapterWord ? rawChTitle : `第${ci + 1}章 ${chTitleBase}`.trim();

    const sections = ch.sections ?? [];
    for (let si = 0; si < sections.length; si++) {
      const sec = sections[si];
      const rawSecTitle = String(sec.title || "").trim();
      const secTitleBase = stripLeadingNumbering(rawSecTitle) || rawSecTitle;
      const hasSectionWord = /节/u.test(rawSecTitle) || /^第[一二三四五六七八九十]+节/u.test(rawSecTitle);
      sec.title = hasSectionWord ? rawSecTitle : `第${si + 1}节 ${secTitleBase}`.trim();
    }
  }
  return plan;
};

const postProcessAiPlan = (plan: AiPlan, fallbackTitle: string) => {
  plan.title = normalizePlanTitle(plan.title, fallbackTitle);
  return ensureChapterSectionLabels(plan);
};

const ensurePlanHasChapterAndSections = (
  plan: AiPlan,
  pdfTitle: string,
  tocEntries: TocEntry[],
  candidates: { page: number; line: string }[],
  maxPage: number,
) => {
  const chapters = plan.chapters ?? [];
  const hasAnySections = chapters.some((c) => Array.isArray(c.sections) && c.sections.length > 0);
  if (hasAnySections) return plan;

  const buildSectionsFromEntries = (entries: { title: string; page: number }[]) => {
    const out: AiSection[] = [];
    const seen = new Set<string>();
    for (const e of entries) {
      const raw = String(e.title || "").trim();
      // 目录项/AI 可能会自带“第X章/第X节”，这里统一剥离后再由 ensureChapterSectionLabels 重新加前缀
      const noChapterPrefix = raw.replace(/^第[一二三四五六七八九十0-9]+章\s*/u, "").trim();
      const noSectionPrefix = noChapterPrefix.replace(/^第[一二三四五六七八九十0-9]+节\s*/u, "").trim();
      const title = stripLeadingNumbering(noSectionPrefix) || noSectionPrefix || raw;
      if (!title) continue;
      const page = Math.max(1, Math.min(maxPage, Math.floor(e.page || 1)));
      const key = `${page}:${title}`;
      if (seen.has(key)) continue;
      seen.add(key);
      out.push({ title, page });
      if (out.length >= 12) break;
    }
    return out;
  };

  // 1) 若 AI 把所有条目都当成“chapter”，则降级：合并成一个 chapter，其它变 section
  if (chapters.length >= 2) {
    const asSections = buildSectionsFromEntries(
      chapters.map((c) => ({ title: String(c.title || ""), page: Math.max(1, Math.min(maxPage, c.page || 1)) })),
    );
    const firstPage = asSections[0]?.page ?? Math.max(1, Math.min(maxPage, chapters[0]?.page ?? 1));
    const merged: AiPlan = {
      title: plan.title,
      chapters: [
        {
          title: `第1章 ${pdfTitle}`.trim(),
          page: firstPage,
          sections: asSections,
        },
      ],
    };
    return ensureChapterSectionLabels(merged);
  }

  // 2) 优先使用目录识别结果作为 sections
  const tocSections = buildSectionsFromEntries(tocEntries);
  if (tocSections.length) {
    const firstPage = tocSections[0]?.page ?? 1;
    const merged: AiPlan = {
      title: plan.title,
      chapters: [
        {
          title: `第1章 ${pdfTitle}`.trim(),
          page: firstPage,
          sections: tocSections,
        },
      ],
    };
    return ensureChapterSectionLabels(merged);
  }

  // 3) 最后用候选标题补一个 section 列表
  const candidateSections = buildSectionsFromEntries(
    candidates
      .map((c) => ({ title: c.line, page: c.page }))
      .filter((c) => c.title && c.title.length <= 80)
      .filter((c) => !/^(目录|目\s*录)$/u.test(c.title.trim())),
  );
  const firstPage = candidateSections[0]?.page ?? 1;
  const merged: AiPlan = {
    title: plan.title,
    chapters: [
      {
        title: `第1章 ${pdfTitle}`.trim(),
        page: firstPage,
        sections: candidateSections,
      },
    ],
  };
  return ensureChapterSectionLabels(merged);
};

const buildMindmapFromPlan = (
  rootTitle: string,
  plan: AiPlan,
  linkForPage: (p: number) => string,
) => {
  const cryptoAny = crypto as unknown as { randomUUID?: () => string };
  const uid = () =>
    typeof cryptoAny.randomUUID === "function"
      ? cryptoAny.randomUUID()
      : Math.random().toString(36).slice(2);

  const title = normalizePlanTitle(plan.title, rootTitle);
  const root = { data: { text: title, uid: uid() }, children: [] as any[] };

  const chapters = plan.chapters ?? [];
  for (const ch of chapters) {
    const chNode = {
      data: {
        text: ch.title,
        uid: uid(),
        hyperlink: linkForPage(ch.page),
        refs: [{ kind: "pdf", page: ch.page, title: ch.title }],
      },
      children: [] as any[],
    };

    const sections = ch.sections ?? [];
    for (const sec of sections) {
      const secNode = {
        data: {
          text: sec.title,
          uid: uid(),
          hyperlink: linkForPage(sec.page),
          refs: [{ kind: "pdf", page: sec.page, title: sec.title }],
        },
        children: [] as any[],
      };

      const points = Array.isArray(sec.points) ? sec.points : [];
      for (const p of points.slice(0, 10)) {
        const rawText = typeof p === "string" ? p : p.text;
        const text = cleanExtractedText(rawText || "");
        if (!text) continue;
        const page = Math.max(1, (typeof p === "string" ? sec.page : (p.page ?? sec.page)));
        secNode.children.push({
          data: {
            text,
            uid: uid(),
            hyperlink: linkForPage(page),
            refs: [{ kind: "pdf", page, title: text }],
          },
          children: [] as any[],
        });
      }

      chNode.children.push(secNode);
    }

    root.children.push(chNode);
  }

  return root;
};

const callPdfExtractor = async (pdfFile: string, maxPages: number) => {
  const script = path.join(process.cwd(), "scripts", "mindmap_ai_pdf_extract.py");
  const run = async (cmd: string, args: string[]) => {
    const { stdout } = await execFileAsync(cmd, args, { windowsHide: true, maxBuffer: 1024 * 1024 * 20 });
    return stdout;
  };
  try {
    return await run("python", [script, "--input", pdfFile, "--max-pages", String(maxPages)]);
  } catch {
    return await run("py", ["-3", script, "--input", pdfFile, "--max-pages", String(maxPages)]);
  }
};

const downloadToTemp = async (fileUrl: string) => {
  const res = await fetch(fileUrl);
  if (!res.ok) throw new Error(`下载 PDF 失败：${res.status}`);
  const arrayBuffer = await res.arrayBuffer();
  const buffer = Buffer.from(arrayBuffer);
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "mindmap-ai-"));
  const file = path.join(dir, `source-${Date.now()}.pdf`);
  await fs.writeFile(file, buffer);
  return { dir, file };
};

const safeFetchableUrl = (raw: string) => {
  const value = (raw ?? "").trim();
  if (!value) return null;
  try {
    const u = new URL(value);
    const host = u.hostname;
    // 基础防护：只允许本机或 supabase（同域/存储域名变化时可扩展）
    if (host === "127.0.0.1" || host === "localhost") return u.toString();
    const supabaseUrl = getMnoteRuntimeConfig().supabaseUrl ?? "";
    if (supabaseUrl) {
      const supaHost = new URL(supabaseUrl).hostname;
      if (host === supaHost) return u.toString();
    }
    // 若没有配置 supabase URL，则仍允许 https（但会有 SSRF 风险，默认不放开）
    return null;
  } catch {
    return null;
  }
};

const buildOutlineWithOllama = async (
  baseUrl: string,
  model: string,
  candidates: { page: number; line: string }[],
  maxPage: number,
  timeoutMs: number,
): Promise<OutlineItem[] | null> => {
  const prompt = [
    "你是一个“文档结构抽取器”。",
    "我将给你一组来自 PDF 的候选标题行（每行都带页码）。请输出一个 JSON 数组，每个元素格式为：",
    '{ "title": string, "level": number, "page": number }',
    "",
    "要求：",
    "- level 从 1 开始，越小越靠近中心主题；最多 4。",
    "- title 要尽量保留原文标题含义，但去掉重复空格。",
    "- page 必须是 1-based 页码。",
    "- 只输出 JSON 数组本身，不要 Markdown，不要解释，不要额外字段。",
    "",
    "候选标题：",
    ...candidates.map((c, idx) => `${idx + 1}. [p${c.page}] ${c.line}`),
  ].join("\n");

  const controller = new AbortController();
  const t = setTimeout(() => controller.abort(), Math.max(500, timeoutMs || 0));
  const res = await fetch(baseUrl, {
    method: "POST",
    signal: controller.signal,
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      model,
      stream: false,
      messages: [
        { role: "system", content: "只输出严格 JSON，不要代码块。" },
        { role: "user", content: prompt },
      ],
    }),
  }).finally(() => clearTimeout(t));
  if (!res.ok) return null;
  const json = (await res.json().catch(() => null)) as any;
  const content = String(json?.message?.content ?? "");
  const arr = extractJsonArray(content);
  if (!arr) return null;
  const normalized = normalizeOutlineItems(arr, maxPage);
  return normalized.length ? normalized : null;
};

export async function POST(request: Request) {
  const payload = (await request.json().catch(() => null)) as RequestPayload | null;
  if (!payload?.source) {
    return NextResponse.json({ error: "缺少 source" }, { status: 400 });
  }

  const maxPages = Math.max(1, Math.min(200, payload.options?.maxPages ?? 30));
  const maxCandidates = Math.max(10, Math.min(200, payload.options?.maxCandidates ?? 80));
  const forceHeuristic = Boolean(payload.options?.forceHeuristic);
  const ollamaTimeoutMs = Math.max(500, Math.min(60_000, payload.options?.ollamaTimeoutMs ?? 8000));
  const onlineTimeoutMs = Math.max(500, Math.min(120_000, payload.options?.onlineTimeoutMs ?? 25_000));
  const preferProvider = payload.options?.preferProvider ?? "online";

  let pdfTitle = "文档";
  let pdfUrlForLink: ((p: number) => string) | null = null;
  let pageLinkPattern = "#page={page}";
  let cleanupDir: string | null = null;
  let pdfFile: string | null = null;

  try {
    if (payload.source.kind === "test") {
      if (process.env.NODE_ENV === "production") {
        return NextResponse.json({ error: "生产环境不支持 test 源" }, { status: 404 });
      }
      const testName = payload.source.name;
      const file = safeResolveTestPdf(testName);
      if (!file) {
        return NextResponse.json({ error: "test 文件名非法" }, { status: 400 });
      }
      pdfFile = file;
      pdfTitle = testName.replace(/\.pdf$/i, "");
      pdfUrlForLink = (p) => `/api/mindmap-ai/test-pdf?name=${encodeURIComponent(testName)}#page=${p}`;
      pageLinkPattern = `/api/mindmap-ai/test-pdf?name=${encodeURIComponent(testName)}#page={page}`;
    } else if (payload.source.kind === "url") {
      const safe = safeFetchableUrl(payload.source.fileUrl);
      if (!safe) {
        return NextResponse.json({ error: "fileUrl 不允许（仅允许本机或 supabase 域名）" }, { status: 400 });
      }
      const downloaded = await downloadToTemp(safe);
      cleanupDir = downloaded.dir;
      pdfFile = downloaded.file;
      pdfTitle = payload.source.title?.trim() || "文档";
      pdfUrlForLink = (p) => `${safe}#page=${p}`;
      pageLinkPattern = `${safe}#page={page}`;
    } else {
      return NextResponse.json({ error: "不支持的 source.kind" }, { status: 400 });
    }

    const extractedRaw = await callPdfExtractor(pdfFile, maxPages);
    const extracted = JSON.parse(extractedRaw) as {
      meta?: { title?: string | null };
      pages: { page: number; text: string }[];
      totalPages: number;
    };

    const maxPage = Math.max(1, Math.min(extracted.totalPages || maxPages, maxPages));
    if (extracted.meta?.title && String(extracted.meta.title).trim()) {
      pdfTitle = String(extracted.meta.title).trim();
    }

    const pages = (extracted.pages ?? []).slice(0, maxPage);
    // 先对分页文本做清洗，降低乱码对候选抽取的影响
    const cleanedPages = pages.map((p) => ({ page: p.page, text: cleanExtractedText(p.text || "") }));
    const candidates = extractHeadingCandidates(cleanedPages, maxCandidates);
    const tocEntries = extractTocEntries(cleanedPages, maxPage);

    let providerUsed: "online" | "ollama" | "heuristic" = "heuristic";
    let plan: AiPlan | null = null;
    let outline: OutlineItem[] | null = null;
    let onlineAvailable = false;
    let onlineAttempted = false;
    let onlineSucceeded = false;
    let onlineError: string | null = null;
    const ollamaBaseUrl = payload.ollama?.baseUrl?.trim() || "http://localhost:11434/api/chat";
    const ollamaModel = payload.ollama?.model?.trim() || "qwen3:30b-a3b-instruct-2507-q4_K_M";

    const linkForPage = pdfUrlForLink ?? ((p) => `#page=${p}`);

    // 1) 在线 AI 优先：输出“章→节→要点”结构
    if (!forceHeuristic && preferProvider === "online") {
      const cfg = await loadOnlineAiConfig().catch(() => null);
      if (cfg) {
        try {
          onlineAvailable = true;
          onlineAttempted = true;
          const system = [
            "你是一个“PDF 思维导图结构化生成器”。",
            "你必须输出严格 JSON（不要 Markdown/不要代码块/不要解释）。",
            "目标结构必须是：章（第一层）→ 节（第二层）。",
            "你要忽略无意义乱码/随机英文串；输出标题必须是可读中文（或常见编号标题，如 2.1 / （一））。",
            "只输出章/节骨架：不要输出 points 字段，不要输出正文。",
          ].join("\n");

          const inputLines =
            tocEntries.length > 0
              ? tocEntries.slice(0, Math.min(60, tocEntries.length)).map((e) => `[p${e.page}] ${e.title}`)
              : candidates.slice(0, Math.min(60, candidates.length)).map((c) => `[p${c.page}] ${c.line}`);

          const user = [
            "请根据下方“目录/标题行（带页码）”生成思维导图结构 JSON。",
            "",
            "输出 JSON Schema：",
            "{",
            '  "title": string,',
            '  "chapters": [',
            "    {",
            '      "title": string,',
            '      "page": number,',
            '      "sections": [',
            "        {",
            '          "title": string,',
            '          "page": number',
            "        }",
            "      ]",
            "    }",
            "  ]",
            "}",
            "",
            "硬性要求：",
            "- 只允许输出上述字段；不要输出其它字段。",
            "- chapters 必须按页码递增；sections 也按页码递增。",
            "- page 为 1-based 页码（必须是整数）。",
            "- title 不能包含无意义乱码（例如连续英文乱序、无意义符号堆叠）。",
            "- 若候选行里存在“第X章/第X节”，优先按该结构组织。",
            "- 输出规模控制：最多 10 个 chapter；每个 chapter 最多 10 个 section。",
            "",
            `输入行来源：${tocEntries.length > 0 ? "目录识别" : "标题候选"}`,
            "输入行（格式：[p页码] 文本）：",
            ...inputLines,
          ].join("\n");

          const callOnce = async (userText: string, maxOutTokens: number) => {
            const { text, raw } = await openAiCompatibleChat(
              [
                { role: "system", content: system },
                { role: "user", content: userText },
              ],
              {
                baseUrl: cfg.baseUrl,
                apiKey: cfg.apiKey,
                model: cfg.model,
                timeoutMs: onlineTimeoutMs,
                maxTokens: maxOutTokens,
                maxCompletionTokens: maxOutTokens,
                responseFormat: "json_object",
              },
            );
            const parsed = tryExtractJsonObject(text);
            if (!parsed) {
              const finishReason = String((raw as any)?.choices?.[0]?.finish_reason ?? "");
              const preview = String(text || "").slice(0, 220).replace(/\s+/g, " ").trim();
              throw new Error(
                `在线 AI 未返回可解析 JSON（finish_reason=${finishReason || "unknown"}）：${preview || "(empty)"}`,
              );
            }
            return parsed;
          };

          // attempt 1：优先使用目录识别（更少噪声），并加大输出 token 限制减少截断概率
          let json = await callOnce(user, 2600);
          plan = normalizeAiPlan(json, maxPage);

          // attempt 2：更严格限制输入行与输出规模（应对网关硬性 token 上限）
          if (!plan) {
            const inputLinesSmall =
              tocEntries.length > 0
                ? tocEntries.slice(0, Math.min(40, tocEntries.length)).map((e) => `[p${e.page}] ${e.title}`)
                : candidates.slice(0, Math.min(40, candidates.length)).map((c) => `[p${c.page}] ${c.line}`);
            const userSmall = [
              "请根据下方“目录/标题行（带页码）”生成思维导图结构 JSON。",
              "",
              "输出 JSON Schema：",
              "{",
              '  "title": string,',
              '  "chapters": [',
              "    {",
              '      "title": string,',
              '      "page": number,',
              '      "sections": [',
              "        {",
              '          "title": string,',
              '          "page": number',
              "        }",
              "      ]",
              "    }",
              "  ]",
              "}",
              "",
              "硬性要求：",
              "- 只允许输出上述字段；不要输出其它字段。",
              "- chapters 必须按 page 递增；sections 也按 page 递增。",
              "- page 为 1-based 页码（必须是整数）。",
              "- title 不能包含无意义乱码。",
              "- 输出规模控制：最多 8 个 chapter；每个 chapter 最多 10 个 section。",
              "",
              `输入行来源：${tocEntries.length > 0 ? "目录识别" : "标题候选"}`,
              "输入行（格式：[p页码] 文本）：",
              ...inputLinesSmall,
            ].join("\n");
            json = await callOnce(userSmall, 2600);
            plan = normalizeAiPlan(json, maxPage);
          }

          if (plan) {
            plan = postProcessAiPlan(plan, pdfTitle);
            plan = ensurePlanHasChapterAndSections(plan, pdfTitle, tocEntries, candidates, maxPage);
            // 第三层“内容/要点”用规则从正文页抽取，避免在线输出膨胀导致截断/乱码
            plan = enrichPlanPointsFromPages(plan, cleanedPages);
            // 兜底：用候选行补点（保持至少有内容）
            plan = enrichPlanPointsFromCandidates(plan, candidates);
            providerUsed = "online";
            onlineSucceeded = true;
          }
        } catch (e) {
          onlineError = e instanceof Error ? e.message : String(e);
          plan = null;
        }
      }
    }

    // 2) 兜底：本地 Ollama（只做标题分级，不生成要点）
    if (!plan && !forceHeuristic && (preferProvider === "ollama" || preferProvider === "online")) {
      outline = await buildOutlineWithOllama(ollamaBaseUrl, ollamaModel, candidates, maxPage, ollamaTimeoutMs).catch(() => null);
      if (outline?.length) providerUsed = "ollama";
    }

    // 3) 最后兜底：纯规则
    if (!plan && !outline) {
      outline = buildOutlineHeuristic(candidates);
      providerUsed = "heuristic";
    }

    // 若仍然极少结构，则兜底按页生成二级结构
    if (!plan && outline && outline.length < 3) {
      outline = cleanedPages.flatMap((p) => [
        { title: `第 ${p.page} 页`, level: 1, page: p.page },
        ...((p.text || "")
          .split(/\r?\n/)
          .map((l) => normalizeLine(l))
          .filter(Boolean)
          .slice(0, 3)
          .map((l) => ({ title: l, level: 2, page: p.page }))),
      ]);
    }

    const finalOutline = plan
      ? plan.chapters.flatMap((chapter) => [
          {
            title: chapter.title,
            level: 1,
            page: chapter.page,
          },
          ...((chapter.sections ?? []).map((section) => ({
            title: section.title,
            level: 2,
            page: section.page,
          }))),
        ])
      : (outline ?? []);

    const context = buildDocumentBridgeContextWithActor({
      request,
      actor: {
        actorType: "service",
        actorId: "mindmap-outline-to-mindmap",
        sessionId: null,
      },
      workspaceId: null,
      source: {
        channel: "mindmap-outline-route",
        client: "wolai-frontend",
      },
    });
    const payload = {
      documentId: "mindmap-outline-to-mindmap",
      mindmapId: "mindmap-outline-to-mindmap",
      rootTitle: pdfTitle,
      pageLinkPattern,
      outline: finalOutline,
    };
    // 说明：导图树构建已经交给 Rust runtime，这里只保留 PDF 提取与 transport 壳。
    const rustResult = await executeRustBridgeTool<{
      ok: boolean;
      data?: unknown;
      meta?: { title?: string | null; outlineCount?: number | null } | null;
    }>({
      context,
      toolName: "mindmap_outline_to_mindmap",
      invocationKind: "command",
      args: payload,
      data: payload,
      mode: "result",
    });
    const mindmapData =
      rustResult.result && typeof rustResult.result === "object" && !Array.isArray(rustResult.result) && "data" in rustResult.result
        ? ((rustResult.result as { data?: unknown }).data ?? {
            data: { text: pdfTitle },
            children: [],
          })
        : {
            data: { text: pdfTitle },
            children: [],
          };

    return NextResponse.json({
      mindmapData,
      outline: null,
      plan,
      candidates,
      tocEntries,
      meta: {
        title: pdfTitle,
        totalPages: extracted.totalPages ?? null,
        providerUsed,
        onlineAvailable,
        onlineAttempted,
        onlineSucceeded,
        onlineError,
        outlineCount: Array.isArray(finalOutline) ? finalOutline.length : 0,
      },
    });
  } catch (error) {
    return NextResponse.json({ error: String(error) }, { status: 500 });
  } finally {
    if (cleanupDir) {
      try {
        await fs.rm(cleanupDir, { recursive: true, force: true });
      } catch {
        // ignore
      }
    }
  }
}
