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

//  ENiGMA½
const Events                = require('./events.js');
const Config                = require('./config.js').get;
const {
    getConfigPath,
    getFullConfig,
}                           = require('./config_util.js');
const UserDb                = require('./database.js').dbs.user;
const {
    getISOTimestampString
}                           = require('./database.js');
const UserInterruptQueue    = require('./user_interrupt_queue.js');
const {
    getConnectionByUserId
}                           = require('./client_connections.js');
const UserProps             = require('./user_property.js');
const {
    Errors,
    ErrorReasons
}                           = require('./enig_error.js');
const { getThemeArt }       = require('./theme.js');
const {
    pipeToAnsi,
    stripMciColorCodes
}                           = require('./color_codes.js');
const stringFormat          = require('./string_format.js');
const StatLog               = require('./stat_log.js');
const Log                   = require('./logger.js').log;
const ConfigCache           = require('./config_cache.js');

//  deps
const _             = require('lodash');
const async         = require('async');
const moment        = require('moment');
const paths         = require('path');

exports.getAchievementsEarnedByUser  = getAchievementsEarnedByUser;

class Achievement {
    constructor(data) {
        this.data = data;

        //  achievements are retroactive by default
        this.data.retroactive = _.get(this.data, 'retroactive', true);
    }

    static factory(data) {
        if(!data) {
            return;
        }
        let achievement;
        switch(data.type) {
            case Achievement.Types.UserStatSet :
            case Achievement.Types.UserStatInc :
            case Achievement.Types.UserStatIncNewVal :
                achievement = new UserStatAchievement(data);
                break;

            default : return;
        }

        if(achievement.isValid()) {
            return achievement;
        }
    }

    static get Types() {
        return {
            UserStatSet         : 'userStatSet',
            UserStatInc         : 'userStatInc',
            UserStatIncNewVal   : 'userStatIncNewVal',
        };
    }

    isValid() {
        switch(this.data.type) {
            case Achievement.Types.UserStatSet :
            case Achievement.Types.UserStatInc :
            case Achievement.Types.UserStatIncNewVal :
                if(!_.isString(this.data.statName)) {
                    return false;
                }
                if(!_.isObject(this.data.match)) {
                    return false;
                }
                break;

            default : return false;
        }
        return true;
    }

    getMatchDetails(/*matchAgainst*/) {
    }

    isValidMatchDetails(details) {
        if(!details || !_.isString(details.title) || !_.isString(details.text) || !_.isNumber(details.points)) {
            return false;
        }
        return (_.isString(details.globalText) || !details.globalText);
    }
}

class UserStatAchievement extends Achievement {
    constructor(data) {
        super(data);

        //  sort match keys for quick match lookup
        this.matchKeys = Object.keys(this.data.match || {}).map(k => parseInt(k)).sort( (a, b) => b - a);
    }

    isValid() {
        if(!super.isValid()) {
            return false;
        }
        return !Object.keys(this.data.match).some(k => !parseInt(k));
    }

    getMatchDetails(matchValue) {
        let ret = [];
        let matchField = this.matchKeys.find(v => matchValue >= v);
        if(matchField) {
            const match = this.data.match[matchField];
            matchField = parseInt(matchField);
            if(this.isValidMatchDetails(match) && !isNaN(matchField)) {
                ret = [ match, matchField, matchValue ];
            }
        }
        return ret;
    }
}

class Achievements {
    constructor(events) {
        this.events = events;
    }

    getAchievementByTag(tag) {
        return this.achievementConfig.achievements[tag];
    }

    isEnabled() {
        return !_.isUndefined(this.achievementConfig);
    }

    init(cb) {
        let achievementConfigPath = _.get(Config(), 'general.achievementFile');
        if(!achievementConfigPath) {
            Log.info('Achievements are not configured');
            return cb(null);
        }
        achievementConfigPath = getConfigPath(achievementConfigPath);   //  qualify

        const configLoaded = (achievementConfig) => {
            if(true !== achievementConfig.enabled) {
                Log.info('Achievements are not enabled');
                this.stopMonitoringUserStatEvents();
                delete this.achievementConfig;
            } else {
                Log.info('Achievements are enabled');
                this.achievementConfig = achievementConfig;
                this.monitorUserStatEvents();
            }
        };

        const changed = ( { fileName, fileRoot } ) => {
            const reCachedPath = paths.join(fileRoot, fileName);
            if(reCachedPath === achievementConfigPath) {
                getFullConfig(achievementConfigPath, (err, achievementConfig) => {
                    if(err) {
                        return Log.error( { error : err.message }, 'Failed to reload achievement config from cache');
                    }
                    configLoaded(achievementConfig);
                });
            }
        };

        ConfigCache.getConfigWithOptions(
            {
                filePath        : achievementConfigPath,
                forceReCache    : true,
                callback        : changed,
            },
            (err, achievementConfig) => {
                if(err) {
                    return cb(err);
                }

                configLoaded(achievementConfig);
                return cb(null);
            }
        );
    }

    loadAchievementHitCount(user, achievementTag, field, cb) {
        UserDb.get(
            `SELECT COUNT() AS count
            FROM user_achievement
            WHERE user_id = ? AND achievement_tag = ? AND match = ?;`,
            [ user.userId, achievementTag, field],
            (err, row) => {
                return cb(err, row ? row.count : 0);
            }
        );
    }

    record(info, localInterruptItem, cb) {
        StatLog.incrementUserStat(info.client.user, UserProps.AchievementTotalCount, 1);
        StatLog.incrementUserStat(info.client.user, UserProps.AchievementTotalPoints, info.details.points);

        const cleanTitle    = stripMciColorCodes(localInterruptItem.title);
        const cleanText     = stripMciColorCodes(localInterruptItem.achievText);

        const recordData = [
            info.client.user.userId, info.achievementTag, getISOTimestampString(info.timestamp), info.matchField,
            cleanTitle, cleanText, info.details.points,
        ];

        UserDb.run(
            `INSERT OR IGNORE INTO user_achievement (user_id, achievement_tag, timestamp, match, title, text, points)
            VALUES (?, ?, ?, ?, ?, ?, ?);`,
            recordData,
            err => {
                if(err) {
                    return cb(err);
                }

                this.events.emit(
                    Events.getSystemEvents().UserAchievementEarned,
                    {
                        user            : info.client.user,
                        achievementTag  : info.achievementTag,
                        points          : info.details.points,
                        title           : cleanTitle,
                        text            : cleanText,
                    }
                );

                return cb(null);
            }
        );
    }

    display(info, interruptItems, cb) {
        if(interruptItems.local) {
            UserInterruptQueue.queue(interruptItems.local, { clients : info.client } );
        }

        if(interruptItems.global) {
            UserInterruptQueue.queue(interruptItems.global, { omit : info.client } );
        }

        return cb(null);
    }

    recordAndDisplayAchievement(info, cb) {
        async.waterfall(
            [
                (callback) => {
                    return this.createAchievementInterruptItems(info, callback);
                },
                (interruptItems, callback) => {
                    this.record(info, interruptItems.local, err => {
                        return callback(err, interruptItems);
                    });
                },
                (interruptItems, callback) => {
                    return this.display(info, interruptItems, callback);
                }
            ],
            err => {
                return cb(err);
            }
        );
    }

    monitorUserStatEvents() {
        if(this.userStatEventListeners) {
            return; //  already listening
        }

        const listenEvents = [
            Events.getSystemEvents().UserStatSet,
            Events.getSystemEvents().UserStatIncrement
        ];

        this.userStatEventListeners = this.events.addMultipleEventListener(listenEvents, userStatEvent => {
            if([ UserProps.AchievementTotalCount, UserProps.AchievementTotalPoints ].includes(userStatEvent.statName)) {
                return;
            }

            if(!_.isNumber(userStatEvent.statValue) && !_.isNumber(userStatEvent.statIncrementBy)) {
                return;
            }

            //  :TODO: Make this code generic - find + return factory created object
            const achievementTags = Object.keys(_.pickBy(
                _.get(this.achievementConfig, 'achievements', {}),
                achievement => {
                    if(false === achievement.enabled) {
                        return false;
                    }
                    const acceptedTypes = [
                        Achievement.Types.UserStatSet,
                        Achievement.Types.UserStatInc,
                        Achievement.Types.UserStatIncNewVal,
                    ];
                    return acceptedTypes.includes(achievement.type) && achievement.statName === userStatEvent.statName;
                }
            ));

            if(0 === achievementTags.length) {
                return;
            }

            async.eachSeries(achievementTags, (achievementTag, nextAchievementTag) => {
                const achievement = Achievement.factory(this.getAchievementByTag(achievementTag));
                if(!achievement) {
                    return nextAchievementTag(null);
                }

                const statValue = parseInt(
                    [ Achievement.Types.UserStatSet, Achievement.Types.UserStatIncNewVal ].includes(achievement.data.type) ?
                        userStatEvent.statValue :
                        userStatEvent.statIncrementBy
                );
                if(isNaN(statValue)) {
                    return nextAchievementTag(null);
                }

                const [ details, matchField, matchValue ] = achievement.getMatchDetails(statValue);
                if(!details) {
                    return nextAchievementTag(null);
                }

                async.waterfall(
                    [
                        (callback) => {
                            this.loadAchievementHitCount(userStatEvent.user, achievementTag, matchField, (err, count) => {
                                if(err) {
                                    return callback(err);
                                }
                                return callback(count > 0 ? Errors.General('Achievement already acquired', ErrorReasons.TooMany) : null);
                            });
                        },
                        (callback) => {
                            const client = getConnectionByUserId(userStatEvent.user.userId);
                            if(!client) {
                                return callback(Errors.UnexpectedState('Failed to get client for user ID'));
                            }

                            const info = {
                                achievementTag,
                                achievement,
                                details,
                                client,
                                matchField,                     //  match - may be in odd format
                                matchValue,                     //  actual value
                                achievedValue   : matchField,   //  achievement value met
                                user            : userStatEvent.user,
                                timestamp       : moment(),
                            };

                            const achievementsInfo = [ info ];
                            return callback(null, achievementsInfo, info);
                        },
                        (achievementsInfo, basicInfo, callback) => {
                            if(true !== achievement.data.retroactive) {
                                return callback(null, achievementsInfo);
                            }

                            const index = achievement.matchKeys.findIndex(v => v < matchField);
                            if(-1 === index || !Array.isArray(achievement.matchKeys)) {
                                return callback(null, achievementsInfo);
                            }

                            //  For userStat, any lesser match keys(values) are also met. Example:
                            //  matchKeys: [ 500, 200, 100, 20, 10, 2 ]
                            //                    ^---- we met here
                            //                         ^------------^ retroactive range
                            //
                            async.eachSeries(achievement.matchKeys.slice(index), (k, nextKey) => {                                
                                const [ det, fld, val ] = achievement.getMatchDetails(k);
                                if(!det) {
                                    return nextKey(null);
                                }

                                this.loadAchievementHitCount(userStatEvent.user, achievementTag, fld, (err, count) => {
                                    if(!err || count && 0 === count) {
                                        achievementsInfo.push(Object.assign(
                                            {},
                                            basicInfo,
                                            {
                                                details         : det,
                                                matchField      : fld,
                                                achievedValue   : fld,
                                                matchValue      : val,
                                            }
                                        ));
                                    }

                                    return nextKey(null);
                                });
                            },
                            () => {
                                return callback(null, achievementsInfo);
                            });
                        },
                        (achievementsInfo, callback) => {
                            //  reverse achievementsInfo so we display smallest > largest
                            achievementsInfo.reverse();

                            async.eachSeries(achievementsInfo, (achInfo, nextAchInfo) => {
                                return this.recordAndDisplayAchievement(achInfo, err => {
                                    return nextAchInfo(err);
                                });
                            },
                            err => {
                                return callback(err);
                            });
                        }
                    ],
                    err => {
                        if(err && ErrorReasons.TooMany !== err.reasonCode) {
                            Log.warn( { error : err.message, userStatEvent }, 'Error handling achievement for user stat event');
                        }
                        return nextAchievementTag(null);    //  always try the next, regardless
                    }
                );
            });
        });
    }

    stopMonitoringUserStatEvents() {
        if(this.userStatEventListeners) {
            this.events.removeMultipleEventListener(this.userStatEventListeners);
            delete this.userStatEventListeners;
        }
    }

    getFormatObject(info) {
        return {
            userName        : info.user.username,
            userRealName    : info.user.properties[UserProps.RealName],
            userLocation    : info.user.properties[UserProps.Location],
            userAffils      : info.user.properties[UserProps.Affiliations],
            nodeId          : info.client.node,
            title           : info.details.title,
            //text            : info.global ? info.details.globalText : info.details.text,
            points          : info.details.points,
            achievedValue   : info.achievedValue,
            matchField      : info.matchField,
            matchValue      : info.matchValue,
            timestamp       : moment(info.timestamp).format(info.dateTimeFormat),
            boardName       : Config().general.boardName,
        };
    }

    getFormattedTextFor(info, textType, defaultSgr = '|07') {
        const themeDefaults = _.get(info.client.currentTheme, 'achievements.defaults', {});
        const textTypeSgr   = themeDefaults[`${textType}SGR`] || defaultSgr;

        const formatObj = this.getFormatObject(info);

        const wrap = (input) => {
            const re = new RegExp(`{(${Object.keys(formatObj).join('|')})([^}]*)}`, 'g');
            return input.replace(re, (m, formatVar, formatOpts) => {
                const varSgr = themeDefaults[`${formatVar}SGR`] || textTypeSgr;
                let r = `${varSgr}{${formatVar}`;
                if(formatOpts) {
                    r += formatOpts;
                }
                return `${r}}${textTypeSgr}`;
            });
        };

        return stringFormat(`${textTypeSgr}${wrap(info.details[textType])}`, formatObj);
    }

    createAchievementInterruptItems(info, cb) {
        info.dateTimeFormat =
            info.details.dateTimeFormat ||
            info.achievement.dateTimeFormat ||
            info.client.currentTheme.helpers.getDateTimeFormat();

        const title = this.getFormattedTextFor(info, 'title');
        const text  = this.getFormattedTextFor(info, 'text');

        let globalText;
        if(info.details.globalText) {
            globalText = this.getFormattedTextFor(info, 'globalText');
        }

        const getArt = (name, callback) => {
            const spec =
                _.get(info.details, `art.${name}`) ||
                _.get(info.achievement, `art.${name}`) ||
                _.get(this.achievementConfig, `art.${name}`);
            if(!spec) {
                return callback(null);
            }
            const getArtOpts = {
                name    : spec,
                client  : this.client,
                random  : false,
            };
            getThemeArt(getArtOpts, (err, artInfo) => {
                //  ignore errors
                return callback(artInfo ? artInfo.data : null);
            });
        };

        const interruptItems = {};
        let itemTypes = [ 'local' ];
        if(globalText) {
            itemTypes.push('global');
        }

        async.each(itemTypes, (itemType, nextItemType) => {
            async.waterfall(
                [
                    (callback) => {
                        getArt(`${itemType}Header`, headerArt => {
                            return callback(null, headerArt);
                        });
                    },
                    (headerArt, callback) => {
                        getArt(`${itemType}Footer`, footerArt => {
                            return callback(null, headerArt, footerArt);
                        });
                    },
                    (headerArt, footerArt, callback) => {
                        const itemText = 'global' === itemType ? globalText : text;
                        interruptItems[itemType] = {
                            title,
                            achievText  : itemText,
                            text        : `${title}\r\n${itemText}`,
                            pause       : true,
                        };
                        if(headerArt || footerArt) {
                            const themeDefaults = _.get(info.client.currentTheme, 'achievements.defaults', {});
                            const defaultContentsFormat = '{title}\r\n{message}';
                            const contentsFormat = 'global' === itemType ?
                                themeDefaults.globalFormat || defaultContentsFormat :
                                themeDefaults.format || defaultContentsFormat;

                            const formatObj = Object.assign(this.getFormatObject(info), {
                                title   : this.getFormattedTextFor(info, 'title', ''),  //  ''=defaultSgr
                                message : itemText,
                            });

                            const contents = pipeToAnsi(stringFormat(contentsFormat, formatObj));

                            interruptItems[itemType].contents =
                                `${headerArt || ''}\r\n${contents}\r\n${footerArt || ''}`;
                        }
                        return callback(null);
                    }
                ],
                err => {
                    return nextItemType(err);
                }
            );
        },
        err => {
            return cb(err, interruptItems);
        });
    }
}

let achievementsInstance;

function getAchievementsEarnedByUser(userId, cb) {
    if(!achievementsInstance) {
        return cb(Errors.UnexpectedState('Achievements not initialized'));
    }

    UserDb.all(
        `SELECT achievement_tag, timestamp, match, title, text, points
        FROM user_achievement
        WHERE user_id = ?
        ORDER BY DATETIME(timestamp);`,
        [ userId ],
        (err, rows) => {
            if(err) {
                return cb(err);
            }

            const earned = rows.map(row => {

                const achievement = Achievement.factory(achievementsInstance.getAchievementByTag(row.achievement_tag));
                if(!achievement) {
                    return;
                }

                const earnedInfo = {
                    achievementTag  : row.achievement_tag,
                    type            : achievement.data.type,
                    retroactive     : achievement.data.retroactive,
                    title           : row.title,
                    text            : row.text,
                    points          : row.points,
                    timestamp       : moment(row.timestamp),
                };

                switch(earnedInfo.type) {
                    case [ Achievement.Types.UserStatSet ] :
                    case [ Achievement.Types.UserStatInc ] :
                    case [ Achievement.Types.UserStatIncNewVal ] :
                        earnedInfo.statName = achievement.data.statName;
                        break;
                }

                return earnedInfo;
            }).filter(a => a);  //  remove any empty records (ie: no achievement.hjson entry exists anymore).

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

exports.moduleInitialize = (initInfo, cb) => {
    achievementsInstance = new Achievements(initInfo.events);
    achievementsInstance.init( err => {
        if(err) {
            return cb(err);
        }

        return cb(null);
    });
};