/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, you can obtain one at http://mozilla.org/MPL/2.0/. */

/*
 * Makes everything awesome if you are Andrew.  Some day it will make everything
 *  awesome if you are not awesome too.
 *
 * Right now the most meaningful thing to know is that if XPCOM failures happen
 *  (and get reported to the error console), this will induce a unit test
 *  failure.  You should think this is awesome no matter whether you are Andrew
 *  or not.
 */

// eslint-disable-next-line mozilla/reject-importGlobalProperties
Cu.importGlobalProperties(["Element", "Node"]);

var _mailnewsTestLogger;
var _xpcshellLogger;
var _testLoggerContexts = [];
var _testLoggerContextId = 0;
var _testLoggerActiveContext;

var _logHelperInterestedListeners = false;

/**
 * Let test code extend the list of allowed XPCOM errors.
 */
var logHelperAllowedErrors = ["NS_ERROR_FAILURE"];
var logHelperAllowedWarnings = [/Quirks Mode/];

/**
 * Let other test helping code decide whether to register for potentially
 *  expensive notifications based on whether anyone can even hear those
 *  results.
 */
function logHelperHasInterestedListeners() {
  return _logHelperInterestedListeners;
}

/**
 * Tunnel nsIScriptErrors that show up on the error console to ConsoleInstance.
 *  We could send everything but I think only script errors are likely of much
 *  concern. Also, this nicely avoids infinite recursions no matter what you do
 *  since what we publish is not going to end up as an nsIScriptError.
 *
 * This is based on my (asuth') exmmad extension.
 */
var _errorConsoleTunnel = {
  initialize() {
    Services.console.registerListener(this);

    // we need to unregister our listener at shutdown if we don't want explosions
    Services.obs.addObserver(this, "quit-application");
  },

  shutdown() {
    Services.console.unregisterListener(this);
    Services.obs.removeObserver(this, "quit-application");
  },

  observe(aMessage, aTopic, aData) {
    if (aTopic == "quit-application") {
      this.shutdown();
      return;
    }

    try {
      if (
        aMessage instanceof Ci.nsIScriptError &&
        !aMessage.errorMessage.includes("Error console says")
      ) {
        // Unfortunately changes to mozilla-central are throwing lots
        // of console errors during testing, so disable (we hope temporarily)
        // failing on XPCOM console errors (see bug 1014350).
        // An XPCOM error aMessage looks like this:
        //   [JavaScript Error: "uncaught exception: 2147500037"]
        // Capture the number, and allow known XPCOM results.
        let matches = /JavaScript Error: "(\w+)/.exec(aMessage);
        let XPCOMresult = null;
        if (matches) {
          for (let result in Cr) {
            if (matches[1] == Cr[result]) {
              XPCOMresult = result;
              break;
            }
          }
          let message = XPCOMresult || aMessage;
          if (logHelperAllowedErrors.some(e => e == matches[1])) {
            if (XPCOMresult) {
              info("Ignoring XPCOM error: " + message);
            }
            return;
          }
          info("Found XPCOM error: " + message);
        }
        // Ignore warnings that match a white-listed pattern.
        if (
          /JavaScript Warning:/.test(aMessage) &&
          logHelperAllowedWarnings.some(w => w.test(aMessage))
        ) {
          return;
        }
        dump(`Error console says: ${aMessage}`);
      }
    } catch (ex) {
      // This is to avoid pathological error loops.  we definitely do not
      // want to propagate an error here.
    }
  },
};

// This defaults to undefined and is for use by test-folder-display-helpers
//  so that it can pre-initialize the value so that when we are evaluated in
//  its subscript loader we see a value of 'true'.
var _do_not_wrap_xpcshell;

/**
 * Initialize logging.  The idea is to:
 *
 * - Always create a dump appender on 'test'.
 * - Check if there's a desire to use a logsploder style network connection
 *    based on the presence of an appropriate file in 'tmp'.  This should be
 *    harmless in cases where there is not such a file.
 *
 * We will wrap the interesting xpcshell functions if we believe there is an
 *  endpoint that cares about these things (such as logsploder).
 */
function _init_log_helper() {
  // - dump on test
  _mailnewsTestLogger = console.createInstance({
    prefix: "test.test",
  });

  // - silent category for xpcshell stuff that already gets dump()ed
  _xpcshellLogger = console.createInstance({
    prefix: "xpcshell",
  });

  // Create a console listener reporting thinger in all cases.  Since XPCOM
  //  failures will show up via the error console, this allows our test to fail
  //  in more situations where we might otherwise silently be cool with bad
  //  things happening.
  _errorConsoleTunnel.initialize();

  if (_logHelperInterestedListeners) {
    if (!_do_not_wrap_xpcshell) {
      _wrap_xpcshell_functions();
    }

    // Send a message telling the listeners about the test file being run.
    _xpcshellLogger.info({
      _jsonMe: true,
      _isContext: true,
      _specialContext: "lifecycle",
      _id: "start",
      testFile: _TEST_FILE,
    });
  }
}
_init_log_helper();

/**
 * Mark the start of a test.  This creates nice console output as well as
 *  setting up logging contexts so that use of other helpers in here
 *  get associated with the context.
 *
 * This will likely only be used by the test driver framework, such as
 *  asyncTestUtils.js.  However, |mark_sub_test_start| is for user test code.
 */
function mark_test_start(aName, aParameter, aDepth) {
  if (aDepth == null) {
    aDepth = 0;
  }

  // clear out any existing contexts
  mark_test_end(aDepth);

  let term = aDepth == 0 ? "test" : "subtest";
  _testLoggerActiveContext = {
    type: term,
    name: aName,
    parameter: aParameter,
    _id: ++_testLoggerContextId,
  };
  if (_testLoggerContexts.length) {
    _testLoggerActiveContext._contextDepth = _testLoggerContexts.length;
    _testLoggerActiveContext._contextParentId =
      _testLoggerContexts[_testLoggerContexts.length - 1]._id;
  }
  _testLoggerContexts.push(_testLoggerActiveContext);

  _mailnewsTestLogger.info(
    _testLoggerActiveContext._id,
    "Starting " + term + ": " + aName + (aParameter ? ", " + aParameter : "")
  );
}

/**
 * Mark the end of a test started by |mark_test_start|.
 */
function mark_test_end(aPopTo) {
  if (aPopTo === undefined) {
    aPopTo = 0;
  }
  // clear out any existing contexts
  while (_testLoggerContexts.length > aPopTo) {
    let context = _testLoggerContexts.pop();
    _mailnewsTestLogger.info(
      context._id,
      "Finished " +
        context.type +
        ": " +
        context.name +
        (context.parameter ? ", " + context.parameter : "")
    );
  }
}

/**
 * For user test code and test support code to mark sub-regions of tests.
 *
 * @param {string} aName The name of the (sub) test.
 * @param {string} [aParameter=null] The parameter if the test is being parameterized.
 * @param {boolean} [aNest=false] Should this nest inside other sub-tests?
 *   If you omit orpass false, we will close out any existing sub-tests.
 *   If you pass true, we nest inside the previous test/sub-test and rely on
 *   you to call |mark_sub_test_end|.
 *   Sub tests can lost no longer than their parent.
 *   You should strongly consider using the aNest parameter if you are test
 *   support code.
 */
function mark_sub_test_start(aName, aParameter, aNest) {
  let depth = aNest ? _testLoggerContexts.length : 1;
  mark_test_start(aName, aParameter, depth);
}

/**
 * Mark the end of a sub-test.  Because sub-tests can't outlive their parents,
 *  there is no ambiguity about what sub-test we are closing out.
 */
function mark_sub_test_end() {
  if (_testLoggerContexts.length <= 1) {
    return;
  }
  mark_test_end(_testLoggerContexts.length - 1);
}

/**
 * Express that all tests were run to completion.  This helps the listener
 *  distinguish between successful termination and abort-style termination where
 *  the process just keeled over and on one told us.
 *
 * This also tells us to clean up.
 */
function mark_all_tests_run() {
  // make sure all tests get closed out
  mark_test_end();

  _xpcshellLogger.info("All finished");
}

function _explode_flags(aFlagWord, aFlagDefs) {
  let flagList = [];

  for (let flagName in aFlagDefs) {
    let flagVal = aFlagDefs[flagName];
    if (flagVal & aFlagWord) {
      flagList.push(flagName);
    }
  }

  return flagList;
}

var _registered_json_normalizers = [];

/**
 * Copy natives or objects, deferring to _normalize_for_json for objects.
 */
function __value_copy(aObj, aDepthAllowed) {
  if (aObj == null || typeof aObj != "object") {
    return aObj;
  }
  return _normalize_for_json(aObj, aDepthAllowed, true);
}

/**
 * Simple object copier to limit accidentally JSON-ing a ridiculously complex
 *  object graph or getting tripped up by prototypes.
 *
 * @param {object} aObj - Input object.
 * @param {integer} aDepthAllowed - How many times we are allowed to recursively
 *   call ourselves.
 */
function __simple_obj_copy(aObj, aDepthAllowed) {
  let oot = {};
  let nextDepth = aDepthAllowed - 1;
  for (let key in aObj) {
    // avoid triggering getters
    if (aObj.__lookupGetter__(key)) {
      oot[key] = "*getter*";
      continue;
    }
    let value = aObj[key];

    if (value == null) {
      oot[key] = null;
    } else if (typeof value != "object") {
      oot[key] = value;
    } else if (!aDepthAllowed) {
      // steal control flow if no more depth is allowed
      oot[key] = "truncated, string rep: " + value.toString();
    } else if (Array.isArray(value)) {
      // array?  (not directly counted, but we will terminate because the
      //  child copying occurs using nextDepth...)
      oot[key] = value.map(v => __value_copy(v, nextDepth));
    } else {
      // it's another object! woo!
      oot[key] = _normalize_for_json(value, nextDepth, true);
    }
  }

  // let's take advantage of the object's native toString now
  oot._stringRep = aObj.toString();

  return oot;
}

var _INTERESTING_MESSAGE_HEADER_PROPERTIES = {
  "gloda-id": 0,
  "gloda-dirty": 0,
  junkscore: "",
  junkscoreorigin: "",
  msgOffset: 0,
  offlineMsgSize: 0,
};

/**
 * Given an object, attempt to normalize it into an interesting JSON
 *  representation.
 *
 * We transform generally interesting mail objects like:
 * - nsIMsgFolder
 * - nsIMsgDBHdr
 */
function _normalize_for_json(aObj, aDepthAllowed, aJsonMeNotNeeded) {
  if (aDepthAllowed === undefined) {
    aDepthAllowed = 2;
  }

  // if it's a simple type just return it direct
  if (typeof aObj != "object") {
    return aObj;
  } else if (aObj == null) {
    return aObj;
  }

  // recursively transform arrays outright
  if (Array.isArray(aObj)) {
    return aObj.map(v => __value_copy(v, aDepthAllowed - 1));
  }

  // === Mail Specific ===
  // (but common and few enough to not split out)
  if (aObj instanceof Ci.nsIMsgFolder) {
    return {
      type: "folder",
      name: aObj.prettyName,
      uri: aObj.URI,
      flags: _explode_flags(aObj.flags, Ci.nsMsgFolderFlags),
    };
  } else if (aObj instanceof Ci.nsIMsgDBHdr) {
    let properties = {};
    for (let name in _INTERESTING_MESSAGE_HEADER_PROPERTIES) {
      let propType = _INTERESTING_MESSAGE_HEADER_PROPERTIES[name];
      if (propType === 0) {
        properties[name] =
          aObj.getStringProperty(name) != ""
            ? aObj.getUint32Property(name)
            : null;
      } else {
        properties[name] = aObj.getStringProperty(name);
      }
    }
    return {
      type: "msgHdr",
      name: aObj.folder.URI + "#" + aObj.messageKey,
      subject: aObj.mime2DecodedSubject,
      from: aObj.mime2DecodedAuthor,
      to: aObj.mime2DecodedRecipients,
      messageKey: aObj.messageKey,
      messageId: aObj.messageId,
      flags: _explode_flags(aObj.flags, Ci.nsMsgMessageFlags),
      interestingProperties: properties,
    };
  } else if (Node.isInstance(aObj)) {
    // === Generic ===
    // DOM nodes, including elements
    let name = aObj.nodeName;
    let objAttrs = {};

    if (Element.isInstance(aObj)) {
      name += "#" + aObj.getAttribute("id");
    }

    if ("attributes" in aObj) {
      let nodeAttrs = aObj.attributes;
      for (let iAttr = 0; iAttr < nodeAttrs.length; iAttr++) {
        objAttrs[nodeAttrs[iAttr].name] = nodeAttrs[iAttr].value;
      }
    }

    let bounds = { left: null, top: null, width: null, height: null };
    if ("getBoundingClientRect" in aObj) {
      bounds = aObj.getBoundingClientRect();
    }

    return {
      type: "domNode",
      name,
      value: aObj.nodeValue,
      namespace: aObj.namespaceURI,
      boundingClientRect: bounds,
      attrs: objAttrs,
    };
  } else if (aObj instanceof Ci.nsIDOMWindow) {
    let winId, title;
    if (aObj.document && aObj.document.documentElement) {
      title = aObj.document.title;
      winId =
        aObj.document.documentElement.getAttribute("windowtype") ||
        aObj.document.documentElement.getAttribute("id") ||
        "unnamed";
    } else {
      winId = "n/a";
      title = "no document";
    }
    return {
      type: "domWindow",
      id: winId,
      title,
      location: "" + aObj.location,
      coords: { x: aObj.screenX, y: aObj.screenY },
      dims: { width: aObj.outerWidth, height: aObj.outerHeight },
    };
  } else if (aObj instanceof Error) {
    // Although straight JS exceptions should serialize pretty well, we can
    //  improve things by making "stack" more friendly.
    return {
      type: "error",
      message: aObj.message,
      fileName: aObj.fileName,
      lineNumber: aObj.lineNumber,
      name: aObj.name,
      stack: aObj.stack ? aObj.stack.split(/\n\r?/g) : null,
      _stringRep: aObj.message,
    };
  } else if (aObj instanceof Ci.nsIException) {
    return {
      type: "error",
      message: "nsIException: " + aObj.name,
      fileName: aObj.filename, // intentionally lower-case
      lineNumber: aObj.lineNumber,
      name: aObj.name,
      result: aObj.result,
      stack: null,
    };
  } else if (aObj instanceof Ci.nsIStackFrame) {
    return {
      type: "stackFrame",
      name: aObj.name,
      fileName: aObj.filename, // intentionally lower-case
      lineNumber: aObj.lineNumber,
    };
  } else if (aObj instanceof Ci.nsIScriptError) {
    return {
      type: "stackFrame",
      name: aObj.errorMessage,
      category: aObj.category,
      fileName: aObj.sourceName,
      lineNumber: aObj.lineNumber,
    };
  }

  for (let [checkType, handler] of _registered_json_normalizers) {
    if (aObj instanceof checkType) {
      return handler(aObj);
    }
  }

  // Do not fall into simple object walking if this is an XPCOM interface.
  // We might run across getters and that leads to nothing good.
  if (aObj instanceof Ci.nsISupports) {
    return {
      type: "XPCOM",
      name: aObj.toString(),
    };
  }

  let simple_obj = __simple_obj_copy(aObj, aDepthAllowed);
  if (!aJsonMeNotNeeded) {
    simple_obj._jsonMe = true;
  }
  return simple_obj;
}

function register_json_normalizer(aType, aHandler) {
  _registered_json_normalizers.push([aType, aHandler]);
}

/*
 * Wrap the xpcshell test functions that do interesting things.  The idea is
 *  that we clobber these only if we're going to value-add; that decision
 *  gets made up top in the initialization function.
 *
 * Since eq/neq fall-through to do_throw in the explosion case, we don't handle
 *  that since the scoping means that we're going to see the resulting
 *  do_throw.
 */

var _orig_do_throw;
var _orig_do_check_neq;
var _orig_do_check_eq;
// do_check_true is implemented in terms of do_check_eq
// do_check_false is implemented in terms of do_check_eq

function _CheckAction(aSuccess, aLeft, aRight, aStack) {
  this.type = "check";
  this.success = aSuccess;
  this.left = _normalize_for_json(aLeft);
  this.right = _normalize_for_json(aRight);
  this.stack = _normalize_for_json(aStack);
}
_CheckAction.prototype = {
  _jsonMe: true,
  // we don't need a toString because we should not go out to the console
};

/**
 * Representation of a failure from do_throw.
 */
function _Failure(aText, aStack) {
  this.type = "failure";
  this.text = aText;
  this.stack = _normalize_for_json(aStack);
}
_Failure.prototype = {
  _jsonMe: true,
};

function _wrapped_do_throw(text, stack) {
  if (!stack) {
    stack = Components.stack.caller;
  }

  // We need to use an info because otherwise explosion loggers can get angry
  //  and they may be indiscriminate about what they subscribe to.
  _xpcshellLogger.info(_testLoggerActiveContext, new _Failure(text, stack));

  return _orig_do_throw(text, stack);
}

function _wrap_xpcshell_functions() {
  _orig_do_throw = do_throw;
  do_throw = _wrapped_do_throw; // eslint-disable-line no-global-assign
}
