/* 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 { BaseStudyAction } from "resource://normandy/actions/BaseStudyAction.sys.mjs";

const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
  ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs",
  BaseAction: "resource://normandy/actions/BaseAction.sys.mjs",
  ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs",
  PreferenceExperiments:
    "resource://normandy/lib/PreferenceExperiments.sys.mjs",
  Sampling: "resource://gre/modules/components-utils/Sampling.sys.mjs",
});

/**
 * Enrolls a user in a preference experiment, in which we assign the
 * user to an experiment branch and modify a preference temporarily to
 * measure how it affects Firefox via Telemetry.
 */
export class PreferenceExperimentAction extends BaseStudyAction {
  get schema() {
    return lazy.ActionSchemas["multi-preference-experiment"];
  }

  constructor() {
    super();
    this.seenExperimentSlugs = new Set();
  }

  async _processRecipe(recipe, suitability) {
    const {
      branches,
      isHighPopulation,
      isEnrollmentPaused,
      slug,
      userFacingName,
      userFacingDescription,
    } = recipe.arguments || {};

    let experiment;
    // Slug might not exist, because if suitability is ARGUMENTS_INVALID, the
    // arguments is not guaranteed to match the schema.
    if (slug) {
      this.seenExperimentSlugs.add(slug);

      try {
        experiment = await lazy.PreferenceExperiments.get(slug);
      } catch (err) {
        // This is probably that the experiment doesn't exist. If that's not the
        // case, re-throw the error.
        if (!(err instanceof lazy.PreferenceExperiments.NotFoundError)) {
          throw err;
        }
      }
    }

    switch (suitability) {
      case lazy.BaseAction.suitability.SIGNATURE_ERROR: {
        this._considerTemporaryError({ experiment, reason: "signature-error" });
        break;
      }

      case lazy.BaseAction.suitability.CAPABILITIES_MISMATCH: {
        if (experiment && !experiment.expired) {
          await lazy.PreferenceExperiments.stop(slug, {
            resetValue: true,
            reason: "capability-mismatch",
            caller:
              "PreferenceExperimentAction._processRecipe::capabilities_mismatch",
          });
        }
        break;
      }

      case lazy.BaseAction.suitability.FILTER_MATCH: {
        // If we're not in the experiment, try to enroll
        if (!experiment) {
          // Check all preferences that could be used by this experiment.
          // If there's already an active experiment that has set that preference, abort.
          const activeExperiments =
            await lazy.PreferenceExperiments.getAllActive();
          for (const branch of branches) {
            const conflictingPrefs = Object.keys(branch.preferences).filter(
              preferenceName => {
                return activeExperiments.some(exp =>
                  exp.preferences.hasOwnProperty(preferenceName)
                );
              }
            );
            if (conflictingPrefs.length) {
              throw new Error(
                `Experiment ${slug} ignored; another active experiment is already using the
            ${conflictingPrefs[0]} preference.`
              );
            }
          }

          // Determine if enrollment is currently paused for this experiment.
          if (isEnrollmentPaused) {
            this.log.debug(`Enrollment is paused for experiment "${slug}"`);
            return;
          }

          // Otherwise, enroll!
          const branch = await this.chooseBranch(slug, branches);
          const experimentType = isHighPopulation ? "exp-highpop" : "exp";
          await lazy.PreferenceExperiments.start({
            slug,
            actionName: this.name,
            branch: branch.slug,
            preferences: branch.preferences,
            experimentType,
            userFacingName,
            userFacingDescription,
          });
        } else if (experiment.expired) {
          this.log.debug(`Experiment ${slug} has expired, aborting.`);
        } else {
          experiment.temporaryErrorDeadline = null;
          await lazy.PreferenceExperiments.update(experiment);
          await lazy.PreferenceExperiments.markLastSeen(slug);
        }
        break;
      }

      case lazy.BaseAction.suitability.FILTER_MISMATCH: {
        if (experiment && !experiment.expired) {
          await lazy.PreferenceExperiments.stop(slug, {
            resetValue: true,
            reason: "filter-mismatch",
            caller:
              "PreferenceExperimentAction._processRecipe::filter_mismatch",
          });
        }
        break;
      }

      case lazy.BaseAction.suitability.FILTER_ERROR: {
        this._considerTemporaryError({ experiment, reason: "filter-error" });
        break;
      }

      case lazy.BaseAction.suitability.ARGUMENTS_INVALID: {
        if (experiment && !experiment.expired) {
          await lazy.PreferenceExperiments.stop(slug, {
            resetValue: true,
            reason: "arguments-invalid",
            caller:
              "PreferenceExperimentAction._processRecipe::arguments_invalid",
          });
        }
        break;
      }

      default: {
        throw new Error(`Unknown recipe suitability "${suitability}".`);
      }
    }
  }

  async _run() {
    throw new Error("_run shouldn't be called anymore");
  }

  async chooseBranch(slug, branches) {
    const ratios = branches.map(branch => branch.ratio);
    const userId = lazy.ClientEnvironment.userId;

    // It's important that the input be:
    // - Unique per-user (no one is bucketed alike)
    // - Unique per-experiment (bucketing differs across multiple experiments)
    // - Differs from the input used for sampling the recipe (otherwise only
    //   branches that contain the same buckets as the recipe sampling will
    //   receive users)
    const input = `${userId}-${slug}-branch`;

    const index = await lazy.Sampling.ratioSample(input, ratios);
    return branches[index];
  }

  /**
   * End any experiments which we didn't see during this session.
   * This is the "normal" way experiments end, as they are disabled on
   * the server and so we stop seeing them.  This can also happen if
   * the user doesn't match the filter any more.
   */
  async _finalize({ noRecipes } = {}) {
    const activeExperiments = await lazy.PreferenceExperiments.getAllActive();

    if (noRecipes && this.seenExperimentSlugs.size) {
      throw new PreferenceExperimentAction.BadNoRecipesArg();
    }

    return Promise.all(
      activeExperiments.map(experiment => {
        if (this.name != experiment.actionName) {
          // Another action is responsible for cleaning this one
          // up. Leave it alone.
          return null;
        }

        if (noRecipes) {
          return this._considerTemporaryError({
            experiment,
            reason: "no-recipes",
          });
        }

        if (this.seenExperimentSlugs.has(experiment.slug)) {
          return null;
        }

        return lazy.PreferenceExperiments.stop(experiment.slug, {
          resetValue: true,
          reason: "recipe-not-seen",
          caller: "PreferenceExperimentAction._finalize",
        }).catch(e => {
          this.log.warn(`Stopping experiment ${experiment.slug} failed: ${e}`);
        });
      })
    );
  }

  /**
   * Given that a temporary error has occurred for an experiment, check if it
   * should be temporarily ignored, or if the deadline has passed. If the
   * deadline is passed, the experiment will be ended. If this is the first
   * temporary error, a deadline will be generated. Otherwise, nothing will
   * happen.
   *
   * If a temporary deadline exists but cannot be parsed, a new one will be
   * made.
   *
   * The deadline is 7 days from the first time that recipe failed, as
   * reckoned by the client's clock.
   *
   * @param {Object} args
   * @param {Experiment} args.experiment The enrolled experiment to potentially unenroll.
   * @param {String} args.reason If the recipe should end, the reason it is ending.
   */
  async _considerTemporaryError({ experiment, reason }) {
    if (!experiment || experiment.expired) {
      return;
    }

    let now = Date.now(); // milliseconds-since-epoch
    let day = 24 * 60 * 60 * 1000;
    let newDeadline = new Date(now + 7 * day).toJSON();

    if (experiment.temporaryErrorDeadline) {
      let deadline = new Date(experiment.temporaryErrorDeadline);
      // if deadline is an invalid date, set it to one week from now.
      if (isNaN(deadline)) {
        experiment.temporaryErrorDeadline = newDeadline;
        await lazy.PreferenceExperiments.update(experiment);
        return;
      }

      if (now > deadline) {
        await lazy.PreferenceExperiments.stop(experiment.slug, {
          resetValue: true,
          reason,
          caller: "PreferenceExperimentAction._considerTemporaryFailure",
        });
      }
    } else {
      // there is no deadline, so set one
      experiment.temporaryErrorDeadline = newDeadline;
      await lazy.PreferenceExperiments.update(experiment);
    }
  }
}

PreferenceExperimentAction.BadNoRecipesArg = class extends Error {
  message = "noRecipes is true, but some recipes observed";
};
