import assert from 'node:assert';
import BoxError from './boxerror.js';
import crypto from 'node:crypto';
import logger from './logger.js';
import { Transform as TransformStream } from 'node:stream';

const { log } = logger('hush');

class EncryptStream extends TransformStream {
    constructor(encryption) {
        super();
        this._headerPushed = false;
        this._iv = crypto.randomBytes(16);
        this._cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(encryption.dataKey, 'hex'), this._iv);
        this._hmac = crypto.createHmac('sha256', Buffer.from(encryption.dataHmacKey, 'hex'));
    }

    pushHeaderIfNeeded() {
        if (!this._headerPushed) {
            const magic = Buffer.from('CBV2');
            this.push(magic);
            this._hmac.update(magic);
            this.push(this._iv);
            this._hmac.update(this._iv);
            this._headerPushed = true;
        }
    }

    _transform(chunk, ignoredEncoding, callback) {
        this.pushHeaderIfNeeded();

        try {
            const crypt = this._cipher.update(chunk);
            this._hmac.update(crypt);
            callback(null, crypt);
        } catch (error) {
            callback(new BoxError(BoxError.CRYPTO_ERROR, `Encryption error when updating: ${error.message}`));
        }
    }

    _flush(callback) {
        try {
            this.pushHeaderIfNeeded(); // for 0-length files
            const crypt = this._cipher.final();
            this.push(crypt);
            this._hmac.update(crypt);
            callback(null, this._hmac.digest()); // +32 bytes
        } catch (error) {
            callback(new BoxError(BoxError.CRYPTO_ERROR, `Encryption error when flushing: ${error.message}`));
        }
    }
}

class DecryptStream extends TransformStream {
    constructor(encryption) {
        super();
        this._key = Buffer.from(encryption.dataKey, 'hex');
        this._header = Buffer.alloc(0);
        this._decipher = null;
        this._hmac = crypto.createHmac('sha256', Buffer.from(encryption.dataHmacKey, 'hex'));
        this._buffer = Buffer.alloc(0);
    }

    _transform(chunk, ignoredEncoding, callback) {
        assert(Buffer.isBuffer(chunk));

        const needed = 20 - this._header.length; // 4 for magic, 16 for iv

        if (this._header.length !== 20) { // not gotten header yet
            this._header = Buffer.concat([this._header, chunk.subarray(0, needed)]);
            if (this._header.length !== 20) return callback();

            if (!this._header.subarray(0, 4).equals(new Buffer.from('CBV2'))) return callback(new BoxError(BoxError.CRYPTO_ERROR, 'Decryption error. Invalid magic in header'));

            const iv = this._header.subarray(4);
            this._decipher = crypto.createDecipheriv('aes-256-cbc', this._key, iv);
            this._hmac.update(this._header);
        }

        this._buffer = Buffer.concat([ this._buffer, chunk.subarray(needed) ]);
        if (this._buffer.length < 32) return callback(); // hmac trailer length is 32

        try {
            const cipherText = this._buffer.subarray(0, -32);
            this._hmac.update(cipherText);
            const plainText = this._decipher.update(cipherText);
            this._buffer = this._buffer.subarray(-32);
            callback(null, plainText);
        } catch (error) {
            callback(new BoxError(BoxError.CRYPTO_ERROR, `Decryption error. ${error.message}`));
        }
    }

    _flush (callback) {
        if (this._buffer.length !== 32) return callback(new BoxError(BoxError.CRYPTO_ERROR, 'Decryption error. Invalid password or tampered file (not enough data)'));

        try {
            if (!this._hmac.digest().equals(this._buffer)) return callback(new BoxError(BoxError.CRYPTO_ERROR, 'Decryption error. Invalid password or tampered file (mac mismatch)'));

            const plainText = this._decipher.final();
            callback(null, plainText);
        } catch (error) {
            callback(new BoxError(BoxError.CRYPTO_ERROR, `Decryption error. Invalid password or tampered file: ${error.message}`));
        }
    }
}

function encryptFilePath(filePath, encryption) {
    assert.strictEqual(typeof filePath, 'string');
    assert.strictEqual(typeof encryption, 'object');

    const encryptedParts = filePath.split('/').map(function (part) {
        const hmac = crypto.createHmac('sha256', Buffer.from(encryption.filenameHmacKey, 'hex'));
        const iv = hmac.update(part).digest().subarray(0, 16); // iv has to be deterministic, for our sync (copy) logic to work
        const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(encryption.filenameKey, 'hex'), iv);
        let crypt = cipher.update(part);
        crypt = Buffer.concat([ iv, crypt, cipher.final() ]);

        return crypt.toString('base64')     // ensures path is valid
            .replace(/\//g, '-')            // replace '/' of base64 since it conflicts with path separator
            .replace(/=/g,'');              // strip trailing = padding. this is only needed if we concat base64 strings, which we don't
    });

    return encryptedParts.join('/');
}

function decryptFilePath(filePath, encryption) {
    assert.strictEqual(typeof filePath, 'string');
    assert.strictEqual(typeof encryption, 'object');

    const decryptedParts = [];
    for (let part of filePath.split('/')) {
        part = part + Array(part.length % 4).join('='); // add back = padding
        part = part.replace(/-/g, '/');                 // replace with '/'

        try {
            const buffer = Buffer.from(part, 'base64');
            const iv = buffer.subarray(0, 16);
            const decrypt = crypto.createDecipheriv('aes-256-cbc', Buffer.from(encryption.filenameKey, 'hex'), iv);
            const plainText = decrypt.update(buffer.subarray(16));
            const plainTextString = Buffer.concat([ plainText, decrypt.final() ]).toString('utf8');
            const hmac = crypto.createHmac('sha256', Buffer.from(encryption.filenameHmacKey, 'hex'));
            if (!hmac.update(plainTextString).digest().subarray(0, 16).equals(iv)) return { error: new BoxError(BoxError.CRYPTO_ERROR, `Decryption error. HMAC error decrypting part ${part} of path ${filePath}`) };

            decryptedParts.push(plainTextString);
        } catch (error) {
            log(`Error decrypting part ${part} of path ${filePath}: %o`, error);
            return { error: new BoxError(BoxError.CRYPTO_ERROR, `Decryption error. ${part} of path ${filePath}: ${error.message}`) };
        }
    }

    return { result: decryptedParts.join('/') };
}

// this function is used in migrations - 20200512172301-settings-backup-encryption.js
function generateEncryptionKeysSync(password) {
    assert.strictEqual(typeof password, 'string');

    const aesKeys = crypto.scryptSync(password, Buffer.from('CLOUDRONSCRYPTSALT', 'utf8'), 128);
    return {
        dataKey: aesKeys.subarray(0, 32).toString('hex'),
        dataHmacKey: aesKeys.subarray(32, 64).toString('hex'),
        filenameKey: aesKeys.subarray(64, 96).toString('hex'),
        filenameHmacKey: aesKeys.subarray(96).toString('hex')
    };
}

export default {
    EncryptStream,
    DecryptStream,

    encryptFilePath,
    decryptFilePath,

    generateEncryptionKeysSync,
};
