"use strict";

const { FirstStartup } = ChromeUtils.importESModule(
  "resource://gre/modules/FirstStartup.sys.mjs"
);
const { NimbusTelemetry } = ChromeUtils.importESModule(
  "resource://nimbus/lib/Telemetry.sys.mjs"
);
const { TestUtils } = ChromeUtils.importESModule(
  "resource://testing-common/TestUtils.sys.mjs"
);
const COLLECTION_ID_PREF = "messaging-system.rsexperimentloader.collection_id";

add_setup(function () {
  Services.fog.initializeFOG();
});

/**
 * #getRecipe
 */
add_task(async function test_getRecipe() {
  const { sandbox, cleanup } = await NimbusTestUtils.setupTest();
  const RECIPE = NimbusTestUtils.factories.recipe("foo");
  const collectionName = Services.prefs.getStringPref(COLLECTION_ID_PREF);
  sandbox.stub(ExperimentAPI._remoteSettingsClient, "get").resolves([RECIPE]);

  const recipe = await ExperimentAPI.getRecipe("foo");
  Assert.deepEqual(
    recipe,
    RECIPE,
    "should return an experiment recipe if found"
  );
  Assert.equal(
    ExperimentAPI._remoteSettingsClient.collectionName,
    collectionName,
    "Loaded the expected collection"
  );

  await cleanup();
});

add_task(async function test_getRecipe_Failure() {
  const { sandbox, cleanup } = await NimbusTestUtils.setupTest();
  sandbox.stub(ExperimentAPI._remoteSettingsClient, "get").throws();

  const recipe = await ExperimentAPI.getRecipe("foo");
  Assert.equal(recipe, undefined, "should return undefined if RS throws");

  await cleanup();
});

/**
 * #getAllBranches
 */
add_task(async function test_getAllBranches() {
  const { sandbox, cleanup } = await NimbusTestUtils.setupTest();
  const RECIPE = NimbusTestUtils.factories.recipe("foo");
  sandbox.stub(ExperimentAPI._remoteSettingsClient, "get").resolves([RECIPE]);

  const branches = await ExperimentAPI.getAllBranches("foo");
  Assert.deepEqual(
    branches,
    RECIPE.branches,
    "should return all branches if found a recipe"
  );

  await cleanup();
});

// API used by Messaging System
add_task(async function test_getAllBranches_featureIdAccessor() {
  const { sandbox, cleanup } = await NimbusTestUtils.setupTest();

  const RECIPE = NimbusTestUtils.factories.recipe("foo");
  sandbox.stub(ExperimentAPI._remoteSettingsClient, "get").resolves([RECIPE]);

  const branches = await ExperimentAPI.getAllBranches("foo");
  Assert.deepEqual(
    branches,
    RECIPE.branches,
    "should return all branches if found a recipe"
  );
  branches.forEach(branch => {
    Assert.equal(
      branch.testFeature.featureId,
      "testFeature",
      "Should use the experimentBranchAccessor proxy getter"
    );
  });

  await cleanup();
});

// For schema version before 1.6.2 branch.feature was accessed
// instead of branch.features
add_task(async function test_getAllBranches_backwardsCompat() {
  const { sandbox, cleanup } = await NimbusTestUtils.setupTest();

  const RECIPE = NimbusTestUtils.factories.recipe("foo");
  delete RECIPE.branches[0].features;
  delete RECIPE.branches[1].features;
  let feature = {
    featureId: "backwardsCompat",
    value: {
      enabled: true,
    },
  };
  RECIPE.branches[0].feature = feature;
  RECIPE.branches[1].feature = feature;
  sandbox.stub(ExperimentAPI._remoteSettingsClient, "get").resolves([RECIPE]);

  const branches = await ExperimentAPI.getAllBranches("foo");
  Assert.deepEqual(
    branches,
    RECIPE.branches,
    "should return all branches if found a recipe"
  );
  branches.forEach(branch => {
    Assert.equal(
      branch.backwardsCompat.featureId,
      "backwardsCompat",
      "Should use the experimentBranchAccessor proxy getter"
    );
  });

  await cleanup();
});

add_task(async function test_getAllBranches_Failure() {
  const { sandbox, cleanup } = await NimbusTestUtils.setupTest();

  sandbox.stub(ExperimentAPI._remoteSettingsClient, "get").throws();

  const branches = await ExperimentAPI.getAllBranches("foo");
  Assert.equal(branches, undefined, "should return undefined if RS throws");

  await cleanup();
});

/**
 * Store events
 */
add_task(async function test_enroll_eventEmit_add() {
  const { sandbox, manager, cleanup } = await NimbusTestUtils.setupTest();
  const store = manager.store;

  const featureStub = sandbox.stub();
  const recipe = NimbusTestUtils.factories.recipe.withFeatureConfig("foo", {
    featureId: "no-feature-firefox-desktop",
  });

  await ExperimentAPI.ready();

  store.on("featureUpdate:no-feature-firefox-desktop", featureStub);

  await manager.enroll(recipe, "test");

  Assert.equal(
    featureStub.callCount,
    1,
    "should call 'featureUpdate' callback for featureId when an experiment is added"
  );
  Assert.equal(
    featureStub.firstCall.args[0],
    "featureUpdate:no-feature-firefox-desktop"
  );
  Assert.equal(featureStub.firstCall.args[1], "experiment-updated");

  store.off("featureUpdate:no-feature-firefox-desktop", featureStub);

  manager.unenroll(recipe.slug);
  await cleanup();
});

add_task(async function test_enroll_unenroll_eventEmit_add_and_update() {
  const { sandbox, manager, store, cleanup } =
    await NimbusTestUtils.setupTest();

  const featureStub = sandbox.stub();
  const recipe = NimbusTestUtils.factories.recipe.withFeatureConfig("foo", {
    featureId: "no-feature-firefox-desktop",
  });

  await manager.enroll(recipe, "test");

  store._onFeatureUpdate("no-feature-firefox-desktop", featureStub);

  manager.unenroll(recipe.slug);

  await TestUtils.waitForCondition(
    () => featureStub.callCount == 2,
    "Wait for `on` method to notify callback about the `add` event."
  );
  // Called twice, once when attaching the event listener (because there is an
  // existing experiment with that name) and 2nd time for the update event
  Assert.equal(featureStub.callCount, 2, "Called twice for feature");
  Assert.equal(
    featureStub.firstCall.args[0],
    "featureUpdate:no-feature-firefox-desktop"
  );
  Assert.equal(featureStub.firstCall.args[1], "experiment-updated");

  store._offFeatureUpdate(
    "featureUpdate:no-feature-firefox-desktop",
    featureStub
  );

  await cleanup();
});

add_task(async function test_deactivateEnrollment_eventEmit_off() {
  const { sandbox, manager, store, cleanup } =
    await NimbusTestUtils.setupTest();

  const featureStub = sandbox.stub();
  const recipe = NimbusTestUtils.factories.recipe.withFeatureConfig("foo", {
    featureId: "no-feature-firefox-desktop",
  });

  store.on("featureUpdate:no-feature-firefox-desktop", featureStub);

  await manager.enroll(recipe, "test");

  store.off("featureUpdate:no-feature-firefox-desktop", featureStub);

  manager.unenroll(recipe.slug);

  Assert.equal(featureStub.callCount, 1, "Called only once before `off`");

  await cleanup();
});

add_task(async function testGetEnrollments() {
  const { manager, cleanup } = await NimbusTestUtils.setupTest();

  const fallbackPref = "nimbus.test-only.feature.baz";
  const cleanupFeature = NimbusTestUtils.addTestFeatures(
    new ExperimentFeature("foo", {
      variables: {
        foo: { type: "int" },
        bar: { type: "boolean" },
        baz: {
          type: "string",
          fallbackPref,
        },
      },
    })
  );

  Services.prefs.setStringPref(fallbackPref, "pref-value");

  await manager.enroll(
    NimbusTestUtils.factories.recipe.withFeatureConfig("experiment", {
      branchSlug: "treatment",
      featureId: "foo",
      value: { foo: 1, baz: "qux" },
    }),
    "test"
  );
  await manager.enroll(
    NimbusTestUtils.factories.recipe.withFeatureConfig(
      "rollout",
      { featureId: "foo", value: { foo: 2, bar: false } },
      { isRollout: true }
    ),
    "test"
  );

  const enrollments = NimbusFeatures.foo
    .getAllEnrollments()
    .sort((a, b) => a.meta.slug.localeCompare(b.meta.slug));

  Assert.deepEqual(
    enrollments,
    [
      {
        meta: {
          slug: "experiment",
          branch: "treatment",
          isRollout: false,
        },
        value: {
          foo: 1,
          baz: "qux",
        },
      },
      {
        meta: {
          slug: "rollout",
          branch: "control",
          isRollout: true,
        },
        value: {
          foo: 2,
          bar: false,
          baz: "pref-value",
        },
      },
    ],
    "Should have two enrollments"
  );

  await NimbusTestUtils.cleanupManager(["experiment", "rollout"], { manager });
  Services.prefs.clearUserPref(fallbackPref);
  cleanupFeature();
  await cleanup();
});

add_task(async function testGetEnrollmentsCoenrolling() {
  const { manager, cleanup } = await NimbusTestUtils.setupTest();

  const fallbackPref = "nimbus.test-only.feature.baz";
  const cleanupFeature = NimbusTestUtils.addTestFeatures(
    new ExperimentFeature("foo", {
      allowCoenrollment: true,
      variables: {
        foo: { type: "int" },
        bar: { type: "boolean" },
        baz: {
          type: "string",
          fallbackPref,
        },
      },
    })
  );

  Services.prefs.setStringPref(fallbackPref, "pref-value");

  await manager.enroll(
    NimbusTestUtils.factories.recipe.withFeatureConfig("experiment-1", {
      branchSlug: "treatment-a",
      featureId: "foo",
      value: { foo: 1, baz: "qux" },
    }),
    "test"
  );
  await manager.enroll(
    NimbusTestUtils.factories.recipe.withFeatureConfig("experiment-2", {
      branchSlug: "treatment-b",
      featureId: "foo",
      value: { foo: 2 },
    }),
    "test"
  );
  await manager.enroll(
    NimbusTestUtils.factories.recipe.withFeatureConfig(
      "rollout-1",
      { featureId: "foo", value: { foo: 3, bar: true } },
      { isRollout: true }
    ),
    "test"
  );
  await manager.enroll(
    NimbusTestUtils.factories.recipe.withFeatureConfig(
      "rollout-2",
      { featureId: "foo", value: { bar: false, baz: "quux" } },
      { isRollout: true }
    ),
    "test"
  );

  const enrollments = NimbusFeatures.foo
    .getAllEnrollments()
    .sort((a, b) => a.meta.slug.localeCompare(b.meta.slug));

  Assert.deepEqual(
    enrollments,
    [
      {
        meta: {
          slug: "experiment-1",
          branch: "treatment-a",
          isRollout: false,
        },
        value: {
          foo: 1,
          baz: "qux",
        },
      },
      {
        meta: {
          slug: "experiment-2",
          branch: "treatment-b",
          isRollout: false,
        },
        value: {
          foo: 2,
          baz: "pref-value",
        },
      },
      {
        meta: {
          slug: "rollout-1",
          branch: "control",
          isRollout: true,
        },
        value: {
          foo: 3,
          bar: true,
          baz: "pref-value",
        },
      },
      {
        meta: {
          slug: "rollout-2",
          branch: "control",
          isRollout: true,
        },
        value: {
          bar: false,
          baz: "quux",
        },
      },
    ],
    "Should have four enrollments"
  );

  await NimbusTestUtils.cleanupManager(
    ["experiment-1", "experiment-2", "rollout-1", "rollout-2"],
    { manager }
  );
  Services.prefs.clearUserPref(fallbackPref);
  cleanupFeature();
  await cleanup();
});

add_task(async function testGetEnrollmentMetadata() {
  const { manager, cleanup } = await NimbusTestUtils.setupTest();

  const cleanupFeature = NimbusTestUtils.addTestFeatures(
    new ExperimentFeature("foo", {
      variables: {},
    })
  );

  await manager.enroll(
    NimbusTestUtils.factories.recipe.withFeatureConfig("experiment", {
      branchSlug: "treatment",
      featureId: "foo",
    }),
    "test"
  );
  await manager.enroll(
    NimbusTestUtils.factories.recipe.withFeatureConfig(
      "rollout",
      { featureId: "foo" },
      { isRollout: true }
    ),
    "test"
  );

  const enrollments = NimbusFeatures.foo
    .getAllEnrollmentMetadata()
    .sort((a, b) => a.slug.localeCompare(b.slug));

  Assert.deepEqual(
    enrollments,
    [
      {
        slug: "experiment",
        branch: "treatment",
        isRollout: false,
      },
      {
        slug: "rollout",
        branch: "control",
        isRollout: true,
      },
    ],
    "Should have two enrollments"
  );

  await NimbusTestUtils.cleanupManager(["experiment", "rollout"], { manager });

  cleanupFeature();
  await cleanup();
});

add_task(async function testGetEnrollmentMetadataCoenrolling() {
  const { manager, cleanup } = await NimbusTestUtils.setupTest();

  const cleanupFeature = NimbusTestUtils.addTestFeatures(
    new ExperimentFeature("foo", {
      allowCoenrollment: true,
      variables: {},
    })
  );

  await manager.enroll(
    NimbusTestUtils.factories.recipe.withFeatureConfig("experiment-1", {
      branchSlug: "treatment-a",
      featureId: "foo",
    }),
    "test"
  );
  await manager.enroll(
    NimbusTestUtils.factories.recipe.withFeatureConfig("experiment-2", {
      branchSlug: "treatment-b",
      featureId: "foo",
    }),
    "test"
  );
  await manager.enroll(
    NimbusTestUtils.factories.recipe.withFeatureConfig(
      "rollout-1",
      { featureId: "foo" },
      { isRollout: true }
    ),
    "test"
  );
  await manager.enroll(
    NimbusTestUtils.factories.recipe.withFeatureConfig(
      "rollout-2",
      { featureId: "foo" },
      { isRollout: true }
    ),
    "test"
  );

  const enrollments = NimbusFeatures.foo
    .getAllEnrollmentMetadata()
    .sort((a, b) => a.slug.localeCompare(b.slug));

  Assert.deepEqual(
    enrollments,
    [
      {
        slug: "experiment-1",
        branch: "treatment-a",
        isRollout: false,
      },
      {
        slug: "experiment-2",
        branch: "treatment-b",
        isRollout: false,
      },
      {
        slug: "rollout-1",
        branch: "control",
        isRollout: true,
      },
      {
        slug: "rollout-2",
        branch: "control",
        isRollout: true,
      },
    ],
    "Should have four enrollments"
  );

  await NimbusTestUtils.cleanupManager(
    ["experiment-1", "experiment-2", "rollout-1", "rollout-2"],
    { manager }
  );

  cleanupFeature();
  await cleanup();
});

add_task(async function testCoenrollingTraditionalApis() {
  const { manager, cleanup } = await NimbusTestUtils.setupTest();

  const cleanupFeature = NimbusTestUtils.addTestFeatures(
    new ExperimentFeature("foo", {
      allowCoenrollment: true,
      variables: {},
    })
  );

  await manager.enroll(
    NimbusTestUtils.factories.recipe.withFeatureConfig("experiment-1", {
      branchSlug: "treatment-a",
      featureId: "foo",
    }),
    "test"
  );
  await manager.enroll(
    NimbusTestUtils.factories.recipe.withFeatureConfig("experiment-2", {
      branchSlug: "treatment-b",
      featureId: "foo",
    }),
    "test"
  );
  await manager.enroll(
    NimbusTestUtils.factories.recipe.withFeatureConfig(
      "rollout-1",
      { featureId: "foo" },
      { isRollout: true }
    ),
    "test"
  );
  await manager.enroll(
    NimbusTestUtils.factories.recipe.withFeatureConfig(
      "rollout-2",
      { featureId: "foo" },
      { isRollout: true }
    ),
    "test"
  );

  Assert.throws(
    () => NimbusFeatures.foo.getAllVariables(),
    /Co-enrolling features must use the getAllEnrollments API/
  );

  Assert.throws(
    () => NimbusFeatures.foo.getVariable("bar"),
    /Co-enrolling features must use the getAllEnrollments API/
  );
  Assert.throws(
    () => NimbusFeatures.foo.recordExposureEvent(),
    /Co-enrolling features must provide slug/
  );

  Assert.throws(
    () => NimbusFeatures.foo.getEnrollmentMetadata(),
    /Co-enrolling features must use the getAllEnrollments or getAllEnrollmentMetadata APIs/
  );

  NimbusFeatures.foo.recordExposureEvent({ slug: "experiment-1" });
  NimbusFeatures.foo.recordExposureEvent({ slug: "rollout-2" });

  Assert.deepEqual(
    Glean.nimbusEvents.exposure.testGetValue("events")?.map(ev => ev.extra),
    [
      {
        experiment: "experiment-1",
        branch: "treatment-a",
        feature_id: "foo",
      },
      {
        experiment: "rollout-2",
        branch: "control",
        feature_id: "foo",
      },
    ]
  );

  await NimbusTestUtils.cleanupManager(
    ["experiment-1", "experiment-2", "rollout-1", "rollout-2"],
    { manager }
  );

  cleanupFeature();
  await cleanup();
});

add_task(async function testGetEnrollmentMetadata() {
  const feature = new ExperimentFeature("test-feature", {
    variables: {},
  });
  const featureId = feature.featureId;
  const cleanupFeature = NimbusTestUtils.addTestFeatures(feature);

  const { manager, cleanup } = await NimbusTestUtils.setupTest();

  const experimentMeta = {
    slug: "experiment-slug",
    branch: "treatment",
    isRollout: false,
  };

  const rolloutMeta = {
    slug: "rollout-slug",
    branch: "control",
    isRollout: true,
  };

  // There are no active enrollments.
  Assert.equal(
    NimbusFeatures[featureId].getEnrollmentMetadata("experiment"),
    null
  );
  Assert.equal(
    NimbusFeatures[featureId].getEnrollmentMetadata("rollout"),
    null
  );
  Assert.equal(NimbusFeatures[featureId].getEnrollmentMetadata(), null);

  await manager.enroll(
    NimbusTestUtils.factories.recipe.withFeatureConfig(
      "rollout-slug",
      { featureId },
      { isRollout: true }
    ),
    "test"
  );

  // Thre are no active experiments, but there is an active rollout.
  Assert.equal(
    NimbusFeatures[featureId].getEnrollmentMetadata("experiment"),
    null
  );
  Assert.deepEqual(
    NimbusFeatures[featureId].getEnrollmentMetadata("rollout"),
    rolloutMeta
  );
  Assert.deepEqual(
    NimbusFeatures[featureId].getEnrollmentMetadata(),
    rolloutMeta
  );

  await manager.enroll(
    NimbusTestUtils.factories.recipe.withFeatureConfig("experiment-slug", {
      featureId,
      branchSlug: "treatment",
    }),
    "test"
  );

  // There is an active experiment and rollout, so we should get the experiment metadata by default.
  Assert.deepEqual(
    NimbusFeatures[featureId].getEnrollmentMetadata("experiment"),
    experimentMeta
  );
  Assert.deepEqual(
    NimbusFeatures[featureId].getEnrollmentMetadata("rollout"),
    rolloutMeta
  );
  Assert.deepEqual(
    NimbusFeatures[featureId].getEnrollmentMetadata(),
    experimentMeta
  );

  manager.unenroll("rollout-slug");

  // There is only an active experiment.
  Assert.deepEqual(
    NimbusFeatures[featureId].getEnrollmentMetadata("experiment"),
    experimentMeta
  );
  Assert.equal(
    NimbusFeatures[featureId].getEnrollmentMetadata("rollout"),
    null
  );
  Assert.deepEqual(
    NimbusFeatures[featureId].getEnrollmentMetadata(),
    experimentMeta
  );

  manager.unenroll("experiment-slug");

  // There are no active enrollments.
  Assert.equal(
    NimbusFeatures[featureId].getEnrollmentMetadata("experiment"),
    null
  );
  Assert.equal(
    NimbusFeatures[featureId].getEnrollmentMetadata("rollout"),
    null
  );
  Assert.equal(NimbusFeatures[featureId].getEnrollmentMetadata(), null);

  await cleanup();
  cleanupFeature();
});

add_task(async function testGetEnrollmentMetadataSafe() {
  const { sandbox, manager, cleanup } = await NimbusTestUtils.setupTest();

  sandbox.stub(NimbusTelemetry, "recordExposure");
  sandbox.stub(manager.store, "getExperimentForFeature").throws();
  sandbox.stub(manager.store, "getRolloutForFeature").throws();

  Assert.equal(
    NimbusFeatures.testFeature.getEnrollmentMetadata(),
    null,
    "Should not throw"
  );
  Assert.equal(
    NimbusFeatures.testFeature.getEnrollmentMetadata("experiment"),
    null,
    "Should not throw"
  );
  Assert.equal(
    NimbusFeatures.testFeature.getEnrollmentMetadata("rollout"),
    null,
    "Should not throw"
  );

  Assert.equal(
    manager.store.getExperimentForFeature.callCount,
    2,
    "getExperimentForFeature called"
  );
  Assert.equal(
    manager.store.getRolloutForFeature.callCount,
    1,
    "getRolloutForFeature called"
  );

  NimbusFeatures.testFeature.recordExposureEvent();
  Assert.ok(
    NimbusTelemetry.recordExposure.notCalled,
    "Should not record exposure"
  );
  Assert.equal(
    manager.store.getExperimentForFeature.callCount,
    3,
    "getExperimentForFeature called"
  );

  await cleanup();
});

add_task(async function testGetProfileId() {
  const { cleanup } = await NimbusTestUtils.setupTest();

  Assert.ok(
    Services.prefs.prefHasUserValue("nimbus.profileId"),
    "nimbus.profileId set on user branch"
  );
  Assert.ok(
    !!Services.prefs.getStringPref("nimbus.profileId"),
    "can get profile ID pref"
  );

  Assert.equal(
    ExperimentAPI.profileId,
    Services.prefs.getStringPref("nimbus.profileId"),
    "ExperimentAPI.profileId matches pref value"
  );

  await cleanup();
});

add_task(async function testFirstStartupTimestamps() {
  {
    const { cleanup } = await NimbusTestUtils.setupTest();

    Assert.equal(
      ExperimentAPI.getAndClearFirstStartupTimestamps(),
      null,
      "Timestamps not recorded outside first startup"
    );

    await cleanup();
  }

  {
    FirstStartup._state = FirstStartup.IN_PROGRESS;

    const { cleanup } = await NimbusTestUtils.setupTest();

    const timestamps = ExperimentAPI.getAndClearFirstStartupTimestamps();
    Assert.deepEqual(
      Object.keys(timestamps).sort(),
      [
        "storeInitEnd",
        "managerInitEnd",
        "loaderInitEnd",
        "nimbusInitEnd",
      ].sort(),
      "All timestamps are present"
    );

    Assert.equal(
      ExperimentAPI.getAndClearFirstStartupTimestamps(),
      null,
      "Timestamps are cleared after first retrieval"
    );

    await cleanup();

    FirstStartup.resetForTesting();
  }
});
