import { createLogger } from '../diagnostics/logger.js';
const logger = createLogger('rate-limit');
const DEFAULT_MAX_KEYS = 10_000;
/**
 * Sweep expired entries on every Nth `check()` call. Tunable at module level
 * so tests can stub it without exposing it via the public config surface.
 */
const SWEEP_EVERY_N_CHECKS = 256;
/** Create a rate limiter backed by an in-memory sliding window (for dev/single-process). */
export function createInMemoryRateLimiter(config) {
    // `Map` preserves insertion order so we can use it as an LRU directly:
    // every `check()` re-inserts the touched key (delete + set) which moves it
    // to the tail. When we exceed the cap, `windows.keys().next().value` is the
    // least-recently-touched entry and we evict it.
    const windows = new Map();
    const maxKeys = config.maxKeys && config.maxKeys > 0 ? config.maxKeys : DEFAULT_MAX_KEYS;
    let checksSinceSweep = 0;
    function sweepExpired(now) {
        // Bounded periodic cleanup of expired entries. We cap iterations so a
        // pathological burst of new keys can't make this O(n) on every call.
        let removed = 0;
        for (const [key, entry] of windows) {
            if (entry.resetAt <= now) {
                windows.delete(key);
                removed++;
                if (removed >= 1000)
                    break;
            }
        }
    }
    function evictIfFull() {
        while (windows.size >= maxKeys) {
            const oldestKey = windows.keys().next().value;
            if (oldestKey === undefined)
                break;
            windows.delete(oldestKey);
        }
    }
    return {
        async check(key) {
            const now = Date.now();
            checksSinceSweep++;
            if (checksSinceSweep >= SWEEP_EVERY_N_CHECKS) {
                checksSinceSweep = 0;
                sweepExpired(now);
            }
            const entry = windows.get(key);
            if (!entry || now > entry.resetAt) {
                const resetAt = now + config.windowMs;
                windows.delete(key);
                evictIfFull();
                windows.set(key, { count: 1, resetAt });
                return { allowed: true, remaining: config.maxRequests - 1, resetAt: new Date(resetAt) };
            }
            entry.count++;
            // Re-insert to mark as most-recently-used.
            windows.delete(key);
            windows.set(key, entry);
            const allowed = entry.count <= config.maxRequests;
            return {
                allowed,
                remaining: Math.max(0, config.maxRequests - entry.count),
                resetAt: new Date(entry.resetAt),
                retryAfter: allowed ? undefined : Math.ceil((entry.resetAt - now) / 1000),
            };
        },
        async reset(key) {
            windows.delete(key);
        },
    };
}
/**
 * Throttle the noisy "Upstash failed" audit event so a sustained outage does
 * not generate one DB write per request. We allow at most one audit log per
 * `AUDIT_THROTTLE_MS`. The `console.error` line still fires on every failure
 * for live operator visibility.
 */
const AUDIT_THROTTLE_MS = 60_000;
let lastAuditAt = 0;
let strictRateLimitWarned = false;
function isProductionRuntime() {
    return process.env.NODE_ENV === 'production';
}
function denyResult(config) {
    const retryAfter = Math.ceil(config.windowMs / 1000);
    return {
        allowed: false,
        remaining: 0,
        resetAt: new Date(Date.now() + config.windowMs),
        retryAfter,
    };
}
function warnStrictRateLimitMisconfig(reason) {
    if (strictRateLimitWarned)
        return;
    strictRateLimitWarned = true;
    logger.error('Strict rate limiters (login, AI, uploads) are misconfigured — requests will be denied until Upstash Redis is available', { reason });
}
/** Log once at boot when strict limiters cannot reach a distributed backend in production. */
export function warnStrictRateLimitConfigAtBoot() {
    if (process.env.ACTUATE_DISABLE_RATE_LIMIT === '1')
        return;
    if (!isProductionRuntime())
        return;
    const { url, token } = resolveUpstashCredentials();
    if (url && token)
        return;
    warnStrictRateLimitMisconfig('Set UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN (or KV_REST_API_URL + KV_REST_API_TOKEN)');
}
function createUnavailableRateLimiter(config, reason) {
    warnStrictRateLimitMisconfig(reason);
    void emitDegradedAudit(reason);
    return {
        async check() {
            return denyResult(config);
        },
        async reset() { },
    };
}
async function emitDegradedAudit(reason) {
    const now = Date.now();
    if (now - lastAuditAt < AUDIT_THROTTLE_MS)
        return;
    lastAuditAt = now;
    try {
        // Lazy import — avoids a static dep cycle (audit.ts -> db.js -> ...).
        const { logEvent } = await import('./audit.js');
        await logEvent({
            event: 'rate_limit.degraded',
            details: { reason, throttleMs: AUDIT_THROTTLE_MS },
        });
    }
    catch {
        // Audit failures are themselves logged in audit.ts; don't double-log here.
    }
}
/**
 * Resolve the Upstash REST URL + token from the environment.
 *
 * Accepts two naming schemes for the same credentials:
 *   1. `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` — what you get
 *      from the Upstash console directly.
 *   2. `KV_REST_API_URL` / `KV_REST_API_TOKEN` — what Vercel's Marketplace
 *      Upstash (Redis/KV) integration auto-provisions. This is the *recommended*
 *      provisioning path in our deploy docs, so we must read these names too —
 *      otherwise a correctly-provisioned store silently falls back to the
 *      in-memory limiter.
 *
 * `UPSTASH_*` wins when both are present so an explicit override is honoured.
 * Both values point at the same REST endpoint / full-access token, so the
 * `/pipeline` transport below works identically regardless of which was set.
 */
export function resolveUpstashCredentials(env = process.env) {
    return {
        url: env.UPSTASH_REDIS_REST_URL ?? env.KV_REST_API_URL,
        token: env.UPSTASH_REDIS_REST_TOKEN ?? env.KV_REST_API_TOKEN,
    };
}
/** Create a rate limiter backed by Upstash Redis (for serverless/production). */
export function createUpstashRateLimiter(config) {
    const { url, token } = resolveUpstashCredentials();
    if (!url || !token) {
        throw new Error('Upstash credentials are required: set UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN ' +
            '(or the Vercel-provisioned KV_REST_API_URL + KV_REST_API_TOKEN).');
    }
    async function redisPipeline(commands) {
        // Upstash exposes pipelined commands via the /pipeline endpoint. Sending
        // INCR + EXPIRE in a single round-trip eliminates the race where the
        // process dies after INCR (or where EXPIRE silently fails) and leaves a
        // counter that never resets — a subtle DoS where one unlucky user is
        // permanently locked out.
        const response = await fetch(`${url}/pipeline`, {
            method: 'POST',
            headers: {
                Authorization: `Bearer ${token}`,
                'Content-Type': 'application/json',
            },
            body: JSON.stringify(commands),
        });
        if (!response.ok) {
            throw new Error(`Upstash pipeline returned ${response.status}`);
        }
        const data = (await response.json());
        return data.map((entry) => entry?.result);
    }
    const windowSec = Math.ceil(config.windowMs / 1000);
    return {
        async check(key) {
            const redisKey = `ratelimit:${key}`;
            try {
                const [count, _expireResult, ttl] = (await redisPipeline([
                    ['INCR', redisKey],
                    ['EXPIRE', redisKey, String(windowSec), 'NX'], // only set when no TTL exists
                    ['TTL', redisKey],
                ]));
                // Belt-and-braces: if Redis reports the key with no TTL, set one. This
                // can only happen if the EXPIRE NX above wasn't supported, in which
                // case we still want to bound the counter's lifetime.
                if (ttl < 0) {
                    await redisPipeline([['EXPIRE', redisKey, String(windowSec)]]).catch(() => undefined);
                }
                const effectiveTtl = ttl > 0 ? ttl : windowSec;
                const resetAt = new Date(Date.now() + effectiveTtl * 1000);
                const allowed = count <= config.maxRequests;
                return {
                    allowed,
                    remaining: Math.max(0, config.maxRequests - count),
                    resetAt,
                    retryAfter: allowed ? undefined : effectiveTtl,
                };
            }
            catch (err) {
                const reason = err instanceof Error ? err.message : String(err);
                if (config.failClosed) {
                    logger.error('Upstash request failed, denying request (fail-closed)', { reason });
                    void emitDegradedAudit(reason);
                    return denyResult(config);
                }
                // Fail open with logging — a Redis outage should NOT take the entire
                // CMS offline. We surface the degradation through:
                //   1. console.error for live ops/log aggregator dashboards;
                //   2. a throttled audit-log event so the outage is preserved in the
                //      durable security record without flooding the DB during long
                //      Upstash incidents.
                logger.error('Upstash request failed, allowing through', { reason });
                void emitDegradedAudit(reason);
                return {
                    allowed: true,
                    remaining: Math.max(0, config.maxRequests - 1),
                    resetAt: new Date(Date.now() + config.windowMs),
                };
            }
        },
        async reset(key) {
            try {
                await redisPipeline([['DEL', `ratelimit:${key}`]]);
            }
            catch (err) {
                logger.error('Upstash reset failed', {
                    reason: err instanceof Error ? err.message : String(err),
                });
            }
        },
    };
}
/**
 * Create a rate limiter with automatic backend detection.
 * Uses Upstash Redis if UPSTASH_REDIS_REST_URL is set, otherwise falls back to in-memory.
 */
/**
 * Returns a rate limiter that always permits requests.
 *
 * Useful for test harnesses (Playwright, Vitest, k6) where every test
 * shares the same client-side IP — without a bypass, the first 5 login
 * attempts exhaust the strict 5-per-15-minute bucket and every later
 * test fails with HTTP 429. Production never sets the env var that
 * activates this; the activation lives in `createRateLimiter` so every
 * caller gets it without scattering env checks across the codebase.
 */
function createPermissiveRateLimiter(config) {
    return {
        async check() {
            return {
                allowed: true,
                remaining: config.maxRequests,
                resetAt: new Date(Date.now() + config.windowMs),
            };
        },
        async reset() { },
    };
}
export function createRateLimiter(config) {
    // Explicit, environment-gated bypass for test harnesses. We deliberately
    // do NOT key off `NODE_ENV === 'test'` because Next.js builds run with
    // NODE_ENV=test in some CI configurations and we want unit tests that
    // explicitly opt in to still exercise the real limiter logic. Vercel +
    // the deploy guide both omit this var, so production never disables.
    if (process.env.ACTUATE_DISABLE_RATE_LIMIT === '1') {
        return createPermissiveRateLimiter(config);
    }
    const { url, token } = resolveUpstashCredentials();
    if (config.failClosed && isProductionRuntime() && (!url || !token)) {
        return createUnavailableRateLimiter(config, 'Upstash credentials missing for strict rate limiter in production');
    }
    if (url && token) {
        try {
            return createUpstashRateLimiter(config);
        }
        catch {
            if (config.failClosed && isProductionRuntime()) {
                return createUnavailableRateLimiter(config, 'Upstash rate limiter init failed');
            }
            // Non-strict limiters fall back to in-memory when Upstash init fails.
        }
    }
    return createInMemoryRateLimiter(config);
}
//# sourceMappingURL=rate-limit.js.map