/**
Copyright: 2024 Contrast Security, Inc
  Contact: support@contrastsecurity.com
  License: Commercial

  NOTICE: This Software and the patented inventions embodied within may only be
  used as part of Contrast Security’s commercial offerings. Even though it is
  made available through public repositories, use of this Software is subject to
  the applicable End User Licensing Agreement found at
  https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
  between Contrast Security and the End User. The Software may not be reverse
  engineered, modified, repackaged, sold, redistributed or otherwise used in a
  way not consistent with the End User License Agreement.
*/
'use strict';

const util = require('util');
const emitter = require('../../agent-emitter');
const logger = require('../../core/logger')('contrast:sensors:express');
const constants = require('../../constants');
const { isString } = require('../../util/is-string');
const patcher = require('../../hooks/patcher');
const { PATCH_TYPES } = require('../../constants');
const fnInspect = require('@contrast/fn-inspect');
const _ = require('lodash');
const { uniqueId, getHandlerName } = require('../../util/route-info');

const {
  LC_HTTP_VERBS,
  WORD_SHARE: { COMMA, SPACE },
  MW_PATH
} = constants;

const EVENTS = {
  REQUEST_READY: 'Express.RequestReady',
  BODY_PARSED: 'Express.bodyParsed',
  COOKIES_PARSED: 'Express.cookiesParsed',
  PARAMS_PARSED: 'Express.paramsParsed',
  REQUEST_SEND: 'Express.requestSend',

  SERVER_LISTEN: 'App.listen',
  APP_USE: 'App.use',
  APP_METHOD: 'App.{{method}}',
  ROUTE_METHOD: 'Route.{{method}}',
  ROUTER_METHOD: 'Router.{{method}}',
  ROUTER_ROUTE: 'Router.route({{path}}).{{method}}',
  ROUTER_USE: 'Router.use' // Same as `App.use`
};

const EXPRESS_CLASSES = {
  APP: 'App',
  ROUTE: 'Route',
  ROUTER: 'Router'
};

const ID_KEY = Symbol('@@__id__@@');
const SIG_KEY = Symbol('@@__signature__@@');

/**
 * Returns the last layer in the provided router's stack.
 * @param {App|Router|Route} router
 * @returns {Layer}
 */
const getLastLayer = (router) => {
  if (router._router) {
    const idx = router._router.stack.length - 1;
    return router._router.stack[idx];
  }
  if (router.stack) {
    const idx = router.stack.length - 1;
    return router.stack[idx];
  }
  if (router.handle && router.handle.stack) {
    const idx = router.handle.stack.length - 1;
    return router.handle.stack[idx];
  }
  if (router.route && router.route.stack) {
    const idx = router.route.stack.length - 1;
    return router.route.stack[idx];
  }

  logger.warn(
    'Express Router stack not found for router:\n%s',
    util.inspect(router, { depth: null })
  );
  return null;
};

/**
 * Shared method that uses local property descriptor to add
 * Contrast-specific properties to Layer instances in order
 * to gather route data from the application stack.
 * @param {Object} item Object on which to add key/value
 * @param {String} key
 * @param {*} value
 */
const capture = (item, key, value) => {
  if (!item) {
    return null;
  }
  Object.defineProperty(item, key, {
    value,
    enumerable: false,
    configurable: true
  });
};

/**
 * Adds ID value to the Layer
 * @param {Layer} layer Layer on which to add ID
 * @returns {String} The new ID
 */
const captureId = (layer) => {
  if (!layer) {
    return null;
  }

  if (!layer[ID_KEY]) {
    capture(layer, ID_KEY, uniqueId());
  }

  return layer[ID_KEY];
};

/**
 * Given the first argument to a route-registration function
 * (e.g. <code>Router.use</code>, <code>Router.route</code>)
 * will return the value of the path segment data that will be
 * captured on the layer for when we do discovery.
 * @param {*} path Argument used in registration method
 * @returns {*}
 */
const resolvePath = (path) => {
  if (typeof path === 'function') {
    return '';
  } else if (isString(path)) {
    return path.valueOf();
  } else {
    // Array, RegExp
    return path;
  }
};

/**
 * Filters a list of arguments and returns only the items which
 * implement the Router interface.
 * @param {*[]} fnArgs List of items
 * @returns {Object[]}
 */
const resolveRouters = (fnArgs) =>
  fnArgs.filter((fn) => isRouter(fn) || isApp(fn));

/**
 * Whether the given function is an Express App instance.
 * @param {Function} fn Function to check
 * @returns {Boolean}
 */
const isApp = (fn) =>
  // Verify all versions of Express
  fn && fn.handle && fn.set && fn.request && fn.response;
/**
 * Whether the given function is an Express Router instance.
 * depending on the express version this is represented as
 * .handle or if the name is "router"
 * @param {Function} fn Function to check
 * @returns {Boolean}
 */
const isRouter = (fn) => fn && fn.handle && fn.name === 'router' && (fn.handle.name === 'handle' || isRouter(fn.handle));

/**
 * Iterates through an Express stack and generates route data. This function
 * requires that we've already tagged each <code>Layer</code> with the ID, path,
 * and signature values at a previous stage of instrumentation.
 * @param {Layer[]} stack           The express application stack
 * @param {Set}     visitedRouters  set of visited routers to prevent inf. recursion.
 * @param {Object} [coll]           The route data we're collecting
 * @returns {Object}
 */
const generateRouteInfos = (stack, visitedRouters = new Set(), coll = {}) => {
  if (!stack) {
    return coll;
  }

  if (visitedRouters.has(stack)) {
    return coll;
  }

  visitedRouters.add(stack);

  for (const layer of stack) {
    const id = layer[ID_KEY];

    if (id) {
      const signature = layer[SIG_KEY];
      coll[id] = {
        id,
        signature
      };
    }

    if (layer.handle && layer.handle.stack) {
      generateRouteInfos(layer.handle.stack, visitedRouters, coll);
    } else if (layer.route && layer.route.stack) {
      generateRouteInfos(layer.route.stack, visitedRouters, coll);
    }
  }

  return coll;
};

/**
 * Returns a signature string built from a set of params.
 * @param {String} className App, Router, or Route
 * @param {String} method
 * @param {Array[*]|arguments} args
 * @returns {String}
 */
const createSignature = (self, className, method, args) => {
  // compacting array because if an arg of signature
  // contains Contrast we return null to remove from final signature
  const sigArgs = _.compact([...args].map(normalizeSignatureArg)).join(
    `${COMMA}${SPACE}`
  );
  const methodInvocation = `${className}.${method}(${sigArgs})`;

  return methodInvocation;
};

/**
 * Adds key/value to layer for signature.
 * @param {Layer} layer The layer to which to add signature
 * @param {String} signature The signature string
 * @returns {Layer}
 */
const captureSignature = (layer, signature) => {
  capture(layer, SIG_KEY, signature);
  return layer;
};

/**
 * Normalizes an argument passed to one of the route registration
 * APIs for express.
 * @param {*} arg The argument to fix for signature/route representation
 * @returns {}
 */
const normalizeSignatureArg = (arg) => {
  // we weave in middleware for express that is prefixed with Contrast
  // remove this from signature
  if (arg === '') {
    return null;
  } else if (typeof arg === 'function' && arg.name.startsWith('Contrast')) {
    return null;
  } else if (typeof arg === 'function') {
    return getHandlerName(arg);
  } else if (Array.isArray(arg)) {
    return `[${arg.map(normalizeSignatureArg).join()}]`;
  }

  return `"${normalizeSegment(arg)}"`;
};

/**
 * Takes a value representing a path segment registred by Express API
 * and returns it normalized to a string wich we'll use for reporting.
 * @param {String|RegExp} segment URL segment registered by framework
 * @returns {String} The normalized string representation
 */
const normalizeSegment = (segment) => {
  if (isString(segment)) {
    const paramMatch = /\/(:([^\\/]+))/;
    if (paramMatch.test(segment)) {
      let matched;
      while ((matched = paramMatch.exec(segment))) {
        segment = segment.replace(matched[1], `{${matched[2]}}`);
      }
    }
  } else if (segment instanceof RegExp) {
    segment = segment.toString().replace(/(^\/?)|(\/?$)/g, '');
    segment = `/{${segment}}`;
  } else if (Array.isArray(segment)) {
    segment = segment.map((s) => `${normalizeSignatureArg(`${s}`)}`);
    segment = `[${segment}]`;
  }

  return segment;
};

/**
 * Abstracted helper for decorating and handling capturing signatures for
 * router.<method> or app.<method>
 * @param {Object} self ExpressSource instance
 * @param {Object} event metadata about the router.<method> or app.<method> implementation
 * @param {String} className name of class for signature creation
 *
 */
const handleMethodDecoration = (self, event, className) => {
  const { args, stack, verb } = event;
  const signature = createSignature(self, className, verb, args);

  stack.map((layer) => captureSignature(layer, signature));
};

/**
 * If a router is passed in as an argument to App/Router.use, go through each layer,
 * extract the route and prepend the prefix to it, create and capture the updated signature
 * @param {*} self ExpressSource instance
 * @param {*} router express router instance
 * @param {*} path Route prefix as string
 */
const updateRouterSignatures = (self, router, path) => {
  // update signature for each route of the router
  router.handle.stack.forEach((layer) => {
    const { route } = layer;
    if (!route) {
      return;
    }
    const routePath = _.get(route, 'path', '');
    const routeHandle = _.get(route, 'stack[0].handle', '');
    const routeMethod = _.get(route, 'stack[0].method');
    if (!routeMethod) {
      return;
    }
    const newSignature = createSignature(self, 'Router', routeMethod, [
      path,
      routePath,
      routeHandle
    ]);
    route.stack.map((layer) => captureSignature(layer, newSignature));
  });
};

/**
 * Abstracted helper for decorating and handling capturing signatures for
 * router.use or app.use
 * @param {Object} self ExpressSource instance
 * @param {Object} event metadata about the router.<method> or app.<method> implementation
 * @param {Object} className name of class for signature creation (e.g. App or Router)
 */
const handleUseDecoration = (self, event, className) => {
  const { args, router } = event;
  const path = resolvePath(args[0]);
  const layer = getLastLayer(router);
  layer[MW_PATH] = path;
  const stack = router._router && router._router.stack;
  if (isRouter(layer)) {
    updateRouterSignatures(self, layer, path);
  }
  if (validRoute(path, layer)) {
    const id = captureId(layer);

    captureSignature(layer, createSignature(self, className, 'use', args));
    instrumentHandler(layer, id, self, stack);
  }
};

/**
 * Patch layer's `handle()` method to to emit observation event when called.
 * Note:
 * We first look at whether the layer has a `__handle` method with name `newFn`.
 * If so we patch that instead. We do this because `express-async-errors`
 * defines a setter on `Layer.prototype.handle` which wraps the value being
 * assigned with a function of that name. The wrapped function adds a `.catch`
 * handler to forward Promise-caught exceptions to the `next` callback.
 * Therefore if `handle` is EVER reassigned, the value will be "double-wrapped"
 * and caught errors will be sent to `next(err)` multiple times which we don't
 * want.
 * See:
 * https://github.com/davidbanham/express-async-errors/blob/master/index.js#L42
 * @param {Layer} layer the layer of the handler we want to instrument
 * @param {String} id  the id of the layer
 * @param {Object} self ExpressSource instance
 * @param {Layer[]} stack Express application stack
 */
const instrumentHandler = (layer, id, self, stack) => {
  if (!layer) return;

  const methodName = getLayerHandleMethod(layer);

  patcher.patch(layer, methodName, {
    name: `express-layer-handler-${methodName}`,
    patchType: PATCH_TYPES.ROUTE_COVERAGE,
    alwaysRun: true,
    pre(data) {
      try {
        const [req] = data.args;
        const verb = req.method && req.method.toLowerCase();
        const url = req.originalUrl;
        if (id && verb) {
          emitter.emit('route-observation', {
            id,
            verb,
            url,
            stack,
            discoveryMethod: RouteCoverage.discoverRoutes
          });
        } else {
          logger.warn(
            'Unable to report route observation; id and verb unknown'
          );
        }
      } catch (err) {
        logger.error(
          'Unable to directly patch express handler: %s',
          err.message
        );
      }
    }
  });
};

/**
 * Determines which method on the later to patch. If 'express-async-errors'
 * has installed `__handle`, prefer that to `handle`. See `instrumentHandler`.
 * @param {Layer} layer Layer instance
 * @returns {string} The name of the method we should patch
 */
function getLayerHandleMethod(layer) {
  let methodName = 'handle';
  const __handleData = fnInspect.funcInfo(layer.__handle);
  if (__handleData && __handleData.file.includes('express-async-errors')) {
    methodName = '__handle';
  }
  return methodName;
}

/**
 * @param {string} path route as a string
 * @param {Layer} args The layer functioning as a handler for a given route
 */
const validRoute = (path, layer) => {
  if (isString(path) && path.startsWith('/') && !isRouter(layer)) {
    return true;
  } else {
    return false;
  }
};

class RouteCoverage {
  static handleDiscoveryEvent(self, eventName, event) {
    if (!event) {
      logger.debug('route event is undefined');
      return;
    }
    switch (eventName) {
      case EVENTS.APP_USE: {
        handleUseDecoration(self, event, EXPRESS_CLASSES.APP);
        return;
      }

      case EVENTS.APP_METHOD: {
        handleMethodDecoration(self, event, EXPRESS_CLASSES.APP);
        return;
      }

      case EVENTS.ROUTER_USE: {
        handleUseDecoration(self, event, EXPRESS_CLASSES.ROUTER);
        return;
      }

      case EVENTS.ROUTER_METHOD: {
        handleMethodDecoration(self, event, EXPRESS_CLASSES.ROUTER);
        return;
      }

      case EVENTS.ROUTER_ROUTE: {
        const { args, route, stack } = event;
        const path = resolvePath(args[0]);

        ['all'].concat(LC_HTTP_VERBS).forEach((verb) => {
          patcher.patch(route, verb, {
            name: `express-router-${verb}`,
            patchType: PATCH_TYPES.ROUTE_COVERAGE,
            alwaysRun: true,
            pre(data) {
              // There's a new stack item for each fn handler.
              if (this.stack) {
                data.nextIdx = this.stack.length;
              }
            },
            post(data) {
              // if the stack didn't actually grow
              // ie if router.get('/') with no callback is called
              // This is done to cache routes in Express, so we want to
              // skip this. In older versions of express stack will be undefined.
              if (!this.stack || this.stack.length === data.nextIdx) {
                logger.debug('express router called without a callback.');
                data.result = undefined;
                return;
              }

              // take the first piece of user middleware on a route
              // to decorate
              const layer = this.stack[data.nextIdx];
              RouteCoverage.handleDiscoveryEvent(self, EVENTS.ROUTE_METHOD, {
                stack,
                layer,
                path
              });
            }
          });
        });
        return;
      }

      // uses the first layer(middleware) on route so we can
      // decorate the .handle to emit route-obervation event
      // with proper url/verb
      case EVENTS.ROUTE_METHOD: {
        const { layer, path, stack } = event;
        const id = captureId(layer);

        captureSignature(
          layer,
          createSignature(self, EXPRESS_CLASSES.ROUTER, 'route', [path])
        );

        instrumentHandler(layer, id, self, stack);

        return;
      }
      default: {
        logger.debug('route event is unknown %s', eventName);
        return;
      }
    }
  }

  /**
   * Traverses the application's stack and capture data that was pinned
   * to the Routers from instrumentation.
   */
  static discoverRoutes(self, stack) {
    const visitedRouters = new Set();
    const routeInfos = generateRouteInfos(stack, visitedRouters);

    if (self.routeInfos) {
      // Support with https://contrast.atlassian.net/browse/CONTRAST-25376
      // Merge routeInfos with this.routeInfos.
    } else {
      self.routeInfos = routeInfos;
    }

    emitter.emit('route-discovery', routeInfos, stack);
  }
}

module.exports = {
  // Helper Functions
  capture,
  captureId,
  captureSignature,
  isApp,
  isRouter,
  resolvePath,
  resolveRouters,
  generateRouteInfos,
  getLastLayer,
  createSignature,
  normalizeSignatureArg,
  normalizeSegment,
  validRoute,
  // Helper Classes
  RouteCoverage,
  // Constants
  EVENTS,
  EXPRESS_CLASSES,
  instrumentHandler,
  // Symbols exported for tests
  ID_KEY,
  SIG_KEY
};
