import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";

/**
 * Password hashing on Node's built-in scrypt — memory-hard, no native module,
 * no extra dependency. Digests are self-describing (`scrypt$N$r$p$salt$hash`)
 * so the cost parameters can be raised later without invalidating old hashes.
 */

const scrypt = promisify(scryptCallback) as (
  password: string,
  salt: Buffer,
  keylen: number,
  options: { N: number; r: number; p: number; maxmem: number }
) => Promise<Buffer>;

const KEY_LENGTH = 64;
// N=2^17, r=8, p=1 costs ~128 MiB per hash (128*N*r bytes) — OWASP's baseline
// scrypt configuration. maxmem is only an upper-bound guard (not the actual
// cost, which comes from the params embedded in each stored hash), so raising
// it here also safely covers verification of older, cheaper hashes.
const PARAMS = { N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };

export async function hashPassword(password: string): Promise<string> {
  const salt = randomBytes(16);
  const derived = await scrypt(password.normalize("NFKC"), salt, KEY_LENGTH, PARAMS);
  return [
    "scrypt",
    PARAMS.N,
    PARAMS.r,
    PARAMS.p,
    salt.toString("base64"),
    derived.toString("base64"),
  ].join("$");
}

export async function verifyPassword(password: string, stored: string | null): Promise<boolean> {
  if (!stored) return false;

  const parts = stored.split("$");
  if (parts.length !== 6 || parts[0] !== "scrypt") return false;

  const [, rawN, rawR, rawP, rawSalt, rawHash] = parts;
  const N = Number(rawN);
  const r = Number(rawR);
  const p = Number(rawP);
  if (!Number.isFinite(N) || !Number.isFinite(r) || !Number.isFinite(p)) return false;

  const salt = Buffer.from(rawSalt, "base64");
  const expected = Buffer.from(rawHash, "base64");

  try {
    const derived = await scrypt(password.normalize("NFKC"), salt, expected.length, {
      N,
      r,
      p,
      maxmem: PARAMS.maxmem,
    });
    return derived.length === expected.length && timingSafeEqual(derived, expected);
  } catch {
    return false;
  }
}

/**
 * Burns roughly one hash worth of time. Called when no account matches, so a
 * wrong email and a wrong password take the same wall-clock time and the login
 * endpoint cannot be used to enumerate which addresses are registered.
 */
export async function equaliseTiming(): Promise<void> {
  await scrypt("timing-equaliser", Buffer.alloc(16), KEY_LENGTH, PARAMS).catch(() => undefined);
}
