import { randomBytes, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import { createServer } from "node:net";
import os from "node:os";
import path from "node:path";

import { describe, expect, it } from "vitest";
import { parseModelRef } from "../agents/model-selection.js";
import { loadConfig } from "../config/config.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { GatewayClient } from "./client.js";
import { renderCatNoncePngBase64 } from "./live-image-probe.js";
import { startGatewayServer } from "./server.js";

const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST);
const CLI_LIVE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND);
const CLI_IMAGE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_IMAGE_PROBE);
const CLI_RESUME = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_RESUME_PROBE);
const describeLive = LIVE && CLI_LIVE ? describe : describe.skip;

const DEFAULT_MODEL = "claude-cli/claude-sonnet-4-5";
const DEFAULT_CLAUDE_ARGS = ["-p", "--output-format", "json", "--dangerously-skip-permissions"];
const DEFAULT_CODEX_ARGS = [
  "exec",
  "--json",
  "--color",
  "never",
  "--sandbox",
  "read-only",
  "--skip-git-repo-check",
];
const DEFAULT_CLEAR_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY_OLD"];

function randomImageProbeCode(len = 6): string {
  // Chosen to avoid common OCR confusions in our 5x7 bitmap font.
  // Notably: 0↔8, B↔8, 6↔9, 3↔B, D↔0.
  // Must stay within the glyph set in `src/gateway/live-image-probe.ts`.
  const alphabet = "24567ACEF";
  const bytes = randomBytes(len);
  let out = "";
  for (let i = 0; i < len; i += 1) {
    out += alphabet[bytes[i] % alphabet.length];
  }
  return out;
}

function editDistance(a: string, b: string): number {
  if (a === b) return 0;
  const aLen = a.length;
  const bLen = b.length;
  if (aLen === 0) return bLen;
  if (bLen === 0) return aLen;

  let prev = Array.from({ length: bLen + 1 }, (_v, idx) => idx);
  let curr = Array.from({ length: bLen + 1 }, () => 0);

  for (let i = 1; i <= aLen; i += 1) {
    curr[0] = i;
    const aCh = a.charCodeAt(i - 1);
    for (let j = 1; j <= bLen; j += 1) {
      const cost = aCh === b.charCodeAt(j - 1) ? 0 : 1;
      curr[j] = Math.min(
        prev[j] + 1, // delete
        curr[j - 1] + 1, // insert
        prev[j - 1] + cost, // substitute
      );
    }
    [prev, curr] = [curr, prev];
  }

  return prev[bLen] ?? Number.POSITIVE_INFINITY;
}

function extractPayloadText(result: unknown): string {
  const record = result as Record<string, unknown>;
  const payloads = Array.isArray(record.payloads) ? record.payloads : [];
  const texts = payloads
    .map((p) => (p && typeof p === "object" ? (p as Record<string, unknown>).text : undefined))
    .filter((t): t is string => typeof t === "string" && t.trim().length > 0);
  return texts.join("\n").trim();
}

function parseJsonStringArray(name: string, raw?: string): string[] | undefined {
  const trimmed = raw?.trim();
  if (!trimmed) return undefined;
  const parsed = JSON.parse(trimmed);
  if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === "string")) {
    throw new Error(`${name} must be a JSON array of strings.`);
  }
  return parsed;
}

function parseImageMode(raw?: string): "list" | "repeat" | undefined {
  const trimmed = raw?.trim();
  if (!trimmed) return undefined;
  if (trimmed === "list" || trimmed === "repeat") return trimmed;
  throw new Error("OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE must be 'list' or 'repeat'.");
}

function withMcpConfigOverrides(args: string[], mcpConfigPath: string): string[] {
  const next = [...args];
  if (!next.includes("--strict-mcp-config")) {
    next.push("--strict-mcp-config");
  }
  if (!next.includes("--mcp-config")) {
    next.push("--mcp-config", mcpConfigPath);
  }
  return next;
}

async function getFreePort(): Promise<number> {
  return await new Promise((resolve, reject) => {
    const srv = createServer();
    srv.on("error", reject);
    srv.listen(0, "127.0.0.1", () => {
      const addr = srv.address();
      if (!addr || typeof addr === "string") {
        srv.close();
        reject(new Error("failed to acquire free port"));
        return;
      }
      const port = addr.port;
      srv.close((err) => {
        if (err) reject(err);
        else resolve(port);
      });
    });
  });
}

async function isPortFree(port: number): Promise<boolean> {
  if (!Number.isFinite(port) || port <= 0 || port > 65535) return false;
  return await new Promise((resolve) => {
    const srv = createServer();
    srv.once("error", () => resolve(false));
    srv.listen(port, "127.0.0.1", () => {
      srv.close(() => resolve(true));
    });
  });
}

async function getFreeGatewayPort(): Promise<number> {
  for (let attempt = 0; attempt < 25; attempt += 1) {
    const port = await getFreePort();
    const candidates = [port, port + 1, port + 2, port + 4];
    const ok = (await Promise.all(candidates.map((candidate) => isPortFree(candidate)))).every(
      Boolean,
    );
    if (ok) return port;
  }
  throw new Error("failed to acquire a free gateway port block");
}

async function connectClient(params: { url: string; token: string }) {
  return await new Promise<GatewayClient>((resolve, reject) => {
    let settled = false;
    const stop = (err?: Error, client?: GatewayClient) => {
      if (settled) return;
      settled = true;
      clearTimeout(timer);
      if (err) reject(err);
      else resolve(client as GatewayClient);
    };
    const client = new GatewayClient({
      url: params.url,
      token: params.token,
      clientName: "vitest-live-cli-backend",
      clientVersion: "dev",
      mode: "test",
      onHelloOk: () => stop(undefined, client),
      onConnectError: (err) => stop(err),
      onClose: (code, reason) =>
        stop(new Error(`gateway closed during connect (${code}): ${reason}`)),
    });
    const timer = setTimeout(() => stop(new Error("gateway connect timeout")), 10_000);
    timer.unref();
    client.start();
  });
}

describeLive("gateway live (cli backend)", () => {
  it("runs the agent pipeline against the local CLI backend", async () => {
    const previous = {
      configPath: process.env.OPENCLAW_CONFIG_PATH,
      token: process.env.OPENCLAW_GATEWAY_TOKEN,
      skipChannels: process.env.OPENCLAW_SKIP_CHANNELS,
      skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER,
      skipCron: process.env.OPENCLAW_SKIP_CRON,
      skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST,
      anthropicApiKey: process.env.ANTHROPIC_API_KEY,
      anthropicApiKeyOld: process.env.ANTHROPIC_API_KEY_OLD,
    };

    process.env.OPENCLAW_SKIP_CHANNELS = "1";
    process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
    process.env.OPENCLAW_SKIP_CRON = "1";
    process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
    delete process.env.ANTHROPIC_API_KEY;
    delete process.env.ANTHROPIC_API_KEY_OLD;

    const token = `test-${randomUUID()}`;
    process.env.OPENCLAW_GATEWAY_TOKEN = token;

    const rawModel = process.env.OPENCLAW_LIVE_CLI_BACKEND_MODEL ?? DEFAULT_MODEL;
    const parsed = parseModelRef(rawModel, "claude-cli");
    if (!parsed) {
      throw new Error(
        `OPENCLAW_LIVE_CLI_BACKEND_MODEL must resolve to a CLI backend model. Got: ${rawModel}`,
      );
    }
    const providerId = parsed.provider;
    const modelKey = `${providerId}/${parsed.model}`;

    const providerDefaults =
      providerId === "claude-cli"
        ? { command: "claude", args: DEFAULT_CLAUDE_ARGS }
        : providerId === "codex-cli"
          ? { command: "codex", args: DEFAULT_CODEX_ARGS }
          : null;

    const cliCommand = process.env.OPENCLAW_LIVE_CLI_BACKEND_COMMAND ?? providerDefaults?.command;
    if (!cliCommand) {
      throw new Error(
        `OPENCLAW_LIVE_CLI_BACKEND_COMMAND is required for provider "${providerId}".`,
      );
    }
    const baseCliArgs =
      parseJsonStringArray(
        "OPENCLAW_LIVE_CLI_BACKEND_ARGS",
        process.env.OPENCLAW_LIVE_CLI_BACKEND_ARGS,
      ) ?? providerDefaults?.args;
    if (!baseCliArgs || baseCliArgs.length === 0) {
      throw new Error(`OPENCLAW_LIVE_CLI_BACKEND_ARGS is required for provider "${providerId}".`);
    }
    const cliClearEnv =
      parseJsonStringArray(
        "OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV",
        process.env.OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV,
      ) ?? (providerId === "claude-cli" ? DEFAULT_CLEAR_ENV : []);
    const cliImageArg = process.env.OPENCLAW_LIVE_CLI_BACKEND_IMAGE_ARG?.trim() || undefined;
    const cliImageMode = parseImageMode(process.env.OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE);

    if (cliImageMode && !cliImageArg) {
      throw new Error(
        "OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE requires OPENCLAW_LIVE_CLI_BACKEND_IMAGE_ARG.",
      );
    }

    const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-live-cli-"));
    const disableMcpConfig = process.env.OPENCLAW_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG !== "0";
    let cliArgs = baseCliArgs;
    if (providerId === "claude-cli" && disableMcpConfig) {
      const mcpConfigPath = path.join(tempDir, "claude-mcp.json");
      await fs.writeFile(mcpConfigPath, `${JSON.stringify({ mcpServers: {} }, null, 2)}\n`);
      cliArgs = withMcpConfigOverrides(baseCliArgs, mcpConfigPath);
    }

    const cfg = loadConfig();
    const existingBackends = cfg.agents?.defaults?.cliBackends ?? {};
    const nextCfg = {
      ...cfg,
      agents: {
        ...cfg.agents,
        defaults: {
          ...cfg.agents?.defaults,
          model: { primary: modelKey },
          models: {
            [modelKey]: {},
          },
          cliBackends: {
            ...existingBackends,
            [providerId]: {
              command: cliCommand,
              args: cliArgs,
              clearEnv: cliClearEnv.length > 0 ? cliClearEnv : undefined,
              systemPromptWhen: "never",
              ...(cliImageArg ? { imageArg: cliImageArg, imageMode: cliImageMode } : {}),
            },
          },
          sandbox: { mode: "off" },
        },
      },
    };
    const tempConfigPath = path.join(tempDir, "openclaw.json");
    await fs.writeFile(tempConfigPath, `${JSON.stringify(nextCfg, null, 2)}\n`);
    process.env.OPENCLAW_CONFIG_PATH = tempConfigPath;

    const port = await getFreeGatewayPort();
    const server = await startGatewayServer(port, {
      bind: "loopback",
      auth: { mode: "token", token },
      controlUiEnabled: false,
    });

    const client = await connectClient({
      url: `ws://127.0.0.1:${port}`,
      token,
    });

    try {
      const sessionKey = "agent:dev:live-cli-backend";
      const runId = randomUUID();
      const nonce = randomBytes(3).toString("hex").toUpperCase();
      const message =
        providerId === "codex-cli"
          ? `Please include the token CLI-BACKEND-${nonce} in your reply.`
          : `Reply with exactly: CLI backend OK ${nonce}.`;
      const payload = await client.request<Record<string, unknown>>(
        "agent",
        {
          sessionKey,
          idempotencyKey: `idem-${runId}`,
          message,
          deliver: false,
        },
        { expectFinal: true },
      );
      if (payload?.status !== "ok") {
        throw new Error(`agent status=${String(payload?.status)}`);
      }
      const text = extractPayloadText(payload?.result);
      if (providerId === "codex-cli") {
        expect(text).toContain(`CLI-BACKEND-${nonce}`);
      } else {
        expect(text).toContain(`CLI backend OK ${nonce}.`);
      }

      if (CLI_RESUME) {
        const runIdResume = randomUUID();
        const resumeNonce = randomBytes(3).toString("hex").toUpperCase();
        const resumeMessage =
          providerId === "codex-cli"
            ? `Please include the token CLI-RESUME-${resumeNonce} in your reply.`
            : `Reply with exactly: CLI backend RESUME OK ${resumeNonce}.`;
        const resumePayload = await client.request<Record<string, unknown>>(
          "agent",
          {
            sessionKey,
            idempotencyKey: `idem-${runIdResume}`,
            message: resumeMessage,
            deliver: false,
          },
          { expectFinal: true },
        );
        if (resumePayload?.status !== "ok") {
          throw new Error(`resume status=${String(resumePayload?.status)}`);
        }
        const resumeText = extractPayloadText(resumePayload?.result);
        if (providerId === "codex-cli") {
          expect(resumeText).toContain(`CLI-RESUME-${resumeNonce}`);
        } else {
          expect(resumeText).toContain(`CLI backend RESUME OK ${resumeNonce}.`);
        }
      }

      if (CLI_IMAGE) {
        // Shorter code => less OCR flake across providers, still tests image attachments end-to-end.
        const imageCode = randomImageProbeCode();
        const imageBase64 = renderCatNoncePngBase64(imageCode);
        const runIdImage = randomUUID();

        const imageProbe = await client.request<Record<string, unknown>>(
          "agent",
          {
            sessionKey,
            idempotencyKey: `idem-${runIdImage}-image`,
            message:
              "Look at the attached image. Reply with exactly two tokens separated by a single space: " +
              "(1) the animal shown or written in the image, lowercase; " +
              "(2) the code printed in the image, uppercase. No extra text.",
            attachments: [
              {
                mimeType: "image/png",
                fileName: `probe-${runIdImage}.png`,
                content: imageBase64,
              },
            ],
            deliver: false,
          },
          { expectFinal: true },
        );
        if (imageProbe?.status !== "ok") {
          throw new Error(`image probe failed: status=${String(imageProbe?.status)}`);
        }
        const imageText = extractPayloadText(imageProbe?.result);
        if (!/\bcat\b/i.test(imageText)) {
          throw new Error(`image probe missing 'cat': ${imageText}`);
        }
        const candidates = imageText.toUpperCase().match(/[A-Z0-9]{6,20}/g) ?? [];
        const bestDistance = candidates.reduce((best, cand) => {
          if (Math.abs(cand.length - imageCode.length) > 2) return best;
          return Math.min(best, editDistance(cand, imageCode));
        }, Number.POSITIVE_INFINITY);
        if (!(bestDistance <= 5)) {
          throw new Error(`image probe missing code (${imageCode}): ${imageText}`);
        }
      }
    } finally {
      client.stop();
      await server.close();
      await fs.rm(tempDir, { recursive: true, force: true });
      if (previous.configPath === undefined) delete process.env.OPENCLAW_CONFIG_PATH;
      else process.env.OPENCLAW_CONFIG_PATH = previous.configPath;
      if (previous.token === undefined) delete process.env.OPENCLAW_GATEWAY_TOKEN;
      else process.env.OPENCLAW_GATEWAY_TOKEN = previous.token;
      if (previous.skipChannels === undefined) delete process.env.OPENCLAW_SKIP_CHANNELS;
      else process.env.OPENCLAW_SKIP_CHANNELS = previous.skipChannels;
      if (previous.skipGmail === undefined) delete process.env.OPENCLAW_SKIP_GMAIL_WATCHER;
      else process.env.OPENCLAW_SKIP_GMAIL_WATCHER = previous.skipGmail;
      if (previous.skipCron === undefined) delete process.env.OPENCLAW_SKIP_CRON;
      else process.env.OPENCLAW_SKIP_CRON = previous.skipCron;
      if (previous.skipCanvas === undefined) delete process.env.OPENCLAW_SKIP_CANVAS_HOST;
      else process.env.OPENCLAW_SKIP_CANVAS_HOST = previous.skipCanvas;
      if (previous.anthropicApiKey === undefined) delete process.env.ANTHROPIC_API_KEY;
      else process.env.ANTHROPIC_API_KEY = previous.anthropicApiKey;
      if (previous.anthropicApiKeyOld === undefined) delete process.env.ANTHROPIC_API_KEY_OLD;
      else process.env.ANTHROPIC_API_KEY_OLD = previous.anthropicApiKeyOld;
    }
  }, 60_000);
});
