// -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
/* 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 https://mozilla.org/MPL/2.0/. */

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import * as constants from "resource://gre/modules/RFPTargetConstants.sys.mjs";

const kPrefResistFingerprinting = "privacy.resistFingerprinting";
const kPrefSpoofEnglish = "privacy.spoof_english";
const kTopicHttpOnModifyRequest = "http-on-modify-request";

const kPrefLetterboxing = "privacy.resistFingerprinting.letterboxing";
const kPrefLetterboxingDimensions =
  "privacy.resistFingerprinting.letterboxing.dimensions";
const kPrefLetterboxingTesting =
  "privacy.resistFingerprinting.letterboxing.testing";
const kTopicDOMWindowOpened = "domwindowopened";

var logConsole;
function log(msg) {
  if (!logConsole) {
    logConsole = console.createInstance({
      prefix: "RFPHelper",
      maxLogLevelPref: "privacy.resistFingerprinting.jsmloglevel",
    });
  }

  logConsole.log(msg);
}

class _RFPHelper {
  // ============================================================================
  // Shared Setup
  // ============================================================================
  constructor() {
    this._initialized = false;
  }

  init() {
    if (this._initialized) {
      return;
    }
    this._initialized = true;

    // Add unconditional observers
    Services.prefs.addObserver(kPrefResistFingerprinting, this);
    Services.prefs.addObserver(kPrefLetterboxing, this);
    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "_letterboxingDimensions",
      kPrefLetterboxingDimensions,
      "",
      null,
      this._parseLetterboxingDimensions
    );
    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "_isLetterboxingTesting",
      kPrefLetterboxingTesting,
      false
    );

    // Add RFP and Letterboxing observers if prefs are enabled
    this._handleResistFingerprintingChanged();
    this._handleLetterboxingPrefChanged();
  }

  uninit() {
    if (!this._initialized) {
      return;
    }
    this._initialized = false;

    // Remove unconditional observers
    Services.prefs.removeObserver(kPrefResistFingerprinting, this);
    Services.prefs.removeObserver(kPrefLetterboxing, this);
    // Remove the RFP observers, swallowing exceptions if they weren't present
    this._removeRFPObservers();
  }

  observe(subject, topic, data) {
    switch (topic) {
      case "nsPref:changed":
        this._handlePrefChanged(data);
        break;
      case kTopicHttpOnModifyRequest:
        this._handleHttpOnModifyRequest(subject, data);
        break;
      case kTopicDOMWindowOpened:
        // We attach to the newly created window by adding tabsProgressListener
        // and event listener on it. We listen for new tabs being added or
        // the change of the content principal and apply margins accordingly.
        this._handleDOMWindowOpened(subject);
        break;
      default:
        break;
    }
  }

  handleEvent(aMessage) {
    switch (aMessage.type) {
      case "TabOpen": {
        let tab = aMessage.target;
        this._addOrClearContentMargin(tab.linkedBrowser);
        break;
      }
      default:
        break;
    }
  }

  _handlePrefChanged(data) {
    switch (data) {
      case kPrefResistFingerprinting:
        this._handleResistFingerprintingChanged();
        break;
      case kPrefSpoofEnglish:
        this._handleSpoofEnglishChanged();
        break;
      case kPrefLetterboxing:
        this._handleLetterboxingPrefChanged();
        break;
      default:
        break;
    }
  }

  contentSizeUpdated(win) {
    this._updateMarginsForTabsInWindow(win);
  }

  // ============================================================================
  // Language Prompt
  // ============================================================================
  _addRFPObservers() {
    Services.prefs.addObserver(kPrefSpoofEnglish, this);
    if (this._shouldPromptForLanguagePref()) {
      Services.obs.addObserver(this, kTopicHttpOnModifyRequest);
    }
  }

  _removeRFPObservers() {
    try {
      Services.prefs.removeObserver(kPrefSpoofEnglish, this);
    } catch (e) {
      // do nothing
    }
    try {
      Services.obs.removeObserver(this, kTopicHttpOnModifyRequest);
    } catch (e) {
      // do nothing
    }
  }

  _handleResistFingerprintingChanged() {
    if (Services.prefs.getBoolPref(kPrefResistFingerprinting)) {
      this._addRFPObservers();
    } else {
      this._removeRFPObservers();
    }
  }

  _handleSpoofEnglishChanged() {
    switch (Services.prefs.getIntPref(kPrefSpoofEnglish)) {
      case 0: // will prompt
      // This should only happen when turning privacy.resistFingerprinting off.
      // Works like disabling accept-language spoofing.
      // fall through
      case 1: // don't spoof
        // We don't reset intl.accept_languages. Instead, setting
        // privacy.spoof_english to 1 allows user to change preferred language
        // settings through Preferences UI.
        break;
      case 2: // spoof
        Services.prefs.setCharPref("intl.accept_languages", "en-US, en");
        break;
      default:
        break;
    }
  }

  _shouldPromptForLanguagePref() {
    return (
      Services.locale.appLocaleAsBCP47.substr(0, 2) !== "en" &&
      Services.prefs.getIntPref(kPrefSpoofEnglish) === 0
    );
  }

  _handleHttpOnModifyRequest(subject) {
    // If we are loading an HTTP page from content, show the
    // "request English language web pages?" prompt.
    let httpChannel = subject.QueryInterface(Ci.nsIHttpChannel);

    let notificationCallbacks = httpChannel.notificationCallbacks;
    if (!notificationCallbacks) {
      return;
    }

    let loadContext = notificationCallbacks.getInterface(Ci.nsILoadContext);
    if (!loadContext || !loadContext.isContent) {
      return;
    }

    if (!subject.URI.schemeIs("http") && !subject.URI.schemeIs("https")) {
      return;
    }
    // The above QI did not throw, the scheme is http[s], and we know the
    // load context is content, so we must have a true HTTP request from content.
    // Stop the observer and display the prompt if another window has
    // not already done so.
    Services.obs.removeObserver(this, kTopicHttpOnModifyRequest);

    if (!this._shouldPromptForLanguagePref()) {
      return;
    }

    this._promptForLanguagePreference();

    // The Accept-Language header for this request was set when the
    // channel was created. Reset it to match the value that will be
    // used for future requests.
    let val = this._getCurrentAcceptLanguageValue(subject.URI);
    if (val) {
      httpChannel.setRequestHeader("Accept-Language", val, false);
    }
  }

  _promptForLanguagePreference() {
    // Display two buttons, both with string titles.
    const l10n = new Localization(
      ["toolkit/global/resistFingerPrinting.ftl"],
      true
    );
    const message = l10n.formatValueSync("privacy-spoof-english");
    const flags = Services.prompt.STD_YES_NO_BUTTONS;
    const response = Services.prompt.confirmEx(
      null,
      "",
      message,
      flags,
      null,
      null,
      null,
      null,
      { value: false }
    );

    // Update preferences to reflect their response and to prevent the prompt
    // from being displayed again.
    Services.prefs.setIntPref(kPrefSpoofEnglish, response == 0 ? 2 : 1);
  }

  _getCurrentAcceptLanguageValue(uri) {
    let channel = Services.io.newChannelFromURI(
      uri,
      null, // aLoadingNode
      Services.scriptSecurityManager.getSystemPrincipal(),
      null, // aTriggeringPrincipal
      Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
      Ci.nsIContentPolicy.TYPE_OTHER
    );
    let httpChannel;
    try {
      httpChannel = channel.QueryInterface(Ci.nsIHttpChannel);
    } catch (e) {
      return null;
    }
    return httpChannel.getRequestHeader("Accept-Language");
  }

  // ==============================================================================
  // Letterboxing
  // ============================================================================
  /**
   * We use the TabsProgressListener to catch the change of the content
   * principal. We would clear the margins around the content viewport if
   * it is the system principal.
   */
  onLocationChange(aBrowser) {
    this._addOrClearContentMargin(aBrowser);
  }

  _handleLetterboxingPrefChanged() {
    if (Services.prefs.getBoolPref(kPrefLetterboxing, false)) {
      Services.ww.registerNotification(this);
      this._registerLetterboxingActor();
      this._attachAllWindows();
    } else {
      this._unregisterLetterboxingActor();
      this._detachAllWindows();
      Services.ww.unregisterNotification(this);
    }
  }

  _registerLetterboxingActor() {
    /*
     * It turns out that this triggers a warning that we're registering a Desktop-only actor
     * in toolkit (which will also run on mobile.)  It just happens this actor only handles
     * letterboxing, which isn't used on mobile, but we should resolve this.
     */
    ChromeUtils.registerWindowActor("RFPHelper", {
      parent: {
        esModuleURI: "resource:///actors/RFPHelperParent.sys.mjs",
      },
      child: {
        esModuleURI: "resource:///actors/RFPHelperChild.sys.mjs",
        events: {
          resize: {},
        },
      },
      allFrames: true,
    });
  }

  _unregisterLetterboxingActor() {
    ChromeUtils.unregisterWindowActor("RFPHelper");
  }

  // The function to parse the dimension set from the pref value. The pref value
  // should be formated as 'width1xheight1, width2xheight2, ...'. For
  // example, '100x100, 200x200, 400x200 ...'.
  _parseLetterboxingDimensions(aPrefValue) {
    if (!aPrefValue || !aPrefValue.match(/^(?:\d+x\d+,\s*)*(?:\d+x\d+)$/)) {
      if (aPrefValue) {
        console.error(
          `Invalid pref value for ${kPrefLetterboxingDimensions}: ${aPrefValue}`
        );
      }
      return [];
    }

    return aPrefValue.split(",").map(item => {
      let sizes = item.split("x").map(size => parseInt(size, 10));

      return {
        width: sizes[0],
        height: sizes[1],
      };
    });
  }

  _addOrClearContentMargin(aBrowser) {
    let tab = aBrowser.getTabBrowser().getTabForBrowser(aBrowser);

    // We won't do anything for lazy browsers.
    if (!aBrowser.isConnected) {
      return;
    }

    // We should apply no margin around an empty tab or a tab with system
    // principal.
    if (tab.isEmpty || aBrowser.contentPrincipal.isSystemPrincipal) {
      this._clearContentViewMargin(aBrowser);
    } else {
      this._roundContentView(aBrowser);
    }
  }

  /**
   * Given a width or height, returns the appropriate margin to apply.
   */
  steppedRange(aDimension, aIsWidth = false) {
    let stepping;
    if (aDimension <= 50) {
      return 0;
    } else if (aDimension <= 500) {
      stepping = 50;
    } else if (aDimension <= 1600) {
      stepping = aIsWidth ? 200 : 100;
    } else {
      stepping = 200;
    }

    return (aDimension % stepping) / 2;
  }

  /**
   * The function will round the given browser by adding margins around the
   * content viewport.
   */
  async _roundContentView(aBrowser) {
    let logId = Math.random();
    log("_roundContentView[" + logId + "]");
    let win = aBrowser.ownerGlobal;
    let browserContainer = aBrowser
      .getTabBrowser()
      .getBrowserContainer(aBrowser);

    let { contentWidth, contentHeight, containerWidth, containerHeight } =
      await win.promiseDocumentFlushed(() => {
        let contentWidth = aBrowser.clientWidth;
        let contentHeight = aBrowser.clientHeight;
        let containerWidth = browserContainer.clientWidth;
        let containerHeight = browserContainer.clientHeight;

        // If the findbar or devtools are out, we need to subtract their height (plus 1
        // for the separator) from the container height, because we need to adjust our
        // letterboxing to account for it; however it is not included in that dimension
        // (but rather is subtracted from the content height.)
        let findBar = win.gFindBarInitialized ? win.gFindBar : undefined;
        let findBarOffset =
          findBar && !findBar.hidden ? findBar.clientHeight + 1 : 0;
        let devtools = browserContainer.getElementsByClassName(
          "devtools-toolbox-bottom-iframe"
        );
        let devtoolsOffset = devtools.length ? devtools[0].clientHeight : 0;

        return {
          contentWidth,
          contentHeight,
          containerWidth,
          containerHeight: containerHeight - findBarOffset - devtoolsOffset,
        };
      });

    log(
      "_roundContentView[" +
        logId +
        "] contentWidth=" +
        contentWidth +
        " contentHeight=" +
        contentHeight +
        " containerWidth=" +
        containerWidth +
        " containerHeight=" +
        containerHeight +
        " "
    );

    let calcMargins = (aWidth, aHeight) => {
      let result;
      log(
        "_roundContentView[" +
          logId +
          "] calcMargins(" +
          aWidth +
          ", " +
          aHeight +
          ")"
      );
      // If the set is empty, we will round the content with the default
      // stepping size.
      if (!this._letterboxingDimensions.length) {
        result = {
          width: this.steppedRange(aWidth, true),
          height: this.steppedRange(aHeight),
        };
        log(
          "_roundContentView[" +
            logId +
            "] calcMargins(" +
            aWidth +
            ", " +
            aHeight +
            ") = " +
            result.width +
            " x " +
            result.height
        );
        return result;
      }

      let matchingArea = aWidth * aHeight;
      let minWaste = Number.MAX_SAFE_INTEGER;
      let targetDimensions = undefined;

      // Find the desired dimensions which waste the least content area.
      for (let dim of this._letterboxingDimensions) {
        // We don't need to consider the dimensions which cannot fit into the
        // real content size.
        if (dim.width > aWidth || dim.height > aHeight) {
          continue;
        }

        let waste = matchingArea - dim.width * dim.height;

        if (waste >= 0 && waste < minWaste) {
          targetDimensions = dim;
          minWaste = waste;
        }
      }

      // If we cannot find any dimensions match to the real content window, this
      // means the content area is smaller the smallest size in the set. In this
      // case, we won't apply any margins.
      if (!targetDimensions) {
        result = {
          width: 0,
          height: 0,
        };
      } else {
        result = {
          width: (aWidth - targetDimensions.width) / 2,
          height: (aHeight - targetDimensions.height) / 2,
        };
      }

      log(
        "_roundContentView[" +
          logId +
          "] calcMargins(" +
          aWidth +
          ", " +
          aHeight +
          ") = " +
          result.width +
          " x " +
          result.height
      );
      return result;
    };

    // Calculating the margins around the browser element in order to round the
    // content viewport. We will use a 200x100 stepping if the dimension set
    // is not given.
    let margins = calcMargins(containerWidth, containerHeight);

    // If the size of the content is already quantized, we do nothing.
    if (aBrowser.style.margin == `${margins.height}px ${margins.width}px`) {
      log("_roundContentView[" + logId + "] is_rounded == true");
      if (this._isLetterboxingTesting) {
        log(
          "_roundContentView[" +
            logId +
            "] is_rounded == true test:letterboxing:update-margin-finish"
        );
        Services.obs.notifyObservers(
          null,
          "test:letterboxing:update-margin-finish"
        );
      }
      return;
    }

    win.requestAnimationFrame(() => {
      log(
        "_roundContentView[" +
          logId +
          "] setting margins to " +
          margins.width +
          " x " +
          margins.height
      );
      // One cannot (easily) control the color of a margin unfortunately.
      // An initial attempt to use a border instead of a margin resulted
      // in offset event dispatching; so for now we use a colorless margin.
      aBrowser.style.margin = `${margins.height}px ${margins.width}px`;
    });
  }

  _clearContentViewMargin(aBrowser) {
    aBrowser.ownerGlobal.requestAnimationFrame(() => {
      aBrowser.style.margin = "";
    });
  }

  _updateMarginsForTabsInWindow(aWindow) {
    let tabBrowser = aWindow.gBrowser;

    for (let tab of tabBrowser.tabs) {
      let browser = tab.linkedBrowser;
      this._addOrClearContentMargin(browser);
    }
  }

  _attachWindow(aWindow) {
    aWindow.gBrowser.addTabsProgressListener(this);
    aWindow.addEventListener("TabOpen", this);

    // Rounding the content viewport.
    this._updateMarginsForTabsInWindow(aWindow);
  }

  _attachAllWindows() {
    let windowList = Services.wm.getEnumerator("navigator:browser");

    while (windowList.hasMoreElements()) {
      let win = windowList.getNext();

      if (win.closed || !win.gBrowser) {
        continue;
      }

      this._attachWindow(win);
    }
  }

  _detachWindow(aWindow) {
    let tabBrowser = aWindow.gBrowser;
    tabBrowser.removeTabsProgressListener(this);
    aWindow.removeEventListener("TabOpen", this);

    // Clear all margins and tooltip for all browsers.
    for (let tab of tabBrowser.tabs) {
      let browser = tab.linkedBrowser;
      this._clearContentViewMargin(browser);
    }
  }

  _detachAllWindows() {
    let windowList = Services.wm.getEnumerator("navigator:browser");

    while (windowList.hasMoreElements()) {
      let win = windowList.getNext();

      if (win.closed || !win.gBrowser) {
        continue;
      }

      this._detachWindow(win);
    }
  }

  _handleDOMWindowOpened(win) {
    let self = this;

    win.addEventListener(
      "load",
      () => {
        // We attach to the new window when it has been loaded if the new loaded
        // window is a browsing window.
        if (
          win.document.documentElement.getAttribute("windowtype") !==
          "navigator:browser"
        ) {
          return;
        }
        self._attachWindow(win);
      },
      { once: true }
    );
  }

  getTargets() {
    return constants.Targets;
  }

  getTargetDefaults() {
    const key =
      Services.appinfo.OS === "Android" ? "ANDROID_DEFAULT" : "DESKTOP_DEFAULT";
    return constants.DefaultTargets[key];
  }
}

export let RFPHelper = new _RFPHelper();
