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

/*
 * A connection between app-services tracing-support library and Firefox.
 *
 * Used by logging and other diagnostic support from app-services Rust components.
 */

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
  // eslint-disable-next-line mozilla/use-console-createInstance
  Log: "resource://gre/modules/Log.sys.mjs",
});

import {
  registerEventSink,
  unregisterEventSink,
  EventSink,
  EventSinkSpecification,
  EventTarget,
  TracingLevel,
} from "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustTracing.sys.mjs";

ChromeUtils.defineLazyGetter(lazy, "console", () =>
  console.createInstance({
    prefix: "AppServices",
    maxLogLevelPref: "toolkit.rust-components.logging.internal-level",
  })
);

/**
 * List of callback/level pairs to send events to
 */
class CallbackList {
  items = [];

  /**
   * Get the maximum level in the callback list
   *
   * Retuns -Infinity when no items are in the list
   */
  maxLevel() {
    return Math.max(...this.items.map(i => i.level));
  }

  /**
   * Add a callback with a given level
   *
   * If the callback is already in the list, then the level will be updated rather than adding a new
   * item.
   *
   * @param {number} level
   * @param {(event: object) => void} callback
   */
  add(level, callback) {
    const index = this.items.findIndex(item => item.callback === callback);
    if (index == -1) {
      this.items.push({ level, callback });
    } else {
      this.items[index].level = level;
    }
  }

  /**
   * Remove a callback from the list, returning the level for that callback
   *
   * If this changes the max level for the list, this returns the new max level otherwise it returns
   * undefined;
   *
   * @param {(event: object) => void} callback
   */
  remove(callback) {
    const index = this.items.find(i => i.callback === callback);
    if (index === undefined) {
      lazy.console.trace(
        "ignoring attempt to remove an event handler that's not registered"
      );
    }
    this.items.splice(index, 1);
  }

  /**
   * Process an event using all items in this CallbackList.
   *
   * The callbacks for item >= `event.level` will be called.
   *
   * @param {object} event
   */
  processEvent(event) {
    for (const item of this.items) {
      if (item.level >= event.level) {
        try {
          item.callback(event);
        } catch (e) {
          lazy.console.error("tracing callback failed", item.callback, e);
        }
      }
    }
  }
}

/** A singleton uniffi callback interface. */
class TracingEventHandler extends EventSink {
  constructor() {
    super();
    // Map targets to CallbackLists
    this.targetCallbackLists = new Map();
    // CallbackList for callbacks registered with registerMinLevelEventSink
    this.minLevelCallbackList = new CallbackList();
    this.eventSinkId = null;

    // Choose `profileBeforeChange` to call `#close()` and deregister our callbacks.
    //
    // Most other components will shutdown during the `profileChangeTeardown` phase, since that's
    // the last opportunity to write to the profile directory.  By choosing the next one, we ensure
    // we can forward any logging that happens when those components shutdown.
    if (lazy.AsyncShutdown.profileBeforeChange.isClosed) {
      // Corner case, where we're already in the shutdown phase while being constructed.  In this
      // case, uninitialize immediately.
      this.#close();
    } else {
      // If we're not in the above corner case, then register a shutdown blocker to uninitialize.
      lazy.AsyncShutdown.profileBeforeChange.addBlocker(
        "TracingEventHandler: deregister callbacks",
        () => this.#close()
      );
    }
  }

  register(target, level, callback) {
    if (this.targetCallbackLists === null) {
      lazy.console.trace(
        "ignoring attempt to register event handler after shutdown has commenced"
      );
      return;
    }
    const callbackList = this._getTargetList(target);
    callbackList.add(level, callback);
  }

  deregister(target, callback) {
    if (this.targetCallbackLists === null) {
      lazy.console.trace(
        "ignoring attempt to register event handler after shutdown has commenced"
      );
      return;
    }
    const callbackList = this._getTargetList(target);
    callbackList.remove(callback);
  }

  registerMinLevelEventSink(level, callback) {
    if (this.minLevelCallbackList === null) {
      lazy.console.trace(
        "ignoring attempt to register min-level event handler after shutdown has commenced"
      );
      return;
    }

    this.minLevelCallbackList.add(level, callback);
  }

  unregisterMinLevelEventSink(callback) {
    if (this.minLevelCallbackList === null) {
      lazy.console.trace(
        "ignoring attempt to unregister min-level event handler after shutdown has commenced"
      );
      return;
    }

    this.minLevelCallbackList.remove(callback);
  }

  _getTargetList(target) {
    if (!this.targetCallbackLists.has(target)) {
      this.targetCallbackLists.set(target, new CallbackList());
    }
    return this.targetCallbackLists.get(target);
  }

  onEvent(event) {
    let target = targetRoot(event.target);
    let targetList = this.targetCallbackLists.get(target);
    if (targetList) {
      targetList.processEvent(event);
    }
    this.minLevelCallbackList.processEvent(event);
  }

  updateRustTracingRegistration() {
    let minLevel = this.minLevelCallbackList.maxLevel();
    if (minLevel == -Infinity) {
      minLevel = null;
    }
    const spec = new EventSinkSpecification({
      targets: [
        ...this.targetCallbackLists.entries().map(([target, callbackList]) => {
          let level = callbackList.maxLevel();
          if (level == -Infinity) {
            level = TracingLevel.DEBUG;
          }
          return new EventTarget({ target, level });
        }),
      ],
      minLevel,
    });
    this.#unregisterWithRustTracing();
    if (spec.minLevel !== null || spec.targets.length) {
      lazy.console.trace("calling registerEventSink", spec);
      this.eventSinkId = registerEventSink(spec, this);
    } else {
      lazy.console.trace(
        "skipping registerEventSink, since there are callbacks"
      );
    }
  }

  #unregisterWithRustTracing() {
    if (this.eventSinkId !== null) {
      lazy.console.trace("calling unregisterEventSink", this.eventSinkId);
      unregisterEventSink(this.eventSinkId);
      this.eventSinkId = null;
    }
  }

  #close() {
    this.#unregisterWithRustTracing();
    this.targetCallbackLists = null;
    this.minLevelCallbackList = null;
  }
}

// the singleton.
let tracingEventHandler = new TracingEventHandler();

// helper to get the pre '::' part of the target.
function targetRoot(target) {
  let colonIndex = target.indexOf(":");
  if (colonIndex > 0) {
    return target.slice(0, colonIndex);
  }
  return target;
}

/*
 * Log.sys.mjs adaptor.
 * Converts tracing log events to Log calls.
 */

// each target may go to one or more logs.
let targetToLogNames = new Map();

// handles a logging event we previously registered for.
function loggerEventHandler(event) {
  let target = targetRoot(event.target);
  for (const name of targetToLogNames.get(target) || []) {
    let log = lazy.Log.repository.getLogger(name);
    let log_level;
    if (event.level == TracingLevel.DEBUG) {
      log_level = lazy.Log.Level.Debug;
    } else if (event.level == TracingLevel.TRACE) {
      log_level = lazy.Log.Level.Trace;
    } else if (event.level == TracingLevel.INFO) {
      log_level = lazy.Log.Level.Info;
    } else if (event.level == TracingLevel.WARN) {
      log_level = lazy.Log.Level.Warn;
    } else {
      log_level = lazy.Log.Level.Error;
    }
    log.log(log_level, event.message);
  }
}

/**
 * send output from a target to a Log.sys.jsm logger.
 *
 * @param {string} target
 * @param {any} log
 */
export function setupLoggerForTarget(target, log) {
  if (typeof log == "string") {
    log = lazy.Log.repository.getLogger(log);
  }
  let log_level = log.level;
  let tracing_level;
  if (log_level == lazy.Log.ERROR) {
    tracing_level = TracingLevel.ERROR;
  } else if (log_level == lazy.Log.WARN) {
    tracing_level = TracingLevel.WARN;
  } else if (log_level == lazy.Log.INFO) {
    tracing_level = TracingLevel.INFO;
  } else if (log_level == lazy.Log.DEBUG) {
    tracing_level = TracingLevel.DEBUG;
  } else {
    tracing_level = TracingLevel.TRACE;
  }
  let logTargets = targetToLogNames.getOrInsert(target, []);
  logTargets.push(log.name);
  tracingEventHandler.register(target, tracing_level, loggerEventHandler);
  tracingEventHandler.updateRustTracingRegistration();
}

/**
 * Handles forwarding a log.
 */
class LogForwarder extends EventSink {
  static PREF_CRATES_TO_FORWARD = "toolkit.rust-components.logging.crates";

  registeredTargets = new Set();
  registeredMinLevelSink = false;

  init() {
    Services.prefs.addObserver(LogForwarder.PREF_CRATES_TO_FORWARD, this);
    this.callback = this.onLog.bind(this);
    this.console = console.createInstance({});
    this.setupForwarding();
  }

  /**
   * Read the current preference value and setup forwarding based on it
   */
  setupForwarding() {
    const prefValue = Services.prefs.getStringPref(
      LogForwarder.PREF_CRATES_TO_FORWARD,
      ""
    );
    const prefValueParsed = this.parsePrefValue(prefValue);

    if (prefValueParsed.minLevel != -Infinity) {
      // Register a min-level sink
      tracingEventHandler.registerMinLevelEventSink(
        prefValueParsed.minLevel,
        this.callback
      );
      this.registeredMinLevelSink = true;
    } else if (this.registeredMinLevelSink) {
      tracingEventHandler.unregisterMinLevelEventSink(this.callback);
      this.registeredMinLevelSink = false;
    }

    const oldRegisteredTargets = this.registeredTargets;
    this.registeredTargets = new Set();
    for (const [target, level] of prefValueParsed.targets) {
      oldRegisteredTargets.delete(target);
      this.registeredTargets.add(target);
      tracingEventHandler.register(target, level, this.callback);
    }
    for (const oldTarget of oldRegisteredTargets) {
      tracingEventHandler.deregister(oldTarget, this.callback);
    }
    tracingEventHandler.updateRustTracingRegistration();
  }

  /**
   * Parse the tracing pref value.
   *
   * @param {string} prefValue
   */
  parsePrefValue(prefValue) {
    const parsed = {
      minLevel: -Infinity,
      targets: [],
    };

    if (prefValue == "") {
      parsed.minLevel = TracingLevel.ERROR;
      return parsed;
    }

    for (let spec of prefValue.split(",")) {
      spec = spec.trim();
      if (spec == "") {
        continue;
      }
      const minLevel = this.parseLevel(spec);
      if (minLevel !== undefined) {
        parsed.minLevel = Math.max(parsed.minLevel, minLevel);
      } else {
        parsed.targets.push(this.parseCrateSpec(spec));
      }
    }
    return parsed;
  }

  parseCrateSpec(spec) {
    var [target, level] = spec.split(":");
    if (level === undefined) {
      return [target, TracingLevel.DEBUG];
    }
    return [target, this.parseLevel(level) ?? TracingLevel.DEBUG];
  }

  parseLevel(levelString) {
    levelString = levelString.toLowerCase();
    if (levelString == "error") {
      return TracingLevel.ERROR;
    } else if (levelString == "warn" || levelString == "warning") {
      return TracingLevel.WARN;
    } else if (levelString == "info") {
      return TracingLevel.INFO;
    } else if (levelString == "debug") {
      return TracingLevel.DEBUG;
    } else if (levelString == "trace") {
      return TracingLevel.TRACE;
    }
    return undefined;
  }

  onLog(event) {
    const msg = `${event.target}: ${event.message}`;
    let log_level = event.level;
    if (log_level == TracingLevel.ERROR) {
      this.console.error(msg);
    } else if (log_level == TracingLevel.WARN) {
      this.console.warn(msg);
    } else if (log_level == TracingLevel.INFO) {
      this.console.info(msg);
    } else if (log_level == TracingLevel.DEBUG) {
      this.console.debug(msg);
    } else {
      this.console.trace(msg);
    }
  }

  observe(_subj, topic, data) {
    switch (topic) {
      case "nsPref:changed":
        if (data === LogForwarder.PREF_CRATES_TO_FORWARD) {
          this.setupForwarding();
        }
        break;
    }
  }
}

export const RustLogForwarder = new LogForwarder();
