// Covers package dist inventory collection and validation.
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempDir } from "../test-helpers/temp-dir.js";
import {
  assertNoLegacyPluginDependencyStagingDebris,
  collectLegacyPluginDependencyStagingDebrisPaths,
  collectPackageDistInventoryErrors,
  LOCAL_BUILD_METADATA_DIST_PATHS,
  PACKAGE_DIST_INVENTORY_RELATIVE_PATH,
  collectPackageDistInventory,
  isLegacyPluginDependencyInstallStagePath,
  writePackageDistInventory,
} from "./package-dist-inventory.js";

describe("package dist inventory", () => {
  it("tracks missing and stale dist files", async () => {
    await withTempDir({ prefix: "openclaw-dist-inventory-" }, async (packageRoot) => {
      const currentFile = path.join(packageRoot, "dist", "current-BR6xv1a1.js");
      await fs.mkdir(path.dirname(currentFile), { recursive: true });
      await fs.writeFile(currentFile, "export {};\n", "utf8");

      await expect(writePackageDistInventory(packageRoot)).resolves.toEqual([
        "dist/current-BR6xv1a1.js",
      ]);
      await expect(collectPackageDistInventoryErrors(packageRoot)).resolves.toStrictEqual([]);

      await fs.rm(currentFile);
      await fs.writeFile(
        path.join(packageRoot, "dist", "stale-CJUAgRQR.js"),
        "export {};\n",
        "utf8",
      );

      await expect(collectPackageDistInventoryErrors(packageRoot)).resolves.toEqual([
        "missing packaged dist file dist/current-BR6xv1a1.js",
        "unexpected packaged dist file dist/stale-CJUAgRQR.js",
      ]);
    });
  });

  it("keeps npm-omitted dist artifacts out of the inventory", async () => {
    await withTempDir({ prefix: "openclaw-dist-inventory-pack-" }, async (packageRoot) => {
      const packagedQaChannelRuntime = path.join(
        packageRoot,
        "dist",
        "extensions",
        "qa-channel",
        "runtime-api.js",
      );
      const packagedQaLabRuntime = path.join(
        packageRoot,
        "dist",
        "extensions",
        "qa-lab",
        "runtime-api.js",
      );
      const omittedQaChunk = path.join(packageRoot, "dist", "extensions", "qa-channel", "cli.js");
      const omittedQaLabChunk = path.join(packageRoot, "dist", "extensions", "qa-lab", "cli.js");
      const omittedQaMatrixChunk = path.join(
        packageRoot,
        "dist",
        "extensions",
        "qa-matrix",
        "index.js",
      );
      const omittedQaLabPluginSdk = path.join(packageRoot, "dist", "plugin-sdk", "qa-lab.js");
      const omittedQaChannelPluginSdk = path.join(
        packageRoot,
        "dist",
        "plugin-sdk",
        "qa-channel.js",
      );
      const omittedQaChannelProtocolPluginSdk = path.join(
        packageRoot,
        "dist",
        "plugin-sdk",
        "qa-channel-protocol.js",
      );
      const omittedQaLabTypes = path.join(
        packageRoot,
        "dist",
        "plugin-sdk",
        "extensions",
        "qa-lab",
        "cli.d.ts",
      );
      const omittedDeepPluginSdkDeclaration = path.join(
        packageRoot,
        "dist",
        "plugin-sdk",
        "src",
        "plugin-sdk",
        "provider-entry.d.ts",
      );
      const flatPluginSdkDeclaration = path.join(
        packageRoot,
        "dist",
        "plugin-sdk",
        "provider-entry.d.ts",
      );
      const omittedQaRuntimeChunk = path.join(packageRoot, "dist", "qa-runtime-B9LDtssJ.js");
      const [omittedBuildStamp, omittedRuntimePostBuildStamp] = LOCAL_BUILD_METADATA_DIST_PATHS.map(
        (relativePath) => path.join(packageRoot, relativePath),
      );
      const omittedMap = path.join(packageRoot, "dist", "feature.runtime.js.map");
      await fs.mkdir(path.dirname(packagedQaChannelRuntime), { recursive: true });
      await fs.mkdir(path.dirname(packagedQaLabRuntime), { recursive: true });
      await fs.mkdir(path.dirname(omittedQaMatrixChunk), { recursive: true });
      await fs.mkdir(path.dirname(omittedQaLabTypes), { recursive: true });
      await fs.mkdir(path.join(packageRoot, "dist", "plugin-sdk"), { recursive: true });
      await fs.mkdir(path.dirname(omittedDeepPluginSdkDeclaration), { recursive: true });
      await fs.writeFile(packagedQaChannelRuntime, "export {};\n", "utf8");
      await fs.writeFile(packagedQaLabRuntime, "export {};\n", "utf8");
      await fs.writeFile(omittedQaChunk, "export {};\n", "utf8");
      await fs.writeFile(omittedQaLabChunk, "export {};\n", "utf8");
      await fs.writeFile(omittedQaMatrixChunk, "export {};\n", "utf8");
      await fs.writeFile(omittedQaLabPluginSdk, "export {};\n", "utf8");
      await fs.writeFile(omittedQaChannelPluginSdk, "export {};\n", "utf8");
      await fs.writeFile(omittedQaChannelProtocolPluginSdk, "export {};\n", "utf8");
      await fs.writeFile(omittedQaLabTypes, "export {};\n", "utf8");
      await fs.writeFile(omittedDeepPluginSdkDeclaration, "export {};\n", "utf8");
      await fs.writeFile(flatPluginSdkDeclaration, "export {};\n", "utf8");
      await fs.writeFile(omittedQaRuntimeChunk, "export {};\n", "utf8");
      await fs.writeFile(omittedBuildStamp, "{}\n", "utf8");
      await fs.writeFile(omittedRuntimePostBuildStamp, "{}\n", "utf8");
      await fs.writeFile(omittedMap, "{}", "utf8");

      await expect(writePackageDistInventory(packageRoot)).resolves.toStrictEqual([
        "dist/plugin-sdk/provider-entry.d.ts",
      ]);
    });
  });

  it("honors package files exclusions when writing the dist inventory", async () => {
    await withTempDir({ prefix: "openclaw-dist-inventory-package-files-" }, async (packageRoot) => {
      const packagedRuntime = path.join(packageRoot, "dist", "plugin-sdk", "runtime.js");
      const omittedTestRuntime = path.join(
        packageRoot,
        "dist",
        "plugin-sdk",
        "plugin-test-runtime.js",
      );
      const omittedTestTypes = path.join(
        packageRoot,
        "dist",
        "plugin-sdk",
        "plugin-test-runtime.d.ts",
      );
      const omittedNestedHelper = path.join(
        packageRoot,
        "dist",
        "plugin-sdk",
        "src",
        "test-utils",
        "helpers.d.ts",
      );
      const omittedQaCompat = path.join(packageRoot, "dist", "plugin-sdk", "qa-channel.js");
      const omittedRuntimeChunk = path.join(packageRoot, "dist", "qa-runtime-AbC123.js");
      const omittedTopLevelMap = path.join(packageRoot, "dist", "runtime.js.map");
      const omittedMap = path.join(packageRoot, "dist", "plugin-sdk", "runtime.js.map");

      await fs.mkdir(path.dirname(packagedRuntime), { recursive: true });
      await fs.mkdir(path.dirname(omittedNestedHelper), { recursive: true });
      await fs.writeFile(
        path.join(packageRoot, "package.json"),
        JSON.stringify({
          files: [
            "dist/",
            "!dist/plugin-sdk/plugin-test-runtime.js",
            "!dist/plugin-sdk/plugin-test-runtime.d.ts",
            "!dist/plugin-sdk/src/test-utils/**",
            "!dist/plugin-sdk/qa-channel.*",
            "!dist/qa-runtime-*.js",
            "!dist/**/*.map",
          ],
        }),
        "utf8",
      );
      await fs.writeFile(packagedRuntime, "export {};\n", "utf8");
      await fs.writeFile(omittedTestRuntime, "export {};\n", "utf8");
      await fs.writeFile(omittedTestTypes, "export {};\n", "utf8");
      await fs.writeFile(omittedNestedHelper, "export {};\n", "utf8");
      await fs.writeFile(omittedQaCompat, "export {};\n", "utf8");
      await fs.writeFile(omittedRuntimeChunk, "export {};\n", "utf8");
      await fs.writeFile(omittedTopLevelMap, "{}", "utf8");
      await fs.writeFile(omittedMap, "{}", "utf8");

      await expect(writePackageDistInventory(packageRoot)).resolves.toEqual([
        "dist/plugin-sdk/runtime.js",
      ]);
    });
  });

  it("keeps transient plugin dependency trees out of the inventory", async () => {
    await withTempDir({ prefix: "openclaw-dist-inventory-plugin-deps-" }, async (packageRoot) => {
      const realFile = path.join(packageRoot, "dist", "index.js");
      const rootDependencyPackage = path.join(
        packageRoot,
        "dist",
        "extensions",
        "node_modules",
        "openclaw",
        "package.json",
      );
      const pluginDependencyPackage = path.join(
        packageRoot,
        "dist",
        "extensions",
        "slack",
        "node_modules",
        "left-pad",
        "package.json",
      );
      await fs.mkdir(path.dirname(realFile), { recursive: true });
      await fs.mkdir(path.dirname(rootDependencyPackage), { recursive: true });
      await fs.mkdir(path.dirname(pluginDependencyPackage), { recursive: true });
      await fs.writeFile(realFile, "export {};\n", "utf8");
      await fs.writeFile(rootDependencyPackage, "{}", "utf8");
      await fs.writeFile(pluginDependencyPackage, "{}", "utf8");

      await expect(writePackageDistInventory(packageRoot)).resolves.toEqual(["dist/index.js"]);
    });
  });

  it("omits packaged extension node_modules while keeping extension runtime files", async () => {
    await withTempDir(
      { prefix: "openclaw-dist-inventory-extension-node-modules-" },
      async (packageRoot) => {
        const extensionRuntime = path.join(
          packageRoot,
          "dist",
          "extensions",
          "demo",
          "runtime-api.js",
        );
        const rootSdkAliasPackage = path.join(
          packageRoot,
          "dist",
          "extensions",
          "node_modules",
          "openclaw",
          "package.json",
        );
        const extensionDependencyPackage = path.join(
          packageRoot,
          "dist",
          "extensions",
          "demo",
          "node_modules",
          "left-pad",
          "package.json",
        );

        await fs.mkdir(path.dirname(extensionRuntime), { recursive: true });
        await fs.mkdir(path.dirname(rootSdkAliasPackage), { recursive: true });
        await fs.mkdir(path.dirname(extensionDependencyPackage), { recursive: true });
        await fs.writeFile(extensionRuntime, "export {};\n", "utf8");
        await fs.writeFile(rootSdkAliasPackage, "{}", "utf8");
        await fs.writeFile(extensionDependencyPackage, "{}", "utf8");

        await expect(writePackageDistInventory(packageRoot)).resolves.toEqual([
          "dist/extensions/demo/runtime-api.js",
        ]);
      },
    );
  });

  it("keeps publishable externalized bundled plugin dist trees out of the inventory", async () => {
    await withTempDir({ prefix: "openclaw-dist-inventory-externalized-" }, async (packageRoot) => {
      const externalizedRuntime = path.join(
        packageRoot,
        "dist",
        "extensions",
        "external-chat",
        "index.js",
      );
      const bundledRuntime = path.join(
        packageRoot,
        "dist",
        "extensions",
        "bundled-chat",
        "index.js",
      );
      const externalizedPackageJson = path.join(
        packageRoot,
        "extensions",
        "external-chat",
        "package.json",
      );
      const bundledPackageJson = path.join(
        packageRoot,
        "extensions",
        "bundled-chat",
        "package.json",
      );
      const rootPackageJson = path.join(packageRoot, "package.json");

      await fs.mkdir(path.dirname(externalizedRuntime), { recursive: true });
      await fs.mkdir(path.dirname(bundledRuntime), { recursive: true });
      await fs.mkdir(path.dirname(externalizedPackageJson), { recursive: true });
      await fs.mkdir(path.dirname(bundledPackageJson), { recursive: true });
      await fs.writeFile(externalizedRuntime, "export {};\n", "utf8");
      await fs.writeFile(bundledRuntime, "export {};\n", "utf8");
      await fs.writeFile(
        rootPackageJson,
        JSON.stringify({
          files: ["dist/", "!dist/extensions/external-chat/**"],
        }),
        "utf8",
      );
      await fs.writeFile(
        externalizedPackageJson,
        JSON.stringify({
          name: "@openclaw/external-chat",
          openclaw: {
            release: {
              publishToClawHub: true,
              publishToNpm: true,
            },
          },
        }),
        "utf8",
      );
      await fs.writeFile(
        bundledPackageJson,
        JSON.stringify({
          name: "@openclaw/bundled-chat",
          openclaw: {},
        }),
        "utf8",
      );

      await expect(writePackageDistInventory(packageRoot)).resolves.toEqual([
        "dist/extensions/bundled-chat/index.js",
      ]);
    });
  });

  it("keeps publishable core-package runtime plugin dist trees in the inventory", async () => {
    await withTempDir({ prefix: "openclaw-dist-inventory-core-runtime-" }, async (packageRoot) => {
      const coreRuntime = path.join(packageRoot, "dist", "extensions", "core-chat", "index.js");
      const corePackageJson = path.join(packageRoot, "extensions", "core-chat", "package.json");

      await fs.mkdir(path.dirname(coreRuntime), { recursive: true });
      await fs.mkdir(path.dirname(corePackageJson), { recursive: true });
      await fs.writeFile(coreRuntime, "export {};\n", "utf8");
      await fs.writeFile(
        corePackageJson,
        JSON.stringify({
          name: "@openclaw/core-chat",
          openclaw: {
            release: {
              publishToClawHub: true,
              publishToNpm: true,
            },
          },
        }),
        "utf8",
      );

      await expect(writePackageDistInventory(packageRoot)).resolves.toEqual([
        "dist/extensions/core-chat/index.js",
      ]);
    });
  });

  it("reports runtime-created install staging dirs during installed dist verification", async () => {
    await withTempDir({ prefix: "openclaw-dist-inventory-stage-" }, async (packageRoot) => {
      const realFile = path.join(packageRoot, "dist", "real-AbC123.js");
      await fs.mkdir(path.dirname(realFile), { recursive: true });
      await fs.writeFile(realFile, "export {};\n", "utf8");
      await writePackageDistInventory(packageRoot);

      const bareStageFile = path.join(
        packageRoot,
        "dist",
        "extensions",
        "brave",
        ".openclaw-install-stage",
        "node_modules",
        "typebox",
        "build",
        "compile",
        "code.mjs",
      );
      const suffixedStageFile = path.join(
        packageRoot,
        "dist",
        "extensions",
        "browser",
        ".openclaw-install-stage-AbC123",
        "node_modules",
        "playwright-core",
        "package.json",
      );
      await fs.mkdir(path.dirname(bareStageFile), { recursive: true });
      await fs.writeFile(bareStageFile, "// staged\n", "utf8");
      await fs.mkdir(path.dirname(suffixedStageFile), { recursive: true });
      await fs.writeFile(suffixedStageFile, "{}", "utf8");

      await expect(collectPackageDistInventoryErrors(packageRoot)).resolves.toEqual([
        "unexpected packaged dist file dist/extensions/brave/.openclaw-install-stage/node_modules/typebox/build/compile/code.mjs",
        "unexpected packaged dist file dist/extensions/browser/.openclaw-install-stage-AbC123/node_modules/playwright-core/package.json",
      ]);
    });
  });

  it("matches install-stage paths case-insensitively across path segments", () => {
    expect(
      isLegacyPluginDependencyInstallStagePath(
        "dist/extensions/brave/.openclaw-install-stage/node_modules/typebox/package.json",
      ),
    ).toBe(true);
    expect(
      isLegacyPluginDependencyInstallStagePath(
        "dist/Extensions/browser/.OPENCLAW-INSTALL-STAGE-AbC123/node_modules/playwright-core/package.json",
      ),
    ).toBe(true);
    expect(
      isLegacyPluginDependencyInstallStagePath(
        "Dist/Extensions/browser/.OpenClaw-Install-Stage/package.json",
      ),
    ).toBe(true);
    expect(
      isLegacyPluginDependencyInstallStagePath(
        "dist/extensions/browser/.openclaw-runtime-deps-copy-AbC123/package.json",
      ),
    ).toBe(false);
    expect(
      isLegacyPluginDependencyInstallStagePath("dist/extensions/.openclaw-install-stage"),
    ).toBe(false);
  });

  it("rejects pre-populated install-stage debris at publish time", async () => {
    await withTempDir({ prefix: "openclaw-dist-inventory-stage-publish-" }, async (packageRoot) => {
      const seededStagePackageJson = path.join(
        packageRoot,
        "dist",
        "extensions",
        "evil",
        ".openclaw-install-stage",
        "package.json",
      );
      const suffixedSeed = path.join(
        packageRoot,
        "dist",
        "extensions",
        "browser",
        ".openclaw-install-stage-AbC123",
        "node_modules",
        "playwright-core",
        "package.json",
      );
      await fs.mkdir(path.dirname(seededStagePackageJson), { recursive: true });
      await fs.writeFile(seededStagePackageJson, "{}", "utf8");
      await fs.mkdir(path.dirname(suffixedSeed), { recursive: true });
      await fs.writeFile(suffixedSeed, "{}", "utf8");

      await expect(collectLegacyPluginDependencyStagingDebrisPaths(packageRoot)).resolves.toEqual([
        "dist/extensions/browser/.openclaw-install-stage-AbC123",
        "dist/extensions/evil/.openclaw-install-stage",
      ]);
      await expect(assertNoLegacyPluginDependencyStagingDebris(packageRoot)).rejects.toThrow(
        /unexpected legacy plugin dependency staging debris/,
      );
      await expect(writePackageDistInventory(packageRoot)).rejects.toThrow(
        /unexpected legacy plugin dependency staging debris/,
      );
    });
  });

  it("rejects mixed-case install-stage debris on case-sensitive release builders", async () => {
    await withTempDir(
      { prefix: "openclaw-dist-inventory-stage-extensions-case-" },
      async (packageRoot) => {
        const mixedCaseStage = path.join(
          packageRoot,
          "dist",
          "Extensions",
          "evil",
          ".OpenClaw-Install-Stage",
          "package.json",
        );
        await fs.mkdir(path.dirname(mixedCaseStage), { recursive: true });
        await fs.writeFile(mixedCaseStage, "{}", "utf8");

        await expect(collectLegacyPluginDependencyStagingDebrisPaths(packageRoot)).resolves.toEqual(
          ["dist/Extensions/evil/.OpenClaw-Install-Stage"],
        );
        await expect(writePackageDistInventory(packageRoot)).rejects.toThrow(
          /unexpected legacy plugin dependency staging debris/,
        );
      },
    );

    await withTempDir(
      { prefix: "openclaw-dist-inventory-stage-root-case-" },
      async (packageRoot) => {
        const mixedCaseStage = path.join(
          packageRoot,
          "Dist",
          "Extensions",
          "browser",
          ".OPENCLAW-INSTALL-STAGE-AbC123",
          "package.json",
        );
        await fs.mkdir(path.dirname(mixedCaseStage), { recursive: true });
        await fs.writeFile(mixedCaseStage, "{}", "utf8");

        await expect(collectLegacyPluginDependencyStagingDebrisPaths(packageRoot)).resolves.toEqual(
          ["Dist/Extensions/browser/.OPENCLAW-INSTALL-STAGE-AbC123"],
        );
        await expect(writePackageDistInventory(packageRoot)).rejects.toThrow(
          /unexpected legacy plugin dependency staging debris/,
        );
      },
    );
  });

  it("treats a missing dist/extensions tree as no staging debris", async () => {
    await withTempDir({ prefix: "openclaw-dist-inventory-no-extensions-" }, async (packageRoot) => {
      await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true });
      await expect(collectLegacyPluginDependencyStagingDebrisPaths(packageRoot)).resolves.toEqual(
        [],
      );
      await expect(
        assertNoLegacyPluginDependencyStagingDebris(packageRoot),
      ).resolves.toBeUndefined();
    });
  });

  it("fails closed when the inventory is missing", async () => {
    await withTempDir({ prefix: "openclaw-dist-inventory-missing-" }, async (packageRoot) => {
      await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true });
      await expect(collectPackageDistInventoryErrors(packageRoot)).resolves.toEqual([
        `missing package dist inventory ${PACKAGE_DIST_INVENTORY_RELATIVE_PATH}`,
      ]);
    });
  });

  it("rejects symlinked dist entries", async () => {
    await withTempDir({ prefix: "openclaw-dist-inventory-symlink-" }, async (packageRoot) => {
      const distDir = path.join(packageRoot, "dist");
      await fs.mkdir(distDir, { recursive: true });
      await fs.writeFile(path.join(packageRoot, "escape.js"), "export {};\n", "utf8");
      await fs.symlink(path.join(packageRoot, "escape.js"), path.join(distDir, "entry.js"));

      await expect(collectPackageDistInventory(packageRoot)).rejects.toThrow(
        "Unsafe package dist path: dist/entry.js",
      );
    });
  });
});
