/* 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 { Log } from "resource://gre/modules/Log.sys.mjs";

import { TelemetrySession } from "resource://gre/modules/TelemetrySession.sys.mjs";
import { TelemetryUtils } from "resource://gre/modules/TelemetryUtils.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";

const lazy = {};

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

const MIN_SUBSESSION_LENGTH_MS =
  Services.prefs.getIntPref("toolkit.telemetry.minSubsessionLength", 5 * 60) *
  1000;

const LOGGER_NAME = "Toolkit.Telemetry";

// Seconds of idle time before pinging.
// On idle-daily a gather-telemetry notification is fired, during it probes can
// start asynchronous tasks to gather data.
const IDLE_TIMEOUT_SECONDS = Services.prefs.getIntPref(
  "toolkit.telemetry.idleTimeout",
  5 * 60
);

// Execute a scheduler tick every 5 minutes.
const SCHEDULER_TICK_INTERVAL_MS =
  Services.prefs.getIntPref(
    "toolkit.telemetry.scheduler.tickInterval",
    5 * 60
  ) * 1000;
// When user is idle, execute a scheduler tick every 60 minutes.
const SCHEDULER_TICK_IDLE_INTERVAL_MS =
  Services.prefs.getIntPref(
    "toolkit.telemetry.scheduler.idleTickInterval",
    60 * 60
  ) * 1000;

// The maximum time (ms) until the tick should moved from the idle
// queue to the regular queue if it hasn't been executed yet.
const SCHEDULER_TICK_MAX_IDLE_DELAY_MS = 60 * 1000;

// The frequency at which we persist session data to the disk to prevent data loss
// in case of aborted sessions (currently 5 minutes).
const ABORTED_SESSION_UPDATE_INTERVAL_MS = 5 * 60 * 1000;

// The tolerance we have when checking if it's midnight (15 minutes).
const SCHEDULER_MIDNIGHT_TOLERANCE_MS = 15 * 60 * 1000;

/**
 * This is a policy object used to override behavior for testing.
 */
export var Policy = {
  now: () => new Date(),
  setSchedulerTickTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
  clearSchedulerTickTimeout: id => clearTimeout(id),
  prioEncode: (batchID, prioParams) => PrioEncoder.encode(batchID, prioParams),
};

/**
 * TelemetryScheduler contains a single timer driving all regularly-scheduled
 * Telemetry related jobs. Having a single place with this logic simplifies
 * reasoning about scheduling actions in a single place, making it easier to
 * coordinate jobs and coalesce them.
 */
export var TelemetryScheduler = {
  // Tracks the main ping
  _lastDailyPingTime: 0,
  // Tracks the aborted session ping
  _lastSessionCheckpointTime: 0,
  // Tracks all other pings at regular intervals
  _lastPeriodicPingTime: 0,

  _log: null,

  // The timer which drives the scheduler.
  _schedulerTimer: null,
  // The interval used by the scheduler timer.
  _schedulerInterval: 0,
  _shuttingDown: true,
  _isUserIdle: false,

  /**
   * Initialises the scheduler and schedules the first daily/aborted session pings.
   */
  init() {
    this._log = Log.repository.getLoggerWithMessagePrefix(
      LOGGER_NAME,
      "TelemetryScheduler::"
    );
    this._log.trace("init");
    this._shuttingDown = false;
    this._isUserIdle = false;

    // Initialize the last daily ping and aborted session last due times to the current time.
    // Otherwise, we might end up sending daily pings even if the subsession is not long enough.
    let now = Policy.now();
    this._lastDailyPingTime = now.getTime();
    this._lastPeriodicPingTime = now.getTime();
    this._lastSessionCheckpointTime = now.getTime();
    this._rescheduleTimeout();

    lazy.idleService.addIdleObserver(this, IDLE_TIMEOUT_SECONDS);
    Services.obs.addObserver(this, "wake_notification");
  },

  /**
   * Stops the scheduler.
   */
  shutdown() {
    if (this._shuttingDown) {
      if (this._log) {
        this._log.error("shutdown - Already shut down");
      } else {
        console.error("TelemetryScheduler.shutdown - Already shut down");
      }
      return;
    }

    this._log.trace("shutdown");
    if (this._schedulerTimer) {
      Policy.clearSchedulerTickTimeout(this._schedulerTimer);
      this._schedulerTimer = null;
    }

    lazy.idleService.removeIdleObserver(this, IDLE_TIMEOUT_SECONDS);
    Services.obs.removeObserver(this, "wake_notification");

    this._shuttingDown = true;
  },

  // Reset some specific innards without shutting down and re-init'ing.
  // Test-only method.
  testReset() {
    this._idleDispatch?.cancel();
    this._idleDispatch = undefined;
  },

  _clearTimeout() {
    if (this._schedulerTimer) {
      Policy.clearSchedulerTickTimeout(this._schedulerTimer);
    }
  },

  /**
   * Reschedules the tick timer.
   */
  _rescheduleTimeout() {
    this._log.trace("_rescheduleTimeout - isUserIdle: " + this._isUserIdle);
    if (this._shuttingDown) {
      this._log.warn("_rescheduleTimeout - already shutdown");
      return;
    }

    this._clearTimeout();

    const now = Policy.now();
    let timeout = SCHEDULER_TICK_INTERVAL_MS;

    // When the user is idle we want to fire the timer less often.
    if (this._isUserIdle) {
      timeout = SCHEDULER_TICK_IDLE_INTERVAL_MS;
      // We need to make sure though that we don't miss sending pings around
      // midnight when we use the longer idle intervals.
      const nextMidnight = TelemetryUtils.getNextMidnight(now);
      timeout = Math.min(timeout, nextMidnight.getTime() - now.getTime());
    }

    this._log.trace(
      "_rescheduleTimeout - scheduling next tick for " +
        new Date(now.getTime() + timeout)
    );
    this._schedulerTimer = Policy.setSchedulerTickTimeout(
      () => this._onSchedulerTick(),
      timeout
    );
  },

  _sentPingToday(pingTime, nowDate) {
    // This is today's date and also the previous midnight (0:00).
    const todayDate = TelemetryUtils.truncateToDays(nowDate);
    // We consider a ping sent for today if it occured after or at 00:00 today.
    return pingTime >= todayDate.getTime();
  },

  /**
   * Checks if we can send a daily ping or not.
   * @param {Object} nowDate A date object.
   * @return {Boolean} True if we can send the daily ping, false otherwise.
   */
  _isDailyPingDue(nowDate) {
    // The daily ping is not due if we already sent one today.
    if (this._sentPingToday(this._lastDailyPingTime, nowDate)) {
      this._log.trace("_isDailyPingDue - already sent one today");
      return false;
    }

    // Avoid overly short sessions.
    const timeSinceLastDaily = nowDate.getTime() - this._lastDailyPingTime;
    if (timeSinceLastDaily < MIN_SUBSESSION_LENGTH_MS) {
      this._log.trace(
        "_isDailyPingDue - delaying daily to keep minimum session length"
      );
      return false;
    }

    this._log.trace("_isDailyPingDue - is due");
    return true;
  },

  /**
   * Checks if we can send a regular ping or not.
   * @param {Object} nowDate A date object.
   * @return {Boolean} True if we can send the regular pings, false otherwise.
   */
  _isPeriodicPingDue(nowDate) {
    // The periodic ping is not due if we already sent one today.
    if (this._sentPingToday(this._lastPeriodicPingTime, nowDate)) {
      this._log.trace("_isPeriodicPingDue - already sent one today");
      return false;
    }

    this._log.trace("_isPeriodicPingDue - is due");
    return true;
  },

  /**
   * An helper function to save an aborted-session ping.
   * @param {Number} now The current time, in milliseconds.
   * @param {Object} [competingPayload=null] If we are coalescing the daily and the
   *                 aborted-session pings, this is the payload for the former. Note
   *                 that the reason field of this payload will be changed.
   * @return {Promise} A promise resolved when the ping is saved.
   */
  _saveAbortedPing(now, competingPayload = null) {
    this._lastSessionCheckpointTime = now;
    return TelemetrySession.saveAbortedSessionPing(competingPayload).catch(e =>
      this._log.error("_saveAbortedPing - Failed", e)
    );
  },

  /**
   * The notifications handler.
   */
  observe(aSubject, aTopic) {
    this._log.trace("observe - aTopic: " + aTopic);
    switch (aTopic) {
      case "idle":
        // If the user is idle, increase the tick interval.
        this._isUserIdle = true;
        return this._onSchedulerTick();
      case "active":
        // User is back to work, restore the original tick interval.
        this._isUserIdle = false;
        return this._onSchedulerTick(true);
      case "wake_notification":
        // The machine woke up from sleep, trigger a tick to avoid sessions
        // spanning more than a day.
        // This is needed because sleep time does not count towards timeouts
        // on Mac & Linux - see bug 1262386, bug 1204823 et al.
        return this._onSchedulerTick(true);
    }
    return undefined;
  },

  /**
   * Creates an object with a method `dispatch` that will call `dispatchFn` unless
   * the method `cancel` is called beforehand.
   *
   * This is used to wrap main thread idle dispatch since it does not provide a
   * cancel mechanism.
   */
  _makeIdleDispatch(dispatchFn) {
    this._log.trace("_makeIdleDispatch");
    let fn = dispatchFn;
    let l = msg => this._log.trace(msg); // need to bind `this`
    return {
      cancel() {
        fn = undefined;
      },
      dispatch(resolve, reject) {
        l("_makeIdleDispatch.dispatch - !!fn: " + !!fn);
        if (!fn) {
          return Promise.resolve().then(resolve, reject);
        }
        return fn(resolve, reject);
      },
    };
  },

  /**
   * Performs a scheduler tick. This function manages Telemetry recurring operations.
   * @param {Boolean} [dispatchOnIdle=false] If true, the tick is dispatched in the
   *                  next idle cycle of the main thread.
   * @return {Promise} A promise, only used when testing, resolved when the scheduled
   *                   operation completes.
   */
  _onSchedulerTick(dispatchOnIdle = false) {
    this._log.trace("_onSchedulerTick - dispatchOnIdle: " + dispatchOnIdle);
    // This call might not be triggered from a timeout. In that case we don't want to
    // leave any previously scheduled timeouts pending.
    this._clearTimeout();

    if (this._idleDispatch) {
      this._idleDispatch.cancel();
    }

    if (this._shuttingDown) {
      this._log.warn("_onSchedulerTick - already shutdown.");
      return Promise.reject(new Error("Already shutdown."));
    }

    let promise = Promise.resolve();
    try {
      if (dispatchOnIdle) {
        this._idleDispatch = this._makeIdleDispatch((resolve, reject) => {
          this._log.trace(
            "_onSchedulerTick - ildeDispatchToMainThread dispatch"
          );
          return this._schedulerTickLogic().then(resolve, reject);
        });
        promise = new Promise((resolve, reject) =>
          Services.tm.idleDispatchToMainThread(() => {
            return this._idleDispatch
              ? this._idleDispatch.dispatch(resolve, reject)
              : Promise.resolve().then(resolve, reject);
          }, SCHEDULER_TICK_MAX_IDLE_DELAY_MS)
        );
      } else {
        promise = this._schedulerTickLogic();
      }
    } catch (e) {
      this._log.error("_onSchedulerTick - There was an exception", e);
    } finally {
      this._rescheduleTimeout();
    }

    // This promise is returned to make testing easier.
    return promise;
  },

  /**
   * Implements the scheduler logic.
   * @return {Promise} Resolved when the scheduled task completes. Only used in tests.
   */
  _schedulerTickLogic() {
    this._log.trace("_schedulerTickLogic");

    let nowDate = Policy.now();
    let now = nowDate.getTime();

    // Check if the daily ping is due.
    const shouldSendDaily = this._isDailyPingDue(nowDate);
    // Check if other regular pings are due.
    const shouldSendPeriodic = this._isPeriodicPingDue(nowDate);

    if (shouldSendPeriodic) {
      this._log.trace("_schedulerTickLogic - Periodic ping due.");
      this._lastPeriodicPingTime = now;
      // Send other pings.
      // ...currently no other pings exist
    }

    if (shouldSendDaily) {
      this._log.trace("_schedulerTickLogic - Daily ping due.");
      this._lastDailyPingTime = now;
      return TelemetrySession.sendDailyPing();
    }

    // Check if the aborted-session ping is due. If a daily ping was saved above, it was
    // already duplicated as an aborted-session ping.
    const isAbortedPingDue =
      now - this._lastSessionCheckpointTime >=
      ABORTED_SESSION_UPDATE_INTERVAL_MS;
    if (isAbortedPingDue) {
      this._log.trace("_schedulerTickLogic - Aborted session ping due.");
      return this._saveAbortedPing(now);
    }

    // No ping is due.
    this._log.trace("_schedulerTickLogic - No ping due.");
    return Promise.resolve();
  },

  /**
   * Re-schedule the daily ping if some other equivalent ping was sent.
   *
   * This is only called from TelemetrySession when a main ping with reason 'environment-change'
   * is sent.
   *
   * @param {Object} [payload] The payload of the ping that was sent,
   *                           to be stored as an aborted-session ping.
   */
  rescheduleDailyPing(payload) {
    if (this._shuttingDown) {
      this._log.error("rescheduleDailyPing - already shutdown");
      return;
    }

    this._log.trace("rescheduleDailyPing");
    let now = Policy.now();

    // We just generated an environment-changed ping, save it as an aborted session and
    // update the schedules.
    this._saveAbortedPing(now.getTime(), payload);

    // If we're close to midnight, skip today's daily ping and reschedule it for tomorrow.
    let nearestMidnight = TelemetryUtils.getNearestMidnight(
      now,
      SCHEDULER_MIDNIGHT_TOLERANCE_MS
    );
    if (nearestMidnight) {
      this._lastDailyPingTime = now.getTime();
    }
  },
};
