/* eslint-disable @typescript-eslint/no-explicit-any */
import {
  Container as BaseContainer,
  MetadataReader,
  interfaces,
  ContainerModule,
  decorate,
} from 'inversify';
import {
  ContainerConfig,
  ModuleOptions,
  FactoryProvider,
  ValueProvider,
  ClassProvider,
  ServiceIdentifier,
  DependencyProviderOption,
} from './interfaces';
import { getMetadata, setModulesDeps, lookupOptionalIdentifier } from './util';
import { createCollector } from './middlewares/collector';
import { METADATA_KEY } from './constants';
import { injectable, inject } from './decorators';
import { defaultUndefinedValue } from './optional';
import { ModuleRef } from './moduleRef';

class CustomMetadataReader extends MetadataReader {
  public getConstructorMetadata(
    constructorFunc: Function
  ): interfaces.ConstructorMetadata {
    const constructorMetadata = super.getConstructorMetadata(constructorFunc);
    // TODO: hook
    return constructorMetadata;
  }
}

function autoBindModules() {
  return new ContainerModule(
    (
      bind: interfaces.Bind,
      unbind: interfaces.Unbind,
      isBound: interfaces.IsBound
    ) => {
      const provideMeta = getMetadata(METADATA_KEY.provide);
      const optionalMeta = getMetadata(METADATA_KEY.optional);
      for (const [identifier, provide] of provideMeta) {
        // default injection without optional module.
        if (
          (!optionalMeta.has(identifier) ||
            lookupOptionalIdentifier(identifier)) &&
          !isBound(identifier)
        ) {
          bind(identifier).to(provide);
        }
      }
    }
  );
}

function isClassProvider(module: ModuleOptions): module is ClassProvider {
  return typeof (module as ClassProvider).useClass === 'function';
}

function isFactoryProvider(module: ModuleOptions): module is FactoryProvider {
  return typeof (module as FactoryProvider).useFactory === 'function';
}

/**
 * It ensures that the parameters of all modules from the configuration are decorated.
 *
 * class Foo {
 *   constructor(bar: Bar) {}
 * }
 *
 * Equate to:
 *
 * class Foo {
 *   constructor(@inject() bar: Bar) {}
 * }
 *
 * @param target {object} decorated target.
 */
function autoDecorateParams(target: object) {
  const metadata: object[] = Reflect.getMetadata(
    METADATA_KEY.inversifyParamtypes,
    target
  );

  metadata.forEach((_, index) => {
    if (
      !Reflect.getMetadata(METADATA_KEY.inversifyTagged, target) ||
      !Reflect.getMetadata(METADATA_KEY.inversifyTagged, target)[index]
    ) {
      inject()(target, undefined, index);
    }
  });
}

export class Container extends BaseContainer {
  constructor(
    options: interfaces.ContainerOptions,
    private _serviceIdentifiers: Map<
      interfaces.ServiceIdentifier<any>,
      interfaces.ServiceIdentifier<any>[]
    >
  ) {
    super(options);
  }

  /**
   * get the loaded module
   */
  got<T>(serviceIdentifier: interfaces.ServiceIdentifier<T>): T | undefined {
    try {
      return this._serviceIdentifiers.has(serviceIdentifier)
        ? this.get(serviceIdentifier)
        : undefined;
    } catch (error) {
      if (__DEV__) {
        console.warn(error);
      }
      return undefined;
    }
  }

  /**
   * get loaded modules
   */
  gotAll<T>(
    serviceIdentifier: interfaces.ServiceIdentifier<T>
  ): T[] | undefined {
    try {
      return this._serviceIdentifiers.has(serviceIdentifier)
        ? this.getAll(serviceIdentifier)
        : undefined;
    } catch (error) {
      if (__DEV__) {
        console.warn(error);
      }
      return undefined;
    }
  }
}

export function bindModules(container: Container, modules: ModuleOptions[]) {
  const provideMeta = getMetadata(METADATA_KEY.provide);
  for (const module of modules) {
    if (typeof module === 'function') {
      // auto decorate `@injectable` for module.
      if (!provideMeta.has(module)) decorate(injectable(), module);
      autoDecorateParams(module);
      container.bind(module).toSelf();
    } else if (typeof module === 'object') {
      if (isClassProvider(module)) {
        // auto decorate `@injectable` for module.useClass
        if (!provideMeta.has(module.useClass))
          decorate(injectable(), module.useClass);
        autoDecorateParams(module.useClass);
        container.bind(module.provide).to(module.useClass);
      } else if (Object.hasOwnProperty.call(module, 'useValue')) {
        container
          .bind(module.provide)
          .toConstantValue((module as ValueProvider).useValue);
      } else if (isFactoryProvider(module)) {
        container
          .bind(module.provide)
          .toFactory((context: interfaces.Context) => {
            const deps = module.deps || [];
            const depInstances = deps.map((identifier) => {
              // TODO: refactor with `is` assertion
              const provide =
                (identifier as DependencyProviderOption).provide ||
                (identifier as ServiceIdentifier<any>);
              if (
                (identifier as DependencyProviderOption).optional &&
                !context.container.isBound(
                  (identifier as DependencyProviderOption).provide
                )
              ) {
                return undefined;
              }
              return context.container.get(provide);
            });
            return module.useFactory(...depInstances);
          });
      } else if (typeof module.provide === 'function') {
        // auto decorate `@injectable` for module.provide
        if (!provideMeta.has(module.provide))
          decorate(injectable(), module.provide);
        autoDecorateParams(module.provide);
        container.bind(module.provide).toSelf();
      } else {
        throw new Error(`${module} option error`);
      }
    } else {
      throw new Error(`${module} option error`);
    }
  }
  // load modules with `@injectable` decoration, but without `@optional` decoration.
  container.load(autoBindModules());
}

export function createContainer({
  ServiceIdentifiers,
  modules = [],
  options,
}: ContainerConfig) {
  setModulesDeps(modules);
  const container = new Container(options!, ServiceIdentifiers);
  container.applyCustomMetadataReader(new CustomMetadataReader());
  bindModules(container, modules);
  container.applyMiddleware(createCollector(ServiceIdentifiers));
  if (container.isBound(ModuleRef)) {
    container.unbind(ModuleRef);
  }
  container.bind(ModuleRef).toConstantValue(container);
  container.bind(defaultUndefinedValue).toConstantValue(undefined);
  return container;
}
