const crypto = require("isomorphic-webcrypto");
const ffmpeg = require('fluent-ffmpeg');
const sharp = require('sharp');
const { imageSize } = require('image-size');
const { imageSizeFromFile } = require('image-size/fromFile');
const tmp = require('tmp');
const fs = require('fs');
const path = require('path');
module.exports = function(RED) {
    function MatrixUploadFile(n) {
        RED.nodes.createNode(this, n);

        let node = this;

        this.name = n.name;
        this.server = RED.nodes.getNode(n.server);
        this.inputType = n.inputType;
        this.inputValue = n.inputValue;
        this.fileNameType = n.fileNameType;
        this.fileNameValue = n.fileNameValue;
        this.contentType = n.contentType;
        this.generateThumbnails = n.generateThumbnails;

        if (!node.server) {
            node.warn("No configuration node");
            return;
        }
        node.server.register(node);

        node.status({ fill: "red", shape: "ring", text: "disconnected" });

        node.server.on("disconnected", function(){
            node.status({ fill: "red", shape: "ring", text: "disconnected" });
        });

        node.server.on("connected", function() {
            node.status({ fill: "green", shape: "ring", text: "connected" });
        });

        async function detectFileType(filename, bufferOrPath, msg) {
            const Mime = (await import('mime')).default;
            let file = Buffer.isBuffer(bufferOrPath) ? filename : bufferOrPath;

            try {
                if(file) {
                    let type = Mime.getType(file);
                    let ext = Mime.getExtension(file);
                    if(type) {
                        return {ext: ext, mime: type};
                    }
                }
            } catch (error) {
                node.error(`Error detecting file type for ${filename}: ${error.message}`, msg);
            }
            return null;
        }

        function getFileBuffer(data, msg) {
            try {
                if (Buffer.isBuffer(data)) {
                    return data;
                }

                if (data && RED.settings.fileWorkingDirectory && !path.isAbsolute(data)) {
                    return fs.readFileSync(path.resolve(path.join(RED.settings.fileWorkingDirectory, data)));
                }
                return fs.readFileSync(data);
            } catch (error) {
                node.error(`Error reading file buffer: ${error.message}`, msg);
                throw error;
            }
        }

        function getToValue(msg, type, property) {
            let value = property;
            try {
                if (type === "msg") {
                    value = RED.util.getMessageProperty(msg, property);
                } else if ((type === 'flow') || (type === 'global')) {
                    try {
                        value = RED.util.evaluateNodeProperty(property, type, node, msg);
                    } catch(e2) {
                        throw new Error("Invalid value evaluation");
                    }
                } else if(type === "bool") {
                    value = (property === 'true');
                } else if(type === "num") {
                    value = Number(property);
                }
            } catch (error) {
                node.error(`Error evaluating value for type ${type}: ${error.message}`);
                throw new Error("Invalid value evaluation");
            }
            return value;
        }

        node.on("input", onInput);
        async function onInput(msg) {
            try {
                if (!node.server || !node.server.matrixClient) {
                    node.warn("No matrix server selected");
                    return;
                }

                if (!node.server.isConnected()) {
                    node.error("Matrix server connection is currently closed", msg);
                    msg.error = "Matrix server connection is currently closed";
                    node.send([null, msg]);
                    return;
                }

                let bufferOrPath = getToValue(msg, node.inputType, node.inputValue);
                if (!bufferOrPath) {
                    node.error('Missing file path/buffer input', msg);
                    msg.error = 'Missing file path/buffer input';
                    node.send([null, msg]);
                    return;
                }

                let filename = getToValue(msg, node.fileNameType, node.fileNameValue);
                if (!filename || typeof filename !== 'string') {
                    if (!Buffer.isBuffer(bufferOrPath)) {
                        filename = path.basename(bufferOrPath);
                    } else {
                        node.error('Missing filename, this is required if input is a file buffer', msg);
                        msg.error = 'Missing filename, this is required if input is a file buffer';
                        node.send([null, msg]);
                        return;
                    }
                }

                msg.contentType = node.contentType || msg.contentType || null;
                let detectedFileType = await detectFileType(filename, bufferOrPath, msg);
                node.log("Detected file type " + JSON.stringify(detectedFileType) + " for " + (Buffer.isBuffer(bufferOrPath) ? 'buffer' : `file ${bufferOrPath}`), msg);

                let contentType = msg.contentType || detectedFileType?.mime || null,
                    msgtype = msg.msgtype || null;
                if (!contentType) {
                    node.warn("Content-type failed to detect, falling back to text/plain", msg);
                    contentType = 'text/plain';
                }
                if (!msgtype) {
                    msgtype = autoDetectMatrixMessageType(detectedFileType);
                }

                let encryptedFile = null;
                if (msg.encrypted) {
                    encryptedFile = await encryptAttachment(getFileBuffer(bufferOrPath, msg));
                }

                node.log("Uploading file ", msg);
                let file;
                try {
                    file = await node.server.matrixClient.uploadContent(
                        encryptedFile?.data || getFileBuffer(bufferOrPath, msg),
                        {
                            name: filename, // Name to give the file on the server.
                            rawResponse: false, // Return the raw body, rather than parsing the JSON.
                            type: contentType, // Content-type for the upload. Defaults to file.type, or applicaton/octet-stream.
                            onlyContentUri: false // Just return the content URI, rather than the whole body. Defaults to false. Ignored if opts.rawResponse is true.
                        });
                } catch (e) {
                    node.error("Upload content error " + e, msg);
                    msg.error = e;
                    node.send([null, msg]);
                    return;
                }

                // we call this method when we need a file and cannot use the buffer
                // so if we get passed a buffer we write it to a tmp file and return that
                // otherwise we just return the string because it's already a file
                let tempFile = null;

                function getFile(bufferOrFile) {
                    try {
                        if (!Buffer.isBuffer(bufferOrFile)) {
                            return bufferOrFile; // already a file
                        }

                        if (tempFile) {
                            return tempFile;
                        }

                        // write buffer to tmp file and return path
                        let tmpObj = tmp.fileSync({postfix: `.${detectedFileType.ext}`});
                        fs.writeFileSync(tmpObj.name, bufferOrFile);
                        tempFile = tmpObj.name;
                        return tmpObj.name;
                    } catch (error) {
                        node.error(`Error creating temp file: ${error.message}`, msg);
                        throw error;
                    }
                }

                function deleteTempFile() {
                    if (!tempFile) return null;
                    try {
                        fs.rmSync(tempFile);
                    } catch (error) {
                        node.error(`Error deleting temp file: ${error.message}`, msg);
                    }
                }

                // get size of a buffer or file in bytes
                function getFileSize(bufferOrPath) {
                    try {
                        if (Buffer.isBuffer(bufferOrPath)) {
                            return Buffer.byteLength(bufferOrPath);
                        }

                        return fs.statSync(bufferOrPath).size;
                    } catch (error) {
                        node.error(`Error getting file size: ${error.message}`, msg);
                        throw error;
                    }
                }

                async function addThumbnail(buffer) {
                    try {
                        let thumbSize = imageSize(Buffer.isBuffer(buffer) ? buffer : buffer.data);
                        msg.payload.info.thumbnail_info = {
                            w: thumbSize.width,
                            h: thumbSize.height,
                            size: getFileSize(Buffer.isBuffer(buffer) ? buffer : buffer.data)
                        }
                        let uploadedThumbnail = await node.server.matrixClient.uploadContent(
                            Buffer.isBuffer(buffer) ? buffer : buffer.data,
                            {
                                name: "thumbnail.png", // Name to give the file on the server.
                                rawResponse: false, // Return the raw body, rather than parsing the JSON.
                                type: "image/png", // Content-type for the upload. Defaults to file.type, or applicaton/octet-stream.
                                onlyContentUri: false // Just return the content URI, rather than the whole body. Defaults to false. Ignored if opts.rawResponse is true.
                            });
                        // delete local file
                        if (msg.encrypted) {
                            msg.payload.info.thumbnail_file.url = uploadedThumbnail.content_uri;
                        } else {
                            msg.payload.info.thumbnail_url = uploadedThumbnail.content_uri;
                        }
                    } catch (error) {
                        node.error(`Error adding thumbnail: ${error.message}`, msg);
                    }
                }

                async function createImageThumbnail(bufferOrPath) {
                    try {
                        if (Buffer.isBuffer(bufferOrPath)) {
                            return sharp(bufferOrPath).resize({width: 320}).toBuffer();
                        }
                        return sharp(fs.readFileSync(bufferOrPath)).resize({width: 320}).toBuffer();
                    } catch (error) {
                        node.error(`Error creating image thumbnail: ${error.message}`);
                        throw error;
                    }
                }

                function _ffmpegVideoThumbnail(filepath) {
                    return new Promise((resolve, reject) => {
                        let filename = `${msg._msgid}-screenshot.png`;
                        ffmpeg(filepath)
                            .on('end', async function () {
                                let path = `/tmp/${filename}`;
                                let buffer = getFileBuffer(path, msg);
                                let encryptedThumbnail = null;
                                if (msg.encrypted) {
                                    encryptedThumbnail = await encryptAttachment(buffer);
                                    msg.payload.info.thumbnail_file = encryptedFile.info;
                                }
                                try {
                                    await addThumbnail(encryptedThumbnail || buffer);
                                    fs.rmSync(path); // delete temporary thumbnail file
                                    resolve();
                                } catch (e) {
                                    return reject(new Error("Thumbnail upload failure: " + e));
                                }
                            })
                            .on('error', function (err) {
                                return reject(err);
                            })
                            .screenshots({
                                timestamps: [0],
                                filename: filename,
                                folder: '/tmp',
                                size: '320x?'
                            });
                    });
                }

                msg.payload = {};
                if (msg.encrypted) {
                    msg.payload.file = encryptedFile?.info || {};
                    msg.payload.file.url = file.content_uri;
                } else {
                    msg.payload.url = file.content_uri;
                }
                msg.payload.msgtype = msgtype;
                msg.payload.body = msg.body || filename || "";
                msg.payload.filename = filename;
                msg.payload.info = {
                    "mimetype": contentType,
                    "size": getFileSize(bufferOrPath),
                };
                if (msgtype === 'm.image') {
                    // detect size of image
                    try {
                        let dimensions = Buffer.isBuffer(bufferOrPath)
                            ? imageSize(bufferOrPath)
                            : await imageSizeFromFile(bufferOrPath);
                        msg.payload.info.h = dimensions.height;
                        msg.payload.info.w = dimensions.width;

                        // Generate thumbnail for image
                        if (node.generateThumbnails) {
                            let thumbnailBuffer = await createImageThumbnail(bufferOrPath);
                            let encryptedThumbnail = null;
                            if (msg.encrypted) {
                                encryptedThumbnail = await encryptAttachment(thumbnailBuffer);
                                msg.payload.info.thumbnail_file = encryptedThumbnail.info;
                            }
                            await addThumbnail(encryptedThumbnail || thumbnailBuffer);
                        }
                    } catch (e) {
                        node.error("thumbnail error: " + e, msg);
                    }
                } else if (msgtype === 'm.audio' && detectedFileType) {
                    try {
                        // detect duration of audio clip
                        let filepath = getFile(bufferOrPath);
                        let metadata = await _ffprobe(filepath);
                        let audioStream = metadata?.streams.filter(function (stream) {
                            return stream.codec_type === "audio" || false;
                        })[0];
                        if (audioStream?.duration) {
                            msg.payload.info.duration = audioStream?.duration * 1000;
                        }
                    } catch (e) {
                        node.error(e, msg);
                    }
                    deleteTempFile();
                } else if (msgtype === 'm.video' && detectedFileType) {
                    let filepath = getFile(bufferOrPath);

                    try {
                        // detect duration & width/height of video clip
                        let metadata = await _ffprobe(filepath);
                        let videoStream = metadata?.streams.filter(function (stream) {
                            return stream.codec_type === "video" || false;
                        })[0];
                        if (videoStream) {
                            msg.payload.info.duration = videoStream.duration * 1000;
                            msg.payload.info.w = videoStream.width;
                            msg.payload.info.h = videoStream.height;
                        }
                    } catch (e) {
                        node.error("ffprobe error: " + e, msg);
                    }

                    if (node.generateThumbnails) {
                        try {
                            await _ffmpegVideoThumbnail(filepath);
                        } catch (e) {
                            node.error("Screenshot generation error: " + e, msg);
                        }
                    }
                    deleteTempFile();
                }

                node.send(msg, null);
            } catch (error) {
                node.error(`Unhandled error: ${error.message}`, msg);
                node.log(error, msg);
            }
        }

        node.on("close", function() {
            node.server.deregister(node);
        });
    }
    RED.nodes.registerType("matrix-upload-file", MatrixUploadFile);

    // 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;
    }

    function autoDetectMatrixMessageType(fileType) {
        switch(fileType ? fileType.mime.split('/')[0].toLowerCase() : undefined) {
            case 'video': return 'm.video';
            case 'image': return 'm.image';
            case 'audio': return 'm.audio';
            default: return 'm.file';
        }
    }

    // ffprobe method for getting metadata from a file wrapped in a promise
    function _ffprobe(filepath){
        return new Promise((resolve,reject) => {
            ffmpeg.ffprobe(filepath, function(err, metadata) {
                if(err) {
                    return reject(new Error(err));
                }

                resolve(metadata);
            });
        });
    }
}