import { randomBytes, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { isLiveTestEnabled } from "../agents/live-test-helpers.js";
import { parseModelRef } from "../agents/model-selection.js";
import { clearRuntimeConfigSnapshot, loadConfig } from "../config/config.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { getFreePortBlockWithPermissionFallback } from "../test-utils/ports.js";
import { GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { GatewayClient } from "./client.js";
import { renderCatNoncePngBase64 } from "./live-image-probe.js";
import { startGatewayServer } from "./server.js";
import { extractPayloadText } from "./test-helpers.agent-results.js";

const LIVE = isLiveTestEnabled();
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-6";
const DEFAULT_CLAUDE_ARGS = [
  "-p",
  "--output-format",
  "json",
  "--permission-mode",
  "bypassPermissions",
];
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 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 getFreeGatewayPort(): Promise<number> {
  return await getFreePortBlockWithPermissionFallback({
    offsets: [0, 1, 2, 4],
    fallbackBase: 40_000,
  });
}

async function connectClient(params: { url: string; token: string }) {
  return await new Promise<GatewayClient>((resolve, reject) => {
    let done = false;
    const finish = (result: { client?: GatewayClient; error?: Error }) => {
      if (done) {
        return;
      }
      done = true;
      clearTimeout(connectTimeout);
      if (result.error) {
        reject(result.error);
        return;
      }
      resolve(result.client as GatewayClient);
    };

    const failWithClose = (code: number, reason: string) =>
      finish({ error: new Error(`gateway closed during connect (${code}): ${reason}`) });

    const client = new GatewayClient({
      url: params.url,
      token: params.token,
      clientName: GATEWAY_CLIENT_NAMES.TEST,
      clientVersion: "dev",
      mode: "test",
      onHelloOk: () => finish({ client }),
      onConnectError: (error) => finish({ error }),
      onClose: failWithClose,
    });

    const connectTimeout = setTimeout(
      () => finish({ error: new Error("gateway connect timeout") }),
      10_000,
    );
    connectTimeout.unref();
    client.start();
  });
}

describeLive("gateway live (cli backend)", () => {
  it("runs the agent pipeline against the local CLI backend", async () => {
    clearRuntimeConfigSnapshot();
    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(), "openclaw-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(
        "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(
          "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(
          "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 {
      clearRuntimeConfigSnapshot();
      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);
});
