// Codex bind live gateway tests verify bundled Codex plugin channel binding and outbound session routing.
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 { renderCatFacePngBase64 } from "../../test/helpers/live-image-probe.js";
import { isLiveTestEnabled } from "../agents/live-test-helpers.js";
import type { ChannelOutboundContext } from "../channels/plugins/types.public.js";
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { getSessionBindingService } from "../infra/outbound/session-binding-service.js";
import { resolveBundledPluginWorkspaceSourcePath } from "../plugins/bundled-plugin-metadata.js";
import { pluginCommands } from "../plugins/command-registry-state.js";
import { clearPluginLoaderCache } from "../plugins/loader.js";
import {
  pinActivePluginChannelRegistry,
  releasePinnedPluginChannelRegistry,
  resetPluginRuntimeStateForTest,
} from "../plugins/runtime.js";
import { extractFirstTextBlock } from "../shared/chat-message-content.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { sleep } from "../utils.js";
import type { GatewayClient } from "./client.js";
import {
  connectTestGatewayClient,
  getFreeGatewayPort,
} from "./gateway-cli-backend.live-helpers.js";
import { startGatewayServer } from "./server.js";

const LIVE = isLiveTestEnabled();
const CODEX_BIND_LIVE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CODEX_BIND);
const describeLive = LIVE && CODEX_BIND_LIVE ? describe : describe.skip;
const CODEX_BIND_TIMEOUT_MS = resolveLiveTimeoutMs(
  process.env.OPENCLAW_LIVE_CODEX_BIND_TIMEOUT_MS,
  900_000,
);
const CODEX_BIND_REQUEST_TIMEOUT_MS = resolveLiveTimeoutMs(
  process.env.OPENCLAW_LIVE_CODEX_BIND_REQUEST_TIMEOUT_MS,
  300_000,
);
const DEFAULT_CODEX_BIND_MODEL = "gpt-5.5";

type CapturedOutboundReply = {
  accountId?: string;
  text: string;
  threadId?: string | number;
  to: string;
};

function resolveLiveTimeoutMs(raw: string | undefined, fallback: number): number {
  const parsed = raw ? Number(raw) : Number.NaN;
  return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
}

function logCodexBindStep(message: string): void {
  console.info(`[live-codex-bind] ${message}`);
}

function createSlackCurrentConversationBindingRegistry(outboundReplies: CapturedOutboundReply[]) {
  return createTestRegistry([
    {
      pluginId: "slack",
      source: "test",
      plugin: {
        id: "slack",
        meta: {
          id: "slack",
          label: "Slack",
          selectionLabel: "Slack",
          docsPath: "/channels/slack",
          blurb: "test stub.",
          aliases: [],
        },
        capabilities: { chatTypes: ["direct"] },
        config: {
          listAccountIds: () => ["default"],
          resolveAccount: () => ({}),
        },
        conversationBindings: {
          supportsCurrentConversationBinding: true,
        },
        outbound: {
          deliveryMode: "direct",
          sendText: async ({ accountId, text, threadId, to }: ChannelOutboundContext) => {
            outboundReplies.push({
              ...(accountId ? { accountId } : {}),
              text,
              ...(threadId != null ? { threadId } : {}),
              to,
            });
            return { channel: "slack", messageId: `slack-${outboundReplies.length}` };
          },
        },
        bindings: {
          compileConfiguredBinding: () => null,
          matchInboundConversation: () => null,
          resolveCommandConversation: ({
            commandTo,
            originatingTo,
            fallbackTo,
          }: {
            commandTo?: string;
            originatingTo?: string;
            fallbackTo?: string;
          }) => {
            const conversationId = [commandTo, originatingTo, fallbackTo].find(Boolean)?.trim();
            return conversationId ? { conversationId } : null;
          },
        },
      },
    },
  ]);
}

function extractAssistantTexts(messages: unknown[]): string[] {
  const texts: string[] = [];
  for (const entry of messages) {
    if (!entry || typeof entry !== "object") {
      continue;
    }
    if ((entry as { role?: unknown }).role !== "assistant") {
      continue;
    }
    const text = extractFirstTextBlock(entry);
    if (typeof text === "string" && text.trim().length > 0) {
      texts.push(text);
    }
  }
  return texts;
}

function formatAssistantTextPreview(texts: string[], maxChars = 800): string {
  const combined = texts.join("\n\n").trim();
  if (!combined) {
    return "<empty>";
  }
  return combined.length <= maxChars ? combined : combined.slice(-maxChars);
}

async function waitForOutboundText(params: {
  replies: CapturedOutboundReply[];
  contains: string;
  minReplyCount?: number;
  timeoutMs?: number;
}): Promise<{ outboundTexts: string[]; matchedText: string }> {
  const timeoutMs = params.timeoutMs ?? 60_000;
  const startedAt = Date.now();

  while (Date.now() - startedAt < timeoutMs) {
    const outboundTexts: string[] = [];
    for (const reply of params.replies) {
      if (reply.text.trim().length > 0) {
        outboundTexts.push(reply.text);
      }
    }
    const minReplyCount = params.minReplyCount ?? 1;
    const matchedText = outboundTexts
      .slice(Math.max(0, minReplyCount - 1))
      .find((text) => text.includes(params.contains));
    if (outboundTexts.length >= minReplyCount && matchedText) {
      return { outboundTexts, matchedText };
    }
    await sleep(500);
  }

  throw new Error(
    `timed out waiting for outbound text containing ${params.contains}: ${formatAssistantTextPreview(
      params.replies.map((reply) => reply.text),
    )}`,
  );
}

function restoreEnvVar(name: string, value: string | undefined): void {
  if (value === undefined) {
    delete process.env[name];
    return;
  }
  process.env[name] = value;
}

async function waitForAgentRunOk(
  client: GatewayClient,
  runId: string,
  context: string,
): Promise<void> {
  let result: { status?: string };
  try {
    result = await client.request(
      "agent.wait",
      { runId, timeoutMs: CODEX_BIND_REQUEST_TIMEOUT_MS },
      { timeoutMs: CODEX_BIND_REQUEST_TIMEOUT_MS + 5_000 },
    );
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    throw new Error(`${context}: agent.wait error for ${runId}: ${message}`, { cause: error });
  }
  if (result?.status !== "ok") {
    throw new Error(`${context}: agent.wait failed for ${runId}: status=${String(result?.status)}`);
  }
}

async function sendChatAndWait(params: {
  client: GatewayClient;
  sessionKey: string;
  idempotencyKey: string;
  context: string;
  message: string;
  originatingChannel: string;
  originatingTo: string;
  originatingAccountId: string;
  deliver?: boolean;
  attachments?: Array<{
    mimeType: string;
    fileName: string;
    content: string;
  }>;
}): Promise<void> {
  const started: { runId?: string; status?: string } = await params.client.request("chat.send", {
    sessionKey: params.sessionKey,
    message: params.message,
    idempotencyKey: params.idempotencyKey,
    originatingChannel: params.originatingChannel,
    originatingTo: params.originatingTo,
    originatingAccountId: params.originatingAccountId,
    deliver: params.deliver,
    attachments: params.attachments,
  });
  if (started?.status !== "started" || typeof started.runId !== "string") {
    throw new Error(
      `${params.context}: chat.send did not start correctly: ${JSON.stringify(started)}`,
    );
  }
  logCodexBindStep(`${params.context} started (${started.runId})`);
  await waitForAgentRunOk(params.client, started.runId, params.context);
  logCodexBindStep(`${params.context} completed`);
}

async function waitForAssistantText(params: {
  client: GatewayClient;
  sessionKey: string;
  contains: string;
  caseInsensitive?: boolean;
  minAssistantCount?: number;
  timeoutMs?: number;
}): Promise<{ messages: unknown[]; assistantTexts: string[]; matchedAssistantText: string }> {
  const timeoutMs = params.timeoutMs ?? 60_000;
  const startedAt = Date.now();

  while (Date.now() - startedAt < timeoutMs) {
    const history: { messages?: unknown[] } = await params.client.request("chat.history", {
      sessionKey: params.sessionKey,
      limit: 24,
    });
    const messages = history.messages ?? [];
    const assistantTexts = extractAssistantTexts(messages);
    const minAssistantCount = params.minAssistantCount ?? 1;
    const expected = params.caseInsensitive ? params.contains.toLowerCase() : params.contains;
    const matchedAssistantText = assistantTexts
      .slice(Math.max(0, minAssistantCount - 1))
      .find((text) => (params.caseInsensitive ? text.toLowerCase() : text).includes(expected));
    if (assistantTexts.length >= minAssistantCount && matchedAssistantText) {
      return { messages, assistantTexts, matchedAssistantText };
    }
    await sleep(500);
  }

  const finalHistory: { messages?: unknown[] } = await params.client.request("chat.history", {
    sessionKey: params.sessionKey,
    limit: 24,
  });
  throw new Error(
    `timed out waiting for assistant text containing ${params.contains}: ${formatAssistantTextPreview(
      extractAssistantTexts(finalHistory.messages ?? []),
    )}`,
  );
}

function resolveCodexPluginRoot(): string {
  const command =
    pluginCommands.get("/codex") ??
    Array.from(pluginCommands.values()).find((candidate) => candidate.pluginId === "codex");
  if (command?.pluginRoot) {
    return command.pluginRoot;
  }
  const pluginRoot = resolveBundledPluginWorkspaceSourcePath({
    rootDir: process.cwd(),
    pluginId: "codex",
  });
  if (!pluginRoot) {
    throw new Error("Codex bundled plugin root was not found");
  }
  return pluginRoot;
}

function resolveBoundSessionKey(params: {
  channel: string;
  accountId: string;
  conversationId: string;
}): string {
  const binding = getSessionBindingService().resolveByConversation({
    channel: params.channel,
    accountId: params.accountId,
    conversationId: params.conversationId,
  });
  if (!binding?.targetSessionKey) {
    throw new Error(
      `No plugin binding target session for ${params.channel}:${params.conversationId}`,
    );
  }
  return binding.targetSessionKey;
}

async function writePluginBindingApproval(params: {
  homeDir: string;
  pluginRoot: string;
  channel: string;
  accountId: string;
}): Promise<void> {
  const openclawDir = path.join(params.homeDir, ".openclaw");
  await fs.mkdir(openclawDir, { recursive: true });
  await fs.writeFile(
    path.join(openclawDir, "plugin-binding-approvals.json"),
    `${JSON.stringify(
      {
        version: 1,
        approvals: [
          {
            pluginRoot: params.pluginRoot,
            pluginId: "codex",
            pluginName: "Codex",
            channel: params.channel,
            accountId: params.accountId,
            approvedAt: Date.now(),
          },
        ],
      },
      null,
      2,
    )}\n`,
  );
}

async function writeGatewayConfig(params: {
  configPath: string;
  model: string;
  modelProvider?: string;
  port: number;
  token: string;
  workspace: string;
}): Promise<void> {
  const modelProvider = params.modelProvider?.trim() || "codex";
  const cfg: OpenClawConfig = {
    gateway: {
      mode: "local",
      port: params.port,
      auth: { mode: "token", token: params.token },
    },
    plugins: {
      allow: ["codex"],
      entries: {
        codex: {
          enabled: true,
          config: {
            appServer: {
              mode: "yolo",
              requestTimeoutMs: CODEX_BIND_REQUEST_TIMEOUT_MS,
              defaultWorkspaceDir: params.workspace,
            },
          },
        },
      },
    },
    agents: {
      defaults: {
        workspace: params.workspace,
        model: { primary: `${modelProvider}/${params.model}` },
        models: {
          [`${modelProvider}/${params.model}`]: { agentRuntime: { id: "codex" } },
        },
        skipBootstrap: true,
        heartbeat: { every: "0m" },
        sandbox: { mode: "off" },
      },
    },
  };
  await fs.writeFile(params.configPath, `${JSON.stringify(cfg, null, 2)}\n`);
}

function resolveCodexBindModelProvider(): string | undefined {
  const configured = process.env.OPENCLAW_LIVE_CODEX_BIND_PROVIDER?.trim();
  if (configured) {
    return configured;
  }
  return process.env.OPENCLAW_LIVE_CODEX_HARNESS_AUTH === "api-key" ? "openai" : undefined;
}

describeLive("gateway live (native Codex conversation binding)", () => {
  it(
    "binds a Slack DM to Codex app-server, updates controls, and forwards image media paths",
    async () => {
      const previous = {
        codexHome: process.env.CODEX_HOME,
        configPath: process.env.OPENCLAW_CONFIG_PATH,
        gatewayToken: process.env.OPENCLAW_GATEWAY_TOKEN,
        home: process.env.HOME,
        skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST,
        skipChannels: process.env.OPENCLAW_SKIP_CHANNELS,
        skipCron: process.env.OPENCLAW_SKIP_CRON,
        skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER,
        stateDir: process.env.OPENCLAW_STATE_DIR,
      };
      const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-live-codex-bind-"));
      const tempHome = path.join(tempRoot, "home");
      const stateDir = path.join(tempRoot, "state");
      const workspace = path.join(tempRoot, "workspace");
      const configPath = path.join(tempRoot, "openclaw.json");
      const token = `test-${randomUUID()}`;
      const port = await getFreeGatewayPort();
      const sessionKey = "main";
      const accountId = "default";
      const slackUserId = `U${randomUUID().replace(/-/g, "").slice(0, 10).toUpperCase()}`;
      const conversationId = `user:${slackUserId}`;
      const bindModel =
        process.env.OPENCLAW_LIVE_CODEX_BIND_MODEL?.trim() || DEFAULT_CODEX_BIND_MODEL;
      const bindProvider = resolveCodexBindModelProvider();
      const outboundReplies: CapturedOutboundReply[] = [];

      await fs.mkdir(workspace, { recursive: true });
      await fs.writeFile(
        path.join(workspace, "AGENTS.md"),
        [
          "# AGENTS.md",
          "",
          "Follow exact reply instructions from the user.",
          "Do not add commentary when asked for an exact response.",
        ].join("\n"),
      );
      await fs.mkdir(tempHome, { recursive: true });
      await fs.mkdir(stateDir, { recursive: true });
      await writeGatewayConfig({
        configPath,
        model: bindModel,
        modelProvider: bindProvider,
        port,
        token,
        workspace,
      });

      clearConfigCache();
      clearRuntimeConfigSnapshot();
      clearPluginLoaderCache();
      resetPluginRuntimeStateForTest();
      const codexHome =
        previous.codexHome || (previous.home ? path.join(previous.home, ".codex") : "");
      if (codexHome) {
        process.env.CODEX_HOME = codexHome;
      } else {
        delete process.env.CODEX_HOME;
      }
      process.env.HOME = tempHome;
      process.env.OPENCLAW_CONFIG_PATH = configPath;
      process.env.OPENCLAW_GATEWAY_TOKEN = token;
      process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
      process.env.OPENCLAW_SKIP_CHANNELS = "1";
      process.env.OPENCLAW_SKIP_CRON = "1";
      process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
      process.env.OPENCLAW_STATE_DIR = stateDir;
      let server: Awaited<ReturnType<typeof startGatewayServer>> | undefined;
      let client: Awaited<ReturnType<typeof connectTestGatewayClient>> | undefined;
      let pinnedChannelRegistry:
        | ReturnType<typeof createSlackCurrentConversationBindingRegistry>
        | undefined;

      try {
        server = await startGatewayServer(port, {
          bind: "loopback",
          auth: { mode: "token", token },
          controlUiEnabled: false,
        });
        client = await connectTestGatewayClient({
          url: `ws://127.0.0.1:${port}`,
          token,
          timeoutMs: 90_000,
          requestTimeoutMs: CODEX_BIND_REQUEST_TIMEOUT_MS,
          clientDisplayName: "vitest-codex-bind-live",
        });
        const activeClient = client;
        const channelRegistry = createSlackCurrentConversationBindingRegistry(outboundReplies);
        pinActivePluginChannelRegistry(channelRegistry);
        pinnedChannelRegistry = channelRegistry;

        await writePluginBindingApproval({
          homeDir: tempHome,
          pluginRoot: resolveCodexPluginRoot(),
          channel: "slack",
          accountId,
        });

        await sendChatAndWait({
          client: activeClient,
          sessionKey,
          idempotencyKey: `idem-codex-bind-${randomUUID()}`,
          context: "bind command",
          message: `/codex bind --cwd ${workspace} --model ${bindModel}${
            bindProvider ? ` --provider ${bindProvider}` : ""
          }`,
          originatingChannel: "slack",
          originatingTo: conversationId,
          originatingAccountId: accountId,
          deliver: true,
        });
        const bindReply = await waitForOutboundText({
          replies: outboundReplies,
          contains: "Bound this conversation to Codex thread",
          timeoutMs: CODEX_BIND_REQUEST_TIMEOUT_MS,
        });
        expect(bindReply.matchedText).toContain("Bound this conversation to Codex thread");
        const boundSessionKey = resolveBoundSessionKey({
          channel: "slack",
          accountId,
          conversationId,
        });
        logCodexBindStep(`binding resolved to ${boundSessionKey}`);
        let commandReplyCount = bindReply.outboundTexts.length;

        const sendCodexCommand = async (message: string, contains: string, timeoutMs = 60_000) => {
          await sendChatAndWait({
            client: activeClient,
            sessionKey,
            idempotencyKey: `idem-codex-command-${randomUUID()}`,
            context: message,
            message,
            originatingChannel: "slack",
            originatingTo: conversationId,
            originatingAccountId: accountId,
            deliver: true,
          });
          const result = await waitForOutboundText({
            replies: outboundReplies,
            contains,
            minReplyCount: commandReplyCount + 1,
            timeoutMs,
          });
          commandReplyCount = result.outboundTexts.length;
          return result;
        };

        await sendCodexCommand(
          "/codex status",
          "Codex app-server: connected",
          CODEX_BIND_REQUEST_TIMEOUT_MS,
        );
        await sendCodexCommand("/codex models", "Codex models:", CODEX_BIND_REQUEST_TIMEOUT_MS);
        await sendCodexCommand("/codex fast on", "Codex fast mode enabled.");
        await sendCodexCommand("/codex fast status", "Codex fast mode: on.");
        await sendCodexCommand("/codex permissions default", "Codex permissions set to default.");
        await sendCodexCommand("/codex permissions status", "Codex permissions: default.");
        await sendCodexCommand("/codex model", `Codex model: ${bindModel}`);
        await sendCodexCommand("/codex stop", "No active Codex run to stop.");

        const bindingStatus = await sendCodexCommand("/codex binding", "- Fast: on");
        if (!bindingStatus.matchedText.includes("- Permissions: default")) {
          throw new Error(
            `binding status did not include default permissions: ${bindingStatus.matchedText}`,
          );
        }

        const textNonce = randomBytes(4).toString("hex").toUpperCase();
        const textToken = `CODEX-BIND-${textNonce}`;
        await sendChatAndWait({
          client: activeClient,
          sessionKey,
          idempotencyKey: `idem-codex-bound-text-${randomUUID()}`,
          context: "bound text turn",
          message: `Reply with exactly this token and nothing else: ${textToken}`,
          originatingChannel: "slack",
          originatingTo: conversationId,
          originatingAccountId: accountId,
        });
        const textHistory = await waitForAssistantText({
          client: activeClient,
          sessionKey: boundSessionKey,
          contains: textToken,
          timeoutMs: CODEX_BIND_REQUEST_TIMEOUT_MS,
        });
        expect(textHistory.matchedAssistantText).toContain(textToken);

        await sendChatAndWait({
          client: activeClient,
          sessionKey,
          idempotencyKey: `idem-codex-bound-image-${randomUUID()}`,
          context: "bound image turn",
          message:
            "What animal is drawn in the attached image? Reply with only the lowercase animal name.",
          originatingChannel: "slack",
          originatingTo: conversationId,
          originatingAccountId: accountId,
          attachments: [
            {
              mimeType: "image/png",
              fileName: `codex-bind-probe-${randomUUID()}.png`,
              content: renderCatFacePngBase64(),
            },
          ],
        });
        const imageHistory = await waitForAssistantText({
          client: activeClient,
          sessionKey: boundSessionKey,
          contains: "cat",
          caseInsensitive: true,
          minAssistantCount: textHistory.assistantTexts.length + 1,
          timeoutMs: CODEX_BIND_REQUEST_TIMEOUT_MS,
        });
        expect(imageHistory.matchedAssistantText.toLowerCase()).toContain("cat");

        await sendCodexCommand("/codex detach", "Detached this conversation from Codex.");
        await sendCodexCommand("/codex binding", "No Codex conversation binding is attached.");
      } finally {
        try {
          if (pinnedChannelRegistry) {
            releasePinnedPluginChannelRegistry(pinnedChannelRegistry);
          }
          clearConfigCache();
          clearRuntimeConfigSnapshot();
          try {
            await client?.stopAndWait({ timeoutMs: 2_000 }).catch(() => {});
          } finally {
            await server?.close();
          }
        } finally {
          await fs.rm(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
          restoreEnvVar("CODEX_HOME", previous.codexHome);
          restoreEnvVar("OPENCLAW_CONFIG_PATH", previous.configPath);
          restoreEnvVar("OPENCLAW_GATEWAY_TOKEN", previous.gatewayToken);
          restoreEnvVar("HOME", previous.home);
          restoreEnvVar("OPENCLAW_SKIP_CANVAS_HOST", previous.skipCanvas);
          restoreEnvVar("OPENCLAW_SKIP_CHANNELS", previous.skipChannels);
          restoreEnvVar("OPENCLAW_SKIP_CRON", previous.skipCron);
          restoreEnvVar("OPENCLAW_SKIP_GMAIL_WATCHER", previous.skipGmail);
          restoreEnvVar("OPENCLAW_STATE_DIR", previous.stateDir);
        }
      }
    },
    CODEX_BIND_TIMEOUT_MS,
  );
});
