// Discord-specific utility functions.
// Handles markdown splitting for Discord's 2000-char limit, code block escaping,
// thread message sending, and channel metadata extraction from topic tags.
import { ChannelType, GuildMember, MessageFlags, PermissionsBitField, } from 'discord.js';
import { REST, Routes } from 'discord.js';
import { discordApiUrl } from './discord-urls.js';
import { Lexer } from 'marked';
import { splitTablesFromMarkdown } from './format-tables.js';
import { getChannelDirectory, getThreadWorktree } from './database.js';
import { limitHeadingDepth } from './limit-heading-depth.js';
import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js';
import { createLogger, LogPrefix } from './logger.js';
import * as errore from 'errore';
import mime from 'mime';
import fs from 'node:fs';
import path from 'node:path';
const discordLogger = createLogger(LogPrefix.DISCORD);
/**
 * Centralized permission check for Kimaki bot access.
 * Returns true if the member has permission to use the bot:
 * - Server owner, Administrator, Manage Server, or "Kimaki" role (case-insensitive).
 * Returns false if member is null or has the "no-kimaki" role (overrides all).
 */
export function hasKimakiBotPermission(member, guild) {
    if (!member) {
        return false;
    }
    const hasNoKimakiRole = hasRoleByName(member, 'no-kimaki', guild);
    if (hasNoKimakiRole) {
        return false;
    }
    const memberPermissions = member instanceof GuildMember
        ? member.permissions
        : new PermissionsBitField(BigInt(member.permissions));
    const ownerId = member instanceof GuildMember ? member.guild.ownerId : guild?.ownerId;
    const memberId = member instanceof GuildMember ? member.id : member.user.id;
    const isOwner = ownerId ? memberId === ownerId : false;
    const isAdmin = memberPermissions.has(PermissionsBitField.Flags.Administrator);
    const canManageServer = memberPermissions.has(PermissionsBitField.Flags.ManageGuild);
    const hasKimakiRole = hasRoleByName(member, 'kimaki', guild);
    return isOwner || isAdmin || canManageServer || hasKimakiRole;
}
function hasRoleByName(member, roleName, guild) {
    const target = roleName.toLowerCase();
    if (member instanceof GuildMember) {
        return member.roles.cache.some((role) => role.name.toLowerCase() === target);
    }
    if (!guild) {
        return false;
    }
    const roleIds = Array.isArray(member.roles) ? member.roles : [];
    for (const roleId of roleIds) {
        const role = guild.roles.cache.get(roleId);
        if (role?.name.toLowerCase() === target) {
            return true;
        }
    }
    return false;
}
/**
 * Check if the member has the "no-kimaki" role that blocks bot access.
 * Separate from hasKimakiBotPermission so callers can show a specific error message.
 */
export function hasNoKimakiRole(member) {
    if (!member?.roles?.cache) {
        return false;
    }
    return member.roles.cache.some((role) => role.name.toLowerCase() === 'no-kimaki');
}
/**
 * React to a thread's starter message with an emoji.
 * Thread ID equals the starter message ID in Discord.
 */
export async function reactToThread({ rest, threadId, channelId, emoji, }) {
    const parentChannelId = await (async () => {
        if (channelId) {
            return channelId;
        }
        // Fetch the thread to get its parent channel ID
        const threadResult = await errore.tryAsync(() => {
            return rest.get(Routes.channel(threadId));
        });
        if (threadResult instanceof Error) {
            discordLogger.warn(`Failed to fetch thread ${threadId}:`, threadResult.message);
            return null;
        }
        return threadResult.parent_id || null;
    })();
    if (!parentChannelId) {
        discordLogger.warn(`Could not resolve parent channel for thread ${threadId}`);
        return;
    }
    // React to the thread starter message in the parent channel.
    // Thread ID equals the starter message ID for threads created from messages.
    const result = await errore.tryAsync(() => {
        return rest.put(Routes.channelMessageOwnReaction(parentChannelId, threadId, encodeURIComponent(emoji)));
    });
    if (result instanceof Error) {
        discordLogger.warn(`Failed to react to thread ${threadId} with ${emoji}:`, result.message);
    }
}
export async function archiveThread({ rest, threadId, parentChannelId, sessionId, client, archiveDelay = 0, }) {
    await reactToThread({
        rest,
        threadId,
        channelId: parentChannelId,
        emoji: '📁',
    });
    if (client && sessionId) {
        const updateResult = await errore.tryAsync({
            try: async () => {
                const sessionResponse = await client.session.get({
                    sessionID: sessionId,
                });
                if (!sessionResponse.data) {
                    return;
                }
                const currentTitle = sessionResponse.data.title || '';
                const newTitle = currentTitle.startsWith('📁')
                    ? currentTitle
                    : `📁 ${currentTitle}`.trim();
                await client.session.update({
                    sessionID: sessionId,
                    title: newTitle,
                });
            },
            catch: (e) => new Error('Failed to update session title', { cause: e }),
        });
        if (updateResult instanceof Error) {
            discordLogger.warn(`[archive-thread] ${updateResult.message}`);
        }
        const abortResult = await errore.tryAsync({
            try: async () => {
                await client.session.abort({ sessionID: sessionId });
            },
            catch: (e) => new Error('Failed to abort session', { cause: e }),
        });
        if (abortResult instanceof Error) {
            discordLogger.warn(`[archive-thread] ${abortResult.message}`);
        }
    }
    if (archiveDelay > 0) {
        await new Promise((resolve) => {
            setTimeout(() => {
                resolve();
            }, archiveDelay);
        });
    }
    await rest.patch(Routes.channel(threadId), {
        body: { archived: true },
    });
}
/** Remove Discord mentions from text so they don't appear in thread titles */
export function stripMentions(text) {
    return text
        .replace(/<@!?\d+>/g, '') // user mentions
        .replace(/<@&\d+>/g, '') // role mentions
        .replace(/<#\d+>/g, '') // channel mentions
        .replace(/\s+/g, ' ')
        .trim();
}
export const SILENT_MESSAGE_FLAGS = 4 | 4096;
// Same as SILENT but without SuppressNotifications - triggers badge/notification
export const NOTIFY_MESSAGE_FLAGS = 4;
export function escapeBackticksInCodeBlocks(markdown) {
    const lexer = new Lexer();
    const tokens = lexer.lex(markdown);
    let result = '';
    for (const token of tokens) {
        if (token.type === 'code') {
            const escapedCode = token.text.replace(/`/g, '\\`');
            result += '```' + (token.lang || '') + '\n' + escapedCode + '\n```\n';
        }
        else {
            result += token.raw;
        }
    }
    return result;
}
export function splitMarkdownForDiscord({ content, maxLength, }) {
    if (content.length <= maxLength) {
        return [content];
    }
    const lexer = new Lexer();
    const tokens = lexer.lex(content);
    const lines = [];
    const ensureNewlineBeforeCode = () => {
        const last = lines[lines.length - 1];
        if (!last) {
            return;
        }
        if (last.text.endsWith('\n')) {
            return;
        }
        lines.push({
            text: '\n',
            inCodeBlock: false,
            lang: '',
            isOpeningFence: false,
            isClosingFence: false,
        });
    };
    for (const token of tokens) {
        if (token.type === 'code') {
            ensureNewlineBeforeCode();
            const lang = token.lang || '';
            lines.push({
                text: '```' + lang + '\n',
                inCodeBlock: false,
                lang,
                isOpeningFence: true,
                isClosingFence: false,
            });
            const codeLines = token.text.split('\n');
            for (const codeLine of codeLines) {
                lines.push({
                    text: codeLine + '\n',
                    inCodeBlock: true,
                    lang,
                    isOpeningFence: false,
                    isClosingFence: false,
                });
            }
            lines.push({
                text: '```\n',
                inCodeBlock: false,
                lang: '',
                isOpeningFence: false,
                isClosingFence: true,
            });
        }
        else {
            const rawLines = token.raw.split('\n');
            for (let i = 0; i < rawLines.length; i++) {
                const isLast = i === rawLines.length - 1;
                const text = isLast ? rawLines[i] : rawLines[i] + '\n';
                if (text) {
                    lines.push({
                        text,
                        inCodeBlock: false,
                        lang: '',
                        isOpeningFence: false,
                        isClosingFence: false,
                    });
                }
            }
        }
    }
    const chunks = [];
    let currentChunk = '';
    let currentLang = null;
    // helper to split a long line into smaller pieces at word boundaries or hard breaks
    const splitLongLine = (text, available, inCode) => {
        const pieces = [];
        let remaining = text;
        while (remaining.length > available) {
            let splitAt = available;
            // for non-code, try to split at word boundary
            if (!inCode) {
                const lastSpace = remaining.lastIndexOf(' ', available);
                if (lastSpace > available * 0.5) {
                    splitAt = lastSpace + 1;
                }
            }
            pieces.push(remaining.slice(0, splitAt));
            remaining = remaining.slice(splitAt);
        }
        if (remaining) {
            pieces.push(remaining);
        }
        return pieces;
    };
    const closingFence = '```\n';
    for (const line of lines) {
        // openingFenceSize accounts for the fence text when starting a fresh chunk
        const openingFenceSize = currentChunk.length === 0 && (line.inCodeBlock || line.isOpeningFence)
            ? ('```' + line.lang + '\n').length
            : 0;
        // When opening fence starts a fresh chunk, its size is in openingFenceSize.
        // Otherwise count it normally so the overflow check doesn't miss the fence text.
        const lineLength = line.isOpeningFence && currentChunk.length === 0 ? 0 : line.text.length;
        const activeFenceOverhead = currentLang !== null || openingFenceSize > 0 ? closingFence.length : 0;
        const wouldExceed = currentChunk.length +
            openingFenceSize +
            lineLength +
            activeFenceOverhead >
            maxLength;
        if (wouldExceed) {
            // handle case where single line is longer than maxLength
            if (line.text.length > maxLength) {
                // first, flush current chunk if any
                if (currentChunk) {
                    if (currentLang !== null) {
                        currentChunk += '```\n';
                    }
                    chunks.push(currentChunk);
                    currentChunk = '';
                }
                // calculate overhead for code block markers
                const codeBlockOverhead = line.inCodeBlock
                    ? ('```' + line.lang + '\n').length + '```\n'.length
                    : 0;
                // ensure at least 10 chars available, even if maxLength is very small
                const availablePerChunk = Math.max(10, maxLength - codeBlockOverhead - 50);
                const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock);
                for (let i = 0; i < pieces.length; i++) {
                    const piece = pieces[i];
                    if (line.inCodeBlock) {
                        chunks.push('```' + line.lang + '\n' + piece + '```\n');
                    }
                    else {
                        chunks.push(piece);
                    }
                }
                currentLang = null;
                continue;
            }
            // normal case: line fits in a chunk but current chunk would overflow
            if (currentChunk) {
                if (currentLang !== null) {
                    currentChunk += '```\n';
                }
                chunks.push(currentChunk);
                if (line.isClosingFence && currentLang !== null) {
                    currentChunk = '';
                    currentLang = null;
                    continue;
                }
                if (line.inCodeBlock || line.isOpeningFence) {
                    const lang = line.lang;
                    currentChunk = '```' + lang + '\n';
                    if (!line.isOpeningFence) {
                        currentChunk += line.text;
                    }
                    currentLang = lang;
                }
                else {
                    currentChunk = line.text;
                    currentLang = null;
                }
            }
            else {
                // currentChunk is empty but line still exceeds - shouldn't happen after above check
                const openingFence = line.inCodeBlock || line.isOpeningFence;
                const openingFenceSize = openingFence
                    ? ('```' + line.lang + '\n').length
                    : 0;
                if (line.text.length + openingFenceSize + activeFenceOverhead >
                    maxLength) {
                    const fencedOverhead = openingFence
                        ? ('```' + line.lang + '\n').length + closingFence.length
                        : 0;
                    const availablePerChunk = Math.max(10, maxLength - fencedOverhead - 50);
                    const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock);
                    for (const piece of pieces) {
                        if (openingFence) {
                            chunks.push('```' + line.lang + '\n' + piece + closingFence);
                        }
                        else {
                            chunks.push(piece);
                        }
                    }
                    currentChunk = '';
                    currentLang = null;
                }
                else {
                    if (openingFence) {
                        currentChunk = '```' + line.lang + '\n';
                        if (!line.isOpeningFence) {
                            currentChunk += line.text;
                        }
                        currentLang = line.lang;
                    }
                    else {
                        currentChunk = line.text;
                        currentLang = null;
                    }
                }
            }
        }
        else {
            currentChunk += line.text;
            if (line.inCodeBlock || line.isOpeningFence) {
                currentLang = line.lang;
            }
            else if (line.isClosingFence) {
                currentLang = null;
            }
        }
    }
    if (currentChunk) {
        if (currentLang !== null) {
            currentChunk += closingFence;
        }
        chunks.push(currentChunk);
    }
    return chunks;
}
export async function sendThreadMessage(thread, content, options) {
    const MAX_LENGTH = 2000;
    // Split content into text and CV2 component segments (tables → Container components)
    const segments = splitTablesFromMarkdown(content);
    const baseFlags = options?.flags ?? SILENT_MESSAGE_FLAGS;
    let firstMessage;
    for (const segment of segments) {
        if (segment.type === 'components') {
            const message = await thread.send({
                components: segment.components,
                flags: MessageFlags.IsComponentsV2 | baseFlags,
            });
            if (!firstMessage) {
                firstMessage = message;
            }
            continue;
        }
        // Apply text transformations to text segments
        let text = segment.text;
        text = unnestCodeBlocksFromLists(text);
        text = limitHeadingDepth(text);
        text = escapeBackticksInCodeBlocks(text);
        if (!text.trim()) {
            continue;
        }
        const sendFlags = options?.flags ?? SILENT_MESSAGE_FLAGS;
        const chunks = splitMarkdownForDiscord({
            content: text,
            maxLength: MAX_LENGTH,
        });
        if (chunks.length > 1) {
            discordLogger.log(`MESSAGE: Splitting ${text.length} chars into ${chunks.length} messages`);
        }
        for (let chunk of chunks) {
            if (!chunk) {
                continue;
            }
            // Safety net: hard-truncate if splitting still produced an oversized chunk
            if (chunk.length > MAX_LENGTH) {
                chunk = chunk.slice(0, MAX_LENGTH - 4) + '...';
            }
            const message = await thread.send({ content: chunk, flags: sendFlags });
            if (!firstMessage) {
                firstMessage = message;
            }
        }
    }
    return firstMessage;
}
export async function resolveTextChannel(channel) {
    if (!channel) {
        return null;
    }
    if (channel.type === ChannelType.GuildText) {
        return channel;
    }
    if (channel.type === ChannelType.PublicThread ||
        channel.type === ChannelType.PrivateThread ||
        channel.type === ChannelType.AnnouncementThread) {
        const parentId = channel.parentId;
        if (parentId) {
            const parent = await channel.guild.channels.fetch(parentId);
            if (parent?.type === ChannelType.GuildText) {
                return parent;
            }
        }
    }
    return null;
}
export function escapeDiscordFormatting(text) {
    return text.replace(/```/g, '\\`\\`\\`').replace(/````/g, '\\`\\`\\`\\`');
}
export async function getKimakiMetadata(textChannel) {
    if (!textChannel) {
        return {};
    }
    const channelConfig = await getChannelDirectory(textChannel.id);
    if (!channelConfig) {
        return {};
    }
    return {
        projectDirectory: channelConfig.directory,
    };
}
/**
 * Resolve project directory from an autocomplete interaction.
 * Uses interaction.channelId (always available from raw payload) instead of
 * interaction.channel (cache-based getter, often null with gateway-proxy).
 * Checks the channel ID directly in DB, then tries thread worktree lookup,
 * then falls back to fetching the channel to resolve thread parent.
 */
export async function resolveProjectDirectoryFromAutocomplete(interaction) {
    const channelId = interaction.channelId;
    // Direct channel lookup — works when the command is run from a project text channel
    const channelConfig = await getChannelDirectory(channelId);
    if (channelConfig) {
        return channelConfig.directory;
    }
    // If we're in a thread, try worktree info first (has project_directory)
    const worktreeInfo = await getThreadWorktree(channelId);
    if (worktreeInfo?.project_directory) {
        return worktreeInfo.project_directory;
    }
    // Thread fallback: resolve parent channel ID and look up its directory.
    // Try cached channel first, then fetch if cache misses (gateway-proxy scenario).
    const cachedParentId = interaction.channel?.isThread() ? interaction.channel.parentId : null;
    if (cachedParentId) {
        const parentConfig = await getChannelDirectory(cachedParentId);
        if (parentConfig) {
            return parentConfig.directory;
        }
    }
    // Last resort: fetch the channel from Discord API to get parentId for threads
    // when the channel isn't cached at all (common with gateway-proxy).
    if (!cachedParentId) {
        const fetched = await errore.tryAsync({
            try: () => { return interaction.client.channels.fetch(channelId); },
            catch: (e) => { return e; },
        });
        if (!(fetched instanceof Error) && fetched?.isThread() && fetched.parentId) {
            const parentConfig = await getChannelDirectory(fetched.parentId);
            if (parentConfig) {
                return parentConfig.directory;
            }
        }
    }
    return undefined;
}
/**
 * Resolve the working directory for a channel or thread.
 * Returns both the base project directory (for server init) and the working directory
 * (worktree directory if in a worktree thread, otherwise same as projectDirectory).
 * This prevents commands from accidentally running in the base project dir when a
 * worktree is active — the bug that caused /diff, /compact, etc. to use wrong cwd.
 */
export async function resolveWorkingDirectory({ channel, }) {
    const isThread = [
        ChannelType.PublicThread,
        ChannelType.PrivateThread,
        ChannelType.AnnouncementThread,
    ].includes(channel.type);
    const textChannel = isThread
        ? await resolveTextChannel(channel)
        : channel;
    const metadata = await getKimakiMetadata(textChannel);
    if (!metadata.projectDirectory) {
        return undefined;
    }
    let workingDirectory = metadata.projectDirectory;
    if (isThread) {
        const worktreeInfo = await getThreadWorktree(channel.id);
        if (worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory) {
            workingDirectory = worktreeInfo.worktree_directory;
        }
    }
    return {
        projectDirectory: metadata.projectDirectory,
        workingDirectory,
    };
}
/**
 * Upload files to a Discord thread/channel in a single message.
 * Sending all files in one message causes Discord to display images in a grid layout.
 */
export async function uploadFilesToDiscord({ threadId, botToken, files, }) {
    if (files.length === 0) {
        return;
    }
    // Build attachments array for all files
    const attachments = files.map((file, index) => ({
        id: index,
        filename: path.basename(file),
    }));
    const formData = new FormData();
    formData.append('payload_json', JSON.stringify({ attachments }));
    // Append each file with its array index, with correct MIME type for grid display
    files.forEach((file, index) => {
        const buffer = fs.readFileSync(file);
        const mimeType = mime.getType(file) || 'application/octet-stream';
        formData.append(`files[${index}]`, new Blob([buffer], { type: mimeType }), path.basename(file));
    });
    const response = await fetch(discordApiUrl(`/channels/${threadId}/messages`), {
        method: 'POST',
        headers: {
            Authorization: `Bot ${botToken}`,
        },
        body: formData,
    });
    if (!response.ok) {
        const error = await response.text();
        throw new Error(`Discord API error: ${response.status} - ${error}`);
    }
}
