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

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

var { UrlbarMuxer, UrlbarProvider, UrlbarQueryContext, UrlbarUtils } =
  ChromeUtils.importESModule("resource:///modules/UrlbarUtils.sys.mjs");

ChromeUtils.defineESModuleGetters(this, {
  AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs",
  PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
  PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
  SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs",
  TestUtils: "resource://testing-common/TestUtils.sys.mjs",
  UrlbarController: "resource:///modules/UrlbarController.sys.mjs",
  UrlbarInput: "resource:///modules/UrlbarInput.sys.mjs",
  UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
  UrlbarProviderOpenTabs: "resource:///modules/UrlbarProviderOpenTabs.sys.mjs",
  UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs",
  UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
  UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs",
  sinon: "resource://testing-common/Sinon.sys.mjs",
});

XPCOMUtils.defineLazyModuleGetters(this, {
  HttpServer: "resource://testing-common/httpd.js",
});

XPCOMUtils.defineLazyGetter(this, "QuickSuggestTestUtils", () => {
  const { QuickSuggestTestUtils: module } = ChromeUtils.importESModule(
    "resource://testing-common/QuickSuggestTestUtils.sys.mjs"
  );
  module.init(this);
  return module;
});

XPCOMUtils.defineLazyGetter(this, "MerinoTestUtils", () => {
  const { MerinoTestUtils: module } = ChromeUtils.importESModule(
    "resource://testing-common/MerinoTestUtils.sys.mjs"
  );
  module.init(this);
  return module;
});

ChromeUtils.defineLazyGetter(this, "UrlbarTestUtils", () => {
  const { UrlbarTestUtils: module } = ChromeUtils.importESModule(
    "resource://testing-common/UrlbarTestUtils.sys.mjs"
  );
  module.init(this);
  return module;
});

XPCOMUtils.defineLazyGetter(this, "PlacesFrecencyRecalculator", () => {
  return Cc["@mozilla.org/places/frecency-recalculator;1"].getService(
    Ci.nsIObserver
  ).wrappedJSObject;
});

SearchTestUtils.init(this);
AddonTestUtils.init(this, false);
AddonTestUtils.createAppInfo(
  "xpcshell@tests.mozilla.org",
  "XPCShell",
  "42",
  "42"
);

const SUGGESTIONS_ENGINE_NAME = "Suggestions";
const TAIL_SUGGESTIONS_ENGINE_NAME = "Tail Suggestions";

/**
 * Gets the database connection.  If the Places connection is invalid it will
 * try to create a new connection.
 *
 * @param [optional] aForceNewConnection
 *        Forces creation of a new connection to the database.  When a
 *        connection is asyncClosed it cannot anymore schedule async statements,
 *        though connectionReady will keep returning true (Bug 726990).
 *
 * @returns The database connection or null if unable to get one.
 */
var gDBConn;
function DBConn(aForceNewConnection) {
  if (!aForceNewConnection) {
    let db = PlacesUtils.history.DBConnection;
    if (db.connectionReady) {
      return db;
    }
  }

  // If the Places database connection has been closed, create a new connection.
  if (!gDBConn || aForceNewConnection) {
    let file = Services.dirsvc.get("ProfD", Ci.nsIFile);
    file.append("places.sqlite");
    let dbConn = (gDBConn = Services.storage.openDatabase(file));

    TestUtils.topicObserved("profile-before-change").then(() =>
      dbConn.asyncClose()
    );
  }

  return gDBConn.connectionReady ? gDBConn : null;
}

/**
 * @param {string} searchString The search string to insert into the context.
 * @param {object} properties Overrides for the default values.
 * @returns {UrlbarQueryContext} Creates a dummy query context with pre-filled
 *          required options.
 */
function createContext(searchString = "foo", properties = {}) {
  info(`Creating new queryContext with searchString: ${searchString}`);
  let context = new UrlbarQueryContext(
    Object.assign(
      {
        allowAutofill: UrlbarPrefs.get("autoFill"),
        isPrivate: true,
        maxResults: UrlbarPrefs.get("maxRichResults"),
        searchString,
      },
      properties
    )
  );
  context.view = {
    get visibleResults() {
      return context.results;
    },
    controller: {
      removeResult() {},
    },
    acknowledgeDismissal() {},
  };
  UrlbarTokenizer.tokenize(context);
  return context;
}

/**
 * Waits for the given notification from the supplied controller.
 *
 * @param {UrlbarController} controller The controller to wait for a response from.
 * @param {string} notification The name of the notification to wait for.
 * @param {boolean} expected Wether the notification is expected.
 * @returns {Promise} A promise that is resolved with the arguments supplied to
 *   the notification.
 */
function promiseControllerNotification(
  controller,
  notification,
  expected = true
) {
  return new Promise((resolve, reject) => {
    let proxifiedObserver = new Proxy(
      {},
      {
        get: (target, name) => {
          if (name == notification) {
            return (...args) => {
              controller.removeQueryListener(proxifiedObserver);
              if (expected) {
                resolve(args);
              } else {
                reject();
              }
            };
          }
          return () => false;
        },
      }
    );
    controller.addQueryListener(proxifiedObserver);
  });
}

/**
 * A basic test provider, returning all the provided matches.
 */
class TestProvider extends UrlbarTestUtils.TestProvider {
  isActive(context) {
    Assert.ok(context, "context is passed-in");
    return true;
  }
  getPriority(context) {
    Assert.ok(context, "context is passed-in");
    return 0;
  }
  async startQuery(context, add) {
    Assert.ok(context, "context is passed-in");
    Assert.equal(typeof add, "function", "add is a callback");
    this._context = context;
    for (const result of this._results) {
      add(this, result);
    }
  }
  cancelQuery(context) {
    // If the query was created but didn't run, this._context will be undefined.
    if (this._context) {
      Assert.equal(this._context, context, "cancelQuery: context is the same");
    }
    if (this._onCancel) {
      this._onCancel();
    }
  }
}

function convertToUtf8(str) {
  return String.fromCharCode(...new TextEncoder().encode(str));
}

/**
 * Helper function to clear the existing providers and register a basic provider
 * that returns only the results given.
 *
 * @param {Array} results The results for the provider to return.
 * @param {Function} [onCancel] Optional, called when the query provider
 *                              receives a cancel instruction.
 * @param {UrlbarUtils.PROVIDER_TYPE} type The provider type.
 * @param {string} [name] Optional, use as the provider name.
 *                        If none, a default name is chosen.
 * @returns {UrlbarProvider} The provider
 */
function registerBasicTestProvider(results = [], onCancel, type, name) {
  let provider = new TestProvider({ results, onCancel, type, name });
  UrlbarProvidersManager.registerProvider(provider);
  registerCleanupFunction(() =>
    UrlbarProvidersManager.unregisterProvider(provider)
  );
  return provider;
}

// Creates an HTTP server for the test.
function makeTestServer(port = -1) {
  let httpServer = new HttpServer();
  httpServer.start(port);
  registerCleanupFunction(() => httpServer.stop(() => {}));
  return httpServer;
}

/**
 * Sets up a search engine that provides some suggestions by appending strings
 * onto the search query.
 *
 * @param {Function} suggestionsFn
 *        A function that returns an array of suggestion strings given a
 *        search string.  If not given, a default function is used.
 * @returns {nsISearchEngine} The new engine.
 */
async function addTestSuggestionsEngine(suggestionsFn = null) {
  // This port number should match the number in engine-suggestions.xml.
  let server = makeTestServer();
  server.registerPathHandler("/suggest", (req, resp) => {
    let params = new URLSearchParams(req.queryString);
    let searchStr = params.get("q");
    let suggestions = suggestionsFn
      ? suggestionsFn(searchStr)
      : [searchStr].concat(["foo", "bar"].map(s => searchStr + " " + s));
    let data = [searchStr, suggestions];
    resp.setHeader("Content-Type", "application/json", false);
    resp.write(JSON.stringify(data));
  });
  await SearchTestUtils.installSearchExtension({
    name: SUGGESTIONS_ENGINE_NAME,
    search_url: `http://localhost:${server.identity.primaryPort}/search`,
    suggest_url: `http://localhost:${server.identity.primaryPort}/suggest`,
    suggest_url_get_params: "?q={searchTerms}",
    // test_search_suggestions_aliases.js uses the search form.
    search_form: `http://localhost:${server.identity.primaryPort}/search?q={searchTerms}`,
  });
  let engine = Services.search.getEngineByName("Suggestions");
  return engine;
}

/**
 * Sets up a search engine that provides some tail suggestions by creating an
 * array that mimics Google's tail suggestion responses.
 *
 * @param {Function} suggestionsFn
 *        A function that returns an array that mimics Google's tail suggestion
 *        responses. See bug 1626897.
 *        NOTE: Consumers specifying suggestionsFn must include searchStr as a
 *              part of the array returned by suggestionsFn.
 * @returns {nsISearchEngine} The new engine.
 */
async function addTestTailSuggestionsEngine(suggestionsFn = null) {
  // This port number should match the number in engine-tail-suggestions.xml.
  let server = makeTestServer();
  server.registerPathHandler("/suggest", (req, resp) => {
    let params = new URLSearchParams(req.queryString);
    let searchStr = params.get("q");
    let suggestions = suggestionsFn
      ? suggestionsFn(searchStr)
      : [
          "what time is it in t",
          ["what is the time today texas"].concat(
            ["toronto", "tunisia"].map(s => searchStr + s.slice(1))
          ),
          [],
          {
            "google:irrelevantparameter": [],
            "google:suggestdetail": [{}].concat(
              ["toronto", "tunisia"].map(s => ({
                mp: "… ",
                t: s,
              }))
            ),
          },
        ];
    let data = suggestions;
    let jsonString = JSON.stringify(data);
    // This script must be evaluated as UTF-8 for this to write out the bytes of
    // the string in UTF-8.  If it's evaluated as Latin-1, the written bytes
    // will be the result of UTF-8-encoding the result-string *twice*, which
    // will break the "… " match prefixes.
    let stringOfUtf8Bytes = convertToUtf8(jsonString);
    resp.setHeader("Content-Type", "application/json", false);
    resp.write(stringOfUtf8Bytes);
  });
  await SearchTestUtils.installSearchExtension({
    name: TAIL_SUGGESTIONS_ENGINE_NAME,
    search_url: `http://localhost:${server.identity.primaryPort}/search`,
    suggest_url: `http://localhost:${server.identity.primaryPort}/suggest`,
    suggest_url_get_params: "?q={searchTerms}",
  });
  let engine = Services.search.getEngineByName("Tail Suggestions");
  return engine;
}

async function addOpenPages(uri, count = 1, userContextId = 0) {
  for (let i = 0; i < count; i++) {
    await UrlbarProviderOpenTabs.registerOpenTab(
      uri.spec,
      userContextId,
      false
    );
  }
}

async function removeOpenPages(aUri, aCount = 1, aUserContextId = 0) {
  for (let i = 0; i < aCount; i++) {
    await UrlbarProviderOpenTabs.unregisterOpenTab(
      aUri.spec,
      aUserContextId,
      false
    );
  }
}

/**
 * Helper for tests that generate search results but aren't interested in
 * suggestions, such as autofill tests. Installs a test engine and disables
 * suggestions.
 */
function testEngine_setup() {
  add_task(async function setup() {
    await cleanupPlaces();
    let engine = await addTestSuggestionsEngine();
    let oldDefaultEngine = await Services.search.getDefault();

    registerCleanupFunction(async () => {
      Services.prefs.clearUserPref("browser.urlbar.suggest.searches");
      Services.prefs.clearUserPref(
        "browser.search.separatePrivateDefault.ui.enabled"
      );
      Services.search.setDefault(
        oldDefaultEngine,
        Ci.nsISearchService.CHANGE_REASON_UNKNOWN
      );
    });

    Services.search.setDefault(
      engine,
      Ci.nsISearchService.CHANGE_REASON_UNKNOWN
    );
    Services.prefs.setBoolPref(
      "browser.search.separatePrivateDefault.ui.enabled",
      false
    );
    Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false);
  });
}

async function cleanupPlaces() {
  Services.prefs.clearUserPref("browser.urlbar.autoFill");
  Services.prefs.clearUserPref("browser.urlbar.autoFill.searchEngines");

  await PlacesUtils.bookmarks.eraseEverything();
  await PlacesUtils.history.clear();
}

/**
 * Creates a UrlbarResult for a bookmark result.
 *
 * @param {UrlbarQueryContext} queryContext
 *   The context that this result will be displayed in.
 * @param {object} options
 *   Options for the result.
 * @param {string} options.title
 *   The page title.
 * @param {string} options.uri
 *   The page URI.
 * @param {string} [options.iconUri]
 *   A URI for the page's icon.
 * @param {Array} [options.tags]
 *   An array of string tags. Defaults to an empty array.
 * @param {boolean} [options.heuristic]
 *   True if this is a heuristic result. Defaults to false.
 * @param {number} [options.source]
 *   Where the results should be sourced from. See {@link UrlbarUtils.RESULT_SOURCE}.
 * @returns {UrlbarResult}
 */
function makeBookmarkResult(
  queryContext,
  {
    title,
    uri,
    iconUri,
    tags = [],
    heuristic = false,
    source = UrlbarUtils.RESULT_SOURCE.BOOKMARKS,
  }
) {
  let result = new UrlbarResult(
    UrlbarUtils.RESULT_TYPE.URL,
    source,
    ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, {
      url: [uri, UrlbarUtils.HIGHLIGHT.TYPED],
      // Check against undefined so consumers can pass in the empty string.
      icon: [typeof iconUri != "undefined" ? iconUri : `page-icon:${uri}`],
      title: [title, UrlbarUtils.HIGHLIGHT.TYPED],
      tags: [tags, UrlbarUtils.HIGHLIGHT.TYPED],
    })
  );

  result.heuristic = heuristic;
  return result;
}

/**
 * Creates a UrlbarResult for a form history result.
 *
 * @param {UrlbarQueryContext} queryContext
 *   The context that this result will be displayed in.
 * @param {object} options
 *   Options for the result.
 * @param {string} options.suggestion
 *   The form history suggestion.
 * @param {string} options.engineName
 *   The name of the engine that will do the search when the result is picked.
 * @returns {UrlbarResult}
 */
function makeFormHistoryResult(queryContext, { suggestion, engineName }) {
  return new UrlbarResult(
    UrlbarUtils.RESULT_TYPE.SEARCH,
    UrlbarUtils.RESULT_SOURCE.HISTORY,
    ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, {
      engine: engineName,
      suggestion: [suggestion, UrlbarUtils.HIGHLIGHT.SUGGESTED],
      lowerCaseSuggestion: suggestion.toLocaleLowerCase(),
    })
  );
}

/**
 * Creates a UrlbarResult for an omnibox extension result. For more information,
 * see the documentation for omnibox.SuggestResult:
 * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/omnibox/SuggestResult
 *
 * @param {UrlbarQueryContext} queryContext
 *   The context that this result will be displayed in.
 * @param {object} options
 *   Options for the result.
 * @param {string} options.content
 *   The string displayed when the result is highlighted.
 * @param {string} options.description
 *   The string displayed in the address bar dropdown.
 * @param {string} options.keyword
 *   The keyword associated with the extension returning the result.
 * @param {boolean} [options.heuristic]
 *   True if this is a heuristic result. Defaults to false.
 * @returns {UrlbarResult}
 */
function makeOmniboxResult(
  queryContext,
  { content, description, keyword, heuristic = false }
) {
  let payload = {
    title: [description, UrlbarUtils.HIGHLIGHT.TYPED],
    content: [content, UrlbarUtils.HIGHLIGHT.TYPED],
    keyword: [keyword, UrlbarUtils.HIGHLIGHT.TYPED],
    icon: [UrlbarUtils.ICON.EXTENSION],
  };
  if (!heuristic) {
    payload.blockL10n = { id: "urlbar-result-menu-dismiss-firefox-suggest" };
  }
  let result = new UrlbarResult(
    UrlbarUtils.RESULT_TYPE.OMNIBOX,
    UrlbarUtils.RESULT_SOURCE.ADDON,
    ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, payload)
  );
  result.heuristic = heuristic;

  return result;
}

/**
 * Creates a UrlbarResult for an switch-to-tab result.
 *
 * @param {UrlbarQueryContext} queryContext
 *   The context that this result will be displayed in.
 * @param {object} options
 *   Options for the result.
 * @param {string} options.uri
 *   The page URI.
 * @param {string} [options.title]
 *   The page title.
 * @param {string} [options.iconUri]
 *   A URI for the page icon.
 * @returns {UrlbarResult}
 */
function makeTabSwitchResult(queryContext, { uri, title, iconUri }) {
  return new UrlbarResult(
    UrlbarUtils.RESULT_TYPE.TAB_SWITCH,
    UrlbarUtils.RESULT_SOURCE.TABS,
    ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, {
      url: [uri, UrlbarUtils.HIGHLIGHT.TYPED],
      title: [title, UrlbarUtils.HIGHLIGHT.TYPED],
      // Check against undefined so consumers can pass in the empty string.
      icon: typeof iconUri != "undefined" ? iconUri : `page-icon:${uri}`,
    })
  );
}

/**
 * Creates a UrlbarResult for a keyword search result.
 *
 * @param {UrlbarQueryContext} queryContext
 *   The context that this result will be displayed in.
 * @param {object} options
 *   Options for the result.
 * @param {string} options.uri
 *   The page URI.
 * @param {string} options.keyword
 *   The page's search keyword.
 * @param {string} [options.title]
 *   The title for the bookmarked keyword page.
 * @param {string} [options.iconUri]
 *   A URI for the engine's icon.
 * @param {string} [options.postData]
 *   The search POST data.
 * @param {boolean} [options.heuristic]
 *   True if this is a heuristic result. Defaults to false.
 * @returns {UrlbarResult}
 */
function makeKeywordSearchResult(
  queryContext,
  { uri, keyword, title, iconUri, postData, heuristic = false }
) {
  let result = new UrlbarResult(
    UrlbarUtils.RESULT_TYPE.KEYWORD,
    UrlbarUtils.RESULT_SOURCE.BOOKMARKS,
    ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, {
      title: [title ? title : uri, UrlbarUtils.HIGHLIGHT.TYPED],
      url: [uri, UrlbarUtils.HIGHLIGHT.TYPED],
      keyword: [keyword, UrlbarUtils.HIGHLIGHT.TYPED],
      input: [queryContext.searchString, UrlbarUtils.HIGHLIGHT.TYPED],
      postData: postData || null,
      icon: typeof iconUri != "undefined" ? iconUri : `page-icon:${uri}`,
    })
  );

  if (heuristic) {
    result.heuristic = heuristic;
  }
  return result;
}

/**
 * Creates a UrlbarResult for a priority search result.
 *
 * @param {UrlbarQueryContext} queryContext
 *   The context that this result will be displayed in.
 * @param {object} options
 *   Options for the result.
 * @param {string} [options.engineName]
 *   The name of the engine providing the suggestion. Leave blank if there
 *   is no suggestion.
 * @param {string} [options.engineIconUri]
 *   A URI for the engine's icon.
 * @param {boolean} [options.heuristic]
 *   True if this is a heuristic result. Defaults to false.
 * @returns {UrlbarResult}
 */
function makePrioritySearchResult(
  queryContext,
  { engineName, engineIconUri, heuristic }
) {
  let result = new UrlbarResult(
    UrlbarUtils.RESULT_TYPE.SEARCH,
    UrlbarUtils.RESULT_SOURCE.SEARCH,
    ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, {
      engine: [engineName, UrlbarUtils.HIGHLIGHT.TYPED],
      icon: engineIconUri,
    })
  );

  if (heuristic) {
    result.heuristic = heuristic;
  }
  return result;
}

/**
 * Creates a UrlbarResult for a remote tab result.
 *
 * @param {UrlbarQueryContext} queryContext
 *   The context that this result will be displayed in.
 * @param {object} options
 *   Options for the result.
 * @param {string} options.uri
 *   The page URI.
 * @param {string} options.device
 *   The name of the device that the remote tab comes from.
 * @param {string} [options.title]
 *   The page title.
 * @param {number} [options.lastUsed]
 *   The last time the remote tab was visited, in epoch seconds. Defaults
 *   to 0.
 * @param {string} [options.iconUri]
 *   A URI for the page's icon.
 * @returns {UrlbarResult}
 */
function makeRemoteTabResult(
  queryContext,
  { uri, device, title, iconUri, lastUsed = 0 }
) {
  let payload = {
    url: [uri, UrlbarUtils.HIGHLIGHT.TYPED],
    device: [device, UrlbarUtils.HIGHLIGHT.TYPED],
    // Check against undefined so consumers can pass in the empty string.
    icon:
      typeof iconUri != "undefined"
        ? iconUri
        : `moz-anno:favicon:page-icon:${uri}`,
    lastUsed: lastUsed * 1000,
  };

  // Check against undefined so consumers can pass in the empty string.
  if (typeof title != "undefined") {
    payload.title = [title, UrlbarUtils.HIGHLIGHT.TYPED];
  } else {
    payload.title = [uri, UrlbarUtils.HIGHLIGHT.TYPED];
  }

  let result = new UrlbarResult(
    UrlbarUtils.RESULT_TYPE.REMOTE_TAB,
    UrlbarUtils.RESULT_SOURCE.TABS,
    ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, payload)
  );

  return result;
}

/**
 * Creates a UrlbarResult for a search result.
 *
 * @param {UrlbarQueryContext} queryContext
 *   The context that this result will be displayed in.
 * @param {object} options
 *   Options for the result.
 * @param {string} [options.suggestion]
 *   The suggestion offered by the search engine.
 * @param {string} [options.tailPrefix]
 *   The characters placed at the end of a Google "tail" suggestion. See
 *   {@link https://firefox-source-docs.mozilla.org/browser/urlbar/nontechnical-overview.html#search-suggestions}
 * @param {*} [options.tail]
 *   The details of the URL bar tail
 * @param {number} [options.tailOffsetIndex]
 *   The index of the first character in the tail suggestion that should be
 * @param {string} [options.engineName]
 *   The name of the engine providing the suggestion. Leave blank if there
 *   is no suggestion.
 * @param {string} [options.uri]
 *   The URI that the search result will navigate to.
 * @param {string} [options.query]
 *   The query that started the search. This overrides
 *   `queryContext.searchString`. This is useful when the query that will show
 *   up in the result object will be different from what was typed. For example,
 *   if a leading restriction token will be used.
 * @param {string} [options.alias]
 *   The alias for the search engine, if the search is an alias search.
 * @param {string} [options.engineIconUri]
 *   A URI for the engine's icon.
 * @param {boolean} [options.heuristic]
 *   True if this is a heuristic result. Defaults to false.
 * @param {boolean} [options.providesSearchMode]
 *   Whether search mode is entered when this result is selected.
 * @param {string} [options.providerName]
 *   The name of the provider offering this result. The test suite will not
 *   check which provider offered a result unless this option is specified.
 * @param {boolean} [options.inPrivateWindow]
 *   If the window to test is a private window.
 * @param {boolean} [options.isPrivateEngine]
 *   If the engine is a private engine.
 * @param {number} [options.type]
 *   The type of the search result. Defaults to UrlbarUtils.RESULT_TYPE.SEARCH.
 * @param {number} [options.source]
 *   The source of the search result. Defaults to UrlbarUtils.RESULT_SOURCE.SEARCH.
 * @param {boolean} [options.satisfiesAutofillThreshold]
 *   If this search should appear in the autofill section of the box
 * @param {boolean} [options.trending]
 *    If the search result is a trending result. `Defaults to false`.
 * @param {boolean} [options.isRichSuggestion]
 *    If the search result is a rich result. `Defaults to false`.
 * @returns {UrlbarResult}
 */
function makeSearchResult(
  queryContext,
  {
    suggestion,
    tailPrefix,
    tail,
    tailOffsetIndex,
    engineName,
    alias,
    uri,
    query,
    engineIconUri,
    providesSearchMode,
    providerName,
    inPrivateWindow,
    isPrivateEngine,
    heuristic = false,
    trending = false,
    isRichSuggestion = false,
    type = UrlbarUtils.RESULT_TYPE.SEARCH,
    source = UrlbarUtils.RESULT_SOURCE.SEARCH,
    satisfiesAutofillThreshold = false,
  }
) {
  // Tail suggestion common cases, handled here to reduce verbosity in tests.
  if (tail) {
    if (!tailPrefix) {
      tailPrefix = "… ";
    }
    if (!tailOffsetIndex) {
      tailOffsetIndex = suggestion.indexOf(tail);
    }
  }

  let payload = {
    engine: [engineName, UrlbarUtils.HIGHLIGHT.TYPED],
    suggestion: [suggestion, UrlbarUtils.HIGHLIGHT.SUGGESTED],
    tailPrefix,
    tail: [tail, UrlbarUtils.HIGHLIGHT.SUGGESTED],
    tailOffsetIndex,
    keyword: [
      alias,
      providesSearchMode
        ? UrlbarUtils.HIGHLIGHT.TYPED
        : UrlbarUtils.HIGHLIGHT.NONE,
    ],
    // Check against undefined so consumers can pass in the empty string.
    query: [
      typeof query != "undefined" ? query : queryContext.trimmedSearchString,
      UrlbarUtils.HIGHLIGHT.TYPED,
    ],
    icon: engineIconUri,
    providesSearchMode,
    inPrivateWindow,
    isPrivateEngine,
  };

  // Passing even an undefined URL in the payload creates a potentially-unwanted
  // displayUrl parameter, so we add it only if specified.
  if (uri) {
    payload.url = uri;
  }
  if (providerName == "TabToSearch") {
    payload.satisfiesAutofillThreshold = satisfiesAutofillThreshold;
    if (payload.url.startsWith("www.")) {
      payload.url = payload.url.substring(4);
    }
    payload.isGeneralPurposeEngine = false;
  }

  let result = new UrlbarResult(
    type,
    source,
    ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, payload)
  );

  if (typeof suggestion == "string") {
    result.payload.lowerCaseSuggestion =
      result.payload.suggestion.toLocaleLowerCase();
    result.payload.trending = trending;
    result.payload.isRichSuggestion = isRichSuggestion;
  }

  if (providerName) {
    result.providerName = providerName;
  }

  result.heuristic = heuristic;
  return result;
}

/**
 * Creates a UrlbarResult for a history result.
 *
 * @param {UrlbarQueryContext} queryContext
 *   The context that this result will be displayed in.
 * @param {object} options Options for the result.
 * @param {string} options.title
 *   The page title.
 * @param {string} [options.fallbackTitle]
 *   The provider has capability to use the actual page title though,
 *   when the provider can’t get the page title, use this value as the fallback.
 * @param {string} options.uri
 *   The page URI.
 * @param {Array} [options.tags]
 *   An array of string tags. Defaults to an empty array.
 * @param {string} [options.iconUri]
 *   A URI for the page's icon.
 * @param {boolean} [options.heuristic]
 *   True if this is a heuristic result. Defaults to false.
 * @param {string} options.providerName
 *   The name of the provider offering this result. The test suite will not
 *   check which provider offered a result unless this option is specified.
 * @param {number} [options.source]
 *   The source of the result
 * @returns {UrlbarResult}
 */
function makeVisitResult(
  queryContext,
  {
    title,
    fallbackTitle,
    uri,
    iconUri,
    providerName,
    tags = [],
    heuristic = false,
    source = UrlbarUtils.RESULT_SOURCE.HISTORY,
  }
) {
  let payload = {
    url: [uri, UrlbarUtils.HIGHLIGHT.TYPED],
  };

  if (title) {
    payload.title = [title, UrlbarUtils.HIGHLIGHT.TYPED];
  }

  if (fallbackTitle) {
    payload.fallbackTitle = [fallbackTitle, UrlbarUtils.HIGHLIGHT.TYPED];
  }

  if (iconUri) {
    payload.icon = iconUri;
  } else if (
    iconUri === undefined &&
    source != UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL
  ) {
    payload.icon = `page-icon:${uri}`;
  }

  if (!heuristic && tags) {
    payload.tags = [tags, UrlbarUtils.HIGHLIGHT.TYPED];
  }

  let result = new UrlbarResult(
    UrlbarUtils.RESULT_TYPE.URL,
    source,
    ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, payload)
  );

  if (providerName) {
    result.providerName = providerName;
  }

  result.heuristic = heuristic;
  return result;
}

/**
 * Checks that the results returned by a UrlbarController match those in
 * the param `matches`.
 *
 * @param {object} options Options for the check.
 * @param {UrlbarQueryContext} options.context
 *   The context for this query.
 * @param {string} [options.incompleteSearch]
 *   A search will be fired for this string and then be immediately canceled by
 *   the query in `context`.
 * @param {string} [options.autofilled]
 *   The autofilled value in the first result.
 * @param {string} [options.completed]
 *   The value that would be filled if the autofill result was confirmed.
 *   Has no effect if `autofilled` is not specified.
 * @param {Array} options.matches
 *   An array of UrlbarResults.
 */
async function check_results({
  context,
  incompleteSearch,
  autofilled,
  completed,
  matches = [],
} = {}) {
  if (!context) {
    return;
  }

  // At this point frecency could still be updating due to latest pages
  // updates.
  // This is not a problem in real life, but autocomplete tests should
  // return reliable resultsets, thus we have to wait.
  await PlacesTestUtils.promiseAsyncUpdates();

  const controller = UrlbarTestUtils.newMockController({
    input: {
      isPrivate: context.isPrivate,
      onFirstResult() {
        return false;
      },
      getSearchSource() {
        return "dummy-search-source";
      },
      window: {
        location: {
          href: AppConstants.BROWSER_CHROME_URL,
        },
      },
    },
  });

  if (incompleteSearch) {
    let incompleteContext = createContext(incompleteSearch, {
      isPrivate: context.isPrivate,
    });
    controller.startQuery(incompleteContext);
  }
  await controller.startQuery(context);

  if (autofilled) {
    Assert.ok(context.results[0], "There is a first result.");
    Assert.ok(
      context.results[0].autofill,
      "The first result is an autofill result"
    );
    Assert.equal(
      context.results[0].autofill.value,
      autofilled,
      "The correct value was autofilled."
    );
    if (completed) {
      Assert.equal(
        context.results[0].payload.url,
        completed,
        "The completed autofill value is correct."
      );
    }
  }
  if (context.results.length != matches.length) {
    info("Actual results: " + JSON.stringify(context.results));
  }
  Assert.equal(
    context.results.length,
    matches.length,
    "Found the expected number of results."
  );

  function getPayload(result) {
    let payload = {};
    for (let [key, value] of Object.entries(result.payload)) {
      if (value !== undefined) {
        payload[key] = value;
      }
    }
    return payload;
  }

  for (let i = 0; i < matches.length; i++) {
    let actual = context.results[i];
    let expected = matches[i];
    info(
      `Comparing results at index ${i}:` +
        " actual=" +
        JSON.stringify(actual) +
        " expected=" +
        JSON.stringify(expected)
    );
    Assert.equal(
      actual.type,
      expected.type,
      `result.type at result index ${i}`
    );
    Assert.equal(
      actual.source,
      expected.source,
      `result.source at result index ${i}`
    );
    Assert.equal(
      actual.heuristic,
      expected.heuristic,
      `result.heuristic at result index ${i}`
    );
    Assert.equal(
      !!actual.isBestMatch,
      !!expected.isBestMatch,
      `result.isBestMatch at result index ${i}`
    );
    if (expected.providerName) {
      Assert.equal(
        actual.providerName,
        expected.providerName,
        `result.providerName at result index ${i}`
      );
    }
    if (expected.hasOwnProperty("suggestedIndex")) {
      Assert.equal(
        actual.suggestedIndex,
        expected.suggestedIndex,
        `result.suggestedIndex at result index ${i}`
      );
    }

    if (expected.payload) {
      Assert.deepEqual(
        getPayload(actual),
        getPayload(expected),
        `result.payload at result index ${i}`
      );
    }
  }
}

/**
 * Returns the frecency of an origin.
 *
 * @param   {string} prefix
 *          The origin's prefix, e.g., "http://".
 * @param   {string} aHost
 *          The origin's host.
 * @returns {number} The origin's frecency.
 */
async function getOriginFrecency(prefix, aHost) {
  let db = await PlacesUtils.promiseDBConnection();
  let rows = await db.execute(
    `
    SELECT frecency
    FROM moz_origins
    WHERE prefix = :prefix AND host = :host
  `,
    { prefix, host: aHost }
  );
  Assert.equal(rows.length, 1);
  return rows[0].getResultByIndex(0);
}

/**
 * Returns the origin frecency stats.
 *
 * @returns {object}
 *          An object { count, sum, squares }.
 */
async function getOriginFrecencyStats() {
  let db = await PlacesUtils.promiseDBConnection();
  let rows = await db.execute(`
    SELECT
      IFNULL((SELECT value FROM moz_meta WHERE key = 'origin_frecency_count'), 0),
      IFNULL((SELECT value FROM moz_meta WHERE key = 'origin_frecency_sum'), 0),
      IFNULL((SELECT value FROM moz_meta WHERE key = 'origin_frecency_sum_of_squares'), 0)
  `);
  let count = rows[0].getResultByIndex(0);
  let sum = rows[0].getResultByIndex(1);
  let squares = rows[0].getResultByIndex(2);
  return { count, sum, squares };
}

/**
 * Returns the origin autofill frecency threshold.
 *
 * @returns {number}
 *          The threshold.
 */
async function getOriginAutofillThreshold() {
  let { count, sum, squares } = await getOriginFrecencyStats();
  if (!count) {
    return 0;
  }
  if (count == 1) {
    return sum;
  }
  let stddevMultiplier = UrlbarPrefs.get("autoFill.stddevMultiplier");
  return (
    sum / count +
    stddevMultiplier * Math.sqrt((squares - (sum * sum) / count) / count)
  );
}

/**
 * Checks that origins appear in a given order in the database.
 *
 * @param {string} host The "fixed" host, without "www."
 * @param {Array} prefixOrder The prefixes (scheme + www.) sorted appropriately.
 */
async function checkOriginsOrder(host, prefixOrder) {
  await PlacesUtils.withConnectionWrapper("checkOriginsOrder", async db => {
    let prefixes = (
      await db.execute(
        `SELECT prefix || iif(instr(host, "www.") = 1, "www.", "")
         FROM moz_origins
         WHERE host = :host OR host = "www." || :host
         ORDER BY ROWID ASC
        `,
        { host }
      )
    ).map(r => r.getResultByIndex(0));
    Assert.deepEqual(prefixes, prefixOrder);
  });
}
