const dateFormat = require('date-format');
const os = require('os');
const util = require('util');
const path = require('path');
const url = require('url');
const debug = require('debug')('log4js:layouts');

const styles = {
  // styles
  bold: [1, 22],
  italic: [3, 23],
  underline: [4, 24],
  inverse: [7, 27],
  // grayscale
  white: [37, 39],
  grey: [90, 39],
  black: [90, 39],
  // colors
  blue: [34, 39],
  cyan: [36, 39],
  green: [32, 39],
  magenta: [35, 39],
  red: [91, 39],
  yellow: [33, 39],
};

function colorizeStart(style) {
  return style ? `\x1B[${styles[style][0]}m` : '';
}

function colorizeEnd(style) {
  return style ? `\x1B[${styles[style][1]}m` : '';
}

/**
 * Taken from masylum's fork (https://github.com/masylum/log4js-node)
 */
function colorize(str, style) {
  return colorizeStart(style) + str + colorizeEnd(style);
}

function timestampLevelAndCategory(loggingEvent, colour) {
  return colorize(
    util.format(
      '[%s] [%s] %s - ',
      dateFormat.asString(loggingEvent.startTime),
      loggingEvent.level.toString(),
      loggingEvent.categoryName
    ),
    colour
  );
}

/**
 * BasicLayout is a simple layout for storing the logs. The logs are stored
 * in following format:
 * <pre>
 * [startTime] [logLevel] categoryName - message\n
 * </pre>
 *
 * @author Stephan Strittmatter
 */
function basicLayout(loggingEvent) {
  return (
    timestampLevelAndCategory(loggingEvent) + util.format(...loggingEvent.data)
  );
}

/**
 * colouredLayout - taken from masylum's fork.
 * same as basicLayout, but with colours.
 */
function colouredLayout(loggingEvent) {
  return (
    timestampLevelAndCategory(loggingEvent, loggingEvent.level.colour) +
    util.format(...loggingEvent.data)
  );
}

function messagePassThroughLayout(loggingEvent) {
  return util.format(...loggingEvent.data);
}

function dummyLayout(loggingEvent) {
  return loggingEvent.data[0];
}

/**
 * PatternLayout
 * Format for specifiers is %[padding].[truncation][field]{[format]}
 * e.g. %5.10p - left pad the log level by 5 characters, up to a max of 10
 * both padding and truncation can be negative.
 * Negative truncation = trunc from end of string
 * Positive truncation = trunc from start of string
 * Negative padding = pad right
 * Positive padding = pad left
 *
 * Fields can be any of:
 *  - %r time in toLocaleTimeString format
 *  - %p log level
 *  - %c log category
 *  - %h hostname
 *  - %m log data
 *  - %m{l} where l is an integer, log data.slice(l)
 *  - %m{l,u} where l and u are integers, log data.slice(l, u)
 *  - %d date in constious formats
 *  - %% %
 *  - %n newline
 *  - %z pid
 *  - %f filename
 *  - %l line number
 *  - %o column postion
 *  - %s call stack
 *  - %C class name [#1316](https://github.com/log4js-node/log4js-node/pull/1316)
 *  - %M method or function name [#1316](https://github.com/log4js-node/log4js-node/pull/1316)
 *  - %A method or function alias [#1316](https://github.com/log4js-node/log4js-node/pull/1316)
 *  - %F fully qualified caller name [#1316](https://github.com/log4js-node/log4js-node/pull/1316)
 *  - %x{<tokenname>} add dynamic tokens to your log. Tokens are specified in the tokens parameter
 *  - %X{<tokenname>} add dynamic tokens to your log. Tokens are specified in logger context
 * You can use %[ and %] to define a colored block.
 *
 * Tokens are specified as simple key:value objects.
 * The key represents the token name whereas the value can be a string or function
 * which is called to extract the value to put in the log message. If token is not
 * found, it doesn't replace the field.
 *
 * A sample token would be: { 'pid' : function() { return process.pid; } }
 *
 * Takes a pattern string, array of tokens and returns a layout function.
 * @return {Function}
 * @param pattern
 * @param tokens
 * @param timezoneOffset
 *
 * @authors ['Stephan Strittmatter', 'Jan Schmidle']
 */
function patternLayout(pattern, tokens) {
  const TTCC_CONVERSION_PATTERN = '%r %p %c - %m%n';
  const regex =
    /%(-?[0-9]+)?(\.?-?[0-9]+)?([[\]cdhmnprzxXyflosCMAF%])(\{([^}]+)\})?|([^%]+)/;

  pattern = pattern || TTCC_CONVERSION_PATTERN;

  function categoryName(loggingEvent, specifier) {
    let loggerName = loggingEvent.categoryName;
    if (specifier) {
      const precision = parseInt(specifier, 10);
      const loggerNameBits = loggerName.split('.');
      if (precision < loggerNameBits.length) {
        loggerName = loggerNameBits
          .slice(loggerNameBits.length - precision)
          .join('.');
      }
    }
    return loggerName;
  }

  function formatAsDate(loggingEvent, specifier) {
    let format = dateFormat.ISO8601_FORMAT;
    if (specifier) {
      format = specifier;
      // Pick up special cases
      switch (format) {
        case 'ISO8601':
        case 'ISO8601_FORMAT':
          format = dateFormat.ISO8601_FORMAT;
          break;
        case 'ISO8601_WITH_TZ_OFFSET':
        case 'ISO8601_WITH_TZ_OFFSET_FORMAT':
          format = dateFormat.ISO8601_WITH_TZ_OFFSET_FORMAT;
          break;
        case 'ABSOLUTE':
          process.emitWarning(
            'Pattern %d{ABSOLUTE} is deprecated in favor of %d{ABSOLUTETIME}. ' +
              'Please use %d{ABSOLUTETIME} instead.',
            'DeprecationWarning',
            'log4js-node-DEP0003'
          );
          debug(
            '[log4js-node-DEP0003]',
            'DEPRECATION: Pattern %d{ABSOLUTE} is deprecated and replaced by %d{ABSOLUTETIME}.'
          );
        // falls through
        case 'ABSOLUTETIME':
        case 'ABSOLUTETIME_FORMAT':
          format = dateFormat.ABSOLUTETIME_FORMAT;
          break;
        case 'DATE':
          process.emitWarning(
            'Pattern %d{DATE} is deprecated due to the confusion it causes when used. ' +
              'Please use %d{DATETIME} instead.',
            'DeprecationWarning',
            'log4js-node-DEP0004'
          );
          debug(
            '[log4js-node-DEP0004]',
            'DEPRECATION: Pattern %d{DATE} is deprecated and replaced by %d{DATETIME}.'
          );
        // falls through
        case 'DATETIME':
        case 'DATETIME_FORMAT':
          format = dateFormat.DATETIME_FORMAT;
          break;
        // no default
      }
    }
    // Format the date
    return dateFormat.asString(format, loggingEvent.startTime);
  }

  function hostname() {
    return os.hostname().toString();
  }

  function formatMessage(loggingEvent, specifier) {
    let dataSlice = loggingEvent.data;
    if (specifier) {
      const [lowerBound, upperBound] = specifier.split(',');
      dataSlice = dataSlice.slice(lowerBound, upperBound);
    }
    return util.format(...dataSlice);
  }

  function endOfLine() {
    return os.EOL;
  }

  function logLevel(loggingEvent) {
    return loggingEvent.level.toString();
  }

  function startTime(loggingEvent) {
    return dateFormat.asString('hh:mm:ss', loggingEvent.startTime);
  }

  function startColour(loggingEvent) {
    return colorizeStart(loggingEvent.level.colour);
  }

  function endColour(loggingEvent) {
    return colorizeEnd(loggingEvent.level.colour);
  }

  function percent() {
    return '%';
  }

  function pid(loggingEvent) {
    return loggingEvent && loggingEvent.pid
      ? loggingEvent.pid.toString()
      : process.pid.toString();
  }

  function clusterInfo() {
    // this used to try to return the master and worker pids,
    // but it would never have worked because master pid is not available to workers
    // leaving this here to maintain compatibility for patterns
    return pid();
  }

  function userDefined(loggingEvent, specifier) {
    if (typeof tokens[specifier] !== 'undefined') {
      return typeof tokens[specifier] === 'function'
        ? tokens[specifier](loggingEvent)
        : tokens[specifier];
    }

    return null;
  }

  function contextDefined(loggingEvent, specifier) {
    const resolver = loggingEvent.context[specifier];

    if (typeof resolver !== 'undefined') {
      return typeof resolver === 'function' ? resolver(loggingEvent) : resolver;
    }

    return null;
  }

  function fileName(loggingEvent, specifier) {
    let filename = loggingEvent.fileName || '';

    // support for ESM as it uses url instead of path for file
    /* istanbul ignore next: unsure how to simulate ESM for test coverage */
    const convertFileURLToPath = function (filepath) {
      const urlPrefix = 'file://';
      if (filepath.startsWith(urlPrefix)) {
        // https://nodejs.org/api/url.html#urlfileurltopathurl
        if (typeof url.fileURLToPath === 'function') {
          filepath = url.fileURLToPath(filepath);
        }
        // backward-compatible for nodejs pre-10.12.0 (without url.fileURLToPath method)
        else {
          // posix: file:///hello/world/foo.txt -> /hello/world/foo.txt -> /hello/world/foo.txt
          // win32: file:///C:/path/foo.txt     -> /C:/path/foo.txt     -> \C:\path\foo.txt     -> C:\path\foo.txt
          // win32: file://nas/foo.txt          -> //nas/foo.txt        -> nas\foo.txt          -> \\nas\foo.txt
          filepath = path.normalize(
            filepath.replace(new RegExp(`^${urlPrefix}`), '')
          );
          if (process.platform === 'win32') {
            if (filepath.startsWith('\\')) {
              filepath = filepath.slice(1);
            } else {
              filepath = path.sep + path.sep + filepath;
            }
          }
        }
      }
      return filepath;
    };
    filename = convertFileURLToPath(filename);

    if (specifier) {
      const fileDepth = parseInt(specifier, 10);
      const fileList = filename.split(path.sep);
      if (fileList.length > fileDepth) {
        filename = fileList.slice(-fileDepth).join(path.sep);
      }
    }

    return filename;
  }

  function lineNumber(loggingEvent) {
    return loggingEvent.lineNumber ? `${loggingEvent.lineNumber}` : '';
  }

  function columnNumber(loggingEvent) {
    return loggingEvent.columnNumber ? `${loggingEvent.columnNumber}` : '';
  }

  function callStack(loggingEvent) {
    return loggingEvent.callStack || '';
  }

  function className(loggingEvent) {
    return loggingEvent.className || '';
  }

  function functionName(loggingEvent) {
    return loggingEvent.functionName || '';
  }

  function functionAlias(loggingEvent) {
    return loggingEvent.functionAlias || '';
  }

  function callerName(loggingEvent) {
    return loggingEvent.callerName || '';
  }

  const replacers = {
    c: categoryName,
    d: formatAsDate,
    h: hostname,
    m: formatMessage,
    n: endOfLine,
    p: logLevel,
    r: startTime,
    '[': startColour,
    ']': endColour,
    y: clusterInfo,
    z: pid,
    '%': percent,
    x: userDefined,
    X: contextDefined,
    f: fileName,
    l: lineNumber,
    o: columnNumber,
    s: callStack,
    C: className,
    M: functionName,
    A: functionAlias,
    F: callerName,
  };

  function replaceToken(conversionCharacter, loggingEvent, specifier) {
    return replacers[conversionCharacter](loggingEvent, specifier);
  }

  function truncate(truncation, toTruncate) {
    let len;
    if (truncation) {
      len = parseInt(truncation.slice(1), 10);
      // negative truncate length means truncate from end of string
      return len > 0 ? toTruncate.slice(0, len) : toTruncate.slice(len);
    }

    return toTruncate;
  }

  function pad(padding, toPad) {
    let len;
    if (padding) {
      if (padding.charAt(0) === '-') {
        len = parseInt(padding.slice(1), 10);
        // Right pad with spaces
        while (toPad.length < len) {
          toPad += ' ';
        }
      } else {
        len = parseInt(padding, 10);
        // Left pad with spaces
        while (toPad.length < len) {
          toPad = ` ${toPad}`;
        }
      }
    }
    return toPad;
  }

  function truncateAndPad(toTruncAndPad, truncation, padding) {
    let replacement = toTruncAndPad;
    replacement = truncate(truncation, replacement);
    replacement = pad(padding, replacement);
    return replacement;
  }

  return function (loggingEvent) {
    let formattedString = '';
    let result;
    let searchString = pattern;

    while ((result = regex.exec(searchString)) !== null) {
      // const matchedString = result[0];
      const padding = result[1];
      const truncation = result[2];
      const conversionCharacter = result[3];
      const specifier = result[5];
      const text = result[6];

      // Check if the pattern matched was just normal text
      if (text) {
        formattedString += text.toString();
      } else {
        // Create a raw replacement string based on the conversion
        // character and specifier
        const replacement = replaceToken(
          conversionCharacter,
          loggingEvent,
          specifier
        );
        formattedString += truncateAndPad(replacement, truncation, padding);
      }
      searchString = searchString.slice(result.index + result[0].length);
    }
    return formattedString;
  };
}

const layoutMakers = {
  messagePassThrough() {
    return messagePassThroughLayout;
  },
  basic() {
    return basicLayout;
  },
  colored() {
    return colouredLayout;
  },
  coloured() {
    return colouredLayout;
  },
  pattern(config) {
    return patternLayout(config && config.pattern, config && config.tokens);
  },
  dummy() {
    return dummyLayout;
  },
};

module.exports = {
  basicLayout,
  messagePassThroughLayout,
  patternLayout,
  colouredLayout,
  coloredLayout: colouredLayout,
  dummyLayout,
  addLayout(name, serializerGenerator) {
    layoutMakers[name] = serializerGenerator;
  },
  layout(name, config) {
    return layoutMakers[name] && layoutMakers[name](config);
  },
};
