// Real Discord end-to-end test for Kimaki onboarding and reply flow.
// Uses a real guild + bot token, then verifies a channel message creates a
// thread and produces an assistant reply through OpenCode.
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import yaml from 'js-yaml';
import { expect, test } from 'vitest';
import { ChannelType, } from 'discord.js';
import { CachedOpencodeProviderProxy } from 'opencode-cached-provider';
import { getDataDir, setDataDir } from './config.js';
import { createDiscordClient, createProjectChannels } from './discord-bot.js';
import { closeDatabase, getBotToken, initDatabase, setBotToken, } from './database.js';
const envBotToken = process.env['KIMAKI_E2E_DISCORD_BOT_TOKEN'] || '';
const envGuildId = process.env['KIMAKI_E2E_DISCORD_GUILD_ID'] || '';
const geminiApiKey = process.env['GEMINI_API_KEY'] ||
    process.env['GOOGLE_GENERATIVE_AI_API_KEY'] ||
    '';
const geminiModel = process.env['GEMINI_FLASH_MODEL'] || 'gemini-2.5-flash';
const e2eTest = geminiApiKey.length > 0 ? test : test.skip;
function createRunDirectories() {
    const root = path.resolve(process.cwd(), 'tmp', 'kimaki-real-discord-e2e');
    const projectDirectory = path.join(root, 'project');
    fs.mkdirSync(root, { recursive: true });
    const dataDir = fs.mkdtempSync(path.join(root, 'data-'));
    const providerCacheDbPath = path.join(root, 'provider-cache.db');
    fs.mkdirSync(projectDirectory, { recursive: true });
    return {
        root,
        projectDirectory,
        dataDir,
        providerCacheDbPath,
    };
}
function chooseLockPort() {
    const now = Date.now() % 10_000;
    return 40_000 + now;
}
async function resolveBotToken() {
    if (envBotToken.length > 0) {
        return envBotToken;
    }
    const previousDataDir = getDataDir();
    const defaultDataDir = path.join(os.homedir(), '.kimaki');
    setDataDir(defaultDataDir);
    try {
        await initDatabase();
        const savedBot = await getBotToken();
        if (!savedBot?.token) {
            return '';
        }
        return savedBot.token;
    }
    finally {
        await closeDatabase().catch(() => {
            return;
        });
        setDataDir(previousDataDir);
    }
}
async function resolveGuildId({ preferredGuildId, client, }) {
    if (preferredGuildId.length > 0) {
        return preferredGuildId;
    }
    const guilds = await client.guilds.fetch();
    const firstGuild = guilds.first();
    if (!firstGuild) {
        return '';
    }
    return firstGuild.id;
}
async function waitForBotReady({ process, timeoutMs, }) {
    await new Promise((resolve, reject) => {
        const startedAt = Date.now();
        let output = '';
        const cleanup = () => {
            process.stdout.off('data', onStdout);
            process.stderr.off('data', onStderr);
            process.off('exit', onExit);
            clearInterval(interval);
        };
        const onStdout = (chunk) => {
            output += chunk.toString();
            if (output.includes('Bot ready! Listening for messages')) {
                cleanup();
                resolve();
            }
        };
        const onStderr = (chunk) => {
            output += chunk.toString();
        };
        const onExit = (code) => {
            cleanup();
            reject(new Error(`kimaki process exited before ready (code ${String(code)}): ${output.slice(-4_000)}`));
        };
        const interval = setInterval(() => {
            const elapsed = Date.now() - startedAt;
            if (elapsed < timeoutMs) {
                return;
            }
            cleanup();
            reject(new Error(`Timed out waiting for bot ready output: ${output.slice(-4_000)}`));
        }, 250);
        process.stdout.on('data', onStdout);
        process.stderr.on('data', onStderr);
        process.on('exit', onExit);
    });
}
async function stopChildProcess({ process, }) {
    if (process.killed) {
        return;
    }
    await new Promise((resolve) => {
        let resolved = false;
        const finish = () => {
            if (resolved) {
                return;
            }
            resolved = true;
            resolve();
        };
        const timeout = setTimeout(() => {
            process.kill('SIGKILL');
            finish();
        }, 5_000);
        process.once('exit', () => {
            clearTimeout(timeout);
            finish();
        });
        process.kill('SIGTERM');
    });
}
async function waitForNewThread({ channel, existingThreadIds, timeoutMs, }) {
    const startedAt = Date.now();
    while (Date.now() - startedAt < timeoutMs) {
        const activeThreads = await channel.threads.fetchActive();
        const created = activeThreads.threads.find((thread) => {
            return !existingThreadIds.has(thread.id);
        });
        if (created) {
            return created;
        }
        await new Promise((resolve) => {
            setTimeout(resolve, 1_000);
        });
    }
    throw new Error('Timed out waiting for a new thread');
}
async function waitForAssistantReply({ thread, starterMessageId, botUserId, timeoutMs, }) {
    const startedAt = Date.now();
    while (Date.now() - startedAt < timeoutMs) {
        const messages = await thread.messages.fetch({ limit: 50 });
        const reply = messages.find((message) => {
            if (message.id === starterMessageId) {
                return false;
            }
            if (message.author.id !== botUserId) {
                return false;
            }
            if (!message.content || message.content.trim().length === 0) {
                return false;
            }
            return true;
        });
        if (reply) {
            return reply;
        }
        await new Promise((resolve) => {
            setTimeout(resolve, 1_000);
        });
    }
    throw new Error('Timed out waiting for assistant reply in thread');
}
e2eTest('onboards project, then channel message creates thread and reply', async () => {
    const directories = createRunDirectories();
    const lockPort = chooseLockPort();
    const botToken = await resolveBotToken();
    if (botToken.length === 0) {
        throw new Error('Missing bot token. Set KIMAKI_E2E_DISCORD_BOT_TOKEN or save bot credentials via kimaki onboarding first.');
    }
    const proxy = new CachedOpencodeProviderProxy({
        cacheDbPath: directories.providerCacheDbPath,
        targetBaseUrl: 'https://generativelanguage.googleapis.com/v1beta',
        apiKey: geminiApiKey,
        cacheMethods: ['POST'],
    });
    setDataDir(directories.dataDir);
    await initDatabase();
    let setupClient = null;
    let textChannel = null;
    let botProcess = null;
    try {
        await proxy.start();
        const opencodeConfig = proxy.buildOpencodeConfig({
            providerName: 'cached-google',
            providerNpm: '@ai-sdk/google',
            model: geminiModel,
            smallModel: geminiModel,
        });
        fs.writeFileSync(path.join(directories.projectDirectory, 'opencode.json'), JSON.stringify(opencodeConfig, null, 2));
        setupClient = await createDiscordClient();
        await setupClient.login(botToken);
        const app = await setupClient.application?.fetch();
        const appId = app?.id;
        if (!appId) {
            throw new Error('Failed to resolve Discord application ID from bot token');
        }
        const guildId = await resolveGuildId({
            preferredGuildId: envGuildId,
            client: setupClient,
        });
        if (!guildId) {
            throw new Error('Missing guild id. Set KIMAKI_E2E_DISCORD_GUILD_ID or add the bot to at least one guild.');
        }
        await setBotToken(appId, botToken);
        const guild = await setupClient.guilds.fetch(guildId);
        const onboardingResult = await createProjectChannels({
            guild,
            projectDirectory: directories.projectDirectory,
            appId,
        });
        const fetchedChannel = await setupClient.channels.fetch(onboardingResult.textChannelId);
        if (!fetchedChannel || fetchedChannel.type !== ChannelType.GuildText) {
            throw new Error('Failed to fetch created onboarding text channel');
        }
        textChannel = fetchedChannel;
        await closeDatabase();
        botProcess = spawn('pnpm', ['tsx', 'src/cli.ts', '--data-dir', directories.dataDir], {
            cwd: process.cwd(),
            env: {
                ...process.env,
                KIMAKI_LOCK_PORT: lockPort.toString(),
            },
            stdio: 'pipe',
        });
        await waitForBotReady({ process: botProcess, timeoutMs: 120_000 });
        const activeThreadsBefore = await textChannel.threads.fetchActive();
        const existingThreadIds = new Set(activeThreadsBefore.threads.map((thread) => {
            return thread.id;
        }));
        const marker = {
            cliThreadPrompt: true,
            username: 'kimaki-e2e',
            userId: 'kimaki-e2e-user',
        };
        const starterMessage = await textChannel.send({
            content: 'Reply with exactly: kimaki e2e ok',
            embeds: [
                {
                    color: 0x5865f2,
                    footer: {
                        text: yaml.dump(marker).trim(),
                    },
                },
            ],
        });
        const createdThread = await waitForNewThread({
            channel: textChannel,
            existingThreadIds,
            timeoutMs: 60_000,
        });
        const botUserId = setupClient.user?.id;
        if (!botUserId) {
            throw new Error('Missing bot user id from setup client');
        }
        const replyMessage = await waitForAssistantReply({
            thread: createdThread,
            starterMessageId: starterMessage.id,
            botUserId,
            timeoutMs: 120_000,
        });
        expect(createdThread.id.length).toBeGreaterThan(0);
        expect(replyMessage.content.trim().length).toBeGreaterThan(0);
    }
    finally {
        if (botProcess) {
            await stopChildProcess({ process: botProcess });
        }
        if (textChannel) {
            await textChannel.delete('kimaki real discord e2e cleanup').catch(() => {
                return;
            });
        }
        if (setupClient) {
            setupClient.destroy();
        }
        await proxy.stop();
        await closeDatabase().catch(() => {
            return;
        });
        fs.rmSync(directories.dataDir, { recursive: true, force: true });
    }
}, 360_000);
