import { spawnSync } from "node:child_process";
import fs from "node:fs";
import { createServer as createHttpServer, type Server as HttpServer } from "node:http";
import { createServer as createNetServer, type Server as NetServer, type Socket } from "node:net";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";

const tempDirs: string[] = [];
const probePath = path.resolve("scripts/e2e/lib/bundled-plugin-install-uninstall/probe.mjs");
const runtimeSmokePath = path.resolve(
  "scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs",
);
const sweepPath = path.resolve("scripts/e2e/lib/bundled-plugin-install-uninstall/sweep.sh");

type PluginListEntry = {
  id: string;
  origin: string;
  rootDir: string;
};

function makePackageRoot(): string {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-probe-"));
  tempDirs.push(root);
  fs.writeFileSync(path.join(root, "package.json"), '{"type":"module"}\n', "utf8");
  fs.mkdirSync(path.join(root, "dist"), { recursive: true });
  return root;
}

function writePluginsList(root: string, plugins: PluginListEntry[]): void {
  fs.writeFileSync(
    path.join(root, "dist", "index.js"),
    [
      `const plugins = ${JSON.stringify(plugins)};`,
      "if (process.argv.slice(2).join(' ') !== 'plugins list --json') {",
      "  console.error(`unexpected argv: ${process.argv.slice(2).join(' ')}`);",
      "  process.exit(1);",
      "}",
      "console.log(JSON.stringify({ plugins }));",
      "",
    ].join("\n"),
    "utf8",
  );
}

function writePluginManifest(root: string, pluginRoot: string, manifest: Record<string, unknown>) {
  const dir = path.join(root, pluginRoot);
  fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(
    path.join(dir, "openclaw.plugin.json"),
    `${JSON.stringify(manifest, null, 2)}\n`,
    "utf8",
  );
}

function runProbe(root: string, env: Record<string, string | undefined> = {}) {
  const childEnv = { ...process.env, ...env };
  for (const [key, value] of Object.entries(childEnv)) {
    if (value === undefined) {
      delete childEnv[key];
    }
  }
  childEnv.OPENCLAW_ENTRY = path.join(root, "dist", "index.js");
  return spawnSync(process.execPath, [probePath, "select"], {
    cwd: root,
    encoding: "utf8",
    env: childEnv as NodeJS.ProcessEnv,
  });
}

function runProbeCommand(root: string, args: string[], env: Record<string, string | undefined>) {
  const childEnv = { ...process.env, ...env };
  for (const [key, value] of Object.entries(childEnv)) {
    if (value === undefined) {
      delete childEnv[key];
    }
  }
  childEnv.OPENCLAW_ENTRY = path.join(root, "dist", "index.js");
  return spawnSync(process.execPath, [probePath, ...args], {
    cwd: root,
    encoding: "utf8",
    env: childEnv as NodeJS.ProcessEnv,
  });
}

function runRuntimeSmoke(root: string, args: string[]) {
  return spawnSync(process.execPath, [runtimeSmokePath, ...args], {
    cwd: root,
    encoding: "utf8",
    env: {
      ...process.env,
      OPENCLAW_ENTRY: path.join(root, "dist", "index.js"),
    },
  });
}

async function importRuntimeSmokeWithEnv(env: Record<string, string | undefined>) {
  const previous = new Map<string, string | undefined>();
  for (const [key, value] of Object.entries(env)) {
    previous.set(key, process.env[key]);
    if (value === undefined) {
      delete process.env[key];
    } else {
      process.env[key] = value;
    }
  }
  try {
    return await import(
      `${pathToFileURL(runtimeSmokePath).href}?case=${Date.now()}-${Math.random()}`
    );
  } finally {
    for (const [key, value] of previous.entries()) {
      if (value === undefined) {
        delete process.env[key];
      } else {
        process.env[key] = value;
      }
    }
  }
}

async function listenOnLoopback(server: HttpServer | NetServer): Promise<number> {
  return new Promise((resolve, reject) => {
    const onError = (error: Error) => {
      server.off("error", onError);
      reject(error);
    };
    server.once("error", onError);
    server.listen(0, "127.0.0.1", () => {
      server.off("error", onError);
      const address = server.address();
      if (!address || typeof address === "string") {
        reject(new Error("server did not bind to a TCP port"));
        return;
      }
      resolve(address.port);
    });
  });
}

async function closeServer(server: HttpServer | NetServer): Promise<void> {
  await new Promise<void>((resolve, reject) => {
    server.close((error?: Error) => {
      if (error) {
        reject(error);
        return;
      }
      resolve();
    });
  });
}

afterEach(() => {
  vi.restoreAllMocks();
  for (const dir of tempDirs.splice(0)) {
    fs.rmSync(dir, { force: true, recursive: true });
  }
});

describe("bundled plugin install/uninstall probe", () => {
  it("keeps the sweep script compatible with macOS Bash 3", () => {
    const sweep = fs.readFileSync(sweepPath, "utf8");

    expect(sweep).not.toContain("mapfile ");
    expect(sweep).not.toContain("readarray ");
  });

  it("keeps runtime command output capture bounded", async () => {
    const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href);

    const first = runtimeSmoke.appendBoundedOutput({ text: "", truncatedChars: 0 }, "abcdef", 5);
    expect(first).toEqual({ text: "bcdef", truncatedChars: 1 });

    const second = runtimeSmoke.appendBoundedOutput(first, "ghij", 5);
    expect(second).toEqual({ text: "fghij", truncatedChars: 5 });
  });

  it("rejects loose runtime output limit env values instead of parsing prefixes", async () => {
    const runtimeSmoke = await importRuntimeSmokeWithEnv({
      OPENCLAW_BUNDLED_PLUGIN_RUNTIME_OUTPUT_CHARS: "5chars",
    });

    expect(runtimeSmoke.appendBoundedOutput({ text: "", truncatedChars: 0 }, "abcdef")).toEqual({
      text: "abcdef",
      truncatedChars: 0,
    });
  });

  it("keeps runtime log tail reads bounded", async () => {
    const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href);
    const root = makePackageRoot();
    const logPath = path.join(root, "gateway.log");
    fs.writeFileSync(logPath, `${"old log line\n".repeat(1000)}[gateway] ready\n`, "utf8");

    const fullRead = vi.spyOn(fs, "readFileSync");
    const tail = runtimeSmoke.readFileTail(logPath, 64);

    expect(tail).toContain("[gateway] ready");
    expect(Buffer.byteLength(tail)).toBeLessThanOrEqual(64);
    expect(fullRead).not.toHaveBeenCalled();
  });

  it("rejects loose runtime log scan byte env values instead of parsing prefixes", async () => {
    const runtimeSmoke = await importRuntimeSmokeWithEnv({
      OPENCLAW_BUNDLED_PLUGIN_RUNTIME_LOG_SCAN_BYTES: "64bytes",
    });
    const root = makePackageRoot();
    const logPath = path.join(root, "gateway.log");
    fs.writeFileSync(logPath, `${"old log line\n".repeat(20)}[gateway] ready\n`, "utf8");

    const tail = runtimeSmoke.readFileTail(logPath);

    expect(Buffer.byteLength(tail)).toBeGreaterThan(64);
    expect(tail).toContain("old log line");
    expect(tail).toContain("[gateway] ready");
  });

  it("remembers runtime ready logs after they fall outside the tail", async () => {
    const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href);
    const root = makePackageRoot();
    const logPath = path.join(root, "gateway.log");
    const readyLogSeen = runtimeSmoke.createReadyLogScanner(logPath);

    fs.writeFileSync(logPath, `[gateway] ready\n${"x".repeat(300_000)}`, "utf8");

    expect(readyLogSeen()).toBe(true);

    fs.appendFileSync(logPath, "more log output".repeat(30_000), "utf8");

    expect(readyLogSeen()).toBe(true);
  });

  it("treats signaled gateway children as already stopped", async () => {
    const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href);
    const child = {
      exitCode: null,
      kill: vi.fn(),
      signalCode: "SIGTERM",
    };

    expect(runtimeSmoke.hasChildExited(child)).toBe(true);
    await runtimeSmoke.stopGateway(child);

    expect(child.kill).not.toHaveBeenCalled();
  });

  it("does not treat shallow HTTP listen logs as runtime readiness", async () => {
    const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href);
    const root = makePackageRoot();
    const logPath = path.join(root, "gateway.log");
    const readyLogSeen = runtimeSmoke.createReadyLogScanner(logPath);

    fs.writeFileSync(logPath, "[gateway] http server listening\n", "utf8");

    expect(readyLogSeen()).toBe(false);
  });

  it("scans only post-ready runtime logs for dependency work", async () => {
    const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href);
    const root = makePackageRoot();
    const logPath = path.join(root, "gateway.log");
    fs.writeFileSync(
      logPath,
      `pre-ready npm install is allowed here\n${"x".repeat(300_000)}\n[gateway] ready\nruntime ok\n`,
      "utf8",
    );

    const fullRead = vi.spyOn(fs, "readFileSync");
    const readyOffset = runtimeSmoke.findReadyLogOffset(logPath);

    expect(() => runtimeSmoke.assertNoPostReadyRuntimeDepsWork(logPath, readyOffset)).not.toThrow();
    expect(fullRead).not.toHaveBeenCalled();

    fs.appendFileSync(logPath, "post-ready pnpm install should fail\n", "utf8");

    expect(() => runtimeSmoke.assertNoPostReadyRuntimeDepsWork(logPath, readyOffset)).toThrow(
      /post-ready runtime dependency work/u,
    );
  });

  it("keeps post-ready scans anchored when ready logs fall outside the tail", async () => {
    const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href);
    const root = makePackageRoot();
    const logPath = path.join(root, "gateway.log");
    fs.writeFileSync(
      logPath,
      `startup\n[gateway] ready\npost-ready yarn install should fail\n${"x".repeat(300_000)}`,
      "utf8",
    );

    const readyOffset = runtimeSmoke.findReadyLogOffset(logPath);

    expect(readyOffset).toBe("startup\n".length);
    expect(() => runtimeSmoke.assertNoPostReadyRuntimeDepsWork(logPath, readyOffset)).toThrow(
      /post-ready runtime dependency work/u,
    );
  });

  it("bounds runtime smoke child commands and preserves captured output", async () => {
    const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href);
    const startedAt = Date.now();

    await expect(
      runtimeSmoke.runCommand(
        process.execPath,
        [
          "-e",
          "process.stdout.write('partial\\n'); process.stderr.write('problem\\n'); setInterval(() => {}, 1000);",
        ],
        { timeoutMs: 200 },
      ),
    ).rejects.toThrow(/timed out after 200ms[\s\S]*partial[\s\S]*problem/u);

    expect(Date.now() - startedAt).toBeLessThan(2_500);
  });

  it("accepts successful runtime HTTP probes", async () => {
    const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href);
    const server = createHttpServer((_request, response) => {
      response.writeHead(204);
      response.end();
    });

    try {
      const port = await listenOnLoopback(server);

      await expect(runtimeSmoke.httpOk(port, "/healthz", { timeoutMs: 1000 })).resolves.toBe(true);
    } finally {
      await closeServer(server);
    }
  });

  it("bounds stalled runtime HTTP probes", async () => {
    const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href);
    const sockets = new Set<Socket>();
    const server = createNetServer((socket) => {
      sockets.add(socket);
      socket.on("close", () => {
        sockets.delete(socket);
      });
    });

    try {
      const port = await listenOnLoopback(server);
      const startedAt = Date.now();

      await expect(runtimeSmoke.httpOk(port, "/healthz", { timeoutMs: 100 })).resolves.toBe(false);

      expect(Date.now() - startedAt).toBeLessThan(2_500);
    } finally {
      for (const socket of sockets) {
        socket.destroy();
      }
      await closeServer(server);
    }
  });

  it("keeps stalled runtime readiness probes inside the ready deadline", async () => {
    const runtimeSmoke = await importRuntimeSmokeWithEnv({
      OPENCLAW_BUNDLED_PLUGIN_RUNTIME_HTTP_MS: "1000",
      OPENCLAW_BUNDLED_PLUGIN_RUNTIME_READY_MS: "50",
    });
    const sockets = new Set<Socket>();
    const server = createNetServer((socket) => {
      sockets.add(socket);
      socket.on("close", () => {
        sockets.delete(socket);
      });
    });
    const root = makePackageRoot();
    const logPath = path.join(root, "gateway.log");
    fs.writeFileSync(logPath, "booting\n", "utf8");

    try {
      const port = await listenOnLoopback(server);
      const startedAt = Date.now();

      await expect(
        runtimeSmoke.waitForReady({
          child: { exitCode: null, signalCode: null },
          logPath,
          port,
        }),
      ).rejects.toThrow("gateway did not become ready");

      expect(Date.now() - startedAt).toBeLessThan(500);
    } finally {
      for (const socket of sockets) {
        socket.destroy();
      }
      await closeServer(server);
    }
  });

  it("creates runtime smoke state with OPENCLAW_HOME at the test home", async () => {
    const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href);
    const env = runtimeSmoke.createIsolatedStateEnv("runtime-env");
    tempDirs.push(path.dirname(env.HOME));

    expect(env.USERPROFILE).toBe(env.HOME);
    expect(env.OPENCLAW_HOME).toBe(env.HOME);
    expect(env.OPENCLAW_STATE_DIR).toBe(path.join(env.HOME, ".openclaw"));
    expect(env.OPENCLAW_CONFIG_PATH).toBe(path.join(env.OPENCLAW_STATE_DIR, "openclaw.json"));
  });

  it("selects packaged installable bundled sources instead of raw dist extension dirs", () => {
    const root = makePackageRoot();
    fs.mkdirSync(path.join(root, "dist", "extensions", "qa-channel"), { recursive: true });
    fs.writeFileSync(
      path.join(root, "dist", "extensions", "qa-channel", "openclaw.plugin.json"),
      '{"id":"qa-channel"}\n',
      "utf8",
    );
    writePluginManifest(root, "dist-runtime/extensions/admin-http-rpc", {
      id: "admin-http-rpc",
      configSchema: { required: ["port"] },
    });
    writePluginsList(root, [
      {
        id: "admin-http-rpc",
        origin: "bundled",
        rootDir: path.join(root, "dist-runtime", "extensions", "admin-http-rpc"),
      },
    ]);

    const result = runProbe(root, {
      OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS: undefined,
    });

    expect(result.status).toBe(0);
    expect(result.stdout.trim()).toBe(
      `admin-http-rpc\tadmin-http-rpc\t1\t${path.join(root, "dist-runtime", "extensions", "admin-http-rpc")}`,
    );
  });

  it("does not select source-only bundled plugins for package-backed sweeps", () => {
    const root = makePackageRoot();
    writePluginManifest(root, "extensions/qa-channel", {
      id: "qa-channel",
    });
    writePluginManifest(root, "dist-runtime/extensions/clickclack", {
      id: "clickclack",
    });
    writePluginsList(root, [
      {
        id: "qa-channel",
        origin: "bundled",
        rootDir: path.join(root, "extensions", "qa-channel"),
      },
      {
        id: "clickclack",
        origin: "bundled",
        rootDir: path.join(root, "dist-runtime", "extensions", "clickclack"),
      },
    ]);

    const result = runProbe(root, {
      OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS: "qa-channel",
    });

    expect(result.status).toBe(1);
    expect(result.stderr).toContain(
      "OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS entry is not an installable bundled plugin in this package: qa-channel",
    );
    expect(result.stderr).toContain("Available: clickclack");
  });

  it("fails explicit ids that are not installable in the packaged runtime", () => {
    const root = makePackageRoot();
    writePluginManifest(root, "dist-runtime/extensions/admin-http-rpc", {
      id: "admin-http-rpc",
    });
    writePluginsList(root, [
      {
        id: "admin-http-rpc",
        origin: "bundled",
        rootDir: path.join(root, "dist-runtime", "extensions", "admin-http-rpc"),
      },
    ]);

    const result = runProbe(root, {
      OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS: "qa-channel",
    });

    expect(result.status).toBe(1);
    expect(result.stderr).toContain(
      "OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS entry is not an installable bundled plugin in this package: qa-channel",
    );
    expect(result.stderr).toContain("Available: admin-http-rpc");
  });

  it("rejects loose packaged plugin list limit env values", () => {
    const root = makePackageRoot();

    const timeout = runProbe(root, {
      OPENCLAW_BUNDLED_PLUGIN_LIST_TIMEOUT_MS: "100ms",
    });
    expect(timeout.status).toBe(1);
    expect(timeout.stderr).toContain("invalid OPENCLAW_BUNDLED_PLUGIN_LIST_TIMEOUT_MS: 100ms");

    const maxBuffer = runProbe(root, {
      OPENCLAW_BUNDLED_PLUGIN_LIST_MAX_BUFFER_BYTES: "64bytes",
    });
    expect(maxBuffer.status).toBe(1);
    expect(maxBuffer.stderr).toContain(
      "invalid OPENCLAW_BUNDLED_PLUGIN_LIST_MAX_BUFFER_BYTES: 64bytes",
    );
  });

  it("rejects loose bundled plugin sweep shard env values", () => {
    const root = makePackageRoot();
    writePluginManifest(root, "dist-runtime/extensions/admin-http-rpc", {
      id: "admin-http-rpc",
    });
    writePluginsList(root, [
      {
        id: "admin-http-rpc",
        origin: "bundled",
        rootDir: path.join(root, "dist-runtime", "extensions", "admin-http-rpc"),
      },
    ]);

    const total = runProbe(root, {
      OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL: "2shards",
    });
    expect(total.status).toBe(1);
    expect(total.stderr).toContain("invalid OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL: 2shards");

    const index = runProbe(root, {
      OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX: "0of2",
    });
    expect(index.status).toBe(1);
    expect(index.stderr).toContain("invalid OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX: 0of2");
  });

  it("bounds plugin list selection when the CLI hangs", () => {
    const root = makePackageRoot();
    fs.writeFileSync(
      path.join(root, "dist", "index.js"),
      "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);\n",
      "utf8",
    );

    const startedAt = Date.now();
    const result = runProbe(root, {
      OPENCLAW_BUNDLED_PLUGIN_LIST_TIMEOUT_MS: "100",
    });

    expect(Date.now() - startedAt).toBeLessThan(2_500);
    expect(result.status).toBe(1);
    expect(result.stderr).toContain("Timed out listing packaged bundled plugins after 100ms");
  });

  it("loads runtime smoke manifests from the selected packaged root", () => {
    const root = makePackageRoot();
    writePluginManifest(root, "dist/extensions/runtime-only", {
      id: "runtime-only",
      contracts: { speechProviders: ["stale-provider"] },
    });
    fs.mkdirSync(path.join(root, "dist-runtime", "extensions", "runtime-only"), {
      recursive: true,
    });
    fs.writeFileSync(
      path.join(root, "dist-runtime", "extensions", "runtime-only", "openclaw.plugin.json"),
      '{"id":"runtime-only"}\n',
      "utf8",
    );

    const result = runRuntimeSmoke(root, [
      "tts-global-disable",
      "runtime-only",
      "runtime-only",
      "0",
      "0",
      path.join(root, "dist-runtime", "extensions", "runtime-only"),
      "",
    ]);

    expect(result.status).toBe(0);
    expect(result.stdout).toContain(
      "Global-disable TTS smoke skipped for runtime-only: no speech provider contract",
    );
  });

  it("accepts native Windows bundled source paths when asserting install state", () => {
    const root = makePackageRoot();
    const stateDir = path.join(root, "state");
    const windowsSourcePath = "C:\\crabbox\\qa-windows\\dist\\extensions\\nostr";
    fs.mkdirSync(path.join(stateDir, "plugins"), { recursive: true });
    fs.writeFileSync(
      path.join(stateDir, "openclaw.json"),
      JSON.stringify({ plugins: { entries: { nostr: { enabled: true } } } }),
      "utf8",
    );
    fs.writeFileSync(
      path.join(stateDir, "plugins", "installs.json"),
      JSON.stringify({
        installRecords: {
          nostr: {
            source: "path",
            sourcePath: windowsSourcePath,
            installPath: windowsSourcePath,
          },
        },
      }),
      "utf8",
    );
    writePluginsList(root, []);

    const result = runProbeCommand(root, ["assert-installed", "nostr", "nostr", "0"], {
      HOME: undefined,
      OPENCLAW_STATE_DIR: stateDir,
    });

    expect(result.status).toBe(0);
  });

  it("detects native Windows bundled load paths after uninstall", () => {
    const root = makePackageRoot();
    const stateDir = path.join(root, "state");
    fs.mkdirSync(path.join(stateDir, "plugins"), { recursive: true });
    fs.writeFileSync(
      path.join(stateDir, "openclaw.json"),
      JSON.stringify({
        plugins: { load: { paths: ["C:\\crabbox\\qa-windows\\dist\\extensions\\nostr"] } },
      }),
      "utf8",
    );
    fs.writeFileSync(
      path.join(stateDir, "plugins", "installs.json"),
      JSON.stringify({ installRecords: {} }),
      "utf8",
    );
    writePluginsList(root, []);

    const result = runProbeCommand(root, ["assert-uninstalled", "nostr", "nostr"], {
      HOME: undefined,
      OPENCLAW_STATE_DIR: stateDir,
    });

    expect(result.status).toBe(1);
    expect(result.stderr).toContain("load path still present after uninstall for nostr");
  });
});
