import { Client, Databases, ID, IndexType, Models, Query, QueryTypes, Teams } from 'node-appwrite';
import { ClientWithToken } from './domain/client-with-token';
import { PermissionType } from './domain/permission-type.enum';
import { Document } from './domain/document.type';
import { isAppwriteException } from './utils/error.utils';

const NOT_FOUND_STATUS_CODE = 404;
const MAX_PAGE_SIZE = 5000;

type PartialDocumentType = Omit<Document, keyof Document>;

export class AppwriteRepository<Schema extends PartialDocumentType> {

  protected databases: Databases;

  constructor(
    private readonly client: Client | ClientWithToken,
    protected readonly databaseId: string,
    protected readonly collectionId: string,
  ) {
    this.databases = new Databases(this.client);
  }

  public async attributeExistsByKey(key: string): Promise<boolean> {
    try {
      await this.getAttribute(key);
      return true;
    } catch (err: unknown) {
      if (isAppwriteException(err) && err.code === NOT_FOUND_STATUS_CODE) {
        return false;
      }
      throw err;
    }
  }

  public async listDocuments(queries?: string[]): Promise<Models.DocumentList<Schema & Document>> {
    return this.databases.listDocuments(this.databaseId, this.collectionId, queries);
  }

  public async listByProperties(properties: Partial<Schema>, extraQueries?: string[]): Promise<Models.DocumentList<Schema & Document>> {
    const queries: string[] = [];
    for (const [key, value] of Object.entries(properties)) {
      queries.push(Query.equal(key, value as QueryTypes));
    }
    if (extraQueries) {
      queries.push(...extraQueries);
    }
    return this.databases.listDocuments(this.databaseId, this.collectionId, queries);
  }

  public async createDocument(data: Schema, permissions?: string[]): Promise<Schema & Document> {
    const result = await this.databases.createDocument(this.databaseId, this.collectionId, ID.unique(), data, permissions);
    return result as Schema & Document;
  }

  public async createDocumentWithId(documentId: string, data: Schema, permissions?: string[]): Promise<Schema & Document> {
    const result = await this.databases.createDocument(this.databaseId, this.collectionId, documentId, data, permissions);
    return result as Schema & Document;
  }

  public async getDocument(documentId: string, queries?: string[]): Promise<(Schema & Document) | null> {
    try {
      return await this.databases.getDocument<Schema & Document>(this.databaseId, this.collectionId, documentId, queries);
    } catch (err: any) {
      if (err.code === NOT_FOUND_STATUS_CODE) {
        return null;
      }
      throw err;
    }
  }

  public async getDocumentBy(query: Partial<Schema & Document>): Promise<Schema & Document | null> {
    const queryList: string[] = [];
    for (const [key, value] of Object.entries(query)) {
      queryList.push(Query.equal(key, value as QueryTypes));
    }
    queryList.push(Query.limit(1));

    const { documents } = await this.databases.listDocuments<Schema & Document>(this.databaseId, this.collectionId, queryList);
    return documents.length ? documents[0] : null;
  }

  public async updateDocument(documentId: string, data?: Partial<Schema>, permissions?: string[]): Promise<Schema & Document> {
    const result = await this.databases.updateDocument(this.databaseId, this.collectionId, documentId, data, permissions);
    return result as Schema & Document;
  }

  public async deleteDocument(documentId: string): Promise<void> {
    await this.databases.deleteDocument(this.databaseId, this.collectionId, documentId);
  }

  public async createOrUpdateDocument(query: Partial<Schema & Document>, data: Schema, permissions?: string[]): Promise<Schema & Document> {
    const existingDocument = await this.getDocumentBy(query);
    if (existingDocument) {
      return this.updateDocument(existingDocument.$id, data, permissions);
    }
    return this.createDocument(data, permissions);
  }

  public async createOrUpdateBooleanAttribute(key: string, required: boolean, xdefault?: boolean, array?: boolean): Promise<void> {
    const exists = await this.attributeExists(key);
    if (exists) {
      await this.databases.updateBooleanAttribute(this.databaseId, this.collectionId, key, required, xdefault ?? null as any);
    } else {
      await this.databases.createBooleanAttribute(this.databaseId, this.collectionId, key, required, xdefault, array);
    }
  }

  public async createOrUpdateStringAttribute(key: string, size: number, required: boolean, xdefault?: string, array?: boolean, encrypt?: boolean): Promise<void> {
    const exists = await this.attributeExists(key);
    if (exists) {
      await this.databases.updateStringAttribute(this.databaseId, this.collectionId, key, required, xdefault ?? null as any);
    } else {
      await this.databases.createStringAttribute(this.databaseId, this.collectionId, key, size, required, xdefault, array, encrypt);
    }
  }

  public async createOrUpdateDateTimeAttribute(key: string, required: boolean, xdefault?: string, array?: boolean): Promise<void> {
    const exists = await this.attributeExists(key);
    if (exists) {
      await this.databases.updateDatetimeAttribute(this.databaseId, this.collectionId, key, required, xdefault ?? null as any);
    } else {
      await this.databases.createDatetimeAttribute(this.databaseId, this.collectionId, key, required, xdefault, array);
    }
  }

  public async createOrUpdateEmailAttribute(key: string, required: boolean, xdefault?: string, array?: boolean): Promise<void> {
    const exists = await this.attributeExists(key);
    if (exists) {
      await this.databases.updateEmailAttribute(this.databaseId, this.collectionId, key, required, xdefault ?? null as any);
    } else {
      await this.databases.createEmailAttribute(this.databaseId, this.collectionId, key, required, xdefault, array);
    }
  }

  public async createOrUpdateEnumAttribute(key: string, elements: string[], required: boolean, xdefault?: string, array?: boolean): Promise<void> {
    const exists = await this.attributeExists(key);
    if (exists) {
      await this.databases.updateEnumAttribute(this.databaseId, this.collectionId, key, elements, required, xdefault ?? null as any);
    } else {
      await this.databases.createEnumAttribute(this.databaseId, this.collectionId, key, elements, required, xdefault, array);
    }
  }

  public async createOrUpdateFloatAttribute(key: string, required: boolean, min?: number, max?: number, xdefault?: number, array?: boolean): Promise<void> {
    const exists = await this.attributeExists(key);
    if (exists) {
      await this.databases.updateFloatAttribute(this.databaseId, this.collectionId, key, required, min ?? null as any, max ?? null as any, xdefault ?? null as any);
    } else {
      await this.databases.createFloatAttribute(this.databaseId, this.collectionId, key, required, min, max, xdefault, array);
    }
  }

  public async createOrUpdateIntegerAttribute(key: string, required: boolean, min?: number, max?: number, xdefault?: number): Promise<void> {
    const exists = await this.attributeExists(key);
    if (exists) {
      await this.databases.updateIntegerAttribute(this.databaseId, this.collectionId, key, required, min ?? null as any, max ?? null as any, xdefault ?? null as any);
    } else {
      await this.databases.createIntegerAttribute(this.databaseId, this.collectionId, key, required, min, max, xdefault);
    }
  }

  public async createOrUpdateIndex(key: string, type: IndexType, attributes: string[], orders?: string[]): Promise<Models.Index> {
    try {
      return await this.databases.getIndex(this.databaseId, this.collectionId, key);
    } catch (err: any) {
      if (err.code === NOT_FOUND_STATUS_CODE) {
        return await this.databases.createIndex(this.databaseId, this.collectionId, key, type, attributes, orders);
      }
      console.error(err);
      return await this.databases.createIndex(this.databaseId, this.collectionId, key, type, attributes, orders);
    }
  }

  public async getIndex(key: string): Promise<Models.Index> {
    return this.databases.getIndex(this.databaseId, this.collectionId, key);
  }

  private async attributeExists(key: string): Promise<boolean> {
    try {
      await this.getAttribute(key);
    } catch (err: any) {
      if (err.code === NOT_FOUND_STATUS_CODE) {
        return false;
      }
    }
    return true;
  }

  private async getAttribute(key: string): Promise<any> {
    return this.databases.getAttribute(this.databaseId, this.collectionId, key);
  }

  public async deleteAttribute(key: string): Promise<void> {
    await this.databases.deleteAttribute(this.databaseId, this.collectionId, key);
  }

  public async getCollection(): Promise<Models.Collection> {
    return this.databases.getCollection(this.databaseId, this.collectionId);
  }

  public async createOrUpdateCollection(name: string, permissions?: string[], documentSecurity?: boolean, enabled?: boolean): Promise<Models.Collection> {
    try {
      await this.databases.getCollection(this.databaseId, this.collectionId);
    } catch (err: any) {
      if (err.code === NOT_FOUND_STATUS_CODE) {
        return this.databases.createCollection(this.databaseId, this.collectionId, name, permissions, documentSecurity, enabled);
      }
      console.error(err);
    }
    return this.databases.updateCollection(this.databaseId, this.collectionId, name, permissions, documentSecurity, enabled);
  }

  public async hasCollectionPermission(client: ClientWithToken, permissionType: PermissionType): Promise<boolean> {
    const collection = await this.getCollection();
    return await this.hasPermission(client, collection.$permissions, permissionType);
  }

  public async hasDocumentPermission(client: ClientWithToken, document: Document, permissionType: PermissionType): Promise<boolean> {
    return this.hasPermission(client, document.$permissions, permissionType);
  }

  /*
    This function checks if the authenticated user has permissions to do a certain action based on a list of permissions
    Currently non-checked permissions are:
     - guests permissions, as these are only for guest users without a session.
     - label permissions, they are not part of the Node.js SDK
     - user status permissions
     - member (within team) permissions
  */
  private async hasPermission(client: ClientWithToken, permissionsToCheck: string[], permissionType: PermissionType): Promise<boolean> {
    const typeIsWritePermission = [PermissionType.Create, PermissionType.Delete, PermissionType.Update].includes(permissionType);
    if (typeIsWritePermission) {
      const allowedByWritePermission = permissionsToCheck.some(permission => permission === 'write("any")');
      if (allowedByWritePermission) {
        return true;
      }
    }

    const permissionsOfType = permissionsToCheck.filter((permission) => permission.startsWith(permissionType));
    const allowedByAnyPermission = permissionsOfType.some(permission => permission === `${permissionType}("any")`);
    if (allowedByAnyPermission) {
      return true;
    }

    const userId = client.decodedToken.userId;
    const allowedByUserPermission = permissionsOfType.some(permission => permission === `${permissionType}("user:${userId}")`);
    if (allowedByUserPermission) {
      return true;
    }

    const clientTeams = await this.findAllTeams(client);
    const teamPermissionsWithoutRole = permissionsOfType.filter(permission => permission.includes('team:'));
    const allowedByTeamPermission = teamPermissionsWithoutRole.some((teamPermission) => {
      return clientTeams.some(team => teamPermission === `${permissionType}("team:${team.$id}")`);
    });
    if (allowedByTeamPermission) {
      return true;
    }

    const teams = new Teams(this.client);
    const teamPermissionsWithRole = permissionsOfType.filter(permission => permission.startsWith(`${permissionType}("team:`) && permission.includes('/'));
    for (const rolePermission of teamPermissionsWithRole) {
      const teamId = this.getTeamIdFromTeamRolePermission(rolePermission);
      if (!teamId) {
        continue;
      }
      const memberships = (await teams.listMemberships(teamId, [Query.equal('userId', client.decodedToken.userId)])).memberships;
      const allMemberRoles = memberships.map(membership => membership.roles).flat();
      const hasRolePermission = allMemberRoles.some((role) => rolePermission === `${permissionType}("team:${teamId}/${role}")`);
      if (hasRolePermission) {
        return true;
      }
    }

    return false;
  }

  private getTeamIdFromTeamRolePermission(permission: string): string | null {
    const teamSearch = 'team:';

    const startIndex = permission.indexOf(teamSearch) + teamSearch.length;
    const endIndex = permission.indexOf('/');

    if (startIndex >= 5 && endIndex !== -1) {
      return permission.substring(startIndex, endIndex);
    } else {
      return null;
    }
  }

  private async findAllTeams(client: ClientWithToken): Promise<Models.Team<{}>[]> {
    const teams = new Teams(client);
    return (await teams.list<{}>([Query.limit(MAX_PAGE_SIZE)])).teams;
  }

  public asAppClient(client: ClientWithToken): AppwriteRepository<Schema> {
    return new AppwriteRepository<Schema>(client, this.databaseId, this.collectionId);
  }
}
