/* 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 { BaseAction } from "resource://normandy/actions/BaseAction.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs",
  AddonRollouts: "resource://normandy/lib/AddonRollouts.sys.mjs",
  NormandyAddonManager: "resource://normandy/lib/NormandyAddonManager.sys.mjs",
  NormandyApi: "resource://normandy/lib/NormandyApi.sys.mjs",
  TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
  TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs",
});

class AddonRolloutError extends Error {
  /**
   * @param {string} slug
   * @param {object} extra Extra details to include when reporting the error to telemetry.
   * @param {string} extra.reason The specific reason for the failure.
   */
  constructor(slug, extra) {
    let message;
    let { reason } = extra;
    switch (reason) {
      case "conflict": {
        message = "an existing rollout already exists for this add-on";
        break;
      }
      case "addon-id-changed": {
        message = "upgrade add-on ID does not match installed add-on ID";
        break;
      }
      case "upgrade-required": {
        message = "a newer version of the add-on is already installed";
        break;
      }
      case "download-failure": {
        message = "the add-on failed to download";
        break;
      }
      case "metadata-mismatch": {
        message = "the server metadata does not match the downloaded add-on";
        break;
      }
      case "install-failure": {
        message = "the add-on failed to install";
        break;
      }
      default: {
        throw new Error(`Unexpected AddonRolloutError reason: ${reason}`);
      }
    }
    super(`Cannot install add-on for rollout (${slug}): ${message}.`);
    this.slug = slug;
    this.extra = extra;
  }
}

export class AddonRolloutAction extends BaseAction {
  get schema() {
    return lazy.ActionSchemas["addon-rollout"];
  }

  async _run(recipe) {
    const { extensionApiId, slug } = recipe.arguments;

    const existingRollout = await lazy.AddonRollouts.get(slug);
    const eventName = existingRollout ? "update" : "enroll";
    const extensionDetails =
      await lazy.NormandyApi.fetchExtensionDetails(extensionApiId);

    // Check if the existing rollout matches the current rollout
    if (
      existingRollout &&
      existingRollout.addonId === extensionDetails.extension_id
    ) {
      const versionCompare = Services.vc.compare(
        existingRollout.addonVersion,
        extensionDetails.version
      );

      if (versionCompare === 0) {
        return; // Do nothing
      }
    }

    const createError = (reason, extra = {}) => {
      return new AddonRolloutError(slug, {
        ...extra,
        reason,
      });
    };

    // Check for a conflict (addon already installed by another rollout)
    const activeRollouts = await lazy.AddonRollouts.getAllActive();
    const conflictingRollout = activeRollouts.find(
      rollout =>
        rollout.slug !== slug &&
        rollout.addonId === extensionDetails.extension_id
    );
    if (conflictingRollout) {
      const conflictError = createError("conflict", {
        addonId: conflictingRollout.addonId,
        conflictingSlug: conflictingRollout.slug,
      });
      this.reportError(conflictError, "enrollFailed");
      throw conflictError;
    }

    const onInstallStarted = (install, installDeferred) => {
      const existingAddon = install.existingAddon;

      if (existingRollout && existingRollout.addonId !== install.addon.id) {
        installDeferred.reject(createError("addon-id-changed"));
        return false; // cancel the upgrade, the add-on ID has changed
      }

      if (
        existingAddon &&
        Services.vc.compare(existingAddon.version, install.addon.version) > 0
      ) {
        installDeferred.reject(createError("upgrade-required"));
        return false; // cancel the installation, must be an upgrade
      }

      return true;
    };

    const applyNormandyChanges = async install => {
      const details = {
        addonId: install.addon.id,
        addonVersion: install.addon.version,
        extensionApiId,
        xpiUrl: extensionDetails.xpi,
        xpiHash: extensionDetails.hash,
        xpiHashAlgorithm: extensionDetails.hash_algorithm,
      };

      if (existingRollout) {
        await lazy.AddonRollouts.update({
          ...existingRollout,
          ...details,
        });
      } else {
        await lazy.AddonRollouts.add({
          recipeId: recipe.id,
          state: lazy.AddonRollouts.STATE_ACTIVE,
          slug,
          ...details,
        });
      }
    };

    const undoNormandyChanges = async () => {
      if (existingRollout) {
        await lazy.AddonRollouts.update(existingRollout);
      } else {
        await lazy.AddonRollouts.delete(recipe.id);
      }
    };

    const [installedId, installedVersion] =
      await lazy.NormandyAddonManager.downloadAndInstall({
        createError,
        extensionDetails,
        applyNormandyChanges,
        undoNormandyChanges,
        onInstallStarted,
        reportError: error => this.reportError(error, `${eventName}Failed`),
      });

    if (existingRollout) {
      this.log.debug(`Updated addon rollout ${slug}`);
    } else {
      this.log.debug(`Enrolled in addon rollout ${slug}`);
      lazy.TelemetryEnvironment.setExperimentActive(
        slug,
        lazy.AddonRollouts.STATE_ACTIVE,
        {
          type: "normandy-addonrollout",
        }
      );
    }

    // All done, report success to Telemetry
    lazy.TelemetryEvents.sendEvent(eventName, "addon_rollout", slug, {
      addonId: installedId,
      addonVersion: installedVersion,
    });
  }

  reportError(error, eventName) {
    if (error instanceof AddonRolloutError) {
      // One of our known errors. Report it nicely to telemetry
      lazy.TelemetryEvents.sendEvent(
        eventName,
        "addon_rollout",
        error.slug,
        error.extra
      );
    } else {
      /*
       * Some unknown error. Add some helpful details, and report it to
       * telemetry. The actual stack trace and error message could possibly
       * contain PII, so we don't include them here. Instead include some
       * information that should still be helpful, and is less likely to be
       * unsafe.
       */
      const safeErrorMessage = `${error.fileName}:${error.lineNumber}:${error.columnNumber} ${error.name}`;
      lazy.TelemetryEvents.sendEvent(eventName, "addon_rollout", error.slug, {
        reason: safeErrorMessage.slice(0, 80), // max length is 80 chars
      });
    }
  }
}
