import PromiseRouter from '../PromiseRouter';
import Config from '../Config';
import express from 'express';
import path from 'path';
import { promises as fs } from 'fs';
import { Parse } from 'parse/node';
import Utils from '../Utils';
import mustache from 'mustache';
import Page from '../Page';

// All pages with custom page key for reference and file name
const pages = Object.freeze({
  passwordReset: new Page({ id: 'passwordReset', defaultFile: 'password_reset.html' }),
  passwordResetSuccess: new Page({
    id: 'passwordResetSuccess',
    defaultFile: 'password_reset_success.html',
  }),
  passwordResetLinkInvalid: new Page({
    id: 'passwordResetLinkInvalid',
    defaultFile: 'password_reset_link_invalid.html',
  }),
  emailVerificationSuccess: new Page({
    id: 'emailVerificationSuccess',
    defaultFile: 'email_verification_success.html',
  }),
  emailVerificationSendFail: new Page({
    id: 'emailVerificationSendFail',
    defaultFile: 'email_verification_send_fail.html',
  }),
  emailVerificationSendSuccess: new Page({
    id: 'emailVerificationSendSuccess',
    defaultFile: 'email_verification_send_success.html',
  }),
  emailVerificationLinkInvalid: new Page({
    id: 'emailVerificationLinkInvalid',
    defaultFile: 'email_verification_link_invalid.html',
  }),
  emailVerificationLinkExpired: new Page({
    id: 'emailVerificationLinkExpired',
    defaultFile: 'email_verification_link_expired.html',
  }),
});

// All page parameters for reference to be used as template placeholders or query params
const pageParams = Object.freeze({
  appName: 'appName',
  appId: 'appId',
  token: 'token',
  username: 'username',
  error: 'error',
  locale: 'locale',
  publicServerUrl: 'publicServerUrl',
});

// The header prefix to add page params as response headers
const pageParamHeaderPrefix = 'x-parse-page-param-';

// The errors being thrown
const errors = Object.freeze({
  jsonFailedFileLoading: 'failed to load JSON file',
  fileOutsideAllowedScope: 'not allowed to read file outside of pages directory',
});

export class PagesRouter extends PromiseRouter {
  /**
   * Constructs a PagesRouter.
   * @param {Object} pages The pages options from the Parse Server configuration.
   */
  constructor(pages = {}) {
    super();

    // Set instance properties
    this.pagesConfig = pages;
    this.pagesEndpoint = pages.pagesEndpoint ? pages.pagesEndpoint : 'apps';
    this.pagesPath = pages.pagesPath
      ? path.resolve('./', pages.pagesPath)
      : path.resolve(__dirname, '../../public');
    this.loadJsonResource();
    this.mountPagesRoutes();
    this.mountCustomRoutes();
    this.mountStaticRoute();
  }

  verifyEmail(req) {
    const config = req.config;
    const { token: rawToken } = req.query;
    const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken;

    if (!config) {
      this.invalidRequest();
    }

    if (!token) {
      return this.goToPage(req, pages.emailVerificationLinkInvalid);
    }

    const userController = config.userController;
    return userController.verifyEmail(token).then(
      () => {
        return this.goToPage(req, pages.emailVerificationSuccess);
      },
      () => {
        return this.goToPage(req, pages.emailVerificationLinkInvalid);
      }
    );
  }

  resendVerificationEmail(req) {
    const config = req.config;
    const username = req.body?.username;
    const token = req.body?.token;

    if (!config) {
      this.invalidRequest();
    }

    if (!username && !token) {
      return this.goToPage(req, pages.emailVerificationLinkInvalid);
    }

    const userController = config.userController;

    return userController.resendVerificationEmail(username, req, token).then(
      () => {
        return this.goToPage(req, pages.emailVerificationSendSuccess);
      },
      () => {
        return this.goToPage(req, pages.emailVerificationSendFail);
      }
    );
  }

  passwordReset(req) {
    const config = req.config;
    const params = {
      [pageParams.appId]: req.params.appId,
      [pageParams.appName]: config.appName,
      [pageParams.token]: req.query.token,
      [pageParams.username]: req.query.username,
      [pageParams.publicServerUrl]: config.publicServerURL,
    };
    return this.goToPage(req, pages.passwordReset, params);
  }

  requestResetPassword(req) {
    const config = req.config;

    if (!config) {
      this.invalidRequest();
    }

    const { token: rawToken } = req.query;
    const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken;

    if (!token) {
      return this.goToPage(req, pages.passwordResetLinkInvalid);
    }

    return config.userController.checkResetTokenValidity(token).then(
      () => {
        const params = {
          [pageParams.token]: token,
          [pageParams.appId]: config.applicationId,
          [pageParams.appName]: config.appName,
        };
        return this.goToPage(req, pages.passwordReset, params);
      },
      () => {
        return this.goToPage(req, pages.passwordResetLinkInvalid);
      }
    );
  }

  resetPassword(req) {
    const config = req.config;

    if (!config) {
      this.invalidRequest();
    }

    const { new_password, token: rawToken } = req.body || {};
    const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken;

    if ((!token || !new_password) && req.xhr === false) {
      return this.goToPage(req, pages.passwordResetLinkInvalid);
    }

    if (!token) {
      throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'Missing token');
    }

    if (!new_password) {
      throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'Missing password');
    }

    return config.userController
      .updatePassword(token, new_password)
      .then(
        () => {
          return Promise.resolve({
            success: true,
          });
        },
        err => {
          return Promise.resolve({
            success: false,
            err,
          });
        }
      )
      .then(result => {
        if (req.xhr) {
          if (result.success) {
            return Promise.resolve({
              status: 200,
              response: 'Password successfully reset',
            });
          }
          if (result.err) {
            throw new Parse.Error(Parse.Error.OTHER_CAUSE, `${result.err}`);
          }
        }

        const query = result.success
          ? {}
          : {
            [pageParams.token]: token,
            [pageParams.appId]: config.applicationId,
            [pageParams.error]: result.err,
            [pageParams.appName]: config.appName,
          };

        if (result?.err === 'The password reset link has expired') {
          delete query[pageParams.token];
          query[pageParams.token] = token;
        }
        const page = result.success ? pages.passwordResetSuccess : pages.passwordReset;

        return this.goToPage(req, page, query, false);
      });
  }

  /**
   * Returns page content if the page is a local file or returns a
   * redirect to a custom page.
   * @param {Object} req The express request.
   * @param {Page} page The page to go to.
   * @param {Object} [params={}] The query parameters to attach to the URL in case of
   * HTTP redirect responses for POST requests, or the placeholders to fill into
   * the response content in case of HTTP content responses for GET requests.
   * @param {Boolean} [responseType] Is true if a redirect response should be forced,
   * false if a content response should be forced, undefined if the response type
   * should depend on the request type by default:
   * - GET request -> content response
   * - POST request -> redirect response (PRG pattern)
   * @returns {Promise<Object>} The PromiseRouter response.
   */
  goToPage(req, page, params = {}, responseType) {
    const config = req.config;

    // Determine redirect either by force, response setting or request method
    const redirect = config.pages.forceRedirect
      ? true
      : responseType !== undefined
        ? responseType
        : req.method == 'POST';

    // Include default parameters
    const defaultParams = this.getDefaultParams(config);
    if (Object.values(defaultParams).includes(undefined)) {
      return this.notFound();
    }
    params = Object.assign(params, defaultParams);

    // Add locale to params to ensure it is passed on with every request;
    // that means, once a locale is set, it is passed on to any follow-up page,
    // e.g. request_password_reset -> password_reset -> password_reset_success
    const locale = this.getLocale(req);
    params[pageParams.locale] = locale;

    // Compose paths and URLs
    const defaultFile = page.defaultFile;
    const defaultPath = this.defaultPagePath(defaultFile);
    const defaultUrl = this.composePageUrl(defaultFile, config.publicServerURL);

    // If custom URL is set redirect to it without localization
    const customUrl = config.pages.customUrls[page.id];
    if (customUrl && !Utils.isPath(customUrl)) {
      return this.redirectResponse(customUrl, params);
    }

    // Get JSON placeholders
    let placeholders = {};
    if (config.pages.enableLocalization && config.pages.localizationJsonPath) {
      placeholders = this.getJsonPlaceholders(locale, params);
    }

    // Send response
    if (config.pages.enableLocalization && locale) {
      return Utils.getLocalizedPath(defaultPath, locale).then(({ path, subdir }) =>
        redirect
          ? this.redirectResponse(
            this.composePageUrl(defaultFile, config.publicServerURL, subdir),
            params
          )
          : this.pageResponse(path, params, placeholders)
      );
    } else {
      return redirect
        ? this.redirectResponse(defaultUrl, params)
        : this.pageResponse(defaultPath, params, placeholders);
    }
  }

  /**
   * Serves a request to a static resource and localizes the resource if it
   * is a HTML file.
   * @param {Object} req The request object.
   * @returns {Promise<Object>} The response.
   */
  staticRoute(req) {
    // Get requested path
    const relativePath = req.params['resource'][0];

    // Resolve requested path to absolute path
    const absolutePath = path.resolve(this.pagesPath, relativePath);

    // If the requested file is not a HTML file send its raw content
    if (!absolutePath || !absolutePath.endsWith('.html')) {
      return this.fileResponse(absolutePath);
    }

    // Get parameters
    const params = this.getDefaultParams(req.config);
    const locale = this.getLocale(req);
    if (locale) {
      params.locale = locale;
    }

    // Get JSON placeholders
    const placeholders = this.getJsonPlaceholders(locale, params);

    return this.pageResponse(absolutePath, params, placeholders);
  }

  /**
   * Returns a translation from the JSON resource for a given locale. The JSON
   * resource is parsed according to i18next syntax.
   *
   * Example JSON content:
   * ```js
   *  {
   *    "en": {               // resource for language `en` (English)
   *      "translation": {
   *        "greeting": "Hello!"
   *      }
   *    },
   *    "de": {               // resource for language `de` (German)
   *      "translation": {
   *        "greeting": "Hallo!"
   *      }
   *    }
   *    "de-CH": {            // resource for locale `de-CH` (Swiss German)
   *      "translation": {
   *        "greeting": "Grüezi!"
   *      }
   *    }
   *  }
   * ```
   * @param {String} locale The locale to translate to.
   * @returns {Object} The translation or an empty object if no matching
   * translation was found.
   */
  getJsonTranslation(locale) {
    // If there is no JSON resource
    if (this.jsonParameters === undefined) {
      return {};
    }

    // If locale is not set use the fallback locale
    locale = locale || this.pagesConfig.localizationFallbackLocale;

    // Get matching translation by locale, language or fallback locale
    const language = locale.split('-')[0];
    const resource =
      this.jsonParameters[locale] ||
      this.jsonParameters[language] ||
      this.jsonParameters[this.pagesConfig.localizationFallbackLocale] ||
      {};
    const translation = resource.translation || {};
    return translation;
  }

  /**
   * Returns a translation from the JSON resource for a given locale with
   * placeholders filled in by given parameters.
   * @param {String} locale The locale to translate to.
   * @param {Object} params The parameters to fill into any placeholders
   * within the translations.
   * @returns {Object} The translation or an empty object if no matching
   * translation was found.
   */
  getJsonPlaceholders(locale, params = {}) {
    // If localization is disabled or there is no JSON resource
    if (!this.pagesConfig.enableLocalization || !this.pagesConfig.localizationJsonPath) {
      return {};
    }

    // Get JSON placeholders
    let placeholders = this.getJsonTranslation(locale);

    // Fill in any placeholders in the translation; this allows a translation
    // to contain default placeholders like {{appName}} which are filled here
    placeholders = JSON.stringify(placeholders);
    placeholders = mustache.render(placeholders, params);
    placeholders = JSON.parse(placeholders);

    return placeholders;
  }

  /**
   * Creates a response with file content.
   * @param {String} path The path of the file to return.
   * @param {Object} [params={}] The parameters to be included in the response
   * header. These will also be used to fill placeholders.
   * @param {Object} [placeholders={}] The placeholders to fill in the content.
   * These will not be included in the response header.
   * @returns {Object} The Promise Router response.
   */
  async pageResponse(path, params = {}, placeholders = {}) {
    // Get file content
    let data;
    try {
      data = await this.readFile(path);
    } catch {
      return this.notFound();
    }

    // Get config placeholders; can be an object, a function or an async function
    let configPlaceholders =
      typeof this.pagesConfig.placeholders === 'function'
        ? this.pagesConfig.placeholders(params)
        : Object.prototype.toString.call(this.pagesConfig.placeholders) === '[object Object]'
          ? this.pagesConfig.placeholders
          : {};
    if (configPlaceholders instanceof Promise) {
      configPlaceholders = await configPlaceholders;
    }

    // Fill placeholders
    const allPlaceholders = Object.assign({}, configPlaceholders, placeholders);
    const paramsAndPlaceholders = Object.assign({}, params, allPlaceholders);
    data = mustache.render(data, paramsAndPlaceholders);

    // Add placeholders in header to allow parsing for programmatic use
    // of response, instead of having to parse the HTML content.
    const headers = Object.entries(params).reduce((m, p) => {
      if (p[1] !== undefined) {
        m[`${pageParamHeaderPrefix}${p[0].toLowerCase()}`] = p[1];
      }
      return m;
    }, {});

    return { text: data, headers: headers };
  }

  /**
   * Creates a response with file content.
   * @param {String} path The path of the file to return.
   * @returns {Object} The PromiseRouter response.
   */
  async fileResponse(path) {
    // Get file content
    let data;
    try {
      data = await this.readFile(path);
    } catch {
      return this.notFound();
    }

    return { text: data };
  }

  /**
   * Reads and returns the content of a file at a given path. File reading to
   * serve content on the static route is only allowed from the pages
   * directory on downwards.
   * -----------------------------------------------------------------------
   * **WARNING:** All file reads in the PagesRouter must be executed by this
   * wrapper because it also detects and prevents common exploits.
   * -----------------------------------------------------------------------
   * @param {String} filePath The path to the file to read.
   * @returns {Promise<String>} The file content.
   */
  async readFile(filePath) {
    // Normalize path to prevent it from containing any directory changing
    // UNIX patterns which could expose the whole file system, e.g.
    // `http://example.com/parse/apps/../file.txt` requests a file outside
    // of the pages directory scope.
    const normalizedPath = path.normalize(filePath);

    // Abort if the path is outside of the path directory scope
    if (!normalizedPath.startsWith(this.pagesPath)) {
      throw errors.fileOutsideAllowedScope;
    }

    return await fs.readFile(normalizedPath, 'utf-8');
  }

  /**
   * Loads a language resource JSON file that is used for translations.
   */
  loadJsonResource() {
    if (this.pagesConfig.localizationJsonPath === undefined) {
      return;
    }
    try {
      const json = require(path.resolve('./', this.pagesConfig.localizationJsonPath));
      this.jsonParameters = json;
    } catch {
      throw errors.jsonFailedFileLoading;
    }
  }

  /**
   * Extracts and returns the page default parameters from the Parse Server
   * configuration. These parameters are made accessible in every page served
   * by this router.
   * @param {Object} config The Parse Server configuration.
   * @returns {Object} The default parameters.
   */
  getDefaultParams(config) {
    return config
      ? {
        [pageParams.appId]: config.appId,
        [pageParams.appName]: config.appName,
        [pageParams.publicServerUrl]: config.publicServerURL,
      }
      : {};
  }

  /**
   * Extracts and returns the locale from an express request.
   * @param {Object} req The express request.
   * @returns {String|undefined} The locale, or undefined if no locale was set.
   */
  getLocale(req) {
    const locale =
      (req.query || {})[pageParams.locale] ||
      (req.body || {})[pageParams.locale] ||
      (req.params || {})[pageParams.locale] ||
      (req.headers || {})[pageParamHeaderPrefix + pageParams.locale];
    return locale;
  }

  /**
   * Creates a response with http redirect.
   * @param {Object} req The express request.
   * @param {String} path The path of the file to return.
   * @param {Object} params The query parameters to include.
   * @returns {Object} The Promise Router response.
   */
  async redirectResponse(url, params) {
    // Remove any parameters with undefined value
    params = Object.entries(params).reduce((m, p) => {
      if (p[1] !== undefined) {
        m[p[0]] = p[1];
      }
      return m;
    }, {});

    // Compose URL with parameters in query
    const location = new URL(url);
    Object.entries(params).forEach(p => location.searchParams.set(p[0], p[1]));
    const locationString = location.toString();

    // Add parameters to header to allow parsing for programmatic use
    // of response, instead of having to parse the HTML content.
    const headers = Object.entries(params).reduce((m, p) => {
      if (p[1] !== undefined) {
        m[`${pageParamHeaderPrefix}${p[0].toLowerCase()}`] = p[1];
      }
      return m;
    }, {});

    return {
      status: 303,
      location: locationString,
      headers: headers,
    };
  }

  defaultPagePath(file) {
    return path.join(this.pagesPath, file);
  }

  composePageUrl(file, publicServerUrl, locale) {
    let url = publicServerUrl;
    url += url.endsWith('/') ? '' : '/';
    url += this.pagesEndpoint + '/';
    url += locale === undefined ? '' : locale + '/';
    url += file;
    return url;
  }

  notFound() {
    return {
      text: 'Not found.',
      status: 404,
    };
  }

  invalidRequest() {
    const error = new Error();
    error.status = 403;
    error.message = 'unauthorized';
    throw error;
  }

  /**
   * Sets the Parse Server configuration in the request object to make it
   * easily accessible throughtout request processing.
   * @param {Object} req The request.
   * @param {Boolean} failGracefully Is true if failing to set the config should
   * not result in an invalid request response. Default is `false`.
   */
  setConfig(req, failGracefully = false) {
    req.config = Config.get(req.params.appId || req.query.appId);
    if (!req.config && !failGracefully) {
      this.invalidRequest();
    }
    return Promise.resolve();
  }

  mountPagesRoutes() {
    this.route(
      'GET',
      `/${this.pagesEndpoint}/:appId/verify_email`,
      req => {
        this.setConfig(req);
      },
      req => {
        return this.verifyEmail(req);
      }
    );

    this.route(
      'POST',
      `/${this.pagesEndpoint}/:appId/resend_verification_email`,
      req => {
        this.setConfig(req);
      },
      req => {
        return this.resendVerificationEmail(req);
      }
    );

    this.route(
      'GET',
      `/${this.pagesEndpoint}/choose_password`,
      req => {
        this.setConfig(req);
      },
      req => {
        return this.passwordReset(req);
      }
    );

    this.route(
      'POST',
      `/${this.pagesEndpoint}/:appId/request_password_reset`,
      req => {
        this.setConfig(req);
      },
      req => {
        return this.resetPassword(req);
      }
    );

    this.route(
      'GET',
      `/${this.pagesEndpoint}/:appId/request_password_reset`,
      req => {
        this.setConfig(req);
      },
      req => {
        return this.requestResetPassword(req);
      }
    );
  }

  mountCustomRoutes() {
    for (const route of this.pagesConfig.customRoutes || []) {
      this.route(
        route.method,
        `/${this.pagesEndpoint}/:appId/${route.path}`,
        req => {
          this.setConfig(req);
        },
        async req => {
          const { file, query = {} } = (await route.handler(req)) || {};

          // If route handler did not return a page send 404 response
          if (!file) {
            return this.notFound();
          }

          // Send page response
          const page = new Page({ id: file, defaultFile: file });
          return this.goToPage(req, page, query, false);
        }
      );
    }
  }

  mountStaticRoute() {
    this.route(
      'GET',
      `/${this.pagesEndpoint}/*resource`,
      req => {
        this.setConfig(req, true);
      },
      req => {
        return this.staticRoute(req);
      }
    );
  }

  expressRouter() {
    const router = express.Router();
    router.use('/', super.expressRouter());
    return router;
  }
}

export default PagesRouter;
module.exports = {
  PagesRouter,
  pageParamHeaderPrefix,
  pageParams,
  pages,
};
