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

/**
 * This file tests abandonment and edge cases related to impressions.
 */

"use strict";

ChromeUtils.defineESModuleGetters(this, {
  CONTEXTUAL_SERVICES_PING_TYPES:
    "resource:///modules/PartnerLinkAttribution.sys.mjs",
  UrlbarView: "resource:///modules/UrlbarView.sys.mjs",
  sinon: "resource://testing-common/Sinon.sys.mjs",
});

const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest;

const REMOTE_SETTINGS_RESULTS = [
  {
    id: 1,
    url: "https://example.com/sponsored",
    title: "Sponsored suggestion",
    keywords: ["sponsored"],
    click_url: "https://example.com/click",
    impression_url: "https://example.com/impression",
    advertiser: "testadvertiser",
  },
  {
    id: 2,
    url: "https://example.com/nonsponsored",
    title: "Non-sponsored suggestion",
    keywords: ["nonsponsored"],
    click_url: "https://example.com/click",
    impression_url: "https://example.com/impression",
    advertiser: "testadvertiser",
    iab_category: "5 - Education",
  },
];

const SPONSORED_RESULT = REMOTE_SETTINGS_RESULTS[0];

// Spy for the custom impression/click sender
let spy;

add_setup(async function () {
  ({ spy } = QuickSuggestTestUtils.createTelemetryPingSpy());

  await PlacesUtils.history.clear();
  await PlacesUtils.bookmarks.eraseEverything();
  await UrlbarTestUtils.formHistory.clear();

  Services.telemetry.clearScalars();
  Services.telemetry.clearEvents();

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

  await QuickSuggestTestUtils.ensureQuickSuggestInit({
    remoteSettingsResults: [
      {
        type: "data",
        attachment: REMOTE_SETTINGS_RESULTS,
      },
    ],
  });
});

// Makes sure impression telemetry is not recorded when the urlbar engagement is
// abandoned.
add_task(async function abandonment() {
  Services.telemetry.clearEvents();
  await UrlbarTestUtils.promiseAutocompleteResultPopup({
    window,
    value: "sponsored",
    fireInputEvent: true,
  });
  await QuickSuggestTestUtils.assertIsQuickSuggest({
    window,
    url: SPONSORED_RESULT.url,
  });
  await UrlbarTestUtils.promisePopupClose(window, () => {
    gURLBar.blur();
  });
  QuickSuggestTestUtils.assertScalars({});
  QuickSuggestTestUtils.assertEvents([]);
  QuickSuggestTestUtils.assertPings(spy, []);
});

// Makes sure impression telemetry is not recorded when a quick suggest result
// is not present.
add_task(async function noQuickSuggestResult() {
  await BrowserTestUtils.withNewTab("about:blank", async () => {
    Services.telemetry.clearEvents();
    await UrlbarTestUtils.promiseAutocompleteResultPopup({
      window,
      value: "noImpression_noQuickSuggestResult",
      fireInputEvent: true,
    });
    await QuickSuggestTestUtils.assertNoQuickSuggestResults(window);
    await UrlbarTestUtils.promisePopupClose(window, () => {
      EventUtils.synthesizeKey("KEY_Enter");
    });
    QuickSuggestTestUtils.assertScalars({});
    QuickSuggestTestUtils.assertEvents([]);
    QuickSuggestTestUtils.assertPings(spy, []);
  });
  await PlacesUtils.history.clear();
});

// When a quick suggest result is added to the view but hidden during the view
// update, impression telemetry should not be recorded for it.
add_task(async function hiddenRow() {
  Services.telemetry.clearEvents();

  // Increase the timeout of the remove-stale-rows timer so that it doesn't
  // interfere with this task.
  let originalRemoveStaleRowsTimeout = UrlbarView.removeStaleRowsTimeout;
  UrlbarView.removeStaleRowsTimeout = 30000;
  registerCleanupFunction(() => {
    UrlbarView.removeStaleRowsTimeout = originalRemoveStaleRowsTimeout;
  });

  // Set up a test provider that doesn't add any results until we resolve its
  // `finishQueryPromise`. For the first search below, it will add many search
  // suggestions.
  let maxCount = UrlbarPrefs.get("maxRichResults");
  let results = [];
  for (let i = 0; i < maxCount; i++) {
    results.push(
      new UrlbarResult(
        UrlbarUtils.RESULT_TYPE.SEARCH,
        UrlbarUtils.RESULT_SOURCE.SEARCH,
        {
          engine: "Example",
          suggestion: "suggestion " + i,
          lowerCaseSuggestion: "suggestion " + i,
          query: "test",
        }
      )
    );
  }
  let provider = new DelayingTestProvider({ results });
  UrlbarProvidersManager.registerProvider(provider);

  // Open a new tab since we'll load a page below.
  let tab = await BrowserTestUtils.openNewForegroundTab({ gBrowser });

  // Do a normal search and allow the test provider to finish.
  provider.finishQueryPromise = Promise.resolve();
  await UrlbarTestUtils.promiseAutocompleteResultPopup({
    window,
    value: "test",
    fireInputEvent: true,
  });

  // Sanity check the rows. After the heuristic, the remaining rows should be
  // the search results added by the test provider.
  Assert.equal(
    UrlbarTestUtils.getResultCount(window),
    maxCount,
    "Row count after first search"
  );
  for (let i = 1; i < maxCount; i++) {
    let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i);
    Assert.equal(
      result.type,
      UrlbarUtils.RESULT_TYPE.SEARCH,
      "Expected result type at index " + i
    );
    Assert.equal(
      result.source,
      UrlbarUtils.RESULT_SOURCE.SEARCH,
      "Expected result source at index " + i
    );
  }

  // Now set up a second search that triggers a quick suggest result. Add a
  // mutation listener to the view so we can tell when the quick suggest row is
  // added.
  let mutationPromise = new Promise(resolve => {
    let observer = new MutationObserver(mutations => {
      let rows = UrlbarTestUtils.getResultsContainer(window).children;
      for (let row of rows) {
        if (row.result.providerName == "UrlbarProviderQuickSuggest") {
          observer.disconnect();
          resolve(row);
          return;
        }
      }
    });
    observer.observe(UrlbarTestUtils.getResultsContainer(window), {
      childList: true,
    });
  });

  // Set the test provider's `finishQueryPromise` to a promise that doesn't
  // resolve. That will prevent the search from completing, which will prevent
  // the view from removing stale rows and showing the quick suggest row.
  let resolveQuery;
  provider.finishQueryPromise = new Promise(
    resolve => (resolveQuery = resolve)
  );

  // Start the second search but don't wait for it to finish.
  gURLBar.focus();
  let queryPromise = UrlbarTestUtils.promiseAutocompleteResultPopup({
    window,
    value: REMOTE_SETTINGS_RESULTS[0].keywords[0],
    fireInputEvent: true,
  });

  // Wait for the quick suggest row to be added to the view. It should be hidden
  // because (a) quick suggest results have a `suggestedIndex`, and rows with
  // suggested indexes can't replace rows without suggested indexes, and (b) the
  // view already contains the maximum number of rows due to the first search.
  // It should remain hidden until the search completes or the remove-stale-rows
  // timer fires. Next, we'll hit enter, which will cancel the search and close
  // the view, so the row should never appear.
  let quickSuggestRow = await mutationPromise;
  Assert.ok(
    BrowserTestUtils.is_hidden(quickSuggestRow),
    "Quick suggest row is hidden"
  );

  // Hit enter to pick the heuristic search result. This will cancel the search
  // and notify the quick suggest provider that an engagement occurred.
  let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
  await UrlbarTestUtils.promisePopupClose(window, () => {
    EventUtils.synthesizeKey("KEY_Enter");
  });
  await loadPromise;

  // Resolve the test provider's promise finally.
  resolveQuery();
  await queryPromise;

  // The quick suggest provider added a result but it wasn't visible in the
  // view. No impression telemetry should be recorded for it.
  QuickSuggestTestUtils.assertScalars({});
  QuickSuggestTestUtils.assertEvents([]);
  QuickSuggestTestUtils.assertPings(spy, []);

  BrowserTestUtils.removeTab(tab);
  UrlbarProvidersManager.unregisterProvider(provider);
  UrlbarView.removeStaleRowsTimeout = originalRemoveStaleRowsTimeout;
});

// When a quick suggest result has not been added to the view, impression
// telemetry should not be recorded for it even if it's the result most recently
// returned by the provider.
add_task(async function notAddedToView() {
  Services.telemetry.clearEvents();

  // Open a new tab since we'll load a page.
  await BrowserTestUtils.withNewTab("about:blank", async () => {
    // Do an initial search that doesn't match any suggestions to make sure
    // there aren't any quick suggest results in the view to start.
    await UrlbarTestUtils.promiseAutocompleteResultPopup({
      window,
      value: "this doesn't match anything",
      fireInputEvent: true,
    });
    await QuickSuggestTestUtils.assertNoQuickSuggestResults(window);
    await UrlbarTestUtils.promisePopupClose(window);

    // Now do a search for a suggestion and hit enter after the provider adds it
    // but before it appears in the view.
    await doEngagementWithoutAddingResultToView(
      REMOTE_SETTINGS_RESULTS[0].keywords[0]
    );

    // The quick suggest provider added a result but it wasn't visible in the
    // view, and no other quick suggest results were visible in the view. No
    // impression telemetry should be recorded.
    QuickSuggestTestUtils.assertScalars({});
    QuickSuggestTestUtils.assertEvents([]);
    QuickSuggestTestUtils.assertPings(spy, []);
  });
});

// When a quick suggest result is visible in the view, impression telemetry
// should be recorded for it even if it's not the result most recently returned
// by the provider.
add_task(async function previousResultStillVisible() {
  Services.telemetry.clearEvents();

  // Open a new tab since we'll load a page.
  await BrowserTestUtils.withNewTab("about:blank", async () => {
    // Do a search for the first suggestion.
    let firstSuggestion = REMOTE_SETTINGS_RESULTS[0];
    await UrlbarTestUtils.promiseAutocompleteResultPopup({
      window,
      value: firstSuggestion.keywords[0],
      fireInputEvent: true,
    });

    let index = 1;
    await QuickSuggestTestUtils.assertIsQuickSuggest({
      window,
      index,
      url: firstSuggestion.url,
    });

    // Without closing the view, do a second search for the second suggestion
    // and hit enter after the provider adds it but before it appears in the
    // view.
    await doEngagementWithoutAddingResultToView(
      REMOTE_SETTINGS_RESULTS[1].keywords[0],
      index
    );

    // An impression for the first suggestion should be recorded since it's
    // still visible in the view, not the second suggestion.
    QuickSuggestTestUtils.assertScalars({
      [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: index + 1,
    });
    QuickSuggestTestUtils.assertEvents([
      {
        category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
        method: "engagement",
        object: "impression_only",
        extra: {
          match_type: "firefox-suggest",
          position: String(index + 1),
          suggestion_type: "sponsored",
        },
      },
    ]);
    QuickSuggestTestUtils.assertPings(spy, [
      {
        type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
        payload: {
          improve_suggest_experience_checked: false,
          block_id: firstSuggestion.id,
          is_clicked: false,
          match_type: "firefox-suggest",
          position: index + 1,
        },
      },
    ]);
  });
});

/**
 * Does a search that causes the quick suggest provider to return a result
 * without adding it to the view and then hits enter to load a SERP and create
 * an engagement.
 *
 * @param {string} searchString
 *   The search string.
 * @param {number} previousResultIndex
 *   If the view is already open and showing a quick suggest result, pass its
 *   index here. Otherwise pass -1.
 */
async function doEngagementWithoutAddingResultToView(
  searchString,
  previousResultIndex = -1
) {
  // Set the timeout of the chunk timer to a really high value so that it will
  // not fire. The view updates when the timer fires, which we specifically want
  // to avoid here.
  let originalChunkDelayMs = UrlbarProvidersManager._chunkResultsDelayMs;
  UrlbarProvidersManager._chunkResultsDelayMs = 30000;
  registerCleanupFunction(() => {
    UrlbarProvidersManager._chunkResultsDelayMs = originalChunkDelayMs;
  });

  // Stub `UrlbarProviderQuickSuggest.getPriority()` to return Infinity.
  let sandbox = sinon.createSandbox();
  let getPriorityStub = sandbox.stub(UrlbarProviderQuickSuggest, "getPriority");
  getPriorityStub.returns(Infinity);

  // Spy on `UrlbarProviderQuickSuggest.onEngagement()`.
  let onEngagementSpy = sandbox.spy(UrlbarProviderQuickSuggest, "onEngagement");

  let sandboxCleanup = () => {
    getPriorityStub?.restore();
    getPriorityStub = null;
    sandbox?.restore();
    sandbox = null;
  };
  registerCleanupFunction(sandboxCleanup);

  // In addition to setting the chunk timeout to a large value above, in order
  // to prevent the view from updating there also needs to be a heuristic
  // provider that takes a long time to add results. Set one up that doesn't add
  // any results until we resolve its `finishQueryPromise`. Set its priority to
  // Infinity too so that only it and the quick suggest provider will be active.
  let provider = new DelayingTestProvider({
    results: [],
    priority: Infinity,
    type: UrlbarUtils.PROVIDER_TYPE.HEURISTIC,
  });
  UrlbarProvidersManager.registerProvider(provider);

  let resolveQuery;
  provider.finishQueryPromise = new Promise(r => (resolveQuery = r));

  // Add a query listener so we can grab the query context.
  let context;
  let queryListener = {
    onQueryStarted: c => (context = c),
  };
  gURLBar.controller.addQueryListener(queryListener);

  // Do a search but don't wait for it to finish.
  gURLBar.focus();
  UrlbarTestUtils.promiseAutocompleteResultPopup({
    window,
    value: searchString,
    fireInputEvent: true,
  });

  // Wait for the quick suggest provider to add its result to `context.unsortedResults`.
  let result = await TestUtils.waitForCondition(() => {
    let query = UrlbarProvidersManager.queries.get(context);
    return query?.unsortedResults.find(
      r => r.providerName == "UrlbarProviderQuickSuggest"
    );
  }, "Waiting for quick suggest result to be added to context.unsortedResults");

  gURLBar.controller.removeQueryListener(queryListener);

  // The view should not have updated, so the result's `rowIndex` should still
  // have its initial value of -1.
  Assert.equal(result.rowIndex, -1, "result.rowIndex is still -1");

  // If there's a result from the previous query, assert it's still in the
  // view. Otherwise assume that the view should be closed. These are mostly
  // sanity checks because they should only fail if the telemetry assertions
  // below also fail.
  if (previousResultIndex >= 0) {
    let rows = gURLBar.view.panel.querySelector(".urlbarView-results");
    Assert.equal(
      rows.children[previousResultIndex].result.providerName,
      "UrlbarProviderQuickSuggest",
      "Result already in view is a quick suggest"
    );
  } else {
    Assert.ok(!gURLBar.view.isOpen, "View is closed");
  }

  // Hit enter to load a SERP for the search string. This should notify the
  // quick suggest provider that an engagement occurred.
  let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
  await UrlbarTestUtils.promisePopupClose(window, () => {
    EventUtils.synthesizeKey("KEY_Enter");
  });
  await loadPromise;

  let engagementCalls = onEngagementSpy.getCalls().filter(call => {
    let state = call.args[1];
    return state == "engagement";
  });
  Assert.equal(engagementCalls.length, 1, "One engagement occurred");

  // Clean up.
  resolveQuery();
  UrlbarProvidersManager.unregisterProvider(provider);
  UrlbarProvidersManager._chunkResultsDelayMs = originalChunkDelayMs;
  sandboxCleanup();
}

/**
 * A test provider that doesn't finish `startQuery()` until `finishQueryPromise`
 * is resolved.
 */
class DelayingTestProvider extends UrlbarTestUtils.TestProvider {
  finishQueryPromise = null;
  async startQuery(context, addCallback) {
    for (let result of this._results) {
      addCallback(this, result);
    }
    await this.finishQueryPromise;
  }
}
