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

/**
 * This test case populates the profile with some fake stored
 * pings, and checks that pending pings are immediatlely sent
 * after delayed init.
 */

"use strict";

const PING_SAVE_FOLDER = "saved-telemetry-pings";
const OLD_FORMAT_PINGS = 4;
const RECENT_PINGS = 4;

var gSeenPings = 0;

/**
 * Creates some Telemetry pings for the and saves them to disk. Each ping gets a
 * unique ID based on an incrementor.
 *
 * @param {Array} aPingInfos An array of ping type objects. Each entry must be an
 *                object containing a "num" field for the number of pings to create and
 *                an "age" field. The latter representing the age in milliseconds to offset
 *                from now. A value of 10 would make the ping 10ms older than now, for
 *                example.
 * @returns Promise
 * @resolve an Array with the created pings ids.
 */
var createSavedPings = async function (aPingInfos) {
  let pingIds = [];
  let now = Date.now();

  for (let type in aPingInfos) {
    let num = aPingInfos[type].num;
    let age = now - (aPingInfos[type].age || 0);
    for (let i = 0; i < num; ++i) {
      let pingId = await TelemetryController.addPendingPing(
        "test-ping",
        {},
        { overwrite: true }
      );
      if (aPingInfos[type].age) {
        // savePing writes to the file synchronously, so we're good to
        // modify the lastModifedTime now.
        let filePath = getSavePathForPingId(pingId);
        await IOUtils.setModificationTime(filePath, age);
      }
      pingIds.push(pingId);
    }
  }

  return pingIds;
};

/**
 * Fakes the pending pings storage quota.
 * @param {Integer} aPendingQuota The new quota, in bytes.
 */
function fakePendingPingsQuota(aPendingQuota) {
  let { Policy } = ChromeUtils.importESModule(
    "resource://gre/modules/TelemetryStorage.sys.mjs"
  );
  Policy.getPendingPingsQuota = () => aPendingQuota;
}

/**
 * Returns a path for the file that a ping should be
 * stored in locally.
 *
 * @returns path
 */
function getSavePathForPingId(aPingId) {
  return PathUtils.join(PathUtils.profileDir, PING_SAVE_FOLDER, aPingId);
}

/**
 * Check if the number of Telemetry pings received by the HttpServer is not equal
 * to aExpectedNum.
 *
 * @param aExpectedNum the number of pings we expect to receive.
 */
function assertReceivedPings(aExpectedNum) {
  Assert.equal(gSeenPings, aExpectedNum);
}

/**
 * Our handler function for the HttpServer that simply
 * increments the gSeenPings global when it successfully
 * receives and decodes a Telemetry payload.
 *
 * @param aRequest the HTTP request sent from HttpServer.
 */
function pingHandler(aRequest) {
  gSeenPings++;
}

add_task(async function test_setup() {
  PingServer.start();
  PingServer.registerPingHandler(pingHandler);
  do_get_profile();
  await loadAddonManager(
    "xpcshell@tests.mozilla.org",
    "XPCShell",
    "1",
    "1.9.2"
  );
  finishAddonManagerStartup();
  fakeIntlReady();
  // Make sure we don't generate unexpected pings due to pref changes.
  await setEmptyPrefWatchlist();

  Services.prefs.setCharPref(
    TelemetryUtils.Preferences.Server,
    "http://localhost:" + PingServer.port
  );
});

/**
 * Setup the tests by making sure the ping storage directory is available, otherwise
 * |TelemetryController.testSaveDirectoryToFile| could fail.
 */
add_task(async function setupEnvironment() {
  // The following tests assume this pref to be true by default.
  Services.prefs.setBoolPref(TelemetryUtils.Preferences.FhrUploadEnabled, true);

  await TelemetryController.testSetup();

  let directory = TelemetryStorage.pingDirectoryPath;
  await IOUtils.makeDirectory(directory, {
    ignoreExisting: true,
    permissions: 0o700,
  });

  await TelemetryStorage.testClearPendingPings();
});

/**
 * Test that really recent pings are sent on Telemetry initialization.
 */
add_task(async function test_recent_pings_sent() {
  let pingTypes = [{ num: RECENT_PINGS }];
  await createSavedPings(pingTypes);

  await TelemetryController.testReset();
  await TelemetrySend.testWaitOnOutgoingPings();
  assertReceivedPings(RECENT_PINGS);

  await TelemetryStorage.testClearPendingPings();
});

/**
 * Create an overdue ping in the old format and try to send it.
 */
add_task(async function test_old_formats() {
  // A test ping in the old, standard format.
  const PING_OLD_FORMAT = {
    slug: "1234567abcd",
    reason: "test-ping",
    payload: {
      info: {
        reason: "test-ping",
        OS: "XPCShell",
        appID: "SomeId",
        appVersion: "1.0",
        appName: "XPCShell",
        appBuildID: "123456789",
        appUpdateChannel: "Test",
        platformBuildID: "987654321",
      },
    },
  };

  // A ping with no info section, but with a slug.
  const PING_NO_INFO = {
    slug: "1234-no-info-ping",
    reason: "test-ping",
    payload: {},
  };

  // A ping with no payload.
  const PING_NO_PAYLOAD = {
    slug: "5678-no-payload",
    reason: "test-ping",
  };

  // A ping with no info and no slug.
  const PING_NO_SLUG = {
    reason: "test-ping",
    payload: {},
  };

  const PING_FILES_PATHS = [
    getSavePathForPingId(PING_OLD_FORMAT.slug),
    getSavePathForPingId(PING_NO_INFO.slug),
    getSavePathForPingId(PING_NO_PAYLOAD.slug),
    getSavePathForPingId("no-slug-file"),
  ];

  // Write the ping to file
  await TelemetryStorage.savePing(PING_OLD_FORMAT, true);
  await TelemetryStorage.savePing(PING_NO_INFO, true);
  await TelemetryStorage.savePing(PING_NO_PAYLOAD, true);
  await TelemetryStorage.savePingToFile(
    PING_NO_SLUG,
    PING_FILES_PATHS[3],
    true
  );

  gSeenPings = 0;
  await TelemetryController.testReset();
  await TelemetrySend.testWaitOnOutgoingPings();
  assertReceivedPings(OLD_FORMAT_PINGS);

  // |TelemetryStorage.cleanup| doesn't know how to remove a ping with no slug or id,
  // so remove it manually so that the next test doesn't fail.
  await IOUtils.remove(PING_FILES_PATHS[3]);

  await TelemetryStorage.testClearPendingPings();
});

add_task(async function test_corrupted_pending_pings() {
  const TEST_TYPE = "test_corrupted";

  Telemetry.getHistogramById("TELEMETRY_PENDING_LOAD_FAILURE_READ").clear();
  Telemetry.getHistogramById("TELEMETRY_PENDING_LOAD_FAILURE_PARSE").clear();

  // Save a pending ping and get its id.
  let pendingPingId = await TelemetryController.addPendingPing(
    TEST_TYPE,
    {},
    {}
  );

  // Try to load it: there should be no error.
  await TelemetryStorage.loadPendingPing(pendingPingId);

  let h = Telemetry.getHistogramById(
    "TELEMETRY_PENDING_LOAD_FAILURE_READ"
  ).snapshot();
  Assert.equal(
    h.sum,
    0,
    "Telemetry must not report a pending ping load failure"
  );
  h = Telemetry.getHistogramById(
    "TELEMETRY_PENDING_LOAD_FAILURE_PARSE"
  ).snapshot();
  Assert.equal(
    h.sum,
    0,
    "Telemetry must not report a pending ping parse failure"
  );

  // Delete it from the disk, so that its id will be kept in the cache but it will
  // fail loading the file.
  await IOUtils.remove(getSavePathForPingId(pendingPingId));

  // Try to load a pending ping which isn't there anymore.
  await Assert.rejects(
    TelemetryStorage.loadPendingPing(pendingPingId),
    /PingReadError/,
    "Telemetry must fail loading a ping which isn't there"
  );

  h = Telemetry.getHistogramById(
    "TELEMETRY_PENDING_LOAD_FAILURE_READ"
  ).snapshot();
  Assert.equal(h.sum, 1, "Telemetry must report a pending ping load failure");
  h = Telemetry.getHistogramById(
    "TELEMETRY_PENDING_LOAD_FAILURE_PARSE"
  ).snapshot();
  Assert.equal(
    h.sum,
    0,
    "Telemetry must not report a pending ping parse failure"
  );

  // Save a new ping, so that it gets in the pending pings cache.
  pendingPingId = await TelemetryController.addPendingPing(TEST_TYPE, {}, {});
  // Overwrite it with a corrupted JSON file and then try to load it.
  const INVALID_JSON = "{ invalid,JSON { {1}";
  await IOUtils.writeUTF8(getSavePathForPingId(pendingPingId), INVALID_JSON);

  // Try to load the ping with the corrupted JSON content.
  await Assert.rejects(
    TelemetryStorage.loadPendingPing(pendingPingId),
    /PingParseError/,
    "Telemetry must fail loading a corrupted ping"
  );

  h = Telemetry.getHistogramById(
    "TELEMETRY_PENDING_LOAD_FAILURE_READ"
  ).snapshot();
  Assert.equal(h.sum, 1, "Telemetry must report a pending ping load failure");
  h = Telemetry.getHistogramById(
    "TELEMETRY_PENDING_LOAD_FAILURE_PARSE"
  ).snapshot();
  Assert.equal(h.sum, 1, "Telemetry must report a pending ping parse failure");

  let exists = await IOUtils.exists(getSavePathForPingId(pendingPingId));
  Assert.ok(!exists, "The unparseable ping should have been removed");

  await TelemetryStorage.testClearPendingPings();
});

/**
 * Create a ping in the old format, send it, and make sure the request URL contains
 * the correct version query parameter.
 */
add_task(async function test_overdue_old_format() {
  // A test ping in the old, standard format.
  const PING_OLD_FORMAT = {
    slug: "1234567abcd",
    reason: "test-ping",
    payload: {
      info: {
        reason: "test-ping",
        OS: "XPCShell",
        appID: "SomeId",
        appVersion: "1.0",
        appName: "XPCShell",
        appBuildID: "123456789",
        appUpdateChannel: "Test",
        platformBuildID: "987654321",
      },
    },
  };

  // Write the ping to file
  await TelemetryStorage.savePing(PING_OLD_FORMAT, true);

  let receivedPings = 0;
  // Register a new prefix handler to validate the URL.
  PingServer.registerPingHandler(request => {
    // Check that we have a version query parameter in the URL.
    Assert.notEqual(request.queryString, "");

    // Make sure the version in the query string matches the old ping format version.
    let params = request.queryString.split("&");
    Assert.ok(params.find(p => p == "v=1"));

    receivedPings++;
  });

  await TelemetryController.testReset();
  await TelemetrySend.testWaitOnOutgoingPings();
  Assert.equal(receivedPings, 1, "We must receive a ping in the old format.");

  await TelemetryStorage.testClearPendingPings();
  PingServer.resetPingHandler();
});

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

  // Disable upload so pings don't get sent and removed from the pending pings directory.
  Services.prefs.setBoolPref(
    TelemetryUtils.Preferences.FhrUploadEnabled,
    false
  );

  // Remove all the pending pings then startup and wait for the cleanup task to complete.
  // There should be nothing to remove.
  await TelemetryStorage.testClearPendingPings();
  await TelemetryController.testReset();
  await TelemetrySend.testWaitOnOutgoingPings();
  await TelemetryStorage.testPendingQuotaTaskPromise();

  // Remove the pending optout ping generated when flipping FHR upload off.
  await TelemetryStorage.testClearPendingPings();

  let expectedPrunedPings = [];
  let expectedNotPrunedPings = [];

  let checkPendingPings = async function () {
    // Check that the pruned pings are not on disk anymore.
    for (let prunedPingId of expectedPrunedPings) {
      await Assert.rejects(
        TelemetryStorage.loadPendingPing(prunedPingId),
        /TelemetryStorage.loadPendingPing - no ping with id/,
        "Ping " + prunedPingId + " should have been pruned."
      );
      const pingPath = getSavePathForPingId(prunedPingId);
      Assert.ok(
        !(await IOUtils.exists(pingPath)),
        "The ping should not be on the disk anymore."
      );
    }

    // Check that the expected pings are there.
    for (let expectedPingId of expectedNotPrunedPings) {
      Assert.ok(
        await TelemetryStorage.loadPendingPing(expectedPingId),
        "Ping" + expectedPingId + " should be among the pending pings."
      );
    }
  };

  let pendingPingsInfo = [];
  let pingsSizeInBytes = 0;

  // Create 10 pings to test the pending pings quota.
  for (let days = 1; days < 11; days++) {
    const date = fakeNow(2010, 1, days, 1, 1, 0);
    const pingId = await TelemetryController.addPendingPing(PING_TYPE, {}, {});

    // Find the size of the ping.
    const pingFilePath = getSavePathForPingId(pingId);
    const pingSize = (await IOUtils.stat(pingFilePath)).size;
    // Add the info at the beginning of the array, so that most recent pings come first.
    pendingPingsInfo.unshift({
      id: pingId,
      size: pingSize,
      timestamp: date.getTime(),
    });

    // Set the last modification date.
    await IOUtils.setModificationTime(pingFilePath, date.getTime());

    // Add it to the pending ping directory size.
    pingsSizeInBytes += pingSize;
  }

  // We need to test the pending pings size before we hit the quota, otherwise a special
  // value is recorded.
  Telemetry.getHistogramById("TELEMETRY_PENDING_PINGS_SIZE_MB").clear();
  Telemetry.getHistogramById(
    "TELEMETRY_PENDING_PINGS_EVICTED_OVER_QUOTA"
  ).clear();
  Telemetry.getHistogramById(
    "TELEMETRY_PENDING_EVICTING_OVER_QUOTA_MS"
  ).clear();

  await TelemetryController.testReset();
  await TelemetryStorage.testPendingQuotaTaskPromise();

  // Check that the correct values for quota probes are reported when no quota is hit.
  let h = Telemetry.getHistogramById(
    "TELEMETRY_PENDING_PINGS_SIZE_MB"
  ).snapshot();
  Assert.equal(
    h.sum,
    Math.round(pingsSizeInBytes / 1024 / 1024),
    "Telemetry must report the correct pending pings directory size."
  );
  h = Telemetry.getHistogramById(
    "TELEMETRY_PENDING_PINGS_EVICTED_OVER_QUOTA"
  ).snapshot();
  Assert.equal(
    h.sum,
    0,
    "Telemetry must report 0 evictions if quota is not hit."
  );
  h = Telemetry.getHistogramById(
    "TELEMETRY_PENDING_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 = pingsSizeInBytes * 0.8;
  fakePendingPingsQuota(testQuotaInBytes);

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

  for (let pingInfo of pendingPingsInfo) {
    sizeInBytes += pingInfo.size;
    if (sizeInBytes >= safeQuotaSize) {
      pingsOutsideQuota.push(pingInfo.id);
      continue;
    }
    pingsWithinQuota.push(pingInfo.id);
  }

  expectedNotPrunedPings = pingsWithinQuota;
  expectedPrunedPings = pingsOutsideQuota;

  // Reset TelemetryController to start the pending pings cleanup.
  await TelemetryController.testReset();
  await TelemetryStorage.testPendingQuotaTaskPromise();
  await checkPendingPings();

  h = Telemetry.getHistogramById(
    "TELEMETRY_PENDING_PINGS_EVICTED_OVER_QUOTA"
  ).snapshot();
  Assert.equal(
    h.sum,
    pingsOutsideQuota.length,
    "Telemetry must correctly report the over quota pings evicted from the pending pings directory."
  );
  h = Telemetry.getHistogramById("TELEMETRY_PENDING_PINGS_SIZE_MB").snapshot();
  Assert.equal(
    h.sum,
    17,
    "Pending pings 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.testPendingQuotaTaskPromise();
  await checkPendingPings();

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

  // Reset the histograms.
  Telemetry.getHistogramById("TELEMETRY_PING_SIZE_EXCEEDED_PENDING").clear();
  Telemetry.getHistogramById(
    "TELEMETRY_DISCARDED_PENDING_PINGS_SIZE_MB"
  ).clear();

  // Try to manually load the oversized ping.
  await Assert.rejects(
    TelemetryStorage.loadPendingPing(OVERSIZED_PING_ID),
    /loadPendingPing - exceeded the maximum ping size/,
    "The oversized ping should have been pruned."
  );
  Assert.ok(
    !(await IOUtils.exists(getSavePathForPingId(OVERSIZED_PING_ID))),
    "The ping should not be on the disk anymore."
  );

  // Make sure we're correctly updating the related histograms.
  h = Telemetry.getHistogramById(
    "TELEMETRY_PING_SIZE_EXCEEDED_PENDING"
  ).snapshot();
  Assert.equal(
    h.sum,
    1,
    "Telemetry must report 1 oversized ping in the pending pings directory."
  );
  h = Telemetry.getHistogramById(
    "TELEMETRY_DISCARDED_PENDING_PINGS_SIZE_MB"
  ).snapshot();
  Assert.equal(h.values[2], 1, "Telemetry must report a 2MB, oversized, ping.");

  // Save the ping again to check if it gets pruned when scanning the pings directory.
  await TelemetryStorage.savePendingPing(OVERSIZED_PING);
  expectedPrunedPings.push(OVERSIZED_PING_ID);

  // Scan the pending pings directory.
  await TelemetryController.testReset();
  await TelemetryStorage.testPendingQuotaTaskPromise();
  await checkPendingPings();

  // Make sure we're correctly updating the related histograms.
  h = Telemetry.getHistogramById(
    "TELEMETRY_PING_SIZE_EXCEEDED_PENDING"
  ).snapshot();
  Assert.equal(
    h.sum,
    2,
    "Telemetry must report 1 oversized ping in the pending pings directory."
  );
  h = Telemetry.getHistogramById(
    "TELEMETRY_DISCARDED_PENDING_PINGS_SIZE_MB"
  ).snapshot();
  Assert.equal(
    h.values[2],
    2,
    "Telemetry must report two 2MB, oversized, pings."
  );

  Services.prefs.setBoolPref(TelemetryUtils.Preferences.FhrUploadEnabled, true);
});

add_task(async function teardown() {
  await PingServer.stop();
});
