/**
 * Status-page subscriber service (PR-19, May 2026).
 *
 * Pure-ish: each method takes its dependencies via param defaults so
 * tests can inject mocks. Side effects (email send, DB write) live
 * behind the injected `mailer` and `prisma` so the verification path
 * stays unit-testable.
 *
 * Public surface (called by API routes + worker):
 *   - requestSubscription(email)    — creates/reuses a row, sends confirm
 *   - confirmSubscription(token)    — flips confirmedAt, returns email
 *   - unsubscribe(token)            — deletes the row
 *   - notifyIncidentOpen(incident)  — fan-out on new StatusIncident
 *   - notifyIncidentResolved(incident)
 *
 * Spam / abuse posture:
 *   - email validated against a strict pattern (no Unicode tricks)
 *   - tokens are 32 bytes of crypto.randomBytes → 64 hex chars
 *   - re-subscribing an existing-confirmed email is a no-op (no email
 *     sent twice) — bots can't use this endpoint to spam a target
 *   - rate-limit + IP throttling are at the API layer, not here
 */
import crypto from 'crypto';
import { prisma as defaultPrisma } from '@/lib/prisma';
import type { PrismaClient } from '@prisma/client';
import { log } from '@/lib/observability/logger';
import { captureException } from '@/lib/observability/sentry';

// Subscriber-incident notifications shouldn't pile up if an incident
// is updated repeatedly inside a tight loop (e.g. an oscillating state).
// Skip a subscriber that was notified within this window.
const NOTIFY_COOLDOWN_MS = parseInt(process.env.SUBSCRIBER_NOTIFY_COOLDOWN_MS || '60000', 10);

// AUDIT-3 (2026-05-28) — constant-time response floor for
// requestSubscription. Without this, the latency difference between
// "already confirmed" (no SMTP send, ~5 ms) and "new" (SMTP send,
// ~500 ms+) leaked which addresses are subscribed via timing.
const MIN_RESPONSE_MS = parseInt(process.env.SUBSCRIBE_MIN_RESPONSE_MS || '600', 10);

// Conservative email check — accepts the common formats and rejects
// obvious abuse vectors (control chars, double-@, header injection).
const EMAIL_RE = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,24}$/;

export function isValidSubscriberEmail(raw: string): boolean {
    if (typeof raw !== 'string') return false;
    const trimmed = raw.trim().toLowerCase();
    if (trimmed.length > 254) return false; // RFC 5321
    if (/[\r\n\t]/.test(trimmed)) return false; // header-injection chars
    return EMAIL_RE.test(trimmed);
}

export function generateSubscriberToken(): string {
    return crypto.randomBytes(32).toString('hex');
}

export interface SubscriberMailer {
    sendConfirm(args: { to: string; confirmUrl: string }): Promise<void>;
    sendIncident(args: { to: string; title: string; description: string; incidentUrl: string; unsubscribeUrl: string }): Promise<void>;
    sendResolved(args: { to: string; title: string; incidentUrl: string; unsubscribeUrl: string }): Promise<void>;
}

export interface ServiceDeps {
    prisma?: PrismaClient;
    mailer?: SubscriberMailer;
    baseUrl?: string;
    now?: () => Date;
}

function resolveBaseUrl(opt?: string): string {
    return opt
        ?? process.env.PUBLIC_BASE_URL
        ?? process.env.NEXTAUTH_URL
        ?? 'http://localhost:3005';
}

export class StatusSubscriberService {
    private readonly prisma: PrismaClient;
    private readonly mailer: SubscriberMailer;
    private readonly baseUrl: string;
    private readonly now: () => Date;

    constructor(deps: ServiceDeps = {}) {
        this.prisma = deps.prisma ?? (defaultPrisma as unknown as PrismaClient);
        this.mailer = deps.mailer ?? defaultMailer;
        this.baseUrl = resolveBaseUrl(deps.baseUrl);
        this.now = deps.now ?? (() => new Date());
    }

    /**
     * Create-or-reuse a subscriber row and send the confirmation email.
     * Returns void to the caller (the API route returns 200 always so
     * the response doesn't leak which addresses are subscribed).
     */
    async requestSubscription(emailRaw: string): Promise<{ created: boolean; alreadyConfirmed: boolean }> {
        const email = emailRaw.trim().toLowerCase();
        if (!isValidSubscriberEmail(email)) {
            throw new Error('Invalid email address.');
        }

        // AUDIT-3 (2026-05-28) — constant-time response.
        //
        // The API route returns the same 200 OK for confirmed /
        // unconfirmed / new addresses (so the body doesn't leak which
        // emails are subscribed). But latency was still asymmetric:
        // already-confirmed emails returned in a few ms (no SMTP send),
        // while new emails hung for ~500 ms on transporter.sendMail.
        // An unauthenticated attacker could enumerate confirmed
        // subscribers by timing the response.
        //
        // Fix: every code path now does roughly the same amount of
        // work. We measure elapsed time and pad to MIN_RESPONSE_MS at
        // the end if we returned early.
        const t0 = Date.now();
        const result = await this.requestSubscriptionInner(email);
        const elapsedMs = Date.now() - t0;
        const padMs = MIN_RESPONSE_MS - elapsedMs;
        if (padMs > 0) {
            await new Promise((r) => setTimeout(r, padMs));
        }
        return result;
    }

    private async requestSubscriptionInner(email: string): Promise<{ created: boolean; alreadyConfirmed: boolean }> {
        const existing = await this.prisma.statusPageSubscriber.findUnique({ where: { email } });
        if (existing) {
            if (existing.confirmedAt) {
                // Already on the list — no email re-sent, no row mutated.
                return { created: false, alreadyConfirmed: true };
            }
            // Provisional row — re-issue the confirm token (don't
            // honour a stale one that might have leaked from an old log).
            const confirmToken = generateSubscriberToken();
            await this.prisma.statusPageSubscriber.update({
                where: { id: existing.id },
                data: { confirmToken },
            });
            await this.safeSendConfirm(email, confirmToken);
            return { created: false, alreadyConfirmed: false };
        }

        const confirmToken = generateSubscriberToken();
        const unsubscribeToken = generateSubscriberToken();
        await this.prisma.statusPageSubscriber.create({
            data: { email, confirmToken, unsubscribeToken },
        });
        await this.safeSendConfirm(email, confirmToken);
        return { created: true, alreadyConfirmed: false };
    }

    async confirmSubscription(token: string): Promise<{ ok: boolean; email?: string }> {
        if (!/^[a-f0-9]{64}$/.test(token)) return { ok: false };
        const row = await this.prisma.statusPageSubscriber.findUnique({ where: { confirmToken: token } });
        if (!row) return { ok: false };
        if (row.confirmedAt) return { ok: true, email: row.email }; // idempotent
        await this.prisma.statusPageSubscriber.update({
            where: { id: row.id },
            data: { confirmedAt: this.now() },
        });
        return { ok: true, email: row.email };
    }

    async unsubscribe(token: string): Promise<{ ok: boolean; email?: string }> {
        if (!/^[a-f0-9]{64}$/.test(token)) return { ok: false };
        const row = await this.prisma.statusPageSubscriber.findUnique({ where: { unsubscribeToken: token } });
        if (!row) return { ok: false };
        await this.prisma.statusPageSubscriber.delete({ where: { id: row.id } });
        return { ok: true, email: row.email };
    }

    /** Returns the count of subscribers notified. */
    async notifyIncidentOpen(args: { id: number; title: string; description: string }): Promise<number> {
        return this.fanout({ subject: 'incident-open', incident: args });
    }

    async notifyIncidentResolved(args: { id: number; title: string }): Promise<number> {
        return this.fanout({ subject: 'incident-resolved', incident: { ...args, description: '' } });
    }

    private async fanout(args: {
        subject: 'incident-open' | 'incident-resolved';
        incident: { id: number; title: string; description: string };
    }): Promise<number> {
        const cooldownCutoff = new Date(this.now().getTime() - NOTIFY_COOLDOWN_MS);
        const subscribers = await this.prisma.statusPageSubscriber.findMany({
            where: {
                confirmedAt: { not: null },
                OR: [
                    { lastNotifiedAt: null },
                    { lastNotifiedAt: { lt: cooldownCutoff } },
                ],
            },
            select: { id: true, email: true, unsubscribeToken: true },
        });

        let sent = 0;
        for (const sub of subscribers) {
            const unsubscribeUrl = `${this.baseUrl}/api/status/unsubscribe?token=${sub.unsubscribeToken}`;
            const incidentUrl = `${this.baseUrl}/status`;
            try {
                if (args.subject === 'incident-open') {
                    await this.mailer.sendIncident({
                        to: sub.email,
                        title: args.incident.title,
                        description: args.incident.description,
                        incidentUrl,
                        unsubscribeUrl,
                    });
                } else {
                    await this.mailer.sendResolved({
                        to: sub.email,
                        title: args.incident.title,
                        incidentUrl,
                        unsubscribeUrl,
                    });
                }
                await this.prisma.statusPageSubscriber.update({
                    where: { id: sub.id },
                    data: { lastNotifiedAt: this.now() },
                });
                sent++;
            } catch (err) {
                log.warn({ err, subscriberId: sub.id, subject: args.subject }, 'Status subscriber notify failed');
                captureException(err, { subscriberId: sub.id, subject: args.subject });
                // continue — one bad address shouldn't block the rest
            }
        }
        return sent;
    }

    private async safeSendConfirm(email: string, token: string): Promise<void> {
        try {
            const confirmUrl = `${this.baseUrl}/api/status/confirm?token=${token}`;
            await this.mailer.sendConfirm({ to: email, confirmUrl });
        } catch (err) {
            // Don't let the mail failure surface to the caller — we
            // already wrote the row, and re-subscribing will re-issue
            // the token + retry the email.
            log.warn({ err, email }, 'Subscriber confirm email failed to send');
            captureException(err, { email, stage: 'subscriber.confirm' });
        }
    }
}

// Default mailer — uses the project's EmailService. Lazy-loaded so
// unit tests can mock the whole class without dragging in nodemailer.
const defaultMailer: SubscriberMailer = {
    async sendConfirm({ to, confirmUrl }) {
        const { EmailService } = await import('./email.service');
        await EmailService.sendRaw(
            to,
            'Confirm your Uptime Sentinel subscription',
            `<p>Confirm your subscription to incident updates by clicking the link below:</p>
             <p><a href="${confirmUrl}">${confirmUrl}</a></p>
             <p>If you didn't request this, ignore this email — no further messages will be sent.</p>`,
        );
    },
    async sendIncident({ to, title, description, incidentUrl, unsubscribeUrl }) {
        const { EmailService } = await import('./email.service');
        await EmailService.sendRaw(
            to,
            `[Incident] ${title}`,
            `<p><strong>${escapeHtml(title)}</strong></p>
             <p>${escapeHtml(description) || '(No details yet — see the status page.)'}</p>
             <p><a href="${incidentUrl}">View on the status page</a></p>
             <p style="font-size:12px;color:#666">
               <a href="${unsubscribeUrl}">Unsubscribe</a>
             </p>`,
        );
    },
    async sendResolved({ to, title, incidentUrl, unsubscribeUrl }) {
        const { EmailService } = await import('./email.service');
        await EmailService.sendRaw(
            to,
            `[Resolved] ${title}`,
            `<p>The incident "<strong>${escapeHtml(title)}</strong>" has been resolved.</p>
             <p><a href="${incidentUrl}">View on the status page</a></p>
             <p style="font-size:12px;color:#666">
               <a href="${unsubscribeUrl}">Unsubscribe</a>
             </p>`,
        );
    },
};

function escapeHtml(s: string): string {
    return s
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;');
}
