import debug from "debug";

import type {
  EnvironmentExtender,
  Network,
  ParamDefinitionAny,
  PolarRuntimeEnvironment,
  ResolvedConfig,
  RunSuperFunction,
  RunTaskFunction,
  RuntimeArgs,
  TaskArguments,
  TaskDefinition,
  TasksMap
} from "../../types";
import { PolarError } from "../core/errors";
import { ERRORS } from "../core/errors-list";
import { OverriddenTaskDefinition } from "./tasks/task-definitions";

const log = debug("polar:core:pre");

export class Environment implements PolarRuntimeEnvironment {
  private static readonly _BLACKLISTED_PROPERTIES: string[] = [
    "injectToGlobal",
    "_runTaskDefinition"
  ];

  public network: Network;

  private readonly _extenders: EnvironmentExtender[];

  /**
   * Initializes the polar Runtime Environment and the given
   * extender functions.
   *
   * @remarks The extenders' execution order is given by the order
   * of the requires in the polar's config file and its plugins.
   *
   * @param config The polar's config object.
   * @param runtimeArgs The parsed polar's arguments.
   * @param tasks A map of tasks.
   * @param extenders A list of extenders.
   * @param networkRequired if true it will assert that a requested network is defined.
   */
  constructor (
    public readonly config: ResolvedConfig,
    public readonly runtimeArgs: RuntimeArgs,
    public readonly tasks: TasksMap,
    extenders: EnvironmentExtender[] = [],
    networkRequired = true
  ) {
    log("Creating RuntimeEnv");

    const ncfg = config.networks[runtimeArgs.network];
    // network configuration is required for all tasks except few setup tasks
    if (!ncfg && networkRequired) {
      throw new PolarError(ERRORS.NETWORK.CONFIG_NOT_FOUND, {
        network: runtimeArgs.network
      });
    }

    log("Runtime environment NOOP (MM)");
    // this.provider .... -- creation goes here

    this._extenders = extenders;
    this.network = {
      name: runtimeArgs.network,
      config: ncfg
    };

    extenders.forEach((extender) => extender(this));
  }

  /**
   * Executes the task with the given name.
   *
   * @param name The task's name.
   * @param taskArguments A map of task's arguments.
   *
   * @throws a POLAR303 if there aren't any defined tasks with the given name.
   * @returns a promise with the task's execution result.
   */
  public readonly run: RunTaskFunction = async (name, taskArguments = {}) => {
    const taskDefinition = this.tasks[name];

    log("Running task %s", name);

    if (taskDefinition === undefined) {
      throw new PolarError(ERRORS.ARGUMENTS.UNRECOGNIZED_TASK, {
        task: name
      });
    }

    const resolvedTaskArguments = this._resolveValidTaskArguments(
      taskDefinition,
      taskArguments
    );

    return await this._runTaskDefinition(taskDefinition, resolvedTaskArguments);
  };

  /**
   * Injects the properties of `this` (the polar Runtime Environment) into the global scope.
   *
   * @param blacklist a list of property names that won't be injected.
   *
   * @returns a function that restores the previous environment.
   */
  public injectToGlobal (
    blacklist: string[] = Environment._BLACKLISTED_PROPERTIES
  ): () => void {
    const globalAsAny = global as any; // eslint-disable-line @typescript-eslint/no-explicit-any
    const previousValues: { [name: string]: any } = {}; // eslint-disable-line @typescript-eslint/no-explicit-any

    for (const [key, value] of Object.entries(this)) {
      if (blacklist.includes(key)) {
        continue;
      }

      previousValues[key] = globalAsAny[key];
      globalAsAny[key] = value;
    }

    return () => {
      for (const [key] of Object.entries(this)) {
        if (blacklist.includes(key)) {
          continue;
        }

        globalAsAny[key] = previousValues[key];
      }
    };
  }

  private async _runTaskDefinition (
    taskDefinition: TaskDefinition,
    taskArguments: TaskArguments
  ): Promise<void> {
    let runSuperFunction: any;  // eslint-disable-line

    if (taskDefinition instanceof OverriddenTaskDefinition) {
      runSuperFunction = async (
        _taskArguments: TaskArguments = taskArguments
      ) => {
        log("Running %s's super", taskDefinition.name);

        return await this._runTaskDefinition(
          taskDefinition.parentTaskDefinition,
          _taskArguments
        );
      };

      runSuperFunction.isDefined = true;
    } else {
      runSuperFunction = async () => {
        throw new PolarError(ERRORS.TASK_DEFINITIONS.RUNSUPER_NOT_AVAILABLE, {
          taskName: taskDefinition.name
        });
      };

      runSuperFunction.isDefined = false;
    }

    const runSuper: RunSuperFunction<TaskArguments> = runSuperFunction;

    const globalAsAny = global as any; // eslint-disable-line @typescript-eslint/no-explicit-any
    const previousRunSuper = globalAsAny.runSuper;
    globalAsAny.runSuper = runSuper;

    const uninjectFromGlobal = this.injectToGlobal();

    try {
      return await taskDefinition.action(taskArguments, this, runSuper);
    } finally {
      uninjectFromGlobal();
      globalAsAny.runSuper = previousRunSuper;
    }
  }

  /**
   * Check that task arguments are within TaskDefinition defined params constraints.
   * Also, populate missing, non-mandatory arguments with default param values (if any).
   *
   * @private
   * @throws PolarError if any of the following are true:
   *  > a required argument is missing
   *  > an argument's value's type doesn't match the defined param type
   *
   * @param taskDefinition
   * @param taskArguments
   * @returns resolvedTaskArguments
   */
  private _resolveValidTaskArguments (
    taskDefinition: TaskDefinition,
    taskArguments: TaskArguments
  ): TaskArguments {
    const { paramDefinitions, positionalParamDefinitions } = taskDefinition;

    const nonPositionalParamDefinitions = Object.values(paramDefinitions);

    // gather all task param definitions
    const allTaskParamDefinitions = [
      ...nonPositionalParamDefinitions,
      ...positionalParamDefinitions
    ];

    const initResolvedArguments: {
      errors: PolarError[]
      values: TaskArguments
    } = { errors: [], values: {} };

    const resolvedArguments = allTaskParamDefinitions.reduce(
      ({ errors, values }, paramDefinition) => {
        try {
          const paramName = paramDefinition.name;
          const argumentValue = taskArguments[paramName];
          const resolvedArgumentValue = this._resolveArgument(
            paramDefinition,
            argumentValue
          );
          if (resolvedArgumentValue !== undefined) {
            values[paramName] = resolvedArgumentValue;
          }
        } catch (error) {
          errors.push(error);
        }
        return { errors, values };
      },
      initResolvedArguments
    );

    const { errors: resolveErrors, values: resolvedValues } = resolvedArguments;

    // if has argument errors, throw the first one
    if (resolveErrors.length > 0) {
      throw resolveErrors[0];
    }

    // append the rest of arguments that where not in the task param definitions
    return { ...taskArguments, ...resolvedValues };
  }

  /**
   * Resolves an argument according to a ParamDefinition rules.
   *
   * @param paramDefinition
   * @param argumentValue
   * @private
   */
  private _resolveArgument (
    paramDefinition: ParamDefinitionAny,
    argumentValue: any // eslint-disable-line @typescript-eslint/no-explicit-any
  ): any { // eslint-disable-line @typescript-eslint/no-explicit-any
    const { name, isOptional, defaultValue } = paramDefinition;

    if (argumentValue === undefined) {
      if (isOptional) {
        // undefined & optional argument -> return defaultValue
        return defaultValue;
      }

      // undefined & mandatory argument -> error
      throw new PolarError(ERRORS.ARGUMENTS.MISSING_TASK_ARGUMENT, {
        param: name
      });
    }

    // arg was present -> validate type, if applicable
    this._checkTypeValidation(paramDefinition, argumentValue);

    return argumentValue;
  }

  /**
   * Checks if value is valid for the specified param definition.
   *
   * @param paramDefinition {ParamDefinition} - the param definition for validation
   * @param argumentValue - the value to be validated
   * @private
   * @throws POLAR301 if value is not valid for the param type
   */
  private _checkTypeValidation (
    paramDefinition: ParamDefinitionAny,
    argumentValue: any // eslint-disable-line @typescript-eslint/no-explicit-any
  ): void {
    const { name: paramName, type, isVariadic } = paramDefinition;
    if (type === undefined || type.validate === undefined) {
      // no type or no validate() method defined, just skip validation.
      return;
    }

    // in case of variadic param, argValue is an array and the
    // type validation must pass for all values.
    // otherwise, it's a single value that is to be validated
    const argumentValueContainer = isVariadic ? argumentValue : [argumentValue];

    for (const value of argumentValueContainer) {
      type.validate(paramName, value);
    }
  }
}
