/**
 * AES-256-GCM envelope for NotificationChannel.config (and any future
 * at-rest secrets).
 *
 * Pure module: only depends on Node's `crypto` and three env vars
 * (CHANNEL_ENCRYPTION_KEY, CHANNEL_ENCRYPTION_KEYRING,
 * CHANNEL_ENCRYPTION_ACTIVE_KID). No NextAuth, no Prisma. Reusable
 * anywhere we need symmetric encryption of small JSON payloads.
 *
 * Envelope formats (single-line, base64-friendly):
 *   enc_v1:<base64-iv>:<base64-ciphertext>:<base64-authtag>
 *   enc_v2:<base64-iv>:<base64-ciphertext>:<base64-authtag>          (with AAD)
 *   enc_v3:<kid>:<base64-iv>:<base64-ciphertext>:<base64-authtag>    (KEYRING + AAD)
 *
 * enc_v1 has NO Additional Authenticated Data: the auth tag proves the
 * ciphertext was encrypted under our key, but does NOT bind it to a
 * specific row. An attacker with DB write access could swap two enc_v1
 * blobs between rows and decryption would still succeed.
 *
 * enc_v2 (AUDIT-7, 2026-05-23) accepts an `aad` parameter on encrypt/
 * decrypt. The AAD is incorporated into the auth tag, so decryption
 * with the wrong AAD throws. Callers supply a row-identifying AAD
 * (e.g. `channel:42`, `user:7:totp`); the envelope itself does NOT
 * carry the AAD — the application reconstructs it at decryption time.
 *
 * enc_v3 (audit3-followup, 2026-05-29) adds a key identifier (kid) so
 * the operator can run multiple keys simultaneously and rotate without
 * re-encrypting historical rows up-front. The kid sits between the
 * version and the IV. Lookup is by kid into the parsed KEYRING.
 * enc_v3 ALWAYS uses AAD — new envelopes should always row-bind, and
 * making AAD optional in enc_v3 would just recreate the enc_v1 footgun
 * for a third time.
 *
 * Prefix history (AUDIT-2 #5, 2026-05-23):
 *   The original prefixes `v1:` / `v2:` were too short — any plaintext
 *   value starting with the literal "v1:" or "v2:" (e.g. a monitor
 *   header value "v1:my-api-version") was misclassified as an envelope
 *   and fed to decrypt(), which then threw. Lengthened to enc_v1: /
 *   enc_v2: / enc_v3: to make collision with real plaintext effectively
 *   impossible.
 *
 *   READ path keeps recognising legacy v1:/v2: prefixes for rows
 *   encrypted before the rename. WRITE path emits only the new
 *   enc_* prefixes. Operators run scripts/maint/
 *   reencrypt-legacy-prefixes.ts to migrate historical rows.
 *
 * KEYRING semantics:
 *   - CHANNEL_ENCRYPTION_KEYRING="kid1:base64key1,kid2:base64key2,..."
 *   - CHANNEL_ENCRYPTION_ACTIVE_KID="kid2"
 *   - When both are set, encrypt() emits enc_v3 using the active kid's key.
 *   - When unset, encrypt() falls back to enc_v1/enc_v2 + CHANNEL_ENCRYPTION_KEY.
 *   - decrypt() handles all four envelope versions automatically.
 *   - CHANNEL_ENCRYPTION_KEY remains valid for decrypting historical
 *     v1/v2/enc_v1/enc_v2 envelopes for as long as the operator keeps
 *     it in .env. Rotation flow doesn't require an immediate
 *     re-encryption pass.
 *
 * Backwards compatibility: decrypt() and decryptIfNeeded() accept all
 * five envelope versions. Read path of legacy enc_v1 rows continues to
 * work indefinitely; callers migrate to enc_v2 by passing an `aad` to
 * encrypt(), or to enc_v3 by configuring the KEYRING + ACTIVE_KID.
 */
import crypto from 'crypto';

const ENC_V1 = 'enc_v1';
const ENC_V2 = 'enc_v2';
const ENC_V3 = 'enc_v3';
// Legacy prefixes: recognised by decrypt()/isEncrypted() for rows
// encrypted before the AUDIT-2 #5 rename. Never produced by encrypt().
const LEGACY_V1 = 'v1';
const LEGACY_V2 = 'v2';
const VALID_VERSIONS = new Set([ENC_V1, ENC_V2, ENC_V3, LEGACY_V1, LEGACY_V2]);
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
const AUTH_TAG_BYTES = 16;
const KEY_BYTES = 32;
const KID_RE = /^[A-Za-z0-9_-]{1,32}$/;

interface KeyringState {
    legacyKey: Buffer | null;
    keyring: Map<string, Buffer>;
    activeKid: string | null;
}

let cached: KeyringState | null = null;

function decodeKey(raw: string, sourceLabel: string): Buffer {
    let decoded: Buffer;
    try {
        decoded = Buffer.from(raw, 'base64');
    } catch {
        throw new Error(`${sourceLabel} is not valid base64`);
    }
    if (decoded.length !== KEY_BYTES) {
        throw new Error(
            `${sourceLabel} must decode to ${KEY_BYTES} bytes (got ${decoded.length}). ` +
            `Generate with: openssl rand -base64 32`
        );
    }
    return decoded;
}

function loadState(): KeyringState {
    if (cached) return cached;

    const keyring = new Map<string, Buffer>();

    // 1. Legacy single key. Keep loading it even when KEYRING is set so
    //    existing enc_v1/enc_v2/v1/v2 envelopes still decrypt.
    let legacyKey: Buffer | null = null;
    const rawLegacy = process.env.CHANNEL_ENCRYPTION_KEY;
    if (rawLegacy && rawLegacy.length > 0) {
        legacyKey = decodeKey(rawLegacy, 'CHANNEL_ENCRYPTION_KEY');
    }

    // 2. KEYRING (optional). Parse "kid:base64,kid:base64,..." into the map.
    const rawKeyring = process.env.CHANNEL_ENCRYPTION_KEYRING;
    if (rawKeyring && rawKeyring.length > 0) {
        const entries = rawKeyring.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
        for (const entry of entries) {
            const colonIdx = entry.indexOf(':');
            if (colonIdx < 0) {
                throw new Error(
                    `CHANNEL_ENCRYPTION_KEYRING entry "${entry}" is malformed. ` +
                    `Expected "kid:base64key".`
                );
            }
            const kid = entry.slice(0, colonIdx);
            const keyB64 = entry.slice(colonIdx + 1);
            if (!KID_RE.test(kid)) {
                throw new Error(
                    `CHANNEL_ENCRYPTION_KEYRING contains invalid kid "${kid}". ` +
                    `kid must match ${KID_RE}.`
                );
            }
            if (keyring.has(kid)) {
                throw new Error(`CHANNEL_ENCRYPTION_KEYRING contains duplicate kid "${kid}".`);
            }
            keyring.set(kid, decodeKey(keyB64, `CHANNEL_ENCRYPTION_KEYRING[${kid}]`));
        }
    }

    // 3. Active kid (required when KEYRING is non-empty). Determines
    //    which key encrypt() uses for new envelopes.
    const activeKid = process.env.CHANNEL_ENCRYPTION_ACTIVE_KID || null;
    if (keyring.size > 0) {
        if (!activeKid) {
            throw new Error(
                'CHANNEL_ENCRYPTION_ACTIVE_KID is required when CHANNEL_ENCRYPTION_KEYRING is set. ' +
                'Set it to one of the kids in the keyring.'
            );
        }
        if (!keyring.has(activeKid)) {
            throw new Error(
                `CHANNEL_ENCRYPTION_ACTIVE_KID="${activeKid}" is not in the KEYRING. ` +
                `Available kids: ${[...keyring.keys()].join(', ')}.`
            );
        }
    } else if (activeKid) {
        // KEYRING empty but ACTIVE_KID set — surface the misconfig instead
        // of silently ignoring it.
        throw new Error(
            'CHANNEL_ENCRYPTION_ACTIVE_KID is set but CHANNEL_ENCRYPTION_KEYRING is empty.'
        );
    }

    if (!legacyKey && keyring.size === 0) {
        throw new Error(
            'No encryption key configured. Set either CHANNEL_ENCRYPTION_KEY or ' +
            'CHANNEL_ENCRYPTION_KEYRING + CHANNEL_ENCRYPTION_ACTIVE_KID. ' +
            'Generate one with: openssl rand -base64 32'
        );
    }

    cached = { legacyKey, keyring, activeKid };
    return cached;
}

/** Test/dev only — drop cached state so new env vars take effect. */
export function _resetKeyCacheForTests(): void {
    cached = null;
}

/**
 * Encrypt a plaintext string.
 *
 * Behaviour:
 *   - If KEYRING + ACTIVE_KID configured → enc_v3:<kid>:... (AAD required)
 *   - Else if aad provided → enc_v2:... (using legacy key)
 *   - Else → enc_v1:... (using legacy key)
 *
 * @param plaintext  The data to encrypt.
 * @param aad        Optional Additional Authenticated Data binding the
 *                   ciphertext to a row identifier. REQUIRED when the
 *                   KEYRING is configured (enc_v3 always uses AAD).
 *                   Recommended for new code even in legacy mode.
 */
export function encrypt(plaintext: string, aad?: string): string {
    const state = loadState();
    const iv = crypto.randomBytes(IV_BYTES);

    // KEYRING mode → enc_v3 with kid + AAD.
    if (state.activeKid && state.keyring.size > 0) {
        if (aad === undefined) {
            throw new Error(
                'encrypt: enc_v3 (KEYRING mode) requires an AAD. ' +
                'Pass a row-identifying string (e.g. `channel:42`, `user:7:totp`).'
            );
        }
        const key = state.keyring.get(state.activeKid);
        if (!key) {
            // loadState() validated this, but the type narrows nicer with the check.
            throw new Error(`encrypt: active kid ${state.activeKid} missing from keyring`);
        }
        const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
        cipher.setAAD(Buffer.from(aad, 'utf8'));
        const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
        const authTag = cipher.getAuthTag();
        return [
            ENC_V3,
            state.activeKid,
            iv.toString('base64'),
            ciphertext.toString('base64'),
            authTag.toString('base64'),
        ].join(':');
    }

    // Legacy mode (single CHANNEL_ENCRYPTION_KEY).
    if (!state.legacyKey) {
        throw new Error(
            'encrypt: no legacy key configured. Either set CHANNEL_ENCRYPTION_KEY ' +
            'or configure CHANNEL_ENCRYPTION_KEYRING + CHANNEL_ENCRYPTION_ACTIVE_KID.'
        );
    }
    const cipher = crypto.createCipheriv(ALGORITHM, state.legacyKey, iv);
    if (aad !== undefined) {
        cipher.setAAD(Buffer.from(aad, 'utf8'));
    }
    const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
    const authTag = cipher.getAuthTag();
    const version = aad === undefined ? ENC_V1 : ENC_V2;
    return [
        version,
        iv.toString('base64'),
        ciphertext.toString('base64'),
        authTag.toString('base64'),
    ].join(':');
}

/**
 * Decrypt an envelope produced by any version this module supports.
 *
 * @param envelope  The encrypted string.
 * @param aad       MUST be supplied for enc_v2 / enc_v3 / v2 envelopes
 *                  and MUST match the AAD used at encrypt time. Ignored
 *                  for enc_v1 / v1.
 * @throws on tamper, bad key, version mismatch, AAD mismatch, or
 *         unknown kid (enc_v3).
 */
export function decrypt(envelope: string, aad?: string): string {
    const parts = envelope.split(':');
    if (parts.length < 4) {
        throw new Error('decrypt: malformed envelope');
    }
    const version = parts[0];
    if (!VALID_VERSIONS.has(version)) {
        throw new Error(`decrypt: unknown envelope version ${version}`);
    }

    const state = loadState();

    // Resolve key (and parse out the kid offset for enc_v3).
    let key: Buffer;
    let ivIdx = 1;
    if (version === ENC_V3) {
        if (parts.length !== 5) {
            throw new Error('decrypt: malformed enc_v3 envelope (expected 5 colon-separated parts)');
        }
        const kid = parts[1];
        const k = state.keyring.get(kid);
        if (!k) {
            throw new Error(
                `decrypt: kid "${kid}" not present in CHANNEL_ENCRYPTION_KEYRING. ` +
                `An envelope produced by a key that's no longer in the keyring cannot be decrypted.`
            );
        }
        key = k;
        ivIdx = 2;
    } else {
        if (parts.length !== 4) {
            throw new Error(`decrypt: malformed ${version} envelope (expected 4 colon-separated parts)`);
        }
        if (!state.legacyKey) {
            throw new Error(
                `decrypt: ${version} envelope requires CHANNEL_ENCRYPTION_KEY to be set. ` +
                `The legacy key was removed from the environment.`
            );
        }
        key = state.legacyKey;
    }

    const iv = Buffer.from(parts[ivIdx], 'base64');
    const ciphertext = Buffer.from(parts[ivIdx + 1], 'base64');
    const authTag = Buffer.from(parts[ivIdx + 2], 'base64');
    if (iv.length !== IV_BYTES) throw new Error('decrypt: bad IV length');
    if (authTag.length !== AUTH_TAG_BYTES) throw new Error('decrypt: bad auth tag length');

    const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
    const usesAad = version === ENC_V2 || version === ENC_V3 || version === LEGACY_V2;
    if (usesAad) {
        if (aad === undefined) {
            throw new Error(`decrypt: ${version} envelope requires an AAD`);
        }
        decipher.setAAD(Buffer.from(aad, 'utf8'));
    }
    decipher.setAuthTag(authTag);
    const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
    return plaintext.toString('utf8');
}

/**
 * Heuristic: does this string look like one of our envelopes?
 *
 * Recognises every supported prefix (enc_v1/enc_v2/enc_v3) plus the
 * legacy v1/v2 prefixes for backwards-compat.
 */
export function isEncrypted(value: string): boolean {
    return (
        value.startsWith(`${ENC_V1}:`) ||
        value.startsWith(`${ENC_V2}:`) ||
        value.startsWith(`${ENC_V3}:`) ||
        value.startsWith(`${LEGACY_V1}:`) ||
        value.startsWith(`${LEGACY_V2}:`)
    );
}

/**
 * Convenience for the read path: decrypt if encrypted, otherwise return
 * as-is. Plaintext passthrough so the migration script can run after
 * the code lands without breaking live rows.
 *
 * @param value  The stored value (envelope OR legacy plaintext).
 * @param aad    Optional AAD; required when the stored value is a v2/v3
 *               envelope.
 */
export function decryptIfNeeded(value: string, aad?: string): string {
    return isEncrypted(value) ? decrypt(value, aad) : value;
}

/**
 * Inspect KEYRING configuration. Used by the audit3-keyring-rotation
 * smoke and by /api/health/deep to surface key-rotation status.
 *
 * Returns null when the KEYRING is empty (legacy-only mode).
 */
export function _getKeyringInfoForOps(): { activeKid: string; kids: string[] } | null {
    const state = loadState();
    if (state.keyring.size === 0 || !state.activeKid) return null;
    return {
        activeKid: state.activeKid,
        kids: [...state.keyring.keys()],
    };
}
