"use strict";

// Verify that when the feature is disabled, the RS client is never
// initialized: get() is never called, no data fetched, no blocking.
add_task(async function test_rs_not_initialized_when_disabled() {
  let client = getRSClient();
  let db = client.db;

  await populateRS(db, "trackers", "disconnect-tracker-base", [
    "||example.org^",
  ]);

  // Spy on client.get() to verify it is never called. Restore on
  // cleanup regardless of pass/fail.
  let getCalled = false;
  let origGet = client.get;
  client.get = async (...args) => {
    getCalled = true;
    return origGet.call(client, ...args);
  };
  registerCleanupFunction(() => {
    client.get = origGet;
  });

  // Feature is disabled (both enabled prefs false), but engines pref is set.
  await pushEnginePrefs({
    protection: "trackers",
    protectionEnabled: false,
  });

  // Open a tab to trigger GetInstance() / channel classification.
  let tab = await openTestTab();
  let browser = tab.linkedBrowser;

  await assertImageLoaded(
    browser,
    TEST_BLOCKED_3RD_PARTY_DOMAIN,
    "example.org should load when feature is disabled"
  );

  // Proving a side effect *didn't* happen requires a bounded wait.
  // 2s is load-bearing: it needs to be long enough to cover a slow
  // async RS init on loaded CI hardware. If this test ever goes
  // intermittent, bump the wait rather than shorten it.
  // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
  await new Promise(resolve => setTimeout(resolve, 2000));

  ok(!getCalled, "RemoteSettings.get() should not be called when disabled");
});

// Basic blocking: a feature selected for blocking via the engines pref
// should cancel matching third-party requests and produce a content
// blocking log entry.
add_task(async function test_rs_blocking() {
  let client = getRSClient();
  let db = client.db;

  let record = await populateRS(db, "trackers", "disconnect-tracker-base", [
    "||example.org^",
  ]);

  await pushEnginePrefs({ protection: "trackers" });

  let tab = await openTestTab();
  let browser = tab.linkedBrowser;
  await syncAndWaitForLists(client, [record]);

  await assertImageBlocked(
    browser,
    TEST_BLOCKED_3RD_PARTY_DOMAIN,
    "Third-party image from example.org should be blocked via RS"
  );

  await assertHasBlockingState(
    browser,
    TEST_BLOCKED_3RD_PARTY_DOMAIN,
    Ci.nsIWebProgressListener.STATE_BLOCKED_TRACKING_CONTENT,
    "Entry has the STATE_BLOCKED_TRACKING_CONTENT flag"
  );
});

// Basic annotation: a feature selected for annotation via the engines pref
// should allow matching requests to load but annotate them in the content
// blocking log.
add_task(async function test_rs_annotation() {
  let client = getRSClient();
  let db = client.db;

  let record = await populateRS(db, "trackers", "disconnect-tracker-base", [
    "||example.com^",
  ]);

  await pushEnginePrefs({ annotation: "trackers" });

  let tab = await openTestTab();
  let browser = tab.linkedBrowser;
  await syncAndWaitForLists(client, [record]);

  BrowserTestUtils.startLoadingURIString(browser, TEST_TOP_PAGE);
  await BrowserTestUtils.browserLoaded(browser);

  await assertImageLoaded(
    browser,
    TEST_ANNOTATED_3RD_PARTY_DOMAIN,
    "Third-party image from example.com should NOT be blocked"
  );

  await assertHasBlockingState(
    browser,
    TEST_ANNOTATED_3RD_PARTY_DOMAIN,
    Ci.nsIWebProgressListener.STATE_LOADED_LEVEL_1_TRACKING_CONTENT,
    "Entry has the STATE_LOADED_LEVEL_1_TRACKING_CONTENT flag"
  );
});

// Feature selection: two features have stored data in RS, but only one is
// selected via the engines pref. The selected feature should block; the
// non-selected feature's rules should have no effect.
add_task(async function test_rs_nonselected_list_not_active() {
  let client = getRSClient();
  let db = client.db;

  let records = await populateMultipleRS(db, [
    {
      id: "trackers",
      name: "disconnect-tracker-base",
      rules: ["||example.org^"],
    },
    {
      id: "fingerprinters",
      name: "disconnect-fingerprinters-base",
      rules: ["||example.com^"],
    },
  ]);

  await pushEnginePrefs({ protection: "trackers" });

  let tab = await openTestTab();
  let browser = tab.linkedBrowser;
  await syncAndWaitForLists(client, records);

  await assertImageBlocked(
    browser,
    TEST_BLOCKED_3RD_PARTY_DOMAIN,
    "example.org should be blocked (active list)"
  );
  await assertImageLoaded(
    browser,
    TEST_ANNOTATED_3RD_PARTY_DOMAIN,
    "example.com should NOT be blocked (inactive list)"
  );
});

// Multiple active features: comma-separated feature names in the engines
// pref should all produce active engines that block their respective
// domains.
add_task(async function test_rs_multiple_active_lists() {
  let client = getRSClient();
  let db = client.db;

  let records = await populateMultipleRS(db, [
    {
      id: "trackers",
      name: "disconnect-tracker-base",
      rules: ["||example.org^"],
    },
    {
      id: "fingerprinters",
      name: "disconnect-fingerprinters-base",
      rules: ["||example.com^"],
    },
  ]);

  await pushEnginePrefs({ protection: "trackers,fingerprinters" });

  let tab = await openTestTab();
  let browser = tab.linkedBrowser;
  await syncAndWaitForLists(client, records);

  await assertImageBlocked(
    browser,
    TEST_BLOCKED_3RD_PARTY_DOMAIN,
    "example.org should be blocked (list-a active)"
  );
  await assertImageBlocked(
    browser,
    TEST_ANNOTATED_3RD_PARTY_DOMAIN,
    "example.com should be blocked (list-b active)"
  );
});

// Block vs annotate separation: one feature is assigned to blocking, another
// to annotation. The block feature should cancel requests, the annotate
// feature should allow them through but tag them in the content blocking
// log. Neither should cross over into the other mode.
add_task(async function test_rs_block_and_annotate_separation() {
  let client = getRSClient();
  let db = client.db;

  let records = await populateMultipleRS(db, [
    {
      id: "trackers",
      name: "disconnect-tracker-base",
      rules: ["||example.org^"],
    },
    {
      id: "fingerprinters",
      name: "disconnect-fingerprinters-base",
      rules: ["||example.com^"],
    },
  ]);

  await pushEnginePrefs({
    protection: "trackers",
    annotation: "fingerprinters",
  });

  let tab = await openTestTab();
  let browser = tab.linkedBrowser;
  await syncAndWaitForLists(client, records);

  BrowserTestUtils.startLoadingURIString(browser, TEST_TOP_PAGE);
  await BrowserTestUtils.browserLoaded(browser);

  await assertImageBlocked(
    browser,
    TEST_BLOCKED_3RD_PARTY_DOMAIN,
    "example.org should be blocked (on block list)"
  );
  await assertImageLoaded(
    browser,
    TEST_ANNOTATED_3RD_PARTY_DOMAIN,
    "example.com should NOT be blocked (on annotate list only)"
  );

  await assertHasBlockingState(
    browser,
    TEST_ANNOTATED_3RD_PARTY_DOMAIN,
    Ci.nsIWebProgressListener.STATE_LOADED_FINGERPRINTING_CONTENT,
    "example.com is annotated as fingerprinting, not blocked"
  );
});

// Pref-driven feature switching: two features have stored data in RS.
// Changing the engines pref at runtime should rebuild engines from
// already-stored data without re-downloading, switching which domain is
// blocked.
add_task(async function test_rs_pref_switch_active_lists() {
  let client = getRSClient();
  let db = client.db;

  let records = await populateMultipleRS(db, [
    {
      id: "trackers",
      name: "disconnect-tracker-base",
      rules: ["||example.org^"],
    },
    {
      id: "fingerprinters",
      name: "disconnect-fingerprinters-base",
      rules: ["||example.com^"],
    },
  ]);

  await pushEnginePrefs({ protection: "trackers" });

  let tab = await openTestTab();
  let browser = tab.linkedBrowser;
  await syncAndWaitForLists(client, records);

  await assertImageBlocked(
    browser,
    TEST_BLOCKED_3RD_PARTY_DOMAIN,
    "example.org blocked with trackers active"
  );
  await assertImageLoaded(
    browser,
    TEST_ANNOTATED_3RD_PARTY_DOMAIN,
    "example.com not blocked with only trackers active"
  );

  // Switch to the fingerprinters feature via pref change. This triggers
  // RebuildEnginesFromStoredData which fires the notification.
  let listsLoaded = TestUtils.topicObserved(LISTS_LOADED_TOPIC);
  await SpecialPowers.pushPrefEnv({
    set: [
      [
        "privacy.trackingprotection.content.protection.engines",
        "fingerprinters",
      ],
    ],
  });
  await listsLoaded;

  BrowserTestUtils.startLoadingURIString(browser, TEST_TOP_PAGE);
  await BrowserTestUtils.browserLoaded(browser);

  await assertImageLoaded(
    browser,
    TEST_BLOCKED_3RD_PARTY_DOMAIN,
    "example.org no longer blocked after switching to fingerprinters"
  );
  await assertImageBlocked(
    browser,
    TEST_ANNOTATED_3RD_PARTY_DOMAIN,
    "example.com now blocked after switching to fingerprinters"
  );
});

// Sync deletion: removing a list via a RemoteSettings sync event should
// remove it from stored data so it no longer blocks matching requests.
add_task(async function test_rs_sync_deletion() {
  let client = getRSClient();
  let db = client.db;

  let record = await populateRS(db, "trackers", "disconnect-tracker-base", [
    "||example.org^",
  ]);

  await pushEnginePrefs({ protection: "trackers" });

  let tab = await openTestTab();
  let browser = tab.linkedBrowser;
  await syncAndWaitForLists(client, [record]);

  await assertImageBlocked(
    browser,
    TEST_BLOCKED_3RD_PARTY_DOMAIN,
    "example.org blocked before deletion"
  );

  let listsLoaded = TestUtils.topicObserved(LISTS_LOADED_TOPIC);
  await client.emit("sync", {
    data: { created: [], updated: [], deleted: [record] },
  });
  await listsLoaded;

  BrowserTestUtils.startLoadingURIString(browser, TEST_TOP_PAGE);
  await BrowserTestUtils.browserLoaded(browser);

  await assertImageLoaded(
    browser,
    TEST_BLOCKED_3RD_PARTY_DOMAIN,
    "example.org no longer blocked after sync deletion"
  );
});

// Sync update: updating a list's attachment via a RemoteSettings sync event
// should replace the old rules with the new ones, changing which domains
// are blocked.
add_task(async function test_rs_sync_update() {
  let client = getRSClient();
  let db = client.db;

  let origRecord = await populateRS(db, "trackers", "disconnect-tracker-base", [
    "||example.org^",
  ]);

  await pushEnginePrefs({ protection: "trackers" });

  let tab = await openTestTab();
  let browser = tab.linkedBrowser;
  await syncAndWaitForLists(client, [origRecord]);

  // Before update: example.org should be blocked, example.com should not.
  await assertImageBlocked(
    browser,
    TEST_BLOCKED_3RD_PARTY_DOMAIN,
    "example.org should be blocked before update"
  );
  await assertImageLoaded(
    browser,
    TEST_ANNOTATED_3RD_PARTY_DOMAIN,
    "example.com should not be blocked before update"
  );

  // Update the list to block example.com instead.
  let newRecord = await populateRS(db, "trackers", "disconnect-tracker-base", [
    "||example.com^",
  ]);

  let listsLoaded = TestUtils.topicObserved(LISTS_LOADED_TOPIC);
  await client.emit("sync", {
    data: {
      created: [],
      updated: [{ old: origRecord, new: newRecord }],
      deleted: [],
    },
  });
  await listsLoaded;

  BrowserTestUtils.startLoadingURIString(browser, TEST_TOP_PAGE);
  await BrowserTestUtils.browserLoaded(browser);

  await assertImageLoaded(
    browser,
    TEST_BLOCKED_3RD_PARTY_DOMAIN,
    "example.org no longer blocked after update"
  );
  await assertImageBlocked(
    browser,
    TEST_ANNOTATED_3RD_PARTY_DOMAIN,
    "example.com now blocked after update"
  );
});

// CRLF line endings: an attachment that uses Windows-style line endings
// should still produce a working filter list. The blank line in the middle
// must not fuse the surrounding rules together, so both example.org and
// example.com (each on their own \r\n-separated line) must independently
// block.
add_task(async function test_rs_crlf_line_endings() {
  let client = getRSClient();
  let db = client.db;

  let [record] = await populateMultipleRS(db, [
    {
      id: "trackers",
      name: "disconnect-tracker-base",
      content: "||example.org^\r\n\r\n||example.com^\r\n",
    },
  ]);

  await pushEnginePrefs({ protection: "trackers" });

  let tab = await openTestTab();
  let browser = tab.linkedBrowser;
  await syncAndWaitForLists(client, [record]);

  await assertImageBlocked(
    browser,
    TEST_BLOCKED_3RD_PARTY_DOMAIN,
    "example.org blocked from CRLF-formatted list (rule before blank line)"
  );
  await assertImageBlocked(
    browser,
    TEST_ANNOTATED_3RD_PARTY_DOMAIN,
    "example.com blocked from CRLF-formatted list (rule after blank line)"
  );
});

// Enable/disable/re-enable: the feature-enabled prefs should drive RS
// client lifecycle. After disable, matching requests must not be blocked.
// After re-enable with the engines pref set, classification must resume.
add_task(async function test_rs_enable_disable_reenable() {
  let client = getRSClient();
  let db = client.db;

  let record = await populateRS(db, "trackers", "disconnect-tracker-base", [
    "||example.org^",
  ]);

  await pushEnginePrefs({ protection: "trackers" });

  let tab = await openTestTab();
  let browser = tab.linkedBrowser;
  await syncAndWaitForLists(client, [record]);

  await assertImageBlocked(
    browser,
    TEST_BLOCKED_3RD_PARTY_DOMAIN,
    "example.org blocked while feature enabled"
  );

  // Disable the feature. This should tear down the RS client and
  // clear engines.
  await SpecialPowers.pushPrefEnv({
    set: [["privacy.trackingprotection.content.protection.enabled", false]],
  });

  BrowserTestUtils.startLoadingURIString(browser, TEST_TOP_PAGE);
  await BrowserTestUtils.browserLoaded(browser);

  await assertImageLoaded(
    browser,
    TEST_BLOCKED_3RD_PARTY_DOMAIN,
    "example.org NOT blocked after disabling feature"
  );

  // Re-enable. RS client should be re-created and re-import data.
  let listsLoaded = TestUtils.topicObserved(LISTS_LOADED_TOPIC);
  await SpecialPowers.pushPrefEnv({
    set: [["privacy.trackingprotection.content.protection.enabled", true]],
  });
  await listsLoaded;

  BrowserTestUtils.startLoadingURIString(browser, TEST_TOP_PAGE);
  await BrowserTestUtils.browserLoaded(browser);

  await assertImageBlocked(
    browser,
    TEST_BLOCKED_3RD_PARTY_DOMAIN,
    "example.org blocked again after re-enabling"
  );
});

// Empty collection: an RS collection with no records must still fire the
// lists-loaded notification so callers aren't left waiting forever, and
// the empty state must not block anything.
add_task(async function test_rs_empty_collection() {
  let client = getRSClient();
  let db = client.db;

  // Explicitly import an empty record set.
  await db.importChanges({}, Date.now(), [], { clear: true });

  // Use a feature whose RS data no other test populates, so the engine
  // truly cannot be built.
  await pushEnginePrefs({ protection: "cryptominers" });

  let tab = await openTestTab();
  let browser = tab.linkedBrowser;

  // Sync with an empty record set must still fire the lists-loaded topic.
  await syncAndWaitForLists(client, []);

  await assertImageLoaded(
    browser,
    TEST_BLOCKED_3RD_PARTY_DOMAIN,
    "example.org should load when collection is empty"
  );
});

// Two RS sync events fired back-to-back drive two real UpdateFeatures
// calls into the off-thread build pipeline. Unlike rapid pushPrefEnv
// calls on the same pref — which the pref system coalesces, so they
// don't reliably fire OnPrefChange twice — separate client.emit("sync")
// calls always run their own JS onSync handler and call
// service.onListsChanged independently. So this exercises the
// back-to-back rebuild path the off-thread pipeline was built for:
// two in-flight closures racing on mBuildThread, where the per-feature
// version counter must ensure the older closure can't clobber the
// newer install if their fetches resolve out of order.
add_task(async function test_rs_back_to_back_sync_updates() {
  let client = getRSClient();
  let db = client.db;

  // Start with rule A (blocks example.org) installed for trackers.
  let recordA = await populateRS(db, "trackers", "disconnect-tracker-base", [
    "||example.org^",
  ]);
  await pushEnginePrefs({ protection: "trackers" });
  let tab = await openTestTab();
  let browser = tab.linkedBrowser;
  await syncAndWaitForLists(client, [recordA]);
  await assertImageBlocked(
    browser,
    TEST_BLOCKED_3RD_PARTY_DOMAIN,
    "example.org blocked under initial rule A"
  );

  // Replace the record (same Name, new rules pointing at a different
  // third-party domain). After this populateRS the DB carries rule B
  // and any subsequent fetch returns rule B.
  let recordB = await populateRS(db, "trackers", "disconnect-tracker-base", [
    "||example.com^",
  ]);

  // Two sync emits in a row. Both kick off UpdateFeatures(trackers)
  // with fresh per-feature versions; both fetches read rule B from
  // the local DB; both produce engine-with-rule-B. The older closure
  // must not clobber the newer install — that's what per-feature
  // versioning guarantees. Unlike the single-sync case, this is the one
  // spot that can legitimately produce two notifications, so require the
  // first then drain a quiet window: that gives any stale closure a chance
  // to (wrongly) clobber the newer install before we sample state.
  let settled = waitForListsSettled({ minNotifies: 1, quietMs: 200 });
  await client.emit("sync", {
    data: {
      created: [],
      updated: [{ old: recordA, new: recordB }],
      deleted: [],
    },
  });
  await client.emit("sync", {
    data: {
      created: [],
      updated: [{ old: recordA, new: recordB }],
      deleted: [],
    },
  });
  await settled;

  BrowserTestUtils.startLoadingURIString(browser, TEST_TOP_PAGE);
  await BrowserTestUtils.browserLoaded(browser);

  // After both rebuilds settle, the surviving engine is rule B:
  // example.org loads (no longer matched) and example.com is blocked.
  await assertImageLoaded(
    browser,
    TEST_BLOCKED_3RD_PARTY_DOMAIN,
    "example.org no longer blocked - rule A was superseded"
  );
  await assertImageBlocked(
    browser,
    TEST_ANNOTATED_3RD_PARTY_DOMAIN,
    "example.com blocked - rule B is the final installed engine"
  );
});
