"use strict";

const { ExperimentStore } = ChromeUtils.importESModule(
  "resource://nimbus/lib/ExperimentStore.sys.mjs"
);
const { FeatureManifest } = ChromeUtils.importESModule(
  "resource://nimbus/FeatureManifest.sys.mjs"
);

const { SYNC_DATA_PREF_BRANCH, SYNC_DEFAULTS_PREF_BRANCH } = ExperimentStore;
const { cleanupStorePrefCache } = ExperimentFakes;

add_task(async function test_sharedDataMap_key() {
  const store = new ExperimentStore();

  // Outside of tests we use sharedDataKey for the profile dir filepath
  // where we store experiments
  Assert.ok(store._sharedDataKey, "Make sure it's defined");
});

add_task(async function test_usageBeforeInitialization() {
  const store = ExperimentFakes.store();
  const experiment = ExperimentFakes.experiment("foo", {
    branch: {
      slug: "variant",
      features: [{ featureId: "purple" }],
    },
  });

  Assert.equal(store.getAll().length, 0, "It should not fail");

  await store.init();
  store.addEnrollment(experiment);

  Assert.equal(
    store.getExperimentForFeature("purple"),
    experiment,
    "should return a matching experiment for the given feature"
  );
});

add_task(async function test_event_add_experiment() {
  const sandbox = sinon.createSandbox();
  const store = ExperimentFakes.store();
  const expected = ExperimentFakes.experiment("foo");
  const updateEventCbStub = sandbox.stub();

  // Setup ExperimentManager and child store for ExperimentAPI
  await store.init();

  // Set update cb
  store.on("update:foo", updateEventCbStub);

  // Add some data
  store.addEnrollment(expected);

  Assert.equal(updateEventCbStub.callCount, 1, "Called once for add");

  store.off("update:foo", updateEventCbStub);
});

add_task(async function test_event_updates_main() {
  const sandbox = sinon.createSandbox();
  const store = ExperimentFakes.store();
  const experiment = ExperimentFakes.experiment("foo");
  const updateEventCbStub = sandbox.stub();

  // Setup ExperimentManager and child store for ExperimentAPI
  await store.init();

  // Set update cb
  store.on(
    `featureUpdate:${experiment.branch.features[0].featureId}`,
    updateEventCbStub
  );

  store.addEnrollment(experiment);
  store.updateExperiment("foo", { active: false });

  Assert.equal(
    updateEventCbStub.callCount,
    2,
    "Should be called twice: add, update"
  );
  Assert.equal(
    updateEventCbStub.firstCall.args[1],
    "experiment-updated",
    "Should be called with updated experiment status"
  );
  Assert.equal(
    updateEventCbStub.secondCall.args[1],
    "experiment-updated",
    "Should be called with updated experiment status"
  );

  store.off(
    `featureUpdate:${experiment.branch.features[0].featureId}`,
    updateEventCbStub
  );
});

add_task(async function test_getExperimentForGroup() {
  const store = ExperimentFakes.store();
  const experiment = ExperimentFakes.experiment("foo", {
    branch: {
      slug: "variant",
      features: [{ featureId: "purple" }],
    },
  });

  await store.init();
  store.addEnrollment(ExperimentFakes.experiment("bar"));
  store.addEnrollment(experiment);

  Assert.equal(
    store.getExperimentForFeature("purple"),
    experiment,
    "should return a matching experiment for the given feature"
  );
});

add_task(async function test_hasExperimentForFeature() {
  const store = ExperimentFakes.store();

  await store.init();
  store.addEnrollment(
    ExperimentFakes.experiment("foo", {
      branch: {
        slug: "variant",
        feature: { featureId: "green" },
      },
    })
  );
  store.addEnrollment(
    ExperimentFakes.experiment("foo2", {
      branch: {
        slug: "variant",
        feature: { featureId: "yellow" },
      },
    })
  );
  store.addEnrollment(
    ExperimentFakes.experiment("bar_expired", {
      active: false,
      branch: {
        slug: "variant",
        feature: { featureId: "purple" },
      },
    })
  );
  Assert.equal(
    store.hasExperimentForFeature(),
    false,
    "should return false if the input is empty"
  );

  Assert.equal(
    store.hasExperimentForFeature(undefined),
    false,
    "should return false if the input is undefined"
  );

  Assert.equal(
    store.hasExperimentForFeature("green"),
    true,
    "should return true if there is an experiment with any of the given groups"
  );

  Assert.equal(
    store.hasExperimentForFeature("purple"),
    false,
    "should return false if there is a non-active experiment with the given groups"
  );
});

add_task(async function test_getAll_getAllActiveExperiments() {
  const store = ExperimentFakes.store();

  await store.init();
  ["foo", "bar", "baz"].forEach(slug =>
    store.addEnrollment(ExperimentFakes.experiment(slug, { active: false }))
  );
  store.addEnrollment(ExperimentFakes.experiment("qux", { active: true }));

  Assert.deepEqual(
    store.getAll().map(e => e.slug),
    ["foo", "bar", "baz", "qux"],
    ".getAll() should return all experiments"
  );
  Assert.deepEqual(
    store.getAllActiveExperiments().map(e => e.slug),
    ["qux"],
    ".getAllActiveExperiments() should return all experiments that are active"
  );
});

add_task(async function test_getAll_getAllActiveExperiments() {
  const store = ExperimentFakes.store();

  await store.init();
  ["foo", "bar", "baz"].forEach(slug =>
    store.addEnrollment(ExperimentFakes.experiment(slug, { active: false }))
  );
  store.addEnrollment(ExperimentFakes.experiment("qux", { active: true }));
  store.addEnrollment(ExperimentFakes.rollout("rol"));

  Assert.deepEqual(
    store.getAll().map(e => e.slug),
    ["foo", "bar", "baz", "qux", "rol"],
    ".getAll() should return all experiments and rollouts"
  );
  Assert.deepEqual(
    store.getAllActiveExperiments().map(e => e.slug),
    ["qux"],
    ".getAllActiveExperiments() should return all experiments that are active and no rollouts"
  );
});

add_task(async function test_getAllActiveRollouts() {
  const store = ExperimentFakes.store();

  await store.init();
  ["foo", "bar", "baz"].forEach(slug =>
    store.addEnrollment(ExperimentFakes.rollout(slug))
  );
  store.addEnrollment(ExperimentFakes.experiment("qux", { active: true }));

  Assert.deepEqual(
    store.getAll().map(e => e.slug),
    ["foo", "bar", "baz", "qux"],
    ".getAll() should return all experiments and rollouts"
  );
  Assert.deepEqual(
    store.getAllActiveRollouts().map(e => e.slug),
    ["foo", "bar", "baz"],
    ".getAllActiveRollouts() should return all rollouts"
  );
});

add_task(async function test_addEnrollment_experiment() {
  const store = ExperimentFakes.store();
  const exp = ExperimentFakes.experiment("foo");

  await store.init();
  store.addEnrollment(exp);

  Assert.equal(store.get("foo"), exp, "should save experiment by slug");
});

add_task(async function test_addEnrollment_rollout() {
  const store = ExperimentFakes.store();
  const rollout = ExperimentFakes.rollout("foo");

  await store.init();
  store.addEnrollment(rollout);

  Assert.equal(store.get("foo"), rollout, "should save rollout by slug");
});

add_task(async function test_updateExperiment() {
  const features = [{ featureId: "cfr" }];
  const experiment = Object.freeze(
    ExperimentFakes.experiment("foo", { features, active: true })
  );
  const store = ExperimentFakes.store();

  await store.init();
  store.addEnrollment(experiment);
  store.updateExperiment("foo", { active: false });

  const actual = store.get("foo");
  Assert.equal(actual.active, false, "should change updated props");
  Assert.deepEqual(
    actual.branch.features,
    features,
    "should not update other props"
  );
});

add_task(async function test_sync_access_before_init() {
  cleanupStorePrefCache();

  let store = ExperimentFakes.store();

  Assert.equal(store.getAll().length, 0, "Start with an empty store");

  const syncAccessExp = ExperimentFakes.experiment("foo", {
    features: [{ featureId: "newtab" }],
  });
  await store.init();
  store.addEnrollment(syncAccessExp);

  let prefValue;
  try {
    prefValue = JSON.parse(
      Services.prefs.getStringPref(`${SYNC_DATA_PREF_BRANCH}newtab`)
    );
  } catch (e) {
    Assert.ok(false, "Failed to parse pref value");
  }

  Assert.ok(prefValue, "Parsed stored experiment");
  Assert.equal(prefValue.slug, syncAccessExp.slug, "Got back the experiment");

  // New un-initialized store that should read the pref value
  store = ExperimentFakes.store();

  Assert.equal(
    store.getExperimentForFeature("newtab").slug,
    "foo",
    "Returns experiment from pref"
  );
});

add_task(async function test_sync_access_update() {
  cleanupStorePrefCache();

  let store = ExperimentFakes.store();
  let experiment = ExperimentFakes.experiment("foo", {
    features: [{ featureId: "aboutwelcome" }],
  });

  await store.init();

  store.addEnrollment(experiment);
  store.updateExperiment("foo", {
    branch: {
      ...experiment.branch,
      features: [
        {
          featureId: "aboutwelcome",
          value: { bar: "bar", enabled: true },
        },
      ],
    },
  });

  store = ExperimentFakes.store();
  let cachedExperiment = store.getExperimentForFeature("aboutwelcome");

  Assert.ok(cachedExperiment, "Got back 1 experiment");
  Assert.deepEqual(
    // `branch.feature` and not `features` because for sync access (early startup)
    // experiments we only store the `isEarlyStartup` feature
    cachedExperiment.branch.feature.value,
    { bar: "bar", enabled: true },
    "Got updated value"
  );
});

add_task(async function test_sync_features_only() {
  cleanupStorePrefCache();

  let store = ExperimentFakes.store();
  let experiment = ExperimentFakes.experiment("foo", {
    features: [{ featureId: "cfr" }],
  });

  await store.init();

  store.addEnrollment(experiment);
  store = ExperimentFakes.store();

  Assert.equal(store.getAll().length, 0, "cfr is not a sync access experiment");
});

add_task(async function test_sync_features_remotely() {
  cleanupStorePrefCache();

  let store = ExperimentFakes.store();
  let experiment = ExperimentFakes.experiment("foo", {
    features: [{ featureId: "cfr", isEarlyStartup: true }],
  });

  await store.init();

  store.addEnrollment(experiment);
  store = ExperimentFakes.store();

  Assert.ok(
    Services.prefs.prefHasUserValue("nimbus.syncdatastore.cfr"),
    "The cfr feature was stored as early access in prefs"
  );
  Assert.equal(store.getAll().length, 0, "Featre restored from prefs");
});

add_task(async function test_sync_access_unenroll() {
  cleanupStorePrefCache();

  let store = ExperimentFakes.store();
  let experiment = ExperimentFakes.experiment("foo", {
    features: [{ featureId: "aboutwelcome" }],
    active: true,
  });

  await store.init();

  store.addEnrollment(experiment);
  store.updateExperiment("foo", { active: false });

  store = ExperimentFakes.store();
  let experiments = store.getAll();

  Assert.equal(experiments.length, 0, "Unenrolled experiment is deleted");
});

add_task(async function test_sync_access_unenroll_2() {
  cleanupStorePrefCache();

  let store = ExperimentFakes.store();
  let experiment1 = ExperimentFakes.experiment("foo", {
    features: [{ featureId: "newtab" }],
  });
  let experiment2 = ExperimentFakes.experiment("bar", {
    features: [{ featureId: "aboutwelcome" }],
  });

  await store.init();

  store.addEnrollment(experiment1);
  store.addEnrollment(experiment2);

  Assert.equal(store.getAll().length, 2, "2/2 experiments");

  let other_store = ExperimentFakes.store();

  Assert.ok(
    other_store.getExperimentForFeature("aboutwelcome"),
    "Fetches experiment from pref cache even before init (aboutwelcome)"
  );

  store.updateExperiment("bar", { active: false });

  Assert.ok(
    other_store.getExperimentForFeature("newtab").slug,
    "Fetches experiment from pref cache even before init (newtab)"
  );
  Assert.ok(
    !other_store.getExperimentForFeature("aboutwelcome")?.slug,
    "Experiment was updated and should not be found"
  );

  store.updateExperiment("foo", { active: false });
  Assert.ok(
    !other_store.getExperimentForFeature("newtab")?.slug,
    "Unenrolled from 2/2 experiments"
  );

  Assert.equal(
    Services.prefs.getStringPref(`${SYNC_DATA_PREF_BRANCH}newtab`, "").length,
    0,
    "Cleared pref 1"
  );
  Assert.equal(
    Services.prefs.getStringPref(`${SYNC_DATA_PREF_BRANCH}aboutwelcome`, "")
      .length,
    0,
    "Cleared pref 2"
  );
});

add_task(async function test_getRolloutForFeature_fromStore() {
  const store = ExperimentFakes.store();
  const rollout = ExperimentFakes.rollout("foo");

  await store.init();
  store.addEnrollment(rollout);

  Assert.deepEqual(
    store.getRolloutForFeature(rollout.featureIds[0]),
    rollout,
    "Should return back the same rollout"
  );
});

add_task(async function test_getRolloutForFeature_fromSyncCache() {
  let store = ExperimentFakes.store();
  const rollout = ExperimentFakes.rollout("foo", {
    branch: {
      slug: "early-startup",
      features: [{ featureId: "aboutwelcome", value: { enabled: true } }],
    },
  });
  let updatePromise = new Promise(resolve =>
    store.on(`update:${rollout.slug}`, resolve)
  );

  await store.init();
  store.addEnrollment(rollout);
  await updatePromise;
  // New uninitialized store will return data from sync cache
  // before init
  store = ExperimentFakes.store();

  Assert.ok(
    Services.prefs.getStringPref(`${SYNC_DEFAULTS_PREF_BRANCH}aboutwelcome`),
    "Sync cache is set"
  );
  Assert.equal(
    store.getRolloutForFeature(rollout.featureIds[0]).slug,
    rollout.slug,
    "Should return back the same rollout"
  );
  Assert.deepEqual(
    store.getRolloutForFeature(rollout.featureIds[0]).branch.feature,
    rollout.branch.features[0],
    "Should return back the same feature"
  );
  cleanupStorePrefCache();
});

add_task(async function test_remoteRollout() {
  let store = ExperimentFakes.store();
  const rollout = ExperimentFakes.rollout("foo", {
    branch: {
      slug: "early-startup",
      features: [{ featureId: "aboutwelcome", value: { enabled: true } }],
    },
  });
  let featureUpdateStub = sinon.stub();
  let updatePromise = new Promise(resolve =>
    store.on(`update:${rollout.slug}`, resolve)
  );
  store.on("featureUpdate:aboutwelcome", featureUpdateStub);

  await store.init();
  store.addEnrollment(rollout);
  await updatePromise;

  Assert.ok(
    Services.prefs.getStringPref(`${SYNC_DEFAULTS_PREF_BRANCH}aboutwelcome`),
    "Sync cache is set"
  );

  updatePromise = new Promise(resolve =>
    store.on(`update:${rollout.slug}`, resolve)
  );
  store.updateExperiment(rollout.slug, { active: false });

  // wait for it to be removed
  await updatePromise;

  Assert.ok(featureUpdateStub.calledTwice, "Called for add and remove");
  Assert.ok(
    store.get(rollout.slug),
    "Rollout is still in the store just not active"
  );
  Assert.ok(
    !store.getRolloutForFeature("aboutwelcome"),
    "Feature rollout should not exist"
  );
  Assert.ok(
    !Services.prefs.getStringPref(
      `${SYNC_DEFAULTS_PREF_BRANCH}aboutwelcome`,
      ""
    ),
    "Sync cache is cleared"
  );
});

add_task(async function test_syncDataStore_setDefault() {
  cleanupStorePrefCache();
  const store = ExperimentFakes.store();

  await store.init();

  Assert.equal(
    Services.prefs.getStringPref(
      `${SYNC_DEFAULTS_PREF_BRANCH}aboutwelcome`,
      ""
    ),
    "",
    "Pref is empty"
  );

  let rollout = ExperimentFakes.rollout("foo", {
    features: [{ featureId: "aboutwelcome", value: { remote: true } }],
  });
  store.addEnrollment(rollout);

  Assert.ok(
    Services.prefs.getStringPref(`${SYNC_DEFAULTS_PREF_BRANCH}aboutwelcome`),
    "Stored in pref"
  );

  cleanupStorePrefCache();
});

add_task(async function test_syncDataStore_getDefault() {
  cleanupStorePrefCache();
  const store = ExperimentFakes.store();
  const rollout = ExperimentFakes.rollout("aboutwelcome-slug", {
    branch: {
      features: [
        {
          featureId: "aboutwelcome",
          value: { remote: true },
        },
      ],
    },
  });

  await store.init();
  await store.addEnrollment(rollout);

  Assert.ok(
    Services.prefs.getStringPref(`${SYNC_DEFAULTS_PREF_BRANCH}aboutwelcome`)
  );

  let restoredRollout = store.getRolloutForFeature("aboutwelcome");

  Assert.ok(restoredRollout);
  Assert.ok(
    restoredRollout.branch.features[0].value.remote,
    "Restore data from pref"
  );

  cleanupStorePrefCache();
});

add_task(async function test_addEnrollment_rollout() {
  const sandbox = sinon.createSandbox();
  const store = ExperimentFakes.store();
  const stub = sandbox.stub();
  const value = { bar: true };
  let rollout = ExperimentFakes.rollout("foo", {
    features: [{ featureId: "aboutwelcome", value }],
  });

  store._onFeatureUpdate("aboutwelcome", stub);

  await store.init();
  store.addEnrollment(rollout);

  Assert.deepEqual(
    store.getRolloutForFeature("aboutwelcome"),
    rollout,
    "should return the stored value"
  );
  Assert.equal(stub.callCount, 1, "Called once on update");
  Assert.equal(
    stub.firstCall.args[1],
    "rollout-updated",
    "Called for correct reason"
  );
});

add_task(async function test_storeValuePerPref_noVariables() {
  const store = ExperimentFakes.store();
  const experiment = ExperimentFakes.experiment("foo", {
    branch: {
      slug: "variant",
      features: [
        {
          // Ensure it gets saved to prefs
          isEarlyStartup: true,
          featureId: "purple",
        },
      ],
    },
  });

  await store.init();
  store.addEnrollment(experiment);

  let branch = Services.prefs.getBranch(`${SYNC_DATA_PREF_BRANCH}purple.`);

  Assert.ok(
    Services.prefs.getStringPref(`${SYNC_DATA_PREF_BRANCH}purple`, ""),
    "Experiment metadata saved to prefs"
  );

  Assert.equal(branch.getChildList("").length, 0, "No variables to store");

  store._updateSyncStore({ ...experiment, active: false });
  Assert.ok(
    !Services.prefs.getStringPref(`${SYNC_DATA_PREF_BRANCH}purple`, ""),
    "Experiment cleanup"
  );
});

add_task(async function test_storeValuePerPref_withVariables() {
  const store = ExperimentFakes.store();
  const experiment = ExperimentFakes.experiment("foo", {
    branch: {
      slug: "variant",
      features: [
        {
          // Ensure it gets saved to prefs
          isEarlyStartup: true,
          featureId: "purple",
          value: { color: "purple", enabled: true },
        },
      ],
    },
  });

  await store.init();
  store.addEnrollment(experiment);

  let branch = Services.prefs.getBranch(`${SYNC_DATA_PREF_BRANCH}purple.`);

  let val = Services.prefs.getStringPref(`${SYNC_DATA_PREF_BRANCH}purple`);
  Assert.equal(
    val.indexOf("color"),
    -1,
    `Experiment metadata does not contain variables ${val}`
  );

  Assert.equal(branch.getChildList("").length, 2, "Enabled and color");

  store._updateSyncStore({ ...experiment, active: false });
  Assert.ok(
    !Services.prefs.getStringPref(`${SYNC_DATA_PREF_BRANCH}purple`, ""),
    "Experiment cleanup"
  );
  Assert.equal(branch.getChildList("").length, 0, "Variables are also removed");
});

add_task(async function test_storeValuePerPref_returnsSameValue() {
  let store = ExperimentFakes.store();
  const experiment = ExperimentFakes.experiment("foo", {
    branch: {
      slug: "variant",
      features: [
        {
          // Ensure it gets saved to prefs
          isEarlyStartup: true,
          featureId: "purple",
          value: { color: "purple", enabled: true },
        },
      ],
    },
  });

  await store.init();
  store.addEnrollment(experiment);
  let branch = Services.prefs.getBranch(`${SYNC_DATA_PREF_BRANCH}purple.`);

  store = ExperimentFakes.store();
  const cachedExperiment = store.getExperimentForFeature("purple");
  // Cached experiment format only stores early access feature
  cachedExperiment.branch.features = [cachedExperiment.branch.feature];
  delete cachedExperiment.branch.feature;
  Assert.deepEqual(cachedExperiment, experiment, "Returns the same value");

  // Cleanup
  store._updateSyncStore({ ...experiment, active: false });
  Assert.ok(
    !Services.prefs.getStringPref(`${SYNC_DATA_PREF_BRANCH}purple`, ""),
    "Experiment cleanup"
  );
  Assert.deepEqual(branch.getChildList(""), [], "Variables are also removed");
});

add_task(async function test_storeValuePerPref_returnsSameValue_allTypes() {
  let store = ExperimentFakes.store();
  // Add a fake feature that matches the variables we're testing
  FeatureManifest.purple = {
    variables: {
      string: { type: "string" },
      bool: { type: "boolean" },
      array: { type: "json" },
      number1: { type: "int" },
      number2: { type: "int" },
      number3: { type: "int" },
      json: { type: "json" },
    },
  };
  const experiment = ExperimentFakes.experiment("foo", {
    branch: {
      slug: "variant",
      features: [
        {
          // Ensure it gets saved to prefs
          isEarlyStartup: true,
          featureId: "purple",
          value: {
            string: "string",
            bool: true,
            array: [1, 2, 3],
            number1: 42,
            number2: 0,
            number3: -5,
            json: { jsonValue: true },
          },
        },
      ],
    },
  });

  await store.init();
  store.addEnrollment(experiment);
  let branch = Services.prefs.getBranch(`${SYNC_DATA_PREF_BRANCH}purple.`);

  store = ExperimentFakes.store();
  Assert.deepEqual(
    store.getExperimentForFeature("purple").branch.feature.value,
    experiment.branch.features[0].value,
    "Returns the same value"
  );

  // Cleanup
  store._updateSyncStore({ ...experiment, active: false });
  Assert.ok(
    !Services.prefs.getStringPref(`${SYNC_DATA_PREF_BRANCH}purple`, ""),
    "Experiment cleanup"
  );
  Assert.deepEqual(branch.getChildList(""), [], "Variables are also removed");
  delete FeatureManifest.purple;
});

add_task(async function test_cleanupOldRecipes() {
  let store = ExperimentFakes.store();
  let sandbox = sinon.createSandbox();
  let stub = sandbox.stub(store, "_removeEntriesByKeys");
  const experiment1 = ExperimentFakes.experiment("foo", {
    branch: {
      slug: "variant",
      features: [{ featureId: "purple" }],
    },
  });
  const experiment2 = ExperimentFakes.experiment("bar", {
    branch: {
      slug: "variant",
      features: [{ featureId: "purple" }],
    },
  });
  const experiment3 = ExperimentFakes.experiment("baz", {
    branch: {
      slug: "variant",
      features: [{ featureId: "purple" }],
    },
  });
  const experiment4 = ExperimentFakes.experiment("faz", {
    branch: {
      slug: "variant",
      features: [{ featureId: "purple" }],
    },
  });
  // Exp 2 is kept because it's recent (even though it's not active)
  // Exp 4 is kept because it's active
  experiment2.lastSeen = new Date().toISOString();
  experiment2.active = false;
  experiment1.lastSeen = new Date("2020-01-01").toISOString();
  experiment1.active = false;
  experiment3.active = false;
  delete experiment3.lastSeen;
  store._data = {
    foo: experiment1,
    bar: experiment2,
    baz: experiment3,
    faz: experiment4,
  };

  store._cleanupOldRecipes();

  Assert.ok(stub.calledOnce, "Recipe cleanup called");
  Assert.equal(
    stub.firstCall.args[0].length,
    2,
    "We call to remove enrollments"
  );
  Assert.equal(
    stub.firstCall.args[0][0],
    experiment1.slug,
    "Should remove expired enrollment"
  );
  Assert.equal(
    stub.firstCall.args[0][1],
    experiment3.slug,
    "Should remove invalid enrollment"
  );
});
