import { createHash } from 'node:crypto';
import { isIP } from 'node:net';

/**
 * 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));
}

/** Key used when no trustworthy client address is available: such requests share one budget. */
export const UNKNOWN_CLIENT = 'unknown';

/**
 * TRUST_PROXY, parsed exactly like the game server (apps/game-server/src/config.ts):
 * unset/0/false → 0 hops; 1/true → 1 (one load balancer, e.g. Render/Fly); N → N.
 */
export function trustProxyHops(raw: string | undefined = process.env.TRUST_PROXY): number {
  if (!raw || raw === 'false' || raw === '0') return 0;
  if (raw === 'true') return 1;
  const n = Number(raw);
  return Number.isInteger(n) && n > 0 && n < 10 ? n : 1;
}

function headerList(value: string | null): string[] {
  return (value ?? '')
    .split(',')
    .map((s) => s.trim())
    .filter(Boolean);
}

/**
 * The client address used for every per-IP limit and IP hash in the web app.
 *
 * - TRUST_PROXY=N (N ≥ 1): each trusted proxy APPENDS the address it saw to X-Forwarded-For, so the
 *   client is the N-th entry from the right. The left-most entries are whatever the client sent.
 * - TRUST_PROXY unset/0: X-Forwarded-For is ignored entirely (anyone can send it). Only headers a
 *   platform overwrites are read — `x-vercel-forwarded-for` / `x-real-ip` (Vercel; nginx with
 *   `proxy_set_header X-Real-IP`) — and without them every request shares the 'unknown' key
 *   (fail closed: limits get stricter, never bypassable).
 *
 * The result is a rate-limit key, not a raw address: IPv6 is reduced to its /64 (one subscriber
 * usually owns a whole /64, so per-address keys would be free to rotate) and IPv4-mapped IPv6 to
 * plain IPv4. Anything that does not parse as an IP becomes 'unknown'.
 */
export function clientIp(headers: Pick<Headers, 'get'>, hops: number = trustProxyHops()): string {
  if (hops > 0) {
    const entries = headerList(headers.get('x-forwarded-for'));
    const candidate = entries[entries.length - hops];
    if (candidate) return ipKey(candidate);
  }
  // Single-value platform headers; if a list ever arrives, the right-most entry is the one the
  // platform itself added.
  for (const name of ['x-vercel-forwarded-for', 'x-real-ip']) {
    const entries = headerList(headers.get(name));
    const last = entries[entries.length - 1];
    if (last) return ipKey(last);
  }
  return UNKNOWN_CLIENT;
}

/** Strip brackets, a port and an IPv6 zone id: "[2001:db8::1%eth0]:443" → "2001:db8::1". */
function bareAddress(raw: string): string {
  let s = raw.trim();
  const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(s);
  if (bracketed) s = bracketed[1] ?? '';
  else if (/^\d{1,3}(\.\d{1,3}){3}:\d+$/.test(s)) s = s.slice(0, s.lastIndexOf(':')); // IPv4 with port
  const zone = s.indexOf('%');
  return zone === -1 ? s : s.slice(0, zone);
}

/** The 8 hextets of a valid IPv6 address (validated by the caller with isIP). */
function ipv6Hextets(address: string): number[] {
  let s = address.toLowerCase();
  // Trailing dotted IPv4 ("::ffff:192.0.2.1") → two hextets.
  const v4 = /(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s);
  if (v4) {
    const [a, b, c, d] = v4.slice(1).map(Number) as [number, number, number, number];
    s = `${s.slice(0, v4.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
  }
  const [head = '', tail] = s.split('::');
  const left = head ? head.split(':') : [];
  const right = tail ? tail.split(':') : [];
  const fill = tail === undefined ? [] : new Array<string>(8 - left.length - right.length).fill('0');
  return [...left, ...fill, ...right].map((h) => parseInt(h, 16));
}

/** Normalise one address to its rate-limit key (IPv4 as-is, IPv6 as its /64, junk → 'unknown'). */
export function ipKey(raw: string): string {
  const address = bareAddress(raw);
  const family = isIP(address);
  if (family === 4) return address;
  if (family !== 6) return UNKNOWN_CLIENT;
  const h = ipv6Hextets(address);
  // IPv4-mapped (::ffff:a.b.c.d): the same client as a.b.c.d.
  if (h.slice(0, 5).every((x) => x === 0) && h[5] === 0xffff) {
    const hi = h[6] ?? 0;
    const lo = h[7] ?? 0;
    return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
  }
  return `${h
    .slice(0, 4)
    .map((x) => x.toString(16))
    .join(':')}::/64`;
}

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 };
  }

  /** Hits for `key` currently inside the window, without counting one. */
  count(key: string, now: number): number {
    const cutoff = now - this.windowMs;
    return (this.hits.get(key) ?? []).filter((t) => t > cutoff).length;
  }

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