/* 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/. */

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
  InteractionsBlocklist: "resource:///modules/InteractionsBlocklist.sys.mjs",
  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
  clearTimeout: "resource://gre/modules/Timer.sys.mjs",
  setTimeout: "resource://gre/modules/Timer.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "logConsole", function () {
  return console.createInstance({
    prefix: "InteractionsManager",
    maxLogLevel: Services.prefs.getBoolPref(
      "browser.places.interactions.log",
      false
    )
      ? "Debug"
      : "Warn",
  });
});

XPCOMUtils.defineLazyServiceGetters(lazy, {
  idleService: ["@mozilla.org/widget/useridleservice;1", "nsIUserIdleService"],
});

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "pageViewIdleTime",
  "browser.places.interactions.pageViewIdleTime",
  60
);

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "saveInterval",
  "browser.places.interactions.saveInterval",
  10000
);

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "isHistoryEnabled",
  "places.history.enabled",
  false
);

const DOMWINDOW_OPENED_TOPIC = "domwindowopened";

/**
 * Returns a monotonically increasing timestamp, that is critical to distinguish
 * database entries by creation time.
 */
let gLastTime = 0;
function monotonicNow() {
  let time = Date.now();
  if (time == gLastTime) {
    time++;
  }
  return (gLastTime = time);
}

/**
 * @typedef {object} DocumentInfo
 *   DocumentInfo is used to pass document information from the child process
 *   to _Interactions.
 * @property {boolean} isActive
 *   Set to true if the document is active, i.e. visible.
 * @property {string} url
 *   The url of the page that was interacted with.
 */

/**
 * @typedef {object} InteractionInfo
 *   InteractionInfo is used to store information associated with interactions.
 * @property {number} totalViewTime
 *   Time in milliseconds that the page has been actively viewed for.
 * @property {string} url
 *   The url of the page that was interacted with.
 * @property {Interactions.DOCUMENT_TYPE} documentType
 *   The type of the document.
 * @property {number} typingTime
 *   Time in milliseconds that the user typed on the page
 * @property {number} keypresses
 *   The number of keypresses made on the page
 * @property {number} scrollingTime
 *   Time in milliseconds that the user spent scrolling the page
 * @property {number} scrollingDistance
 *   The distance, in pixels, that the user scrolled the page
 * @property {number} created_at
 *   Creation time as the number of milliseconds since the epoch.
 * @property {number} updated_at
 *   Last updated time as the number of milliseconds since the epoch.
 * @property {string} referrer
 *   The referrer to the url of the page that was interacted with (may be empty)
 */

/**
 * The Interactions object sets up listeners and other approriate tools for
 * obtaining interaction information and passing it to the InteractionsManager.
 */
class _Interactions {
  DOCUMENT_TYPE = {
    // Used when the document type is unknown.
    GENERIC: 0,
    // Used for pages serving media, e.g. videos.
    MEDIA: 1,
  };

  /**
   * This is used to store potential interactions. It maps the browser
   * to the current interaction information.
   * The current interaction is updated to the database when it transitions
   * to non-active, which occurs before a browser tab is closed, hence this
   * can be a weak map.
   *
   * @type {WeakMap<browser, InteractionInfo>}
   */
  #interactions = new WeakMap();

  /**
   * Tracks the currently active window so that we can avoid recording
   * interactions in non-active windows.
   *
   * @type {DOMWindow}
   */
  #activeWindow = undefined;

  /**
   * Tracks if the user is idle.
   *
   * @type {boolean}
   */
  #userIsIdle = false;

  /**
   * This stores the page view start time of the current page view.
   * For any single page view, this may be moved multiple times as the
   * associated interaction is updated for the current total page view time.
   *
   * @type {number}
   */
  _pageViewStartTime = Cu.now();

  /**
   * Stores interactions in the database, see the {@link InteractionsStore}
   * class. This is created lazily, see the `store` getter.
   *
   * @type {InteractionsStore | undefined}
   */
  #store = undefined;

  /**
   * Whether the component has been initialized.
   */
  #initialized = false;

  /**
   * Initializes, sets up actors and observers.
   */
  init() {
    if (
      !Services.prefs.getBoolPref("browser.places.interactions.enabled", false)
    ) {
      return;
    }

    ChromeUtils.registerWindowActor("Interactions", {
      parent: {
        esModuleURI: "resource:///actors/InteractionsParent.sys.mjs",
      },
      child: {
        esModuleURI: "resource:///actors/InteractionsChild.sys.mjs",
        events: {
          DOMContentLoaded: {},
          pagehide: { mozSystemGroup: true },
        },
      },
      messageManagerGroups: ["browsers"],
    });

    this.#activeWindow = Services.wm.getMostRecentBrowserWindow();

    for (let win of lazy.BrowserWindowTracker.orderedWindows) {
      if (!win.closed) {
        this.#registerWindow(win);
      }
    }
    Services.obs.addObserver(this, DOMWINDOW_OPENED_TOPIC, true);
    lazy.idleService.addIdleObserver(this, lazy.pageViewIdleTime);
    this.#initialized = true;
  }

  /**
   * Uninitializes, removes any observers that need cleaning up manually.
   */
  uninit() {
    if (this.#initialized) {
      lazy.idleService.removeIdleObserver(this, lazy.pageViewIdleTime);
    }
  }

  /**
   * Resets any stored user or interaction state.
   * Used by tests.
   */
  async reset() {
    lazy.logConsole.debug("Database reset");
    this.#interactions = new WeakMap();
    this.#userIsIdle = false;
    this._pageViewStartTime = Cu.now();
    ChromeUtils.consumeInteractionData();
    await _Interactions.interactionUpdatePromise;
    await this.store.reset();
  }

  /**
   * Retrieve the underlying InteractionsStore object. This exists for testing
   * purposes and should not be abused by production code (for example it'd be
   * a bad idea to force flushes).
   *
   * @returns {InteractionsStore}
   */
  get store() {
    if (!this.#store) {
      this.#store = new InteractionsStore();
    }
    return this.#store;
  }

  /**
   * Registers the start of a new interaction.
   *
   * @param {Browser} browser
   *   The browser object associated with the interaction.
   * @param {DocumentInfo} docInfo
   *   The document information of the page associated with the interaction.
   */
  registerNewInteraction(browser, docInfo) {
    if (
      !browser ||
      !lazy.isHistoryEnabled ||
      !browser.browsingContext.useGlobalHistory
    ) {
      return;
    }
    let interaction = this.#interactions.get(browser);
    if (interaction && interaction.url != docInfo.url) {
      this.registerEndOfInteraction(browser);
    }

    if (lazy.InteractionsBlocklist.isUrlBlocklisted(docInfo.url)) {
      lazy.logConsole.debug(
        "Ignoring a page as the URL is blocklisted",
        docInfo
      );
      return;
    }

    lazy.logConsole.debug("Tracking a new interaction", docInfo);
    let now = monotonicNow();
    interaction = {
      url: docInfo.url,
      referrer: docInfo.referrer,
      totalViewTime: 0,
      typingTime: 0,
      keypresses: 0,
      scrollingTime: 0,
      scrollingDistance: 0,
      created_at: now,
      updated_at: now,
    };
    this.#interactions.set(browser, interaction);

    // Only reset the time if this is being loaded in the active tab of the
    // active window.
    if (docInfo.isActive && browser.ownerGlobal == this.#activeWindow) {
      this._pageViewStartTime = Cu.now();
    }
  }

  /**
   * Registers the end of an interaction, e.g. if the user navigates away
   * from the page. This will store the final interaction details and clear
   * the current interaction.
   *
   * @param {Browser} browser
   *   The browser object associated with the interaction.
   */
  registerEndOfInteraction(browser) {
    // Not having a browser passed to us probably means the tab has gone away
    // before we received the notification - due to the tab being a background
    // tab. Since that will be a non-active tab, it is acceptable that we don't
    // update the interaction. When switching away from active tabs, a TabSelect
    // notification is generated which we handle elsewhere.
    if (
      !browser ||
      !lazy.isHistoryEnabled ||
      !browser.browsingContext.useGlobalHistory
    ) {
      return;
    }
    lazy.logConsole.debug("Saw the end of an interaction");

    this.#updateInteraction(browser);
    this.#interactions.delete(browser);
  }

  /**
   * Updates the current interaction
   *
   * @param {Browser} [browser]
   *   The browser object that has triggered the update, if known. This is
   *   used to check if the browser is in the active window, and as an
   *   optimization to avoid obtaining the browser object.
   */
  #updateInteraction(browser = undefined) {
    _Interactions.#updateInteraction_async(
      browser,
      this.#activeWindow,
      this.#userIsIdle,
      this.#interactions,
      this._pageViewStartTime,
      this.store
    );
  }

  /**
   * Stores the promise created in updateInteraction_async so that we can await its fulfillment
   * when sychronization is needed.
   */
  static interactionUpdatePromise = Promise.resolve();

  /**
   * Returns the interactions update promise to be used when sychronization is needed from tests.
   *
   * @returns {Promise<void>}
   */
  get interactionUpdatePromise() {
    return _Interactions.interactionUpdatePromise;
  }

  /**
   * Updates the current interaction on fulfillment of the asynchronous collection of scrolling interactions.
   *
   * @param {Browser} browser
   *   The browser object that has triggered the update, if known.
   * @param {DOMWindow} activeWindow
   *   The active window.
   * @param {boolean} userIsIdle
   *   Whether the user is idle.
   * @param {WeakMap<Browser, InteractionInfo>} interactions
   *   A map of interactions for each browser instance
   * @param {number} pageViewStartTime
   *   The time the page was loaded.
   * @param {InteractionsStore} store
   *   The interactions store.
   */
  static async #updateInteraction_async(
    browser,
    activeWindow,
    userIsIdle,
    interactions,
    pageViewStartTime,
    store
  ) {
    if (!activeWindow || (browser && browser.ownerGlobal != activeWindow)) {
      lazy.logConsole.debug(
        "Not updating interaction as there is no active window"
      );
      return;
    }

    // We do not update the interaction when the user is idle, since we will
    // have already updated it when idle was signalled.
    // Sometimes an interaction may be signalled before idle is cleared, however
    // worst case we'd only loose approx 2 seconds of interaction detail.
    if (userIsIdle) {
      lazy.logConsole.debug("Not updating interaction as the user is idle");
      return;
    }

    if (!browser) {
      browser = activeWindow.gBrowser.selectedTab.linkedBrowser;
    }

    let interaction = interactions.get(browser);
    if (!interaction) {
      lazy.logConsole.debug("No interaction to update");
      return;
    }

    interaction.totalViewTime += Cu.now() - pageViewStartTime;
    Interactions._pageViewStartTime = Cu.now();

    const interactionData = ChromeUtils.consumeInteractionData();
    const typing = interactionData.Typing;
    if (typing) {
      interaction.typingTime += typing.interactionTimeInMilliseconds;
      interaction.keypresses += typing.interactionCount;
    }

    // Collect the scrolling data and add the interaction to the store on completion
    _Interactions.interactionUpdatePromise =
      _Interactions.interactionUpdatePromise
        .then(async () => ChromeUtils.collectScrollingData())
        .then(
          result => {
            interaction.scrollingTime += result.interactionTimeInMilliseconds;
            interaction.scrollingDistance += result.scrollingDistanceInPixels;
          },
          reason => {
            console.error(reason);
          }
        )
        .then(() => {
          interaction.updated_at = monotonicNow();

          lazy.logConsole.debug("Add to store: ", interaction);
          store.add(interaction);
        });
  }

  /**
   * Handles a window becoming active.
   *
   * @param {DOMWindow} win
   *   The window that has become active.
   */
  #onActivateWindow(win) {
    lazy.logConsole.debug("Window activated");

    if (lazy.PrivateBrowsingUtils.isWindowPrivate(win)) {
      return;
    }

    this.#activeWindow = win;
    this._pageViewStartTime = Cu.now();
  }

  /**
   * Handles a window going inactive.
   */
  #onDeactivateWindow() {
    lazy.logConsole.debug("Window deactivate");

    this.#updateInteraction();
    this.#activeWindow = undefined;
  }

  /**
   * Handles the TabSelect notification. Updates the current interaction and
   * then switches it to the interaction for the new tab. The new interaction
   * may be null if it doesn't exist.
   *
   * @param {Browser} previousBrowser
   *   The instance of the browser that the user switched away from.
   */
  #onTabSelect(previousBrowser) {
    lazy.logConsole.debug("Tab switched");

    this.#updateInteraction(previousBrowser);
    this._pageViewStartTime = Cu.now();
  }

  /**
   * Handles various events and forwards them to appropriate functions.
   *
   * @param {DOMEvent} event
   *   The event that will be handled
   */
  handleEvent(event) {
    switch (event.type) {
      case "TabSelect":
        this.#onTabSelect(event.detail.previousTab.linkedBrowser);
        break;
      case "activate":
        this.#onActivateWindow(event.target);
        break;
      case "deactivate":
        this.#onDeactivateWindow(event.target);
        break;
      case "unload":
        this.#unregisterWindow(event.target);
        break;
    }
  }

  /**
   * Handles notifications from the observer service.
   *
   * @param {nsISupports} subject
   *   The subject of the notification.
   * @param {string} topic
   *   The topic of the notification.
   */
  observe(subject, topic) {
    switch (topic) {
      case DOMWINDOW_OPENED_TOPIC:
        this.#onWindowOpen(subject);
        break;
      case "idle":
        lazy.logConsole.debug("User went idle");
        // We save the state of the current interaction when we are notified
        // that the user is idle.
        this.#updateInteraction();
        this.#userIsIdle = true;
        break;
      case "active":
        lazy.logConsole.debug("User became active");
        this.#userIsIdle = false;
        this._pageViewStartTime = Cu.now();
        break;
    }
  }

  /**
   * Handles registration of listeners in a new window.
   *
   * @param {DOMWindow} win
   *   The window to register in.
   */
  #registerWindow(win) {
    if (lazy.PrivateBrowsingUtils.isWindowPrivate(win)) {
      return;
    }

    win.addEventListener("TabSelect", this, true);
    win.addEventListener("deactivate", this, true);
    win.addEventListener("activate", this, true);
  }

  /**
   * Handles removing of listeners from a window.
   *
   * @param {DOMWindow} win
   *   The window to remove listeners from.
   */
  #unregisterWindow(win) {
    win.removeEventListener("TabSelect", this, true);
    win.removeEventListener("deactivate", this, true);
    win.removeEventListener("activate", this, true);
  }

  /**
   * Handles a new window being opened, waits for load and checks that
   * it is a browser window, then adds listeners.
   *
   * @param {DOMWindow} win
   *   The window being opened.
   */
  #onWindowOpen(win) {
    win.addEventListener(
      "load",
      () => {
        if (
          win.document.documentElement.getAttribute("windowtype") !=
          "navigator:browser"
        ) {
          return;
        }
        this.#registerWindow(win);
      },
      { once: true }
    );
  }

  QueryInterface = ChromeUtils.generateQI([
    "nsIObserver",
    "nsISupportsWeakReference",
  ]);
}

export const Interactions = new _Interactions();

/**
 * Store interactions data in the Places database.
 * To improve performance the writes are buffered every `saveInterval`
 * milliseconds. Even if this means we could be trying to write interaction for
 * pages that in the meanwhile have been removed, that's not a problem because
 * we won't be able to insert entries having a NULL place_id, they will just be
 * ignored.
 * Use .add(interaction) to request storing of an interaction.
 * Use .pendingPromise to await for any pending writes to have happened.
 */
class InteractionsStore {
  /**
   * Timer to run database updates on.
   */
  #timer = undefined;
  /**
   * Tracks interactions replicating the unique index in the underlying schema.
   * Interactions are keyed by url and then created_at.
   *
   * @type {Map<string, Map<number, InteractionInfo>>}
   */
  #interactions = new Map();
  /**
   * Used to unblock the queue of promises when the timer is cleared.
   */
  #timerResolve = undefined;

  constructor() {
    // Block async shutdown to ensure the last write goes through.
    this.progress = {};
    lazy.PlacesUtils.history.shutdownClient.jsclient.addBlocker(
      "Interactions.sys.mjs:: store",
      async () => this.flush(),
      { fetchState: () => this.progress }
    );

    // Can be used to wait for the last pending write to have happened.
    this.pendingPromise = Promise.resolve();
  }

  /**
   * Synchronizes the pending interactions with the storage device.
   *
   * @returns {Promise} resolved when the pending data is on disk.
   */
  async flush() {
    if (this.#timer) {
      lazy.clearTimeout(this.#timer);
      this.#timerResolve();
      await this.#updateDatabase();
    }
  }

  /**
   * Completely clears the store and any pending writes.
   * This exists for testing purposes.
   */
  async reset() {
    await lazy.PlacesUtils.withConnectionWrapper(
      "Interactions.sys.mjs::reset",
      async db => {
        await db.executeCached(`DELETE FROM moz_places_metadata`);
      }
    );
    if (this.#timer) {
      lazy.clearTimeout(this.#timer);
      this.#timer = undefined;
      this.#timerResolve();
      this.#interactions.clear();
    }
  }

  /**
   * Registers an interaction to be stored persistently. At the end of the call
   * the interaction has not yet been added to the store, tests can await
   * flushStore() for that.
   *
   * @param {InteractionInfo} interaction
   *   The document information to write.
   */
  add(interaction) {
    lazy.logConsole.debug("Preparing interaction for storage", interaction);

    let interactionsForUrl = this.#interactions.get(interaction.url);
    if (!interactionsForUrl) {
      interactionsForUrl = new Map();
      this.#interactions.set(interaction.url, interactionsForUrl);
    }
    interactionsForUrl.set(interaction.created_at, interaction);

    if (!this.#timer) {
      let promise = new Promise(resolve => {
        this.#timerResolve = resolve;
        this.#timer = lazy.setTimeout(() => {
          this.#updateDatabase().catch(console.error).then(resolve);
        }, lazy.saveInterval);
      });
      this.pendingPromise = this.pendingPromise.then(() => promise);
    }
  }

  async #updateDatabase() {
    this.#timer = undefined;

    // Reset the buffer.
    let interactions = this.#interactions;
    if (!interactions.size) {
      return;
    }
    // Don't clear() this, since that would also clear interactions.
    this.#interactions = new Map();

    let params = {};
    let SQLInsertFragments = [];
    let i = 0;
    for (let interactionsForUrl of interactions.values()) {
      for (let interaction of interactionsForUrl.values()) {
        params[`url${i}`] = interaction.url;
        params[`referrer${i}`] = interaction.referrer;
        params[`created_at${i}`] = interaction.created_at;
        params[`updated_at${i}`] = interaction.updated_at;
        params[`document_type${i}`] =
          interaction.documentType ?? Interactions.DOCUMENT_TYPE.GENERIC;
        params[`total_view_time${i}`] =
          Math.round(interaction.totalViewTime) || 0;
        params[`typing_time${i}`] = Math.round(interaction.typingTime) || 0;
        params[`key_presses${i}`] = interaction.keypresses || 0;
        params[`scrolling_time${i}`] =
          Math.round(interaction.scrollingTime) || 0;
        params[`scrolling_distance${i}`] =
          Math.round(interaction.scrollingDistance) || 0;
        SQLInsertFragments.push(`(
          (SELECT id FROM moz_places_metadata
            WHERE place_id = (SELECT id FROM moz_places WHERE url_hash = hash(:url${i}) AND url = :url${i})
              AND created_at = :created_at${i}),
          (SELECT id FROM moz_places WHERE url_hash = hash(:url${i}) AND url = :url${i}),
          (SELECT id FROM moz_places WHERE url_hash = hash(:referrer${i}) AND url = :referrer${i} AND :referrer${i} != :url${i}),
          :created_at${i},
          :updated_at${i},
          :document_type${i},
          :total_view_time${i},
          :typing_time${i},
          :key_presses${i},
          :scrolling_time${i},
          :scrolling_distance${i}
        )`);
        i++;
      }
    }

    lazy.logConsole.debug(`Storing ${i} entries in the database`);

    this.progress.pendingUpdates = i;
    await lazy.PlacesUtils.withConnectionWrapper(
      "Interactions.sys.mjs::updateDatabase",
      async db => {
        await db.executeCached(
          `
          WITH inserts (id, place_id, referrer_place_id, created_at, updated_at, document_type, total_view_time, typing_time, key_presses, scrolling_time, scrolling_distance) AS (
            VALUES ${SQLInsertFragments.join(", ")}
          )
          INSERT OR REPLACE INTO moz_places_metadata (
            id, place_id, referrer_place_id, created_at, updated_at, document_type, total_view_time, typing_time, key_presses, scrolling_time, scrolling_distance
          ) SELECT * FROM inserts WHERE place_id NOT NULL;
        `,
          params
        );
      }
    );
    this.progress.pendingUpdates = 0;

    Services.obs.notifyObservers(null, "places-metadata-updated");
  }
}
