/* 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 { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs";

export class GeckoViewProcessHangMonitor extends GeckoViewModule {
  constructor(aModuleInfo) {
    super(aModuleInfo);

    /**
     * Collection of hang reports that haven't expired or been dismissed
     * by the user. These are nsIHangReports.
     */
    this._activeReports = new Set();

    /**
     * Collection of hang reports that have been suppressed for a short
     * period of time. Keys are nsIHangReports. Values are timeouts for
     * when the wait time expires.
     */
    this._pausedReports = new Map();

    /**
     * Simple index used for report identification
     */
    this._nextIndex = 0;

    /**
     * Map of report IDs to report objects.
     * Keys are numbers. Values are nsIHangReports.
     */
    this._reportIndex = new Map();

    /**
     * Map of report objects to report IDs.
     * Keys are nsIHangReports. Values are numbers.
     */
    this._reportLookupIndex = new Map();
  }

  onInit() {
    debug`onInit`;
    Services.obs.addObserver(this, "process-hang-report");
    Services.obs.addObserver(this, "clear-hang-report");
  }

  onDestroy() {
    debug`onDestroy`;
    Services.obs.removeObserver(this, "process-hang-report");
    Services.obs.removeObserver(this, "clear-hang-report");
  }

  onEnable() {
    debug`onEnable`;
    this.registerListener([
      "GeckoView:HangReportStop",
      "GeckoView:HangReportWait",
    ]);
  }

  onDisable() {
    debug`onDisable`;
    this.unregisterListener();
  }

  // Bundle event handler.
  onEvent(aEvent, aData) {
    debug`onEvent: event=${aEvent}, data=${aData}`;

    if (this._reportIndex.has(aData.hangId)) {
      const report = this._reportIndex.get(aData.hangId);
      switch (aEvent) {
        case "GeckoView:HangReportStop":
          this.stopHang(report);
          break;
        case "GeckoView:HangReportWait":
          this.pauseHang(report);
          break;
      }
    } else {
      debug`Report not found: reportIndex=${this._reportIndex}`;
    }
  }

  // nsIObserver event handler
  observe(aSubject, aTopic) {
    debug`observe(aTopic=${aTopic})`;
    aSubject.QueryInterface(Ci.nsIHangReport);
    if (!aSubject.isReportForBrowserOrChildren(this.browser.frameLoader)) {
      return;
    }

    switch (aTopic) {
      case "process-hang-report": {
        this.reportHang(aSubject);
        break;
      }
      case "clear-hang-report": {
        this.clearHang(aSubject);
        break;
      }
    }
  }

  /**
   * This timeout is the wait period applied after a user selects "Wait" in
   * an existing notification.
   */
  get WAIT_EXPIRATION_TIME() {
    try {
      return Services.prefs.getIntPref("browser.hangNotification.waitPeriod");
    } catch (ex) {
      return 10000;
    }
  }

  /**
   * Terminate whatever is causing this report, be it an add-on or page script.
   * This is done without updating any report notifications.
   */
  stopHang(report) {
    report.terminateScript();
  }

  /**
   *
   */
  pauseHang(report) {
    this._activeReports.delete(report);

    // Create a new timeout with notify callback
    const timer = this.window.setTimeout(() => {
      for (const [stashedReport, otherTimer] of this._pausedReports) {
        if (otherTimer === timer) {
          this._pausedReports.delete(stashedReport);

          // We're still hung, so move the report back to the active
          // list.
          this._activeReports.add(report);
          break;
        }
      }
    }, this.WAIT_EXPIRATION_TIME);

    this._pausedReports.set(report, timer);
  }

  /**
   * construct an information bundle
   */
  notifyReport(report) {
    this.eventDispatcher.sendRequest({
      type: "GeckoView:HangReport",
      hangId: this._reportLookupIndex.get(report),
      scriptFileName: report.scriptFileName,
    });
  }

  /**
   * Handle a potentially new hang report.
   */
  reportHang(report) {
    // if we aren't enabled then default to stopping the script
    if (!this.enabled) {
      this.stopHang(report);
      return;
    }

    // if we have already notified, remind
    if (this._activeReports.has(report)) {
      this.notifyReport(report);
      return;
    }

    // If this hang was already reported and paused by the user then ignore it.
    if (this._pausedReports.has(report)) {
      return;
    }

    const index = this._nextIndex++;
    this._reportLookupIndex.set(report, index);
    this._reportIndex.set(index, report);
    this._activeReports.add(report);

    // Actually notify the new report
    this.notifyReport(report);
  }

  clearHang(report) {
    this._activeReports.delete(report);

    const timer = this._pausedReports.get(report);
    if (timer) {
      this.window.clearTimeout(timer);
    }
    this._pausedReports.delete(report);

    if (this._reportLookupIndex.has(report)) {
      const index = this._reportLookupIndex.get(report);
      this._reportIndex.delete(index);
    }
    this._reportLookupIndex.delete(report);
    report.userCanceled();
  }
}

const { debug, warn } = GeckoViewProcessHangMonitor.initLogging(
  "GeckoViewProcessHangMonitor"
);
