"use strict";

const { FirstStartup } = ChromeUtils.importESModule(
  "resource://gre/modules/FirstStartup.sys.mjs"
);
const { EnrollmentsContext, MatchStatus } = ChromeUtils.importESModule(
  "resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs"
);
const { RemoteSettings } = ChromeUtils.importESModule(
  "resource://services-settings/remote-settings.sys.mjs"
);
const { TestUtils } = ChromeUtils.importESModule(
  "resource://testing-common/TestUtils.sys.mjs"
);

const RUN_INTERVAL_PREF = "app.normandy.run_interval_seconds";
const STUDIES_OPT_OUT_PREF = "app.shield.optoutstudies.enabled";
const UPLOAD_PREF = "datareporting.healthreport.uploadEnabled";
const DEBUG_PREF = "nimbus.debug";

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

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

  sandbox.stub(loader, "updateRecipes").resolves();

  Services.prefs.setIntPref(RUN_INTERVAL_PREF, 123456);
  equal(
    loader.intervalInSeconds,
    123456,
    `should set intervalInSeconds to the value of ${RUN_INTERVAL_PREF}`
  );

  Services.prefs.clearUserPref(RUN_INTERVAL_PREF);

  await cleanup();
});

add_task(async function test_init() {
  const { sandbox, loader, initExperimentAPI, cleanup } =
    await NimbusTestUtils.setupTest({ init: false });
  sandbox.spy(loader, "setTimer");
  sandbox.spy(loader, "updateRecipes");

  await initExperimentAPI();

  Assert.ok(loader.setTimer.calledOnce, "should call .setTimer");
  Assert.ok(loader.updateRecipes.calledOnce, "should call .updateRecipes");

  await cleanup();
});

add_task(async function test_init_with_opt_in() {
  const { sandbox, loader, initExperimentAPI, cleanup } =
    await NimbusTestUtils.setupTest({ init: false });
  sandbox.spy(loader, "setTimer");
  sandbox.spy(loader, "updateRecipes");

  Services.prefs.setBoolPref(STUDIES_OPT_OUT_PREF, false);

  await initExperimentAPI();

  equal(
    loader.setTimer.callCount,
    0,
    `should not initialize if ${STUDIES_OPT_OUT_PREF} pref is false`
  );

  Services.prefs.setBoolPref(STUDIES_OPT_OUT_PREF, true);
  Assert.ok(loader.setTimer.calledOnce, "should call .setTimer");
  Assert.ok(loader.updateRecipes.calledOnce, "should call .updateRecipes");

  await cleanup();
});

add_task(async function test_updateRecipes() {
  const passRecipe = NimbusTestUtils.factories.recipe("pass", {
    bucketConfig: {
      ...NimbusTestUtils.factories.recipe.bucketConfig,
      count: 0,
    },
    targeting: "true",
  });
  const failRecipe = NimbusTestUtils.factories.recipe("fail", {
    targeting: "false",
  });

  const { sandbox, loader, manager, initExperimentAPI, cleanup } =
    await NimbusTestUtils.setupTest({
      init: false,
      experiments: [passRecipe, failRecipe],
    });

  sandbox.spy(loader, "updateRecipes");
  sandbox.stub(manager, "onRecipe").resolves();

  await initExperimentAPI();

  Assert.ok(loader.updateRecipes.calledOnce, "should call .updateRecipes");
  Assert.equal(
    loader.manager.onRecipe.callCount,
    2,
    "should call .onRecipe only for all recipes"
  );

  Assert.ok(
    loader.manager.onRecipe.calledWith(passRecipe, "rs-loader", {
      ok: true,
      status: MatchStatus.TARGETING_ONLY,
    }),
    "should call .onRecipe for pass recipe with TARGETING_ONLY"
  );
  Assert.ok(
    loader.manager.onRecipe.calledWith(failRecipe, "rs-loader", {
      ok: true,
      status: MatchStatus.NO_MATCH,
    }),
    "should call .onRecipe for fail recipe with NO_MATCH"
  );

  await cleanup();
});

add_task(async function test_loadingErrorOnEmptyRecipesWithNullLastModified() {
  Services.fog.testResetFOG();
  const { sandbox, loader, cleanup } = await NimbusTestUtils.setupTest({
    clearTelemetry: true,
  });

  Assert.deepEqual(
    Glean.nimbusEvents.remoteSettingsSync
      .testGetValue("events")
      ?.map(ev => ev.extra) ?? [],
    [
      {
        force_sync: "false",
        experiments_success: "true",
        secure_experiments_success: "true",
        experiments_empty: "true",
        secure_experiments_empty: "true",
        trigger: "migration",
      },
      {
        force_sync: "false",
        experiments_success: "true",
        secure_experiments_success: "true",
        experiments_empty: "true",
        secure_experiments_empty: "true",
        trigger: "enabled",
      },
    ],
    "Submitted initial remoteSettingsSync telemetry"
  );
  Services.fog.testResetFOG();

  sandbox
    .stub(loader.remoteSettingsClients.experiments.db, "getLastModified")
    .resolves(null);

  let { loadingError } = await loader.getRecipesFromAllCollections({
    trigger: "test",
  });

  Assert.ok(
    loadingError,
    "should error when loading empty recipes collection with null last modified"
  );

  Assert.deepEqual(
    Glean.nimbusEvents.remoteSettingsSync
      .testGetValue("events")
      ?.map(ev => ev.extra) ?? [],
    [
      {
        force_sync: "false",
        experiments_success: "false",
        secure_experiments_success: "true",
        secure_experiments_empty: "true",
        trigger: "test",
      },
    ],
    "Submitted failure telemetry"
  );

  Services.fog.testResetFOG();

  loader.remoteSettingsClients.experiments.get.resolves([
    NimbusTestUtils.factories.recipe("test"),
  ]);

  ({ loadingError } = await loader.getRecipesFromAllCollections({
    trigger: "test",
  }));

  Assert.strictEqual(
    loadingError,
    false,
    "should not error when loading nonempty recipes collection"
  );

  Assert.deepEqual(
    Glean.nimbusEvents.remoteSettingsSync
      .testGetValue("events")
      ?.map(ev => ev.extra) ?? [],
    [],
    "Didn't submit success telemetry"
  );

  await cleanup();
});

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

  sandbox.stub(FirstStartup, "state").get(() => FirstStartup.IN_PROGRESS);

  const ctx = new EnrollmentsContext(manager);

  Assert.ok(
    await ctx.checkTargeting(
      NimbusTestUtils.factories.recipe("is-first-startup", {
        targeting: "isFirstStartup",
      })
    ),
    "isFirstStartup targeting works when true"
  );

  sandbox.stub(FirstStartup, "state").get(() => FirstStartup.NOT_STARTED);

  Assert.ok(
    await ctx.checkTargeting(
      NimbusTestUtils.factories.recipe("not-first-startup", {
        targeting: "!isFirstStartup",
      })
    ),
    "isFirstStartup targeting works when false"
  );

  await cleanup();
});

add_task(async function test_checkTargeting() {
  const loader = NimbusTestUtils.stubs.rsLoader();

  const ctx = new EnrollmentsContext(loader.manager);
  Assert.equal(
    await ctx.checkTargeting({}),
    true,
    "should return true if .targeting is not defined"
  );
  Assert.equal(
    await ctx.checkTargeting({
      targeting: "'foo'",
      slug: "test_checkTargeting",
    }),
    true,
    "should return true for truthy expression"
  );
  Assert.equal(
    await ctx.checkTargeting({
      targeting: "aPropertyThatDoesNotExist",
      slug: "test_checkTargeting",
    }),
    false,
    "should return false for falsey expression"
  );
});

add_task(async function test_checkExperimentSelfReference() {
  const loader = NimbusTestUtils.stubs.rsLoader();
  const ctx = new EnrollmentsContext(loader.manager);
  const PASS_FILTER_RECIPE = NimbusTestUtils.factories.recipe("foo", {
    targeting:
      "experiment.slug == 'foo' && experiment.branches[0].slug == 'control'",
  });

  const FAIL_FILTER_RECIPE = NimbusTestUtils.factories.recipe("foo", {
    targeting: "experiment.slug == 'bar'",
  });

  Assert.equal(
    await ctx.checkTargeting(PASS_FILTER_RECIPE),
    true,
    "Should return true for matching on slug name and branch"
  );
  Assert.equal(
    await ctx.checkTargeting(FAIL_FILTER_RECIPE),
    false,
    "Should fail targeting"
  );
});

add_task(async function test_optIn_debug_disabled() {
  info("Testing users cannot opt-in when nimbus.debug is false");

  const recipe = NimbusTestUtils.factories.recipe("foo", {
    targeting: "false",
  });
  const { loader, initExperimentAPI, cleanup } =
    await NimbusTestUtils.setupTest({
      init: false,
      experiments: [recipe],
    });

  await initExperimentAPI();

  Services.prefs.setBoolPref(DEBUG_PREF, false);
  Services.prefs.setBoolPref(UPLOAD_PREF, true);
  Services.prefs.setBoolPref(STUDIES_OPT_OUT_PREF, true);

  await Assert.rejects(
    loader._optInToExperiment({
      slug: recipe.slug,
      branchSlug: recipe.branches[0].slug,
    }),
    /Could not opt in/
  );

  Services.prefs.clearUserPref(DEBUG_PREF);
  Services.prefs.clearUserPref(UPLOAD_PREF);
  Services.prefs.clearUserPref(STUDIES_OPT_OUT_PREF);

  await cleanup();
});

add_task(async function test_optIn_studies_disabled() {
  info(
    "Testing users cannot opt-in when telemetry is disabled or studies are disabled."
  );

  const recipe = NimbusTestUtils.factories.recipe("foo", {
    targeting: "false",
  });
  const { loader, initExperimentAPI, cleanup } =
    await NimbusTestUtils.setupTest({ init: false, experiments: [recipe] });

  await initExperimentAPI();

  Services.prefs.setBoolPref(DEBUG_PREF, true);

  for (const pref of [UPLOAD_PREF, STUDIES_OPT_OUT_PREF]) {
    Services.prefs.setBoolPref(UPLOAD_PREF, true);
    Services.prefs.setBoolPref(STUDIES_OPT_OUT_PREF, true);

    Services.prefs.setBoolPref(pref, false);

    await Assert.rejects(
      loader._optInToExperiment({
        slug: recipe.slug,
        branchSlug: recipe.branches[0].slug,
      }),
      /Could not opt in: studies are disabled/
    );
  }

  Services.prefs.clearUserPref(DEBUG_PREF);
  Services.prefs.clearUserPref(UPLOAD_PREF);
  Services.prefs.clearUserPref(STUDIES_OPT_OUT_PREF);

  await cleanup();
});

add_task(async function test_enrollment_changed_notification() {
  const recipe = NimbusTestUtils.factories.recipe("foo");

  const { sandbox, loader, initExperimentAPI, cleanup } =
    await NimbusTestUtils.setupTest({ init: false, experiments: [recipe] });
  sandbox.spy(loader, "updateRecipes");
  sandbox.stub(loader.manager, "onRecipe").resolves();

  const enrollmentChanged = TestUtils.topicObserved(
    "nimbus:enrollments-updated"
  );

  await initExperimentAPI();
  await enrollmentChanged;

  Assert.ok(loader.updateRecipes.called, "should call .updateRecipes");

  await cleanup();
});

add_task(async function test_experiment_optin_targeting() {
  Services.prefs.setBoolPref(DEBUG_PREF, true);

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

  const recipe = NimbusTestUtils.factories.recipe("foo", {
    targeting: "false",
  });

  sandbox.stub(RemoteSettings("nimbus-preview"), "get").resolves([recipe]);

  await Assert.rejects(
    loader._optInToExperiment({
      slug: recipe.slug,
      branch: recipe.branches[0].slug,
      collection: "nimbus-preview",
      applyTargeting: true,
    }),
    /Recipe foo did not match targeting/,
    "optInToExperiment should throw"
  );

  Assert.ok(
    !manager.store.getExperimentForFeature("testFeature"),
    "Should not enroll in experiment"
  );

  await loader._optInToExperiment({
    slug: recipe.slug,
    branch: recipe.branches[0].slug,
    collection: "nimbus-preview",
  });

  Assert.equal(
    manager.store.getExperimentForFeature("testFeature").slug,
    `optin-${recipe.slug}`,
    "Should enroll in experiment"
  );

  manager.unenroll(`optin-${recipe.slug}`);

  Services.prefs.clearUserPref(DEBUG_PREF);

  await cleanup();
});
