const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
  TestUtils: "resource://testing-common/TestUtils.sys.mjs",
});

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

export var PlacesTestUtils = Object.freeze({
  /**
   * Asynchronously adds visits to a page.
   *
   * @param {*} aPlaceInfo
   *        A string URL, nsIURI, Window.URL object, info object (explained
   *        below), or an array of any of those.  Info objects describe the
   *        visits to add more fully than URLs/URIs alone and look like this:
   *
   *          {
   *            uri|url: href, URL or nsIURI of the page,
   *            [optional] transition: one of the TRANSITION_* from nsINavHistoryService,
   *            [optional] title: title of the page,
   *            [optional] visitDate: visit date, either in microseconds from the epoch or as a date object
   *            [optional] referrer: nsIURI of the referrer for this visit
   *          }
   *
   * @return {Promise}
   * @resolves When all visits have been added successfully.
   * @rejects JavaScript exception.
   */
  async addVisits(placeInfo) {
    let places = [];
    let infos = [];

    if (Array.isArray(placeInfo)) {
      places.push(...placeInfo);
    } else {
      places.push(placeInfo);
    }

    // Create a PageInfo for each entry.
    let seenUrls = new Set();
    let lastStoredVisit;
    for (let obj of places) {
      let place;
      if (
        obj instanceof Ci.nsIURI ||
        URL.isInstance(obj) ||
        typeof obj == "string"
      ) {
        place = { uri: obj };
      } else if (typeof obj == "object" && (obj.uri || obj.url)) {
        place = obj;
      } else {
        throw new Error("Unsupported type passed to addVisits");
      }

      let referrer = place.referrer
        ? lazy.PlacesUtils.toURI(place.referrer)
        : null;
      let info = { url: place.uri || place.url };
      let spec =
        info.url instanceof Ci.nsIURI ? info.url.spec : new URL(info.url).href;
      info.exposableURI = Services.io.createExposableURI(
        Services.io.newURI(spec)
      );
      info.title = "title" in place ? place.title : "test visit for " + spec;
      let visitDate = place.visitDate;
      if (visitDate) {
        if (visitDate.constructor.name != "Date") {
          // visitDate should be in microseconds. It's easy to do the wrong thing
          // and pass milliseconds, so we lazily check for that.
          // While it's not easily distinguishable, since both are integers, we
          // can check if the value is very far in the past, and assume it's
          // probably a mistake.
          if (visitDate <= Date.now()) {
            throw new Error(
              "AddVisits expects a Date object or _micro_seconds!"
            );
          }
          visitDate = lazy.PlacesUtils.toDate(visitDate);
        }
      } else {
        visitDate = new Date();
      }
      info.visits = [
        {
          transition: place.transition,
          date: visitDate,
          referrer,
        },
      ];
      seenUrls.add(info.url);
      infos.push(info);
      if (
        !place.transition ||
        place.transition != lazy.PlacesUtils.history.TRANSITIONS.EMBED
      ) {
        lastStoredVisit = info;
      }
    }
    await lazy.PlacesUtils.history.insertMany(infos);
    if (seenUrls.size > 1) {
      // If there's only one URL then history has updated frecency already,
      // otherwise we must force a recalculation.
      await lazy.PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
    }
    if (lastStoredVisit) {
      await lazy.TestUtils.waitForCondition(
        () => lazy.PlacesUtils.history.fetch(lastStoredVisit.exposableURI),
        "Ensure history has been updated and is visible to read-only connections"
      );
    }
  },

  /*
   * Add Favicons
   *
   * @param {Map} faviconURLs  keys are page URLs, values are their
   *                           associated favicon URLs.
   */

  async addFavicons(faviconURLs) {
    let faviconPromises = [];

    // If no favicons were provided, we do not want to continue on
    if (!faviconURLs) {
      throw new Error("No favicon URLs were provided");
    }
    for (let [key, val] of faviconURLs) {
      if (!val) {
        throw new Error("URL does not exist");
      }
      faviconPromises.push(
        new Promise((resolve, reject) => {
          let uri = Services.io.newURI(key);
          let faviconURI = Services.io.newURI(val);
          try {
            lazy.PlacesUtils.favicons.setAndFetchFaviconForPage(
              uri,
              faviconURI,
              false,
              lazy.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
              resolve,
              Services.scriptSecurityManager.getSystemPrincipal()
            );
          } catch (ex) {
            reject(ex);
          }
        })
      );
    }
    await Promise.all(faviconPromises);
  },

  /*
   * Helper function to call PlacesUtils.favicons.setFaviconForPage() and waits
   * finishing setting. This function throws an error if the status of
   * PlacesUtils.favicons.setFaviconForPage() is not success.
   *
   * @param {string or nsIURI} pageURI
   * @param {string or nsIURI} faviconURI
   * @param {string or nsIURI} faviconDataURL
   * @param {Number} [optional] expiration
   * @return {Promise} waits for finishing setting
   */
  setFaviconForPage(pageURI, faviconURI, faviconDataURL, expiration = 0) {
    return new Promise((resolve, reject) => {
      lazy.PlacesUtils.favicons.setFaviconForPage(
        pageURI instanceof Ci.nsIURI ? pageURI : Services.io.newURI(pageURI),
        faviconURI instanceof Ci.nsIURI
          ? faviconURI
          : Services.io.newURI(faviconURI),
        faviconDataURL instanceof Ci.nsIURI
          ? faviconDataURL
          : Services.io.newURI(faviconDataURL),
        expiration,
        status => {
          if (Components.isSuccessCode(status)) {
            resolve(status);
          } else {
            reject(
              new Error(
                `Failed to process setFaviconForPage(): status code = ${status}`
              )
            );
          }
        }
      );
    });
  },

  /**
   * Clears any favicons stored in the database.
   */
  async clearFavicons() {
    return new Promise(resolve => {
      Services.obs.addObserver(function observer() {
        Services.obs.removeObserver(observer, "places-favicons-expired");
        resolve();
      }, "places-favicons-expired");
      lazy.PlacesUtils.favicons.expireAllFavicons();
    });
  },

  /**
   * Adds a bookmark to the database. This should only be used when you need to
   * add keywords. Otherwise, use `PlacesUtils.bookmarks.insert()`.
   * @param {string} aBookmarkObj.uri
   * @param {string} [aBookmarkObj.title]
   * @param {string} [aBookmarkObj.keyword]
   */
  async addBookmarkWithDetails(aBookmarkObj) {
    await lazy.PlacesUtils.bookmarks.insert({
      parentGuid: lazy.PlacesUtils.bookmarks.unfiledGuid,
      title: aBookmarkObj.title || "A bookmark",
      url: aBookmarkObj.uri,
    });

    if (aBookmarkObj.keyword) {
      await lazy.PlacesUtils.keywords.insert({
        keyword: aBookmarkObj.keyword,
        url:
          aBookmarkObj.uri instanceof Ci.nsIURI
            ? aBookmarkObj.uri.spec
            : aBookmarkObj.uri,
        postData: aBookmarkObj.postData,
      });
    }

    if (aBookmarkObj.tags) {
      let uri =
        aBookmarkObj.uri instanceof Ci.nsIURI
          ? aBookmarkObj.uri
          : Services.io.newURI(aBookmarkObj.uri);
      lazy.PlacesUtils.tagging.tagURI(uri, aBookmarkObj.tags);
    }
  },

  /**
   * Waits for all pending async statements on the default connection.
   *
   * @return {Promise}
   * @resolves When all pending async statements finished.
   * @rejects Never.
   *
   * @note The result is achieved by asynchronously executing a query requiring
   *       a write lock.  Since all statements on the same connection are
   *       serialized, the end of this write operation means that all writes are
   *       complete.  Note that WAL makes so that writers don't block readers, but
   *       this is a problem only across different connections.
   */
  promiseAsyncUpdates() {
    return lazy.PlacesUtils.withConnectionWrapper(
      "promiseAsyncUpdates",
      async function (db) {
        try {
          await db.executeCached("BEGIN EXCLUSIVE");
          await db.executeCached("COMMIT");
        } catch (ex) {
          // If we fail to start a transaction, it's because there is already one.
          // In such a case we should not try to commit the existing transaction.
        }
      }
    );
  },

  /**
   * Asynchronously checks if an address is found in the database.
   * @param aURI
   *        nsIURI or address to look for.
   *
   * @return {Promise}
   * @resolves Returns true if the page is found.
   * @rejects JavaScript exception.
   */
  async isPageInDB(aURI) {
    return (
      (await this.getDatabaseValue("moz_places", "id", { url: aURI })) !==
      undefined
    );
  },

  /**
   * Asynchronously checks how many visits exist for a specified page.
   * @param aURI
   *        nsIURI or address to look for.
   *
   * @return {Promise}
   * @resolves Returns the number of visits found.
   * @rejects JavaScript exception.
   */
  async visitsInDB(aURI) {
    let url = aURI instanceof Ci.nsIURI ? aURI.spec : aURI;
    let db = await lazy.PlacesUtils.promiseDBConnection();
    let rows = await db.executeCached(
      `SELECT count(*) FROM moz_historyvisits v
       JOIN moz_places h ON h.id = v.place_id
       WHERE url_hash = hash(:url) AND url = :url`,
      { url }
    );
    return rows[0].getResultByIndex(0);
  },

  /**
   * Marks all syncable bookmarks as synced by setting their sync statuses to
   * "NORMAL", resetting their change counters, and removing all tombstones.
   * Used by tests to avoid calling `PlacesSyncUtils.bookmarks.pullChanges`
   * and `PlacesSyncUtils.bookmarks.pushChanges`.
   *
   * @resolves When all bookmarks have been updated.
   * @rejects JavaScript exception.
   */
  markBookmarksAsSynced() {
    return lazy.PlacesUtils.withConnectionWrapper(
      "PlacesTestUtils: markBookmarksAsSynced",
      function (db) {
        return db.executeTransaction(async function () {
          await db.executeCached(
            `WITH RECURSIVE
           syncedItems(id) AS (
             SELECT b.id FROM moz_bookmarks b
             WHERE b.guid IN ('menu________', 'toolbar_____', 'unfiled_____',
                              'mobile______')
             UNION ALL
             SELECT b.id FROM moz_bookmarks b
             JOIN syncedItems s ON b.parent = s.id
           )
           UPDATE moz_bookmarks
           SET syncChangeCounter = 0,
               syncStatus = :syncStatus
           WHERE id IN syncedItems`,
            { syncStatus: lazy.PlacesUtils.bookmarks.SYNC_STATUS.NORMAL }
          );
          await db.executeCached("DELETE FROM moz_bookmarks_deleted");
        });
      }
    );
  },

  /**
   * Sets sync fields for multiple bookmarks.
   * @param aStatusInfos
   *        One or more objects with the following properties:
   *          { [required] guid: The bookmark's GUID,
   *            syncStatus: An `nsINavBookmarksService::SYNC_STATUS_*` constant,
   *            syncChangeCounter: The sync change counter value,
   *            lastModified: The last modified time,
   *            dateAdded: The date added time.
   *          }
   *
   * @resolves When all bookmarks have been updated.
   * @rejects JavaScript exception.
   */
  setBookmarkSyncFields(...aFieldInfos) {
    return lazy.PlacesUtils.withConnectionWrapper(
      "PlacesTestUtils: setBookmarkSyncFields",
      function (db) {
        return db.executeTransaction(async function () {
          for (let info of aFieldInfos) {
            if (!lazy.PlacesUtils.isValidGuid(info.guid)) {
              throw new Error(`Invalid GUID: ${info.guid}`);
            }
            await db.executeCached(
              `UPDATE moz_bookmarks
             SET syncStatus = IFNULL(:syncStatus, syncStatus),
                 syncChangeCounter = IFNULL(:syncChangeCounter, syncChangeCounter),
                 lastModified = IFNULL(:lastModified, lastModified),
                 dateAdded = IFNULL(:dateAdded, dateAdded)
             WHERE guid = :guid`,
              {
                guid: info.guid,
                syncChangeCounter: info.syncChangeCounter,
                syncStatus: "syncStatus" in info ? info.syncStatus : null,
                lastModified:
                  "lastModified" in info
                    ? lazy.PlacesUtils.toPRTime(info.lastModified)
                    : null,
                dateAdded:
                  "dateAdded" in info
                    ? lazy.PlacesUtils.toPRTime(info.dateAdded)
                    : null,
              }
            );
          }
        });
      }
    );
  },

  async fetchBookmarkSyncFields(...aGuids) {
    let db = await lazy.PlacesUtils.promiseDBConnection();
    let results = [];
    for (let guid of aGuids) {
      let rows = await db.executeCached(
        `
        SELECT syncStatus, syncChangeCounter, lastModified, dateAdded
        FROM moz_bookmarks
        WHERE guid = :guid`,
        { guid }
      );
      if (!rows.length) {
        throw new Error(`Bookmark ${guid} does not exist`);
      }
      results.push({
        guid,
        syncStatus: rows[0].getResultByName("syncStatus"),
        syncChangeCounter: rows[0].getResultByName("syncChangeCounter"),
        lastModified: lazy.PlacesUtils.toDate(
          rows[0].getResultByName("lastModified")
        ),
        dateAdded: lazy.PlacesUtils.toDate(
          rows[0].getResultByName("dateAdded")
        ),
      });
    }
    return results;
  },

  async fetchSyncTombstones() {
    let db = await lazy.PlacesUtils.promiseDBConnection();
    let rows = await db.executeCached(`
      SELECT guid, dateRemoved
      FROM moz_bookmarks_deleted
      ORDER BY guid`);
    return rows.map(row => ({
      guid: row.getResultByName("guid"),
      dateRemoved: lazy.PlacesUtils.toDate(row.getResultByName("dateRemoved")),
    }));
  },

  /**
   * Returns a promise that waits until happening Places events specified by
   * notification parameter.
   *
   * @param {string} notification
   *        Available values are:
   *          bookmark-added
   *          bookmark-removed
   *          bookmark-moved
   *          bookmark-guid_changed
   *          bookmark-keyword_changed
   *          bookmark-tags_changed
   *          bookmark-time_changed
   *          bookmark-title_changed
   *          bookmark-url_changed
   *          favicon-changed
   *          history-cleared
   *          page-removed
   *          page-title-changed
   *          page-visited
   *          pages-rank-changed
   *          purge-caches
   * @param {Function} conditionFn [optional]
   *        If need some more condition to wait, please use conditionFn.
   *        This is an optional, but if set, should returns true when the wait
   *        condition is met.
   * @return {Promise}
   *         A promise that resolved if the wait condition is met.
   *         The resolved value is an array of PlacesEvent object.
   */
  waitForNotification(notification, conditionFn) {
    return new Promise(resolve => {
      function listener(events) {
        if (!conditionFn || conditionFn(events)) {
          PlacesObservers.removeListener([notification], listener);
          resolve(events);
        }
      }
      PlacesObservers.addListener([notification], listener);
    });
  },

  /**
   * A debugging helper that dumps the contents of an SQLite table.
   *
   * @param {String} table
   *        The table name.
   * @param {Sqlite.OpenedConnection} [db]
   *        The mirror database connection.
   * @param {String[]} [columns]
   *        Clumns to be printed, defaults to all.
   */
  async dumpTable({ table, db, columns }) {
    if (!table) {
      throw new Error("Must pass a `table` name");
    }
    if (!db) {
      db = await lazy.PlacesUtils.promiseDBConnection();
    }
    if (!columns) {
      columns = (await db.execute(`PRAGMA table_info('${table}')`)).map(r =>
        r.getResultByName("name")
      );
    }
    let results = [columns.join("\t")];

    let rows = await db.execute(`SELECT ${columns.join()} FROM ${table}`);
    dump(`>> Table ${table} contains ${rows.length} rows\n`);

    for (let row of rows) {
      let numColumns = row.numEntries;
      let rowValues = [];
      for (let i = 0; i < numColumns; ++i) {
        let value = "N/A";
        switch (row.getTypeOfIndex(i)) {
          case Ci.mozIStorageValueArray.VALUE_TYPE_NULL:
            value = "NULL";
            break;
          case Ci.mozIStorageValueArray.VALUE_TYPE_INTEGER:
            value = row.getInt64(i);
            break;
          case Ci.mozIStorageValueArray.VALUE_TYPE_FLOAT:
            value = row.getDouble(i);
            break;
          case Ci.mozIStorageValueArray.VALUE_TYPE_TEXT:
            value = JSON.stringify(row.getString(i));
            break;
        }
        rowValues.push(value.toString().padStart(columns[i].length, " "));
      }
      results.push(rowValues.join("\t"));
    }
    results.push("\n");
    dump(results.join("\n"));
  },

  /**
   * Removes all stored metadata.
   */
  clearMetadata() {
    return lazy.PlacesUtils.withConnectionWrapper(
      "PlacesTestUtils: clearMetadata",
      async db => {
        await db.execute(`DELETE FROM moz_meta`);
        lazy.PlacesUtils.metadata.cache.clear();
      }
    );
  },

  /**
   * Clear moz_inputhistory table.
   */
  async clearInputHistory() {
    await lazy.PlacesUtils.withConnectionWrapper(
      "test:clearInputHistory",
      db => {
        return db.executeCached("DELETE FROM moz_inputhistory");
      }
    );
  },

  /**
   * Clear moz_historyvisits table.
   */
  async clearHistoryVisits() {
    await lazy.PlacesUtils.withConnectionWrapper(
      "test:clearHistoryVisits",
      db => {
        return db.executeCached("DELETE FROM moz_historyvisits");
      }
    );
  },

  /**
   * Compares 2 place: URLs ignoring the order of their params.
   * @param url1 First URL to compare
   * @param url2 Second URL to compare
   * @return whether the URLs are the same
   */
  ComparePlacesURIs(url1, url2) {
    url1 = url1 instanceof Ci.nsIURI ? url1.spec : new URL(url1);
    if (url1.protocol != "place:") {
      throw new Error("Expected a place: uri, got " + url1.href);
    }
    url2 = url2 instanceof Ci.nsIURI ? url2.spec : new URL(url2);
    if (url2.protocol != "place:") {
      throw new Error("Expected a place: uri, got " + url2.href);
    }
    let tokens1 = url1.pathname.split("&").sort().join("&");
    let tokens2 = url2.pathname.split("&").sort().join("&");
    if (tokens1 != tokens2) {
      dump(`Failed comparison between:\n${tokens1}\n${tokens2}\n`);
      return false;
    }
    return true;
  },

  /**
   * Retrieves a single value from a specified field in a database table, based
   * on the given conditions.
   * @param {string} table - The name of the database table to query.
   * @param {string} field - The name of the field to retrieve a value from.
   * @param {Object} [conditions] - An object containing the conditions to
   * filter the query results. The keys represent the names of the columns to
   * filter by, and the values represent the filter values.  It's possible to
   * pass an array as value where the first element is an operator
   * (e.g. "<", ">") and the second element is the actual value.
   * @return {Promise} A Promise that resolves to the value of the specified
   * field from the database table, or null if the query returns no results.
   * @throws If more than one result is found for the given conditions.
   */
  async getDatabaseValue(table, field, conditions = {}) {
    let { fragment: where, params } = this._buildWhereClause(table, conditions);
    let query = `SELECT ${field} FROM ${table} ${where}`;
    let conn = await lazy.PlacesUtils.promiseDBConnection();
    let rows = await conn.executeCached(query, params);
    if (rows.length > 1) {
      throw new Error(
        "getDatabaseValue doesn't support returning multiple results"
      );
    }
    return rows[0]?.getResultByIndex(0);
  },

  /**
   * Updates specified fields in a database table, based on the given
   * conditions.
   * @param {string} table - The name of the database table to add to.
   * @param {string} fields - an object with field, value pairs
   * @param {Object} [conditions] - An object containing the conditions to
   * filter the query results. The keys represent the names of the columns to
   * filter by, and the values represent the filter values. It's possible to
   * pass an array as value where the first element is an operator
   * (e.g. "<", ">") and the second element is the actual value.
   * @return {Promise} A Promise that resolves to the number of affected rows.
   * @throws If no rows were affected.
   */
  async updateDatabaseValues(table, fields, conditions = {}) {
    let { fragment: where, params } = this._buildWhereClause(table, conditions);
    let query = `UPDATE ${table} SET ${Object.keys(fields)
      .map(f => f + " = :" + f)
      .join()} ${where} RETURNING rowid`;
    params = Object.assign(fields, params);
    return lazy.PlacesUtils.withConnectionWrapper(
      "setDatabaseValue",
      async conn => {
        let rows = await conn.executeCached(query, params);
        if (!rows.length) {
          throw new Error("setDatabaseValue didn't update any value");
        }
        return rows.length;
      }
    );
  },

  async promiseItemId(guid) {
    return this.getDatabaseValue("moz_bookmarks", "id", { guid });
  },

  async promiseItemGuid(id) {
    return this.getDatabaseValue("moz_bookmarks", "guid", { id });
  },

  async promiseManyItemIds(guids) {
    let conn = await lazy.PlacesUtils.promiseDBConnection();
    let rows = await conn.executeCached(`
      SELECT guid, id FROM moz_bookmarks WHERE guid IN (${guids
        .map(guid => "'" + guid + "'")
        .join()}
      )`);
    return new Map(
      rows.map(r => [r.getResultByName("guid"), r.getResultByName("id")])
    );
  },

  _buildWhereClause(table, conditions) {
    let fragments = [];
    let params = {};
    for (let [column, value] of Object.entries(conditions)) {
      if (column == "url") {
        if (value instanceof Ci.nsIURI) {
          value = value.spec;
        } else if (URL.isInstance(value)) {
          value = value.href;
        }
      }
      if (column == "url" && table == "moz_places") {
        fragments.push("url_hash = hash(:url) AND url = :url");
      } else if (Array.isArray(value)) {
        // First element is the operator, second element is the value.
        let [op, actualValue] = value;
        fragments.push(`${column} ${op} :${column}`);
        value = actualValue;
      } else {
        fragments.push(`${column} = :${column}`);
      }
      params[column] = value;
    }
    return {
      fragment: fragments.length ? `WHERE ${fragments.join(" AND ")}` : "",
      params,
    };
  },
});