// Transitive Manifest Risk Report tests cover transitive manifest risk report script behavior.
import { spawnSync } from "node:child_process";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
  createTransitiveManifestRiskReport,
  fetchNpmManifest,
  readBoundedNpmRegistryText,
  renderTransitiveManifestRiskMarkdownReport,
} from "../../scripts/transitive-manifest-risk-report.mts";

function runCli(...args: string[]) {
  return spawnSync(
    process.execPath,
    ["--import", "tsx", "scripts/transitive-manifest-risk-report.mts", ...args],
    {
      cwd: path.resolve("."),
      encoding: "utf8",
    },
  );
}

function expectNoNodeStack(stderr: string) {
  expect(stderr).not.toContain("Node.js");
  expect(stderr).not.toContain("\n    at ");
}

describe("transitive-manifest-risk-report", () => {
  it("reports CLI argument errors without a Node stack trace", () => {
    const unknownArg = runCli("--wat");
    expect(unknownArg.status).toBe(1);
    expect(unknownArg.stdout).toBe("");
    expect(unknownArg.stderr.trim()).toBe("Unsupported argument: --wat");
    expectNoNodeStack(unknownArg.stderr);
  });

  it("reports floating transitive specs, lifecycle scripts, exotic sources, and recently published versions", async () => {
    const report = await createTransitiveManifestRiskReport({
      packageVersions: [
        { packageName: "parent", version: "1.0.0" },
        { packageName: "tarball-package", version: "https://example.test/pkg.tgz" },
      ],
      now: new Date("2026-05-12T00:00:00Z"),
      minimumReleaseAgeMinutes: 2_880,
      manifestLoader: async ({ packageName, version }) => {
        if (packageName !== "parent" || version !== "1.0.0") {
          throw new Error("unexpected manifest request");
        }
        return {
          publishedAt: "2026-05-11T23:00:00Z",
          manifest: {
            dependencies: {
              floating: "^1.2.3",
              exact: "2.0.0",
              gitdep: "github:owner/repo#main",
            },
            optionalDependencies: {
              optionalFloating: "~3.0.0",
            },
            scripts: {
              install: "node install.js",
            },
          },
        };
      },
    });

    expect(report.byType).toEqual({
      "exotic-source": 2,
      "floating-transitive-spec": 3,
      "lifecycle-script": 1,
      "recently-published-version": 1,
    });
    expect(report.workspaceExcludedFindings).toEqual([]);
    expect(report.metadataFailures).toEqual([]);
  });

  it("uses pnpm minimum release age exclusions for recently published versions", async () => {
    const report = await createTransitiveManifestRiskReport({
      packageVersions: [
        { packageName: "regular", version: "1.0.0" },
        { packageName: "exact-package", version: "2.0.0" },
        { packageName: "either-version", version: "5.102.1" },
        { packageName: "@scope/native-linux-x64", version: "3.0.0" },
      ],
      now: new Date("2026-05-12T00:00:00Z"),
      minimumReleaseAgeMinutes: 2_880,
      minimumReleaseAgeExclude: [
        "exact-package@2.0.0",
        "either-version@4.47.0 || 5.102.1",
        "@scope/native-*",
      ],
      manifestLoader: async () => ({
        publishedAt: "2026-05-11T23:00:00Z",
        manifest: {},
      }),
    });

    expect(report.byType).toEqual({
      "recently-published-version": 1,
    });
    expect(report.workspaceExcludedByType).toEqual({
      "recently-published-version": 3,
    });
    expect(report.findings).toMatchObject([
      {
        packageName: "regular",
        type: "recently-published-version",
      },
    ]);
    expect(report.workspaceExcludedFindings).toMatchObject([
      {
        packageName: "@scope/native-linux-x64",
        type: "recently-published-version",
        workspaceExcluded: true,
        workspaceExclusion: "@scope/native-*",
      },
      {
        packageName: "either-version",
        type: "recently-published-version",
        workspaceExcluded: true,
        workspaceExclusion: "either-version@4.47.0 || 5.102.1",
      },
      {
        packageName: "exact-package",
        type: "recently-published-version",
        workspaceExcluded: true,
        workspaceExclusion: "exact-package@2.0.0",
      },
    ]);

    const markdown = renderTransitiveManifestRiskMarkdownReport(report);
    expect(markdown).toContain(
      "## Recently Published Versions Not Covered By Workspace Exclusions",
    );
    expect(markdown).toContain("## Recently Published Versions Covered By Workspace Exclusions");
    expect(markdown).toContain("Workspace minimum release age: 2880 minutes.");
    expect(markdown).toContain("`regular@1.0.0`: published 2026-05-11T23:00:00Z");
    expect(markdown).toContain(
      "`exact-package@2.0.0`: published 2026-05-11T23:00:00Z; workspace exclusion `exact-package@2.0.0`",
    );
    expect(markdown).not.toContain(
      "`regular@1.0.0`: published 2026-05-11T23:00:00Z; minimum release age 2880 minutes",
    );
  });

  it("documents JSON completeness and renders grouped Markdown summaries", async () => {
    const report = await createTransitiveManifestRiskReport({
      packageVersions: [
        { packageName: "openclaw/plugin-sdk/llm", version: "0.74.0" },
        { packageName: "aaa-package", version: "1.0.0" },
        { packageName: "recent-package", version: "1.0.0" },
      ],
      now: new Date("2026-05-12T00:00:00Z"),
      minimumReleaseAgeMinutes: 2_880,
      minimumReleaseAgeExclude: ["recent-package@1.0.0"],
      manifestLoader: async ({ packageName }) => ({
        publishedAt:
          packageName === "recent-package" ? "2026-05-11T23:00:00Z" : "2026-04-01T00:00:00Z",
        manifest:
          packageName === "openclaw/plugin-sdk/llm"
            ? {
                dependencies: {
                  "@mistralai/mistralai": "^2.2.0",
                },
              }
            : packageName === "recent-package"
              ? {
                  dependencies: {
                    "recent-dependency": "^1.0.0",
                  },
                }
              : {
                  dependencies: {
                    "aaa-dependency": "^1.0.0",
                  },
                },
      }),
    });

    const markdown = renderTransitiveManifestRiskMarkdownReport(report);

    expect(markdown).toContain("# Transitive Manifest Risk Report");
    expect(markdown).toContain("## Scope");
    expect(markdown).toContain("published package manifests for resolved packages");
    expect(markdown).toContain("It is report-only.");
    expect(markdown).toContain("Resolved package versions inspected");
    expect(markdown).toContain("Reported risk signals");
    expect(markdown).toContain("Signals covered by workspace policy exclusions");
    expect(markdown).toContain("## Reported Risk Signals By Type");
    expect(markdown).toContain("## Signals Covered By Workspace Policy Exclusions");
    expect(markdown).toContain("not included in the reported risk signal totals");
    expect(markdown).toContain("## Complete Evidence");
    expect(markdown).toContain("The complete reported signal list is available in the JSON report");
    expect(markdown).toContain("## Published Package Manifests With Risk Findings");
    expect(markdown).toContain("`openclaw/plugin-sdk/llm@0.74.0`: 1 manifest finding");
    expect(markdown).toContain("`aaa-package@1.0.0`: 1 manifest finding");
    expect(markdown).toContain("## Floating Dependency Targets");
    expect(markdown).toContain("`@mistralai/mistralai`: 1 declarations");
    expect(markdown).toContain("`aaa-dependency`: 1 declarations");
    expect(markdown).not.toContain("## Packages With Findings");
    expect(markdown).not.toContain("## Finding Details");
    expect(markdown).not.toContain("## Notable Findings");
    expect(markdown).not.toContain("## Additional Sample Findings");
  });

  it("fetches full npm packuments for the requested manifest version", async () => {
    const fetchCalls: Array<{ url: string; accept: string | null; signal: AbortSignal | null }> =
      [];
    const manifest = await fetchNpmManifest({
      packageName: "@scope/package",
      version: "1.0.0",
      registryBaseUrl: "https://registry.example.test",
      fetchImpl: async (url, init) => {
        const requestUrl = url instanceof Request ? url.url : url instanceof URL ? url.href : url;
        fetchCalls.push({
          url: requestUrl,
          accept: new Headers(init?.headers).get("accept"),
          signal: init?.signal instanceof AbortSignal ? init.signal : null,
        });
        return new Response(
          JSON.stringify({
            time: {
              "1.0.0": "2026-05-12T00:00:00.000Z",
            },
            versions: {
              "1.0.0": {
                dependencies: {
                  exact: "1.2.3",
                },
                scripts: {
                  install: "node install.js",
                },
              },
            },
          }),
          {
            status: 200,
          },
        );
      },
    });

    expect(fetchCalls).toEqual([
      {
        url: "https://registry.example.test/@scope%2Fpackage",
        accept: "application/json",
        signal: expect.any(AbortSignal),
      },
    ]);
    expect(manifest).toEqual({
      publishedAt: "2026-05-12T00:00:00.000Z",
      manifest: {
        dependencies: {
          exact: "1.2.3",
        },
        scripts: {
          install: "node install.js",
        },
      },
    });
  });

  it("cancels stalled npm registry body reads when the request aborts", async () => {
    const controller = new AbortController();
    let canceled = false;
    const response = {
      headers: new Headers(),
      body: {
        getReader() {
          return {
            read() {
              return new Promise<ReadableStreamReadResult<Uint8Array>>(() => {});
            },
            async cancel() {
              canceled = true;
            },
            releaseLock() {
              throw new Error("releaseLock should not run while a read is pending");
            },
          };
        },
      },
    } as unknown as Response;

    const readPromise = readBoundedNpmRegistryText(response, 8, {
      signal: controller.signal,
    });
    controller.abort(new Error("npm registry request timed out"));

    await expect(readPromise).rejects.toThrow("npm registry request timed out");
    expect(canceled).toBe(true);
  });

  it("rejects npm registry bodies that exceed the content-length cap", async () => {
    let canceled = false;
    const response = new Response(
      new ReadableStream({
        cancel() {
          canceled = true;
        },
      }),
      {
        headers: {
          "content-length": "12",
        },
      },
    );

    await expect(readBoundedNpmRegistryText(response, 8)).rejects.toThrow(
      "npm registry response exceeded 8 bytes",
    );
    expect(canceled).toBe(true);
  });

  it("rejects npm registry bodies that exceed the content-length cap without a body", async () => {
    const response = new Response(null, {
      headers: {
        "content-length": "12",
      },
    });

    await expect(readBoundedNpmRegistryText(response, 8)).rejects.toThrow(
      "npm registry response exceeded 8 bytes",
    );
  });

  it("streams non-decimal npm registry content-length values through the body cap", async () => {
    const encoder = new TextEncoder();
    let readStarted = false;
    let canceled = false;
    const response = new Response(
      new ReadableStream({
        pull(controller) {
          readStarted = true;
          controller.enqueue(encoder.encode("123456789"));
        },
        cancel() {
          canceled = true;
        },
      }),
      {
        headers: {
          "content-length": "1e3",
        },
      },
    );

    await expect(readBoundedNpmRegistryText(response, 8)).rejects.toThrow(
      "npm registry response exceeded 8 bytes",
    );
    expect(readStarted).toBe(true);
    expect(canceled).toBe(true);
  });

  it("rejects npm registry bodies that grow past the stream cap", async () => {
    const encoder = new TextEncoder();
    const response = new Response(
      new ReadableStream({
        start(controller) {
          controller.enqueue(encoder.encode("1234"));
          controller.enqueue(encoder.encode("5678"));
          controller.enqueue(encoder.encode("9"));
          controller.close();
        },
      }),
    );

    await expect(readBoundedNpmRegistryText(response, 8)).rejects.toThrow(
      "npm registry response exceeded 8 bytes",
    );
  });
});
