/**
 * In-memory login throttle for the local-auth endpoints.
 *
 * Local login (`POST /auth/local`) had no rate limiting, so an attacker could
 * script unlimited online password guesses. This adds a per-key (IP and
 * account) failure counter with temporary lockout. scrypt verification already
 * makes each guess costly; this caps the attempt rate on top.
 *
 * Scope: process-local. The orchestrator's default deployment is single-process,
 * so a Map is sufficient and avoids a new dependency. A multi-process / HA
 * deployment behind a load balancer would need a shared store (Redis) — noted
 * for operators; not implemented here.
 */
export interface LoginRateLimitOptions {
  /** Failures allowed within the window before lockout kicks in. */
  maxAttempts: number;
  /** Sliding window for counting failures (ms). */
  windowMs: number;
  /** How long a key stays locked out after exceeding maxAttempts (ms). */
  lockoutMs: number;
  /**
   * Hard cap on tracked keys (memory-DoS bound). When exceeded, expired entries
   * are swept; if still over, the oldest entry is evicted. Default 10000.
   */
  maxKeys?: number;
  /** Injectable clock for tests. Defaults to Date.now. */
  now?: () => number;
}

interface Attempt {
  count: number;
  firstAt: number;
  blockedUntil: number;
}

export class LoginRateLimiter {
  private readonly byKey = new Map<string, Attempt>();
  private readonly opts: Required<LoginRateLimitOptions>;

  constructor(opts: LoginRateLimitOptions) {
    this.opts = { now: () => Date.now(), maxKeys: 10_000, ...opts };
  }

  private now(): number {
    return this.opts.now();
  }

  /** Remaining lockout in ms for this key, or 0 if it may proceed. */
  retryAfterMs(key: string): number {
    const a = this.byKey.get(key);
    if (!a) return 0;
    const now = this.now();
    if (a.blockedUntil > now) return a.blockedUntil - now;
    return 0;
  }

  /** True when any of the supplied keys is currently locked out. */
  isLocked(...keys: string[]): boolean {
    return keys.some((k) => this.retryAfterMs(k) > 0);
  }

  /** Record a failed attempt for a key; trips the lockout past the threshold. */
  recordFailure(key: string): void {
    const now = this.now();
    let a = this.byKey.get(key);
    // Never replace an entry whose lockout is still active: with
    // lockoutMs > windowMs, a failure after the window expired would otherwise
    // reset blockedUntil to 0 and silently clear the lockout. (Callers gate on
    // isLocked() first, but the class must stay safe without that gate.)
    if (!a || (now - a.firstAt > this.opts.windowMs && a.blockedUntil <= now)) {
      // New/expired key: enforce the memory bound before inserting.
      if (!a && this.byKey.size >= this.opts.maxKeys) this.evictToBound();
      a = { count: 0, firstAt: now, blockedUntil: 0 };
      this.byKey.set(key, a);
    }
    a.count += 1;
    if (a.count >= this.opts.maxAttempts) {
      a.blockedUntil = now + this.opts.lockoutMs;
    }
  }

  /** Sweep expired entries; if still at/over the cap, evict the oldest. */
  private evictToBound(): void {
    this.sweep();
    while (this.byKey.size >= this.opts.maxKeys) {
      // Map preserves insertion order, so the first key is the oldest.
      const oldest = this.byKey.keys().next().value;
      if (oldest === undefined) break;
      this.byKey.delete(oldest);
    }
  }

  /** Clear a key's failure record (call on a successful login). */
  reset(key: string): void {
    this.byKey.delete(key);
  }

  /** Drop stale entries; call periodically to bound memory. */
  sweep(): void {
    const now = this.now();
    for (const [k, a] of this.byKey) {
      if (a.blockedUntil <= now && now - a.firstAt > this.opts.windowMs) {
        this.byKey.delete(k);
      }
    }
  }
}

/**
 * Throttle scope for a client IP.
 *
 * IPv4 (and 'unknown' / IPv4-mapped IPv6) key on the full address — one host.
 * Raw IPv6 keys on the /64 prefix: a residential/cloud client is typically
 * allocated a whole /64 (2^64 addresses) and can rotate the source address per
 * request, which would defeat a full-address throttle. Collapsing to /64 keeps
 * the limit meaningful against that rotation while not over-blocking unrelated
 * networks (a /64 is a single subscriber's allocation).
 */
export function throttleScopeForIp(ip: string): string {
  const bare = (ip || '').split('%')[0].toLowerCase(); // strip zone id
  // IPv4, 'unknown', or IPv4-mapped IPv6 (contains a dotted quad): key whole.
  if (!bare.includes(':') || bare.includes('.')) return bare;
  // Expand a '::' run so we can read the leading 4 hextets (the /64 network).
  let groups: string[];
  if (bare.includes('::')) {
    const [l, r] = bare.split('::');
    const left = l ? l.split(':') : [];
    const right = r ? r.split(':') : [];
    const fill = Math.max(0, 8 - left.length - right.length);
    groups = [...left, ...Array(fill).fill('0'), ...right];
  } else {
    groups = bare.split(':');
  }
  if (groups.length < 4) return bare; // malformed — key the whole address
  return groups.slice(0, 4).map((g) => g || '0').join(':') + '::/64';
}
