/**
 * OutboxService: the only public surface of the outbox module.
 *
 *   enqueue(input)  -> persist an event row, due now
 *   tick(limit)     -> claim due rows, deliver each, record outcome
 *
 * The service is constructor-injectable for tests; production callers use
 * `OutboxService.default()` which wires the Prisma repository + default
 * channel dispatcher + default retry policy.
 */

import type {
    OutboxEntry,
    OutboxInput,
    TickResult,
} from './types';
import { PrismaOutboxRepository, type OutboxRepository } from './outbox.repository';
import { defaultDelivery, type OutboxDeliveryFn } from './outbox.dispatcher';
import { redactHighEntropy } from '../redact';
import {
    DEFAULT_MAX_ATTEMPTS,
    nextAttemptAt as nextAttemptAtFn,
    shouldGiveUp,
} from './retry-policy';

export interface RetryPolicy {
    nextAttemptAt(now: Date, attempts: number): Date;
    shouldGiveUp(attempts: number, maxAttempts: number): boolean;
}

const defaultPolicy: RetryPolicy = {
    nextAttemptAt: nextAttemptAtFn,
    shouldGiveUp,
};

export interface OutboxServiceOptions {
    repository?: OutboxRepository;
    deliver?: OutboxDeliveryFn;
    policy?: RetryPolicy;
    now?: () => Date;
}

export class OutboxService {
    private readonly repo: OutboxRepository;
    private readonly deliver: OutboxDeliveryFn;
    private readonly policy: RetryPolicy;
    private readonly now: () => Date;

    constructor(opts: OutboxServiceOptions = {}) {
        this.repo = opts.repository ?? new PrismaOutboxRepository();
        this.deliver = opts.deliver ?? defaultDelivery;
        this.policy = opts.policy ?? defaultPolicy;
        this.now = opts.now ?? (() => new Date());
    }

    static default(): OutboxService {
        return new OutboxService();
    }

    async enqueue(input: OutboxInput): Promise<OutboxEntry> {
        return this.repo.create({
            ...input,
            maxAttempts: input.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
            nextAttemptAt: input.nextAttemptAt ?? this.now(),
        });
    }

    async tick(limit: number = 50): Promise<TickResult> {
        const now = this.now();
        const claimed = await this.repo.claimDue(limit, now);
        const result: TickResult = {
            claimed: claimed.length,
            delivered: 0,
            retried: 0,
            permanentlyFailed: 0,
        };

        // AUDIT-9 (2026-05-23): bounded parallel dispatch. The previous
        // serial for-loop meant one stuck Slack webhook (30s timeout)
        // blocked the next 49 entries. During a regional outage with
        // 100+ flapping monitors, the outbox backlog grew unbounded
        // and customers found out about their own outage from their
        // customers.
        //
        // boundedPool caps in-flight HTTP requests. Default 8 deliveries
        // concurrent; tunable via OUTBOX_CONCURRENCY env var. The
        // per-entry post-actions (markDelivered / markFailedAttempt /
        // notifyChannelDead) still serialise on the DB but that's not
        // the bottleneck — channel I/O is.
        const concurrency = parseInt(process.env.OUTBOX_CONCURRENCY || '8', 10) || 8;
        const { boundedPool } = await import('@/lib/utils/bounded-pool');
        const gate = boundedPool(concurrency);

        await Promise.all(claimed.map((entry) => gate(async () => {
            const outcome = await this.deliver(entry);
            if (outcome.ok) {
                await this.repo.markDelivered(entry.id, this.now());
                result.delivered++;
                return;
            }

            if (this.policy.shouldGiveUp(entry.attempts, entry.maxAttempts)) {
                await this.repo.markPermanentFailure(entry.id, outcome.error, this.now());
                result.permanentlyFailed++;
                // P1-11: fire a meta-alert so an operator knows a channel
                // is dead. Best-effort: a failure here must not crash
                // the outbox tick.
                this.notifyChannelDead(entry, outcome.error).catch(() => undefined);
            } else {
                await this.repo.markFailedAttempt(
                    entry.id,
                    outcome.error,
                    this.policy.nextAttemptAt(this.now(), entry.attempts)
                );
                result.retried++;
            }
        })));

        return result;
    }

    /**
     * Fire a one-shot in-app notification to every ADMIN telling them a
     * notification channel is dead. Intentionally NOT routed through the
     * outbox itself — recursive failure would amplify the noise.
     */
    private async notifyChannelDead(
        entry: { id: number; channelType: string; channelId: number | null; eventType: string },
        error: string
    ): Promise<void> {
        try {
            const { prisma } = await import('@/lib/prisma');
            const admins = await prisma.user.findMany({
                where: { role: 'ADMIN', deletedAt: null },
                select: { id: true },
            });
            if (admins.length === 0) return;
            await prisma.userNotification.createMany({
                data: admins.map((a) => ({
                    userId: a.id,
                    type: 'error',
                    title: `Notification channel #${entry.channelId ?? 'rule'} failed permanently`,
                    message:
                        `Outbox entry #${entry.id} (${entry.channelType} for ${entry.eventType}) ` +
                        // AUDIT-2 #9 (2026-05-24): defense in depth — channel
                        // already redacts, but a future channel that forgets
                        // would otherwise leak into UserNotification.
                        `exhausted all retries. Last error: ${redactHighEntropy(error).slice(0, 256)}.`,
                    link: '/settings',
                })),
            });
        } catch {
            // Swallow; this is best-effort.
        }
    }
}
