import {
  BindingFromClassOptions,
  BindingScope,
  Constructor,
  ContextBindings,
  ControllerClass,
  Interceptor,
  InterceptorBindingOptions,
  Provider,
  ServiceOptions,
  ServiceOrProviderClass,
} from '@loopback/core';
import {Class, juggler, Repository} from '@loopback/repository';
import * as _ from 'lodash';
import {camelCase} from 'lodash';
import {ObjectTreesApplicationInterface} from '../application.interface';
import {ObjectSubType} from '../models';
import {ObjectNode} from '../models/object-node.model';
import {ObjectType} from '../models/object-type.model';
import {
  ApplicationExtensionContext,
  ApplicationService,
  ExpectedValue,
} from '../services/application.service';
import {ObjectNodeService} from '../services/object-node/object-node.service';
import {ObjectTreeService} from '../services/object-tree/object-tree.service';
import {ObjectTypeService} from '../services/object-type.service';
import {DataEntity} from './../models/data-entity.model';
import {AccessRightsScope} from './../services/access-rights/access-rights.const';
import {AccessRightsService} from './../services/access-rights/access-rights.service';
import {
  InterceptorDescription,
  RunnerTreatmentDescription,
} from './extension-description';

export type ExtensionProviderClass = new (
  app: ObjectTreesApplicationInterface,
) => ExtensionProvider;

export type ObjectTypeDefinition = Partial<ObjectType> & {
  name: string;
  forcedAccessRights?: {[scope in AccessRightsScope]?: boolean};
};

export type CalculatedString = string | (() => string);
export type ObjectSubTypeDefinition = Partial<ObjectSubType> & {
  typeName: CalculatedString;
  subTypeName: CalculatedString;
};

export interface ObjectTreeDefinition {
  treeNode: Partial<ObjectNode>;
  children: {
    [objectTypeId: string]: {
      [name: string]: ObjectTreeDefinition[];
    };
  };
}

export type CalculatedNode =
  | {
      ownerTypeId: string;
      ownerName: string;
      namespaceTypeId: string;
      namespaceName: string;
    }
  | (() => Promise<ObjectNode>);

export class TypeMigrationOperation {
  parentType: string;
  previousType: string;
  newType: string;
}

export class NameMigrationOperation {
  previousName: string;
  newName: string;
  type?: string;
  parentType?: string;
}

export type MigrationOperation =
  | TypeMigrationOperation
  | NameMigrationOperation;

export abstract class ExtensionProvider {
  protected appCtx: ApplicationService;
  protected ctx: ApplicationExtensionContext;
  protected objectTypeService: ObjectTypeService;
  protected objectNodeService: ObjectNodeService;
  protected objectTreeService: ObjectTreeService;
  constructor(
    public name: string,
    protected app: ObjectTreesApplicationInterface,
  ) {
    console.log('Adding ' + this.name + '.');
  }
  requiredProviders: ExtensionProviderClass[] = [];

  _objectTypes: {[typeField: string]: ObjectTypeDefinition} = {};

  objectTypes: ObjectTypeDefinition[] = [];

  objectSubTypes: ObjectSubTypeDefinition[] = [];

  objectTrees: {
    [nodeField: string]: {
      parentNode: () => ObjectNode;
      treeNodeTypeId: string;
      treeNodeName: string;
      reset?: boolean;
      tree: ObjectTreeDefinition;
    };
  } = {};

  services: {
    cls: ServiceOrProviderClass;
    nameOrOptions?: string | ServiceOptions;
  }[] = [];

  models: Class<DataEntity>[] = [];

  repositories: {
    repoClass: Class<Repository<DataEntity>>;
    nameOrOptions?: string | BindingFromClassOptions | undefined;
  }[] = [];

  interceptorsPrepend: {
    id: string;
    interceptor: Interceptor | Constructor<Provider<Interceptor>>;
    nameOrOptions?: string | InterceptorBindingOptions;
    description: InterceptorDescription;
  }[] = [];

  interceptorsAppend: {
    id: string;
    interceptor: Interceptor | Constructor<Provider<Interceptor>>;
    nameOrOptions?: string | InterceptorBindingOptions;
    description: InterceptorDescription;
  }[] = [];

  controllers: {
    controllerCtor: ControllerClass;
    nameOrOptions?: string | BindingFromClassOptions;
    description: RunnerTreatmentDescription;
  }[] = [];

  dataSources: {
    dataSource: juggler.DataSource | Class<juggler.DataSource>;
    name: string;
  }[] = [];

  migrations: MigrationOperation[] = [];

  private _forcedAccessRightsByObjectType: {
    [objectTypeId: string]: {[scope in AccessRightsScope]?: boolean};
  } = {};

  public async setContext(appCtx: ApplicationService): Promise<void> {
    this.appCtx = appCtx;
    this.objectTreeService = await this.app.getService<ObjectTreeService>(
      ObjectTreeService,
    );
    this.objectNodeService = await this.app.getService<ObjectNodeService>(
      ObjectNodeService,
    );
    this.objectTypeService = await this.app.getService<ObjectTypeService>(
      ObjectTypeService,
    );

    this.ctx = appCtx.getExtensionContext<ApplicationExtensionContext>(
      this.name,
    );
  }

  public async beforeBoot(): Promise<void> {
    console.log('Initializing ' + this.name + '.');
    for (const dataSource of this.dataSources) {
      let name = dataSource.name;
      if (name.startsWith('datasources.')) {
        name = name.substr('datasources.'.length);
      }
      this.app
        .dataSource(dataSource.dataSource, name)
        .inScope(BindingScope.SINGLETON);
    }

    for (const model of this.models) {
      this.app.model(model);
    }

    for (const repository of this.repositories) {
      this.app.repository(repository.repoClass, repository.nameOrOptions);
    }

    for (const serviceProvider of this.services) {
      this.app.service(
        serviceProvider.cls,
        serviceProvider.nameOrOptions
          ? serviceProvider.nameOrOptions
          : {defaultScope: BindingScope.SINGLETON},
      );
      await this.app.getService(serviceProvider.cls);
      console.log(serviceProvider.cls.name + ' started !');
    }
    for (const interceptorProvider of this.interceptorsAppend) {
      this.app.addInterceptor(this.name, interceptorProvider);
      const binding = this.app.getBinding(
        ContextBindings.GLOBAL_INTERCEPTOR_ORDERED_GROUPS,
      );
      const interceptor: string[] = binding.getValue(this.app) as string[];
      interceptor.push(interceptorProvider.id);
      binding.to(interceptor);
      console.log(
        'Interceptors list updated',
        this.app
          .getBinding(ContextBindings.GLOBAL_INTERCEPTOR_ORDERED_GROUPS)
          .getValue(this.app),
      );
    }
    for (const interceptorProvider of this.interceptorsPrepend) {
      this.app.addInterceptor(this.name, interceptorProvider);

      const binding = this.app.getBinding(
        ContextBindings.GLOBAL_INTERCEPTOR_ORDERED_GROUPS,
      );
      const interceptor: string[] = binding.getValue(this.app) as string[];
      interceptor.unshift(interceptorProvider.id);
      binding.to(interceptor);
      console.log(
        'Interceptors list updated',
        this.app
          .getBinding(ContextBindings.GLOBAL_INTERCEPTOR_ORDERED_GROUPS)
          .getValue(this.app),
      );
    }

    for (const controller of this.controllers) {
      this.app.addController(this.name, controller);
    }

    for (const objectType of this.objectTypes) {
      await this.addType(objectType);
    }

    for (const subType of this.objectSubTypes) {
      await this.addSubType(subType);
    }
  }

  protected async addType(objectType: ObjectTypeDefinition): Promise<void> {
    const typeField = camelCase(objectType.name);
    if (!this.ctx.types[typeField]) {
      this.ctx.types[typeField] = new ExpectedValue<ObjectType>();
    }
    await this.ctx.types[typeField].getOrSetValue(
      async (): Promise<ObjectType> => {
        if ('forcedAccessRights' in objectType) {
          this._forcedAccessRightsByObjectType[
            objectType.name
          ] = objectType.forcedAccessRights as {
            [scope in AccessRightsScope]?: boolean;
          };
          delete objectType.forcedAccessRights;
        }
        return this.objectTypeService.registerApplicationType(objectType);
      },
    );
  }

  protected async addSubType(subType: ObjectSubTypeDefinition): Promise<void> {
    const typeName = _.isString(subType.typeName)
      ? subType.typeName
      : subType.typeName();
    const subTypeName = _.isString(subType.subTypeName)
      ? subType.subTypeName
      : subType.subTypeName();
    if (!subType.name) {
      subType.name = subTypeName;
    }
    await this.objectTypeService.getOrCreateObjectSubType(
      typeName,
      subTypeName,
      _.pick(subType, [
        'name',
        'title',
        'acl',
        'owner',
        'namespace',
        'tree',
        'min',
        'max',
        'exclusions',
        'mandatories',
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
      ]) as any,
    );
  }

  public async migrateBeforeBoot(): Promise<void> {
    for (const migration of this.migrations) {
      if ('newType' in migration) {
        await this.objectNodeService.migrateType(
          migration.parentType,
          migration.previousType,
          migration.newType,
        );
      } else if ('newName' in migration) {
        await this.objectNodeService.migrateName(
          migration.previousName,
          migration.newName,
          migration.type,
          migration.parentType,
        );
      }
    }
  }

  async boot(): Promise<void> {
    console.log('Booting ' + this.name + '.');

    const accessRightsService: AccessRightsService = await this.app.getService<AccessRightsService>(
      AccessRightsService,
    );
    if (accessRightsService) {
      for (const objectTypeId in this._forcedAccessRightsByObjectType) {
        accessRightsService.addForcedAccessRightsForObjectType(
          objectTypeId,
          this._forcedAccessRightsByObjectType[objectTypeId],
        );
      }
    }

    for (const nodeField in this.objectTrees) {
      if (!this.ctx.nodes[nodeField]) {
        this.ctx.nodes[nodeField] = new ExpectedValue<ObjectNode>();
      }
      await this.ctx.nodes[nodeField].getOrSetValue(
        async (): Promise<ObjectNode> => {
          return this.objectTreeService.registerApplicationTree(
            this.objectTrees[nodeField].parentNode(),
            this.objectTrees[nodeField].treeNodeName,
            this.objectTrees[nodeField].treeNodeTypeId,
            this.objectTrees[nodeField].tree,
            !!this.objectTrees[nodeField].reset,
          );
        },
      );
    }
    this.ctx.resolve();
  }
}
