'use strict';

const io = require('socket.io');
const ioClient = require('socket.io-client');
const EventEmitter = require('events').EventEmitter;
const _ = require('lodash');
const logger = require('paradox-logger')('cluster');
const request = require('request');
const asyncp = require('paradox-asyncp');
const si = require('systeminformation');
const nodegit = require('nodegit');
const path = require("path");
const fs = require("fs");
const cache = require('memory-cache');
const Promise = require('bluebird');
const __ = require('paradox-utils');

class ClusterManager extends EventEmitter {

    constructor(socket, type, options) {
        super();

        this.TYPE_MASTER = 'master';
        this.TYPE_SLAVE = 'slave';

        this.socketClient = null;

        this.type = type;

        this.globalSocket = socket;
        this.socket = socket.of('/cluster');

        this.options = _.defaults(options, {
            config: {},
            master_address: null,
            heartbeat_interval: 1000,
            cleanOfflineSlavesInterval: 24 * 60 * 1000,
            fatalTimeout: 70 * 1000,
            ignoreMasterVersion: false,
            serverPort: null,
        });

        this.sessionId = null;

        this.startedAt = new Date();
        this.connected = false;
        this.questions = {};
        this.slaves = {};
        this.pong = null;
        this.pong_at = null;

        this.stats = {
            connection: {
                logIn: 0,
                logOut: 0,
            },
            questions: {
                total: 0,
                ok: 0,
                error: 0,
            },
        };
        this.activeQuestions = [];
    }

    start() {
        if(this.isMaster()) {
            return this.startMaster();

        } else if(this.isSlave()) {
            return this.startSlave();

        } else {
            return Promise.reject(Error('unknown cluster type: ' + this.type));
        }
    }

    startSlave() {
        let socket = new ioClient('ws://' + this.options.master_address + '/cluster', {
            reconnection: true,
            reconnectionAttempts: Infinity,
        });

        this.socketClient = socket;

        socket.on('error', err => {
            logger.error('socket error detected: ' + err.message);
        });

        socket.on('connect_error', err => {
            logger.error('Slave socket connection error: ' + err.message);
        });

        socket.on('connect_timeout', () => {
            logger.error('Slave socket connection timeout');
        });

        socket.on('reconnect', attempt => {
            logger.error('Slave socket reconnection attempt: ' + attempt);
        });

        socket.on('ping', () => {
            // logger.debug('Slave socket ping');
        });

        socket.on('pong', ms => {
            this.pong = ms;
            this.pong_at = new Date();
            // logger.debug('Slave socket pong ('+ ms +')');
        });

        socket.on('connect', () => {
            this.fetchMachineInfo()
                .then(data => {
                    this.connected = true;
                    this.stats.connection.logIn++;

                    logger.info('Slave connected to master');
                    socket.emit('slave.hi', data);
                    this.socket.emit('cluster_changed');
                })
                .catch(err => {
                    logger.error(err);
                });
        });

        socket.on('disconnect', () => {
            this.connected = false;
            this.stats.connection.logOut++;

            logger.info('Slave disconnected from master');
            this.socket.emit('cluster_changed');
        });

        setInterval(() => {
            if(!this.isConnected()) {
                return;
            }

            this.fetchMachineInfo()
                .then(data => {
                    socket.emit('slave.ping', data);
                })
                .catch(err => {
                    logger.error(err);
                });

        }, this.options.heartbeat_interval);

        socket.on('question', (question, callback) => {

            this.answerQuestion(question.name, question.data)
                .then(res => {
                    callback({
                        success: true,
                        result: __.object.simplify(res),
                    });
                })
                .catch(err => {
                    callback({
                        success: false,
                        error: {
                            type: typeof err,
                            message: err.message,
                            code: err.code,
                            trace: err.trace,
                            retriable: err.retriable,
                            errorType: err.errorType,
                        }
                    });
                });
        });

        this.startSlaveProxy();
    }

    startSlaveProxy() {
        let emit = this.globalSocket.emit;
        let client = this.socketClient;
        let me = this;

        let ignorePattern = /^(slave\.|cluster_|log)/i;

        this.globalSocket.emit = function() {
            let name = arguments[0];
            let data = arguments[1];

            let res = emit.apply(this, arguments);

            if(me.isConnected()) {
                if(name && !name.match(ignorePattern)) {
                    client.emit('slave.event', {
                        name,
                        data,
                    });
                }
            }

            return res;
        };
    }

    startMaster() {
        setInterval(() => {
            this.cleanOfflineSlaves();
        }, 1000);

        this.socket.on('connection', (socket) => {
            let slaveId = socket.customId || socket.client.id;

            socket.on('question', (question, callback) => {
                this.answerQuestion(question.name, question.data, question.options)
                    .then(res => {
                        callback({
                            success: true,
                            result: __.object.simplify(res),
                        });
                    })
                    .catch(err => {
                        callback({
                            success: false,
                            error: {
                                type: typeof err,
                                message: err.message,
                                code: err.code,
                                trace: err.trace,
                                retriable: err.retriable,
                                errorType: err.errorType,
                            }
                        });
                    });
            });

            socket.on('slave.event', (event) => {
                let name = event.name;
                let data = event.data;

                this.globalSocket.emit(name, data);
            });

            socket.on('slave.hi', data => {
                socket.customId = data.id;
                slaveId = data.id;

                if(!(slaveId in this.slaves)) {
                    this.slaves[slaveId] = {
                        id: slaveId,
                        client: socket,
                        questions: [],
                    };
                }

                this.slaves[slaveId].connected = true;
                this.slaves[slaveId].heartbeat_at = new Date();
                this.slaves[slaveId].connected_at = new Date();
                this.slaves[slaveId].disconnected_at = null;
                this.slaves[slaveId].client = socket;
                this.slaves[slaveId].questions = [];
                this.slaves[slaveId].data = data;

                logger.info('Slave connected: ' + slaveId);
                this.socket.emit('cluster_changed');

                this.fetchAppInfo()
                    .then(masterData => {
                        if(masterData.git.last_commit.ref != data.app.git.last_commit.ref) {
                            logger.info('Slave ' + slaveId + ' has a different version, ask for checkout to master version');

                            return this
                                .askSlave(slaveId, 'checkout', {
                                    commit: masterData.git.last_commit.ref,
                                    restart: true,
                                })
                                .then(response => {
                                    if(response == 'ignore') {
                                        logger.warn('Slave ' + slaveId + ' doesn\'t want to use this version');
                                    }
                                })
                        }
                    });
            });

            socket.on('slave.ping', data => {
                if(slaveId in this.slaves) {
                    this.slaves[slaveId].heartbeat_at = new Date();
                    this.slaves[slaveId].data = data;

                    this.socket.emit('cluster_slave_ping', this.cleanNode(this.slaves[slaveId]));

                    // this.askMaster('slave_ping', data, {hiddenQuestion: true});
                    // this.askAllNodes('slave_ping', this.cleanNode(this.slaves[slaveId]), {hiddenQuestion: true})
                    //     .catch(err => {
                    //         logger.exception(err);
                    //     });
                }
            });

            socket.on('disconnect', () => {
                if(slaveId in this.slaves) {
                    this.slaves[slaveId].connected = false;
                    this.slaves[slaveId].disconnected_at = new Date();

                    logger.info('Slave disconnected: ' + slaveId);
                    this.socket.emit('cluster_changed');
                }
            });
        });

        setInterval(() => {
            if(!this.isConnected()) {
                return;
            }

            this.fetchMachineInfo()
                .then(data => {
                    let masterNode = this.createMasterNode(data);
                    this.socket.emit('cluster_slave_ping', masterNode);
                })
                .catch(err => {
                    logger.error(err);
                });

        }, this.options.heartbeat_interval);
    }

    cleanOfflineSlaves() {
        for(let key in this.slaves) {
            if(this.slaves[key].connected) {
                continue;
            }

            let elapsed = Date.now() - this.slaves[key].disconnected_at;

            if(elapsed >= this.options.cleanOfflineSlavesInterval) {
                delete this.slaves[key];
                this.socket.emit('cluster_changed');
            }
        }
    }

    /* -------------------------------------------------------------------------
     * Utils
     * ---------------------------------------------------------------------- */

    getId() {
        if(this.isMaster()) {
            return 'master';

        } else {
            return this.sessionId || this.socketClient.id;
        }
    }

    isMaster() {
        return this.type == this.TYPE_MASTER;
    }

    isSlave() {
        return this.type == this.TYPE_SLAVE;
    }

    isConnected() {
        if(this.isMaster()) {
            return true;
        }

        return this.connected;
    }

    fetchMachineInfo() {
        let result = {
            id: null,
            app: null,
            config: null,
            connection: null,
            machine: null,
            tasks: null,
            process: {
                node_version: null,
                versions: null,
                loop_delay: null,
            }
        };

        let info = [
            {key: 'app', fnc: 'fetchAppInfo', cache: 5 * 1000},
            {key: 'config', fnc: 'fetchConfigInfo', cache: null},
            {key: 'connection', fnc: 'fetchConnectionInfo', cache: 60 * 1000},
            {key: 'machine', fnc: 'fetchSystemInfo', cache: null},
            {key: 'tasks', fnc: 'fetchQuestions', cache: null},
        ];

        return asyncp
            .eachLimit(info, 1, node => {
                if(node.cache) {
                    let cachedVal = cache.get('cluster.' + node.key);

                    if(cachedVal) {
                        result[node.key] = cachedVal;
                        return;
                    }
                }

                return Promise
                    .try(() => this[node.fnc].apply(this, []))
                    .then(res => {
                        result[node.key] = res;

                        if(node.cache) {
                            cache.put('cluster.' + node.key, res, node.cache);
                        }
                    });
            })
            .then(() => {
                result.id = result.machine.os.hostname + '-' + result.connection.server_port;
                // result.id = result.machine.system.uuid + '-' + result.connection.server_port;

                this.sessionId = result.id;

                result.connection.pong = this.pong;
                result.connection.pong_at = this.pong_at;

                result.process.node_version = process.version;
                result.process.versions = process.versions;

                return new Promise((resolve, reject) => {
                    let TIME_INTERVAL = 0;
                    let oldTime = Date.now();

                    setTimeout(function() {
                        let newTime = Date.now();
                        let delay = newTime - oldTime;
                        oldTime = newTime;

                        result.process.loop_delay = Math.ceil(delay);
                        resolve();

                    }, TIME_INTERVAL);
                });
            })
            .then(() => {
                return result;
            })
    }

    fetchQuestions() {
        let questions = this
            .activeQuestions
            .filter(question => {
                return question.active;
            });

        return Promise.resolve({
            questions
        });
    }

    fetchConfigInfo() {
        return Promise.resolve(this.options.config);
    }

    fetchAppInfo() {
        let result = {
            started_at: this.startedAt,
            version: null,
            last_version: null,
            git: {},
            stats: this.stats,
        };

        return nodegit
            .Repository
            .open(path.resolve("./.git"))
            .catch(() => {
                return null;
            })
            .then((repo) => {
                if(!repo) {
                    return;
                }

                return repo.getHeadCommit()
                    .then(commit => {
                        result.git.last_commit = {
                            ref: commit.id().tostrS(),
                            shortref: commit.id().tostrS().substring(0, 6),
                            message: commit.message().trim(),
                            author: {
                                name: commit.author().name,
                                email: commit.author().email,
                            }
                        };

                        return repo.getCurrentBranch();
                    })
                    .then(branch => {
                        result.git.branch = {
                            name: branch.shorthand(),
                            fullname: branch.name(),
                        };
                    });
            })
            .then(() => {
                let changelogFile = './CHANGELOG.md';

                if(!fs.existsSync(changelogFile)) {
                    return result;
                }

                // TODO: Changelog to file
                let text = fs.readFileSync(changelogFile, 'utf8');
                let match = text.match(/## (.+) - (.+)/);

                if(match) {
                    result.version = match[1];
                    result.last_version = match[2];
                }

                return result;
            })
    }

    fetchSystemInfo() {
        let result = {};

        let info = [
            {fnc: 'cpu', key: 'cpu', cache: 60 * 1000},
            {fnc: 'system', key: 'system', cache: 60 * 1000},
            {fnc: 'osInfo', key: 'os', cache: 30 * 1000},
            {fnc: 'fsSize', key: 'disk', cache: 60 * 1000},
            {fnc: 'mem', key: 'memory', cache: null},
            {fnc: 'currentLoad', key: 'load', cache: null},
        ];

        return asyncp
            .eachLimit(info, 1, node => {
                if(node.cache) {
                    let cachedVal = cache.get('cluster.machine.' + node.key);

                    if(cachedVal) {
                        result[node.key] = cachedVal;
                        return;
                    }
                }

                return Promise.try(() => si[node.fnc].apply(this, [])).then(res => {
                    result[node.key] = res;

                    if(node.cache) {
                        cache.put('cluster.machine.' + node.key, res, node.cache);
                    }
                });
            })
            .then(() => {
                return result;
            });
    }

    fetchConnectionInfo() {
        return new Promise((resolve, reject) => {
            let start = new Date();

            request({
                method: 'get',
                uri: 'http://lumtest.com/myip.json',
                timeout: 5 * 1000,
                json: true,

            }, (err, response, body) => {
                if(err) return reject(err);

                if(response.statusCode == 200) {
                    let data = {
                        interned_speed: (new Date() - start),
                        remote_ip: body.ip,
                        server_port: this.options.serverPort,
                        location: {
                            country: body.geoip_country,
                            org_name: body.asn.org_name,
                            geo: body.geo,
                        },
                    };

                    resolve(data);

                } else {
                    reject(new Error('invalid response code'));
                }
            });
        });
    }

    sendMasterBeacon(name, data, options) {
        options = options || {};

        if(!this.isSlave()) {
            return Promise.reject(new Error('only slaves can send this beacon'));
        }

        return new Promise((resolve, reject) => {
            this.socketClient.emit('question', {
                name,
                data: __.object.simplify(data),
                options: options,

            }, (data) => {

                if(data.success) {
                    resolve(data.result);

                } else {
                    let err = new Error(data.error.message);
                    err.code = data.error.code;
                    err.trace = data.error.trace;
                    err.retriable = data.error.retriable;
                    err.errorType = data.error.errorType;

                    reject(err)
                }
            });
        });
    }

    sendSlaveBeacon(id, name, data, options) {
        options = _.defaults(options || {}, {
            hiddenQuestion: false,
        });

        if(!this.isMaster()) {
            return Promise.reject(new Error('only master can send this beacon'));
        }

        return new Promise((resolve, reject) => {
            let slave = this.getSlaveById(id);

            if(!slave.connected) {
                return reject(new Error('slave is not connected'));
            }

            let activeQuestion = {
                active: true,
                added_at: new Date(),
                name: name,
                data: data,
            };

            if(!options.hiddenQuestion) {
                slave.questions.push(activeQuestion);
            }

            slave
                .client
                .emit('question', {
                    name,
                    data: __.object.simplify(data),
                    options: options,

                }, (data) => {
                    if(!options.hiddenQuestion) {
                        activeQuestion.active = false;
                    }

                    if(data.success) {
                        resolve(data.result);

                    } else {
                        let err = new Error(data.error.message);
                        err.code = data.error.code;
                        err.trace = data.error.trace;
                        err.retriable = data.error.retriable;
                        err.errorType = data.error.errorType;

                        reject(err);
                    }
                });

        });
    }

    /* -------------------------------------------------------------------------
     * Clients
     * ---------------------------------------------------------------------- */

    getSlaveById(id) {
        if(id in this.slaves) {
            return this.slaves[id];
        }

        throw new Error('slave with id ' + id + ' not found');
    }

    /* -------------------------------------------------------------------------
     * Both
     * ---------------------------------------------------------------------- */

    getTotalSlaves(online) {
        return this.askMaster('total_slaves', {
            online,
        }, {
            silence: true,
        });
    }

    getSlaves(online) {
        return this.askMaster('slaves', {
            online,
        }, {
            silence: true,
        });
    }

    getNodes() {
        return this
            .askMaster('nodes')
            .then(nodes => {
                let master = null;

                nodes.forEach(node => {
                    if(node.type == 'master') {
                        master = node;
                    }
                });

                nodes.forEach(node => {
                    node.current = node.id == this.getId();

                    let masterCommit = _.queryArray(master, 'data.app.git.last_commit.ref');
                    let slaveCommit = _.queryArray(node, 'data.app.git.last_commit.ref');

                    if(!masterCommit) {
                        logger.error('Master don\'t have version data');
                    }

                    if(!slaveCommit) {
                        logger.error('Slave don\'t have version data');
                    }

                    node.outdated = master && node.type != 'master' && masterCommit != slaveCommit;
                });

                return nodes;
            });
    }

    askNode(id, question, data, options) {
        if(id == 'master') {
            return this.askMaster(question, data, options);

        } else {
            return this.askSlave(id, question, data, options);
        }
    }

    askMaster(question, data, options) {
        options = _.defaults(options, {
            silence: false,
        });

        let end = options.silence ? () => '' : logger.trace('Asking master for: ' + question);
        let res = null;

        if(this.isMaster()) {
            res = this.answerQuestion(question, data, options);

        } else {
            res = this.sendMasterBeacon(question, data, options);
        }

        return res.finally(() => end());
    }

    askSlave(id, question, data, options) {
        options = _.defaults(options, {
            silence: false,
        });

        let end = options.silence ? () => '' : logger.trace('Asking slave "' + id + '" for: ' + question);
        let res = null;

        if(this.isSlave() && id == this.getId()) {
            res = this.answerQuestion(question, data, options);

        } else {
            res = this.sendSlaveBeacon(id, question, data, options);
        }

        return Promise
            .resolve(res)
            .timeout(this.options.fatalTimeout)
            .finally(() => end());
    }

    askAllNodes(question, data, options) {
        return asyncp
            .eachLimit(this.slaves, 10, slave => {
                return this.askSlave(slave.id, question, data, options);
            })
            .then(() => {
                return this.askMaster(question, data, options);
            });
    }

    askAllSlaves(question, data, options) {
        return asyncp.eachLimit(this.slaves, 20, slave => {
            return this.askSlave(slave.id, question, data, options);
        });
    }

    /* -------------------------------------------------------------------------
     * Questions
     * ---------------------------------------------------------------------- */

    onQuestion(name, callback) {
        this.questions[name] = {
            callback,
        };

        return this;
    }

    promiseDone(promise, done, success, error) {
        return Promise
            .resolve(promise)
            .finally(() => {
                done && done();
            })
            .then(res => {
                success && success(res);
                return res;
            })
            .catch(err => {
                error && error(err);
                return Promise.reject(err);
            });
    }

    answerQuestion(name, data, options) {
        data = data || {};
        options = _.defaults(options, {
            silence: false,
        });

        let end = options.silence ? () => '' : logger.trace('Cluster question received: ' + name);

        let activeQuestion = {
            active: true,
            added_at: new Date(),
            name: name,
            data: data,
        };

        if(!options.hiddenQuestion) {
            this.activeQuestions.push(activeQuestion);
        }

        if(name in this.questions) {
            let res = Promise
                .try(() => {
                    return this.questions[name].callback.apply(this, [data]);
                });

            if(options.hiddenQuestion) {
                return res
                    .finally(() => {
                        end();
                    });
            }

            return this.promiseDone(res, () => {
                this.stats.questions.total++;
                activeQuestion.active = false;
                end();

            }, () => {
                this.stats.questions.ok++;

            }, () => {
                this.stats.questions.error++;
            });
        }

        let res = null;

        switch(name) {
            case 'exit':
                res = Promise.try(() => this._exit.apply(this, [data]));
                break;
            case 'checkout':
                res = Promise.try(() => this._checkout.apply(this, [data.commit, data.restart || false]));
                break;
            case 'restart':
                res = Promise.try(() => this._restart.apply(this, [data]));
                break;
            case 'total_slaves':
                res = Promise.try(() => this._getTotalSlaves.apply(this, [data.online]));
                break;
            case 'slaves':
                res = Promise.try(() => this._getSlaves.apply(this, [data.online]));
                break;
            case 'nodes':
                res = Promise.try(() => this._getNodes.apply(this, [data]));
                break;
            case 'slave_ping':
                res = Promise.try(() => this._slavePing.apply(this, [data]));
                break;
            default:
                return Promise.reject(new Error('unknown question: ' + name));
        }

        return this.promiseDone(res, () => {
            end();
            this.stats.questions.total++;
            activeQuestion.active = false;

        }, () => {
            this.stats.questions.ok++;

        }, () => {
            this.stats.questions.error++;
        });
    }

    cleanNode(node) {
        let clean = {};

        for(let key in node) {
            if(key != 'client') {
                clean[key] = node[key];
            }
        }

        return clean;
    }

    createMasterNode(data) {
        return {
            type: 'master',
            id: 'master',
            connected: true,
            heartbeat_at: new Date(),
            connected_at: this.startedAt,
            disconnected_at: null,
            data: data,
        };
    }

    /* -------------------------------------------------------------------------
     * Master - Only master knowns
     * ---------------------------------------------------------------------- */

    _exit() {
        setTimeout(() => {
            process.exit(0);
        }, 250);

        return Promise.resolve();
    }

    _slavePing(data) {
        this.socket.emit('cluster_slave_ping', data);
        return Promise.resolve();
    }

    _checkout(commit, restart) {
        if(this.options.ignoreMasterVersion) {
            logger.info('This slaved has ignored the master version');
            return Promise.resolve('ignore');
        }

        return nodegit.Repository
            .open(path.resolve('./.git'))
            .then(repo => {
                return repo
                    .fetchAll({
                        callbacks: {
                            certificateCheck: function() {
                                return 1;
                            },
                            credentials: function(url, userName) {
                                return nodegit.Cred.sshKeyNew(userName, path.resolve('./data/certs/comcom_id_rsa'), path.resolve('./data/certs/comcom_id_rsa.pub'), '');
                            }
                        }
                    })
                    .then(() => {
                        return repo.getCommit(commit);
                    })
                    .then(commit => {
                        repo.setHeadDetached(commit);
                        return nodegit.Reset.reset(repo, commit, nodegit.Reset.TYPE.HARD);
                    })
                    .then(() => {
                        if(restart) {
                            setTimeout(() => {
                                // Install packages
                                var child_process = require('child_process');
                                child_process.execSync("npm install", {stdio: [0, 1, 2]});

                                // Exit
                                process.exit(0x359);

                            }, 250);

                            return 'restart';
                        }

                        return 'ok';
                    });
            });
    }

    _restart() {
        setTimeout(() => {
            process.exit(0x350);
        }, 250);

        return Promise.resolve();
    }

    _getSlaves(online) {
        return Object
            .values(this.slaves)
            .map(slave => {
                if(online && !slave.connected) {
                    return null;
                }

                return this.cleanNode(slave);
            })
            .filter(v => {
                return !!v;
            });
    }

    _getNodes() {
        let slaves = this._getSlaves();

        return this
            .fetchMachineInfo()
            .then(data => {
                let nodes = _.cloneDeep(slaves);

                nodes.forEach(v => {
                    v.type = 'slave';
                });

                nodes.unshift(this.createMasterNode(data));
                return nodes;
            });
    }

    _getTotalSlaves(online) {
        return this._getSlaves(online).length;
    }
}

module.exports = ClusterManager;