import { afterEach, describe, expect, it, vi } from "vitest";
import os from "node:os";
import path from "node:path";
import { promises as fs } from "node:fs";
import {
  assertConfiguredLocalFolder,
  assertWritableConfiguredLocalFolder,
  inspectPluginLocalFolder,
  listPluginLocalFolderEntries,
  preparePluginLocalFolder,
  readPluginLocalFolderText,
  resolvePluginLocalFolderPath,
  deletePluginLocalFolderFile,
  writePluginLocalFolderTextAtomic,
} from "../services/plugin-local-folders.js";

describe("plugin local folders", () => {
  const tempRoots: string[] = [];

  afterEach(async () => {
    await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })));
    tempRoots.length = 0;
  });

  async function makeRoot() {
    const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-folder-"));
    tempRoots.push(root);
    return root;
  }

  it("reports a healthy generic folder when required paths exist", async () => {
    const root = await makeRoot();
    await fs.mkdir(path.join(root, "sources"));
    await fs.writeFile(path.join(root, "schema.md"), "schema", "utf8");

    const status = await inspectPluginLocalFolder({
      folderKey: "content-root",
      storedConfig: {
        path: root,
        access: "readWrite",
        requiredDirectories: ["sources"],
        requiredFiles: ["schema.md"],
      },
    });

    expect(status.healthy).toBe(true);
    expect(status.problems).toEqual([]);
    expect(status.requiredDirectories).toEqual(["sources"]);
    expect(status.requiredFiles).toEqual(["schema.md"]);
  });

  it("reports missing required folders and files without using product-specific branches", async () => {
    const root = await makeRoot();

    const status = await inspectPluginLocalFolder({
      folderKey: "content-root",
      storedConfig: {
        path: root,
        requiredDirectories: ["sources"],
        requiredFiles: ["schema.md"],
      },
    });

    expect(status.healthy).toBe(false);
    expect(status.missingDirectories).toEqual(["sources"]);
    expect(status.missingFiles).toEqual(["schema.md"]);
    expect(status.problems.map((item) => item.code)).toEqual(
      expect.arrayContaining(["missing_directory", "missing_file"]),
    );
  });

  it("reports all required paths as missing when the configured root does not exist", async () => {
    const root = await makeRoot();
    const missingRoot = path.join(root, "missing-root");

    const status = await inspectPluginLocalFolder({
      folderKey: "content-root",
      storedConfig: {
        path: missingRoot,
        requiredDirectories: ["sources"],
        requiredFiles: ["schema.md"],
      },
    });

    expect(status.healthy).toBe(false);
    expect(status.configured).toBe(true);
    expect(status.readable).toBe(false);
    expect(status.missingDirectories).toEqual(["sources"]);
    expect(status.missingFiles).toEqual(["schema.md"]);
    expect(status.problems.map((item) => item.code)).toContain("missing");
  });

  it("uses manifest declaration access and required paths over stored or caller overrides", async () => {
    const root = await makeRoot();
    await fs.mkdir(path.join(root, "manifest-dir"));
    await fs.writeFile(path.join(root, "manifest.md"), "schema", "utf8");

    const status = await inspectPluginLocalFolder({
      folderKey: "content-root",
      declaration: {
        folderKey: "content-root",
        displayName: "Content root",
        access: "read",
        requiredDirectories: ["manifest-dir"],
        requiredFiles: ["manifest.md"],
      },
      storedConfig: {
        path: root,
        access: "readWrite",
        requiredDirectories: ["stored-dir"],
        requiredFiles: ["stored.md"],
      },
      overrideConfig: {
        access: "readWrite",
        requiredDirectories: ["override-dir"],
        requiredFiles: ["override.md"],
      },
    });

    expect(status.access).toBe("read");
    expect(status.writable).toBe(false);
    expect(status.requiredDirectories).toEqual(["manifest-dir"]);
    expect(status.requiredFiles).toEqual(["manifest.md"]);
    expect(status.healthy).toBe(true);
  });

  it("prepares required directories for a read-write folder without creating required files", async () => {
    const root = await makeRoot();

    await preparePluginLocalFolder({
      folderKey: "content-root",
      storedConfig: {
        path: root,
        access: "readWrite",
        requiredDirectories: ["sources", "wiki/concepts"],
        requiredFiles: ["schema.md"],
      },
    });

    await expect(fs.stat(path.join(root, "sources"))).resolves.toMatchObject({});
    await expect(fs.stat(path.join(root, "wiki/concepts"))).resolves.toMatchObject({});
    await expect(fs.stat(path.join(root, "schema.md"))).rejects.toMatchObject({ code: "ENOENT" });

    const status = await inspectPluginLocalFolder({
      folderKey: "content-root",
      storedConfig: {
        path: root,
        access: "readWrite",
        requiredDirectories: ["sources", "wiki/concepts"],
        requiredFiles: ["schema.md"],
      },
    });
    expect(status.missingDirectories).toEqual([]);
    expect(status.missingFiles).toEqual(["schema.md"]);
  });

  it("allows write access to repair folders that are only missing required paths", async () => {
    const root = await makeRoot();
    const status = await inspectPluginLocalFolder({
      folderKey: "content-root",
      storedConfig: {
        path: root,
        access: "readWrite",
        requiredFiles: ["schema.md"],
      },
    });

    expect(status.healthy).toBe(false);
    expect(() => assertConfiguredLocalFolder(status)).toThrow("Local folder is not healthy");
    expect(() => assertWritableConfiguredLocalFolder(status)).not.toThrow();

    await writePluginLocalFolderTextAtomic(root, "schema.md", "schema");
    const repaired = await inspectPluginLocalFolder({
      folderKey: "content-root",
      storedConfig: {
        path: root,
        access: "readWrite",
        requiredFiles: ["schema.md"],
      },
    });
    expect(repaired.healthy).toBe(true);
  });

  it("rejects traversal outside the configured folder", async () => {
    const root = await makeRoot();

    await expect(resolvePluginLocalFolderPath(root, "../outside.txt")).rejects.toMatchObject({
      status: 403,
    });
  });

  it("detects required symlinks that escape the configured folder", async () => {
    const root = await makeRoot();
    const outside = await makeRoot();
    await fs.writeFile(path.join(outside, "secret.txt"), "nope", "utf8");
    await fs.symlink(path.join(outside, "secret.txt"), path.join(root, "linked.txt"));

    const status = await inspectPluginLocalFolder({
      folderKey: "content-root",
      storedConfig: {
        path: root,
        requiredFiles: ["linked.txt"],
      },
    });

    expect(status.healthy).toBe(false);
    expect(status.problems.some((item) => item.code === "symlink_escape")).toBe(true);
  });

  it("writes files atomically under the root and can read them back", async () => {
    const root = await makeRoot();
    await fs.mkdir(path.join(root, "nested"));

    await writePluginLocalFolderTextAtomic(root, "nested/page.md", "hello");
    await writePluginLocalFolderTextAtomic(root, "nested/page.md", "updated");

    await expect(readPluginLocalFolderText(root, "nested/page.md")).resolves.toBe("updated");
    const leftovers = await fs.readdir(path.join(root, "nested"));
    expect(leftovers.filter((name) => name.includes(".paperclip-"))).toEqual([]);
  });

  it("creates missing nested parent directories for atomic writes", async () => {
    const root = await makeRoot();

    await writePluginLocalFolderTextAtomic(root, "cases/active/smoke/README.md", "hello");

    await expect(readPluginLocalFolderText(root, "cases/active/smoke/README.md")).resolves.toBe("hello");
  });

  it("returns the real folder key after deleting a file", async () => {
    const root = await makeRoot();
    await fs.writeFile(path.join(root, "stale.md"), "delete me", "utf8");

    const status = await deletePluginLocalFolderFile(root, "stale.md", "content-root");

    expect(status.folderKey).toBe("content-root");
    await expect(fs.stat(path.join(root, "stale.md"))).rejects.toMatchObject({ code: "ENOENT" });
  });

  it("lists nested local folder entries without following symlink escapes", async () => {
    const root = await makeRoot();
    const outside = await makeRoot();
    await fs.mkdir(path.join(root, "wiki/concepts"), { recursive: true });
    await fs.writeFile(path.join(root, "wiki/concepts/live.md"), "# Live\n", "utf8");
    await fs.writeFile(path.join(outside, "secret.md"), "# Secret\n", "utf8");
    await fs.symlink(outside, path.join(root, "wiki/outside"));

    const listing = await listPluginLocalFolderEntries(root, {
      relativePath: "wiki",
      recursive: true,
      maxEntries: 20,
    });

    expect(listing.entries.map((entry) => entry.path)).toContain("wiki/concepts/live.md");
    expect(listing.entries.map((entry) => entry.path)).not.toContain("wiki/outside/secret.md");
    expect(listing.truncated).toBe(false);
  });

  it("revalidates temp-file containment before writing atomic contents", async () => {
    const root = await makeRoot();
    const outside = await makeRoot();
    const nested = path.join(root, "nested");
    await fs.mkdir(nested);
    const originalOpen = fs.open.bind(fs);
    const openSpy = vi.spyOn(fs, "open");
    openSpy.mockImplementationOnce(async (file, flags, mode) => {
      await fs.rm(nested, { recursive: true, force: true });
      await fs.symlink(outside, nested);
      return originalOpen(file, flags, mode);
    });

    try {
      await expect(writePluginLocalFolderTextAtomic(root, "nested/page.md", "secret")).rejects.toMatchObject({
        status: 403,
      });
      await expect(fs.readFile(path.join(outside, "page.md"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
      expect(await fs.readdir(outside)).toEqual([]);
    } finally {
      openSpy.mockRestore();
    }
  });
});
