import { inspect } from "node:util";
import { Client } from "@buape/carbon";
import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway";
import { Routes } from "discord-api-types/v10";
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import { listNativeCommandSpecsForConfig } from "../../auto-reply/commands-registry.js";
import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js";
import { mergeAllowlist, summarizeMapping } from "../../channels/allowlists/resolve-utils.js";
import { isNativeCommandsExplicitlyDisabled, resolveNativeCommandsEnabled, resolveNativeSkillsEnabled, } from "../../config/commands.js";
import { loadConfig } from "../../config/config.js";
import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { createDiscordRetryRunner } from "../../infra/retry-policy.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { resolveDiscordAccount } from "../accounts.js";
import { attachDiscordGatewayLogging } from "../gateway-logging.js";
import { getDiscordGatewayEmitter, waitForDiscordGatewayStop } from "../monitor.gateway.js";
import { fetchDiscordApplicationId } from "../probe.js";
import { resolveDiscordChannelAllowlist } from "../resolve-channels.js";
import { resolveDiscordUserAllowlist } from "../resolve-users.js";
import { normalizeDiscordToken } from "../token.js";
import { DiscordMessageListener, DiscordPresenceListener, DiscordReactionListener, DiscordReactionRemoveListener, registerDiscordListener, } from "./listeners.js";
import { createDiscordMessageHandler } from "./message-handler.js";
import { createDiscordCommandArgFallbackButton, createDiscordNativeCommand, } from "./native-command.js";
import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js";
function summarizeAllowList(list) {
    if (!list || list.length === 0)
        return "any";
    const sample = list.slice(0, 4).map((entry) => String(entry));
    const suffix = list.length > sample.length ? ` (+${list.length - sample.length})` : "";
    return `${sample.join(", ")}${suffix}`;
}
function summarizeGuilds(entries) {
    if (!entries || Object.keys(entries).length === 0)
        return "any";
    const keys = Object.keys(entries);
    const sample = keys.slice(0, 4);
    const suffix = keys.length > sample.length ? ` (+${keys.length - sample.length})` : "";
    return `${sample.join(", ")}${suffix}`;
}
async function deployDiscordCommands(params) {
    if (!params.enabled)
        return;
    const runWithRetry = createDiscordRetryRunner({ verbose: shouldLogVerbose() });
    try {
        await runWithRetry(() => params.client.handleDeployRequest(), "command deploy");
    }
    catch (err) {
        const details = formatDiscordDeployErrorDetails(err);
        params.runtime.error?.(danger(`discord: failed to deploy native commands: ${formatErrorMessage(err)}${details}`));
    }
}
function formatDiscordDeployErrorDetails(err) {
    if (!err || typeof err !== "object")
        return "";
    const status = err.status;
    const discordCode = err.discordCode;
    const rawBody = err.rawBody;
    const details = [];
    if (typeof status === "number")
        details.push(`status=${status}`);
    if (typeof discordCode === "number" || typeof discordCode === "string") {
        details.push(`code=${discordCode}`);
    }
    if (rawBody !== undefined) {
        let bodyText = "";
        try {
            bodyText = JSON.stringify(rawBody);
        }
        catch {
            bodyText =
                typeof rawBody === "string" ? rawBody : inspect(rawBody, { depth: 3, breakLength: 120 });
        }
        if (bodyText) {
            const maxLen = 800;
            const trimmed = bodyText.length > maxLen ? `${bodyText.slice(0, maxLen)}...` : bodyText;
            details.push(`body=${trimmed}`);
        }
    }
    return details.length > 0 ? ` (${details.join(", ")})` : "";
}
function resolveDiscordGatewayIntents(intentsConfig) {
    let intents = GatewayIntents.Guilds |
        GatewayIntents.GuildMessages |
        GatewayIntents.MessageContent |
        GatewayIntents.DirectMessages |
        GatewayIntents.GuildMessageReactions |
        GatewayIntents.DirectMessageReactions;
    if (intentsConfig?.presence) {
        intents |= GatewayIntents.GuildPresences;
    }
    if (intentsConfig?.guildMembers) {
        intents |= GatewayIntents.GuildMembers;
    }
    return intents;
}
export async function monitorDiscordProvider(opts = {}) {
    const cfg = opts.config ?? loadConfig();
    const account = resolveDiscordAccount({
        cfg,
        accountId: opts.accountId,
    });
    const token = normalizeDiscordToken(opts.token ?? undefined) ?? account.token;
    if (!token) {
        throw new Error(`Discord bot token missing for account "${account.accountId}" (set discord.accounts.${account.accountId}.token or DISCORD_BOT_TOKEN for default).`);
    }
    const runtime = opts.runtime ?? {
        log: console.log,
        error: console.error,
        exit: (code) => {
            throw new Error(`exit ${code}`);
        },
    };
    const discordCfg = account.config;
    const dmConfig = discordCfg.dm;
    let guildEntries = discordCfg.guilds;
    const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
    const groupPolicy = discordCfg.groupPolicy ?? defaultGroupPolicy ?? "open";
    if (discordCfg.groupPolicy === undefined &&
        discordCfg.guilds === undefined &&
        defaultGroupPolicy === undefined &&
        groupPolicy === "open") {
        runtime.log?.(warn('discord: groupPolicy defaults to "open" when channels.discord is missing; set channels.discord.groupPolicy (or channels.defaults.groupPolicy) or add channels.discord.guilds to restrict access.'));
    }
    let allowFrom = dmConfig?.allowFrom;
    const mediaMaxBytes = (opts.mediaMaxMb ?? discordCfg.mediaMaxMb ?? 8) * 1024 * 1024;
    const textLimit = resolveTextChunkLimit(cfg, "discord", account.accountId, {
        fallbackLimit: 2000,
    });
    const historyLimit = Math.max(0, opts.historyLimit ?? discordCfg.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? 20);
    const replyToMode = opts.replyToMode ?? discordCfg.replyToMode ?? "off";
    const dmEnabled = dmConfig?.enabled ?? true;
    const dmPolicy = dmConfig?.policy ?? "pairing";
    const groupDmEnabled = dmConfig?.groupEnabled ?? false;
    const groupDmChannels = dmConfig?.groupChannels;
    const nativeEnabled = resolveNativeCommandsEnabled({
        providerId: "discord",
        providerSetting: discordCfg.commands?.native,
        globalSetting: cfg.commands?.native,
    });
    const nativeSkillsEnabled = resolveNativeSkillsEnabled({
        providerId: "discord",
        providerSetting: discordCfg.commands?.nativeSkills,
        globalSetting: cfg.commands?.nativeSkills,
    });
    const nativeDisabledExplicit = isNativeCommandsExplicitlyDisabled({
        providerSetting: discordCfg.commands?.native,
        globalSetting: cfg.commands?.native,
    });
    const useAccessGroups = cfg.commands?.useAccessGroups !== false;
    const sessionPrefix = "discord:slash";
    const ephemeralDefault = true;
    if (token) {
        if (guildEntries && Object.keys(guildEntries).length > 0) {
            try {
                const entries = [];
                for (const [guildKey, guildCfg] of Object.entries(guildEntries)) {
                    if (guildKey === "*")
                        continue;
                    const channels = guildCfg?.channels ?? {};
                    const channelKeys = Object.keys(channels).filter((key) => key !== "*");
                    if (channelKeys.length === 0) {
                        const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey;
                        entries.push({ input, guildKey });
                        continue;
                    }
                    for (const channelKey of channelKeys) {
                        entries.push({
                            input: `${guildKey}/${channelKey}`,
                            guildKey,
                            channelKey,
                        });
                    }
                }
                if (entries.length > 0) {
                    const resolved = await resolveDiscordChannelAllowlist({
                        token,
                        entries: entries.map((entry) => entry.input),
                    });
                    const nextGuilds = { ...guildEntries };
                    const mapping = [];
                    const unresolved = [];
                    for (const entry of resolved) {
                        const source = entries.find((item) => item.input === entry.input);
                        if (!source)
                            continue;
                        const sourceGuild = guildEntries?.[source.guildKey] ?? {};
                        if (!entry.resolved || !entry.guildId) {
                            unresolved.push(entry.input);
                            continue;
                        }
                        mapping.push(entry.channelId
                            ? `${entry.input}→${entry.guildId}/${entry.channelId}`
                            : `${entry.input}→${entry.guildId}`);
                        const existing = nextGuilds[entry.guildId] ?? {};
                        const mergedChannels = { ...sourceGuild.channels, ...existing.channels };
                        const mergedGuild = { ...sourceGuild, ...existing, channels: mergedChannels };
                        nextGuilds[entry.guildId] = mergedGuild;
                        if (source.channelKey && entry.channelId) {
                            const sourceChannel = sourceGuild.channels?.[source.channelKey];
                            if (sourceChannel) {
                                nextGuilds[entry.guildId] = {
                                    ...mergedGuild,
                                    channels: {
                                        ...mergedChannels,
                                        [entry.channelId]: {
                                            ...sourceChannel,
                                            ...mergedChannels?.[entry.channelId],
                                        },
                                    },
                                };
                            }
                        }
                    }
                    guildEntries = nextGuilds;
                    summarizeMapping("discord channels", mapping, unresolved, runtime);
                }
            }
            catch (err) {
                runtime.log?.(`discord channel resolve failed; using config entries. ${formatErrorMessage(err)}`);
            }
        }
        const allowEntries = allowFrom?.filter((entry) => String(entry).trim() && String(entry).trim() !== "*") ?? [];
        if (allowEntries.length > 0) {
            try {
                const resolvedUsers = await resolveDiscordUserAllowlist({
                    token,
                    entries: allowEntries.map((entry) => String(entry)),
                });
                const mapping = [];
                const unresolved = [];
                const additions = [];
                for (const entry of resolvedUsers) {
                    if (entry.resolved && entry.id) {
                        mapping.push(`${entry.input}→${entry.id}`);
                        additions.push(entry.id);
                    }
                    else {
                        unresolved.push(entry.input);
                    }
                }
                allowFrom = mergeAllowlist({ existing: allowFrom, additions });
                summarizeMapping("discord users", mapping, unresolved, runtime);
            }
            catch (err) {
                runtime.log?.(`discord user resolve failed; using config entries. ${formatErrorMessage(err)}`);
            }
        }
        if (guildEntries && Object.keys(guildEntries).length > 0) {
            const userEntries = new Set();
            for (const guild of Object.values(guildEntries)) {
                if (!guild || typeof guild !== "object")
                    continue;
                const users = guild.users;
                if (Array.isArray(users)) {
                    for (const entry of users) {
                        const trimmed = String(entry).trim();
                        if (trimmed && trimmed !== "*")
                            userEntries.add(trimmed);
                    }
                }
                const channels = guild.channels ?? {};
                for (const channel of Object.values(channels)) {
                    if (!channel || typeof channel !== "object")
                        continue;
                    const channelUsers = channel.users;
                    if (!Array.isArray(channelUsers))
                        continue;
                    for (const entry of channelUsers) {
                        const trimmed = String(entry).trim();
                        if (trimmed && trimmed !== "*")
                            userEntries.add(trimmed);
                    }
                }
            }
            if (userEntries.size > 0) {
                try {
                    const resolvedUsers = await resolveDiscordUserAllowlist({
                        token,
                        entries: Array.from(userEntries),
                    });
                    const resolvedMap = new Map(resolvedUsers.map((entry) => [entry.input, entry]));
                    const mapping = resolvedUsers
                        .filter((entry) => entry.resolved && entry.id)
                        .map((entry) => `${entry.input}→${entry.id}`);
                    const unresolved = resolvedUsers
                        .filter((entry) => !entry.resolved)
                        .map((entry) => entry.input);
                    const nextGuilds = { ...guildEntries };
                    for (const [guildKey, guildConfig] of Object.entries(guildEntries ?? {})) {
                        if (!guildConfig || typeof guildConfig !== "object")
                            continue;
                        const nextGuild = { ...guildConfig };
                        const users = guildConfig.users;
                        if (Array.isArray(users) && users.length > 0) {
                            const additions = [];
                            for (const entry of users) {
                                const trimmed = String(entry).trim();
                                const resolved = resolvedMap.get(trimmed);
                                if (resolved?.resolved && resolved.id)
                                    additions.push(resolved.id);
                            }
                            nextGuild.users = mergeAllowlist({ existing: users, additions });
                        }
                        const channels = guildConfig.channels ?? {};
                        if (channels && typeof channels === "object") {
                            const nextChannels = { ...channels };
                            for (const [channelKey, channelConfig] of Object.entries(channels)) {
                                if (!channelConfig || typeof channelConfig !== "object")
                                    continue;
                                const channelUsers = channelConfig.users;
                                if (!Array.isArray(channelUsers) || channelUsers.length === 0)
                                    continue;
                                const additions = [];
                                for (const entry of channelUsers) {
                                    const trimmed = String(entry).trim();
                                    const resolved = resolvedMap.get(trimmed);
                                    if (resolved?.resolved && resolved.id)
                                        additions.push(resolved.id);
                                }
                                nextChannels[channelKey] = {
                                    ...channelConfig,
                                    users: mergeAllowlist({ existing: channelUsers, additions }),
                                };
                            }
                            nextGuild.channels = nextChannels;
                        }
                        nextGuilds[guildKey] = nextGuild;
                    }
                    guildEntries = nextGuilds;
                    summarizeMapping("discord channel users", mapping, unresolved, runtime);
                }
                catch (err) {
                    runtime.log?.(`discord channel user resolve failed; using config entries. ${formatErrorMessage(err)}`);
                }
            }
        }
    }
    if (shouldLogVerbose()) {
        logVerbose(`discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))} native=${nativeEnabled ? "on" : "off"} nativeSkills=${nativeSkillsEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"}`);
    }
    const applicationId = await fetchDiscordApplicationId(token, 4000);
    if (!applicationId) {
        throw new Error("Failed to resolve Discord application id");
    }
    const maxDiscordCommands = 100;
    let skillCommands = nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : [];
    let commandSpecs = nativeEnabled
        ? listNativeCommandSpecsForConfig(cfg, { skillCommands, provider: "discord" })
        : [];
    const initialCommandCount = commandSpecs.length;
    if (nativeEnabled && nativeSkillsEnabled && commandSpecs.length > maxDiscordCommands) {
        skillCommands = [];
        commandSpecs = listNativeCommandSpecsForConfig(cfg, { skillCommands: [], provider: "discord" });
        runtime.log?.(warn(`discord: ${initialCommandCount} commands exceeds limit; removing per-skill commands and keeping /skill.`));
    }
    if (nativeEnabled && commandSpecs.length > maxDiscordCommands) {
        runtime.log?.(warn(`discord: ${commandSpecs.length} commands exceeds limit; some commands may fail to deploy.`));
    }
    const commands = commandSpecs.map((spec) => createDiscordNativeCommand({
        command: spec,
        cfg,
        discordConfig: discordCfg,
        accountId: account.accountId,
        sessionPrefix,
        ephemeralDefault,
    }));
    // Initialize exec approvals handler if enabled
    const execApprovalsConfig = discordCfg.execApprovals ?? {};
    const execApprovalsHandler = execApprovalsConfig.enabled
        ? new DiscordExecApprovalHandler({
            token,
            accountId: account.accountId,
            config: execApprovalsConfig,
            cfg,
            runtime,
        })
        : null;
    const components = [
        createDiscordCommandArgFallbackButton({
            cfg,
            discordConfig: discordCfg,
            accountId: account.accountId,
            sessionPrefix,
        }),
    ];
    if (execApprovalsHandler) {
        components.push(createExecApprovalButton({ handler: execApprovalsHandler }));
    }
    const client = new Client({
        baseUrl: "http://localhost",
        deploySecret: "a",
        clientId: applicationId,
        publicKey: "a",
        token,
        autoDeploy: false,
    }, {
        commands,
        listeners: [],
        components,
    }, [
        new GatewayPlugin({
            reconnect: {
                maxAttempts: Number.POSITIVE_INFINITY,
            },
            intents: resolveDiscordGatewayIntents(discordCfg.intents),
            autoInteractions: true,
        }),
    ]);
    await deployDiscordCommands({ client, runtime, enabled: nativeEnabled });
    const logger = createSubsystemLogger("discord/monitor");
    const guildHistories = new Map();
    let botUserId;
    if (nativeDisabledExplicit) {
        await clearDiscordNativeCommands({
            client,
            applicationId,
            runtime,
        });
    }
    try {
        const botUser = await client.fetchUser("@me");
        botUserId = botUser?.id;
    }
    catch (err) {
        runtime.error?.(danger(`discord: failed to fetch bot identity: ${String(err)}`));
    }
    const messageHandler = createDiscordMessageHandler({
        cfg,
        discordConfig: discordCfg,
        accountId: account.accountId,
        token,
        runtime,
        botUserId,
        guildHistories,
        historyLimit,
        mediaMaxBytes,
        textLimit,
        replyToMode,
        dmEnabled,
        groupDmEnabled,
        groupDmChannels,
        allowFrom,
        guildEntries,
    });
    registerDiscordListener(client.listeners, new DiscordMessageListener(messageHandler, logger));
    registerDiscordListener(client.listeners, new DiscordReactionListener({
        cfg,
        accountId: account.accountId,
        runtime,
        botUserId,
        guildEntries,
        logger,
    }));
    registerDiscordListener(client.listeners, new DiscordReactionRemoveListener({
        cfg,
        accountId: account.accountId,
        runtime,
        botUserId,
        guildEntries,
        logger,
    }));
    if (discordCfg.intents?.presence) {
        registerDiscordListener(client.listeners, new DiscordPresenceListener({ logger, accountId: account.accountId }));
        runtime.log?.("discord: GuildPresences intent enabled — presence listener registered");
    }
    runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`);
    // Start exec approvals handler after client is ready
    if (execApprovalsHandler) {
        await execApprovalsHandler.start();
    }
    const gateway = client.getPlugin("gateway");
    const gatewayEmitter = getDiscordGatewayEmitter(gateway);
    const stopGatewayLogging = attachDiscordGatewayLogging({
        emitter: gatewayEmitter,
        runtime,
    });
    const abortSignal = opts.abortSignal;
    const onAbort = () => {
        if (!gateway)
            return;
        // Carbon emits an error when maxAttempts is 0; keep a one-shot listener to avoid
        // an unhandled error after we tear down listeners during abort.
        gatewayEmitter?.once("error", () => { });
        gateway.options.reconnect = { maxAttempts: 0 };
        gateway.disconnect();
    };
    if (abortSignal?.aborted) {
        onAbort();
    }
    else {
        abortSignal?.addEventListener("abort", onAbort, { once: true });
    }
    // Timeout to detect zombie connections where HELLO is never received.
    const HELLO_TIMEOUT_MS = 30000;
    let helloTimeoutId;
    const onGatewayDebug = (msg) => {
        const message = String(msg);
        if (!message.includes("WebSocket connection opened"))
            return;
        if (helloTimeoutId)
            clearTimeout(helloTimeoutId);
        helloTimeoutId = setTimeout(() => {
            if (!gateway?.isConnected) {
                runtime.log?.(danger(`connection stalled: no HELLO received within ${HELLO_TIMEOUT_MS}ms, forcing reconnect`));
                gateway?.disconnect();
                gateway?.connect(false);
            }
            helloTimeoutId = undefined;
        }, HELLO_TIMEOUT_MS);
    };
    gatewayEmitter?.on("debug", onGatewayDebug);
    try {
        await waitForDiscordGatewayStop({
            gateway: gateway
                ? {
                    emitter: gatewayEmitter,
                    disconnect: () => gateway.disconnect(),
                }
                : undefined,
            abortSignal,
            onGatewayError: (err) => {
                runtime.error?.(danger(`discord gateway error: ${String(err)}`));
            },
            shouldStopOnError: (err) => {
                const message = String(err);
                return (message.includes("Max reconnect attempts") || message.includes("Fatal Gateway error"));
            },
        });
    }
    finally {
        stopGatewayLogging();
        if (helloTimeoutId)
            clearTimeout(helloTimeoutId);
        gatewayEmitter?.removeListener("debug", onGatewayDebug);
        abortSignal?.removeEventListener("abort", onAbort);
        if (execApprovalsHandler) {
            await execApprovalsHandler.stop();
        }
    }
}
async function clearDiscordNativeCommands(params) {
    try {
        await params.client.rest.put(Routes.applicationCommands(params.applicationId), {
            body: [],
        });
        logVerbose("discord: cleared native commands (commands.native=false)");
    }
    catch (err) {
        params.runtime.error?.(danger(`discord: failed to clear native commands: ${String(err)}`));
    }
}
