/**
 * This file is part of Threema Web.
 *
 * Threema Web is free software: you can redistribute it and/or modify it
 * under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or (at
 * your option) any later version.
 *
 * This program is distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
 * General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
 */

// tslint:disable:no-reference

/// <reference path="../types/broadcastchannel.d.ts" />

import {BrowserService} from '../services/browser';
import {ControllerService} from '../services/controller';
import {TrustedKeyStoreService} from '../services/keystore';
import {PushService} from '../services/push';
import {StateService} from '../services/state';
import {VersionService} from '../services/version';
import {WebClientService} from '../services/webclient';

import GlobalConnectionState = threema.GlobalConnectionState;

class DialogController {
    // TODO: This is also used in partials/messenger.ts. We could somehow
    // extract it into a separate file.
    public static $inject = ['$mdDialog'];

    public $mdDialog;

    constructor($mdDialog) {
        this.$mdDialog = $mdDialog;
    }

    public cancel() {
        this.$mdDialog.cancel();
    }
}

class WelcomeController {

    private static REDIRECT_DELAY = 500;

    private logTag: string = '[WelcomeController]';

    // Angular services
    private $scope: ng.IScope;
    private $state: ng.ui.IStateService;
    private $timeout: ng.ITimeoutService;
    private $interval: ng.IIntervalService;
    private $log: ng.ILogService;
    private $window: ng.IWindowService;

    // Material design services
    private $mdDialog: ng.material.IDialogService;
    private $translate: ng.translate.ITranslateService;

    // Custom services
    private webClientService: WebClientService;
    private trustedKeyStore: TrustedKeyStoreService;
    private pushService: PushService;
    private stateService: StateService;
    private config: threema.Config;

    // Other
    public name = 'welcome';
    private mode: 'scan' | 'unlock';
    private qrCode;
    private password: string = '';
    private pleaseUpdateAppMsg: string = null;
    private browser: threema.BrowserInfo;

    public static $inject = [
        '$scope', '$state', '$stateParams', '$timeout', '$interval', '$log', '$window', '$mdDialog', '$translate',
        'WebClientService', 'TrustedKeyStore', 'StateService', 'PushService', 'BrowserService', 'VersionService',
        'BROWSER_MIN_VERSIONS', 'CONFIG', 'ControllerService',
    ];
    constructor($scope: ng.IScope, $state: ng.ui.IStateService, $stateParams: threema.WelcomeStateParams,
                $timeout: ng.ITimeoutService, $interval: ng.IIntervalService,
                $log: ng.ILogService, $window: ng.IWindowService, $mdDialog: ng.material.IDialogService,
                $translate: ng.translate.ITranslateService,
                webClientService: WebClientService, trustedKeyStore: TrustedKeyStoreService,
                stateService: StateService, pushService: PushService,
                browserService: BrowserService,
                versionService: VersionService,
                minVersions: threema.BrowserMinVersions,
                config: threema.Config,
                controllerService: ControllerService) {
        controllerService.setControllerName('welcome');
        // Angular services
        this.$scope = $scope;
        this.$state = $state;
        this.$timeout = $timeout;
        this.$interval = $interval;
        this.$log = $log;
        this.$window = $window;
        this.$mdDialog = $mdDialog;
        this.$translate = $translate;

        // Own services
        this.webClientService = webClientService;
        this.trustedKeyStore = trustedKeyStore;
        this.stateService = stateService;
        this.pushService = pushService;
        this.config = config;

        // Determine whether browser warning should be shown
        this.browser = browserService.getBrowser();
        const version = this.browser.version;
        $log.debug('Detected browser:', this.browser.textInfo);
        if (version === undefined) {
            $log.warn('Could not determine browser version');
            this.showBrowserWarning();
        } else if (this.browser.chrome === true) {
            if (version < minVersions.CHROME) {
                $log.warn('Chrome is too old (' + version + ' < ' + minVersions.CHROME + ')');
                this.showBrowserWarning();
            }
        } else if (this.browser.firefox === true) {
            if (version < minVersions.FF) {
                $log.warn('Firefox is too old (' + version + ' < ' + minVersions.FF + ')');
                this.showBrowserWarning();
            }
        } else if (this.browser.opera === true) {
            if (version < minVersions.OPERA) {
                $log.warn('Opera is too old (' + version + ' < ' + minVersions.OPERA + ')');
                this.showBrowserWarning();
            }
        } else if (this.browser.safari === true) {
            if (version < minVersions.SAFARI) {
                $log.warn('Safari is too old (' + version + ' < ' + minVersions.SAFARI + ')');
                this.showBrowserWarning();
            }
        } else {
            $log.warn('Non-supported browser, please use Chrome, Firefox or Opera');
            this.showBrowserWarning();
        }

        // Determine whether local storage is available
        if (this.trustedKeyStore.blocked === true) {
            $log.error('Cannot access local storage. Is it being blocked by a browser add-on?');
            this.showLocalStorageWarning();
        }

        // Determine current version
        versionService.initVersion();

        // Determine last version with previous protocol version
        if (this.config.PREV_PROTOCOL_LAST_VERSION !== null) {
            this.pleaseUpdateAppMsg = this.$translate.instant('troubleshooting.PLEASE_UPDATE_APP');
            if (!this.config.SELF_HOSTED) {
                this.pleaseUpdateAppMsg += ' ' + this.$translate.instant('troubleshooting.USE_ARCHIVE_VERSION', {
                    archiveUrl: `https://web.threema.ch/archive/${this.config.PREV_PROTOCOL_LAST_VERSION}/`,
                });
            }
        }

        // Clear cache
        this.webClientService.clearCache();

        // Determine whether trusted key is available
        let hasTrustedKey = null;
        try {
            hasTrustedKey = this.trustedKeyStore.hasTrustedKey();
        } catch (e) {
            $log.error('Exception while accessing local storage:', e);
            this.showLocalStorageException(e);
        }

        // Determine connection mode
        if ($stateParams.initParams !== null) {
            this.mode = 'unlock';
            const keyStore = $stateParams.initParams.keyStore;
            const peerTrustedKey = $stateParams.initParams.peerTrustedKey;
            this.reconnect(keyStore, peerTrustedKey);
        } else if (hasTrustedKey) {
            this.mode = 'unlock';
            this.unlock();
        } else {
            this.mode = 'scan';
            this.scan();
        }
    }

    /**
     * Whether or not to show the loading indicator.
     */
    public get showLoadingIndicator(): boolean {
        switch (this.stateService.connectionBuildupState) {
            case 'push':
            case 'peer_handshake':
            case 'loading':
            case 'done':
                return true;
            default:
                return false;
        }
    }

    /**
     * Getter for connection buildup state.
     *
     * Only to be used by the template.
     */
    public get state(): threema.ConnectionBuildupState {
        return this.stateService.connectionBuildupState;
    }

    /**
     * Getter for connection buildup progress.
     *
     * Only to be used by the template.
     */
    public get progress(): number {
        return this.stateService.progress;
    }

    /**
     * Getter for slow connect status.
     *
     * Only to be used by the template.
     */
    public get slowConnect(): boolean {
        return this.stateService.slowConnect;
    }

    /**
     * Initiate a new session by scanning a new QR code.
     */
    private scan(): void {
        this.$log.info(this.logTag, 'Initialize session by scanning QR code...');

        // Initialize webclient with new keystore
        this.webClientService.init();

        // Set up the broadcast channel that checks whether we're already connected in another tab
        this.setupBroadcastChannel(this.webClientService.salty.keyStore.publicKeyHex);

        // Initialize QR code params
        this.$scope.$watch(() => this.password, () => {
            const payload = this.webClientService.buildQrCodePayload(this.password.length > 0);
            this.qrCode = this.buildQrCode(payload);
        });

        // Start webclient
        this.start();
    }

    /**
     * Initiate a new session by unlocking a trusted key.
     */
    private unlock(): void {
        this.$log.info(this.logTag, 'Initialize session by unlocking trusted key...');
    }

    /**
     * Decrypt the keys and initiate the session.
     */
    private unlockConfirm(): void {
        const decrypted: threema.TrustedKeyStoreData = this.trustedKeyStore.retrieveTrustedKey(this.password);
        if (decrypted === null) {
            return this.showDecryptionFailed();
        }

        // Instantiate new keystore
        const keyStore = new saltyrtcClient.KeyStore(decrypted.ownSecretKey);

        // Set up the broadcast channel that checks whether we're already connected in another tab
        this.setupBroadcastChannel(keyStore.publicKeyHex);

        // Initialize push service
        if (decrypted.pushToken !== null && decrypted.pushTokenType !== null) {
            this.webClientService.updatePushToken(decrypted.pushToken, decrypted.pushTokenType);
            this.pushService.init(decrypted.pushToken, decrypted.pushTokenType);
        }

        // Reconnect
        this.reconnect(keyStore, decrypted.peerPublicKey);
    }

    /**
     * Set up a `BroadcastChannel` to check if there are other tabs running on
     * the same session.
     *
     * The `publicKeyHex` parameter is the hex-encoded public key of the keystore
     * used to establish the SaltyRTC connection.
     */
    private setupBroadcastChannel(publicKeyHex: string) {
        if (!('BroadcastChannel' in this.$window)) {
            // No BroadcastChannel support in this browser
            this.$log.warn(this.logTag, 'BroadcastChannel not supported in this browser');
            return;
        }

        // Config constants
        const CHANNEL_NAME = 'session-check';
        const TYPE_PUBLIC_KEY = 'public-key';
        const TYPE_ALREADY_OPEN = 'already-open';

        // Set up new BroadcastChannel
        const channel = new BroadcastChannel(CHANNEL_NAME);

        // Register a message handler
        channel.onmessage = (event: MessageEvent) => {
            const message = JSON.parse(event.data);
            switch (message.type) {
                case TYPE_PUBLIC_KEY:
                    // Another tab is trying to connect to a session.
                    // Is it the same public key as the one we are using?
                    if (message.key === publicKeyHex
                            && (this.stateService.connectionBuildupState === 'loading'
                             || this.stateService.connectionBuildupState === 'done')) {
                        // Yes it is, notify them that the session is already active
                        this.$log.debug(
                            this.logTag,
                            'Another tab is trying to connect to our session. Respond with a broadcast.',
                        );
                        channel.postMessage(JSON.stringify({
                            type: TYPE_ALREADY_OPEN,
                            key: publicKeyHex,
                        }));
                    }
                    break;
                case TYPE_ALREADY_OPEN:
                    // Another tab notified us that the session we're trying to connect to
                    // is already active.
                    if (message.key === publicKeyHex && this.stateService.connectionBuildupState !== 'done') {
                        this.$log.error(this.logTag, 'Session already connected in another tab or window');
                        this.$timeout(() => {
                            this.stateService.updateConnectionBuildupState('already_connected');
                            this.stateService.state = GlobalConnectionState.Error;
                        }, 500);
                    }
                    break;
                default:
                    this.$log.warn(this.logTag, 'Unknown broadcast message type:', message.type);
                    break;
            }
        };

        // Notify other tabs that we're trying to connect
        this.$log.debug(this.logTag, 'Checking if the session is already open in another tab or window');
        channel.postMessage(JSON.stringify({
            type: TYPE_PUBLIC_KEY,
            key: publicKeyHex,
        }));
    }

    /**
     * Reconnect using a specific keypair and peer public key.
     */
    private reconnect(keyStore: saltyrtc.KeyStore, peerTrustedKey: Uint8Array): void {
        this.webClientService.init(keyStore, peerTrustedKey);
        this.start();
    }

    /**
     * Show a browser warning dialog.
     */
    private showBrowserWarning(): void {
        this.$translate.onReady().then(() => {
            const confirm = this.$mdDialog.confirm()
                .title(this.$translate.instant('welcome.BROWSER_NOT_SUPPORTED'))
                .htmlContent(this.$translate.instant('welcome.BROWSER_NOT_SUPPORTED_DETAILS'))
                .ok(this.$translate.instant('welcome.CONTINUE_ANYWAY'))
                .cancel(this.$translate.instant('welcome.ABORT'));
            this.$mdDialog.show(confirm).then(() => {
                // do nothing
            }, () => {
                // Redirect to Threema website
                window.location.replace('https://threema.ch/');
            });
        });
    }

    /**
     * Show a dialog indicating that local storage is not available.
     */
    private showLocalStorageWarning(): void {
        this.$translate.onReady().then(() => {
            const confirm = this.$mdDialog.alert()
                .title(this.$translate.instant('common.ERROR'))
                .htmlContent(this.$translate.instant('welcome.LOCAL_STORAGE_MISSING_DETAILS'))
                .ok(this.$translate.instant('common.OK'));
            this.$mdDialog.show(confirm);
        });
    }

    /**
     * Show a dialog indicating that local storage cannot be accessed.
     */
    private showLocalStorageException(e: Error): void {
        this.$translate.onReady().then(() => {
            const confirm = this.$mdDialog.alert()
                .title(this.$translate.instant('common.ERROR'))
                .htmlContent(this.$translate.instant('welcome.LOCAL_STORAGE_EXCEPTION_DETAILS', {
                    errorMsg: e.name,
                }))
                .ok(this.$translate.instant('common.OK'));
            this.$mdDialog.show(confirm);
        });
    }

    /**
     * Show the "decryption failed" dialog.
     */
    private showDecryptionFailed(): void {
        this.$mdDialog.show({
            controller: DialogController,
            controllerAs: 'ctrl',
            templateUrl: 'partials/dialog.unlockfailed.html',
            parent: angular.element(document.body),
            clickOutsideToClose: true,
            fullscreen: true,
        });
    }

    /**
     * Show the "already connected" dialog.
     */
    private showAlreadyConnected(): void {
        this.$translate.onReady().then(() => {
            const confirm = this.$mdDialog.alert()
            .title(this.$translate.instant('welcome.ALREADY_CONNECTED'))
            .htmlContent(this.$translate.instant('welcome.ALREADY_CONNECTED_DETAILS'))
            .ok(this.$translate.instant('common.OK'));
            this.$mdDialog.show(confirm);
        });
    }

    /**
     * Forget trusted keys.
     */
    private deleteSession(ev) {
        const confirm = this.$mdDialog.confirm()
             .title(this.$translate.instant('common.SESSION_DELETE'))
             .textContent(this.$translate.instant('common.CONFIRM_DELETE_BODY'))
             .targetEvent(ev)
             .ok(this.$translate.instant('common.YES'))
             .cancel(this.$translate.instant('common.CANCEL'));

        this.$mdDialog.show(confirm).then(() =>  {
            // Force-stop the webclient
            const deleteStoredData = true;
            const resetPush = true;
            const redirect = false;
            this.webClientService.stop(true, deleteStoredData, resetPush, redirect);

            // Reset state
            this.stateService.updateConnectionBuildupState('new');

            // Go back to scan mode
            this.mode = 'scan';
            this.password = '';

            // Initiate scan
            this.scan();
        }, () => {
            // do nothing
        });
    }

    private buildQrCode(payload: string) {
        // To calculate version and error correction, refer to this table:
        // http://www.thonky.com/qr-code-tutorial/character-capacities
        // The qr generator uses byte mode, therefore for 92 characters with
        // error correction level 'M' we need version 6.
        const len = payload.length;
        let version: number;
        if (len <= 134) {
            version = 6;
        } else if (len <= 154) {
            version = 7;
        } else if (len <= 192) {
            version = 8;
        } else if (len <= 230) {
            version = 9;
        } else if (len <= 271) {
            version = 10;
        } else if (len <= 321) {
            version = 11;
        } else if (len <= 367) {
            version = 12;
        } else if (len <= 425) {
            version = 13;
        } else if (len <= 458) {
            version = 14;
        } else if (len <= 520) {
            version = 15;
        } else if (len <= 586) {
            version = 16;
        } else {
            this.$log.error(this.logTag, 'QR Code payload too large: Is your SaltyRTC host string huge?');
            version = 40;
        }
        return {
            version: version,
            errorCorrectionLevel: 'L',
            size: 384,
            data: payload,
        };
    }

    /**
     * Actually start the webclient.
     *
     * It must be initialized before calling this method.
     */
    private start() {
        this.webClientService.start().then(
            // If connection buildup is done...
            () => {
                // Pass password to webclient service
                this.webClientService.setPassword(this.password);

                // Clear local password variable
                this.password = '';

                // Redirect to home
                this.$timeout(() => this.$state.go('messenger.home'), WelcomeController.REDIRECT_DELAY);
            },

            // If an error occurs...
            (error) => {
                this.$log.error(this.logTag, 'Error state:', error);
                // TODO: should probably show an error message instead
                this.$timeout(() => this.$state.reload(), WelcomeController.REDIRECT_DELAY);
            },

            // State updates
            (progress: threema.ConnectionBuildupStateChange) => {
                // Do nothing
            },
        );
    }

    /**
     * Reload the page.
     */
    public reload() {
        this.$window.location.reload();
    }
}

angular.module('3ema.welcome', [])

.config(['$stateProvider', ($stateProvider: ng.ui.IStateProvider) => {

    $stateProvider

        .state('welcome', {
            url: '/welcome',
            templateUrl: 'partials/welcome.html',
            controller: 'WelcomeController',
            controllerAs: 'ctrl',
            params: {initParams: null},
        });

}])

.controller('WelcomeController', WelcomeController);
