"use strict";

const { PreferenceExperiments } = ChromeUtils.importESModule(
  "resource://normandy/lib/PreferenceExperiments.sys.mjs"
);
const { CleanupManager } = ChromeUtils.importESModule(
  "resource://normandy/lib/CleanupManager.sys.mjs"
);
const { NormandyUtils } = ChromeUtils.importESModule(
  "resource://normandy/lib/NormandyUtils.sys.mjs"
);
const { NormandyTestUtils } = ChromeUtils.importESModule(
  "resource://testing-common/NormandyTestUtils.sys.mjs"
);

// Save ourselves some typing
const { withMockExperiments } = PreferenceExperiments;
const DefaultPreferences = new Preferences({ defaultBranch: true });
const startupPrefs = "app.normandy.startupExperimentPrefs";
const { preferenceStudyFactory } = NormandyTestUtils.factories;

const NOW = new Date();

const mockV1Data = {
  hypothetical_experiment: {
    name: "hypothetical_experiment",
    branch: "hypo_1",
    expired: false,
    lastSeen: NOW.toJSON(),
    preferenceName: "some.pref",
    preferenceValue: 2,
    preferenceType: "integer",
    previousPreferenceValue: 1,
    preferenceBranchType: "user",
    experimentType: "exp",
  },
  another_experiment: {
    name: "another_experiment",
    branch: "another_4",
    expired: true,
    lastSeen: NOW.toJSON(),
    preferenceName: "another.pref",
    preferenceValue: true,
    preferenceType: "boolean",
    previousPreferenceValue: false,
    preferenceBranchType: "default",
    experimentType: "exp",
  },
};

const mockV2Data = {
  experiments: {
    hypothetical_experiment: {
      name: "hypothetical_experiment",
      branch: "hypo_1",
      expired: false,
      lastSeen: NOW.toJSON(),
      preferenceName: "some.pref",
      preferenceValue: 2,
      preferenceType: "integer",
      previousPreferenceValue: 1,
      preferenceBranchType: "user",
      experimentType: "exp",
    },
    another_experiment: {
      name: "another_experiment",
      branch: "another_4",
      expired: true,
      lastSeen: NOW.toJSON(),
      preferenceName: "another.pref",
      preferenceValue: true,
      preferenceType: "boolean",
      previousPreferenceValue: false,
      preferenceBranchType: "default",
      experimentType: "exp",
    },
  },
};

const mockV3Data = {
  experiments: {
    hypothetical_experiment: {
      name: "hypothetical_experiment",
      branch: "hypo_1",
      expired: false,
      lastSeen: NOW.toJSON(),
      preferences: {
        "some.pref": {
          preferenceValue: 2,
          preferenceType: "integer",
          previousPreferenceValue: 1,
          preferenceBranchType: "user",
        },
      },
      experimentType: "exp",
    },
    another_experiment: {
      name: "another_experiment",
      branch: "another_4",
      expired: true,
      lastSeen: NOW.toJSON(),
      preferences: {
        "another.pref": {
          preferenceValue: true,
          preferenceType: "boolean",
          previousPreferenceValue: false,
          preferenceBranchType: "default",
        },
      },
      experimentType: "exp",
    },
  },
};

const mockV4Data = {
  experiments: {
    hypothetical_experiment: {
      name: "hypothetical_experiment",
      branch: "hypo_1",
      actionName: "SinglePreferenceExperimentAction",
      expired: false,
      lastSeen: NOW.toJSON(),
      preferences: {
        "some.pref": {
          preferenceValue: 2,
          preferenceType: "integer",
          previousPreferenceValue: 1,
          preferenceBranchType: "user",
        },
      },
      experimentType: "exp",
    },
    another_experiment: {
      name: "another_experiment",
      branch: "another_4",
      actionName: "SinglePreferenceExperimentAction",
      expired: true,
      lastSeen: NOW.toJSON(),
      preferences: {
        "another.pref": {
          preferenceValue: true,
          preferenceType: "boolean",
          previousPreferenceValue: false,
          preferenceBranchType: "default",
        },
      },
      experimentType: "exp",
    },
  },
};

const mockV5Data = {
  experiments: {
    hypothetical_experiment: {
      slug: "hypothetical_experiment",
      branch: "hypo_1",
      actionName: "SinglePreferenceExperimentAction",
      expired: false,
      lastSeen: NOW.toJSON(),
      preferences: {
        "some.pref": {
          preferenceValue: 2,
          preferenceType: "integer",
          previousPreferenceValue: 1,
          preferenceBranchType: "user",
        },
      },
      experimentType: "exp",
    },
    another_experiment: {
      slug: "another_experiment",
      branch: "another_4",
      actionName: "SinglePreferenceExperimentAction",
      expired: true,
      lastSeen: NOW.toJSON(),
      preferences: {
        "another.pref": {
          preferenceValue: true,
          preferenceType: "boolean",
          previousPreferenceValue: false,
          preferenceBranchType: "default",
        },
      },
      experimentType: "exp",
    },
  },
};

const migrationsInfo = [
  {
    migration: PreferenceExperiments.migrations.migration01MoveExperiments,
    dataBefore: mockV1Data,
    dataAfter: mockV2Data,
  },
  {
    migration: PreferenceExperiments.migrations.migration02MultiPreference,
    dataBefore: mockV2Data,
    dataAfter: mockV3Data,
  },
  {
    migration: PreferenceExperiments.migrations.migration03AddActionName,
    dataBefore: mockV3Data,
    dataAfter: mockV4Data,
  },
  {
    migration: PreferenceExperiments.migrations.migration04RenameNameToSlug,
    dataBefore: mockV4Data,
    dataAfter: mockV5Data,
  },
  // Migration 5 is not a simple data migration. This style of tests does not apply to it.
];

/**
 * Make a mock `JsonFile` object with a no-op `saveSoon` method and a deep copy
 * of the data passed.
 *
 * @param {object} data the data in the store
 */
function makeMockJsonFile(data = {}) {
  return {
    // Deep clone the data in case migrations mutate it.
    data: JSON.parse(JSON.stringify(data)),
    saveSoon: () => {},
  };
}

/** Test that each migration results in the expected data */
add_task(async function test_migrations() {
  for (const { migration, dataAfter, dataBefore } of migrationsInfo) {
    let mockJsonFile = makeMockJsonFile(dataBefore);
    await migration(mockJsonFile);
    Assert.deepEqual(
      mockJsonFile.data,
      dataAfter,
      `Migration ${migration.name} should result in the expected data`
    );
  }
});

add_task(async function migrations_are_idempotent() {
  for (const { migration, dataBefore } of migrationsInfo) {
    const mockJsonFileOnce = makeMockJsonFile(dataBefore);
    const mockJsonFileTwice = makeMockJsonFile(dataBefore);
    await migration(mockJsonFileOnce);
    await migration(mockJsonFileTwice);
    await migration(mockJsonFileTwice);
    Assert.deepEqual(
      mockJsonFileOnce.data,
      mockJsonFileTwice.data,
      "migrating data twice should be idempotent for " + migration.name
    );
  }
});

add_task(async function migration03KeepsActionName() {
  let mockData = JSON.parse(JSON.stringify(mockV3Data));
  mockData.experiments.another_experiment.actionName = "SomeOldAction";
  const mockJsonFile = makeMockJsonFile(mockData);
  // Output should be the same as mockV4Data, but preserving the action.
  const migratedData = JSON.parse(JSON.stringify(mockV4Data));
  migratedData.experiments.another_experiment.actionName = "SomeOldAction";

  await PreferenceExperiments.migrations.migration03AddActionName(mockJsonFile);
  Assert.deepEqual(mockJsonFile.data, migratedData);
});

// Test that migration 5 works as expected
decorate_task(
  withMockExperiments([
    NormandyTestUtils.factories.preferenceStudyFactory({
      actionName: "PreferenceExperimentAction",
      expired: false,
    }),
    NormandyTestUtils.factories.preferenceStudyFactory({
      actionName: "SinglePreferenceExperimentAction",
      expired: false,
    }),
  ]),
  async function migration05Works({ prefExperiments: [expKeep, expExpire] }) {
    // pre check
    const activeSlugsBefore = (await PreferenceExperiments.getAllActive()).map(
      e => e.slug
    );
    Assert.deepEqual(
      activeSlugsBefore,
      [expKeep.slug, expExpire.slug],
      "Both experiments should be present and active before the migration"
    );

    // run the migration
    await PreferenceExperiments.migrations.migration05RemoveOldAction();

    // verify behavior
    const activeSlugsAfter = (await PreferenceExperiments.getAllActive()).map(
      e => e.slug
    );
    Assert.deepEqual(
      activeSlugsAfter,
      [expKeep.slug],
      "The single pref experiment should be ended by the migration"
    );
    const allSlugsAfter = (await PreferenceExperiments.getAll()).map(
      e => e.slug
    );
    Assert.deepEqual(
      allSlugsAfter,
      [expKeep.slug, expExpire.slug],
      "Both experiments should still exist after the migration"
    );
  }
);

// clearAllExperimentStorage
decorate_task(
  withMockExperiments([preferenceStudyFactory({ slug: "test" })]),
  async function () {
    ok(await PreferenceExperiments.has("test"), "Mock experiment is detected.");
    await PreferenceExperiments.clearAllExperimentStorage();
    ok(
      !(await PreferenceExperiments.has("test")),
      "clearAllExperimentStorage removed all stored experiments"
    );
  }
);

// start should throw if an experiment with the given name already exists
decorate_task(
  withMockExperiments([preferenceStudyFactory({ slug: "test" })]),
  withSendEventSpy(),
  async function ({ sendEventSpy }) {
    await Assert.rejects(
      PreferenceExperiments.start({
        slug: "test",
        actionName: "SomeAction",
        branch: "branch",
        preferences: {
          "fake.preference": {
            preferenceValue: "value",
            preferenceType: "string",
            preferenceBranchType: "default",
          },
        },
      }),
      /test.*already exists/,
      "start threw an error due to a conflicting experiment name"
    );

    sendEventSpy.assertEvents([
      ["enrollFailed", "preference_study", "test", { reason: "name-conflict" }],
    ]);
  }
);

// start should throw if an experiment for any of the given
// preferences are active
decorate_task(
  withMockExperiments([
    preferenceStudyFactory({
      slug: "test",
      preferences: { "fake.preferenceinteger": {} },
    }),
  ]),
  withSendEventSpy(),
  async function ({ sendEventSpy }) {
    await Assert.rejects(
      PreferenceExperiments.start({
        slug: "different",
        actionName: "SomeAction",
        branch: "branch",
        preferences: {
          "fake.preference": {
            preferenceValue: "value",
            preferenceType: "string",
            preferenceBranchType: "default",
          },
          "fake.preferenceinteger": {
            preferenceValue: 2,
            preferenceType: "integer",
            preferenceBranchType: "default",
          },
        },
      }),
      /another.*is currently active/i,
      "start threw an error due to an active experiment for the given preference"
    );

    sendEventSpy.assertEvents([
      [
        "enrollFailed",
        "preference_study",
        "different",
        { reason: "pref-conflict" },
      ],
    ]);
  }
);

// start should throw if an invalid preferenceBranchType is given
decorate_task(
  withMockExperiments(),
  withSendEventSpy(),
  async function ({ sendEventSpy }) {
    await Assert.rejects(
      PreferenceExperiments.start({
        slug: "test",
        actionName: "SomeAction",
        branch: "branch",
        preferences: {
          "fake.preference": {
            preferenceValue: "value",
            preferenceType: "string",
            preferenceBranchType: "invalid",
          },
        },
      }),
      /invalid value for preferenceBranchType: invalid/i,
      "start threw an error due to an invalid preference branch type"
    );

    sendEventSpy.assertEvents([
      [
        "enrollFailed",
        "preference_study",
        "test",
        { reason: "invalid-branch" },
      ],
    ]);
  }
);

// start should save experiment data, modify preferences, and register a
// watcher.
decorate_task(
  withMockExperiments(),
  withMockPreferences(),
  withStub(PreferenceExperiments, "startObserver"),
  withSendEventSpy(),
  async function testStart({ mockPreferences, startObserverStub }) {
    mockPreferences.set("fake.preference", "oldvalue", "default");
    mockPreferences.set("fake.preference", "uservalue", "user");
    mockPreferences.set("fake.preferenceinteger", 1, "default");
    mockPreferences.set("fake.preferenceinteger", 101, "user");

    const experiment = {
      slug: "test",
      actionName: "SomeAction",
      branch: "branch",
      preferences: {
        "fake.preference": {
          preferenceValue: "newvalue",
          preferenceBranchType: "default",
          preferenceType: "string",
        },
        "fake.preferenceinteger": {
          preferenceValue: 2,
          preferenceBranchType: "default",
          preferenceType: "integer",
        },
      },
    };
    await PreferenceExperiments.start(experiment);
    ok(await PreferenceExperiments.get("test"), "start saved the experiment");
    ok(
      startObserverStub.calledWith("test", experiment.preferences),
      "start registered an observer"
    );

    const expectedExperiment = {
      slug: "test",
      branch: "branch",
      expired: false,
      preferences: {
        "fake.preference": {
          preferenceValue: "newvalue",
          preferenceType: "string",
          previousPreferenceValue: "oldvalue",
          preferenceBranchType: "default",
          overridden: true,
        },
        "fake.preferenceinteger": {
          preferenceValue: 2,
          preferenceType: "integer",
          previousPreferenceValue: 1,
          preferenceBranchType: "default",
          overridden: true,
        },
      },
    };
    const experimentSubset = {};
    const actualExperiment = await PreferenceExperiments.get("test");
    Object.keys(expectedExperiment).forEach(
      key => (experimentSubset[key] = actualExperiment[key])
    );
    Assert.deepEqual(
      experimentSubset,
      expectedExperiment,
      "start saved the experiment"
    );

    is(
      DefaultPreferences.get("fake.preference"),
      "newvalue",
      "start modified the default preference"
    );
    is(
      Preferences.get("fake.preference"),
      "uservalue",
      "start did not modify the user preference"
    );
    is(
      Preferences.get(`${startupPrefs}.fake.preference`),
      "newvalue",
      "start saved the experiment value to the startup prefs tree"
    );
    is(
      DefaultPreferences.get("fake.preferenceinteger"),
      2,
      "start modified the default preference"
    );
    is(
      Preferences.get("fake.preferenceinteger"),
      101,
      "start did not modify the user preference"
    );
    is(
      Preferences.get(`${startupPrefs}.fake.preferenceinteger`),
      2,
      "start saved the experiment value to the startup prefs tree"
    );
  }
);

// start should modify the user preference for the user branch type
decorate_task(
  withMockExperiments(),
  withMockPreferences(),
  withStub(PreferenceExperiments, "startObserver"),
  async function ({ mockPreferences, startObserverStub }) {
    mockPreferences.set("fake.preference", "olddefaultvalue", "default");
    mockPreferences.set("fake.preference", "oldvalue", "user");

    const experiment = {
      slug: "test",
      actionName: "SomeAction",
      branch: "branch",
      preferences: {
        "fake.preference": {
          preferenceValue: "newvalue",
          preferenceType: "string",
          preferenceBranchType: "user",
        },
      },
    };
    await PreferenceExperiments.start(experiment);
    ok(
      startObserverStub.calledWith("test", experiment.preferences),
      "start registered an observer"
    );

    const expectedExperiment = {
      slug: "test",
      branch: "branch",
      expired: false,
      preferences: {
        "fake.preference": {
          preferenceValue: "newvalue",
          preferenceType: "string",
          previousPreferenceValue: "oldvalue",
          preferenceBranchType: "user",
        },
      },
    };

    const experimentSubset = {};
    const actualExperiment = await PreferenceExperiments.get("test");
    Object.keys(expectedExperiment).forEach(
      key => (experimentSubset[key] = actualExperiment[key])
    );
    Assert.deepEqual(
      experimentSubset,
      expectedExperiment,
      "start saved the experiment"
    );

    Assert.notEqual(
      DefaultPreferences.get("fake.preference"),
      "newvalue",
      "start did not modify the default preference"
    );
    is(
      Preferences.get("fake.preference"),
      "newvalue",
      "start modified the user preference"
    );
  }
);

// start should detect if a new preference value type matches the previous value type
decorate_task(
  withMockPreferences(),
  withSendEventSpy(),
  async function ({ mockPreferences, sendEventSpy }) {
    mockPreferences.set("fake.type_preference", "oldvalue");

    await Assert.rejects(
      PreferenceExperiments.start({
        slug: "test",
        actionName: "SomeAction",
        branch: "branch",
        preferences: {
          "fake.type_preference": {
            preferenceBranchType: "user",
            preferenceValue: 12345,
            preferenceType: "integer",
          },
        },
      }),
      /previous preference value is of type/i,
      "start threw error for incompatible preference type"
    );

    sendEventSpy.assertEvents([
      ["enrollFailed", "preference_study", "test", { reason: "invalid-type" }],
    ]);
  }
);

// startObserver should throw if an observer for the experiment is already
// active.
decorate_task(withMockExperiments(), async function () {
  PreferenceExperiments.startObserver("test", {
    "fake.preference": {
      preferenceType: "string",
      preferenceValue: "newvalue",
    },
  });
  Assert.throws(
    () =>
      PreferenceExperiments.startObserver("test", {
        "another.fake": {
          preferenceType: "string",
          preferenceValue: "othervalue",
        },
      }),
    /observer.*is already active/i,
    "startObservers threw due to a conflicting active observer"
  );
  PreferenceExperiments.stopAllObservers();
});

// startObserver should register an observer that sends an event when preference
// changes from its experimental value.
decorate_task(
  withMockExperiments(),
  withMockPreferences(),
  withStub(PreferenceExperiments, "recordPrefChange"),
  async function testObserversCanObserveChanges({
    mockPreferences,
    recordPrefChangeStub,
  }) {
    const preferences = {
      "fake.preferencestring": {
        preferenceType: "string",
        previousPreferenceValue: "startvalue",
        preferenceValue: "experimentvalue",
      },
      // "newvalue",
      "fake.preferenceboolean": {
        preferenceType: "boolean",
        previousPreferenceValue: false,
        preferenceValue: true,
      }, // false
      "fake.preferenceinteger": {
        preferenceType: "integer",
        previousPreferenceValue: 1,
        preferenceValue: 2,
      }, // 42
    };
    const newValues = {
      "fake.preferencestring": "newvalue",
      "fake.preferenceboolean": false,
      "fake.preferenceinteger": 42,
    };

    for (const [testPref, newValue] of Object.entries(newValues)) {
      const experimentSlug = "test-" + testPref;
      for (const [prefName, prefInfo] of Object.entries(preferences)) {
        mockPreferences.set(prefName, prefInfo.previousPreferenceValue);
      }

      // NOTE: startObserver does not modify the pref
      PreferenceExperiments.startObserver(experimentSlug, preferences);

      // Setting it to the experimental value should not trigger the call.
      for (const [prefName, prefInfo] of Object.entries(preferences)) {
        mockPreferences.set(prefName, prefInfo.preferenceValue);
        ok(
          !recordPrefChangeStub.called,
          "Changing to the experimental pref value did not trigger the observer"
        );
      }

      // Setting it to something different should trigger the call.
      mockPreferences.set(testPref, newValue);
      Assert.deepEqual(
        recordPrefChangeStub.args,
        [[{ experimentSlug, preferenceName: testPref, reason: "observer" }]],
        "Changing to a different value triggered the observer"
      );

      PreferenceExperiments.stopAllObservers();
      recordPrefChangeStub.resetHistory();
    }
  }
);

// Changes to prefs that have an experimental pref as a prefix should not trigger the observer.
decorate_task(
  withMockExperiments(),
  withMockPreferences(),
  withStub(PreferenceExperiments, "recordPrefChange"),
  async function testObserversCanObserveChanges({
    mockPreferences,
    recordPrefChangeStub,
  }) {
    const preferences = {
      "fake.preference": {
        preferenceType: "string",
        previousPreferenceValue: "startvalue",
        preferenceValue: "experimentvalue",
      },
    };

    const experimentSlug = "test-prefix";
    for (const [prefName, prefInfo] of Object.entries(preferences)) {
      mockPreferences.set(prefName, prefInfo.preferenceValue);
    }
    PreferenceExperiments.startObserver(experimentSlug, preferences);

    // Changing a preference that has the experimental pref as a prefix should
    // not trigger the observer.
    mockPreferences.set("fake.preference.extra", "value");
    // Setting it to the experimental value should not trigger the call.
    ok(
      !recordPrefChangeStub.called,
      "Changing to the experimental pref value did not trigger the observer"
    );

    PreferenceExperiments.stopAllObservers();
  }
);

decorate_task(withMockExperiments(), async function testHasObserver() {
  PreferenceExperiments.startObserver("test", {
    "fake.preference": {
      preferenceType: "string",
      preferenceValue: "experimentValue",
    },
  });

  ok(
    await PreferenceExperiments.hasObserver("test"),
    "hasObserver should detect active observers"
  );
  ok(
    !(await PreferenceExperiments.hasObserver("missing")),
    "hasObserver shouldn't detect inactive observers"
  );

  PreferenceExperiments.stopAllObservers();
});

// stopObserver should throw if there is no observer active for it to stop.
decorate_task(withMockExperiments(), async function () {
  Assert.throws(
    () => PreferenceExperiments.stopObserver("neveractive"),
    /no observer.*found/i,
    "stopObserver threw because there was not matching active observer"
  );
});

// stopObserver should cancel an active observers.
decorate_task(
  withMockExperiments(),
  withMockPreferences(),
  withStub(PreferenceExperiments, "stop", { returnValue: Promise.resolve() }),
  async function ({ mockPreferences, stopStub }) {
    const preferenceInfo = {
      "fake.preferencestring": {
        preferenceType: "string",
        preferenceValue: "experimentvalue",
      },
      "fake.preferenceinteger": {
        preferenceType: "integer",
        preferenceValue: 2,
      },
    };
    mockPreferences.set("fake.preference", "startvalue");

    PreferenceExperiments.startObserver("test", preferenceInfo);
    PreferenceExperiments.stopObserver("test");

    // Setting the preference now that the observer is stopped should not call
    // stop.
    mockPreferences.set("fake.preferencestring", "newvalue");
    ok(
      !stopStub.called,
      "stopObserver successfully removed the observer for string"
    );

    mockPreferences.set("fake.preferenceinteger", 42);
    ok(
      !stopStub.called,
      "stopObserver successfully removed the observer for integer"
    );

    // Now that the observer is stopped, start should be able to start a new one
    // without throwing.
    try {
      PreferenceExperiments.startObserver("test", preferenceInfo);
    } catch (err) {
      ok(
        false,
        "startObserver did not throw an error for an observer that was already stopped"
      );
    }

    PreferenceExperiments.stopAllObservers();
  }
);

// stopAllObservers
decorate_task(
  withMockExperiments(),
  withMockPreferences(),
  withStub(PreferenceExperiments, "stop", { returnValue: Promise.resolve() }),
  async function ({ mockPreferences, stopStub }) {
    mockPreferences.set("fake.preference", "startvalue");
    mockPreferences.set("other.fake.preference", "startvalue");

    PreferenceExperiments.startObserver("test", {
      "fake.preference": {
        preferenceType: "string",
        preferenceValue: "experimentvalue",
      },
    });
    PreferenceExperiments.startObserver("test2", {
      "other.fake.preference": {
        preferenceType: "string",
        preferenceValue: "experimentvalue",
      },
    });
    PreferenceExperiments.stopAllObservers();

    // Setting the preference now that the observers are stopped should not call
    // stop.
    mockPreferences.set("fake.preference", "newvalue");
    mockPreferences.set("other.fake.preference", "newvalue");
    ok(!stopStub.called, "stopAllObservers successfully removed all observers");

    // Now that the observers are stopped, start should be able to start new
    // observers without throwing.
    try {
      PreferenceExperiments.startObserver("test", {
        "fake.preference": {
          preferenceType: "string",
          preferenceValue: "experimentvalue",
        },
      });
      PreferenceExperiments.startObserver("test2", {
        "other.fake.preference": {
          preferenceType: "string",
          preferenceValue: "experimentvalue",
        },
      });
    } catch (err) {
      ok(
        false,
        "startObserver did not throw an error for an observer that was already stopped"
      );
    }

    PreferenceExperiments.stopAllObservers();
  }
);

// markLastSeen should throw if it can't find a matching experiment
decorate_task(withMockExperiments(), async function () {
  await Assert.rejects(
    PreferenceExperiments.markLastSeen("neveractive"),
    /could not find/i,
    "markLastSeen threw because there was not a matching experiment"
  );
});

// markLastSeen should update the lastSeen date
const oldDate = new Date(1988, 10, 1).toJSON();
decorate_task(
  withMockExperiments([
    preferenceStudyFactory({ slug: "test", lastSeen: oldDate }),
  ]),
  async function ({ prefExperiments: [experiment] }) {
    await PreferenceExperiments.markLastSeen("test");
    Assert.notEqual(
      experiment.lastSeen,
      oldDate,
      "markLastSeen updated the experiment lastSeen date"
    );
  }
);

// stop should throw if an experiment with the given name doesn't exist
decorate_task(
  withMockExperiments(),
  withSendEventSpy(),
  async function ({ sendEventSpy }) {
    await Assert.rejects(
      PreferenceExperiments.stop("test"),
      /could not find/i,
      "stop threw an error because there are no experiments with the given name"
    );

    sendEventSpy.assertEvents([
      [
        "unenrollFailed",
        "preference_study",
        "test",
        { reason: "does-not-exist" },
      ],
    ]);
  }
);

// stop should throw if the experiment is already expired
decorate_task(
  withMockExperiments([
    preferenceStudyFactory({ slug: "test", expired: true }),
  ]),
  withSendEventSpy(),
  async function ({ sendEventSpy }) {
    await Assert.rejects(
      PreferenceExperiments.stop("test"),
      /already expired/,
      "stop threw an error because the experiment was already expired"
    );

    sendEventSpy.assertEvents([
      [
        "unenrollFailed",
        "preference_study",
        "test",
        { reason: "already-unenrolled" },
      ],
    ]);
  }
);

// stop should mark the experiment as expired, stop its observer, and revert the
// preference value.
decorate_task(
  withMockExperiments([
    preferenceStudyFactory({
      slug: "test",
      expired: false,
      branch: "fakebranch",
      preferences: {
        "fake.preference": {
          preferenceValue: "experimentvalue",
          preferenceType: "string",
          previousPreferenceValue: "oldvalue",
          preferenceBranchType: "default",
        },
      },
    }),
  ]),
  withMockPreferences(),
  withSpy(PreferenceExperiments, "stopObserver"),
  withSendEventSpy(),
  async function testStop({ mockPreferences, stopObserverSpy, sendEventSpy }) {
    // this assertion is mostly useful for --verify test runs, to make
    // sure that tests clean up correctly.
    ok(!Preferences.get("fake.preference"), "preference should start unset");

    mockPreferences.set(
      `${startupPrefs}.fake.preference`,
      "experimentvalue",
      "user"
    );
    mockPreferences.set("fake.preference", "experimentvalue", "default");
    PreferenceExperiments.startObserver("test", {
      "fake.preference": {
        preferenceType: "string",
        preferenceValue: "experimentvalue",
      },
    });

    await PreferenceExperiments.stop("test", { reason: "test-reason" });
    ok(stopObserverSpy.calledWith("test"), "stop removed an observer");
    const experiment = await PreferenceExperiments.get("test");
    is(experiment.expired, true, "stop marked the experiment as expired");
    is(
      DefaultPreferences.get("fake.preference"),
      "oldvalue",
      "stop reverted the preference to its previous value"
    );
    ok(
      !Services.prefs.prefHasUserValue(`${startupPrefs}.fake.preference`),
      "stop cleared the startup preference for fake.preference."
    );

    sendEventSpy.assertEvents([
      [
        "unenroll",
        "preference_study",
        "test",
        {
          didResetValue: "true",
          reason: "test-reason",
          branch: "fakebranch",
        },
      ],
    ]);

    PreferenceExperiments.stopAllObservers();
  }
);

// stop should also support user pref experiments
decorate_task(
  withMockExperiments([
    preferenceStudyFactory({
      slug: "test",
      expired: false,
      preferences: {
        "fake.preference": {
          preferenceValue: "experimentvalue",
          preferenceType: "string",
          previousPreferenceValue: "oldvalue",
          preferenceBranchType: "user",
        },
      },
    }),
  ]),
  withMockPreferences(),
  withStub(PreferenceExperiments, "stopObserver"),
  withStub(PreferenceExperiments, "hasObserver"),
  async function testStopUserPrefs({
    mockPreferences,
    stopObserverStub,
    hasObserverStub,
  }) {
    hasObserverStub.returns(true);

    mockPreferences.set("fake.preference", "experimentvalue", "user");
    PreferenceExperiments.startObserver("test", {
      "fake.preference": {
        preferenceType: "string",
        preferenceValue: "experimentvalue",
      },
    });

    await PreferenceExperiments.stop("test");
    ok(stopObserverStub.calledWith("test"), "stop removed an observer");
    const experiment = await PreferenceExperiments.get("test");
    is(experiment.expired, true, "stop marked the experiment as expired");
    is(
      Preferences.get("fake.preference"),
      "oldvalue",
      "stop reverted the preference to its previous value"
    );
    stopObserverStub.restore();
    PreferenceExperiments.stopAllObservers();
  }
);

// stop should remove a preference that had no value prior to an experiment for user prefs
decorate_task(
  withMockExperiments([
    preferenceStudyFactory({
      slug: "test",
      expired: false,
      preferences: {
        "fake.preference": {
          preferenceValue: "experimentvalue",
          preferenceType: "string",
          previousPreferenceValue: null,
          preferenceBranchType: "user",
        },
      },
    }),
  ]),
  withMockPreferences(),
  withStub(PreferenceExperiments, "stopObserver"),
  async function ({ mockPreferences }) {
    mockPreferences.set("fake.preference", "experimentvalue", "user");

    await PreferenceExperiments.stop("test");
    ok(
      !Preferences.isSet("fake.preference"),
      "stop removed the preference that had no value prior to the experiment"
    );
  }
);

// stop should not modify a preference if resetValue is false
decorate_task(
  withMockExperiments([
    preferenceStudyFactory({
      slug: "test",
      expired: false,
      branch: "fakebranch",
      preferences: {
        "fake.preference": {
          preferenceValue: "experimentvalue",
          preferenceType: "string",
          previousPreferenceValue: "oldvalue",
          preferenceBranchType: "default",
        },
      },
    }),
  ]),
  withMockPreferences(),
  withStub(PreferenceExperiments, "stopObserver"),
  withSendEventSpy(),
  async function testStopReset({ mockPreferences, sendEventSpy }) {
    mockPreferences.set("fake.preference", "customvalue", "default");

    await PreferenceExperiments.stop("test", {
      reason: "test-reason",
      resetValue: false,
    });
    is(
      DefaultPreferences.get("fake.preference"),
      "customvalue",
      "stop did not modify the preference"
    );
    sendEventSpy.assertEvents([
      [
        "unenroll",
        "preference_study",
        "test",
        {
          didResetValue: "false",
          reason: "test-reason",
          branch: "fakebranch",
        },
      ],
    ]);
  }
);

// stop should include the system that stopped it
decorate_task(
  withMockExperiments([preferenceStudyFactory({ expired: true })]),
  withSendEventSpy,
  async function testStopUserPrefs([experiment], sendEventSpy) {
    await Assert.rejects(
      PreferenceExperiments.stop(experiment.slug, {
        caller: "testCaller",
        reason: "original-reason",
      }),
      /.*already expired.*/,
      "Stopped an expired experiment should throw an exception"
    );

    const expectedExtra = {
      reason: "already-unenrolled",
      originalReason: "original-reason",
    };
    if (AppConstants.NIGHTLY_BUILD) {
      expectedExtra.caller = "testCaller";
    }

    sendEventSpy.assertEvents([
      ["unenrollFailed", "preference_study", experiment.slug, expectedExtra],
    ]);
  }
);

// get should throw if no experiment exists with the given name
decorate_task(withMockExperiments(), async function () {
  await Assert.rejects(
    PreferenceExperiments.get("neverexisted"),
    /could not find/i,
    "get rejects if no experiment with the given name is found"
  );
});

// get
decorate_task(
  withMockExperiments([preferenceStudyFactory({ slug: "test" })]),
  async function () {
    const experiment = await PreferenceExperiments.get("test");
    is(experiment.slug, "test", "get fetches the correct experiment");

    // Modifying the fetched experiment must not edit the data source.
    experiment.slug = "othername";
    const refetched = await PreferenceExperiments.get("test");
    is(refetched.slug, "test", "get returns a copy of the experiment");
  }
);

// get all
decorate_task(
  withMockExperiments([
    preferenceStudyFactory({ slug: "experiment1", disabled: false }),
    preferenceStudyFactory({ slug: "experiment2", disabled: true }),
  ]),
  async function testGetAll({ prefExperiments: [experiment1, experiment2] }) {
    const fetchedExperiments = await PreferenceExperiments.getAll();
    is(
      fetchedExperiments.length,
      2,
      "getAll returns a list of all stored experiments"
    );
    Assert.deepEqual(
      fetchedExperiments.find(e => e.slug === "experiment1"),
      experiment1,
      "getAll returns a list with the correct experiments"
    );
    const fetchedExperiment2 = fetchedExperiments.find(
      e => e.slug === "experiment2"
    );
    Assert.deepEqual(
      fetchedExperiment2,
      experiment2,
      "getAll returns a list with the correct experiments, including disabled ones"
    );

    fetchedExperiment2.slug = "otherslug";
    is(
      experiment2.slug,
      "experiment2",
      "getAll returns copies of the experiments"
    );
  }
);

// get all active
decorate_task(
  withMockExperiments([
    preferenceStudyFactory({
      slug: "active",
      expired: false,
    }),
    preferenceStudyFactory({
      slug: "inactive",
      expired: true,
    }),
  ]),
  withMockPreferences(),
  async function testGetAllActive({ prefExperiments: [activeExperiment] }) {
    let allActiveExperiments = await PreferenceExperiments.getAllActive();
    Assert.deepEqual(
      allActiveExperiments,
      [activeExperiment],
      "getAllActive only returns active experiments"
    );

    allActiveExperiments[0].slug = "newfakename";
    allActiveExperiments = await PreferenceExperiments.getAllActive();
    Assert.notEqual(
      allActiveExperiments,
      "newfakename",
      "getAllActive returns copies of stored experiments"
    );
  }
);

// has
decorate_task(
  withMockExperiments([preferenceStudyFactory({ slug: "test" })]),
  async function () {
    ok(
      await PreferenceExperiments.has("test"),
      "has returned true for a stored experiment"
    );
    ok(
      !(await PreferenceExperiments.has("missing")),
      "has returned false for a missing experiment"
    );
  }
);

// init should register telemetry experiments
decorate_task(
  withMockExperiments([
    preferenceStudyFactory({
      slug: "test",
      branch: "branch",
      preferences: {
        "fake.pref": {
          preferenceValue: "experiment value",
          preferenceBranchType: "default",
          preferenceType: "string",
        },
      },
    }),
  ]),
  withMockPreferences(),
  withStub(TelemetryEnvironment, "setExperimentActive"),
  withStub(PreferenceExperiments, "startObserver"),
  async function testInit({ mockPreferences, setExperimentActiveStub }) {
    mockPreferences.set("fake.pref", "experiment value");
    await PreferenceExperiments.init();
    ok(
      setExperimentActiveStub.calledWith("test", "branch", {
        type: "normandy-exp",
      }),
      "Experiment is registered by init"
    );
  }
);

// init should use the provided experiment type
decorate_task(
  withMockExperiments([
    preferenceStudyFactory({
      slug: "test",
      branch: "branch",
      preferences: {
        "fake.pref": {
          preferenceValue: "experiment value",
          preferenceType: "string",
        },
      },
      experimentType: "pref-test",
    }),
  ]),
  withMockPreferences(),
  withStub(TelemetryEnvironment, "setExperimentActive"),
  withStub(PreferenceExperiments, "startObserver"),
  async function testInit({ mockPreferences, setExperimentActiveStub }) {
    mockPreferences.set("fake.pref", "experiment value");
    await PreferenceExperiments.init();
    ok(
      setExperimentActiveStub.calledWith("test", "branch", {
        type: "normandy-pref-test",
      }),
      "init should use the provided experiment type"
    );
  }
);

// starting and stopping experiments should register in telemetry
decorate_task(
  withMockExperiments(),
  withStub(TelemetryEnvironment, "setExperimentActive"),
  withStub(TelemetryEnvironment, "setExperimentInactive"),
  withSendEventSpy(),
  async function testStartAndStopTelemetry({
    setExperimentActiveStub,
    setExperimentInactiveStub,
    sendEventSpy,
  }) {
    await PreferenceExperiments.start({
      slug: "test",
      actionName: "SomeAction",
      branch: "branch",
      preferences: {
        "fake.preference": {
          preferenceValue: "value",
          preferenceType: "string",
          preferenceBranchType: "default",
        },
      },
    });

    Assert.deepEqual(
      setExperimentActiveStub.getCall(0).args,
      ["test", "branch", { type: "normandy-exp" }],
      "Experiment is registered by start()"
    );
    await PreferenceExperiments.stop("test", { reason: "test-reason" });
    Assert.deepEqual(
      setExperimentInactiveStub.args,
      [["test"]],
      "Experiment is unregistered by stop()"
    );

    sendEventSpy.assertEvents([
      [
        "enroll",
        "preference_study",
        "test",
        {
          experimentType: "exp",
          branch: "branch",
        },
      ],
      [
        "unenroll",
        "preference_study",
        "test",
        {
          reason: "test-reason",
          didResetValue: "true",
          branch: "branch",
        },
      ],
    ]);
  }
);

// starting experiments should use the provided experiment type
decorate_task(
  withMockExperiments(),
  withStub(TelemetryEnvironment, "setExperimentActive"),
  withStub(TelemetryEnvironment, "setExperimentInactive"),
  withSendEventSpy(),
  async function testInitTelemetryExperimentType({
    setExperimentActiveStub,
    sendEventSpy,
  }) {
    await PreferenceExperiments.start({
      slug: "test",
      actionName: "SomeAction",
      branch: "branch",
      preferences: {
        "fake.preference": {
          preferenceValue: "value",
          preferenceType: "string",
          preferenceBranchType: "default",
        },
      },
      experimentType: "pref-test",
    });

    Assert.deepEqual(
      setExperimentActiveStub.getCall(0).args,
      ["test", "branch", { type: "normandy-pref-test" }],
      "start() should register the experiment with the provided type"
    );

    sendEventSpy.assertEvents([
      [
        "enroll",
        "preference_study",
        "test",
        {
          experimentType: "pref-test",
          branch: "branch",
        },
      ],
    ]);

    // start sets the passed preference in a way that is hard to mock.
    // Reset the preference so it doesn't interfere with other tests.
    Services.prefs.getDefaultBranch("fake.preference").deleteBranch("");
  }
);

// When a default-branch experiment starts, and some preferences already have
// user set values, they should immediately send telemetry events.
decorate_task(
  withMockExperiments(),
  withStub(TelemetryEnvironment, "setExperimentActive"),
  withStub(TelemetryEnvironment, "setExperimentInactive"),
  withSendEventSpy(),
  withMockPreferences(),
  async function testOverriddenAtEnroll({ sendEventSpy, mockPreferences }) {
    // consts for preference names to avoid typos
    const prefNames = {
      defaultNoOverride: "fake.preference.default-no-override",
      defaultWithOverride: "fake.preference.default-with-override",
      userNoOverride: "fake.preference.user-no-override",
      userWithOverride: "fake.preference.user-with-override",
    };

    // Set up preferences for the test. Two preferences with only default
    // values, and two preferences with both default and user values.
    mockPreferences.set(
      prefNames.defaultNoOverride,
      "default value",
      "default"
    );
    mockPreferences.set(
      prefNames.defaultWithOverride,
      "default value",
      "default"
    );
    mockPreferences.set(prefNames.defaultWithOverride, "user value", "user");
    mockPreferences.set(prefNames.userNoOverride, "default value", "default");
    mockPreferences.set(prefNames.userWithOverride, "default value", "default");
    mockPreferences.set(prefNames.userWithOverride, "user value", "user");

    // Start the experiment with two each of default-branch and user-branch
    // methods, one each of which will already be overridden.
    const { slug } = await PreferenceExperiments.start({
      slug: "test-experiment",
      actionName: "someAction",
      branch: "experimental-branch",
      preferences: {
        [prefNames.defaultNoOverride]: {
          preferenceValue: "experimental value",
          preferenceType: "string",
          preferenceBranchType: "default",
        },
        [prefNames.defaultWithOverride]: {
          preferenceValue: "experimental value",
          preferenceType: "string",
          preferenceBranchType: "default",
        },
        [prefNames.userNoOverride]: {
          preferenceValue: "experimental value",
          preferenceType: "string",
          preferenceBranchType: "user",
        },
        [prefNames.userWithOverride]: {
          preferenceValue: "experimental value",
          preferenceType: "string",
          preferenceBranchType: "user",
        },
      },
      experimentType: "pref-test",
    });

    sendEventSpy.assertEvents([
      [
        "enroll",
        "preference_study",
        slug,
        {
          experimentType: "pref-test",
          branch: "experimental-branch",
        },
      ],
      [
        "expPrefChanged",
        "preference_study",
        slug,
        {
          preferenceName: prefNames.defaultWithOverride,
          reason: "onEnroll",
        },
      ],
    ]);
  }
);

// Experiments shouldn't be recorded by init() in telemetry if they are expired
decorate_task(
  withMockExperiments([
    preferenceStudyFactory({
      slug: "expired",
      branch: "branch",
      expired: true,
    }),
  ]),
  withStub(TelemetryEnvironment, "setExperimentActive"),
  async function testInitTelemetryExpired({ setExperimentActiveStub }) {
    await PreferenceExperiments.init();
    ok(
      !setExperimentActiveStub.called,
      "Expired experiment is not registered by init"
    );
  }
);

// Experiments should record if the preference has been changed when init() is
// called and no previous override had been observed.
decorate_task(
  withMockExperiments([
    preferenceStudyFactory({
      slug: "test",
      preferences: {
        "fake.preference.1": {
          preferenceValue: "experiment value 1",
          preferenceType: "string",
          overridden: false,
        },
        "fake.preference.2": {
          preferenceValue: "experiment value 2",
          preferenceType: "string",
          overridden: true,
        },
      },
    }),
  ]),
  withMockPreferences(),
  withStub(PreferenceExperiments, "recordPrefChange"),
  async function testInitChanges({
    mockPreferences,
    recordPrefChangeStub,
    prefExperiments: [experiment],
  }) {
    mockPreferences.set("fake.preference.1", "experiment value 1", "default");
    mockPreferences.set("fake.preference.1", "changed value 1", "user");
    mockPreferences.set("fake.preference.2", "experiment value 2", "default");
    mockPreferences.set("fake.preference.2", "changed value 2", "user");
    await PreferenceExperiments.init();

    is(
      Preferences.get("fake.preference.1"),
      "changed value 1",
      "Preference value was not changed"
    );
    is(
      Preferences.get("fake.preference.2"),
      "changed value 2",
      "Preference value was not changed"
    );

    Assert.deepEqual(
      recordPrefChangeStub.args,
      [
        [
          {
            experiment,
            preferenceName: "fake.preference.1",
            reason: "sideload",
          },
        ],
      ],
      "Only one experiment preference change should be recorded"
    );
  }
);

// init should register an observer for experiments
decorate_task(
  withMockExperiments([
    preferenceStudyFactory({
      slug: "test",
      preferences: {
        "fake.preference": {
          preferenceValue: "experiment value",
          preferenceType: "string",
          previousPreferenceValue: "oldfakevalue",
        },
      },
    }),
  ]),
  withMockPreferences(),
  withStub(PreferenceExperiments, "startObserver"),
  withStub(PreferenceExperiments, "stop"),
  withStub(CleanupManager, "addCleanupHandler"),
  async function testInitRegistersObserver({
    mockPreferences,
    startObserverStub,
    stopStub,
  }) {
    stopStub.throws("Stop should not be called");
    mockPreferences.set("fake.preference", "experiment value", "default");
    is(
      Preferences.get("fake.preference"),
      "experiment value",
      "pref shouldn't have a user value"
    );
    await PreferenceExperiments.init();

    ok(startObserverStub.calledOnce, "init should register an observer");
    Assert.deepEqual(
      startObserverStub.getCall(0).args,
      [
        "test",
        {
          "fake.preference": {
            preferenceType: "string",
            preferenceValue: "experiment value",
            previousPreferenceValue: "oldfakevalue",
            preferenceBranchType: "default",
            overridden: false,
          },
        },
      ],
      "init should register an observer with the right args"
    );
  }
);

// saveStartupPrefs
decorate_task(
  withMockExperiments([
    preferenceStudyFactory({
      slug: "char",
      preferences: {
        "fake.char": {
          preferenceValue: "string",
          preferenceType: "string",
        },
      },
    }),
    preferenceStudyFactory({
      slug: "int",
      preferences: {
        "fake.int": {
          preferenceValue: 2,
          preferenceType: "int",
        },
      },
    }),
    preferenceStudyFactory({
      slug: "bool",
      preferences: {
        "fake.bool": {
          preferenceValue: true,
          preferenceType: "boolean",
        },
      },
    }),
  ]),
  async function testSaveStartupPrefs() {
    Services.prefs.deleteBranch(startupPrefs);
    Services.prefs.setBoolPref(`${startupPrefs}.fake.old`, true);
    await PreferenceExperiments.saveStartupPrefs();

    ok(
      Services.prefs.getBoolPref(`${startupPrefs}.fake.bool`),
      "The startup value for fake.bool was saved."
    );
    is(
      Services.prefs.getCharPref(`${startupPrefs}.fake.char`),
      "string",
      "The startup value for fake.char was saved."
    );
    is(
      Services.prefs.getIntPref(`${startupPrefs}.fake.int`),
      2,
      "The startup value for fake.int was saved."
    );
    ok(
      !Services.prefs.prefHasUserValue(`${startupPrefs}.fake.old`),
      "saveStartupPrefs deleted old startup pref values."
    );
  }
);

// saveStartupPrefs errors for invalid pref type
decorate_task(
  withMockExperiments([
    preferenceStudyFactory({
      slug: "test",
      preferences: {
        "fake.invalidValue": {
          preferenceValue: new Date(),
        },
      },
    }),
  ]),
  async function testSaveStartupPrefsError() {
    await Assert.rejects(
      PreferenceExperiments.saveStartupPrefs(),
      /invalid preference type/i,
      "saveStartupPrefs throws if an experiment has an invalid preference value type"
    );
  }
);

// saveStartupPrefs should not store values for user-branch recipes
decorate_task(
  withMockExperiments([
    preferenceStudyFactory({
      slug: "defaultBranchRecipe",
      preferences: {
        "fake.default": {
          preferenceValue: "experiment value",
          preferenceType: "string",
          preferenceBranchType: "default",
        },
      },
    }),
    preferenceStudyFactory({
      slug: "userBranchRecipe",
      preferences: {
        "fake.user": {
          preferenceValue: "experiment value",
          preferenceType: "string",
          preferenceBranchType: "user",
        },
      },
    }),
  ]),
  async function testSaveStartupPrefsUserBranch() {
    Assert.deepEqual(
      Services.prefs.getChildList(startupPrefs),
      [],
      "As a prerequisite no startup prefs are set"
    );

    await PreferenceExperiments.saveStartupPrefs();

    Assert.deepEqual(
      Services.prefs.getChildList(startupPrefs),
      [`${startupPrefs}.fake.default`],
      "only the expected prefs are set"
    );
    is(
      Services.prefs.getCharPref(
        `${startupPrefs}.fake.default`,
        "fallback value"
      ),
      "experiment value",
      "The startup value for fake.default was set"
    );
    is(
      Services.prefs.getPrefType(`${startupPrefs}.fake.user`),
      Services.prefs.PREF_INVALID,
      "The startup value for fake.user was not set"
    );

    Services.prefs.deleteBranch(startupPrefs);
  }
);

// test that default branch prefs restore to the right value if the default pref changes
decorate_task(
  withMockExperiments(),
  withMockPreferences(),
  withStub(PreferenceExperiments, "startObserver"),
  withStub(PreferenceExperiments, "stopObserver"),
  async function testDefaultBranchStop({ mockPreferences }) {
    const prefName = "fake.preference";
    mockPreferences.set(prefName, "old version's value", "default");

    // start an experiment
    await PreferenceExperiments.start({
      slug: "test",
      actionName: "SomeAction",
      branch: "branch",
      preferences: {
        [prefName]: {
          preferenceValue: "experiment value",
          preferenceBranchType: "default",
          preferenceType: "string",
        },
      },
    });

    is(
      Services.prefs.getCharPref(prefName),
      "experiment value",
      "Starting an experiment should change the pref"
    );

    // Now pretend that firefox has updated and restarted to a version
    // where the built-default value of fake.preference is something
    // else. Bootstrap has run and changed the pref to the
    // experimental value, and produced the call to
    // recordOriginalValues below.
    PreferenceExperiments.recordOriginalValues({
      [prefName]: "new version's value",
    });
    is(
      Services.prefs.getCharPref(prefName),
      "experiment value",
      "Recording original values shouldn't affect the preference."
    );

    // Now stop the experiment. It should revert to the new version's default, not the old.
    await PreferenceExperiments.stop("test");
    is(
      Services.prefs.getCharPref(prefName),
      "new version's value",
      "Preference should revert to new default"
    );
  }
);

// test that default branch prefs restore to the right value if the preference is removed
decorate_task(
  withMockExperiments(),
  withMockPreferences(),
  withStub(PreferenceExperiments, "startObserver"),
  withStub(PreferenceExperiments, "stopObserver"),
  async function testDefaultBranchStop({ mockPreferences }) {
    const prefName = "fake.preference";
    mockPreferences.set(prefName, "old version's value", "default");

    // start an experiment
    await PreferenceExperiments.start({
      slug: "test",
      actionName: "SomeAction",
      branch: "branch",
      preferences: {
        [prefName]: {
          preferenceValue: "experiment value",
          preferenceBranchType: "default",
          preferenceType: "string",
        },
      },
    });

    is(
      Services.prefs.getCharPref(prefName),
      "experiment value",
      "Starting an experiment should change the pref"
    );

    // Now pretend that firefox has updated and restarted to a version
    // where fake.preference has been removed in the default pref set.
    // Bootstrap has run and changed the pref to the experimental
    // value, and produced the call to recordOriginalValues below.
    PreferenceExperiments.recordOriginalValues({ [prefName]: null });
    is(
      Services.prefs.getCharPref(prefName),
      "experiment value",
      "Recording original values shouldn't affect the preference."
    );

    // Now stop the experiment. It should remove the preference
    await PreferenceExperiments.stop("test");
    is(
      Services.prefs.getCharPref(prefName, "DEFAULT"),
      "DEFAULT",
      "Preference should be absent"
    );
  }
).skip(/* bug 1502410 and bug 1505941 */);

// stop should pass "unknown" to telemetry event for `reason` if none is specified
decorate_task(
  withMockExperiments([
    preferenceStudyFactory({
      slug: "test",
      preferences: {
        "fake.preference": {
          preferenceValue: "experiment value",
          preferenceType: "string",
        },
      },
    }),
  ]),
  withMockPreferences(),
  withStub(PreferenceExperiments, "stopObserver"),
  withSendEventSpy(),
  async function testStopUnknownReason({ mockPreferences, sendEventSpy }) {
    mockPreferences.set("fake.preference", "default value", "default");
    await PreferenceExperiments.stop("test");
    is(
      sendEventSpy.getCall(0).args[3].reason,
      "unknown",
      "PreferenceExperiments.stop() should use unknown as the default reason"
    );
  }
);

// stop should pass along the value for resetValue to Telemetry Events as didResetValue
decorate_task(
  withMockExperiments([
    preferenceStudyFactory({
      slug: "test1",
      preferences: {
        "fake.preference1": {
          preferenceValue: "experiment value",
          preferenceType: "string",
          previousValue: "previous",
        },
      },
    }),
    preferenceStudyFactory({
      slug: "test2",
      preferences: {
        "fake.preference2": {
          preferenceValue: "experiment value",
          preferenceType: "string",
          previousValue: "previous",
        },
      },
    }),
  ]),
  withMockPreferences(),
  withStub(PreferenceExperiments, "stopObserver"),
  withSendEventSpy(),
  async function testStopResetValue({ mockPreferences, sendEventSpy }) {
    mockPreferences.set("fake.preference1", "default value", "default");
    await PreferenceExperiments.stop("test1", { resetValue: true });
    is(sendEventSpy.callCount, 1);
    is(
      sendEventSpy.getCall(0).args[3].didResetValue,
      "true",
      "PreferenceExperiments.stop() should pass true values of resetValue as didResetValue"
    );

    mockPreferences.set("fake.preference2", "default value", "default");
    await PreferenceExperiments.stop("test2", { resetValue: false });
    is(sendEventSpy.callCount, 2);
    is(
      sendEventSpy.getCall(1).args[3].didResetValue,
      "false",
      "PreferenceExperiments.stop() should pass false values of resetValue as didResetValue"
    );
  }
);

// `recordPrefChange` should send the right telemetry and mark the pref as
// overridden when passed an experiment
decorate_task(
  withMockExperiments([
    preferenceStudyFactory({
      preferences: {
        "test.pref": {},
      },
    }),
  ]),
  withSendEventSpy(),
  async function testRecordPrefChangeWorks({
    sendEventSpy,
    prefExperiments: [experiment],
  }) {
    is(
      experiment.preferences["test.pref"].overridden,
      false,
      "Precondition: the pref should not be overridden yet"
    );

    await PreferenceExperiments.recordPrefChange({
      experiment,
      preferenceName: "test.pref",
      reason: "test-run",
    });

    experiment = await PreferenceExperiments.get(experiment.slug);
    is(
      experiment.preferences["test.pref"].overridden,
      true,
      "The pref should be marked as overridden"
    );
    sendEventSpy.assertEvents([
      [
        "expPrefChanged",
        "preference_study",
        experiment.slug,
        {
          preferenceName: "test.pref",
          reason: "test-run",
        },
      ],
    ]);
  }
);

// `recordPrefChange` should send the right telemetry and mark the pref as
// overridden when passed a slug
decorate_task(
  withMockExperiments([
    preferenceStudyFactory({
      preferences: {
        "test.pref": {},
      },
    }),
  ]),
  withSendEventSpy(),
  async function testRecordPrefChangeWorks({
    sendEventSpy,
    prefExperiments: [experiment],
  }) {
    is(
      experiment.preferences["test.pref"].overridden,
      false,
      "Precondition: the pref should not be overridden yet"
    );

    await PreferenceExperiments.recordPrefChange({
      experimentSlug: experiment.slug,
      preferenceName: "test.pref",
      reason: "test-run",
    });

    experiment = await PreferenceExperiments.get(experiment.slug);
    is(
      experiment.preferences["test.pref"].overridden,
      true,
      "The pref should be marked as overridden"
    );
    sendEventSpy.assertEvents([
      [
        "expPrefChanged",
        "preference_study",
        experiment.slug,
        {
          preferenceName: "test.pref",
          reason: "test-run",
        },
      ],
    ]);
  }
);

// When a default-branch experiment starts, prefs that already have user values
// should not be changed.
decorate_task(
  withMockExperiments(),
  withStub(TelemetryEnvironment, "setExperimentActive"),
  withStub(TelemetryEnvironment, "setExperimentInactive"),
  withSendEventSpy(),
  withMockPreferences(),
  async function testOverriddenAtEnrollNoChange({ mockPreferences }) {
    // Set up a situation where the user has changed the value of the pref away
    // from the default. Then run a default experiment that changes the
    // preference to the same value.
    mockPreferences.set("test.pref", "old value", "default");
    mockPreferences.set("test.pref", "new value", "user");

    await PreferenceExperiments.start({
      slug: "test-experiment",
      actionName: "someAction",
      branch: "experimental-branch",
      preferences: {
        "test.pref": {
          preferenceValue: "new value",
          preferenceType: "string",
          preferenceBranchType: "default",
        },
      },
      experimentType: "pref-test",
    });

    is(
      Services.prefs.getCharPref("test.pref"),
      "new value",
      "User value should be preserved"
    );
    is(
      Services.prefs.getDefaultBranch("").getCharPref("test.pref"),
      "old value",
      "Default value should not have changed"
    );

    const experiment = await PreferenceExperiments.get("test-experiment");
    ok(
      experiment.preferences["test.pref"].overridden,
      "Pref should be marked as overridden"
    );
  }
);

// When a default-branch experiment starts, prefs that already exist and that
// have user values should not be changed.
decorate_task(
  withMockExperiments(),
  withStub(TelemetryEnvironment, "setExperimentActive"),
  withStub(TelemetryEnvironment, "setExperimentInactive"),
  withSendEventSpy(),
  withMockPreferences(),
  async function testOverriddenAtEnrollNoChange({ mockPreferences }) {
    // Set up a situation where the user has changed the value of the pref away
    // from the default. Then run a default experiment that changes the
    // preference to the same value.

    // An arbitrary string preference that won't interact with Normandy.
    let pref = "extensions.recommendations.privacyPolicyUrl";
    let defaultValue = Services.prefs.getCharPref(pref);

    mockPreferences.set(pref, "user-set-value", "user");

    await PreferenceExperiments.start({
      slug: "test-experiment",
      actionName: "someAction",
      branch: "experimental-branch",
      preferences: {
        [pref]: {
          preferenceValue: "experiment-value",
          preferenceType: "string",
          preferenceBranchType: "default",
        },
      },
      experimentType: "pref-test",
    });

    is(
      Services.prefs.getCharPref(pref),
      "user-set-value",
      "User value should be preserved"
    );
    is(
      Services.prefs.getDefaultBranch("").getCharPref(pref),
      defaultValue,
      "Default value should not have changed"
    );

    const experiment = await PreferenceExperiments.get("test-experiment");
    ok(
      experiment.preferences[pref].overridden,
      "Pref should be marked as overridden"
    );
  }
  // Bug 1735344:
  // eslint-disable-next-line mozilla/reject-addtask-only
).only();
