// Per-email login lockout abstraction with two implementations:
// - memory: per-process Map (the original routes/auth.ts behavior)
// - redis: shared counter across all instances so the lockout cannot be
//   bypassed by round-robining replicas
//
// Semantics: recordFailure starts a window on the first failure; once the
// failure count reaches the max the email is locked until the window expires;
// clear removes the counter on successful login. Selection is based on
// REDIS_URL. The redis implementation fails open (never locked, failures not
// recorded) — the per-IP auth rate limit remains as a backstop during a
// Redis outage.

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

export const MAX_LOGIN_ATTEMPTS = 5;
export const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutes

export interface LockoutStatus {
  locked: boolean;
  // Seconds until the lockout lifts; only set when locked.
  retryAfter?: number;
}

export interface LoginLockout {
  readonly backend: 'memory' | 'redis';
  isLocked(email: string): Promise<LockoutStatus>;
  recordFailure(email: string): Promise<void>;
  clear(email: string): Promise<void>;
}

// Emails are compared case-insensitively so "User@x.com" and "user@x.com"
// share one counter.
function normalize(email: string): string {
  return email.trim().toLowerCase();
}

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

export class MemoryLoginLockout implements LoginLockout {
  readonly backend = 'memory' as const;
  private attempts = new Map<string, { count: number; resetAt: number }>();

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

  async isLocked(email: string): Promise<LockoutStatus> {
    const entry = this.attempts.get(normalize(email));
    const now = Date.now();
    if (!entry || now > entry.resetAt) return { locked: false };
    if (entry.count >= MAX_LOGIN_ATTEMPTS) {
      return { locked: true, retryAfter: Math.ceil((entry.resetAt - now) / 1000) };
    }
    return { locked: false };
  }

  async recordFailure(email: string): Promise<void> {
    const key = normalize(email);
    const now = Date.now();
    const entry = this.attempts.get(key);
    if (!entry || now > entry.resetAt) {
      this.attempts.set(key, { count: 1, resetAt: now + LOCKOUT_DURATION_MS });
      return;
    }
    entry.count++;
  }

  async clear(email: string): Promise<void> {
    this.attempts.delete(normalize(email));
  }
}

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

// Same atomic INCR+PEXPIRE shape as the rate limiter's consume script: the
// window starts at the first failure and any TTL-less counter is repaired.
const RECORD_FAILURE_SCRIPT = `
local count = redis.call('INCR', KEYS[1])
if count == 1 then
  redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
if redis.call('PTTL', KEYS[1]) < 0 then
  redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
return count
`;

type RedisWithLockout = Redis & {
  lockoutRecordFailure(key: string, windowMs: number): Promise<number>;
};

function withLockoutCommand(redis: Redis): RedisWithLockout {
  if (typeof (redis as any).lockoutRecordFailure !== 'function') {
    redis.defineCommand('lockoutRecordFailure', { numberOfKeys: 1, lua: RECORD_FAILURE_SCRIPT });
  }
  return redis as RedisWithLockout;
}

export class RedisLoginLockout implements LoginLockout {
  readonly backend = 'redis' as const;

  private key(email: string): string {
    return `lockout:${normalize(email)}`;
  }

  async isLocked(email: string): Promise<LockoutStatus> {
    const redis = getRedis();
    if (!redis) return { locked: false };
    try {
      const [count, ttl] = await Promise.all([
        redis.get(this.key(email)),
        redis.pttl(this.key(email)),
      ]);
      if (count !== null && parseInt(count, 10) >= MAX_LOGIN_ATTEMPTS) {
        const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(LOCKOUT_DURATION_MS / 1000);
        return { locked: true, retryAfter };
      }
      return { locked: false };
    } catch (err: any) {
      // Fail open: the per-IP auth rate limit still applies.
      console.error('[loginLockout] redis error, treating as unlocked:', err?.message || err);
      return { locked: false };
    }
  }

  async recordFailure(email: string): Promise<void> {
    const redis = getRedis();
    if (!redis) return;
    try {
      await withLockoutCommand(redis).lockoutRecordFailure(this.key(email), LOCKOUT_DURATION_MS);
    } catch (err: any) {
      console.error('[loginLockout] redis error recording failure:', err?.message || err);
    }
  }

  async clear(email: string): Promise<void> {
    const redis = getRedis();
    if (!redis) return;
    try {
      await redis.del(this.key(email));
    } catch (err: any) {
      console.error('[loginLockout] redis error clearing failures:', err?.message || err);
    }
  }
}

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

let instance: LoginLockout | null = null;

export function getLoginLockout(): LoginLockout {
  if (!instance) {
    instance = isRedisEnabled() ? new RedisLoginLockout() : new MemoryLoginLockout();
  }
  return instance;
}
