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

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const INTERNAL_FIELDS = new Set(["_level", "_message", "_time", "_namespace"]);

/*
 * Dump a message everywhere we can if we have a failure.
 */
function dumpError(text) {
  dump(text + "\n");
  // TODO: Bug 1801091 - Figure out how to replace this.
  // eslint-disable-next-line mozilla/no-cu-reportError
  Cu.reportError(text);
}

export var Log = {
  Level: {
    Fatal: 70,
    Error: 60,
    Warn: 50,
    Info: 40,
    Config: 30,
    Debug: 20,
    Trace: 10,
    All: -1, // We don't want All to be falsy.
    Desc: {
      70: "FATAL",
      60: "ERROR",
      50: "WARN",
      40: "INFO",
      30: "CONFIG",
      20: "DEBUG",
      10: "TRACE",
      "-1": "ALL",
    },
    Numbers: {
      FATAL: 70,
      ERROR: 60,
      WARN: 50,
      INFO: 40,
      CONFIG: 30,
      DEBUG: 20,
      TRACE: 10,
      ALL: -1,
    },
  },

  get repository() {
    delete Log.repository;
    Log.repository = new LoggerRepository();
    return Log.repository;
  },
  set repository(value) {
    delete Log.repository;
    Log.repository = value;
  },

  _formatError(e) {
    let result = String(e);
    if (e.fileName) {
      let loc = [e.fileName];
      if (e.lineNumber) {
        loc.push(e.lineNumber);
      }
      if (e.columnNumber) {
        loc.push(e.columnNumber);
      }
      result += `(${loc.join(":")})`;
    }
    return `${result} ${Log.stackTrace(e)}`;
  },

  // This is for back compatibility with services/common/utils.js; we duplicate
  // some of the logic in ParameterFormatter
  exceptionStr(e) {
    if (!e) {
      return String(e);
    }
    if (e instanceof Ci.nsIException) {
      return `${e} ${Log.stackTrace(e)}`;
    } else if (isError(e)) {
      return Log._formatError(e);
    }
    // else
    let message = e.message || e;
    return `${message} ${Log.stackTrace(e)}`;
  },

  stackTrace(e) {
    if (!e) {
      return Components.stack.caller.formattedStack.trim();
    }
    // Wrapped nsIException
    if (e.location) {
      let frame = e.location;
      let output = [];
      while (frame) {
        // Works on frames or exceptions, munges file:// URIs to shorten the paths
        // FIXME: filename munging is sort of hackish.
        let str = "<file:unknown>";

        let file = frame.filename || frame.fileName;
        if (file) {
          str = file.replace(/^(?:chrome|file):.*?([^\/\.]+(\.\w+)+)$/, "$1");
        }

        if (frame.lineNumber) {
          str += ":" + frame.lineNumber;
        }

        if (frame.name) {
          str = frame.name + "()@" + str;
        }

        if (str) {
          output.push(str);
        }
        frame = frame.caller;
      }
      return `Stack trace: ${output.join("\n")}`;
    }
    // Standard JS exception
    if (e.stack) {
      let stack = e.stack;
      return (
        "JS Stack trace: " +
        stack.trim().replace(/@[^@]*?([^\/\.]+(\.\w+)+:)/g, "@$1")
      );
    }

    if (e instanceof Ci.nsIStackFrame) {
      return e.formattedStack.trim();
    }
    return "No traceback available";
  },
};

/*
 * LogMessage
 * Encapsulates a single log event's data
 */
class LogMessage {
  constructor(loggerName, level, message, params) {
    this.loggerName = loggerName;
    this.level = level;
    /*
     * Special case to handle "log./level/(object)", for example logging a caught exception
     * without providing text or params like: catch(e) { logger.warn(e) }
     * Treating this as an empty text with the object in the 'params' field causes the
     * object to be formatted properly by BasicFormatter.
     */
    if (
      !params &&
      message &&
      typeof message == "object" &&
      typeof message.valueOf() != "string"
    ) {
      this.message = null;
      this.params = message;
    } else {
      // If the message text is empty, or a string, or a String object, normal handling
      this.message = message;
      this.params = params;
    }

    // The _structured field will correspond to whether this message is to
    // be interpreted as a structured message.
    this._structured = this.params && this.params.action;
    this.time = Date.now();
  }

  get levelDesc() {
    if (this.level in Log.Level.Desc) {
      return Log.Level.Desc[this.level];
    }
    return "UNKNOWN";
  }

  toString() {
    let msg = `${this.time} ${this.level} ${this.message}`;
    if (this.params) {
      msg += ` ${JSON.stringify(this.params)}`;
    }
    return `LogMessage [${msg}]`;
  }
}

/*
 * Logger
 * Hierarchical version.  Logs to all appenders, assigned or inherited
 */

class Logger {
  constructor(name, repository) {
    if (!repository) {
      repository = Log.repository;
    }
    this._name = name;
    this.children = [];
    this.ownAppenders = [];
    this.appenders = [];
    this._repository = repository;

    this._levelPrefName = null;
    this._levelPrefValue = null;
    this._level = null;
    this._parent = null;
  }

  get name() {
    return this._name;
  }

  get level() {
    if (this._levelPrefName) {
      // We've been asked to use a preference to configure the logs. If the
      // pref has a value we use it, otherwise we continue to use the parent.
      const lpv = this._levelPrefValue;
      if (lpv) {
        const levelValue = Log.Level[lpv];
        if (levelValue) {
          // stash it in _level just in case a future value of the pref is
          // invalid, in which case we end up continuing to use this value.
          this._level = levelValue;
          return levelValue;
        }
      } else {
        // in case the pref has transitioned from a value to no value, we reset
        // this._level and fall through to using the parent.
        this._level = null;
      }
    }
    if (this._level != null) {
      return this._level;
    }
    if (this.parent) {
      return this.parent.level;
    }
    dumpError("Log warning: root logger configuration error: no level defined");
    return Log.Level.All;
  }
  set level(level) {
    if (this._levelPrefName) {
      // I guess we could honor this by nuking this._levelPrefValue, but it
      // almost certainly implies confusion, so we'll warn and ignore.
      dumpError(
        `Log warning: The log '${this.name}' is configured to use ` +
          `the preference '${this._levelPrefName}' - you must adjust ` +
          `the level by setting this preference, not by using the ` +
          `level setter`
      );
      return;
    }
    this._level = level;
  }

  get parent() {
    return this._parent;
  }
  set parent(parent) {
    if (this._parent == parent) {
      return;
    }
    // Remove ourselves from parent's children
    if (this._parent) {
      let index = this._parent.children.indexOf(this);
      if (index != -1) {
        this._parent.children.splice(index, 1);
      }
    }
    this._parent = parent;
    parent.children.push(this);
    this.updateAppenders();
  }

  manageLevelFromPref(prefName) {
    if (prefName == this._levelPrefName) {
      // We've already configured this log with an observer for that pref.
      return;
    }
    if (this._levelPrefName) {
      dumpError(
        `The log '${this.name}' is already configured with the ` +
          `preference '${this._levelPrefName}' - ignoring request to ` +
          `also use the preference '${prefName}'`
      );
      return;
    }
    this._levelPrefName = prefName;
    XPCOMUtils.defineLazyPreferenceGetter(this, "_levelPrefValue", prefName);
  }

  updateAppenders() {
    if (this._parent) {
      let notOwnAppenders = this._parent.appenders.filter(function (appender) {
        return !this.ownAppenders.includes(appender);
      }, this);
      this.appenders = notOwnAppenders.concat(this.ownAppenders);
    } else {
      this.appenders = this.ownAppenders.slice();
    }

    // Update children's appenders.
    for (let i = 0; i < this.children.length; i++) {
      this.children[i].updateAppenders();
    }
  }

  addAppender(appender) {
    if (this.ownAppenders.includes(appender)) {
      return;
    }
    this.ownAppenders.push(appender);
    this.updateAppenders();
  }

  removeAppender(appender) {
    let index = this.ownAppenders.indexOf(appender);
    if (index == -1) {
      return;
    }
    this.ownAppenders.splice(index, 1);
    this.updateAppenders();
  }

  _unpackTemplateLiteral(string, params) {
    if (!Array.isArray(params)) {
      // Regular log() call.
      return [string, params];
    }

    if (!Array.isArray(string)) {
      // Not using template literal. However params was packed into an array by
      // the this.[level] call, so we need to unpack it here.
      return [string, params[0]];
    }

    // We're using template literal format (logger.warn `foo ${bar}`). Turn the
    // template strings into one string containing "${0}"..."${n}" tokens, and
    // feed it to the basic formatter. The formatter will treat the numbers as
    // indices into the params array, and convert the tokens to the params.

    if (!params.length) {
      // No params; we need to set params to undefined, so the formatter
      // doesn't try to output the params array.
      return [string[0], undefined];
    }

    let concat = string[0];
    for (let i = 0; i < params.length; i++) {
      concat += `\${${i}}${string[i + 1]}`;
    }
    return [concat, params];
  }

  log(level, string, params) {
    if (this.level > level) {
      return;
    }

    // Hold off on creating the message object until we actually have
    // an appender that's responsible.
    let message;
    let appenders = this.appenders;
    for (let appender of appenders) {
      if (appender.level > level) {
        continue;
      }
      if (!message) {
        [string, params] = this._unpackTemplateLiteral(string, params);
        message = new LogMessage(this._name, level, string, params);
      }
      appender.append(message);
    }
  }

  fatal(string, ...params) {
    this.log(Log.Level.Fatal, string, params);
  }
  error(string, ...params) {
    this.log(Log.Level.Error, string, params);
  }
  warn(string, ...params) {
    this.log(Log.Level.Warn, string, params);
  }
  info(string, ...params) {
    this.log(Log.Level.Info, string, params);
  }
  config(string, ...params) {
    this.log(Log.Level.Config, string, params);
  }
  debug(string, ...params) {
    this.log(Log.Level.Debug, string, params);
  }
  trace(string, ...params) {
    this.log(Log.Level.Trace, string, params);
  }
}

/*
 * LoggerRepository
 * Implements a hierarchy of Loggers
 */

class LoggerRepository {
  constructor() {
    this._loggers = {};
    this._rootLogger = null;
  }

  get rootLogger() {
    if (!this._rootLogger) {
      this._rootLogger = new Logger("root", this);
      this._rootLogger.level = Log.Level.All;
    }
    return this._rootLogger;
  }
  set rootLogger(logger) {
    throw new Error("Cannot change the root logger");
  }

  _updateParents(name) {
    let pieces = name.split(".");
    let cur, parent;

    // find the closest parent
    // don't test for the logger name itself, as there's a chance it's already
    // there in this._loggers
    for (let i = 0; i < pieces.length - 1; i++) {
      if (cur) {
        cur += "." + pieces[i];
      } else {
        cur = pieces[i];
      }
      if (cur in this._loggers) {
        parent = cur;
      }
    }

    // if we didn't assign a parent above, there is no parent
    if (!parent) {
      this._loggers[name].parent = this.rootLogger;
    } else {
      this._loggers[name].parent = this._loggers[parent];
    }

    // trigger updates for any possible descendants of this logger
    for (let logger in this._loggers) {
      if (logger != name && logger.indexOf(name) == 0) {
        this._updateParents(logger);
      }
    }
  }

  /**
   * Obtain a named Logger.
   *
   * The returned Logger instance for a particular name is shared among
   * all callers. In other words, if two consumers call getLogger("foo"),
   * they will both have a reference to the same object.
   *
   * @return Logger
   */
  getLogger(name) {
    if (name in this._loggers) {
      return this._loggers[name];
    }
    this._loggers[name] = new Logger(name, this);
    this._updateParents(name);
    return this._loggers[name];
  }

  /**
   * Obtain a Logger that logs all string messages with a prefix.
   *
   * A common pattern is to have separate Logger instances for each instance
   * of an object. But, you still want to distinguish between each instance.
   * Since Log.repository.getLogger() returns shared Logger objects,
   * monkeypatching one Logger modifies them all.
   *
   * This function returns a new object with a prototype chain that chains
   * up to the original Logger instance. The new prototype has log functions
   * that prefix content to each message.
   *
   * @param name
   *        (string) The Logger to retrieve.
   * @param prefix
   *        (string) The string to prefix each logged message with.
   */
  getLoggerWithMessagePrefix(name, prefix) {
    let log = this.getLogger(name);

    let proxy = Object.create(log);
    proxy.log = (level, string, params) => {
      if (Array.isArray(string) && Array.isArray(params)) {
        // Template literal.
        // We cannot change the original array, so create a new one.
        string = [prefix + string[0]].concat(string.slice(1));
      } else {
        string = prefix + string; // Regular string.
      }
      return log.log(level, string, params);
    };
    return proxy;
  }
}

/*
 * Formatters
 * These massage a LogMessage into whatever output is desired.
 */

// Basic formatter that doesn't do anything fancy.
class BasicFormatter {
  constructor(dateFormat) {
    if (dateFormat) {
      this.dateFormat = dateFormat;
    }
    this.parameterFormatter = new ParameterFormatter();
  }

  /**
   * Format the text of a message with optional parameters.
   * If the text contains ${identifier}, replace that with
   * the value of params[identifier]; if ${}, replace that with
   * the entire params object. If no params have been substituted
   * into the text, format the entire object and append that
   * to the message.
   */
  formatText(message) {
    let params = message.params;
    if (typeof params == "undefined") {
      return message.message || "";
    }
    // Defensive handling of non-object params
    // We could add a special case for NSRESULT values here...
    let pIsObject = typeof params == "object" || typeof params == "function";

    // if we have params, try and find substitutions.
    if (this.parameterFormatter) {
      // have we successfully substituted any parameters into the message?
      // in the log message
      let subDone = false;
      let regex = /\$\{(\S*?)\}/g;
      let textParts = [];
      if (message.message) {
        textParts.push(
          message.message.replace(regex, (_, sub) => {
            // ${foo} means use the params['foo']
            if (sub) {
              if (pIsObject && sub in message.params) {
                subDone = true;
                return this.parameterFormatter.format(message.params[sub]);
              }
              return "${" + sub + "}";
            }
            // ${} means use the entire params object.
            subDone = true;
            return this.parameterFormatter.format(message.params);
          })
        );
      }
      if (!subDone) {
        // There were no substitutions in the text, so format the entire params object
        let rest = this.parameterFormatter.format(message.params);
        if (rest !== null && rest != "{}") {
          textParts.push(rest);
        }
      }
      return textParts.join(": ");
    }
    return undefined;
  }

  format(message) {
    return (
      message.time +
      "\t" +
      message.loggerName +
      "\t" +
      message.levelDesc +
      "\t" +
      this.formatText(message)
    );
  }
}

/**
 * Test an object to see if it is a Mozilla JS Error.
 */
function isError(aObj) {
  return (
    aObj &&
    typeof aObj == "object" &&
    "name" in aObj &&
    "message" in aObj &&
    "fileName" in aObj &&
    "lineNumber" in aObj &&
    "stack" in aObj
  );
}

/*
 * Parameter Formatters
 * These massage an object used as a parameter for a LogMessage into
 * a string representation of the object.
 */

class ParameterFormatter {
  constructor() {
    this._name = "ParameterFormatter";
  }

  format(ob) {
    try {
      if (ob === undefined) {
        return "undefined";
      }
      if (ob === null) {
        return "null";
      }
      // Pass through primitive types and objects that unbox to primitive types.
      if (
        (typeof ob != "object" || typeof ob.valueOf() != "object") &&
        typeof ob != "function"
      ) {
        return ob;
      }
      if (ob instanceof Ci.nsIException) {
        return `${ob} ${Log.stackTrace(ob)}`;
      } else if (isError(ob)) {
        return Log._formatError(ob);
      }
      // Just JSONify it. Filter out our internal fields and those the caller has
      // already handled.
      return JSON.stringify(ob, (key, val) => {
        if (INTERNAL_FIELDS.has(key)) {
          return undefined;
        }
        return val;
      });
    } catch (e) {
      dumpError(
        `Exception trying to format object for log message: ${Log.exceptionStr(
          e
        )}`
      );
    }
    // Fancy formatting failed. Just toSource() it - but even this may fail!
    try {
      return ob.toSource();
    } catch (_) {}
    try {
      return String(ob);
    } catch (_) {
      return "[object]";
    }
  }
}

/*
 * Appenders
 * These can be attached to Loggers to log to different places
 * Simply subclass and override doAppend to implement a new one
 */

class Appender {
  constructor(formatter) {
    this.level = Log.Level.All;
    this._name = "Appender";
    this._formatter = formatter || new BasicFormatter();
  }

  append(message) {
    if (message) {
      this.doAppend(this._formatter.format(message));
    }
  }

  toString() {
    return `${this._name} [level=${this.level}, formatter=${this._formatter}]`;
  }
}

/*
 * DumpAppender
 * Logs to standard out
 */

class DumpAppender extends Appender {
  constructor(formatter) {
    super(formatter);
    this._name = "DumpAppender";
  }

  doAppend(formatted) {
    dump(formatted + "\n");
  }
}

/*
 * ConsoleAppender
 * Logs to the javascript console
 */

class ConsoleAppender extends Appender {
  constructor(formatter) {
    super(formatter);
    this._name = "ConsoleAppender";
  }

  // XXX this should be replaced with calls to the Browser Console
  append(message) {
    if (message) {
      let m = this._formatter.format(message);
      if (message.level > Log.Level.Warn) {
        // TODO: Bug 1801091 - Figure out how to replace this.
        // eslint-disable-next-line mozilla/no-cu-reportError
        Cu.reportError(m);
        return;
      }
      this.doAppend(m);
    }
  }

  doAppend(formatted) {
    Services.console.logStringMessage(formatted);
  }
}

Object.assign(Log, {
  LogMessage,
  Logger,
  LoggerRepository,

  BasicFormatter,

  Appender,
  DumpAppender,
  ConsoleAppender,

  ParameterFormatter,
});
