import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { ButtonInteraction, ComponentData } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { clearSessionStoreCacheForTest } from "../../../../src/config/sessions.js";
import type { DiscordExecApprovalConfig } from "../../../../src/config/types.discord.js";
import {
  buildExecApprovalCustomId,
  extractDiscordChannelId,
  parseExecApprovalData,
  type ExecApprovalRequest,
  DiscordExecApprovalHandler,
  ExecApprovalButton,
  type ExecApprovalButtonContext,
} from "./exec-approvals.js";

const STORE_PATH = path.join(os.tmpdir(), "openclaw-exec-approvals-test.json");

const writeStore = (store: Record<string, unknown>) => {
  fs.writeFileSync(STORE_PATH, `${JSON.stringify(store, null, 2)}\n`, "utf8");
  // CI runners can have coarse mtime resolution; avoid returning stale cached stores.
  clearSessionStoreCacheForTest();
};

beforeEach(() => {
  writeStore({});
  mockGatewayClientCtor.mockClear();
  mockResolveGatewayConnectionAuth.mockReset().mockImplementation(
    async (params: {
      config?: {
        gateway?: {
          auth?: {
            token?: string;
            password?: string;
          };
        };
      };
      env: NodeJS.ProcessEnv;
    }) => {
      const configToken = params.config?.gateway?.auth?.token;
      const configPassword = params.config?.gateway?.auth?.password;
      const envToken = params.env.OPENCLAW_GATEWAY_TOKEN;
      const envPassword = params.env.OPENCLAW_GATEWAY_PASSWORD;
      return { token: envToken ?? configToken, password: envPassword ?? configPassword };
    },
  );
});

// ─── Mocks ────────────────────────────────────────────────────────────────────

const mockRestPost = vi.hoisted(() => vi.fn());
const mockRestPatch = vi.hoisted(() => vi.fn());
const mockRestDelete = vi.hoisted(() => vi.fn());
const gatewayClientStarts = vi.hoisted(() => vi.fn());
const gatewayClientStops = vi.hoisted(() => vi.fn());
const gatewayClientRequests = vi.hoisted(() => vi.fn(async () => ({ ok: true })));
const gatewayClientParams = vi.hoisted(() => [] as Array<Record<string, unknown>>);
const mockGatewayClientCtor = vi.hoisted(() => vi.fn());
const mockResolveGatewayConnectionAuth = vi.hoisted(() => vi.fn());

vi.mock("../send.shared.js", async (importOriginal) => {
  const actual = await importOriginal<typeof import("../send.shared.js")>();
  return {
    ...actual,
    createDiscordClient: () => ({
      rest: {
        post: mockRestPost,
        patch: mockRestPatch,
        delete: mockRestDelete,
      },
      request: (_fn: () => Promise<unknown>, _label: string) => _fn(),
    }),
  };
});

vi.mock("../../../../src/gateway/client.js", () => ({
  GatewayClient: class {
    private params: Record<string, unknown>;
    constructor(params: Record<string, unknown>) {
      this.params = params;
      gatewayClientParams.push(params);
      mockGatewayClientCtor(params);
    }
    start() {
      gatewayClientStarts();
    }
    stop() {
      gatewayClientStops();
    }
    async request() {
      return gatewayClientRequests();
    }
  },
}));

vi.mock("../../../../src/gateway/connection-auth.js", () => ({
  resolveGatewayConnectionAuth: mockResolveGatewayConnectionAuth,
}));

vi.mock("../../../../src/logger.js", () => ({
  logDebug: vi.fn(),
  logError: vi.fn(),
}));

// ─── Helpers ──────────────────────────────────────────────────────────────────

function createHandler(config: DiscordExecApprovalConfig, accountId = "default") {
  return new DiscordExecApprovalHandler({
    token: "test-token",
    accountId,
    config,
    cfg: { session: { store: STORE_PATH } },
  });
}

function mockSuccessfulDmDelivery(params?: {
  noteChannelId?: string;
  expectedNoteText?: string;
  throwOnUnexpectedRoute?: boolean;
}) {
  mockRestPost.mockImplementation(
    async (route: string, requestParams?: { body?: { content?: string } }) => {
      if (params?.noteChannelId && route === Routes.channelMessages(params.noteChannelId)) {
        if (params.expectedNoteText) {
          expect(requestParams?.body?.content).toContain(params.expectedNoteText);
        }
        return { id: "note-1", channel_id: params.noteChannelId };
      }
      if (route === Routes.userChannels()) {
        return { id: "dm-1" };
      }
      if (route === Routes.channelMessages("dm-1")) {
        return { id: "msg-1", channel_id: "dm-1" };
      }
      if (params?.throwOnUnexpectedRoute) {
        throw new Error(`unexpected route: ${route}`);
      }
      return { id: "msg-unknown" };
    },
  );
}

async function expectGatewayAuthStart(params: {
  handler: DiscordExecApprovalHandler;
  expectedUrl: string;
  expectedSource: "cli" | "env";
  expectedToken?: string;
  expectedPassword?: string;
}) {
  await params.handler.start();

  expect(mockResolveGatewayConnectionAuth).toHaveBeenCalledWith(
    expect.objectContaining({
      env: process.env,
      urlOverride: params.expectedUrl,
      urlOverrideSource: params.expectedSource,
    }),
  );

  const expectedClientParams: Record<string, unknown> = {
    url: params.expectedUrl,
  };
  if (params.expectedToken !== undefined) {
    expectedClientParams.token = params.expectedToken;
  }
  if (params.expectedPassword !== undefined) {
    expectedClientParams.password = params.expectedPassword;
  }
  expect(mockGatewayClientCtor).toHaveBeenCalledWith(expect.objectContaining(expectedClientParams));
}

type ExecApprovalHandlerInternals = {
  pending: Map<
    string,
    { discordMessageId: string; discordChannelId: string; timeoutId: NodeJS.Timeout }
  >;
  requestCache: Map<string, ExecApprovalRequest>;
  handleApprovalRequested: (request: ExecApprovalRequest) => Promise<void>;
  handleApprovalTimeout: (approvalId: string, source?: "channel" | "dm") => Promise<void>;
};

function getHandlerInternals(handler: DiscordExecApprovalHandler): ExecApprovalHandlerInternals {
  return handler as unknown as ExecApprovalHandlerInternals;
}

function clearPendingTimeouts(handler: DiscordExecApprovalHandler) {
  const internals = getHandlerInternals(handler);
  for (const pending of internals.pending.values()) {
    clearTimeout(pending.timeoutId);
  }
  internals.pending.clear();
}

function createRequest(
  overrides: Partial<ExecApprovalRequest["request"]> = {},
): ExecApprovalRequest {
  return {
    id: "test-id",
    request: {
      command: "echo hello",
      cwd: "/home/user",
      host: "gateway",
      agentId: "test-agent",
      sessionKey: "agent:test-agent:discord:channel:999888777",
      ...overrides,
    },
    createdAtMs: Date.now(),
    expiresAtMs: Date.now() + 60000,
  };
}

beforeEach(() => {
  mockRestPost.mockReset();
  mockRestPatch.mockReset();
  mockRestDelete.mockReset();
  gatewayClientStarts.mockReset();
  gatewayClientStops.mockReset();
  gatewayClientRequests.mockReset();
  gatewayClientRequests.mockResolvedValue({ ok: true });
  gatewayClientParams.length = 0;
});

// ─── buildExecApprovalCustomId ────────────────────────────────────────────────

describe("buildExecApprovalCustomId", () => {
  it("encodes approval id and action", () => {
    const customId = buildExecApprovalCustomId("abc-123", "allow-once");
    expect(customId).toBe("execapproval:id=abc-123;action=allow-once");
  });

  it("encodes special characters in approval id", () => {
    const customId = buildExecApprovalCustomId("abc=123;test", "deny");
    expect(customId).toBe("execapproval:id=abc%3D123%3Btest;action=deny");
  });
});

// ─── parseExecApprovalData ────────────────────────────────────────────────────

describe("parseExecApprovalData", () => {
  it("parses valid data", () => {
    const result = parseExecApprovalData({ id: "abc-123", action: "allow-once" });
    expect(result).toEqual({ approvalId: "abc-123", action: "allow-once" });
  });

  it("parses encoded data", () => {
    const result = parseExecApprovalData({
      id: "abc%3D123%3Btest",
      action: "allow-always",
    });
    expect(result).toEqual({ approvalId: "abc=123;test", action: "allow-always" });
  });

  it("rejects invalid action", () => {
    const result = parseExecApprovalData({ id: "abc-123", action: "invalid" });
    expect(result).toBeNull();
  });

  it("rejects missing id", () => {
    const result = parseExecApprovalData({ action: "deny" });
    expect(result).toBeNull();
  });

  it("rejects missing action", () => {
    const result = parseExecApprovalData({ id: "abc-123" });
    expect(result).toBeNull();
  });

  it("rejects null/undefined input", () => {
    // oxlint-disable-next-line typescript/no-explicit-any
    expect(parseExecApprovalData(null as any)).toBeNull();
    // oxlint-disable-next-line typescript/no-explicit-any
    expect(parseExecApprovalData(undefined as any)).toBeNull();
  });

  it("accepts all valid actions", () => {
    expect(parseExecApprovalData({ id: "x", action: "allow-once" })?.action).toBe("allow-once");
    expect(parseExecApprovalData({ id: "x", action: "allow-always" })?.action).toBe("allow-always");
    expect(parseExecApprovalData({ id: "x", action: "deny" })?.action).toBe("deny");
  });
});

// ─── roundtrip encoding ───────────────────────────────────────────────────────

describe("roundtrip encoding", () => {
  it("encodes and decodes correctly", () => {
    const approvalId = "test-approval-with=special;chars&more";
    const action = "allow-always" as const;
    const customId = buildExecApprovalCustomId(approvalId, action);

    // Parse the key=value pairs from the custom ID
    const parts = customId.split(";");
    const data: Record<string, string> = {};
    for (const part of parts) {
      const match = part.match(/^([^:]+:)?([^=]+)=(.+)$/);
      if (match) {
        data[match[2]] = match[3];
      }
    }

    const result = parseExecApprovalData(data);
    expect(result).toEqual({ approvalId, action });
  });
});

// ─── extractDiscordChannelId ──────────────────────────────────────────────────

describe("extractDiscordChannelId", () => {
  it("extracts channel IDs and rejects invalid session key inputs", () => {
    const cases: Array<{
      name: string;
      input: string | null | undefined;
      expected: string | null;
    }> = [
      {
        name: "standard session key",
        input: "agent:main:discord:channel:123456789",
        expected: "123456789",
      },
      {
        name: "agent-specific session key",
        input: "agent:test-agent:discord:channel:999888777",
        expected: "999888777",
      },
      {
        name: "group session key",
        input: "agent:main:discord:group:222333444",
        expected: "222333444",
      },
      {
        name: "longer session key",
        input: "agent:my-agent:discord:channel:111222333:thread:444555",
        expected: "111222333",
      },
      {
        name: "non-discord session key",
        input: "agent:main:telegram:channel:123456789",
        expected: null,
      },
      {
        name: "missing channel/group segment",
        input: "agent:main:discord:dm:123456789",
        expected: null,
      },
      { name: "null input", input: null, expected: null },
      { name: "undefined input", input: undefined, expected: null },
      { name: "empty input", input: "", expected: null },
    ];

    for (const testCase of cases) {
      expect(extractDiscordChannelId(testCase.input), testCase.name).toBe(testCase.expected);
    }
  });
});

// ─── DiscordExecApprovalHandler.shouldHandle ──────────────────────────────────

describe("DiscordExecApprovalHandler.shouldHandle", () => {
  it("returns false when disabled", () => {
    const handler = createHandler({ enabled: false, approvers: ["123"] });
    expect(handler.shouldHandle(createRequest())).toBe(false);
  });

  it("returns false when no approvers", () => {
    const handler = createHandler({ enabled: true, approvers: [] });
    expect(handler.shouldHandle(createRequest())).toBe(false);
  });

  it("returns true with minimal config", () => {
    const handler = createHandler({ enabled: true, approvers: ["123"] });
    expect(handler.shouldHandle(createRequest())).toBe(true);
  });

  it("filters by agent ID", () => {
    const handler = createHandler({
      enabled: true,
      approvers: ["123"],
      agentFilter: ["allowed-agent"],
    });
    expect(handler.shouldHandle(createRequest({ agentId: "allowed-agent" }))).toBe(true);
    expect(handler.shouldHandle(createRequest({ agentId: "other-agent" }))).toBe(false);
    expect(handler.shouldHandle(createRequest({ agentId: null }))).toBe(false);
  });

  it("filters by session key substring", () => {
    const handler = createHandler({
      enabled: true,
      approvers: ["123"],
      sessionFilter: ["discord"],
    });
    expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:discord:123" }))).toBe(
      true,
    );
    expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:telegram:123" }))).toBe(
      false,
    );
    expect(handler.shouldHandle(createRequest({ sessionKey: null }))).toBe(false);
  });

  it("filters by session key regex", () => {
    const handler = createHandler({
      enabled: true,
      approvers: ["123"],
      sessionFilter: ["^agent:.*:discord:"],
    });
    expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:discord:123" }))).toBe(
      true,
    );
    expect(handler.shouldHandle(createRequest({ sessionKey: "other:test:discord:123" }))).toBe(
      false,
    );
  });

  it("rejects unsafe nested-repetition regex in session filter", () => {
    const handler = createHandler({
      enabled: true,
      approvers: ["123"],
      sessionFilter: ["(a+)+$"],
    });
    expect(handler.shouldHandle(createRequest({ sessionKey: `${"a".repeat(28)}!` }))).toBe(false);
  });

  it("matches long session keys with tail-bounded regex checks", () => {
    const handler = createHandler({
      enabled: true,
      approvers: ["123"],
      sessionFilter: ["discord:tail$"],
    });
    expect(
      handler.shouldHandle(createRequest({ sessionKey: `${"x".repeat(5000)}discord:tail` })),
    ).toBe(true);
  });

  it("filters by discord account when session store includes account", () => {
    writeStore({
      "agent:test-agent:discord:channel:999888777": {
        sessionId: "sess",
        updatedAt: Date.now(),
        origin: { provider: "discord", accountId: "secondary" },
        lastAccountId: "secondary",
      },
    });
    const handler = createHandler({ enabled: true, approvers: ["123"] }, "default");
    expect(handler.shouldHandle(createRequest())).toBe(false);
    const matching = createHandler({ enabled: true, approvers: ["123"] }, "secondary");
    expect(matching.shouldHandle(createRequest())).toBe(true);
  });

  it("combines agent and session filters", () => {
    const handler = createHandler({
      enabled: true,
      approvers: ["123"],
      agentFilter: ["my-agent"],
      sessionFilter: ["discord"],
    });
    expect(
      handler.shouldHandle(
        createRequest({
          agentId: "my-agent",
          sessionKey: "agent:my-agent:discord:123",
        }),
      ),
    ).toBe(true);
    expect(
      handler.shouldHandle(
        createRequest({
          agentId: "other-agent",
          sessionKey: "agent:other:discord:123",
        }),
      ),
    ).toBe(false);
    expect(
      handler.shouldHandle(
        createRequest({
          agentId: "my-agent",
          sessionKey: "agent:my-agent:telegram:123",
        }),
      ),
    ).toBe(false);
  });
});

// ─── DiscordExecApprovalHandler.getApprovers ──────────────────────────────────

describe("DiscordExecApprovalHandler.getApprovers", () => {
  it("returns approvers for configured, empty, and undefined lists", () => {
    const cases = [
      {
        name: "configured approvers",
        config: { enabled: true, approvers: ["111", "222"] } as DiscordExecApprovalConfig,
        expected: ["111", "222"],
      },
      {
        name: "empty approvers",
        config: { enabled: true, approvers: [] } as DiscordExecApprovalConfig,
        expected: [],
      },
      {
        name: "undefined approvers",
        config: { enabled: true } as DiscordExecApprovalConfig,
        expected: [],
      },
    ] as const;

    for (const testCase of cases) {
      const handler = createHandler(testCase.config);
      expect(handler.getApprovers(), testCase.name).toEqual(testCase.expected);
    }
  });
});

// ─── ExecApprovalButton authorization ─────────────────────────────────────────

describe("ExecApprovalButton", () => {
  function createMockHandler(approverIds: string[]) {
    const handler = createHandler({
      enabled: true,
      approvers: approverIds,
    });
    // Mock resolveApproval to track calls
    handler.resolveApproval = vi.fn().mockResolvedValue(true);
    return handler;
  }

  function createMockInteraction(userId: string) {
    const reply = vi.fn().mockResolvedValue(undefined);
    const acknowledge = vi.fn().mockResolvedValue(undefined);
    const followUp = vi.fn().mockResolvedValue(undefined);
    const interaction = {
      userId,
      reply,
      acknowledge,
      followUp,
    } as unknown as ButtonInteraction;
    return { interaction, reply, acknowledge, followUp };
  }

  it("denies unauthorized users with ephemeral message", async () => {
    const handler = createMockHandler(["111", "222"]);
    const ctx: ExecApprovalButtonContext = { handler };
    const button = new ExecApprovalButton(ctx);

    const { interaction, reply, acknowledge } = createMockInteraction("999");
    const data: ComponentData = { id: "test-approval", action: "allow-once" };

    await button.run(interaction, data);

    expect(reply).toHaveBeenCalledWith({
      content: "⛔ You are not authorized to approve exec requests.",
      ephemeral: true,
    });
    expect(acknowledge).not.toHaveBeenCalled();
    // oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock
    expect(handler.resolveApproval).not.toHaveBeenCalled();
  });

  it("allows authorized user and resolves approval", async () => {
    const handler = createMockHandler(["111", "222"]);
    const ctx: ExecApprovalButtonContext = { handler };
    const button = new ExecApprovalButton(ctx);

    const { interaction, reply, acknowledge } = createMockInteraction("222");
    const data: ComponentData = { id: "test-approval", action: "allow-once" };

    await button.run(interaction, data);

    expect(reply).not.toHaveBeenCalled();
    expect(acknowledge).toHaveBeenCalledTimes(1);
    // oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock
    expect(handler.resolveApproval).toHaveBeenCalledWith("test-approval", "allow-once");
  });

  it("acknowledges allow-always interactions before resolving", async () => {
    const handler = createMockHandler(["111"]);
    const ctx: ExecApprovalButtonContext = { handler };
    const button = new ExecApprovalButton(ctx);

    const { interaction, acknowledge } = createMockInteraction("111");
    const data: ComponentData = { id: "test-approval", action: "allow-always" };

    await button.run(interaction, data);

    expect(acknowledge).toHaveBeenCalledTimes(1);
    // oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock
    expect(handler.resolveApproval).toHaveBeenCalledWith("test-approval", "allow-always");
  });

  it("acknowledges deny interactions before resolving", async () => {
    const handler = createMockHandler(["111"]);
    const ctx: ExecApprovalButtonContext = { handler };
    const button = new ExecApprovalButton(ctx);

    const { interaction, acknowledge } = createMockInteraction("111");
    const data: ComponentData = { id: "test-approval", action: "deny" };

    await button.run(interaction, data);

    expect(acknowledge).toHaveBeenCalledTimes(1);
    // oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock
    expect(handler.resolveApproval).toHaveBeenCalledWith("test-approval", "deny");
  });

  it("handles invalid data gracefully", async () => {
    const handler = createMockHandler(["111"]);
    const ctx: ExecApprovalButtonContext = { handler };
    const button = new ExecApprovalButton(ctx);

    const { interaction, acknowledge, reply } = createMockInteraction("111");
    const data: ComponentData = { id: "", action: "invalid" };

    await button.run(interaction, data);

    expect(reply).toHaveBeenCalledWith({
      content: "This approval is no longer valid.",
      ephemeral: true,
    });
    expect(acknowledge).not.toHaveBeenCalled();
    // oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock
    expect(handler.resolveApproval).not.toHaveBeenCalled();
  });

  it("follows up with error when resolve fails", async () => {
    const handler = createMockHandler(["111"]);
    handler.resolveApproval = vi.fn().mockResolvedValue(false);
    const ctx: ExecApprovalButtonContext = { handler };
    const button = new ExecApprovalButton(ctx);

    const { interaction, followUp } = createMockInteraction("111");
    const data: ComponentData = { id: "test-approval", action: "allow-once" };

    await button.run(interaction, data);

    expect(followUp).toHaveBeenCalledWith({
      content:
        "Failed to submit approval decision for **Allowed (once)**. The request may have expired or already been resolved.",
      ephemeral: true,
    });
  });

  it("matches approvers with string coercion", async () => {
    // Approvers might be numbers in config
    const handler = createHandler({
      enabled: true,
      approvers: [111 as unknown as string],
    });
    handler.resolveApproval = vi.fn().mockResolvedValue(true);
    const ctx: ExecApprovalButtonContext = { handler };
    const button = new ExecApprovalButton(ctx);

    const { interaction, acknowledge, reply } = createMockInteraction("111");
    const data: ComponentData = { id: "test-approval", action: "allow-once" };

    await button.run(interaction, data);

    // Should match because getApprovers returns [111] and button does String(id) === userId
    expect(reply).not.toHaveBeenCalled();
    expect(acknowledge).toHaveBeenCalled();
  });
});

// ─── Target routing (handler config) ──────────────────────────────────────────

describe("DiscordExecApprovalHandler target config", () => {
  beforeEach(() => {
    mockRestPost.mockClear().mockResolvedValue({ id: "mock-message", channel_id: "mock-channel" });
    mockRestPatch.mockClear().mockResolvedValue({});
    mockRestDelete.mockClear().mockResolvedValue({});
  });

  it("accepts all target modes and defaults to dm when target is omitted", () => {
    const cases = [
      {
        name: "default target",
        config: { enabled: true, approvers: ["123"] } as DiscordExecApprovalConfig,
        expectedTarget: undefined,
      },
      {
        name: "channel target",
        config: {
          enabled: true,
          approvers: ["123"],
          target: "channel",
        } as DiscordExecApprovalConfig,
      },
      {
        name: "both target",
        config: {
          enabled: true,
          approvers: ["123"],
          target: "both",
        } as DiscordExecApprovalConfig,
      },
      {
        name: "dm target",
        config: {
          enabled: true,
          approvers: ["123"],
          target: "dm",
        } as DiscordExecApprovalConfig,
      },
    ] as const;

    for (const testCase of cases) {
      if ("expectedTarget" in testCase) {
        expect(testCase.config.target, testCase.name).toBe(testCase.expectedTarget);
      }
      const handler = createHandler(testCase.config);
      expect(handler.shouldHandle(createRequest()), testCase.name).toBe(true);
    }
  });
});

describe("DiscordExecApprovalHandler gateway auth", () => {
  it("passes the shared gateway token from config into GatewayClient", async () => {
    const handler = new DiscordExecApprovalHandler({
      token: "discord-bot-token",
      accountId: "default",
      config: { enabled: true, approvers: ["123"] },
      cfg: {
        gateway: {
          mode: "local",
          bind: "loopback",
          auth: { mode: "token", token: "shared-gateway-token" },
        },
      },
    });

    await handler.start();

    expect(gatewayClientStarts).toHaveBeenCalledTimes(1);
    expect(gatewayClientParams[0]).toMatchObject({
      url: "ws://127.0.0.1:18789",
      token: "shared-gateway-token",
      password: undefined,
      scopes: ["operator.approvals"],
    });
  });

  it("prefers OPENCLAW_GATEWAY_TOKEN when config token is missing", async () => {
    vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "env-gateway-token");
    const handler = new DiscordExecApprovalHandler({
      token: "discord-bot-token",
      accountId: "default",
      config: { enabled: true, approvers: ["123"] },
      cfg: {
        gateway: {
          mode: "local",
          bind: "loopback",
          auth: { mode: "token" },
        },
      },
    });

    try {
      await handler.start();
    } finally {
      vi.unstubAllEnvs();
    }

    expect(gatewayClientStarts).toHaveBeenCalledTimes(1);
    expect(gatewayClientParams[0]).toMatchObject({
      token: "env-gateway-token",
      password: undefined,
    });
  });
});

// ─── Timeout cleanup ─────────────────────────────────────────────────────────

describe("DiscordExecApprovalHandler timeout cleanup", () => {
  beforeEach(() => {
    mockRestPost.mockClear().mockResolvedValue({ id: "mock-message", channel_id: "mock-channel" });
    mockRestPatch.mockClear().mockResolvedValue({});
    mockRestDelete.mockClear().mockResolvedValue({});
  });

  it("cleans up request cache for the exact approval id", async () => {
    const handler = createHandler({ enabled: true, approvers: ["123"] });
    const internals = getHandlerInternals(handler);
    const requestA = { ...createRequest(), id: "abc" };
    const requestB = { ...createRequest(), id: "abc2" };

    internals.requestCache.set("abc", requestA);
    internals.requestCache.set("abc2", requestB);

    const timeoutIdA = setTimeout(() => {}, 0);
    const timeoutIdB = setTimeout(() => {}, 0);
    clearTimeout(timeoutIdA);
    clearTimeout(timeoutIdB);

    internals.pending.set("abc:dm", {
      discordMessageId: "m1",
      discordChannelId: "c1",
      timeoutId: timeoutIdA,
    });
    internals.pending.set("abc2:dm", {
      discordMessageId: "m2",
      discordChannelId: "c2",
      timeoutId: timeoutIdB,
    });

    await internals.handleApprovalTimeout("abc", "dm");

    expect(internals.pending.has("abc:dm")).toBe(false);
    expect(internals.requestCache.has("abc")).toBe(false);
    expect(internals.requestCache.has("abc2")).toBe(true);

    clearPendingTimeouts(handler);
  });
});

// ─── Delivery routing ────────────────────────────────────────────────────────

describe("DiscordExecApprovalHandler delivery routing", () => {
  beforeEach(() => {
    mockRestPost.mockClear().mockResolvedValue({ id: "mock-message", channel_id: "mock-channel" });
    mockRestPatch.mockClear().mockResolvedValue({});
    mockRestDelete.mockClear().mockResolvedValue({});
  });

  it("falls back to DM delivery when channel target has no channel id", async () => {
    const handler = createHandler({
      enabled: true,
      approvers: ["123"],
      target: "channel",
    });
    const internals = getHandlerInternals(handler);

    mockSuccessfulDmDelivery();

    const request = createRequest({ sessionKey: "agent:main:discord:dm:123" });
    await internals.handleApprovalRequested(request);

    expect(mockRestPost).toHaveBeenCalledTimes(2);
    expect(mockRestPost).toHaveBeenCalledWith(Routes.userChannels(), {
      body: { recipient_id: "123" },
    });
    expect(mockRestPost).toHaveBeenCalledWith(
      Routes.channelMessages("dm-1"),
      expect.objectContaining({
        body: expect.objectContaining({
          components: expect.any(Array),
        }),
      }),
    );

    clearPendingTimeouts(handler);
  });

  it("posts an in-channel note when target is dm and the request came from a non-DM discord conversation", async () => {
    const handler = createHandler({
      enabled: true,
      approvers: ["123"],
      target: "dm",
    });
    const internals = getHandlerInternals(handler);

    mockSuccessfulDmDelivery({
      noteChannelId: "999888777",
      expectedNoteText: "I sent the allowed approvers DMs",
      throwOnUnexpectedRoute: true,
    });

    await internals.handleApprovalRequested(createRequest());

    expect(mockRestPost).toHaveBeenCalledWith(
      Routes.channelMessages("999888777"),
      expect.objectContaining({
        body: expect.objectContaining({
          content: expect.stringContaining("I sent the allowed approvers DMs"),
        }),
      }),
    );
    expect(mockRestPost).toHaveBeenCalledWith(
      Routes.channelMessages("dm-1"),
      expect.objectContaining({
        body: expect.any(Object),
      }),
    );

    clearPendingTimeouts(handler);
  });

  it("does not post an in-channel note when the request already came from a discord DM", async () => {
    const handler = createHandler({
      enabled: true,
      approvers: ["123"],
      target: "dm",
    });
    const internals = getHandlerInternals(handler);

    mockSuccessfulDmDelivery({ throwOnUnexpectedRoute: true });

    await internals.handleApprovalRequested(
      createRequest({ sessionKey: "agent:main:discord:dm:123" }),
    );

    expect(mockRestPost).not.toHaveBeenCalledWith(
      Routes.channelMessages("999888777"),
      expect.anything(),
    );

    clearPendingTimeouts(handler);
  });
});

describe("DiscordExecApprovalHandler gateway auth resolution", () => {
  it("passes CLI URL overrides to shared gateway auth resolver", async () => {
    mockResolveGatewayConnectionAuth.mockResolvedValue({
      token: "resolved-token",
      password: "resolved-password", // pragma: allowlist secret
    });
    const handler = new DiscordExecApprovalHandler({
      token: "test-token",
      accountId: "default",
      gatewayUrl: "wss://override.example/ws",
      config: { enabled: true, approvers: ["123"] },
      cfg: { session: { store: STORE_PATH } },
    });

    await expectGatewayAuthStart({
      handler,
      expectedUrl: "wss://override.example/ws",
      expectedSource: "cli",
      expectedToken: "resolved-token",
      expectedPassword: "resolved-password", // pragma: allowlist secret
    });

    await handler.stop();
  });

  it("passes env URL overrides to shared gateway auth resolver", async () => {
    const previousGatewayUrl = process.env.OPENCLAW_GATEWAY_URL;
    try {
      process.env.OPENCLAW_GATEWAY_URL = "wss://gateway-from-env.example/ws";
      const handler = new DiscordExecApprovalHandler({
        token: "test-token",
        accountId: "default",
        config: { enabled: true, approvers: ["123"] },
        cfg: { session: { store: STORE_PATH } },
      });

      await expectGatewayAuthStart({
        handler,
        expectedUrl: "wss://gateway-from-env.example/ws",
        expectedSource: "env",
      });

      await handler.stop();
    } finally {
      if (typeof previousGatewayUrl === "string") {
        process.env.OPENCLAW_GATEWAY_URL = previousGatewayUrl;
      } else {
        delete process.env.OPENCLAW_GATEWAY_URL;
      }
    }
  });
});
