/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
/* 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/. */

"use strict";

ChromeUtils.defineESModuleGetters(this, {
  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
});

var { normalizeTime } = ExtensionCommon;

let nsINavHistoryService = Ci.nsINavHistoryService;
const TRANSITION_TO_TRANSITION_TYPES_MAP = new Map([
  ["link", nsINavHistoryService.TRANSITION_LINK],
  ["typed", nsINavHistoryService.TRANSITION_TYPED],
  ["auto_bookmark", nsINavHistoryService.TRANSITION_BOOKMARK],
  ["auto_subframe", nsINavHistoryService.TRANSITION_EMBED],
  ["manual_subframe", nsINavHistoryService.TRANSITION_FRAMED_LINK],
  ["reload", nsINavHistoryService.TRANSITION_RELOAD],
]);

let TRANSITION_TYPE_TO_TRANSITIONS_MAP = new Map();
for (let [transition, transitionType] of TRANSITION_TO_TRANSITION_TYPES_MAP) {
  TRANSITION_TYPE_TO_TRANSITIONS_MAP.set(transitionType, transition);
}

const getTransitionType = transition => {
  // cannot set a default value for the transition argument as the framework sets it to null
  transition = transition || "link";
  let transitionType = TRANSITION_TO_TRANSITION_TYPES_MAP.get(transition);
  if (!transitionType) {
    throw new Error(
      `|${transition}| is not a supported transition for history`
    );
  }
  return transitionType;
};

const getTransition = transitionType => {
  return TRANSITION_TYPE_TO_TRANSITIONS_MAP.get(transitionType) || "link";
};

/*
 * Converts a mozIStorageRow into a HistoryItem
 */
const convertRowToHistoryItem = row => {
  return {
    id: row.getResultByName("guid"),
    url: row.getResultByName("url"),
    title: row.getResultByName("page_title"),
    lastVisitTime: PlacesUtils.toDate(
      row.getResultByName("last_visit_date")
    ).getTime(),
    visitCount: row.getResultByName("visit_count"),
  };
};

/*
 * Converts a mozIStorageRow into a VisitItem
 */
const convertRowToVisitItem = row => {
  return {
    id: row.getResultByName("guid"),
    visitId: String(row.getResultByName("id")),
    visitTime: PlacesUtils.toDate(row.getResultByName("visit_date")).getTime(),
    referringVisitId: String(row.getResultByName("from_visit")),
    transition: getTransition(row.getResultByName("visit_type")),
  };
};

/*
 * Converts a mozIStorageResultSet into an array of objects
 */
const accumulateNavHistoryResults = (resultSet, converter, results) => {
  let row;
  while ((row = resultSet.getNextRow())) {
    results.push(converter(row));
  }
};

function executeAsyncQuery(historyQuery, options, resultConverter) {
  let results = [];
  return new Promise((resolve, reject) => {
    PlacesUtils.history.asyncExecuteLegacyQuery(historyQuery, options, {
      handleResult(resultSet) {
        accumulateNavHistoryResults(resultSet, resultConverter, results);
      },
      handleError(error) {
        reject(
          new Error(
            "Async execution error (" + error.result + "): " + error.message
          )
        );
      },
      handleCompletion() {
        resolve(results);
      },
    });
  });
}

this.history = class extends ExtensionAPIPersistent {
  PERSISTENT_EVENTS = {
    onVisited({ fire }) {
      const listener = events => {
        for (const event of events) {
          const visit = {
            id: event.pageGuid,
            url: event.url,
            title: event.lastKnownTitle || "",
            lastVisitTime: event.visitTime,
            visitCount: event.visitCount,
            typedCount: event.typedCount,
          };
          fire.sync(visit);
        }
      };

      PlacesUtils.observers.addListener(["page-visited"], listener);
      return {
        unregister() {
          PlacesUtils.observers.removeListener(["page-visited"], listener);
        },
        convert(_fire) {
          fire = _fire;
        },
      };
    },
    onVisitRemoved({ fire }) {
      const listener = events => {
        const removedURLs = [];

        for (const event of events) {
          switch (event.type) {
            case "history-cleared": {
              fire.sync({ allHistory: true, urls: [] });
              break;
            }
            case "page-removed": {
              if (!event.isPartialVisistsRemoval) {
                removedURLs.push(event.url);
              }
              break;
            }
          }
        }

        if (removedURLs.length) {
          fire.sync({ allHistory: false, urls: removedURLs });
        }
      };

      PlacesUtils.observers.addListener(
        ["history-cleared", "page-removed"],
        listener
      );
      return {
        unregister() {
          PlacesUtils.observers.removeListener(
            ["history-cleared", "page-removed"],
            listener
          );
        },
        convert(_fire) {
          fire = _fire;
        },
      };
    },
    onTitleChanged({ fire }) {
      const listener = events => {
        for (const event of events) {
          const titleChanged = {
            id: event.pageGuid,
            url: event.url,
            title: event.title,
          };
          fire.sync(titleChanged);
        }
      };

      PlacesUtils.observers.addListener(["page-title-changed"], listener);
      return {
        unregister() {
          PlacesUtils.observers.removeListener(
            ["page-title-changed"],
            listener
          );
        },
        convert(_fire) {
          fire = _fire;
        },
      };
    },
  };

  getAPI(context) {
    return {
      history: {
        addUrl: function (details) {
          let transition, date;
          try {
            transition = getTransitionType(details.transition);
          } catch (error) {
            return Promise.reject({ message: error.message });
          }
          if (details.visitTime) {
            date = normalizeTime(details.visitTime);
          }
          let pageInfo = {
            title: details.title,
            url: details.url,
            visits: [
              {
                transition,
                date,
              },
            ],
          };
          try {
            return PlacesUtils.history.insert(pageInfo).then(() => undefined);
          } catch (error) {
            return Promise.reject({ message: error.message });
          }
        },

        deleteAll: function () {
          return PlacesUtils.history.clear();
        },

        deleteRange: function (filter) {
          let newFilter = {
            beginDate: normalizeTime(filter.startTime),
            endDate: normalizeTime(filter.endTime),
          };
          // History.removeVisitsByFilter returns a boolean, but our API should return nothing
          return PlacesUtils.history
            .removeVisitsByFilter(newFilter)
            .then(() => undefined);
        },

        deleteUrl: function (details) {
          let url = details.url;
          // History.remove returns a boolean, but our API should return nothing
          return PlacesUtils.history.remove(url).then(() => undefined);
        },

        search: function (query) {
          let beginTime =
            query.startTime == null
              ? PlacesUtils.toPRTime(Date.now() - 24 * 60 * 60 * 1000)
              : PlacesUtils.toPRTime(normalizeTime(query.startTime));
          let endTime =
            query.endTime == null
              ? Number.MAX_VALUE
              : PlacesUtils.toPRTime(normalizeTime(query.endTime));
          if (beginTime > endTime) {
            return Promise.reject({
              message: "The startTime cannot be after the endTime",
            });
          }

          let options = PlacesUtils.history.getNewQueryOptions();
          options.includeHidden = true;
          options.sortingMode = options.SORT_BY_DATE_DESCENDING;
          options.maxResults = query.maxResults || 100;

          let historyQuery = PlacesUtils.history.getNewQuery();
          historyQuery.searchTerms = query.text;
          historyQuery.beginTime = beginTime;
          historyQuery.endTime = endTime;
          return executeAsyncQuery(
            historyQuery,
            options,
            convertRowToHistoryItem
          );
        },

        getVisits: function (details) {
          let url = details.url;
          if (!url) {
            return Promise.reject({
              message: "A URL must be provided for getVisits",
            });
          }

          let options = PlacesUtils.history.getNewQueryOptions();
          options.includeHidden = true;
          options.sortingMode = options.SORT_BY_DATE_DESCENDING;
          options.resultType = options.RESULTS_AS_VISIT;

          let historyQuery = PlacesUtils.history.getNewQuery();
          historyQuery.uri = Services.io.newURI(url);
          return executeAsyncQuery(
            historyQuery,
            options,
            convertRowToVisitItem
          );
        },

        onVisited: new EventManager({
          context,
          module: "history",
          event: "onVisited",
          extensionApi: this,
        }).api(),

        onVisitRemoved: new EventManager({
          context,
          module: "history",
          event: "onVisitRemoved",
          extensionApi: this,
        }).api(),

        onTitleChanged: new EventManager({
          context,
          module: "history",
          event: "onTitleChanged",
          extensionApi: this,
        }).api(),
      },
    };
  }
};
