/* import-globals-from ../../../common/tests/unit/head_helpers.js */

const { AppConstants } = ChromeUtils.importESModule(
  "resource://gre/modules/AppConstants.sys.mjs"
);
const { setTimeout } = ChromeUtils.importESModule(
  "resource://gre/modules/Timer.sys.mjs"
);

const { UptakeTelemetry, Policy } = ChromeUtils.importESModule(
  "resource://services-common/uptake-telemetry.sys.mjs"
);
const { RemoteSettingsClient } = ChromeUtils.importESModule(
  "resource://services-settings/RemoteSettingsClient.sys.mjs"
);
const { pushBroadcastService } = ChromeUtils.importESModule(
  "resource://gre/modules/PushBroadcastService.sys.mjs"
);
const { SyncHistory } = ChromeUtils.importESModule(
  "resource://services-settings/SyncHistory.sys.mjs"
);
const { RemoteSettings, remoteSettingsBroadcastHandler, BROADCAST_ID } =
  ChromeUtils.importESModule(
    "resource://services-settings/remote-settings.sys.mjs"
  );
const { Utils } = ChromeUtils.importESModule(
  "resource://services-settings/Utils.sys.mjs"
);
const { TelemetryTestUtils } = ChromeUtils.importESModule(
  "resource://testing-common/TelemetryTestUtils.sys.mjs"
);

const IS_ANDROID = AppConstants.platform == "android";

const PREF_SETTINGS_SERVER = "services.settings.server";
const PREF_SETTINGS_SERVER_BACKOFF = "services.settings.server.backoff";
const PREF_LAST_UPDATE = "services.settings.last_update_seconds";
const PREF_LAST_ETAG = "services.settings.last_etag";
const PREF_CLOCK_SKEW_SECONDS = "services.settings.clock_skew_seconds";

// Telemetry report result.
const TELEMETRY_COMPONENT = "remotesettings";
const TELEMETRY_SOURCE_POLL = "settings-changes-monitoring";
const TELEMETRY_SOURCE_SYNC = "settings-sync";
const CHANGES_PATH = "/v1" + Utils.CHANGES_PATH;

var server;

async function clear_state() {
  // set up prefs so the kinto updater talks to the test server
  Services.prefs.setCharPref(
    PREF_SETTINGS_SERVER,
    `http://localhost:${server.identity.primaryPort}/v1`
  );

  // set some initial values so we can check these are updated appropriately
  Services.prefs.setIntPref(PREF_LAST_UPDATE, 0);
  Services.prefs.setIntPref(PREF_CLOCK_SKEW_SECONDS, 0);
  Services.prefs.clearUserPref(PREF_LAST_ETAG);

  // Clear events snapshot.
  TelemetryTestUtils.assertEvents([], {}, { process: "dummy" });

  // Clear sync history.
  await new SyncHistory("").clear();
}

function serveChangesEntries(serverTime, entriesOrFunc) {
  return (request, response) => {
    response.setStatusLine(null, 200, "OK");
    response.setHeader("Content-Type", "application/json; charset=UTF-8");
    response.setHeader("Date", new Date(serverTime).toUTCString());
    const entries =
      typeof entriesOrFunc == "function" ? entriesOrFunc() : entriesOrFunc;
    const latest = entries[0]?.last_modified ?? 42;
    if (entries.length) {
      response.setHeader("ETag", `"${latest}"`);
    }
    response.write(JSON.stringify({ timestamp: latest, changes: entries }));
  };
}

function run_test() {
  // Set up an HTTP Server
  server = new HttpServer();
  server.start(-1);

  // Pretend we are in nightly channel to make sure all telemetry events are sent.
  let oldGetChannel = Policy.getChannel;
  Policy.getChannel = () => "nightly";

  run_next_test();

  registerCleanupFunction(() => {
    Policy.getChannel = oldGetChannel;
    server.stop(() => {});
  });
}

add_task(clear_state);

add_task(async function test_an_event_is_sent_on_start() {
  server.registerPathHandler(CHANGES_PATH, (request, response) => {
    response.write(JSON.stringify({ timestamp: 42, changes: [] }));
    response.setHeader("Content-Type", "application/json; charset=UTF-8");
    response.setHeader("ETag", '"42"');
    response.setHeader("Date", new Date().toUTCString());
    response.setStatusLine(null, 200, "OK");
  });
  let notificationObserved = null;
  const observer = {
    observe(aSubject, aTopic, aData) {
      Services.obs.removeObserver(this, "remote-settings:changes-poll-start");
      notificationObserved = JSON.parse(aData);
    },
  };
  Services.obs.addObserver(observer, "remote-settings:changes-poll-start");

  await RemoteSettings.pollChanges({ expectedTimestamp: 13 });

  Assert.equal(
    notificationObserved.expectedTimestamp,
    13,
    "start notification should have been observed"
  );
});
add_task(clear_state);

add_task(async function test_offline_is_reported_if_relevant() {
  const startSnapshot = getUptakeTelemetrySnapshot(
    TELEMETRY_COMPONENT,
    TELEMETRY_SOURCE_POLL
  );
  const offlineBackup = Services.io.offline;
  try {
    Services.io.offline = true;

    await RemoteSettings.pollChanges();

    const endSnapshot = getUptakeTelemetrySnapshot(
      TELEMETRY_COMPONENT,
      TELEMETRY_SOURCE_POLL
    );
    const expectedIncrements = {
      [UptakeTelemetry.STATUS.NETWORK_OFFLINE_ERROR]: 1,
    };
    checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements);
  } finally {
    Services.io.offline = offlineBackup;
  }
});
add_task(clear_state);

add_task(async function test_check_success() {
  const serverTime = 8000;

  server.registerPathHandler(
    CHANGES_PATH,
    serveChangesEntries(serverTime, [
      {
        id: "330a0c5f-fadf-ff0b-40c8-4eb0d924ff6a",
        last_modified: 1100,
        host: "localhost",
        bucket: "some-other-bucket",
        collection: "test-collection",
      },
      {
        id: "254cbb9e-6888-4d9f-8e60-58b74faa8778",
        last_modified: 1000,
        host: "localhost",
        bucket: "test-bucket",
        collection: "test-collection",
      },
    ])
  );

  // add a test kinto client that will respond to lastModified information
  // for a collection called 'test-collection'.
  // Let's use a bucket that is not the default one (`test-bucket`).
  const c = RemoteSettings("test-collection", {
    bucketName: "test-bucket",
  });
  let maybeSyncCalled = false;
  c.maybeSync = () => {
    maybeSyncCalled = true;
  };

  // Ensure that the remote-settings:changes-poll-end notification works
  let notificationObserved = false;
  const observer = {
    observe(aSubject, aTopic, aData) {
      Services.obs.removeObserver(this, "remote-settings:changes-poll-end");
      notificationObserved = true;
    },
  };
  Services.obs.addObserver(observer, "remote-settings:changes-poll-end");

  await RemoteSettings.pollChanges();

  // It didn't fail, hence we are sure that the unknown collection ``some-other-bucket/test-collection``
  // was ignored, otherwise it would have tried to reach the network.

  Assert.ok(maybeSyncCalled, "maybeSync was called");
  Assert.ok(notificationObserved, "a notification should have been observed");
  // Last timestamp was saved. An ETag header value is a quoted string.
  Assert.equal(Services.prefs.getCharPref(PREF_LAST_ETAG), '"1100"');
  // check the last_update is updated
  Assert.equal(Services.prefs.getIntPref(PREF_LAST_UPDATE), serverTime / 1000);

  // ensure that we've accumulated the correct telemetry
  TelemetryTestUtils.assertEvents(
    [
      [
        "uptake.remotecontent.result",
        "uptake",
        "remotesettings",
        UptakeTelemetry.STATUS.SUCCESS,
        {
          source: TELEMETRY_SOURCE_POLL,
          trigger: "manual",
        },
      ],
      [
        "uptake.remotecontent.result",
        "uptake",
        "remotesettings",
        UptakeTelemetry.STATUS.SUCCESS,
        {
          source: TELEMETRY_SOURCE_SYNC,
          trigger: "manual",
        },
      ],
    ],
    TELEMETRY_EVENTS_FILTERS
  );
});
add_task(clear_state);

add_task(async function test_update_timer_interface() {
  const remoteSettings = Cc["@mozilla.org/services/settings;1"].getService(
    Ci.nsITimerCallback
  );

  const serverTime = 8000;
  server.registerPathHandler(
    CHANGES_PATH,
    serveChangesEntries(serverTime, [
      {
        id: "028261ad-16d4-40c2-a96a-66f72914d125",
        last_modified: 42,
        host: "localhost",
        bucket: "main",
        collection: "whatever-collection",
      },
    ])
  );

  await new Promise(resolve => {
    const e = "remote-settings:changes-poll-end";
    const changesPolledObserver = {
      observe(aSubject, aTopic, aData) {
        Services.obs.removeObserver(this, e);
        resolve();
      },
    };
    Services.obs.addObserver(changesPolledObserver, e);
    remoteSettings.notify(null);
  });

  // Everything went fine.
  Assert.equal(Services.prefs.getCharPref(PREF_LAST_ETAG), '"42"');
  Assert.equal(Services.prefs.getIntPref(PREF_LAST_UPDATE), serverTime / 1000);
});
add_task(clear_state);

add_task(async function test_check_up_to_date() {
  // Simulate a poll with up-to-date collection.
  const startSnapshot = getUptakeTelemetrySnapshot(
    TELEMETRY_COMPONENT,
    TELEMETRY_SOURCE_POLL
  );

  const serverTime = 4000;
  server.registerPathHandler(CHANGES_PATH, serveChangesEntries(serverTime, []));

  Services.prefs.setCharPref(PREF_LAST_ETAG, '"1100"');

  // Ensure that the remote-settings:changes-poll-end notification is sent.
  let notificationObserved = false;
  const observer = {
    observe(aSubject, aTopic, aData) {
      Services.obs.removeObserver(this, "remote-settings:changes-poll-end");
      notificationObserved = true;
    },
  };
  Services.obs.addObserver(observer, "remote-settings:changes-poll-end");

  // If server has no change, maybeSync() is not called.
  let maybeSyncCalled = false;
  const c = RemoteSettings("test-collection", {
    bucketName: "test-bucket",
  });
  c.maybeSync = () => {
    maybeSyncCalled = true;
  };

  await RemoteSettings.pollChanges();

  Assert.ok(notificationObserved, "a notification should have been observed");
  Assert.ok(!maybeSyncCalled, "maybeSync should not be called");
  // Last update is overwritten
  Assert.equal(Services.prefs.getIntPref(PREF_LAST_UPDATE), serverTime / 1000);

  // ensure that we've accumulated the correct telemetry
  const endSnapshot = getUptakeTelemetrySnapshot(
    TELEMETRY_COMPONENT,
    TELEMETRY_SOURCE_POLL
  );
  const expectedIncrements = {
    [UptakeTelemetry.STATUS.UP_TO_DATE]: 1,
  };
  checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements);
});
add_task(clear_state);

add_task(async function test_expected_timestamp() {
  function withCacheBust(request, response) {
    const entries = [
      {
        id: "695c2407-de79-4408-91c7-70720dd59d78",
        last_modified: 1100,
        host: "localhost",
        bucket: "main",
        collection: "with-cache-busting",
      },
    ];
    if (
      request.queryString.includes(`_expected=${encodeURIComponent('"42"')}`)
    ) {
      response.write(
        JSON.stringify({
          timestamp: 1110,
          changes: entries,
        })
      );
    }
    response.setHeader("Content-Type", "application/json; charset=UTF-8");
    response.setHeader("ETag", '"1100"');
    response.setHeader("Date", new Date().toUTCString());
    response.setStatusLine(null, 200, "OK");
  }
  server.registerPathHandler(CHANGES_PATH, withCacheBust);

  const c = RemoteSettings("with-cache-busting");
  let maybeSyncCalled = false;
  c.maybeSync = () => {
    maybeSyncCalled = true;
  };

  await RemoteSettings.pollChanges({ expectedTimestamp: '"42"' });

  Assert.ok(maybeSyncCalled, "maybeSync was called");
});
add_task(clear_state);

add_task(async function test_client_last_check_is_saved() {
  server.registerPathHandler(CHANGES_PATH, (request, response) => {
    response.write(
      JSON.stringify({
        timestamp: 42,
        changes: [
          {
            id: "695c2407-de79-4408-91c7-70720dd59d78",
            last_modified: 1100,
            host: "localhost",
            bucket: "main",
            collection: "models-recipes",
          },
        ],
      })
    );
    response.setHeader("Content-Type", "application/json; charset=UTF-8");
    response.setHeader("ETag", '"42"');
    response.setHeader("Date", new Date().toUTCString());
    response.setStatusLine(null, 200, "OK");
  });

  const c = RemoteSettings("models-recipes");
  c.maybeSync = () => {};

  equal(
    c.lastCheckTimePref,
    "services.settings.main.models-recipes.last_check"
  );
  Services.prefs.setIntPref(c.lastCheckTimePref, 0);

  await RemoteSettings.pollChanges({ expectedTimestamp: '"42"' });

  notEqual(Services.prefs.getIntPref(c.lastCheckTimePref), 0);
});
add_task(clear_state);

const TELEMETRY_EVENTS_FILTERS = {
  category: "uptake.remotecontent.result",
  method: "uptake",
};
add_task(async function test_age_of_data_is_reported_in_uptake_status() {
  const serverTime = 1552323900000;
  const recordsTimestamp = serverTime - 3600 * 1000;
  server.registerPathHandler(
    CHANGES_PATH,
    serveChangesEntries(serverTime, [
      {
        id: "b6ba7fab-a40a-4d03-a4af-6b627f3c5b36",
        last_modified: recordsTimestamp,
        host: "localhost",
        bucket: "main",
        collection: "some-entry",
      },
    ])
  );

  await RemoteSettings.pollChanges();

  TelemetryTestUtils.assertEvents(
    [
      [
        "uptake.remotecontent.result",
        "uptake",
        "remotesettings",
        UptakeTelemetry.STATUS.SUCCESS,
        {
          source: TELEMETRY_SOURCE_POLL,
          age: "3600",
          trigger: "manual",
        },
      ],
      [
        "uptake.remotecontent.result",
        "uptake",
        "remotesettings",
        UptakeTelemetry.STATUS.SUCCESS,
        {
          source: TELEMETRY_SOURCE_SYNC,
          duration: () => true,
          trigger: "manual",
          timestamp: `"${recordsTimestamp}"`,
        },
      ],
    ],
    TELEMETRY_EVENTS_FILTERS
  );
});
add_task(clear_state);

add_task(
  async function test_synchronization_duration_is_reported_in_uptake_status() {
    server.registerPathHandler(
      CHANGES_PATH,
      serveChangesEntries(10000, [
        {
          id: "b6ba7fab-a40a-4d03-a4af-6b627f3c5b36",
          last_modified: 42,
          host: "localhost",
          bucket: "main",
          collection: "some-entry",
        },
      ])
    );
    const c = RemoteSettings("some-entry");
    // Simulate a synchronization that lasts 1 sec.
    // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
    c.maybeSync = () => new Promise(resolve => setTimeout(resolve, 1000));

    await RemoteSettings.pollChanges();

    TelemetryTestUtils.assertEvents(
      [
        [
          "uptake.remotecontent.result",
          "uptake",
          "remotesettings",
          "success",
          {
            source: TELEMETRY_SOURCE_POLL,
            age: () => true,
            trigger: "manual",
          },
        ],
        [
          "uptake.remotecontent.result",
          "uptake",
          "remotesettings",
          "success",
          {
            source: TELEMETRY_SOURCE_SYNC,
            duration: v => v >= 1000,
            trigger: "manual",
          },
        ],
      ],
      TELEMETRY_EVENTS_FILTERS
    );
  }
);
add_task(clear_state);

add_task(async function test_success_with_partial_list() {
  function partialList(request, response) {
    const entries = [
      {
        id: "028261ad-16d4-40c2-a96a-66f72914d125",
        last_modified: 43,
        host: "localhost",
        bucket: "main",
        collection: "cid-1",
      },
      {
        id: "98a34576-bcd6-423f-abc2-1d290b776ed8",
        last_modified: 42,
        host: "localhost",
        bucket: "main",
        collection: "poll-test-collection",
      },
    ];
    if (request.queryString.includes(`_since=${encodeURIComponent('"42"')}`)) {
      response.write(
        JSON.stringify({
          timestamp: 43,
          changes: entries.slice(0, 1),
        })
      );
    } else {
      response.write(
        JSON.stringify({
          timestamp: 42,
          changes: entries,
        })
      );
    }
    response.setHeader("Content-Type", "application/json; charset=UTF-8");
    response.setHeader("Date", new Date().toUTCString());
    response.setStatusLine(null, 200, "OK");
  }
  server.registerPathHandler(CHANGES_PATH, partialList);

  const c = RemoteSettings("poll-test-collection");
  let maybeSyncCount = 0;
  c.maybeSync = () => {
    maybeSyncCount++;
  };

  await RemoteSettings.pollChanges();
  await RemoteSettings.pollChanges();

  // On the second call, the server does not mention the poll-test-collection
  // and maybeSync() is not called.
  Assert.equal(maybeSyncCount, 1, "maybeSync should not be called twice");
});
add_task(clear_state);

add_task(async function test_full_polling() {
  server.registerPathHandler(
    CHANGES_PATH,
    serveChangesEntries(10000, [
      {
        id: "b6ba7fab-a40a-4d03-a4af-6b627f3c5b36",
        last_modified: 42,
        host: "localhost",
        bucket: "main",
        collection: "poll-test-collection",
      },
    ])
  );

  const c = RemoteSettings("poll-test-collection");
  let maybeSyncCount = 0;
  c.maybeSync = () => {
    maybeSyncCount++;
  };

  await RemoteSettings.pollChanges();
  await RemoteSettings.pollChanges({ full: true });

  // Since the second call is full, clients are called
  Assert.equal(maybeSyncCount, 2, "maybeSync should be called twice");
});
add_task(clear_state);

add_task(async function test_server_bad_json() {
  const startSnapshot = getUptakeTelemetrySnapshot(
    TELEMETRY_COMPONENT,
    TELEMETRY_SOURCE_POLL
  );

  function simulateBadJSON(request, response) {
    response.setHeader("Content-Type", "application/json; charset=UTF-8");
    response.write("<html></html>");
    response.setStatusLine(null, 200, "OK");
  }
  server.registerPathHandler(CHANGES_PATH, simulateBadJSON);

  let error;
  try {
    await RemoteSettings.pollChanges();
  } catch (e) {
    error = e;
  }
  Assert.ok(/JSON.parse: unexpected character/.test(error.message));

  const endSnapshot = getUptakeTelemetrySnapshot(
    TELEMETRY_COMPONENT,
    TELEMETRY_SOURCE_POLL
  );
  const expectedIncrements = {
    [UptakeTelemetry.STATUS.PARSE_ERROR]: 1,
  };
  checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements);
});
add_task(clear_state);

add_task(async function test_server_bad_content_type() {
  const startSnapshot = getUptakeTelemetrySnapshot(
    TELEMETRY_COMPONENT,
    TELEMETRY_SOURCE_POLL
  );

  function simulateBadContentType(request, response) {
    response.setHeader("Content-Type", "text/html");
    response.write("<html></html>");
    response.setStatusLine(null, 200, "OK");
  }
  server.registerPathHandler(CHANGES_PATH, simulateBadContentType);

  let error;
  try {
    await RemoteSettings.pollChanges();
  } catch (e) {
    error = e;
  }
  Assert.ok(/Unexpected content-type/.test(error.message));

  const endSnapshot = getUptakeTelemetrySnapshot(
    TELEMETRY_COMPONENT,
    TELEMETRY_SOURCE_POLL
  );
  const expectedIncrements = {
    [UptakeTelemetry.STATUS.CONTENT_ERROR]: 1,
  };
  checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements);
});
add_task(clear_state);

add_task(async function test_server_404_response() {
  function simulateDummy404(request, response) {
    response.setHeader("Content-Type", "text/html; charset=UTF-8");
    response.write("<html></html>");
    response.setStatusLine(null, 404, "OK");
  }
  server.registerPathHandler(CHANGES_PATH, simulateDummy404);

  await RemoteSettings.pollChanges(); // Does not fail when running from tests.
});
add_task(clear_state);

add_task(async function test_server_error() {
  const startSnapshot = getUptakeTelemetrySnapshot(
    TELEMETRY_COMPONENT,
    TELEMETRY_SOURCE_POLL
  );

  // Simulate a server error.
  function simulateErrorResponse(request, response) {
    response.setHeader("Date", new Date(3000).toUTCString());
    response.setHeader("Content-Type", "application/json; charset=UTF-8");
    response.write(
      JSON.stringify({
        code: 503,
        errno: 999,
        error: "Service Unavailable",
      })
    );
    response.setStatusLine(null, 503, "Service Unavailable");
  }
  server.registerPathHandler(CHANGES_PATH, simulateErrorResponse);

  let notificationObserved = false;
  const observer = {
    observe(aSubject, aTopic, aData) {
      Services.obs.removeObserver(this, "remote-settings:changes-poll-end");
      notificationObserved = true;
    },
  };
  Services.obs.addObserver(observer, "remote-settings:changes-poll-end");
  Services.prefs.setIntPref(PREF_LAST_UPDATE, 42);

  // pollChanges() fails with adequate error and no notification.
  let error;
  try {
    await RemoteSettings.pollChanges();
  } catch (e) {
    error = e;
  }

  Assert.ok(
    !notificationObserved,
    "a notification should not have been observed"
  );
  Assert.ok(/Polling for changes failed/.test(error.message));
  // When an error occurs, last update was not overwritten.
  Assert.equal(Services.prefs.getIntPref(PREF_LAST_UPDATE), 42);
  // ensure that we've accumulated the correct telemetry
  const endSnapshot = getUptakeTelemetrySnapshot(
    TELEMETRY_COMPONENT,
    TELEMETRY_SOURCE_POLL
  );
  const expectedIncrements = {
    [UptakeTelemetry.STATUS.SERVER_ERROR]: 1,
  };
  checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements);
});
add_task(clear_state);

add_task(async function test_server_error_5xx() {
  const startSnapshot = getUptakeTelemetrySnapshot(
    TELEMETRY_COMPONENT,
    TELEMETRY_SOURCE_POLL
  );

  function simulateErrorResponse(request, response) {
    response.setHeader("Date", new Date(3000).toUTCString());
    response.setHeader("Content-Type", "text/html; charset=UTF-8");
    response.write("<html></html>");
    response.setStatusLine(null, 504, "Gateway Timeout");
  }
  server.registerPathHandler(CHANGES_PATH, simulateErrorResponse);

  try {
    await RemoteSettings.pollChanges();
  } catch (e) {}

  const endSnapshot = getUptakeTelemetrySnapshot(
    TELEMETRY_COMPONENT,
    TELEMETRY_SOURCE_POLL
  );
  const expectedIncrements = {
    [UptakeTelemetry.STATUS.SERVER_ERROR]: 1,
  };
  checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements);
});
add_task(clear_state);

add_task(async function test_server_error_4xx() {
  function simulateErrorResponse(request, response) {
    response.setHeader("Date", new Date(3000).toUTCString());
    response.setHeader("Content-Type", "application/json; charset=UTF-8");
    if (request.queryString.includes(`_since=${encodeURIComponent('"abc"')}`)) {
      response.setStatusLine(null, 400, "Bad Request");
      response.write(JSON.stringify({}));
    } else {
      response.setStatusLine(null, 200, "OK");
      response.write(JSON.stringify({ changes: [] }));
    }
  }
  server.registerPathHandler(CHANGES_PATH, simulateErrorResponse);

  Services.prefs.setCharPref(PREF_LAST_ETAG, '"abc"');

  let error;
  try {
    await RemoteSettings.pollChanges();
  } catch (e) {
    error = e;
  }

  Assert.ok(error.message.includes("400 Bad Request"), "Polling failed");
  Assert.ok(
    !Services.prefs.prefHasUserValue(PREF_LAST_ETAG),
    "Last ETag pref was cleared"
  );

  await RemoteSettings.pollChanges(); // Does not raise.
});
add_task(clear_state);

add_task(async function test_client_error() {
  const startSnapshot = getUptakeTelemetrySnapshot(
    TELEMETRY_COMPONENT,
    TELEMETRY_SOURCE_SYNC
  );

  const collectionDetails = {
    id: "b6ba7fab-a40a-4d03-a4af-6b627f3c5b36",
    last_modified: 42,
    host: "localhost",
    bucket: "main",
    collection: "some-entry",
  };
  server.registerPathHandler(
    CHANGES_PATH,
    serveChangesEntries(10000, [collectionDetails])
  );
  const c = RemoteSettings("some-entry");
  c.maybeSync = () => {
    throw new RemoteSettingsClient.CorruptedDataError("main/some-entry");
  };

  let notificationsObserved = [];
  const observer = {
    observe(aSubject, aTopic, aData) {
      Services.obs.removeObserver(this, aTopic);
      notificationsObserved.push([aTopic, aSubject.wrappedJSObject]);
    },
  };
  Services.obs.addObserver(observer, "remote-settings:changes-poll-end");
  Services.obs.addObserver(observer, "remote-settings:sync-error");
  Services.prefs.setIntPref(PREF_LAST_ETAG, 42);

  // pollChanges() fails with adequate error and a sync-error notification.
  let error;
  try {
    await RemoteSettings.pollChanges();
  } catch (e) {
    error = e;
  }

  Assert.equal(
    notificationsObserved.length,
    1,
    "only the error notification should not have been observed"
  );
  console.log(notificationsObserved);
  let [topicObserved, subjectObserved] = notificationsObserved[0];
  Assert.equal(topicObserved, "remote-settings:sync-error");
  Assert.ok(
    subjectObserved.error instanceof RemoteSettingsClient.CorruptedDataError,
    `original error is provided (got ${subjectObserved.error})`
  );
  Assert.deepEqual(
    subjectObserved.error.details,
    collectionDetails,
    "information about collection is provided"
  );

  Assert.ok(/Corrupted/.test(error.message), "original client error is thrown");
  // When an error occurs, last etag was not overwritten.
  Assert.equal(Services.prefs.getIntPref(PREF_LAST_ETAG), 42);
  // ensure that we've accumulated the correct telemetry
  const endSnapshot = getUptakeTelemetrySnapshot(
    TELEMETRY_COMPONENT,
    TELEMETRY_SOURCE_SYNC
  );
  const expectedIncrements = {
    [UptakeTelemetry.STATUS.SYNC_ERROR]: 1,
  };
  checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements);
});
add_task(clear_state);

add_task(async function test_sync_success_is_stored_in_history() {
  const collectionDetails = {
    last_modified: 444,
    bucket: "main",
    collection: "desktop-manager",
  };
  server.registerPathHandler(
    CHANGES_PATH,
    serveChangesEntries(10000, [collectionDetails])
  );
  const c = RemoteSettings("desktop-manager");
  c.maybeSync = () => {};
  try {
    await RemoteSettings.pollChanges({ expectedTimestamp: 555 });
  } catch (e) {}

  const { history } = await RemoteSettings.inspect();

  Assert.deepEqual(history, {
    [TELEMETRY_SOURCE_SYNC]: [
      {
        timestamp: 444,
        status: "success",
        infos: {},
        datetime: new Date(444),
      },
    ],
  });
});
add_task(clear_state);

add_task(async function test_sync_error_is_stored_in_history() {
  const collectionDetails = {
    last_modified: 1337,
    bucket: "main",
    collection: "desktop-manager",
  };
  server.registerPathHandler(
    CHANGES_PATH,
    serveChangesEntries(10000, [collectionDetails])
  );
  const c = RemoteSettings("desktop-manager");
  c.maybeSync = () => {
    throw new RemoteSettingsClient.MissingSignatureError(
      "main/desktop-manager"
    );
  };
  try {
    await RemoteSettings.pollChanges({ expectedTimestamp: 123456 });
  } catch (e) {}

  const { history } = await RemoteSettings.inspect();

  Assert.deepEqual(history, {
    [TELEMETRY_SOURCE_SYNC]: [
      {
        timestamp: 1337,
        status: "sync_error",
        infos: {
          expectedTimestamp: 123456,
          errorName: "MissingSignatureError",
        },
        datetime: new Date(1337),
      },
    ],
  });
});
add_task(clear_state);

add_task(
  async function test_sync_broken_signal_is_sent_on_consistent_failure() {
    const startSnapshot = getUptakeTelemetrySnapshot(
      TELEMETRY_COMPONENT,
      TELEMETRY_SOURCE_POLL
    );
    // Wait for the "sync-broken-error" notification.
    let notificationObserved = false;
    const observer = {
      observe(aSubject, aTopic, aData) {
        notificationObserved = true;
      },
    };
    Services.obs.addObserver(observer, "remote-settings:broken-sync-error");
    // Register a client with a failing sync method.
    const c = RemoteSettings("desktop-manager");
    c.maybeSync = () => {
      throw new RemoteSettingsClient.InvalidSignatureError(
        "main/desktop-manager"
      );
    };
    // Simulate a response whose ETag gets incremented on each call
    // (in order to generate several history entries, indexed by timestamp).
    let timestamp = 1337;
    server.registerPathHandler(
      CHANGES_PATH,
      serveChangesEntries(10000, () => {
        return [
          {
            last_modified: ++timestamp,
            bucket: "main",
            collection: "desktop-manager",
          },
        ];
      })
    );

    // Now obtain several failures in a row (less than threshold).
    for (var i = 0; i < 9; i++) {
      try {
        await RemoteSettings.pollChanges();
      } catch (e) {}
    }
    Assert.ok(!notificationObserved, "Not notified yet");

    // Fail again once. Will now notify.
    try {
      await RemoteSettings.pollChanges();
    } catch (e) {}
    Assert.ok(notificationObserved, "Broken sync notified");
    // Uptake event to notify broken sync is sent.
    const endSnapshot = getUptakeTelemetrySnapshot(
      TELEMETRY_COMPONENT,
      TELEMETRY_SOURCE_SYNC
    );
    const expectedIncrements = {
      [UptakeTelemetry.STATUS.SYNC_ERROR]: 10,
      [UptakeTelemetry.STATUS.SYNC_BROKEN_ERROR]: 1,
    };
    checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements);

    // Synchronize successfully.
    notificationObserved = false;
    const failingSync = c.maybeSync;
    c.maybeSync = () => {};
    await RemoteSettings.pollChanges();

    const { history } = await RemoteSettings.inspect();
    Assert.equal(
      history[TELEMETRY_SOURCE_SYNC][0].status,
      UptakeTelemetry.STATUS.SUCCESS,
      "Last sync is success"
    );
    Assert.ok(!notificationObserved, "Not notified after success");

    // Now fail again. Broken sync isn't notified, we need several in a row.
    c.maybeSync = failingSync;
    try {
      await RemoteSettings.pollChanges();
    } catch (e) {}
    Assert.ok(!notificationObserved, "Not notified on single error");
    Services.obs.removeObserver(observer, "remote-settings:broken-sync-error");
  }
);
add_task(clear_state);

add_task(async function test_check_clockskew_is_updated() {
  const serverTime = 2000;

  function serverResponse(request, response) {
    response.setHeader("Content-Type", "application/json; charset=UTF-8");
    response.setHeader("Date", new Date(serverTime).toUTCString());
    response.write(JSON.stringify({ timestamp: 42, changes: [] }));
    response.setStatusLine(null, 200, "OK");
  }
  server.registerPathHandler(CHANGES_PATH, serverResponse);

  let startTime = Date.now();

  await RemoteSettings.pollChanges();

  // How does the clock difference look?
  let endTime = Date.now();
  let clockDifference = Services.prefs.getIntPref(PREF_CLOCK_SKEW_SECONDS);
  // we previously set the serverTime to 2 (seconds past epoch)
  Assert.ok(
    clockDifference <= endTime / 1000 &&
      clockDifference >= Math.floor(startTime / 1000) - serverTime / 1000
  );

  // check negative clock skew times
  // set to a time in the future
  server.registerPathHandler(
    CHANGES_PATH,
    serveChangesEntries(Date.now() + 10000, [])
  );

  await RemoteSettings.pollChanges();

  clockDifference = Services.prefs.getIntPref(PREF_CLOCK_SKEW_SECONDS);
  // we previously set the serverTime to Date.now() + 10000 ms past epoch
  Assert.ok(clockDifference <= 0 && clockDifference >= -10);
});
add_task(clear_state);

add_task(async function test_check_clockskew_takes_age_into_account() {
  const currentTime = Date.now();
  const skewSeconds = 5;
  const ageCDNSeconds = 3600;
  const serverTime = currentTime - skewSeconds * 1000 - ageCDNSeconds * 1000;

  function serverResponse(request, response) {
    response.setHeader("Content-Type", "application/json; charset=UTF-8");
    response.setHeader("Date", new Date(serverTime).toUTCString());
    response.setHeader("Age", `${ageCDNSeconds}`);
    response.write(JSON.stringify({ timestamp: 42, changes: [] }));
    response.setStatusLine(null, 200, "OK");
  }
  server.registerPathHandler(CHANGES_PATH, serverResponse);

  await RemoteSettings.pollChanges();

  const clockSkew = Services.prefs.getIntPref(PREF_CLOCK_SKEW_SECONDS);
  Assert.ok(clockSkew >= skewSeconds, `clockSkew is ${clockSkew}`);
});
add_task(clear_state);

add_task(async function test_backoff() {
  const startSnapshot = getUptakeTelemetrySnapshot(
    TELEMETRY_COMPONENT,
    TELEMETRY_SOURCE_POLL
  );

  function simulateBackoffResponse(request, response) {
    response.setHeader("Content-Type", "application/json; charset=UTF-8");
    response.setHeader("Backoff", "10");
    response.write(JSON.stringify({ timestamp: 42, changes: [] }));
    response.setStatusLine(null, 200, "OK");
  }
  server.registerPathHandler(CHANGES_PATH, simulateBackoffResponse);

  // First will work.
  await RemoteSettings.pollChanges();
  // Second will fail because we haven't waited.
  try {
    await RemoteSettings.pollChanges();
    // The previous line should have thrown an error.
    Assert.ok(false);
  } catch (e) {
    Assert.ok(
      /Server is asking clients to back off; retry in \d+s./.test(e.message)
    );
  }

  // Once backoff time has expired, polling for changes can start again.
  server.registerPathHandler(
    CHANGES_PATH,
    serveChangesEntries(12000, [
      {
        id: "6a733d4a-601e-11e8-837a-0f85257529a1",
        last_modified: 1300,
        host: "localhost",
        bucket: "some-bucket",
        collection: "some-collection",
      },
    ])
  );
  Services.prefs.setCharPref(
    PREF_SETTINGS_SERVER_BACKOFF,
    `${Date.now() - 1000}`
  );

  await RemoteSettings.pollChanges();

  // Backoff tracking preference was cleared.
  Assert.ok(!Services.prefs.prefHasUserValue(PREF_SETTINGS_SERVER_BACKOFF));

  // Ensure that we've accumulated the correct telemetry
  const endSnapshot = getUptakeTelemetrySnapshot(
    TELEMETRY_COMPONENT,
    TELEMETRY_SOURCE_POLL
  );
  const expectedIncrements = {
    [UptakeTelemetry.STATUS.SUCCESS]: 1,
    [UptakeTelemetry.STATUS.UP_TO_DATE]: 1,
    [UptakeTelemetry.STATUS.BACKOFF]: 1,
  };
  checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements);
});
add_task(clear_state);

add_task(async function test_network_error() {
  const startSnapshot = getUptakeTelemetrySnapshot(
    TELEMETRY_COMPONENT,
    TELEMETRY_SOURCE_POLL
  );

  // Simulate a network error (to check telemetry report).
  Services.prefs.setCharPref(PREF_SETTINGS_SERVER, "http://localhost:42/v1");
  try {
    await RemoteSettings.pollChanges();
  } catch (e) {}

  // ensure that we've accumulated the correct telemetry
  const endSnapshot = getUptakeTelemetrySnapshot(
    TELEMETRY_COMPONENT,
    TELEMETRY_SOURCE_POLL
  );
  const expectedIncrements = {
    [UptakeTelemetry.STATUS.NETWORK_ERROR]: 1,
  };
  checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements);
});
add_task(clear_state);

add_task(async function test_syncs_clients_with_local_database() {
  server.registerPathHandler(
    CHANGES_PATH,
    serveChangesEntries(42000, [
      {
        id: "d4a14f44-601f-11e8-8b8a-030f3dc5b844",
        last_modified: 10000,
        host: "localhost",
        bucket: "main",
        collection: "some-unknown",
      },
      {
        id: "39f57e4e-6023-11e8-8b74-77c8dedfb389",
        last_modified: 9000,
        host: "localhost",
        bucket: "blocklists",
        collection: "addons",
      },
      {
        id: "9a594c1a-601f-11e8-9c8a-33b2239d9113",
        last_modified: 8000,
        host: "localhost",
        bucket: "main",
        collection: "recipes",
      },
    ])
  );

  // This simulates what remote-settings would do when initializing a local database.
  // We don't want to instantiate a client using the RemoteSettings() API
  // since we want to test «unknown» clients that have a local database.
  new RemoteSettingsClient("addons", {
    bucketName: "blocklists",
  }).db.importChanges({}, 42);
  new RemoteSettingsClient("recipes").db.importChanges({}, 43);

  let error;
  try {
    await RemoteSettings.pollChanges();
    Assert.ok(false, "pollChange() should throw when pulling recipes");
  } catch (e) {
    error = e;
  }

  // The `main/some-unknown` should be skipped because it has no local database.
  // The `blocklists/addons` should be skipped because it is not the main bucket.
  // The `recipes` has a local database, and should cause a network error because the test
  // does not setup the server to receive the requests of `maybeSync()`.
  Assert.ok(/HTTP 404/.test(error.message), "server will return 404 on sync");
  Assert.equal(error.details.collection, "recipes");
});
add_task(clear_state);

add_task(async function test_syncs_clients_with_local_dump() {
  if (IS_ANDROID) {
    // Skip test: we don't ship remote settings dumps on Android (see package-manifest).
    return;
  }
  server.registerPathHandler(
    CHANGES_PATH,
    serveChangesEntries(42000, [
      {
        id: "d4a14f44-601f-11e8-8b8a-030f3dc5b844",
        last_modified: 10000,
        host: "localhost",
        bucket: "main",
        collection: "some-unknown",
      },
      {
        id: "39f57e4e-6023-11e8-8b74-77c8dedfb389",
        last_modified: 9000,
        host: "localhost",
        bucket: "blocklists",
        collection: "addons",
      },
      {
        id: "9a594c1a-601f-11e8-9c8a-33b2239d9113",
        last_modified: 8000,
        host: "localhost",
        bucket: "main",
        collection: "example",
      },
    ])
  );

  let error;
  try {
    await RemoteSettings.pollChanges();
  } catch (e) {
    error = e;
  }

  // The `main/some-unknown` should be skipped because it has no dump.
  // The `blocklists/addons` should be skipped because it is not the main bucket.
  // The `example` has a dump, and should cause a network error because the test
  // does not setup the server to receive the requests of `maybeSync()`.
  Assert.ok(/HTTP 404/.test(error.message), "server will return 404 on sync");
  Assert.equal(error.details.collection, "example");
});
add_task(clear_state);

add_task(async function test_adding_client_resets_polling() {
  function serve200(request, response) {
    const entries = [
      {
        id: "aa71e6cc-9f37-447a-b6e0-c025e8eabd03",
        last_modified: 42,
        host: "localhost",
        bucket: "main",
        collection: "a-collection",
      },
    ];
    if (request.queryString.includes("_since")) {
      response.write(
        JSON.stringify({
          timestamp: 42,
          changes: [],
        })
      );
    } else {
      response.write(
        JSON.stringify({
          timestamp: 42,
          changes: entries,
        })
      );
    }
    response.setStatusLine(null, 200, "OK");
    response.setHeader("Content-Type", "application/json; charset=UTF-8");
    response.setHeader("Date", new Date().toUTCString());
  }
  server.registerPathHandler(CHANGES_PATH, serve200);

  // Poll once, without any client for "a-collection"
  await RemoteSettings.pollChanges();

  // Register a new client.
  let maybeSyncCalled = false;
  const c = RemoteSettings("a-collection");
  c.maybeSync = () => {
    maybeSyncCalled = true;
  };

  // Poll again.
  await RemoteSettings.pollChanges();

  // The new client was called, even if the server data didn't change.
  Assert.ok(maybeSyncCalled);

  // Poll again. This time maybeSync() won't be called.
  maybeSyncCalled = false;
  await RemoteSettings.pollChanges();
  Assert.ok(!maybeSyncCalled);
});
add_task(clear_state);

add_task(
  async function test_broadcast_handler_passes_version_and_trigger_values() {
    // The polling will use the broadcast version as cache busting query param.
    let passedQueryString;
    function serveCacheBusted(request, response) {
      passedQueryString = request.queryString;
      const entries = [
        {
          id: "b6ba7fab-a40a-4d03-a4af-6b627f3c5b36",
          last_modified: 42,
          host: "localhost",
          bucket: "main",
          collection: "from-broadcast",
        },
      ];
      response.write(
        JSON.stringify({
          changes: entries,
          timestamp: 42,
        })
      );
      response.setHeader("ETag", '"42"');
      response.setStatusLine(null, 200, "OK");
      response.setHeader("Content-Type", "application/json; charset=UTF-8");
      response.setHeader("Date", new Date().toUTCString());
    }
    server.registerPathHandler(CHANGES_PATH, serveCacheBusted);

    let passedTrigger;
    const c = RemoteSettings("from-broadcast");
    c.maybeSync = (last_modified, { trigger }) => {
      passedTrigger = trigger;
    };

    const version = "1337";

    let context = { phase: pushBroadcastService.PHASES.HELLO };
    await remoteSettingsBroadcastHandler.receivedBroadcastMessage(
      version,
      BROADCAST_ID,
      context
    );
    Assert.equal(passedTrigger, "startup");
    Assert.equal(passedQueryString, `_expected=${version}`);

    clear_state();

    context = { phase: pushBroadcastService.PHASES.REGISTER };
    await remoteSettingsBroadcastHandler.receivedBroadcastMessage(
      version,
      BROADCAST_ID,
      context
    );
    Assert.equal(passedTrigger, "startup");

    clear_state();

    context = { phase: pushBroadcastService.PHASES.BROADCAST };
    await remoteSettingsBroadcastHandler.receivedBroadcastMessage(
      version,
      BROADCAST_ID,
      context
    );
    Assert.equal(passedTrigger, "broadcast");
  }
);
add_task(clear_state);
