import { Injectable } from '@nestjs/common';
import { EntityManager, Repository } from 'typeorm';
import { ModuleRef } from '@nestjs/core';
import { PARAMTYPES_METADATA } from '@nestjs/common/constants';

type ClassType<T = any> = new (...args: any[]) => T;
type ForwardRef = {
  forwardRef: () => any;
};

export interface WithTransactionOptions {
  /**
   * Class types, that will not rebuild in transaction,
   * for example - it can be a service without any repositories
   * or some cache service, and that service must not rebuild in
   * app in any time
   */
  excluded?: ClassType[];
}

@Injectable()
export class TransactionFor<T = any> {
  private cache: Map<string, any> = new Map();

  constructor(private moduleRef: ModuleRef) {
  }

  public withTransaction(manager: EntityManager, transactionOptions: WithTransactionOptions = {}): this {
    const newInstance = this.findArgumentsForProvider(this.constructor as ClassType<this>, manager, transactionOptions.excluded ?? []);
    this.cache.clear();
    return newInstance;
  }

  private getArgument(param: string | ClassType | ForwardRef, manager: EntityManager, excluded: ClassType[]): any {
    if (typeof param === 'object' && 'forwardRef' in param) {
      return this.moduleRef.get(param.forwardRef().name, { strict: false });
    }
    const id = typeof param === 'string' ? param : typeof param === 'function' ? param.name : undefined;
    if (id === undefined) {
      throw new Error(`Can't get injection token from ${param}`);
    }
    const isExcluded = excluded.length > 0 && excluded.some((ex) => ex.name === id);
    if (id === `${ModuleRef.name}`) {
      return this.moduleRef;
    }
    if (isExcluded) {
      /// Returns current instance of service, if it is excluded
      return this.moduleRef.get(id, { strict: false });
    }
    let argument: Repository<any>;
    if (this.cache.has(id)) {
      return this.cache.get(id);
    }
    const canBeRepository = id.includes('Repository');
    if (typeof param === 'string' || canBeRepository) {
      // Fetch the dependency
      let dependency: Repository<any>;
      try {
        if (canBeRepository) {
          // Return directly if param is custom repository.
          return manager.getCustomRepository(param as any);
        }
      } catch (error) {
        dependency = this.moduleRef.get(param, { strict: false });
      }
      if (dependency! instanceof Repository || canBeRepository) {
        // If the dependency is a repository, make a new repository with the desired transaction manager.
        const entity: any = dependency!.metadata.target;
        argument = manager.getRepository(entity);
      } else {
        // The dependency is not a repository, use it directly.
        argument = dependency!;
      }
    } else {
      argument = this.findArgumentsForProvider(param as ClassType, manager, excluded);
    }
    this.cache.set(id, argument);
    return argument;
  }

  private findArgumentsForProvider(constructor: ClassType, manager: EntityManager, excluded: ClassType[]) {
    const args: any[] = [];
    const keys = Reflect.getMetadataKeys(constructor);
    keys.forEach((key) => {
      if (key === PARAMTYPES_METADATA) {
        const paramTypes: Array<string | ClassType> = Reflect.getMetadata(key, constructor);
        for (const param of paramTypes) {
          const argument = this.getArgument(param, manager, excluded);
          args.push(argument);
        }
      }
    });
    return new constructor(...args);
  }
}