'use strict';

const assert = require('assert').strict;
const log4js = require('log4js');
const httpLogger = log4js.getLogger('http');
const settings = require('../../utils/Settings');
const hooks = require('../../../static/js/pluginfw/hooks');
const readOnlyManager = require('../../db/ReadOnlyManager');

hooks.deprecationNotices.authFailure = 'use the authnFailure and authzFailure hooks instead';

const staticPathsRE = new RegExp(`^/(?:${[
  'api(?:/.*)?',
  'favicon\\.ico',
  'ep/pad/connection-diagnostic-info',
  'javascript',
  'javascripts/.*',
  'jserror/?',
  'locales\\.json',
  'locales/.*',
  'rest/.*',
  'pluginfw/.*',
  'robots.txt',
  'static/.*',
  'stats/?',
  'tests/frontend(?:/.*)?',
].join('|')})$`);

exports.normalizeAuthzLevel = (level) => {
  if (!level) return false;
  switch (level) {
    case true:
      return 'create';
    case 'readOnly':
    case 'modify':
    case 'create':
      return level;
    default:
      httpLogger.warn(`Unknown authorization level '${level}', denying access`);
  }
  return false;
};

exports.userCanModify = (padId, req) => {
  if (readOnlyManager.isReadOnlyId(padId)) return false;
  if (!settings.requireAuthentication) return true;
  const {session: {user} = {}} = req;
  assert(user); // If authn required and user == null, the request should have already been denied.
  if (user.readOnly) return false;
  assert(user.padAuthorizations); // This is populated even if !settings.requireAuthorization.
  const level = exports.normalizeAuthzLevel(user.padAuthorizations[padId]);
  assert(level); // If !level, the request should have already been denied.
  return level !== 'readOnly';
};

// Exported so that tests can set this to 0 to avoid unnecessary test slowness.
exports.authnFailureDelayMs = 1000;

const checkAccess = async (req, res, next) => {
  // Promisified wrapper around hooks.aCallFirst.
  const aCallFirst = (hookName, context, pred = null) => new Promise((resolve, reject) => {
    hooks.aCallFirst(hookName, context, (err, r) => err != null ? reject(err) : resolve(r), pred);
  });

  const aCallFirst0 =
      async (hookName, context, pred = null) => (await aCallFirst(hookName, context, pred))[0];

  const requireAdmin = req.path.toLowerCase().indexOf('/admin') === 0;

  // This helper is used in steps 2 and 4 below, so it may be called twice per access: once before
  // authentication is checked and once after (if settings.requireAuthorization is true).
  const authorize = async () => {
    const grant = async (level) => {
      level = exports.normalizeAuthzLevel(level);
      if (!level) return false;
      const user = req.session.user;
      if (user == null) return true; // This will happen if authentication is not required.
      const encodedPadId = (req.path.match(/^\/p\/([^/]*)/) || [])[1];
      if (encodedPadId == null) return true;
      let padId = decodeURIComponent(encodedPadId);
      if (readOnlyManager.isReadOnlyId(padId)) {
        // pad is read-only, first get the real pad ID
        padId = await readOnlyManager.getPadId(padId);
        if (padId == null) return false;
      }
      // The user was granted access to a pad. Remember the authorization level in the user's
      // settings so that SecurityManager can approve or deny specific actions.
      if (user.padAuthorizations == null) user.padAuthorizations = {};
      user.padAuthorizations[padId] = level;
      return true;
    };
    const isAuthenticated = req.session && req.session.user;
    if (isAuthenticated && req.session.user.is_admin) return await grant('create');
    const requireAuthn = requireAdmin || settings.requireAuthentication;
    if (!requireAuthn) return await grant('create');
    if (!isAuthenticated) return await grant(false);
    if (requireAdmin && !req.session.user.is_admin) return await grant(false);
    if (!settings.requireAuthorization) return await grant('create');
    return await grant(await aCallFirst0('authorize', {req, res, next, resource: req.path}));
  };

  // ///////////////////////////////////////////////////////////////////////////////////////////////
  // Step 1: Check the preAuthorize hook for early permit/deny (permit is only allowed for non-admin
  // pages). If any plugin explicitly grants or denies access, skip the remaining steps. Plugins can
  // use the preAuthzFailure hook to override the default 403 error.
  // ///////////////////////////////////////////////////////////////////////////////////////////////

  let results;
  try {
    results = await aCallFirst('preAuthorize', {req, res, next},
        // This predicate will cause aCallFirst to call the hook functions one at a time until one
        // of them returns a non-empty list, with an exception: If the request is for an /admin
        // page, truthy entries are filtered out before checking to see whether the list is empty.
        // This prevents plugin authors from accidentally granting admin privileges to the general
        // public.
        (r) => (r != null && r.filter((x) => (!requireAdmin || !x)).length > 0));
  } catch (err) {
    httpLogger.error(`Error in preAuthorize hook: ${err.stack || err.toString()}`);
    return res.status(500).send('Internal Server Error');
  }
  if (staticPathsRE.test(req.path)) results.push(true);
  if (requireAdmin) {
    // Filter out all 'true' entries to prevent plugin authors from accidentally granting admin
    // privileges to the general public.
    results = results.filter((x) => !x);
  }
  if (results.length > 0) {
    // Access was explicitly granted or denied. If any value is false then access is denied.
    if (results.every((x) => x)) return next();
    if (await aCallFirst0('preAuthzFailure', {req, res})) return;
    // No plugin handled the pre-authentication authorization failure.
    return res.status(403).send('Forbidden');
  }

  // ///////////////////////////////////////////////////////////////////////////////////////////////
  // Step 2: Try to just access the thing. If access fails (perhaps authentication has not yet
  // completed, or maybe different credentials are required), go to the next step.
  // ///////////////////////////////////////////////////////////////////////////////////////////////

  if (await authorize()) return next();

  // ///////////////////////////////////////////////////////////////////////////////////////////////
  // Step 3: Authenticate the user. (Or, if already logged in, reauthenticate with different
  // credentials if supported by the authn scheme.) If authentication fails, give the user a 401
  // error to request new credentials. Otherwise, go to the next step. Plugins can use the
  // authnFailure hook to override the default error handling behavior (e.g., to redirect to a login
  // page).
  // ///////////////////////////////////////////////////////////////////////////////////////////////

  if (settings.users == null) settings.users = {};
  const ctx = {req, res, users: settings.users, next};
  // If the HTTP basic auth header is present, extract the username and password so it can be given
  // to authn plugins.
  const httpBasicAuth =
      req.headers.authorization && req.headers.authorization.search('Basic ') === 0;
  if (httpBasicAuth) {
    const userpass =
        Buffer.from(req.headers.authorization.split(' ')[1], 'base64').toString().split(':');
    ctx.username = userpass.shift();
    ctx.password = userpass.join(':');
  }
  if (!(await aCallFirst0('authenticate', ctx))) {
    // Fall back to HTTP basic auth.
    const {[ctx.username]: {password} = {}} = settings.users;
    if (!httpBasicAuth || password == null || password !== ctx.password) {
      httpLogger.info(`Failed authentication from IP ${req.ip}`);
      if (await aCallFirst0('authnFailure', {req, res})) return;
      if (await aCallFirst0('authFailure', {req, res, next})) return;
      // No plugin handled the authentication failure. Fall back to basic authentication.
      res.header('WWW-Authenticate', 'Basic realm="Protected Area"');
      // Delay the error response for 1s to slow down brute force attacks.
      await new Promise((resolve) => setTimeout(resolve, exports.authnFailureDelayMs));
      res.status(401).send('Authentication Required');
      return;
    }
    settings.users[ctx.username].username = ctx.username;
    // Make a shallow copy so that the password property can be deleted (to prevent it from
    // appearing in logs or in the database) without breaking future authentication attempts.
    req.session.user = {...settings.users[ctx.username]};
    delete req.session.user.password;
  }
  if (req.session.user == null) {
    httpLogger.error('authenticate hook failed to add user settings to session');
    return res.status(500).send('Internal Server Error');
  }
  const {username = '<no username>'} = req.session.user;
  httpLogger.info(`Successful authentication from IP ${req.ip} for user ${username}`);

  // ///////////////////////////////////////////////////////////////////////////////////////////////
  // Step 4: Try to access the thing again. If this fails, give the user a 403 error. Plugins can
  // use the authzFailure hook to override the default error handling behavior (e.g., to redirect to
  // a login page).
  // ///////////////////////////////////////////////////////////////////////////////////////////////

  if (await authorize()) return next();
  if (await aCallFirst0('authzFailure', {req, res})) return;
  if (await aCallFirst0('authFailure', {req, res, next})) return;
  // No plugin handled the authorization failure.
  res.status(403).send('Forbidden');
};

exports.expressConfigure = (hookName, args, cb) => {
  args.app.use((req, res, next) => { checkAccess(req, res, next).catch(next); });
  return cb();
};
