import { assert } from "chai";
import { strToU8, zipSync } from "fflate";
import {
  buildChunkMetadata,
  buildEvidencePack,
  buildFullPaperContext,
  buildPaperKey,
  buildPaperRetrievalCandidates,
  ensurePDFTextCached,
  renderEvidencePack,
} from "../src/modules/contextPanel/pdfContext";
import {
  buildManifest,
  readManifest,
  writeMineruCacheFiles,
} from "../src/modules/contextPanel/mineruCache";
import { tokenizeRetrievalText } from "../src/modules/contextPanel/retrievalTokenizer";
import { buildRetrievalQueryPlan } from "../src/modules/contextPanel/retrievalQueryPlan";
import { pdfTextCache } from "../src/modules/contextPanel/state";
import type {
  ChunkStat,
  PaperContextRef,
  PdfContext,
} from "../src/modules/contextPanel/types";

const encoder = new TextEncoder();

type MemoryIO = {
  files: Map<string, Uint8Array>;
  dirs: Set<string>;
  writes: string[];
};

function bytes(value: string | number[]): Uint8Array {
  return typeof value === "string"
    ? encoder.encode(value)
    : new Uint8Array(value);
}

function normalizePath(path: string): string {
  return path.replace(/\\/g, "/").replace(/\/+$/g, "") || "/";
}

function parentPath(path: string): string {
  const normalized = normalizePath(path);
  const index = normalized.lastIndexOf("/");
  return index <= 0 ? "/" : normalized.slice(0, index);
}

function addDir(dirs: Set<string>, path: string): void {
  let current = normalizePath(path);
  const ancestors: string[] = [];
  while (current && current !== "/") {
    ancestors.push(current);
    current = parentPath(current);
  }
  ancestors.push("/");
  for (const dir of ancestors.reverse()) dirs.add(dir);
}

function setupMemoryIO(): MemoryIO {
  const files = new Map<string, Uint8Array>();
  const dirs = new Set<string>();
  const writes: string[] = [];
  addDir(dirs, "/tmp/zotero");

  const io = {
    exists: async (path: string) => {
      const normalized = normalizePath(path);
      return files.has(normalized) || dirs.has(normalized);
    },
    read: async (path: string) => {
      const normalized = normalizePath(path);
      const data = files.get(normalized);
      if (!data) throw new Error(`Missing file: ${path}`);
      return data;
    },
    makeDirectory: async (path: string) => {
      addDir(dirs, path);
    },
    write: async (path: string, data: Uint8Array) => {
      const normalized = normalizePath(path);
      addDir(dirs, parentPath(normalized));
      files.set(normalized, data);
      writes.push(normalized);
    },
    remove: async (path: string) => {
      const normalized = normalizePath(path);
      for (const key of [...files.keys()]) {
        if (key === normalized || key.startsWith(`${normalized}/`)) {
          files.delete(key);
        }
      }
      for (const key of [...dirs.keys()]) {
        if (key === normalized || key.startsWith(`${normalized}/`)) {
          dirs.delete(key);
        }
      }
    },
    getChildren: async (path: string) => {
      const normalized = normalizePath(path);
      const prefix = normalized === "/" ? "/" : `${normalized}/`;
      const children = new Set<string>();
      for (const key of [...dirs, ...files.keys()]) {
        if (!key.startsWith(prefix) || key === normalized) continue;
        const rest = key.slice(prefix.length);
        const childName = rest.split("/")[0];
        if (childName) children.add(`${prefix}${childName}`);
      }
      return [...children];
    },
  };

  (globalThis as unknown as { IOUtils: typeof io }).IOUtils = io;
  return { files, dirs, writes };
}

function setupZoteroGlobals(parentTitle = "Mock MinerU Paper"): void {
  const parentItem = {
    getField: (field: string) => (field === "title" ? parentTitle : ""),
  };
  (globalThis as unknown as { Zotero: unknown }).Zotero = {
    DataDirectory: { dir: "/tmp/zotero" },
    Profile: { dir: "/tmp/profile" },
    Prefs: {
      get: (key: string) => (key.endsWith(".mineruEnabled") ? true : undefined),
      set: () => {},
    },
    Items: {
      get: (id: number) => (id === 100 ? parentItem : null),
    },
    PDFWorker: {
      getFullText: async () => ({ text: "" }),
    },
  };
  (globalThis as unknown as { ztoolkit: unknown }).ztoolkit = {
    log: () => {},
  };
}

function mockPdfAttachment(id: number): Zotero.Item {
  return {
    id,
    parentID: 100,
    attachmentContentType: "application/pdf",
    isAttachment: () => true,
    getField: (field: string) => (field === "title" ? "PDF" : ""),
  } as unknown as Zotero.Item;
}

function mockTextAttachment(options: {
  id: number;
  filename: string;
  contentType: string;
  path: string;
}): Zotero.Item {
  return {
    id: options.id,
    parentID: 100,
    attachmentContentType: options.contentType,
    attachmentFilename: options.filename,
    isAttachment: () => true,
    getFilePath: () => options.path,
    getField: (field: string) => (field === "title" ? options.filename : ""),
  } as unknown as Zotero.Item;
}

function fullPaperRef(contextItemId: number): PaperContextRef {
  return {
    itemId: 100,
    contextItemId,
    title: "Mock MinerU Paper",
    firstCreator: "Tester",
    year: "2026",
  };
}

function tokenize(text: string): string[] {
  return tokenizeRetrievalText(text);
}

function buildPdfContext(chunks: string[]): PdfContext {
  const docFreq: Record<string, number> = {};
  const chunkStats: ChunkStat[] = chunks.map((chunk, index) => {
    const tf: Record<string, number> = {};
    const terms = tokenize(chunk);
    for (const term of terms) {
      tf[term] = (tf[term] || 0) + 1;
    }
    const uniqueTerms = Object.keys(tf);
    for (const term of uniqueTerms) {
      docFreq[term] = (docFreq[term] || 0) + 1;
    }
    return {
      index,
      length: terms.length,
      tf,
      uniqueTerms,
    };
  });
  const avgChunkLength = chunkStats.length
    ? chunkStats.reduce((sum, chunk) => sum + chunk.length, 0) /
      chunkStats.length
    : 0;
  return {
    title: "Mock Paper",
    chunks,
    chunkMeta: buildChunkMetadata(chunks),
    chunkStats,
    docFreq,
    avgChunkLength,
    fullLength: chunks.join("\n\n").length,
  };
}

describe("pdfContext multi-context helpers", function () {
  let originalZotero: unknown;
  let originalIOUtils: unknown;
  let originalZtoolkit: unknown;

  before(function () {
    originalZotero = (globalThis as typeof globalThis & { Zotero?: unknown })
      .Zotero;
    originalIOUtils = (globalThis as unknown as { IOUtils?: unknown }).IOUtils;
    originalZtoolkit = (globalThis as unknown as { ztoolkit?: unknown })
      .ztoolkit;
  });

  beforeEach(function () {
    pdfTextCache.clear();
    setupZoteroGlobals();
  });

  afterEach(function () {
    pdfTextCache.clear();
    delete (globalThis as unknown as { IOUtils?: unknown }).IOUtils;
    delete (globalThis as unknown as { ztoolkit?: unknown }).ztoolkit;
  });

  after(function () {
    (globalThis as typeof globalThis & { Zotero?: unknown }).Zotero =
      originalZotero;
    if (originalIOUtils === undefined) {
      delete (globalThis as unknown as { IOUtils?: unknown }).IOUtils;
    } else {
      (globalThis as unknown as { IOUtils?: unknown }).IOUtils =
        originalIOUtils;
    }
    if (originalZtoolkit === undefined) {
      delete (globalThis as unknown as { ztoolkit?: unknown }).ztoolkit;
    } else {
      (globalThis as unknown as { ztoolkit?: unknown }).ztoolkit =
        originalZtoolkit;
    }
  });

  it("caches TXT child attachments as paper text", async function () {
    const io = setupMemoryIO();
    io.files.set(
      "/tmp/zotero/note.txt",
      bytes("Plain attachment text for retrieval."),
    );
    const attachment = mockTextAttachment({
      id: 321,
      filename: "note.txt",
      contentType: "text/plain",
      path: "/tmp/zotero/note.txt",
    });

    await ensurePDFTextCached(attachment, { sourceMode: "txt" });

    const cached = pdfTextCache.get(321);
    assert.equal(cached?.sourceType, "attachment-txt");
    assert.deepEqual(cached?.chunks, ["Plain attachment text for retrieval."]);
  });

  it("caches DOCX child attachments as plain text", async function () {
    const io = setupMemoryIO();
    io.files.set(
      "/tmp/zotero/notes.docx",
      zipSync({
        "word/document.xml": strToU8(
          '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Docx paragraph</w:t></w:r></w:p></w:body></w:document>',
        ),
      }),
    );
    const attachment = mockTextAttachment({
      id: 322,
      filename: "notes.docx",
      contentType:
        "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
      path: "/tmp/zotero/notes.docx",
    });

    await ensurePDFTextCached(attachment, { sourceMode: "docx" });

    const cached = pdfTextCache.get(322);
    assert.equal(cached?.sourceType, "attachment-docx");
    assert.deepEqual(cached?.chunks, ["Docx paragraph"]);
  });

  it("builds retrieval candidates with scores and metadata", async function () {
    const paper: PaperContextRef = {
      itemId: 1,
      contextItemId: 11,
      title: "Paper A",
      firstCreator: "Alice",
      year: "2024",
    };
    const context = buildPdfContext([
      "Gamma delta shared finding from paper A.",
      "Ablation and method details.",
      "Unrelated appendix details.",
    ]);
    const candidates = await buildPaperRetrievalCandidates(
      paper,
      context,
      "gamma delta finding",
      undefined,
      { topK: 2 },
    );
    assert.lengthOf(candidates, 2);
    assert.equal(candidates[0].paperKey, buildPaperKey(paper));
    assert.equal(candidates[0].itemId, 1);
    assert.isAtLeast(candidates[0].estimatedTokens, 1);
  });

  it("uses query-plan variants for lexical chunk retrieval", async function () {
    const paper: PaperContextRef = {
      itemId: 1,
      contextItemId: 11,
      title: "Paper A",
      firstCreator: "Alice",
      year: "2024",
    };
    const context = buildPdfContext([
      "The methods use calcium imaging to measure neural drift.",
      "This unrelated appendix describes baseline calibration.",
    ]);
    const queryPlan = buildRetrievalQueryPlan({
      query: "钙成像",
      queryVariants: ["calcium imaging"],
    });
    const candidates = await buildPaperRetrievalCandidates(
      paper,
      context,
      "钙成像",
      undefined,
      { topK: 2, queryPlan },
    );

    assert.equal(candidates[0].chunkIndex, 0);
    assert.isAbove(candidates[0].bm25Score, candidates[1].bm25Score);
    assert.include(candidates[0].matchedQueryVariants || [], "calcium imaging");
  });

  it("falls back to Zotero full-text cache when PDFWorker returns no text", async function () {
    const io = setupMemoryIO();
    const cachePath = "/tmp/zotero/storage/ABCD1234/.zotero-ft-cache";
    io.files.set(
      cachePath,
      bytes(
        [
          "Abstract",
          "This indexed Zotero text should be used when PDFWorker is empty.",
          "",
          "Discussion",
          "The full-text cache still contains usable paper content.",
        ].join("\n"),
      ),
    );
    const zotero = (globalThis as unknown as { Zotero: any }).Zotero;
    zotero.Fulltext = {
      getItemCacheFile: () => ({
        path: cachePath,
        exists: () => true,
      }),
    };

    await ensurePDFTextCached(mockPdfAttachment(123));
    const context = pdfTextCache.get(123);

    assert.equal(context?.sourceType, "zotero-fulltext-cache");
    assert.isAtLeast(context?.chunks.length || 0, 1);
    assert.include(
      context?.chunks.join("\n") || "",
      "indexed Zotero text should be used",
    );
  });

  it("ranks matching Korean text with Hangul n-gram retrieval", async function () {
    const paper: PaperContextRef = {
      itemId: 1,
      contextItemId: 11,
      title: "Korean Paper",
      firstCreator: "Kim",
      year: "2026",
    };
    const context = buildPdfContext([
      "이 논문은 표현학습방법을 제안하고 실험으로 검증한다.",
      "This unrelated appendix describes baseline calibration.",
    ]);
    const candidates = await buildPaperRetrievalCandidates(
      paper,
      context,
      "학습방법",
      undefined,
      { topK: 2 },
    );

    assert.lengthOf(candidates, 2);
    assert.equal(candidates[0].chunkIndex, 0);
    assert.isAbove(candidates[0].bm25Score, candidates[1].bm25Score);
  });

  it("renders full paper context with metadata", function () {
    const paper: PaperContextRef = {
      itemId: 2,
      contextItemId: 22,
      title: "Paper B",
      citationKey: "Smith2023",
      firstCreator: "Smith et al.",
      year: "2023",
    };
    const context = buildPdfContext(["Main finding.", "Conclusion."]);
    const text = buildFullPaperContext(paper, context);
    assert.include(text, "Title: Paper B");
    assert.include(text, "Citation key: Smith2023");
    assert.include(text, "Source label: (Smith et al., 2023)");
    assert.include(text, "Answer format when quoting this paper:");
    assert.include(text, "Paper Text:");
  });

  it("renders text-like child attachments as selected attachment sources", function () {
    const paper: PaperContextRef = {
      itemId: 2,
      contextItemId: 23,
      title: "Episodic and associative memory from spatial scaffolds",
      attachmentTitle: "paper_ocr.md",
      firstCreator: "Chandra et al.",
      year: "2025",
      contentSourceMode: "markdown",
    };
    const context = buildPdfContext([
      "This OCR markdown says spatial scaffolds organize memory retrieval.",
    ]);
    const text = buildFullPaperContext(paper, context);

    assert.include(text, "Parent Zotero Item:");
    assert.include(
      text,
      "Title: Episodic and associative memory from spatial scaffolds",
    );
    assert.include(text, "Selected Source:");
    assert.include(text, "Type: Markdown attachment");
    assert.include(text, "Attachment title: paper_ocr.md");
    assert.include(
      text,
      "Relationship: Child attachment under the parent item; it may be user OCR, a translated file, supplement, notes, or another related file.",
    );
    assert.include(
      text,
      "Source label: (paper_ocr.md, attachment under Chandra et al., 2025)",
    );
    assert.include(text, "Selected attachment guidance:");
    assert.include(
      text,
      "Treat this selected attachment as the primary evidence source",
    );
    assert.include(text, "Selected Attachment Text:");
    assert.include(
      text,
      "This OCR markdown says spatial scaffolds organize memory retrieval.",
    );
    assert.notInclude(text, "Paper Text:");
    assert.notInclude(text, "[No extractable PDF text available.");
  });

  it("renders evidence pack with quote-plus-source formatting", function () {
    const paperA: PaperContextRef = {
      itemId: 1,
      contextItemId: 11,
      title: "Paper A",
      firstCreator: "Zheng et al.",
      year: "2026",
    };
    const paperB: PaperContextRef = {
      itemId: 2,
      contextItemId: 22,
      title: "Paper B",
      citationKey: "Smith2023",
    };
    const rendered = renderEvidencePack({
      papers: [paperA, paperB],
      candidates: [
        {
          paperKey: buildPaperKey(paperA),
          itemId: 1,
          contextItemId: 11,
          title: "Paper A",
          firstCreator: "Zheng et al.",
          year: "2026",
          chunkIndex: 3,
          chunkText:
            "Abstract\nDespite global representational drift, the relative geometry remained stable across conditions.",
          estimatedTokens: 8,
          bm25Score: 0.7,
          embeddingScore: 0.1,
          hybridScore: 0.4,
          evidenceScore: 1.3,
        },
        {
          paperKey: buildPaperKey(paperB),
          itemId: 2,
          contextItemId: 22,
          title: "Paper B",
          citationKey: "Smith2023",
          chunkIndex: 1,
          chunkText: "Shared claim B",
          estimatedTokens: 8,
          bm25Score: 0.6,
          embeddingScore: 0.2,
          hybridScore: 0.4,
          evidenceScore: 0.9,
        },
      ],
    });
    assert.include(
      rendered,
      "Paper-grounded citation format for the final answer:",
    );
    assert.include(rendered, "Source label: (Zheng et al., 2026)");
    assert.include(
      rendered,
      "> Despite global representational drift, the relative geometry remained stable across conditions.",
    );
    assert.include(rendered, "Source label: (Paper 2)");
    assert.include(rendered, "> Shared claim B");
    assert.notInclude(rendered, "[P1-C4]");

    const pack = buildEvidencePack({
      papers: [paperA, paperB],
      candidates: [
        {
          paperKey: buildPaperKey(paperA),
          itemId: 1,
          contextItemId: 11,
          title: "Paper A",
          firstCreator: "Zheng et al.",
          year: "2026",
          chunkIndex: 3,
          chunkText:
            "Abstract\nDespite global representational drift, the relative geometry remained stable across conditions.",
          estimatedTokens: 8,
          bm25Score: 0.7,
          embeddingScore: 0.1,
          hybridScore: 0.4,
          evidenceScore: 1.3,
        },
      ],
    });
    assert.lengthOf(pack.quoteCitations, 1);
    assert.match(pack.quoteCitations[0].id, /^Q_/);
    assert.include(pack.contextText, `[[quote:${pack.quoteCitations[0].id}]]`);
  });

  it("labels retrieved evidence from text-like attachments as attachment-derived", function () {
    const attachmentPaper: PaperContextRef = {
      itemId: 7,
      contextItemId: 70,
      title: "Parent Paper",
      attachmentTitle: "translation.md",
      firstCreator: "Rivera",
      year: "2024",
      contentSourceMode: "markdown",
    };
    const pack = buildEvidencePack({
      papers: [attachmentPaper],
      candidates: [
        {
          paperKey: buildPaperKey(attachmentPaper),
          itemId: 7,
          contextItemId: 70,
          title: "Parent Paper",
          chunkIndex: 0,
          chunkText: "Translated passage about the main experiment.",
          estimatedTokens: 6,
          bm25Score: 0.5,
          embeddingScore: 0.1,
          hybridScore: 0.3,
          evidenceScore: 0.8,
        },
      ],
    });

    assert.lengthOf(pack.quoteCitations, 1);
    assert.equal(
      pack.quoteCitations[0].citationLabel,
      "(translation.md, attachment under Rivera, 2024)",
    );
    assert.include(
      pack.contextText,
      "Source-grounded citation format for the final answer:",
    );
    assert.include(
      pack.contextText,
      "Source label: (translation.md, attachment under Rivera, 2024)",
    );
    assert.include(
      pack.contextText,
      "The full paper or selected attachment source remains available in paper chat.",
    );
  });

  it("builds chunk metadata with section labels and cleaned anchors", function () {
    const metadata = buildChunkMetadata([
      "Results\n\n23 activity. Representational drift increases over days.",
    ]);
    assert.lengthOf(metadata, 1);
    assert.equal(metadata[0].sectionLabel, "Results");
    assert.equal(metadata[0].chunkKind, "results");
    assert.equal(
      metadata[0].anchorText,
      "Representational drift increases over days.",
    );
    assert.isTrue(Boolean(metadata[0].leadingNoiseRemoved));
  });

  it("recognizes supplementary figure captions as figure-caption chunks", function () {
    const metadata = buildChunkMetadata([
      "Figure S7. Preserved relationship between place and grid cells across environments.",
    ]);
    assert.lengthOf(metadata, 1);
    assert.equal(metadata[0].chunkKind, "figure-caption");
  });

  it("keeps MinerU full.md text when manifest headings contain regex syntax", async function () {
    setupMemoryIO();
    const attachmentId = 1201;
    const specialHeading = "化粪池 $+$ 土地渗滤系统";
    const mdContent = [
      "# Introduction",
      "This paper introduces dry and cold region toilet renovation.",
      `# ${specialHeading}`,
      "This section compares septic tank plus soil infiltration systems.",
      "# Conclusion",
      "The paper recommends matching technologies to local constraints.",
    ].join("\n\n");

    await writeMineruCacheFiles(attachmentId, mdContent, [
      { relativePath: "paper/full.md", data: bytes(mdContent) },
      {
        relativePath: "paper/content_list.json",
        data: bytes(
          JSON.stringify([
            { type: "text", text_level: 1, text: "Introduction", page_idx: 0 },
            {
              type: "text",
              text_level: 1,
              text: specialHeading,
              page_idx: 1,
            },
            { type: "text", text_level: 1, text: "Conclusion", page_idx: 2 },
          ]),
        ),
      },
    ]);

    await ensurePDFTextCached(mockPdfAttachment(attachmentId));
    const context = pdfTextCache.get(attachmentId);
    assert.exists(context);
    const rendered = buildFullPaperContext(fullPaperRef(attachmentId), context);

    assert.include(rendered, "Paper Text:");
    assert.include(rendered, specialHeading);
    assert.include(rendered, "septic tank plus soil infiltration");
    assert.notInclude(
      rendered,
      "[No extractable PDF text available. Using metadata only.]",
    );
    assert.include(
      context!.chunkMeta.map((meta) => meta.sectionLabel),
      specialHeading,
    );
  });

  it("falls back to all MinerU markdown when manifest chunk metadata fails", async function () {
    const io = setupMemoryIO();
    const attachmentId = 1202;
    const mdContent = [
      "# Introduction",
      "The readable MinerU text should survive manifest failure.",
      "# Body",
      "Fallback-only text must still be visible to the model.",
      "# Conclusion",
      "The plugin must not degrade to metadata only.",
    ].join("\n\n");

    await writeMineruCacheFiles(attachmentId, mdContent, [
      { relativePath: "paper/full.md", data: bytes(mdContent) },
    ]);
    io.files.set(
      `/tmp/zotero/llm-for-zotero-mineru/${attachmentId}/manifest.json`,
      bytes(
        JSON.stringify({
          sections: [
            { heading: null, charStart: 0, charEnd: mdContent.length },
          ],
          totalChars: mdContent.length,
        }),
      ),
    );

    await ensurePDFTextCached(mockPdfAttachment(attachmentId));
    const context = pdfTextCache.get(attachmentId);
    assert.exists(context);
    const rendered = buildFullPaperContext(fullPaperRef(attachmentId), context);

    assert.include(rendered, "Paper Text:");
    assert.include(rendered, "Fallback-only text must still be visible");
    assert.notInclude(
      rendered,
      "[No extractable PDF text available. Using metadata only.]",
    );
    assert.isAbove(context!.chunks.length, 0);
    assert.equal(context!.fullLength, mdContent.length);
  });

  it("strips legacy MinerU source image embeds from PDF context chunks", async function () {
    const io = setupMemoryIO();
    const attachmentId = 1204;
    const contentList = [
      { type: "text", text_level: 1, text: "Introduction", page_idx: 0 },
      { type: "text", text_level: 1, text: "Results", page_idx: 1 },
      {
        type: "image",
        img_path: "images/raw-result.png",
        image_caption: ["Figure 1. Legacy figure caption."],
        page_idx: 1,
      },
      { type: "text", text_level: 1, text: "Conclusion", page_idx: 2 },
    ];
    const rawMd = [
      "# Introduction",
      "Intro text.",
      "# Results",
      "![](images/raw-result.png)",
      "Result text.",
      "Figure 1. Legacy figure caption.",
      "# Conclusion",
      "Conclusion text.",
    ].join("\n\n");

    await writeMineruCacheFiles(attachmentId, rawMd, [
      { relativePath: "paper/full.md", data: bytes(rawMd) },
      {
        relativePath: "paper/content_list.json",
        data: bytes(JSON.stringify(contentList)),
      },
    ]);
    io.files.set(
      `/tmp/zotero/llm-for-zotero-mineru/${attachmentId}/full.md`,
      bytes(rawMd),
    );
    io.files.set(
      `/tmp/zotero/llm-for-zotero-mineru/${attachmentId}/manifest.json`,
      bytes(JSON.stringify(buildManifest(rawMd, contentList))),
    );

    await ensurePDFTextCached(mockPdfAttachment(attachmentId));
    const context = pdfTextCache.get(attachmentId);
    assert.exists(context);
    const rendered = buildFullPaperContext(fullPaperRef(attachmentId), context);

    assert.include(rendered, "Result text.");
    assert.include(rendered, "Figure 1. Legacy figure caption.");
    assert.notInclude(rendered, "images/raw-result.png");
    assert.notInclude(rendered, "![](");
    assert.notInclude(context!.chunks.join("\n"), "images/raw-result.png");
  });

  it("rebuilds stale MinerU manifests before chunking", async function () {
    const io = setupMemoryIO();
    const attachmentId = 1203;
    const mdV1 = [
      "# Introduction",
      "Old introduction.",
      "# Methods",
      "Old methods.",
      "# Conclusion",
      "Old conclusion.",
    ].join("\n\n");
    const mdV2 = [
      "# Introduction",
      "Updated introduction that should be sent to the model.",
      "# Methods",
      "Updated methods with more detail.",
      "# Conclusion",
      "Updated conclusion.",
    ].join("\n\n");

    await writeMineruCacheFiles(attachmentId, mdV1, [
      { relativePath: "paper/full.md", data: bytes(mdV1) },
      {
        relativePath: "paper/content_list.json",
        data: bytes(
          JSON.stringify([
            { type: "text", text_level: 1, text: "Introduction", page_idx: 0 },
            { type: "text", text_level: 1, text: "Methods", page_idx: 1 },
            { type: "text", text_level: 1, text: "Conclusion", page_idx: 2 },
          ]),
        ),
      },
    ]);
    io.files.set(
      `/tmp/zotero/llm-for-zotero-mineru/${attachmentId}/full.md`,
      bytes(mdV2),
    );

    await ensurePDFTextCached(mockPdfAttachment(attachmentId));
    const rebuiltManifest = await readManifest(attachmentId);
    const context = pdfTextCache.get(attachmentId);
    assert.exists(context);
    const rendered = buildFullPaperContext(fullPaperRef(attachmentId), context);

    assert.equal(rebuiltManifest?.totalChars, mdV2.length);
    assert.include(rendered, "Updated introduction that should be sent");
    assert.notInclude(rendered, "Old introduction.");
    assert.notInclude(
      rendered,
      "[No extractable PDF text available. Using metadata only.]",
    );
  });
});
