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

/**
 * Unit tests that verify that the state machine controlling throttling (not
 * actually updating after not running the browser for a long period) and
 * debouncing (not taking any action at all within a short window) is working as
 * expected.
 *
 * Also unit tests that `runActions` invokes appropriate action methods.
 */

"use strict";

const { Actions, runActions } = ChromeUtils.importESModule(
  "resource://gre/modules/backgroundtasks/BackgroundTask_backgroundupdate.sys.mjs"
);
const { AppUpdater } = ChromeUtils.importESModule(
  "resource://gre/modules/AppUpdater.sys.mjs"
);
const { BackgroundUpdate } = ChromeUtils.importESModule(
  "resource://gre/modules/BackgroundUpdate.sys.mjs"
);
const { sinon } = ChromeUtils.importESModule(
  "resource://testing-common/Sinon.sys.mjs"
);

const ACTION = BackgroundUpdate.ACTION;

let ACTIONS_ALL = new Set(Object.values(ACTION));
let ACTIONS_UNTHROTTLED = ACTIONS_ALL.difference(
  new Set([ACTION.UPDATE_CHECK])
);
let ACTIONS_THROTTLED = new Set([
  ACTION.EXPERIMENTER,
  ACTION.SUBMIT_PING,
  ACTION.UPDATE_CHECK,
]);
let ACTIONS_DEBOUNCED = new Set([]);

const HOUR_IN_SECONDS = 60 * 60;
const DAY_IN_SECONDS = 24 * HOUR_IN_SECONDS;

function getLastTaskRunSeconds() {
  return Services.prefs.getIntPref(
    "app.update.background.lastTaskRunSecondsAfterEpoch",
    -1
  );
}

add_setup(async function test_setup() {
  // FOG needs a profile directory to put its data in.
  do_get_profile();

  // We need to initialize it once, otherwise operations will be stuck in the pre-init queue.
  Services.fog.initializeFOG();

  // We're in an xpcshell context, not an actual background task context, so we
  // need to set some background task-specific prefs here for testing.
  Services.prefs.setBoolPref(
    "app.update.background.checkPolicy.throttleEnabled",
    true
  );
  Services.prefs.setIntPref(
    "app.update.background.checkPolicy.throttleAfterDays",
    14
  );
  Services.prefs.setIntPref(
    "app.update.background.checkPolicy.throttleDebouncePeriodInHours",
    24
  );

  // For convenience.
  Services.prefs.setCharPref("app.update.background.loglevel", "debug");
});

async function testWithActionsToPerform(
  {
    nowSeconds = Math.floor(Date.now() / 1000),
    lastBrowsedDays = 0,
    lastTaskRunHours = 0,
    lastTaskRunUnset = false,
  },
  expectedActions
) {
  info(
    `nowSeconds=${nowSeconds} lastBrowsedDays=${lastBrowsedDays} lastTaskRunHours=${lastTaskRunHours} lastTaskRunUnset=${lastTaskRunUnset}`
  );

  // This should really be `Services.fog.testResetFOG()`. But this works
  // well enough and, currently, Bug 1954203 prevents us from successfully using
  // `testResetFOG`.
  GleanPings.backgroundUpdate.submit();

  let environment = {
    currentDate: 1000 * (nowSeconds - lastBrowsedDays * DAY_IN_SECONDS),
  };

  let prevTaskRunSeconds = getLastTaskRunSeconds();

  try {
    let startingTaskRunSeconds =
      nowSeconds - lastTaskRunHours * HOUR_IN_SECONDS;

    if (!lastTaskRunUnset) {
      Services.prefs.setIntPref(
        "app.update.background.lastTaskRunSecondsAfterEpoch",
        startingTaskRunSeconds
      );
    } else {
      Services.prefs.clearUserPref(
        "app.update.background.lastTaskRunSecondsAfterEpoch"
      );
    }

    let beforeTaskRunSeconds;
    let actions = await BackgroundUpdate.withActionsToPerform(
      { defaultProfileTargetingSnapshot: { environment }, nowSeconds },
      actionSet => {
        beforeTaskRunSeconds = getLastTaskRunSeconds();
        return actionSet;
      }
    );

    Assert.deepEqual(
      Array.from(actions).toSorted(),
      Array.from(expectedActions).toSorted()
    );

    let afterTaskRunSeconds = getLastTaskRunSeconds();

    if (expectedActions.size) {
      Assert.equal(
        afterTaskRunSeconds,
        nowSeconds,
        "Some expected actions, so last task run timestamp changed after task complete callback invoked"
      );
    } else {
      Assert.equal(
        afterTaskRunSeconds,
        beforeTaskRunSeconds,
        "No expected actions, so last task run timestamp not changed when task is debounced"
      );
    }

    Assert.equal(
      Glean.backgroundUpdate.daysSinceLastBrowsed.testGetValue(),
      lastBrowsedDays,
      "`daysSinceLastBrowsed` instrumentation is correct"
    );

    Assert.equal(
      Glean.backgroundUpdate.throttled.testGetValue(),
      !actions.has(ACTION.UPDATE),
      "`throttled` instrumentation is correct"
    );

    Assert.equal(
      Glean.backgroundUpdate.debounced.testGetValue(),
      actions.size ? null : 1,
      "`debounced` instrumentation is correct"
    );
  } finally {
    Services.prefs.setIntPref(
      "app.update.background.lastTaskRunSecondsAfterEpoch",
      prevTaskRunSeconds
    );
  }
}

add_task(async function test_disabled() {
  // Setting in this test is generally `true`, so undo the change after we're done.
  let prev = Services.prefs.getBoolPref(
    "app.update.background.checkPolicy.throttleEnabled",
    false
  );
  try {
    Services.prefs.setBoolPref(
      "app.update.background.checkPolicy.throttleEnabled",
      false
    );

    await testWithActionsToPerform(
      {
        lastBrowsedDays: 100,
        lastTaskRunHours: 0,
      },
      ACTIONS_UNTHROTTLED
    );

    // Test with the task never having run before.
    await testWithActionsToPerform(
      {
        lastBrowsedDays: 100,
        lastTaskRunUnset: true,
      },
      ACTIONS_UNTHROTTLED
    );
  } finally {
    Services.prefs.setBoolPref(
      "app.update.background.checkPolicy.throttleEnabled",
      prev
    );
  }
});

add_task(async function test_all() {
  // Last task run days don't matter, since not throttled.
  await testWithActionsToPerform(
    {
      lastBrowsedDays: 10,
      lastTaskRunHours: 0,
    },
    ACTIONS_UNTHROTTLED
  );

  await testWithActionsToPerform(
    {
      lastBrowsedDays: 10,
      lastTaskRunHours: 23,
    },
    ACTIONS_UNTHROTTLED
  );

  await testWithActionsToPerform(
    {
      lastBrowsedDays: 10,
      lastTaskRunHours: 48,
    },
    ACTIONS_UNTHROTTLED
  );

  // Test the boundary condition.
  await testWithActionsToPerform(
    {
      lastBrowsedDays: 10,
      lastTaskRunHours: 0,
    },
    ACTIONS_UNTHROTTLED
  );

  await testWithActionsToPerform(
    {
      lastBrowsedDays: 10,
      lastTaskRunHours: 23,
    },
    ACTIONS_UNTHROTTLED
  );

  await testWithActionsToPerform(
    {
      lastBrowsedDays: 10,
      lastTaskRunHours: 48,
    },
    ACTIONS_UNTHROTTLED
  );

  // Test with the task never having run before.
  await testWithActionsToPerform(
    {
      lastBrowsedDays: 10,
      lastTaskRunUnset: true,
    },
    ACTIONS_UNTHROTTLED
  );

  // Test the boundary condition.
  await testWithActionsToPerform(
    {
      lastBrowsedDays: 14,
      lastTaskRunHours: 48,
    },
    ACTIONS_UNTHROTTLED
  );
});

add_task(async function test_throttled() {
  // Test the boundary condition.
  await testWithActionsToPerform(
    {
      lastBrowsedDays: 15,
      lastTaskRunHours: 24,
    },
    ACTIONS_THROTTLED
  );

  // More obvious cases.
  await testWithActionsToPerform(
    {
      lastBrowsedDays: 100,
      lastTaskRunHours: 48,
    },
    ACTIONS_THROTTLED
  );

  await testWithActionsToPerform(
    {
      lastBrowsedDays: 100,
      lastTaskRunHours: 48,
    },
    ACTIONS_THROTTLED
  );
});

add_task(async function test_debounced() {
  await testWithActionsToPerform(
    {
      lastBrowsedDays: 91,
      lastTaskRunHours: 0,
    },
    ACTIONS_DEBOUNCED
  );

  await testWithActionsToPerform(
    {
      lastBrowsedDays: 100,
      lastTaskRunHours: 0,
    },
    ACTIONS_DEBOUNCED
  );

  // Verify to the hour.
  await testWithActionsToPerform(
    {
      lastBrowsedDays: 100,
      lastTaskRunHours: 0,
    },
    ACTIONS_DEBOUNCED
  );

  await testWithActionsToPerform(
    {
      lastBrowsedDays: 100,
      lastTaskRunHours: 12,
    },
    ACTIONS_DEBOUNCED
  );

  await testWithActionsToPerform(
    {
      lastBrowsedDays: 100,
      lastTaskRunHours: 23,
    },
    ACTIONS_DEBOUNCED
  );

  // Boundary condition.
  await testWithActionsToPerform(
    {
      lastBrowsedDays: 100,
      lastTaskRunHours: 24,
    },
    ACTIONS_THROTTLED
  );

  await testWithActionsToPerform(
    {
      lastBrowsedDays: 100,
      lastTaskRunHours: 25,
    },
    ACTIONS_THROTTLED
  );

  // Test with the task never having run before.
  await testWithActionsToPerform(
    {
      lastBrowsedDays: 100,
      lastTaskRunUnset: true,
    },
    ACTIONS_THROTTLED
  );
});

async function testRunActions(actions) {
  let cmdLine = Cu.createCommandLine(
    [],
    null,
    Ci.nsICommandLine.STATE_INITIAL_LAUNCH
  );

  let sandbox = sinon.createSandbox();

  let stubs = {
    [ACTION.EXPERIMENTER]: sandbox
      .stub(Actions, "enableNimbusAndFirefoxMessagingSystem")
      .resolves(null),
    [ACTION.UPDATE]: sandbox
      .stub(Actions, "attemptBackgroundUpdate")
      .resolves(AppUpdater.STATUS.NO_UPDATES_FOUND),
    [ACTION.UPDATE_CHECK]: sandbox
      .stub(Actions, "checkForUpdate")
      .resolves(AppUpdater.STATUS.NO_UPDATES_FOUND),
    [ACTION.SUBMIT_PING]: sandbox
      .stub(Actions, "maybeSubmitBackgroundUpdatePing")
      .returns(null),
  };

  try {
    await runActions(cmdLine, {}, actions);

    for (let action of ACTIONS_ALL) {
      if (actions.has(action)) {
        Assert.ok(stubs[action].calledOnce, `${action} invoked exactly once`);
      } else {
        Assert.ok(!stubs[action].calledOnce, `${action} not invoked`);
      }
    }
  } finally {
    sandbox.restore();
  }
}

add_task(async function test_runActionsUnthrottled() {
  await testRunActions(ACTIONS_UNTHROTTLED);
});

add_task(async function test_runActionsThrottled() {
  await testRunActions(ACTIONS_THROTTLED);
});

add_task(async function test_runActionsDebounced() {
  await testRunActions(ACTIONS_DEBOUNCED);
});
