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

"use strict";

ChromeUtils.import("resource://gre/modules/TelemetryController.jsm", this);
ChromeUtils.import("resource://gre/modules/TelemetryUtils.jsm", this);
ChromeUtils.import("resource://gre/modules/Preferences.jsm", this);

XPCOMUtils.defineLazyModuleGetters(this, {
  ONLOGIN_NOTIFICATION: "resource://gre/modules/FxAccountsCommon.js",
  ONLOGOUT_NOTIFICATION: "resource://gre/modules/FxAccountsCommon.js",
  ONVERIFIED_NOTIFICATION: "resource://gre/modules/FxAccountsCommon.js",
});
ChromeUtils.defineModuleGetter(
  this,
  "EcosystemTelemetry",
  "resource://gre/modules/EcosystemTelemetry.jsm"
);

const TEST_PING_TYPE = "test-ping-type";

const RE_VALID_GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;

function fakeIdleNotification(topic) {
  let scheduler = ChromeUtils.import(
    "resource://gre/modules/TelemetryScheduler.jsm",
    null
  );
  return scheduler.TelemetryScheduler.observe(null, topic, null);
}

async function promiseNoPing() {
  // We check there's not one of our pings pending by sending a test ping, then
  // immediately fetching a pending ping and checking it's that test one.
  TelemetryController.submitExternalPing(TEST_PING_TYPE, {}, {});
  let ping = await PingServer.promiseNextPing();
  Assert.equal(ping.type, TEST_PING_TYPE, "Should be a test ping.");
}

function checkPingStructure(ping, reason) {
  Assert.equal(
    ping.type,
    EcosystemTelemetry.PING_TYPE,
    "Should be an ecosystem ping."
  );

  Assert.ok(!("clientId" in ping), "Ping must not contain a client ID.");
  Assert.ok("environment" in ping, "Ping must contain an environment.");
  let environment = ping.environment;

  // Check that the environment is indeed minimal
  const ALLOWED_ENVIRONMENT_KEYS = ["settings", "system", "profile"];
  Assert.deepEqual(
    ALLOWED_ENVIRONMENT_KEYS,
    Object.keys(environment),
    "Environment should only contain a limited set of keys."
  );

  // Check that fields of the environment are indeed minimal
  Assert.deepEqual(
    ["locale"],
    Object.keys(environment.settings),
    "Settings environment should only contain locale"
  );
  Assert.deepEqual(
    ["cpu", "memoryMB", "os"],
    Object.keys(environment.system).sort(),
    "System environment should contain a limited set of keys"
  );
  Assert.deepEqual(
    ["locale", "name", "version"],
    Object.keys(environment.system.os).sort(),
    "system.environment.os should contain a limited set of keys"
  );

  // Check the payload for required fields.
  let payload = ping.payload;
  Assert.equal(payload.reason, reason, "Ping reason must match.");
  Assert.ok(
    payload.duration >= 0,
    "Payload must have a duration greater or equal to 0"
  );
  Assert.ok("ecosystemAnonId" in payload, "payload must have ecosystemAnonId");
  Assert.ok(
    RE_VALID_GUID.test(payload.ecosystemClientId),
    "ecosystemClientId must be a valid GUID"
  );

  Assert.ok("scalars" in payload, "Payload must contain scalars");
  Assert.ok("keyedScalars" in payload, "Payload must contain keyed scalars");
  Assert.ok("histograms" in payload, "Payload must contain histograms");
  Assert.ok(
    "keyedHistograms" in payload,
    "Payload must contain keyed histograms"
  );
}

function fakeAnonId(fn) {
  const m = ChromeUtils.import(
    "resource://gre/modules/EcosystemTelemetry.jsm",
    null
  );
  let oldFn = m.Policy.getEcosystemAnonId;
  m.Policy.getEcosystemAnonId = fn;
  return oldFn;
}

registerCleanupFunction(function() {
  PingServer.stop();
});

add_task(async function setup() {
  // Trigger a proper telemetry init.
  do_get_profile(true);
  // Make sure we don't generate unexpected pings due to pref changes.
  await setEmptyPrefWatchlist();

  // Start the local ping server and setup Telemetry to use it during the tests.
  PingServer.start();
  Preferences.set(
    TelemetryUtils.Preferences.Server,
    "http://localhost:" + PingServer.port
  );
  TelemetrySend.setServer("http://localhost:" + PingServer.port);

  await TelemetryController.testSetup();
});

// We make absolute sure the Ecosystem ping is never triggered on Fennec/Non-unified Telemetry
add_task(
  {
    skip_if: () => !gIsAndroid,
  },
  async function test_no_ecosystem_ping_on_fennec() {
    // Force preference to true, we should have an additional check on Android/Unified Telemetry
    Preferences.set(TelemetryUtils.Preferences.EcosystemTelemetryEnabled, true);
    EcosystemTelemetry.testReset();

    // This is invoked in regular intervals by the timer.
    // Would trigger ping sending.
    EcosystemTelemetry.periodicPing();
    await promiseNoPing();
  }
);

add_task(
  {
    skip_if: () => gIsAndroid,
  },
  async function test_disabled_non_fxa_production() {
    Preferences.set(TelemetryUtils.Preferences.EcosystemTelemetryEnabled, true);
    Assert.ok(EcosystemTelemetry.enabled(), "enabled by default");
    Preferences.set("identity.fxaccounts.autoconfig.uri", "http://");
    Assert.ok(!EcosystemTelemetry.enabled(), "disabled if non-prod");
    Preferences.set(
      TelemetryUtils.Preferences.EcosystemTelemetryAllowForNonProductionFxA,
      true
    );
    Assert.ok(
      EcosystemTelemetry.enabled(),
      "enabled for non-prod but preference override"
    );
    Preferences.reset("identity.fxaccounts.autoconfig.uri");
    Preferences.reset(
      TelemetryUtils.Preferences.EcosystemTelemetryAllowForNonProductionFxA
    );
  }
);

add_task(
  {
    skip_if: () => gIsAndroid,
  },
  async function test_nosending_if_disabled() {
    Preferences.set(
      TelemetryUtils.Preferences.EcosystemTelemetryEnabled,
      false
    );
    EcosystemTelemetry.testReset();

    // This is invoked in regular intervals by the timer.
    // Would trigger ping sending.
    EcosystemTelemetry.periodicPing();
    await promiseNoPing();
  }
);

add_task(
  {
    skip_if: () => gIsAndroid,
  },
  async function test_no_default_send() {
    // No user's logged in, nothing is mocked, so nothing is sent.
    Preferences.set(TelemetryUtils.Preferences.EcosystemTelemetryEnabled, true);
    EcosystemTelemetry.testReset();

    // This is invoked in regular intervals by the timer.
    EcosystemTelemetry.periodicPing();

    await promiseNoPing();
  }
);

add_task(
  {
    skip_if: () => gIsAndroid,
  },
  async function test_login_workflow() {
    // Fake the whole login/logout workflow by triggering the events directly.

    Preferences.set(TelemetryUtils.Preferences.EcosystemTelemetryEnabled, true);
    EcosystemTelemetry.testReset();

    let originalAnonId = fakeAnonId(() => null);
    let ping;

    // 1. No user, timer invoked
    EcosystemTelemetry.periodicPing();
    await promiseNoPing();

    // 2. User logs in, but we fail to obtain a valid uid.
    //    No ping will be generated.
    fakeAnonId(() => null);
    EcosystemTelemetry.observe(null, ONLOGIN_NOTIFICATION, null);

    EcosystemTelemetry.periodicPing();
    await promiseNoPing();

    // Once we've failed to get the ID, we don't try again until next startup
    // or another login-related event - so...
    // 3. uid becomes available after verification.
    fakeAnonId(() => "test_login_workflow:my.anon.id");
    EcosystemTelemetry.observe(null, ONVERIFIED_NOTIFICATION, null);
    print("triggering ping now that we have an anon-id");
    EcosystemTelemetry.periodicPing();
    ping = await PingServer.promiseNextPing();
    checkPingStructure(ping, "periodic");
    Assert.equal(
      ping.payload.ecosystemAnonId,
      "test_login_workflow:my.anon.id"
    );
    const origClientId = ping.payload.ecosystemClientId;

    // 4. User disconnects account, should get an immediate ping.
    print("user disconnects");
    // We need to arrange for the new empty anonid before the notification.
    fakeAnonId(() => null);
    await EcosystemTelemetry.observe(null, ONLOGOUT_NOTIFICATION, null);
    ping = await PingServer.promiseNextPing();
    checkPingStructure(ping, "logout");
    Assert.equal(
      ping.payload.ecosystemAnonId,
      "test_login_workflow:my.anon.id",
      "should have been submitted with the old anonid"
    );
    Assert.equal(
      ping.payload.ecosystemClientId,
      origClientId,
      "should have been submitted with the old clientid"
    );
    Assert.equal(
      await EcosystemTelemetry.promiseEcosystemAnonId,
      null,
      "should resolve to null immediately after logout"
    );

    // 5. No user, timer invoked
    print("timer fires after disconnection");
    EcosystemTelemetry.periodicPing();
    await promiseNoPing();

    // 6. Transition back to logged in, pings should again be sent.
    fakeAnonId(() => "test_login_workflow:my.anon.id.2");
    EcosystemTelemetry.observe(null, ONVERIFIED_NOTIFICATION, null);
    print("triggering ping now the user has logged back in");
    EcosystemTelemetry.periodicPing();
    ping = await PingServer.promiseNextPing();
    checkPingStructure(ping, "periodic");
    Assert.equal(
      ping.payload.ecosystemAnonId,
      "test_login_workflow:my.anon.id.2"
    );
    Assert.notEqual(
      ping.payload.ecosystemClientId,
      origClientId,
      "should have a different clientid after signing out then back in"
    );

    // Reset policy.
    fakeAnonId(originalAnonId);
  }
);

add_task(
  {
    skip_if: () => gIsAndroid,
  },
  async function test_shutdown_logged_in() {
    // Check shutdown when a user's logged in does the right thing.
    Preferences.set(TelemetryUtils.Preferences.EcosystemTelemetryEnabled, true);
    EcosystemTelemetry.testReset();

    let originalAnonId = fakeAnonId(() =>
      Promise.resolve("test_shutdown_logged_in:my.anon.id")
    );

    EcosystemTelemetry.observe(null, ONLOGIN_NOTIFICATION, null);

    // No ping expected yet.
    await promiseNoPing();

    // Shutdown
    EcosystemTelemetry.shutdown();
    let ping = await PingServer.promiseNextPing();
    checkPingStructure(ping, "shutdown");
    Assert.equal(
      ping.payload.ecosystemAnonId,
      "test_shutdown_logged_in:my.anon.id",
      "our anon ID is in the ping"
    );
    fakeAnonId(originalAnonId);
  }
);

add_task(
  {
    skip_if: () => gIsAndroid,
  },
  async function test_shutdown_not_logged_in() {
    // Check shutdown when no user is logged in does the right thing.
    Preferences.set(TelemetryUtils.Preferences.EcosystemTelemetryEnabled, true);
    EcosystemTelemetry.testReset();

    let originalAnonId = fakeAnonId(() => Promise.resolve(null));

    // No ping expected yet.
    await promiseNoPing();

    // Shutdown
    EcosystemTelemetry.shutdown();

    // Still no ping.
    await promiseNoPing();
    fakeAnonId(originalAnonId);
  }
);

// Test that a periodic ping is triggered by the scheduler at midnight
//
// Based on `test_TelemetrySession#test_DailyDueAndIdle`.
add_task(
  {
    skip_if: () => gIsAndroid,
  },
  async function test_periodic_ping() {
    await TelemetryStorage.testClearPendingPings();
    PingServer.clearRequests();

    let receivedPing = null;
    // Register a ping handler that will assert when receiving multiple ecosystem pings.
    // We can ignore other pings, such as the periodic ping.
    PingServer.registerPingHandler(req => {
      const ping = decodeRequestPayload(req);
      if (ping.type == EcosystemTelemetry.PING_TYPE) {
        Assert.ok(
          !receivedPing,
          "Telemetry must only send one periodic ecosystem ping."
        );
        receivedPing = ping;
      }
    });

    // Faking scheduler timer has to happen before resetting TelemetryController
    // to be effective.
    let schedulerTickCallback = null;
    let now = new Date(2040, 1, 1, 0, 0, 0);
    fakeNow(now);
    // Fake scheduler functions to control periodic collection flow in tests.
    fakeSchedulerTimer(
      callback => (schedulerTickCallback = callback),
      () => {}
    );
    await TelemetryController.testReset();

    Preferences.set(TelemetryUtils.Preferences.EcosystemTelemetryEnabled, true);
    EcosystemTelemetry.testReset();

    // Have to arrange for an anon-id to be configured.
    let originalAnonId = fakeAnonId(() => "test_periodic_ping:my.anon.id");
    EcosystemTelemetry.observe(null, ONLOGIN_NOTIFICATION, null);

    // As a sanity check we trigger a keyedHistogram and scalar declared as
    // being in our ping, just to help ensure that the payload was assembled
    // in the correct shape.
    let h = Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS");
    h.add("test-key");
    Telemetry.scalarSet("browser.engagement.total_uri_count", 2);

    // Trigger the periodic ecosystem ping.
    let firstPeriodicDue = new Date(2040, 1, 2, 0, 0, 0);
    fakeNow(firstPeriodicDue);

    // Run a scheduler tick: it should trigger the periodic ping.
    Assert.ok(!!schedulerTickCallback);
    let tickPromise = schedulerTickCallback();

    // Send an idle and then an active user notification.
    fakeIdleNotification("idle");
    fakeIdleNotification("active");

    // Wait on the tick promise.
    await tickPromise;

    await TelemetrySend.testWaitOnOutgoingPings();

    // Decode the ping contained in the request and check that's a periodic ping.
    Assert.ok(receivedPing, "Telemetry must send one ecosystem periodic ping.");
    checkPingStructure(receivedPing, "periodic");
    // And check the content we expect is there.
    Assert.ok(receivedPing.payload.keyedHistograms.parent.SEARCH_COUNTS);
    Assert.equal(
      receivedPing.payload.scalars.parent["browser.engagement.total_uri_count"],
      2
    );

    fakeAnonId(originalAnonId);
  }
);
