/**
 * Opt-in client-side encryption for backup bundles
 * (docs/superpowers/specs/2026-06-04-backup-destinations-design.md).
 *
 * When a destination has `encryptBundle` on, the tar is encrypted to
 * `<name>.tar.enc` before it leaves this server, so a compromised destination
 * (untrusted SFTP host, third-party bucket) never holds raw signed contracts +
 * GDPR data.
 *
 * Format (AES-256-GCM, scrypt KDF):
 *
 *   ┌────────┬──────────┬──────────┬──────────────┬──────────┐
 *   │ magic  │ salt     │ iv       │ ciphertext … │ authTag  │
 *   │ 5 bytes│ 16 bytes │ 12 bytes │ (streamed)   │ 16 bytes │
 *   └────────┴──────────┴──────────┴──────────────┴──────────┘
 *
 * Streaming throughout (memory stays O(chunk)). The auth tag is written last
 * because GCM only produces it after the final block; decryption reads it from
 * the file tail first, then streams the ciphertext through the decipher.
 */

import { createCipheriv, createDecipheriv, randomBytes, scrypt as scryptCb } from 'node:crypto';
import { createReadStream, createWriteStream } from 'node:fs';
import { open, stat } from 'node:fs/promises';
import { pipeline } from 'node:stream/promises';
import { promisify } from 'node:util';

const scrypt = promisify(scryptCb);

const MAGIC = Buffer.from('PNBK1', 'ascii'); // 5 bytes
const SALT_LEN = 16;
const IV_LEN = 12;
const TAG_LEN = 16;
const HEADER_LEN = MAGIC.length + SALT_LEN + IV_LEN; // 33

async function deriveKey(passphrase: string, salt: Buffer): Promise<Buffer> {
  return (await scrypt(passphrase, salt, 32)) as Buffer;
}

/** Encrypt `srcPath` → `destPath` with a passphrase-derived AES-256-GCM key. */
export async function encryptFileToFile(
  srcPath: string,
  destPath: string,
  passphrase: string,
): Promise<void> {
  const salt = randomBytes(SALT_LEN);
  const iv = randomBytes(IV_LEN);
  const key = await deriveKey(passphrase, salt);

  const cipher = createCipheriv('aes-256-gcm', key, iv);
  const out = createWriteStream(destPath);
  out.write(Buffer.concat([MAGIC, salt, iv]));

  // Pipe plaintext → cipher → file, writing to `out` by hand (rather than
  // letting pipeline end it) so we can append the auth tag once the cipher has
  // flushed its final block.
  await pipeline(createReadStream(srcPath), cipher, async (source) => {
    for await (const chunk of source) {
      if (!out.write(chunk as Buffer)) {
        await new Promise<void>((resolve) => out.once('drain', () => resolve()));
      }
    }
  });

  out.write(cipher.getAuthTag());
  await new Promise<void>((resolve, reject) => {
    out.end((err?: Error | null) => (err ? reject(err) : resolve()));
  });
}

/** Decrypt a file produced by {@link encryptFileToFile}. Throws on wrong key / tamper. */
export async function decryptFileToFile(
  srcPath: string,
  destPath: string,
  passphrase: string,
): Promise<void> {
  const { size } = await stat(srcPath);
  if (size < HEADER_LEN + TAG_LEN) {
    throw new Error('Encrypted backup is too small / not a PNBK1 bundle');
  }

  // Read the fixed header + the trailing auth tag.
  const fh = await open(srcPath, 'r');
  try {
    const header = Buffer.alloc(HEADER_LEN);
    await fh.read(header, 0, HEADER_LEN, 0);
    if (!header.subarray(0, MAGIC.length).equals(MAGIC)) {
      throw new Error('Not a PNBK1 encrypted backup (bad magic)');
    }
    const salt = header.subarray(MAGIC.length, MAGIC.length + SALT_LEN);
    const iv = header.subarray(MAGIC.length + SALT_LEN, HEADER_LEN);

    const tag = Buffer.alloc(TAG_LEN);
    await fh.read(tag, 0, TAG_LEN, size - TAG_LEN);

    const key = await deriveKey(passphrase, salt);
    const decipher = createDecipheriv('aes-256-gcm', key, iv);
    decipher.setAuthTag(tag);

    // Stream only the ciphertext region [HEADER_LEN, size - TAG_LEN).
    const cipherStream = createReadStream(srcPath, {
      start: HEADER_LEN,
      end: size - TAG_LEN - 1,
    });
    await pipeline(cipherStream, decipher, createWriteStream(destPath));
  } finally {
    await fh.close();
  }
}
