/**
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.
*/
/**
 * This is based off of/borrows from https://github.com/visionmedia/debug
 * Export a wrapper to an instance of the factory class
 *
 * Also, we use child loggers as a way to implement namespaces
 * https://github.com/winstonjs/winston/issues/1482
 */
'use strict';

const process = require('process');
const winston = require('winston');
const util = require('util');
const isPipedToDev = require('../../util/is-piped-to-dev');
const DailyRotateFile = require('./daily-rotate-file');
const Scopes = require('../../core/async-storage/scopes');

const DEFAULT_NAMESPACE = 'contrast:*';
const DEADZONE_NAME = 'logger';
const levels = {
  error: 1,
  warn: 2,
  info: 3,
  debug: 4,
  trace: 5
};
const levelNames = Object.keys(levels);

function noop() {}

class NoopLogger {
  constructor() {
    [...levelNames, 'log', 'console'].forEach((level) => {
      this[level] = noop;
    });
  }
}

/*
 * Instance used for all disabled loggers
 */
const noopLogger = new NoopLogger();

class DebugLogFactory {
  /**
   * This requires a config argument. Prior to reading the agent's config, we
   * pass a provisional config with our default values for level, append, and
   * stdout. We pass an empty path value so as to not create a file that may not
   * get written to.
   * @param {Config} config Config instance
   */
  constructor(config) {
    // regexes for namespaces which should log
    this.names = [];
    // regexes for namespaces which should not log
    this.skips = [];
    // will contain transports after configuration
    this.transports = [];
    this.formatters = [];
    this.dateFormat = 'YYYY-MM-DD';
    this.mute = false;
    this.parentLogger = null;
    this.loggers = new Map();
    this.saveNamespaces(process.env.DEBUG);
    // This is used to create child loggers.
    this.setFormatters();
    this.init(config);
  }

  /**
   * Call with a configuration defining level, path, and append and stdout. This
   * will reconfigure the parent logger via parent.configure(opts). It will also
   * update existing loggers with new settings by creating a new child logger
   * per existing namespace and merging into the existing children.
   * @param {object} config Config instance
   */
  init(config) {
    if (config) {
      const {
        level,
        path,
        backups,
        roll_size,
        stdout,
        append
      } = config.agent.logger;
      this.level = level;
      this.loggerPath = path;
      this.maxFiles = backups;
      this.maxSize = roll_size;
      this.stdout = stdout;
      this.append = append;
    }
    this.filename = this.loggerPath ? `${this.loggerPath}-%DATE%.log` : null;

    // We always log to a file, but check whether we should log to stdout
    if (
      this.loggerPath &&
      (!this.stdout ||
        !process.env.DEBUG ||
        !/(^|,\s*)contrast:.+/.test(process.env.DEBUG))
    ) {
      this.mute = true;
    }

    // if log appending is off, use date.now in the now so we always make a new file
    if (!this.append) {
      // Note: this is kind of a hack. instead of changing the date format and relying on regex,
      // just replace the format with a unix time literal
      this.dateFormat = `${Date.now()}-${process.pid}`;
    }

    // create or renew transports from config
    this.setTransports(config);

    const loggerOpts = {
      transports: this.transports,
      levels,
      level: this.level
    };

    // Create or reconfigure the parent logger. All loggers returned from this
    // factory are created using `this.parent.child({ label: namespace })`
    if (!this.parentLogger) {
      this.parentLogger = winston.createLogger(loggerOpts);
    } else {
      this.parentLogger.configure(loggerOpts);
    }
  }

  /**
   * Based on the config will build an array of transports to be used by parent
   * logger. At most there will be two transports. One Console and either a
   * DailyRotateFile or File transport depending on path config.
   */
  setTransports() {
    const {
      mute,
      transports,
      loggerPath,
      filename,
      dateFormat,
      formatters,
      maxSize,
      maxFiles
    } = this;

    transports.length = 0;

    if (!mute) {
      transports.push(
        new winston.transports.Console({
          format: winston.format.combine(
            winston.format.colorize(),
            ...formatters
          )
        })
      );
    }

    if (loggerPath) {
      if (isPipedToDev(loggerPath)) {
        transports.push(
          new winston.transports.File({
            filename: loggerPath,
            format: winston.format.combine(...formatters)
          })
        );
      } else {
        transports.push(
          new DailyRotateFile({
            filename,
            datePattern: dateFormat,
            format: winston.format.combine(...formatters),
            maxSize,
            maxFiles
          })
        );
      }
    }
  }

  /**
   * Pushes all the formatters we use into the instance's formatters collection.
   * Should be called once during instantiation.
   */
  setFormatters() {
    this.formatters.push(
      winston.format((info) => {
        if (info.message instanceof Error) {
          info.message = info.message.stack;
        } else if (info instanceof Error) {
          info.message = info.stack;
        }
        return info;
      })(),
      winston.format.timestamp(),
      winston.format.splat(),
      winston.format.printf((info) => {
        let { message } = info;
        const { level, timestamp, label } = info;
        if (typeof message !== 'string') {
          message = util.inspect(message);
        }
        // level: timestamp pid namespace - message
        return `${level}: ${timestamp} ${process.pid} ${label} - ${message}`;
      })
    );
  }

  /**
   * Captures the enabled contrast namespaces from DEBUG environment variable.
   * These include modes separated by a colon and wildcards e.g. contrast:hooks,
   * or contrast:speedracer:*. We default to logging all contrast namespaces if
   * none are specified by the env var. Also, in that case we still log to a
   * file but will disable stdout logging.
   * @param {String} namespaces
   */
  saveNamespaces(namespaces) {
    const split = (namespaces || DEFAULT_NAMESPACE).split(/[\s,]+/);
    const len = split.length;
    let namespace;

    for (let i = 0; i < len; i++) {
      if (!split[i] || !/^-?contrast.*/.test(split[i])) continue; // ignore empty strings
      namespace = split[i].replace(/\*/g, '.*?');
      if (namespace[0] === '-') {
        this.skips.push(new RegExp(`^${namespace.substr(1)}$`));
      } else {
        this.names.push(new RegExp(`^${namespace}$`));
      }
    }
  }

  /**
   * Disable debug output.
   * @public
   */
  disable() {
    this.skips.length = 0;
    this.names.length = 0;
    this.saveNamespaces('-contrast:*');
  }

  /**
   * Returns true if the given mode name is enabled, false otherwise.
   * @param {String} name namespace
   * @return {boolean}
   * @public
   */
  enabled(namespace) {
    let i, len;
    for (i = 0, len = this.skips.length; i < len; i++) {
      if (this.skips[i].test(namespace)) {
        return false;
      }
    }
    for (i = 0, len = this.names.length; i < len; i++) {
      if (this.names[i].test(namespace)) {
        return true;
      }
    }
    return false;
  }

  /**
   * Create a debug logger with the given namespace.
   *
   * @param {String} namespace
   * @return {Function}
   * @public
   */
  make(namespace) {
    namespace = DebugLogFactory.checkNamespace(namespace);

    if (!this.loggers.has(namespace)) {
      const logger = this.makeDecoratedLogger(namespace);
      this.loggers.set(namespace, logger);
    }

    return this.loggers.get(namespace);
  }

  /**
   * Sets namespace on the loggers, Noops disabled loggers, and
   * ensures enabled loggers call logging functions in NO_INSTRUMENTATION scope.
   *
   * @param {} namespace
   * @returns {}
   */
  makeDecoratedLogger(namespace) {
    const logger = this.parentLogger.child({ label: namespace });

    if (!this.enabled(namespace)) {
      return noopLogger;
    }

    logger.console = this.mute
      ? noop
      : function() {
        return console.log(...arguments); // eslint-disable-line prefer-rest-params
      };

    [...levelNames, 'console'].forEach((level) => {
      if (level === 'console') {
        const orig = logger[level].bind(logger);
        logger[level] = function(...args) {
          return Scopes.runInNoInstrumentationScope(
            () => orig(...args),
            DEADZONE_NAME
          );
        };
      } else {
        logger[level] = function(message, ...splat) {
          return Scopes.runInNoInstrumentationScope(
            () => logger.log({ level, message, splat }),
            DEADZONE_NAME
          );
        };
      }
    });

    return logger;
  }

  /**
   * Ensure that namespace used begins with contrast:*
   * so that logs are not lost when DEBUG=contrast:* is set.
   * To this end, we don't care if it ends up being contrast:contrest:whatever
   * @param   {String} namespace the registered namespace
   * @returns {String}           the namespace with contrast: prepended.
   */
  static checkNamespace(namespace) {
    if (!namespace.startsWith('contrast:')) {
      return `contrast:${namespace}`;
    }
    return namespace;
  }
}

winston.addColors({
  error: 'red',
  warn: 'yellow',
  info: 'green',
  debug: 'blue',
  trace: 'blue'
});

module.exports = DebugLogFactory;
module.exports.DebugFactory = DebugLogFactory;
module.exports.NoopLogger = NoopLogger;
