module.exports = function(RED) {
    const crypto = require('isomorphic-webcrypto');

    function MatrixDecryptFile(n) {
        RED.nodes.createNode(this, n);

        var node = this;

        this.name = n.name;

        node.on("input", async function (msg) {
            const got = (await import('got')).default;

            if(!msg.type) {
                node.error('msg.type is required.', msg);
                return;
            }

            if(!msg.content) {
                node.error('msg.content is required.', msg);
                return;
            }

            if(!msg.content.file) {
                node.error('msg.content.file is required.', msg);
                return;
            }

            if(!msg.url) {
                node.error('msg.url is required.', msg);
                return;
            }

            try{
                const requestOptions = getRequestOptions(msg);

                let buffer = await downloadBufferWithFallback(got, msg.url, requestOptions);
                msg.payload = Buffer.from(await decryptAttachment(buffer, msg.content.file));

                // handle thumbnail decryption if necessary
                if(
                    msg.type.toLowerCase() === 'm.image'
                    && msg.thumbnail_url
                    && msg.content.info.thumbnail_file
                ) {
                    let thumb_buffer = await downloadBufferWithFallback(got, msg.thumbnail_url, requestOptions);
                    msg.thumbnail_payload = Buffer.from(await decryptAttachment(thumb_buffer, msg.content.info.thumbnail_file));
                }
            } catch(error){
                node.error(error);
                msg.error = error;
                node.send([null, msg]);
                return;
            }

            msg.filename = msg.content.filename || msg.content.body;

            node.send([msg, null]);
        });
    }
    RED.nodes.registerType("matrix-decrypt-file", MatrixDecryptFile);

    function getRequestOptions(msg) {
        const headers = { ...(msg.headers || {}) };
        if (!headers.Authorization && msg.access_token) {
            headers.Authorization = `Bearer ${msg.access_token}`;
        }

        return Object.keys(headers).length ? { headers } : {};
    }

    function getMediaEndpointFallbackUrl(url) {
        if (typeof url !== "string") {
            return null;
        }

        if (url.includes("/_matrix/media/v3/download/")) {
            return url.replace("/_matrix/media/v3/download/", "/_matrix/client/v1/media/download/");
        }

        if (url.includes("/_matrix/client/v1/media/download/")) {
            return url.replace("/_matrix/client/v1/media/download/", "/_matrix/media/v3/download/");
        }

        if (url.includes("/_matrix/media/v3/thumbnail/")) {
            return url.replace("/_matrix/media/v3/thumbnail/", "/_matrix/client/v1/media/thumbnail/");
        }

        if (url.includes("/_matrix/client/v1/media/thumbnail/")) {
            return url.replace("/_matrix/client/v1/media/thumbnail/", "/_matrix/media/v3/thumbnail/");
        }

        return null;
    }

    async function downloadBufferWithFallback(got, url, requestOptions) {
        try {
            return await got(url, requestOptions).buffer();
        } catch (error) {
            const fallbackUrl = getMediaEndpointFallbackUrl(url);
            if (error?.response?.statusCode === 404 && fallbackUrl && fallbackUrl !== url) {
                return await got(fallbackUrl, requestOptions).buffer();
            }

            throw error;
        }
    }

    function atob(a) {
        return Buffer.from(a, 'base64').toString('binary');
    }

    function btoa(b) {
        return Buffer.from(b).toString('base64');
    }

    // the following was taken & modified from https://github.com/matrix-org/browser-encrypt-attachment/blob/master/index.js
    /**
     * Encrypt an attachment.
     * @param {ArrayBuffer} plaintextBuffer The attachment data buffer.
     * @return {Promise} A promise that resolves with an object when the attachment is encrypted.
     *      The object has a "data" key with an ArrayBuffer of encrypted data and an "info" key
     *      with an object containing the info needed to decrypt the data.
     */
    function encryptAttachment(plaintextBuffer) {
        let cryptoKey; // The AES key object.
        let exportedKey; // The AES key exported as JWK.
        let ciphertextBuffer; // ArrayBuffer of encrypted data.
        let sha256Buffer; // ArrayBuffer of digest.
        let ivArray; // Uint8Array of AES IV
        // Generate an IV where the first 8 bytes are random and the high 8 bytes
        // are zero. We set the counter low bits to 0 since it makes it unlikely
        // that the 64 bit counter will overflow.
        ivArray = new Uint8Array(16);
        crypto.getRandomValues(ivArray.subarray(0,8));
        // Load the encryption key.
        return crypto.subtle.generateKey(
            {"name": "AES-CTR", length: 256}, true, ["encrypt", "decrypt"]
        ).then(function(generateKeyResult) {
            cryptoKey = generateKeyResult;
            // Export the Key as JWK.
            return crypto.subtle.exportKey("jwk", cryptoKey);
        }).then(function(exportKeyResult) {
            exportedKey = exportKeyResult;
            // Encrypt the input ArrayBuffer.
            // Use half of the iv as the counter by setting the "length" to 64.
            return crypto.subtle.encrypt(
                {name: "AES-CTR", counter: ivArray, length: 64}, cryptoKey, plaintextBuffer
            );
        }).then(function(encryptResult) {
            ciphertextBuffer = encryptResult;
            // SHA-256 the encrypted data.
            return crypto.subtle.digest("SHA-256", ciphertextBuffer);
        }).then(function (digestResult) {
            sha256Buffer = digestResult;

            return {
                data: ciphertextBuffer,
                info: {
                    v: "v2",
                    key: exportedKey,
                    iv: encodeBase64(ivArray),
                    hashes: {
                        sha256: encodeBase64(new Uint8Array(sha256Buffer)),
                    },
                },
            };
        });
    }

    /**
     * Decrypt an attachment.
     * @param {ArrayBuffer} ciphertextBuffer The encrypted attachment data buffer.
     * @param {Object} info The information needed to decrypt the attachment.
     * @param {Object} info.key AES-CTR JWK key object.
     * @param {string} info.iv Base64 encoded 16 byte AES-CTR IV.
     * @param {string} info.hashes.sha256 Base64 encoded SHA-256 hash of the ciphertext.
     * @return {Promise} A promise that resolves with an ArrayBuffer when the attachment is decrypted.
     */
    function decryptAttachment(ciphertextBuffer, info) {

        if (info === undefined || info.key === undefined || info.iv === undefined
            || info.hashes === undefined || info.hashes.sha256 === undefined) {
            throw new Error("Invalid info. Missing info.key, info.iv or info.hashes.sha256 key");
        }

        let cryptoKey; // The AES key object.
        let ivArray = decodeBase64(info.iv);
        let expectedSha256base64 = info.hashes.sha256;
        // Load the AES from the "key" key of the info object.
        return crypto.subtle.importKey(
            "jwk", info.key, {"name": "AES-CTR"}, false, ["encrypt", "decrypt"]
        ).then(function (importKeyResult) {
            cryptoKey = importKeyResult;
            // Check the sha256 hash
            return crypto.subtle.digest("SHA-256", ciphertextBuffer);
        }).then(function (digestResult) {
            if (encodeBase64(new Uint8Array(digestResult)) !== expectedSha256base64) {
                throw new Error("Mismatched SHA-256 digest (expected: " + encodeBase64(new Uint8Array(digestResult)) + ") got (" + expectedSha256base64 + ")");
            }
            let counterLength;
            if (info.v.toLowerCase() === "v1" || info.v.toLowerCase() === "v2") {
                // Version 1 and 2 use a 64 bit counter.
                counterLength = 64;
            } else {
                // Version 0 uses a 128 bit counter.
                counterLength = 128;
            }
            return crypto.subtle.decrypt(
                {name: "AES-CTR", counter: ivArray, length: counterLength}, cryptoKey, ciphertextBuffer
            );
        });
    }

    /**
     * Encode a typed array of uint8 as base64.
     * @param {Uint8Array} uint8Array The data to encode.
     * @return {string} The base64 without padding.
     */
    function encodeBase64(uint8Array) {
        // Misinterpt the Uint8Array as Latin-1.
        // window.btoa expects a unicode string with codepoints in the range 0-255.
        // var latin1String = String.fromCharCode.apply(null, uint8Array);
        // Use the builtin base64 encoder.
        var paddedBase64 = btoa(uint8Array);
        // Calculate the unpadded length.
        var inputLength = uint8Array.length;
        var outputLength = 4 * Math.floor((inputLength + 2) / 3) + (inputLength + 2) % 3 - 2;
        // Return the unpadded base64.
        return paddedBase64.slice(0, outputLength);
    }

    /**
     * Decode a base64 string to a typed array of uint8.
     * This will decode unpadded base64, but will also accept base64 with padding.
     * @param {string} base64 The unpadded base64 to decode.
     * @return {Uint8Array} The decoded data.
     */
    function decodeBase64(base64) {
        // Pad the base64 up to the next multiple of 4.
        var paddedBase64 = base64 + "===".slice(0, (4 - base64.length % 4) % 4);
        // Decode the base64 as a misinterpreted Latin-1 string.
        // window.atob returns a unicode string with codepoints in the range 0-255.
        var latin1String = atob(paddedBase64);
        // Encode the string as a Uint8Array as Latin-1.
        var uint8Array = new Uint8Array(latin1String.length);
        for (var i = 0; i < latin1String.length; i++) {
            uint8Array[i] = latin1String.charCodeAt(i);
        }
        return uint8Array;
    }
}
