/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */

// Tests the `urlbar-keyword-exposure` ping.

const WAIT_FOR_PING_TIMEOUT_MS = 1000;

// Avoid timeouts in verify mode, especially on Mac.
requestLongerTimeout(3);

add_setup(async function test_setup() {
  await PlacesUtils.history.clear();
  Services.fog.testResetFOG();

  // Add a mock engine so we don't hit the network.
  await SearchTestUtils.installSearchExtension({}, { setAsDefault: true });

  registerCleanupFunction(() => {
    Services.fog.testResetFOG();
  });
});

add_task(async function oneKeyword_noMatch_1() {
  await doTest({
    keywords: ["example"],
    searchStrings: ["exam"],
    expectedEvents: [],
  });
});

add_task(async function oneKeyword_noMatch_2() {
  await doTest({
    keywords: ["exam"],
    searchStrings: ["example"],
    expectedEvents: [],
  });
});

add_task(async function oneKeyword_oneMatch_terminal_1() {
  await doTest({
    keywords: ["example"],
    searchStrings: ["example"],
    expectedEvents: [{ extra: { keyword: "example", terminal: true } }],
  });
});

add_task(async function oneKeyword_oneMatch_terminal_2() {
  await doTest({
    keywords: ["example"],
    searchStrings: ["exam", "example"],
    expectedEvents: [{ extra: { keyword: "example", terminal: true } }],
  });
});

add_task(async function oneKeyword_oneMatch_nonterminal_1() {
  await doTest({
    keywords: ["example"],
    searchStrings: ["example", "exam"],
    expectedEvents: [{ extra: { keyword: "example", terminal: false } }],
  });
});

add_task(async function oneKeyword_oneMatch_nonterminal_2() {
  await doTest({
    keywords: ["example"],
    searchStrings: ["ex", "example", "exam"],
    expectedEvents: [{ extra: { keyword: "example", terminal: false } }],
  });
});

add_task(async function oneKeyword_dupeMatches_terminal_1() {
  await doTest({
    keywords: ["example"],
    searchStrings: ["example", "example"],
    expectedEvents: [
      { extra: { keyword: "example", terminal: false } },
      { extra: { keyword: "example", terminal: true } },
    ],
  });
});

add_task(async function oneKeyword_dupeMatches_terminal_2() {
  await doTest({
    keywords: ["example"],
    searchStrings: ["example", "exampl", "example"],
    expectedEvents: [
      { extra: { keyword: "example", terminal: false } },
      { extra: { keyword: "example", terminal: true } },
    ],
  });
});

add_task(async function oneKeyword_dupeMatches_terminal_3() {
  await doTest({
    keywords: ["example"],
    searchStrings: ["exam", "example", "example"],
    expectedEvents: [
      { extra: { keyword: "example", terminal: false } },
      { extra: { keyword: "example", terminal: true } },
    ],
  });
});

add_task(async function oneKeyword_dupeMatches_terminal_4() {
  await doTest({
    keywords: ["example"],
    searchStrings: ["exam", "example", "exampl", "example"],
    expectedEvents: [
      { extra: { keyword: "example", terminal: false } },
      { extra: { keyword: "example", terminal: true } },
    ],
  });
});

add_task(async function oneKeyword_dupeMatches_nonterminal_1() {
  await doTest({
    keywords: ["example"],
    searchStrings: ["example", "example", "exampl"],
    expectedEvents: [
      { extra: { keyword: "example", terminal: false } },
      { extra: { keyword: "example", terminal: false } },
    ],
  });
});

add_task(async function oneKeyword_dupeMatches_nonterminal_2() {
  await doTest({
    keywords: ["example"],
    searchStrings: ["exam", "example", "example", "exampl"],
    expectedEvents: [
      { extra: { keyword: "example", terminal: false } },
      { extra: { keyword: "example", terminal: false } },
    ],
  });
});

add_task(async function oneKeyword_dupeMatches_nonterminal_3() {
  await doTest({
    keywords: ["example"],
    searchStrings: ["example", "exam", "example", "exampl"],
    expectedEvents: [
      { extra: { keyword: "example", terminal: false } },
      { extra: { keyword: "example", terminal: false } },
    ],
  });
});

add_task(async function oneKeyword_dupeMatches_nonterminal_4() {
  await doTest({
    keywords: ["example"],
    searchStrings: ["exam", "example", "exampl", "example", "exampl"],
    expectedEvents: [
      { extra: { keyword: "example", terminal: false } },
      { extra: { keyword: "example", terminal: false } },
    ],
  });
});

add_task(async function manyKeywords_noMatch() {
  await doTest({
    keywords: ["foo", "bar", "baz"],
    searchStrings: ["example"],
    expectedEvents: [],
  });
});

add_task(async function manyKeywords_oneMatch_terminal_1() {
  await doTest({
    keywords: ["foo", "bar", "baz"],
    searchStrings: ["bar"],
    expectedEvents: [{ extra: { keyword: "bar", terminal: true } }],
  });
});

add_task(async function manyKeywords_oneMatch_terminal_2() {
  await doTest({
    keywords: ["foo", "bar", "baz"],
    searchStrings: ["example", "bar"],
    expectedEvents: [{ extra: { keyword: "bar", terminal: true } }],
  });
});

add_task(async function manyKeywords_oneMatch_nonterminal_1() {
  await doTest({
    keywords: ["foo", "bar", "baz"],
    searchStrings: ["bar", "example"],
    expectedEvents: [{ extra: { keyword: "bar", terminal: false } }],
  });
});

add_task(async function manyKeywords_oneMatch_nonterminal_2() {
  await doTest({
    keywords: ["foo", "bar", "baz"],
    searchStrings: ["exam", "bar", "example"],
    expectedEvents: [{ extra: { keyword: "bar", terminal: false } }],
  });
});

add_task(async function manyKeywords_manyMatches_terminal_1() {
  let keywords = ["foo", "bar", "baz"];
  await doTest({
    keywords,
    searchStrings: keywords,
    expectedEvents: keywords.map((keyword, i) => ({
      extra: { keyword, terminal: i == keywords.length - 1 },
    })),
  });
});

add_task(async function manyKeywords_manyMatches_terminal_2() {
  let keywords = ["foo", "bar", "baz"];
  await doTest({
    keywords,
    searchStrings: ["exam", "foo", "exampl", "bar", "example", "baz"],
    expectedEvents: keywords.map((keyword, i) => ({
      extra: { keyword, terminal: i == keywords.length - 1 },
    })),
  });
});

add_task(async function manyKeywords_manyMatches_nonterminal_1() {
  let keywords = ["foo", "bar", "baz"];
  await doTest({
    keywords,
    searchStrings: ["foo", "bar", "baz", "example"],
    expectedEvents: keywords.map(keyword => ({
      extra: { keyword, terminal: false },
    })),
  });
});

add_task(async function manyKeywords_manyMatches_nonterminal_2() {
  let keywords = ["foo", "bar", "baz"];
  await doTest({
    keywords,
    searchStrings: ["exam", "foo", "exampl", "bar", "example", "baz", "exam"],
    expectedEvents: keywords.map(keyword => ({
      extra: { keyword, terminal: false },
    })),
  });
});

add_task(async function manyKeywords_dupeMatches_terminal() {
  let keywords = ["foo", "bar", "baz"];
  let searchStrings = [...keywords, ...keywords];
  await doTest({
    keywords,
    searchStrings,
    expectedEvents: searchStrings.map((keyword, i) => ({
      extra: { keyword, terminal: i == 2 * keywords.length - 1 },
    })),
  });
});

add_task(async function manyKeywords_dupeMatches_nonterminal() {
  let keywords = ["foo", "bar", "baz"];
  let searchStrings = [...keywords, ...keywords, "example"];
  await doTest({
    keywords,
    searchStrings,
    expectedEvents: [...keywords, ...keywords].map(keyword => ({
      extra: { keyword, terminal: false },
    })),
  });
});

add_task(async function manyResults() {
  await doTest({
    keywords: [
      // "foo" matches different results of the same type
      { keyword: "foo", resultType: "history" },
      { keyword: "foo", resultType: "history" },
      { keyword: "foo", resultType: "history" },
      // "bar" matches different result types
      { keyword: "bar", resultType: "history" },
      { keyword: "bar", resultType: "bookmark" },
      { keyword: "baz", resultType: "bookmark" },
    ],
    searchStrings: ["foo", "bar", "baz", "bar"],
    expectedEvents: [
      { extra: { keyword: "foo", result: "history", terminal: false } },
      { extra: { keyword: "foo", result: "history", terminal: false } },
      { extra: { keyword: "foo", result: "history", terminal: false } },
      { extra: { keyword: "bar", result: "history", terminal: false } },
      { extra: { keyword: "bar", result: "bookmark", terminal: false } },
      { extra: { keyword: "baz", result: "bookmark", terminal: false } },
      { extra: { keyword: "bar", result: "history", terminal: true } },
      { extra: { keyword: "bar", result: "bookmark", terminal: true } },
    ],
  });
});

add_task(async function searchStringNormalization_terminal() {
  await doTest({
    keywords: ["example"],
    searchStrings: ["    ExaMPLe  "],
    expectedEvents: [{ extra: { keyword: "example", terminal: true } }],
  });
});

add_task(async function searchStringNormalization_nonterminal() {
  await doTest({
    keywords: ["example"],
    searchStrings: ["    ExaMPLe  ", "foo"],
    expectedEvents: [{ extra: { keyword: "example", terminal: false } }],
  });
});

add_task(async function multiWordKeyword() {
  await doTest({
    keywords: ["this has multiple words"],
    searchStrings: ["this has multiple words"],
    expectedEvents: [
      { extra: { keyword: "this has multiple words", terminal: true } },
    ],
  });
});

add_task(async function subset_matched() {
  await doTest({
    // "history" is in `exposureResults` but not `keywordExposureResults`
    exposureResults: "bookmark,history",
    keywordExposureResults: "bookmark",
    keywords: [{ keyword: "example", resultType: "bookmark" }],
    searchStrings: ["example"],
    expectedEvents: [
      { extra: { keyword: "example", result: "bookmark", terminal: true } },
    ],
  });
});

add_task(async function subset_notMatched() {
  await doTest({
    // "history" is in `exposureResults` but not `keywordExposureResults`
    exposureResults: "bookmark,history",
    keywordExposureResults: "bookmark",
    keywords: [{ keyword: "example", resultType: "history" }],
    searchStrings: ["example"],
    expectedEvents: [],
  });
});

add_task(async function superset_matched() {
  await doTest({
    // "history" is in `keywordExposureResults` but not `exposureResults`
    exposureResults: "bookmark",
    keywordExposureResults: "bookmark,history",
    keywords: [{ keyword: "example", resultType: "bookmark" }],
    searchStrings: ["example"],
    expectedEvents: [
      { extra: { keyword: "example", result: "bookmark", terminal: true } },
    ],
  });
});

add_task(async function superset_notMatched() {
  await doTest({
    // "history" is in `keywordExposureResults` but not `exposureResults`
    exposureResults: "bookmark",
    keywordExposureResults: "bookmark,history",
    keywords: [{ keyword: "example", resultType: "history" }],
    searchStrings: ["example"],
    expectedEvents: [],
  });
});

// Smoke test that ends a session with an engagement instead of an abandonment
// as other tasks in this file do.
add_task(async function engagement() {
  await BrowserTestUtils.withNewTab("about:blank", async () => {
    await doTest({
      keywords: ["example"],
      searchStrings: ["example"],
      endSession: () =>
        // Hit the Enter key on the heuristic search result.
        UrlbarTestUtils.promisePopupClose(window, () =>
          EventUtils.synthesizeKey("KEY_Enter")
        ),
      expectedEvents: [{ extra: { keyword: "example", terminal: true } }],
    });
  });
});

// Smoke test that uses Nimbus instead of a pref as other tasks in this file do.
add_task(async function nimbus() {
  let keywords = ["foo", "bar", "baz"];
  await doTest({
    useNimbus: true,
    keywords,
    searchStrings: keywords,
    expectedEvents: keywords.map((keyword, i) => ({
      extra: { keyword, terminal: i == keywords.length - 1 },
    })),
  });
});

// The ping should not be submitted for sessions in private windows.
add_task(async function privateWindow() {
  let privateWin = await BrowserTestUtils.openNewBrowserWindow({
    private: true,
  });
  await doTest({
    win: privateWin,
    keywords: ["example"],
    searchStrings: ["example"],
    expectedEvents: [],
  });
  await BrowserTestUtils.closeWindow(privateWin);
});

// Test a different sap. We choose handoff because it's easy to test.
// More saps are tested in the engagement telemetry tests.
add_task(async function sapUrlbarHandoff() {
  // Simulate handoff session.
  gURLBar._isHandoffSession = true;
  await doTest({
    keywords: ["example"],
    searchStrings: ["example"],
    expectedEvents: [
      { extra: { keyword: "example", terminal: true, sap: "handoff" } },
    ],
  });
});

async function doTest({
  keywords,
  searchStrings,
  expectedEvents,
  exposureResults = "history,bookmark",
  keywordExposureResults = exposureResults,
  useNimbus = false,
  win = window,
  endSession = () =>
    UrlbarTestUtils.promisePopupClose(win, () => win.gURLBar.blur()),
}) {
  // Assume all callers are testing with history and/or bookmarks.
  let resultSourceByType = {
    history: UrlbarUtils.RESULT_SOURCE.HISTORY,
    bookmark: UrlbarUtils.RESULT_SOURCE.BOOKMARKS,
  };

  // Map the keywords array to objects: `{ keyword, resultType }`
  keywords = keywords.map(keyword =>
    typeof keyword == "string" ? { keyword, resultType: "history" } : keyword
  );

  // Register a high-priority provider that returns the given types of results
  // when the search string matches a keyword.
  let provider = new UrlbarTestUtils.TestProvider({
    priority: Infinity,
    results: [],
  });
  let getMatchingKeywords = context =>
    keywords.filter(
      ({ keyword }) => keyword == context.trimmedLowerCaseSearchString
    );
  provider.isActive = async context => {
    return !!getMatchingKeywords(context).length;
  };
  provider.startQuery = (context, addCallback) => {
    let kws = getMatchingKeywords(context);
    for (let { resultType } of kws) {
      let source = resultSourceByType[resultType];
      if (!source) {
        let msg = "No result source for type: " + resultType;
        Assert.ok(false, msg);
        throw new Error(msg);
      }
      addCallback(
        provider,
        new UrlbarResult({
          type: UrlbarUtils.RESULT_TYPE.URL,
          source,
          payload: { url: "https://example.com/" },
        })
      );
    }
  };
  let providersManager = ProvidersManager.getInstanceForSap("urlbar");
  providersManager.registerProvider(provider);
  registerCleanupFunction(() => providersManager.unregisterProvider(provider));

  // Set up the prefs/Nimbus.
  let nimbusCleanup;
  if (useNimbus) {
    nimbusCleanup = await UrlbarTestUtils.initNimbusFeature({
      exposureResults,
      keywordExposureResults,
    });
  } else {
    await SpecialPowers.pushPrefEnv({
      set: [
        ["browser.urlbar.exposureResults", exposureResults],
        ["browser.urlbar.keywordExposureResults", keywordExposureResults],
      ],
    });
  }

  // Do the searches and end the session.
  let pingPromise = waitForPing();

  for (let value of searchStrings) {
    await UrlbarTestUtils.promiseAutocompleteResultPopup({
      value,
      window: win,
    });
  }
  await endSession();

  // Wait `WAIT_FOR_PING_TIMEOUT_MS` for the ping to be submitted before
  // reporting a timeout. Note that some tasks do not expect a ping to be
  // submitted, and they rely on this timeout behavior.
  info("Awaiting ping promise");
  let events = null;
  events = await Promise.race([
    pingPromise,
    new Promise(resolve =>
      // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
      setTimeout(() => {
        if (!events) {
          info("Timed out waiting for ping");
        }
        resolve([]);
      }, WAIT_FOR_PING_TIMEOUT_MS)
    ),
  ]);

  assertEvents(events, expectedEvents);

  if (nimbusCleanup) {
    await nimbusCleanup();
  } else {
    await SpecialPowers.popPrefEnv();
  }
  Services.fog.testResetFOG();
  providersManager.unregisterProvider(provider);

  Assert.deepEqual(
    [...UrlbarPrefs.get("exposureResults").values()],
    [],
    "Sanity check: exposureResults is empty after clearing prefs/uninstalling experiment"
  );
  Assert.strictEqual(
    UrlbarPrefs.get("keywordExposureResults").size,
    0,
    "Sanity check: keywordExposureResults is empty after clearing prefs/uninstalling experiment"
  );
}

function waitForPing() {
  return new Promise(resolve => {
    GleanPings.urlbarKeywordExposure.testBeforeNextSubmit(() => {
      let events = Glean.urlbar.keywordExposure.testGetValue();
      info("testBeforeNextSubmit got events: " + JSON.stringify(events));
      resolve(events);
    });
  });
}

function assertEvents(actual, expected) {
  info("Comparing events: " + JSON.stringify({ actual, expected }));

  // Add some expected boilerplate properties to the expected events so that
  // callers don't have to but so that we still check them.
  expected = expected.map(e => {
    // `testGetValue()` stringifies booleans for some reason. Let callers
    // specify booleans since booleans are correct, and stringify them here.
    e = stringifyBooleans(e);
    // Most tasks only use history results, so for convenience set the result
    // type here unless a task already did.
    e.extra.result ??= "history";
    // Again, for convenience, use urlbar_newtab as the default sap.
    e.extra.sap ??= "urlbar_newtab";
    return {
      category: "urlbar",
      name: "keyword_exposure",
      ...e,
    };
  });

  // Filter out properties from the actual events that aren't defined in the
  // expected events. Ignore unimportant properties like timestamps.
  actual = actual.map((a, i) =>
    Object.fromEntries(
      Object.entries(a).filter(([key]) => expected[i]?.hasOwnProperty(key))
    )
  );

  Assert.deepEqual(actual, expected, "Checking expected Glean events");
}

function stringifyBooleans(obj) {
  let newObj = {};
  for (let [key, value] of Object.entries(obj)) {
    if (value && typeof value == "object") {
      newObj[key] = stringifyBooleans(value);
    } else if (typeof value == "boolean") {
      newObj[key] = String(value);
    } else {
      newObj[key] = value;
    }
  }
  return newObj;
}
