/**
 * Slack Socket Mode ingestor.
 *
 * Connects to Slack via Socket Mode WebSocket and streams real-time events
 * (messages, slash commands, interactions, etc.) into the ring buffer.
 *
 * Implements the Socket Mode lifecycle:
 *   1. POST apps.connections.open (app-level token) → get WebSocket URL
 *   2. Connect to WebSocket → receive `hello`
 *   3. Receive envelopes → acknowledge → buffer events
 *   4. Handle `disconnect` messages → reconnect
 *
 * Uses the native WebSocket API (Node 22+) and native fetch.
 *
 * @see https://docs.slack.dev/apis/events-api/using-socket-mode
 */
import { BaseIngestor } from '../base-ingestor.js';
import { registerIngestorFactory } from '../registry.js';
import { extractSlackEventType, extractSlackChannelId, extractSlackUserId, } from './types.js';
import { createLogger } from '../../../shared/logger.js';
const log = createLogger('slack-sm');
// ── Slack Socket Mode ingestor ──────────────────────────────────────────
export class SlackSocketModeIngestor extends BaseIngestor {
    wsConfig;
    ws = null;
    reconnectAttempts = 0;
    maxReconnectAttempts = 10;
    reconnectTimer = null;
    /** The URL for the apps.connections.open endpoint. */
    connectUrl;
    eventFilter;
    channelIds;
    userIds;
    constructor(connectionAlias, secrets, wsConfig, bufferSize, instanceId) {
        super(connectionAlias, 'websocket', secrets, bufferSize, instanceId);
        this.wsConfig = wsConfig;
        this.connectUrl = wsConfig.gatewayUrl;
        this.eventFilter = wsConfig.eventFilter ?? [];
        this.channelIds = new Set(wsConfig.channelIds ?? []);
        this.userIds = new Set(wsConfig.userIds ?? []);
    }
    async start() {
        this.state = 'starting';
        await this.openConnection();
    }
    stop(_permanent) {
        this.state = 'stopped';
        this.clearReconnectTimer();
        if (this.ws) {
            const ws = this.ws;
            this.ws = null;
            // Wait for the WebSocket to fully close before resolving.
            // This prevents a race where the old connection is still alive when a new
            // ingestor starts, causing Slack to distribute events to the dying
            // connection (where nobody processes them).
            return new Promise((resolve) => {
                const onClose = () => {
                    ws.removeEventListener('close', onClose);
                    resolve();
                };
                if (ws.readyState === WebSocket.CLOSED) {
                    resolve();
                }
                else {
                    ws.addEventListener('close', onClose);
                    ws.close(1000, 'Shutting down');
                    // Safety timeout — don't block forever if close event never fires
                    setTimeout(() => {
                        ws.removeEventListener('close', onClose);
                        resolve();
                    }, 5000);
                }
            });
        }
        return Promise.resolve();
    }
    // ── Connection establishment ────────────────────────────────────────
    /**
     * Call apps.connections.open to get a dynamic WebSocket URL, then connect.
     */
    async openConnection() {
        const appToken = this.secrets.SLACK_APP_TOKEN;
        if (!appToken) {
            this.state = 'error';
            this.errorMessage = 'SLACK_APP_TOKEN not found in resolved secrets';
            log.error(`${this.errorMessage} (${this.connectionAlias})`);
            return;
        }
        let wsUrl;
        try {
            const response = await fetch(this.connectUrl, {
                method: 'POST',
                headers: {
                    Authorization: `Bearer ${appToken}`,
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
            });
            const body = (await response.json());
            if (!body.ok || !body.url) {
                this.state = 'error';
                this.errorMessage = `apps.connections.open failed: ${body.error ?? 'no URL returned'}`;
                log.error(`${this.errorMessage} (${this.connectionAlias})`);
                return;
            }
            wsUrl = body.url;
        }
        catch (err) {
            this.state = 'error';
            this.errorMessage = `Failed to call apps.connections.open: ${err instanceof Error ? err.message : String(err)}`;
            log.error(`${this.errorMessage} (${this.connectionAlias})`);
            return;
        }
        this.connectWebSocket(wsUrl);
    }
    // ── WebSocket connection ────────────────────────────────────────────
    connectWebSocket(url) {
        try {
            this.ws = new WebSocket(url);
        }
        catch (err) {
            this.state = 'error';
            this.errorMessage = `Failed to create WebSocket: ${err instanceof Error ? err.message : String(err)}`;
            log.error(`${this.errorMessage} (${this.connectionAlias})`);
            return;
        }
        this.ws.addEventListener('open', () => {
            log.info(`WebSocket connected for ${this.connectionAlias}`);
        });
        this.ws.addEventListener('message', (event) => {
            try {
                const data = typeof event.data === 'string' ? event.data : String(event.data);
                const envelope = JSON.parse(data);
                this.handleEnvelope(envelope);
            }
            catch (err) {
                log.error(`Failed to parse Socket Mode message:`, err);
            }
        });
        this.ws.addEventListener('close', (event) => {
            log.info(`Connection closed for ${this.connectionAlias}: ${event.code} ${event.reason}`);
            if (this.state !== 'stopped') {
                this.scheduleReconnect();
            }
        });
        this.ws.addEventListener('error', () => {
            // The 'close' event always follows 'error', so we handle reconnection there.
            log.error(`WebSocket error for ${this.connectionAlias}`);
        });
    }
    // ── Envelope dispatch ──────────────────────────────────────────────
    handleEnvelope(envelope) {
        switch (envelope.type) {
            case 'hello':
                this.handleHello(envelope);
                break;
            case 'disconnect':
                this.handleDisconnect(envelope);
                break;
            case 'events_api':
            case 'slash_commands':
            case 'interactive':
                this.handleEvent(envelope);
                break;
            default:
                log.warn(`Unknown message type "${String(envelope.type)}" for ${this.connectionAlias}`);
                // Still acknowledge if there's an envelope_id
                if (envelope.envelope_id) {
                    this.acknowledge(envelope.envelope_id);
                }
                break;
        }
    }
    // ── Hello ─────────────────────────────────────────────────────────
    handleHello(hello) {
        this.state = 'connected';
        this.reconnectAttempts = 0;
        log.info(`Connected for ${this.connectionAlias} ` +
            `(app: ${hello.connection_info.app_id}, connections: ${hello.num_connections}, ` +
            `refresh in ~${hello.debug_info.approximate_connection_time}s)`);
    }
    // ── Disconnect ────────────────────────────────────────────────────
    handleDisconnect(disconnect) {
        log.info(`Disconnect for ${this.connectionAlias}: ${disconnect.reason} ` +
            `(host: ${disconnect.debug_info.host})`);
        switch (disconnect.reason) {
            case 'refresh_requested':
            case 'warning':
                // Normal refresh — reconnect immediately
                this.initiateReconnect();
                break;
            case 'link_disabled':
                // Socket Mode was disabled for the app — stop permanently
                this.state = 'error';
                this.errorMessage = 'Socket Mode was disabled for this app (link_disabled)';
                log.error(`${this.errorMessage} (${this.connectionAlias})`);
                if (this.ws) {
                    this.ws.close(1000, 'Link disabled');
                    this.ws = null;
                }
                break;
        }
    }
    // ── Event handling ────────────────────────────────────────────────
    handleEvent(envelope) {
        // Always acknowledge first — Slack expects prompt acks
        if (envelope.envelope_id) {
            this.acknowledge(envelope.envelope_id);
        }
        // Determine the specific event type for filtering and buffering
        const eventType = extractSlackEventType(envelope);
        // Apply event type filter (empty filter = capture all)
        if (this.eventFilter.length > 0 && !this.eventFilter.includes(eventType)) {
            log.debug(`${this.connectionAlias} event filtered out by eventFilter: ${eventType}`);
            return;
        }
        // Apply payload-level filters.
        // Events without the filtered field pass through (same convention as Discord).
        if (this.channelIds.size > 0) {
            const channelId = extractSlackChannelId(envelope);
            if (channelId && !this.channelIds.has(channelId))
                return;
        }
        if (this.userIds.size > 0) {
            const userId = extractSlackUserId(envelope);
            if (userId && !this.userIds.has(userId))
                return;
        }
        // Buffer the event (use envelope_id as idempotency key for retry dedup)
        const idempotencyKey = envelope.envelope_id ? `slack:${envelope.envelope_id}` : undefined;
        log.debug(`${this.connectionAlias} dispatching event: ${eventType} (envelope: ${envelope.type})`);
        this.pushEvent(eventType, envelope.payload, idempotencyKey);
    }
    // ── Acknowledgment ────────────────────────────────────────────────
    acknowledge(envelopeId) {
        this.send({ envelope_id: envelopeId });
    }
    // ── Reconnection ──────────────────────────────────────────────────
    initiateReconnect() {
        this.clearReconnectTimer();
        if (this.ws) {
            this.ws.close(1000, 'Reconnecting');
            this.ws = null;
        }
        // Reset attempts for intentional refreshes (Slack-initiated)
        this.reconnectAttempts = 0;
        this.scheduleReconnect();
    }
    scheduleReconnect() {
        if (this.state === 'stopped')
            return;
        if (this.reconnectAttempts >= this.maxReconnectAttempts) {
            this.state = 'error';
            this.errorMessage = `Max reconnect attempts (${this.maxReconnectAttempts}) exceeded`;
            log.error(`${this.errorMessage} (${this.connectionAlias})`);
            return;
        }
        this.state = 'reconnecting';
        this.reconnectAttempts++;
        const backoff = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30_000);
        log.info(`Reconnecting ${this.connectionAlias} in ${backoff}ms ` +
            `(attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
        this.reconnectTimer = setTimeout(() => {
            this.reconnectTimer = null;
            // Must re-fetch a new WebSocket URL each time — Slack URLs are single-use
            void this.openConnection();
        }, backoff);
    }
    // ── Helpers ───────────────────────────────────────────────────────
    send(data) {
        if (this.ws?.readyState === WebSocket.OPEN) {
            this.ws.send(JSON.stringify(data));
        }
    }
    clearReconnectTimer() {
        if (this.reconnectTimer) {
            clearTimeout(this.reconnectTimer);
            this.reconnectTimer = null;
        }
    }
}
// ── Self-registration ────────────────────────────────────────────────────
registerIngestorFactory('websocket:slack', (connectionAlias, config, secrets, bufferSize, instanceId) => {
    if (!config.websocket) {
        log.error(`Missing websocket config for ${connectionAlias}`);
        return null;
    }
    return new SlackSocketModeIngestor(connectionAlias, secrets, config.websocket, bufferSize, instanceId);
});
//# sourceMappingURL=socket-mode.js.map