import { plainToInstance } from "class-transformer";
import { validate } from "class-validator";
import { InvokeAction } from "./types/invoke.type";
import { SagaResponse } from "./types/saga-response";
import { ServicesList } from "./types/services.type";
import { Workflow } from "./types/workflow.type";

export class SagaProcessor<T> {
  private steps = [];
  private history = [];
  private services = {};
  private ctx = {} as T;
  private currentStep = 0;
  private toCompensate = [];
  private errors: Error[] = [];
  private currentExecution = null;
  private errorFormatter = (err) => err;

  constructor(services: ServicesList = {}) {
    // Inject services
    this.services = services;
  }

  // --------------------
  // Add a workflow to execute
  // --------------------
  add(workflow: Workflow): SagaProcessor<T> {
    this.steps = [...this.steps, ...workflow];
    return this;
  }

  // --------------------
  // Change exception formatter to handle errors
  // --------------------
  handleExceptions(formatter) {
    this.errorFormatter = formatter;
  }

  // --------------------
  // Start workflows execution
  // --------------------
  async start(): Promise<SagaResponse<T>> {
    this.debug("Run workflow");
    await this.runStep();
    return this.formatResponse();
  }

  // --------------------
  // Run a step
  // --------------------
  private async runStep(): Promise<void> {
    // Get step to execute
    const currentStep = this.steps[this.currentStep];

    const { name, condition, validate, invokes, withCompensation } =
      currentStep;

    try {
      // If there is a condition, check it
      // If condition is true, run step
      if (this.checkCondition(condition)) {
        // If we execute this step, we inject compensation to the backward stack
        if (validate) await this.validate(validate);
        if (withCompensation) this.toCompensate.push(withCompensation);

        this.debug("\tStart step:", name);

        // Execute each invoke of this step (cascade)
        await invokes.reduce(
          (acc, curr) => acc.then(() => this.runInvoke(curr)),
          Promise.resolve()
        );
      }

      // Run next step
      await this.makeStepForward(this.currentStep + 1);
    } catch (err) {
      // An error occured, catch it
      this.errors.push(this.errorFormatter(err));
      this.endLog("error");

      await this.compensate();
    }
  }

  // --------------------
  // Execute a function into a step
  // --------------------
  private async runInvoke(invoke: InvokeAction): Promise<void> {
    const { name, condition, action, withCompensation } = invoke;

    // If there is a condition, check it
    // If condition is wrong, jump invoke
    if (!this.checkCondition(condition)) return;

    // If we execute this step, we inject compensation to the backward stack
    if (withCompensation) this.toCompensate.push(withCompensation);

    // Recording state
    this.debug("\t\tRun invoke:", name);
    this.startLog(name);

    // Execute Invoke
    const response = await action(this.ctx, this.services);

    // Enrich context
    this.ctx = { ...this.ctx, ...response };

    // End recording
    this.endLog("success");
  }

  // --------------------
  // Validate data
  // --------------------
  async validate(dto) {
    if (!dto) return;

    if (typeof dto !== "function") {
      throw new Error("DTO for validation must be a class constructor");
    }

    const objInstance: any = plainToInstance(dto, this.ctx || {});
    const errors = (await validate(objInstance)) || [];

    if (errors.length > 0) {
      errors.map(({ property, constraints = {} }) => {
        this.errors.push(
          new Error(
            `Validation failed on "${property}" : ` +
              Object.values(constraints).join(", ")
          )
        );
      });

      throw new Error("Stop saga");
    }
  }

  // --------------------
  // Run a condition
  // --------------------
  checkCondition(condition) {
    if (!condition) return true;
    return condition({ ...this.ctx });
  }

  // --------------------
  // Run next step or handle workflow end
  // --------------------
  private async makeStepForward(index: number) {
    if (index >= this.steps.length) {
      return this.ctx;
    }

    this.currentStep = index;
    return this.runStep();
  }

  // --------------------
  // Start to compensate
  // --------------------
  private async compensate() {
    // Cascade compensation functions
    await this.toCompensate.reverse().reduce(
      async (acc, cur) =>
        acc
          .then(() => cur(this.ctx, this.services))
          .then((response = {}) => {
            // Enrich context
            this.ctx = { ...this.ctx, ...response };
          })
          .catch((err) => {
            this.errors.push(err);
            return;
          }),
      Promise.resolve()
    );
  }

  // --------------------
  // --------------------
  private async formatResponse(): Promise<SagaResponse<T>> {
    const hasErrors = this.errors.length > 0;

    return {
      state: hasErrors ? "failed" : "success",
      context: this.ctx,
      errors: this.errors,
      history: this.history,
    };
  }

  startLog(name) {
    const currentStep = this.steps[this.currentStep];

    const execution = {
      step: this.currentStep,
      name: currentStep.name,
      invoke: name,
      startAt: new Date(),
      endAt: 0,
      state: "pending",
    };

    this.currentExecution = execution;
  }

  endLog(state) {
    if (!this.currentExecution) return;
    this.currentExecution.state = state;
    this.currentExecution.endAt = new Date();
    this.history.push({ ...this.currentExecution });
  }

  debug(...params) {
    const currentStep = this.steps[this.currentStep];
    if (currentStep.debug) console.log(...params);
  }
}
