/**
 * Unit tests for TelegramBot + truncateForTelegram.
 *
 * Strategy:
 *   - TelegramBot is driven by an injected ITelegramClient stub. We script
 *     `getUpdates` to return queued batches and assert that:
 *       - the offset cursor advances correctly,
 *       - replies fire through sendMessage,
 *       - errors trigger backoff via the injected sleep,
 *       - aborted shutdown exits cleanly without sending residual messages,
 *       - invalid-token (401) stops the loop without burning retries.
 *   - The `sleep` injection turns the polling backoff into a counter we can
 *     drain synchronously inside the test loop.
 */

import { TelegramBot } from '../src/integrations/telegram/telegramBot';
import {
    TelegramClientError,
    truncateForTelegram,
    type ITelegramClient,
    type GetUpdatesOptions,
    type SendMessageOptions,
} from '../src/integrations/telegram/telegramClient';
import type { TelegramMessage, TelegramUpdate, TelegramUser } from '../src/integrations/telegram/types';

class StubClient implements ITelegramClient {
    public sent: SendMessageOptions[] = [];
    public getUpdatesCalls: GetUpdatesOptions[] = [];
    private _queue: Array<TelegramUpdate[] | Error> = [];
    private _onDrain?: () => void;
    private _waiters: Array<() => void> = [];
    public failSendOnce: Error | null = null;

    constructor(public meValue: TelegramUser = { id: 1, is_bot: true, first_name: 'TestBot' }) {}

    queueBatch(updates: TelegramUpdate[]) {
        this._queue.push(updates);
        this._wakeWaiters();
    }
    queueError(err: Error) {
        this._queue.push(err);
        this._wakeWaiters();
    }
    onceDrained(cb: () => void) { this._onDrain = cb; }

    private _wakeWaiters() {
        const w = this._waiters.splice(0);
        for (const fn of w) fn();
    }

    async getMe(): Promise<TelegramUser> { return this.meValue; }

    async getUpdates(opts: GetUpdatesOptions): Promise<TelegramUpdate[]> {
        this.getUpdatesCalls.push(opts);
        if (this._queue.length === 0) {
            this._onDrain?.();
            await new Promise<void>((resolve, reject) => {
                const onAbort = () => reject(new TelegramClientError('aborted', 'aborted'));
                opts.signal?.addEventListener('abort', onAbort);
                this._waiters.push(() => {
                    opts.signal?.removeEventListener('abort', onAbort);
                    resolve();
                });
            });
            if (this._queue.length === 0) return [];
        }
        const next = this._queue.shift()!;
        if (next instanceof Error) throw next;
        return next;
    }

    async sendMessage(opts: SendMessageOptions): Promise<TelegramMessage> {
        if (this.failSendOnce) {
            const err = this.failSendOnce;
            this.failSendOnce = null;
            throw err;
        }
        this.sent.push(opts);
        return {
            message_id: 1,
            date: 0,
            chat: { id: opts.chatId, type: 'private' },
            text: opts.text,
        };
    }
}

function update(id: number, chatId: number, text: string): TelegramUpdate {
    return {
        update_id: id,
        message: { message_id: id, date: 0, chat: { id: chatId, type: 'private' }, text },
    };
}

const flush = () => new Promise<void>((r) => setImmediate(r));

describe('truncateForTelegram', () => {
    test('passes through short text', () => {
        expect(truncateForTelegram('hello')).toBe('hello');
    });

    test('truncates over 4096 chars with ellipsis', () => {
        const long = 'x'.repeat(5000);
        const out = truncateForTelegram(long);
        expect(out.length).toBeLessThanOrEqual(4096);
        expect(out.endsWith('(truncated)')).toBe(true);
    });

    test('handles non-string defensively', () => {
        expect(truncateForTelegram(undefined as any)).toBe('');
    });
});

describe('TelegramBot', () => {
    let client: StubClient;
    let handled: Array<{ text: string; chatId: number }>;
    let bot: TelegramBot;

    beforeEach(() => {
        client = new StubClient();
        handled = [];
    });

    afterEach(async () => {
        if (bot) await bot.stop();
    });

    test('start is idempotent', () => {
        bot = new TelegramBot({ client, handle: async () => null });
        bot.start();
        expect(bot.isRunning()).toBe(true);
        bot.start();
        expect(bot.isRunning()).toBe(true);
    });

    test('processes updates and advances offset', async () => {
        bot = new TelegramBot({
            client,
            handle: async (text, chatId) => { handled.push({ text, chatId }); return `echo: ${text}`; },
            sleep: () => Promise.resolve(),
        });
        client.queueBatch([update(101, 999, 'hi'), update(102, 999, 'second')]);
        client.onceDrained(() => { /* now hanging, safe to stop */ void bot.stop(); });

        bot.start();
        await new Promise<void>((r) => setTimeout(r, 30));
        await bot.stop();

        expect(handled).toEqual([
            { text: 'hi', chatId: 999 },
            { text: 'second', chatId: 999 },
        ]);
        expect(client.sent.map(s => s.text)).toEqual(['echo: hi', 'echo: second']);
        // Second poll uses offset = 103 (lastId + 1)
        const offsets = client.getUpdatesCalls.map(c => c.offset);
        expect(offsets[1]).toBe(103);
    });

    test('skips reply when handler returns null', async () => {
        bot = new TelegramBot({
            client,
            handle: async () => null,
            sleep: () => Promise.resolve(),
        });
        client.queueBatch([update(1, 1, 'ignored')]);
        client.onceDrained(() => void bot.stop());

        bot.start();
        await new Promise<void>((r) => setTimeout(r, 20));
        await bot.stop();

        expect(client.sent).toEqual([]);
    });

    test('handler exception is converted to a reply (loop survives)', async () => {
        bot = new TelegramBot({
            client,
            handle: async () => { throw new Error('boom'); },
            sleep: () => Promise.resolve(),
        });
        client.queueBatch([update(1, 1, 'hi')]);
        client.onceDrained(() => void bot.stop());

        bot.start();
        await new Promise<void>((r) => setTimeout(r, 20));
        await bot.stop();

        expect(client.sent).toHaveLength(1);
        expect(client.sent[0].text).toContain('boom');
    });

    test('network error triggers backoff and retries', async () => {
        const sleeps: number[] = [];
        bot = new TelegramBot({
            client,
            handle: async () => null,
            initialBackoffMs: 100,
            maxBackoffMs: 800,
            sleep: async (ms) => { sleeps.push(ms); },
        });
        client.queueError(new TelegramClientError('network', 'temp net'));
        client.queueError(new TelegramClientError('network', 'temp net'));
        client.queueBatch([update(1, 1, 'after-recovery')]);
        client.onceDrained(() => void bot.stop());

        bot.start();
        await new Promise<void>((r) => setTimeout(r, 30));
        await bot.stop();

        // First two failures should produce 100ms then 200ms (exponential, capped at 800).
        expect(sleeps.length).toBeGreaterThanOrEqual(2);
        expect(sleeps[0]).toBe(100);
        expect(sleeps[1]).toBe(200);
        // Loop continued past errors and eventually saw the success batch.
        expect(client.getUpdatesCalls.length).toBeGreaterThanOrEqual(3);
    });

    test('401 invalid-token stops loop without retries', async () => {
        const sleeps: number[] = [];
        bot = new TelegramBot({
            client,
            handle: async () => null,
            sleep: async (ms) => { sleeps.push(ms); },
        });
        client.queueError(new TelegramClientError('api', 'unauthorized', 401));

        bot.start();
        await new Promise<void>((r) => setTimeout(r, 20));
        // Bot should have stopped itself.
        expect(bot.isRunning()).toBe(false);
        expect(sleeps).toEqual([]);
    });

    test('aborted exits cleanly without backoff', async () => {
        const sleeps: number[] = [];
        bot = new TelegramBot({
            client,
            handle: async () => null,
            sleep: async (ms) => { sleeps.push(ms); },
        });
        client.onceDrained(() => void bot.stop());

        bot.start();
        await new Promise<void>((r) => setTimeout(r, 20));
        await bot.stop();
        expect(sleeps).toEqual([]); // no backoff on graceful abort
    });

    test('reply send failure is logged but loop continues', async () => {
        bot = new TelegramBot({
            client,
            handle: async () => 'reply',
            sleep: () => Promise.resolve(),
        });
        client.failSendOnce = new Error('send failed');
        client.queueBatch([update(1, 1, 'first'), update(2, 1, 'second')]);
        client.onceDrained(() => void bot.stop());

        bot.start();
        await new Promise<void>((r) => setTimeout(r, 30));
        await bot.stop();

        // First send failed, second still attempted.
        expect(client.sent).toHaveLength(1);
        expect(client.sent[0].text).toBe('reply');
    });

    test('enrollNextChat captures the next message and skips the AI handler', async () => {
        bot = new TelegramBot({
            client,
            handle: async (text, chatId) => { handled.push({ text, chatId }); return 'should-not-fire'; },
            sleep: () => Promise.resolve(),
        });
        client.queueBatch([
            {
                update_id: 50,
                message: {
                    message_id: 1, date: 0,
                    chat: { id: 777, type: 'private' },
                    from: { id: 777, is_bot: false, first_name: 'Alice', username: 'alice' },
                    text: 'hello',
                },
            },
        ]);
        client.onceDrained(() => void bot.stop());
        bot.start();
        const captured = await bot.enrollNextChat(5000);
        await bot.stop();

        expect(captured.chatId).toBe(777);
        expect(captured.username).toBe('alice');
        expect(captured.firstName).toBe('Alice');
        // Normal handler must NOT have fired for the captured update.
        expect(handled).toEqual([]);
        // Bot should have sent the enrollment ack (not a real reply).
        expect(client.sent).toHaveLength(1);
        expect(client.sent[0].text).toContain('등록');
    });

    test('enrollNextChat times out cleanly when no message arrives', async () => {
        bot = new TelegramBot({
            client,
            handle: async () => null,
            sleep: () => Promise.resolve(),
        });
        bot.start();
        await expect(bot.enrollNextChat(50)).rejects.toThrow(/within|received/i);
        await bot.stop();
    });

    test('a second enrollNextChat supersedes the first', async () => {
        bot = new TelegramBot({
            client,
            handle: async () => null,
            sleep: () => Promise.resolve(),
        });
        bot.start();
        const first = bot.enrollNextChat(60_000);
        // Eat the unhandled rejection by attaching a handler immediately.
        const firstFailed = first.catch((e) => e.message);
        const secondP = bot.enrollNextChat(60_000);
        client.queueBatch([
            { update_id: 1, message: { message_id: 1, date: 0, chat: { id: 5, type: 'private' }, text: 'hi' } },
        ]);
        client.onceDrained(() => void bot.stop());
        const second = await secondP;
        await bot.stop();
        expect(second.chatId).toBe(5);
        await expect(firstFailed).resolves.toMatch(/superseded/i);
    });

    test('cancelEnrollment rejects the pending promise', async () => {
        bot = new TelegramBot({
            client,
            handle: async () => null,
            sleep: () => Promise.resolve(),
        });
        bot.start();
        const p = bot.enrollNextChat(60_000);
        bot.cancelEnrollment();
        await expect(p).rejects.toThrow(/cancel/i);
        await bot.stop();
    });

    test('updates without text or chatId are ignored', async () => {
        bot = new TelegramBot({
            client,
            handle: async (text, chatId) => { handled.push({ text, chatId }); return null; },
            sleep: () => Promise.resolve(),
        });
        const malformed: TelegramUpdate = { update_id: 1, message: { message_id: 1, date: 0, chat: { id: 0, type: 'private' } } };
        const noChat: TelegramUpdate = { update_id: 2, message: { message_id: 2, date: 0, chat: undefined as any, text: 'hi' } };
        const valid = update(3, 99, 'real');
        client.queueBatch([malformed, noChat, valid]);
        client.onceDrained(() => void bot.stop());

        bot.start();
        await new Promise<void>((r) => setTimeout(r, 20));
        await bot.stop();

        expect(handled).toEqual([{ text: 'real', chatId: 99 }]);
    });
});
