/* 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";

/* globals browser, module, onMessageFromTab */

const releaseBranchPromise = browser.appConstants.getReleaseBranch();

const platformPromise = browser.runtime.getPlatformInfo().then(info => {
  return info.os === "android" ? "android" : "desktop";
});

let debug = async function() {
  if ((await releaseBranchPromise) !== "beta_or_release") {
    console.debug.apply(this, arguments);
  }
};
let error = async function() {
  if ((await releaseBranchPromise) !== "beta_or_release") {
    console.error.apply(this, arguments);
  }
};
let warn = async function() {
  if ((await releaseBranchPromise) !== "beta_or_release") {
    console.warn.apply(this, arguments);
  }
};

class Shim {
  constructor(opts) {
    const { matches, unblocksOnOptIn } = opts;

    this.branches = opts.branches;
    this.bug = opts.bug;
    this.file = opts.file;
    this.hosts = opts.hosts;
    this.id = opts.id;
    this.matches = matches;
    this.name = opts.name;
    this.notHosts = opts.notHosts;
    this.onlyIfBlockedByETP = opts.onlyIfBlockedByETP;
    this._options = opts.options || {};
    this.needsShimHelpers = opts.needsShimHelpers;
    this.platform = opts.platform || "all";
    this.unblocksOnOptIn = unblocksOnOptIn;

    this._hostOptIns = new Set();

    this._disabledByConfig = opts.disabled;
    this._disabledGlobally = false;
    this._disabledByPlatform = false;
    this._disabledByReleaseBranch = false;

    const pref = `disabled_shims.${this.id}`;

    browser.aboutConfigPrefs.onPrefChange.addListener(async () => {
      const value = await browser.aboutConfigPrefs.getPref(pref);
      this._disabledPrefValue = value;
      this._onEnabledStateChanged();
    }, pref);

    this.ready = Promise.all([
      browser.aboutConfigPrefs.getPref(pref).then(value => {
        this._disabledPrefValue = value;
      }),
      platformPromise.then(platform => {
        this._disabledByPlatform =
          this.platform !== "all" && this.platform !== platform;
        return platform;
      }),
      releaseBranchPromise.then(branch => {
        this._disabledByReleaseBranch =
          this.branches && !this.branches.includes(branch);
        return branch;
      }),
    ]).then(([_, platform, branch]) => {
      this._preprocessOptions(platform, branch);
      this._onEnabledStateChanged();
    });
  }

  _preprocessOptions(platform, branch) {
    // options may be any value, but can optionally be gated for specified
    // platform/branches, if in the format `{value, branches, platform}`
    this.options = {};
    for (const [k, v] of Object.entries(this._options)) {
      if (v?.value) {
        if (
          (!v.platform || v.platform === platform) &&
          (!v.branches || v.branches.includes(branch))
        ) {
          this.options[k] = v.value;
        }
      } else {
        this.options[k] = v;
      }
    }
  }

  get enabled() {
    if (this._disabledGlobally) {
      return false;
    }

    if (this._disabledPrefValue !== undefined) {
      return !this._disabledPrefValue;
    }

    return (
      !this._disabledByConfig &&
      !this._disabledByPlatform &&
      !this._disabledByReleaseBranch
    );
  }

  enable() {
    this._disabledGlobally = false;
    this._onEnabledStateChanged();
  }

  disable() {
    this._disabledGlobally = true;
    this._onEnabledStateChanged();
  }

  _onEnabledStateChanged() {
    if (!this.enabled) {
      return this._revokeRequestsInETP();
    }
    return this._allowRequestsInETP();
  }

  _allowRequestsInETP() {
    return browser.trackingProtection.allow(this.id, this.matches, {
      hosts: this.hosts,
      notHosts: this.notHosts,
    });
  }

  _revokeRequestsInETP() {
    return browser.trackingProtection.revoke(this.id);
  }

  meantForHost(host) {
    const { hosts, notHosts } = this;
    if (hosts || notHosts) {
      if (
        (notHosts && notHosts.includes(host)) ||
        (hosts && !hosts.includes(host))
      ) {
        return false;
      }
    }
    return true;
  }

  isTriggeredByURL(url) {
    if (!this.matches) {
      return false;
    }

    if (!this._matcher) {
      this._matcher = browser.matchPatterns.getMatcher(this.matches);
    }

    return this._matcher.matches(url);
  }

  async onUserOptIn(host) {
    const { unblocksOnOptIn } = this;
    if (unblocksOnOptIn) {
      await browser.trackingProtection.allow(this.id, unblocksOnOptIn, {
        hosts: [host],
      });
    }

    this._hostOptIns.add(host);
  }

  hasUserOptedInAlready(host) {
    return this._hostOptIns.has(host);
  }
}

class Shims {
  constructor(availableShims) {
    if (!browser.trackingProtection) {
      console.error("Required experimental add-on APIs for shims unavailable");
      return;
    }

    this._registerShims(availableShims);

    onMessageFromTab(this._onMessageFromShim.bind(this));

    this.ENABLED_PREF = "enable_shims";
    browser.aboutConfigPrefs.onPrefChange.addListener(() => {
      this._checkEnabledPref();
    }, this.ENABLED_PREF);
    this._haveCheckedEnabledPref = this._checkEnabledPref();
  }

  _registerShims(shims) {
    if (this.shims) {
      throw new Error("_registerShims has already been called");
    }

    this.shims = new Map();
    for (const shimOpts of shims) {
      const { id } = shimOpts;
      if (!this.shims.has(id)) {
        this.shims.set(shimOpts.id, new Shim(shimOpts));
      }
    }

    const allShimPatterns = new Set();
    for (const { matches } of this.shims.values()) {
      for (const matchPattern of matches) {
        allShimPatterns.add(matchPattern);
      }
    }

    if (!allShimPatterns.size) {
      debug("Skipping shims; none enabled");
      return;
    }

    const urls = [...allShimPatterns];
    debug("Shimming these match patterns", urls);

    browser.webRequest.onBeforeRequest.addListener(
      this._ensureShimForRequestOnTab.bind(this),
      { urls, types: ["script"] },
      ["blocking"]
    );
  }

  async _checkEnabledPref() {
    await browser.aboutConfigPrefs.getPref(this.ENABLED_PREF).then(value => {
      if (value === undefined) {
        browser.aboutConfigPrefs.setPref(this.ENABLED_PREF, true);
      } else if (value === false) {
        this.enabled = false;
      } else {
        this.enabled = true;
      }
    });
  }

  get enabled() {
    return this._enabled;
  }

  set enabled(enabled) {
    if (enabled === this._enabled) {
      return;
    }

    this._enabled = enabled;

    for (const shim of this.shims.values()) {
      if (enabled) {
        shim.enable();
      } else {
        shim.disable();
      }
    }
  }

  async _onMessageFromShim(payload, sender, sendResponse) {
    const { tab, frameId } = sender;
    const { id, url } = tab;
    const { shimId, message } = payload;

    // Ignore unknown messages (for instance, from about:compat).
    if (message !== "getOptions" && message !== "optIn") {
      return undefined;
    }

    if (sender.id !== browser.runtime.id || id === -1) {
      throw new Error("not allowed");
    }

    // Important! It is entirely possible for sites to spoof
    // these messages, due to shims allowing web pages to
    // communicate with the extension.

    const shim = this.shims.get(shimId);
    if (!shim?.needsShimHelpers?.includes(message)) {
      throw new Error("not allowed");
    }

    if (message === "getOptions") {
      return shim.options;
    } else if (message === "optIn") {
      try {
        await shim.onUserOptIn(new URL(url).hostname);
        const { name, bug } = shim;
        const origin = new URL(tab.url).origin;
        warn(
          "** User opted in for",
          name,
          "shim on",
          origin,
          "on tab",
          id,
          "frame",
          frameId
        );
        const warning = `${name} is now being allowed on ${origin} for this browsing session. See https://bugzilla.mozilla.org/show_bug.cgi?id=${bug} for details.`;
        await browser.tabs.executeScript(id, {
          code: `console.warn(${JSON.stringify(warning)})`,
          frameId,
          runAt: "document_start",
        });
      } catch (err) {
        console.error(err);
        throw new Error("error");
      }
    }

    return undefined;
  }

  async _ensureShimForRequestOnTab(details) {
    await this._haveCheckedEnabledPref;

    if (!this.enabled) {
      return undefined;
    }

    // We only ever reach this point if a request is for a URL which ought to
    // be shimmed. We never get here if a request is blocked, and we only
    // unblock requests if at least one shim matches it.

    const { frameId, originUrl, requestId, tabId, url } = details;

    // Ignore requests unrelated to tabs
    if (tabId < 0) {
      return undefined;
    }

    // We need to base our checks not on the frame's host, but the tab's.
    const topHost = new URL((await browser.tabs.get(tabId)).url).hostname;
    const unblocked = await browser.trackingProtection.wasRequestUnblocked(
      requestId
    );

    let shimToApply;
    for (const shim of this.shims.values()) {
      await shim.ready;

      if (!shim.enabled) {
        continue;
      }

      // Do not apply the shim if it is only meant to apply when strict mode ETP
      // (content blocking) was going to block the request.
      if (!unblocked && shim.onlyIfBlockedByETP) {
        continue;
      }

      if (!shim.meantForHost(topHost)) {
        continue;
      }

      // If the user has already opted in for this shim, all requests it covers
      // should be allowed; no need for a shim anymore.
      if (shim.hasUserOptedInAlready(topHost)) {
        return undefined;
      }

      // If this URL isn't meant for this shim, don't apply it.
      if (!shim.isTriggeredByURL(url)) {
        continue;
      }

      shimToApply = shim;
      break;
    }

    if (shimToApply) {
      // Note that sites may request the same shim twice, but because the requests
      // may differ enough for some to fail (CSP/CORS/etc), we always re-run the
      // shim JS just in case. Shims should gracefully handle this as well.
      const { bug, file, id, name, needsShimHelpers } = shimToApply;
      warn("Shimming", name, "on tabId", tabId, "frameId", frameId);

      const warning = `${name} is being shimmed by Firefox. See https://bugzilla.mozilla.org/show_bug.cgi?id=${bug} for details.`;

      try {
        if (needsShimHelpers?.length) {
          await browser.tabs.executeScript(tabId, {
            file: "/lib/shim_messaging_helper.js",
            frameId,
            runAt: "document_start",
          });
          const origin = new URL(originUrl).origin;
          await browser.tabs.sendMessage(
            tabId,
            { origin, shimId: id, needsShimHelpers, warning },
            { frameId }
          );
        } else {
          await browser.tabs.executeScript(tabId, {
            code: `console.warn(${JSON.stringify(warning)})`,
            frameId,
            runAt: "document_start",
          });
        }
      } catch (_) {}

      // If any shims matched the script to replace it, then let the original
      // request complete without ever hitting the network, with a blank script.
      return { redirectUrl: browser.runtime.getURL(`shims/${file}`) };
    }

    // Sanity check: if no shims are over-riding a given URL and it was meant to
    // be blocked by ETP, then block it.
    if (unblocked) {
      error("unexpected:", url, "was not shimmed, and had to be re-blocked");
      return { cancel: true };
    }

    debug("allowing", url);
    return undefined;
  }
}

module.exports = Shims;
