/**
 * BetterDiscord E2EE Module
 * Copyright (c) 2015-present Jiiks/JsSucks - https://github.com/Jiiks / https://github.com/JsSucks
 * All rights reserved.
 * https://betterdiscord.net
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
*/

import { Settings, Cache, Events } from 'modules';
import BuiltinModule from '../BuiltinModule';
import { Reflection, ReactComponents, DiscordApi, Security } from 'modules';
import { VueInjector, Modals, Toasts } from 'ui';
import { ClientLogger as Logger, ClientIPC } from 'common';
import { request } from 'vendor';
import { Utils } from 'common';
import E2EEComponent from './E2EEComponent.vue';
import E2EEMessageButton from './E2EEMessageButton.vue';
import nodecrypto from 'node-crypto';

const userMentionPattern = new RegExp(`<@!?([0-9]{10,})>`, 'g');
const roleMentionPattern = new RegExp(`<@&([0-9]{10,})>`, 'g');
const everyoneMentionPattern = new RegExp(`(?:\\s+|^)@everyone(?:\\s+|$)`);

const START_DATE = new Date();
const TEMP_KEY = 'temporarymasterkey';
const ECDH_STORAGE = {};
let seed;

export default new class E2EE extends BuiltinModule {

    /* Getters */

    get moduleName() { return 'E2EE' }

    get settingPath() { return ['security', 'default', 'e2ee'] }

    get database() { return Settings.getSetting('security', 'e2eedb', 'e2ekvps').value }

    constructor() {
        super();
        this.encryptNewMessages = true;
        this.ecdhDate = START_DATE;
        this.handlePublicKey = this.handlePublicKey.bind(this);
        this.fetchMasterKey = this.fetchMasterKey.bind(this);
    }

    async enabled(e) {
        await this.fetchMasterKey();
        Events.on('discord:MESSAGE_CREATE', this.handlePublicKey);
        Settings.getSetting('security', 'default', 'use-keytar').on('setting-updated', this.fetchMasterKey);
    }

    async disabled(e) {
        Settings.getSetting('security', 'default', 'use-keytar').off('setting-updated', this.fetchMasterKey);
        Events.off('discord:MESSAGE_CREATE', this.handlePublicKey);
        const ctaComponent = await ReactComponents.getComponent('ChannelTextArea');
        ctaComponent.forceUpdateAll();
    }

    /* Methods */
    async fetchMasterKey() {
        try {
            if (Settings.get('security', 'default', 'use-keytar')) {
                const master = await ClientIPC.getPassword('betterdiscord', 'master');
                if (master) return this.setMaster(master);

                if (Settings.getSetting('security', 'e2eedb', 'e2ekvps').items.length) {
                    // Ask the user for their current password to save to the system keychain
                    const currentMaster = await Modals.input('Save to System Keychain', 'Master Password', true).promise;
                    await ClientIPC.setPassword('betterdiscord', 'master', currentMaster);
                    return this.setMaster(currentMaster);
                }

                // Generate a new master password and save it to the system keychain
                const newMaster = Security.randomBytes();
                await ClientIPC.setPassword('betterdiscord', 'master', newMaster);
                return this.setMaster(newMaster);
            }

            const newMaster = await Modals.input('Open Database', 'Master Password', true).promise;
            return this.setMaster(newMaster);
        } catch (err) {
            Settings.getSetting(...this.settingPath).value = false;
            Toasts.error('Invalid master password! E2EE Disabled');
            Logger.err('E2EE', ['Error fetching master password', err]);
        }
    }

    setMaster(key) {
        seed = Security.randomBytes();
        const newMaster = Security.encrypt(seed, key);
        // TODO re-encrypt everything with new master
        return (this.master = newMaster);
    }

    encrypt(key, content, prefix = '') {
        if (!key) {
            // Encrypt something with master
            return Security.encrypt(Security.decrypt(seed, this.master), content);
        }

        if (!content) {
            // Get key for current channel and encrypt
            const haveKey = this.getKey(DiscordApi.currentChannel.id);
            if (!haveKey) return 'nokey';
            return Security.encrypt(Security.decrypt(seed, [this.master, haveKey]), key);
        }
        return prefix + Security.encrypt(key, content);
    }

    decrypt(key, content, prefix = '') {
        return Security.decrypt(key, content, prefix);
    }

    async createHmac(data) {
        const haveKey = this.getKey(DiscordApi.currentChannel.id);
        if (!haveKey) return null;
        return Security.createHmac(Security.decrypt(seed, [this.master, haveKey]), data);
    }

    getKey(channelId) {
        const haveKey = this.database.find(kvp => kvp.value.key === channelId);
        if (!haveKey) return null;
        return haveKey.value.value;
    }

    setKey(channelId, key) {
        const items = Settings.getSetting('security', 'e2eedb', 'e2ekvps').items;
        const index = items.findIndex(kvp => kvp.value.key === channelId);
        if (index > -1) {
            items[index].value = { key: channelId, value: key };
            return;
        }
        Settings.getSetting('security', 'e2eedb', 'e2ekvps').addItem({ value: { key: channelId, value: key } });
    }

    createKeyExchange(dmChannelID) {
        if (ECDH_STORAGE.hasOwnProperty(dmChannelID)) return null;
        ECDH_STORAGE[dmChannelID] = Security.createECDH();
        setTimeout(() => {
            if (ECDH_STORAGE.hasOwnProperty(dmChannelID)) {
                delete ECDH_STORAGE[dmChannelID];
                Toasts.error('Key exchange expired!');
                if (this.preExchangeState) this.encryptNewMessages = this.preExchangeState;
                this.preExchangeState = null;
            }
        }, 30000);
        return Security.generateECDHKeys(ECDH_STORAGE[dmChannelID]);
    }

    publicKeyFor(dmChannelID) {
        return Security.getECDHPublicKey(ECDH_STORAGE[dmChannelID]);
    }

    computeSecret(dmChannelID, otherKey) {
        try {
            const secret = Security.computeECDHSecret(ECDH_STORAGE[dmChannelID], otherKey);
            delete ECDH_STORAGE[dmChannelID];
            return Security.hash('sha384', secret, 'hex');
        } catch (e) {
            throw e;
        }
    }

    /* Patches */
    async applyPatches() {
        if (this.patches.length) return;

        const { Dispatcher } = Reflection.modules;
        this.patch(Dispatcher, 'dispatch', this.dispatcherPatch, 'before');
        this.patchMessageContent();

        const ChannelTextArea = await ReactComponents.getComponent('ChannelTextArea');
        this.patchChannelTextArea(ChannelTextArea);
        this.patchChannelTextAreaSubmit(ChannelTextArea);
        ChannelTextArea.forceUpdateAll();
    }

    dispatcherPatch(_, [event]) {
        if (!event || event.type !== 'MESSAGE_CREATE') return;

        const key = this.getKey(event.message.channel_id);
        if (!key) return; // We don't have a key for this channel

        if (typeof event.message.content !== 'string') return; // Ignore any non string content
        if (!event.message.content.startsWith('$:')) return; // Not an encrypted string
        let decrypt;
        try {
            decrypt = this.decrypt(this.decrypt(this.decrypt(seed, this.master), key), event.message.content);
        } catch (err) { return } // Ignore errors such as non empty

        const { MessageParser, Permissions, DiscordConstants } = Reflection.modules;

        const currentChannel = DiscordApi.Channel.fromId(event.message.channel_id).discordObject;

        // Create a generic message object to parse mentions with
        const parsed = MessageParser.parse(currentChannel, decrypt).content;

        if (userMentionPattern.test(parsed))
            event.message.mentions = parsed.match(userMentionPattern).map(m => { return { id: m.replace(/[^0-9]/g, '') } });
        if (roleMentionPattern.test(parsed))
            event.message.mention_roles = parsed.match(roleMentionPattern).map(m => m.replace(/[^0-9]/g, ''));
        if (everyoneMentionPattern.test(parsed))
            event.message.mention_everyone = Permissions.can(DiscordConstants.Permissions.MENTION_EVERYONE, currentChannel);
    }

    // TODO Received exchange should also expire if not accepted in time
    async handlePublicKey(e) {
        if (!DiscordApi.currentChannel) return;
        if (DiscordApi.currentChannel.type !== 'DM') return;
        const { id, content, author, channelId } = e.args;
        if (author.id === DiscordApi.currentUser.id || channelId !== DiscordApi.currentChannel.id) return;

        const [tagstart, begin, key, end, tagend] = content.split('\n');
        if (begin !== '-----BEGIN PUBLIC KEY-----' || end !== '-----END PUBLIC KEY-----') return;

        try {
            await Modals.confirm('Key Exchange', `Key exchange request from: ${author.username}#${author.discriminator}`, 'Accept', 'Reject').promise;
            // We already sent our key
            if (!ECDH_STORAGE.hasOwnProperty(channelId)) {
                const publicKeyMessage = `\`\`\`\n-----BEGIN PUBLIC KEY-----\n${this.createKeyExchange(channelId)}\n-----END PUBLIC KEY-----\n\`\`\``;
                if (this.encryptNewMessages) this.encryptNewMessages = false;
                Reflection.modules.DraftActions.saveDraft(channelId, publicKeyMessage);
            }
            const secret = this.computeSecret(channelId, key);
            this.setKey(channelId, secret);
            Toasts.success('Key exchange complete!');
            if (this.preExchangeState) this.encryptNewMessages = this.preExchangeState;
            this.preExchangeState = null;
        } catch (err) {
            console.log(err);
            return;
        }
    }

    async patchMessageContent() {
        const MessageContent = await ReactComponents.getComponent('MessageContent');
        this.patch(MessageContent.component.prototype, 'render', this.beforeRenderMessageContent, 'before');
        this.childPatch(MessageContent.component.prototype, 'render', ['props', 'children'], this.afterRenderMessageContent);
        MessageContent.forceUpdateAll();

        const ImageWrapper = await ReactComponents.getComponent('ImageWrapper');
        this.patch(ImageWrapper.component.prototype, 'render', this.beforeRenderImageWrapper, 'before');
        ImageWrapper.forceUpdateAll();
    }

    beforeRenderMessageContent(component) {
        if (!component.props || !component.props.message) return;

        const key = this.getKey(component.props.message.channel_id);
        if (!key) return; // We don't have a key for this channel

        const Message = Reflection.module.byPrototypes('isMentioned');
        const { MessageParser, Permissions, DiscordConstants } = Reflection.modules;
        const currentChannel = DiscordApi.Channel.fromId(component.props.message.channel_id).discordObject;

        if (typeof component.props.message.content !== 'string') return; // Ignore any non string content
        if (!component.props.message.content.startsWith('$:')) return; // Not an encrypted string
        let decrypt;
        try {
            decrypt = Security.decrypt(seed, [this.master, key, component.props.message.content]);
        } catch (err) { return } // Ignore errors such as non empty

        component.props.message.bd_encrypted = true; // signal as encrypted

        // Create a generic message object to parse mentions with
        const message = MessageParser.createMessage(currentChannel.id, MessageParser.parse(currentChannel, decrypt).content);

        if (userMentionPattern.test(message.content))
            message.mentions = message.content.match(userMentionPattern).map(m => { return { id: m.replace(/[^0-9]/g, '') } });
        if (roleMentionPattern.test(message.content))
            message.mention_roles = message.content.match(roleMentionPattern).map(m => m.replace(/[^0-9]/g, ''));
        if (everyoneMentionPattern.test(message.content))
            message.mention_everyone = Permissions.can(DiscordConstants.Permissions.MENTION_EVERYONE, currentChannel);

        // Create a new message to parse it properly
        const create = Message.create(message);
        if (!create.content || !create.contentParsed) return;

        component.props.message.mentions = create.mentions;
        component.props.message.mentionRoles = create.mentionRoles;
        component.props.message.mentionEveryone = create.mentionEveryone;
        component.props.message.mentioned = create.mentioned;
        component.props.message.content = create.content;
        component.props.message.contentParsed = create.contentParsed;
    }

    afterRenderMessageContent(component, _childrenObject, args, retVal) {
        if (!component.props.message.bd_encrypted) return;

        const { className } = Reflection.resolve('buttonContainer', 'avatar', 'username');
        const buttonContainer = Utils.findInReactTree(retVal, m => m && m.className && m.className.indexOf(className) !== -1);
        if (!buttonContainer) return;

        const buttons = buttonContainer.children.props.children;
        if (!buttons) return;

        try {
            buttons.unshift(VueInjector.createReactElement(E2EEMessageButton));
        } catch (err) {
            Logger.err('E2EE', err.message);
        }
    }

    beforeRenderImageWrapper(component, args, retVal) {
        if (!component.props || !component.props.src) return;
        if (component.props.decrypting) return;
        component.props.decrypting = true;

        const src = component.props.original || component.props.src.split('?')[0];
        if (!src.includes('bde2ee')) return;
        component.props.className = 'bd-encryptedImage';

        const haveKey = this.getKey(DiscordApi.currentChannel.id);
        if (!haveKey) return;

        const cached = Cache.find('e2ee:images', item => item.src === src);
        if (cached) {
            if (cached.invalidKey) { // TODO If key has changed we should recheck all with invalid key
                component.props.className = 'bd-encryptedImage bd-encryptedImageBadKey';
                component.props.readyState = 'READY';
                return;
            }
            Logger.info('E2EE', 'Returning encrypted image from cache');
            try {
                const decrypt = Security.decrypt(seed, [this.master, haveKey, cached.image]);
                component.props.className = 'bd-decryptedImage';
                component.props.src = component.props.original = `data:;base64,${decrypt}`;
            } catch (err) { return } finally { component.props.readyState = 'READY' }
            return;
        }

        component.props.readyState = 'LOADING';
        Logger.info('E2EE', `Decrypting image: ${src}`);

        (async () => {
            try {
                const res = await request.get(src, { encoding: 'binary' });

                const arr = new Uint8Array(new ArrayBuffer(res.length));
                for (let i = 0; i < res.length; i++) arr[i] = res.charCodeAt(i);

                const aobindex = Utils.aobscan(arr, [73, 69, 78, 68]) + 8;
                const sliced = arr.slice(aobindex);
                const image = new TextDecoder().decode(sliced);

                const hmac = image.slice(-64);
                const data = image.slice(0, -64);
                const validateHmac = await this.createHmac(data);
                if (hmac !== validateHmac) {
                    Cache.push('e2ee:images', { src, invalidKey: true });
                    if (component && component.props) {
                        component.props.decrypting = false;
                        component.forceUpdate();
                    }
                    return;
                }

                Cache.push('e2ee:images', { src, image: data });

                if (!component || !component.props) {
                    Logger.warn('E2EE', 'Component seems to be gone');
                    return;
                }

                component.props.decrypting = false;
                component.forceUpdate();
            } catch (err) {
                console.log('request error', err);
            }
        })();
    }

    patchChannelTextArea(cta) {
        this.patch(cta.component.prototype, 'render', this.renderChannelTextArea);
    }

    renderChannelTextArea(component, args, retVal) {
        if (!(retVal.props.children instanceof Array)) retVal.props.children = [retVal.props.children];
        const inner = retVal.props.children.find(child => child.props.className && child.props.className.includes('inner'));
        inner.props.children.splice(0, 0, VueInjector.createReactElement(E2EEComponent));
    }

    patchChannelTextAreaSubmit(cta) {
        this.patch(cta.component.prototype, 'handleSubmit', this.handleChannelTextAreaSubmit, 'before');
    }

    handleChannelTextAreaSubmit(component, args, retVal) {
        const key = this.getKey(DiscordApi.currentChannel.id);
        if (!this.encryptNewMessages || !key) return;
        component.props.value = Security.encrypt(Security.decrypt(seed, [this.master, key]), component.props.value, '$:');
    }

}
