import { Logger, Provider } from '@nestjs/common';
import { AsyncModelFactory } from './interface/async-model-factory.interface';
import typeMetadataStorage from './storage/type-metadata.storage';
import { CLIENT_PROVIDER_NAME, CONFIG_PROVIDER_NAME } from './appwrite.constants';
import { Client, Databases, IndexType, Models, Query } from 'node-appwrite';
import { AppwriteRepository } from 'appwrite-database-repository';
import { Config } from './config/config.interface';
import { AppwriteAttribute } from './interface/appwrite-attribute.interface';
import { DatabaseResourceStatus } from './domain/attribute-status.enum';

const MAX_PAGE_SIZE = 5000;
const HALF_SECOND_IN_MS = 500;
const logger = new Logger('appwrite.providers');

type AttributeType = Models.AttributeInteger | Models.AttributeBoolean | Models.AttributeEnum | Models.AttributeEmail
  | Models.AttributeDatetime | Models.AttributeFloat | Models.AttributeString;

const setupAttributes = async (option: AsyncModelFactory, repository: AppwriteRepository<Object>, config: Config): Promise<void> => {
  const classProperties = typeMetadataStorage.getClassProperties(option.class);
  const currentAttributes = (await repository.getCollection()).attributes as unknown as AttributeType[];

  await Promise.all(classProperties.map(async (prop) => {
    const { options } = prop;
    try {
      switch (prop.options.type) {
        case String: {
          if (options.enum) {
            return await repository.createOrUpdateEnumAttribute(prop.propertyKey, Object.values(prop.options.enum as any), options.required, options.default, options.isArray);
          }
          if (options.isEmail) {
            return await repository.createOrUpdateEmailAttribute(prop.propertyKey, options.required, options.default, options.isArray);
          }
          return await repository.createOrUpdateStringAttribute(prop.propertyKey, options.size as number, options.required, options.default, options.isArray, options.encrypt);
        }
        case Number: {
          if (options.isInt) {
            return await repository.createOrUpdateIntegerAttribute(prop.propertyKey, options.required, options.min, options.max, options.default);
          }
          return await repository.createOrUpdateFloatAttribute(prop.propertyKey, options.required, options.min, options.max, options.default);
        }
        case Boolean: {
          return await repository.createOrUpdateBooleanAttribute(prop.propertyKey, options.required, options.default, options.isArray);
        }
        case Date: {
          return await repository.createOrUpdateDateTimeAttribute(prop.propertyKey, options.required, options.default, options.isArray);
        }
        default: {
          logger.warn(`Property type ${prop.options.type} is not a valid Appwrite property type`);
        }
      }
    } catch (err) {
      logger.log(`Caught err for attribute ${prop.propertyKey} of type ${prop.options.type}}`);
      logger.error(err);
    }
    return;
  }));

  if (!config.DELETE_REMOVED_ATTRIBUTES) {
    return;
  }

  const collection = await repository.getCollection();
  const collectionAttributes = collection.attributes as unknown as AppwriteAttribute[];

  const attributeKeys = classProperties.map(property => property.propertyKey);
  const deletedAttributes = currentAttributes.filter(attribute => !attributeKeys.includes(attribute.key));
  await Promise.all(deletedAttributes.map(async (attribute) => {
    const attributeFound = collectionAttributes.find(collectionAttribute => attribute.key === collectionAttribute.key);
    if (attributeFound?.status === DatabaseResourceStatus.Processing) {
      return;
    }

    try {
      return await repository.deleteAttribute(attribute.key);
    } catch (err) {
      logger.log(`Caught err when deleting attribute with key ${attribute.key}`);
      logger.error(err);
    }
  }))
};

const waitForAttributesToBeCreated = async (client: Client, config: Config, collectionId: string): Promise<void> => {
  const databases = new Databases(client);

  let attributesCreating = true;
  while (attributesCreating) {
    const attributes = (await databases.listAttributes(config.APPWRITE_DATABASE_ID, collectionId, [Query.limit(MAX_PAGE_SIZE)])).attributes as unknown as AppwriteAttribute[];
    attributesCreating = attributes.some((attribute) => attribute.status === DatabaseResourceStatus.Processing);

    if (attributesCreating) {
      logger.log('Waiting for attribute to be created');
      await new Promise(resolve => setTimeout(resolve, HALF_SECOND_IN_MS));
    }
  }
};

const setupIndexes = async (option: AsyncModelFactory, repository: AppwriteRepository<Object>): Promise<void> => {
  const classIndexes = typeMetadataStorage.getClassIndexes(option.class);

  await Promise.all(classIndexes.map(async (index) => {
    const { options } = index;

    const indexName = `${index.propertyKey}-${options.type}`;
    try {
      let existingIndex = await repository.createOrUpdateIndex(indexName, options.type as IndexType, options.attributes, options.orders);

      let indexCreating = existingIndex.status === DatabaseResourceStatus.Processing;
      while (indexCreating) {
        await new Promise(resolve => setTimeout(resolve, HALF_SECOND_IN_MS));
        existingIndex = await repository.getIndex(indexName);
        indexCreating = existingIndex.status === DatabaseResourceStatus.Processing;
      }
    } catch (err) {
      logger.log(`Caught err for index ${indexName} of type ${options.type}}`);
      logger.error(err);
    }
  }));
};

export function createAppwriteAsyncProviders(
  modelFactories: AsyncModelFactory[] = [],
): Provider[] {
  return modelFactories.reduce((providers, option) => {
    return [
      ...providers,
      {
        provide: option.class.name,
        useFactory: async (client: Client, config: Config): Promise<AppwriteRepository<Object>> => {
          const { name } = option.class;
          logger.debug(`Initializing schema: ${name}`);

          const schemaMetaData = typeMetadataStorage.getSchemaMetadata(option.class);
          const repository = new AppwriteRepository(
            client,
            config.APPWRITE_DATABASE_ID,
            schemaMetaData.collectionId,
          );
          try {
            await repository.createOrUpdateCollection(schemaMetaData.collectionName, schemaMetaData.permissions, schemaMetaData.documentSecurity, schemaMetaData.enabled);
          } catch (err) {
            logger.log(`Caught err for update collection ${schemaMetaData.collectionName}`);
            logger.error(err);
          }

          await setupAttributes(option, repository, config);

          await waitForAttributesToBeCreated(client, config, schemaMetaData.collectionId);
          await setupIndexes(option, repository);

          logger.debug(`Done initializing schema: ${name}`);
          return repository;
        },
        inject: [CLIENT_PROVIDER_NAME, CONFIG_PROVIDER_NAME, ...(option.inject || [])],
      },
    ];
  }, [] as Provider[]);
}
