/**
 * Escalation service (PR-26, May 2026).
 *
 * Backs NotificationRule.escalationDelay. The dispatch flow:
 *
 *   notification event fires
 *      ↓
 *   RulesEngine.evaluate -> matched rules
 *      ↓
 *   for each rule:
 *      if escalationDelay > 0  -> schedule(payload, ruleId, delay)
 *      else                    -> dispatch immediately (existing path)
 *
 * The worker tick (every minute) checks scheduled rows whose
 * scheduledFor <= now and either dispatches or cancels each one.
 *
 * Cancellation rules:
 *   - monitor.up event       -> cancel all rows where monitorId = ...
 *   - incident.resolve event -> cancel all rows where incidentId = ...
 *
 * The dispatch path re-injects the serialized payload into the same
 * NotificationDispatcher channels-fanout logic that immediate
 * notifications use, so the per-channel dedup / outbox guarantees
 * stay intact.
 */
import type { PrismaClient } from '@prisma/client';
import { prisma as defaultPrisma } from '@/lib/prisma';
import { log } from '@/lib/observability/logger';
import { captureException } from '@/lib/observability/sentry';

export interface ScheduleArgs {
    ruleId: number;
    eventKey: string;
    event: string;
    payload: object;
    delayMinutes: number;
    monitorId?: number | null;
    incidentId?: number | null;
}

export class EscalationService {
    constructor(
        private readonly prisma: PrismaClient = defaultPrisma as unknown as PrismaClient,
        private readonly now: () => Date = () => new Date(),
    ) { }

    /**
     * Persist a delayed escalation row. The caller (NotificationDispatcher)
     * should ONLY call this when delayMinutes > 0; delay = 0 still
     * dispatches immediately via the existing path.
     */
    async schedule(args: ScheduleArgs): Promise<number> {
        const scheduledFor = new Date(this.now().getTime() + args.delayMinutes * 60_000);
        const row = await this.prisma.pendingEscalation.create({
            data: {
                ruleId: args.ruleId,
                eventKey: args.eventKey,
                event: args.event,
                payload: JSON.stringify(args.payload),
                monitorId: args.monitorId ?? null,
                incidentId: args.incidentId ?? null,
                scheduledFor,
            },
            select: { id: true },
        });
        log.info(
            { id: row.id, ruleId: args.ruleId, scheduledFor: scheduledFor.toISOString(), delayMinutes: args.delayMinutes },
            'Escalation scheduled',
        );
        return row.id;
    }

    /**
     * Cancel all pending escalations referencing a monitor that just
     * recovered. Called from the monitor.up event listener.
     */
    async cancelForMonitor(monitorId: number): Promise<number> {
        const res = await this.prisma.pendingEscalation.deleteMany({
            where: { monitorId },
        });
        if (res.count > 0) {
            log.info({ monitorId, cancelled: res.count }, 'Escalations cancelled (monitor recovered)');
        }
        return res.count;
    }

    /**
     * Cancel all pending escalations referencing a resolved incident.
     */
    async cancelForIncident(incidentId: number): Promise<number> {
        const res = await this.prisma.pendingEscalation.deleteMany({
            where: { incidentId },
        });
        if (res.count > 0) {
            log.info({ incidentId, cancelled: res.count }, 'Escalations cancelled (incident resolved)');
        }
        return res.count;
    }

    /**
     * Worker tick. Find all due rows, re-check the underlying
     * condition for each, and either dispatch or drop. Returns counts
     * for observability.
     */
    async tick(batchSize: number = 50): Promise<{ inspected: number; dispatched: number; cancelled: number; errored: number }> {
        const now = this.now();
        const due = await this.prisma.pendingEscalation.findMany({
            where: { scheduledFor: { lte: now } },
            orderBy: { scheduledFor: 'asc' },
            take: batchSize,
        });

        let dispatched = 0;
        let cancelled = 0;
        let errored = 0;

        for (const row of due) {
            try {
                const stillFiring = await this.isStillFiring(row.event, row.monitorId, row.incidentId);
                if (stillFiring) {
                    await this.dispatchOne(row.event, row.payload);
                    dispatched++;
                } else {
                    cancelled++;
                }
                await this.prisma.pendingEscalation.delete({ where: { id: row.id } });
            } catch (err) {
                log.error({ err, id: row.id }, 'Escalation tick: row failed');
                captureException(err, { id: row.id, stage: 'escalation.tick' });
                errored++;
                // Don't delete on error — leave the row for the next tick to retry.
            }
        }

        return { inspected: due.length, dispatched, cancelled, errored };
    }

    /**
     * Determine whether the original trigger condition still holds.
     * Used by tick() to decide between dispatch and cancellation.
     *
     * Heuristic (intentionally simple — over-firing is worse than
     * under-firing because alert fatigue is the operator's #1 pain):
     *   - monitor_down: latest heartbeat for the monitor must be down
     *   - monitor_recovered: skip the dispatch (already past it)
     *   - incident_create: incident still exists + not resolved
     *   - other events: dispatch (no cancellation signal)
     */
    private async isStillFiring(event: string, monitorId: number | null, incidentId: number | null): Promise<boolean> {
        if (monitorId && event === 'monitor_down') {
            const hb = await this.prisma.heartbeat.findFirst({
                where: { monitorId },
                orderBy: { createdAt: 'desc' },
                select: { status: true },
            });
            return hb?.status === 'down';
        }
        if (incidentId && event === 'incident_create') {
            const inc = await this.prisma.statusIncident.findUnique({
                where: { id: incidentId },
                select: { deletedAt: true, state: { select: { name: true } } },
            });
            if (!inc) return false;
            if (inc.deletedAt) return false;
            // Resolved state cancels the escalation.
            if (inc.state?.name?.toLowerCase() === 'resolved') return false;
            return true;
        }
        // Default: dispatch (no inverse signal we can detect cheaply).
        return true;
    }

    private async dispatchOne(event: string, payloadJson: string): Promise<void> {
        const payload = JSON.parse(payloadJson) as Record<string, unknown>;
        // Re-enter the dispatcher path. NotificationDispatcher.emit runs
        // the rules engine again, but the dedup window (per-channel) +
        // outbox dedup will collapse this back to a single send — the
        // important behavioral change is that the timing is delayed.
        const { NotificationDispatcher } = await import('@/lib/notification-system');
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        await NotificationDispatcher.emit(event as never, payload as any);
    }
}
