// OutboxService.notifyChannelDead lazy-imports @/lib/prisma and queries
// prisma.user.findMany when a permanent failure fires the meta-alert.
// Without this mock the test reaches a real prisma client which, in CI
// without a DB, fails async after teardown and trips jest's
// "Cannot log after tests are done" → exit 1.
jest.mock('@/lib/prisma', () => ({
    prisma: {
        user: { findMany: jest.fn().mockResolvedValue([]) },
        userNotification: { create: jest.fn() },
    },
}));

import { OutboxService } from '../outbox.service';
import type { OutboxRepository } from '../outbox.repository';
import type { OutboxEntry, OutboxInput, DeliveryResult } from '../types';

function makeEntry(overrides: Partial<OutboxEntry> = {}): OutboxEntry {
    return {
        id: 1,
        eventKey: 'monitor:1:down',
        eventType: 'monitor_down',
        channelType: 'webhook',
        channelId: 10,
        payload: JSON.stringify({ message: 'hi', config: { webhookUrl: 'https://example' } }),
        attempts: 1,
        maxAttempts: 3,
        lastError: null,
        nextAttemptAt: new Date('2026-05-12T10:00:00Z'),
        deliveredAt: null,
        failedAt: null,
        createdAt: new Date('2026-05-12T09:59:00Z'),
        ...overrides,
    };
}

class FakeRepo implements OutboxRepository {
    public created: OutboxInput[] = [];
    public delivered: number[] = [];
    public failedAttempts: Array<{ id: number; error: string; nextAttemptAt: Date }> = [];
    public permanentFailures: Array<{ id: number; error: string }> = [];
    public claimQueues: OutboxEntry[][] = [];

    constructor(claimQueues: OutboxEntry[][] = []) {
        this.claimQueues = claimQueues;
    }

    async create(input: OutboxInput): Promise<OutboxEntry> {
        this.created.push(input);
        return makeEntry({ id: this.created.length, ...input } as Partial<OutboxEntry>);
    }
    async claimDue(): Promise<OutboxEntry[]> {
        return this.claimQueues.shift() ?? [];
    }
    async markDelivered(id: number): Promise<void> {
        this.delivered.push(id);
    }
    async markFailedAttempt(id: number, error: string, nextAttemptAt: Date): Promise<void> {
        this.failedAttempts.push({ id, error, nextAttemptAt });
    }
    async markPermanentFailure(id: number, error: string): Promise<void> {
        this.permanentFailures.push({ id, error });
    }
}

describe('OutboxService', () => {
    describe('enqueue', () => {
        it('persists the row with default maxAttempts and nextAttemptAt = now', async () => {
            const repo = new FakeRepo();
            const fixedNow = new Date('2026-05-12T15:00:00Z');
            const svc = new OutboxService({ repository: repo, now: () => fixedNow });

            await svc.enqueue({
                eventKey: 'monitor:5:down',
                eventType: 'monitor_down',
                channelType: 'webhook',
                channelId: 7,
                payload: 'payload',
            });

            expect(repo.created).toHaveLength(1);
            // P1-11: default max attempts is now 5 across an extended schedule.
            expect(repo.created[0].maxAttempts).toBe(5);
            expect(repo.created[0].nextAttemptAt).toEqual(fixedNow);
        });

        it('respects caller-supplied maxAttempts and nextAttemptAt', async () => {
            const repo = new FakeRepo();
            const svc = new OutboxService({ repository: repo, now: () => new Date('2026-05-12T15:00:00Z') });
            const future = new Date('2026-06-01T00:00:00Z');

            await svc.enqueue({
                eventKey: 'k',
                eventType: 'monitor_down',
                channelType: 'telegram',
                channelId: null,
                payload: 'p',
                maxAttempts: 7,
                nextAttemptAt: future,
            });

            expect(repo.created[0].maxAttempts).toBe(7);
            expect(repo.created[0].nextAttemptAt).toEqual(future);
        });
    });

    describe('tick', () => {
        it('marks successfully delivered entries and returns a count', async () => {
            const repo = new FakeRepo([[makeEntry({ id: 42 })]]);
            const deliver = jest.fn<Promise<DeliveryResult>, [OutboxEntry]>().mockResolvedValue({ ok: true });
            const svc = new OutboxService({ repository: repo, deliver, now: () => new Date('2026-05-12T15:00:00Z') });

            const result = await svc.tick(10);

            expect(deliver).toHaveBeenCalledTimes(1);
            expect(repo.delivered).toEqual([42]);
            expect(result).toEqual({ claimed: 1, delivered: 1, retried: 0, permanentlyFailed: 0 });
        });

        it('schedules a retry when delivery fails and attempts < max', async () => {
            const entry = makeEntry({ id: 7, attempts: 1, maxAttempts: 3 });
            const repo = new FakeRepo([[entry]]);
            const deliver = jest.fn<Promise<DeliveryResult>, [OutboxEntry]>().mockResolvedValue({ ok: false, error: 'boom' });
            const fixedNow = new Date('2026-05-12T15:00:00Z');
            const svc = new OutboxService({ repository: repo, deliver, now: () => fixedNow });

            const result = await svc.tick(10);

            expect(repo.delivered).toEqual([]);
            expect(repo.failedAttempts).toHaveLength(1);
            expect(repo.failedAttempts[0].id).toBe(7);
            expect(repo.failedAttempts[0].error).toBe('boom');
            // P1-11: attempts=1 -> nextDelay=5s in the extended schedule.
            expect(repo.failedAttempts[0].nextAttemptAt.getTime() - fixedNow.getTime()).toBe(5 * 1000);
            expect(result.retried).toBe(1);
            expect(result.permanentlyFailed).toBe(0);
        });

        it('marks permanent failure when attempts reach maxAttempts', async () => {
            const entry = makeEntry({ id: 9, attempts: 3, maxAttempts: 3 });
            const repo = new FakeRepo([[entry]]);
            const deliver = jest.fn<Promise<DeliveryResult>, [OutboxEntry]>().mockResolvedValue({ ok: false, error: 'still boom' });
            const svc = new OutboxService({ repository: repo, deliver, now: () => new Date('2026-05-12T15:00:00Z') });

            const result = await svc.tick(10);

            expect(repo.failedAttempts).toEqual([]);
            expect(repo.permanentFailures).toEqual([{ id: 9, error: 'still boom' }]);
            expect(result.permanentlyFailed).toBe(1);
            expect(result.retried).toBe(0);
        });

        it('processes a mixed batch (success, retry, give-up) in one tick', async () => {
            const ok = makeEntry({ id: 1, attempts: 1 });
            const retry = makeEntry({ id: 2, attempts: 1, maxAttempts: 3 });
            const dead = makeEntry({ id: 3, attempts: 3, maxAttempts: 3 });
            const repo = new FakeRepo([[ok, retry, dead]]);
            const deliver = jest.fn<Promise<DeliveryResult>, [OutboxEntry]>()
                .mockResolvedValueOnce({ ok: true })
                .mockResolvedValueOnce({ ok: false, error: 'transient' })
                .mockResolvedValueOnce({ ok: false, error: 'terminal' });
            const svc = new OutboxService({ repository: repo, deliver, now: () => new Date('2026-05-12T15:00:00Z') });

            const result = await svc.tick(10);

            expect(result).toEqual({ claimed: 3, delivered: 1, retried: 1, permanentlyFailed: 1 });
            expect(repo.delivered).toEqual([1]);
            expect(repo.failedAttempts.map((f) => f.id)).toEqual([2]);
            expect(repo.permanentFailures.map((f) => f.id)).toEqual([3]);
        });

        it('returns zeros when nothing is due', async () => {
            const repo = new FakeRepo([[]]);
            const deliver = jest.fn();
            const svc = new OutboxService({ repository: repo, deliver, now: () => new Date('2026-05-12T15:00:00Z') });

            const result = await svc.tick(10);

            expect(deliver).not.toHaveBeenCalled();
            expect(result).toEqual({ claimed: 0, delivered: 0, retried: 0, permanentlyFailed: 0 });
        });
    });
});
