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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  JsonSchemaValidator:
    "resource://gre/modules/components-utils/JsonSchemaValidator.sys.mjs",
  LogManager: "resource://normandy/lib/LogManager.sys.mjs",
});

/**
 * Base class for local actions.
 *
 * This should be subclassed. Subclasses must implement _run() for
 * per-recipe behavior, and may implement _preExecution and _finalize
 * for actions to be taken once before and after recipes are run.
 *
 * Other methods should be overridden with care, to maintain the life
 * cycle events and error reporting implemented by this class.
 */
export class BaseAction {
  constructor() {
    this.state = BaseAction.STATE_PREPARING;
    this.log = lazy.LogManager.getLogger(`action.${this.name}`);
    this.lastError = null;
  }

  /**
   * Be sure to run the _preExecution() hook once during its
   * lifecycle.
   *
   * This is not intended for overriding by subclasses.
   */
  _ensurePreExecution() {
    if (this.state !== BaseAction.STATE_PREPARING) {
      return;
    }

    try {
      this._preExecution();
      // if _preExecution changed the state, don't overwrite it
      if (this.state === BaseAction.STATE_PREPARING) {
        this.state = BaseAction.STATE_READY;
      }
    } catch (err) {
      // Sometimes err.message is editable. If it is, add helpful details.
      // Otherwise log the helpful details and move on.
      try {
        err.message = `Could not initialize action ${this.name}: ${err.message}`;
      } catch (_e) {
        this.log.error(
          `Could not initialize action ${this.name}, error follows.`
        );
      }
      this.fail(err);
    }
  }

  get schema() {
    return {
      type: "object",
      properties: {},
    };
  }

  /**
   * Disable the action for a non-error reason, such as the user opting out of
   * this type of action.
   */
  disable() {
    this.state = BaseAction.STATE_DISABLED;
  }

  fail(err) {
    switch (this.state) {
      case BaseAction.STATE_PREPARING: {
        Uptake.reportAction(this.name, Uptake.ACTION_PRE_EXECUTION_ERROR);
        break;
      }
      default: {
        console.error(new Error("BaseAction.fail() called at unexpected time"));
      }
    }
    this.state = BaseAction.STATE_FAILED;
    this.lastError = err;
    console.error(err);
  }

  // Gets the name of the action. Does not necessarily match the
  // server slug for the action.
  get name() {
    return this.constructor.name;
  }

  /**
   * Action specific pre-execution behavior should be implemented
   * here. It will be called once per execution session.
   */
  _preExecution() {
    // Does nothing, may be overridden
  }

  validateArguments(args, schema = this.schema) {
    let { valid, parsedValue: validated } = lazy.JsonSchemaValidator.validate(
      args,
      schema,
      {
        allowAdditionalProperties: true,
      }
    );
    if (!valid) {
      throw new Error(
        `Arguments do not match schema. arguments:\n${JSON.stringify(args)}\n` +
          `schema:\n${JSON.stringify(schema)}`
      );
    }
    return validated;
  }

  /**
   * Execute the per-recipe behavior of this action for a given
   * recipe.  Reports Uptake telemetry for the execution of the recipe.
   *
   * @param {Recipe} recipe
   * @param {BaseAction.suitability} suitability
   * @throws If this action has already been finalized.
   */
  async processRecipe(recipe, suitability) {
    if (!BaseAction.suitabilitySet.has(suitability)) {
      throw new Error(`Unknown recipe status ${suitability}`);
    }

    this._ensurePreExecution();

    if (this.state === BaseAction.STATE_FINALIZED) {
      throw new Error("Action has already been finalized");
    }

    if (this.state !== BaseAction.STATE_READY) {
      Uptake.reportRecipe(recipe, Uptake.RECIPE_ACTION_DISABLED);
      this.log.warn(
        `Skipping recipe ${recipe.name} because ${this.name} was disabled during preExecution.`
      );
      return;
    }

    let uptakeResult = BaseAction.suitabilityToUptakeStatus[suitability];
    if (!uptakeResult) {
      throw new Error(
        `Coding error, no uptake status for suitability ${suitability}`
      );
    }

    // If capabilties don't match, we can't even be sure that the arguments
    // should be valid. In that case don't try to validate them.
    if (suitability !== BaseAction.suitability.CAPABILITIES_MISMATCH) {
      try {
        recipe.arguments = this.validateArguments(recipe.arguments);
      } catch (error) {
        console.error(error);
        uptakeResult = Uptake.RECIPE_EXECUTION_ERROR;
        suitability = BaseAction.suitability.ARGUMENTS_INVALID;
      }
    }

    try {
      await this._processRecipe(recipe, suitability);
    } catch (err) {
      console.error(err);
      uptakeResult = Uptake.RECIPE_EXECUTION_ERROR;
    }
    Uptake.reportRecipe(recipe, uptakeResult);
  }

  /**
   * Action specific recipe behavior may be implemented here. It will be
   * executed once for each recipe that applies to this client.
   * The recipe will be passed as a parameter.
   *
   * @param {Recipe} recipe
   */
  async _run() {
    throw new Error("Not implemented");
  }

  /**
   * Action specific recipe behavior should be implemented here. It will be
   * executed once for every recipe currently published. The suitability of the
   * recipe will be passed, it will be one of the constants from
   * `BaseAction.suitability`.
   *
   * By default, this calls `_run()` for recipes with `status == FILTER_MATCH`,
   * and does nothing for all other recipes. It is invalid for an action to
   * override both `_run` and `_processRecipe`.
   *
   * @param {Recipe} recipe
   * @param {RecipeSuitability} suitability
   */
  async _processRecipe(recipe, suitability) {
    if (!suitability) {
      throw new Error("Suitability is undefined:", suitability);
    }
    if (suitability == BaseAction.suitability.FILTER_MATCH) {
      await this._run(recipe);
    }
  }

  /**
   * Finish an execution session. After this method is called, no
   * other methods may be called on this method, and all relevant
   * recipes will be assumed to have been seen.
   */
  async finalize(options) {
    // It's possible that no recipes used this action, so processRecipe()
    // was never called. In that case, we should ensure that we call
    // _preExecute() here.
    this._ensurePreExecution();

    let status;
    switch (this.state) {
      case BaseAction.STATE_FINALIZED: {
        throw new Error("Action has already been finalized");
      }
      case BaseAction.STATE_READY: {
        try {
          await this._finalize(options);
          status = Uptake.ACTION_SUCCESS;
        } catch (err) {
          status = Uptake.ACTION_POST_EXECUTION_ERROR;
          // Sometimes Error.message can be updated in place. This gives better messages when debugging errors.
          try {
            err.message = `Could not run postExecution hook for ${this.name}: ${err.message}`;
          } catch (err) {
            // Sometimes Error.message cannot be updated. Log a warning, and move on.
            this.log.debug(`Could not run postExecution hook for ${this.name}`);
          }

          this.lastError = err;
          console.error(err);
        }
        break;
      }
      case BaseAction.STATE_DISABLED: {
        this.log.debug(
          `Skipping post-execution hook for ${this.name} because it is disabled.`
        );
        status = Uptake.ACTION_SUCCESS;
        break;
      }
      case BaseAction.STATE_FAILED: {
        this.log.debug(
          `Skipping post-execution hook for ${this.name} because it failed during pre-execution.`
        );
        // Don't report a status. A status should have already been reported by this.fail().
        break;
      }
      default: {
        throw new Error(`Unexpected state during finalize: ${this.state}`);
      }
    }

    this.state = BaseAction.STATE_FINALIZED;
    if (status) {
      Uptake.reportAction(this.name, status);
    }
  }

  /**
   * Action specific post-execution behavior should be implemented
   * here. It will be executed once after all recipes have been
   * processed.
   */
  async _finalize(_options = {}) {
    // Does nothing, may be overridden
  }
}

BaseAction.STATE_PREPARING = "ACTION_PREPARING";
BaseAction.STATE_READY = "ACTION_READY";
BaseAction.STATE_DISABLED = "ACTION_DISABLED";
BaseAction.STATE_FAILED = "ACTION_FAILED";
BaseAction.STATE_FINALIZED = "ACTION_FINALIZED";

// Make sure to update the docs in ../docs/suitabilities.rst when changing this.
BaseAction.suitability = {
  /**
   * The recipe's signature is not valid. If any action is taken this recipe
   * should be treated with extreme suspicion.
   */
  SIGNATURE_ERROR: "RECIPE_SUITABILITY_SIGNATURE_ERROR",

  /**
   * The recipe requires capabilities that this recipe runner does not have.
   * Use caution when interacting with this recipe, as it may not match the
   * expected schema.
   */
  CAPABILITIES_MISMATCH: "RECIPE_SUITABILITY_CAPABILITIES_MISMATCH",

  /**
   * The recipe is suitable to execute in this client.
   */
  FILTER_MATCH: "RECIPE_SUITABILITY_FILTER_MATCH",

  /**
   * This client does not match the recipe's filter, but it is otherwise a
   * suitable recipe.
   */
  FILTER_MISMATCH: "RECIPE_SUITABILITY_FILTER_MISMATCH",

  /**
   * There was an error while evaluating the filter. It is unknown if this
   * client matches this filter. This may be temporary, due to network errors,
   * or permanent due to syntax errors.
   */
  FILTER_ERROR: "RECIPE_SUITABILITY_FILTER_ERROR",

  /**
   * The arguments of the recipe do not match the expected schema for the named
   * action.
   */
  ARGUMENTS_INVALID: "RECIPE_SUITABILITY_ARGUMENTS_INVALID",
};

BaseAction.suitabilitySet = new Set(Object.values(BaseAction.suitability));

BaseAction.suitabilityToUptakeStatus = {
  [BaseAction.suitability.SIGNATURE_ERROR]: Uptake.RECIPE_INVALID_SIGNATURE,
  [BaseAction.suitability.CAPABILITIES_MISMATCH]:
    Uptake.RECIPE_INCOMPATIBLE_CAPABILITIES,
  [BaseAction.suitability.FILTER_MATCH]: Uptake.RECIPE_SUCCESS,
  [BaseAction.suitability.FILTER_MISMATCH]: Uptake.RECIPE_DIDNT_MATCH_FILTER,
  [BaseAction.suitability.FILTER_ERROR]: Uptake.RECIPE_FILTER_BROKEN,
  [BaseAction.suitability.ARGUMENTS_INVALID]: Uptake.RECIPE_ARGUMENTS_INVALID,
};
