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

const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
  TorLauncherUtil: "resource://gre/modules/TorLauncherUtil.sys.mjs",
  TorProvider: "resource://gre/modules/TorProvider.sys.mjs",
});

export const TorProviderTopics = Object.freeze({
  ProcessExited: "TorProcessExited",
  BootstrapStatus: "TorBootstrapStatus",
  BootstrapError: "TorBootstrapError",
  TorLog: "TorLog",
  HasWarnOrErr: "TorLogHasWarnOrErr",
  BridgeChanged: "TorBridgeChanged",
  CircuitCredentialsMatched: "TorCircuitCredentialsMatched",
});

/**
 * Wrapper error class for errors raised during TorProvider.init.
 */
export class TorProviderInitError extends Error {
  /**
   * Create a new instance.
   *
   * @param {any} error - The raised error that we want to wrap.
   */
  constructor(error) {
    super(error?.message, { cause: error });
    this.name = "TorProviderInitError";
  }
}

/**
 * Bootstrap errors raised by the TorProvider.
 */
export class TorBootstrapError extends Error {
  /**
   * Create a new instance.
   *
   * @param {object} details - Details about the error.
   * @param {string} details.summary - A summary of the error.
   * @param {string} details.phase - The bootstrap phase when the error occured.
   * @param {string} details.reason - The reason for the bootsrap failure.
   */
  constructor(details) {
    super(details.summary);
    this.name = "TorBootstrapError";
    this.phase = details.phase;
    this.reason = details.reason;
  }
}

export const TorProviders = Object.freeze({
  none: 0,
  tor: 1,
});

/**
 * @typedef {object} LogEntry An object with a log message
 * @property {string} timestamp The local date-time stamp at which we received the message
 * @property {string} type The message level
 * @property {string} msg The message
 */

/**
 * The factory to get a Tor provider.
 * Currently we support only TorProvider, i.e., the one that interacts with
 * C-tor through the control port protocol.
 */
export class TorProviderBuilder {
  /**
   * A promise with the instance of the provider that we are using.
   *
   * @type {Promise<TorProvider>?}
   */
  static #provider = null;

  /**
   * A record of the log messages from all TorProvider instances.
   *
   * @type {LogEntry[]}
   */
  static #log = [];

  /**
   * Get a record of historic log entries.
   *
   * @returns {LogEntry[]} - The record of entries.
   */
  static getLog() {
    return structuredClone(this.#log);
  }

  /**
   * The limit on the number of log entries we should store.
   *
   * @type {integer}
   */
  static #logLimit;

  /**
   * The observer that checks for new TorLog messages.
   *
   * @type {Function}
   */
  static #logObserver;

  /**
   * Add a new log message.
   *
   * @param {LogEntry} logEntry - The log entry to add.
   */
  static #addLogEntry(logEntry) {
    if (this.#logLimit > 0 && this.#log.length >= this.#logLimit) {
      this.#log.splice(0, 1);
    }
    this.#log.push(logEntry);
  }

  /**
   * The observer that checks when the tor process exits, and reinitializes the
   * provider.
   *
   * @type {Function}
   */
  static #exitObserver = null;

  /**
   * Tell whether the browser UI is ready.
   * We ignore any errors until it is because we cannot show them.
   *
   * @type {boolean}
   */
  static #uiReady = false;

  /**
   * Initialize the provider of choice.
   */
  static init() {
    this.#logLimit = Services.prefs.getIntPref(
      "extensions.torlauncher.max_tor_log_entries",
      1000
    );
    this.#logObserver = subject => {
      this.#addLogEntry(subject.wrappedJSObject);
    };
    Services.obs.addObserver(this.#logObserver, TorProviderTopics.TorLog);

    switch (this.providerType) {
      case TorProviders.tor:
        // Even though initialization of the initial TorProvider is
        // asynchronous, we do not expect the caller to await it. The reason is
        // that any call to build() will wait the initialization anyway (and
        // re-throw any initialization error).
        this.#initTorProvider();
        break;
      case TorProviders.none:
        lazy.TorLauncherUtil.setProxyConfiguration(
          lazy.TorLauncherUtil.getPreferredSocksConfiguration()
        );
        break;
      default:
        console.error(`Unknown tor provider ${this.providerType}.`);
        break;
    }
  }

  /**
   * Replace #provider with a new instance.
   *
   * @returns {Promise<TorProvider>} The new instance.
   */
  static #initTorProvider() {
    if (!this.#exitObserver) {
      this.#exitObserver = this.#torExited.bind(this);
      Services.obs.addObserver(
        this.#exitObserver,
        TorProviderTopics.ProcessExited
      );
    }

    // NOTE: We need to ensure that the #provider is set as soon
    // TorProviderBuilder.init is called.
    // I.e. it should be safe to call
    //   TorProviderBuilder.init();
    //   TorProviderBuilder.build();
    // without any await.
    //
    // Therefore, we await the oldProvider within the Promise rather than make
    // #initTorProvider async.
    //
    // In particular, this is needed by TorConnect when the user has selected
    // quickstart, in which case `TorConnect.init` will immediately request the
    // provider. See tor-browser#41921.
    this.#provider = this.#replaceTorProvider(this.#provider);
    return this.#provider;
  }

  /**
   * Replace a TorProvider instance. Resolves once the TorProvider is
   * initialised.
   *
   * @param {Promise<TorProvider>?} oldProvider - The previous's provider's
   *   promise, if any.
   * @returns {TorProvider} The new TorProvider instance.
   */
  static async #replaceTorProvider(oldProvider) {
    try {
      // Uninitialise the old TorProvider, if there is any.
      (await oldProvider)?.uninit();
    } catch {}
    const provider = new lazy.TorProvider();
    try {
      await provider.init();
    } catch (error) {
      // Wrap in an error type for callers to know whether the error comes from
      // initialisation or something else.
      throw new TorProviderInitError(error);
    }
    return provider;
  }

  static uninit() {
    this.#provider?.then(provider => {
      provider.uninit();
      this.#provider = null;
    });
    if (this.#exitObserver) {
      Services.obs.removeObserver(
        this.#exitObserver,
        TorProviderTopics.ProcessExited
      );
      this.#exitObserver = null;
    }
    Services.obs.removeObserver(this.#logObserver, TorProviderTopics.TorLog);
  }

  /**
   * Build a provider.
   * This method will wait for the system to be initialized, and allows you to
   * catch also any initialization errors.
   *
   * @returns {TorProvider} A TorProvider instance
   */
  static async build() {
    if (!this.#provider && this.providerType === TorProviders.none) {
      throw new Error(
        "Tor Browser has been configured to use only the proxy functionalities."
      );
    } else if (!this.#provider) {
      throw new Error(
        "The provider has not been initialized or already uninitialized."
      );
    }
    return this.#provider;
  }

  /**
   * Check if the provider has been succesfully initialized when the first
   * browser window is shown.
   * This is a workaround we need because ideally we would like the tor process
   * to start as soon as possible, to avoid delays in the about:torconnect page,
   * but we should modify TorConnect and about:torconnect to handle this case
   * there with a better UX.
   */
  static async firstWindowLoaded() {
    // FIXME: Just integrate this with the about:torconnect or about:tor UI.
    if (
      !lazy.TorLauncherUtil.shouldStartAndOwnTor ||
      this.providerType !== TorProviders.tor
    ) {
      // If we are not managing the Tor daemon we cannot restart it, so just
      // early return.
      return;
    }
    let running = false;
    try {
      const provider = await this.#provider;
      // The initialization might have succeeded, but so far we have ignored any
      // error notification. So, check that the process has not exited after the
      // provider has been initialized successfully, but the UI was not ready
      // yet.
      running = provider.isRunning;
    } catch {
      // Not even initialized, running is already false.
    }
    while (!running && lazy.TorLauncherUtil.showRestartPrompt(true)) {
      try {
        await this.#initTorProvider();
        running = true;
      } catch {}
    }
    // The user might have canceled the restart, but at this point the UI is
    // ready in any case.
    this.#uiReady = true;
  }

  static async #torExited() {
    if (!this.#uiReady) {
      console.warn(
        `Seen ${TorProviderTopics.ProcessExited}, but not doing anything because the UI is not ready yet.`
      );
      return;
    }
    while (lazy.TorLauncherUtil.showRestartPrompt(false)) {
      try {
        await this.#initTorProvider();
        break;
      } catch {}
    }
  }

  /**
   * Return the provider chosen by the user.
   * This function checks the TOR_PROVIDER environment variable and if it is a
   * known provider, it returns its associated value.
   * Otherwise, if it is not valid, the C tor implementation is chosen as the
   * default one.
   *
   * @returns {number} An entry from TorProviders
   */
  static get providerType() {
    // TODO: Add a preference to permanently save this without and avoid always
    // using an environment variable.
    let provider = TorProviders.tor;
    const kEnvName = "TOR_PROVIDER";
    if (
      Services.env.exists(kEnvName) &&
      Services.env.get(kEnvName) in TorProviders
    ) {
      provider = TorProviders[Services.env.get(kEnvName)];
    }
    return provider;
  }
}
