/**
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 _ = require('lodash');

const os = require('os');
const process = require('process');
const path = require('path');
const fs = require('fs');
const yaml = require('js-yaml');
const stringify = require('json-stable-stringify');

const common = require('./options');
const configOptions = common.options;
const util = module.exports;

/**
 * Sets initial config values to the config.
 * Passthrough for _.set, which records whether or not
 * the property was set from its default value.
 *
 * @param {Config} conf the config. mutates.
 * @param {string} name name of the option, eg 'agent.logger.stdout'
 * @param {*} value
 * @param {boolean} def set from default or not
 */
function set(conf, name, value, def, origin) {
  // use _.set so we can nest properties for names like 'application.vulnerability.tags'
  // and ensure 'var t = config.application.vulnerability.tags' is not going
  // to throw anything like 'can't read property tagRanges of undefined'
  _.set(conf, name, value);
  conf._default[name] = def;
  // the _flat array is only used for logging the config
  if (!name.startsWith('api.')) {
    conf._flat[name] = value;
  }
  conf._sources[name] = origin;
}

class ConfigurationError extends Error {
  constructor(message) {
    super(`Configuration Error: ${message}`);
  }
}

class Config {
  constructor() {
    this._default = {};
    this._flat = {};
    this._sources = {};

    // all properties on node.agent should always exist, so that
    // no code needs to check whether they exist or not. this rule
    // assures minimal need to use _.get().
    this.agent = {
      node: {
        agent: {},
        api: {},
        application: {},
        inventory: {},
        assess: {},
        protect: {},
        server: {},
        dev: {},
      },
    };
  }

  /**
   * Checks whether or not an option was set by its default
   *
   * @param {string} name name of the option, eg 'agent.logger.stdout'
   * @return {true} Description
   */
  isDefault(name) {
    return this._default[name];
  }

  getOrigin(name) {
    return this._sources[name];
  }

  query(name) {
    const isDefault = this.isDefault(name);
    const value = this.get(name);

    return { isDefault, value };
  }

  /**
   * Override the setting, if set from its default.
   *
   * @param {string} name name of the option, eg 'agent.logger.stdout'
   * @param {*} value
   * @return {boolean} whether or not we overrode the value
   */
  override(name, value) {
    if (this.isDefault(name)) {
      set(this, name, value, true);
      return true;
    }

    return false;
  }

  /**
   * Convenience method/passthrough for _.get
   */
  get(name) {
    return _.get(this, name.split('.'));
  }

  /**
   * Convenience method/passthrough for _.set
   */
  set(name, value) {
    _.set(this, name, value);
  }

  /**
   * Custom validation logic.
   * @throws {Error}
   */
  validate() {
    // Mutually exclusive options:
    if (
      this.get('application.session_id') &&
      this.get('application.session_metadata')
    ) {
      throw new ConfigurationError(
        'Cannot set both `application.session_id` and `application.session_metadata`'
      );
    }
  }
}

/**
 * Find location of config given options and name of config file
 * @return {string|void} path, if valid
 */
function checkConfigPath() {
  const configDir =
    os.platform() === 'win32'
      ? `${process.env['ProgramData']}\\contrast`
      : '/etc/contrast';
  const configSubDir = `${configDir}${path.sep}node`;

  for (const dir of [process.cwd(), configSubDir, configDir]) {
    const checkPath = path.resolve(dir, 'contrast_security.yaml');
    if (fs.existsSync(checkPath)) {
      return checkPath;
    }
  }
  return;
}

/**
 * @param {Object} cliOptions
 * @param {string} cliOptions.script
 * @param {string} cliOptions.configFile
 * @return {string|void} path, if valid.
 */
function getConfigPath(cliOptions) {
  return (
    cliOptions.configFile ||
    process.env['CONTRAST_CONFIG_PATH'] ||
    checkConfigPath()
  );
}

/**
 * Read config into object
 * @param {Object} cliOptions
 * @param {string} cliOptions.script
 * @param {string} cliOptions.configFile
 * @return {Object} configuration
 */
function readConfig(cliOptions, logger) {
  const configPath = getConfigPath(cliOptions);
  let config = {};
  let fileContents;

  if (configPath) {
    try {
      fileContents = fs.readFileSync(path.resolve(configPath)).toString('utf-8');
    } catch (e) {
      logger.error(`Unable to read config file. ${e.message}`);
    }
  }

  if (fileContents) {
    // Slight hack: JSON can be parsed as YAML, so don't worry about file type.
    // Prevents having to depend on file extension, or nest try/catches to
    // parse yaml and JSON separately.
    try {
      config = yaml.load(fileContents, {
        json: true,
      });
    } catch (e) {
      logger.error(
        `YAML validator found an error in ${cliOptions.configFile} - ${e.message}`
      );
    }
  }

  return { config, configPath };
}

/**
 * We want to "un-flatten" the config, in the
 * event that there are any dot-delimited keys remaining.
 * FIXME: when we get rid of JSON, don't do this anymore
 * @param {Object} cliOptions
 * @return {Object} "un-flattened" file options
 */
util.getFileOptions = function(cliOptions, logger) {
  const { config, configPath } = readConfig(cliOptions, logger);
  return _.reduce({ ...config, configPath },
    (memo, value, key) => {
      // Merge if necessary,
      if (memo[key]) {
        _.merge(memo[key], value);
      } else {
        // but otherwise set the config path.
        _.set(memo, key, value);
      }
      return memo;
    },
    {}
  );
};

/**
 * only set autoEnv if it's a common config option
 * @param {string} name
 */
function getAutoEnv(name) {
  return name === 'configFile'
    ? undefined
    : process.env[`CONTRAST__${name.toUpperCase().replace(/\./g, '__')}`] ||
        process.env[name.toUpperCase().replace(/\./g, '__')];
}

/**
 * @param {Object} cliOptions
 * @return {Config} merged options
 */
function mergeCliOptions(cliOptions, logger) {
  const fileOptions = util.getFileOptions(cliOptions, logger);

  // eslint-disable-next-line complexity
  return configOptions.reduce((options, option) => {
    const {
      default: optDefault,
      enum: optEnum,
      fn = _.identity,
      name,
      required,
    } = option;

    const env = process.env[option.env];
    const cliFlag = cliOptions[name];
    const autoEnv = getAutoEnv(name);
    const fileFlag = _.get(fileOptions, name);

    // For some values, we want to know if we assigned by falling back to default
    // cli > env > file > default
    let isFromDefault, origin;

    if (cliFlag != null) {
      origin = 'CLI';
    } else if (env != null || autoEnv != null) {
      origin = 'ENV';
    } else if (fileFlag != null) {
      origin = 'YAML';
    }

    let value = [cliFlag, env, autoEnv, fileFlag]
      .map((v) => fn(v, logger)).find((flag) => flag !== undefined);

    // This is kind of a special case. We never set a default value for it so
    // if we want to see it in the config, it needs to be set explicitly
    if (name == 'configFile' && value == null) {
      value = fileOptions.configPath;
      origin = 'DEFAULT';
    }

    // if it's an enum, find it in the enum or set the value to default
    // ineffective if optDefault wasn't in the enum;
    // optDefault won't get passed through fn, so it needs to be valid.
    if (optEnum && optEnum.indexOf(value) === -1) {
      const defaultOption = fn(optDefault, logger);
      // If value is set but not in optEnum
      if (value) {
        logger.error(
          "'%s' is not a valid option for %s\n\
          Valid options include: %s\n\
          Setting to default option: '%s'",
          value,
          name,
          optEnum,
          defaultOption
        );
      }
      value = defaultOption;
      isFromDefault = true;
      origin = 'DEFAULT';
    }

    // set default last and separately, so that we can mark that the option was
    // set from default
    if (value === undefined) {
      if (required) {
        logger.error("Missing required option '%s'", name);
        return options;
      }
      value = fn(optDefault, logger);
      isFromDefault = true;
      origin = 'DEFAULT';
    }
    set(options, name, value, isFromDefault, origin);

    return options;
  }, new Config());
}

/**
 * When you run an appliaction with pm2 on cluster mode, pm2 attaches the
 * process.env to a property pm2_env(only for the agent since we start it with -r flag), so
 * the function checks if there is pm2_env property and merge contrast
 * related properties to process.env
 */
function mergePM2Envs() {
  if (!process.env.pm2_env) return;

  const pm2_env = JSON.parse(process.env.pm2_env);

  const contrastEnvs = ['DEBUG', 'PGHOST', 'PGPORT'];
  const objectEntries = Object.entries(pm2_env.env)
    .concat(Object.entries(pm2_env))
    .concat(['DEBUG', 'PGHOST', 'PGPORT']);

  const pm2ConfigPath =
    pm2_env.env.CONTRAST_CONFIG_PATH || pm2_env.CONTRAST_CONFIG_PATH;
  if (pm2ConfigPath) process.env.CONTRAST_CONFIG_PATH = pm2ConfigPath;

  objectEntries.forEach(([key, value]) => {
    if (
      !process.env[key] &&
      (key.toLocaleLowerCase().includes('contrast') ||
        contrastEnvs.includes(key))
    ) {
      process.env[key] = value;
    }
  });
}

util.Config = Config;
util.setup = function setup(cliOptions, logger) {
  mergePM2Envs();

  const { script, configFile } = cliOptions;
  const mergedCliOptions = mergeCliOptions(cliOptions, logger);
  if (script) mergedCliOptions.script = script;
  if (configFile) mergedCliOptions.configFile = configFile;

  mergedCliOptions.validate();

  return mergedCliOptions;
};

util.logConfig = function logConfig(mergedConfig, logger) {
  logger.info(
    'Current Configuration: %s',
    stringify(mergedConfig._flat, {
      replacer: (k, v) => (v == undefined ? null : v),
      space: 2,
    })
  );
};
