/**
 * companion-chat-rate-limiter.ts
 *
 * Token-bucket rate limiter for CompanionChatManager.
 *
 * Enforces two independent limits:
 *   - per-client: max N messages/minute across all sessions for a given clientId.
 *   - per-session: max M messages/minute for a given sessionId regardless of client.
 *
 * When a limit is exceeded, throws GoodVibesSdkError with kind='rate-limit',
 * including an actionable message following the standard three-part format:
 *   [what happened] · [why] · [what to do]
 */
import { GoodVibesSdkError } from '@pellux/goodvibes-errors';
// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
export const DEFAULT_MESSAGES_PER_MINUTE_PER_CLIENT = 30;
export const DEFAULT_MESSAGES_PER_MINUTE_PER_SESSION = 10;
/**
 * Maximum number of distinct clientId/sessionId buckets to track concurrently.
 * A slow attacker sending requests with distinct IDs would otherwise grow the
 * Map without bound between cleanup() cycles. LRU eviction caps the attack
 * surface at O(MAX_BUCKETS) entries per map.
 */
export const MAX_RATE_LIMITER_BUCKETS = 10_000;
/**
 * Read the per-session threshold override from the environment.
 * GOODVIBES_CHAT_LIMITER_THRESHOLD=<int> overrides the per-session limit.
 * Returns undefined when unset or not a positive integer.
 */
export function readThresholdFromEnv(env = process.env) {
    const raw = env['GOODVIBES_CHAT_LIMITER_THRESHOLD'];
    if (!raw)
        return undefined;
    const parsed = Number.parseInt(raw, 10);
    return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
export class CompanionChatRateLimiter {
    perClientLimit;
    /**
     * Base per-session limit: resolved at constructor time from explicit option >
     * env var > compile-time default. May be overridden per-call by the config
     * manager when `configManager` is provided.
     *
     * **Note**: `GOODVIBES_CHAT_LIMITER_THRESHOLD` is read **once** at constructor
     * time and cached here. Changing the env var after daemon startup has no effect
     * without a daemon restart. Use the `runtime.companionChatLimiter.perSessionLimit`
     * config key for live changes.
     */
    perSessionLimitBase;
    windowMs;
    configManager;
    /** clientId → bucket */
    clientBuckets = new Map();
    /** sessionId → bucket */
    sessionBuckets = new Map();
    constructor(options = {}, env = process.env) {
        // Precedence: explicit config option > env var > compile-time default
        const envThreshold = readThresholdFromEnv(env);
        this.perClientLimit = options.perClientLimit ?? DEFAULT_MESSAGES_PER_MINUTE_PER_CLIENT;
        this.perSessionLimitBase = options.perSessionLimit ?? envThreshold ?? DEFAULT_MESSAGES_PER_MINUTE_PER_SESSION;
        this.windowMs = options.windowMs ?? 60_000;
        this.configManager = options.configManager ?? null;
    }
    /**
     * Resolve the effective per-session limit for the current call.
     *
     * Runtime config key `runtime.companionChatLimiter.perSessionLimit` takes
     * precedence over the constructor-time baseline when it resolves to a
     * positive integer.
     */
    resolvePerSessionLimit() {
        if (this.configManager) {
            const raw = this.configManager.get('runtime.companionChatLimiter.perSessionLimit');
            if (typeof raw === 'number' && Number.isInteger(raw) && raw > 0) {
                return raw;
            }
        }
        return this.perSessionLimitBase;
    }
    /**
     * Check and record a message attempt.
     *
     * Throws GoodVibesSdkError{kind:'rate-limit'} if either limit is exceeded.
     * On success, the attempt is recorded in both buckets.
     *
     * @param sessionId - The session receiving the message.
     * @param clientId  - The client sending the message (use '' to skip per-client check).
     */
    check(sessionId, clientId) {
        const now = Date.now();
        const cutoff = now - this.windowMs;
        // --- Per-session check ---
        const perSessionLimit = this.resolvePerSessionLimit();
        const sessionBucket = this.getOrCreate(this.sessionBuckets, sessionId, cutoff);
        if (sessionBucket.timestamps.length >= perSessionLimit) {
            const oldestTs = sessionBucket.timestamps[0];
            const retryAfterMs = this.windowMs - (now - oldestTs) + 1;
            throw new GoodVibesSdkError(`Companion chat session rate limit exceeded: this session has sent ${perSessionLimit} messages in the last minute. ` +
                `Each session is limited to ${perSessionLimit} messages per minute to prevent runaway conversations. ` +
                `Wait ${Math.ceil(retryAfterMs / 1000)} seconds before sending the next message, or create a new session.`, {
                category: 'rate_limit',
                source: 'runtime',
                recoverable: true,
                retryAfterMs,
                code: 'COMPANION_CHAT_SESSION_RATE_LIMIT',
            });
        }
        // --- Per-client check (skip when clientId is empty) ---
        if (clientId) {
            const clientBucket = this.getOrCreate(this.clientBuckets, clientId, cutoff);
            if (clientBucket.timestamps.length >= this.perClientLimit) {
                const oldestTs = clientBucket.timestamps[0];
                const retryAfterMs = this.windowMs - (now - oldestTs) + 1;
                throw new GoodVibesSdkError(`Companion chat client rate limit exceeded: client '${clientId}' has sent ${this.perClientLimit} messages in the last minute. ` +
                    `Each client is limited to ${this.perClientLimit} messages per minute across all sessions. ` +
                    `Wait ${Math.ceil(retryAfterMs / 1000)} seconds before sending the next message.`, {
                    category: 'rate_limit',
                    source: 'runtime',
                    recoverable: true,
                    retryAfterMs,
                    code: 'COMPANION_CHAT_CLIENT_RATE_LIMIT',
                });
            }
            clientBucket.timestamps.push(now);
        }
        // Record in session bucket (after both checks pass)
        sessionBucket.timestamps.push(now);
    }
    /** Remove stale buckets to prevent unbounded memory growth. */
    cleanup() {
        const cutoff = Date.now() - this.windowMs;
        for (const [id, bucket] of this.sessionBuckets) {
            prune(bucket, cutoff);
            if (bucket.timestamps.length === 0)
                this.sessionBuckets.delete(id);
        }
        for (const [id, bucket] of this.clientBuckets) {
            prune(bucket, cutoff);
            if (bucket.timestamps.length === 0)
                this.clientBuckets.delete(id);
        }
    }
    getOrCreate(map, key, cutoff) {
        let bucket = map.get(key);
        if (!bucket) {
            // LRU eviction — evict the least-recently-used entry when the map
            // is at capacity. JS Map preserves insertion order; the first key is LRU.
            if (map.size >= MAX_RATE_LIMITER_BUCKETS) {
                const lruKey = map.keys().next().value;
                map.delete(lruKey);
            }
            bucket = { timestamps: [] };
            map.set(key, bucket);
        }
        else {
            // Promote to MRU position via delete + re-set (O(1)).
            map.delete(key);
            prune(bucket, cutoff);
            map.set(key, bucket);
        }
        return bucket;
    }
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function prune(bucket, cutoff) {
    // Remove timestamps older than the rolling window cutoff
    let i = 0;
    while (i < bucket.timestamps.length && bucket.timestamps[i] <= cutoff)
        i++;
    if (i > 0)
        bucket.timestamps.splice(0, i);
}
