'use strict';

exports = module.exports = {
    CertificatesError: CertificatesError,

    ensureFallbackCertificate: ensureFallbackCertificate,
    setFallbackCertificate: setFallbackCertificate,

    validateCertificate: validateCertificate,
    ensureCertificate: ensureCertificate,

    setAdminCertificate: setAdminCertificate,
    getAdminCertificate: getAdminCertificate,

    renewAll: renewAll,

    initialize: initialize,
    uninitialize: uninitialize,

    events: null,

    EVENT_CERT_CHANGED: 'cert_changed',

    // exported for testing
    _getApi: getApi
};

var acme = require('./cert/acme.js'),
    apps = require('./apps.js'),
    assert = require('assert'),
    async = require('async'),
    caas = require('./cert/caas.js'),
    config = require('./config.js'),
    constants = require('./constants.js'),
    debug = require('debug')('box:src/certificates'),
    eventlog = require('./eventlog.js'),
    fallback = require('./cert/fallback.js'),
    fs = require('fs'),
    mailer = require('./mailer.js'),
    nginx = require('./nginx.js'),
    path = require('path'),
    paths = require('./paths.js'),
    safe = require('safetydance'),
    settings = require('./settings.js'),
    user = require('./user.js'),
    util = require('util');

function CertificatesError(reason, errorOrMessage) {
    assert.strictEqual(typeof reason, 'string');
    assert(errorOrMessage instanceof Error || typeof errorOrMessage === 'string' || typeof errorOrMessage === 'undefined');

    Error.call(this);
    Error.captureStackTrace(this, this.constructor);

    this.name = this.constructor.name;
    this.reason = reason;
    if (typeof errorOrMessage === 'undefined') {
        this.message = reason;
    } else if (typeof errorOrMessage === 'string') {
        this.message = errorOrMessage;
    } else {
        this.message = 'Internal error';
        this.nestedError = errorOrMessage;
    }
}
util.inherits(CertificatesError, Error);
CertificatesError.INTERNAL_ERROR = 'Internal Error';
CertificatesError.INVALID_CERT = 'Invalid certificate';
CertificatesError.NOT_FOUND = 'Not Found';

function initialize(callback) {
    assert.strictEqual(typeof callback, 'function');

    exports.events = new (require('events').EventEmitter)();
    callback();
}

function uninitialize(callback) {
    assert.strictEqual(typeof callback, 'function');

    exports.events = null;
    callback();
}

function getApi(app, callback) {
    assert.strictEqual(typeof app, 'object');
    assert.strictEqual(typeof callback, 'function');

    settings.getTlsConfig(function (error, tlsConfig) {
        if (error) return callback(error);

        if (tlsConfig.provider === 'fallback') return callback(null, fallback, {});

        // use acme if we have altDomain or the tlsConfig is not caas
        var api = (app.altDomain || tlsConfig.provider) !== 'caas' ? acme : caas;

        var options = { };
        if (tlsConfig.provider === 'caas') {
            options.prod = !config.isDev(); // with altDomain, we will choose acme setting based on this
        } else { // acme
            options.prod = tlsConfig.provider.match(/.*-prod/) !== null;
        }

        // registering user with an email requires A or MX record (https://github.com/letsencrypt/boulder/issues/1197)
        // we cannot use admin@fqdn because the user might not have set it up.
        // we simply update the account with the latest email we have each time when getting letsencrypt certs
        // https://github.com/ietf-wg-acme/acme/issues/30
        user.getOwner(function (error, owner) {
            options.email = error ? 'support@cloudron.io' : (owner.alternateEmail || owner.email); // can error if not activated yet

            callback(null, api, options);
        });
    });
}

function ensureFallbackCertificate(callback) {
    // ensure a fallback certificate that much of our code requires
    var certFilePath = path.join(paths.APP_CERTS_DIR, 'host.cert');
    var keyFilePath = path.join(paths.APP_CERTS_DIR, 'host.key');

    var fallbackCertPath = path.join(paths.NGINX_CERT_DIR, 'host.cert');
    var fallbackKeyPath = path.join(paths.NGINX_CERT_DIR, 'host.key');

    if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) { // existing custom fallback certs (when restarting, restoring, updating)
        debug('ensureFallbackCertificate: using fallback certs provided by user');
        if (!safe.child_process.execSync('cp ' + certFilePath + ' ' + fallbackCertPath)) return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, safe.error.message));
        if (!safe.child_process.execSync('cp ' + keyFilePath + ' ' + fallbackKeyPath)) return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, safe.error.message));

        return callback();
    }

    if (config.tlsCert() && config.tlsKey()) {
        // cert from CaaS or cloudron-setup. these files should _not_ be part of the backup
        debug('ensureFallbackCertificate: using CaaS/cloudron-setup fallback certs');
        if (!safe.fs.writeFileSync(fallbackCertPath, config.tlsCert())) return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, safe.error.message));
        if (!safe.fs.writeFileSync(fallbackKeyPath, config.tlsKey())) return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, safe.error.message));

        return callback();
    }

    // generate a self-signed cert. it's in backup dir so that we don't create a new cert across restarts
    // FIXME: this cert does not cover the naked domain. needs SAN
    if (config.fqdn()) {
        debug('ensureFallbackCertificate: generating self-signed certificate');
        var certCommand = util.format('openssl req -x509 -newkey rsa:2048 -keyout %s -out %s -days 3650 -subj /CN=*.%s -nodes', keyFilePath, certFilePath, config.fqdn());
        safe.child_process.execSync(certCommand);

        if (!safe.child_process.execSync('cp ' + certFilePath + ' ' + fallbackCertPath)) return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, safe.error.message));
        if (!safe.child_process.execSync('cp ' + keyFilePath + ' ' + fallbackKeyPath)) return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, safe.error.message));

        return callback();
    } else {
        debug('ensureFallbackCertificate: cannot generate fallback certificate without domain');
        return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, 'No domain set'));
    }
}

function isExpiringSync(certFilePath, hours) {
    assert.strictEqual(typeof certFilePath, 'string');
    assert.strictEqual(typeof hours, 'number');

    if (!fs.existsSync(certFilePath)) return 2; // not found

    var result = safe.child_process.spawnSync('/usr/bin/openssl', [ 'x509', '-checkend', String(60 * 60 * hours), '-in', certFilePath ]);

    debug('isExpiringSync: %s %s %s', certFilePath, result.stdout.toString('utf8').trim(), result.status);

    return result.status === 1; // 1 - expired 0 - not expired
}

function renewAll(auditSource, callback) {
    assert.strictEqual(typeof auditSource, 'object');
    assert.strictEqual(typeof callback, 'function');

    debug('renewAll: Checking certificates for renewal');

    apps.getAll(function (error, allApps) {
        if (error) return callback(error);

        allApps.push({ location: constants.ADMIN_LOCATION }); // inject fake webadmin app

        var expiringApps = [ ];
        for (var i = 0; i < allApps.length; i++) {
            var appDomain = allApps[i].altDomain || config.appFqdn(allApps[i].location);

            var certFilePath = path.join(paths.APP_CERTS_DIR, appDomain + '.user.cert');
            var keyFilePath = path.join(paths.APP_CERTS_DIR, appDomain + '.user.key');

            if (safe.fs.existsSync(certFilePath) && safe.fs.existsSync(keyFilePath)) {
                debug('renewAll: existing user key file for %s. skipping', appDomain);
                continue;
            }

            // check if we have an auto cert to be renewed
            certFilePath = path.join(paths.APP_CERTS_DIR, appDomain + '.cert');
            keyFilePath = path.join(paths.APP_CERTS_DIR, appDomain + '.key');

            if (!safe.fs.existsSync(keyFilePath)) {
                debug('renewAll: no existing key file for %s. skipping', appDomain);
                continue;
            }

            if (isExpiringSync(certFilePath, 24 * 30)) { // expired or not found
                expiringApps.push(allApps[i]);
            }
        }

        debug('renewAll: %j needs to be renewed', expiringApps.map(function (a) { return a.altDomain || config.appFqdn(a.location); }));

        async.eachSeries(expiringApps, function iterator(app, iteratorCallback) {
            var domain = app.altDomain || config.appFqdn(app.location);

            getApi(app, function (error, api, apiOptions) {
                if (error) return callback(error);

                debug('renewAll: renewing cert for %s with options %j', domain, apiOptions);

                api.getCertificate(domain, apiOptions, function (error) {
                    var certFilePath = path.join(paths.APP_CERTS_DIR, domain + '.cert');
                    var keyFilePath = path.join(paths.APP_CERTS_DIR, domain + '.key');

                    var errorMessage = error ? error.message : '';
                    eventlog.add(eventlog.ACTION_CERTIFICATE_RENEWAL, auditSource, { domain: domain, errorMessage: errorMessage });

                    if (error) {
                        debug('renewAll: could not renew cert for %s because %s', domain, error);

                        mailer.certificateRenewalError(domain, errorMessage);

                        // check if we should fallback if we expire in the coming day
                        if (!isExpiringSync(certFilePath, 24 * 1)) return iteratorCallback();

                        debug('renewAll: using fallback certs for %s since it expires soon', domain, error);

                        certFilePath = 'cert/host.cert';
                        keyFilePath = 'cert/host.key';
                    } else {
                        debug('renewAll: certificate for %s renewed', domain);
                    }

                    // reconfigure and reload nginx. this is required for the case where we got a renewed cert after fallback
                    var configureFunc = app.location === constants.ADMIN_LOCATION ?
                        nginx.configureAdmin.bind(null, certFilePath, keyFilePath, constants.NGINX_ADMIN_CONFIG_FILE_NAME, config.adminFqdn())
                        : nginx.configureApp.bind(null, app, certFilePath, keyFilePath);

                    configureFunc(function (ignoredError) {
                        if (ignoredError) debug('fallbackExpiredCertificates: error reconfiguring app', ignoredError);

                        exports.events.emit(exports.EVENT_CERT_CHANGED, domain);

                        iteratorCallback(); // move to next app
                    });
                });
            });
        });
    });
}

// note: https://tools.ietf.org/html/rfc4346#section-7.4.2 (certificate_list) requires that the
// servers certificate appears first (and not the intermediate cert)
function validateCertificate(cert, key, fqdn) {
    assert(cert === null || typeof cert === 'string');
    assert(key === null || typeof key === 'string');
    assert.strictEqual(typeof fqdn, 'string');

    if (cert === null && key === null) return null;
    if (!cert && key) return new Error('missing cert');
    if (cert && !key) return new Error('missing key');

    function matchesDomain(domain) {
        if (typeof domain !== 'string') return false;
        if (domain === fqdn) return true;
        if (domain.indexOf('*') === 0 && domain.slice(2) === fqdn.slice(fqdn.indexOf('.') + 1)) return true;

        return false;
    }

    // get commonName (http://stackoverflow.com/questions/17353122/parsing-strings-crt-files)
    var result = safe.child_process.execSync('openssl x509 -noout -subject | sed -r "s|.*CN=(.*)|\\1|; s|/[^/]*=.*$||"', { encoding: 'utf8', input: cert });
    if (!result) return new Error(util.format('could not get CN'));
    var commonName = result.trim();
    debug('validateCertificate: detected commonName as %s', commonName);

    // https://github.com/drwetter/testssl.sh/pull/383
    var cmd = `openssl x509 -noout -text | grep -A3 "Subject Alternative Name" | \
               grep "DNS:" | \
               sed -e "s/DNS://g" -e "s/ //g" -e "s/,/ /g" -e "s/othername:<unsupported>//g"`;
    result = safe.child_process.execSync(cmd, { encoding: 'utf8', input: cert });
    var altNames = result ? [ ] : result.trim().split(' '); // might fail if cert has no SAN
    debug('validateCertificate: detected altNames as %j', altNames);

    // check altNames
    var domains = altNames.concat(commonName);
    if (!domains.some(matchesDomain)) return new Error(util.format('cert is not valid for this domain. Expecting %s in %j', fqdn, domains));

    // http://httpd.apache.org/docs/2.0/ssl/ssl_faq.html#verify
    var certModulus = safe.child_process.execSync('openssl x509 -noout -modulus', { encoding: 'utf8', input: cert });
    var keyModulus = safe.child_process.execSync('openssl rsa -noout -modulus', { encoding: 'utf8', input: key });
    if (certModulus !== keyModulus) return new Error('key does not match the cert');

   // check expiration
    result = safe.child_process.execSync('openssl x509 -checkend 0', { encoding: 'utf8', input: cert });
    if (!result) return new Error('cert expired');

    return null;
}

function setFallbackCertificate(cert, key, callback) {
    assert.strictEqual(typeof cert, 'string');
    assert.strictEqual(typeof key, 'string');
    assert.strictEqual(typeof callback, 'function');

    var error = validateCertificate(cert, key, '*.' + config.fqdn());
    if (error) return callback(new CertificatesError(CertificatesError.INVALID_CERT, error.message));

    // backup the cert
    if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, 'host.cert'), cert)) return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, safe.error.message));
    if (!safe.fs.writeFileSync(path.join(paths.APP_CERTS_DIR, 'host.key'), key)) return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, safe.error.message));

    // copy over fallback cert
    if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, 'host.cert'), cert)) return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, safe.error.message));
    if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, 'host.key'), key)) return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, safe.error.message));

    exports.events.emit(exports.EVENT_CERT_CHANGED, '*.' + config.fqdn());

    nginx.reload(function (error) {
        if (error) return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, error));

        return callback(null);
    });
}

function getFallbackCertificatePath(callback) {
    assert.strictEqual(typeof callback, 'function');

    // any user fallback cert is always copied over to nginx cert dir
    callback(null, path.join(paths.NGINX_CERT_DIR, 'host.cert'), path.join(paths.NGINX_CERT_DIR, 'host.key'));
}

function setAdminCertificate(cert, key, callback) {
    assert.strictEqual(typeof cert, 'string');
    assert.strictEqual(typeof key, 'string');
    assert.strictEqual(typeof callback, 'function');

    var vhost = config.adminFqdn();
    var certFilePath = path.join(paths.APP_CERTS_DIR, vhost + '.user.cert');
    var keyFilePath = path.join(paths.APP_CERTS_DIR, vhost + '.user.key');

    var error = validateCertificate(cert, key, vhost);
    if (error) return callback(new CertificatesError(CertificatesError.INVALID_CERT, error.message));

    // backup the cert
    if (!safe.fs.writeFileSync(certFilePath, cert)) return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, safe.error.message));
    if (!safe.fs.writeFileSync(keyFilePath, key)) return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, safe.error.message));

    exports.events.emit(exports.EVENT_CERT_CHANGED, vhost);

    nginx.configureAdmin(certFilePath, keyFilePath, constants.NGINX_ADMIN_CONFIG_FILE_NAME, config.adminFqdn(), callback);
}

function getAdminCertificatePath(callback) {
    assert.strictEqual(typeof callback, 'function');

    var vhost = config.adminFqdn();
    var certFilePath = path.join(paths.APP_CERTS_DIR, vhost + '.user.cert');
    var keyFilePath = path.join(paths.APP_CERTS_DIR, vhost + '.user.key');

    if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) return callback(null, certFilePath, keyFilePath);

    certFilePath = path.join(paths.APP_CERTS_DIR, vhost + '.cert');
    keyFilePath = path.join(paths.APP_CERTS_DIR, vhost + '.key');

    if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) return callback(null, certFilePath, keyFilePath);

    getFallbackCertificatePath(callback);
}

function getAdminCertificate(callback) {
    assert.strictEqual(typeof callback, 'function');

    getAdminCertificatePath(function (error, certFilePath, keyFilePath) {
        if (error) return callback(error);

        var cert = safe.fs.readFileSync(certFilePath);
        if (!cert) return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, safe.error));

        var key = safe.fs.readFileSync(keyFilePath);
        if (!cert) return callback(new CertificatesError(CertificatesError.INTERNAL_ERROR, safe.error));

        return callback(null, cert, key);
    });
}

function ensureCertificate(app, callback) {
    assert.strictEqual(typeof app, 'object');
    assert.strictEqual(typeof callback, 'function');

    var domain = app.altDomain || config.appFqdn(app.location);

    var certFilePath = path.join(paths.APP_CERTS_DIR, domain + '.user.cert');
    var keyFilePath = path.join(paths.APP_CERTS_DIR, domain + '.user.key');

    if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) {
        debug('ensureCertificate: %s. user certificate already exists at %s', domain, keyFilePath);
        return callback(null, certFilePath, keyFilePath);
    }

    certFilePath = path.join(paths.APP_CERTS_DIR, domain + '.cert');
    keyFilePath = path.join(paths.APP_CERTS_DIR, domain + '.key');

    if (fs.existsSync(certFilePath) && fs.existsSync(keyFilePath)) {
        debug('ensureCertificate: %s. certificate already exists at %s', domain, keyFilePath);

        if (!isExpiringSync(certFilePath, 24 * 1)) return callback(null, certFilePath, keyFilePath);
        debug('ensureCertificate: %s cert require renewal', domain);
    } else {
        debug('ensureCertificate: %s cert does not exist', domain);
    }

    getApi(app, function (error, api, apiOptions) {
        if (error) return callback(error);

        debug('ensureCertificate: getting certificate for %s with options %j', domain, apiOptions);

        api.getCertificate(domain, apiOptions, function (error, certFilePath, keyFilePath) {
            if (error) {
                debug('ensureCertificate: could not get certificate. using fallback certs', error);
                return callback(null, 'cert/host.cert', 'cert/host.key'); // use fallback certs
            }

            callback(null, certFilePath, keyFilePath);
        });
    });
}
