import { AutoType } from 'auto-type';
import Backbone from 'backbone';
import { Storage } from 'storage';
import { EntryCollection } from 'collections/entry-collection';
import { FileCollection } from 'collections/file-collection';
import { FileInfoCollection } from 'collections/file-info-collection';
import { RuntimeInfo } from 'comp/app/runtime-info';
import { Launcher } from 'comp/launcher';
import { Timeouts } from 'const/timeouts';
import { AppSettingsModel } from 'models/app-settings-model';
import { EntryModel } from 'models/entry-model';
import { FileInfoModel } from 'models/file-info-model';
import { FileModel } from 'models/file-model';
import { GroupModel } from 'models/group-model';
import { MenuModel } from 'models/menu/menu-model';
import { PluginManager } from 'plugins/plugin-manager';
import { Features } from 'util/features';
import { DateFormat } from 'util/formatting/date-format';
import { UrlFormat } from 'util/formatting/url-format';
import { IdGenerator } from 'util/generators/id-generator';
import { Locale } from 'util/locale';
import { Logger } from 'util/logger';
import 'util/kdbxweb/protected-value-ex';

const AppModel = Backbone.Model.extend({
    defaults: {},

    initialize() {
        this.tags = [];
        this.files = new FileCollection();
        this.fileInfos = FileInfoCollection.instance;
        this.menu = new MenuModel();
        this.filter = {};
        this.sort = 'title';
        this.settings = AppSettingsModel.instance;
        this.activeEntryId = null;
        this.isBeta = RuntimeInfo.beta;
        this.advancedSearch = null;

        this.listenTo(Backbone, 'refresh', this.refresh);
        this.listenTo(Backbone, 'set-filter', this.setFilter);
        this.listenTo(Backbone, 'add-filter', this.addFilter);
        this.listenTo(Backbone, 'set-sort', this.setSort);
        this.listenTo(Backbone, 'empty-trash', this.emptyTrash);
        this.listenTo(Backbone, 'select-entry', this.selectEntry);
        this.listenTo(Backbone, 'unset-keyfile', this.unsetKeyFile);

        this.appLogger = new Logger('app');

        AppModel.instance = this;
    },

    prepare() {
        AutoType.init(this);
        _.forEach(Storage, prv => prv.init());
    },

    loadConfig(configLocation) {
        return new Promise((resolve, reject) => {
            this.ensureCanLoadConfig(configLocation);
            this.appLogger.debug('Loading config from', configLocation);
            const ts = this.appLogger.ts();
            const xhr = new XMLHttpRequest();
            xhr.open('GET', configLocation);
            xhr.responseType = 'json';
            xhr.send();
            xhr.addEventListener('load', () => {
                let response = xhr.response;
                if (!response) {
                    const errorDesc = xhr.statusText === 'OK' ? 'Malformed JSON' : xhr.statusText;
                    this.appLogger.error('Error loading app config', errorDesc);
                    return reject('Error loading app config');
                }
                if (typeof response === 'string') {
                    try {
                        response = JSON.parse(response);
                    } catch (e) {
                        this.appLogger.error('Error parsing response', e, response);
                        return reject('Error parsing response');
                    }
                }
                if (!response.settings) {
                    this.appLogger.error('Invalid app config, no settings section', response);
                    return reject('Invalid app config, no settings section');
                }
                this.appLogger.info(
                    'Loaded app config from',
                    configLocation,
                    this.appLogger.ts(ts)
                );
                resolve(response);
            });
            xhr.addEventListener('error', () => {
                this.appLogger.error('Error loading app config', xhr.statusText, xhr.status);
                reject('Error loading app config');
            });
        }).then(config => {
            return this.applyUserConfig(config);
        });
    },

    ensureCanLoadConfig(url) {
        if (!Features.isSelfHosted) {
            throw 'Configs are supported only in self-hosted installations';
        }
        const link = document.createElement('a');
        link.href = url;
        const isExternal = link.host && link.host !== location.host;
        if (isExternal) {
            throw 'Loading config from this location is not allowed';
        }
    },

    applyUserConfig(config) {
        this.settings.set(config.settings);
        if (config.files) {
            if (config.showOnlyFilesFromConfig) {
                this.fileInfos.reset();
            }
            config.files
                .filter(
                    file =>
                        file &&
                        file.storage &&
                        file.name &&
                        file.path &&
                        !this.fileInfos.getMatch(file.storage, file.name, file.path)
                )
                .map(
                    file =>
                        new FileInfoModel({
                            id: IdGenerator.uuid(),
                            name: file.name,
                            storage: file.storage,
                            path: file.path,
                            opts: file.options
                        })
                )
                .reverse()
                .forEach(fi => this.fileInfos.unshift(fi));
        }
        if (config.plugins) {
            const pluginsPromises = config.plugins.map(plugin =>
                PluginManager.instance.installIfNew(plugin.url, plugin.manifest, true)
            );
            return Promise.all(pluginsPromises).then(() => {
                this.settings.set(config.settings);
            });
        }
        if (config.advancedSearch) {
            this.advancedSearch = config.advancedSearch;
            this.addFilter({ advanced: this.advancedSearch });
        }
    },

    addFile(file) {
        if (this.files.get(file.id)) {
            return false;
        }
        this.files.add(file);
        file.get('groups').forEach(function(group) {
            this.menu.groupsSection.addItem(group);
        }, this);
        this._addTags(file);
        this._tagsChanged();
        this.menu.filesSection.addItem({
            icon: 'lock',
            title: file.get('name'),
            page: 'file',
            file
        });
        this.refresh();
        this.listenTo(file, 'reload', this.reloadFile);
        return true;
    },

    reloadFile(file) {
        this.menu.groupsSection.replaceByFile(file, file.get('groups').first());
        this.updateTags();
    },

    _addTags(file) {
        const tagsHash = {};
        this.tags.forEach(tag => {
            tagsHash[tag.toLowerCase()] = true;
        });
        file.forEachEntry({}, entry => {
            _.forEach(entry.tags, tag => {
                if (!tagsHash[tag.toLowerCase()]) {
                    tagsHash[tag.toLowerCase()] = true;
                    this.tags.push(tag);
                }
            });
        });
        this.tags.sort();
    },

    _tagsChanged() {
        if (this.tags.length) {
            this.menu.tagsSection.set('scrollable', true);
            this.menu.tagsSection.setItems(
                this.tags.map(tag => {
                    return {
                        title: tag,
                        icon: 'tag',
                        filterKey: 'tag',
                        filterValue: tag,
                        editable: true
                    };
                })
            );
        } else {
            this.menu.tagsSection.set('scrollable', false);
            this.menu.tagsSection.removeAllItems();
        }
    },

    updateTags() {
        const oldTags = this.tags.slice();
        this.tags.splice(0, this.tags.length);
        this.files.forEach(function(file) {
            this._addTags(file);
        }, this);
        if (!_.isEqual(oldTags, this.tags)) {
            this._tagsChanged();
        }
    },

    renameTag(from, to) {
        this.files.forEach(file => file.renameTag(from, to));
        this.updateTags();
    },

    closeAllFiles() {
        this.files.each(file => {
            file.close();
            this.fileClosed(file);
        });
        this.files.reset();
        this.menu.groupsSection.removeAllItems();
        this.menu.tagsSection.set('scrollable', false);
        this.menu.tagsSection.removeAllItems();
        this.menu.filesSection.removeAllItems();
        this.tags.splice(0, this.tags.length);
        this.filter = {};
        this.menu.select({ item: this.menu.allItemsItem });
    },

    closeFile(file) {
        file.close();
        this.fileClosed(file);
        this.files.remove(file);
        this.updateTags();
        this.menu.groupsSection.removeByFile(file);
        this.menu.filesSection.removeByFile(file);
        this.menu.select({ item: this.menu.allItemsSection.get('items').first() });
    },

    emptyTrash() {
        this.files.forEach(file => file.emptyTrash());
        this.refresh();
    },

    setFilter(filter) {
        this.filter = this.prepareFilter(filter);
        this.filter.subGroups = this.settings.get('expandGroups');
        if (!this.filter.advanced && this.advancedSearch) {
            this.filter.advanced = this.advancedSearch;
        }
        const entries = this.getEntries();
        if (!this.activeEntryId || !entries.get(this.activeEntryId)) {
            const firstEntry = entries.first();
            this.activeEntryId = firstEntry ? firstEntry.id : null;
        }
        Backbone.trigger('filter', { filter: this.filter, sort: this.sort, entries });
        Backbone.trigger('entry-selected', entries.get(this.activeEntryId));
    },

    refresh() {
        this.setFilter(this.filter);
    },

    selectEntry(entry) {
        this.activeEntryId = entry.id;
        this.refresh();
    },

    addFilter(filter) {
        this.setFilter(_.extend(this.filter, filter));
    },

    setSort(sort) {
        this.sort = sort;
        this.setFilter(this.filter);
    },

    getEntries() {
        const entries = this.getEntriesByFilter(this.filter);
        entries.sortEntries(this.sort, this.filter);
        if (this.filter.trash) {
            this.addTrashGroups(entries);
        }
        return entries;
    },

    getEntriesByFilter(filter) {
        const preparedFilter = this.prepareFilter(filter);
        const entries = new EntryCollection();
        this.files.forEach(file => {
            file.forEachEntry(preparedFilter, entry => entries.push(entry));
        });
        return entries;
    },

    addTrashGroups(collection) {
        this.files.forEach(file => {
            const trashGroup = file.getTrashGroup();
            if (trashGroup) {
                trashGroup.getOwnSubGroups().forEach(group => {
                    collection.unshift(GroupModel.fromGroup(group, file, trashGroup));
                });
            }
        });
    },

    prepareFilter(filter) {
        filter = _.clone(filter);
        filter.textLower = filter.text ? filter.text.toLowerCase() : '';
        filter.tagLower = filter.tag ? filter.tag.toLowerCase() : '';
        return filter;
    },

    getFirstSelectedGroup() {
        const selGroupId = this.filter.group;
        let file, group;
        if (selGroupId) {
            this.files.some(f => {
                file = f;
                group = f.getGroup(selGroupId);
                return group;
            });
        }
        if (!group) {
            file = this.files.first();
            group = file.get('groups').first();
        }
        return { group, file };
    },

    completeUserNames(part) {
        const userNames = {};
        this.files.forEach(file => {
            file.forEachEntry(
                { text: part, textLower: part.toLowerCase(), advanced: { user: true } },
                entry => {
                    const userName = entry.user;
                    if (userName) {
                        userNames[userName] = (userNames[userName] || 0) + 1;
                    }
                }
            );
        });
        const matches = _.pairs(userNames);
        matches.sort((x, y) => y[1] - x[1]);
        const maxResults = 5;
        if (matches.length > maxResults) {
            matches.length = maxResults;
        }
        return matches.map(m => m[0]);
    },

    getEntryTemplates() {
        const entryTemplates = [];
        this.files.forEach(file => {
            file.forEachEntryTemplate(entry => {
                entryTemplates.push({ file, entry });
            });
        });
        return entryTemplates;
    },

    createNewEntry(args) {
        const sel = this.getFirstSelectedGroup();
        if (args && args.template) {
            if (sel.file !== args.template.file) {
                sel.file = args.template.file;
                sel.group = args.template.file.get('groups').first();
            }
            const templateEntry = args.template.entry;
            const newEntry = EntryModel.newEntry(sel.group, sel.file);
            newEntry.copyFromTemplate(templateEntry);
            return newEntry;
        } else {
            return EntryModel.newEntry(sel.group, sel.file);
        }
    },

    createNewGroup() {
        const sel = this.getFirstSelectedGroup();
        return GroupModel.newGroup(sel.group, sel.file);
    },

    createNewTemplateEntry() {
        const file = this.getFirstSelectedGroup().file;
        const group = file.getEntryTemplatesGroup() || file.createEntryTemplatesGroup();
        return EntryModel.newEntry(group, file);
    },

    createDemoFile() {
        if (!this.files.getByName('Demo')) {
            const demoFile = new FileModel({ id: IdGenerator.uuid() });
            demoFile.openDemo(() => {
                this.addFile(demoFile);
            });
            return true;
        } else {
            return false;
        }
    },

    createNewFile() {
        let name;
        for (let i = 0; ; i++) {
            name = 'New' + (i || '');
            if (!this.files.getByName(name) && !this.fileInfos.getByName(name)) {
                break;
            }
        }
        const newFile = new FileModel({ id: IdGenerator.uuid() });
        newFile.create(name);
        this.addFile(newFile);
    },

    openFile(params, callback) {
        const logger = new Logger('open', params.name);
        logger.info('File open request');

        const fileInfo = params.id
            ? this.fileInfos.get(params.id)
            : this.fileInfos.getMatch(params.storage, params.name, params.path);

        if (!params.opts && fileInfo && fileInfo.get('opts')) {
            params.opts = fileInfo.get('opts');
        }

        if (fileInfo && fileInfo.get('modified')) {
            logger.info('Open file from cache because it is modified');
            this.openFileFromCache(
                params,
                (err, file) => {
                    if (!err && file) {
                        logger.info('Sync just opened modified file');
                        _.defer(() => this.syncFile(file));
                    }
                    callback(err);
                },
                fileInfo
            );
        } else if (params.fileData) {
            logger.info('Open file from supplied content');
            const needSaveToCache = params.storage !== 'file';
            this.openFileWithData(params, callback, fileInfo, params.fileData, needSaveToCache);
        } else if (!params.storage) {
            logger.info('Open file from cache as main storage');
            this.openFileFromCache(params, callback, fileInfo);
        } else if (
            fileInfo &&
            fileInfo.get('openDate') &&
            fileInfo.get('rev') === params.rev &&
            fileInfo.get('storage') !== 'file'
        ) {
            logger.info('Open file from cache because it is latest');
            this.openFileFromCache(
                params,
                (err, file) => {
                    if (err) {
                        logger.info(
                            'Error loading file from cache, trying to open from storage',
                            err
                        );
                        this.openFileFromStorage(params, callback, fileInfo, logger, true);
                    } else {
                        callback(err, file);
                    }
                },
                fileInfo
            );
        } else if (!fileInfo || !fileInfo.get('openDate') || params.storage === 'file') {
            this.openFileFromStorage(params, callback, fileInfo, logger);
        } else {
            logger.info('Open file from cache, will sync after load', params.storage);
            this.openFileFromCache(
                params,
                (err, file) => {
                    if (!err && file) {
                        logger.info('Sync just opened file');
                        _.defer(() => this.syncFile(file));
                        callback(err);
                    } else {
                        logger.info(
                            'Error loading file from cache, trying to open from storage',
                            err
                        );
                        this.openFileFromStorage(params, callback, fileInfo, logger, true);
                    }
                },
                fileInfo
            );
        }
    },

    openFileFromCache(params, callback, fileInfo) {
        Storage.cache.load(fileInfo.id, null, (err, data) => {
            if (!data) {
                err = Locale.openFileNoCacheError;
            }
            new Logger('open', params.name).info('Loaded file from cache', err);
            if (err) {
                callback(err);
            } else {
                this.openFileWithData(params, callback, fileInfo, data);
            }
        });
    },

    openFileFromStorage(params, callback, fileInfo, logger, noCache) {
        logger.info('Open file from storage', params.storage);
        const storage = Storage[params.storage];
        const storageLoad = () => {
            logger.info('Load from storage');
            storage.load(params.path, params.opts, (err, data, stat) => {
                if (err) {
                    if (fileInfo && fileInfo.get('openDate')) {
                        logger.info('Open file from cache because of storage load error', err);
                        this.openFileFromCache(params, callback, fileInfo);
                    } else {
                        logger.info('Storage load error', err);
                        callback(err);
                    }
                } else {
                    logger.info('Open file from content loaded from storage');
                    params.fileData = data;
                    params.rev = (stat && stat.rev) || null;
                    const needSaveToCache = storage.name !== 'file';
                    this.openFileWithData(params, callback, fileInfo, data, needSaveToCache);
                }
            });
        };
        const cacheRev = (fileInfo && fileInfo.get('rev')) || null;
        if (cacheRev && storage.stat) {
            logger.info('Stat file');
            storage.stat(params.path, params.opts, (err, stat) => {
                if (
                    !noCache &&
                    fileInfo &&
                    storage.name !== 'file' &&
                    (err || (stat && stat.rev === cacheRev))
                ) {
                    logger.info(
                        'Open file from cache because ' + (err ? 'stat error' : 'it is latest'),
                        err
                    );
                    this.openFileFromCache(params, callback, fileInfo);
                } else if (stat) {
                    logger.info(
                        'Open file from storage (' + stat.rev + ', local ' + cacheRev + ')'
                    );
                    storageLoad();
                } else {
                    logger.info('Stat error', err);
                    callback(err);
                }
            });
        } else {
            storageLoad();
        }
    },

    openFileWithData(params, callback, fileInfo, data, updateCacheOnSuccess) {
        const logger = new Logger('open', params.name);
        let needLoadKeyFile = false;
        if (!params.keyFileData && fileInfo && fileInfo.get('keyFileName')) {
            params.keyFileName = fileInfo.get('keyFileName');
            if (this.settings.get('rememberKeyFiles') === 'data') {
                params.keyFileData = FileModel.createKeyFileWithHash(fileInfo.get('keyFileHash'));
            } else if (
                this.settings.get('rememberKeyFiles') === 'path' &&
                fileInfo.get('keyFilePath')
            ) {
                params.keyFilePath = fileInfo.get('keyFilePath');
                if (Storage.file.enabled) {
                    needLoadKeyFile = true;
                }
            }
        } else if (params.keyFilePath && !params.keyFileData && !fileInfo) {
            needLoadKeyFile = true;
        }
        const file = new FileModel({
            id: fileInfo ? fileInfo.id : IdGenerator.uuid(),
            name: params.name,
            storage: params.storage,
            path: params.path,
            keyFileName: params.keyFileName,
            keyFilePath: params.keyFilePath,
            backup: (fileInfo && fileInfo.get('backup')) || null,
            fingerprint: (fileInfo && fileInfo.get('fingerprint')) || null
        });
        const openComplete = err => {
            if (err) {
                return callback(err);
            }
            if (this.files.get(file.id)) {
                return callback('Duplicate file id');
            }
            if (fileInfo && fileInfo.get('modified')) {
                if (fileInfo.get('editState')) {
                    logger.info('Loaded local edit state');
                    file.setLocalEditState(fileInfo.get('editState'));
                }
                logger.info('Mark file as modified');
                file.set('modified', true);
            }
            if (fileInfo) {
                file.set('syncDate', fileInfo.get('syncDate'));
            }
            if (updateCacheOnSuccess) {
                logger.info('Save loaded file to cache');
                Storage.cache.save(file.id, null, params.fileData);
            }
            const rev = params.rev || (fileInfo && fileInfo.get('rev'));
            this.setFileOpts(file, params.opts);
            this.addToLastOpenFiles(file, rev);
            this.addFile(file);
            callback(null, file);
            this.fileOpened(file, data, params);
        };
        const open = () => {
            file.open(params.password, data, params.keyFileData, openComplete);
        };
        if (needLoadKeyFile) {
            Storage.file.load(params.keyFilePath, {}, (err, data) => {
                if (err) {
                    logger.info('Storage load error', err);
                    callback(err);
                } else {
                    params.keyFileData = data;
                    open();
                }
            });
        } else {
            open();
        }
    },

    importFileWithXml(params, callback) {
        const logger = new Logger('import', params.name);
        logger.info('File import request with supplied xml');
        const file = new FileModel({
            id: IdGenerator.uuid(),
            name: params.name,
            storage: params.storage,
            path: params.path
        });
        file.importWithXml(params.fileXml, err => {
            logger.info('Import xml complete ' + (err ? 'with error' : ''), err);
            if (err) {
                return callback(err);
            }
            this.addFile(file);
            this.fileOpened(file);
        });
    },

    addToLastOpenFiles(file, rev) {
        this.appLogger.debug(
            'Add last open file',
            file.id,
            file.get('name'),
            file.get('storage'),
            file.get('path'),
            rev
        );
        const dt = new Date();
        const fileInfo = new FileInfoModel({
            id: file.id,
            name: file.get('name'),
            storage: file.get('storage'),
            path: file.get('path'),
            opts: this.getStoreOpts(file),
            modified: file.get('modified'),
            editState: file.getLocalEditState(),
            rev,
            syncDate: file.get('syncDate') || dt,
            openDate: dt,
            backup: file.get('backup'),
            fingerprint: file.get('fingerprint')
        });
        switch (this.settings.get('rememberKeyFiles')) {
            case 'data':
                fileInfo.set({
                    keyFileName: file.get('keyFileName') || null,
                    keyFileHash: file.getKeyFileHash()
                });
                break;
            case 'path':
                fileInfo.set({
                    keyFileName: file.get('keyFileName') || null,
                    keyFilePath: file.get('keyFilePath') || null
                });
        }
        this.fileInfos.remove(file.id);
        this.fileInfos.unshift(fileInfo);
        this.fileInfos.save();
    },

    getStoreOpts(file) {
        const opts = file.get('opts');
        const storage = file.get('storage');
        if (Storage[storage] && Storage[storage].fileOptsToStoreOpts && opts) {
            return Storage[storage].fileOptsToStoreOpts(opts, file);
        }
        return null;
    },

    setFileOpts(file, opts) {
        const storage = file.get('storage');
        if (Storage[storage] && Storage[storage].storeOptsToFileOpts && opts) {
            file.set('opts', Storage[storage].storeOptsToFileOpts(opts, file));
        }
    },

    fileOpened(file, data, params) {
        if (file.get('storage') === 'file') {
            Storage.file.watch(
                file.get('path'),
                _.debounce(() => {
                    this.syncFile(file);
                }, Timeouts.FileChangeSync)
            );
        }
        if (file.isKeyChangePending(true)) {
            Backbone.trigger('key-change-pending', { file });
        }
        const backup = file.get('backup');
        if (data && backup && backup.enabled && backup.pending) {
            this.scheduleBackupFile(file, data);
        }
        if (params) {
            this.saveFileFingerprint(file, params.password);
        }
    },

    fileClosed(file) {
        if (file.get('storage') === 'file') {
            Storage.file.unwatch(file.get('path'));
        }
    },

    removeFileInfo(id) {
        Storage.cache.remove(id);
        this.fileInfos.remove(id);
        this.fileInfos.save();
    },

    getFileInfo(file) {
        return (
            this.fileInfos.get(file.id) ||
            this.fileInfos.getMatch(file.get('storage'), file.get('name'), file.get('path'))
        );
    },

    syncFile(file, options, callback) {
        if (file.get('demo')) {
            return callback && callback();
        }
        if (file.get('syncing')) {
            return callback && callback('Sync in progress');
        }
        if (!options) {
            options = {};
        }
        const logger = new Logger('sync', file.get('name'));
        const storage = options.storage || file.get('storage');
        let path = options.path || file.get('path');
        const opts = options.opts || file.get('opts');
        if (
            storage &&
            Storage[storage].getPathForName &&
            (!path || storage !== file.get('storage'))
        ) {
            path = Storage[storage].getPathForName(file.get('name'));
        }
        const optionsForLogging = _.clone(options);
        if (optionsForLogging && optionsForLogging.opts && optionsForLogging.opts.password) {
            optionsForLogging.opts = _.clone(optionsForLogging.opts);
            optionsForLogging.opts.password = '***';
        }
        logger.info('Sync started', storage, path, optionsForLogging);
        let fileInfo = this.getFileInfo(file);
        if (!fileInfo) {
            logger.info('Create new file info');
            const dt = new Date();
            fileInfo = new FileInfoModel({
                id: IdGenerator.uuid(),
                name: file.get('name'),
                storage: file.get('storage'),
                path: file.get('path'),
                opts: this.getStoreOpts(file),
                modified: file.get('modified'),
                editState: null,
                rev: null,
                syncDate: dt,
                openDate: dt,
                backup: file.get('backup')
            });
        }
        file.setSyncProgress();
        const complete = (err, savedToCache) => {
            if (!err) {
                savedToCache = true;
            }
            logger.info('Sync finished', err || 'no error');
            file.setSyncComplete(path, storage, err ? err.toString() : null, savedToCache);
            fileInfo.set({
                name: file.get('name'),
                storage,
                path,
                opts: this.getStoreOpts(file),
                modified: file.get('modified'),
                editState: file.getLocalEditState(),
                syncDate: file.get('syncDate')
            });
            if (this.settings.get('rememberKeyFiles') === 'data') {
                fileInfo.set({
                    keyFileName: file.get('keyFileName') || null,
                    keyFileHash: file.getKeyFileHash()
                });
            }
            if (!this.fileInfos.get(fileInfo.id)) {
                this.fileInfos.unshift(fileInfo);
            }
            this.fileInfos.save();
            if (callback) {
                callback(err);
            }
        };
        if (!storage) {
            if (!file.get('modified') && fileInfo.id === file.id) {
                logger.info('Local, not modified');
                return complete();
            }
            logger.info('Local, save to cache');
            file.getData((data, err) => {
                if (err) {
                    return complete(err);
                }
                Storage.cache.save(fileInfo.id, null, data, err => {
                    logger.info('Saved to cache', err || 'no error');
                    complete(err);
                    if (!err) {
                        this.scheduleBackupFile(file, data);
                    }
                });
            });
        } else {
            const maxLoadLoops = 3;
            let loadLoops = 0;
            const loadFromStorageAndMerge = () => {
                if (++loadLoops === maxLoadLoops) {
                    return complete('Too many load attempts');
                }
                logger.info('Load from storage, attempt ' + loadLoops);
                Storage[storage].load(path, opts, (err, data, stat) => {
                    logger.info('Load from storage', stat, err || 'no error');
                    if (err) {
                        return complete(err);
                    }
                    file.mergeOrUpdate(data, options.remoteKey, err => {
                        logger.info('Merge complete', err || 'no error');
                        this.refresh();
                        if (err) {
                            if (err.code === 'InvalidKey') {
                                logger.info('Remote key changed, request to enter new key');
                                Backbone.trigger('remote-key-changed', { file });
                            }
                            return complete(err);
                        }
                        if (stat && stat.rev) {
                            logger.info('Update rev in file info');
                            fileInfo.set('rev', stat.rev);
                        }
                        file.set('syncDate', new Date());
                        if (file.get('modified')) {
                            logger.info('Updated sync date, saving modified file');
                            saveToCacheAndStorage();
                        } else if (file.get('dirty')) {
                            logger.info('Saving not modified dirty file to cache');
                            Storage.cache.save(fileInfo.id, null, data, err => {
                                if (err) {
                                    return complete(err);
                                }
                                file.set('dirty', false);
                                logger.info('Complete, remove dirty flag');
                                complete();
                            });
                        } else {
                            logger.info('Complete, no changes');
                            complete();
                        }
                    });
                });
            };
            const saveToStorage = data => {
                logger.info('Save data to storage');
                const storageRev =
                    fileInfo.get('storage') === storage ? fileInfo.get('rev') : undefined;
                Storage[storage].save(
                    path,
                    opts,
                    data,
                    (err, stat) => {
                        if (err && err.revConflict) {
                            logger.info('Save rev conflict, reloading from storage');
                            loadFromStorageAndMerge();
                        } else if (err) {
                            logger.info('Error saving data to storage');
                            complete(err);
                        } else {
                            if (stat && stat.rev) {
                                logger.info('Update rev in file info');
                                fileInfo.set('rev', stat.rev);
                            }
                            if (stat && stat.path) {
                                logger.info('Update path in file info', stat.path);
                                file.set('path', stat.path);
                                fileInfo.set('path', stat.path);
                                path = stat.path;
                            }
                            file.set('syncDate', new Date());
                            logger.info('Save to storage complete, update sync date');
                            this.scheduleBackupFile(file, data);
                            complete();
                        }
                    },
                    storageRev
                );
            };
            const saveToCacheAndStorage = () => {
                logger.info('Getting file data for saving');
                file.getData((data, err) => {
                    if (err) {
                        return complete(err);
                    }
                    if (storage === 'file') {
                        logger.info('Saving to file storage');
                        saveToStorage(data);
                    } else if (!file.get('dirty')) {
                        logger.info('Saving to storage, skip cache because not dirty');
                        saveToStorage(data);
                    } else {
                        logger.info('Saving to cache');
                        Storage.cache.save(fileInfo.id, null, data, err => {
                            if (err) {
                                return complete(err);
                            }
                            file.set('dirty', false);
                            logger.info('Saved to cache, saving to storage');
                            saveToStorage(data);
                        });
                    }
                });
            };
            logger.info('Stat file');
            Storage[storage].stat(path, opts, (err, stat) => {
                if (err) {
                    if (err.notFound) {
                        logger.info('File does not exist in storage, creating');
                        saveToCacheAndStorage();
                    } else if (file.get('dirty')) {
                        logger.info('Stat error, dirty, save to cache', err || 'no error');
                        file.getData(data => {
                            if (data) {
                                Storage.cache.save(fileInfo.id, null, data, e => {
                                    if (!e) {
                                        file.set('dirty', false);
                                    }
                                    logger.info(
                                        'Saved to cache, exit with error',
                                        err || 'no error'
                                    );
                                    complete(err);
                                });
                            }
                        });
                    } else {
                        logger.info('Stat error, not dirty', err || 'no error');
                        complete(err);
                    }
                } else if (stat.rev === fileInfo.get('rev')) {
                    if (file.get('modified')) {
                        logger.info('Stat found same version, modified, saving');
                        saveToCacheAndStorage();
                    } else {
                        logger.info('Stat found same version, not modified');
                        complete();
                    }
                } else {
                    logger.info('Found new version, loading from storage');
                    loadFromStorageAndMerge();
                }
            });
        }
    },

    clearStoredKeyFiles() {
        this.fileInfos.each(fileInfo => {
            fileInfo.set({
                keyFileName: null,
                keyFilePath: null,
                keyFileHash: null
            });
        });
        this.fileInfos.save();
    },

    unsetKeyFile(fileId) {
        const fileInfo = this.fileInfos.get(fileId);
        fileInfo.set({
            keyFileName: null,
            keyFilePath: null,
            keyFileHash: null
        });
        this.fileInfos.save();
    },

    setFileBackup(fileId, backup) {
        const fileInfo = this.fileInfos.get(fileId);
        if (fileInfo) {
            fileInfo.set('backup', backup);
        }
        this.fileInfos.save();
    },

    backupFile(file, data, callback) {
        const opts = file.get('opts');
        let backup = file.get('backup');
        const logger = new Logger('backup', file.get('name'));
        if (!backup || !backup.storage || !backup.path) {
            return callback('Invalid backup settings');
        }
        let path = backup.path.replace('{date}', DateFormat.dtStrFs(new Date()));
        logger.info('Backup file to', backup.storage, path);
        const saveToFolder = () => {
            if (Storage[backup.storage].getPathForName) {
                path = Storage[backup.storage].getPathForName(path);
            }
            Storage[backup.storage].save(path, opts, data, err => {
                if (err) {
                    logger.error('Backup error', err);
                } else {
                    logger.info('Backup complete');
                    backup = file.get('backup');
                    backup.lastTime = Date.now();
                    delete backup.pending;
                    file.set('backup', backup);
                    this.setFileBackup(file.id, backup);
                }
                callback(err);
            });
        };
        let folderPath = UrlFormat.fileToDir(path);
        if (Storage[backup.storage].getPathForName) {
            folderPath = Storage[backup.storage].getPathForName(folderPath).replace('.kdbx', '');
        }
        Storage[backup.storage].stat(folderPath, opts, err => {
            if (err) {
                if (err.notFound) {
                    logger.info('Backup folder does not exist');
                    if (!Storage[backup.storage].mkdir) {
                        return callback('Mkdir not supported by ' + backup.storage);
                    }
                    Storage[backup.storage].mkdir(folderPath, err => {
                        if (err) {
                            logger.error('Error creating backup folder', err);
                            callback('Error creating backup folder');
                        } else {
                            logger.info('Backup folder created');
                            saveToFolder();
                        }
                    });
                } else {
                    logger.error('Stat folder error', err);
                    callback('Cannot stat backup folder');
                }
            } else {
                logger.info('Backup folder exists, saving');
                saveToFolder();
            }
        });
    },

    scheduleBackupFile(file, data) {
        const backup = file.get('backup');
        if (!backup || !backup.enabled) {
            return;
        }
        const logger = new Logger('backup', file.get('name'));
        let needBackup = false;
        if (!backup.lastTime) {
            needBackup = true;
            logger.debug('No last backup time, backup now');
        } else {
            const dt = new Date(backup.lastTime);
            switch (backup.schedule) {
                case '0':
                    break;
                case '1d':
                    dt.setDate(dt.getDate() + 1);
                    break;
                case '1w':
                    dt.setDate(dt.getDate() + 7);
                    break;
                case '1m':
                    dt.setMonth(dt.getMonth() + 1);
                    break;
                default:
                    return;
            }
            if (dt.getTime() <= Date.now()) {
                needBackup = true;
            }
            logger.debug(
                'Last backup time: ' +
                    new Date(backup.lastTime) +
                    ', schedule: ' +
                    backup.schedule +
                    ', next time: ' +
                    dt +
                    ', ' +
                    (needBackup ? 'backup now' : 'skip backup')
            );
        }
        if (!backup.pending) {
            backup.pending = true;
            this.setFileBackup(file.id, backup);
        }
        if (needBackup) {
            this.backupFile(file, data, _.noop);
        }
    },

    saveFileFingerprint(file, password) {
        if (Launcher && Launcher.fingerprints && !file.has('fingerprint')) {
            const fileInfo = this.fileInfos.get(file.id);
            Launcher.fingerprints.register(file.id, password, token => {
                if (token) {
                    fileInfo.set({ fingerprint: token });
                    this.fileInfos.save();
                }
            });
        }
    }
});

export { AppModel };
