/**
 * Credential helpers — password hashing, magic-link auth, email verification, WebAuthn stubs.
 *
 * Consolidates password.ts, magic_link.ts, email_verification.ts, and webauthn.ts.
 */

// ─── Imports ──────────────────────────────────────────────────────────────────

import { eq } from 'drizzle-orm';
import { bytesToHex } from '@/lib/encoding.js';
import type { DrizzleClient } from '@/server/db/client.js';
import { magicLinks } from '@/server/db/schema.js';
import { bumpSessionVersion } from '@/server/auth/session.js';
import { encrypt } from '@/server/db/crypto.js';
import * as evQ from '@/server/db/queries/email_verifications.js';
import * as magicLinkQ from '@/server/db/queries/magic_links.js';
import { updatePasswordHash } from '@/server/db/queries/users.js';
import { captureCaught } from '@/server/observability/capture.server.js';
import type { MultidealEnv } from '@/server/env.js';
import { verifyPasswordViaDO } from '@/server/do-client.js';

// ─── Shared token helpers ─────────────────────────────────────────────────────

function generateToken(): string {
  const bytes = new Uint8Array(32);
  crypto.getRandomValues(bytes);
  return bytesToHex(bytes);
}

async function hashToken(raw: string): Promise<string> {
  const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw));
  return bytesToHex(buf);
}

// ─── Password ─────────────────────────────────────────────────────────────────

/**
 * PBKDF2 password hashing — Web Crypto API only.
 * Stored format: "pbkdf2:<saltHex>:<hashHex>"
 */

const ITERATIONS = 100_000;
const SALT_BYTES = 16;
const HASH_BITS = 256;

function randomBytes(n: number): Uint8Array<ArrayBuffer> {
  const bytes = new Uint8Array(n);
  crypto.getRandomValues(bytes);
  return bytes;
}

function toHex(buf: ArrayBuffer | Uint8Array): string {
  return bytesToHex(buf);
}

function fromHex(hex: string): Uint8Array<ArrayBuffer> {
  // Strict: reject odd-length or non-hex input so it can't silently mis-decode
  // (parseInt would coerce "1g"→1 / "zz"→NaN). Callers on the verify path wrap
  // this in try/catch and fail closed.
  if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) {
    throw new Error('fromHex: invalid hex input');
  }
  const result = new Uint8Array(hex.length / 2);
  for (let i = 0; i < hex.length; i += 2) {
    result[i / 2] = parseInt(hex.slice(i, i + 2), 16);
  }
  return result as Uint8Array<ArrayBuffer>;
}

function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) {
    diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }
  return diff === 0;
}

/** Run PBKDF2 over the password+salt and return the RAW derived bytes (ArrayBuffer). */
async function derivePbkdf2Bits(
  password: string,
  salt: Uint8Array<ArrayBuffer>,
): Promise<ArrayBuffer> {
  const keyMaterial = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(password),
    'PBKDF2',
    false,
    ['deriveBits'],
  );
  return crypto.subtle.deriveBits(
    { name: 'PBKDF2', salt, iterations: ITERATIONS, hash: 'SHA-256' },
    keyMaterial,
    HASH_BITS,
  );
}

/**
 * After-KDF pepper: HMAC-SHA256 over the raw derived KDF bytes, keyed by the
 * hex-decoded pepper. Returns the HMAC as a lowercase hex string.
 *
 * MUST stay byte-identical to the at-rest pgcrypto migration:
 *   hmac(decode(hashHex,'hex'), decode(:pepperHex,'hex'), 'sha256')
 * i.e. message = raw derived bytes, key = raw (hex-decoded) pepper bytes.
 */
async function hmacPepper(derivedBits: ArrayBuffer, pepperHex: string): Promise<string> {
  const key = await crypto.subtle.importKey(
    'raw',
    // HMAC key — raw bytes — must match pgcrypto decode(:pepperHex,'hex'). Hex-decode
    // the pepper secret; do NOT pass the hex string straight in.
    fromHex(pepperHex),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );
  // HMAC message — raw bytes — must match pgcrypto decode(hashHex,'hex'). Sign the
  // raw deriveBits ArrayBuffer, NOT the hex string of it.
  const sig = await crypto.subtle.sign('HMAC', key, derivedBits);
  return toHex(sig);
}

/**
 * Produce a peppered password hash in the current format: `pep1:<saltHex>:<hmacHex>`,
 * where hmacHex = HMAC-SHA256(key = hex-decoded pepper, message = raw PBKDF2 output).
 *
 * @param pepperHex `PASSWORD_PEPPER_V1` (CF secret binding) as a hex string.
 */
export async function hashPassword(password: string, pepperHex: string): Promise<string> {
  const salt = randomBytes(SALT_BYTES);
  const derived = await derivePbkdf2Bits(password, salt);
  const hmacHex = await hmacPepper(derived, pepperHex);
  return `pep1:${toHex(salt)}:${hmacHex}`;
}

/**
 * Persist a new password hash and bump sessionVersion to revoke all access JWTs.
 * Call from password-change and password-reset flows.
 */
export async function updatePassword(
  db: DrizzleClient,
  userId: string,
  newPassword: string,
  pepperHex: string,
): Promise<void> {
  const passwordHash = await hashPassword(newPassword, pepperHex);
  await updatePasswordHash(db, userId, passwordHash);
  await bumpSessionVersion(db, userId);
}

/**
 * Verify a password against a stored hash. Dispatches on the format prefix:
 *   - `pep1:<saltHex>:<hmacHex>`   — peppered (current). Recompute PBKDF2, then
 *     HMAC the raw derived bytes with the hex-decoded pepper, compare to hmacHex.
 *   - `pbkdf2:<saltHex>:<hashHex>` — legacy, unpeppered. Kept for back-compat during
 *     rollout / before the at-rest migration; pepper is not used on this branch.
 * Unknown prefixes return false. The PBKDF2 derive runs regardless of prefix so the
 * comparison work is uniform.
 *
 * @param pepperHex `PASSWORD_PEPPER_V1` (CF secret binding) as a hex string.
 */
export async function verifyPassword(
  password: string,
  stored: string,
  pepperHex: string,
): Promise<boolean> {
  const parts = stored.split(':');
  if (parts.length !== 3) return false;
  const [scheme, saltHex, expectedHex] = parts;

  // Fail closed on ANY malformed/unknown stored value: a corrupt salt (odd-length
  // or non-hex), a bad pepperHex, or an unknown scheme must return false, never throw.
  try {
    const salt = fromHex(saltHex!);
    const derived = await derivePbkdf2Bits(password, salt);

    if (scheme === 'pep1') {
      const hmacHex = await hmacPepper(derived, pepperHex);
      return timingSafeEqual(hmacHex, expectedHex!);
    }
    if (scheme === 'pbkdf2') {
      return timingSafeEqual(toHex(derived), expectedHex!);
    }
    return false;
  } catch (err) {
    // Malformed/unknown stored value (e.g. corrupt salt) — log as a data-integrity
    // signal and fail closed. The error carries no password/hash material.
    captureCaught(err, { scope: 'auth.verifyPassword', severity: 'warning' });
    return false;
  }
}

/** PHC-style argon2id hashes use `$` separators, e.g. `argon2id$v=19$...`. */
export const ARGON2ID_PREFIX = 'argon2id$';

/**
 * Verify a password, routing on the stored hash's scheme:
 *   - `argon2id$...` (PHC) → delegate to PasswordHashDO via verifyPasswordViaDO.
 *     PHC uses `$` separators, so it MUST be detected before the 3-part `:`-split
 *     in verifyPassword (which would otherwise reject it as malformed).
 *     A DO failure THROWS (PasswordHashDoError) — callers surface it as 503; it is
 *     never coerced into a wrong-password.
 *   - everything else (`pep1:` / `pbkdf2:` / unknown) → existing verifyPassword,
 *     which is left fully intact for the non-DO schemes.
 *
 * @param userId   keys the per-user PasswordHashDO instance (idFromName).
 */
export async function verifyPasswordSmart(
  env: MultidealEnv,
  userId: string,
  password: string,
  stored: string,
): Promise<boolean> {
  if (stored.startsWith(ARGON2ID_PREFIX)) {
    // Throws PasswordHashDoError on DO outage — intentionally not caught here.
    return verifyPasswordViaDO(env, userId, password, stored);
  }
  return verifyPassword(password, stored, env.PASSWORD_PEPPER_V1);
}

// ─── Magic Link ───────────────────────────────────────────────────────────────

/**
 * Magic-link authentication for guest → user conversion (FDS §2.1).
 * Raw token is NEVER stored — only SHA-256 hash.
 */

const MAGIC_LINK_TTL_MS = 60 * 60 * 1000;
const MAGIC_LINK_BASE_PATH = '/register-from-magic-link';

export interface IssueMagicLinkEnv {
  PII_KEY: string;
  PUBLIC_SITE_URL: string;
}

export interface MagicLinkPreFill {
  email: string;
  name?: string;
  phone?: string;
}

export interface ConsumeMagicLinkResult {
  preFill: MagicLinkPreFill;
  linkId: string;
}

export class MagicLinkError extends Error {
  constructor(
    public readonly code: 'not_found' | 'expired' | 'already_used',
    message: string,
  ) {
    super(message);
    this.name = 'MagicLinkError';
  }
}

export async function issueMagicLink(
  db: DrizzleClient,
  env: IssueMagicLinkEnv,
  email: string,
  preFill: MagicLinkPreFill,
  purchaseId?: string,
  redirectPath?: string,
): Promise<string> {
  const token = generateToken();
  const tokenHash = await hashToken(token);
  const expiresAt = new Date(Date.now() + MAGIC_LINK_TTL_MS);

  const linkId = crypto.randomUUID();
  await magicLinkQ.create(db, {
    id: linkId,
    tokenHash,
    emailEncrypted: encrypt(email, env.PII_KEY) as unknown as string,
    preFill: { ...preFill, purchaseId: purchaseId ?? null } as Record<string, unknown>,
    expiresAt,
  });

  const url = new URL(MAGIC_LINK_BASE_PATH, env.PUBLIC_SITE_URL);
  url.searchParams.set('token', token);
  if (redirectPath && redirectPath.startsWith('/') && !redirectPath.startsWith('//')) {
    url.searchParams.set('redirect', redirectPath);
  }
  return url.toString();
}

export async function consumeMagicLink(
  db: DrizzleClient,
  token: string,
): Promise<ConsumeMagicLinkResult> {
  const tokenHash = await hashToken(token);
  const now = new Date();

  const rows = await db
    .select()
    .from(magicLinks)
    .where(eq(magicLinks.tokenHash, tokenHash))
    .limit(1);
  const link = rows[0];

  if (!link) throw new MagicLinkError('not_found', 'Magic link not found');
  if (link.expiresAt <= now) throw new MagicLinkError('expired', 'Magic link has expired');
  if (link.usedAt !== null && link.usedAt !== undefined)
    throw new MagicLinkError('already_used', 'Magic link has already been used');

  await magicLinkQ.consume(db, tokenHash);

  return {
    preFill: link.preFill as MagicLinkPreFill,
    linkId: link.id,
  };
}

// ─── Email Verification ───────────────────────────────────────────────────────

/**
 * Email-verification token issuer + consumer.
 * 24h TTL, single-use. SHA-256 hash stored, raw token emailed.
 */

const EV_TTL_MS = 24 * 60 * 60 * 1000;
const VERIFY_PATH = '/verify-email';

export type EmailVerificationPurpose = 'signup' | 'change';

export interface CreateTokenOpts {
  userId: string;
  email: string;
  purpose: EmailVerificationPurpose;
  baseUrl: string;
  piiKey: string;
}

export interface CreateTokenResult {
  verifyUrl: string;
  expiryIso: string;
}

export type ConsumeTokenResult =
  | { ok: true; userId: string; email: string; purpose: EmailVerificationPurpose }
  | { ok: false; reason: 'invalid' | 'expired' | 'consumed' };

export async function createToken(
  db: DrizzleClient,
  opts: CreateTokenOpts,
): Promise<CreateTokenResult> {
  const raw = generateToken();
  const tokenHash = await hashToken(raw);
  const expiresAt = new Date(Date.now() + EV_TTL_MS);
  const emailEncrypted = encrypt(opts.email.toLowerCase(), opts.piiKey) as unknown as string;

  await evQ.insert(db, {
    userId: opts.userId,
    tokenHash,
    emailEncrypted,
    purpose: opts.purpose,
    expiresAt,
  });

  const url = new URL(VERIFY_PATH, opts.baseUrl);
  url.searchParams.set('token', raw);
  return { verifyUrl: url.toString(), expiryIso: expiresAt.toISOString() };
}

export async function consumeToken(
  db: DrizzleClient,
  rawToken: string,
  piiKey: string,
): Promise<ConsumeTokenResult> {
  const tokenHash = await hashToken(rawToken);
  const row = await evQ.findByTokenHashWithEmail(db, tokenHash, piiKey);
  if (!row) return { ok: false, reason: 'invalid' };
  if (row.expiresAt.getTime() <= Date.now()) return { ok: false, reason: 'expired' };
  if (row.consumedAt) return { ok: false, reason: 'consumed' };
  await evQ.markConsumed(db, row.id, new Date());
  return { ok: true, userId: row.userId, email: row.email, purpose: row.purpose };
}

// ─── WebAuthn (stubs) ─────────────────────────────────────────────────────────

export interface WebAuthnCredential {
  id: string;
  userId: string;
  credentialId: string;
  publicKey: string;
  counter: number;
  createdAt: Date;
}

export interface WebAuthnChallengeRecord {
  id: string;
  userId: string;
  challenge: string;
  createdAt: Date;
}

export interface WebAuthnVerifyRegistrationResult {
  ok: boolean;
  credentialId?: string;
  publicKey?: string;
  counter?: number;
  error?: string;
}

export interface WebAuthnVerifyAuthenticationResult {
  ok: boolean;
  newCounter?: number;
  error?: string;
}

/** Returns false until WebAuthn passkey auth is implemented. */
export function isAdminWebAuthnEnabled(): boolean {
  return false;
}

/**
 * WebAuthn helpers are not yet implemented.
 * All functions below return a structured "not_supported" result so callers
 * can surface a 501 response instead of an uncaught exception.
 */

export class WebAuthnNotSupportedError extends Error {
  constructor() {
    super('webauthn_not_supported');
    this.name = 'WebAuthnNotSupportedError';
  }
}

export async function generateRegistrationOptions(
  _userId: string,
  _userName: string,
): Promise<never> {
  throw new WebAuthnNotSupportedError();
}

export async function verifyRegistrationResponse(
  _response: unknown,
  _expectedChallenge: string,
): Promise<never> {
  throw new WebAuthnNotSupportedError();
}

export async function generateAuthenticationOptions(_userId: string): Promise<never> {
  throw new WebAuthnNotSupportedError();
}

export async function verifyAuthenticationResponse(
  _response: unknown,
  _expectedChallenge: string,
  _userId: string,
): Promise<never> {
  throw new WebAuthnNotSupportedError();
}
