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

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  _ExperimentFeature: "resource://nimbus/ExperimentAPI.sys.mjs",
  ASRouterTargeting:
    // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
    "resource:///modules/asrouter/ASRouterTargeting.sys.mjs",
  CleanupManager: "resource://normandy/lib/CleanupManager.sys.mjs",
  ExperimentManager: "resource://nimbus/lib/ExperimentManager.sys.mjs",
  JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs",
  NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
  RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
  TargetingContext: "resource://messaging-system/targeting/Targeting.sys.mjs",
  recordTargetingContext:
    "resource://nimbus/lib/TargetingContextRecorder.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "log", () => {
  const { Logger } = ChromeUtils.importESModule(
    "resource://messaging-system/lib/Logger.sys.mjs"
  );
  return new Logger("RSLoader");
});

XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "timerManager",
  "@mozilla.org/updates/timer-manager;1",
  "nsIUpdateTimerManager"
);

const COLLECTION_ID_PREF = "messaging-system.rsexperimentloader.collection_id";
const COLLECTION_ID_FALLBACK = "nimbus-desktop-experiments";
const ENABLED_PREF = "messaging-system.rsexperimentloader.enabled";
const TARGETING_CONTEXT_TELEMETRY_ENABLED_PREF =
  "nimbus.telemetry.targetingContextEnabled";

const TIMER_NAME = "rs-experiment-loader-timer";
const TIMER_LAST_UPDATE_PREF = `app.update.lastUpdateTime.${TIMER_NAME}`;
// Use the same update interval as normandy
const RUN_INTERVAL_PREF = "app.normandy.run_interval_seconds";
const NIMBUS_DEBUG_PREF = "nimbus.debug";
const NIMBUS_VALIDATION_PREF = "nimbus.validation.enabled";
const NIMBUS_APPID_PREF = "nimbus.appId";

const STUDIES_ENABLED_CHANGED = "nimbus:studies-enabled-changed";

const SECURE_EXPERIMENTS_COLLECTION_ID = "nimbus-secure-experiments";

const EXPERIMENTS_COLLECTION = "experiments";
const SECURE_EXPERIMENTS_COLLECTION = "secureExperiments";

const RS_COLLECTION_OPTIONS = {
  [EXPERIMENTS_COLLECTION]: {
    disallowedFeatureIds: ["prefFlips"],
  },

  [SECURE_EXPERIMENTS_COLLECTION]: {
    allowedFeatureIds: ["prefFlips"],
  },
};

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "COLLECTION_ID",
  COLLECTION_ID_PREF,
  COLLECTION_ID_FALLBACK
);
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "NIMBUS_DEBUG",
  NIMBUS_DEBUG_PREF,
  false
);
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "APP_ID",
  NIMBUS_APPID_PREF,
  "firefox-desktop"
);
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "TARGETING_CONTEXT_TELEMETRY_ENABLED",
  TARGETING_CONTEXT_TELEMETRY_ENABLED_PREF
);

const SCHEMAS = {
  get NimbusExperiment() {
    return fetch("resource://nimbus/schemas/NimbusExperiment.schema.json", {
      credentials: "omit",
    }).then(rsp => rsp.json());
  },
};

export const RecipeStatus = Object.freeze({
  TARGETING_MATCH: "TARGETING_MATCH",
  TARGETING_MISMATCH: "TARGETING_MISMATCH",
  INVALID: "INVALID",

  isValid(status) {
    return (
      status === RecipeStatus.TARGETING_MATCH ||
      status === RecipeStatus.TARGETING_MISMATCH
    );
  },
});

export class _RemoteSettingsExperimentLoader {
  constructor() {
    // Has the timer been set?
    this._initialized = false;
    // Are we in the middle of updating recipes already?
    this._updating = false;
    // Have we updated recipes at least once?
    this._hasUpdatedOnce = false;
    // deferred promise object that resolves after recipes are updated
    this._updatingDeferred = Promise.withResolvers();

    // Make it possible to override for testing
    this.manager = lazy.ExperimentManager;

    this.remoteSettingsClients = {};
    ChromeUtils.defineLazyGetter(
      this.remoteSettingsClients,
      EXPERIMENTS_COLLECTION,
      () => {
        return lazy.RemoteSettings(lazy.COLLECTION_ID);
      }
    );
    ChromeUtils.defineLazyGetter(
      this.remoteSettingsClients,
      SECURE_EXPERIMENTS_COLLECTION,
      () => {
        return lazy.RemoteSettings(SECURE_EXPERIMENTS_COLLECTION_ID);
      }
    );

    Services.obs.addObserver(this, STUDIES_ENABLED_CHANGED);

    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "enabled",
      ENABLED_PREF,
      false,
      this.onEnabledPrefChange.bind(this)
    );

    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "intervalInSeconds",
      RUN_INTERVAL_PREF,
      21600,
      () => this.setTimer()
    );

    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "validationEnabled",
      NIMBUS_VALIDATION_PREF,
      true
    );
  }

  get studiesEnabled() {
    return this.manager.studiesEnabled;
  }

  /**
   * Initialize the loader, updating recipes from Remote Settings.
   *
   * @param {Object} options            additional options.
   * @param {bool}   options.forceSync  force Remote Settings to sync recipe collection
   *                                    before updating recipes; throw if sync fails.
   * @return {Promise}                  which resolves after initialization and recipes
   *                                    are updated.
   */
  async init(options = {}) {
    const { forceSync = false } = options;

    if (this._initialized || !this.enabled || !this.studiesEnabled) {
      return;
    }

    this.setTimer();
    lazy.CleanupManager.addCleanupHandler(() => this.uninit());
    this._initialized = true;

    await this.updateRecipes(undefined, { forceSync });
  }

  uninit() {
    if (!this._initialized) {
      return;
    }
    lazy.timerManager.unregisterTimer(TIMER_NAME);
    this._initialized = false;
    this._updating = false;
    this._hasUpdatedOnce = false;
  }

  /**
   * Get all recipes from remote settings and update enrollments.
   *
   * @param {string} trigger - What caused the update to occur?
   * @param {object} options
   * @param {boolean}   options.forceSync - Force Remote Settings to sync recipe
   *                                     collection before updating recipes.
   */
  async updateRecipes(trigger, { forceSync = false } = {}) {
    if (this._updating || !this._initialized) {
      return;
    }

    this._updating = true;
    this.manager.optInRecipes = [];

    // If recipes have been updated once, replace the promise with a new one
    // such that we reset the resolved state of it from the previous .updateRecipes call.
    if (this._hasUpdatedOnce) {
      this._updatingDeferred = Promise.withResolvers();
    }

    // The targeting context metrics do not work in artifact builds.
    // See-also: https://bugzilla.mozilla.org/show_bug.cgi?id=1936317
    // See-also: https://bugzilla.mozilla.org/show_bug.cgi?id=1936319
    if (lazy.TARGETING_CONTEXT_TELEMETRY_ENABLED) {
      lazy.recordTargetingContext();
    }

    // Since this method is async, the enabled pref could change between await
    // points. We don't want to half validate experiments, so we cache this to
    // keep it consistent throughout updating.
    const validationEnabled = this.validationEnabled;

    let recipeValidator;

    if (validationEnabled) {
      recipeValidator = new lazy.JsonSchema.Validator(
        await SCHEMAS.NimbusExperiment
      );
    }

    lazy.log.debug(`Updating recipes with trigger "${trigger ?? ""}`);

    const recipes = [];
    let loadingError = false;

    const experiments = await this.getRecipesFromCollection({
      forceSync,
      client: this.remoteSettingsClients[EXPERIMENTS_COLLECTION],
      ...RS_COLLECTION_OPTIONS[EXPERIMENTS_COLLECTION],
    });

    if (experiments !== null) {
      recipes.push(...experiments);
    } else {
      loadingError = true;
    }

    const secureExperiments = await this.getRecipesFromCollection({
      forceSync,
      client: this.remoteSettingsClients[SECURE_EXPERIMENTS_COLLECTION],
      ...RS_COLLECTION_OPTIONS[SECURE_EXPERIMENTS_COLLECTION],
    });

    if (secureExperiments !== null) {
      recipes.push(...secureExperiments);
    } else {
      loadingError = true;
    }

    recipes.sort(
      (a, b) => new Date(a.publishedDate ?? 0) - new Date(b.publishedDate ?? 0)
    );

    const enrollmentsCtx = new EnrollmentsContext(
      this.manager,
      recipeValidator,
      { validationEnabled, shouldCheckTargeting: true }
    );

    if (recipes && !loadingError) {
      for (const recipe of recipes) {
        const status = await enrollmentsCtx.checkRecipe(recipe);
        if (RecipeStatus.isValid(status)) {
          await this.manager.onRecipe(
            recipe,
            "rs-loader",
            status === RecipeStatus.TARGETING_MATCH
          );
        }
      }

      lazy.log.debug(
        `${enrollmentsCtx.matches} recipes matched. Finalizing ExperimentManager.`
      );
      this.manager.onFinalize("rs-loader", enrollmentsCtx.getResults());
    }

    if (trigger !== "timer") {
      const lastUpdateTime = Math.round(Date.now() / 1000);
      Services.prefs.setIntPref(TIMER_LAST_UPDATE_PREF, lastUpdateTime);
    }

    Services.obs.notifyObservers(null, "nimbus:enrollments-updated");

    this._updating = false;
    this._hasUpdatedOnce = true;
    this._updatingDeferred.resolve();

    this.recordIsReady();
  }

  /**
   * Return the recipes from a given collection.
   *
   * @param {object} options
   * @param {RemoteSettings} options.client
   *        The RemoteSettings client that will be used to fetch recipes.
   * @param {boolean} options.forceSync
   *        Force the RemoteSettings client to sync the collection before retrieving recipes.
   * @param {string[] | null} options.allowedFeatureIds
   *        If non-null, any recipe that uses a feature ID not in this list will
   *        be rejected.
   * @param {string[]} options.disallowedFeatureIds
   *        If a recipe uses any features in this list, it will be rejected.
   *
   * @returns {object[] | null}
   *          Recipes from the collection, filtered to match the allowed and
   *          disallowed feature IDs, or null if there was an error syncing the
   *          collection.
   */
  async getRecipesFromCollection({
    client,
    forceSync = false,
    allowedFeatureIds = null,
    disallowedFeatureIds = [],
  } = {}) {
    let recipes;
    try {
      recipes = await client.get({
        forceSync,
        emptyListFallback: false, // Throw instead of returning an empty list.
      });
      lazy.log.debug(
        `Got ${recipes.length} recipes from ${client.collectionName}`
      );
    } catch (e) {
      lazy.log.debug(
        `Error getting recipes from Remote Settings collection ${client.collectionName}`
      );
      console.error(e);

      return null;
    }

    return recipes.filter(recipe => {
      for (const featureId of recipe.featureIds) {
        if (allowedFeatureIds !== null) {
          if (!allowedFeatureIds.includes(featureId)) {
            lazy.log.warn(
              `Recipe ${recipe.slug} not returned from collection ${client.collectionName} because it contains feature ${featureId}, which is disallowed for that collection.`
            );
            return false;
          }
        }

        if (disallowedFeatureIds.includes(featureId)) {
          lazy.log.warn(
            `Recipe ${recipe.slug} not returned from collection ${client.collectionName} because it contains feature ${featureId}, which is disallowed for that collection.`
          );
          return false;
        }
      }

      return true;
    });
  }

  async optInToExperiment({
    slug,
    branch: branchSlug,
    collection,
    applyTargeting = false,
  }) {
    lazy.log.debug(`Attempting force enrollment with ${slug} / ${branchSlug}`);

    if (!lazy.NIMBUS_DEBUG) {
      lazy.log.debug(
        `Force enrollment only works when '${NIMBUS_DEBUG_PREF}' is enabled.`
      );
      // More generic error if no debug preference is on.
      throw new Error("Could not opt in.");
    }

    if (!this.studiesEnabled) {
      lazy.log.debug(
        "Force enrollment does not work when studies are disabled."
      );
      throw new Error("Could not opt in: studies are disabled.");
    }

    let recipes;
    try {
      recipes = await lazy
        .RemoteSettings(collection || lazy.COLLECTION_ID)
        .get({
          // Throw instead of returning an empty list.
          emptyListFallback: false,
        });
    } catch (e) {
      console.error(e);
      throw new Error("Error getting recipes from remote settings.");
    }

    const recipe = recipes.find(r => r.slug === slug);

    if (!recipe) {
      throw new Error(
        `Could not find experiment slug ${slug} in collection ${
          collection || lazy.COLLECTION_ID
        }.`
      );
    }

    const recipeValidator = new lazy.JsonSchema.Validator(
      await SCHEMAS.NimbusExperiment
    );
    const enrollmentsCtx = new EnrollmentsContext(
      this.manager,
      recipeValidator,
      {
        validationEnabled: this.validationEnabled,
        shouldCheckTargeting: applyTargeting,
      }
    );

    // If a recipe is either targeting mismatch or invalid, ouput or throw the
    // specific error message.
    const result = await enrollmentsCtx.checkRecipe(recipe);
    if (result !== RecipeStatus.TARGETING_MATCH) {
      const results = enrollmentsCtx.getResults();

      if (results.recipeMismatches.length) {
        throw new Error(`Recipe ${recipe.slug} did not match targeting`);
      } else if (results.invalidRecipes.length) {
        console.error(`Recipe ${recipe.slug} did not match recipe schema`);
      } else if (results.invalidBranches.size) {
        // There will only be one entry becuase we only validated a single recipe.
        for (const branches of results.invalidBranches.values()) {
          for (const branch of branches) {
            console.error(
              `Recipe ${recipe.slug} failed feature validation for branch ${branch}`
            );
          }
        }
      } else if (results.invalidFeatures.length) {
        for (const featureIds of results.invalidFeatures.values()) {
          for (const featureId of featureIds) {
            console.error(
              `Recipe ${recipe.slug} references unknown feature ID ${featureId}`
            );
          }
        }
      }

      throw new Error(
        `Recipe ${recipe.slug} failed validation: ${JSON.stringify(results)}`
      );
    }

    let branch = recipe.branches.find(b => b.slug === branchSlug);
    if (!branch) {
      throw new Error(`Could not find branch slug ${branchSlug} in ${slug}.`);
    }

    await this.manager.forceEnroll(recipe, branch);
  }

  /**
   * Handles feature status based on feature pref and STUDIES_OPT_OUT_PREF.
   * Changing any of them to false will turn off any recipe fetching and
   * processing.
   */
  onEnabledPrefChange() {
    if (this._initialized && !(this.enabled && this.studiesEnabled)) {
      this.uninit();
    } else if (!this._initialized && this.enabled && this.studiesEnabled) {
      // If the feature pref is turned on then turn on recipe processing.
      // If the opt in pref is turned on then turn on recipe processing only if
      // the feature pref is also enabled.
      this.init();
    }
  }

  observe(aSubect, aTopic) {
    if (aTopic === STUDIES_ENABLED_CHANGED) {
      this.onEnabledPrefChange();
    }
  }

  /**
   * Sets a timer to update recipes every this.intervalInSeconds
   */
  setTimer() {
    if (this.intervalInSeconds === 0) {
      // Used in tests where we want to turn this mechanism off
      return;
    }
    // The callbacks will be called soon after the timer is registered
    lazy.timerManager.registerTimer(
      TIMER_NAME,
      () => this.updateRecipes("timer"),
      this.intervalInSeconds
    );
    lazy.log.debug("Registered update timer");
  }

  recordIsReady() {
    const eventCount =
      lazy.NimbusFeatures.nimbusIsReady.getVariable("eventCount") ?? 1;
    for (let i = 0; i < eventCount; i++) {
      Glean.nimbusEvents.isReady.record();
    }
  }

  /**
   * Returns a promise to the caller waiting for the recipes to be updated,
   * which is resolved in the .updateRecipe function.
   */
  updatingRecipes() {
    return this._updatingDeferred.promise;
  }
}

export class EnrollmentsContext {
  constructor(
    experimentManager,
    recipeValidator,
    { validationEnabled = true, shouldCheckTargeting = true } = {}
  ) {
    this.experimentManager = experimentManager;
    this.recipeValidator = recipeValidator;

    this.validationEnabled = validationEnabled;
    this.shouldCheckTargeting = shouldCheckTargeting;
    this.matches = 0;

    this.recipeMismatches = [];
    this.invalidRecipes = [];
    this.invalidBranches = new Map();
    this.invalidFeatures = new Map();
    this.validatorCache = {};
    this.missingLocale = [];
    this.missingL10nIds = new Map();

    this.locale = Services.locale.appLocaleAsBCP47;
  }

  getResults() {
    return {
      recipeMismatches: this.recipeMismatches,
      invalidRecipes: this.invalidRecipes,
      invalidBranches: this.invalidBranches,
      invalidFeatures: this.invalidFeatures,
      missingLocale: this.missingLocale,
      missingL10nIds: this.missingL10nIds,
      locale: this.locale,
      validationEnabled: this.validationEnabled,
    };
  }

  async checkRecipe(recipe) {
    if (recipe.appId !== "firefox-desktop") {
      // Skip over recipes not intended for desktop. Experimenter publishes
      // recipes into a collection per application (desktop goes to
      // `nimbus-desktop-experiments`) but all preview experiments share the
      // same collection (`nimbus-preview`).
      //
      // This is *not* the same as `lazy.APP_ID` which is used to
      // distinguish between desktop Firefox and the desktop background
      // updater.
      return RecipeStatus.INVALID;
    }

    const validateFeatureSchemas =
      this.validationEnabled && !recipe.featureValidationOptOut;

    if (this.validationEnabled) {
      let validation = this.recipeValidator.validate(recipe);
      if (!validation.valid) {
        console.error(
          `Could not validate experiment recipe ${recipe.id}: ${JSON.stringify(
            validation.errors,
            null,
            2
          )}`
        );
        if (recipe.slug) {
          this.invalidRecipes.push(recipe.slug);
        }
        return RecipeStatus.INVALID;
      }
    }

    const featureIds =
      recipe.featureIds ??
      recipe.branches
        .flatMap(branch => branch.features ?? [branch.feature])
        .map(featureDef => featureDef.featureId);

    let haveAllFeatures = true;

    for (const featureId of featureIds) {
      const feature = lazy.NimbusFeatures[featureId];

      // If validation is enabled, we want to catch this later in
      // _validateBranches to collect the correct stats for telemetry.
      if (!feature) {
        continue;
      }

      if (!feature.applications.includes(lazy.APP_ID)) {
        lazy.log.debug(
          `${recipe.id} uses feature ${featureId} which is not enabled for this application (${lazy.APP_ID}) -- skipping`
        );
        haveAllFeatures = false;
        break;
      }
    }

    if (!haveAllFeatures) {
      return RecipeStatus.INVALID;
    }

    if (this.shouldCheckTargeting) {
      const match = await this.checkTargeting(recipe);

      if (match) {
        const type = recipe.isRollout ? "rollout" : "experiment";
        lazy.log.debug(`[${type}] ${recipe.id} matched targeting`);
      } else {
        lazy.log.debug(`${recipe.id} did not match due to targeting`);
        this.recipeMismatches.push(recipe.slug);
        return RecipeStatus.TARGETING_MISMATCH;
      }
    }

    this.matches++;

    if (
      typeof recipe.localizations === "object" &&
      recipe.localizations !== null
    ) {
      if (
        typeof recipe.localizations[this.locale] !== "object" ||
        recipe.localizations[this.locale] === null
      ) {
        this.missingLocale.push(recipe.slug);
        lazy.log.debug(
          `${recipe.id} is localized but missing locale ${this.locale}`
        );
        return RecipeStatus.INVALID;
      }
    }

    const result = await this._validateBranches(recipe, validateFeatureSchemas);
    if (!result.valid) {
      if (result.invalidBranchSlugs.length) {
        this.invalidBranches.set(recipe.slug, result.invalidBranchSlugs);
      }
      if (result.invalidFeatureIds.length) {
        this.invalidFeatures.set(recipe.slug, result.invalidFeatureIds);
      }
      if (result.missingL10nIds.length) {
        this.missingL10nIds.set(recipe.slug, result.missingL10nIds);
      }
      lazy.log.debug(`${recipe.id} did not validate`);
      return RecipeStatus.INVALID;
    }

    return RecipeStatus.TARGETING_MATCH;
  }

  async evaluateJexl(jexlString, customContext) {
    if (customContext && !customContext.experiment) {
      throw new Error(
        "Expected an .experiment property in second param of this function"
      );
    }

    if (!customContext.source) {
      throw new Error(
        "Expected a .source property that identifies which targeting expression is being evaluated."
      );
    }

    const context = lazy.TargetingContext.combineContexts(
      customContext,
      this.experimentManager.createTargetingContext(),
      lazy.ASRouterTargeting.Environment
    );

    lazy.log.debug("Testing targeting expression:", jexlString);
    const targetingContext = new lazy.TargetingContext(context, {
      source: customContext.source,
    });

    let result = null;
    try {
      result = await targetingContext.evalWithDefault(jexlString);
    } catch (e) {
      lazy.log.debug("Targeting failed because of an error", e);
      console.error(e);
    }
    return result;
  }

  /**
   * Checks targeting of a recipe if it is defined
   * @param {Recipe} recipe
   * @param {{[key: string]: any}} customContext A custom filter context
   * @returns {Promise<boolean>} Should we process the recipe?
   */
  async checkTargeting(recipe) {
    if (!recipe.targeting) {
      lazy.log.debug("No targeting for recipe, so it matches automatically");
      return true;
    }

    const result = await this.evaluateJexl(recipe.targeting, {
      experiment: recipe,
      source: recipe.slug,
    });

    return Boolean(result);
  }

  /**
   * Validate the branches of an experiment.
   *
   * @param {object} recipe The recipe object.
   * @param {boolean} validateSchema Whether to validate the feature values
   *        using JSON schemas.
   *
   * @returns {object} The lists of invalid branch slugs and invalid feature
   *                   IDs.
   */
  async _validateBranches({ id, branches, localizations }, validateSchema) {
    const invalidBranchSlugs = [];
    const invalidFeatureIds = new Set();
    const missingL10nIds = new Set();

    if (validateSchema || typeof localizations !== "undefined") {
      for (const [branchIdx, branch] of branches.entries()) {
        const features = branch.features ?? [branch.feature];
        for (const feature of features) {
          const { featureId, value } = feature;
          if (!lazy.NimbusFeatures[featureId]) {
            console.error(
              `Experiment ${id} has unknown featureId: ${featureId}`
            );

            invalidFeatureIds.add(featureId);
            continue;
          }

          let substitutedValue = value;

          if (localizations) {
            // We already know that we have a localization table for this locale
            // because we checked in `checkRecipe`.
            try {
              substitutedValue =
                lazy._ExperimentFeature.substituteLocalizations(
                  value,
                  localizations[Services.locale.appLocaleAsBCP47],
                  missingL10nIds
                );
            } catch (e) {
              if (e?.reason === "l10n-missing-entry") {
                // Skip validation because it *will* fail.
                continue;
              }
              throw e;
            }
          }

          if (validateSchema) {
            let validator;
            if (this.validatorCache[featureId]) {
              validator = this.validatorCache[featureId];
            } else if (lazy.NimbusFeatures[featureId].manifest.schema?.uri) {
              const uri = lazy.NimbusFeatures[featureId].manifest.schema.uri;
              try {
                const schema = await fetch(uri, {
                  credentials: "omit",
                }).then(rsp => rsp.json());

                validator = this.validatorCache[featureId] =
                  new lazy.JsonSchema.Validator(schema);
              } catch (e) {
                throw new Error(
                  `Could not fetch schema for feature ${featureId} at "${uri}": ${e}`
                );
              }
            } else {
              const schema = this._generateVariablesOnlySchema(
                lazy.NimbusFeatures[featureId]
              );
              validator = this.validatorCache[featureId] =
                new lazy.JsonSchema.Validator(schema);
            }

            const result = validator.validate(substitutedValue);
            if (!result.valid) {
              console.error(
                `Experiment ${id} branch ${branchIdx} feature ${featureId} does not validate: ${JSON.stringify(
                  result.errors,
                  undefined,
                  2
                )}`
              );
              invalidBranchSlugs.push(branch.slug);
            }
          }
        }
      }
    }

    return {
      invalidBranchSlugs,
      invalidFeatureIds: Array.from(invalidFeatureIds),
      missingL10nIds: Array.from(missingL10nIds),
      valid:
        invalidBranchSlugs.length === 0 &&
        invalidFeatureIds.size === 0 &&
        missingL10nIds.size === 0,
    };
  }

  _generateVariablesOnlySchema({ featureId, manifest }) {
    // See-also: https://github.com/mozilla/experimenter/blob/main/app/experimenter/features/__init__.py#L21-L64
    const schema = {
      $schema: "https://json-schema.org/draft/2019-09/schema",
      title: featureId,
      description: manifest.description,
      type: "object",
      properties: {},
      additionalProperties: true,
    };

    for (const [varName, desc] of Object.entries(manifest.variables)) {
      const prop = {};
      switch (desc.type) {
        case "boolean":
        case "string":
          prop.type = desc.type;
          break;

        case "int":
          prop.type = "integer";
          break;

        case "json":
          // NB: Don't set a type of json fields, since they can be of any type.
          break;

        default:
          // NB: Experimenter doesn't outright reject invalid types either.
          console.error(
            `Feature ID ${featureId} has variable ${varName} with invalid FML type: ${prop.type}`
          );
          break;
      }

      if (prop.type === "string" && !!desc.enum) {
        prop.enum = [...desc.enum];
      }

      schema.properties[varName] = prop;
    }

    return schema;
  }
}

export const RemoteSettingsExperimentLoader =
  new _RemoteSettingsExperimentLoader();
