/* Any copyright is dedicated to the Public Domain.
 * http://creativecommons.org/publicdomain/zero/1.0/
 */

const { ExperimentAPI } = ChromeUtils.importESModule(
  "resource://nimbus/ExperimentAPI.sys.mjs"
);
const { NewTabUtils } = ChromeUtils.importESModule(
  "resource://gre/modules/NewTabUtils.sys.mjs"
);

const {
  ATTRIBUTE_TRANSFORMS,
  PREFS,
  recordTargetingContext,
  normalizeAttributeName,
  normalizePrefName,
} = ChromeUtils.importESModule(
  "resource://nimbus/lib/TargetingContextRecorder.sys.mjs"
);

const TARGETING_CONTEXT_METRICS = Object.keys(ATTRIBUTE_TRANSFORMS).map(
  normalizeAttributeName
);

/**
 * Setup a test environment with a fake ExperimentManager and mock out
 * ExperimentAPI to use that manager.
 */
async function setupNimbusForTest() {
  const sandbox = sinon.createSandbox();
  const manager = ExperimentFakes.manager();

  sandbox.stub(ExperimentAPI, "_manager").get(() => manager);
  sandbox.stub(ExperimentAPI, "_store").get(() => manager.store);

  await manager.onStartup();
  await manager.store.ready();

  const localeService = Services.locale;
  const mockLocaleService = new Proxy(localeService, {
    get(obj, prop) {
      if (prop === "appLocaleAsBCP47") {
        return "en-US";
      }

      return obj[prop];
    },
  });

  Services.locale = mockLocaleService;

  const cleanup = async () => {
    Services.locale = localeService;
    sandbox.restore();
    await assertEmptyStore(manager.store, { cleanup: true });

    Services.fog.testResetFOG();
  };

  return { cleanup, manager, sandbox };
}

/**
 * Return an object containing each nimbus_targeting_context metric mapped to
 * its recorded value.
 *
 * @returns {object} A mapping of metric names to their values.
 */
function getRecordedTargetingContextMetrics() {
  const values = {};
  for (const metric of TARGETING_CONTEXT_METRICS) {
    const value = Glean.nimbusTargetingContext[metric].testGetValue();

    if (value !== null) {
      values[metric] = value;
    }
  }
  return values;
}

/**
 * Assert there were the expected errors when recording the Nimbus targeting
 * context.
 *
 * @param {object} expectedErrors
 * @param {string[]} expectedErrors.attrEvalErrors
 *        Attributes that should have failed to record in the
 *        nimbus_targeting_context metrics and should appear in the
 *        nimbus_targeting_environment.attr_eval_errors metric.
 * @param {string[]} expectedErrors.prefTypeErrors
 *        Preferences that should have failed to record in the
 *        nimbus_targeting_environment.pref_values metric and should appear in
 *        the nimbus_targeting_environment.pref_type_errors metric.
 */
function assertRecordingFailures({
  attrEvalErrors = [],
  prefTypeErrors = [],
} = {}) {
  // userSetPrefs should record no errors, so this should not throw.
  Glean.nimbusTargetingEnvironment.userSetPrefs.testGetValue();

  const prefValues = Glean.nimbusTargetingEnvironment.prefValues.testGetValue();
  for (const pref of Object.keys(PREFS)) {
    const errorCount =
      Glean.nimbusTargetingEnvironment.prefTypeErrors[pref].testGetValue() ?? 0;

    if (prefTypeErrors.includes(pref)) {
      Assert.ok(errorCount > 0, `An type error was reported for pref ${pref}`);
      Assert.ok(
        !Object.hasOwn(prefValues, normalizePrefName(pref)),
        `The pref ${pref} should not be recorded`
      );
    } else {
      Assert.equal(errorCount, 0, `An error was not reported for pref ${pref}`);
    }
  }

  const targetingContextMetrics = getRecordedTargetingContextMetrics();
  for (const attr of Object.keys(ATTRIBUTE_TRANSFORMS)) {
    const errorCount =
      Glean.nimbusTargetingEnvironment.attrEvalErrors[attr].testGetValue() ?? 0;

    if (attrEvalErrors.includes(attr)) {
      Assert.ok(errorCount > 0, `An error was reported for attribute ${attr}`);
      Assert.ok(
        !Object.hasOwn(targetingContextMetrics, normalizeAttributeName(attr)),
        `The attribute ${attr} should not have been recorded`
      );
    } else {
      Assert.equal(
        errorCount,
        0,
        `An error was not reported for attribute ${attr}`
      );
    }
  }
}

/**
 * Register a callback for the next submisison of the nimbus-targeting-context
 * ping and then trigger the recording of the Nimbus targeting context.
 *
 * FOG will be reset after this call.
 *
 * @param {() => void} testFn
 *        The callback to register with Glean.
 */
async function recordAndTestPingSubmission(testFn) {
  GleanPings.nimbusTargetingContext.testBeforeNextSubmit(testFn);
  await recordTargetingContext();
  Services.fog.testResetFOG();
}

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

add_task(async function testAttributeTransforms() {
  info(
    "testing all attributes in ATTRIBUTE_TRANSFORMS have callable transforms"
  );
  for (const [attribute, transform] of Object.entries(ATTRIBUTE_TRANSFORMS)) {
    Assert.ok(
      typeof transform === "function",
      `Attribute ${attribute} has a callable transform`
    );
  }
});

add_task(async function testNimbusTargetingContextAllKeysPresent() {
  info(
    "testing nimbus_targeting_context metrics contain all keys in the Nimbus targeting context"
  );

  const { cleanup, manager, sandbox } = await setupNimbusForTest();

  // Glean doesn't serializer empty arrays, so lets put some entries into activeExperiments and
  // activeRollouts so that they appear in the context.
  manager.store.set("experiment", ExperimentFakes.experiment("experiment"));
  manager.store.set("rollout", ExperimentFakes.rollout("rollout"));

  // Stub this for userMonthlyActivity
  sandbox
    .stub(NewTabUtils.activityStreamProvider, "getUserMonthlyActivity")
    .returns(Promise.resolve([[1, "1960-01-01"]]));

  await recordAndTestPingSubmission(() => {
    assertRecordingFailures();

    const values = getRecordedTargetingContextMetrics();

    Assert.ok(
      Object.keys(values).length !== 0,
      "nimbusTargetingContext metrics were recorded"
    );

    for (const metric of TARGETING_CONTEXT_METRICS) {
      Assert.ok(
        Object.hasOwn(values, metric),
        `nimbusTargetingContext.${metric} was recorded`
      );
    }
  });

  manager.store._deleteForTests("experiment");
  manager.store._deleteForTests("rollout");

  await cleanup();
});

add_task(async function testNimbusTargetingEnvironmentUserSetPrefs() {
  info("testing nimbus.targetingContext.pref_is_user_set");

  const { cleanup } = await setupNimbusForTest();

  await recordAndTestPingSubmission(() => {
    assertRecordingFailures();

    const prefs = Glean.nimbusTargetingEnvironment.userSetPrefs.testGetValue();
    Assert.ok(
      !prefs.includes("nimbus.testing.testInt"),
      "nimbus.testing.testInt is not set and not in telemetry"
    );
    Assert.ok(
      !prefs.includes("nimbus.testing.testSetString"),
      "nimbus.testing.testInt is not set and not in telemetry"
    );
  });

  // This pref is a fallbackPref, so should not appear in the list.
  Services.prefs.setIntPref("nimbus.testing.testInt", 123);

  // These two prefs are setPref, and so should appear in the list.
  Services.prefs.setStringPref("nimbus.testing.testSetString", "test");

  await recordAndTestPingSubmission(() => {
    assertRecordingFailures();

    const prefs = Glean.nimbusTargetingEnvironment.userSetPrefs.testGetValue();

    Assert.ok(
      !prefs.includes("nimbus.testing.testInt"),
      "nimbus.testing.testInt is set and not in telemetry"
    );
    Assert.ok(
      prefs.includes("nimbus.testing.testSetString"),
      "nimbus.testing.testSetString is set and in telemetry"
    );
  });

  Services.prefs.deleteBranch("nimbus.testing.testInt");
  Services.prefs.deleteBranch("nimbus.testing.testSetString");

  await cleanup();
});

add_task(async function testNimbusTargetingEnvironmentPrefValues() {
  info("testing nimbus.targetingContext.pref_values collects pref values");

  const { cleanup } = await setupNimbusForTest();
  const PREF = "messaging-system-action.testday";
  const PREF_KEY = "messaging_system_action__testday";

  await recordAndTestPingSubmission(() => {
    assertRecordingFailures();

    const prefs = Glean.nimbusTargetingEnvironment.prefValues.testGetValue();
    Assert.ok(
      !Object.hasOwn(prefs, PREF_KEY),
      `${PREF} not set and not present in telemetry`
    );
  });

  Services.prefs.getDefaultBranch(null).setStringPref(PREF, "default");

  await recordAndTestPingSubmission(() => {
    assertRecordingFailures();

    const prefs = Glean.nimbusTargetingEnvironment.prefValues.testGetValue();
    Assert.equal(
      prefs[PREF_KEY],
      "default",
      `${PREF} set on the default branch and present in telemetry`
    );
  });

  Services.prefs.setStringPref(PREF, "user");

  await recordAndTestPingSubmission(() => {
    assertRecordingFailures();

    const prefs = Glean.nimbusTargetingEnvironment.prefValues.testGetValue();
    Assert.equal(
      prefs[PREF_KEY],
      "user",
      `${PREF} set on the user branch and present in telemetry`
    );
  });

  Services.prefs.deleteBranch(PREF);

  await cleanup();
});

add_task(async function testExperimentMetrics() {
  info(
    "testing values.activeExperiments, values.activeEnrollments, and values.enrollmentsMap"
  );

  const { cleanup, manager } = await setupNimbusForTest();

  await recordAndTestPingSubmission(() => {
    assertRecordingFailures();

    const values = getRecordedTargetingContextMetrics();

    Assert.deepEqual(values.activeExperiments, []);
    Assert.deepEqual(values.activeRollouts, []);
    Assert.deepEqual(values.enrollmentsMap, []);
  });

  manager.store.set(
    "experiment-1",
    ExperimentFakes.experiment("experiment-1", {
      branch: ExperimentFakes.recipe.branches[0],
    })
  );
  manager.store.set(
    "experiment-2",
    ExperimentFakes.experiment("experiment-2", {
      branch: ExperimentFakes.recipe.branches[1],
    })
  );
  manager.store.set(
    "rollout-1",
    ExperimentFakes.rollout("rollout-1", {
      branch: { ...ExperimentFakes.recipe.branches[0], slug: "rollout" },
    })
  );

  await recordAndTestPingSubmission(() => {
    assertRecordingFailures();

    const values = getRecordedTargetingContextMetrics();

    Assert.deepEqual(values.activeExperiments.sort(), [
      "experiment-1",
      "experiment-2",
    ]);
    Assert.deepEqual(values.activeRollouts, ["rollout-1"]);
    Assert.deepEqual(
      values.enrollmentsMap.sort(),
      [
        { experimentSlug: "experiment-1", branchSlug: "control" },
        { experimentSlug: "experiment-2", branchSlug: "treatment" },
        { experimentSlug: "rollout-1", branchSlug: "rollout" },
      ].sort()
    );
  });

  manager.store.updateExperiment("experiment-1", { active: false });
  manager.store.updateExperiment("experiment-2", { active: false });
  manager.store.updateExperiment("rollout-1", { active: false });

  await recordAndTestPingSubmission(() => {
    assertRecordingFailures();

    const values = getRecordedTargetingContextMetrics();

    Assert.deepEqual(values.activeExperiments, []);
    Assert.deepEqual(values.activeRollouts, []);
    Assert.deepEqual(
      values.enrollmentsMap.sort(),
      [
        { experimentSlug: "experiment-1", branchSlug: "control" },
        { experimentSlug: "experiment-2", branchSlug: "treatment" },
        { experimentSlug: "rollout-1", branchSlug: "rollout" },
      ].sort()
    );
  });

  manager.store._deleteForTests("experiment-1");
  manager.store._deleteForTests("experiment-2");
  manager.store._deleteForTests("rollout-1");

  await cleanup();
});

add_task(async function testErrorMetrics() {
  info(
    "testing nimbus_targeting_environment.{attr_eval_errors,pref_type_errors} telemetry"
  );

  const { cleanup, manager, sandbox } = await setupNimbusForTest();
  const PREF = "messaging-system-action.testday";
  const PREF_KEY = "messaging_system_action__testday";

  Assert.ok(
    !Services.prefs.prefHasUserValue(PREF),
    `${PREF} not set on user branch`
  );
  Assert.ok(
    !Services.prefs.prefHasDefaultValue(PREF),
    `${PREF} not set on default branch`
  );

  await recordAndTestPingSubmission(() => {
    assertRecordingFailures();

    const prefs = Glean.nimbusTargetingEnvironment.prefValues.testGetValue();
    Assert.ok(
      !Object.hasOwn(prefs, PREF_KEY),
      `${PREF_KEY} not set and not present in telemetry`
    );
  });

  info(
    "testing prefs with the wrong type are recorded in the pref_type_errors metric"
  );

  Services.prefs.setIntPref(PREF, 123);

  await recordAndTestPingSubmission(() => {
    assertRecordingFailures({
      prefTypeErrors: [PREF],
    });

    const prefs = Glean.nimbusTargetingEnvironment.prefValues.testGetValue();
    Assert.ok(
      !Object.hasOwn(prefs, PREF_KEY),
      "nimbus.qa.pref-1 not set and not present in telemetry"
    );
  });

  Services.prefs.deleteBranch(PREF);

  info(
    "testing values from the context that throw are recorded in the attr_eval_errors metric"
  );

  sandbox.stub(manager, "createTargetingContext").callsFake(function () {
    return {
      isFirstStartup: "invalid",
      activeExperiments: [],
      activeRollouts: [],
      enrollmentsMap: {},
      get currentDate() {
        throw new Error("uh oh");
      },
    };
  });

  await recordAndTestPingSubmission(() => {
    assertRecordingFailures({
      attrEvalErrors: ["currentDate", "isFirstStartup"],
    });
  });

  await cleanup();

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

add_task(async function testRecordingErrors() {
  info("testing failures recording nimbus_targeting_context metrics");

  const { cleanup, manager, sandbox } = await setupNimbusForTest();

  sandbox.stub(manager, "createTargetingContext").callsFake(function () {
    return {
      activeExperiments: [1, 2, 3],
      activeRollouts: [4, 5, 6],
      enrollmentsMap: { foo: 1, bar: 2 },
    };
  });

  function assertMetricErrors() {
    for (const metric of [
      "activeExperiments",
      "activeRollouts",
      "enrollmentsMap",
    ]) {
      try {
        console.log(
          "metric value",
          Glean.nimbusTargetingContext[metric].testGetValue()
        );
      } catch (ex) {}

      Assert.throws(
        () => Glean.nimbusTargetingContext[metric].testGetValue(),
        /Metric had 1 error\(s\) of type invalid_value/,
        `There should be Glean error for metric ${metric}`
      );
    }
  }

  await recordAndTestPingSubmission(() => {
    assertMetricErrors();

    Assert.equal(
      Glean.nimbusTargetingEnvironment.targetingContextValue.testGetValue(),
      null,
      "The targetingContextValue metric is not recorded by default."
    );
  });

  // In the real world this would be done via the nimbusTelemetry feature.
  Services.fog.applyServerKnobsConfig(
    JSON.stringify({
      metrics_enabled: {
        "nimbus_targeting_environment.targeting_context_value": true,
      },
    })
  );

  await recordAndTestPingSubmission(() => {
    assertMetricErrors();

    const stringifiedCtx =
      Glean.nimbusTargetingEnvironment.targetingContextValue.testGetValue();
    Assert.ok(
      typeof stringifiedCtx === "string",
      "The targetingContextValue metric is recorded"
    );

    const context = JSON.parse(stringifiedCtx);
    Assert.ok(
      Object.hasOwn(context, "activeExperiments"),
      "activeExperiments should be recorded in targetingContextValue"
    );
    Assert.deepEqual(
      context.activeExperiments,
      [1, 2, 3],
      "activeExperiments should have the invalid value in the targetingContextValue metric"
    );
    Assert.ok(
      Object.hasOwn(context, "activeRollouts"),
      "activeRollouts should be recorded in targetingContextValue"
    );
    Assert.deepEqual(
      context.activeRollouts,
      [4, 5, 6],
      "activeExperiments should have the invalid value in the targetingContextValue metric"
    );
    Assert.ok(
      Object.hasOwn(context, "enrollmentsMap"),
      "enrollmentsMap should be recorded in targetingContextValue"
    );
    Assert.deepEqual(
      context.enrollmentsMap,
      [
        {
          experimentSlug: "foo",
          branchSlug: 1,
        },
        {
          experimentSlug: "bar",
          branchSlug: 2,
        },
      ],
      "activeExperiments should have the invalid value in the targetingContextValue metric"
    );
  });

  await cleanup();
});
