/**
 * audit3-followup (2026-05-29) — MySQL-backed brute-force counter.
 *
 * Replaces the per-process in-memory Maps in login-attempts.ts and
 * twofa-attempts.ts. Surviving process restarts matters: in production,
 * dropping the counter on every deploy gave a sustained attacker an
 * easy "wait for the next release window" bypass.
 *
 * Public API mirrors the old in-memory modules (isLocked / recordFailure
 * / recordSuccess) but is async and parameterised by `bucket` so one
 * table backs every counter type.
 *
 * Buckets currently used:
 *   - 'login'      — per-email failed-login counter (bucketKey = email)
 *   - 'twofa-ip'   — per-(userId,ip) 2FA verification (bucketKey = `${userId}|${ip}`)
 *   - 'twofa-user' — per-userId 2FA verification     (bucketKey = `${userId}`)
 *
 * Each row carries its own `windowMs`, so the same table can host
 * counters with different sliding windows without a cleanup sweep
 * shortening anyone's effective window (the same trick rate-limiter.ts
 * uses inside its in-memory store — see AUDIT-2 #1).
 *
 * Failures are stored as a JSON array of epoch-ms numbers. MySQL 8's
 * JSON_ARRAY_APPEND is index-friendly enough for this volume (a typical
 * brute-force attempt count peaks well under 100 per row), and reading
 * the column is cheap.
 *
 * Cleanup: on every read, expired-window failures are dropped before
 * the lock decision. Rows with `lockedUntil` null AND an empty failures
 * array are deleted lazily (no nightly cron required at our scale; if
 * the table grows, add one and target rows where updatedAt < NOW() -
 * INTERVAL 7 DAY AND lockedUntil IS NULL).
 */
import { prisma } from '@/lib/prisma';

export interface LockStatus {
    locked: boolean;
    /** Seconds the caller should ask the user to wait. Set only when locked. */
    retryAfterSeconds?: number;
}

export interface CounterConfig {
    maxFailures: number;
    windowMs: number;
    lockoutMs: number;
}

/**
 * Inspect lock state for this bucket/key. Expired locks are cleared
 * lazily so the caller doesn't see a "ghost lock" after the lockout
 * window elapsed.
 */
export async function isLocked(
    bucket: string,
    bucketKey: string,
    cfg: CounterConfig,
): Promise<LockStatus> {
    const row = await prisma.bruteForceCounter.findUnique({
        where: { bucket_bucketKey: { bucket, bucketKey } },
    });
    if (!row) return { locked: false };

    const now = Date.now();

    if (row.lockedUntil) {
        const lockedUntilMs = row.lockedUntil.getTime();
        if (lockedUntilMs > now) {
            return {
                locked: true,
                retryAfterSeconds: Math.ceil((lockedUntilMs - now) / 1000),
            };
        }
        // Lock expired — clear it so the user can try again fresh.
        await prisma.bruteForceCounter.update({
            where: { bucket_bucketKey: { bucket, bucketKey } },
            data: { lockedUntil: null, failures: [] },
        });
        return { locked: false };
    }

    // No active lock — but make sure expired failures aren't lingering
    // (would falsely lock on the next attempt).
    const cutoff = now - cfg.windowMs;
    const failures = parseFailures(row.failures).filter((t) => t >= cutoff);
    if (failures.length !== parseFailures(row.failures).length) {
        if (failures.length === 0) {
            await prisma.bruteForceCounter.delete({
                where: { bucket_bucketKey: { bucket, bucketKey } },
            });
        } else {
            await prisma.bruteForceCounter.update({
                where: { bucket_bucketKey: { bucket, bucketKey } },
                data: { failures },
            });
        }
    }
    return { locked: false };
}

/**
 * Record one failure. Locks the bucket if the threshold is hit.
 * No-ops if a lock is already active (so a sustained attacker can't
 * extend the lock indefinitely by record-then-extend).
 */
export async function recordFailure(
    bucket: string,
    bucketKey: string,
    cfg: CounterConfig,
): Promise<void> {
    const now = Date.now();
    const row = await prisma.bruteForceCounter.findUnique({
        where: { bucket_bucketKey: { bucket, bucketKey } },
    });

    if (row?.lockedUntil && row.lockedUntil.getTime() > now) {
        // Lock active — nothing further to record.
        return;
    }

    const cutoff = now - cfg.windowMs;
    const existingFailures = row ? parseFailures(row.failures).filter((t) => t >= cutoff) : [];
    const failures = [...existingFailures, now];
    const lockedUntil = failures.length >= cfg.maxFailures
        ? new Date(now + cfg.lockoutMs)
        : null;

    await prisma.bruteForceCounter.upsert({
        where: { bucket_bucketKey: { bucket, bucketKey } },
        create: {
            bucket,
            bucketKey,
            failures,
            lockedUntil,
            windowMs: cfg.windowMs,
        },
        update: {
            failures,
            lockedUntil,
            windowMs: cfg.windowMs,
        },
    });
}

/** Clear all state for this bucket/key. Call after successful authentication. */
export async function recordSuccess(bucket: string, bucketKey: string): Promise<void> {
    await prisma.bruteForceCounter.deleteMany({
        where: { bucket, bucketKey },
    });
}

/**
 * Drop every row in a bucket whose key starts with the given prefix.
 * Used by 2FA verify to clear all (userId, *) IP-keyed rows after a
 * successful verification.
 */
export async function recordSuccessByPrefix(bucket: string, keyPrefix: string): Promise<void> {
    await prisma.bruteForceCounter.deleteMany({
        where: { bucket, bucketKey: { startsWith: keyPrefix } },
    });
}

function parseFailures(raw: unknown): number[] {
    if (Array.isArray(raw)) {
        return raw.filter((v): v is number => typeof v === 'number');
    }
    return [];
}
