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

// This tests the public Telemetry API for submitting pings.

"use strict";

const { ClientID } = ChromeUtils.importESModule(
  "resource://gre/modules/ClientID.sys.mjs"
);
const { TelemetryArchive } = ChromeUtils.importESModule(
  "resource://gre/modules/TelemetryArchive.sys.mjs"
);

ChromeUtils.defineLazyGetter(this, "gPingsArchivePath", function () {
  return PathUtils.join(PathUtils.profileDir, "datareporting", "archived");
});

/**
 * Fakes the archive storage quota.
 * @param {Integer} aArchiveQuota The new quota, in bytes.
 */
function fakeStorageQuota(aArchiveQuota) {
  let { Policy } = ChromeUtils.importESModule(
    "resource://gre/modules/TelemetryStorage.sys.mjs"
  );
  Policy.getArchiveQuota = () => aArchiveQuota;
}

/**
 * Lists all the valid archived pings and their metadata, sorted by creation date.
 *
 * @return {Object[]} A list of objects with the extracted data in the form:
 *                  { timestamp: <number>,
 *                    id: <string>,
 *                    type: <string>,
 *                    size: <integer> }
 */
var getArchivedPingsInfo = async function () {
  let archivedPings = [];

  // Iterate through the subdirs of |gPingsArchivePath|.
  for (const dir of await IOUtils.getChildren(gPingsArchivePath)) {
    const { type } = await IOUtils.stat(dir);
    if (type != "directory") {
      continue;
    }

    // Then get a list of the files for the current subdir.
    for (const filePath of await IOUtils.getChildren(dir)) {
      const fileInfo = await IOUtils.stat(filePath);
      if (fileInfo.type == "directory") {
        continue;
      }
      let pingInfo = TelemetryStorage._testGetArchivedPingDataFromFileName(
        PathUtils.filename(filePath)
      );
      if (!pingInfo) {
        // This is not a valid archived ping, skip it.
        continue;
      }
      // Find the size of the ping and then add the info to the array.
      pingInfo.size = fileInfo.size;
      archivedPings.push(pingInfo);
    }
  }

  // Sort the list by creation date and then return it.
  archivedPings.sort((a, b) => b.timestamp - a.timestamp);
  return archivedPings;
};

add_task(async function test_setup() {
  do_get_profile(true);
  // Make sure we don't generate unexpected pings due to pref changes.
  await setEmptyPrefWatchlist();
});

add_task(async function test_archivedPings() {
  // TelemetryController should not be fully initialized at this point.
  // Submitting pings should still work fine.

  const PINGS = [
    {
      type: "test-ping-api-1",
      payload: { foo: "bar" },
      dateCreated: new Date(2010, 1, 1, 10, 0, 0),
    },
    {
      type: "test-ping-api-2",
      payload: { moo: "meh" },
      dateCreated: new Date(2010, 2, 1, 10, 0, 0),
    },
  ];

  // Submit pings and check the ping list.
  let expectedPingList = [];

  for (let data of PINGS) {
    fakeNow(data.dateCreated);
    data.id = await TelemetryController.submitExternalPing(
      data.type,
      data.payload
    );
    let list = await TelemetryArchive.promiseArchivedPingList();

    expectedPingList.push({
      id: data.id,
      type: data.type,
      timestampCreated: data.dateCreated.getTime(),
    });
    Assert.deepEqual(
      list,
      expectedPingList,
      "Archived ping list should contain submitted pings"
    );
  }

  // Check loading the archived pings.
  let checkLoadingPings = async function () {
    for (let data of PINGS) {
      let ping = await TelemetryArchive.promiseArchivedPingById(data.id);
      Assert.equal(ping.id, data.id, "Archived ping should have matching id");
      Assert.equal(
        ping.type,
        data.type,
        "Archived ping should have matching type"
      );
      Assert.equal(
        ping.creationDate,
        data.dateCreated.toISOString(),
        "Archived ping should have matching creation date"
      );
    }
  };

  await checkLoadingPings();

  // Check that we find the archived pings again by scanning after a restart.
  await TelemetryController.testReset();

  let pingList = await TelemetryArchive.promiseArchivedPingList();
  Assert.deepEqual(
    expectedPingList,
    pingList,
    "Should have submitted pings in archive list after restart"
  );
  await checkLoadingPings();

  // Write invalid pings into the archive with both valid and invalid names.
  let writeToArchivedDir = async function (
    dirname,
    filename,
    content,
    compressed
  ) {
    const dirPath = PathUtils.join(gPingsArchivePath, dirname);
    await IOUtils.makeDirectory(dirPath, { ignoreExisting: true });
    const filePath = PathUtils.join(dirPath, filename);
    const options = { tmpPath: filePath + ".tmp", mode: "overwrite" };
    if (compressed) {
      options.compress = true;
    }
    await IOUtils.writeUTF8(filePath, content, options);
  };

  const FAKE_ID1 = "10000000-0123-0123-0123-0123456789a1";
  const FAKE_ID2 = "20000000-0123-0123-0123-0123456789a2";
  const FAKE_ID3 = "20000000-0123-0123-0123-0123456789a3";
  const FAKE_TYPE = "foo";

  // These should get rejected.
  await writeToArchivedDir("xx", "foo.json", "{}");
  await writeToArchivedDir("2010-02", "xx.xx.xx.json", "{}");
  // This one should get picked up...
  await writeToArchivedDir(
    "2010-02",
    "1." + FAKE_ID1 + "." + FAKE_TYPE + ".json",
    "{}"
  );
  // ... but get overwritten by this one.
  await writeToArchivedDir(
    "2010-02",
    "2." + FAKE_ID1 + "." + FAKE_TYPE + ".json",
    ""
  );
  // This should get picked up fine.
  await writeToArchivedDir(
    "2010-02",
    "3." + FAKE_ID2 + "." + FAKE_TYPE + ".json",
    ""
  );
  // This compressed ping should get picked up fine as well.
  await writeToArchivedDir(
    "2010-02",
    "4." + FAKE_ID3 + "." + FAKE_TYPE + ".jsonlz4",
    ""
  );

  expectedPingList.push({
    id: FAKE_ID1,
    type: "foo",
    timestampCreated: 2,
  });
  expectedPingList.push({
    id: FAKE_ID2,
    type: "foo",
    timestampCreated: 3,
  });
  expectedPingList.push({
    id: FAKE_ID3,
    type: "foo",
    timestampCreated: 4,
  });
  expectedPingList.sort((a, b) => a.timestampCreated - b.timestampCreated);

  // Reset the TelemetryArchive so we scan the archived dir again.
  await TelemetryController.testReset();

  // Check that we are still picking up the valid archived pings on disk,
  // plus the valid ones above.
  pingList = await TelemetryArchive.promiseArchivedPingList();
  Assert.deepEqual(
    expectedPingList,
    pingList,
    "Should have picked up valid archived pings"
  );
  await checkLoadingPings();

  // Now check that we fail to load the two invalid pings from above.
  Assert.ok(
    await promiseRejects(TelemetryArchive.promiseArchivedPingById(FAKE_ID1)),
    "Should have rejected invalid ping"
  );
  Assert.ok(
    await promiseRejects(TelemetryArchive.promiseArchivedPingById(FAKE_ID2)),
    "Should have rejected invalid ping"
  );
});

add_task(async function test_archiveCleanup() {
  const PING_TYPE = "foo";

  // Empty the archive.
  await IOUtils.remove(gPingsArchivePath, { recursive: true });

  Telemetry.getHistogramById("TELEMETRY_ARCHIVE_SCAN_PING_COUNT").clear();
  Telemetry.getHistogramById("TELEMETRY_ARCHIVE_DIRECTORIES_COUNT").clear();
  // Also reset these histograms to make sure normal sized pings don't get counted.
  Telemetry.getHistogramById("TELEMETRY_PING_SIZE_EXCEEDED_ARCHIVED").clear();
  Telemetry.getHistogramById(
    "TELEMETRY_DISCARDED_ARCHIVED_PINGS_SIZE_MB"
  ).clear();

  // Build the cache. Nothing should be evicted as there's no ping directory.
  await TelemetryController.testReset();
  await TelemetryStorage.testCleanupTaskPromise();
  await TelemetryArchive.promiseArchivedPingList();

  let h = Telemetry.getHistogramById(
    "TELEMETRY_ARCHIVE_SCAN_PING_COUNT"
  ).snapshot();
  Assert.equal(
    h.sum,
    0,
    "Telemetry must report 0 pings scanned if no archive dir exists."
  );
  // One directory out of four was removed as well.
  h = Telemetry.getHistogramById(
    "TELEMETRY_ARCHIVE_EVICTED_OLD_DIRS"
  ).snapshot();
  Assert.equal(
    h.sum,
    0,
    "Telemetry must report 0 evicted dirs if no archive dir exists."
  );

  let expectedPrunedInfo = [];
  let expectedNotPrunedInfo = [];

  let checkArchive = async function () {
    // Check that the pruned pings are not on disk anymore.
    for (let prunedInfo of expectedPrunedInfo) {
      await Assert.rejects(
        TelemetryArchive.promiseArchivedPingById(prunedInfo.id),
        /TelemetryStorage.loadArchivedPing - no ping with id/,
        "Ping " + prunedInfo.id + " should have been pruned."
      );
      const pingPath = TelemetryStorage._testGetArchivedPingPath(
        prunedInfo.id,
        prunedInfo.creationDate,
        PING_TYPE
      );
      Assert.ok(
        !(await IOUtils.exists(pingPath)),
        "The ping should not be on the disk anymore."
      );
    }

    // Check that the expected pings are there.
    for (let expectedInfo of expectedNotPrunedInfo) {
      Assert.ok(
        await TelemetryArchive.promiseArchivedPingById(expectedInfo.id),
        "Ping" + expectedInfo.id + " should be in the archive."
      );
    }
  };

  Telemetry.getHistogramById("TELEMETRY_ARCHIVE_SESSION_PING_COUNT").clear();

  // Create a ping which should be pruned because it is past the retention period.
  let date = fakeNow(2010, 1, 1, 1, 0, 0);
  let firstDate = date;
  let pingId = await TelemetryController.submitExternalPing(PING_TYPE, {}, {});
  expectedPrunedInfo.push({ id: pingId, creationDate: date });

  // Create a ping which should be kept because it is within the retention period.
  const oldestDirectoryDate = fakeNow(2010, 2, 1, 1, 0, 0);
  pingId = await TelemetryController.submitExternalPing(PING_TYPE, {}, {});
  expectedNotPrunedInfo.push({ id: pingId, creationDate: oldestDirectoryDate });

  // Create 20 other pings which are within the retention period, but would be affected
  // by the disk quota.
  for (let month of [3, 4]) {
    for (let minute = 0; minute < 10; minute++) {
      date = fakeNow(2010, month, 1, 1, minute, 0);
      pingId = await TelemetryController.submitExternalPing(PING_TYPE, {}, {});
      expectedNotPrunedInfo.push({ id: pingId, creationDate: date });
    }
  }

  // We expect all the pings we archived to be in this histogram.
  h = Telemetry.getHistogramById("TELEMETRY_ARCHIVE_SESSION_PING_COUNT");
  Assert.equal(
    h.snapshot().sum,
    22,
    "All the pings must be live-accumulated in the histogram."
  );
  // Reset the histogram that will be populated by the archive scan.
  Telemetry.getHistogramById("TELEMETRY_ARCHIVE_EVICTED_OLD_DIRS").clear();
  Telemetry.getHistogramById("TELEMETRY_ARCHIVE_OLDEST_DIRECTORY_AGE").clear();

  // Move the current date 60 days ahead of the first ping.
  fakeNow(futureDate(firstDate, 60 * MILLISECONDS_PER_DAY));
  // Reset TelemetryArchive and TelemetryController to start the startup cleanup.
  await TelemetryController.testReset();
  // Wait for the cleanup to finish.
  await TelemetryStorage.testCleanupTaskPromise();
  // Then scan the archived dir.
  await TelemetryArchive.promiseArchivedPingList();

  // Check that the archive is in the correct state.
  await checkArchive();

  // Make sure the ping count is correct after the scan (one ping was removed).
  h = Telemetry.getHistogramById(
    "TELEMETRY_ARCHIVE_SCAN_PING_COUNT"
  ).snapshot();
  Assert.equal(
    h.sum,
    21,
    "The histogram must count all the pings in the archive."
  );
  // One directory out of four was removed as well.
  h = Telemetry.getHistogramById(
    "TELEMETRY_ARCHIVE_EVICTED_OLD_DIRS"
  ).snapshot();
  Assert.equal(
    h.sum,
    1,
    "Telemetry must correctly report removed archive directories."
  );
  // Check that the remaining directories are correctly counted.
  h = Telemetry.getHistogramById(
    "TELEMETRY_ARCHIVE_DIRECTORIES_COUNT"
  ).snapshot();
  Assert.equal(
    h.sum,
    3,
    "Telemetry must correctly report the remaining archive directories."
  );
  // Check that the remaining directories are correctly counted.
  const oldestAgeInMonths = 1;
  h = Telemetry.getHistogramById(
    "TELEMETRY_ARCHIVE_OLDEST_DIRECTORY_AGE"
  ).snapshot();
  Assert.equal(
    h.sum,
    oldestAgeInMonths,
    "Telemetry must correctly report age of the oldest directory in the archive."
  );

  // We need to test the archive size before we hit the quota, otherwise a special
  // value is recorded.
  Telemetry.getHistogramById("TELEMETRY_ARCHIVE_SIZE_MB").clear();
  Telemetry.getHistogramById("TELEMETRY_ARCHIVE_EVICTED_OVER_QUOTA").clear();
  Telemetry.getHistogramById(
    "TELEMETRY_ARCHIVE_EVICTING_OVER_QUOTA_MS"
  ).clear();

  // Move the current date 60 days ahead of the second ping.
  fakeNow(futureDate(oldestDirectoryDate, 60 * MILLISECONDS_PER_DAY));
  // Reset TelemetryController and TelemetryArchive.
  await TelemetryController.testReset();
  // Wait for the cleanup to finish.
  await TelemetryStorage.testCleanupTaskPromise();
  // Then scan the archived dir again.
  await TelemetryArchive.promiseArchivedPingList();

  // Move the oldest ping to the unexpected pings list.
  expectedPrunedInfo.push(expectedNotPrunedInfo.shift());
  // Check that the archive is in the correct state.
  await checkArchive();

  // Find how much disk space the archive takes.
  const archivedPingsInfo = await getArchivedPingsInfo();
  let archiveSizeInBytes = archivedPingsInfo.reduce(
    (lastResult, element) => lastResult + element.size,
    0
  );

  // Check that the correct values for quota probes are reported when no quota is hit.
  h = Telemetry.getHistogramById("TELEMETRY_ARCHIVE_SIZE_MB").snapshot();
  Assert.equal(
    h.sum,
    Math.round(archiveSizeInBytes / 1024 / 1024),
    "Telemetry must report the correct archive size."
  );
  h = Telemetry.getHistogramById(
    "TELEMETRY_ARCHIVE_EVICTED_OVER_QUOTA"
  ).snapshot();
  Assert.equal(
    h.sum,
    0,
    "Telemetry must report 0 evictions if quota is not hit."
  );
  h = Telemetry.getHistogramById(
    "TELEMETRY_ARCHIVE_EVICTING_OVER_QUOTA_MS"
  ).snapshot();
  Assert.equal(
    h.sum,
    0,
    "Telemetry must report a null elapsed time if quota is not hit."
  );

  // Set the quota to 80% of the space.
  const testQuotaInBytes = archiveSizeInBytes * 0.8;
  fakeStorageQuota(testQuotaInBytes);

  // The storage prunes archived pings until we reach 90% of the requested storage quota.
  // Based on that, find how many pings should be kept.
  const safeQuotaSize = testQuotaInBytes * 0.9;
  let sizeInBytes = 0;
  let pingsWithinQuota = [];
  let pingsOutsideQuota = [];

  for (let pingInfo of archivedPingsInfo) {
    sizeInBytes += pingInfo.size;
    if (sizeInBytes >= safeQuotaSize) {
      pingsOutsideQuota.push({
        id: pingInfo.id,
        creationDate: new Date(pingInfo.timestamp),
      });
      continue;
    }
    pingsWithinQuota.push({
      id: pingInfo.id,
      creationDate: new Date(pingInfo.timestamp),
    });
  }

  expectedNotPrunedInfo = pingsWithinQuota;
  expectedPrunedInfo = expectedPrunedInfo.concat(pingsOutsideQuota);

  // Reset TelemetryArchive and TelemetryController to start the startup cleanup.
  await TelemetryController.testReset();
  await TelemetryStorage.testCleanupTaskPromise();
  await TelemetryArchive.promiseArchivedPingList();
  // Check that the archive is in the correct state.
  await checkArchive();

  h = Telemetry.getHistogramById(
    "TELEMETRY_ARCHIVE_EVICTED_OVER_QUOTA"
  ).snapshot();
  Assert.equal(
    h.sum,
    pingsOutsideQuota.length,
    "Telemetry must correctly report the over quota pings evicted from the archive."
  );
  h = Telemetry.getHistogramById("TELEMETRY_ARCHIVE_SIZE_MB").snapshot();
  Assert.equal(
    h.sum,
    300,
    "Archive quota was hit, a special size must be reported."
  );

  // Trigger a cleanup again and make sure we're not removing anything.
  await TelemetryController.testReset();
  await TelemetryStorage.testCleanupTaskPromise();
  await TelemetryArchive.promiseArchivedPingList();
  await checkArchive();

  const OVERSIZED_PING_ID = "9b21ec8f-f762-4d28-a2c1-44e1c4694f24";
  // Create and archive an oversized, uncompressed, ping.
  const OVERSIZED_PING = {
    id: OVERSIZED_PING_ID,
    type: PING_TYPE,
    creationDate: new Date().toISOString(),
    // Generate a ~2MB string to use as the payload.
    payload: generateRandomString(2 * 1024 * 1024),
  };
  await TelemetryArchive.promiseArchivePing(OVERSIZED_PING);

  // Get the size of the archived ping.
  const oversizedPingPath =
    TelemetryStorage._testGetArchivedPingPath(
      OVERSIZED_PING.id,
      new Date(OVERSIZED_PING.creationDate),
      PING_TYPE
    ) + "lz4";
  const archivedPingSizeMB = Math.floor(
    (await IOUtils.stat(oversizedPingPath)).size / 1024 / 1024
  );

  // We expect the oversized ping to be pruned when scanning the archive.
  expectedPrunedInfo.push({
    id: OVERSIZED_PING_ID,
    creationDate: new Date(OVERSIZED_PING.creationDate),
  });

  // Scan the archive.
  await TelemetryController.testReset();
  await TelemetryStorage.testCleanupTaskPromise();
  await TelemetryArchive.promiseArchivedPingList();
  // The following also checks that non oversized pings are not removed.
  await checkArchive();

  // Make sure we're correctly updating the related histograms.
  h = Telemetry.getHistogramById(
    "TELEMETRY_PING_SIZE_EXCEEDED_ARCHIVED"
  ).snapshot();
  Assert.equal(
    h.sum,
    1,
    "Telemetry must report 1 oversized ping in the archive."
  );
  h = Telemetry.getHistogramById(
    "TELEMETRY_DISCARDED_ARCHIVED_PINGS_SIZE_MB"
  ).snapshot();
  Assert.equal(
    h.values[archivedPingSizeMB],
    1,
    "Telemetry must report the correct size for the oversized ping."
  );
});

add_task(async function test_clientId() {
  // Check that a ping submitted after the delayed telemetry initialization completed
  // should get a valid client id.
  await TelemetryController.testReset();
  const clientId = await ClientID.getClientID();
  const profileGroupId = await ClientID.getProfileGroupID();

  let id = await TelemetryController.submitExternalPing(
    "test-type",
    {},
    { addClientId: true }
  );
  let ping = await TelemetryArchive.promiseArchivedPingById(id);

  Assert.ok(!!ping, "Should have loaded the ping.");
  Assert.ok("clientId" in ping, "Ping should have a client id.");
  Assert.ok(UUID_REGEX.test(ping.clientId), "Client id is in UUID format.");
  Assert.equal(
    ping.clientId,
    clientId,
    "Ping client id should match the global client id."
  );
  Assert.ok("profileGroupId" in ping, "Ping should have a profile group id.");
  Assert.ok(
    UUID_REGEX.test(ping.profileGroupId),
    "Profile group id is in UUID format."
  );
  Assert.equal(
    ping.profileGroupId,
    profileGroupId,
    "Ping profile group id should match the global profile group id."
  );

  // We should have cached the client id now. Lets confirm that by
  // checking the client id on a ping submitted before the async
  // controller setup is finished.
  let promiseSetup = TelemetryController.testReset();
  id = await TelemetryController.submitExternalPing(
    "test-type",
    {},
    { addClientId: true }
  );
  ping = await TelemetryArchive.promiseArchivedPingById(id);
  Assert.equal(ping.clientId, clientId);

  // Finish setup.
  await promiseSetup;
});

add_task(async function test_InvalidPingType() {
  const TYPES = [
    "a",
    "-",
    "¿€€€?",
    "-foo-",
    "-moo",
    "zoo-",
    ".bar",
    "asfd.asdf",
  ];

  for (let type of TYPES) {
    let histogram = Telemetry.getKeyedHistogramById(
      "TELEMETRY_INVALID_PING_TYPE_SUBMITTED"
    );
    Assert.ok(
      !(type in histogram.snapshot()),
      "Should not have counted this invalid ping yet: " + type
    );
    Assert.ok(
      promiseRejects(TelemetryController.submitExternalPing(type, {})),
      "Ping type should have been rejected."
    );
    Assert.equal(
      histogram.snapshot()[type].sum,
      1,
      "Should have counted this as an invalid ping type."
    );
  }
});

add_task(async function test_InvalidPayloadType() {
  const PAYLOAD_TYPES = [19, "string", [1, 2, 3, 4], null, undefined];

  let histogram = Telemetry.getHistogramById(
    "TELEMETRY_INVALID_PAYLOAD_SUBMITTED"
  );
  for (let i = 0; i < PAYLOAD_TYPES.length; i++) {
    histogram.clear();
    Assert.equal(
      histogram.snapshot().sum,
      0,
      "Should not have counted this invalid payload yet: " +
        JSON.stringify(PAYLOAD_TYPES[i])
    );
    Assert.ok(
      await promiseRejects(
        TelemetryController.submitExternalPing("payload-test", PAYLOAD_TYPES[i])
      ),
      "Payload type should have been rejected."
    );
    Assert.equal(
      histogram.snapshot().sum,
      1,
      "Should have counted this as an invalid payload type."
    );
  }
});

add_task(async function test_currentPingData() {
  await TelemetryController.testSetup();

  // Setup test data.
  let h = Telemetry.getHistogramById("TELEMETRY_TEST_RELEASE_OPTOUT");
  h.clear();
  h.add(1);
  let k = Telemetry.getKeyedHistogramById(
    "TELEMETRY_TEST_KEYED_RELEASE_OPTOUT"
  );
  k.clear();
  k.add("a", 1);

  // Get current ping data objects and check that their data is sane.
  for (let subsession of [true, false]) {
    let ping = TelemetryController.getCurrentPingData(subsession);

    Assert.ok(!!ping, "Should have gotten a ping.");
    Assert.equal(ping.type, "main", "Ping should have correct type.");
    const expectedReason = subsession
      ? "gather-subsession-payload"
      : "gather-payload";
    Assert.equal(
      ping.payload.info.reason,
      expectedReason,
      "Ping should have the correct reason."
    );

    let id = "TELEMETRY_TEST_RELEASE_OPTOUT";
    Assert.ok(
      id in ping.payload.histograms,
      "Payload should have test count histogram."
    );
    Assert.equal(
      ping.payload.histograms[id].sum,
      1,
      "Test count value should match."
    );
    id = "TELEMETRY_TEST_KEYED_RELEASE_OPTOUT";
    Assert.ok(
      id in ping.payload.keyedHistograms,
      "Payload should have keyed test histogram."
    );
    Assert.equal(
      ping.payload.keyedHistograms[id].a.sum,
      1,
      "Keyed test value should match."
    );
  }
});

add_task(async function test_shutdown() {
  await TelemetryController.testShutdown();
});
