import { assert } from "chai";
import { unzipSync, zipSync } from "fflate";
import {
  isMineruSyncEnabled,
  setMineruSyncEnabled,
} from "../src/utils/mineruConfig";
import {
  buildManifest,
  getMineruItemDir,
  hasCachedMineruMd,
  MINERU_SOURCE_PROVENANCE_FILE,
  readCachedMineruMd,
  readMineruSourceProvenance,
  writeMineruCacheFiles,
  writeMineruSourceProvenanceForAttachment,
} from "../src/modules/contextPanel/mineruCache";
import { pdfTextCache } from "../src/modules/contextPanel/state";
import {
  buildMineruSyncPackageBytes,
  cleanSyncedMineruPackages,
  cleanupMineruArtifactsForRemovedAttachment,
  ensureMineruCacheDirForAttachment,
  ensureMineruRuntimeCacheForAttachment,
  getMineruAvailabilityForAttachment,
  MINERU_SYNC_ATTACHMENT_TITLE_PREFIX,
  MINERU_SYNC_METADATA_FILE,
  MINERU_SYNC_PACKAGE_KIND,
  MINERU_SYNC_PACKAGE_VERSION,
  publishMineruCachePackageForAttachment,
  repairMineruCaches,
  repairSyncedMineruCacheForAttachment,
  restoreSyncedMineruCacheForAttachment,
  shouldIncludeMineruCachePackageEntry,
} from "../src/modules/contextPanel/mineruSync";
import {
  PDF_FIGURE_CROP_ALGORITHM_VERSION,
  PDF_FIGURE_CROP_CACHE_VERSION,
  buildPdfFigureCropManifestHash,
} from "../src/modules/contextPanel/pdfFigureCropCache";
import { createReadLibraryTool } from "../src/agent/tools/read/readLibrary";
import { ZoteroGateway } from "../src/agent/services/zoteroGateway";

const encoder = new TextEncoder();
const decoder = new TextDecoder();

type MockItem = {
  id: number;
  key: string;
  libraryID: number;
  parentID?: number;
  attachmentContentType?: string;
  attachmentFilename?: string;
  deleted?: boolean;
  attachmentIDs?: number[];
  isAttachment: () => boolean;
  isRegularItem?: () => boolean;
  getAttachments?: () => number[];
  getField?: (field: string) => string;
  setField?: (field: string, value: string) => void;
  saveTx: () => Promise<void>;
  getFilePathAsync?: () => Promise<string | false>;
};

type MemoryIO = {
  files: Map<string, Uint8Array>;
  dirs: Set<string>;
  remove: (path: string) => Promise<void>;
};

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>();
  addDir(dirs, "/tmp/zotero");
  addDir(dirs, "/tmp/zotero-tmp");

  const 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);
      }
    }
  };

  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);
    },
    remove,
    getChildren: async (path: string) => {
      const normalized = normalizePath(path);
      if (files.has(normalized)) throw new Error("Not a directory");
      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, remove };
}

function setupZotero(items: Map<number, MockItem>, io: MemoryIO): void {
  const prefs = new Map<string, unknown>();
  let nextId = 9000;
  (globalThis as unknown as { Zotero: unknown }).Zotero = {
    DataDirectory: { dir: "/tmp/zotero" },
    Profile: { dir: "/tmp/profile" },
    getTempDirectory: () => ({ path: "/tmp/zotero-tmp" }),
    Prefs: {
      get: (key: string) => prefs.get(key),
      set: (key: string, value: unknown) => {
        prefs.set(key, value);
      },
    },
    Libraries: {
      userLibraryID: 1,
      getAll: () => [{ libraryID: 1, name: "My Library" }],
    },
    Items: {
      get: (id: number) => items.get(id) || null,
      getByLibraryAndKey: (libraryID: number, key: string) =>
        [...items.values()].find(
          (item) => item.libraryID === libraryID && item.key === key,
        ) || null,
      getAll: async (libraryID: number) =>
        [...items.values()].filter((item) => item.libraryID === libraryID),
    },
    Fulltext: {
      getIndexedState: async () => 3,
    },
    Attachments: {
      importFromFile: async (options: {
        file: string | { path?: string };
        parentItemID?: number;
        libraryID?: number;
        title?: string;
        fileBaseName?: string;
        contentType?: string;
      }) => {
        const sourcePath =
          typeof options.file === "string"
            ? options.file
            : options.file.path || "";
        const data = io.files.get(normalizePath(sourcePath));
        if (!data) throw new Error("Missing imported package");
        const id = nextId++;
        const attachmentFilename = `${options.fileBaseName || "package"}.zip`;
        const storedPath = `/tmp/zotero/storage/${id}/${attachmentFilename}`;
        io.files.set(normalizePath(storedPath), data);
        const imported = createAttachment({
          id,
          key: `PKG${id}`,
          parentID: options.parentItemID,
          contentType: options.contentType || "application/zip",
          filename: attachmentFilename,
          filePath: storedPath,
          title: options.title || attachmentFilename,
        });
        items.set(id, imported);
        const parent = options.parentItemID
          ? items.get(options.parentItemID)
          : null;
        parent?.attachmentIDs?.push(id);
        return imported as unknown as Zotero.Item;
      },
    },
  };
  (globalThis as unknown as { ztoolkit: unknown }).ztoolkit = {
    log: () => {},
  };
}

function createAttachment(params: {
  id: number;
  key: string;
  parentID?: number;
  contentType: string;
  filename: string;
  filePath?: string;
  title?: string;
}): MockItem {
  let title = params.title || params.filename;
  return {
    id: params.id,
    key: params.key,
    libraryID: 1,
    parentID: params.parentID,
    attachmentContentType: params.contentType,
    attachmentFilename: params.filename,
    isAttachment: () => true,
    isRegularItem: () => false,
    getField: (field) => (field === "title" ? title : ""),
    setField: (field, value) => {
      if (field === "title") title = value;
    },
    saveTx: async () => {},
    getFilePathAsync: async () => params.filePath || false,
  };
}

function createParent(): MockItem {
  return {
    id: 10,
    key: "PARENTKEY",
    libraryID: 1,
    attachmentIDs: [],
    isAttachment: () => false,
    isRegularItem: () => true,
    getAttachments() {
      return this.attachmentIDs || [];
    },
    getField: (field) => (field === "title" ? "Parent paper" : ""),
    saveTx: async () => {},
  };
}

async function writeSampleCache(attachmentId: number): Promise<void> {
  await writeMineruCacheFiles(
    attachmentId,
    "# Intro\n![Fig](images/fig1.png)\n# Results\ncontent",
    [
      {
        relativePath: "full.md",
        data: bytes("# Intro\n![Fig](images/fig1.png)\n# Results\ncontent"),
      },
      { relativePath: "images/fig1.png", data: bytes([1, 2, 3]) },
      {
        relativePath: "content_list.json",
        data: bytes(
          JSON.stringify([
            { type: "text", text_level: 1, text: "Intro", page_idx: 0 },
            {
              type: "image",
              img_path: "images/fig1.png",
              image_caption: ["Fig. 1 caption"],
              page_idx: 0,
            },
            { type: "text", text_level: 1, text: "Results", page_idx: 1 },
          ]),
        ),
      },
      { relativePath: "layout.json", data: bytes("{}") },
    ],
  );
}

function attachPackage(params: {
  io: MemoryIO;
  items: Map<number, MockItem>;
  parent: MockItem;
  id: number;
  key: string;
  sourceKey: string;
  bytes: Uint8Array;
}): MockItem {
  const packagePath = `/tmp/zotero/package-${params.id}.zip`;
  params.io.files.set(packagePath, params.bytes);
  const packageItem = createAttachment({
    id: params.id,
    key: params.key,
    parentID: params.parent.id,
    contentType: "application/zip",
    filename: `package-${params.id}.zip`,
    filePath: packagePath,
    title: `${MINERU_SYNC_ATTACHMENT_TITLE_PREFIX} ${params.sourceKey}.zip`,
  });
  params.parent.attachmentIDs!.push(packageItem.id);
  params.items.set(packageItem.id, packageItem);
  return packageItem;
}

describe("mineruSync", function () {
  afterEach(function () {
    delete (globalThis as unknown as { IOUtils?: unknown }).IOUtils;
    delete (globalThis as unknown as { Zotero?: unknown }).Zotero;
    delete (globalThis as unknown as { ztoolkit?: unknown }).ztoolkit;
    pdfTextCache.clear();
  });

  it("filters to durable text metadata and PDF crop cache artifacts", function () {
    assert.isTrue(shouldIncludeMineruCachePackageEntry("full.md"));
    assert.isTrue(shouldIncludeMineruCachePackageEntry("manifest.json"));
    assert.isTrue(shouldIncludeMineruCachePackageEntry("content_list.json"));
    assert.isTrue(
      shouldIncludeMineruCachePackageEntry("legacy_uuid_content_list.json"),
    );
    assert.isTrue(
      shouldIncludeMineruCachePackageEntry(
        "auto/legacy_uuid_content_list.json",
      ),
    );
    assert.isFalse(shouldIncludeMineruCachePackageEntry("images/figure.png"));
    assert.isFalse(shouldIncludeMineruCachePackageEntry("layout.json"));
    assert.isFalse(shouldIncludeMineruCachePackageEntry("middle.json"));
    assert.isFalse(shouldIncludeMineruCachePackageEntry("tables/table-1.png"));
    assert.isTrue(
      shouldIncludeMineruCachePackageEntry("figure_crops/figure_geometry.json"),
    );
    assert.isTrue(
      shouldIncludeMineruCachePackageEntry("figure_crops/crops/figure-1.png"),
    );
    assert.isTrue(
      shouldIncludeMineruCachePackageEntry(MINERU_SOURCE_PROVENANCE_FILE),
    );
    assert.isFalse(shouldIncludeMineruCachePackageEntry("../full.md"));
    assert.isFalse(shouldIncludeMineruCachePackageEntry("/tmp/full.md"));
    assert.isFalse(shouldIncludeMineruCachePackageEntry("__MACOSX/full.md"));
  });

  it("builds a compact package with full.md, manifest, content_list, and PDF crops", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdf = createAttachment({
      id: 42,
      key: "PDFKEY",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "paper.pdf",
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);

    await writeSampleCache(pdf.id);
    const zipBytes = await buildMineruSyncPackageBytes(
      pdf as unknown as Zotero.Item,
    );
    assert.exists(zipBytes);

    const entries = unzipSync(zipBytes!);
    assert.containsAllKeys(entries, [
      MINERU_SYNC_METADATA_FILE,
      "full.md",
      "manifest.json",
      "content_list.json",
    ]);
    assert.notProperty(entries, "images/fig1.png");
    assert.notProperty(entries, "layout.json");
    const metadata = JSON.parse(
      decoder.decode(entries[MINERU_SYNC_METADATA_FILE]),
    );
    assert.equal(metadata.sourceAttachmentKey, "PDFKEY");
    assert.equal(metadata.parentItemKey, "PARENTKEY");
    assert.match(metadata.cacheContentHash, /^fnv1a32-[a-f0-9]{8}$/);
    assert.equal(metadata.mineruCacheVersion, "mineru-cache-v1");
    assert.notProperty(metadata, "sourceFingerprint");
    assert.notProperty(metadata, "provenanceStatus");
  });

  it("does not publish a companion ZIP when MinerU sync is disabled", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const pdf = createAttachment({
      id: 42,
      key: "PDFKEY",
      contentType: "application/pdf",
      filename: "paper.pdf",
    });
    items.set(pdf.id, pdf);
    setupZotero(items, io);
    await writeSampleCache(pdf.id);

    const result = await publishMineruCachePackageForAttachment(pdf.id);
    assert.equal(result.status, "disabled");
    assert.equal(
      [...items.values()].filter((item) => item.id >= 9000).length,
      0,
    );
  });

  it("keeps MinerU sync disabled by default", function () {
    const io = setupMemoryIO();
    setupZotero(new Map<number, MockItem>(), io);

    assert.isFalse(isMineruSyncEnabled());
  });

  it("publishes and cleans only plugin-owned MinerU package attachments", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdf = createAttachment({
      id: 42,
      key: "PDFKEY",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "paper.pdf",
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);
    setMineruSyncEnabled(true);
    await writeSampleCache(pdf.id);

    const published = await publishMineruCachePackageForAttachment(pdf.id);
    assert.equal(published.status, "published");
    assert.isNumber(published.packageAttachmentId);

    const packageItem = items.get(published.packageAttachmentId!);
    assert.include(
      packageItem?.getField?.("title") || "",
      MINERU_SYNC_ATTACHMENT_TITLE_PREFIX,
    );

    const cleaned = await cleanSyncedMineruPackages();
    assert.equal(cleaned.deleted, 1);
    assert.equal(cleaned.failed, 0);
    assert.isTrue(packageItem?.deleted);
    assert.isFalse(pdf.deleted === true);
  });

  it("cleans only the removed PDF cache and matching same-parent sync package", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const oldPdf = createAttachment({
      id: 501,
      key: "OLDPDFKEY",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "preprint.pdf",
    });
    const journalPdf = createAttachment({
      id: 502,
      key: "JOURNALPDFKEY",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "journal.pdf",
    });
    const otherPdf = createAttachment({
      id: 503,
      key: "OTHERPDFKEY",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "other.pdf",
    });
    parent.attachmentIDs!.push(oldPdf.id, journalPdf.id, otherPdf.id);
    items.set(parent.id, parent);
    items.set(oldPdf.id, oldPdf);
    items.set(journalPdf.id, journalPdf);
    items.set(otherPdf.id, otherPdf);
    setupZotero(items, io);
    setMineruSyncEnabled(true);

    await writeSampleCache(oldPdf.id);
    await writeMineruSourceProvenanceForAttachment(
      oldPdf as unknown as Zotero.Item,
    );
    const oldPackageBytes = await buildMineruSyncPackageBytes(
      oldPdf as unknown as Zotero.Item,
    );
    assert.exists(oldPackageBytes);
    const oldPackage = attachPackage({
      io,
      items,
      parent,
      id: 504,
      key: "PKGOLDPDF",
      sourceKey: "OLDPDFKEY",
      bytes: oldPackageBytes!,
    });

    await writeSampleCache(otherPdf.id);
    await writeMineruSourceProvenanceForAttachment(
      otherPdf as unknown as Zotero.Item,
    );
    const otherPackageBytes = await buildMineruSyncPackageBytes(
      otherPdf as unknown as Zotero.Item,
    );
    assert.exists(otherPackageBytes);
    const otherPackage = attachPackage({
      io,
      items,
      parent,
      id: 505,
      key: "PKGOTHERPDF",
      sourceKey: "OTHERPDFKEY",
      bytes: otherPackageBytes!,
    });

    (Zotero.Items as unknown as { getAll: () => Promise<never> }).getAll =
      async () => {
        throw new Error("cleanup must not scan the whole library");
      };
    pdfTextCache.set(oldPdf.id, {
      title: "stale",
      chunks: ["stale"],
      chunkMeta: [],
      chunkStats: [],
      docFreq: {},
      avgChunkLength: 1,
      fullLength: 5,
      sourceType: "mineru",
    });
    oldPdf.deleted = true;

    const result = await cleanupMineruArtifactsForRemovedAttachment(oldPdf.id);

    assert.isTrue(result.localCacheDeleted);
    assert.equal(result.removedSyncPackages, 1);
    assert.equal(result.failed, 0);
    assert.isFalse(await hasCachedMineruMd(oldPdf.id));
    assert.isFalse(pdfTextCache.has(oldPdf.id));
    assert.isTrue(oldPackage.deleted);
    assert.isFalse(otherPackage.deleted === true);
    assert.isFalse(journalPdf.deleted === true);
  });

  it("uses local source provenance to clean a removed PDF whose item is already unavailable", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdf = createAttachment({
      id: 506,
      key: "MISSINGPDFKEY",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "missing.pdf",
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);
    setMineruSyncEnabled(true);

    await writeSampleCache(pdf.id);
    await writeMineruSourceProvenanceForAttachment(
      pdf as unknown as Zotero.Item,
    );
    const packageBytes = await buildMineruSyncPackageBytes(
      pdf as unknown as Zotero.Item,
    );
    assert.exists(packageBytes);
    const packageItem = attachPackage({
      io,
      items,
      parent,
      id: 507,
      key: "PKGMISSINGPDF",
      sourceKey: "MISSINGPDFKEY",
      bytes: packageBytes!,
    });
    items.delete(pdf.id);
    parent.attachmentIDs = [packageItem.id];

    const result = await cleanupMineruArtifactsForRemovedAttachment(pdf.id);

    assert.isTrue(result.localCacheDeleted);
    assert.equal(result.removedSyncPackages, 1);
    assert.equal(result.failed, 0);
    assert.isFalse(await hasCachedMineruMd(pdf.id));
    assert.isTrue(packageItem.deleted);
  });

  it("removes a local cache without provenance but skips synced package deletion", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const packageItem = attachPackage({
      io,
      items,
      parent,
      id: 508,
      key: "PKGUNRELATED",
      sourceKey: "UNRELATEDKEY",
      bytes: zipSync({
        [MINERU_SYNC_METADATA_FILE]: bytes(
          JSON.stringify({
            kind: "llm-for-zotero/mineru-cache",
            version: 1,
            createdAt: "2026-01-01T00:00:00.000Z",
            addonName: "llm-for-zotero",
            addonVersion: "0.0.0",
            sourceAttachmentKey: "UNRELATEDKEY",
          }),
        ),
        "full.md": bytes("# unrelated"),
      }),
    });
    items.set(parent.id, parent);
    setupZotero(items, io);
    await writeSampleCache(509);

    const result = await cleanupMineruArtifactsForRemovedAttachment(509);

    assert.isTrue(result.localCacheDeleted);
    assert.equal(result.removedSyncPackages, 0);
    assert.equal(result.failed, 0);
    assert.isFalse(await hasCachedMineruMd(509));
    assert.isFalse(packageItem.deleted === true);
  });

  it("publishes and restores MinerU packages for parentless raw PDFs", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const pdf = createAttachment({
      id: 43,
      key: "RAWPDFKEY",
      contentType: "application/pdf",
      filename: "raw.pdf",
    });
    items.set(pdf.id, pdf);
    setupZotero(items, io);
    setMineruSyncEnabled(true);
    await writeSampleCache(pdf.id);

    const published = await publishMineruCachePackageForAttachment(pdf.id);
    assert.equal(published.status, "published");
    const packageItem = items.get(published.packageAttachmentId!);
    assert.exists(packageItem);
    assert.isUndefined(packageItem?.parentID);

    await io.remove(`/tmp/zotero/llm-for-zotero-mineru/${pdf.id}`);
    const restored = await restoreSyncedMineruCacheForAttachment(
      pdf as unknown as Zotero.Item,
    );

    assert.equal(restored.status, "restored");
    assert.equal(
      await readCachedMineruMd(pdf.id),
      "# Intro\n# Results\ncontent",
    );
  });

  it("reports MinerU availability across local and synced package states", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdf = createAttachment({
      id: 51,
      key: "PDFAVAIL",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "available.pdf",
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);
    setMineruSyncEnabled(true);

    assert.equal(
      (await getMineruAvailabilityForAttachment(pdf as unknown as Zotero.Item))
        .status,
      "missing",
    );

    await writeSampleCache(pdf.id);
    assert.equal(
      (await getMineruAvailabilityForAttachment(pdf as unknown as Zotero.Item))
        .status,
      "local",
    );

    const zipBytes = await buildMineruSyncPackageBytes(
      pdf as unknown as Zotero.Item,
    );
    assert.exists(zipBytes);
    attachPackage({
      io,
      items,
      parent,
      id: 89,
      key: "PKGAVAIL",
      sourceKey: "PDFAVAIL",
      bytes: zipBytes!,
    });
    assert.equal(
      (await getMineruAvailabilityForAttachment(pdf as unknown as Zotero.Item))
        .status,
      "both",
    );

    await io.remove(`/tmp/zotero/llm-for-zotero-mineru/${pdf.id}`);
    assert.equal(
      (await getMineruAvailabilityForAttachment(pdf as unknown as Zotero.Item))
        .status,
      "synced",
    );
  });

  it("does not report synced packages as available when sync is disabled", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdf = createAttachment({
      id: 52,
      key: "PDFDISABLED",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "disabled-sync.pdf",
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);

    await writeSampleCache(pdf.id);
    const zipBytes = await buildMineruSyncPackageBytes(
      pdf as unknown as Zotero.Item,
    );
    assert.exists(zipBytes);
    attachPackage({
      io,
      items,
      parent,
      id: 90,
      key: "PKGDISABLED",
      sourceKey: "PDFDISABLED",
      bytes: zipBytes!,
    });
    setMineruSyncEnabled(false);

    const localAvailability = await getMineruAvailabilityForAttachment(
      pdf as unknown as Zotero.Item,
    );
    assert.equal(localAvailability.status, "local");
    assert.isTrue(localAvailability.localCached);
    assert.isFalse(localAvailability.syncedPackage);

    await io.remove(`/tmp/zotero/llm-for-zotero-mineru/${pdf.id}`);
    const syncedOnlyAvailability = await getMineruAvailabilityForAttachment(
      pdf as unknown as Zotero.Item,
    );
    assert.equal(syncedOnlyAvailability.status, "missing");
    assert.isFalse(syncedOnlyAvailability.localCached);
    assert.isFalse(syncedOnlyAvailability.syncedPackage);
  });

  it("does not report unreadable title-matched packages as synced", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdf = createAttachment({
      id: 53,
      key: "PDFINVALID",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "invalid-package.pdf",
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);
    setMineruSyncEnabled(true);

    attachPackage({
      io,
      items,
      parent,
      id: 91,
      key: "PKGINVALID",
      sourceKey: "PDFINVALID",
      bytes: bytes("not a zip"),
    });

    const availability = await getMineruAvailabilityForAttachment(
      pdf as unknown as Zotero.Item,
    );
    assert.equal(availability.status, "missing");
    assert.isFalse(availability.localCached);
    assert.isFalse(availability.syncedPackage);
  });

  it("can report title-matched synced packages without reading ZIP bytes", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdf = createAttachment({
      id: 54,
      key: "PDFFAST",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "fast-availability.pdf",
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);
    setMineruSyncEnabled(true);

    const packageItem = attachPackage({
      io,
      items,
      parent,
      id: 92,
      key: "PKGFAST",
      sourceKey: "PDFFAST",
      bytes: bytes("not a zip"),
    });
    packageItem.getFilePathAsync = async () => {
      throw new Error("fast availability should not read package bytes");
    };

    const availability = await getMineruAvailabilityForAttachment(
      pdf as unknown as Zotero.Item,
      { validateSyncedPackage: false },
    );
    assert.equal(availability.status, "synced");
    assert.isFalse(availability.localCached);
    assert.isTrue(availability.syncedPackage);
  });

  it("restores a missing local cache from a matching synced package", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdf = createAttachment({
      id: 77,
      key: "PDFRESTORE",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "restore.pdf",
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);
    setMineruSyncEnabled(true);

    await writeSampleCache(pdf.id);
    const zipBytes = await buildMineruSyncPackageBytes(
      pdf as unknown as Zotero.Item,
    );
    assert.exists(zipBytes);
    await io.remove(`/tmp/zotero/llm-for-zotero-mineru/${pdf.id}`);

    const packagePath = "/tmp/zotero/package.zip";
    io.files.set(packagePath, zipBytes!);
    const packageItem = createAttachment({
      id: 88,
      key: "PKGRESTORE",
      parentID: parent.id,
      contentType: "application/zip",
      filename: "package.zip",
      filePath: packagePath,
      title: `${MINERU_SYNC_ATTACHMENT_TITLE_PREFIX} PDFRESTORE.zip`,
    });
    parent.attachmentIDs!.push(packageItem.id);
    items.set(packageItem.id, packageItem);

    const restored = await restoreSyncedMineruCacheForAttachment(
      pdf as unknown as Zotero.Item,
    );
    assert.equal(restored.status, "restored");
    assert.equal(
      await readCachedMineruMd(pdf.id),
      "# Intro\n# Results\ncontent",
    );
  });

  it("restores legacy synced packages with native MinerU content-list filenames", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdf = createAttachment({
      id: 177,
      key: "PDFLEGACYCL",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "legacy-content-list.pdf",
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);
    setMineruSyncEnabled(true);

    const zipBytes = zipSync(
      {
        [MINERU_SYNC_METADATA_FILE]: bytes(
          JSON.stringify({
            kind: MINERU_SYNC_PACKAGE_KIND,
            version: MINERU_SYNC_PACKAGE_VERSION,
            createdAt: "2026-06-27T00:00:00.000Z",
            addonName: "LLM for Zotero",
            addonVersion: "test",
            sourceAttachmentKey: "PDFLEGACYCL",
            sourceAttachmentFilename: "legacy-content-list.pdf",
            parentItemKey: "PARENTKEY",
          }),
        ),
        "full.md": bytes(
          "# Intro\nsummary\n# Methods\n![Fig](images/fig1.png)\n# Results\ncontent",
        ),
        "auto/legacy_uuid_content_list.json": bytes(
          JSON.stringify([
            { type: "text", text_level: 1, text: "Intro", page_idx: 0 },
            { type: "text", text_level: 1, text: "Methods", page_idx: 0 },
            {
              type: "image",
              img_path: "images/fig1.png",
              image_caption: ["Fig. 1 legacy caption"],
              page_idx: 0,
            },
            { type: "text", text_level: 1, text: "Results", page_idx: 1 },
          ]),
        ),
      },
      { level: 6 },
    );
    attachPackage({
      io,
      items,
      parent,
      id: 178,
      key: "PKGLEGACYCL",
      sourceKey: "PDFLEGACYCL",
      bytes: zipBytes,
    });

    const restored = await restoreSyncedMineruCacheForAttachment(
      pdf as unknown as Zotero.Item,
    );

    assert.equal(restored.status, "restored");
    const itemDir = getMineruItemDir(pdf.id);
    assert.isTrue(io.files.has(normalizePath(`${itemDir}/content_list.json`)));
    assert.isFalse(
      io.files.has(
        normalizePath(`${itemDir}/auto/legacy_uuid_content_list.json`),
      ),
    );
    const manifest = JSON.parse(
      decoder.decode(io.files.get(normalizePath(`${itemDir}/manifest.json`))!),
    );
    assert.deepInclude(manifest.allFigures, {
      label: "Fig. 1",
      baseLabel: "Figure 1",
      path: "images/fig1.png",
      caption: "Fig. 1 legacy caption",
      page: 0,
      section: "Methods",
    });
  });

  it("lazily restores a MinerU cache dir from a synced package and reuses the local cache", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdf = createAttachment({
      id: 80,
      key: "PDFLAZY",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "lazy.pdf",
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);
    setMineruSyncEnabled(true);

    await writeSampleCache(pdf.id);
    const zipBytes = await buildMineruSyncPackageBytes(
      pdf as unknown as Zotero.Item,
    );
    assert.exists(zipBytes);
    await io.remove(`/tmp/zotero/llm-for-zotero-mineru/${pdf.id}`);

    const packageItem = attachPackage({
      io,
      items,
      parent,
      id: 93,
      key: "PKGLAZY",
      sourceKey: "PDFLAZY",
      bytes: zipBytes!,
    });
    const readPackagePath = packageItem.getFilePathAsync!.bind(packageItem);
    let packageReads = 0;
    packageItem.getFilePathAsync = async () => {
      packageReads += 1;
      return readPackagePath();
    };
    pdfTextCache.set(pdf.id, {
      title: "Stale Zotero text",
      chunks: ["stale"],
      chunkMeta: [],
      chunkStats: [],
      docFreq: {},
      avgChunkLength: 1,
      fullLength: 5,
      sourceType: "zotero-worker",
    } as any);

    const restoredDir = await ensureMineruCacheDirForAttachment(
      pdf as unknown as Zotero.Item,
    );
    assert.equal(restoredDir, getMineruItemDir(pdf.id));
    assert.equal(packageReads, 1);
    assert.isTrue(await hasCachedMineruMd(pdf.id));
    assert.isUndefined(pdfTextCache.get(pdf.id));

    packageItem.getFilePathAsync = async () => {
      throw new Error("Local cache should skip package reads");
    };
    const reusedDir = await ensureMineruCacheDirForAttachment(
      pdf as unknown as Zotero.Item,
    );
    assert.equal(reusedDir, getMineruItemDir(pdf.id));
  });

  it("exposes mineruCacheDir from read_library after lazy restoring a synced package", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdf = createAttachment({
      id: 81,
      key: "PDFREADLIB",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "read-library.pdf",
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);
    setMineruSyncEnabled(true);

    await writeSampleCache(pdf.id);
    const zipBytes = await buildMineruSyncPackageBytes(
      pdf as unknown as Zotero.Item,
    );
    assert.exists(zipBytes);
    await io.remove(`/tmp/zotero/llm-for-zotero-mineru/${pdf.id}`);
    attachPackage({
      io,
      items,
      parent,
      id: 94,
      key: "PKGREADLIB",
      sourceKey: "PDFREADLIB",
      bytes: zipBytes!,
    });

    const tool = createReadLibraryTool(new ZoteroGateway());
    const validated = tool.validate({
      itemIds: [parent.id],
      sections: ["attachments"],
    });
    assert.isTrue(validated.ok);
    if (!validated.ok) return;

    const result = await tool.execute(validated.value, {
      request: {
        conversationKey: 1,
        mode: "agent",
        userText: "summarize",
        activeItemId: parent.id,
        libraryID: 1,
      },
      item: null,
      currentAnswerText: "",
      modelName: "gpt-5.5",
    });
    const entry = (result as { results: Record<string, any> }).results[
      String(parent.id)
    ];
    const pdfAttachment = entry.attachments.find(
      (attachment: { contextItemId: number }) =>
        attachment.contextItemId === pdf.id,
    );
    assert.equal(pdfAttachment?.mineruCacheDir, getMineruItemDir(pdf.id));
    assert.equal(
      await readCachedMineruMd(pdf.id),
      "# Intro\n# Results\ncontent",
    );
  });

  it("does not lazy restore when sync is disabled, package is invalid, or item is not a PDF", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdf = createAttachment({
      id: 82,
      key: "PDFFAILURES",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "failures.pdf",
    });
    const nonPdf = createAttachment({
      id: 83,
      key: "NONPDF",
      parentID: parent.id,
      contentType: "text/plain",
      filename: "note.txt",
    });
    parent.attachmentIDs!.push(pdf.id, nonPdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    items.set(nonPdf.id, nonPdf);
    setupZotero(items, io);

    await writeSampleCache(pdf.id);
    const zipBytes = await buildMineruSyncPackageBytes(
      pdf as unknown as Zotero.Item,
    );
    assert.exists(zipBytes);
    await io.remove(`/tmp/zotero/llm-for-zotero-mineru/${pdf.id}`);
    attachPackage({
      io,
      items,
      parent,
      id: 95,
      key: "PKGDISABLEDLZ",
      sourceKey: "PDFFAILURES",
      bytes: zipBytes!,
    });

    setMineruSyncEnabled(false);
    assert.isUndefined(
      await ensureMineruCacheDirForAttachment(pdf as unknown as Zotero.Item),
    );
    assert.isFalse(await hasCachedMineruMd(pdf.id));
    assert.isUndefined(
      await ensureMineruCacheDirForAttachment(nonPdf as unknown as Zotero.Item),
    );

    const invalidPdf = createAttachment({
      id: 84,
      key: "PDFINVALIDLZ",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "invalid-lazy.pdf",
    });
    parent.attachmentIDs!.push(invalidPdf.id);
    items.set(invalidPdf.id, invalidPdf);
    attachPackage({
      io,
      items,
      parent,
      id: 96,
      key: "PKGINVALIDLZ",
      sourceKey: "PDFINVALIDLZ",
      bytes: bytes("not a zip"),
    });
    setMineruSyncEnabled(true);

    assert.isUndefined(
      await ensureMineruCacheDirForAttachment(
        invalidPdf as unknown as Zotero.Item,
      ),
    );
    assert.isFalse(await hasCachedMineruMd(invalidPdf.id));
  });

  it("skips package reads when runtime cache already exists", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdf = createAttachment({
      id: 78,
      key: "PDFLOCALFIRST",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "local-first.pdf",
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);
    setMineruSyncEnabled(true);

    await writeSampleCache(pdf.id);
    const zipBytes = await buildMineruSyncPackageBytes(
      pdf as unknown as Zotero.Item,
    );
    assert.exists(zipBytes);
    const packageItem = attachPackage({
      io,
      items,
      parent,
      id: 79,
      key: "PKGLOCALFIRST",
      sourceKey: "PDFLOCALFIRST",
      bytes: zipBytes!,
    });
    packageItem.getFilePathAsync = async () => {
      throw new Error("Runtime restore should not read package bytes");
    };

    const restored = await ensureMineruRuntimeCacheForAttachment(
      pdf as unknown as Zotero.Item,
    );
    assert.equal(restored.status, "already_cached");
    assert.equal(
      await readCachedMineruMd(pdf.id),
      "# Intro\n# Results\ncontent",
    );
  });

  it("does not republish an unchanged synced package", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdf = createAttachment({
      id: 91,
      key: "PDFDUP",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "dup.pdf",
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);
    setMineruSyncEnabled(true);
    await writeSampleCache(pdf.id);

    const first = await publishMineruCachePackageForAttachment(pdf.id);
    assert.equal(first.status, "published");
    const second = await publishMineruCachePackageForAttachment(pdf.id);
    assert.equal(second.status, "up_to_date");
    assert.equal(
      [...items.values()].filter((item) => item.id >= 9000).length,
      1,
    );
  });

  it("prunes duplicate synced packages when an unchanged package is already current", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdf = createAttachment({
      id: 94,
      key: "PDFPRUNE",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "prune.pdf",
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);
    setMineruSyncEnabled(true);
    await writeSampleCache(pdf.id);

    const zipBytes = await buildMineruSyncPackageBytes(
      pdf as unknown as Zotero.Item,
    );
    assert.exists(zipBytes);
    const older = attachPackage({
      io,
      items,
      parent,
      id: 95,
      key: "PKGPRUNE1",
      sourceKey: "PDFPRUNE",
      bytes: zipBytes!,
    });
    const newer = attachPackage({
      io,
      items,
      parent,
      id: 96,
      key: "PKGPRUNE2",
      sourceKey: "PDFPRUNE",
      bytes: zipBytes!,
    });

    const result = await publishMineruCachePackageForAttachment(pdf.id);
    assert.equal(result.status, "up_to_date");
    assert.equal(result.packageAttachmentId, newer.id);
    assert.isTrue(older.deleted);
    assert.isFalse(newer.deleted === true);
  });

  it("leaves existing local cache untouched during runtime restore", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdf = createAttachment({
      id: 92,
      key: "PDFAUTH",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "authoritative.pdf",
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);
    setMineruSyncEnabled(true);

    await writeMineruCacheFiles(pdf.id, "# Synced source", [
      { relativePath: "content_list.json", data: bytes("[]") },
    ]);
    const zipBytes = await buildMineruSyncPackageBytes(
      pdf as unknown as Zotero.Item,
    );
    assert.exists(zipBytes);
    attachPackage({
      io,
      items,
      parent,
      id: 93,
      key: "PKGAUTH",
      sourceKey: "PDFAUTH",
      bytes: zipBytes!,
    });

    await writeMineruCacheFiles(pdf.id, "# Divergent local", [
      { relativePath: "content_list.json", data: bytes("[]") },
    ]);
    const restored = await restoreSyncedMineruCacheForAttachment(
      pdf as unknown as Zotero.Item,
    );
    assert.equal(restored.status, "already_cached");
    assert.equal(await readCachedMineruMd(pdf.id), "# Divergent local");
  });

  it("repairs over divergent local cache because the selected ZIP is authoritative", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdf = createAttachment({
      id: 192,
      key: "PDFAUTHREPAIR",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "authoritative-repair.pdf",
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);
    setMineruSyncEnabled(true);

    await writeMineruCacheFiles(pdf.id, "# Synced source", [
      { relativePath: "content_list.json", data: bytes("[]") },
    ]);
    const zipBytes = await buildMineruSyncPackageBytes(
      pdf as unknown as Zotero.Item,
    );
    assert.exists(zipBytes);
    attachPackage({
      io,
      items,
      parent,
      id: 193,
      key: "PKGAUTHREPAIR",
      sourceKey: "PDFAUTHREPAIR",
      bytes: zipBytes!,
    });

    await writeMineruCacheFiles(pdf.id, "# Divergent local", [
      { relativePath: "content_list.json", data: bytes("[]") },
    ]);
    const restored = await repairSyncedMineruCacheForAttachment(
      pdf as unknown as Zotero.Item,
    );
    assert.equal(restored.status, "restored");
    assert.equal(await readCachedMineruMd(pdf.id), "# Synced source");
  });

  it("uses the latest synced package and prunes older duplicates during repair", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdf = createAttachment({
      id: 97,
      key: "PDFLATEST",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "latest.pdf",
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);
    setMineruSyncEnabled(true);

    await writeMineruCacheFiles(pdf.id, "# Older synced", [
      { relativePath: "content_list.json", data: bytes("[]") },
    ]);
    const olderZip = await buildMineruSyncPackageBytes(
      pdf as unknown as Zotero.Item,
    );
    assert.exists(olderZip);
    const older = attachPackage({
      io,
      items,
      parent,
      id: 98,
      key: "PKGLATEST1",
      sourceKey: "PDFLATEST",
      bytes: olderZip!,
    });

    await writeMineruCacheFiles(pdf.id, "# Newer synced", [
      { relativePath: "content_list.json", data: bytes("[]") },
    ]);
    const newerZip = await buildMineruSyncPackageBytes(
      pdf as unknown as Zotero.Item,
    );
    assert.exists(newerZip);
    const newer = attachPackage({
      io,
      items,
      parent,
      id: 99,
      key: "PKGLATEST2",
      sourceKey: "PDFLATEST",
      bytes: newerZip!,
    });
    await io.remove(`/tmp/zotero/llm-for-zotero-mineru/${pdf.id}`);

    const restored = await repairSyncedMineruCacheForAttachment(
      pdf as unknown as Zotero.Item,
    );
    assert.equal(restored.status, "restored");
    assert.equal(restored.packageAttachmentId, newer.id);
    assert.equal(await readCachedMineruMd(pdf.id), "# Newer synced");
    assert.isTrue(older.deleted);
    assert.isFalse(newer.deleted === true);
  });

  it("uses the latest synced package without pruning during runtime restore", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdf = createAttachment({
      id: 197,
      key: "PDFRUNTIMELATEST",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "runtime-latest.pdf",
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);
    setMineruSyncEnabled(true);

    await writeMineruCacheFiles(pdf.id, "# Older synced", [
      { relativePath: "content_list.json", data: bytes("[]") },
    ]);
    const olderZip = await buildMineruSyncPackageBytes(
      pdf as unknown as Zotero.Item,
    );
    assert.exists(olderZip);
    const older = attachPackage({
      io,
      items,
      parent,
      id: 198,
      key: "PKGRUNTIMELATEST1",
      sourceKey: "PDFRUNTIMELATEST",
      bytes: olderZip!,
    });

    await writeMineruCacheFiles(pdf.id, "# Newer synced", [
      { relativePath: "content_list.json", data: bytes("[]") },
    ]);
    const newerZip = await buildMineruSyncPackageBytes(
      pdf as unknown as Zotero.Item,
    );
    assert.exists(newerZip);
    const newer = attachPackage({
      io,
      items,
      parent,
      id: 199,
      key: "PKGRUNTIMELATEST2",
      sourceKey: "PDFRUNTIMELATEST",
      bytes: newerZip!,
    });
    await io.remove(`/tmp/zotero/llm-for-zotero-mineru/${pdf.id}`);

    const restored = await ensureMineruRuntimeCacheForAttachment(
      pdf as unknown as Zotero.Item,
    );
    assert.equal(restored.status, "restored");
    assert.equal(restored.packageAttachmentId, newer.id);
    assert.equal(await readCachedMineruMd(pdf.id), "# Newer synced");
    assert.isFalse(older.deleted === true);
    assert.isFalse(newer.deleted === true);
  });

  it("repairs orphan local folders without backfilling source metadata", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdf = createAttachment({
      id: 301,
      key: "PDFREPAIRLOCAL",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "repair-local.pdf",
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);

    await writeSampleCache(pdf.id);
    await writeSampleCache(999);

    const result = await repairMineruCaches();

    assert.equal(result.checked, 1);
    assert.equal(result.removedOrphanCaches, 1);
    assert.isTrue(await hasCachedMineruMd(pdf.id));
    assert.isFalse(await hasCachedMineruMd(999));
    const provenance = await readMineruSourceProvenance(pdf.id);
    assert.isNull(provenance);
  });

  it("keeps source images during repair until figure crops are ready", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdf = createAttachment({
      id: 307,
      key: "PDFREPAIRMIGRATE",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "repair-migrate.pdf",
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);

    const itemDir = getMineruItemDir(pdf.id);
    io.files.set(
      normalizePath(`${itemDir}/full.md`),
      bytes(
        "# Intro\n![Fig](images/fig1.png)\nFig. 1 caption\n# Results\ncontent",
      ),
    );
    io.files.set(normalizePath(`${itemDir}/images/fig1.png`), bytes([1, 2, 3]));
    io.files.set(
      normalizePath(`${itemDir}/content_list.json`),
      bytes(
        JSON.stringify([
          { type: "text", text_level: 1, text: "Intro", page_idx: 0 },
          {
            type: "image",
            img_path: "images/fig1.png",
            image_caption: ["Fig. 1 caption"],
            page_idx: 0,
          },
          { type: "text", text_level: 1, text: "Results", page_idx: 1 },
        ]),
      ),
    );
    io.files.set(normalizePath(`${itemDir}/layout.json`), bytes("{}"));
    io.files.set(normalizePath(`${itemDir}/middle.json`), bytes("{}"));
    io.files.set(
      normalizePath(`${itemDir}/tables/table-1.png`),
      bytes([1, 2, 3]),
    );

    const result = await repairMineruCaches();

    assert.equal(result.checked, 1);
    assert.equal(
      await readCachedMineruMd(pdf.id),
      "# Intro\nFig. 1 caption\n# Results\ncontent",
    );
    assert.isTrue(io.files.has(normalizePath(`${itemDir}/images/fig1.png`)));
    assert.isFalse(io.files.has(normalizePath(`${itemDir}/layout.json`)));
    assert.isFalse(io.files.has(normalizePath(`${itemDir}/middle.json`)));
    assert.isFalse(
      io.files.has(normalizePath(`${itemDir}/tables/table-1.png`)),
    );
    const manifest = JSON.parse(
      decoder.decode(io.files.get(normalizePath(`${itemDir}/manifest.json`))!),
    );
    assert.deepEqual(manifest.figureBlocks[0].imagePaths, ["images/fig1.png"]);
  });

  it("strips source images during repair after figure crops are ready", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdf = createAttachment({
      id: 407,
      key: "PDFREPAIRCROPS",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "repair-crops.pdf",
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);

    const itemDir = getMineruItemDir(pdf.id);
    const sourceMd =
      "# Intro\n![Fig](images/fig1.png)\nFig. 1 caption\n# Results\ncontent";
    const canonicalMd = "# Intro\nFig. 1 caption\n# Results\ncontent";
    const contentList = [
      { type: "text", text_level: 1, text: "Intro", page_idx: 0 },
      {
        type: "image",
        img_path: "images/fig1.png",
        image_caption: ["Fig. 1 caption"],
        page_idx: 0,
      },
      { type: "text", text_level: 1, text: "Results", page_idx: 1 },
    ];
    const sourceManifest = buildManifest(sourceMd, contentList);
    const canonicalManifest = buildManifest(canonicalMd, contentList);
    const finalizedManifest = {
      ...canonicalManifest,
      figureBlocks: sourceManifest.figureBlocks,
    };
    const cropPath = `${itemDir}/figure_crops/crops/figure-1.png`;
    io.files.set(normalizePath(`${itemDir}/full.md`), bytes(sourceMd));
    io.files.set(normalizePath(`${itemDir}/images/fig1.png`), bytes([1, 2, 3]));
    io.files.set(
      normalizePath(`${itemDir}/content_list.json`),
      bytes(JSON.stringify(contentList)),
    );
    io.files.set(normalizePath(cropPath), bytes([137, 80, 78, 71, 1]));
    io.files.set(
      normalizePath(`${itemDir}/figure_crops/figure_geometry.json`),
      bytes(
        JSON.stringify({
          version: PDF_FIGURE_CROP_CACHE_VERSION,
          attachmentId: pdf.id,
          manifestHash: buildPdfFigureCropManifestHash(finalizedManifest),
          pdfFingerprint: "test",
          renderScale: 1.8,
          algorithmVersion: PDF_FIGURE_CROP_ALGORITHM_VERSION,
          generatedAt: 1,
          expectedFigures: [
            {
              label: "Figure 1",
              baseLabel: "Figure 1",
              pageNumber: 1,
              status: "ok",
              cropPath,
            },
          ],
          missingFigures: [],
          entries: [
            {
              id: "figure-1",
              label: "Figure 1",
              baseLabel: "Figure 1",
              pageNumber: 1,
              cropPath,
              rect: { left: 0, top: 0, width: 100, height: 100 },
              confidence: 0.9,
              source: "pdf-image-object",
              warnings: [],
              mineruImagePaths: [],
            },
          ],
        }),
      ),
    );

    const result = await repairMineruCaches();

    assert.equal(result.checked, 1);
    assert.equal(await readCachedMineruMd(pdf.id), canonicalMd);
    assert.isFalse(io.files.has(normalizePath(`${itemDir}/images/fig1.png`)));
    assert.isTrue(io.files.has(normalizePath(cropPath)));
  });

  it("keeps a local cache when the current PDF bytes changed", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdfPath = "/tmp/zotero/storage/302/stale.pdf";
    const pdf = createAttachment({
      id: 302,
      key: "PDFSTALELOCAL",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "stale.pdf",
      filePath: pdfPath,
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);
    io.files.set(normalizePath(pdfPath), bytes([1, 2, 3, 4]));

    await writeSampleCache(pdf.id);
    await writeMineruSourceProvenanceForAttachment(
      pdf as unknown as Zotero.Item,
    );
    io.files.set(normalizePath(pdfPath), bytes([9, 8, 7, 6]));

    const result = await repairMineruCaches();

    assert.equal(result.checked, 1);
    assert.isTrue(await hasCachedMineruMd(pdf.id));
  });

  it("restores a missing local cache from a synced ZIP and records restore metadata", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdfPath = "/tmp/zotero/storage/303/restorable.pdf";
    const pdf = createAttachment({
      id: 303,
      key: "PDFREPAIRSTORE",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "restorable.pdf",
      filePath: pdfPath,
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);
    io.files.set(normalizePath(pdfPath), bytes([1, 3, 5, 7]));

    await writeSampleCache(pdf.id);
    await writeMineruSourceProvenanceForAttachment(
      pdf as unknown as Zotero.Item,
    );
    const zipBytes = await buildMineruSyncPackageBytes(
      pdf as unknown as Zotero.Item,
    );
    assert.exists(zipBytes);
    const packageItem = attachPackage({
      io,
      items,
      parent,
      id: 304,
      key: "PKGREPAIRSTORE",
      sourceKey: "PDFREPAIRSTORE",
      bytes: zipBytes!,
    });
    await io.remove(`/tmp/zotero/llm-for-zotero-mineru/${pdf.id}`);

    const result = await repairMineruCaches();

    assert.equal(result.restored, 1);
    assert.equal(
      await readCachedMineruMd(pdf.id),
      "# Intro\n# Results\ncontent",
    );
    const provenance = await readMineruSourceProvenance(pdf.id);
    assert.equal(provenance?.origin, "restored");
    assert.equal(provenance?.packageAttachmentId, packageItem.id);
    assert.match(provenance?.cacheContentHash || "", /^fnv1a32-[a-f0-9]{8}$/);

    const secondRepair = await repairSyncedMineruCacheForAttachment(
      pdf as unknown as Zotero.Item,
      { ignoreSyncPreference: true },
    );
    assert.equal(secondRepair.status, "already_cached");
  });

  it("restores from a legacy synced ZIP while ignoring old fingerprint metadata", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdfPath = "/tmp/zotero/storage/305/mismatch.pdf";
    const pdf = createAttachment({
      id: 305,
      key: "PDFREPAIRMISMATCH",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "mismatch.pdf",
      filePath: pdfPath,
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);
    io.files.set(normalizePath(pdfPath), bytes([2, 4, 6, 8]));

    await writeSampleCache(pdf.id);
    await writeMineruSourceProvenanceForAttachment(
      pdf as unknown as Zotero.Item,
    );
    const zipBytes = await buildMineruSyncPackageBytes(
      pdf as unknown as Zotero.Item,
    );
    assert.exists(zipBytes);
    const entries = unzipSync(zipBytes!);
    const metadata = JSON.parse(
      decoder.decode(entries[MINERU_SYNC_METADATA_FILE]),
    );
    metadata.sourceFingerprint = {
      kind: "file-chunk-hash",
      value: "fnv1a32-legacy",
      size: 4,
      strong: true,
    };
    metadata.provenanceStatus = "verified";
    entries[MINERU_SYNC_METADATA_FILE] = encoder.encode(
      JSON.stringify(metadata),
    );
    attachPackage({
      io,
      items,
      parent,
      id: 306,
      key: "PKGREPAIRMISMATCH",
      sourceKey: "PDFREPAIRMISMATCH",
      bytes: zipSync(entries, { level: 6 }),
    });
    await io.remove(`/tmp/zotero/llm-for-zotero-mineru/${pdf.id}`);
    io.files.set(normalizePath(pdfPath), bytes([8, 6, 4, 2]));

    const result = await repairMineruCaches();

    assert.equal(result.restored, 1);
    assert.equal(result.failed, 0);
    assert.isTrue(await hasCachedMineruMd(pdf.id));
    assert.equal(
      await readCachedMineruMd(pdf.id),
      "# Intro\n# Results\ncontent",
    );
  });

  it("deletes orphan plugin-owned synced ZIP packages during repair", async function () {
    const io = setupMemoryIO();
    const items = new Map<number, MockItem>();
    const parent = createParent();
    const pdfPath = "/tmp/zotero/storage/307/orphaned.pdf";
    const pdf = createAttachment({
      id: 307,
      key: "PDFORPHANZIP",
      parentID: parent.id,
      contentType: "application/pdf",
      filename: "orphaned.pdf",
      filePath: pdfPath,
    });
    parent.attachmentIDs!.push(pdf.id);
    items.set(parent.id, parent);
    items.set(pdf.id, pdf);
    setupZotero(items, io);
    io.files.set(normalizePath(pdfPath), bytes([7, 7, 7, 7]));

    await writeSampleCache(pdf.id);
    await writeMineruSourceProvenanceForAttachment(
      pdf as unknown as Zotero.Item,
    );
    const zipBytes = await buildMineruSyncPackageBytes(
      pdf as unknown as Zotero.Item,
    );
    assert.exists(zipBytes);
    const packageItem = attachPackage({
      io,
      items,
      parent,
      id: 308,
      key: "PKGORPHANZIP",
      sourceKey: "PDFORPHANZIP",
      bytes: zipBytes!,
    });

    items.delete(pdf.id);
    parent.attachmentIDs = [packageItem.id];
    await io.remove(`/tmp/zotero/llm-for-zotero-mineru/${pdf.id}`);

    const result = await repairMineruCaches();

    assert.equal(result.checked, 0);
    assert.equal(result.removedOrphanSyncPackages, 1);
    assert.isTrue(packageItem.deleted);
  });
});
