/* jslint node: true */
'use strict';

const Config = require('./config.js').get;
const art = require('./art.js');
const ansi = require('./ansi_term.js');
const Log = require('./logger.js').log;
const asset = require('./asset.js');
const ViewController = require('./view_controller.js').ViewController;
const Errors = require('./enig_error.js').Errors;
const Events = require('./events.js');
const AnsiPrep = require('./ansi_prep.js');
const UserProps = require('./user_property.js');

const ConfigLoader = require('./config_loader');
const { getConfigPath } = require('./config_util');

//  deps
const fs = require('graceful-fs');
const paths = require('path');
const async = require('async');
const _ = require('lodash');
const assert = require('assert');

exports.getThemeArt = getThemeArt;
exports.getAvailableThemes = getAvailableThemes;
exports.getRandomTheme = getRandomTheme;
exports.setClientTheme = setClientTheme;
exports.displayPreparedArt = displayPreparedArt;
exports.displayThemeArt = displayThemeArt;
exports.displayThemedPause = displayThemedPause;
exports.displayThemedPrompt = displayThemedPrompt;
exports.displayThemedAsset = displayThemedAsset;

//  global instance of ThemeManager; see ThemeManager.create()
let themeManagerInstance;

exports.ThemeManager = class ThemeManager {
    constructor() {
        this.availableThemes = new Map();
    }

    static create(cb) {
        themeManagerInstance = new ThemeManager();
        themeManagerInstance.init(err => {
            if (!err) {
                themeManagerInstance
                    .getAvailableThemes()
                    .forEach((themeConfig, themeId) => {
                        const { name, author, group } = themeConfig.get().info;
                        Log.info(
                            { themeId, themeName: name, author, group },
                            'Theme loaded'
                        );
                    });
            }

            return cb(err);
        });
    }

    getAvailableThemes() {
        return this.availableThemes;
    }

    init(cb) {
        this.menuConfig = new ConfigLoader({
            onReload: err => {
                if (!err) {
                    //  menu.hjson/includes have changed; this could affect
                    //  all themes, so they must be reloaded
                    Events.emit(Events.getSystemEvents().MenusChanged);

                    this._reloadAllThemes();
                }
            },
        });

        this.menuConfig.init(getConfigPath(Config().general.menuFile), err => {
            if (err) {
                return cb(err);
            }

            return this._loadThemes(cb);
        });
    }

    _loadThemes(cb) {
        const themeDir = Config().paths.themes;

        fs.readdir(themeDir, (err, files) => {
            if (err) {
                return cb(err);
            }

            async.filter(
                files,
                (filename, nextFilename) => {
                    const fullPath = paths.join(themeDir, filename);
                    fs.stat(fullPath, (err, stats) => {
                        if (err) {
                            return nextFilename(err);
                        }

                        return nextFilename(null, stats.isDirectory());
                    });
                },
                (err, themeIds) => {
                    if (err) {
                        return cb(err);
                    }

                    async.each(
                        themeIds,
                        (themeId, nextThemeId) => {
                            return this._loadTheme(themeId, nextThemeId);
                        },
                        err => {
                            return cb(err);
                        }
                    );
                }
            );
        });
    }

    _loadTheme(themeId, cb) {
        const themeConfig = new ConfigLoader({
            onReload: err => {
                if (!err) {
                    //  this particular theme has changed
                    this._themeLoaded(themeId, themeConfig, err => {
                        if (!err) {
                            Events.emit(Events.getSystemEvents().ThemeChanged, {
                                themeId,
                            });
                        }
                    });
                }
            },
        });

        const themeConfigPath = paths.join(Config().paths.themes, themeId, 'theme.hjson');

        themeConfig.init(themeConfigPath, err => {
            if (err) {
                return cb(err);
            }

            this._themeLoaded(themeId, themeConfig);
            return cb(null);
        });
    }

    _themeLoaded(themeId, themeConfig) {
        const theme = themeConfig.get();

        //  do some basic validation
        //  :TODO: schema validation here
        if (
            !_.isObject(theme.info) ||
            !_.isString(theme.info.name) ||
            !_.isString(theme.info.author)
        ) {
            return Log.warn(
                { themeId },
                'Theme contains invalid or missing "info" section'
            );
        }

        if (false === _.get(theme, 'info.enabled')) {
            Log.info({ themeId }, 'Theme is disabled');
            return this.availableThemes.delete(themeId);
        }

        //  merge menu.hjson+theme.hjson/etc. to the final usable theme
        this._finalizeTheme(themeId, themeConfig);

        //  Theme is valid and enabled; update it in available themes
        this.availableThemes.set(themeId, themeConfig);

        Events.emit(Events.getSystemEvents().ThemeChanged, { themeId });
    }

    _finalizeTheme(themeId, themeConfig) {
        //  These TODOs are left over from the old system - decide what/if to do with them:
        //  :TODO: merge in defaults (customization.defaults{} )
        //  :TODO: apply generic stuff, e.g. "VM" (vs "VM1")

        //  start out with menu.hjson
        const mergedTheme = _.cloneDeep(this.menuConfig.get());

        const theme = themeConfig.get();

        //  some data brought directly over
        mergedTheme.info = Object.assign({}, theme.info, { themeId });
        mergedTheme.achievements = _.get(theme, 'customization.achievements');

        //  Create some helpers for this theme
        this._setThemeHelpers(mergedTheme);

        //  merge customizer to disallow immutable MCI properties
        const ImmutableMCIProperties = ['maxLength', 'argName', 'submit', 'validate'];

        const mciCustomizer = (objVal, srcVal, key) => {
            return ImmutableMCIProperties.indexOf(key) > -1 ? objVal : srcVal;
        };

        const getFormKeys = obj => {
            //  remove all non-numbers
            return _.remove(Object.keys(obj), k => !isNaN(k));
        };

        const mergeMciProperties = (dst, src) => {
            Object.keys(src).forEach(mci => {
                if (dst[mci]) {
                    _.mergeWith(dst[mci], src[mci], mciCustomizer);
                } else {
                    //  theme contains a MCI that's not found in menu
                    dst[mci] = src[mci];
                }
            });
        };

        const applyThemeMciBlock = (dst, src, formKey) => {
            if (_.isObject(src.mci)) {
                mergeMciProperties(dst, src.mci);
            } else if (_.has(src, [formKey, 'mci'])) {
                mergeMciProperties(dst, src[formKey].mci);
            }
        };

        //
        //  menu.hjson can have a couple different structures:
        //  1)  Explicit declaration of expected MCI code(s) under 'form:<id>' before a 'mci' block
        //      (this allows multiple layout types defined by one menu for example)
        //
        //  2)  Non-explicit declaration: 'mci' directly under 'form:<id>'
        //
        //  theme.hjson has it's own mix:
        //  1)  Explicit: Form ID before 'mci' (generally used where there are > 1 forms)
        //
        //  2)  Non-explicit: 'mci' directly under an entry
        //
        //  Additionally, #1 or #2 may be under an explicit key of MCI code(s) to match up
        //  with menu.hjson in #1.
        //
        //  *   When theming an explicit menu.hjson entry (1), we will use a matching explicit
        //      entry with a matching MCI code(s) key in theme.hjson (e.g. menu="ETVM"/theme="ETVM"
        //      and fall back to generic if a match is not found.
        //
        //  *   If theme.hjson provides form ID's, use them. Otherwise, we'll apply directly assuming
        //      there is a generic 'mci' block.
        //
        const applyToForm = (form, menuTheme, formKey) => {
            if (_.isObject(form.mci)) {
                //   non-explicit: no MCI code(s) key assumed since we found 'mci' directly under form ID
                applyThemeMciBlock(form.mci, menuTheme, formKey);
            } else {
                //  remove anything not uppercase
                const menuMciCodeKeys = _.remove(
                    _.keys(form),
                    k => k === k.toUpperCase()
                );

                menuMciCodeKeys.forEach(mciKey => {
                    const src = _.has(menuTheme, [mciKey, 'mci'])
                        ? menuTheme[mciKey]
                        : menuTheme;

                    applyThemeMciBlock(form[mciKey].mci, src, formKey);
                });
            }
        };

        ['menus', 'prompts'].forEach(sectionName => {
            if (!_.isObject(mergedTheme[sectionName])) {
                return Log.error({ sectionName }, 'Merged theme is missing section');
            }

            Object.keys(mergedTheme[sectionName]).forEach(entryName => {
                let createdFormSection = false;
                const mergedThemeMenu = mergedTheme[sectionName][entryName];

                const menuTheme = _.get(theme, ['customization', sectionName, entryName]);
                if (menuTheme) {
                    if (menuTheme.config) {
                        //  :TODO: should this be _.merge() ?
                        mergedThemeMenu.config = _.assign(
                            mergedThemeMenu.config || {},
                            menuTheme.config
                        );
                    }

                    if ('menus' === sectionName) {
                        if (_.isObject(mergedThemeMenu.form)) {
                            getFormKeys(mergedThemeMenu.form).forEach(formKey => {
                                applyToForm(
                                    mergedThemeMenu.form[formKey],
                                    menuTheme,
                                    formKey
                                );
                            });
                        } else if (_.isObject(menuTheme.mci)) {
                            //
                            //  Not specified at menu level means we apply anything from the
                            //  theme to form.0.mci{}
                            //
                            mergedThemeMenu.form = { 0: { mci: {} } };
                            mergeMciProperties(mergedThemeMenu.form[0], menuTheme);
                            createdFormSection = true;
                        }
                    } else if ('prompts' === sectionName) {
                        //  no 'form' or form keys for prompts -- direct to mci
                        applyToForm(mergedThemeMenu, menuTheme);
                    }
                }

                //
                //  Finished merging for this menu/prompt
                //
                //  If the following conditions are true, set runtime.autoNext to true:
                //  *   This is a menu
                //  *   There is/was no explicit 'form' section
                //  *   There is no 'prompt' specified
                //
                if (
                    'menus' === sectionName &&
                    !_.isString(mergedThemeMenu.prompt) &&
                    (createdFormSection || !_.isObject(mergedThemeMenu.form))
                ) {
                    mergedThemeMenu.runtime = _.merge(mergedThemeMenu.runtime || {}, {
                        autoNext: true,
                    });
                }
            });
        });

        themeConfig.current = mergedTheme;
    }

    _setThemeHelpers(theme) {
        theme.helpers = {
            getPasswordChar: function () {
                let pwChar = _.get(
                    theme,
                    'customization.defaults.passwordChar',
                    Config().theme.passwordChar
                );

                if (_.isString(pwChar)) {
                    pwChar = pwChar.substr(0, 1);
                } else if (_.isNumber(pwChar)) {
                    pwChar = String.fromCharCode(pwChar);
                }

                return pwChar;
            },
            getDateFormat: function (style = 'short') {
                const format = Config().theme.dateFormat[style] || 'MM/DD/YYYY';
                return _.get(theme, `customization.defaults.dateFormat.${style}`, format);
            },
            getTimeFormat: function (style = 'short') {
                const format = Config().theme.timeFormat[style] || 'h:mm a';
                return _.get(theme, `customization.defaults.timeFormat.${style}`, format);
            },
            getDateTimeFormat: function (style = 'short') {
                const format =
                    Config().theme.dateTimeFormat[style] || 'MM/DD/YYYY h:mm a';
                return _.get(
                    theme,
                    `customization.defaults.dateTimeFormat.${style}`,
                    format
                );
            },
            getStatusAvailIndicators: function () {
                const format = Config().theme.statusAvailableIndicators || ['Y', 'N'];
                return _.get(
                    theme,
                    'customization.defaults.statusAvailableIndicators',
                    format
                );
            },
            getStatusVisibleIndicators: function () {
                const format = Config().theme.statusVisibleIndicators || ['Y', 'N'];
                return _.get(
                    theme,
                    'customization.defaults.statusVisibleIndicators',
                    format
                );
            },
        };
    }

    _reloadAllThemes() {
        async.each([...this.availableThemes.keys()], (themeId, nextThemeId) => {
            this._loadTheme(themeId, err => {
                if (!err) {
                    Log.info({ themeId }, `Theme "${themeId}" reloaded`);
                }
                return nextThemeId(null); //  always proceed
            });
        });
    }
};

function getAvailableThemes() {
    return themeManagerInstance.getAvailableThemes();
}

function getRandomTheme() {
    const avail = getAvailableThemes();
    if (avail.size > 0) {
        const themeIds = [...avail.keys()];
        return themeIds[Math.floor(Math.random() * themeIds.length)];
    }
}

function setClientTheme(client, themeId) {
    const availThemes = getAvailableThemes();

    let msg;
    let setThemeId;
    const config = Config();
    if (availThemes.has(themeId)) {
        msg = 'Set client theme';
        setThemeId = themeId;
    } else if (availThemes.has(config.theme.default)) {
        msg = 'Failed setting theme by supplied ID; Using default';
        setThemeId = config.theme.default;
    } else {
        msg =
            'Failed setting theme by system default ID; Using the first one we can find';
        setThemeId = availThemes.keys().next().value;
    }

    client.currentTheme = availThemes.get(setThemeId);
    client.log.debug(
        { setThemeId, requestedThemeId: themeId, info: client.currentTheme.info },
        msg
    );
}

function getThemeArt(options, cb) {
    //
    //  options - required:
    //      name
    //
    //  options - optional
    //      client - needed for user's theme/etc.
    //      themeId
    //      asAnsi
    //      readSauce
    //      random
    //
    const config = Config();
    if (
        !options.themeId &&
        _.has(options, ['client', 'user', 'properties', UserProps.ThemeId])
    ) {
        options.themeId = options.client.user.properties[UserProps.ThemeId];
    }

    options.themeId = options.themeId || config.theme.default;

    //  :TODO: replace asAnsi stuff with something like retrieveAs = 'ansi' | 'pipe' | ...
    //  :TODO: Some of these options should only be set if not provided!
    options.asAnsi = true; //  always convert to ANSI
    options.readSauce = _.get(options, 'readSauce', true); //  read SAUCE, if avail
    options.random = _.get(options, 'random', true); //  FILENAME<n>.EXT support

    //
    //  We look for themed art in the following order:
    //  1)  Direct/relative path
    //  2)  Via theme supplied by |themeId|
    //  3)  Via default theme
    //  4)  General art directory
    //
    async.waterfall(
        [
            function fromPath(callback) {
                //
                //  We allow relative (to enigma-bbs) or full paths
                //
                if ('/' === options.name.charAt(0)) {
                    //  just take the path as-is
                    options.basePath = paths.dirname(options.name);
                } else if (options.name.indexOf('/') > -1) {
                    //  make relative to base BBS dir
                    options.basePath = paths.join(
                        __dirname,
                        '../',
                        paths.dirname(options.name)
                    );
                } else {
                    return callback(null, null);
                }

                art.getArt(options.name, options, (err, artInfo) => {
                    return callback(null, artInfo);
                });
            },
            function fromSuppliedTheme(artInfo, callback) {
                if (artInfo) {
                    return callback(null, artInfo);
                }

                options.basePath = paths.join(config.paths.themes, options.themeId);
                art.getArt(options.name, options, (err, artInfo) => {
                    return callback(null, artInfo);
                });
            },
            function fromDefaultTheme(artInfo, callback) {
                if (artInfo || config.theme.default === options.themeId) {
                    return callback(null, artInfo);
                }

                options.basePath = paths.join(config.paths.themes, config.theme.default);
                art.getArt(options.name, options, (err, artInfo) => {
                    return callback(null, artInfo);
                });
            },
            function fromGeneralArtDir(artInfo, callback) {
                if (artInfo) {
                    return callback(null, artInfo);
                }

                options.basePath = config.paths.art;
                art.getArt(options.name, options, (err, artInfo) => {
                    return callback(err, artInfo);
                });
            },
        ],
        function complete(err, artInfo) {
            if (err) {
                const logger = _.get(options, 'client.log') || Log;
                logger.debug({ reason: err.message }, 'Cannot find theme art');
            }
            return cb(err, artInfo);
        }
    );
}

function displayPreparedArt(options, artInfo, cb) {
    const displayOpts = {
        sauce: artInfo.sauce,
        font: options.font,
        trailingLF: options.trailingLF,
        startRow: options.startRow,
    };
    art.display(options.client, artInfo.data, displayOpts, (err, mciMap, extraInfo) => {
        return cb(err, { mciMap: mciMap, artInfo: artInfo, extraInfo: extraInfo });
    });
}

function displayThemeArt(options, cb) {
    assert(_.isObject(options));
    assert(_.isObject(options.client));
    assert(_.isString(options.name));

    async.waterfall(
        [
            function getArt(callback) {
                return getThemeArt(options, callback);
            },
            function prepWork(artInfo, callback) {
                if (_.isObject(options.ansiPrepOptions)) {
                    AnsiPrep(artInfo.data, options.ansiPrepOptions, (err, prepped) => {
                        if (!err && prepped) {
                            artInfo.data = prepped;
                            return callback(null, artInfo);
                        }
                    });
                } else {
                    return callback(null, artInfo);
                }
            },
            function disp(artInfo, callback) {
                return displayPreparedArt(options, artInfo, callback);
            },
        ],
        (err, artData) => {
            return cb(err, artData);
        }
    );
}

function displayThemedPrompt(name, client, options, cb) {
    const usingTempViewController = _.isUndefined(options.viewController);

    async.waterfall(
        [
            function display(callback) {
                const promptConfig = client.currentTheme.prompts[name];
                if (!promptConfig) {
                    return callback(
                        Errors.DoesNotExist(`Missing "${name}" prompt configuration!`)
                    );
                }

                if (options.clearScreen) {
                    client.term.rawWrite(ansi.resetScreen());
                    options.position = { row: 1, column: 1 };
                }

                //
                //  If we did *not* clear the screen, don't let the font change
                //  doing so messes things up -- most terminals that support font
                //  changing can only display a single font at at time.
                //
                const dispOptions = Object.assign({}, options, promptConfig.config);
                //  :TODO: We can use term detection to do nifty things like avoid this kind of kludge:
                // if(!options.clearScreen) {
                //     dispOptions.font = 'not_really_a_font!';    //  kludge :)
                // }

                displayThemedAsset(
                    promptConfig.art,
                    client,
                    dispOptions,
                    (err, artInfo) => {
                        if (err) {
                            return callback(err);
                        }

                        return callback(null, promptConfig, artInfo);
                    }
                );
            },
            function discoverCursorPosition(promptConfig, artInfo, callback) {
                if (!options.clearPrompt) {
                    //  no need to query cursor - we're not gonna use it
                    return callback(null, promptConfig, artInfo);
                }

                if (_.isNumber(options?.position?.row)) {
                    artInfo.startRow = options.position.row;
                    if (
                        client.term.termHeight > 0 &&
                        artInfo.startRow + artInfo.height > client.term.termHeight
                    ) {
                        // in this case, we will have scrolled
                        artInfo.startRow = client.term.termHeight - artInfo.height;
                    }
                }

                return callback(null, promptConfig, artInfo);
            },
            function createMCIViews(promptConfig, artInfo, callback) {
                const assocViewController = usingTempViewController
                    ? new ViewController({ client: client })
                    : options.viewController;

                // adjust MCI positions relative to |position|
                if (options.position) {
                    _.forEach(artInfo.mciMap, mci => {
                        if (mci.position) {
                            mci.position[0] = options.position.row;
                            mci.position[1] += options.position.col;
                        }
                    });
                }

                const loadOpts = {
                    promptName: name,
                    mciMap: artInfo.mciMap,
                    config: promptConfig,
                    submitNotify: options.submitNotify,
                };

                assocViewController.loadFromPromptConfig(loadOpts, () => {
                    return callback(null, artInfo, assocViewController);
                });
            },
            function pauseForUserInput(artInfo, assocViewController, callback) {
                if (!options.pause) {
                    return callback(null, artInfo, assocViewController);
                }

                client.waitForKeyPress(() => {
                    return callback(null, artInfo, assocViewController);
                });
            },
            function clearPauseArt(artInfo, assocViewController, callback) {
                // Only clear with height if clearPrompt is true and if we were able
                // to determine the row
                if (options.clearPrompt && artInfo.startRow) {
                    if (artInfo.startRow && artInfo.height) {
                        client.term.rawWrite(ansi.goto(artInfo.startRow, 1));

                        //  Note: Does not work properly in NetRunner < 2.0b17:
                        client.term.rawWrite(ansi.deleteLine(artInfo.height));
                    } else {
                        client.term.rawWrite(ansi.eraseLine(1));
                    }
                }

                return callback(null, assocViewController, artInfo);
            },
        ],
        (err, assocViewController, artInfo) => {
            if (err) {
                client.log.warn(
                    { error: err.message },
                    `Failed displaying "${name}" prompt`
                );
            }

            if (assocViewController && usingTempViewController) {
                assocViewController.detachClientEvents();
            }

            return cb(null, artInfo);
        }
    );
}

//
//  Pause prompts are a special prompt by the name 'pause'.
//
function displayThemedPause(client, options, cb) {
    if (!cb && _.isFunction(options)) {
        cb = options;
        options = {};
    }

    if (!_.isBoolean(options.clearPrompt)) {
        options.clearPrompt = true;
    }

    const promptOptions = Object.assign({}, options, { pause: true });
    return displayThemedPrompt('pause', client, promptOptions, cb);
}

function displayThemedAsset(assetSpec, client, options, cb) {
    assert(_.isObject(client));

    //  options are... optional
    if (3 === arguments.length) {
        cb = options;
        options = {};
    }

    if (Array.isArray(assetSpec)) {
        const acsCondMember = options.acsCondMember || 'art';
        assetSpec = client.acs.getConditionalValue(assetSpec, acsCondMember);
    }

    const artAsset = asset.getArtAsset(assetSpec);
    if (!artAsset) {
        return cb(new Error('Asset not found: ' + assetSpec));
    }

    const dispOpts = Object.assign({}, options, { client, name: artAsset.asset });
    switch (artAsset.type) {
        case 'art':
            displayThemeArt(dispOpts, function displayed(err, artData) {
                return cb(
                    err,
                    err
                        ? null
                        : { mciMap: artData.mciMap, height: artData.extraInfo.height }
                );
            });
            break;

        case 'method':
            //  :TODO: fetch & render via method
            break;

        case 'inline ':
            //  :TODO: think about this more in relation to themes, etc. How can this come
            //  from a theme (with override from menu.json) ???
            //  look @ client.currentTheme.inlineArt[name] -> menu/prompt[name]
            break;

        default:
            return cb(new Error('Unsupported art asset type: ' + artAsset.type));
    }
}
