'use strict';

const _ = {
    extend: require('lodash/extend'),
    find: require('lodash/find'),
    each: require('lodash/each'),
    defer: require('lodash/defer'),
    bind: require('lodash/bind'),
};
const EventEmitter = require('eventemitter3');
const MiddlewareHandler = require('middleware-handler');
const IrcCommandHandler = require('./commands/').CommandHandler;
const IrcMessage = require('./ircmessage');
const Connection = require('./connection');
const NetworkInfo = require('./networkinfo');
const User = require('./user');
const Channel = require('./channel');
const { lineBreak } = require('./linebreak');
const MessageTags = require('./messagetags');

let default_transport = null;

module.exports = class IrcClient extends EventEmitter {
    constructor(options) {
        super();

        this.request_extra_caps = [];
        this.options = options || null;

        this.createStructure();
    }

    static setDefaultTransport(transport) {
        default_transport = transport;
    }

    get Message() {
        return IrcMessage;
    }

    _applyDefaultOptions(user_options) {
        const defaults = {
            nick: 'ircbot',
            username: 'ircbot',
            gecos: 'ircbot',
            encoding: 'utf8',
            version: 'node.js irc-framework',
            enable_chghost: false,
            enable_setname: false,
            enable_echomessage: false,
            enable_standardreplies: false,
            auto_reconnect: true,
            auto_reconnect_max_wait: 300000,
            auto_reconnect_max_retries: 3,
            ping_interval: 30,
            ping_timeout: 120,
            message_max_length: 350,
            sasl_disconnect_on_fail: false,
            transport: default_transport,
            websocket_protocol: 'text.ircv3.net'
        };

        const props = Object.keys(defaults);
        for (let i = 0; i < props.length; i++) {
            if (typeof user_options[props[i]] === 'undefined') {
                user_options[props[i]] = defaults[props[i]];
            }
        }

        return user_options;
    }

    createStructure() {
        const client = this;

        // Provides middleware hooks for either raw IRC commands or the easier to use parsed commands
        client.raw_middleware = new MiddlewareHandler();
        client.parsed_middleware = new MiddlewareHandler();

        client.connection = new Connection(client.options);
        client.network = new NetworkInfo();
        client.user = new User();

        client.command_handler = new IrcCommandHandler(client);

        client.timers = {
            ping: 0,
            timeout: 0,
        };

        client.addCommandHandlerListeners();

        // Proxy some connection events onto this client
        [
            'connecting',
            'reconnecting',
            'close',
            'socket close',
            'socket error',
            'raw socket connected',
            'debug',
            'raw'
        ].forEach(function(event_name) {
            client.connection.on(event_name, function() {
                const args = Array.prototype.slice.call(arguments);
                client.emit.apply(client, [event_name].concat(args));
            });
        });

        client.connection.on('socket connected', function() {
            client.emit('socket connected');
            client.registerToNetwork();
            client.startPingTimeoutTimer();
        });

        client.connection.on('connecting', function() {
            // Reset cap negotiation on a new connection
            // This prevents stale state if a connection gets closed during CAP negotiation
            client.network.cap.negotiating = false;
            client.network.cap.requested = [];
            client.network.cap.enabled = [];
            client.network.cap.available.clear();

            client.command_handler.resetCache();
        });

        // IRC command routing
        client.connection.on('message', function(message, raw_line) {
            client.raw_middleware.handle([message.command, message, raw_line, client], function(err) {
                if (err) {
                    console.log(err.stack);
                    return;
                }

                client.command_handler.dispatch(message);
            });
        });

        client.on('registered', function(event) {
            // PING is not a valid command until after registration
            client.startPeriodicPing();
        });

        client.on('away', function(event) {
            if (client.caseCompare(event.nick, client.user.nick)) {
                client.user.away = true;
            }
        });

        client.on('back', function(event) {
            if (client.caseCompare(event.nick, client.user.nick)) {
                client.user.away = false;
            }
        });

        // Proxy the command handler events onto the client object, with some added sugar
        client.proxyIrcEvents();

        const whox_token = {
            value: 0,
            requests: [],
            next: () => {
                if (whox_token.value >= 999) {
                    // whox token is limited to 3 characters
                    whox_token.value = 0;
                }
                const token = ++whox_token.value;
                whox_token.requests.push(token);
                return token;
            },
            validate: (token) => {
                const idx = whox_token.requests.indexOf(token);
                if (idx !== -1) {
                    whox_token.requests.splice(idx, 1);
                    return true;
                }
                return false;
            },
        };
        client.whox_token = whox_token;

        Object.defineProperty(client, 'connected', {
            enumerable: true,
            get: function() {
                return client.connection && client.connection.connected;
            }
        });
    }

    requestCap(cap) {
        this.request_extra_caps = this.request_extra_caps.concat(cap);
    }

    use(middleware_fn) {
        middleware_fn(this, this.raw_middleware, this.parsed_middleware);
        return this;
    }

    connect(options) {
        const client = this;

        // Use the previous options object if we're calling .connect() again
        if (!options && !client.options) {
            throw new Error('Options object missing from IrcClient.connect()');
        } else if (!options) {
            options = client.options;
        } else {
            client.options = options;
        }

        client._applyDefaultOptions(options);

        if (client.connection && client.connection.connected) {
            client.debugOut('connect() called when already connected');
            client.connection.end();
        }

        client.user.nick = options.nick;
        client.user.username = options.username;
        client.user.gecos = options.gecos;

        client.command_handler.requestExtraCaps(client.request_extra_caps);

        // Everything is setup and prepared, start connecting
        client.connection.connect(options);
    }

    // Proxy the command handler events onto the client object, with some added sugar
    // Events are handled in order:
    // 1. Received from the command handler
    // 2. Checked if any extra properties/methods are to be added to the event + re-emitted
    // 3. Routed through middleware
    // 4. Emitted from the client instance
    proxyIrcEvents() {
        const client = this;

        this.command_handler.on('all', function(event_name, event_arg) {
            client.resetPingTimeoutTimer();

            // Add a reply() function to selected message events
            if (['privmsg', 'notice', 'action'].indexOf(event_name) > -1) {
                event_arg.reply = function(message) {
                    const dest = event_arg.target === client.user.nick ?
                        event_arg.nick :
                        event_arg.target;

                    client.say(dest, message);
                };

                // These events with .reply() function are all messages. Emit it separately
                // TODO: Should this consider a notice a message?
                client.command_handler.emit('message', _.extend({ type: event_name }, event_arg));
            }

            client.parsed_middleware.handle([event_name, event_arg, client], function(err) {
                if (err) {
                    console.error(err.stack);
                    return;
                }

                client.emit(event_name, event_arg);
            });
        });
    }

    addCommandHandlerListeners() {
        const client = this;
        const commands = this.command_handler;

        commands.on('nick', function(event) {
            if (client.user.nick === event.nick) {
                // nicks starting with numbers are reserved for uuids
                // we dont want to store these as they cannot be used
                if (event.new_nick.match(/^\d/)) {
                    return;
                }
                client.user.nick = event.new_nick;
            }
        });

        commands.on('mode', function(event) {
            if (client.user.nick === event.target) {
                event.modes.forEach(function(mode) {
                    client.user.toggleModes(mode.mode);
                });
            }
        });

        commands.on('wholist', function(event) {
            const thisUser = _.find(event.users, { nick: client.user.nick });
            if (thisUser) {
                client.user.username = thisUser.ident;
                client.user.host = thisUser.hostname;
            }
        });

        commands.on('registered', function(event) {
            client.user.nick = event.nick;
            client.connection.registeredSuccessfully();
            client.emit('connected', event);
        });

        commands.on('displayed host', function(event) {
            if (client.user.nick === event.nick) {
                client.user.host = event.hostname;
            }
        });

        // Don't let IRC ERROR command kill the node.js process if unhandled
        commands.on('error', function(event) {
        });
    }

    registerToNetwork() {
        const webirc = this.options.webirc;

        if (webirc) {
            let address = String(webirc.address);

            // Prepend a zero to addresses that begin with colon (like ::1)
            // as colon is using to denote last argument in IRC
            if (address[0] === ':') {
                address = '0' + address;
            }

            this.raw(
                'WEBIRC',
                webirc.password,
                webirc.username,
                webirc.hostname,
                address,
                MessageTags.encode(webirc.options || {}, ' ')
            );
        }

        this.raw('CAP LS 302');

        if (this.options.password) {
            this.raw('PASS', this.options.password);
        }

        this.raw('NICK', this.user.nick);
        this.raw('USER', this.options.username, 0, '*', this.user.gecos);
    }

    startPeriodicPing() {
        const client = this;

        if (client.options.ping_interval <= 0 || client.timers.ping > 0) {
            return;
        }

        // Constantly ping the server for lag and time syncing functions
        function pingServer() {
            client.ping();
        }

        function resetPingTimer() {
            client.connection.clearTimeout(client.timers.ping);
            client.timers.ping = client.connection.setTimeout(pingServer, client.options.ping_interval * 1000);
        }

        // Browsers have started throttling looped timeout callbacks
        // using the pong event to set the next ping breaks this loop
        client.command_handler.on('pong', resetPingTimer);

        // Socket has disconnected, remove 'pong' listener until next 'registered' event
        client.connection.once('socket close', () => {
            client.command_handler.off('pong', resetPingTimer);
            client.connection.clearTimeout(client.timers.ping);
            client.connection.clearTimeout(client.timers.timeout);
            client.timers.ping = 0;
            client.timers.timeout = 0;
        });

        // Start timer
        resetPingTimer();
    }

    startPingTimeoutTimer() {
        const client = this;

        if (client.options.ping_timeout <= 0) {
            return;
        }

        // Data from the server was detected so restart the timeout
        function resetPingTimeoutTimer() {
            client.connection.clearTimeout(client.timers.timeout);
            client.timers.timeout = client.connection.setTimeout(pingTimeout, client.options.ping_timeout * 1000);
        }

        function pingTimeout() {
            client.debugOut('Ping timeout (' + client.options.ping_timeout + ' seconds)');
            client.emit('ping timeout');
            const end_msg = client.rawString('QUIT', 'Ping timeout (' + client.options.ping_timeout + ' seconds)');
            client.connection.end(end_msg, true);
        }

        this.resetPingTimeoutTimer = resetPingTimeoutTimer;
        this.resetPingTimeoutTimer();
    }

    // Gets overridden with a function in startPeriodicPing(). Only set here for completeness.
    resetPingTimeoutTimer() {}

    debugOut(out) {
        this.emit('debug', 'Client ' + out);
    }

    /**
     * Client API
     */
    raw(input) {
        if (input instanceof IrcMessage) {
            this.connection.write(input.to1459());
        } else {
            this.connection.write(this.rawString.apply(this, arguments));
        }
    }

    rawString(input) {
        let args;

        if (input.constructor === Array) {
            args = input;
        } else {
            args = Array.prototype.slice.call(arguments, 0);
        }

        args = args.filter(function(item) {
            return (typeof item === 'number' || typeof item === 'string');
        });

        if (args.length > 1 && args[args.length - 1].match(/^:|\s/)) {
            args[args.length - 1] = ':' + args[args.length - 1];
        }

        return args.join(' ');
    }

    quit(message) {
        this.connection.end(this.rawString('QUIT', message));
    }

    ping(message) {
        this.raw('PING', message || Date.now().toString());
    }

    changeNick(nick) {
        this.raw('NICK', nick);
    }

    sendMessage(commandName, target, message, tags) {
        const lines = message
            .split(/\r\n|\n|\r/)
            .filter(i => i);

        lines.forEach(line => {
            // Maximum length of target + message we can send to the IRC server is 500 characters
            // but we need to leave extra room for the sender prefix so the entire message can
            // be sent from the IRCd to the target without being truncated.
            const blocks = [
                ...lineBreak(line, {
                    bytes: this.options.message_max_length,
                    allowBreakingWords: true,
                    allowBreakingGraphemes: true,
                })
            ];

            blocks.forEach(block => {
                if (tags && Object.keys(tags).length) {
                    const msg = new IrcMessage(commandName, target, block);
                    msg.tags = tags;
                    this.raw(msg);
                } else {
                    this.raw(commandName, target, block);
                }
            });
        });
    }

    say(target, message, tags) {
        return this.sendMessage('PRIVMSG', target, message, tags);
    }

    notice(target, message, tags) {
        return this.sendMessage('NOTICE', target, message, tags);
    }

    tagmsg(target, tags = {}) {
        const msg = new IrcMessage('TAGMSG', target);
        msg.tags = tags;
        this.raw(msg);
    }

    join(channel, key) {
        const raw = ['JOIN', channel];
        if (key) {
            raw.push(key);
        }
        this.raw(raw);
    }

    part(channel, message) {
        const raw = ['PART', channel];
        if (message) {
            raw.push(message);
        }
        this.raw(raw);
    }

    mode(channel, mode, extra_args) {
        let raw = ['MODE', channel, mode];

        if (extra_args) {
            if (Array.isArray(extra_args)) {
                raw = raw.concat(extra_args);
            } else {
                raw.push(extra_args);
            }
        }

        this.raw(raw);
    }

    inviteList(channel, cb) {
        const client = this;
        const invex = this.network.supports('INVEX');
        let mode = 'I';

        if (typeof invex === 'string' && invex) {
            mode = invex;
        }

        function onInviteList(event) {
            if (client.caseCompare(event.channel, channel)) {
                unbindEvents();
                if (typeof cb === 'function') {
                    cb(event);
                }
            }
        }

        function onInviteListErr(event) {
            if (event.error === 'chanop_privs_needed') {
                unbindEvents();
                if (typeof cb === 'function') {
                    cb(null);
                }
            }
        }

        function bindEvents() {
            client.on('inviteList', onInviteList);
            client.on('irc error', onInviteListErr);
        }

        function unbindEvents() {
            client.removeListener('inviteList', onInviteList);
            client.removeListener('irc error', onInviteListErr);
        }

        bindEvents();
        this.raw(['MODE', channel, mode]);
    }

    invite(channel, nick) {
        const raw = ['INVITE', nick, channel];
        this.raw(raw);
    }

    addInvite(channel, mask) {
        let mode = 'I';
        const invex = this.network.supports('INVEX');
        if (typeof invex === 'string') {
            mode = invex;
        }

        const raw = ['MODE', channel, '+' + mode, mask];
        this.raw(raw);
    }

    removeInvite(channel, mask) {
        let mode = 'I';
        const invex = this.network.supports('INVEX');
        if (typeof invex === 'string') {
            mode = invex;
        }

        const raw = ['MODE', channel, '-' + mode, mask];
        this.raw(raw);
    }

    banlist(channel, cb) {
        const client = this;
        const raw = ['MODE', channel, 'b'];

        this.on('banlist', function onBanlist(event) {
            if (client.caseCompare(event.channel, channel)) {
                client.removeListener('banlist', onBanlist);
                if (typeof cb === 'function') {
                    cb(event);
                }
            }
        });

        this.raw(raw);
    }

    ban(channel, mask) {
        const raw = ['MODE', channel, '+b', mask];
        this.raw(raw);
    }

    unban(channel, mask) {
        const raw = ['MODE', channel, '-b', mask];
        this.raw(raw);
    }

    setTopic(channel, newTopic) {
        if (!newTopic || !newTopic.trim()) {
            // If newTopic is undefined or empty, remove the existing topic
            // this check is to prevent unexpectedly requesting the current topic
            // when trying to clear the topic
            this.clearTopic(channel);
            return;
        }

        this.raw('TOPIC', channel, newTopic);
    }

    clearTopic(channel) {
        // The trailing `:` is required otherwise it would be requesting the topic
        // and not clearing it
        this.raw(`TOPIC ${channel} :`);
    }

    ctcpRequest(target, type /*, paramN */) {
        const params = Array.prototype.slice.call(arguments, 1);

        // make sure the CTCP type is uppercased
        params[0] = params[0].toUpperCase();

        this.raw(
            'PRIVMSG',
            target,
            String.fromCharCode(1) + params.join(' ') + String.fromCharCode(1)
        );
    }

    ctcpResponse(target, type /*, paramN */) {
        const params = Array.prototype.slice.call(arguments, 1);

        // make sure the CTCP type is uppercased
        params[0] = params[0].toUpperCase();

        this.raw(
            'NOTICE',
            target,
            String.fromCharCode(1) + params.join(' ') + String.fromCharCode(1)
        );
    }

    action(target, message) {
        const that = this;

        // Maximum length of target + message we can send to the IRC server is 500 characters
        // but we need to leave extra room for the sender prefix so the entire message can
        // be sent from the IRCd to the target without being truncated.

        // The block length here is the max, but without the non-content characters:
        // the command name, the space, and the two SOH chars

        const commandName = 'ACTION';
        const blockLength = this.options.message_max_length - (commandName.length + 3);
        const blocks = [...lineBreak(message, { bytes: blockLength, allowBreakingWords: true, allowBreakingGraphemes: true })];

        blocks.forEach(function(block) {
            that.ctcpRequest(target, commandName, block);
        });

        return blocks;
    }

    whois(target, _cb) {
        const client = this;
        let cb;
        const irc_args = ['WHOIS'];

        // Support whois(target, arg1, arg2, argN, cb)
        _.each(arguments, function(arg) {
            if (typeof arg === 'function') {
                cb = arg;
            } else {
                irc_args.push(arg);
            }
        });

        this.on('whois', function onWhois(event) {
            if (client.caseCompare(event.nick, target)) {
                client.removeListener('whois', onWhois);
                if (typeof cb === 'function') {
                    cb(event);
                }
            }
        });

        this.raw(irc_args);
    }

    whowas(target, _cb) {
        const client = this;
        let cb;
        const irc_args = ['WHOWAS'];

        // Support whowas(target, arg1, arg2, argN, cb)
        _.each(arguments, function(arg) {
            if (typeof arg === 'function') {
                cb = arg;
            } else {
                irc_args.push(arg);
            }
        });

        this.on('whowas', function onWhowas(event) {
            if (client.caseCompare(event.nick, target)) {
                client.removeListener('whowas', onWhowas);
                if (typeof cb === 'function') {
                    cb(event);
                }
            }
        });

        this.raw(irc_args);
    }

    /**
     * WHO requests are queued up to run serially.
     * This is mostly because networks will only reply serially and it makes
     * it easier to include the correct replies to callbacks
     */
    who(target, cb) {
        if (!this.who_queue) {
            this.who_queue = [];
        }
        this.who_queue.push([target, cb]);
        this.processNextWhoQueue();
    }

    monitorlist(cb) {
        const client = this;
        const raw = ['MONITOR', 'L'];

        this.on('monitorList', function onMonitorlist(event) {
            client.removeListener('monitorList', onMonitorlist);
            if (typeof cb === 'function') {
                cb(event);
            }
        });

        this.raw(raw);
    }

    addMonitor(target) {
        const raw = ['MONITOR', '+', target];

        this.raw(raw);
    }

    removeMonitor(target) {
        const raw = ['MONITOR', '-', target];

        this.raw(raw);
    }

    queryMonitor() {
        const raw = ['MONITOR', 'S'];

        this.raw(raw);
    }

    clearMonitor() {
        const raw = ['MONITOR', 'C'];

        this.raw(raw);
    }

    processNextWhoQueue() {
        const client = this;

        // No items in the queue or the queue is already running?
        if (client.who_queue.length === 0 || client.who_queue.is_running) {
            return;
        }

        client.who_queue.is_running = true;

        const this_who = client.who_queue.shift();
        const target = this_who[0];
        const cb = this_who[1];

        if (!target || typeof target !== 'string') {
            if (typeof cb === 'function') {
                _.defer(cb, {
                    target: target,
                    users: []
                });
            }

            // Start the next queued WHO request
            client.who_queue.is_running = false;
            _.defer(_.bind(client.processNextWhoQueue, client));

            return;
        }

        client.on('wholist', function onWho(event) {
            client.removeListener('wholist', onWho);

            // Start the next queued WHO request
            client.who_queue.is_running = false;
            _.defer(_.bind(client.processNextWhoQueue, client));

            if (typeof cb === 'function') {
                cb({
                    target: target,
                    users: event.users
                });
            }
        });

        if (client.network.supports('whox')) {
            const token = client.whox_token.next();
            client.raw('WHO', target, `%tcuhsnfdaor,${token}`);
        } else {
            client.raw('WHO', target);
        }
    }

    /**
     * Explicitely start a channel list, avoiding potential issues with broken IRC servers not sending RPL_LISTSTART
     */
    list(/* paramN */) {
        const args = Array.prototype.slice.call(arguments);
        this.command_handler.cache('chanlist').channels = [];
        args.unshift('LIST');
        this.raw(args);
    }

    channel(channel_name, key) {
        return new Channel(this, channel_name, key);
    }

    match(match_regex, cb, message_type) {
        const client = this;

        const onMessage = function(event) {
            if (event.message.match(match_regex)) {
                cb(event);
            }
        };

        this.on(message_type || 'message', onMessage);

        return {
            stop: function() {
                client.removeListener(message_type || 'message', onMessage);
            }
        };
    }

    matchNotice(match_regex, cb) {
        return this.match(match_regex, cb, 'notice');
    }

    matchMessage(match_regex, cb) {
        return this.match(match_regex, cb, 'privmsg');
    }

    matchAction(match_regex, cb) {
        return this.match(match_regex, cb, 'action');
    }

    caseCompare(string1, string2) {
        const length = string1.length;

        if (length !== string2.length) {
            return false;
        }

        const upperBound = this._getCaseMappingUpperAsciiBound();

        for (let i = 0; i < length; i++) {
            let charCode1 = string1.charCodeAt(i);
            let charCode2 = string2.charCodeAt(i);

            if (charCode1 >= 65 && charCode1 <= upperBound) {
                charCode1 += 32;
            }

            if (charCode2 >= 65 && charCode2 <= upperBound) {
                charCode2 += 32;
            }

            if (charCode1 !== charCode2) {
                return false;
            }
        }

        return true;
    }

    caseLower(string) {
        const upperBound = this._getCaseMappingUpperAsciiBound();
        let result = '';

        for (let i = 0; i < string.length; i++) {
            const charCode = string.charCodeAt(i);

            // ASCII character from 'A' to upper bound defined above
            if (charCode >= 65 && charCode <= upperBound) {
                // All the relevant uppercase characters are exactly
                // 32 bytes apart from lowercase ones, so we simply add 32
                // and get the equivalent character in lower case
                result += String.fromCharCode(charCode + 32);
            } else {
                result += string[i];
            }
        }

        return result;
    }

    caseUpper(string) {
        const upperBound = this._getCaseMappingUpperAsciiBound() + 32;
        let result = '';

        for (let i = 0; i < string.length; i++) {
            const charCode = string.charCodeAt(i);

            // ASCII character from 'a' to upper bound defined above
            if (charCode >= 97 && charCode <= upperBound) {
                // All the relevant lowercase characters are exactly
                // 32 bytes apart from lowercase ones, so we simply subtract 32
                // and get the equivalent character in upper case
                result += String.fromCharCode(charCode - 32);
            } else {
                result += string[i];
            }
        }

        return result;
    }

    _getCaseMappingUpperAsciiBound() {
        if (this.network.options.CASEMAPPING === 'ascii') {
            return 90; // 'Z'
        } else if (this.network.options.CASEMAPPING === 'strict-rfc1459') {
            return 93; // ']'
        }

        return 94; // '^' - default casemapping=rfc1459
    }
};
