// Slack adapter for Kimaki's platform interface.
// Owns Slack Web API message/thread operations and stores webhook-dispatched
// callback handlers for Slack messages, commands, buttons, selects, and modals.
import { WebClient } from '@slack/web-api';
import { renderSlackMessage } from './slack-markdown.js';
function normalizeReplyText(options) {
    if (typeof options === 'string') {
        return options;
    }
    return options.content || '';
}
function wrapSlackUser(user) {
    const username = user.displayName || user.username || user.id;
    return {
        id: user.id,
        username,
        displayName: username,
        globalName: user.displayName || user.username || user.id,
        bot: user.bot ?? false,
    };
}
function normalizeReactionName(emoji) {
    const trimmed = emoji.trim();
    if (trimmed.startsWith(':') && trimmed.endsWith(':')) {
        return trimmed.slice(1, -1);
    }
    if (/^[a-z0-9_+-]+$/i.test(trimmed)) {
        return trimmed;
    }
    if (trimmed === '🌳') {
        return 'deciduous_tree';
    }
    return trimmed;
}
function flattenModalValues(payload) {
    const values = {};
    for (const blockValues of Object.values(payload.view.state.values)) {
        for (const [actionId, input] of Object.entries(blockValues)) {
            const currentValues = [
                ...(input.value ? [input.value] : []),
                ...(input.selected_option?.value ? [input.selected_option.value] : []),
                ...(input.selected_options || []).map((option) => {
                    return option.value;
                }),
            ];
            values[actionId] = currentValues;
        }
    }
    return values;
}
function getModalMetadataValue({ privateMetadata, key, }) {
    if (!privateMetadata) {
        return undefined;
    }
    const params = new URLSearchParams(privateMetadata);
    return params.get(key) || undefined;
}
function wrapSlackMessage(message) {
    if (!message.ts || !message.channel) {
        throw new Error('Slack message is missing ts or channel');
    }
    return {
        id: message.ts,
        content: message.text || null,
        channelId: message.channel,
        author: {
            id: message.user || message.bot_id || 'unknown',
            username: message.username || message.user || message.bot_id || 'unknown',
            displayName: message.username || message.user || message.bot_id || 'unknown',
            globalName: message.username || message.user || message.bot_id || 'unknown',
            bot: Boolean(message.bot_id),
        },
        attachments: new Map((message.files || []).flatMap((file) => {
            if (!file.id || !file.url_private) {
                return [];
            }
            return [
                [
                    file.id,
                    {
                        url: file.url_private,
                        contentType: file.mimetype,
                        name: file.name,
                    },
                ],
            ];
        })),
        embeds: [],
    };
}
function wrapSlackThread(input) {
    return {
        id: input.threadId,
        name: input.name,
        kind: 'thread',
        type: 'thread',
        parentId: input.channelId,
        guildId: null,
        isThread() {
            return true;
        },
    };
}
function createSlackModalView({ channelId, modal, threadId, }) {
    const privateMetadata = new URLSearchParams({
        channelId,
        ...(threadId ? { threadId } : {}),
    }).toString();
    return {
        type: 'modal',
        callback_id: modal.id,
        private_metadata: privateMetadata,
        title: {
            type: 'plain_text',
            text: modal.title.slice(0, 24),
            emoji: true,
        },
        submit: {
            type: 'plain_text',
            text: 'Submit',
            emoji: true,
        },
        close: {
            type: 'plain_text',
            text: 'Cancel',
            emoji: true,
        },
        blocks: modal.inputs.map((input) => {
            if (input.type === 'text') {
                return {
                    type: 'input',
                    block_id: input.id,
                    label: {
                        type: 'plain_text',
                        text: input.label.slice(0, 200),
                        emoji: true,
                    },
                    optional: !(input.required ?? true),
                    element: {
                        type: input.style === 'paragraph' ? 'plain_text_input' : 'plain_text_input',
                        action_id: input.id,
                        multiline: input.style === 'paragraph',
                        placeholder: input.placeholder
                            ? {
                                type: 'plain_text',
                                text: input.placeholder.slice(0, 150),
                                emoji: true,
                            }
                            : undefined,
                    },
                };
            }
            return {
                type: 'input',
                block_id: input.id,
                label: {
                    type: 'plain_text',
                    text: input.label.slice(0, 200),
                    emoji: true,
                },
                optional: false,
                hint: input.description
                    ? {
                        type: 'plain_text',
                        text: input.description.slice(0, 150),
                        emoji: true,
                    }
                    : undefined,
                element: {
                    type: 'file_input',
                    action_id: input.id,
                    max_files: input.maxFiles,
                },
            };
        }),
    };
}
export class SlackAdapter {
    name = 'slack';
    admin = {
        listGuilds: async () => {
            return [];
        },
        resolveGuild: async () => {
            return null;
        },
        registerCommands: async () => {
            throw new Error('Slack adapter does not support Discord slash command registration');
        },
        ensureGuildAccessPolicy: async () => { },
        listChannels: async () => {
            return [];
        },
        createCategory: async () => {
            throw new Error('Slack adapter does not support guild category creation');
        },
        createTextChannel: async () => {
            throw new Error('Slack adapter does not support guild text channel creation');
        },
        fetchChannel: async () => {
            return null;
        },
        fetchChannelById: async () => {
            return null;
        },
        deleteChannel: async () => {
            return 'missing';
        },
        listGuildMembers: async () => {
            return [];
        },
        searchGuildMembers: async () => {
            return [];
        },
    };
    content = {
        resolveMentions: async (message) => {
            return message.content || '';
        },
        getTextAttachments: async (_message) => {
            return '';
        },
        getFileAttachments: async (_message) => {
            return [];
        },
    };
    permissions = {
        getMessageAccess: async () => {
            return 'allowed';
        },
    };
    client;
    appId = 'slack';
    botUserId;
    threadChannelIds = new Map();
    readyHandlers = [];
    messageHandlers = [];
    threadCreateHandlers = [];
    threadDeleteHandlers = [];
    commandHandlers = [];
    autocompleteHandlers = [];
    buttonHandlers = [];
    selectMenuHandlers = [];
    modalSubmitHandlers = [];
    errorHandlers = [];
    constructor({ client } = {}) {
        this.client = client || new WebClient();
    }
    async login(token) {
        this.client = new WebClient(token);
        const auth = await this.client.auth.test();
        this.botUserId = auth.user_id || undefined;
        this.appId = auth.bot_id || auth.team_id || 'slack';
        await this.emitReady();
    }
    destroy() { }
    conversation(target) {
        return {
            target,
            send: async (message) => {
                return this.postUiMessage({
                    channelId: target.channelId,
                    message,
                    threadId: target.threadId || message.replyToMessageId,
                });
            },
            update: async (messageId, message) => {
                await this.updateUiMessage({
                    channelId: target.channelId,
                    message,
                    messageId,
                });
            },
            delete: async (messageId) => {
                await this.client.chat.delete({ channel: target.channelId, ts: messageId });
            },
            message: async (messageId) => {
                const data = await this.fetchMessage(target, messageId);
                return {
                    data,
                    startThread: async ({ name, autoArchiveDuration, reason, }) => {
                        return this.createThreadFromMessage({
                            message: data,
                            name,
                            autoArchiveDuration,
                            reason,
                        });
                    },
                };
            },
            startTyping: async () => { },
        };
    }
    async channel(channelId) {
        return {
            data: {
                id: channelId,
                kind: 'text',
                type: 'text',
                parentId: null,
                guildId: null,
                isThread() {
                    return false;
                },
            },
            conversation: () => {
                return this.conversation({ channelId });
            },
        };
    }
    async thread({ threadId, parentId }) {
        const channelId = parentId || this.threadChannelIds.get(threadId);
        if (!channelId) {
            return null;
        }
        const data = wrapSlackThread({ channelId, threadId, name: threadId });
        return {
            data,
            conversation: () => {
                return this.conversation({ channelId, threadId });
            },
            message: async (messageId) => {
                return this.conversation({ channelId, threadId }).message(messageId);
            },
            starterMessage: async () => {
                return this.fetchStarterMessage(threadId);
            },
            rename: async (_name) => { },
            archive: async () => { },
            addMember: async (_userId) => { },
            addStarterReaction: async (emoji) => {
                await this.addThreadStarterReaction({ channelId, threadId }, emoji);
            },
            reference: () => {
                return `#${threadId}`;
            },
        };
    }
    rememberThread({ channelId, threadId }) {
        this.threadChannelIds.set(threadId, channelId);
    }
    async emitReady() {
        for (const handler of this.readyHandlers) {
            try {
                await handler();
            }
            catch (error) {
                this.emitError(error);
            }
        }
    }
    emitError(error) {
        const normalizedError = error instanceof Error ? error : new Error(String(error));
        for (const handler of this.errorHandlers) {
            handler(normalizedError);
        }
    }
    async postUiMessage({ channelId, message, threadId, }) {
        const rendered = renderSlackMessage(message);
        const response = await this.client.chat.postMessage({
            channel: channelId,
            text: rendered.text,
            blocks: rendered.blocks,
            thread_ts: threadId,
        });
        if (!response.ts) {
            throw new Error('Slack postMessage did not return a timestamp');
        }
        if (threadId) {
            this.rememberThread({ channelId, threadId });
        }
        return { id: response.ts };
    }
    async updateUiMessage({ channelId, message, messageId, }) {
        const rendered = renderSlackMessage(message);
        await this.client.chat.update({
            channel: channelId,
            ts: messageId,
            text: rendered.text,
            blocks: rendered.blocks,
        });
    }
    async fetchMessage(target, messageId) {
        if (target.threadId && target.threadId !== messageId) {
            const replies = await this.client.conversations.replies({
                channel: target.channelId,
                ts: target.threadId,
                inclusive: true,
                latest: messageId,
                oldest: messageId,
            });
            const message = replies.messages?.find((candidate) => {
                return candidate.ts === messageId;
            });
            if (!message) {
                throw new Error(`Slack message not found: ${messageId}`);
            }
            this.rememberThread({ channelId: target.channelId, threadId: target.threadId });
            return wrapSlackMessage(message);
        }
        const history = await this.client.conversations.history({
            channel: target.channelId,
            inclusive: true,
            latest: messageId,
            oldest: messageId,
            limit: 1,
        });
        const message = history.messages?.[0];
        if (!message) {
            throw new Error(`Slack message not found: ${messageId}`);
        }
        return wrapSlackMessage({
            ...message,
            channel: target.channelId,
        });
    }
    async startTyping(_target) { }
    async renameThread(_threadId, _name) { }
    async createThreadFromMessage(input) {
        const rootTs = input.message.id;
        const channelId = input.message.channelId;
        await this.postUiMessage({
            channelId,
            threadId: rootTs,
            message: {
                markdown: input.name,
            },
        });
        this.rememberThread({ channelId, threadId: rootTs });
        return {
            thread: wrapSlackThread({
                channelId,
                threadId: rootTs,
                name: input.name,
            }),
            target: {
                channelId,
                threadId: rootTs,
            },
        };
    }
    async createThread(input) {
        const message = await this.fetchMessage({ channelId: input.channelId }, input.messageId);
        return this.createThreadFromMessage({
            message,
            name: input.name,
            autoArchiveDuration: input.autoArchiveDuration,
            reason: input.reason,
        });
    }
    async addThreadMember(_threadId, _userId) { }
    async addThreadStarterReaction(input, emoji) {
        this.rememberThread({ channelId: input.channelId, threadId: input.threadId });
        await this.client.reactions.add({
            channel: input.channelId,
            name: normalizeReactionName(emoji),
            timestamp: input.threadId,
        });
    }
    async fetchStarterMessage(threadId) {
        const channelId = this.threadChannelIds.get(threadId);
        if (!channelId) {
            return null;
        }
        const replies = await this.client.conversations.replies({
            channel: channelId,
            ts: threadId,
            inclusive: true,
            latest: threadId,
            oldest: threadId,
        });
        const starter = replies.messages?.find((message) => {
            return message.ts === threadId;
        });
        if (!starter) {
            return null;
        }
        return wrapSlackMessage({
            ...starter,
            channel: channelId,
        });
    }
    onReady(handler) {
        this.readyHandlers.push(handler);
    }
    onMessage(handler) {
        this.messageHandlers.push(handler);
    }
    onThreadCreate(handler) {
        this.threadCreateHandlers.push(handler);
    }
    onThreadDelete(handler) {
        this.threadDeleteHandlers.push(handler);
    }
    onCommand(handler) {
        this.commandHandlers.push(handler);
    }
    onAutocomplete(handler) {
        this.autocompleteHandlers.push(handler);
    }
    onButton(handler) {
        this.buttonHandlers.push(handler);
    }
    onSelectMenu(handler) {
        this.selectMenuHandlers.push(handler);
    }
    onModalSubmit(handler) {
        this.modalSubmitHandlers.push(handler);
    }
    onError(handler) {
        this.errorHandlers.push(handler);
    }
    // TODO: Verify forwarded Slack webhook requests with the planned JWT-like
    // gateway token before dispatching any stored handlers.
    async dispatchIncomingMessage(payload) {
        if (payload.thread_ts) {
            this.rememberThread({ channelId: payload.channel, threadId: payload.thread_ts });
        }
        const target = payload.thread_ts
            ? { channelId: payload.channel, threadId: payload.thread_ts }
            : { channelId: payload.channel };
        const isMention = payload.type === 'app_mention' ||
            Boolean(this.botUserId && payload.text?.includes(`<@${this.botUserId}>`));
        const event = {
            message: wrapSlackMessage({
                ...payload,
                channel: payload.channel,
            }),
            conversation: this.conversation(target),
            thread: payload.thread_ts
                ? wrapSlackThread({
                    channelId: payload.channel,
                    threadId: payload.thread_ts,
                    name: payload.thread_ts,
                })
                : undefined,
            kind: payload.thread_ts ? 'thread' : 'channel',
            isMention,
        };
        for (const handler of this.messageHandlers) {
            try {
                await handler(event);
            }
            catch (error) {
                this.emitError(error);
            }
        }
    }
    async dispatchThreadCreate(payload) {
        this.rememberThread({ channelId: payload.channelId, threadId: payload.threadId });
        const thread = wrapSlackThread({
            channelId: payload.channelId,
            threadId: payload.threadId,
            name: payload.threadName || payload.threadId,
        });
        const threadHandle = await this.thread({
            threadId: payload.threadId,
            parentId: payload.channelId,
        });
        if (!threadHandle) {
            throw new Error(`Slack thread not found: ${payload.threadId}`);
        }
        const event = {
            thread,
            threadHandle,
            conversation: this.conversation({
                channelId: payload.channelId,
                threadId: payload.threadId,
            }),
            newlyCreated: payload.newlyCreated,
        };
        for (const handler of this.threadCreateHandlers) {
            try {
                await handler(event);
            }
            catch (error) {
                this.emitError(error);
            }
        }
    }
    dispatchThreadDelete(threadId) {
        this.threadChannelIds.delete(threadId);
        for (const handler of this.threadDeleteHandlers) {
            try {
                handler(threadId);
            }
            catch (error) {
                this.emitError(error);
            }
        }
    }
    async replyFromContext({ context, options, }) {
        await this.postUiMessage({
            channelId: context.channelId,
            threadId: context.threadId,
            message: {
                markdown: normalizeReplyText(options),
            },
        });
    }
    async editFromContext({ context, options, }) {
        if (!context.messageTs) {
            await this.replyFromContext({ context, options });
            return;
        }
        await this.client.chat.update({
            channel: context.channelId,
            ts: context.messageTs,
            text: normalizeReplyText(options),
        });
    }
    async showModalFromContext({ context, modal, }) {
        if (!context.triggerId) {
            throw new Error('Slack modal requires a trigger ID');
        }
        await this.client.views.open({
            trigger_id: context.triggerId,
            view: createSlackModalView({
                channelId: context.channelId,
                modal,
                threadId: context.threadId,
            }),
        });
    }
    createInteractionContextFromActions(payload) {
        const channelId = payload.channel?.id || payload.container?.channel_id;
        if (!channelId) {
            throw new Error('Slack block action is missing a channel ID');
        }
        const threadId = payload.message?.thread_ts || payload.container?.thread_ts;
        if (threadId) {
            this.rememberThread({ channelId, threadId });
        }
        return {
            channelId,
            messageTs: payload.message?.ts || payload.container?.message_ts,
            responseUrl: payload.response_url,
            threadId,
            triggerId: payload.trigger_id,
            user: payload.user,
        };
    }
    async dispatchBlockActions(payload) {
        const context = this.createInteractionContextFromActions(payload);
        for (const action of payload.actions) {
            const shared = {
                raw: payload,
                appId: this.appId,
                channelId: context.channelId,
                messageTs: context.messageTs,
                responseUrl: context.responseUrl,
                threadId: context.threadId,
                triggerId: context.triggerId,
                user: wrapSlackUser({
                    id: payload.user.id,
                    username: payload.user.username || payload.user.name || payload.user.id,
                    displayName: payload.user.name || payload.user.username || payload.user.id,
                }),
                reply: async (options) => {
                    await this.replyFromContext({ context, options });
                },
                replyUi: async (message) => {
                    await this.postUiMessage({
                        channelId: context.channelId,
                        threadId: context.threadId,
                        message,
                    });
                },
                editReply: async (options) => {
                    await this.editFromContext({ context, options });
                },
                editUiReply: async (message) => {
                    if (!context.messageTs) {
                        await this.postUiMessage({
                            channelId: context.channelId,
                            threadId: context.threadId,
                            message,
                        });
                        return;
                    }
                    await this.updateUiMessage({
                        channelId: context.channelId,
                        message,
                        messageId: context.messageTs,
                    });
                },
                followUp: async (options) => {
                    await this.replyFromContext({ context, options });
                },
                update: async (options) => {
                    await this.editFromContext({ context, options });
                },
                updateUi: async (message) => {
                    if (!context.messageTs) {
                        await this.postUiMessage({
                            channelId: context.channelId,
                            threadId: context.threadId,
                            message,
                        });
                        return;
                    }
                    await this.updateUiMessage({
                        channelId: context.channelId,
                        message,
                        messageId: context.messageTs,
                    });
                },
                showModal: async (modal) => {
                    await this.showModalFromContext({ context, modal });
                },
            };
            if (action.type === 'static_select' || action.type === 'multi_static_select') {
                const event = {
                    ...shared,
                    customId: action.action_id,
                    values: [
                        ...(action.selected_option?.value ? [action.selected_option.value] : []),
                        ...(action.selected_options || []).map((option) => {
                            return option.value;
                        }),
                    ],
                };
                for (const handler of this.selectMenuHandlers) {
                    try {
                        await handler(event);
                    }
                    catch (error) {
                        this.emitError(error);
                    }
                }
                continue;
            }
            const event = {
                ...shared,
                customId: action.action_id,
            };
            for (const handler of this.buttonHandlers) {
                try {
                    await handler(event);
                }
                catch (error) {
                    this.emitError(error);
                }
            }
        }
    }
    async dispatchModalSubmit(payload) {
        const values = flattenModalValues(payload);
        const channelId = getModalMetadataValue({
            privateMetadata: payload.view.private_metadata,
            key: 'channelId',
        });
        const threadId = getModalMetadataValue({
            privateMetadata: payload.view.private_metadata,
            key: 'threadId',
        });
        if (channelId && threadId) {
            this.rememberThread({ channelId, threadId });
        }
        const context = {
            channelId: channelId || payload.user.id,
            threadId,
            triggerId: payload.trigger_id,
            user: payload.user,
        };
        const event = {
            raw: payload,
            appId: this.appId,
            channelId: channelId || null,
            customId: payload.view.callback_id,
            triggerId: payload.trigger_id,
            user: wrapSlackUser({
                id: payload.user.id,
                username: payload.user.username || payload.user.name || payload.user.id,
                displayName: payload.user.name || payload.user.username || payload.user.id,
            }),
            values,
            fields: {
                getTextInputValue: (id) => {
                    return values[id]?.[0] || '';
                },
            },
            reply: async (options) => {
                await this.replyFromContext({ context, options });
            },
            replyUi: async (message) => {
                await this.postUiMessage({
                    channelId: context.channelId,
                    threadId: context.threadId,
                    message,
                });
            },
            editReply: async (options) => {
                await this.replyFromContext({ context, options });
            },
            editUiReply: async (message) => {
                await this.postUiMessage({
                    channelId: context.channelId,
                    threadId: context.threadId,
                    message,
                });
            },
        };
        for (const handler of this.modalSubmitHandlers) {
            try {
                await handler(event);
            }
            catch (error) {
                this.emitError(error);
            }
        }
    }
    async dispatchCommand(payload) {
        const context = {
            channelId: payload.channel_id,
            responseUrl: payload.response_url,
            triggerId: payload.trigger_id,
            user: {
                id: payload.user_id,
                username: payload.user_name || payload.user_id,
                name: payload.user_name || payload.user_id,
            },
        };
        const commandName = payload.command.replace(/^\//, '');
        const event = {
            raw: payload,
            appId: this.appId,
            commandName,
            channelId: payload.channel_id,
            responseUrl: payload.response_url,
            text: payload.text || '',
            triggerId: payload.trigger_id,
            user: wrapSlackUser({
                id: payload.user_id,
                username: payload.user_name || payload.user_id,
                displayName: payload.user_name || payload.user_id,
            }),
            reply: async (options) => {
                await this.replyFromContext({ context, options });
            },
            replyUi: async (message) => {
                await this.postUiMessage({
                    channelId: context.channelId,
                    message,
                });
            },
            editReply: async (options) => {
                await this.replyFromContext({ context, options });
            },
            editUiReply: async (message) => {
                await this.postUiMessage({
                    channelId: context.channelId,
                    message,
                });
            },
            showModal: async (modal) => {
                await this.showModalFromContext({ context, modal });
            },
        };
        for (const handler of this.commandHandlers) {
            try {
                await handler(event);
            }
            catch (error) {
                this.emitError(error);
            }
        }
    }
}
export async function createSlackAdapter({ client, } = {}) {
    return new SlackAdapter({ client });
}
