/*!
 * Copyright 2014 Apereo Foundation (AF) Licensed under the
 * Educational Community License, Version 2.0 (the "License"); you may
 * not use this file except in compliance with the License. You may
 * obtain a copy of the License at
 *
 *     http://opensource.org/licenses/ECL-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an "AS IS"
 * BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
 * or implied. See the License for the specific language governing
 * permissions and limitations under the License.
 */

var _ = require('underscore');
var util = require('util');

var Cassandra = require('oae-util/lib/cassandra');
var EmitterAPI = require('oae-emitter');
var log = require('oae-logger').logger('oae-tenants');
var OAE = require('oae-util/lib/oae');
var Pubsub = require('oae-util/lib/pubsub');
var Validator = require('oae-util/lib/validator').Validator;

var Tenant = require('./model').Tenant;
var TenantNetworksDAO = require('./internal/dao.networks');

// Variable that caches the server configuration as specified in the config.js file
var serverConfig = null;

// Variable that caches the available tenants, keyed by their alias
var tenants = {};
// Variable that caches the available tenants, keyed by their hostname. This will be used for
// quick look-ups when checking whether or not a hostname is associated to a tenant
var tenantsByHost = {};

// Variable that caches the global administration tenant object
var globalTenant = null;

/**
 * ### Events
 *
 * The `TenantsAPI` emits the following events:
 *
 * * `cached`: The tenants have been cached
 * * `created(tenant)`: A tenant has been created
 * * `preCache`: An operation triggered an update of the cache. The `cached` event will be emitted once the tenants have been re-cached
 * * `refresh(tenant)`: A request has been received to "refresh" the metadata of a tenant
 * * `start(tenant)`: A request has been received to "start" a tenant
 * * `stop(tenant)`: A request has been received to "stop" a tenant
 */
var TenantsAPI = module.exports = new EmitterAPI.EventEmitter();

/*!
 * Listen for cluster wide requests involving tenants
 *
 * @param  {String}  message    A brief command in the form of `start cam`, `stop cam` or `refresh cam`
 */
Pubsub.on('oae-tenants', function (message) {
    var args = message.split(' ');
    var cmd = args.shift();
    var alias = args.shift();

    // Re-cache the available tenants
    _cacheTenants(function(err) {
        if (err) {
            return log().error('There was an error caching the tenants');
        }

        TenantsAPI.emit(cmd, getTenant(alias));
    });
});

/**
 * Initialize the middleware that will put the tenant object onto the request and cache all of the registered
 * tenants
 *
 * @param  {Object}         serverConfig        Server configuration object containing global admin tenant information
 * @param  {Function}       callback            Standard callback function
 * @param  {Object}         callback.err        An error that occurred, if any
 */
var init = module.exports.init = function(_serverConfig, callback) {
    // Cache the server configuration
    serverConfig = _serverConfig;

    // This middleware adds the tenant to each request on the global admin server
    OAE.globalAdminServer.use(function(req, res, next) {
        req.tenant = globalTenant;
        return next();
    });

    // This middleware adds the tenant to each request on the user tenant server
    OAE.tenantServer.use(function(req, res, next) {
        if (serverConfig.shibbolethSPHost && req.headers.host === serverConfig.shibbolethSPHost) {
            req.tenant = new Tenant('shib-sp', 'Shibboleth SP hardcoded host', serverConfig.shibbolethSPHost, {
                'active': true,
                'deleted': false
            });
        } else {
            req.tenant = getTenantByHost(req.headers.host);
        }

        // We stop the request if we can't find a tenant associated to the current hostname
        if (!req.tenant) {
            res.setHeader('Connection', 'Close');
            return res.send(418, 'This hostname is not associated to any tenant');
        }

        // Check whether or not the tenant has been disabled
        if (!req.tenant.active) {
            // If the tenant has been stopped, there is no point in keeping connections open
            res.setHeader('Connection', 'Close');
            return res.send(503, 'This server is currently disabled. Please check back later.');
        }

        return next();
    });

    // Cache the available tenants
    _cacheTenants(function(err) {
        if (err) {
            return callback(err);
        }

        TenantNetworksDAO.init();
        return callback();
    });
};

/**
 * Get a list of all available tenants from cache. The global admin tenant will be excluded from the resulting tenant list
 *
 * @param  {Boolean}        [excludeDisabled]   Whether or not disabled tenants should be included. By default, all tenants will be returned
 * @return {Object}                             An object keyed by tenant alias holding all the tenants
 */
var getTenants = module.exports.getTenants = function(excludeDisabled) {
    var filteredTenants = {};
    _.each(tenants, function(tenant, tenantAlias) {
        // Exclude all disabled tenants when `exludeDisabled` has been provided
        if (!tenant.isGlobalAdminServer && (!excludeDisabled || tenant.active)) {
            filteredTenants[tenantAlias] = _copyTenant(tenant);
        }
    });
    return filteredTenants;
};

/**
 * Get a tenant by alias from cache
 *
 * @param  {String}         tenantAlias         Alias for the tenant that should be retrieved
 * @return {Tenant}                             Tenant object associated to the provided tenant alias
 */
var getTenant = module.exports.getTenant = function(tenantAlias) {
    return _copyTenant(tenants[tenantAlias]);
};

/**
 * Get a tenant by host name from cache
 *
 * @param  {String}         tenantHost          Host name for the tenant that should be retrieved
 * @return {Tenant}                             Tenant object associated to the provided tenant host
 */
var getTenantByHost = module.exports.getTenantByHost = function(tenantHost) {
    return _copyTenant(tenantsByHost[tenantHost.toLowerCase()]);
};

/**
 * Fetch the available tenants and cache them
 *
 * @param  {Function}       [callback]          Standard callback function
 * @param  {Object}         [callback.err]      Error object containing the error code and message
 * @api private
 */
var _cacheTenants = function(callback) {
    callback = callback || function(err) {
        if (err) {
            log().error({'err': err}, 'Failed to re-cache the tenants');
        }
    };

    // Get the available tenants
    Cassandra.runQuery('SELECT * FROM "Tenant"', false, function(err, rows) {
        if (err) {
            return callback(err);
        }

        // Reset the previously cached tenants
        tenants = {};
        tenantsByHost = {};

        // Create a dummy tenant object that can serve as the global admin tenant object
        globalTenant = new Tenant(serverConfig.globalAdminAlias, 'Global admin server', serverConfig.globalAdminHost, {'isGlobalAdminServer': true});

        // Cache it as part of the available tenants
        tenants[globalTenant.alias] = globalTenant;
        tenantsByHost[globalTenant.host] = globalTenant;

        _.each(rows, function(row) {
            var tenant = mapToTenant(row);
            // Cache all tenants
            tenants[tenant.alias] = tenant;
            tenantsByHost[tenant.host] = tenant;
        });

        // Indicate that all tenants have been cached
        TenantsAPI.emit('cached');

        return callback(null, tenants);
    });
};

/**
 * Create a new tenant and spin it up on the fly
 *
 * @param  {Context}    ctx                     Standard context object containing the current user and the current tenant
 * @param  {String}     alias                   The unique alias for the tenant
 * @param  {String}     displayName             A descriptive short name for the tenant
 * @param  {String}     host                    The host on which this tenant will be proxying (e.g. oae.cam.ac.uk or oae.gatech.edu)
 * @param  {Function}   callback                Standard callback function
 * @param  {Object}     callback.err            An error that occurred, if any
 * @param  {Tenant}     callback.tenant         The created tenant
 */
var createTenant = module.exports.createTenant = function(ctx, alias, displayName, host, callback) {
    callback = callback || function() {};

    if (!ctx.user() || !ctx.user().isGlobalAdmin()) {
        return callback({'code': 401, 'msg': 'Only global administrators can create new tenants'});
    }

    var validator = new Validator();
    validator.check(alias, {'code': 400, 'msg': 'Missing alias'}).notEmpty();
    validator.check(alias, {'code': 400, 'msg': 'The tenant alias should not contain a space'}).notContains(' ');
    validator.check(alias, {'code': 400, 'msg': 'The tenant alias should not contain a colon'}).notContains(':');
    validator.check(displayName, {'code': 400, 'msg': 'Missing tenant displayName'}).notEmpty();
    validator.check(host, {'code': 400, 'msg': 'Missing tenant host'}).notEmpty();
    if (host) {
        validator.check(host.toLowerCase(), {'code': 400, 'msg': 'This hostname is reserved'}).not(serverConfig.shibbolethSPHost);
    }
    if (validator.hasErrors()) {
        return callback(validator.getFirstError());
    }

    // Make sure the tenant alias and host name are all lower-case
    alias = alias.toLowerCase();
    host = host.toLowerCase();

    // Make sure that a tenant with the same alias doesn't exist yet
    if (!getTenant(alias)) {
        // Make sure that a tenant with the same hostname doesn't exist yet
        if (!getTenantByHost(host)) {
            // Create the tenant
            var tenant = new Tenant(alias, displayName, host);
            Cassandra.runQuery('UPDATE "Tenant" SET "displayName" = ?, "host" = ?, "active" = ? WHERE "alias" = ?',
                [tenant.displayName, host, tenant.active, tenant.alias], function(err) {
                if (err) {
                    return callback(err);
                }

                // This event is not strictly necessary as it will be emitted by our PubSub publisher
                // as well. We emit it before we return to the caller so our unit tests can keep track
                // of config updates BEFORE the REST request completes. If we didn't there would be a
                // short period between the REST request returning and the config re-caching where the
                // config would be out-of-date. The length of this period is determined by how fast Redis
                // can broadcast the pubsub messages. When the system is under load and suffering from IO
                // starvation (such as during unit tests) this could lead to intermittent test failures.
                // The downside of emitting this event here is that it will lead to authentication strategies
                // and config elements being recached twice
                TenantsAPI.emit('created', tenant);

                // Indicate that a caching operation is pending
                TenantsAPI.emit('preCache');

                // Send a message to all the app servers in the cluster notifying them that the tenant should be started
                Pubsub.publish('oae-tenants', 'created ' + tenant.alias, function(err) {
                    if (err) {
                        return callback(err);
                    }

                    return callback(null, tenant);
                });
            });
        } else {
            callback({'code': 400, 'msg': 'A tenant with the host ' + host + ' already exists'});
        }
    } else {
        callback({'code': 400, 'msg': 'A tenant with the alias ' + alias + ' already exists'});
    }
};

/**
 * Update a tenant's metadata
 *
 * @param  {Context}    ctx                             Standard context object containing the current user and the current tenant
 * @param  {String}     alias                           The alias of the tenant to update
 * @param  {Object}     tenantUpdates                   Object where the keys represents the metadata identifiers and the values represent the new metadata values
 * @param  {String}     [tenantUpdates.displayName]     Updated tenant display name
 * @param  {String}     [tenantUpdates.host]            Updated tenant host name
 * @param  {Function}   callback                        Standard callback function
 * @param  {Object}     callback.err                    An error that occurred, if any
 */
var updateTenant = module.exports.updateTenant = function(ctx, alias, tenantUpdates, callback) {
    if (!ctx.user() || !ctx.user().isAdmin(ctx.user().tenant.alias)) {
        return callback({'code': 401, 'msg': 'Unauthorized users cannot update tenants'});
    }

    var validator = new Validator();
    validator.check(alias, {'code': 400, 'msg': 'Missing alias'}).notEmpty();
    validator.check(getTenant(alias), {'code': 404, 'msg': util.format('Tenant with alias "%s" does not exist and cannot be updated', alias)}).notNull();

    // Check that at least either a new display name or hostname have been provided
    var updateFields = tenantUpdates ? _.keys(tenantUpdates) : [];
    validator.check(updateFields.length, {'code': 400, 'msg': 'You should at least specify a new displayName or hostname'}).min(1);
    _.each(tenantUpdates, function(updateValue, updateField) {
        validator.check(updateField, {'code': 400, 'msg': updateField + ' is not a recognized tenant update field'}).isIn(['displayName', 'host']);
        if (updateField === 'displayName') {
            validator.check(updateValue, {'code': 400, 'msg': 'A displayName cannot be empty'}).notEmpty();
        } else if (updateField === 'host') {
            validator.check(updateValue, {'code': 400, 'msg': 'A hostname cannot be empty'}).notEmpty();
            validator.check(getTenantByHost(updateValue), {'code': 400, 'msg': 'The hostname has already been taken'}).isNull();
            validator.check(tenantUpdates.host.toLowerCase(), {'code': 400, 'msg': 'This hostname is reserved'}).not(serverConfig.shibbolethSPHost);
        }
    });
    if (validator.hasErrors()) {
        return callback(validator.getFirstError());
    }

    // Make sure the tenant host name is all lower-case
    if (tenantUpdates.host) {
        tenantUpdates.host = tenantUpdates.host.toLowerCase();
    }

    var q = Cassandra.constructUpsertCQL('Tenant', 'alias', alias, tenantUpdates);
    Cassandra.runQuery(q.query, q.parameters, function(err) {
        if (err) {
            return callback(err);
        }

        // Indicate that a caching operation is pending
        TenantsAPI.emit('preCache');

        // Emit a cluster-wide event to let the app servers re-cache the tenant's metadata
        Pubsub.publish('oae-tenants', 'refresh ' + alias, callback);
    });
};

/**
 * Disable or enable a tenant
 *
 * @param  {Context}      ctx             Standard context object containing the current user and the current tenant
 * @param  {String[]}     aliases         An array of aliases representing the tenants that should be stopped
 * @param  {Boolean}      disabled        True if the tenant needs to be disabled
 * @param  {Function}     [callback]      Callback function executed when request is completed
 * @param  {Object}       callback.err    An error that occurred, if any
 */
var disableTenants = module.exports.disableTenants = function(ctx, aliases, disabled, callback) {
    callback = callback || function() {};
    aliases = (_.isArray(aliases)) ? aliases : [aliases];
    aliases = _.compact(aliases);

    var validator = new Validator();
    validator.check(null, {'code': 401, 'msg': 'You must be a global admin user to enable or disable a tenant'}).isGlobalAdministratorUser(ctx);
    validator.check(aliases.length, {'code': 400, 'msg': 'You must provide at least one alias to enable or disable'}).min(1);
    _.each(aliases, function(alias) {
        validator.check(getTenant(alias), {'code': 404, 'msg': util.format('Tenant with alias "%s" does not exist and cannot be enabled or disabled', alias)}).notNull();
    });
    if (validator.hasErrors()) {
        return callback(validator.getFirstError());
    }

    // Store the "active" flag in cassandra
    var queries = _.map(aliases, function(alias) {
        return {
            'query': 'UPDATE "Tenant" SET "active" = ? WHERE "alias" = ?',
            'parameters': [!disabled, alias]
        };
    });

    Cassandra.runBatchQuery(queries, function(err) {
        if (err) {
            return callback(err);
        }

        // Broadcast the message accross the cluster so we can start/stop the tenants
        var cmd = disabled ? 'stop' : 'start';
        _.each(aliases, function(alias) {
            // Indicate that a caching operation is pending
            TenantsAPI.emit('preCache');

            // Broadcast an event around the cluster to start or stop a tenant
            Pubsub.publish('oae-tenants', cmd + ' ' + alias, callback);
        });
    });
};


//////////////////////////
// TENANT LANDING PAGES //
//////////////////////////

/**
 * Get the configured landing page for the current tenant
 *
 * @param  {Context}        ctx                 Standard context object containing the current user and the current tenant
 * @return {Object[]}                           The configured landing page blocks for the current tenant
 */
var getLandingPage = module.exports.getLandingPage = function(ctx) {
    var landingPage = [];
    for (var i = 1; i <= 12; i++) {
        var blockName = util.format('block_%d', i);

        // Construct the block
        var block = _getLandingPageBlock(ctx, blockName);

        // If the type is not configured, there's no value in returning this block
        if (block.type && block.type !== 'empty') {
            landingPage.push(block);
        }
    }
    return landingPage;
};

/**
 * Get a landing page block
 *
 * @param  {Context}        ctx                 Standard context object containing the current user and the current tenant
 * @param  {String}         blockName           The name of the block. Formatted as `block_i`
 * @return {Object}                             A landing page block
 * @api private
 */
var _getLandingPageBlock = function(ctx, blockName) {
    var block = {};

    // Get the block's information from the tenant config
    _setLandingPageBlockAttribute(ctx, block, blockName, 'type');
    _setLandingPageBlockAttribute(ctx, block, blockName, 'xs');
    _setLandingPageBlockAttribute(ctx, block, blockName, 'sm');
    _setLandingPageBlockAttribute(ctx, block, blockName, 'md');
    _setLandingPageBlockAttribute(ctx, block, blockName, 'lg');
    _setLandingPageBlockAttribute(ctx, block, blockName, 'minHeight');
    _setLandingPageBlockAttribute(ctx, block, blockName, 'horizontalAlign');
    _setLandingPageBlockAttribute(ctx, block, blockName, 'verticalAlign');
    _setLandingPageBlockAttribute(ctx, block, blockName, 'bgColor');
    _setLandingPageBlockAttribute(ctx, block, blockName, 'titleColor');
    _setLandingPageBlockAttribute(ctx, block, blockName, 'textColor');
    _setLandingPageBlockAttribute(ctx, block, blockName, 'text');
    _setLandingPageBlockAttribute(ctx, block, blockName, 'icon');
    _setLandingPageBlockAttribute(ctx, block, blockName, 'imgUrl');
    _setLandingPageBlockAttribute(ctx, block, blockName, 'videoUrl');
    _setLandingPageBlockAttribute(ctx, block, blockName, 'videoPlaceholder');

    // Use the correct `text` value, if any
    if (block.text) {
        var locale = ctx.resolvedLocale();
        block.text = block.text[locale] || block.text['default'];
    }

    // We have to require the UI api inline, as this would
    // otherwise lead to circular require calls
    var UIAPI = require('oae-ui');

    // If any URLs are configured, we try to resolve them in the hashed UI files
    if (block.imgUrl) {
        block.imgUrl = UIAPI.getHashedPath(block.imgUrl);
    }
    if (block.videoPlaceholder) {
        block.videoPlaceholder = UIAPI.getHashedPath(block.videoPlaceholder);
    }

    return block;
};

/**
 * Set an attribute value on a landing page block
 *
 * @param  {Context}        ctx                 Standard context object containing the current user and the current tenant
 * @param  {Object}         block               The block on which to set an attribute
 * @param  {String}         blockName           The name of the block. Formatted as `block_i`
 * @param  {String}         attributeName       The name of the attribute to set on the block
 * @api private
 */
var _setLandingPageBlockAttribute = function(ctx, block, blockName, attributeName) {
    // We have to require the config api inline, as this would
    // otherwise lead to circular require calls
    var TenantsConfig = require('oae-config').config('oae-tenants');

    // Set the attribute value
    block[attributeName] = TenantsConfig.getValue(ctx.tenant().alias, blockName, attributeName);
};


///////////////
// UTILITIES //
///////////////

/**
 * Create a new version of the tenant with the same information.
 *
 * @param  {Tenant}     tenant      The tenant whose data to copy to a new tenant object
 * @return {Tenant}                 A copy of the tenant object that was provided, such that modifying its properties is safe
 * @api private
 */
var _copyTenant = function(tenant) {
    if (!tenant) {
        return null;
    }

    return new Tenant(tenant.alias, tenant.displayName, tenant.host, {
        'active': tenant.active,
        'isGlobalAdminServer': tenant.isGlobalAdminServer
    });
};

/**
 * Map a cassandra row element to a tenant object.
 *
 * @param  {Row}    row     The row to be mapped
 * @return {Tenant}         A tenant corresponding to the row
 * @api private
 */
var mapToTenant = function(row) {
    var hash = Cassandra.rowToHash(row);
    return new Tenant(hash.alias, hash.displayName, hash.host.toLowerCase(), {
        'active': hash.active
    });
};
