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

"use strict";

// Keep this synchronized with the value of the same name in
// toolkit/xre/nsAppRunner.cpp.
const BROWSER_TOOLBOX_WINDOW_URL =
  "chrome://devtools/content/framework/browser-toolbox/window.html";
const CHROME_DEBUGGER_PROFILE_NAME = "chrome_debugger_profile";

import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
import { require } from "resource://devtools/shared/loader/Loader.sys.mjs";
import { Subprocess } from "resource://gre/modules/Subprocess.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const {
  useDistinctSystemPrincipalLoader,
  releaseDistinctSystemPrincipalLoader,
} = ChromeUtils.importESModule(
  "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs",
  { global: "shared" }
);
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
  BackgroundTasksUtils: "resource://gre/modules/BackgroundTasksUtils.sys.mjs",
});

XPCOMUtils.defineLazyServiceGetters(lazy, {
  XreDirProvider: [
    "@mozilla.org/xre/directory-provider;1",
    "nsIXREDirProvider",
  ],
});

const Telemetry = require("resource://devtools/client/shared/telemetry.js");
const EventEmitter = require("resource://devtools/shared/event-emitter.js");

const processes = new Set();

/**
 * @typedef {Object} BrowserToolboxLauncherArgs
 * @property {function} onRun - A function called when the process starts running.
 * @property {boolean} overwritePreferences - Set to force overwriting the toolbox
 *                     profile's preferences with the current set of preferences.
 * @property {boolean} forceMultiprocess - Set to force the Browser Toolbox to be in
 *                     multiprocess mode.
 */

export class BrowserToolboxLauncher extends EventEmitter {
  /**
   * Initializes and starts a chrome toolbox process if the appropriated prefs are enabled
   *
   * @param {BrowserToolboxLauncherArgs} args
   * @return {BrowserToolboxLauncher|null} The created instance, or null if the required prefs
   *         are not set.
   */
  static init(args) {
    if (
      !Services.prefs.getBoolPref("devtools.chrome.enabled") ||
      !Services.prefs.getBoolPref("devtools.debugger.remote-enabled")
    ) {
      console.error("Could not start Browser Toolbox, you need to enable it.");
      return null;
    }
    return new BrowserToolboxLauncher(args);
  }

  /**
   * Figure out if there are any open Browser Toolboxes that'll need to be restored.
   * @return {boolean}
   */
  static getBrowserToolboxSessionState() {
    return processes.size !== 0;
  }

  #closed;
  #devToolsServer;
  #dbgProfilePath;
  #dbgProcess;
  #listener;
  #loader;
  #port;
  #telemetry = new Telemetry();

  /**
   * Constructor for creating a process that will hold a chrome toolbox.
   *
   * @param {...BrowserToolboxLauncherArgs} args
   */
  constructor({ forceMultiprocess, onRun, overwritePreferences } = {}) {
    super();

    if (onRun) {
      this.once("run", onRun);
    }

    this.close = this.close.bind(this);
    Services.obs.addObserver(this.close, "quit-application");
    this.#initServer();
    this.#initProfile(overwritePreferences);
    this.#create({ forceMultiprocess });

    processes.add(this);
  }

  /**
   * Initializes the devtools server.
   */
  #initServer() {
    if (this.#devToolsServer) {
      dumpn("The chrome toolbox server is already running.");
      return;
    }

    dumpn("Initializing the chrome toolbox server.");

    // Create a separate loader instance, so that we can be sure to receive a
    // separate instance of the DebuggingServer from the rest of the devtools.
    // This allows us to safely use the tools against even the actors and
    // DebuggingServer itself, especially since we can mark this loader as
    // invisible to the debugger (unlike the usual loader settings).
    this.#loader = useDistinctSystemPrincipalLoader(this);
    const { DevToolsServer } = this.#loader.require(
      "resource://devtools/server/devtools-server.js"
    );
    const { SocketListener } = this.#loader.require(
      "resource://devtools/shared/security/socket.js"
    );
    this.#devToolsServer = DevToolsServer;
    dumpn("Created a separate loader instance for the DevToolsServer.");

    this.#devToolsServer.init();
    // We mainly need a root actor and target actors for opening a toolbox, even
    // against chrome/content. But the "no auto hide" button uses the
    // preference actor, so also register the browser actors.
    this.#devToolsServer.registerAllActors();
    this.#devToolsServer.allowChromeProcess = true;
    dumpn("initialized and added the browser actors for the DevToolsServer.");

    const bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService(
      Ci.nsIBackgroundTasks
    );
    if (bts?.isBackgroundTaskMode) {
      // A special root actor, just for background tasks invoked with
      // `--backgroundtask TASK --jsdebugger`.
      const { createRootActor } = this.#loader.require(
        "resource://gre/modules/backgroundtasks/dbg-actors.js"
      );
      this.#devToolsServer.setRootActor(createRootActor);
    }

    const chromeDebuggingWebSocket = Services.prefs.getBoolPref(
      "devtools.debugger.chrome-debugging-websocket"
    );
    const socketOptions = {
      fromBrowserToolbox: true,
      portOrPath: -1,
      webSocket: chromeDebuggingWebSocket,
    };
    const listener = new SocketListener(this.#devToolsServer, socketOptions);
    listener.open();
    this.#listener = listener;
    this.#port = listener.port;

    if (!this.#port) {
      throw new Error("No devtools server port");
    }

    dumpn("Finished initializing the chrome toolbox server.");
    dump(
      `DevTools Server for Browser Toolbox listening on port: ${this.#port}\n`
    );
  }

  /**
   * Initializes a profile for the remote debugger process.
   */
  #initProfile(overwritePreferences) {
    dumpn("Initializing the chrome toolbox user profile.");

    const bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService(
      Ci.nsIBackgroundTasks
    );

    let debuggingProfileDir = Services.dirsvc.get("ProfD", Ci.nsIFile);
    if (bts?.isBackgroundTaskMode) {
      // Background tasks run with a temporary ephemeral profile.  We move the
      // browser toolbox profile out of that ephemeral profile so that it has
      // alonger life then the background task profile.  This preserves
      // breakpoints, etc, across repeated debugging invocations.  This
      // directory is close to the background task temporary profile name(s),
      // but doesn't match the prefix that will get purged by the stale
      // ephemeral profile cleanup mechanism.
      //
      // For example, the invocation
      // `firefox --backgroundtask success --jsdebugger --wait-for-jsdebugger`
      // might run with ephemeral profile
      // `/tmp/MozillaBackgroundTask-<HASH>-success`
      // and sibling directory browser toolbox profile
      // `/tmp/MozillaBackgroundTask-<HASH>-chrome_debugger_profile-success`
      //
      // See `BackgroundTasks::Shutdown` for ephemeral profile cleanup details.
      debuggingProfileDir = debuggingProfileDir.parent;
      debuggingProfileDir.append(
        `${Services.appinfo.vendor}BackgroundTask-` +
          `${lazy.XreDirProvider.getInstallHash()}-${CHROME_DEBUGGER_PROFILE_NAME}-${bts.backgroundTaskName()}`
      );
    } else {
      debuggingProfileDir.append(CHROME_DEBUGGER_PROFILE_NAME);
    }
    try {
      debuggingProfileDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
    } catch (ex) {
      if (ex.result === Cr.NS_ERROR_FILE_ALREADY_EXISTS) {
        if (!overwritePreferences) {
          this.#dbgProfilePath = debuggingProfileDir.path;
          return;
        }
        // Fall through and copy the current set of prefs to the profile.
      } else {
        dumpn("Error trying to create a profile directory, failing.");
        dumpn("Error: " + (ex.message || ex));
        return;
      }
    }

    this.#dbgProfilePath = debuggingProfileDir.path;

    // We would like to copy prefs into this new profile...
    const prefsFile = debuggingProfileDir.clone();
    prefsFile.append("prefs.js");

    if (bts?.isBackgroundTaskMode) {
      // Background tasks run under a temporary profile.  In order to set
      // preferences for the launched browser toolbox, take the preferences from
      // the default profile.  This is the standard pattern for controlling
      // background task settings.  Without this, there'd be no way to increase
      // logging in the browser toolbox process, etc.
      const defaultProfile = lazy.BackgroundTasksUtils.getDefaultProfile();
      if (!defaultProfile) {
        throw new Error(
          "Cannot start Browser Toolbox from background task with no default profile"
        );
      }

      const defaultPrefsFile = defaultProfile.rootDir.clone();
      defaultPrefsFile.append("prefs.js");
      defaultPrefsFile.copyTo(prefsFile.parent, prefsFile.leafName);

      dumpn(
        `Copied browser toolbox prefs at '${prefsFile.path}'` +
          ` from default profiles prefs at '${defaultPrefsFile.path}'`
      );
    } else {
      // ... but unfortunately, when we run tests, it seems the starting profile
      // clears out the prefs file before re-writing it, and in practice the
      // file is empty when we get here. So just copying doesn't work in that
      // case.
      // We could force a sync pref flush and then copy it... but if we're doing
      // that, we might as well just flush directly to the new profile, which
      // always works:
      Services.prefs.savePrefFile(prefsFile);
    }

    dumpn(
      "Finished creating the chrome toolbox user profile at: " +
        this.#dbgProfilePath
    );
  }

  /**
   * Creates and initializes the profile & process for the remote debugger.
   *
   * @param {Object} options
   * @param {boolean} options.forceMultiprocess: Set to true to force the Browser Toolbox to be in
   *                    multiprocess mode.
   */
  #create({ forceMultiprocess } = {}) {
    dumpn("Initializing chrome debugging process.");

    let command = Services.dirsvc.get("XREExeF", Ci.nsIFile).path;
    let profilePath = this.#dbgProfilePath;

    // MOZ_BROWSER_TOOLBOX_BINARY is an absolute file path to a custom firefox binary.
    // This is especially useful when debugging debug builds which are really slow
    // so that you could pass an optimized build for the browser toolbox.
    // This is also useful when debugging a patch that break devtools,
    // so that you could use a build that works for the browser toolbox.
    const customBinaryPath = Services.env.get("MOZ_BROWSER_TOOLBOX_BINARY");
    if (customBinaryPath) {
      command = customBinaryPath;
      profilePath = PathUtils.join(PathUtils.tempDir, "browserToolboxProfile");
    }

    dumpn("Running chrome debugging process.");
    const args = [
      "-foreground",
      "-profile",
      profilePath,
      "-chrome",
      BROWSER_TOOLBOX_WINDOW_URL,
    ];

    const isInputContextEnabled = Services.prefs.getBoolPref(
      "devtools.webconsole.input.context",
      false
    );
    const environment = {
      // Allow recording the startup of the browser toolbox when setting
      // MOZ_BROWSER_TOOLBOX_PROFILER_STARTUP=1 when running firefox.
      MOZ_PROFILER_STARTUP: Services.env.get(
        "MOZ_BROWSER_TOOLBOX_PROFILER_STARTUP"
      ),
      // And prevent profiling any subsequent toolbox
      MOZ_BROWSER_TOOLBOX_PROFILER_STARTUP: "0",

      MOZ_BROWSER_TOOLBOX_FORCE_MULTIPROCESS: forceMultiprocess ? "1" : "0",
      // Similar, but for the WebConsole input context dropdown.
      MOZ_BROWSER_TOOLBOX_INPUT_CONTEXT: isInputContextEnabled ? "1" : "0",
      // Disable safe mode for the new process in case this was opened via the
      // keyboard shortcut.
      MOZ_DISABLE_SAFE_MODE_KEY: "1",
      MOZ_BROWSER_TOOLBOX_PORT: String(this.#port),
      MOZ_HEADLESS: null,
      // Never enable Marionette for the new process.
      MOZ_MARIONETTE: null,
      // Don't inherit debug settings from the process launching us.  This can
      // cause errors when log files collide.
      MOZ_LOG: null,
      MOZ_LOG_FILE: null,
      XPCOM_MEM_BLOAT_LOG: null,
      XPCOM_MEM_LEAK_LOG: null,
      XPCOM_MEM_LOG_CLASSES: null,
      XPCOM_MEM_REFCNT_LOG: null,
      XRE_PROFILE_PATH: null,
      XRE_PROFILE_LOCAL_PATH: null,
    };

    // During local development, incremental builds can trigger the main process
    // to clear its startup cache with the "flag file" .purgecaches, but this
    // file is removed during app startup time, so we aren't able to know if it
    // was present in order to also clear the child profile's startup cache as
    // well.
    //
    // As an approximation of "isLocalBuild", check for an unofficial build.
    if (!AppConstants.MOZILLA_OFFICIAL) {
      args.push("-purgecaches");
    }

    dump(`Starting Browser Toolbox ${command} ${args.join(" ")}\n`);
    IOUtils.makeDirectory(profilePath, { ignoreExisting: true })
      .then(() =>
        Subprocess.call({
          command,
          arguments: args,
          environmentAppend: true,
          stderr: "stdout",
          environment,
        })
      )
      .then(proc => {
        this.#dbgProcess = proc;

        this.#telemetry.toolOpened("jsbrowserdebugger", this);

        dumpn("Chrome toolbox is now running...");
        this.emit("run", this, proc, this.#dbgProfilePath);

        proc.stdin.close();
        const dumpPipe = async pipe => {
          let leftover = "";
          let data = await pipe.readString();
          while (data) {
            data = leftover + data;
            const lines = data.split(/\r\n|\r|\n/);
            if (lines.length) {
              for (const line of lines.slice(0, -1)) {
                dump(`${proc.pid}> ${line}\n`);
              }
              leftover = lines[lines.length - 1];
            }
            data = await pipe.readString();
          }
          if (leftover) {
            dump(`${proc.pid}> ${leftover}\n`);
          }
        };
        dumpPipe(proc.stdout);

        proc.wait().then(() => this.close());

        return proc;
      })
      .catch(err => {
        console.log(
          `Error loading Browser Toolbox: ${command} ${args.join(" ")}`,
          err
        );
      });
  }

  /**
   * Closes the remote debugging server and kills the toolbox process.
   */
  async close() {
    if (this.#closed) {
      return;
    }

    this.#closed = true;

    dumpn("Cleaning up the chrome debugging process.");

    Services.obs.removeObserver(this.close, "quit-application");

    // We tear down before killing the browser toolbox process to avoid leaking
    // socket connection objects.
    if (this.#listener) {
      this.#listener.close();
    }

    // Note that the DevToolsServer can be shared with the DevToolsServer
    // spawned by DevToolsFrameChild. We shouldn't destroy it from here.
    // Instead we should let it auto-destroy itself once the last connection is closed.
    this.#devToolsServer = null;

    this.#dbgProcess.stdout.close();
    await this.#dbgProcess.kill();

    this.#telemetry.toolClosed("jsbrowserdebugger", this);

    dumpn("Chrome toolbox is now closed...");
    processes.delete(this);

    this.#dbgProcess = null;
    if (this.#loader) {
      releaseDistinctSystemPrincipalLoader(this);
    }
    this.#loader = null;
    this.#telemetry = null;
  }
}

/**
 * Helper method for debugging.
 * @param string
 */
function dumpn(str) {
  if (wantLogging) {
    dump("DBG-FRONTEND: " + str + "\n");
  }
}

var wantLogging = Services.prefs.getBoolPref("devtools.debugger.log");
const prefObserver = {
  observe: (...args) => {
    wantLogging = Services.prefs.getBoolPref(args.pop());
  },
};
Services.prefs.addObserver("devtools.debugger.log", prefObserver);
const unloadObserver = function (subject) {
  if (subject.wrappedJSObject == require("@loader/unload")) {
    Services.prefs.removeObserver("devtools.debugger.log", prefObserver);
    Services.obs.removeObserver(unloadObserver, "devtools:loader:destroy");
  }
};
Services.obs.addObserver(unloadObserver, "devtools:loader:destroy");
