import { assert } from "chai";
import {
  PDF_FIGURE_CROP_ALGORITHM_VERSION,
  PDF_FIGURE_CROP_CACHE_VERSION,
  buildPdfFigureCropManifestHash,
} from "../src/modules/contextPanel/pdfFigureCropCache";
import {
  buildManifest,
  getManifestFigureBaseLabel,
  MINERU_SOURCE_PROVENANCE_KIND,
  MINERU_SOURCE_PROVENANCE_VERSION,
  normalizeMineruCacheFiles,
  readCachedMineruMd,
  readMineruSourceProvenance,
  readManifest,
  writeMineruCacheFiles,
  writeMineruSourceProvenanceForAttachment,
  type MineruCacheFile,
} from "../src/modules/contextPanel/mineruCache";

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

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(
  options: { maxWritePathLength?: number } = {},
): 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,
      _opts?: { createAncestors?: boolean; ignoreExisting?: boolean },
    ) => {
      addDir(dirs, path);
    },
    write: async (path: string, data: Uint8Array) => {
      const normalized = normalizePath(path);
      if (
        options.maxWritePathLength &&
        normalized.length > options.maxWritePathLength
      ) {
        throw new Error(
          `Path exceeds ${options.maxWritePathLength} characters`,
        );
      }
      addDir(dirs, parentPath(normalized));
      files.set(normalized, data);
      writes.push(normalized);
    },
    remove: async (
      path: string,
      _opts?: { recursive?: boolean; ignoreAbsent?: boolean },
    ) => {
      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;
  (globalThis as unknown as { Zotero: unknown }).Zotero = {
    DataDirectory: { dir: "/tmp/zotero" },
    Profile: { dir: "/tmp/profile" },
  };
  (globalThis as unknown as { ztoolkit: unknown }).ztoolkit = {
    log: () => {},
  };

  return { files, dirs, writes };
}

describe("mineruCache", 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;
  });

  describe("manifest figure grouping", function () {
    it("normalizes same-number figure panels to a shared base label", function () {
      assert.equal(getManifestFigureBaseLabel("Fig. 1a"), "Figure 1");
      assert.equal(getManifestFigureBaseLabel("Figure 1B"), "Figure 1");
      assert.equal(getManifestFigureBaseLabel("Figure 1"), "Figure 1");
      assert.equal(getManifestFigureBaseLabel("Table 2c"), "Table 2");
      assert.equal(
        getManifestFigureBaseLabel("Supplementary Fig. S7b"),
        "Supplementary Figure S7",
      );
    });

    it("stores base labels for compound figure entries in the manifest", function () {
      const md = [
        "# Abstract",
        "Opening text.",
        "# Results",
        "![Fig 1a](images/fig1a.png)",
        "![Fig 1b](images/fig1b.png)",
        "# Discussion",
        "Closing text.",
      ].join("\n\n");

      const manifest = buildManifest(md, [
        { type: "text", text_level: 1, text: "Abstract", page_idx: 0 },
        { type: "text", text_level: 1, text: "Results", page_idx: 1 },
        {
          type: "image",
          img_path: "images/fig1a.png",
          image_caption: ["Figure 1a. Screening path."],
          page_idx: 1,
        },
        {
          type: "image",
          img_path: "images/fig1b.png",
          image_caption: ["Figure 1b. Validation path."],
          page_idx: 1,
        },
        { type: "text", text_level: 1, text: "Discussion", page_idx: 2 },
      ]);

      assert.deepEqual(
        manifest.allFigures.map((figure) => figure.baseLabel),
        ["Figure 1", "Figure 1"],
      );
      assert.deepEqual(
        manifest.sections[1].figures.map((figure) => figure.path),
        ["images/fig1a.png", "images/fig1b.png"],
      );
      assert.deepEqual(manifest.figureBlocks?.[0]?.imagePaths, [
        "images/fig1a.png",
        "images/fig1b.png",
      ]);
    });
  });

  it("normalizes long MinerU root/container paths and rewrites references", function () {
    const title = "A ".repeat(90).trim();
    const originalImagePath = `${title}/auto/images/fig1.png`;
    const originalContentListPath = `${title}/auto/${title}_content_list.json`;
    const files: MineruCacheFile[] = [
      {
        relativePath: `${title}/auto/${title}.md`,
        data: bytes(`# Intro\n![Fig](${originalImagePath})`),
      },
      { relativePath: originalImagePath, data: bytes([1, 2, 3]) },
      {
        relativePath: originalContentListPath,
        data: bytes(
          JSON.stringify([
            { type: "text", text_level: 1, text: "Intro", page_idx: 0 },
            {
              type: "image",
              img_path: originalImagePath,
              image_caption: ["Fig. 1 caption"],
              page_idx: 0,
            },
          ]),
        ),
      },
    ];

    const normalized = normalizeMineruCacheFiles(
      `# Intro\n![Fig](${originalImagePath})`,
      files,
    );

    assert.include(normalized.mdContent, "](images/fig1.png)");
    assert.sameMembers(
      normalized.files.map((file) => file.relativePath),
      ["images/fig1.png", "content_list.json"],
    );
    assert.isFalse(
      normalized.files.some((file) => file.relativePath.includes(title)),
    );

    const contentList = normalized.files.find(
      (file) => file.relativePath === "content_list.json",
    );
    assert.exists(contentList);
    const parsed = JSON.parse(decoder.decode(contentList!.data));
    assert.equal(parsed[1].img_path, "images/fig1.png");
  });

  it("writes stripped full.md while preserving logical figure paths in manifest", async function () {
    const io = setupMemoryIO();
    const originalImagePath = "Long Paper Title/auto/images/fig1.png";
    const mdContent = [
      "# Intro",
      `![Fig](${originalImagePath})`,
      "Fig. 1 caption",
      "# Methods",
      "methods",
      "# Results",
      "results",
    ].join("\n");

    await writeMineruCacheFiles(42, mdContent, [
      {
        relativePath: "Long Paper Title/auto/Long Paper Title.md",
        data: bytes(mdContent),
      },
      { relativePath: originalImagePath, data: bytes([1, 2, 3, 4]) },
      {
        relativePath:
          "Long Paper Title/auto/Long Paper Title_content_list.json",
        data: bytes(
          JSON.stringify([
            { type: "text", text_level: 1, text: "Intro", page_idx: 0 },
            {
              type: "image",
              img_path: originalImagePath,
              image_caption: ["Fig. 1 caption"],
              page_idx: 0,
            },
            { type: "text", text_level: 1, text: "Methods", page_idx: 1 },
            { type: "text", text_level: 1, text: "Results", page_idx: 2 },
          ]),
        ),
      },
    ]);

    assert.equal(
      await readCachedMineruMd(42),
      [
        "# Intro",
        "Fig. 1 caption",
        "# Methods",
        "methods",
        "# Results",
        "results",
      ].join("\n"),
    );
    assert.isTrue(
      io.files.has("/tmp/zotero/llm-for-zotero-mineru/42/images/fig1.png"),
    );

    const manifest = await readManifest(42);
    assert.equal(manifest?.sections[0].figures[0].path, "images/fig1.png");
    assert.deepEqual(manifest?.figureBlocks?.[0]?.imagePaths, [
      "images/fig1.png",
    ]);
  });

  it("normalizes local MinerU nested ZIP output into agent-readable cache files", async function () {
    const io = setupMemoryIO();
    const archiveImagePath = "paper/pipeline/images/fig1.png";
    const imageRefPath = "images/fig1.png";
    const mdContent = [
      "# Intro",
      `![Fig](${imageRefPath})`,
      "# Results",
      "results",
    ].join("\n");

    await writeMineruCacheFiles(51, mdContent, [
      {
        relativePath: "paper/pipeline/full.md",
        data: bytes(mdContent),
      },
      {
        relativePath: archiveImagePath,
        data: bytes([1, 2, 3, 4]),
      },
      {
        relativePath: "paper/pipeline/paper_content_list.json",
        data: bytes(
          JSON.stringify([
            { type: "text", text_level: 1, text: "Intro", page_idx: 0 },
            {
              type: "image",
              img_path: imageRefPath,
              image_caption: ["Fig. 1 caption"],
              page_idx: 0,
            },
            { type: "text", text_level: 1, text: "Results", page_idx: 1 },
          ]),
        ),
      },
      {
        relativePath: "paper/pipeline/layout.json",
        data: bytes("{}"),
      },
      {
        relativePath: "paper/pipeline/middle.json",
        data: bytes("{}"),
      },
      {
        relativePath: "paper/pipeline/tables/table-1.png",
        data: bytes([1, 2, 3]),
      },
    ]);

    assert.equal(await readCachedMineruMd(51), "# Intro\n# Results\nresults");
    assert.includeMembers(io.writes, [
      "/tmp/zotero/llm-for-zotero-mineru/51/full.md",
      "/tmp/zotero/llm-for-zotero-mineru/51/content_list.json",
      "/tmp/zotero/llm-for-zotero-mineru/51/manifest.json",
    ]);
    assert.include(
      io.writes,
      "/tmp/zotero/llm-for-zotero-mineru/51/images/fig1.png",
    );
    assert.notInclude(
      io.writes,
      "/tmp/zotero/llm-for-zotero-mineru/51/layout.json",
    );
    assert.notInclude(
      io.writes,
      "/tmp/zotero/llm-for-zotero-mineru/51/middle.json",
    );
    assert.notInclude(
      io.writes,
      "/tmp/zotero/llm-for-zotero-mineru/51/tables/table-1.png",
    );

    const manifest = await readManifest(51);
    assert.equal(manifest?.sections[0].figures[0].path, "images/fig1.png");
    assert.deepEqual(manifest?.figureBlocks?.[0]?.imagePaths, [
      "images/fig1.png",
    ]);
  });

  it("skips unsafe archive paths", function () {
    const normalized = normalizeMineruCacheFiles("# Intro", [
      { relativePath: "paper/full.md", data: bytes("# Intro") },
      { relativePath: "../evil.png", data: bytes([1]) },
      { relativePath: "/tmp/evil.png", data: bytes([2]) },
      { relativePath: "C:\\tmp\\evil.png", data: bytes([3]) },
      { relativePath: "C:tmp/evil.png", data: bytes([4]) },
      { relativePath: "\\\\server\\share\\evil.png", data: bytes([4]) },
      { relativePath: "paper/images/good.png", data: bytes([5]) },
    ]);

    assert.deepEqual(
      normalized.files.map((file) => file.relativePath),
      ["images/good.png"],
    );
  });

  it("keeps simple cache layouts readable", async function () {
    const io = setupMemoryIO();
    await writeMineruCacheFiles(7, "# Simple\n![x](images/a.png)", [
      { relativePath: "full.md", data: bytes("# Simple\n![x](images/a.png)") },
      { relativePath: "images/a.png", data: bytes([9, 8, 7]) },
    ]);

    assert.equal(await readCachedMineruMd(7), "# Simple");
    assert.isTrue(
      io.files.has("/tmp/zotero/llm-for-zotero-mineru/7/images/a.png"),
    );
  });

  it("does not persist raw MinerU source images after figure crops are ready", async function () {
    const io = setupMemoryIO();
    const itemDir = "/tmp/zotero/llm-for-zotero-mineru/77";
    const cropPath = `${itemDir}/figure_crops/crops/figure-1.png`;
    const markdown = [
      "# Results",
      "![Figure 1](images/fig1.png)",
      "Figure 1. Better crop replacement.",
    ].join("\n");
    const contentList = [
      { type: "text", text_level: 1, text: "Results", page_idx: 0 },
      {
        type: "image",
        img_path: "images/fig1.png",
        image_caption: ["Figure 1. Better crop replacement."],
        page_idx: 0,
      },
    ];
    const sourceManifest = buildManifest(markdown, contentList);
    const canonicalManifest = buildManifest(
      "# Results\nFigure 1. Better crop replacement.",
      contentList,
    );
    const finalizedManifest = {
      ...canonicalManifest,
      figureBlocks: sourceManifest.figureBlocks,
    };
    io.files.set(cropPath, bytes([137, 80, 78, 71, 1]));
    io.files.set(
      `${itemDir}/figure_crops/figure_geometry.json`,
      bytes(
        JSON.stringify({
          version: PDF_FIGURE_CROP_CACHE_VERSION,
          attachmentId: 77,
          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: [],
            },
          ],
        }),
      ),
    );
    await writeMineruCacheFiles(77, markdown, [
      { relativePath: "full.md", data: bytes(markdown) },
      {
        relativePath: "content_list.json",
        data: bytes(JSON.stringify(contentList)),
      },
      {
        relativePath: "images/fig1.png",
        data: bytes([137, 80, 78, 71, 2]),
      },
    ]);

    assert.isFalse(
      io.files.has("/tmp/zotero/llm-for-zotero-mineru/77/images/fig1.png"),
    );
    assert.isTrue(io.files.has(cropPath));
  });

  it("keeps compound figure metadata without source image files", async function () {
    const io = setupMemoryIO();
    const markdown = [
      "## Results",
      "",
      "![Figure 2a](images/fig2a.png)",
      "",
      "![Figure 2b](images/fig2b.png)",
      "",
      "![Figure 2c](images/fig2c.png)",
      "",
      "Figure 2. Attractor network for probabilistic decision-making.",
    ].join("\n");
    await writeMineruCacheFiles(78, markdown, [
      { relativePath: "full.md", data: bytes(markdown) },
      {
        relativePath: "paper_content_list.json",
        data: bytes(
          JSON.stringify([
            {
              type: "image",
              img_path: "images/fig2a.png",
              image_caption: ["Figure 2. Attractor network."],
            },
            { type: "image", img_path: "images/fig2b.png" },
            { type: "image", img_path: "images/fig2c.png" },
          ]),
        ),
      },
      { relativePath: "images/fig2a.png", data: bytes([137, 80, 78, 71, 1]) },
      { relativePath: "images/fig2b.png", data: bytes([137, 80, 78, 71, 2]) },
      { relativePath: "images/fig2c.png", data: bytes([137, 80, 78, 71, 3]) },
    ]);

    const manifest = await readManifest(78);
    assert.deepEqual(manifest?.figureBlocks?.[0]?.imagePaths, [
      "images/fig2a.png",
      "images/fig2b.png",
      "images/fig2c.png",
    ]);
    for (const name of ["fig2a", "fig2b", "fig2c"]) {
      assert.isTrue(
        io.files.has(`/tmp/zotero/llm-for-zotero-mineru/78/images/${name}.png`),
      );
    }
  });

  it("writes lightweight parsed source metadata without fingerprinting the PDF", async function () {
    const io = setupMemoryIO();
    const pdfPath = "/tmp/zotero/storage/42/paper.pdf";
    const parent = {
      id: 10,
      key: "PARENTKEY",
      isRegularItem: () => true,
      isAttachment: () => false,
      getAttachments: () => [42],
    };
    const pdf = {
      id: 42,
      key: "PDFKEY",
      parentID: 10,
      attachmentFilename: "paper.pdf",
      attachmentContentType: "application/pdf",
      isRegularItem: () => false,
      isAttachment: () => true,
      getFilePathAsync: async () => {
        throw new Error("source metadata should not read PDF bytes");
      },
    };
    (globalThis as any).Zotero.Items = {
      get: (id: number) => (id === 10 ? parent : id === 42 ? pdf : null),
    };
    io.files.set(normalizePath(pdfPath), bytes([1, 2, 3, 4, 5]));

    await writeMineruCacheFiles(42, "# Intro", []);
    const written = await writeMineruSourceProvenanceForAttachment(
      pdf as unknown as Zotero.Item,
    );

    const raw = JSON.parse(
      decoder.decode(
        io.files.get(
          normalizePath(
            "/tmp/zotero/llm-for-zotero-mineru/42/_llm_source.json",
          ),
        )!,
      ),
    );
    assert.equal(raw.kind, MINERU_SOURCE_PROVENANCE_KIND);
    assert.equal(raw.version, MINERU_SOURCE_PROVENANCE_VERSION);
    assert.equal(raw.origin, "parsed");
    assert.equal(raw.parsedAt, raw.recordedAt);
    assert.notProperty(raw, "sourceFingerprint");
    assert.notProperty(raw, "provenanceStatus");

    const provenance = await readMineruSourceProvenance(42);
    assert.deepEqual(provenance, written);
    assert.equal(provenance?.attachmentKey, "PDFKEY");
    assert.equal(provenance?.parentItemKey, "PARENTKEY");
  });

  it("reads legacy source metadata without preserving fingerprint state", async function () {
    const io = setupMemoryIO();
    io.files.set(
      normalizePath("/tmp/zotero/llm-for-zotero-mineru/51/_llm_source.json"),
      bytes(
        JSON.stringify({
          attachmentId: 51,
          attachmentKey: "PDFMETA",
          sourceFilename: "original.pdf",
          sourceFingerprint: {
            kind: "file-chunk-hash",
            value: "fnv1a32-legacy",
            size: 4,
            strong: true,
          },
          provenanceStatus: "legacy_unverified",
          parsedAt: "2020-01-01T00:00:00.000Z",
        }),
      ),
    );

    const provenance = await readMineruSourceProvenance(51);

    assert.equal(provenance?.kind, MINERU_SOURCE_PROVENANCE_KIND);
    assert.equal(provenance?.version, MINERU_SOURCE_PROVENANCE_VERSION);
    assert.equal(provenance?.origin, "parsed");
    assert.equal(provenance?.recordedAt, "2020-01-01T00:00:00.000Z");
    assert.equal(provenance?.parsedAt, "2020-01-01T00:00:00.000Z");
    assert.notProperty(
      provenance as Record<string, unknown>,
      "sourceFingerprint",
    );
    assert.notProperty(
      provenance as Record<string, unknown>,
      "provenanceStatus",
    );
  });

  it("keeps normalized writes below a Windows-style path limit", async function () {
    const io = setupMemoryIO({ maxWritePathLength: 260 });
    const title = "Long Windows Path Title ".repeat(12).trim();
    await writeMineruCacheFiles(
      55,
      `# Intro\n![Fig](${title}/auto/images/fig.png)`,
      [
        {
          relativePath: `${title}/auto/${title}.md`,
          data: bytes("# Intro"),
        },
        {
          relativePath: `${title}/auto/images/fig.png`,
          data: bytes([1, 2, 3]),
        },
      ],
    );

    assert.isTrue(io.writes.every((path) => path.length <= 260));
    assert.include(io.writes, "/tmp/zotero/llm-for-zotero-mineru/55/full.md");
    assert.include(
      io.writes,
      "/tmp/zotero/llm-for-zotero-mineru/55/images/fig.png",
    );
  });

  it("ignores unknown MinerU artifacts instead of writing them", async function () {
    const io = setupMemoryIO();

    await writeMineruCacheFiles(99, "# Intro", [
      { relativePath: "paper/full.md", data: bytes("# Intro") },
      {
        relativePath:
          "paper/very-long-nonimage-artifact-name-that-still-gets-written.txt",
        data: bytes([1]),
      },
    ]);

    assert.equal(await readCachedMineruMd(99), "# Intro");
    assert.notInclude(
      io.writes,
      "/tmp/zotero/llm-for-zotero-mineru/99/very-long-nonimage-artifact-name-that-still-gets-written.txt",
    );
  });
});
