// Discord Bot API Adapter using discord.js
// Implements MessengerAdapter interface with typing indicator support
import { Client, GatewayIntentBits, Partials, ChannelType, } from 'discord.js';
import { markdownToDiscord } from './markdown-to-discord.js';
import { splitMessage } from '../../../utils/message-split.js';
import { logger as rootLogger } from '../../../core/logger.js';
const log = rootLogger.child({ component: 'discord' });
// Discord message limit is 2000 characters
const DISCORD_MAX_LENGTH = 2000;
export class DiscordAdapter {
    name = 'discord';
    client = null;
    config = null;
    messageHandler;
    typingIntervals = new Map();
    /** L7: companion AbortController per typing thread. Lets the periodic
     *  refresh callback bail BEFORE invoking sendTyping when stop() has
     *  fired, instead of racing against client.destroy(). */
    typingAbortControllers = new Map();
    async start() {
        // Load config
        const { readFile } = await import('node:fs/promises');
        const { join } = await import('node:path');
        const { AGIM_HOME } = await import('../../../core/agim-paths.js');
        const configPath = join(AGIM_HOME, 'config.json');
        try {
            const data = await readFile(configPath, 'utf-8');
            const config = JSON.parse(data);
            this.config = config.discord;
        }
        catch {
            throw new Error('Discord config not found. Run "agim config discord" first.');
        }
        if (!this.config?.botToken) {
            throw new Error('Discord bot token not configured. Run "agim config discord" first.');
        }
        // Initialize Discord client with required intents
        this.client = new Client({
            intents: [
                GatewayIntentBits.Guilds,
                GatewayIntentBits.GuildMessages,
                GatewayIntentBits.MessageContent, // Privileged intent - must enable in Developer Portal
                GatewayIntentBits.DirectMessages, // For DM support
            ],
            partials: [
                Partials.Channel, // Required for DM support
                Partials.Message,
            ],
        });
        // Ready event
        this.client.on('ready', (client) => {
            log.info({ user: client.user.tag, guilds: client.guilds.cache.size }, `Discord bot logged in as ${client.user.tag}`);
        });
        // Set up message handler
        this.client.on('messageCreate', async (message) => {
            await this.handleDiscordMessage(message);
        });
        // Error handling
        this.client.on('error', (error) => {
            log.error({ err: error.message }, 'Discord client error');
        });
        this.client.on('warn', (warning) => {
            log.warn({ warning }, 'Discord client warning');
        });
        // Login to Discord
        await this.client.login(this.config.botToken);
        log.info('Discord adapter started');
    }
    async stop() {
        // L7: abort first (signal in-flight refresh callbacks to bail) then
        // clearInterval (prevent new ticks). Doing it in the other order would
        // leave one tick that already entered the callback racing with
        // client.destroy(); even though sendTyping errors are caught, the
        // log line is noise and the API call wastes a request.
        for (const ac of this.typingAbortControllers.values()) {
            ac.abort();
        }
        this.typingAbortControllers.clear();
        for (const interval of this.typingIntervals.values()) {
            clearInterval(interval);
        }
        this.typingIntervals.clear();
        if (this.client) {
            this.client.destroy();
            this.client = null;
        }
        log.info('Discord adapter stopped');
    }
    onMessage(handler) {
        this.messageHandler = handler;
    }
    async sendMessage(threadId, text) {
        if (!this.client) {
            throw new Error('Discord adapter not started');
        }
        const channel = await this.fetchSendableChannel(threadId);
        if (!channel) {
            throw new Error(`Channel ${threadId} not found or not text-based`);
        }
        // Convert markdown and split into chunks
        const discordText = markdownToDiscord(text);
        const chunks = splitMessage(discordText, {
            maxLength: DISCORD_MAX_LENGTH,
            addContinuationMarker: false,
        });
        for (const chunk of chunks) {
            await channel.send(chunk);
        }
    }
    async sendTyping(threadId, isTyping) {
        if (!this.client) {
            return;
        }
        if (isTyping) {
            const channel = await this.fetchSendableChannel(threadId);
            if (!channel) {
                return;
            }
            // Send initial typing indicator
            try {
                await channel.sendTyping();
            }
            catch {
                // Ignore errors during typing
            }
            // Clear any existing interval + abort signal — both per-thread.
            const existing = this.typingIntervals.get(threadId);
            if (existing)
                clearInterval(existing);
            const existingAc = this.typingAbortControllers.get(threadId);
            if (existingAc)
                existingAc.abort();
            // Discord typing indicator lasts ~10 seconds, refresh every 8 seconds.
            // L7: AbortController gates each tick — when stop() (or a subsequent
            // sendTyping(false)) aborts, in-flight callbacks bail early instead
            // of racing client.destroy().
            const ac = new AbortController();
            const interval = setInterval(async () => {
                if (ac.signal.aborted)
                    return;
                try {
                    await channel.sendTyping();
                }
                catch {
                    // Ignore errors during typing refresh
                }
            }, 8000);
            this.typingIntervals.set(threadId, interval);
            this.typingAbortControllers.set(threadId, ac);
        }
        else {
            // Clear the refresh interval + abort signal
            const interval = this.typingIntervals.get(threadId);
            if (interval) {
                clearInterval(interval);
                this.typingIntervals.delete(threadId);
            }
            const ac = this.typingAbortControllers.get(threadId);
            if (ac) {
                ac.abort();
                this.typingAbortControllers.delete(threadId);
            }
            // Note: Discord has no "cancel typing" API - it just expires naturally
        }
    }
    // ============================================
    // Internal methods
    // ============================================
    async fetchSendableChannel(threadId) {
        if (!this.client)
            return null;
        const channel = await this.client.channels.fetch(threadId).catch(() => null);
        if (!channel)
            return null;
        if (channel.type === ChannelType.GuildText || channel.type === ChannelType.DM) {
            return channel;
        }
        // Also support announcement/thread channels
        if (channel.isTextBased() && 'send' in channel) {
            return channel;
        }
        return null;
    }
    async handleDiscordMessage(message) {
        // Ignore bot messages (including our own)
        if (message.author.bot) {
            return;
        }
        // Guild whitelist filter
        if (this.config?.allowedGuilds?.length && message.guildId) {
            if (!this.config.allowedGuilds.includes(message.guildId)) {
                log.debug({ guildId: message.guildId }, 'Message from non-whitelisted guild, ignoring');
                return;
            }
        }
        // Channel whitelist filter
        if (this.config?.allowedChannels?.length) {
            if (!this.config.allowedChannels.includes(message.channelId)) {
                log.debug({ channelId: message.channelId }, 'Message from non-whitelisted channel, ignoring');
                return;
            }
        }
        // Skip empty messages (e.g. image-only, embed-only)
        if (!message.content && !message.attachments.size) {
            return;
        }
        if (!this.messageHandler) {
            log.debug('No message handler registered');
            return;
        }
        try {
            const msg = {
                id: message.id,
                threadId: message.channelId,
                userId: message.author.id,
                text: message.content || '',
                timestamp: message.createdAt,
                channelId: this.config?.channelId || 'default',
            };
            const msgCtx = {
                message: msg,
                platform: 'discord',
                channelId: this.config?.channelId || 'default',
            };
            log.debug({ threadId: msg.threadId, userId: msg.userId }, 'Calling message handler');
            await this.messageHandler(msgCtx);
            log.debug({ threadId: msg.threadId }, 'Message handler completed');
        }
        catch (err) {
            const errMsg = err instanceof Error ? err.message : String(err);
            const stack = err instanceof Error ? err.stack : undefined;
            log.error({ err: errMsg, stack }, 'Error in message handler');
        }
    }
}
// Singleton instance
export const discordAdapter = new DiscordAdapter();
//# sourceMappingURL=discord-adapter.js.map