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

class Interventions {
  constructor(availableInterventions, customFunctions) {
    this.INTERVENTION_PREF = "perform_injections";

    this._interventionsEnabled = true;

    this._availableInterventions = Object.entries(availableInterventions).map(
      ([id, obj]) => {
        obj.id = id;
        return obj;
      }
    );
    this._customFunctions = customFunctions;

    // We do not try to enable/disable until we finish any such previous operation.
    this._enablingOrDisablingOperationInProgress = Promise.resolve();

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

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

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

  checkInterventionPref() {
    browser.aboutConfigPrefs.getPref(this.INTERVENTION_PREF).then(value => {
      if (value === undefined) {
        browser.aboutConfigPrefs.setPref(this.INTERVENTION_PREF, true);
      } else if (value === false) {
        this.disableInterventions();
      } else {
        this.enableInterventions();
      }
    });
  }

  checkOverridePref() {
    browser.aboutConfigPrefs.getPref(this.OVERRIDE_PREF).then(value => {
      if (value === undefined) {
        browser.aboutConfigPrefs.setPref(this.OVERRIDE_PREF, true);
      } else if (value === false) {
        this.unregisterUAOverrides();
      } else {
        this.registerUAOverrides();
      }
    });
  }

  getAvailableInterventions() {
    return this._availableInterventions;
  }

  isEnabled() {
    return this._interventionsEnabled;
  }

  async enableInterventions() {
    await this._enablingOrDisablingOperationInProgress;

    this._enablingOrDisablingOperationInProgress = new Promise(done => {
      this._enableInterventionsNow();
      done();
    });

    return this._enablingOrDisablingOperationInProgress;
  }

  async disableInterventions() {
    await this._enablingOrDisablingOperationInProgress;

    this._enablingOrDisablingOperationInProgress = new Promise(done => {
      for (const config of this._availableInterventions) {
        this._disableInterventionNow(config);
      }

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

      browser.testUtils.interventionsInactive();
      done();
    });

    return this._enablingOrDisablingOperationInProgress;
  }

  async _enableInterventionsNow() {
    await this._enablingOrDisablingOperationInProgress;

    const skipped = [];

    const { version } = await browser.runtime.getBrowserInfo();
    const cleanVersion = parseFloat(version.match(/\d+(\.\d+)?/)[0]);

    const { os } = await browser.runtime.getPlatformInfo();
    this.currentPlatform = os;

    for (const config of this._availableInterventions) {
      for (const intervention of config.interventions) {
        if (await InterventionHelpers.shouldSkip(intervention, cleanVersion)) {
          continue;
        }
        if (!(await InterventionHelpers.checkPlatformMatches(intervention))) {
          continue;
        }
        intervention.enabled = true;
        config.availableOnPlatform = true;
      }

      if (!config.availableOnPlatform) {
        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) {
      console.warn(
        "Skipping",
        skipped.length,
        "un-needed interventions",
        skipped.sort()
      );
    }

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

    await browser.testUtils.interventionsActive();
  }

  async enableIntervention(config) {
    await this._enablingOrDisablingOperationInProgress;

    this._enablingOrDisablingOperationInProgress = new Promise(done => {
      this._enableInterventionNow(config);
      done();
    });

    return this._enablingOrDisablingOperationInProgress;
  }

  async disableIntervention(config) {
    await this._enablingOrDisablingOperationInProgress;

    this._enablingOrDisablingOperationInProgress = new Promise(done => {
      this._disableInterventionNow(config);
      done();
    });

    return this._enablingOrDisablingOperationInProgress;
  }

  async _enableInterventionNow(config) {
    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);

    for (const intervention of config.interventions) {
      await this._changeCustomFuncs("enable", label, intervention, config);
      await this._enableContentScripts(label, intervention, matches);
      await this._enableUAOverrides(label, intervention, matches);
      await this._enableRequestBlocks(label, intervention, blocks);
    }

    config.active = true;
  }

  async _disableInterventionNow(config) {
    const { active, label, interventions } = config;

    if (!active) {
      return;
    }

    for (const intervention of interventions) {
      await this._changeCustomFuncs("disable", label, intervention, config);
      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) {
        try {
          await customFunc[action](config, intervention);
        } 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;

    console.info(`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;
    console.info(`Blocking requests as specified for ${label}`);
  }

  async _enableContentScripts(label, intervention, matches) {
    if (!("content_scripts" in intervention)) {
      return;
    }

    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);
      console.info(
        `Registered still-not-active content scripts for ${label}`,
        stillNeeded
      );
    } catch (e) {
      try {
        await browser.scripting.registerContentScripts(scriptsToReg);
        console.debug(
          `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}`,
      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];
  }
}

module.exports = Interventions;
