/**
 * LoginThrottleService
 *
 * Horizontally-safe, database-backed login throttle.
 * All API replicas share the same throttle budget via a MySQL row
 * in `login_throttle_buckets`, keyed by a SHA-256 digest of
 * the normalised username + "|" + ip.
 *
 * Budget: MAX_ATTEMPTS atomic reservations within WINDOW_MS → locked.
 * Successful login clears the bucket.
 *
 * Fail-closed: any DB error throws a ServiceUnavailableException
 * rather than silently falling back to process-local state.
 */

import {
  Injectable,
  ForbiddenException,
  ServiceUnavailableException,
  Logger,
} from '@nestjs/common';
import { createHash } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';

const MAX_ATTEMPTS = 8;
const WINDOW_MS = 1000 * 60 * 15; // 15 minutes

@Injectable()
export class LoginThrottleService {
  private readonly logger = new Logger(LoginThrottleService.name);

  constructor(private readonly prisma: PrismaService) {}

  /**
   * Build the bucket key: SHA-256 hex of "username_lower|ip".
   * Keeps PII out of the primary key while still being deterministic.
   */
  bucketKey(username: string, ip?: string): string {
    const raw = `${username.toLowerCase()}|${ip ?? 'unknown'}`;
    return createHash('sha256').update(raw).digest('hex');
  }

  /**
   * Atomically reserve one password-verification attempt for the
   * (username, ip) pair before any credential lookup or hash verification.
   *
   * The upsert and count read share one DB transaction/connection. MySQL's
   * unique-key row lock serializes concurrent replicas, so only the first
   * MAX_ATTEMPTS reservations in a window can proceed. The stored counter is
   * capped at MAX_ATTEMPTS + 1 to avoid unbounded growth while locked.
   *
   * Successful authentication clears the reservation bucket. Invalid
   * credentials leave it in place. DB errors fail closed.
   */
  async reserveAttempt(username: string, ip?: string): Promise<void> {
    const key = this.bucketKey(username, ip);
    const now = new Date();
    const windowMs = BigInt(WINDOW_MS);
    let count: number;

    try {
      count = await this.prisma.$transaction(async (tx) => {
        await tx.$executeRaw`
          INSERT INTO login_throttle_buckets
            (bucket_key, count, window_start, updated_at)
          VALUES
            (${key}, 1, ${now}, ${now})
          ON DUPLICATE KEY UPDATE
            count        = IF(
                             TIMESTAMPDIFF(MICROSECOND, window_start, ${now}) > ${windowMs} * 1000,
                             1,
                             LEAST(count + 1, ${MAX_ATTEMPTS + 1})
                           ),
            window_start = IF(
                             TIMESTAMPDIFF(MICROSECOND, window_start, ${now}) > ${windowMs} * 1000,
                             ${now},
                             window_start
                           ),
            updated_at   = ${now}
        `;
        const rows = await tx.$queryRaw<Array<{ count: number }>>`
          SELECT count
          FROM login_throttle_buckets
          WHERE bucket_key = ${key}
          LIMIT 1
          FOR UPDATE
        `;
        if (!rows[0]) throw new Error('Throttle reservation row missing');
        return Number(rows[0].count);
      });
    } catch (err) {
      this.logger.error('Throttle reserveAttempt DB failure (fail-closed)', err);
      throw new ServiceUnavailableException(
        'Authentication service temporarily unavailable. Please try again.',
      );
    }

    if (count > MAX_ATTEMPTS) {
      throw new ForbiddenException('Too many login attempts. Try again later.');
    }
  }

  /**
   * Delete the throttle bucket for the (username, ip) pair on successful login.
   * Throws ServiceUnavailableException on DB failure (fail-closed).
   */
  async clearBucket(username: string, ip?: string): Promise<void> {
    const key = this.bucketKey(username, ip);
    try {
      await this.prisma.$executeRaw`
        DELETE FROM login_throttle_buckets WHERE bucket_key = ${key}
      `;
    } catch (err) {
      this.logger.error('Throttle clearBucket DB failure (fail-closed)', err);
      throw new ServiceUnavailableException(
        'Authentication service temporarily unavailable. Please try again.',
      );
    }
  }
}
