// Rate limiter abstraction with two implementations:
// - memory: per-process fixed window (the original behavior)
// - redis: shared fixed window across all instances, implemented as a single
//   Lua script so INCR and PEXPIRE are atomic (a crash between separate calls
//   would otherwise strand a counter with no expiry)
//
// Selection happens once based on REDIS_URL. On any Redis error the limiter
// fails open (allows the request) so a Redis blip never takes the API down.

import type Redis from 'ioredis';
import { getRedis, isRedisEnabled } from '../redis.js';

export interface RateLimitResult {
  allowed: boolean;
  retryAfter?: number;
}

export interface RateLimiter {
  readonly backend: 'memory' | 'redis';
  consume(key: string, max: number, windowMs: number): Promise<RateLimitResult>;
}

// ==================== Memory implementation ====================

interface Bucket {
  count: number;
  resetAt: number;
}

export class MemoryRateLimiter implements RateLimiter {
  readonly backend = 'memory' as const;
  private buckets = new Map<string, Bucket>();

  constructor() {
    // Periodically drop expired buckets so the Map does not grow unbounded.
    const cleanup = setInterval(() => {
      const now = Date.now();
      for (const [key, bucket] of this.buckets) {
        if (now > bucket.resetAt) this.buckets.delete(key);
      }
    }, 60_000);
    (cleanup as any).unref?.();
  }

  async consume(key: string, max: number, windowMs: number): Promise<RateLimitResult> {
    const now = Date.now();
    const bucket = this.buckets.get(key);

    if (!bucket || now > bucket.resetAt) {
      this.buckets.set(key, { count: 1, resetAt: now + windowMs });
      return { allowed: true };
    }

    bucket.count++;
    if (bucket.count > max) {
      return { allowed: false, retryAfter: Math.ceil((bucket.resetAt - now) / 1000) };
    }
    return { allowed: true };
  }
}

// ==================== Redis implementation ====================

// Atomically increments the window counter, sets the expiry on the first hit,
// and repairs any counter left without a TTL (self-heals keys stranded by the
// pre-Lua implementation or a lost PEXPIRE). Returns {count, ttlMs}.
export const CONSUME_SCRIPT = `
local count = redis.call('INCR', KEYS[1])
if count == 1 then
  redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
local ttl = redis.call('PTTL', KEYS[1])
if ttl < 0 then
  redis.call('PEXPIRE', KEYS[1], ARGV[1])
  ttl = tonumber(ARGV[1])
end
return {count, ttl}
`;

type RedisWithConsume = Redis & {
  rlConsume(key: string, windowMs: number): Promise<[number, number]>;
};

function withConsumeCommand(redis: Redis): RedisWithConsume {
  if (typeof (redis as any).rlConsume !== 'function') {
    // ioredis caches the script SHA and transparently handles NOSCRIPT.
    redis.defineCommand('rlConsume', { numberOfKeys: 1, lua: CONSUME_SCRIPT });
  }
  return redis as RedisWithConsume;
}

export class RedisRateLimiter implements RateLimiter {
  readonly backend = 'redis' as const;

  async consume(key: string, max: number, windowMs: number): Promise<RateLimitResult> {
    const redis = getRedis();
    if (!redis) return { allowed: true };

    try {
      const [count, ttl] = await withConsumeCommand(redis).rlConsume(`rl:${key}`, windowMs);
      if (count > max) {
        const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(windowMs / 1000);
        return { allowed: false, retryAfter };
      }
      return { allowed: true };
    } catch (err: any) {
      // Fail open: never block traffic because Redis is unavailable.
      console.error('[rateLimiter] redis error, allowing request:', err?.message || err);
      return { allowed: true };
    }
  }
}

// ==================== Selection ====================

let instance: RateLimiter | null = null;

export function getRateLimiter(): RateLimiter {
  if (!instance) {
    instance = isRedisEnabled() ? new RedisRateLimiter() : new MemoryRateLimiter();
  }
  return instance;
}
