/**
 * @typedef {import('../runner.cjs')} Runner
 * @typedef {import('../test.js')} Test
 * @typedef {import('../types.d.ts').FullErrorStack} FullErrorStack
 */

/**
 * @module Base
 */
/**
 * Module dependencies.
 */

import * as diff from "diff";
import milliseconds from "ms";
import utils from "../utils.cjs";
import supportsColor from "supports-color";
import Runner from "../runner.cjs";

const { constants } = Runner;
var EVENT_TEST_PASS = constants.EVENT_TEST_PASS;
var EVENT_TEST_FAIL = constants.EVENT_TEST_FAIL;

const isBrowser = utils.isBrowser();

function getBrowserWindowSize() {
  if ("innerHeight" in global) {
    return [global.innerHeight, global.innerWidth];
  }
  // In a Web Worker, the DOM Window is not available.
  return [640, 480];
}

/**
 * Check if both stdio streams are associated with a tty.
 */

var isatty = isBrowser || (process.stdout.isTTY && process.stderr.isTTY);

/**
 * Save log references to avoid tests interfering (see GH-3604).
 */
var consoleLog = console.log;

/**
 * @abstract
 * @description
 * All other reporters generally inherit from this reporter.
 */
export class Base {
  /**
   * Constructs a new `Base` reporter instance.
   *
   * @public
   * @memberof Mocha.reporters
   * @param {Runner} runner - Instance triggers reporter actions.
   * @param {Object} [options] - runner options
   */
  constructor(runner, options) {
    var failures = (this.failures = []);

    if (!runner) {
      throw new TypeError("Missing runner argument");
    }
    this.options = options || {};
    this.runner = runner;
    this.stats = runner.stats; // assigned so Reporters keep a closer reference

    var maxDiffSizeOpt =
      this.options.reporterOption && this.options.reporterOption.maxDiffSize;
    if (maxDiffSizeOpt !== undefined && !isNaN(Number(maxDiffSizeOpt))) {
      Base.maxDiffSize = Number(maxDiffSizeOpt);
    }

    runner.on(EVENT_TEST_PASS, function (test) {
      if (test.duration > test.slow()) {
        test.speed = "slow";
      } else if (test.duration > test.slow() / 2) {
        test.speed = "medium";
      } else {
        test.speed = "fast";
      }
    });

    runner.on(EVENT_TEST_FAIL, function (test, err) {
      if (Base.showDiff(err)) {
        stringifyDiffObjs(err);
      }
      // more than one error per test
      if (test.err && err instanceof Error) {
        test.err.multiple = (test.err.multiple || []).concat(err);
      } else {
        test.err = err;
      }
      failures.push(test);
    });
  }

  /**
   * Outputs common epilogue used by many of the bundled reporters.
   *
   * @public
   * @memberof Mocha.reporters
   */
  epilogue() {
    var stats = this.stats;
    var fmt;

    Base.consoleLog();

    // passes
    fmt =
      Base.color("bright pass", " ") +
      Base.color("green", " %d passing") +
      Base.color("light", " (%s)");

    Base.consoleLog(fmt, stats.passes || 0, milliseconds(stats.duration));

    // pending
    if (stats.pending) {
      fmt = Base.color("pending", " ") + Base.color("pending", " %d pending");

      Base.consoleLog(fmt, stats.pending);
    }

    // failures
    if (stats.failures) {
      fmt = Base.color("fail", "  %d failing");

      Base.consoleLog(fmt, stats.failures);

      Base.list(this.failures);
      Base.consoleLog();
    }

    Base.consoleLog();
  }
}

Base.consoleLog = consoleLog;

Base.abstract = true;

/**
 * Enable coloring by default, except in the browser interface.
 */
Base.useColors =
  !isBrowser &&
  (supportsColor.stdout || process.env.MOCHA_COLORS !== undefined);

/**
 * Inline diffs instead of +/-
 */

Base.inlineDiffs = false;

/**
 * Truncate diffs longer than this value to avoid slow performance
 */
Base.maxDiffSize = 8192;

/**
 * Default color map.
 */

Base.colors = {
  pass: 90,
  fail: 31,
  "bright pass": 92,
  "bright fail": 91,
  "bright yellow": 93,
  pending: 36,
  suite: 0,
  "error title": 0,
  "error message": 31,
  "error stack": 90,
  checkmark: 32,
  fast: 90,
  medium: 33,
  slow: 31,
  green: 32,
  light: 90,
  "diff gutter": 90,
  "diff added": 32,
  "diff removed": 31,
  "diff added inline": "30;42",
  "diff removed inline": "30;41",
};

/**
 * Default symbol map.
 */

Base.symbols = {
  ok: utils.logSymbols.success,
  err: utils.logSymbols.error,
  dot: ".",
  comma: ",",
  bang: "!",
};

/**
 * Color `str` with the given `type`,
 * allowing colors to be disabled,
 * as well as user-defined color
 * schemes.
 *
 * @private
 * @param {string} type
 * @param {string} str
 * @return {string}
 */
Base.color = function (type, str) {
  if (!Base.useColors) {
    return String(str);
  }
  return "\u001b[" + Base.colors[type] + "m" + str + "\u001b[0m";
};

/**
 * Expose term window size, with some defaults for when stderr is not a tty.
 */

Base.window = {
  width: 75,
};

if (isatty) {
  if (isBrowser) {
    Base.window.width = getBrowserWindowSize()[1];
  } else {
    Base.window.width = process.stdout.getWindowSize(1)[0];
  }
}

/**
 * Expose some basic cursor interactions that are common among reporters.
 */

Base.cursor = {
  hide: function () {
    isatty && process.stdout.write("\u001b[?25l");
  },

  show: function () {
    isatty && process.stdout.write("\u001b[?25h");
  },

  deleteLine: function () {
    isatty && process.stdout.write("\u001b[2K");
  },

  beginningOfLine: function () {
    isatty && process.stdout.write("\u001b[0G");
  },

  CR: function () {
    if (isatty) {
      Base.cursor.deleteLine();
      Base.cursor.beginningOfLine();
    } else {
      process.stdout.write("\r");
    }
  },
};

Base.showDiff = function (err) {
  return (
    err &&
    err.showDiff !== false &&
    sameType(err.actual, err.expected) &&
    err.expected !== undefined
  );
};

/**
 * Estimate the serialized size of a value.
 * Returns -1 if the value is too complex to estimate safely.
 *
 * @private
 * @param {*} val
 * @param {number} maxDepth
 * @param {Set<*>} [seen]
 * @returns {number}
 */
function estimateSize(val, maxDepth, seen) {
  if (maxDepth <= 0) return -1;

  seen = seen || new Set();

  const type = typeof val;
  if (type === "string") return val.length;
  if (type === "number" || type === "boolean") return 8;
  if (val === null || val === undefined) return 4;

  // Avoid circular references
  if (type === "object") {
    if (seen.has(val)) return -1;
    seen.add(val);

    // Special handling for Buffers - they can be massive
    if (typeof Buffer !== "undefined" && Buffer.isBuffer(val)) {
      return val.length > 10000 ? -1 : val.length * 2;
    }

    let size = 2; // {} or []
    try {
      const keys = Object.keys(val);
      if (keys.length > 1000) return -1; // Too many keys

      for (const key of keys) {
        const childSize = estimateSize(val[key], maxDepth - 1, seen);
        if (childSize === -1) return -1;
        size += key.length + childSize + 4; // key + value + formatting
        if (size > 100000) return -1; // Bail early if getting huge
      }
    } catch {
      return -1;
    }

    seen.delete(val);
    return size;
  }

  return 50; // Default estimate for other types
}

function stringifyDiffObjs(err) {
  if (!utils.isString(err.actual) || !utils.isString(err.expected)) {
    // Estimate size before stringifying to avoid hangs
    const maxSafeSize = Base.maxDiffSize || 8192;
    const actualSize = estimateSize(err.actual, 10);
    const expectedSize = estimateSize(err.expected, 10);

    if (
      actualSize === -1 ||
      expectedSize === -1 ||
      actualSize > maxSafeSize ||
      expectedSize > maxSafeSize
    ) {
      // Values too large/complex - provide safe fallback
      err.actual = "[object too large to diff]";
      err.expected = "[object too large to diff]";
      return;
    }

    err.actual = utils.stringify(err.actual);
    err.expected = utils.stringify(err.expected);
  }
}

/**
 * Returns a diff between 2 strings with coloured ANSI output.
 *
 * @description
 * The diff will be either inline or unified dependent on the value
 * of `Base.inlineDiff`.
 *
 * @param {string} actual
 * @param {string} expected
 * @return {string} Diff
 */

Base.generateDiff = function (actual, expected) {
  try {
    var maxLen = Base.maxDiffSize;
    var skipped = 0;
    if (maxLen > 0) {
      skipped = Math.max(actual.length - maxLen, expected.length - maxLen);
      actual = actual.slice(0, maxLen);
      expected = expected.slice(0, maxLen);
    }
    let result = Base.inlineDiffs
      ? inlineDiff(actual, expected)
      : unifiedDiff(actual, expected);
    if (skipped > 0) {
      result = `${result}\n      [mocha] output truncated to ${maxLen} characters, see "maxDiffSize" reporter-option\n`;
    }
    return result;
  } catch {
    var msg =
      "\n      " +
      Base.color("diff added", "+ expected") +
      " " +
      Base.color("diff removed", "- actual:  failed to generate Mocha diff") +
      "\n";
    return msg;
  }
};

/**
 * Traverses err.cause and returns all stack traces
 *
 * @private
 * @param {Error} err
 * @param {Set<Error>} [seen]
 * @return {FullErrorStack}
 */
var getFullErrorStack = function (err, seen) {
  if (seen && seen.has(err)) {
    return { message: "", msg: "<circular>", stack: "" };
  }

  var message;

  if (typeof err.inspect === "function") {
    message = err.inspect() + "";
  } else if (err.message && typeof err.message.toString === "function") {
    message = err.message + "";
  } else {
    message = "";
  }

  var msg;
  var stack = err.stack || message;
  var index = message ? stack.indexOf(message) : -1;

  if (index === -1) {
    msg = message;
  } else {
    index += message.length;
    msg = stack.slice(0, index);
    // remove msg from stack
    stack = stack.slice(index + 1);

    if (err.cause) {
      seen = seen || new Set();
      seen.add(err);
      const causeStack = getFullErrorStack(err.cause, seen);
      stack +=
        "\n   Caused by: " +
        causeStack.msg +
        (causeStack.stack ? "\n" + causeStack.stack : "");
    }
  }

  return {
    message,
    msg,
    stack,
  };
};

/**
 * Outputs the given `failures` as a list.
 *
 * @public
 * @memberof Mocha.reporters.Base
 * @variation 1
 * @param {Object[]} failures - Each is Test instance with corresponding
 *     Error property
 */
Base.list = function (failures) {
  var multipleErr, multipleTest;
  Base.consoleLog();
  failures.forEach(function (test, i) {
    // format
    var fmt =
      Base.color("error title", "  %s) %s:\n") +
      Base.color("error message", "     %s") +
      Base.color("error stack", "\n%s\n");

    // msg
    var err;
    if (test.err && test.err.multiple) {
      if (multipleTest !== test) {
        multipleTest = test;
        multipleErr = [test.err].concat(test.err.multiple);
      }
      err = multipleErr.shift();
    } else {
      err = test.err;
    }

    var { message, msg, stack } = getFullErrorStack(err);

    // uncaught
    if (err.uncaught) {
      msg = "Uncaught " + msg;
    }
    // explicitly show diff
    if (!Base.hideDiff && Base.showDiff(err)) {
      stringifyDiffObjs(err);
      fmt =
        Base.color("error title", "  %s) %s:\n%s") +
        Base.color("error stack", "\n%s\n");
      var match = message.match(/^([^:]+): expected/);
      msg = "\n      " + Base.color("error message", match ? match[1] : msg);

      msg += Base.generateDiff(err.actual, err.expected);
    }

    // indent stack trace
    stack = stack.replace(/^/gm, "  ");

    // indented test title
    var testTitle = "";
    test.titlePath().forEach(function (str, index) {
      if (index !== 0) {
        testTitle += "\n     ";
      }
      for (var i = 0; i < index; i++) {
        testTitle += "  ";
      }
      testTitle += str;
    });

    Base.consoleLog(fmt, i + 1, testTitle, msg, stack);
  });
};

/**
 * Pads the given `str` to `len`.
 *
 * @private
 * @param {string} str
 * @param {string} len
 * @return {string}
 */
function pad(str, len) {
  str = String(str);
  return Array(len - str.length + 1).join(" ") + str;
}

/**
 * Returns inline diff between 2 strings with coloured ANSI output.
 *
 * @private
 * @param {String} actual
 * @param {String} expected
 * @return {string} Diff
 */
function inlineDiff(actual, expected) {
  var msg = errorDiff(actual, expected);

  // linenos
  var lines = msg.split("\n");
  if (lines.length > 4) {
    var width = String(lines.length).length;
    msg = lines
      .map(function (str, i) {
        return pad(i + 1, width) + " |" + " " + str;
      })
      .join("\n");
  }

  // legend
  msg =
    "\n" +
    Base.color("diff removed inline", "actual") +
    " " +
    Base.color("diff added inline", "expected") +
    "\n\n" +
    msg +
    "\n";

  // indent
  msg = msg.replace(/^/gm, "      ");
  return msg;
}

/**
 * Returns unified diff between two strings with coloured ANSI output.
 *
 * @private
 * @param {String} actual
 * @param {String} expected
 * @return {string} The diff.
 */
function unifiedDiff(actual, expected) {
  var indent = "      ";
  function cleanUp(line) {
    if (line[0] === "+") {
      return indent + colorLines("diff added", line);
    }
    if (line[0] === "-") {
      return indent + colorLines("diff removed", line);
    }
    if (line.match(/@@/)) {
      return "--";
    }
    if (line.match(/\\ No newline/)) {
      return null;
    }
    return indent + line;
  }
  function notBlank(line) {
    return typeof line !== "undefined" && line !== null;
  }
  var msg = diff.createPatch("string", actual, expected);
  var lines = msg.split("\n").splice(5);
  return (
    "\n      " +
    colorLines("diff added", "+ expected") +
    " " +
    colorLines("diff removed", "- actual") +
    "\n\n" +
    lines.map(cleanUp).filter(notBlank).join("\n")
  );
}

/**
 * Returns character diff for `err`.
 *
 * @private
 * @param {String} actual
 * @param {String} expected
 * @return {string} the diff
 */
function errorDiff(actual, expected) {
  return diff
    .diffWordsWithSpace(actual, expected)
    .map(function (str) {
      if (str.added) {
        return colorLines("diff added inline", str.value);
      }
      if (str.removed) {
        return colorLines("diff removed inline", str.value);
      }
      return str.value;
    })
    .join("");
}

/**
 * Colors lines for `str`, using the color `name`.
 *
 * @private
 * @param {string} name
 * @param {string} str
 * @return {string}
 */
function colorLines(name, str) {
  return str
    .split("\n")
    .map(function (str) {
      return Base.color(name, str);
    })
    .join("\n");
}

/**
 * Object#toString reference.
 */
var objToString = Object.prototype.toString;

/**
 * Checks that a / b have the same type.
 *
 * @private
 * @param {Object} a
 * @param {Object} b
 * @return {boolean}
 */
function sameType(a, b) {
  return objToString.call(a) === objToString.call(b);
}
