import { createHash } from 'node:crypto';

/**
 * Privacy-preserving rate limiting helpers.
 * IPs are never stored: they are hashed with a secret salt plus the UTC date, so hashes rotate
 * daily and cannot be joined across days or reversed without the salt.
 */

const DAY_MS = 24 * 60 * 60 * 1000;

export function utcDay(now: number): string {
  return new Date(now).toISOString().slice(0, 10);
}

export function hashIp(ip: string, salt: string, day: string): string {
  return createHash('sha256').update(`${salt}:${day}:${ip}`).digest('hex');
}

/**
 * Every hash this IP could have had during [since, now]. A one-hour window can straddle UTC
 * midnight, so the database count must look for yesterday's hash too.
 */
export function ipHashesSince(ip: string, salt: string, since: number, now: number): string[] {
  const days = new Set<string>();
  for (let t = since; t < now; t += DAY_MS) days.add(utcDay(t));
  days.add(utcDay(now));
  return [...days].map((day) => hashIp(ip, salt, day));
}

/** First hop of X-Forwarded-For (set by the hosting proxy), then X-Real-IP. */
export function clientIp(headers: Pick<Headers, 'get'>): string {
  const forwarded = headers.get('x-forwarded-for');
  const first = forwarded?.split(',')[0]?.trim();
  if (first) return first;
  return headers.get('x-real-ip')?.trim() || 'unknown';
}

export interface LimitResult {
  allowed: boolean;
  remaining: number;
  /** Milliseconds until the oldest counted hit leaves the window (0 when allowed). */
  retryAfterMs: number;
}

/**
 * In-memory sliding-window limiter. Per process only (a first line of defence in front of the
 * database check), bounded in size so a flood of distinct keys cannot exhaust memory.
 */
export class SlidingWindowLimiter {
  private readonly hits = new Map<string, number[]>();

  constructor(
    private readonly limit: number,
    private readonly windowMs: number,
    private readonly maxKeys = 10_000,
  ) {}

  hit(key: string, now: number): LimitResult {
    const cutoff = now - this.windowMs;
    const recent = (this.hits.get(key) ?? []).filter((t) => t > cutoff);
    if (recent.length >= this.limit) {
      this.hits.set(key, recent);
      return { allowed: false, remaining: 0, retryAfterMs: Math.max(0, (recent[0] ?? now) - cutoff) };
    }
    recent.push(now);
    // Re-insert so Map order tracks recency; evict the stalest key when full.
    this.hits.delete(key);
    this.hits.set(key, recent);
    if (this.hits.size > this.maxKeys) {
      const oldest = this.hits.keys().next().value;
      if (oldest !== undefined) this.hits.delete(oldest);
    }
    return { allowed: true, remaining: this.limit - recent.length, retryAfterMs: 0 };
  }

  /** Number of tracked keys (for tests and diagnostics). */
  get size(): number {
    return this.hits.size;
  }
}
