import { getRandomValues, webcrypto as crypto } from 'node:crypto';
import { transformStream } from './streams.js';
/*
input: a ReadableStream containing data to be transformed
key:  Uint8Array containing key of size KEY_LENGTH
rs:   int containing record size, optional
salt: ArrayBuffer containing salt of KEY_LENGTH length, optional
*/
export function encryptStream(input, key, rs = ECE_RECORD_SIZE, salt = generateSalt(KEY_LENGTH_BYTES)) {
    const mode = 'encrypt';
    const inputStream = transformStream(input, new ECEStreamSlicer(rs, mode));
    return transformStream(inputStream, new ECETransformer(mode, key, rs, salt));
}
/*
input: a ReadableStream containing data to be transformed
key:  Uint8Array containing key of size KEY_LENGTH
rs:   int containing record size, optional
*/
export function decryptStream(input, key, rs = ECE_RECORD_SIZE) {
    const mode = 'decrypt';
    const inputStream = transformStream(input, new ECEStreamSlicer(rs, mode));
    return transformStream(inputStream, new ECETransformer(mode, key, rs));
}
function concat(b1, b2) {
    const result = new Uint8Array(b1.length + b2.length);
    result.set(b1, 0);
    result.set(b2, b1.length);
    return result;
}
const NONCE_LENGTH = 12;
const TAG_LENGTH = 16;
const KEY_LENGTH_BYTES = 32;
const HEADER_LENGTH_BYTES = KEY_LENGTH_BYTES + 4 + 1;
const MODE_ENCRYPT = 'encrypt';
const MODE_DECRYPT = 'decrypt';
const KEY_INFO = 'Content-Encoding: aes256gcm\0';
const NONCE_INFO = 'Content-Encoding: nonce\0';
export const ECE_RECORD_SIZE = 1024 * 64;
export function encryptedSize(size, rs = ECE_RECORD_SIZE, tagLength = TAG_LENGTH) {
    const chunk_meta = tagLength + 1; // Chunk metadata, tag and delimiter
    return HEADER_LENGTH_BYTES + size + chunk_meta * Math.ceil(size / (rs - chunk_meta));
}
function generateSalt(len) {
    const randSalt = new Uint8Array(len);
    getRandomValues(randSalt);
    return randSalt.buffer;
}
class ECETransformer {
    mode;
    ikm;
    rs;
    salt;
    seq = 0;
    prevChunk = null;
    firstchunk = true;
    nonceBase;
    key;
    constructor(mode, ikm, rs, salt) {
        this.mode = mode;
        this.ikm = ikm;
        this.rs = rs;
        this.salt = salt;
    }
    async generateKey() {
        const inputKey = await crypto.subtle.importKey('raw', this.ikm, 'HKDF', false, ['deriveKey']);
        const encoder = new TextEncoder();
        return crypto.subtle.deriveKey({
            name: 'HKDF',
            salt: this.salt,
            info: encoder.encode(KEY_INFO),
            hash: 'SHA-256',
        }, inputKey, {
            name: 'AES-GCM',
            length: KEY_LENGTH_BYTES * 8,
        }, true, // Edge polyfill requires key to be extractable to encrypt :/
        ['encrypt', 'decrypt']);
    }
    async generateNonceBase() {
        const inputKey = await crypto.subtle.importKey('raw', this.ikm, 'HKDF', false, ['deriveBits']);
        const encoder = new TextEncoder();
        return await crypto.subtle.deriveBits({
            name: 'HKDF',
            salt: this.salt,
            info: encoder.encode(NONCE_INFO),
            hash: 'SHA-256',
        }, inputKey, NONCE_LENGTH * 8);
    }
    generateNonce(seq) {
        if (seq > 0xffffffff) {
            throw new Error('record sequence number exceeds limit');
        }
        const nonce = new DataView(this.nonceBase.slice(0));
        const m = nonce.getUint32(nonce.byteLength - 4);
        const xor = (m ^ seq) >>> 0; //forces unsigned int xor
        nonce.setUint32(nonce.byteLength - 4, xor);
        return new Uint8Array(nonce.buffer);
    }
    pad(data, isLast) {
        const len = data.length;
        if (len + TAG_LENGTH >= this.rs) {
            throw new Error('data too large for record size');
        }
        if (isLast) {
            return concat(data, Uint8Array.of(2));
        }
        else {
            const padding = new Uint8Array(this.rs - len - TAG_LENGTH);
            padding[0] = 1;
            return concat(data, padding);
        }
    }
    unpad(data, isLast) {
        for (let i = data.length - 1; i >= 0; i--) {
            if (data[i]) {
                if (isLast) {
                    if (data[i] !== 2) {
                        throw new Error('delimiter of final record is not 2');
                    }
                }
                else {
                    if (data[i] !== 1) {
                        throw new Error('delimiter of not final record is not 1');
                    }
                }
                return data.slice(0, i);
            }
        }
        throw new Error('no delimiter found');
    }
    createHeader() {
        const nums = new DataView(new ArrayBuffer(5));
        nums.setUint32(0, this.rs);
        return concat(new Uint8Array(this.salt), new Uint8Array(nums.buffer));
    }
    readHeader(buffer) {
        if (buffer.length < HEADER_LENGTH_BYTES) {
            throw new Error('chunk too small for reading header');
        }
        const dv = new DataView(buffer.buffer);
        const idlen = dv.getUint8(KEY_LENGTH_BYTES + 4);
        const header = {
            salt: buffer.slice(0, KEY_LENGTH_BYTES),
            rs: dv.getUint32(KEY_LENGTH_BYTES),
            length: idlen + KEY_LENGTH_BYTES + 5,
        };
        return header;
    }
    async encryptRecord(buffer, seq, isLast) {
        const nonce = this.generateNonce(seq);
        const encrypted = await crypto.subtle.encrypt({
            name: 'AES-GCM',
            iv: nonce,
            tagLength: 128,
        }, this.key, this.pad(buffer, isLast));
        return new Uint8Array(encrypted);
    }
    async decryptRecord(buffer, seq, isLast) {
        const nonce = this.generateNonce(seq);
        const data = await crypto.subtle.decrypt({
            name: 'AES-GCM',
            iv: nonce,
            tagLength: 128,
        }, this.key, buffer);
        return this.unpad(new Uint8Array(data), isLast);
    }
    async start(controller) {
        if (this.mode === MODE_ENCRYPT) {
            this.key = await this.generateKey();
            this.nonceBase = await this.generateNonceBase();
            controller.enqueue(this.createHeader());
        }
        else if (this.mode !== MODE_DECRYPT) {
            throw new Error('mode must be either encrypt or decrypt');
        }
    }
    async transformPrevChunk(isLast, controller) {
        if (this.mode === MODE_ENCRYPT) {
            controller.enqueue(await this.encryptRecord(this.prevChunk, this.seq, isLast));
            this.seq++;
        }
        else {
            if (this.seq === 0) {
                //the first chunk during decryption contains only the header
                const header = this.readHeader(this.prevChunk);
                this.salt = header.salt.buffer;
                this.rs = header.rs;
                this.key = await this.generateKey();
                this.nonceBase = await this.generateNonceBase();
            }
            else {
                controller.enqueue(await this.decryptRecord(this.prevChunk, this.seq - 1, isLast));
            }
            this.seq++;
        }
    }
    async transform(chunk, controller) {
        if (!this.firstchunk) {
            await this.transformPrevChunk(false, controller);
        }
        this.firstchunk = false;
        this.prevChunk = new Uint8Array(chunk.buffer);
    }
    async flush(controller) {
        //console.log('ece stream ends')
        if (this.prevChunk) {
            await this.transformPrevChunk(true, controller);
        }
    }
}
class ECEStreamSlicer {
    rs;
    mode;
    chunkSize;
    partialChunk;
    offset = 0;
    constructor(rs, mode) {
        this.rs = rs;
        this.mode = mode;
        this.chunkSize = mode === MODE_ENCRYPT ? rs - 17 : HEADER_LENGTH_BYTES; // Section 2 of RFC 8188 states rs-17
        this.partialChunk = new Uint8Array(this.chunkSize); //where partial chunks are saved
    }
    send(buf, controller) {
        controller.enqueue(buf);
        if (this.chunkSize === HEADER_LENGTH_BYTES && this.mode === MODE_DECRYPT) {
            this.chunkSize = this.rs;
        }
        this.partialChunk = new Uint8Array(this.chunkSize);
        this.offset = 0;
    }
    //reslice input into record sized chunks
    transform(chunk, controller) {
        //console.log('Received chunk with %d bytes.', chunk.byteLength)
        let i = 0;
        if (this.offset > 0) {
            const len = Math.min(chunk.byteLength, this.chunkSize - this.offset);
            this.partialChunk.set(chunk.slice(0, len), this.offset);
            this.offset += len;
            i += len;
            if (this.offset === this.chunkSize) {
                this.send(this.partialChunk, controller);
            }
        }
        while (i < chunk.byteLength) {
            const remainingBytes = chunk.byteLength - i;
            if (remainingBytes >= this.chunkSize) {
                const record = chunk.slice(i, i + this.chunkSize);
                i += this.chunkSize;
                this.send(record, controller);
            }
            else {
                const end = chunk.slice(i, i + remainingBytes);
                i += end.byteLength;
                this.partialChunk.set(end);
                this.offset = end.byteLength;
            }
        }
    }
    flush(controller) {
        if (this.offset > 0) {
            controller.enqueue(this.partialChunk.slice(0, this.offset));
        }
    }
}
