/* 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/. */

export function UrlClassifierExceptionListService() {}

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
});

const COLLECTION_NAME = "url-classifier-exceptions";

/**
 * Tracks URL classifier exception list entries for a single named feature.
 */
class Feature {
  constructor(name, prefName) {
    this.name = name;
    this.prefName = prefName;
    this.observers = new Set();
    this.prefValue = null;
    this.remoteEntries = null;

    if (prefName) {
      this.prefValue = Services.prefs.getStringPref(this.prefName, null);
      Services.prefs.addObserver(prefName, this);
    }
  }

  async addAndRunObserver(observer) {
    this.observers.add(observer);
    this.notifyObservers(observer);
  }

  removeObserver(observer) {
    this.observers.delete(observer);
  }

  observe(subject, topic, data) {
    if (topic != "nsPref:changed" || data != this.prefName) {
      console.error(`Unexpected event ${topic} with ${data}`);
      return;
    }

    this.prefValue = Services.prefs.getStringPref(this.prefName, null);
    this.notifyObservers();
  }

  onRemoteSettingsUpdate(entries) {
    this.remoteEntries = [];

    for (let jsEntry of entries) {
      let { classifierFeatures } = jsEntry;
      if (classifierFeatures.includes(this.name)) {
        let entry = Feature.rsObjectToEntry(jsEntry);
        if (entry) {
          this.remoteEntries.push(entry);
        }
      }
    }
  }

  /**
   * Convert a JS object from RemoteSettings to an nsIUrlClassifierExceptionListEntry.
   *
   * @param {object} rsObject - The JS object from RemoteSettings to convert.
   * @returns {nsIUrlClassifierExceptionListEntry} The converted nsIUrlClassifierExceptionListEntry.
   */
  static rsObjectToEntry(rsObject) {
    let entry = Cc[
      "@mozilla.org/url-classifier/exception-list-entry;1"
    ].createInstance(Ci.nsIUrlClassifierExceptionListEntry);

    let {
      category: categoryStr,
      urlPattern,
      topLevelUrlPattern = "",
      isPrivateBrowsingOnly = false,
      filterContentBlockingCategories = [],
      classifierFeatures = [],
    } = rsObject;

    const CATEGORY_STR_TO_ENUM = {
      "internal-pref":
        Ci.nsIUrlClassifierExceptionListEntry.CATEGORY_INTERNAL_PREF,
      baseline: Ci.nsIUrlClassifierExceptionListEntry.CATEGORY_BASELINE,
      convenience: Ci.nsIUrlClassifierExceptionListEntry.CATEGORY_CONVENIENCE,
    };

    let category = CATEGORY_STR_TO_ENUM[categoryStr];
    if (category == null) {
      console.error(
        "Invalid or unknown category",
        { rsObject },
        { categories: Object.keys(CATEGORY_STR_TO_ENUM) }
      );
      return null;
    }

    try {
      entry.init(
        category,
        urlPattern,
        topLevelUrlPattern,
        isPrivateBrowsingOnly,
        filterContentBlockingCategories,
        classifierFeatures
      );
    } catch (e) {
      console.error(
        "Error initializing url classifier exception list entry " + e.message,
        e,
        { rsObject }
      );
      return null;
    }

    return entry;
  }

  notifyObservers(observer = null) {
    let entries = [];
    if (this.prefValue) {
      for (let prefEntry of this.prefValue.split(",")) {
        let entry = Feature.rsObjectToEntry({
          category: "internal-pref",
          urlPattern: prefEntry,
          classifierFeatures: [this.name],
        });
        if (entry) {
          entries.push(entry);
        }
      }
    }

    if (this.remoteEntries) {
      for (let entry of this.remoteEntries) {
        entries.push(entry);
      }
    }

    // Construct nsIUrlClassifierExceptionList with all entries that belong to
    // this feature.
    let list = Cc[
      "@mozilla.org/url-classifier/exception-list;1"
    ].createInstance(Ci.nsIUrlClassifierExceptionList);
    for (let entry of entries) {
      try {
        list.addEntry(entry);
      } catch (e) {
        console.error(
          "Error adding url classifier exception list entry " + e.message,
          e,
          entry
        );
      }
    }

    if (observer) {
      observer.onExceptionListUpdate(list);
    } else {
      for (let obs of this.observers) {
        obs.onExceptionListUpdate(list);
      }
    }
  }
}

UrlClassifierExceptionListService.prototype = {
  classID: Components.ID("{b9f4fd03-9d87-4bfd-9958-85a821750ddc}"),
  QueryInterface: ChromeUtils.generateQI([
    "nsIUrlClassifierExceptionListService",
    "nsIObserver",
  ]),

  features: {},
  _initialized: false,

  ETP_PREFERENCES: [
    "privacy.trackingprotection.allow_list.baseline.enabled",
    "privacy.trackingprotection.allow_list.convenience.enabled",
    "browser.contentblocking.category",
  ],
  PREF_ALLOW_LIST_USER_INTERACTED:
    "privacy.trackingprotection.allow_list.hasUserInteractedWithETPSettings",

  observe(subject, topic, data) {
    if (topic === "idle-daily") {
      const baseline = Services.prefs.getBoolPref(
        "privacy.trackingprotection.allow_list.baseline.enabled"
      );
      const convenience = Services.prefs.getBoolPref(
        "privacy.trackingprotection.allow_list.convenience.enabled"
      );
      Glean.contentblocking.tpAllowlistBaselineEnabled.set(baseline);
      // If baseline is false, having convenience as true has no effect, so we treat it as false.
      Glean.contentblocking.tpAllowlistConvenienceEnabled.set(
        baseline ? convenience : false
      );
    }
    if (topic === "nsPref:changed") {
      // If the user changes the baseline, convenience, or category preference, we set
      // hasUserInteractedWithETP to true to indicate interaction with ETP settings.
      // This lets us skip the infobar prompting users to enable allowlists if they’ve
      // already made a choice.
      if (this.ETP_PREFERENCES.includes(data)) {
        Services.prefs.setBoolPref(this.PREF_ALLOW_LIST_USER_INTERACTED, true);
      }
    }
  },

  async lazyInit() {
    if (this._initialized) {
      return;
    }

    this.maybeMigrateCategoryPrefs();

    // Add ETP preference observers AFTER migration to avoid false positives. The migration function
    // above may programmatically change ETP preferences, which would incorrectly trigger our user
    // interaction tracking if observers were already installed. By adding observers after
    // migration, we ensure we only detect user changes to ETP settings.
    this.addETPUserInteractionPrefObservers();

    let rs = lazy.RemoteSettings(COLLECTION_NAME);
    rs.on("sync", event => {
      let {
        data: { current },
      } = event;
      this.entries = current || [];
      this.onUpdateEntries(current);
    });

    this._initialized = true;

    // If the remote settings list hasn't been populated yet we have to make sure
    // to do it before firing the first notification.
    // This has to be run after _initialized is set because we'll be
    // blocked while getting entries from RemoteSetting, and we don't want
    // LazyInit is executed again.
    try {
      // The data will be initially available from the local DB (via a
      // resource:// URI).
      this.entries = await rs.get();
    } catch (e) {}

    // RemoteSettings.get() could return null, ensure passing a list to
    // onUpdateEntries.
    if (!this.entries) {
      this.entries = [];
    }

    this.onUpdateEntries(this.entries);
  },

  /**
   * Runs migration code for the allow-list category prefs.
   * Users who have ETP "strict" or "custom" enabled should not automatically
   * get enrolled into the new allow-list categories. Instead they should have
   * the opportunity to opt in/out via the preferences UI.
   */
  maybeMigrateCategoryPrefs() {
    const ALLOW_LIST_CATEGORY_MIGRATION_PREF =
      "privacy.trackingprotection.allow_list.hasMigratedCategoryPrefs";

    if (Services.prefs.getBoolPref(ALLOW_LIST_CATEGORY_MIGRATION_PREF, false)) {
      // Already migrated.
      return;
    }

    // Set the migration pref to true so we only run the migration once.
    Services.prefs.setBoolPref(ALLOW_LIST_CATEGORY_MIGRATION_PREF, true);

    // This pref is set on both Desktop and Fenix (Bug 1956620).
    let cbCategory = Services.prefs.getStringPref(
      "browser.contentblocking.category",
      "standard"
    );
    // Don't migrate if the user is using the default category. The default
    // category pref states are already correct.
    if (cbCategory == "standard") {
      return;
    }

    // cbCategory is either "strict" or "custom". Disable both allow list
    // categories.
    Services.prefs.setBoolPref(
      "privacy.trackingprotection.allow_list.baseline.enabled",
      false
    );
    Services.prefs.setBoolPref(
      "privacy.trackingprotection.allow_list.convenience.enabled",
      false
    );
  },

  onUpdateEntries(entries) {
    for (let key of Object.keys(this.features)) {
      let feature = this.features[key];
      feature.onRemoteSettingsUpdate(entries);
      feature.notifyObservers();
    }
  },

  registerAndRunExceptionListObserver(feature, prefName, observer) {
    // We don't await this; the caller is C++ and won't await this function,
    // and because we prevent re-entering into this method, once it's been
    // called once any subsequent calls will early-return anyway - so
    // awaiting that would be meaningless. Instead, `Feature` implementations
    // make sure not to call into observers until they have data, and we
    // make sure to let feature instances know whether we have data
    // immediately.
    this.lazyInit();

    if (!this.features[feature]) {
      let featureObj = new Feature(feature, prefName);
      this.features[feature] = featureObj;
      // If we've previously initialized, we need to pass the entries
      // we already have to the new feature.
      if (this.entries) {
        featureObj.onRemoteSettingsUpdate(this.entries);
      }
    }
    this.features[feature].addAndRunObserver(observer);
  },

  unregisterExceptionListObserver(feature, observer) {
    if (!this.features[feature]) {
      return;
    }
    this.features[feature].removeObserver(observer);
  },

  /**
   * Adds preference observers to track user interactions with ETP settings.
   * These observers monitor changes to the baseline allow list, convenience allow list, and
   * content blocking category preferences to detect when users modify ETP-related settings.
   */
  addETPUserInteractionPrefObservers() {
    this.ETP_PREFERENCES.forEach(pref => {
      Services.prefs.addObserver(pref, this.observe.bind(this));
    });
  },

  removeETPUserInteractionPrefObservers() {
    this.ETP_PREFERENCES.forEach(pref => {
      Services.prefs.removeObserver(pref, this.observe.bind(this));
    });
  },

  clear() {
    this.features = {};
    this._initialized = false;
    this.entries = null;
    this.removeETPUserInteractionPrefObservers();
  },
};
