/**
 * Password hashing for venue hosts and platform admins: scrypt from node:crypto, self-describing
 * format `scrypt$N$r$p$<saltB64url>$<hashB64url>` so parameters can be raised later without a
 * migration (old hashes keep verifying with the parameters stored next to them).
 */
import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from 'node:crypto';
import type { PrismaClient } from './generated/prisma/client';

const SCRYPT_N = 16_384;
const SCRYPT_R = 8;
const SCRYPT_P = 1;
const SALT_BYTES = 16;
const KEY_BYTES = 32;
/** Generous cap so a long passphrase works but a megabyte "password" can't tie up the CPU. */
export const MAX_PASSWORD_LENGTH = 1024;

/** scrypt needs ~128*N*r bytes; refuse stored parameters that would need more than this. */
const MAX_SCRYPT_MEMORY = 64 * 1024 * 1024;
const B64URL = /^[A-Za-z0-9_-]+$/;
const INT = /^[1-9]\d{0,9}$/;

function deriveKey(password: string, salt: Buffer, keyLength: number, options: ScryptOptions): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    scrypt(password, salt, keyLength, options, (err, key) => (err ? reject(err) : resolve(key)));
  });
}

/** NFKC so the same passphrase typed on different keyboards/OSes hashes identically (NIST 800-63B). */
function normalize(password: string): string {
  return password.normalize('NFKC');
}

export async function hashPassword(password: string): Promise<string> {
  if (typeof password !== 'string' || password.length === 0) throw new TypeError('Password must be a non-empty string');
  if (password.length > MAX_PASSWORD_LENGTH) throw new RangeError(`Password must be at most ${MAX_PASSWORD_LENGTH} characters`);
  const salt = randomBytes(SALT_BYTES);
  const key = await deriveKey(normalize(password), salt, KEY_BYTES, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P });
  return ['scrypt', SCRYPT_N, SCRYPT_R, SCRYPT_P, salt.toString('base64url'), key.toString('base64url')].join('$');
}

interface ParsedHash {
  N: number;
  r: number;
  p: number;
  salt: Buffer;
  key: Buffer;
}

/** Parse a stored hash, rejecting anything malformed or with parameters outside sane bounds. */
function parseHash(stored: string): ParsedHash | null {
  if (typeof stored !== 'string') return null;
  const parts = stored.split('$');
  if (parts.length !== 6 || parts[0] !== 'scrypt') return null;
  const [, nStr, rStr, pStr, saltStr, keyStr] = parts as [string, string, string, string, string, string];
  if (!INT.test(nStr) || !INT.test(rStr) || !INT.test(pStr)) return null;
  const N = Number(nStr);
  const r = Number(rStr);
  const p = Number(pStr);
  // Power of two (scrypt requirement) within bounds that keep verification cheap enough to serve.
  if (N < 1024 || N > 1_048_576 || (N & (N - 1)) !== 0 || r > 32 || p > 16) return null;
  if (128 * N * r > MAX_SCRYPT_MEMORY) return null;
  if (!B64URL.test(saltStr) || !B64URL.test(keyStr)) return null;
  const salt = Buffer.from(saltStr, 'base64url');
  const key = Buffer.from(keyStr, 'base64url');
  // Round-trip check rejects non-canonical base64 (e.g. stray trailing bits).
  if (salt.toString('base64url') !== saltStr || key.toString('base64url') !== keyStr) return null;
  if (salt.length < SALT_BYTES || key.length < 16 || key.length > 128) return null;
  return { N, r, p, salt, key };
}

export function isPasswordHash(stored: string): boolean {
  return parseHash(stored) !== null;
}

/** Constant-time check. Returns false (never throws) for wrong passwords and malformed hashes. */
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
  if (typeof password !== 'string' || password.length === 0 || password.length > MAX_PASSWORD_LENGTH) return false;
  const parsed = parseHash(stored);
  if (!parsed) return false;
  const { N, r, p, salt, key } = parsed;
  const candidate = await deriveKey(normalize(password), salt, key.length, { N, r, p, maxmem: 2 * MAX_SCRYPT_MEMORY });
  return timingSafeEqual(candidate, key);
}

/** Lower-cased, trimmed e-mail used as the login identifier. */
export function normalizeEmail(email: string): string {
  return email.trim().toLowerCase();
}

// A real hash of a random secret: unknown e-mails still pay for one scrypt run, so response time
// doesn't reveal which addresses have accounts.
let dummyHash: Promise<string> | null = null;
function getDummyHash(): Promise<string> {
  dummyHash ??= hashPassword(randomBytes(18).toString('base64url'));
  return dummyHash;
}

async function checkCredentials<T extends { passwordHash: string }>(
  user: T | null,
  password: string,
): Promise<T | null> {
  if (!user) {
    await verifyPassword(password, await getDummyHash());
    return null;
  }
  return (await verifyPassword(password, user.passwordHash)) ? user : null;
}

/** Host login. Returns the venue user (stamping lastLoginAt) or null for any failure. */
export async function authenticateVenueUser(db: PrismaClient, email: string, password: string) {
  const user = await db.venueUser.findUnique({ where: { email: normalizeEmail(email) } });
  const ok = await checkCredentials(user, password);
  if (!ok) return null;
  return db.venueUser.update({ where: { id: ok.id }, data: { lastLoginAt: new Date() } });
}

/** Platform admin login. Returns the admin (stamping lastLoginAt) or null for any failure. */
export async function authenticatePlatformAdmin(db: PrismaClient, email: string, password: string) {
  const admin = await db.platformAdmin.findUnique({ where: { email: normalizeEmail(email) } });
  const ok = await checkCredentials(admin, password);
  if (!ok) return null;
  return db.platformAdmin.update({ where: { id: ok.id }, data: { lastLoginAt: new Date() } });
}
