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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs",
  TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
});

/**
 * AddonRollouts store info about an active or expired addon rollouts.
 *
 * @typedef {object} AddonRollout
 * @property {int} recipeId
 *   The ID of the recipe.
 * @property {string} slug
 *   Unique slug of the rollout.
 * @property {string} state
 *   The current state of the rollout: "active", or "rolled-back".
 *   Active means that Normandy is actively managing therollout. Rolled-back
 *   means that the rollout was previously active, but has been rolled back for
 *   this user.
 * @property {int} extensionApiId
 *   The ID used to look up the extension in Normandy's API.
 * @property {string} addonId
 *   The add-on ID for this particular rollout.
 * @property {string} addonVersion
 *   The rollout add-on version number
 * @property {string} xpiUrl
 *   URL that the add-on was installed from.
 * @property {string} xpiHash
 *   The hash of the XPI file.
 * @property {string} xpiHashAlgorithm
 *   The algorithm used to hash the XPI file.
 */

const DB_NAME = "normandy-addon-rollout";
const STORE_NAME = "addon-rollouts";
const DB_VERSION = 1;

/**
 * Create a new connection to the database.
 */
function openDatabase() {
  return lazy.IndexedDB.open(DB_NAME, DB_VERSION, db => {
    db.createObjectStore(STORE_NAME, {
      keyPath: "slug",
    });
  });
}

/**
 * Cache the database connection so that it is shared among multiple operations.
 */
let databasePromise;
function getDatabase() {
  if (!databasePromise) {
    databasePromise = openDatabase();
  }
  return databasePromise;
}

/**
 * Get a transaction for interacting with the rollout store.
 *
 * @param {IDBDatabase} db
 * @param {string} mode Either "readonly" or "readwrite"
 *
 * NOTE: Methods on the store returned by this function MUST be called
 * synchronously, otherwise the transaction with the store will expire.
 * This is why the helper takes a database as an argument; if we fetched the
 * database in the helper directly, the helper would be async and the
 * transaction would expire before methods on the store were called.
 */
function getStore(db, mode) {
  if (!mode) {
    throw new Error("mode is required");
  }
  return db.objectStore(STORE_NAME, mode);
}

export const AddonRollouts = {
  STATE_ACTIVE: "active",
  STATE_ROLLED_BACK: "rolled-back",

  async init() {
    for (const rollout of await this.getAllActive()) {
      lazy.TelemetryEnvironment.setExperimentActive(
        rollout.slug,
        rollout.state,
        {
          type: "normandy-addonrollout",
        }
      );
    }
  },

  /**
   * Add a new rollout
   *
   * @param {AddonRollout} rollout
   */
  async add(rollout) {
    const db = await getDatabase();
    return getStore(db, "readwrite").add(rollout);
  },

  /**
   * Update an existing rollout
   *
   * @param {AddonRollout} rollout
   * @throws If a matching rollout does not exist.
   */
  async update(rollout) {
    if (!(await this.has(rollout.slug))) {
      throw new Error(
        `Tried to update ${rollout.slug}, but it doesn't already exist.`
      );
    }
    const db = await getDatabase();
    return getStore(db, "readwrite").put(rollout);
  },

  /**
   * Update many existing rollouts. More efficient than calling `update` many
   * times in a row.
   *
   * @param {Array<PreferenceRollout>} rollouts
   * @throws If any of the passed rollouts have a slug that doesn't exist in the database already.
   */
  async updateMany(rollouts) {
    // Don't touch the database if there is nothing to do
    if (!rollouts.length) {
      return;
    }

    // Both of the below operations use .map() instead of a normal loop becaues
    // once we get the object store, we can't let it expire by spinning the
    // event loop. This approach queues up all the interactions with the store
    // immediately, preventing it from expiring too soon.

    const db = await getDatabase();
    let store = await getStore(db, "readonly");
    await Promise.all(
      rollouts.map(async ({ slug }) => {
        let existingRollout = await store.get(slug);
        if (!existingRollout) {
          throw new Error(`Tried to update ${slug}, but it doesn't exist.`);
        }
      })
    );

    // awaiting spun the event loop, so the store is now invalid. Get a new
    // store. This is also a chance to get it in readwrite mode.
    store = await getStore(db, "readwrite");
    await Promise.all(rollouts.map(rollout => store.put(rollout)));
  },

  /**
   * Test whether there is a rollout in storage with the given slug.
   *
   * @param {string} slug
   * @returns {Promise<boolean>}
   */
  async has(slug) {
    const db = await getDatabase();
    const rollout = await getStore(db, "readonly").get(slug);
    return !!rollout;
  },

  /**
   * Get a rollout by slug
   *
   * @param {string} slug
   */
  async get(slug) {
    const db = await getDatabase();
    return getStore(db, "readonly").get(slug);
  },

  /** Get all rollouts in the database. */
  async getAll() {
    const db = await getDatabase();
    return getStore(db, "readonly").getAll();
  },

  /** Get all rollouts in the "active" state. */
  async getAllActive() {
    const rollouts = await this.getAll();
    return rollouts.filter(rollout => rollout.state === this.STATE_ACTIVE);
  },

  /**
   * Test wrapper that temporarily replaces the stored rollout data with fake
   * data for testing.
   */
  withTestMock() {
    return function (testFunction) {
      return async function inner(...args) {
        let db = await getDatabase();
        const oldData = await getStore(db, "readonly").getAll();
        await getStore(db, "readwrite").clear();
        try {
          await testFunction(...args);
        } finally {
          db = await getDatabase();
          await getStore(db, "readwrite").clear();
          const store = getStore(db, "readwrite");
          await Promise.all(oldData.map(d => store.add(d)));
        }
      };
    };
  },
};
