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

const userDb        = require('./database.js').dbs.user;
const Config        = require('./config.js').get;
const userGroup     = require('./user_group.js');
const Errors        = require('./enig_error.js').Errors;
const Events        = require('./events.js');

//  deps
const crypto        = require('crypto');
const assert        = require('assert');
const async         = require('async');
const _             = require('lodash');
const moment        = require('moment');

exports.isRootUserId = function(id) { return 1 === id; };

module.exports = class User {
    constructor() {
        this.userId     = 0;
        this.username   = '';
        this.properties = {};   //  name:value
        this.groups     = [];   //  group membership(s)
    }

    //  static property accessors
    static get RootUserID() {
        return 1;
    }

    static get PBKDF2() {
        return {
            iterations  : 1000,
            keyLen      : 128,
            saltLen     : 32,
        };
    }

    static get StandardPropertyGroups() {
        return {
            password    : [ 'pw_pbkdf2_salt', 'pw_pbkdf2_dk' ],
        };
    }

    static get AccountStatus() {
        return {
            disabled    : 0,
            inactive    : 1,
            active      : 2,
        };
    }

    isAuthenticated() {
        return true === this.authenticated;
    }

    isValid() {
        if(this.userId <= 0 || this.username.length < Config().users.usernameMin) {
            return false;
        }

        return this.hasValidPassword();
    }

    hasValidPassword() {
        if(!this.properties || !this.properties.pw_pbkdf2_salt || !this.properties.pw_pbkdf2_dk) {
            return false;
        }

        return ((this.properties.pw_pbkdf2_salt.length === User.PBKDF2.saltLen * 2) &&
            (this.properties.pw_pbkdf2_dk.length === User.PBKDF2.keyLen * 2));
    }

    isRoot() {
        return User.isRootUserId(this.userId);
    }

    isSysOp() { //  alias to isRoot()
        return this.isRoot();
    }

    isGroupMember(groupNames) {
        if(_.isString(groupNames)) {
            groupNames = [ groupNames ];
        }

        const isMember = groupNames.some(gn => (-1 !== this.groups.indexOf(gn)));
        return isMember;
    }

    getLegacySecurityLevel() {
        if(this.isRoot() || this.isGroupMember('sysops')) {
            return 100;
        }

        if(this.isGroupMember('users')) {
            return 30;
        }

        return 10;  //  :TODO: Is this what we want?
    }

    authenticate(username, password, cb) {
        const self = this;
        const cachedInfo = {};

        async.waterfall(
            [
                function fetchUserId(callback) {
                    //  get user ID
                    User.getUserIdAndName(username, (err, uid, un) => {
                        cachedInfo.userId   = uid;
                        cachedInfo.username = un;

                        return callback(err);
                    });
                },
                function getRequiredAuthProperties(callback) {
                    //  fetch properties required for authentication
                    User.loadProperties(cachedInfo.userId, { names : User.StandardPropertyGroups.password }, (err, props) => {
                        return callback(err, props);
                    });
                },
                function getDkWithSalt(props, callback) {
                    //  get DK from stored salt and password provided
                    User.generatePasswordDerivedKey(password, props.pw_pbkdf2_salt, (err, dk) => {
                        return callback(err, dk, props.pw_pbkdf2_dk);
                    });
                },
                function validateAuth(passDk, propsDk, callback) {
                    //
                    //  Use constant time comparison here for security feel-goods
                    //
                    const passDkBuf     = Buffer.from(passDk,   'hex');
                    const propsDkBuf    = Buffer.from(propsDk,  'hex');

                    if(passDkBuf.length !== propsDkBuf.length) {
                        return callback(Errors.AccessDenied('Invalid password'));
                    }

                    let c = 0;
                    for(let i = 0; i < passDkBuf.length; i++) {
                        c |= passDkBuf[i] ^ propsDkBuf[i];
                    }

                    return callback(0 === c ? null : Errors.AccessDenied('Invalid password'));
                },
                function initProps(callback) {
                    User.loadProperties(cachedInfo.userId, (err, allProps) => {
                        if(!err) {
                            cachedInfo.properties = allProps;
                        }

                        return callback(err);
                    });
                },
                function initGroups(callback) {
                    userGroup.getGroupsForUser(cachedInfo.userId, (err, groups) => {
                        if(!err) {
                            cachedInfo.groups = groups;
                        }

                        return callback(err);
                    });
                }
            ],
            err => {
                if(!err) {
                    self.userId         = cachedInfo.userId;
                    self.username       = cachedInfo.username;
                    self.properties     = cachedInfo.properties;
                    self.groups         = cachedInfo.groups;
                    self.authenticated  = true;
                }

                return cb(err);
            }
        );
    }

    create(createUserInfo , cb) {
        assert(0 === this.userId);
        const config = Config();

        if(this.username.length < config.users.usernameMin || this.username.length > config.users.usernameMax) {
            return cb(Errors.Invalid('Invalid username length'));
        }

        const self = this;

        //  :TODO: set various defaults, e.g. default activation status, etc.
        self.properties.account_status = config.users.requireActivation ? User.AccountStatus.inactive : User.AccountStatus.active;

        async.waterfall(
            [
                function beginTransaction(callback) {
                    return userDb.beginTransaction(callback);
                },
                function createUserRec(trans, callback) {
                    trans.run(
                        `INSERT INTO user (user_name)
                        VALUES (?);`,
                        [ self.username ],
                        function inserted(err) {    //  use classic function for |this|
                            if(err) {
                                return callback(err);
                            }

                            self.userId = this.lastID;

                            //  Do not require activation for userId 1 (root/admin)
                            if(User.RootUserID === self.userId) {
                                self.properties.account_status = User.AccountStatus.active;
                            }

                            return callback(null, trans);
                        }
                    );
                },
                function genAuthCredentials(trans, callback) {
                    User.generatePasswordDerivedKeyAndSalt(createUserInfo.password, (err, info) => {
                        if(err) {
                            return callback(err);
                        }

                        self.properties.pw_pbkdf2_salt  = info.salt;
                        self.properties.pw_pbkdf2_dk    = info.dk;
                        return callback(null, trans);
                    });
                },
                function setInitialGroupMembership(trans, callback) {
                    self.groups = config.users.defaultGroups;

                    if(User.RootUserID === self.userId) {   //  root/SysOp?
                        self.groups.push('sysops');
                    }

                    return callback(null, trans);
                },
                function saveAll(trans, callback) {
                    self.persistWithTransaction(trans, err => {
                        return callback(err, trans);
                    });
                },
                function sendEvent(trans, callback) {
                    Events.emit(
                        Events.getSystemEvents().NewUser,
                        {
                            user : Object.assign({}, self, { sessionId : createUserInfo.sessionId } )
                        }
                    );
                    return callback(null, trans);
                }
            ],
            (err, trans) => {
                if(trans) {
                    trans[err ? 'rollback' : 'commit'](transErr => {
                        return cb(err ? err : transErr);
                    });
                } else {
                    return cb(err);
                }
            }
        );
    }

    persistWithTransaction(trans, cb) {
        assert(this.userId > 0);

        const self = this;

        async.series(
            [
                function saveProps(callback) {
                    self.persistProperties(self.properties, trans, err => {
                        return callback(err);
                    });
                },
                function saveGroups(callback) {
                    userGroup.addUserToGroups(self.userId, self.groups, trans, err => {
                        return callback(err);
                    });
                }
            ],
            err => {
                return cb(err);
            }
        );
    }

    persistProperty(propName, propValue, cb) {
        //  update live props
        this.properties[propName] = propValue;

        userDb.run(
            `REPLACE INTO user_property (user_id, prop_name, prop_value)
            VALUES (?, ?, ?);`,
            [ this.userId, propName, propValue ],
            err => {
                if(cb) {
                    return cb(err);
                }
            }
        );
    }

    removeProperty(propName, cb) {
        //  update live
        delete this.properties[propName];

        userDb.run(
            `DELETE FROM user_property
            WHERE user_id = ? AND prop_name = ?;`,
            [ this.userId, propName ],
            err => {
                if(cb) {
                    return cb(err);
                }
            }
        );
    }

    persistProperties(properties, transOrDb, cb) {
        if(!_.isFunction(cb) && _.isFunction(transOrDb)) {
            cb = transOrDb;
            transOrDb = userDb;
        }

        const self = this;

        //  update live props
        _.merge(this.properties, properties);

        const stmt = transOrDb.prepare(
            `REPLACE INTO user_property (user_id, prop_name, prop_value)
            VALUES (?, ?, ?);`
        );

        async.each(Object.keys(properties), (propName, nextProp) => {
            stmt.run(self.userId, propName, properties[propName], err => {
                return nextProp(err);
            });
        },
        err => {
            if(err) {
                return cb(err);
            }

            stmt.finalize( () => {
                return cb(null);
            });
        });
    }

    setNewAuthCredentials(password, cb) {
        User.generatePasswordDerivedKeyAndSalt(password, (err, info) => {
            if(err) {
                return cb(err);
            }

            const newProperties = {
                pw_pbkdf2_salt  : info.salt,
                pw_pbkdf2_dk    : info.dk,
            };

            this.persistProperties(newProperties, err => {
                return cb(err);
            });
        });
    }

    getAge() {
        if(_.has(this.properties, 'birthdate')) {
            return moment().diff(this.properties.birthdate, 'years');
        }
    }

    static getUser(userId, cb) {
        async.waterfall(
            [
                function fetchUserId(callback) {
                    User.getUserName(userId, (err, userName) => {
                        return callback(null, userName);
                    });
                },
                function initProps(userName, callback) {
                    User.loadProperties(userId, (err, properties) => {
                        return callback(err, userName, properties);
                    });
                },
                function initGroups(userName, properties, callback) {
                    userGroup.getGroupsForUser(userId, (err, groups) => {
                        return callback(null, userName, properties, groups);
                    });
                }
            ],
            (err, userName, properties, groups) => {
                const user = new User();
                user.userId         = userId;
                user.username       = userName;
                user.properties     = properties;
                user.groups         = groups;
                user.authenticated  = false;    //  this is NOT an authenticated user!

                return cb(err, user);
            }
        );
    }

    static isRootUserId(userId) {
        return (User.RootUserID === userId);
    }

    static getUserIdAndName(username, cb) {
        userDb.get(
            `SELECT id, user_name
            FROM user
            WHERE user_name LIKE ?;`,
            [ username ],
            (err, row) => {
                if(err) {
                    return cb(err);
                }

                if(row) {
                    return cb(null, row.id, row.user_name);
                }

                return cb(Errors.DoesNotExist('No matching username'));
            }
        );
    }

    static getUserIdAndNameByRealName(realName, cb) {
        userDb.get(
            `SELECT id, user_name
            FROM user
            WHERE id = (
                SELECT user_id
                FROM user_property
                WHERE prop_name='real_name' AND prop_value LIKE ?
            );`,
            [ realName ],
            (err, row) => {
                if(err) {
                    return cb(err);
                }

                if(row) {
                    return cb(null, row.id, row.user_name);
                }

                return cb(Errors.DoesNotExist('No matching real name'));
            }
        );
    }

    static getUserIdAndNameByLookup(lookup, cb) {
        User.getUserIdAndName(lookup, (err, userId, userName) => {
            if(err) {
                User.getUserIdAndNameByRealName(lookup, (err, userId, userName) => {
                    return cb(err, userId, userName);
                });
            } else {
                return cb(null, userId, userName);
            }
        });
    }

    static getUserName(userId, cb) {
        userDb.get(
            `SELECT user_name
            FROM user
            WHERE id = ?;`,
            [ userId ],
            (err, row) => {
                if(err) {
                    return cb(err);
                }

                if(row) {
                    return cb(null, row.user_name);
                }

                return cb(Errors.DoesNotExist('No matching user ID'));
            }
        );
    }

    static loadProperties(userId, options, cb) {
        if(!cb && _.isFunction(options)) {
            cb = options;
            options = {};
        }

        let sql =
            `SELECT prop_name, prop_value
            FROM user_property
            WHERE user_id = ?`;

        if(options.names) {
            sql += ` AND prop_name IN("${options.names.join('","')}");`;
        } else {
            sql += ';';
        }

        let properties = {};
        userDb.each(sql, [ userId ], (err, row) => {
            if(err) {
                return cb(err);
            }
            properties[row.prop_name] = row.prop_value;
        }, (err) => {
            return cb(err, err ? null : properties);
        });
    }

    //  :TODO: make this much more flexible - propValue should allow for case-insensitive compare, etc.
    static getUserIdsWithProperty(propName, propValue, cb) {
        let userIds = [];

        userDb.each(
            `SELECT user_id
            FROM user_property
            WHERE prop_name = ? AND prop_value = ?;`,
            [ propName, propValue ],
            (err, row) => {
                if(row) {
                    userIds.push(row.user_id);
                }
            },
            () => {
                return cb(null, userIds);
            }
        );
    }

    static getUserList(options, cb) {
        let userList = [];
        let orderClause = 'ORDER BY ' + (options.order || 'user_name');

        userDb.each(
            `SELECT id, user_name
            FROM user
            ${orderClause};`,
            (err, row) => {
                if(row) {
                    userList.push({
                        userId      : row.id,
                        userName    : row.user_name,
                    });
                }
            },
            () => {
                options.properties = options.properties || [];
                async.map(userList, (user, nextUser) => {
                    userDb.each(
                        `SELECT prop_name, prop_value
                        FROM user_property
                        WHERE user_id = ? AND prop_name IN ("${options.properties.join('","')}");`,
                        [ user.userId ],
                        (err, row) => {
                            if(row) {
                                user[row.prop_name] = row.prop_value;
                            }
                        },
                        err => {
                            return nextUser(err, user);
                        }
                    );
                },
                (err, transformed) => {
                    return cb(err, transformed);
                });
            }
        );
    }

    static generatePasswordDerivedKeyAndSalt(password, cb) {
        async.waterfall(
            [
                function getSalt(callback) {
                    User.generatePasswordDerivedKeySalt( (err, salt) => {
                        return callback(err, salt);
                    });
                },
                function getDk(salt, callback) {
                    User.generatePasswordDerivedKey(password, salt, (err, dk) => {
                        return callback(err, salt, dk);
                    });
                }
            ],
            (err, salt, dk) => {
                return cb(err, { salt : salt, dk : dk } );
            }
        );
    }

    static generatePasswordDerivedKeySalt(cb) {
        crypto.randomBytes(User.PBKDF2.saltLen, (err, salt) => {
            if(err) {
                return cb(err);
            }
            return cb(null, salt.toString('hex'));
        });
    }

    static generatePasswordDerivedKey(password, salt, cb) {
        password = Buffer.from(password).toString('hex');

        crypto.pbkdf2(password, salt, User.PBKDF2.iterations, User.PBKDF2.keyLen, 'sha1', (err, dk) => {
            if(err) {
                return cb(err);
            }

            return cb(null, dk.toString('hex'));
        });
    }
};