import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { WORKSPACE_TEMPLATE_PACK_PATHS } from "../scripts/lib/workspace-bootstrap-smoke.mjs";
import {
  compareReleaseVersions,
  collectControlUiPackErrors,
  collectForbiddenPackedContentErrors,
  collectForbiddenPackedPathErrors,
  collectPackedTestCargoErrors,
  collectReleasePackageMetadataErrors,
  collectReleaseTagErrors,
  parseNpmPackJsonOutput,
  parseReleaseTagVersion,
  parseReleaseVersion,
  resolveNpmDistTagMirrorAuth,
  resolveNpmPublishPlan,
  resolveNpmCommandInvocation,
  shouldSkipPackedTarballValidation,
  utcCalendarDayDistance,
} from "../scripts/openclaw-npm-release-check.ts";
import {
  LOCAL_BUILD_METADATA_DIST_PATHS,
  PACKAGE_DIST_INVENTORY_RELATIVE_PATH,
} from "../src/infra/package-dist-inventory.ts";

const REQUIRED_PACKED_PATHS = [
  PACKAGE_DIST_INVENTORY_RELATIVE_PATH,
  ...WORKSPACE_TEMPLATE_PACK_PATHS,
] as const;

describe("parseReleaseVersion", () => {
  it("parses stable CalVer releases", () => {
    expect(parseReleaseVersion("2026.3.10")).toStrictEqual({
      version: "2026.3.10",
      baseVersion: "2026.3.10",
      channel: "stable",
      year: 2026,
      month: 3,
      day: 10,
      alphaNumber: undefined,
      betaNumber: undefined,
      date: new Date(Date.UTC(2026, 2, 10)),
    });
  });

  it("parses beta CalVer releases", () => {
    expect(parseReleaseVersion("2026.3.10-beta.2")).toStrictEqual({
      version: "2026.3.10-beta.2",
      baseVersion: "2026.3.10",
      channel: "beta",
      year: 2026,
      month: 3,
      day: 10,
      alphaNumber: undefined,
      betaNumber: 2,
      date: new Date(Date.UTC(2026, 2, 10)),
    });
  });

  it("parses alpha CalVer releases", () => {
    expect(parseReleaseVersion("2026.3.10-alpha.2")).toStrictEqual({
      version: "2026.3.10-alpha.2",
      baseVersion: "2026.3.10",
      channel: "alpha",
      year: 2026,
      month: 3,
      day: 10,
      alphaNumber: 2,
      betaNumber: undefined,
      date: new Date(Date.UTC(2026, 2, 10)),
    });
  });

  it("parses stable correction releases", () => {
    expect(parseReleaseVersion("2026.3.10-1")).toStrictEqual({
      version: "2026.3.10-1",
      baseVersion: "2026.3.10",
      channel: "stable",
      year: 2026,
      month: 3,
      day: 10,
      alphaNumber: undefined,
      betaNumber: undefined,
      date: new Date(Date.UTC(2026, 2, 10)),
      correctionNumber: 1,
    });
  });

  it("rejects legacy and malformed release formats", () => {
    expect(parseReleaseVersion("2026.03.09")).toBeNull();
    expect(parseReleaseVersion("v2026.3.10")).toBeNull();
    expect(parseReleaseVersion("2026.2.30")).toBeNull();
    expect(parseReleaseVersion("2026.3.10-0")).toBeNull();
    expect(parseReleaseVersion("2.0.0-beta2")).toBeNull();
  });
});

describe("parseReleaseTagVersion", () => {
  it("accepts correction release tags", () => {
    expect(parseReleaseTagVersion("2026.3.10-2")).toStrictEqual({
      version: "2026.3.10-2",
      packageVersion: "2026.3.10-2",
      baseVersion: "2026.3.10",
      channel: "stable",
      date: new Date(Date.UTC(2026, 2, 10)),
      correctionNumber: 2,
    });
  });

  it("rejects beta correction tags and malformed correction tags", () => {
    expect(parseReleaseTagVersion("2026.3.10-beta.1-1")).toBeNull();
    expect(parseReleaseTagVersion("2026.3.10-0")).toBeNull();
  });
});

describe("resolveNpmPublishPlan", () => {
  it("publishes beta prereleases to beta only", () => {
    expect(resolveNpmPublishPlan("2026.3.29-beta.2")).toEqual({
      channel: "beta",
      publishTag: "beta",
      mirrorDistTags: [],
    });
  });

  it("publishes alpha prereleases to alpha only", () => {
    expect(resolveNpmPublishPlan("2026.3.29-alpha.2", undefined, "alpha")).toEqual({
      channel: "alpha",
      publishTag: "alpha",
      mirrorDistTags: [],
    });
  });

  it("publishes stable releases to beta first", () => {
    expect(resolveNpmPublishPlan("2026.3.29")).toEqual({
      channel: "stable",
      publishTag: "beta",
      mirrorDistTags: [],
    });
  });

  it("publishes stable correction releases to beta first too", () => {
    expect(resolveNpmPublishPlan("2026.3.29-2")).toEqual({
      channel: "stable",
      publishTag: "beta",
      mirrorDistTags: [],
    });
  });

  it("can publish stable releases directly to latest when requested", () => {
    expect(resolveNpmPublishPlan("2026.3.29", undefined, "latest")).toEqual({
      channel: "stable",
      publishTag: "latest",
      mirrorDistTags: [],
    });
  });

  it("can publish stable correction releases directly to latest when requested", () => {
    expect(resolveNpmPublishPlan("2026.3.29-1", undefined, "latest")).toEqual({
      channel: "stable",
      publishTag: "latest",
      mirrorDistTags: [],
    });
  });

  it("ignores current beta dist-tag state for stable publishes", () => {
    expect(resolveNpmPublishPlan("2026.3.29", "2026.4.1-beta.1")).toEqual({
      channel: "stable",
      publishTag: "beta",
      mirrorDistTags: [],
    });
  });

  it("rejects publishing beta prereleases to latest", () => {
    expect(() => resolveNpmPublishPlan("2026.3.29-beta.2", undefined, "latest")).toThrow(
      "Beta prereleases must publish to the beta dist-tag.",
    );
  });

  it("rejects publishing alpha prereleases to beta or latest", () => {
    expect(() => resolveNpmPublishPlan("2026.3.29-alpha.2")).toThrow(
      "Alpha prereleases must publish to the alpha dist-tag.",
    );
    expect(() => resolveNpmPublishPlan("2026.3.29-alpha.2", undefined, "latest")).toThrow(
      "Alpha prereleases must publish to the alpha dist-tag.",
    );
  });
});

describe("resolveNpmDistTagMirrorAuth", () => {
  it("prefers NODE_AUTH_TOKEN when both auth env vars exist", () => {
    expect(
      resolveNpmDistTagMirrorAuth({
        nodeAuthToken: "node-token",
        npmToken: "npm-token",
      }),
    ).toEqual({
      hasAuth: true,
      source: "node-auth-token",
    });
  });

  it("falls back to NPM_TOKEN when NODE_AUTH_TOKEN is missing", () => {
    expect(
      resolveNpmDistTagMirrorAuth({
        nodeAuthToken: "  ",
        npmToken: "npm-token",
      }),
    ).toEqual({
      hasAuth: true,
      source: "npm-token",
    });
  });

  it("reports missing auth when neither token exists", () => {
    expect(
      resolveNpmDistTagMirrorAuth({
        nodeAuthToken: "",
        npmToken: undefined,
      }),
    ).toEqual({
      hasAuth: false,
      source: "none",
    });
  });
});

describe("shouldSkipPackedTarballValidation", () => {
  it("defaults to full pack validation", () => {
    expect(shouldSkipPackedTarballValidation({})).toBe(false);
  });

  it("accepts truthy values for metadata-only validation", () => {
    expect(
      shouldSkipPackedTarballValidation({
        OPENCLAW_NPM_RELEASE_SKIP_PACK_CHECK: "1",
      }),
    ).toBe(true);
  });

  it("treats false-like values as disabled", () => {
    expect(
      shouldSkipPackedTarballValidation({
        OPENCLAW_NPM_RELEASE_SKIP_PACK_CHECK: "false",
      }),
    ).toBe(false);
  });
});

describe("compareReleaseVersions", () => {
  it("treats stable as newer than same-day beta", () => {
    expect(compareReleaseVersions("2026.3.29", "2026.3.29-beta.2")).toBe(1);
  });

  it("orders alpha before beta on the same day", () => {
    expect(compareReleaseVersions("2026.3.29-alpha.2", "2026.3.29-beta.1")).toBe(-1);
  });

  it("treats a newer beta day as newer than an older stable day", () => {
    expect(compareReleaseVersions("2026.4.1-beta.1", "2026.3.29")).toBe(1);
  });

  it("orders stable correction releases after the base stable release", () => {
    expect(compareReleaseVersions("2026.3.29-2", "2026.3.29")).toBe(1);
  });

  it("returns null when either version is not release-shaped", () => {
    expect(compareReleaseVersions("latest", "2026.3.29")).toBeNull();
  });
});

describe("utcCalendarDayDistance", () => {
  it("compares UTC calendar days rather than wall-clock hours", () => {
    const left = new Date("2026-03-09T23:59:59Z");
    const right = new Date("2026-03-11T00:00:01Z");
    expect(utcCalendarDayDistance(left, right)).toBe(2);
  });
});

describe("resolveNpmCommandInvocation", () => {
  it("uses npm_execpath when it points to npm", () => {
    expect(
      resolveNpmCommandInvocation({
        npmExecPath: "/usr/local/lib/node_modules/npm/bin/npm-cli.js",
        nodeExecPath: "/usr/local/bin/node",
        platform: "linux",
      }),
    ).toEqual({
      command: "/usr/local/bin/node",
      args: ["/usr/local/lib/node_modules/npm/bin/npm-cli.js"],
    });
  });

  it("falls back to the npm command when npm_execpath points to pnpm", () => {
    expect(
      resolveNpmCommandInvocation({
        npmExecPath: "/home/test/.cache/node/corepack/v1/pnpm/10.23.0/bin/pnpm.cjs",
        nodeExecPath: "/usr/local/bin/node",
        platform: "linux",
      }),
    ).toEqual({
      command: "npm",
      args: [],
    });
  });

  it("uses the platform npm command when npm_execpath is missing", () => {
    expect(resolveNpmCommandInvocation({ platform: "win32" })).toEqual({
      command: "npm.cmd",
      args: [],
    });
  });
});

describe("parseNpmPackJsonOutput", () => {
  it("parses a plain npm pack JSON array", () => {
    expect(parseNpmPackJsonOutput('[{"filename":"openclaw.tgz","files":[]}]')).toEqual([
      { filename: "openclaw.tgz", files: [] },
    ]);
  });

  it("parses the trailing JSON payload after npm lifecycle logs", () => {
    const stdout = [
      'npm warn Unknown project config "node-linker".',
      "",
      "> openclaw@2026.3.23 prepack",
      "> pnpm build && pnpm ui:build",
      "",
      "[copy-hook-metadata] Copied 4 hook metadata files.",
      '[{"filename":"openclaw.tgz","files":[{"path":"dist/control-ui/index.html"}]}]',
    ].join("\n");

    expect(parseNpmPackJsonOutput(stdout)).toEqual([
      {
        filename: "openclaw.tgz",
        files: [{ path: "dist/control-ui/index.html" }],
      },
    ]);
  });

  it("returns null when no JSON payload is present", () => {
    expect(parseNpmPackJsonOutput("> openclaw@2026.3.23 prepack")).toBeNull();
  });
});

describe("collectControlUiPackErrors", () => {
  it("rejects packs that ship the dashboard HTML without the asset payload", () => {
    expect(collectControlUiPackErrors(["dist/control-ui/index.html"])).toEqual([
      ...REQUIRED_PACKED_PATHS.map(
        (requiredPath) =>
          `npm package is missing required path "${requiredPath}". Ensure UI assets are built and included before publish.`,
      ),
      'npm package is missing Control UI asset payload under "dist/control-ui/assets/". Refuse release when the dashboard tarball would be empty.',
    ]);
  });

  it("accepts packs that ship dashboard HTML and bundled assets", () => {
    expect(
      collectControlUiPackErrors([
        "dist/control-ui/index.html",
        ...REQUIRED_PACKED_PATHS,
        "dist/control-ui/assets/index-Bu8rSoJV.js",
        "dist/control-ui/assets/index-BK0yXA_h.css",
      ]),
    ).toStrictEqual([]);
  });
});

describe("collectForbiddenPackedPathErrors", () => {
  it("rejects generated docs artifacts in npm pack output", () => {
    expect(
      collectForbiddenPackedPathErrors([
        "dist/index.js",
        "docs/.generated/config-baseline.json",
        "docs/.generated/config-baseline.plugin.json",
      ]),
    ).toEqual([
      'npm package must not include generated docs artifact "docs/.generated/config-baseline.json".',
      'npm package must not include generated docs artifact "docs/.generated/config-baseline.plugin.json".',
    ]);
  });

  it("rejects local build metadata in npm pack output", () => {
    expect(
      collectForbiddenPackedPathErrors(["dist/index.js", ...LOCAL_BUILD_METADATA_DIST_PATHS]),
    ).toEqual([
      'npm package must not include local build metadata "dist/.buildstamp".',
      'npm package must not include local build metadata "dist/.runtime-postbuildstamp".',
    ]);
  });

  it("rejects private qa artifacts in npm pack output", () => {
    expect(
      collectForbiddenPackedPathErrors([
        "dist/extensions/qa-channel/runtime-api.js",
        "dist/extensions/qa-channel/package.json",
        "dist/extensions/qa-lab/runtime-api.js",
        "dist/extensions/qa-lab/src/cli.js",
        "dist/plugin-sdk/extensions/qa-channel/api.d.ts",
        "dist/plugin-sdk/extensions/qa-lab/cli.d.ts",
        "dist/plugin-sdk/qa-channel.js",
        "dist/plugin-sdk/qa-channel-protocol.d.ts",
        "dist/qa-runtime-B9LDtssJ.js",
        "docs/channels/qa-channel.md",
        "qa/scenarios/index.md",
      ]),
    ).toEqual([
      'npm package must not include private QA channel artifact "dist/extensions/qa-channel/package.json".',
      'npm package must not include private QA channel artifact "dist/extensions/qa-channel/runtime-api.js".',
      'npm package must not include private QA channel docs "docs/channels/qa-channel.md".',
      'npm package must not include private QA channel SDK artifact "dist/plugin-sdk/qa-channel-protocol.d.ts".',
      'npm package must not include private QA channel SDK artifact "dist/plugin-sdk/qa-channel.js".',
      'npm package must not include private QA channel type artifact "dist/plugin-sdk/extensions/qa-channel/api.d.ts".',
      'npm package must not include private QA lab artifact "dist/extensions/qa-lab/runtime-api.js".',
      'npm package must not include private QA lab artifact "dist/extensions/qa-lab/src/cli.js".',
      'npm package must not include private QA lab type artifact "dist/plugin-sdk/extensions/qa-lab/cli.d.ts".',
      'npm package must not include private QA runtime chunk "dist/qa-runtime-B9LDtssJ.js".',
      'npm package must not include private QA suite artifact "qa/scenarios/index.md".',
    ]);
  });

  it("rejects legacy update verifier QA runtime sidecars", () => {
    expect(
      collectForbiddenPackedPathErrors([
        "dist/extensions/qa-channel/runtime-api.js",
        "dist/extensions/qa-lab/runtime-api.js",
      ]),
    ).toEqual([
      'npm package must not include private QA channel artifact "dist/extensions/qa-channel/runtime-api.js".',
      'npm package must not include private QA lab artifact "dist/extensions/qa-lab/runtime-api.js".',
    ]);
  });

  it("rejects root dist chunks that still reference the private qa lab", () => {
    const rootDir = mkdtempSync(join(tmpdir(), "openclaw-pack-private-qa-"));

    try {
      mkdirSync(join(rootDir, "dist"), { recursive: true });
      writeFileSync(
        join(rootDir, "dist", "entry.js"),
        "//#region extensions/qa-lab/src/cli.ts\n",
        "utf8",
      );
      writeFileSync(join(rootDir, "README.md"), "developer docs mention extensions/qa-lab/\n");

      expect(collectForbiddenPackedContentErrors(["dist/entry.js", "README.md"], rootDir)).toEqual([
        'npm package must not include private QA lab marker "//#region extensions/qa-lab/" in "dist/entry.js".',
      ]);
    } finally {
      rmSync(rootDir, { recursive: true, force: true });
    }
  });

  it("rejects private QA paths in the generated dist inventory", () => {
    const rootDir = mkdtempSync(join(tmpdir(), "openclaw-pack-inventory-"));

    try {
      mkdirSync(join(rootDir, "dist"), { recursive: true });
      writeFileSync(
        join(rootDir, PACKAGE_DIST_INVENTORY_RELATIVE_PATH),
        JSON.stringify(["dist/extensions/qa-lab/runtime-api.js"]),
        "utf8",
      );

      expect(
        collectForbiddenPackedContentErrors([PACKAGE_DIST_INVENTORY_RELATIVE_PATH], rootDir),
      ).toEqual([
        'npm package must not include private QA lab marker "qa-lab/runtime-api.js" in "dist/postinstall-inventory.json".',
      ]);
    } finally {
      rmSync(rootDir, { recursive: true, force: true });
    }
  });
});

describe("collectPackedTestCargoErrors", () => {
  it("rejects packed test files and test directories", () => {
    expect(
      collectPackedTestCargoErrors([
        "dist/extensions/webhooks/node_modules/zod/src/v3/tests/all-errors.test.ts",
        "dist/extensions/whatsapp/node_modules/pino/test/basic.test.js",
        "dist/extensions/whatsapp/node_modules/@jimp/plugin-crop/src/__snapshots__/crop.test.ts.snap",
        "dist/index.js",
      ]),
    ).toEqual([
      'npm package must not include test cargo "dist/extensions/webhooks/node_modules/zod/src/v3/tests/all-errors.test.ts".',
      'npm package must not include test cargo "dist/extensions/whatsapp/node_modules/@jimp/plugin-crop/src/__snapshots__/crop.test.ts.snap".',
      'npm package must not include test cargo "dist/extensions/whatsapp/node_modules/pino/test/basic.test.js".',
    ]);
  });

  it("allows normal runtime files", () => {
    expect(
      collectPackedTestCargoErrors([
        "dist/index.js",
        "dist/extensions/whatsapp/node_modules/pino/lib/proto.js",
        "dist/extensions/webhooks/node_modules/zod/v4/core/api.js",
      ]),
    ).toStrictEqual([]);
  });

  it("allows legitimate package roots named test under node_modules", () => {
    expect(
      collectPackedTestCargoErrors([
        "dist/extensions/fixture-plugin/node_modules/direct/node_modules/test/index.js",
        "dist/extensions/fixture-plugin/node_modules/direct/node_modules/@scope/tests/index.js",
      ]),
    ).toStrictEqual([]);
  });

  it("allows leaf runtime filenames named test or tests", () => {
    expect(
      collectPackedTestCargoErrors([
        "dist/extensions/fixture-plugin/node_modules/direct/bin/test",
        "dist/extensions/fixture-plugin/node_modules/direct/bin/tests",
      ]),
    ).toStrictEqual([]);
  });

  it("normalizes Windows or mixed separators before classifying test cargo", () => {
    expect(
      collectPackedTestCargoErrors([
        String.raw`dist\extensions\fixture-plugin\node_modules\direct\__tests__\index.js`,
        String.raw`dist/extensions/fixture-plugin\node_modules/direct/src/runtime.spec.ts`,
        String.raw`dist\extensions\fixture-plugin\node_modules\direct\node_modules\test\index.js`,
      ]),
    ).toEqual([
      `npm package must not include test cargo "${String.raw`dist/extensions/fixture-plugin\node_modules/direct/src/runtime.spec.ts`}".`,
      `npm package must not include test cargo "${String.raw`dist\extensions\fixture-plugin\node_modules\direct\__tests__\index.js`}".`,
    ]);
  });
});

describe("collectReleaseTagErrors", () => {
  it("accepts versions within the two-day CalVer window", () => {
    expect(
      collectReleaseTagErrors({
        packageVersion: "2026.3.10",
        releaseTag: "v2026.3.10",
        now: new Date("2026-03-11T12:00:00Z"),
      }),
    ).toStrictEqual([]);
  });

  it("rejects versions outside the two-day CalVer window", () => {
    expect(
      collectReleaseTagErrors({
        packageVersion: "2026.3.10",
        releaseTag: "v2026.3.10",
        now: new Date("2026-03-13T00:00:00Z"),
      }),
    ).toStrictEqual([
      "Release version 2026.3.10 is 3 days away from current UTC date 2026-03-13; release CalVer date 2026-03-10 must be within 2 days.",
    ]);
  });

  it("accepts fallback correction tags for stable package versions", () => {
    expect(
      collectReleaseTagErrors({
        packageVersion: "2026.3.10",
        releaseTag: "v2026.3.10-1",
        now: new Date("2026-03-10T00:00:00Z"),
      }),
    ).toStrictEqual([]);
  });

  it("accepts correction package versions paired with matching correction tags", () => {
    expect(
      collectReleaseTagErrors({
        packageVersion: "2026.3.10-1",
        releaseTag: "v2026.3.10-1",
        now: new Date("2026-03-10T00:00:00Z"),
      }),
    ).toStrictEqual([]);
  });

  it("rejects beta package versions paired with fallback correction tags", () => {
    expect(
      collectReleaseTagErrors({
        packageVersion: "2026.3.10-beta.1",
        releaseTag: "v2026.3.10-1",
        now: new Date("2026-03-10T00:00:00Z"),
      }),
    ).toStrictEqual([
      "Release tag v2026.3.10-1 does not match package.json version 2026.3.10-beta.1; expected v2026.3.10-beta.1.",
    ]);
  });
});

describe("collectReleasePackageMetadataErrors", () => {
  it("validates the expected npm package metadata", () => {
    expect(
      collectReleasePackageMetadataErrors({
        name: "openclaw",
        description: "Multi-channel AI gateway with extensible messaging integrations",
        license: "MIT",
        repository: { url: "git+https://github.com/openclaw/openclaw.git" },
        bin: { openclaw: "openclaw.mjs" },
      }),
    ).toStrictEqual([]);
  });

  it("rejects node-llama-cpp as a peer dependency", () => {
    expect(
      collectReleasePackageMetadataErrors({
        name: "openclaw",
        description: "Multi-channel AI gateway with extensible messaging integrations",
        license: "MIT",
        repository: { url: "git+https://github.com/openclaw/openclaw.git" },
        bin: { openclaw: "openclaw.mjs" },
        peerDependencies: { "node-llama-cpp": "3.18.1" },
        peerDependenciesMeta: { "node-llama-cpp": { optional: true } },
      }),
    ).toEqual([
      'package.json peerDependencies["node-llama-cpp"] must be omitted; keep it optional.',
      'package.json peerDependenciesMeta["node-llama-cpp"] must be omitted; keep it optional.',
    ]);
  });

  it("rejects node-llama-cpp as a direct runtime dependency", () => {
    expect(
      collectReleasePackageMetadataErrors({
        name: "openclaw",
        description: "Multi-channel AI gateway with extensible messaging integrations",
        license: "MIT",
        repository: { url: "git+https://github.com/openclaw/openclaw.git" },
        bin: { openclaw: "openclaw.mjs" },
        dependencies: { "node-llama-cpp": "3.18.1" },
      }),
    ).toContain('package.json dependencies["node-llama-cpp"] must be omitted; keep it optional.');
  });

  it("rejects local fs-safe dependency specs for npm release", () => {
    expect(
      collectReleasePackageMetadataErrors({
        name: "openclaw",
        description: "Multi-channel AI gateway with extensible messaging integrations",
        license: "MIT",
        repository: { url: "git+https://github.com/openclaw/openclaw.git" },
        bin: { openclaw: "openclaw.mjs" },
        dependencies: { "@openclaw/fs-safe": "link:../fs-safe" },
      }),
    ).toContain(
      'package.json dependencies["@openclaw/fs-safe"] must use a published semver range before npm release; found "link:../fs-safe".',
    );
  });

  it("rejects node-llama-cpp as an optional dependency", () => {
    expect(
      collectReleasePackageMetadataErrors({
        name: "openclaw",
        description: "Multi-channel AI gateway with extensible messaging integrations",
        license: "MIT",
        repository: { url: "git+https://github.com/openclaw/openclaw.git" },
        bin: { openclaw: "openclaw.mjs" },
        optionalDependencies: { "node-llama-cpp": "3.18.1" },
      }),
    ).toContain(
      'package.json optionalDependencies["node-llama-cpp"] must be omitted; keep it operator-installed.',
    );
  });
});
