/* 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.
 */

var {Log4Moz} = ChromeUtils.import("resource:///modules/gloda/log4moz.js");
var {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
var {IOUtils} = ChromeUtils.import("resource:///modules/IOUtils.js");
// eslint-disable-next-line mozilla/reject-importGlobalProperties
Cu.importGlobalProperties(["Element", "Node"]);

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

var _logHelperInterestedListeners = false;

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

/**
 * 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 Log4Moz.  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 {
      // meh, let's just use mark_failure for now.
      // and let's avoid feedback loops (happens in mozmill)
      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 = /exception: (\d+)/.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);
        }
        mark_failure(["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() {
  let rootLogger = Log4Moz.repository.rootLogger;
  rootLogger.level = Log4Moz.Level.All;

  // - dump on test
  _mailnewsTestLogger = Log4Moz.repository.getLogger("test.test");
  let formatter = new Log4Moz.BasicFormatter();
  let dapp = new Log4Moz.DumpAppender(formatter);
  dapp.level = Log4Moz.Level.All;
  _mailnewsTestLogger.addAppender(dapp);

  // - silent category for xpcshell stuff that already gets dump()ed
  _xpcshellLogger = Log4Moz.repository.getLogger("xpcshell");

  // - logsploder
  let file = Services.dirsvc.get("TmpD", Ci.nsIFile);
  file.append("logsploder.ptr");
  if (file.exists()) {
    _logHelperInterestedListeners = true;

    let data = IOUtils.loadFileToString(file);
    data = data.trim();
    let [host, port] = data.split(":");
    let jf = new Log4Moz.JSONFormatter();
    let sapp = new Log4Moz.SocketAppender(host, Number(port), jf);
    // this goes on the root so it can see all
    rootLogger.addAppender(sapp);
  }

  // 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();

function _cleanup_log_helper() {
  let rootLogger = Log4Moz.repository.rootLogger;
  for (let appender of rootLogger.appenders) {
    if ("closeStream" in appender)
      appender.closeStream();
  }
  rootLogger._appenders = [];
}

/**
 * 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 like
 *  mark_action 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 = _mailnewsTestLogger.newContext({
    type: term,
    name: aName,
    parameter: aParameter,
  });
  if (_testLoggerContexts.length) {
    _testLoggerActiveContext._contextDepth = _testLoggerContexts.length;
    _testLoggerActiveContext._contextParentId =
      _testLoggerContexts[_testLoggerContexts.length - 1]._id;
  }
  _testLoggerContexts.push(_testLoggerActiveContext);

  _mailnewsTestLogger.info(_testLoggerActiveContext,
                           "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();
    context.finish();
    _mailnewsTestLogger.info(context, "Finished " + context.type + ": " + context.name +
                             (context.parameter ? (", " + context.parameter) : ""));
  }
}

/**
 * For user test code and test support code to mark sub-regions of tests.
 *
 * @param aName The name of the (sub) test.
 * @param [aParameter=null] The parameter if the test is being parameterized.
 * @param [aNest=false] Should this nest inside other sub-tests?  If you omit or
 *     pass 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({
    _jsonMe: true,
    _isContext: true,
    _specialContext: "lifecycle",
    _id: "finish",
    done: true,
  });

  _cleanup_log_helper();
}

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 aObj Input object.
 * @param 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]);
}

/**
 * Helper for |mark_action| that creates json-transportable representation so
 *  cool UI on the other end can do something.
 */
function _MarkAction(aWho, aWhat, aArgs) {
  this.type = "action";
  this.who = aWho;
  this.what = aWhat;
  this.args = aArgs;
}
_MarkAction.prototype = {
  _jsonMe: true,
  toString() {
    let argStr;
    if (this.args) {
      argStr = ":";
      for (let arg of this.args) {
        if (arg != null && (typeof(arg) == "object") && ("type" in arg)) {
          if ("name" in arg)
            argStr += " " + arg.type + ": " + arg.name;
          else
            argStr += " " + arg.type;
        } else {
          argStr += " " + arg;
        }
      }
    } else {
      argStr = "";
    }
    return this.who + " " + this.what + argStr;
  },
};

/**
 * Report performance of an action (by testing code).  You would use this rather
 *  than dump because we attempt to do interesting and useful logging things.
 *  In the future, this may mean prettier logs when buildbot runs a test and it
 *  fails, but right now it means great fun for people who use logsploder and
 *  just nicely formatted text for people looking at the console output.
 *
 * @param aWho Think of this like a logger handle... it might be soon.
 * @param aWhat What did you do?
 * @param aArgs A list of arguments, which could each be something like an
 *     nsIMsgFolder or nsIMsgDBHdr or something like that.  It uses
 *     |_normalize_for_json| which can handle some native objects, be extended
 *     to handle more, and does a fair job on straight JS objects.
 */
function mark_action(aWho, aWhat, aArgs) {
  let logger = Log4Moz.repository.getLogger("test." + aWho);

  aArgs = aArgs.map(arg => _normalize_for_json(arg, undefined, true));
  logger.info(_testLoggerActiveContext, new _MarkAction(aWho, aWhat, aArgs));
}

/*
 * 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 mark_failure(aRichString) {
  let args = [_testLoggerActiveContext];
  let text = "";
  for (let [i, richThing] of aRichString.entries()) {
    text += (i ? " " : "");
    if (richThing == null || typeof(richThing) != "object") {
      text += richThing;
      args.push(richThing);
    } else {
      let jsonThing = _normalize_for_json(richThing);
      if (("type" in jsonThing) && ("name" in jsonThing))
        text += "[" + jsonThing.type + " " + jsonThing.name + "]";
      else
        text += "[" + jsonThing + "]";

      // hook things up to be json serialized.
      if (!("_jsonMe" in jsonThing))
        jsonThing._jsonMe = true;
      args.push(jsonThing);
    }
  }
  _xpcshellLogger.info.apply(_xpcshellLogger, args);

  do_throw(text, Components.stack.caller);
}

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
}
