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

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 = `${kRegionPrefBranch}.${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 => {
      let homeRegion = lazy.Preferences.get(`${kGlobalPrefBranch}.home-region`);
      if (homeRegion) {
        kRegionPrefBranch = `${kGlobalPrefBranch}.${homeRegion.toLowerCase()}`;
        resolve();
        return;
      }

      let updateRegionAndResolve = () => {
        kRegionPrefBranch = `${kGlobalPrefBranch}.${lazy.Region.home.toLowerCase()}`;
        lazy.Preferences.set(
          `${kGlobalPrefBranch}.home-region`,
          lazy.Region.home
        );
        resolve();
      };

      if (lazy.Region.home) {
        updateRegionAndResolve();
        return;
      }

      Services.obs.addObserver(function obs(sub, top, data) {
        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);

    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);

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

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

  observe(subject, topic, data) {
    switch (topic) {
      case kPrefChangedTopic:
        let allowedPrefs = Object.getOwnPropertyNames(kConfigPrefs).map(
          k => kConfigPrefs[k]
        );
        if (
          !allowedPrefs.some(pref =>
            [
              `${kRegionPrefBranch}.${pref}`,
              `${kGlobalPrefBranch}.${pref}`,
            ].includes(data)
          )
        ) {
          break;
        }
        this.notifyNewConfig();
        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 = lazy.Preferences.get(`${kGlobalPrefBranch}.home-region`);
    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;
    }

    if (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;

    DoHConfigController.notifyNewConfig();
  },

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

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