'use strict';

var createQueue = require('./message-queue');
var SMTPConnection = require('smtp-connection');
var dns = require('dns');
var net = require('net');
var os = require('os');
var util = require('util');
var packageData = require('../package.json');
var EventEmitter = require('events').EventEmitter;

// Expose to the world
module.exports = function(options) {
    return new DirectMailer(options);
};

/**
 * Creates a new DirectMailer instance. Provides method 'send' to queue
 * outgoing e-mails. The queue is processed in the background.
 *
 * @constructor
 * @param {Object} [options] Optional options object
 */
function DirectMailer(options) {
    EventEmitter.call(this);
    this._options = options || {};
    this._queue = createQueue();
    this._started = false;
    this._lastId = 0;

    // temporary object
    var connection = new SMTPConnection({});

    this.name = 'SMTP (direct)';
    this.version = packageData.version + '[client:' + connection.version + ']';
}
util.inherits(DirectMailer, EventEmitter);

// Adds a dynamic property 'length'
Object.defineProperty(DirectMailer.prototype, 'length', {
    get: function() {
        return this._queue._instantQueue.length + this._queue._sortedQueue.length;
    }
});

/**
 * Adds an outgoing message to the queue. Recipient addresses are sorted
 * by the receiving domain and for every domain, a copy of the message is queued.
 *
 * If input is deemed invalid, an error is thrown, so be ready to catch these
 * when calling directmail.send(...)
 *
 * @param {Object} mail Mail object
 * @param {Function} callback Callback function
 */
DirectMailer.prototype.send = function(mail, callback) {

    var envelope = mail.data.envelope || mail.message.getEnvelope();
    var domainEnvelopes = {};

    if (!envelope.from) {
        return callback(new Error('"From" address missing'));
    }

    envelope.to = [].concat(envelope.to || []);

    if (!envelope.to.length) {
        return callback('"Recipients" addresses missing');
    }

    // We cant't run existing streams more than once so we need to change these
    // to buffers. Filenames, URLs etc are not affected – for every
    // message copy a new file stream will be created
    this._clearStreams(mail, function() {

        this._formatMessage(mail.message);

        envelope.to.forEach(function(recipient) {
            recipient = (recipient || '').toString();

            var domain = (recipient.split('@').pop() || '').toLowerCase().trim();

            if (!domainEnvelopes[domain]) {
                domainEnvelopes[domain] = {
                    from: envelope.from,
                    to: [recipient]
                };
            } else if (domainEnvelopes[domain].to.indexOf(recipient) < 0) {
                domainEnvelopes[domain].to.push(recipient);
            }
        });

        var returned = 0;
        var domains = Object.keys(domainEnvelopes);
        var combinedInfo = {
            accepted: [],
            rejected: [],
            pending: [],
            errors: [],
            envelope: mail.data.envelope || mail.message.getEnvelope()
        };

        domains.forEach((function(domain) {
            var called = false;
            var id = ++this._lastId;
            var item = {
                envelope: domainEnvelopes[domain],
                data: mail.data,
                message: mail.message,
                domain: domain,
                id: id,
                callback: function(err, info) {
                    if (called) {
                        this._log('Callback for #%s already called. Updated values: %s', id, JSON.stringify(err || info));
                        return;
                    }

                    called = true;
                    returned++;

                    if (err) {
                        combinedInfo.errors.push(err);
                        if (err.recipients) {
                            combinedInfo.rejected = combinedInfo.rejected.concat(err.recipients || []);
                        }
                    } else if (info) {
                        combinedInfo.accepted = combinedInfo.accepted.concat(info.accepted || []);
                        combinedInfo.rejected = combinedInfo.rejected.concat(info.rejected || []);
                        combinedInfo.pending = combinedInfo.pending.concat(info.pending || []);
                        combinedInfo.messageId = info.messageId;
                    }

                    if (returned >= domains.length) {
                        if (combinedInfo.errors.length === domains.length) {
                            var error = new Error('Sending failed');
                            error.errors = combinedInfo.errors;
                            callback(error);
                        } else {
                            callback(null, combinedInfo);
                        }
                    }
                }.bind(this)
            };

            this._queue.insert(item);
        }).bind(this));

        // start send loop if needed
        if (!this._started) {
            this._started = true;

            // do not start the loop before current execution context is finished
            setImmediate(this._loop.bind(this));
        }

    }.bind(this));
};

/**
 * Emit a log event
 */
DirectMailer.prototype._log = function( /*str, arg1, arg2, ..., argN*/ ) {
    if (!this._options.debug) {
        return;
    }
    var args = Array.prototype.slice.call(arguments);
    this.emit('log', {
        type: 'direct',
        message: util.format.apply(util, args)
    });
};

/**
 * Looping function to fetch a message from the queue and send it.
 */
DirectMailer.prototype._loop = function() {

    // callback is fired when a message is added to the queue
    this._queue.get((function(data) {

        this._log('Retrieved message #%s from the queue, resolving %s', data.id, data.domain);

        // Resolve destination MX server
        this._resolveMx(data.domain, (function(err, list) {

            if (err) {
                this._log('Resolving %s for #%s failed', data.domain, data.id);
                this._log(err);
            } else if (!list || !list.length) {
                this._log('Could not resolve any MX servers for %s', data.domain);
            }
            if (err || !list || !list.length) {
                data.callback(err || new Error('Could not resolve MX for ' + data.domain));
                return setImmediate(this._loop.bind(this));
            }

            // Sort MX list by priority field
            list.sort(function(a, b) {
                return (a && a.priority || 0) - (b && b.priority || 0);
            });

            // Use the first server on the list
            var exchange = list[0] && list[0].exchange;

            this._log('%s resolved to %s for #%s', data.domain, exchange, data.id);

            // Try to send the message
            this._process(exchange, data, (function(err, response) {
                if (err) {
                    this._log('Failed processing message #%s', data.id);
                } else {
                    this._log('Server responded for #%s: %s', data.id, JSON.stringify(response));
                }

                if (err) {
                    if (err.responseCode && err.responseCode >= 500) {
                        err.domain = data.domain;
                        err.exchange = exchange;
                        err.recipients = data.envelope.to;
                        data.callback(err);
                    } else {
                        data.replies = (data.replies || 0) + 1;
                        if (data.replies <= 5) {
                            this._queue.insert(data, this._options.retryDelay || data.replies * 15 * 60 * 1000);
                            this._log('Message #%s requeued', data.id);
                            data.callback(null, {
                                pending: {
                                    domain: data.domain,
                                    exchange: exchange,
                                    recipients: data.envelope.to,
                                    response: err.response
                                }
                            });
                        } else {
                            err.domain = data.domain;
                            err.exchange = exchange;
                            err.recipients = data.envelope.to;
                            data.callback(err);
                        }
                    }
                } else {
                    data.callback(null, response);
                }

                setImmediate(this._loop.bind(this));
            }).bind(this));
        }).bind(this));
    }).bind(this));
};

/**
 * Sends a message to provided MX server
 *
 * @param {String} exchange MX server
 * @param {Object} data Message object
 * @param {Function} callback Callback to run once the message is either sent or sending fails
 */
DirectMailer.prototype._process = function(exchange, data, callback) {
    this._log('Connecting to %s:%s for message #%s', exchange, this._options.port || 25, data.id);

    var options = {
        host: exchange,
        port: this._options.port || 25,
        ignoreTLS: true
    };

    // Add options from DirectMailer options to simplesmtp client
    Object.keys(this._options).forEach((function(key) {
        options[key] = this._options[key];
    }).bind(this));

    var connection = new SMTPConnection(options);
    var returned = false;

    connection.on('log', function(log) {
        this.emit('log', log);
    }.bind(this));

    connection.once('error', function(err) {
        if (returned) {
            return;
        }
        returned = true;
        return callback(err);
    });

    var sendMessage = function() {
        connection.send(data.envelope, data.message.createReadStream(), function(err, info) {
            if (returned) {
                return;
            }
            returned = true;

            connection.close();
            if (err) {
                return callback(err);
            }

            info.messageId = (data.message.getHeader('message-id') || '').replace(/[<>\s]/g, '');
            return callback(null, info);
        });
    };

    connection.connect(function() {
        if (returned) {
            return;
        }
        sendMessage();
    }.bind(this));
};

/**
 * Adds additional headers to the outgoing message
 *
 * @param {Object} message BuildMail message object
 */
DirectMailer.prototype._formatMessage = function(message) {
    var hostname = this._resolveHostname(this._options.name);

    // set the first header as 'Received:'
    message._headers.unshift({
        key: 'Received',
        value: 'from localhost (127.0.0.1) by ' + hostname + ' with SMTP; ' + Date()
    });
};

/**
 * Detects stream objects and resolves these to buffers before sending. File paths,
 * urls etc. are not affected.
 *
 * @param {Object} message BuildMail message object
 * @param {Function} callback Callback to run
 */
DirectMailer.prototype._clearStreams = function(mail, callback) {
    var streamNodes = [];

    function walkNode(node) {
        if (node.content && typeof node.content.pipe === 'function') {
            streamNodes.push(node);
        }
        if (node.childNodes && node.childNodes.length) {
            node.childNodes.forEach(walkNode);
        }
    }
    walkNode(mail.message);

    function resolveNodes() {
        if (!streamNodes.length) {
            return callback();
        }
        var node = streamNodes.shift();

        mail.resolveContent(node, 'content', function(err) {
            if (err) {
                node.content = new Buffer('<' + err.message + '>');
            }
            setImmediate(resolveNodes);
        });
    }

    resolveNodes();
};

/**
 * Resolves MX server for a domain
 *
 * @param {String} domain Domain to resolve the MX to
 * @param {Function} callback Callback function to run
 */
DirectMailer.prototype._resolveMx = function(domain, callback) {
    domain = domain.replace(/[\[\]]/g, '');

    // Do not try to resolve the domain name if it is an IP address
    if (net.isIP(domain)) {
        return callback(null, [{
            'priority': 0,
            'exchange': domain
        }]);
    }

    dns.resolveMx(domain, function(err, list) {
        if (err) {
            if (err.code === 'ENODATA') {
                // fallback to A
                dns.resolve4(domain, function(err, list) {
                    if (err) {
                        if (err.code === 'ENODATA') {
                            // fallback to AAAA
                            dns.resolve6(domain, function(err, list) {
                                if (err) {
                                    return callback(err);
                                }

                                // return the first resolved Ipv6 with priority 0
                                return callback(null, [].concat(list || []).map(function(entry) {
                                    return {
                                        'priority': 0,
                                        'exchange': entry
                                    };
                                }).slice(0, 1));
                            });
                        } else {
                            callback(err);
                        }
                        return;
                    }

                    // return the first resolved Ipv4 with priority 0
                    return callback(null, [].concat(list || []).map(function(entry) {
                        return {
                            'priority': 0,
                            'exchange': entry
                        };
                    }).slice(0, 1));
                });
            } else {
                callback(err);
            }
            return;
        }
        callback(null, list);
    });
};

/**
 * Resolves current hostname. If resolved name is an IP address, uses 'localhost'.
 *
 * @param {String} [name] Preferred hostname
 * @return {String} Resolved hostname
 */
DirectMailer.prototype._resolveHostname = function(name) {
    if (!name || net.isIP(name.replace(/[\[\]]/g, '').trim())) {
        name = (os.hostname && os.hostname()) || '';
    }

    if (!name || net.isIP(name.replace(/[\[\]]/g, '').trim())) {
        name = 'localhost';
    }

    return name.toLowerCase();
};
