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

/*
 * This module provides an interface to access DoH configuration - e.g. whether
 * DoH is enabled, whether capabilities are enabled, etc. The configuration is
 * sourced from either Remote Settings or pref values, with Remote Settings
 * being preferred.
 */

import { RemoteSettings } from "resource://services-settings/remote-settings.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  Preferences: "resource://gre/modules/Preferences.sys.mjs",
  Region: "resource://gre/modules/Region.sys.mjs",
});

const kGlobalPrefBranch = "doh-rollout";
function regionPrefBranch() {
  let homeRegion = Services.prefs.getStringPref(
    `${kGlobalPrefBranch}.home-region`,
    undefined
  );
  if (!homeRegion) {
    return undefined;
  }
  return `${kGlobalPrefBranch}.${homeRegion.toLowerCase()}`;
}

function currentRegion() {
  if (lazy.Region.current) {
    return lazy.Region.current;
  }
  return lazy.Region.home;
}

const kConfigPrefs = {
  kEnabledPref: "enabled",
  kProvidersPref: "provider-list",
  kTRRSelectionEnabledPref: "trr-selection.enabled",
  kTRRSelectionProvidersPref: "trr-selection.provider-list",
  kTRRSelectionCommitResultPref: "trr-selection.commit-result",
  kProviderSteeringEnabledPref: "provider-steering.enabled",
  kProviderSteeringListPref: "provider-steering.provider-list",
};

const kPrefChangedTopic = "nsPref:changed";

const gProvidersCollection = RemoteSettings("doh-providers");
const gConfigCollection = RemoteSettings("doh-config");

function getPrefValueRegionFirst(prefName) {
  let regionalPrefName = `${regionPrefBranch()}.${prefName}`;
  let regionalPrefValue = lazy.Preferences.get(regionalPrefName);
  if (regionalPrefValue !== undefined) {
    return regionalPrefValue;
  }
  return lazy.Preferences.get(`${kGlobalPrefBranch}.${prefName}`);
}

function getProviderListFromPref(prefName) {
  let prefVal = getPrefValueRegionFirst(prefName);
  if (prefVal) {
    try {
      return JSON.parse(prefVal);
    } catch (e) {
      console.error(`DoH provider list not a valid JSON array: ${prefName}`);
    }
  }
  return undefined;
}

// Generate a base config object with getters that return pref values. When
// Remote Settings values become available, a new config object will be
// generated from this and specific fields will be replaced by the RS value.
// If we use a class to store base config and instantiate new config objects
// from it, we lose the ability to override getters because they are defined
// as non-configureable properties on class instances. So just use a function.
function makeBaseConfigObject() {
  function makeConfigProperty({
    obj,
    propName,
    defaultVal,
    prefName,
    isProviderList,
  }) {
    let prefFn = isProviderList
      ? getProviderListFromPref
      : getPrefValueRegionFirst;

    let overridePropName = "_" + propName;

    Object.defineProperty(obj, propName, {
      get() {
        // If a pref value exists, it gets top priority. Otherwise, if it has an
        // explicitly set value (from Remote Settings), we return that.
        let prefVal = prefFn(prefName);
        if (prefVal !== undefined) {
          return prefVal;
        }
        if (this[overridePropName] !== undefined) {
          return this[overridePropName];
        }
        return defaultVal;
      },
      set(val) {
        this[overridePropName] = val;
      },
    });
  }
  let newConfig = {
    get fallbackProviderURI() {
      return this.providerList[0]?.uri;
    },
    trrSelection: {},
    providerSteering: {},
  };
  makeConfigProperty({
    obj: newConfig,
    propName: "enabled",
    defaultVal: false,
    prefName: kConfigPrefs.kEnabledPref,
    isProviderList: false,
  });
  makeConfigProperty({
    obj: newConfig,
    propName: "providerList",
    defaultVal: [],
    prefName: kConfigPrefs.kProvidersPref,
    isProviderList: true,
  });
  makeConfigProperty({
    obj: newConfig.trrSelection,
    propName: "enabled",
    defaultVal: false,
    prefName: kConfigPrefs.kTRRSelectionEnabledPref,
    isProviderList: false,
  });
  makeConfigProperty({
    obj: newConfig.trrSelection,
    propName: "commitResult",
    defaultVal: false,
    prefName: kConfigPrefs.kTRRSelectionCommitResultPref,
    isProviderList: false,
  });
  makeConfigProperty({
    obj: newConfig.trrSelection,
    propName: "providerList",
    defaultVal: [],
    prefName: kConfigPrefs.kTRRSelectionProvidersPref,
    isProviderList: true,
  });
  makeConfigProperty({
    obj: newConfig.providerSteering,
    propName: "enabled",
    defaultVal: false,
    prefName: kConfigPrefs.kProviderSteeringEnabledPref,
    isProviderList: false,
  });
  makeConfigProperty({
    obj: newConfig.providerSteering,
    propName: "providerList",
    defaultVal: [],
    prefName: kConfigPrefs.kProviderSteeringListPref,
    isProviderList: true,
  });
  return newConfig;
}

export const DoHConfigController = {
  initComplete: null,
  _resolveInitComplete: null,

  // This field always contains the current config state, for
  // consumer use.
  currentConfig: makeBaseConfigObject(),

  // Loads the client's region via Region.sys.mjs. This might mean waiting
  // until the region is available.
  async loadRegion() {
    await new Promise(resolve => {
      // If the region has changed since it was last set, update the pref.
      let homeRegionChanged = Services.prefs.getBoolPref(
        `${kGlobalPrefBranch}.home-region-changed`,
        false
      );
      if (homeRegionChanged) {
        Services.prefs.clearUserPref(
          `${kGlobalPrefBranch}.home-region-changed`
        );
        Services.prefs.clearUserPref(`${kGlobalPrefBranch}.home-region`);
      }

      let homeRegion = Services.prefs.getStringPref(
        `${kGlobalPrefBranch}.home-region`,
        undefined
      );
      if (homeRegion) {
        resolve();
        return;
      }

      let updateRegionAndResolve = () => {
        Services.prefs.setStringPref(
          `${kGlobalPrefBranch}.home-region`,
          currentRegion()
        );
        resolve();
      };

      if (currentRegion()) {
        updateRegionAndResolve();
        return;
      }

      Services.obs.addObserver(function obs() {
        Services.obs.removeObserver(obs, lazy.Region.REGION_TOPIC);
        updateRegionAndResolve();
      }, lazy.Region.REGION_TOPIC);
    });

    // Finally, reload config.
    await this.updateFromRemoteSettings();
  },

  async init() {
    await this.loadRegion();

    Services.prefs.addObserver(`${kGlobalPrefBranch}.`, this, true);
    Services.obs.addObserver(this, "idle-daily", true);
    Services.obs.addObserver(this, "default-timezone-changed", true);

    gProvidersCollection.on("sync", this.updateFromRemoteSettings);
    gConfigCollection.on("sync", this.updateFromRemoteSettings);

    this._resolveInitComplete();
  },

  // Useful for tests to set prior state before init()
  async _uninit() {
    await this.initComplete;

    Services.prefs.removeObserver(`${kGlobalPrefBranch}`, this);
    Services.obs.removeObserver(this, "idle-daily");
    Services.obs.removeObserver(this, "default-timezone-changed");

    gProvidersCollection.off("sync", this.updateFromRemoteSettings);
    gConfigCollection.off("sync", this.updateFromRemoteSettings);

    this.initComplete = new Promise(resolve => {
      this._resolveInitComplete = resolve;
    });
  },

  updateRegionIfChanged(trigger) {
    let oldRegion = Services.prefs.getStringPref(
      `${kGlobalPrefBranch}.home-region`,
      undefined
    );
    if (currentRegion() && currentRegion() != oldRegion) {
      let newRegion = currentRegion();
      Services.prefs.setStringPref(
        `${kGlobalPrefBranch}.home-region`,
        newRegion
      );
      Glean.doh.regionChanged.record({
        old_region: oldRegion || "unknown",
        new_region: newRegion,
        trigger,
      });
      this.notifyNewConfig();
    }
  },

  // Performs a region check when the timezone changes
  async getRegionAndNotify() {
    await lazy.Region._fetchRegion();
    this.updateRegionIfChanged("timezone-changed");
  },

  observe(subject, topic, data) {
    switch (topic) {
      case kPrefChangedTopic:
        {
          let allowedPrefs = Object.getOwnPropertyNames(kConfigPrefs).map(
            k => kConfigPrefs[k]
          );
          if (
            !allowedPrefs.some(pref =>
              [
                `${regionPrefBranch()}.${pref}`,
                `${kGlobalPrefBranch}.${pref}`,
              ].includes(data)
            )
          ) {
            break;
          }
          this.notifyNewConfig();
        }
        break;
      case "idle-daily":
        this.updateRegionIfChanged("idle-daily");
        break;
      case "default-timezone-changed":
        this.getRegionAndNotify();
        break;
    }
  },

  QueryInterface: ChromeUtils.generateQI([
    "nsIObserver",
    "nsISupportsWeakReference",
  ]),

  // Creates new config object from currently available
  // Remote Settings values.
  async updateFromRemoteSettings() {
    let providers = await gProvidersCollection.get();
    let config = await gConfigCollection.get();

    let providersById = new Map();
    providers.forEach(p => providersById.set(p.id, p));

    let configByRegion = new Map();
    config.forEach(c => {
      c.id = c.id.toLowerCase();
      configByRegion.set(c.id, c);
    });

    let homeRegion = Services.prefs.getStringPref(
      `${kGlobalPrefBranch}.home-region`,
      undefined
    );
    let localConfig =
      configByRegion.get(homeRegion?.toLowerCase()) ||
      configByRegion.get("global");

    // Make a new config object first, mutate it as needed, then synchronously
    // replace the currentConfig object at the end to ensure atomicity.
    let newConfig = makeBaseConfigObject();

    if (!localConfig) {
      DoHConfigController.currentConfig = newConfig;
      DoHConfigController.notifyNewConfig();
      return;
    }

    let isAndroid = Services.appinfo.OS === "Android";
    if (
      (isAndroid && localConfig.androidRolloutEnabled) ||
      (!isAndroid && localConfig.rolloutEnabled)
    ) {
      newConfig.enabled = true;
    }

    let parseProviderList = (list, checkFn) => {
      let parsedList = [];
      list?.split(",")?.forEach(p => {
        p = p.trim();
        if (!p.length) {
          return;
        }
        p = providersById.get(p);
        if (!p || (checkFn && !checkFn(p))) {
          return;
        }
        parsedList.push(p);
      });
      return parsedList;
    };

    let regionalProviders = parseProviderList(localConfig.providers);
    if (regionalProviders?.length) {
      newConfig.providerList = regionalProviders;
    }

    if (localConfig.steeringEnabled) {
      let steeringProviders = parseProviderList(
        localConfig.steeringProviders,
        p => p.canonicalName?.length
      );
      if (steeringProviders?.length) {
        newConfig.providerSteering.providerList = steeringProviders;
        newConfig.providerSteering.enabled = true;
      }
    }

    if (localConfig.autoDefaultEnabled) {
      let defaultProviders = parseProviderList(
        localConfig.autoDefaultProviders
      );
      if (defaultProviders?.length) {
        newConfig.trrSelection.providerList = defaultProviders;
        newConfig.trrSelection.enabled = true;
      }
    }

    // Finally, update the currentConfig object synchronously.
    DoHConfigController.currentConfig = newConfig;

    function applyHttp3FirstForProviders(providerList = []) {
      for (const provider of providerList) {
        try {
          let uri = Services.io.newURI(provider.uri);
          let host = uri.host;
          Services.dns.setHttp3FirstForServer(host, !!provider.http3First);
        } catch (e) {
          console.error(`Failed to set http3First for ${provider.uri}: ${e}`);
        }
      }
    }

    applyHttp3FirstForProviders(providers);

    DoHConfigController.notifyNewConfig();
  },

  kConfigUpdateTopic: "doh-config-updated",
  notifyNewConfig() {
    Services.obs.notifyObservers(null, this.kConfigUpdateTopic);
  },
};

DoHConfigController.initComplete = new Promise(resolve => {
  DoHConfigController._resolveInitComplete = resolve;
});
DoHConfigController.init();
