// Server-side encryption for chunked uploads.
//
// FILE FORMAT (v2)
//
//   header:  magic (8 bytes, "TTENC2\0\0") | salt (16 bytes)
//   frames:  [ iv (12) | payloadLen uint32BE (4) | ciphertext || authTag ] *
//
// Every chunk is an independent AES-256-GCM message with its OWN random IV and
// its OWN auth tag stored alongside it. This matters: an earlier version of
// this file encrypted every chunk under one shared session IV and discarded the
// tags, which reuses the GCM keystream (XORing two ciphertexts recovers the
// plaintext without the key) and made the stored file undecryptable.
//
// Never encrypt two chunks under the same (key, iv) pair.

import crypto from 'crypto';

export const MAGIC = Buffer.from('TTENC2\0\0', 'binary'); // 8 bytes
export const SALT_LENGTH = 16;
export const IV_LENGTH = 12;
export const AUTH_TAG_LENGTH = 16;
export const LENGTH_PREFIX_BYTES = 4;
export const HEADER_LENGTH = MAGIC.length + SALT_LENGTH;
export const FRAME_PREFIX_LENGTH = IV_LENGTH + LENGTH_PREFIX_BYTES;

const PBKDF2_ITERATIONS = 100000;
const KEY_LENGTH = 32;

/**
 * Derive an encryption key from a password using PBKDF2.
 */
export async function deriveKeyFromPassword(password: string, salt: Buffer): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    crypto.pbkdf2(password, salt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha256', (err, derivedKey) => {
      if (err) reject(err);
      else resolve(derivedKey);
    });
  });
}

/**
 * Build the file header. Written once, before any frame.
 */
export function buildHeader(salt: Buffer): Buffer {
  if (salt.length !== SALT_LENGTH) {
    throw new Error(`Salt must be ${SALT_LENGTH} bytes`);
  }
  return Buffer.concat([MAGIC, salt]);
}

/**
 * Parse the file header, returning the salt.
 * Throws if the magic does not match (e.g. a file written by the old format).
 */
export function parseHeader(header: Buffer): { salt: Buffer } {
  if (header.length < HEADER_LENGTH || !header.subarray(0, MAGIC.length).equals(MAGIC)) {
    throw new Error('UNSUPPORTED_FORMAT');
  }
  return { salt: header.subarray(MAGIC.length, HEADER_LENGTH) };
}

/**
 * Encrypt one chunk into a self-contained frame.
 * A fresh random IV is generated per call — callers must not supply one.
 */
export function encryptChunkFrame(plaintext: Buffer, key: Buffer): Buffer {
  const iv = crypto.randomBytes(IV_LENGTH);
  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
  const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
  const authTag = cipher.getAuthTag();

  const lengthPrefix = Buffer.alloc(LENGTH_PREFIX_BYTES);
  lengthPrefix.writeUInt32BE(ciphertext.length + authTag.length, 0);

  return Buffer.concat([iv, lengthPrefix, ciphertext, authTag]);
}

/**
 * Decrypt one frame payload (ciphertext || authTag) using its frame IV.
 * Throws if the tag does not verify — i.e. wrong password or tampered data.
 */
export function decryptChunkPayload(payload: Buffer, key: Buffer, iv: Buffer): Buffer {
  if (payload.length < AUTH_TAG_LENGTH) {
    throw new Error('Malformed frame: payload shorter than auth tag');
  }
  const ciphertext = payload.subarray(0, payload.length - AUTH_TAG_LENGTH);
  const authTag = payload.subarray(payload.length - AUTH_TAG_LENGTH);

  const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
  decipher.setAuthTag(authTag);
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}

/**
 * Read an encrypted file and emit decrypted chunks one frame at a time, so a
 * multi-gigabyte transfer never has to be held in memory.
 *
 * Yields plaintext buffers in order. Throws 'UNSUPPORTED_FORMAT' for files
 * written by the pre-v2 scheme, and a GCM auth error for a wrong password.
 */
export async function* decryptFileStream(
  filePath: string,
  password: string
): AsyncGenerator<Buffer> {
  const { promises: fsPromises } = await import('fs');
  const handle = await fsPromises.open(filePath, 'r');

  try {
    const header = Buffer.alloc(HEADER_LENGTH);
    const { bytesRead } = await handle.read(header, 0, HEADER_LENGTH, 0);
    if (bytesRead < HEADER_LENGTH) {
      throw new Error('UNSUPPORTED_FORMAT');
    }

    const { salt } = parseHeader(header);
    const key = await deriveKeyFromPassword(password, salt);

    let offset = HEADER_LENGTH;
    const framePrefix = Buffer.alloc(FRAME_PREFIX_LENGTH);

    for (;;) {
      const { bytesRead: prefixRead } = await handle.read(
        framePrefix, 0, FRAME_PREFIX_LENGTH, offset
      );
      if (prefixRead === 0) break; // clean end of file
      if (prefixRead < FRAME_PREFIX_LENGTH) {
        throw new Error('Malformed frame: truncated header');
      }
      offset += FRAME_PREFIX_LENGTH;

      const iv = Buffer.from(framePrefix.subarray(0, IV_LENGTH));
      const payloadLength = framePrefix.readUInt32BE(IV_LENGTH);

      const payload = Buffer.alloc(payloadLength);
      const { bytesRead: payloadRead } = await handle.read(payload, 0, payloadLength, offset);
      if (payloadRead < payloadLength) {
        throw new Error('Malformed frame: truncated payload');
      }
      offset += payloadLength;

      yield decryptChunkPayload(payload, key, iv);
    }
  } finally {
    await handle.close();
  }
}
