'use strict';

const EventEmitter = require('events');
const SMTPConnection = require('../smtp-connection');
const wellKnown = require('../well-known');
const shared = require('../shared');
const XOAuth2 = require('../xoauth2');
const errors = require('../errors');
const packageData = require('../../package.json');

/**
 * Creates a SMTP transport object for Nodemailer
 *
 * @constructor
 * @param {Object} options Connection options
 */
class SMTPTransport extends EventEmitter {
    constructor(options) {
        super();

        options = options || {};

        if (typeof options === 'string') {
            options = {
                url: options
            };
        }

        let urlData;
        let service = options.service;

        if (typeof options.getSocket === 'function') {
            this.getSocket = options.getSocket;
        }

        if (options.url) {
            urlData = shared.parseConnectionUrl(options.url);
            service = service || urlData.service;
        }

        this.options = shared.assign(
            false, // create new object
            options, // regular options
            urlData, // url options
            service && wellKnown(service) // wellknown options
        );

        this.logger = shared.getLogger(this.options, {
            component: this.options.component || 'smtp-transport'
        });

        this.name = 'SMTP';
        this.version = packageData.version + '[client:' + packageData.version + ']';

        if (this.options.auth) {
            this.auth = this.getAuth({});
        }
    }

    /**
     * Placeholder function for creating proxy sockets. This method immediatelly returns
     * without a socket
     *
     * @param {Object} options Connection options
     * @param {Function} callback Callback function to run with the socket keys
     */
    getSocket(options, callback) {
        // return immediatelly
        return setImmediate(() => callback(null, false));
    }

    getAuth(authOpts) {
        if (!authOpts) {
            if (this.auth && this.auth.oauth2 && this.mailer) {
                // Transport-level auth is resolved in the constructor, before the Mail wrapper
                // assigns `this.mailer`, so a provision callback registered with
                // `transporter.set('oauth2_provision_cb', ...)` has to be re-checked here
                this.auth.oauth2.provisionCallback = this.mailer.get('oauth2_provision_cb') || this.auth.oauth2.provisionCallback;
            }
            return this.auth;
        }

        const authData = Object.assign(
            {},
            this.options.auth && typeof this.options.auth === 'object' ? this.options.auth : {},
            typeof authOpts === 'object' ? authOpts : {}
        );

        if (Object.keys(authData).length === 0) {
            return false;
        }

        switch ((authData.type || '').toString().toUpperCase()) {
            case 'OAUTH2': {
                if (!authData.service && !authData.user) {
                    return false;
                }
                const oauth2 = new XOAuth2(authData, this.logger);
                oauth2.provisionCallback = (this.mailer && this.mailer.get('oauth2_provision_cb')) || oauth2.provisionCallback;
                oauth2.on('token', token => this.mailer.emit('token', token));
                oauth2.on('error', err => this.emit('error', err));
                return {
                    type: 'OAUTH2',
                    user: authData.user,
                    oauth2,
                    method: 'XOAUTH2'
                };
            }
            default:
                return {
                    type: (authData.type || '').toString().toUpperCase() || 'LOGIN',
                    user: authData.user,
                    credentials: {
                        user: authData.user || '',
                        pass: authData.pass,
                        options: authData.options
                    },
                    method: (authData.method || '').trim().toUpperCase() || this.options.authMethod || false
                };
        }
    }

    /**
     * Sends an e-mail using the selected settings
     *
     * @param {Object} mail Mail object
     * @param {Function} callback Callback function
     */
    send(mail, callback) {
        this.getSocket(this.options, (err, socketOptions) => {
            if (err) {
                return callback(err);
            }

            let returned = false;
            let options = this.options;
            if (socketOptions && socketOptions.connection) {
                this.logger.info(
                    {
                        tnx: 'proxy',
                        remoteAddress: socketOptions.connection.remoteAddress,
                        remotePort: socketOptions.connection.remotePort,
                        destHost: options.host || '',
                        destPort: options.port || '',
                        action: 'connected'
                    },
                    'Using proxied socket from %s:%s to %s:%s',
                    socketOptions.connection.remoteAddress,
                    socketOptions.connection.remotePort,
                    options.host || '',
                    options.port || ''
                );

                // only copy options if we need to modify it
                options = Object.assign(shared.assign(false, options), socketOptions);
            }

            const connection = new SMTPConnection(options);

            let perCallAuth;
            const cleanupPerCallAuth = () => {
                if (perCallAuth && perCallAuth !== this.auth && perCallAuth.oauth2) {
                    perCallAuth.oauth2.removeAllListeners();
                }
                perCallAuth = null;
            };

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

            connection.once('end', () => {
                if (returned) {
                    return;
                }

                const timer = setTimeout(() => {
                    if (returned) {
                        return;
                    }
                    returned = true;
                    cleanupPerCallAuth();
                    // still have not returned, this means we have an unexpected connection close
                    const err = new Error('Unexpected socket close');
                    if (connection && connection._socket && connection._socket.upgrading) {
                        // starttls connection errors
                        err.code = errors.ETLS;
                    }
                    callback(err);
                }, 1000);

                try {
                    timer.unref();
                } catch (_E) {
                    // Ignore. Happens on envs with non-node timer implementation
                }
            });

            const sendMessage = () => {
                const envelope = mail.message.getEnvelope();
                const messageId = mail.message.messageId();

                const recipients = [].concat(envelope.to || []);
                if (recipients.length > 3) {
                    recipients.push('...and ' + recipients.splice(2).length + ' more');
                }

                if (mail.data.dsn) {
                    envelope.dsn = mail.data.dsn;
                }

                // RFC 8689: Pass requireTLSExtensionEnabled to envelope for MAIL FROM parameter
                if (mail.data.requireTLSExtensionEnabled) {
                    envelope.requireTLSExtensionEnabled = mail.data.requireTLSExtensionEnabled;
                }

                this.logger.info(
                    {
                        tnx: 'send',
                        messageId
                    },
                    'Sending message %s to <%s>',
                    messageId,
                    recipients.join(', ')
                );

                connection.send(envelope, mail.message.createReadStream(), (err, info) => {
                    returned = true;
                    cleanupPerCallAuth();
                    connection.close();
                    if (err) {
                        this.logger.error(
                            {
                                err,
                                tnx: 'send'
                            },
                            'Send error for %s: %s',
                            messageId,
                            err.message
                        );
                        return callback(err);
                    }
                    info.envelope = {
                        from: envelope.from,
                        to: envelope.to
                    };
                    info.messageId = messageId;
                    try {
                        return callback(null, info);
                    } catch (E) {
                        this.logger.error(
                            {
                                err: E,
                                tnx: 'callback'
                            },
                            'Callback error for %s: %s',
                            messageId,
                            E.message
                        );
                    }
                });
            };

            connection.connect(() => {
                if (returned) {
                    return;
                }

                perCallAuth = this.getAuth(mail.data.auth);

                if (perCallAuth && (connection.allowsAuth || options.forceAuth)) {
                    connection.login(perCallAuth, err => {
                        cleanupPerCallAuth();
                        if (returned) {
                            return;
                        }

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

                        sendMessage();
                    });
                } else {
                    sendMessage();
                }
            });
        });
    }

    /**
     * Verifies SMTP configuration
     *
     * @param {Function} callback Callback function
     */
    verify(callback) {
        let promise;

        if (!callback) {
            promise = new Promise((resolve, reject) => {
                callback = shared.callbackPromise(resolve, reject);
            });
        }

        this.getSocket(this.options, (err, socketOptions) => {
            if (err) {
                return callback(err);
            }

            let options = this.options;
            if (socketOptions && socketOptions.connection) {
                this.logger.info(
                    {
                        tnx: 'proxy',
                        remoteAddress: socketOptions.connection.remoteAddress,
                        remotePort: socketOptions.connection.remotePort,
                        destHost: options.host || '',
                        destPort: options.port || '',
                        action: 'connected'
                    },
                    'Using proxied socket from %s:%s to %s:%s',
                    socketOptions.connection.remoteAddress,
                    socketOptions.connection.remotePort,
                    options.host || '',
                    options.port || ''
                );

                options = Object.assign(shared.assign(false, options), socketOptions);
            }

            const connection = new SMTPConnection(options);
            let returned = false;
            let perCallAuth;
            const cleanupPerCallAuth = () => {
                if (perCallAuth && perCallAuth !== this.auth && perCallAuth.oauth2) {
                    perCallAuth.oauth2.removeAllListeners();
                }
                perCallAuth = null;
            };

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

            connection.once('end', () => {
                if (returned) {
                    return;
                }
                returned = true;
                cleanupPerCallAuth();
                return callback(new Error('Connection closed'));
            });

            const finalize = () => {
                if (returned) {
                    return;
                }
                returned = true;
                cleanupPerCallAuth();
                connection.quit();
                return callback(null, true);
            };

            connection.connect(() => {
                if (returned) {
                    return;
                }

                perCallAuth = this.getAuth({});

                if (perCallAuth && (connection.allowsAuth || options.forceAuth)) {
                    connection.login(perCallAuth, err => {
                        cleanupPerCallAuth();
                        if (returned) {
                            return;
                        }

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

                        finalize();
                    });
                } else if (!perCallAuth && connection.allowsAuth && options.forceAuth) {
                    const err = new Error('Authentication info was not provided');
                    err.code = errors.ENOAUTH;

                    returned = true;
                    cleanupPerCallAuth();
                    connection.close();
                    return callback(err);
                } else {
                    finalize();
                }
            });
        });

        return promise;
    }

    /**
     * Releases resources
     */
    close() {
        if (this.auth && this.auth.oauth2) {
            this.auth.oauth2.removeAllListeners();
        }
        this.emit('close');
    }
}

// expose to the world
module.exports = SMTPTransport;
