import "../server-only.js";

/** Non-secret, authenticated metadata. Supply the same expected context on reads. */
export type EncryptionContext = Readonly<Record<string, string>>;

/** Options shared by string encryption and decryption. */
export interface EncryptionValueOptions {
  /** Plaintext for encrypt; the complete encrypted value for decrypt. */
  value: string;
  /** Bind the value to its purpose, tenant, or record. This is not authorization. */
  context?: EncryptionContext;
}

/** Server-side authenticated string encryption. */
export interface EncryptionPort {
  /** Encrypt a string; persist the complete returned value. */
  encrypt(options: EncryptionValueOptions): Promise<string>;
  /** Authenticate and decrypt a value with the same expected context. */
  decrypt(options: EncryptionValueOptions): Promise<string>;
}

/** Configuration for the built-in AES-256-GCM implementation. */
export interface CreateEncryptionOptions {
  /** A base64: prefixed, canonical Base64 encoding of 32 random bytes. */
  key: string;
  /** Decryption-only keys in the same format. New writes always use key. */
  previousKeys?: readonly string[];
}

/** Malformed, unauthenticated, or undecryptable ciphertext. Contains no input. */
export class EncryptionDecryptionError extends Error {
  /** Stable error name, independent of the decryption failure's cause. */
  readonly name = "EncryptionDecryptionError";

  constructor() {
    super("Unable to decrypt encrypted value.");
  }
}

const envelopePrefix = "beignet:enc:v1:";
const ivLength = 12;
const tagLength = 16;
const encoder = new TextEncoder();
const decoder = new TextDecoder("utf-8", { fatal: true });

function encodeBase64(bytes: Uint8Array): string {
  // Bound each spread so large values do not overflow the argument stack.
  const chunks: string[] = [];
  for (let offset = 0; offset < bytes.length; offset += 8192) {
    chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + 8192)));
  }
  return btoa(chunks.join(""));
}

function decodeBase64(value: string): Uint8Array<ArrayBuffer> {
  if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
    throw new Error("Invalid Base64 encoding.");
  }
  const bytes = Uint8Array.from(atob(value), (character) =>
    character.charCodeAt(0),
  );
  if (encodeBase64(bytes) !== value)
    throw new Error("Noncanonical Base64 encoding.");
  return bytes;
}

function decodeKey(value: string, label: string): Uint8Array<ArrayBuffer> {
  try {
    if (
      typeof value !== "string" ||
      value.length !== 51 ||
      !value.startsWith("base64:")
    )
      throw new Error();
    const bytes = decodeBase64(value.slice(7));
    if (bytes.length !== 32) throw new Error();
    return bytes;
  } catch {
    throw new TypeError(
      `${label} must be base64: followed by the Base64 encoding of 32 bytes. Use generateEncryptionKey().`,
    );
  }
}

function authenticatedData(
  context: EncryptionContext | undefined,
): Uint8Array<ArrayBuffer> {
  if (
    context !== undefined &&
    (context === null ||
      typeof context !== "object" ||
      (Object.getPrototypeOf(context) !== Object.prototype &&
        Object.getPrototypeOf(context) !== null))
  ) {
    throw new TypeError(
      "Encryption context must be a plain record of strings.",
    );
  }
  const entries = Object.entries(context ?? {}).sort(([left], [right]) =>
    left < right ? -1 : left > right ? 1 : 0,
  );
  if (entries.some(([, value]) => typeof value !== "string")) {
    throw new TypeError("Encryption context values must be strings.");
  }
  return encoder.encode(JSON.stringify([envelopePrefix, entries]));
}

/** Generate a new 256-bit key. Store it in a server secret store; never log it. */
export function generateEncryptionKey(): string {
  return `base64:${encodeBase64(crypto.getRandomValues(new Uint8Array(32)))}`;
}

/**
 * Create authenticated string encryption using Web Crypto AES-256-GCM.
 * Configuration is validated synchronously. Each write uses a random 96-bit IV
 * and a 128-bit authentication tag. Previous keys are used only for reads.
 * No environment variables are read and no plaintext, keys, or context are logged.
 */
export function createEncryption(
  options: CreateEncryptionOptions,
): EncryptionPort {
  const current = decodeKey(options?.key, "Encryption key");
  if (
    options.previousKeys !== undefined &&
    !Array.isArray(options.previousKeys)
  ) {
    throw new TypeError("Encryption previousKeys must be an array of keys.");
  }
  const rawKeys = [
    current,
    ...Array.from(options.previousKeys ?? [], (key, index) =>
      decodeKey(key, `Encryption previousKeys[${index}]`),
    ),
  ];
  const importedKeys: Array<Promise<CryptoKey> | undefined> = [];
  const getKey = (index: number): Promise<CryptoKey> => {
    const existing = importedKeys[index];
    if (existing) return existing;
    const imported = crypto.subtle.importKey(
      "raw",
      rawKeys[index],
      "AES-GCM",
      false,
      ["encrypt", "decrypt"],
    );
    importedKeys[index] = imported;
    return imported;
  };

  return {
    async encrypt({ value, context }) {
      if (typeof value !== "string")
        throw new TypeError("Encryption value must be a string.");
      const additionalData = authenticatedData(context);
      const iv = crypto.getRandomValues(new Uint8Array(ivLength));
      const ciphertext = new Uint8Array(
        await crypto.subtle.encrypt(
          { name: "AES-GCM", iv, additionalData, tagLength: tagLength * 8 },
          await getKey(0),
          // JSON preserves all JavaScript strings, including lone UTF-16 surrogates.
          encoder.encode(JSON.stringify(value)),
        ),
      );
      const envelope = new Uint8Array(iv.length + ciphertext.length);
      envelope.set(iv);
      envelope.set(ciphertext, iv.length);
      return `${envelopePrefix}${encodeBase64(envelope)}`;
    },
    async decrypt(options) {
      try {
        const { value, context } = options;
        if (typeof value !== "string" || !value.startsWith(envelopePrefix))
          throw new Error();
        const envelope = decodeBase64(value.slice(envelopePrefix.length));
        if (envelope.length < ivLength + tagLength + 2) throw new Error();
        const additionalData = authenticatedData(context);
        const iv = envelope.slice(0, ivLength);
        const ciphertext = envelope.slice(ivLength);
        for (let index = 0; index < rawKeys.length; index++) {
          try {
            const plaintext = await crypto.subtle.decrypt(
              { name: "AES-GCM", iv, additionalData, tagLength: tagLength * 8 },
              await getKey(index),
              ciphertext,
            );
            const parsed: unknown = JSON.parse(decoder.decode(plaintext));
            if (typeof parsed === "string") return parsed;
          } catch {
            // Try only the configured key ring; never return unauthenticated data.
          }
        }
      } catch {
        // All decryption failures share one non-sensitive public error.
      }
      throw new EncryptionDecryptionError();
    },
  };
}
