/************************************************************************
 * This file is part of EspoCRM.
 *
 * EspoCRM - Open Source CRM application.
 * Copyright (C) 2014-2023 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
 * Website: https://www.espocrm.com
 *
 * EspoCRM is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * EspoCRM 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 General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with EspoCRM. If not, see http://www.gnu.org/licenses/.
 *
 * The interactive user interfaces in modified source and object code versions
 * of this program must display Appropriate Legal Notices, as required under
 * Section 5 of the GNU General Public License version 3.
 *
 * In accordance with Section 7(b) of the GNU General Public License version 3,
 * these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
 ************************************************************************/

/** @module app */

import $ from 'jquery';
import Backbone from 'backbone';
import {Events, View as BullView, Factory as BullFactory} from 'bullbone';
import Base64 from 'js-base64';
import Ui from 'ui';
import Utils from 'utils';
import AclManager from 'acl-manager';
import Cache from 'cache';
import Storage from 'storage';
import Settings from 'models/settings';
import Language from 'language';
import Metadata from 'metadata';
import FieldManager from 'field-manager';
import User from 'models/user';
import Preferences from 'models/preferences';
import ModelFactory from 'model-factory';
import CollectionFactory from 'collection-factory';
import BaseController from 'controllers/base';
import Router from 'router';
import DateTime from 'date-time';
import LayoutManager from 'layout-manager';
import ThemeManager from 'theme-manager';
import SessionStorage from 'session-storage';
import ViewHelper from 'view-helper';
import WebSocketManager from 'web-socket-manager';
import Ajax from 'ajax';
import NumberUtil from 'number-util';
import PageTitle from 'page-title';
import BroadcastChannel from 'broadcast-channel';

/**
 * A main application class.
 *
 * @mixes Bull.Events
 */
class App {

    /**
     * @param {module:app~Options} options Options.
     * @param {module:app~callback} callback A callback.
     */
    constructor(options, callback) {
        options = options || {};

        /**
         * An application ID.
         *
         * @private
         * @type {string}
         */
        this.id = options.id || 'espocrm-application-id';

        /**
         * Use cache.
         *
         * @private
         * @type {boolean}
         */
        this.useCache = options.useCache || this.useCache;

        this.apiUrl = options.apiUrl || this.apiUrl;

        /**
         * A base path.
         *
         * @type {string}
         */
        this.basePath = options.basePath || '';

        /**
         * A default ajax request timeout.
         *
         * @private
         * @type {Number}
         */
        this.ajaxTimeout = options.ajaxTimeout || 0;

        /**
         * A list of internal modules.
         *
         * @private
         * @type {string[]}
         */
        this.internalModuleList = options.internalModuleList || [];

        /**
         * A list of bundled modules.
         *
         * @private
         * @type {string[]}
         */
        this.bundledModuleList = options.bundledModuleList || [];

        this.appTimestamp = options.appTimestamp;

        this.initCache(options)
            .then(() => this.init(options, callback));

        this.initDomEventListeners();
    }

    /**
     * @private
     * @type {boolean}
     */
    useCache = false

    /**
     * @protected
     * @type {module:models/user}
     */
    user = null

    /**
     * @private
     * @type {module:models/preferences}
     */
    preferences = null

    /**
     * @protected
     * @type {module:models/settings}
     */
    settings = null

    /**
     * @private
     * @type {module:metadata}
     */
    metadata = null

    /**
     * @private
     * @type {module:language}
     */
    language = null

    /**
     * @private
     * @type {module:field-manager}
     */
    fieldManager = null

    /**
     * @private
     * @type {module:cache|null}
     */
    cache = null

    /**
     * @private
     * @type {module:storage|null}
     */
    storage = null

    /**
     * @private
     */
    loader = null

    /**
     * An API URL.
     *
     * @private
     */
    apiUrl = 'api/v1'

    /**
     * An auth credentials string.
     *
     * @private
     * @type {?string}
     */
    auth = null

    /**
     * Another user to login as.
     *
     * @private
     * @type {?string}
     */
    anotherUser = null

    /**
     * A base controller.
     *
     * @private
     * @type {module:controllers/base}
     */
    baseController = null

    /**
     * @private
     */
    controllers = null

    /**
     * @private
     * @type {module:router}
     */
    router = null

    /**
     * @private
     * @type {module:model-factory}
     */
    modelFactory = null

    /**
     * @private
     * @type {module:collection-factory}
     */
    collectionFactory = null

    /**
     * A view factory.
     *
     * @private
     * @type {Factory}
     */
    viewFactory = null

    /**
     * @type {function(string, function(View))}
     * @private
     */
    viewLoader = null

    /**
     * @private
     * @type {module:view-helper}
     */
    viewHelper = null

    /**
     * A body view.
     *
     * @protected
     * @type {string}
     */
    masterView = 'views/site/master'

    /**
     * @private
     * @type {Cache|null}
     */
    responseCache = null

    /**
     * @private
     * @type {module:broadcast-channel|null}
     */
    broadcastChannel = null

    /**
     * @private
     * @type {module:date-time|null}
     */
    dateTime = null

    /**
     * @private
     * @type {module:num-util|null}
     */
    numberUtil = null

    /**
     * @private
     * @type {module:web-socket-manager|null}
     */
    webSocketManager = null

    /**
     * An application timestamp. Used for asset cache busting and update detection.
     *
     * @private
     * @type {Number|null}
     */
    appTimestamp = null

    /** @private */
    started = false

    /** @private */
    aclName = 'acl'

    /**
     * @private
     * @param {module:app~Options} options
     * @return Promise
     */
    initCache(options) {
        if (!this.useCache) {
            return Promise.resolve();
        }

        let cacheTimestamp = options.cacheTimestamp || null;

        this.cache = new Cache(cacheTimestamp);

        let storedCacheTimestamp = this.cache.getCacheTimestamp();

        cacheTimestamp ?
            this.cache.handleActuality(cacheTimestamp) :
            this.cache.storeTimestamp();

        if (!window.caches) {
            return Promise.resolve();
        }

        return new Promise(resolve => {
            let deleteCache = !cacheTimestamp ||
                !storedCacheTimestamp ||
                cacheTimestamp !== storedCacheTimestamp;

            (
                deleteCache ?
                    caches.delete('espo') :
                    Promise.resolve()
            )
                .then(() => caches.open('espo'))
                .then(cache => {
                    this.responseCache = cache;

                    resolve();
                })
                .catch(() => {
                    console.error(`Could not open 'espo' cache.`);
                    resolve();
                });
        });
    }

    /**
     * @private
     * @param {module:app~Options} options
     * @param {function} [callback]
     */
    init(options, callback) {
        /** @type {Object.<string, *>} */
        this.appParams = {};
        this.controllers = {};

        /**
         * @type {Espo.loader}
         * @private
         */
        this.loader = Espo.loader;

        this.loader.setResponseCache(this.responseCache);

        if (this.useCache && !this.loader.getCacheTimestamp() && options.cacheTimestamp) {
            this.loader.setCacheTimestamp(options.cacheTimestamp);
        }

        this.storage = new Storage();
        this.sessionStorage = new SessionStorage();

        this.setupAjax();

        this.settings = new Settings(null);
        this.language = new Language(this.cache);
        this.metadata = new Metadata(this.cache);
        this.fieldManager = new FieldManager();

        this.initBroadcastChannel();

        Promise
            .all([
                this.settings.load(),
                this.language.loadDefault(),
                this.initTemplateBundles(),
            ])
            .then(() => {
                this.loader.setIsDeveloperMode(this.settings.get('isDeveloperMode'));

                this.user = new User();
                this.preferences = new Preferences();

                this.preferences.settings = this.settings;

                /** @type {module:acl-manager} */
                this.acl = this.createAclManager();

                this.fieldManager.acl = this.acl;

                this.themeManager = new ThemeManager(this.settings, this.preferences, this.metadata);
                this.modelFactory = new ModelFactory(this.metadata);
                this.collectionFactory = new CollectionFactory(this.modelFactory, this.settings, this.metadata);

                if (this.settings.get('useWebSocket')) {
                    this.webSocketManager = new WebSocketManager(this.settings);
                }

                this.initUtils();
                this.initView();
                this.initBaseController();

                callback.call(this, this);
            });
    }

    /**
     * Start the application.
     */
    start() {
        this.initAuth();

        this.started = true;

        if (!this.auth) {
            this.baseController.login();

            return;
        }

        this.initUserData(null, () => this.onAuth.call(this));
    }

    /**
     * @private
     * @param {boolean} [afterLogin]
     */
    onAuth(afterLogin) {
        this.metadata.load().then(() => {
            this.fieldManager.defs = this.metadata.get('fields');
            this.fieldManager.metadata = this.metadata;

            this.settings.defs = this.metadata.get('entityDefs.Settings') || {};
            this.user.defs = this.metadata.get('entityDefs.User');
            this.preferences.defs = this.metadata.get('entityDefs.Preferences');
            this.viewHelper.layoutManager.userId = this.user.id;

            if (this.themeManager.isUserTheme()) {
                this.loadStylesheet();
            }

            if (this.anotherUser) {
                this.viewHelper.webSocketManager = null;
                this.webSocketManager = null;
            }

            if (this.webSocketManager) {
                this.webSocketManager.connect(this.auth, this.user.id);
            }

            let promiseList = [];
            let aclImplementationClassMap = {};

            let clientDefs = this.metadata.get('clientDefs') || {};

            Object.keys(clientDefs).forEach(scope => {
                let o = clientDefs[scope];

                let implClassName = (o || {})[this.aclName];

                if (!implClassName) {
                    return;
                }

                promiseList.push(
                    new Promise(resolve => {
                        this.loader.require(implClassName, implClass => {
                            aclImplementationClassMap[scope] = implClass;

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

            if (!this.themeManager.isApplied() && this.themeManager.isUserTheme()) {
                promiseList.push(
                    new Promise(resolve => {
                        const check = i => {
                            if (this.themeManager.isApplied() || i === 50) {
                                resolve();

                                return;
                            }

                            i = i || 0;

                            setTimeout(() => check(i + 1), 10);
                        }

                        check();
                    })
                );
            }

            Promise.all(promiseList)
                .then(() => {
                    this.acl.implementationClassMap = aclImplementationClassMap;

                    this.initRouter();
                });

            if (afterLogin) {
                this.broadcastChannel.postMessage('logged-in');
            }
        });
    }

    /**
     * @private
     */
    initRouter() {
        let routes = this.metadata.get(['app', 'clientRoutes']) || {};

        this.router = new Router({routes: routes});

        this.viewHelper.router = this.router;

        this.baseController.setRouter(this.router);

        this.router.confirmLeaveOutMessage = this.language.translate('confirmLeaveOutMessage', 'messages');
        this.router.confirmLeaveOutConfirmText = this.language.translate('Yes');
        this.router.confirmLeaveOutCancelText = this.language.translate('Cancel');

        this.router.on('routed', params => this.doAction(params));

        try {
            Backbone.history.start({root: window.location.pathname});
        }
        catch (e) {
            Backbone.history.loadUrl();
        }
    }

    /**
     * Do an action.
     *
     * @public
     * @param {{
     *   controller?: string,
     *   action: string,
     *   options?: Object.<string,*>,
     *   controllerClassName?: string,
     * }} params
     */
    doAction(params) {
        this.trigger('action', params);

        this.baseController.trigger('action');

        let callback = controller => {
            try {
                controller.doAction(params.action, params.options);

                this.trigger('action:done');
            }
            catch (e) {
                console.error(e);

                switch (e.name) {
                    case 'AccessDenied':
                        this.baseController.error403();

                        break;

                    case 'NotFound':
                        this.baseController.error404();

                        break;

                    default:
                        throw e;
                }
            }
        };

        if (params.controllerClassName) {
            this.createController(params.controllerClassName, null, callback);

            return;
        }

        this.getController(params.controller, callback);
    }

    /**
     * @private
     */
    initBaseController() {
        this.baseController = new BaseController({}, this.getControllerInjection());

        this.viewHelper.baseController = this.baseController;
    }

    /**
     * @private
     */
    getControllerInjection() {
        return {
            viewFactory: this.viewFactory,
            modelFactory: this.modelFactory,
            collectionFactory: this.collectionFactory,
            settings: this.settings,
            user: this.user,
            preferences: this.preferences,
            acl: this.acl,
            cache: this.cache,
            router: this.router,
            storage: this.storage,
            metadata: this.metadata,
            dateTime: this.dateTime,
            broadcastChannel: this.broadcastChannel,
            baseController: this.baseController,
        };
    }

    /**
     * @param {string} name
     * @param {function(module:controller): void} callback
     * @private
     */
    getController(name, callback) {
        if (!name) {
            callback(this.baseController);

            return;
        }

        if (name in this.controllers) {
            callback(this.controllers[name]);

            return;
        }

        try {
            let className = this.metadata.get(['clientDefs', name, 'controller']);

            if (!className) {
                let module = this.metadata.get(['scopes', name, 'module']);

                className = Utils.composeClassName(module, name, 'controllers');
            }

            this.createController(className, name, callback);
        }
        catch (e) {
            this.baseController.error404();
        }
    }

    /**
     * @private
     * @return {module:controller}
     */
    createController(className, name, callback) {
        Espo.loader.require(
            className,
            controllerClass => {
                let injections = this.getControllerInjection();

                let controller = new controllerClass(this.baseController.params, injections);

                controller.name = name;
                controller.masterView = this.masterView;

                this.controllers[name] = controller

                callback(controller);
            },
            () => this.baseController.error404()
        );
    }

    /**
     * @private
     */
    initUtils() {
        this.dateTime = new DateTime();
        this.modelFactory.dateTime = this.dateTime;
        this.dateTime.setSettingsAndPreferences(this.settings, this.preferences);
        this.numberUtil = new NumberUtil(this.settings, this.preferences);
    }

    /**
     * Create an acl-manager.
     *
     * @protected
     * @return {module:acl-manager}
     */
    createAclManager() {
        return new AclManager(this.user, null, this.settings.get('aclAllowDeleteCreated'));
    }

    /**
     * @private
     */
    initView() {
        let helper = this.viewHelper = new ViewHelper();

        helper.layoutManager = new LayoutManager(this.cache, this.id);
        helper.settings = this.settings;
        helper.config = this.settings;
        helper.user = this.user;
        helper.preferences = this.preferences;
        helper.acl = this.acl;
        helper.modelFactory = this.modelFactory;
        helper.collectionFactory = this.collectionFactory;
        helper.storage = this.storage;
        helper.sessionStorage = this.sessionStorage;
        helper.dateTime = this.dateTime;
        helper.language = this.language;
        helper.metadata = this.metadata;
        helper.fieldManager = this.fieldManager;
        helper.cache = this.cache;
        helper.themeManager = this.themeManager;
        helper.webSocketManager = this.webSocketManager;
        helper.numberUtil = this.numberUtil;
        helper.pageTitle = new PageTitle(this.settings);
        helper.basePath = this.basePath;
        helper.appParams = this.appParams;
        helper.broadcastChannel = this.broadcastChannel;

        this.viewLoader = (viewName, callback) => {
            this.loader.require(Utils.composeViewClassName(viewName), callback);
        };

        let internalModuleMap = {};

        const isModuleInternal = (module) => {
            if (!(module in internalModuleMap)) {
                internalModuleMap[module] = this.internalModuleList.indexOf(module) !== -1;
            }

            return internalModuleMap[module];
        };

        const getResourceInnerPath = (type, name) => {
            let path = null;

            switch (type) {
                case 'template':
                    if (~name.indexOf('.')) {
                        console.warn(name + ': template name should use slashes for a directory separator.');
                    }

                    path = 'res/templates/' + name.split('.').join('/') + '.tpl';

                    break;

                case 'layoutTemplate':
                    path = 'res/layout-types/' + name + '.tpl';

                    break;
            }

            return path;
        };

        const getResourcePath = (type, name) => {
            if (!name.includes(':')) {
                return 'client/' + getResourceInnerPath(type, name);
            }

            let [mod, path] = name.split(':');

            if (mod === 'custom') {
                return 'client/custom/' + getResourceInnerPath(type, path);
            }

            if (isModuleInternal(mod)) {
                return 'client/modules/' + mod + '/' + getResourceInnerPath(type, path);
            }

            return 'client/custom/modules/' + mod + '/' + getResourceInnerPath(type, path);
        };

        this.viewFactory = new BullFactory({
            defaultViewName: 'views/base',
            helper: helper,
            viewLoader: this.viewLoader,
            resources: {
                loaders: {
                    template: (name, callback) => {
                        let path = getResourcePath('template', name);

                        this.loader.require('res!' + path, callback);
                    },
                    layoutTemplate: (name, callback) => {
                        if (Espo.layoutTemplates && name in Espo.layoutTemplates) {
                            callback(Espo.layoutTemplates[name]);

                            return;
                        }

                        let path = getResourcePath('layoutTemplate', name);

                        this.loader.require('res!' + path, callback);
                    },
                },
            },
            preCompiledTemplates: Espo.preCompiledTemplates || {},
        });
    }

    /**
     * @public
     */
    initAuth() {
        this.auth = this.storage.get('user', 'auth') || null;
        this.anotherUser = this.storage.get('user', 'anotherUser') || null;

        this.baseController.on('login', data => {
            let userId = data.user.id;
            let userName = data.auth.userName;
            let token = data.auth.token;
            let anotherUser = data.auth.anotherUser || null;

            this.auth = Base64.encode(userName  + ':' + token);
            this.anotherUser = anotherUser;

            let lastUserId = this.storage.get('user', 'lastUserId');

            if (lastUserId !== userId) {
                this.metadata.clearCache();
                this.language.clearCache();
            }

            this.storage.set('user', 'auth', this.auth);
            this.storage.set('user', 'lastUserId', userId);
            this.storage.set('user', 'anotherUser', this.anotherUser);

            this.setCookieAuth(userName, token);

            this.initUserData(data, () => this.onAuth(true));
        });

        this.baseController.on('logout', () => this.logout());
    }

    /**
     * @private
     */
    logout(afterFail, silent) {
        let logoutWait = false;

        if (this.auth && !afterFail) {
            let arr = Base64.decode(this.auth).split(':');

            if (arr.length > 1) {
                logoutWait = this.appParams.logoutWait || false;

                Ajax.postRequest('App/destroyAuthToken', {token: arr[1]}, {resolveWithXhr: true})
                    .then(/** XMLHttpRequest */xhr => {
                        let redirectUrl = xhr.getResponseHeader('X-Logout-Redirect-Url');

                        if (redirectUrl) {
                            setTimeout(() => window.location.href = redirectUrl, 50);

                            return;
                        }

                        if (logoutWait) {
                            this.doAction({action: 'login'});
                        }
                    });
            }
        }

        if (this.webSocketManager) {
            this.webSocketManager.close();
        }

        silent = silent || afterFail &&
            this.auth &&
            this.auth !== this.storage.get('user', 'auth');

        this.auth = null;
        this.anotherUser = null;

        this.user.clear();
        this.preferences.clear();
        this.acl.clear();

        if (!silent) {
            this.storage.clear('user', 'auth');
            this.storage.clear('user', 'anotherUser');
        }

        let action = logoutWait ? 'logoutWait' : 'login';

        this.doAction({action: action});

        if (!silent) {
            this.unsetCookieAuth();
        }

        if (this.broadcastChannel.object) {
            if (!silent) {
                this.broadcastChannel.postMessage('logged-out');
            }
        }

        if (!silent) {
            this.sendLogoutRequest();
        }

        this.loadStylesheet();
    }

    /**
     * @private
     */
    sendLogoutRequest() {
        let xhr = new XMLHttpRequest;

        xhr.open('GET', this.basePath + this.apiUrl + '/');
        xhr.setRequestHeader('Authorization', 'Basic ' + Base64.encode('**logout:logout'));
        xhr.send('');
        xhr.abort();
    }

    /**
     * @private
     */
    loadStylesheet() {
        if (!this.metadata.get(['themes'])) {
            return;
        }

        let stylesheetPath = this.basePath + this.themeManager.getStylesheet();

        $('#main-stylesheet').attr('href', stylesheetPath);
    }

    /**
     * @private
     */
    setCookieAuth(username, token) {
        let date = new Date();

        date.setTime(date.getTime() + (1000 * 24 * 60 * 60 * 1000));

        document.cookie = 'auth-token=' + token + '; SameSite=Lax; expires=' + date.toUTCString() + '; path=/';
    }

    /**
     * @private
     */
    unsetCookieAuth() {
        document.cookie = 'auth-token' + '=; SameSite=Lax; expires=Thu, 01 Jan 1970 00:00:01 GMT; path=/';
    }

    /**
     * @private
     */
    initUserData(options, callback) {
        options = options || {};

        if (this.auth === null) {
            return;
        }

        new Promise(resolve => {
            if (options.user) {
                resolve(options);

                return;
            }

            this.requestUserData(data => {
                options = data;

                resolve(options);
            });
        })
            .then(options => {
                this.language.name = options.language;

                return this.language.load();
            })
            .then(() => {
                this.dateTime.setLanguage(this.language);

                let userData = options.user || null;
                let preferencesData = options.preferences || null;
                let aclData = options.acl || null;

                let settingData = options.settings || {};

                this.user.set(userData);
                this.preferences.set(preferencesData);

                this.settings.set(settingData);
                this.acl.set(aclData);

                for (let param in options.appParams) {
                    this.appParams[param] = options.appParams[param];
                }

                if (!this.auth) {
                    return;
                }

                let xhr = new XMLHttpRequest();

                xhr.open('GET', this.basePath + this.apiUrl + '/');
                xhr.setRequestHeader('Authorization', 'Basic ' + this.auth);

                xhr.onreadystatechange = () => {
                    if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
                        let arr = Base64.decode(this.auth).split(':');

                        this.setCookieAuth(arr[0], arr[1]);

                        callback();
                    }

                    if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 401) {
                        Ui.error('Auth error');
                    }
                };

                xhr.send('');
            });
    }

    /**
     * @private
     */
    requestUserData(callback) {
        Ajax.getRequest('App/user', {}, {appStart: true})
            .then(callback);
    }

    /**
     * @private
     */
    setupAjax() {
        /**
         * @param {XMLHttpRequest} xhr
         * @param {Object.<string, *>} options
         */
        const beforeSend = (xhr, options) => {
            if (this.auth !== null && !options.login) {
                xhr.setRequestHeader('Authorization', 'Basic ' + this.auth);
                xhr.setRequestHeader('Espo-Authorization', this.auth);
                xhr.setRequestHeader('Espo-Authorization-By-Token', 'true');
            }

            if (this.anotherUser !== null && !options.login) {
                xhr.setRequestHeader('X-Another-User', this.anotherUser);
            }
        };

        let appTimestampChangeProcessed = false;

        /**
         * @param {XMLHttpRequest} xhr
         * @param {Object.<string, *>} options
         */
        const onSuccess = (xhr, options) => {
            let appTimestampHeader = xhr.getResponseHeader('X-App-Timestamp');

            if (!appTimestampHeader || appTimestampChangeProcessed) {
                return;
            }

            let appTimestamp = parseInt(appTimestampHeader);

            // noinspection JSUnresolvedReference
            let bypassAppReload = options.bypassAppReload;

            if (
                this.appTimestamp &&
                // this.appTimestamp is set to current time if cache disabled.
                appTimestamp > this.appTimestamp &&
                !bypassAppReload
            ) {
                appTimestampChangeProcessed = true;

                Ui
                    .confirm(
                        this.language.translate('confirmAppRefresh', 'messages'),
                        {
                            confirmText: this.language.translate('Refresh'),
                            cancelText: this.language.translate('Cancel'),
                            backdrop: 'static',
                            confirmStyle: 'success',
                        }
                    )
                    .then(() => {
                        window.location.reload();

                        if (this.broadcastChannel) {
                            this.broadcastChannel.postMessage('reload');
                        }
                    });
            }
        };

        /**
         * @param {module:ajax.Xhr} xhr
         * @param {Object.<string, *>} options
         */
        const onError = (xhr, options) => {
            setTimeout(() => {
                if (xhr.errorIsHandled) {
                    return;
                }

                switch (xhr.status) {
                    case 200:
                        Ui.error(this.language.translate('Bad server response'));

                        console.error('Bad server response: ' + xhr.responseText);

                        break;

                    case 401:
                        // noinspection JSUnresolvedReference
                        if (options.login) {
                            break;
                        }

                        if (this.auth && this.router && !this.router.confirmLeaveOut) {
                            this.logout(true);

                            break;
                        }

                        if (this.auth && this.router && this.router.confirmLeaveOut) {
                            Ui.error(this.language.translate('loggedOutLeaveOut', 'messages'), true);

                            this.router.trigger('logout');

                            break;
                        }

                        if (this.auth) {
                            // noinspection JSUnresolvedReference
                            let silent = !options.appStart;

                            this.logout(true, silent);
                        }

                        console.error('Error 401: Unauthorized.');

                        break;

                    case 403:
                        // noinspection JSUnresolvedReference
                        if (options.main) {
                            this.baseController.error403();

                            break;
                        }

                        this._processErrorAlert(xhr, 'Access denied');

                        break;

                    case 400:
                        this._processErrorAlert(xhr, 'Bad request');

                        break;

                    case 404:
                        // noinspection JSUnresolvedReference
                        if (options.main) {
                            this.baseController.error404();

                            break
                        }

                        this._processErrorAlert(xhr, 'Not found', true);

                        break;

                    default:
                        this._processErrorAlert(xhr, null);
                }

                let statusReason = xhr.getResponseHeader('X-Status-Reason');

                if (statusReason) {
                    console.error('Server side error ' + xhr.status + ': ' + statusReason);
                }
            }, 0);
        };

        const onTimeout = () => {
            Ui.error(this.language.translate('Timeout'), true);
        };

        Ajax.configure({
            apiUrl: this.basePath + this.apiUrl,
            timeout: this.ajaxTimeout,
            beforeSend: beforeSend,
            onSuccess: onSuccess,
            onError: onError,
            onTimeout: onTimeout,
        });

        // For backward compatibility.
        // @todo Remove in v9.0.
        $.ajaxSetup({
            beforeSend: (xhr, options) => {
                if (!options.url || !options.url.includes('q=')) {
                    console.warn(`$.ajax is deprecated, support will be removed in v9.0. Use Espo.Ajax instead.`);
                }

                // noinspection JSUnresolvedReference
                if (!options.local && this.apiUrl) {
                    options.url = Utils.trimSlash(this.apiUrl) + '/' + options.url;
                }

                // noinspection JSUnresolvedReference
                if (!options.local && this.basePath !== '') {
                    options.url = this.basePath + options.url;
                }

                if (this.auth !== null) {
                    xhr.setRequestHeader('Authorization', 'Basic ' + this.auth);
                    xhr.setRequestHeader('Espo-Authorization', this.auth);
                    xhr.setRequestHeader('Espo-Authorization-By-Token', 'true');
                }

                if (this.anotherUser !== null) {
                    xhr.setRequestHeader('X-Another-User', this.anotherUser);
                }
            },
            dataType: 'json',
            timeout: this.ajaxTimeout,
            contentType: 'application/json',
        });
    }

    /**
     * @private
     */
    _processErrorAlert(xhr, label, noDetail) {
        let msg = this.language.translate('Error') + ' ' + xhr.status;

        if (label) {
            msg += ': ' + this.language.translate(label);
        }

        let obj = {
            msg: msg,
            closeButton: false,
        };

        let isMessageDone = false;

        if (noDetail) {
            isMessageDone = true;
        }

        if (!isMessageDone && xhr.responseText && xhr.responseText[0] === '{') {
            /** @type {Object.<string, *>|null} */
            let data = null;

            try {
                data = JSON.parse(xhr.responseText);
            }
            catch (e) {}

            if (data && data.messageTranslation && data.messageTranslation.label) {
                let msgDetail = this.language.translate(
                    data.messageTranslation.label,
                    'messages',
                    data.messageTranslation.scope
                );

                let msgData = data.messageTranslation.data || {};

                for (let key in msgData) {
                    msgDetail = msgDetail.replace('{' + key + '}', msgData[key]);
                }

                obj.msg += '\n' + msgDetail;
                obj.closeButton = true;

                isMessageDone = true;
            }
        }

        if (!isMessageDone) {
            let statusReason = xhr.getResponseHeader('X-Status-Reason');

            if (statusReason) {
                obj.msg += '\n' + statusReason;
                obj.closeButton = true;
            }
        }

        Ui.error(obj.msg, obj.closeButton);
    }

    /**
     * @private
     */
    initBroadcastChannel() {
        this.broadcastChannel = new BroadcastChannel();

        this.broadcastChannel.subscribe(event => {
            if (!this.auth && this.started) {
                if (event.data === 'logged-in') {
                    // This works if the same instance opened in different tabs.
                    // This does not work for different instances on the same domain
                    // which may be the case in dev environment.
                    window.location.reload();
                }

                return;
            }

            if (event.data === 'update:all') {
                this.metadata.loadSkipCache();
                this.settings.load();
                this.language.loadSkipCache();
                this.viewHelper.layoutManager.clearLoadedData();

                return;
            }

            if (event.data === 'update:metadata') {
                this.metadata.loadSkipCache();

                return;
            }

            if (event.data === 'update:config') {
                this.settings.load();

                return;
            }

            if (event.data === 'update:language') {
                this.language.loadSkipCache();

                return;
            }

            if (event.data === 'update:layout') {
                this.viewHelper.layoutManager.clearLoadedData();

                return;
            }

            if (event.data === 'reload') {
                window.location.reload();

                return;
            }

            if (event.data === 'logged-out' && this.started) {
                if (this.auth && this.router.confirmLeaveOut) {
                    Ui.error(this.language.translate('loggedOutLeaveOut', 'messages'), true);

                    this.router.trigger('logout');

                    return;
                }

                this.logout(true);
            }
        });
    }

    /**
     * @private
     */
    initDomEventListeners() {
        $(document).on('keydown.espo.button', e => {
            if (
                e.code !== 'Enter' ||
                e.target.tagName !== 'A' ||
                e.target.getAttribute('role') !== 'button' ||
                e.target.getAttribute('href') ||
                e.ctrlKey ||
                e.altKey ||
                e.metaKey
            ) {
                return;
            }

            $(e.target).click();

            e.preventDefault();
        });
    }

    /**
     * @private
     * @return {Promise}
     */
    initTemplateBundles() {
        if (!this.responseCache) {
            return Promise.resolve();
        }

        const key = 'templateBundlesCached';

        if (this.cache.get('app', key)) {
            return Promise.resolve();
        }

        let files = ['client/lib/templates.tpl'];

        this.bundledModuleList.forEach(mod => {
            let file = this.internalModuleList.includes(mod) ?
                `client/modules/${mod}/lib/templates.tpl` :
                `client/custom/modules/${mod}/lib/templates.tpl`;

            files.push(file);
        });

        let baseUrl = Utils.obtainBaseUrl();
        let timestamp = this.loader.getCacheTimestamp();

        let promiseList = files.map(file => {
            let url = new URL(baseUrl + this.basePath + file);
            url.searchParams.append('t', this.appTimestamp);

            return new Promise(resolve => {
                fetch(url)
                    .then(response => {
                        if (!response.ok) {
                            console.error(`Could not fetch ${url}.`);
                            resolve();

                            return;
                        }

                        let promiseList = [];

                        response.text().then(text => {
                            let index = text.indexOf('\n');

                            if (index <= 0) {
                                resolve();

                                return;
                            }

                            let delimiter = text.slice(0, index + 1);
                            text = text.slice(index + 1);

                            text.split(delimiter).forEach(item => {
                                let index = item.indexOf('\n');

                                let file = item.slice(0, index);
                                let content = item.slice(index + 1);

                                let url = baseUrl + this.basePath + 'client/' + file;

                                let urlObj = new URL(url);
                                urlObj.searchParams.append('r', timestamp);

                                promiseList.push(
                                    this.responseCache.put(urlObj, new Response(content))
                                );
                            });
                        });

                        Promise.all(promiseList).then(() => resolve());
                    });
            });
        });

        return Promise.all(promiseList)
            .then(() => {
                this.cache.set('app', key, true);
            });
    }
}

/**
 * @callback module:app~callback
 * @param {App} app A created application instance.
 */

/**
 * Application options.
 *
 * @typedef {Object} module:app~Options
 * @property {string} [id] An application ID.
 * @property {string} [basePath] A base path.
 * @property {boolean} [useCache] Use cache.
 * @property {string} [apiUrl] An API URL.
 * @property {Number} [ajaxTimeout] A default ajax request timeout.
 * @property {string} [internalModuleList] A list of internal modules.
 *   Internal modules located in the `client/modules` directory.
 * @property {string} [bundledModuleList] A list of bundled modules.
 * @property {Number|null} [cacheTimestamp] A cache timestamp.
 * @property {Number|null} [appTimestamp] An application timestamp.
 */

Object.assign(App.prototype, Events);

App.extend = BullView.extend;

export default App;
