/* 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, InterventionHelpers */

const debugLoggingPrefValue = browser.aboutConfigPrefs.getPref(
  "disable_debug_logging"
);
let debugLog = function () {
  if (debugLoggingPrefValue !== true) {
    console.debug.apply(this, arguments);
  }
};

class Interventions {
  constructor(availableInterventions, customFunctions) {
    this._originalInterventions = availableInterventions;

    this.INTERVENTION_PREF = "enable_interventions";

    this._interventionsEnabled = true;

    this._readyPromise = new Promise(done => (this._resolveReady = done));

    this._disabledPrefListeners = {};

    this._availableInterventions = this._reformatSourceJSON(
      availableInterventions
    );
    this._customFunctions = customFunctions;

    this._activeListenersPerIntervention = new Map();
    this._contentScriptsPerIntervention = new Map();
  }

  _reformatSourceJSON(availableInterventions) {
    return Object.entries(availableInterventions).map(([id, obj]) => {
      obj.id = id;
      return obj;
    });
  }

  async onRemoteSettingsUpdate(updatedInterventions) {
    const oldReadyPromise = this._readyPromise;
    this._readyPromise = new Promise(done => (this._resolveReady = done));
    await oldReadyPromise;
    this._updateInterventions(updatedInterventions);
  }

  async _updateInterventions(updatedInterventions) {
    await this.disableInterventions();
    this._availableInterventions =
      this._reformatSourceJSON(updatedInterventions);
    await this.enableInterventions();
  }

  async _resetToDefaultInterventions() {
    await this._updateInterventions(this._originalInterventions);
  }

  ready() {
    return this._readyPromise;
  }

  bindAboutCompatBroker(broker) {
    this._aboutCompatBroker = broker;
  }

  bootup() {
    browser.aboutConfigPrefs.onPrefChange.addListener(() => {
      this.checkInterventionPref();
    }, this.INTERVENTION_PREF);
    this.checkInterventionPref();
  }

  async updateInterventions(_data) {
    const data = structuredClone(_data);
    await this.disableInterventions(data);
    await this.enableInterventions(data);
    for (const intervention of data) {
      const { id } = intervention;
      const i = this._availableInterventions.findIndex(v => v.id === id);
      if (i > -1) {
        this._availableInterventions[i] = intervention;
      } else {
        this._availableInterventions.push(intervention);
      }
    }
    return data;
  }

  checkInterventionPref() {
    navigator.locks.request("pref_check_lock", async () => {
      const value = browser.aboutConfigPrefs.getPref(this.INTERVENTION_PREF);
      if (value === undefined) {
        await browser.aboutConfigPrefs.setPref(this.INTERVENTION_PREF, true);
      } else if (value === false) {
        await this.disableInterventions();
      } else {
        await this.enableInterventions();
      }
    });
  }

  getAvailableInterventions() {
    return this._availableInterventions;
  }

  _getActiveInterventionById(whichId) {
    return this._availableInterventions.find(({ id }) => id === whichId);
  }

  isEnabled() {
    return this._interventionsEnabled;
  }

  async enableInterventions(whichInterventions = this._availableInterventions) {
    return navigator.locks.request("intervention_lock", async () => {
      await this._enableInterventionsNow(whichInterventions);
    });
  }

  async disableInterventions(
    whichInterventions = this._availableInterventions
  ) {
    return navigator.locks.request("intervention_lock", async () => {
      for (const config of whichInterventions) {
        const disabling_pref_listener = this._disabledPrefListeners[config.id];
        if (disabling_pref_listener) {
          browser.aboutConfigPrefs.onPrefChange.removeListener(
            disabling_pref_listener
          );
          delete this._disabledPrefListeners[config.id];
        }

        await this._disableInterventionNow(config);
      }

      this._interventionsEnabled = false;
      this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({
        interventionsChanged: false,
      });
    });
  }

  #checkedPrefListeners = new Map();
  #checkedPrefCache = new Map();

  async onCheckedPrefChanged(pref) {
    navigator.locks.request("pref_check_lock", async () => {
      this.#checkedPrefCache.delete(pref);
      const toRecheck = this._availableInterventions.filter(cfg =>
        cfg.interventions.find(i => i.pref_check && pref in i.pref_check)
      );
      await this.updateInterventions(toRecheck);
    });
  }

  _check_for_needed_prefs(intervention) {
    if (!intervention.pref_check) {
      return true;
    }
    for (const pref of Object.keys(intervention.pref_check ?? {})) {
      if (!this.#checkedPrefListeners.has(pref)) {
        const listener = () => this.onCheckedPrefChanged(pref);
        this.#checkedPrefListeners.set(pref, listener);
        browser.aboutConfigPrefs.onPrefChange.addListener(listener, pref);
      }
    }
    for (const [pref, value] of Object.entries(intervention.pref_check ?? {})) {
      if (!this.#checkedPrefCache.has(pref)) {
        this.#checkedPrefCache.set(
          pref,
          browser.aboutConfigPrefs.getPref(pref)
        );
      }
      if (value !== this.#checkedPrefCache.get(pref)) {
        return false;
      }
    }
    return true;
  }

  async _enableInterventionsNow(whichInterventions) {
    // resolveReady may change while we're updating
    const resolveReady = this._resolveReady;

    const skipped = [];

    const channel = await browser.appConstants.getEffectiveUpdateChannel();
    const version =
      this.versionForTesting ??
      (await browser.runtime.getBrowserInfo()).version;
    const cleanVersion = parseFloat(version.match(/\d+(\.\d+)?/)[0]);

    const os = await InterventionHelpers.getOS();
    this.currentPlatform = os;

    const customFunctionNames = new Set(Object.keys(this._customFunctions));

    for (const config of whichInterventions) {
      config.active = false;

      if (config.isMissingFiles) {
        skipped.push(config.label);
        continue;
      }

      config.DISABLING_PREF = `disabled_interventions.${config.id}`;
      const disabledPrefListener = () => {
        navigator.locks.request("pref_check_lock", async () => {
          const value = browser.aboutConfigPrefs.getPref(config.DISABLING_PREF);
          if (value === true) {
            await this.disableIntervention(config);
            debugLog(
              `Webcompat intervention for ${config.label} disabled by pref`
            );
          } else {
            await this.enableIntervention(config);
            debugLog(
              `Webcompat intervention for ${config.label} enabled by pref`
            );
          }
          this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({
            interventionsChanged:
              this._aboutCompatBroker.filterInterventions(whichInterventions),
          });
        });
      };
      this._disabledPrefListeners[config.id] = disabledPrefListener;
      browser.aboutConfigPrefs.onPrefChange.addListener(
        disabledPrefListener,
        config.DISABLING_PREF
      );

      const disablingPrefValue = browser.aboutConfigPrefs.getPref(
        config.DISABLING_PREF
      );

      for (const intervention of config.interventions) {
        intervention.enabled = false;
        if (!this._check_for_needed_prefs(intervention)) {
          continue;
        }
        if (
          InterventionHelpers.shouldSkip(intervention, cleanVersion, channel)
        ) {
          continue;
        }
        if (
          InterventionHelpers.isMissingCustomFunctions(
            intervention,
            customFunctionNames
          )
        ) {
          continue;
        }
        if (!(await InterventionHelpers.checkPlatformMatches(intervention))) {
          // special case: allow platforms=[] to indicate "disabled by default"
          if (
            intervention.platforms &&
            !intervention.platforms.length &&
            !intervention.not_platforms
          ) {
            config.availableOnPlatform = true;
          }
          continue;
        }
        intervention.enabled = true;
        config.availableOnPlatform = true;
      }

      if (!config.availableOnPlatform) {
        skipped.push(config.label);
        continue;
      }
      if (disablingPrefValue === true) {
        skipped.push(config.label);
        continue;
      }

      try {
        await this._enableInterventionNow(config);
      } catch (e) {
        console.error("Error enabling intervention(s) for", config.label, e);
      }
    }

    if (skipped.length) {
      debugLog(
        "Skipping",
        skipped.length,
        "un-needed interventions",
        skipped.sort()
      );
    }

    this._interventionsEnabled = true;
    this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({
      interventionsChanged:
        this._aboutCompatBroker.filterInterventions(whichInterventions),
    });

    resolveReady();
  }

  async enableIntervention(config, force = false) {
    return navigator.locks.request("intervention_lock", async () => {
      await this._enableInterventionNow(config, force);
    });
  }

  async disableIntervention(config) {
    return navigator.locks.request("intervention_lock", async () => {
      await this._disableInterventionNow(config);
    });
  }

  async _enableInterventionNow(config, force = false) {
    if (config.active) {
      return;
    }

    const { bugs, label } = config;
    const blocks = Object.values(bugs)
      .map(bug => bug.blocks)
      .flat()
      .filter(v => v !== undefined);
    const matches = Object.values(bugs)
      .map(bug => bug.matches)
      .flat()
      .filter(v => v !== undefined);

    let somethingWasEnabled = false;
    for (const intervention of config.interventions) {
      if (!intervention.enabled && !force) {
        continue;
      }

      await this._changeCustomFuncs("enable", label, intervention, config);
      if (intervention.content_scripts) {
        await this._enableContentScripts(
          config.id,
          label,
          intervention,
          matches
        );
      }
      await this._enableUAOverrides(label, intervention, matches);
      await this._enableRequestBlocks(label, intervention, blocks);
      somethingWasEnabled = true;
      intervention.enabled = true;
    }

    if (!this._getActiveInterventionById(config.id)) {
      this._availableInterventions.push(config);
      debugLog("Added webcompat intervention", config.id, config);
    } else {
      for (const [index, oldConfig] of this._availableInterventions.entries()) {
        if (oldConfig.id === config.id && oldConfig !== config) {
          debugLog("Replaced webcompat intervention", oldConfig.id, config);
          this._availableInterventions[index] = config;
        }
      }
    }

    config.active = somethingWasEnabled;
  }

  async _disableInterventionNow(_config) {
    const config = this._getActiveInterventionById(_config?.id ?? _config);
    if (!config) {
      return;
    }

    const { active, label, interventions } = config;

    if (!active) {
      return;
    }

    for (const intervention of interventions) {
      if (!intervention.enabled) {
        continue;
      }

      await this._changeCustomFuncs("disable", label, intervention, config);
      if (intervention.content_scripts) {
        await this._disableContentScripts(label, intervention);
      }

      // This covers both request blocks and ua_string cases
      const listeners = this._activeListenersPerIntervention.get(intervention);
      if (listeners) {
        for (const [name, listener] of Object.entries(listeners)) {
          browser.webRequest[name].removeListener(listener);
        }
        this._activeListenersPerIntervention.delete(intervention);
      }
    }

    config.active = false;
  }

  async _changeCustomFuncs(action, label, intervention, config) {
    for (const [customFuncName, customFunc] of Object.entries(
      this._customFunctions
    )) {
      if (customFuncName in intervention) {
        for (const details of intervention[customFuncName]) {
          try {
            await customFunc[action](details, config);
          } catch (e) {
            console.trace(
              `Error while calling custom function ${customFuncName}.${action} for ${label}:`,
              e
            );
          }
        }
      }
    }
  }

  async _enableUAOverrides(label, intervention, matches) {
    if (!("ua_string" in intervention)) {
      return;
    }

    let listeners = this._activeListenersPerIntervention.get(intervention);
    if (!listeners) {
      listeners = {};
      this._activeListenersPerIntervention.set(intervention, listeners);
    }

    const listener = details => {
      const { enabled, ua_string } = intervention;

      // Don't actually override the UA for an experiment if the user is not
      // part of the experiment (unless they force-enabed the override).
      if (
        enabled &&
        (!intervention.experiment || intervention.permanentPrefEnabled === true)
      ) {
        for (const header of details.requestHeaders) {
          if (header.name.toLowerCase() !== "user-agent") {
            continue;
          }

          // Don't override the UA if we're on a mobile device that has the
          // "Request Desktop Site" mode enabled. The UA for the desktop mode
          // is set inside Gecko with a simple string replace, so we can use
          // that as a check, see https://searchfox.org/mozilla-central/rev/89d33e1c3b0a57a9377b4815c2f4b58d933b7c32/mobile/android/chrome/geckoview/GeckoViewSettingsChild.js#23-28
          let isMobileWithDesktopMode =
            this.currentPlatform == "android" &&
            header.value.includes("X11; Linux x86_64");
          if (isMobileWithDesktopMode) {
            continue;
          }

          header.value = InterventionHelpers.applyUAChanges(
            header.value,
            ua_string
          );
        }
      }
      return { requestHeaders: details.requestHeaders };
    };

    browser.webRequest.onBeforeSendHeaders.addListener(
      listener,
      { urls: matches },
      ["blocking", "requestHeaders"]
    );

    listeners.onBeforeSendHeaders = listener;

    debugLog(`Enabled UA override for ${label}`);
  }

  async _enableRequestBlocks(label, intervention, blocks) {
    if (!blocks.length) {
      return;
    }

    let listeners = this._activeListenersPerIntervention.get(intervention);
    if (!listeners) {
      listeners = {};
      this._activeListenersPerIntervention.set(intervention, listeners);
    }

    const listener = () => {
      return { cancel: true };
    };

    browser.webRequest.onBeforeRequest.addListener(listener, { urls: blocks }, [
      "blocking",
    ]);

    listeners.onBeforeRequest = listener;
    debugLog(`Blocking requests as specified for ${label}`);
  }

  async _enableContentScripts(bug, label, intervention, matches) {
    const scriptsToReg = this._buildContentScriptRegistrations(
      label,
      intervention,
      matches
    );
    this._contentScriptsPerIntervention.set(intervention, scriptsToReg);

    // Try to avoid re-registering scripts already registered
    // (e.g. if the webcompat background page is restarted
    // after an extension process crash, after having registered
    // the content scripts already once), but do not prevent
    // to try registering them again if the getRegisteredContentScripts
    // method returns an unexpected rejection.

    const ids = scriptsToReg.map(s => s.id);
    try {
      const alreadyRegged = await browser.scripting.getRegisteredContentScripts(
        { ids }
      );
      const alreadyReggedIds = alreadyRegged.map(script => script.id);
      const stillNeeded = scriptsToReg.filter(
        ({ id }) => !alreadyReggedIds.includes(id)
      );
      await browser.scripting.registerContentScripts(stillNeeded);
      debugLog(
        `Registered still-not-active content scripts for ${label}`,
        stillNeeded
      );
    } catch (e) {
      try {
        await browser.scripting.registerContentScripts(scriptsToReg);
        debugLog(
          `Registered all content scripts for ${label} after error registering just non-active ones`,
          scriptsToReg,
          e
        );
      } catch (e2) {
        console.error(
          `Error while registering content scripts for ${label}:`,
          e2,
          scriptsToReg
        );
      }
    }
  }

  async _disableContentScripts(label, intervention) {
    const contentScripts =
      this._contentScriptsPerIntervention.get(intervention);
    if (contentScripts) {
      const ids = contentScripts.map(s => s.id);
      await browser.scripting.unregisterContentScripts({ ids });
    }
  }

  _buildContentScriptRegistrations(label, intervention, matches) {
    const registration = {
      id: `webcompat intervention for ${label}: ${JSON.stringify(intervention.content_scripts)}`,
      matches,
      persistAcrossSessions: false,
    };

    let { all_frames, css, js, run_at } = intervention.content_scripts;
    if (!css && !js) {
      console.error(`Missing js or css for content_script in ${label}`);
      return [];
    }
    if (all_frames) {
      registration.allFrames = true;
    }
    if (css) {
      registration.css = css.map(item => {
        if (item.includes("/")) {
          return item;
        }
        return `injections/css/${item}`;
      });
    }
    if (js) {
      registration.js = js.map(item => {
        if (item.includes("/")) {
          return item;
        }
        return `injections/js/${item}`;
      });
    }
    if (run_at) {
      registration.runAt = run_at;
    } else {
      registration.runAt = "document_start";
    }

    return [registration];
  }
}
