import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';

/**
 * AES-256-GCM helpers for session payload encryption at rest (per
 * ADR-0010 §"At-rest encryption").
 *
 * The session middleware wires these into the `connect-redis`
 * serializer: the JSON-encoded session is encrypted with this
 * module's {@link encrypt} before it lands in Redis, and decrypted
 * on read. A Redis snapshot, RDB dump, or memory inspection
 * therefore surfaces only ciphertext — useless without the key.
 *
 * **Granularity.** ADR-0010's first cut scoped at-rest encryption
 * to a `tokens` sub-field. This implementation upgrades that to the
 * entire payload — the session also carries claims (`oid`, `tid`,
 * `preferred_username`, …) that qualify as PII under GDPR, and an
 * APF-Handicap portal handles health-adjacent data. Encrypting the
 * whole envelope removes the need to classify fields one by one and
 * costs essentially the same. The ADR text will be amended to match.
 *
 * **Format.** A single dot-delimited string with a leading version
 * tag so we can rotate the algorithm or key derivation later without
 * a flag-day re-encryption:
 *
 *   v1.<iv-b64url>.<tag-b64url>.<ciphertext-b64url>
 *
 * `v1` ⇒ AES-256-GCM with a random 96-bit IV and the 128-bit GCM
 * auth tag. Any unknown version is rejected on read.
 *
 * **Failure mode.** On any tampering, key mismatch, or malformed
 * payload, {@link decrypt} throws a {@link SessionDecryptError}. The
 * middleware treats that as "no session" — the browser sees a 401
 * and re-authenticates. (Per ADR-0010 §"Confirmation": "rejects (and
 * logs as an audit event) any record whose authentication tag fails
 * to verify".)
 */

const VERSION = 'v1';
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12; // 96 bits — the GCM-recommended IV length
const TAG_BYTES = 16; // 128 bits — full GCM auth tag

export class SessionDecryptError extends Error {
  constructor(reason: string) {
    super(`session payload could not be decrypted: ${reason}`);
    this.name = 'SessionDecryptError';
  }
}

export function encrypt(plaintext: string, key: Buffer): string {
  // A fresh random IV per encryption is the GCM correctness
  // requirement — reusing an (IV, key) pair across two messages
  // catastrophically breaks confidentiality and authentication.
  const iv = randomBytes(IV_BYTES);
  const cipher = createCipheriv(ALGORITHM, key, iv);
  const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
  const tag = cipher.getAuthTag();
  return [
    VERSION,
    iv.toString('base64url'),
    tag.toString('base64url'),
    ciphertext.toString('base64url'),
  ].join('.');
}

export function decrypt(payload: string, key: Buffer): string {
  const parts = payload.split('.');
  if (parts.length !== 4) {
    throw new SessionDecryptError('malformed envelope');
  }
  const [version, ivB64, tagB64, ciphertextB64] = parts as [string, string, string, string];

  if (version !== VERSION) {
    throw new SessionDecryptError(`unsupported version "${version}"`);
  }

  const iv = Buffer.from(ivB64, 'base64url');
  const tag = Buffer.from(tagB64, 'base64url');
  const ciphertext = Buffer.from(ciphertextB64, 'base64url');

  if (iv.length !== IV_BYTES) {
    throw new SessionDecryptError(`iv length ${iv.length} (expected ${IV_BYTES})`);
  }
  if (tag.length !== TAG_BYTES) {
    throw new SessionDecryptError(`tag length ${tag.length} (expected ${TAG_BYTES})`);
  }

  const decipher = createDecipheriv(ALGORITHM, key, iv);
  decipher.setAuthTag(tag);

  try {
    const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
    return plaintext.toString('utf8');
  } catch (err) {
    // `decipher.final()` throws on auth-tag mismatch — i.e. tampered
    // ciphertext, wrong key, or a record encrypted with a different
    // key version that we haven't been told about.
    throw new SessionDecryptError(err instanceof Error ? err.message : String(err));
  }
}
