/* 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, {
  DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
  PersistentCache: "resource://newtab/lib/PersistentCache.sys.mjs",
});

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "MAX_SUBMISSION_DELAY_PREF_VALUE",
  "browser.newtabpage.activity-stream.telemetry.privatePing.maxSubmissionDelayMs",
  5000
);

const EVENT_STATS_KEY = "event_stats";
const CACHE_KEY = "newtab_content_event_stats";

const CLICK_EVENT_ID = "click";

const EVENT_STATS_DAILY_PERIOD_MS = 60 * 60 * 24 * 1000;
const EVENT_STATS_WEEKLY_PERIOD_MS = 7 * 60 * 60 * 24 * 1000;

const MAX_UINT32 = 0xffffffff;

export class NewTabContentPing {
  #eventBuffer = [];
  #deferredTask = null;
  #lastDelaySelection = 0;
  #maxDailyEvents = 0;
  #maxDailyClickEvents = 0;
  #maxWeeklyClickEvents = 0;
  #curInstanceEventsSent = 0; // Used for tests

  constructor() {
    this.#maxDailyEvents = 0;
    this.#maxDailyClickEvents = 0;
    this.#maxWeeklyClickEvents = 0;
    this.cache = this.PersistentCache(CACHE_KEY, true);
  }

  /**
   * Set the maximum number of events to send in a 24 hour period
   *
   * @param {int} maxEvents
   */
  setMaxEventsPerDay(maxEvents) {
    this.#maxDailyEvents = maxEvents || 0;
  }

  /**
   * Set the maximum number of events to send in a 24 hour period
   *
   * @param {int} maxEvents
   */
  setMaxClickEventsPerDay(maxEvents) {
    this.#maxDailyClickEvents = maxEvents || 0;
  }

  /**
   * Set the maximum number of events to send in a 24 hour period
   *
   * @param {int} maxEvents
   */
  setMaxClickEventsPerWeek(maxEvents) {
    this.#maxWeeklyClickEvents = maxEvents || 0;
  }

  /**
   * Adds a event recording for Glean.newtabContent to the internal buffer.
   * The event will be recorded when the ping is sent.
   *
   * @param {string} name
   *   The name of the event to record.
   * @param {object} data
   *   The extra data being recorded with the event.
   */
  recordEvent(name, data) {
    this.#eventBuffer.push([name, this.sanitizeEventData(data)]);
  }

  /**
   * Schedules the sending of the newtab-content ping at some randomly selected
   * point in the future.
   *
   * @param {object} privateMetrics
   *   The metrics to send along with the ping when it is sent, keyed on the
   *   name of the metric.
   */
  scheduleSubmission(privateMetrics) {
    for (let metric of Object.keys(privateMetrics)) {
      try {
        Glean.newtabContent[metric].set(privateMetrics[metric]);
      } catch (e) {
        console.error(e);
      }
    }

    if (!this.#deferredTask) {
      this.#lastDelaySelection = this.#generateRandomSubmissionDelayMs();
      this.#deferredTask = new lazy.DeferredTask(async () => {
        await this.#flushEventsAndSubmit();
      }, this.#lastDelaySelection);
      this.#deferredTask.arm();
    }
  }

  /**
   * Disarms any pre-existing scheduled newtab-content pings and clears the
   * event buffer.
   */
  uninit() {
    this.#deferredTask?.disarm();
    this.#eventBuffer = [];
  }

  /**
   * Resets the impression stats object of the Newtab_content ping and returns it.
   */
  async resetDailyStats(eventStats = {}) {
    const stats = {
      ...eventStats,
      dailyCount: 0,
      lastUpdatedDaily: this.Date().now(),
      dailyClickCount: 0,
    };
    await this.cache.set(EVENT_STATS_KEY, stats);
    return stats;
  }

  async resetWeeklyStats(eventStats = {}) {
    const stats = {
      ...eventStats,
      lastUpdatedWeekly: this.Date().now(),
      weeklyClickCount: 0,
    };
    await this.cache.set(EVENT_STATS_KEY, stats);
    return stats;
  }

  /**
   * Resets all stats for testing purposes.
   */
  async test_only_resetAllStats() {
    let eventStats = await this.resetDailyStats();
    await this.resetWeeklyStats(eventStats);
  }

  /**
   * Randomly shuffles the elements of an array in place using the Fisher–Yates algorithm.
   *
   * @param {Array} array - The array to shuffle. This array will be modified.
   * @returns {Array} The same array instance, shuffled randomly.
   */
  static shuffleArray(array) {
    for (let i = array.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      const temp = array[i];
      array[i] = array[j];
      array[j] = temp;
    }
    return array;
  }
  /**
   * Called by the DeferredTask when the randomly selected delay has elapsed
   * after calling scheduleSubmission.
   */
  async #flushEventsAndSubmit() {
    const isOrganicClickEvent = (event, data) => {
      return event === CLICK_EVENT_ID && !data.is_sponsored;
    };

    this.#deferredTask = null;

    // See if we have no event stats or the stats period has cycled
    let eventStats = await this.cache.get(EVENT_STATS_KEY, {});

    if (
      !eventStats?.lastUpdatedDaily ||
      !(
        this.Date().now() - eventStats.lastUpdatedDaily <
        EVENT_STATS_DAILY_PERIOD_MS
      )
    ) {
      eventStats = await this.resetDailyStats(eventStats);
    }

    if (
      !eventStats?.lastUpdatedWeekly ||
      !(
        this.Date().now() - eventStats.lastUpdatedWeekly <
        EVENT_STATS_WEEKLY_PERIOD_MS
      )
    ) {
      eventStats = await this.resetWeeklyStats(eventStats);
    }

    let events = this.#eventBuffer;
    this.#eventBuffer = [];
    if (this.#maxDailyEvents > 0) {
      if (eventStats?.dailyCount >= this.#maxDailyEvents) {
        // Drop all events. Don't send
        return;
      }
    }
    let clickEvents = events.filter(([eventName, data]) =>
      isOrganicClickEvent(eventName, data)
    );
    let numOriginalClickEvents = clickEvents.length;
    // Check if we need to cap organic click events
    if (
      numOriginalClickEvents > 0 &&
      (this.#maxDailyClickEvents > 0 || this.#maxWeeklyClickEvents > 0)
    ) {
      if (this.#maxDailyClickEvents > 0) {
        clickEvents = clickEvents.slice(
          0,
          Math.max(0, this.#maxDailyClickEvents - eventStats?.dailyClickCount)
        );
      }
      if (this.#maxWeeklyClickEvents > 0) {
        clickEvents = clickEvents.slice(
          0,
          Math.max(0, this.#maxWeeklyClickEvents - eventStats?.weeklyClickCount)
        );
      }
      events = events
        .filter(([eventName, data]) => !isOrganicClickEvent(eventName, data))
        .concat(clickEvents);
    }

    eventStats.dailyCount += events.length;
    eventStats.weeklyClickCount += clickEvents.length;
    eventStats.dailyClickCount += clickEvents.length;

    await this.cache.set(EVENT_STATS_KEY, eventStats);

    for (let [eventName, data] of NewTabContentPing.shuffleArray(events)) {
      try {
        Glean.newtabContent[eventName].record(data);
      } catch (e) {
        console.error(e);
      }
    }
    GleanPings.newtabContent.submit();
    this.#curInstanceEventsSent += events.length;
  }

  /**
   * Returns number of events sent through Glean in this instance of the class.
   */
  get testOnlyCurInstanceEventCount() {
    return this.#curInstanceEventsSent;
  }

  /**
   * Removes fields from an event that can be linked to a user in any way, in
   * order to preserve anonymity of the newtab_content ping. This is just to
   * ensure we don't accidentally send these if copying information between
   * the newtab ping and the newtab-content ping.
   *
   * @param {object} eventDataDict
   *   The Glean event data that would be passed to a `record` method.
   * @returns {object}
   *   The sanitized event data.
   */
  sanitizeEventData(eventDataDict) {
    const {
      // eslint-disable-next-line no-unused-vars
      tile_id,
      // eslint-disable-next-line no-unused-vars
      newtab_visit_id,
      // eslint-disable-next-line no-unused-vars
      matches_selected_topic,
      // eslint-disable-next-line no-unused-vars
      recommended_at,
      // eslint-disable-next-line no-unused-vars
      received_rank,
      // eslint-disable-next-line no-unused-vars
      event_source,
      // eslint-disable-next-line no-unused-vars
      recommendation_id,
      // eslint-disable-next-line no-unused-vars
      layout_name,
      ...result
    } = eventDataDict;
    return result;
  }

  /**
   * Generate a random delay to submit the ping from the point of
   * scheduling. This uses a cryptographically secure mechanism for
   * generating the random delay and returns it in millseconds.
   *
   * @returns {number}
   *   A random number between 1000 and the max new content ping submission
   *   delay pref.
   */
  #generateRandomSubmissionDelayMs() {
    const MIN_SUBMISSION_DELAY = 1000;

    if (lazy.MAX_SUBMISSION_DELAY_PREF_VALUE <= MIN_SUBMISSION_DELAY) {
      // Somehow we got configured with a maximum delay less than the minimum...
      // Let's fallback to 5000 then.
      console.error(
        "Can not have a newtab-content maximum submission delay less" +
          ` than 1000: ${lazy.MAX_SUBMISSION_DELAY_PREF_VALUE}`
      );
    }
    const MAX_SUBMISSION_DELAY =
      lazy.MAX_SUBMISSION_DELAY_PREF_VALUE > MIN_SUBMISSION_DELAY
        ? lazy.MAX_SUBMISSION_DELAY_PREF_VALUE
        : 5000;

    const RANGE = MAX_SUBMISSION_DELAY - MIN_SUBMISSION_DELAY + 1;
    const selection = NewTabContentPing.secureRandIntInRange(RANGE);
    return MIN_SUBMISSION_DELAY + (selection % RANGE);
  }

  /**
   * Returns a secure random number between 0 and range
   *
   * @param {int} range Integer value range
   * @returns {int} Random value between 0 and range non-inclusive
   */
  static secureRandIntInRange(range) {
    // To ensure a uniform distribution, we discard values that could introduce
    // modulo bias. We divide the 2^32 range into equal-sized "buckets" and only
    // accept random values that fall entirely within one of these buckets.
    // This ensures each possible output in the target range is equally likely.

    const BUCKET_SIZE = Math.floor(MAX_UINT32 / range);
    const MAX_ACCEPTABLE = BUCKET_SIZE * range;

    let selection;
    let randomValues = new Uint32Array(1);
    do {
      crypto.getRandomValues(randomValues);
      [selection] = randomValues;
    } while (selection >= MAX_ACCEPTABLE);
    return selection % range;
  }

  /**
   * Returns true or false with a certain proability specified
   *
   * @param {number} prob Probability
   * @returns {boolean} Random boolean result of probability prob. A higher prob
   *   increases the chance of true being returned.
   */
  static decideWithProbability(prob) {
    if (prob <= 0) {
      return false;
    }
    if (prob >= 1) {
      return true;
    }
    const randomValues = new Uint32Array(1);
    crypto.getRandomValues(randomValues);
    const random = randomValues[0] / MAX_UINT32;
    return random < prob;
  }

  /**
   * This is a test-only function that will disarm the DeferredTask from sending
   * the newtab-content ping, and instead send it manually. The originally
   * selected submission delay is returned.
   *
   * This function is a no-op when not running in test automation.
   *
   * @returns {number}
   *   The originally selected random delay for submitting the newtab-content
   *   ping.
   * @throws {Error}
   *   Function throws an exception if this is called when no submission has been scheduled yet.
   */
  async testOnlyForceFlush() {
    if (!Cu.isInAutomation) {
      return 0;
    }

    if (this.#deferredTask) {
      this.#deferredTask.disarm();
      this.#deferredTask = null;
      await this.#flushEventsAndSubmit();
      return this.#lastDelaySelection;
    }
    throw new Error("No submission was scheduled.");
  }
}

/**
 * Creating a thin wrapper around PersistentCache, and Date.
 * This makes it easier for us to write automated tests
 */
NewTabContentPing.prototype.PersistentCache = (...args) => {
  return new lazy.PersistentCache(...args);
};

NewTabContentPing.prototype.Date = () => {
  return Date;
};
