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

var gArgs;
var gBrowser;
var gURLBar;
var gDebugger;
var gMultiProcessBrowser = window.docShell.QueryInterface(
  Ci.nsILoadContext
).useRemoteTabs;
var gFissionBrowser = window.docShell.QueryInterface(
  Ci.nsILoadContext
).useRemoteSubframes;
var gWritingProfile = false;
var gWrittenProfile = false;

const { E10SUtils } = ChromeUtils.importESModule(
  "resource://gre/modules/E10SUtils.sys.mjs"
);
const { Preferences } = ChromeUtils.importESModule(
  "resource://gre/modules/Preferences.sys.mjs"
);
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
  BrowserToolboxLauncher:
    "resource://devtools/client/framework/browser-toolbox/Launcher.sys.mjs",
});

const FEATURES = {
  paintDumping: "nglayout.debug.paint_dumping",
  invalidateDumping: "nglayout.debug.invalidate_dumping",
  eventDumping: "nglayout.debug.event_dumping",
  motionEventDumping: "nglayout.debug.motion_event_dumping",
  crossingEventDumping: "nglayout.debug.crossing_event_dumping",
  reflowCounts: "layout.reflow.showframecounts",
};

const COMMANDS = [
  "dumpContent",
  "dumpFrames",
  "dumpFramesInCSSPixels",
  "dumpTextRuns",
  "dumpViews",
  "dumpCounterManager",
  "dumpStyleSheets",
  "dumpMatchedRules",
  "dumpComputedStyles",
  "dumpReflowStats",
];

class Debugger {
  constructor() {
    this._flags = new Map();
    this._pagedMode = false;
    this._attached = false;

    for (let [name, pref] of Object.entries(FEATURES)) {
      this._flags.set(name, !!Preferences.get(pref, false));
    }

    this.attachBrowser();
  }

  detachBrowser() {
    if (!this._attached) {
      return;
    }
    gBrowser.removeProgressListener(this._progressListener);
    this._progressListener = null;
    this._attached = false;
  }

  attachBrowser() {
    if (this._attached) {
      throw "already attached";
    }
    this._progressListener = new nsLDBBrowserContentListener();
    gBrowser.addProgressListener(this._progressListener);
    this._attached = true;
  }

  dumpProcessIDs() {
    let parentPid = Services.appinfo.processID;
    let [contentPid, ...framePids] = E10SUtils.getBrowserPids(
      gBrowser,
      gFissionBrowser
    );

    dump(`Parent pid: ${parentPid}\n`);
    dump(`Content pid: ${contentPid || "-"}\n`);
    if (gFissionBrowser) {
      dump(`Subframe pids: ${framePids.length ? framePids.join(", ") : "-"}\n`);
    }
  }

  get pagedMode() {
    return this._pagedMode;
  }

  set pagedMode(v) {
    v = !!v;
    this._pagedMode = v;
    this.setPagedMode(this._pagedMode);
  }

  setPagedMode(v) {
    this._sendMessage("setPagedMode", v);
  }

  openDevTools() {
    lazy.BrowserToolboxLauncher.init();
  }

  async _sendMessage(name, arg) {
    await this._sendMessageTo(gBrowser.browsingContext, name, arg);
  }

  async _sendMessageTo(context, name, arg) {
    let global = context.currentWindowGlobal;
    if (global) {
      await global
        .getActor("LayoutDebug")
        .sendQuery("LayoutDebug:Call", { name, arg });
    }

    for (let c of context.children) {
      await this._sendMessageTo(c, name, arg);
    }
  }
}

for (let [name, pref] of Object.entries(FEATURES)) {
  Object.defineProperty(Debugger.prototype, name, {
    get: function () {
      return this._flags.get(name);
    },
    set: function (v) {
      v = !!v;
      Preferences.set(pref, v);
      this._flags.set(name, v);
      // XXX PresShell should watch for this pref change itself.
      if (name == "reflowCounts") {
        this._sendMessage("setReflowCounts", v);
      }
      this._sendMessage("forceRefresh");
    },
  });
}

for (let name of COMMANDS) {
  Debugger.prototype[name] = function () {
    this._sendMessage(name);
  };
}

function autoCloseIfNeeded(aCrash) {
  if (!gArgs.autoclose) {
    return;
  }
  setTimeout(function () {
    if (aCrash) {
      let browser = document.createXULElement("browser");
      // FIXME(emilio): we could use gBrowser if we bothered get the process switches right.
      //
      // Doesn't seem worth for this particular case.
      document.documentElement.appendChild(browser);
      browser.loadURI(Services.io.newURI("about:crashparent"), {
        triggeringPrincipal:
          Services.scriptSecurityManager.getSystemPrincipal(),
      });
      return;
    }
    if (gArgs.profile && Services.profiler) {
      dumpProfile();
    } else {
      Services.startup.quit(Ci.nsIAppStartup.eAttemptQuit);
    }
  }, gArgs.delay * 1000);
}

function nsLDBBrowserContentListener() {
  this.init();
}

nsLDBBrowserContentListener.prototype = {
  init: function () {
    this.mStatusText = document.getElementById("status-text");
    this.mForwardButton = document.getElementById("forward-button");
    this.mBackButton = document.getElementById("back-button");
    this.mStopButton = document.getElementById("stop-button");
  },

  QueryInterface: ChromeUtils.generateQI([
    "nsIWebProgressListener",
    "nsISupportsWeakReference",
  ]),

  // nsIWebProgressListener implementation
  onStateChange: function (aWebProgress, aRequest, aStateFlags, aStatus) {
    if (!(aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK)) {
      return;
    }

    if (aStateFlags & Ci.nsIWebProgressListener.STATE_START) {
      this.setButtonEnabled(this.mStopButton, true);
      this.setButtonEnabled(this.mForwardButton, gBrowser.canGoForward);
      this.setButtonEnabled(this.mBackButton, gBrowser.canGoBack);
      this.mStatusText.value = "loading...";
      this.mLoading = true;
    } else if (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
      this.setButtonEnabled(this.mStopButton, false);
      this.mStatusText.value = gURLBar.value + " loaded";
      this.mLoading = false;

      if (gDebugger.pagedMode) {
        // Change to paged mode after the page is loaded.
        gDebugger.setPagedMode(true);
      }

      if (gBrowser.currentURI.spec != "about:blank") {
        // We check for about:blank just to avoid one or two STATE_STOP
        // notifications that occur before the loadURI() call completes.
        // This does mean that --autoclose doesn't work when the URL on
        // the command line is about:blank (or not specified), but that's
        // not a big deal.
        autoCloseIfNeeded(false);
      }
    }
  },

  onProgressChange: function (
    aWebProgress,
    aRequest,
    aCurSelfProgress,
    aMaxSelfProgress,
    aCurTotalProgress,
    aMaxTotalProgress
  ) {},

  onLocationChange: function (aWebProgress, aRequest, aLocation, aFlags) {
    gURLBar.value = aLocation.spec;
    this.setButtonEnabled(this.mForwardButton, gBrowser.canGoForward);
    this.setButtonEnabled(this.mBackButton, gBrowser.canGoBack);
  },

  onStatusChange: function (aWebProgress, aRequest, aStatus, aMessage) {
    this.mStatusText.value = aMessage;
  },

  onSecurityChange: function (aWebProgress, aRequest, aState) {},

  onContentBlockingEvent: function (aWebProgress, aRequest, aEvent) {},

  // non-interface methods
  setButtonEnabled: function (aButtonElement, aEnabled) {
    if (aEnabled) {
      aButtonElement.removeAttribute("disabled");
    } else {
      aButtonElement.setAttribute("disabled", "true");
    }
  },

  mStatusText: null,
  mForwardButton: null,
  mBackButton: null,
  mStopButton: null,

  mLoading: false,
};

function parseArguments() {
  let args = {
    url: null,
    autoclose: false,
    delay: 0,
    paged: false,
  };
  if (window.arguments) {
    args.url = window.arguments[0];
    for (let i = 1; i < window.arguments.length; ++i) {
      let arg = window.arguments[i];
      if (/^autoclose=(.*)$/.test(arg)) {
        args.autoclose = true;
        args.delay = +RegExp.$1;
      } else if (/^profile=(.*)$/.test(arg)) {
        args.profile = true;
        args.profileFilename = RegExp.$1;
      } else if (/^paged$/.test(arg)) {
        args.paged = true;
      } else {
        throw `Unknown option ${arg}`;
      }
    }
  }
  return args;
}

const TabCrashedObserver = {
  observe(subject, topic, data) {
    switch (topic) {
      case "ipc:content-shutdown":
        subject.QueryInterface(Ci.nsIPropertyBag2);
        if (!subject.get("abnormal")) {
          return;
        }
        break;
      case "oop-frameloader-crashed":
        break;
    }
    autoCloseIfNeeded(true);
  },
};

function OnLDBLoad() {
  gBrowser = document.getElementById("browser");
  gURLBar = document.getElementById("urlbar");

  try {
    ChromeUtils.registerWindowActor("LayoutDebug", {
      child: {
        moduleURI: "resource://gre/actors/LayoutDebugChild.jsm",
      },
      allFrames: true,
    });
  } catch (ex) {
    // Only register the actor once.
  }

  gDebugger = new Debugger();

  Services.obs.addObserver(TabCrashedObserver, "ipc:content-shutdown");
  Services.obs.addObserver(TabCrashedObserver, "oop-frameloader-crashed");

  // Pretend slightly to be like a normal browser, so that SessionStore.sys.mjs
  // doesn't get too confused.  The effect is that we'll never switch process
  // type when navigating, and for layout debugging purposes we don't bother
  // about getting that right.
  gBrowser.getTabForBrowser = function () {
    return null;
  };

  gArgs = parseArguments();

  if (gArgs.profile) {
    if (Services.profiler) {
      if (!Services.env.exists("MOZ_PROFILER_SYMBOLICATE")) {
        dump(
          "Warning: MOZ_PROFILER_SYMBOLICATE environment variable not set; " +
            "profile will not be symbolicated.\n"
        );
      }
      Services.profiler.StartProfiler(
        1 << 20,
        1,
        ["default"],
        ["GeckoMain", "Compositor", "Renderer", "RenderBackend", "StyleThread"]
      );
      if (gArgs.url) {
        // Switch to the right kind of content process, and wait a bit so that
        // the profiler has had a chance to attach to it.
        loadStringURI(gArgs.url, { delayLoad: 3000 });
        return;
      }
    } else {
      dump("Cannot profile Layout Debugger; profiler was not compiled in.\n");
    }
  }

  // The URI is not loaded yet. Just set the internal variable.
  gDebugger._pagedMode = gArgs.paged;

  if (gArgs.url) {
    loadStringURI(gArgs.url);
  }

  // Some command line arguments may toggle menu items. Call this after
  // processing all the arguments.
  checkPersistentMenus();
}

function checkPersistentMenu(item) {
  var menuitem = document.getElementById("menu_" + item);
  menuitem.setAttribute("checked", gDebugger[item]);
}

function checkPersistentMenus() {
  // Restore the toggles that are stored in prefs.
  checkPersistentMenu("paintDumping");
  checkPersistentMenu("invalidateDumping");
  checkPersistentMenu("eventDumping");
  checkPersistentMenu("motionEventDumping");
  checkPersistentMenu("crossingEventDumping");
  checkPersistentMenu("reflowCounts");
  checkPersistentMenu("pagedMode");
}

function dumpProfile() {
  gWritingProfile = true;

  let cwd = Services.dirsvc.get("CurWorkD", Ci.nsIFile).path;
  let filename = PathUtils.join(cwd, gArgs.profileFilename);

  dump(`Writing profile to ${filename}...\n`);

  Services.profiler.dumpProfileToFileAsync(filename).then(function () {
    gWritingProfile = false;
    gWrittenProfile = true;
    dump(`done\n`);
    Services.startup.quit(Ci.nsIAppStartup.eAttemptQuit);
  });
}

function OnLDBBeforeUnload(event) {
  if (gArgs.profile && Services.profiler) {
    if (gWrittenProfile) {
      // We've finished writing the profile.  Allow the window to close.
      return;
    }

    event.preventDefault();

    if (gWritingProfile) {
      // Wait for the profile to finish being written out.
      return;
    }

    // The dumpProfileToFileAsync call can block for a while, so run it off a
    // timeout to avoid annoying the window manager if we're doing this in
    // response to clicking the window's close button.
    setTimeout(dumpProfile, 0);
  }
}

function OnLDBUnload() {
  gDebugger.detachBrowser();
  Services.obs.removeObserver(TabCrashedObserver, "ipc:content-shutdown");
  Services.obs.removeObserver(TabCrashedObserver, "oop-frameloader-crashed");
}

function toggle(menuitem) {
  // trim the initial "menu_"
  var feature = menuitem.id.substring(5);
  gDebugger[feature] = menuitem.getAttribute("checked") == "true";
}

function openFile() {
  var fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
  fp.init(window, "Select a File", Ci.nsIFilePicker.modeOpen);
  fp.appendFilters(Ci.nsIFilePicker.filterHTML | Ci.nsIFilePicker.filterAll);
  fp.open(rv => {
    if (
      rv == Ci.nsIFilePicker.returnOK &&
      fp.fileURL.spec &&
      fp.fileURL.spec.length > 0
    ) {
      loadURIObject(fp.fileURL);
    }
  });
}

// A simplified version of the function with the same name in tabbrowser.js.
function updateBrowserRemotenessByURL(aURL) {
  let oa = E10SUtils.predictOriginAttributes({ browser: gBrowser });
  let remoteType = E10SUtils.getRemoteTypeForURIObject(aURL, {
    multiProcess: gMultiProcessBrowser,
    remoteSubFrames: gFissionBrowser,
    preferredRemoteType: gBrowser.remoteType,
    currentURI: gBrowser.currentURI,
    originAttributes: oa,
  });
  if (gBrowser.remoteType != remoteType) {
    gDebugger.detachBrowser();
    if (remoteType == E10SUtils.NOT_REMOTE) {
      gBrowser.removeAttribute("remote");
      gBrowser.removeAttribute("remoteType");
    } else {
      gBrowser.setAttribute("remote", "true");
      gBrowser.setAttribute("remoteType", remoteType);
    }
    gBrowser.changeRemoteness({ remoteType });
    gBrowser.construct();
    gDebugger.attachBrowser();
  }
}

function loadStringURI(aURLString, aOptions) {
  let realURL;
  try {
    realURL = Services.uriFixup.getFixupURIInfo(aURLString).preferredURI;
  } catch (ex) {
    alert(
      "Couldn't work out how to create a URL from input: " +
        aURLString.substring(0, 100)
    );
    return;
  }
  return loadURIObject(realURL, aOptions);
}

async function loadURIObject(aURL, { delayLoad } = {}) {
  // We don't bother trying to handle navigations within the browser to new URLs
  // that should be loaded in a different process.
  updateBrowserRemotenessByURL(aURL);
  // When attaching the profiler we may want to delay the actual load a bit
  // after switching remoteness.
  if (delayLoad) {
    await new Promise(r => setTimeout(r, delayLoad));
  }
  gBrowser.loadURI(aURL, {
    triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
  });
}

function focusURLBar() {
  gURLBar.focus();
  gURLBar.select();
}

function go() {
  loadStringURI(gURLBar.value);
  gBrowser.focus();
}
