'use strict';

// Load modules

const Boom = require('boom');
const Bounce = require('bounce');
const Hoek = require('hoek');

const Config = require('./config');


// Declare internals

const internals = {
    missing: Symbol('missing')
};


exports = module.exports = internals.Auth = class {

    constructor(core) {

        this._core = core;
        this._schemes = {};
        this._strategies = {};
        this.settings = {
            default: null           // Strategy used as default if route has no auth settings
        };

        this.api = {};
    }

    scheme(name, scheme) {

        Hoek.assert(name, 'Authentication scheme must have a name');
        Hoek.assert(!this._schemes[name], 'Authentication scheme name already exists:', name);
        Hoek.assert(typeof scheme === 'function', 'scheme must be a function:', name);

        this._schemes[name] = scheme;
    }

    _strategy(server, name, scheme, options = {}) {

        Hoek.assert(name, 'Authentication strategy must have a name');
        Hoek.assert(typeof options === 'object', 'options must be an object');
        Hoek.assert(name !== 'bypass', 'Cannot use reserved strategy name: bypass');
        Hoek.assert(!this._strategies[name], 'Authentication strategy name already exists');
        Hoek.assert(scheme, 'Authentication strategy', name, 'missing scheme');
        Hoek.assert(this._schemes[scheme], 'Authentication strategy', name, 'uses unknown scheme:', scheme);

        server = server._clone();
        const strategy = this._schemes[scheme](server, options);

        Hoek.assert(strategy.authenticate, 'Invalid scheme:', name, 'missing authenticate() method');
        Hoek.assert(typeof strategy.authenticate === 'function', 'Invalid scheme:', name, 'invalid authenticate() method');
        Hoek.assert(!strategy.payload || typeof strategy.payload === 'function', 'Invalid scheme:', name, 'invalid payload() method');
        Hoek.assert(!strategy.response || typeof strategy.response === 'function', 'Invalid scheme:', name, 'invalid response() method');
        strategy.options = strategy.options || {};
        Hoek.assert(strategy.payload || !strategy.options.payload, 'Cannot require payload validation without a payload method');

        this._strategies[name] = {
            methods: strategy,
            realm: server.realm
        };

        if (strategy.api) {
            this.api[name] = strategy.api;
        }
    }

    default(options) {

        Hoek.assert(!this.settings.default, 'Cannot set default strategy more than once');
        options = Config.apply('auth', options, 'default strategy');

        this.settings.default = this._setupRoute(Hoek.clone(options));      // Prevent changes to options

        const routes = this._core.router.table();
        for (const route of routes) {
            route.rebuild();
        }
    }

    async test(name, request) {

        Hoek.assert(name, 'Missing authentication strategy name');
        const strategy = this._strategies[name];
        Hoek.assert(strategy, 'Unknown authentication strategy:', name);

        const bind = strategy.methods;
        const realm = strategy.realm;
        const response = await request._core.toolkit.execute(strategy.methods.authenticate, request, { bind, realm, auth: true });

        if (!response.isAuth) {
            throw response;
        }

        if (response.error) {
            throw response.error;
        }

        return response.data.credentials;
    }

    static testAccess(request, route) {

        const auth = request._core.auth;

        try {
            return auth._access(request, route);
        }
        catch (err) {
            Bounce.rethrow(err, 'system');
            return false;
        }
    }

    _setupRoute(options, path) {

        if (!options) {
            return options;         // Preserve the difference between undefined and false
        }

        if (typeof options === 'string') {
            options = { strategies: [options] };
        }
        else if (options.strategy) {
            options.strategies = [options.strategy];
            delete options.strategy;
        }

        if (path &&
            !options.strategies) {

            Hoek.assert(this.settings.default, 'Route missing authentication strategy and no default defined:', path);
            options = Hoek.applyToDefaults(this.settings.default, options);
        }

        path = path || 'default strategy';
        Hoek.assert(options.strategies && options.strategies.length, 'Missing authentication strategy:', path);

        options.mode = options.mode || 'required';

        if (options.entity !== undefined ||                                             // Backwards compatibility with <= 11.x.x
            options.scope !== undefined) {

            options.access = [{ entity: options.entity, scope: options.scope }];
            delete options.entity;
            delete options.scope;
        }

        if (options.access) {
            for (const access of options.access) {
                access.scope = internals.setupScope(access);
            }
        }

        if (options.payload === true) {
            options.payload = 'required';
        }

        let hasAuthenticatePayload = false;
        for (const name of options.strategies) {
            const strategy = this._strategies[name];
            Hoek.assert(strategy, 'Unknown authentication strategy', name, 'in', path);

            Hoek.assert(strategy.methods.payload || options.payload !== 'required', 'Payload validation can only be required when all strategies support it in', path);
            hasAuthenticatePayload = hasAuthenticatePayload || strategy.methods.payload;
            Hoek.assert(!strategy.methods.options.payload || options.payload === undefined || options.payload === 'required', 'Cannot set authentication payload to', options.payload, 'when a strategy requires payload validation in', path);
        }

        Hoek.assert(!options.payload || hasAuthenticatePayload, 'Payload authentication requires at least one strategy with payload support in', path);

        return options;
    }

    lookup(route) {

        if (route.settings.auth === false) {
            return false;
        }

        return route.settings.auth || this.settings.default;
    }

    _enabled(route, type) {

        const config = this.lookup(route);
        if (!config) {
            return false;
        }

        if (type === 'authenticate') {
            return true;
        }

        if (type === 'access') {
            return !!config.access;
        }

        for (const name of config.strategies) {
            const strategy = this._strategies[name];
            if (strategy.methods[type]) {
                return true;
            }
        }

        return false;
    }

    static authenticate(request) {

        const auth = request._core.auth;
        return auth._authenticate(request);
    }

    async _authenticate(request) {

        const config = this.lookup(request.route);

        const errors = [];
        request.auth.mode = config.mode;

        // Injection bypass

        if (request.auth.credentials) {
            internals.validate(null, { credentials: request.auth.credentials, artifacts: request.auth.artifacts }, 'bypass', config, request, errors);
            return;
        }

        // Try each strategy

        for (const name of config.strategies) {
            const strategy = this._strategies[name];

            const bind = strategy.methods;
            const realm = strategy.realm;
            const response = await request._core.toolkit.execute(strategy.methods.authenticate, request, { bind, realm, auth: true });

            const message = (response.isAuth ? internals.validate(response.error, response.data, name, config, request, errors) : internals.validate(response, null, name, config, request, errors));
            if (!message) {
                return;
            }

            if (message !== internals.missing) {
                return message;
            }
        }

        // No more strategies

        const err = Boom.unauthorized('Missing authentication', errors);
        if (config.mode === 'required') {
            throw err;
        }

        request.auth.isAuthenticated = false;
        request.auth.credentials = null;
        request.auth.error = err;
        request._log(['auth', 'unauthenticated']);
    }

    static access(request) {

        const auth = request._core.auth;
        request.auth.isAuthorized = auth._access(request);
    }

    _access(request, route) {

        const config = this.lookup(route || request.route);
        if (!config ||
            !config.access) {

            return true;
        }

        const credentials = request.auth.credentials;
        if (!credentials) {
            if (config.mode !== 'required') {
                return false;
            }

            throw Boom.forbidden('Request is unauthenticated');
        }

        const requestEntity = (credentials.user ? 'user' : 'app');

        const scopeErrors = [];
        for (const access of config.access) {

            // Check entity

            const entity = access.entity;
            if (entity &&
                entity !== 'any' &&
                entity !== requestEntity) {

                continue;
            }

            // Check scope

            let scope = access.scope;
            if (scope) {
                if (!credentials.scope) {
                    scopeErrors.push(scope);
                    continue;
                }

                scope = internals.expandScope(request, scope);
                if (!internals.validateScope(credentials, scope, 'required') ||
                    !internals.validateScope(credentials, scope, 'selection') ||
                    !internals.validateScope(credentials, scope, 'forbidden')) {

                    scopeErrors.push(scope);
                    continue;
                }
            }

            return true;
        }

        // Scope error

        if (scopeErrors.length) {
            request._log(['auth', 'scope', 'error']);
            throw Boom.forbidden('Insufficient scope', { got: credentials.scope, need: scopeErrors });
        }

        // Entity error

        if (requestEntity === 'app') {
            request._log(['auth', 'entity', 'user', 'error']);
            throw Boom.forbidden('Application credentials cannot be used on a user endpoint');
        }

        request._log(['auth', 'entity', 'app', 'error']);
        throw Boom.forbidden('User credentials cannot be used on an application endpoint');
    }

    static async payload(request) {

        if (!request.auth.isAuthenticated ||
            request.auth.strategy === 'bypass') {

            return;
        }

        const auth = request._core.auth;
        const strategy = auth._strategies[request.auth.strategy];

        if (!strategy.methods.payload) {
            return;
        }

        const config = auth.lookup(request.route);
        const setting = config.payload || (strategy.methods.options.payload ? 'required' : false);
        if (!setting) {
            return;
        }

        const bind = strategy.methods;
        const realm = strategy.realm;
        const response = await request._core.toolkit.execute(strategy.methods.payload, request, { bind, realm });

        if (response.isBoom &&
            response.isMissing) {

            return (setting === 'optional' ? undefined : Boom.unauthorized('Missing payload authentication'));
        }

        return response;
    }

    static async response(request) {

        const auth = request._core.auth;
        if (!request.auth.isAuthenticated ||
            request.auth.strategy === 'bypass') {

            return;
        }

        const strategy = auth._strategies[request.auth.strategy];
        if (!strategy.methods.response) {
            return;
        }

        const bind = strategy.methods;
        const realm = strategy.realm;
        const error = await request._core.toolkit.execute(strategy.methods.response, request, { bind, realm, continue: 'undefined' });
        if (error) {
            throw error;
        }
    }
};


internals.setupScope = function (access) {

    if (!access.scope) {
        return false;
    }

    const scope = {};
    for (const value of access.scope) {
        const prefix = value[0];
        const type = (prefix === '+' ? 'required' : (prefix === '!' ? 'forbidden' : 'selection'));
        const clean = (type === 'selection' ? value : value.slice(1));
        scope[type] = scope[type] || [];
        scope[type].push(clean);

        if ((!scope._parameters || !scope._parameters[type]) &&
            /{([^}]+)}/.test(clean)) {

            scope._parameters = scope._parameters || {};
            scope._parameters[type] = true;
        }
    }

    return scope;
};


internals.validate = function (err, result, name, config, request, errors) {                 // err can be Boom, Error, or a valid response object

    result = result || {};

    // Unauthenticated

    if (err) {
        if (err instanceof Error === false) {
            request._log(['auth', 'unauthenticated', 'response', name], { statusCode: err.statusCode });
            return err;     // Non-error response
        }

        if (err.isMissing) {

            // Try next strategy

            request._log(['auth', 'unauthenticated', 'missing', name], err);
            errors.push(err.output.headers['WWW-Authenticate']);
            return internals.missing;
        }

        if (config.mode === 'try') {
            request.auth.isAuthenticated = false;
            request.auth.strategy = name;
            request.auth.credentials = result.credentials;
            request.auth.artifacts = result.artifacts;
            request.auth.error = err;
            request._log(['auth', 'unauthenticated', 'try', name], err);
            return;
        }

        request._log(['auth', 'unauthenticated', 'error', name], err);
        throw err;
    }

    // Authenticated

    const credentials = result.credentials;
    request.auth.strategy = name;
    request.auth.credentials = credentials;
    request.auth.artifacts = result.artifacts;
    request.auth.isAuthenticated = true;
};


internals.expandScope = function (request, scope) {

    if (!scope._parameters) {
        return scope;
    }

    const expanded = {
        required: internals.expandScopeType(request, scope, 'required'),
        selection: internals.expandScopeType(request, scope, 'selection'),
        forbidden: internals.expandScopeType(request, scope, 'forbidden')
    };

    return expanded;
};


internals.expandScopeType = function (request, scope, type) {

    if (!scope[type] ||
        !scope._parameters[type]) {

        return scope[type];
    }

    const expanded = [];
    const context = {
        params: request.params,
        query: request.query,
        payload: request.payload,
        credentials: request.auth.credentials
    };

    for (const template of scope[type]) {
        expanded.push(Hoek.reachTemplate(context, template));
    }

    return expanded;
};


internals.validateScope = function (credentials, scope, type) {

    if (!scope[type]) {
        return true;
    }

    const count = typeof credentials.scope === 'string' ?
        (scope[type].indexOf(credentials.scope) !== -1 ? 1 : 0) :
        Hoek.intersect(scope[type], credentials.scope).length;

    if (type === 'forbidden') {
        return count === 0;
    }

    if (type === 'required') {
        return count === scope.required.length;
    }

    return !!count;
};
