import {
  catchError,
  combineLatest,
  delayWhen,
  filter,
  firstValueFrom,
  from,
  ignoreElements,
  map,
  Observable,
  of,
  shareReplay,
  switchMap,
} from "rxjs";

import {
  CollectionView,
  Collection,
  CollectionData,
} from "@bitwarden/common/admin-console/models/collections";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { KeyService } from "@bitwarden/key-management";

import { CollectionEncryptionService } from "../abstractions/collection-encryption.service";
import { CollectionService } from "../abstractions/collection.service";

import { DECRYPTED_COLLECTION_DATA_KEY, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state";

const NestingDelimiter = "/";

export class DefaultCollectionService implements CollectionService {
  constructor(
    private keyService: KeyService,
    private encryptService: EncryptService,
    private i18nService: I18nService,
    protected stateProvider: StateProvider,
    private collectionEncryptionService: CollectionEncryptionService,
  ) {}

  private collectionViewCache = new Map<UserId, Observable<CollectionView[]>>();

  /**
   * @returns a SingleUserState for encrypted collection data.
   */
  private encryptedState(userId: UserId): SingleUserState<Record<CollectionId, CollectionData>> {
    return this.stateProvider.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY);
  }

  /**
   * @returns a SingleUserState for decrypted collection data.
   */
  private decryptedState(userId: UserId): SingleUserState<CollectionView[] | null> {
    return this.stateProvider.getUser(userId, DECRYPTED_COLLECTION_DATA_KEY);
  }

  encryptedCollections$(userId: UserId): Observable<Collection[] | null> {
    return this.encryptedState(userId).state$.pipe(
      map((collections) => {
        if (collections == null) {
          return null;
        }

        return Object.values(collections).map((c) => Collection.fromCollectionData(c));
      }),
    );
  }

  decryptedCollections$(userId: UserId): Observable<CollectionView[]> {
    const cachedResult = this.collectionViewCache.get(userId);
    if (cachedResult) {
      return cachedResult;
    }

    const result$ = this.decryptedState(userId).state$.pipe(
      switchMap((decryptedState) => {
        // If decrypted state is already populated, return that. A persisted empty array is only
        // ever written by a successful decryption (failed decryptions are not cached - see
        // initializeDecryptedState), so an empty cached value is a valid empty vault and is served
        // as-is rather than re-decrypted.
        if (decryptedState !== null) {
          return of(decryptedState ?? []);
        }

        return this.initializeDecryptedState(userId);
      }),
      shareReplay({ bufferSize: 1, refCount: true }),
    );

    this.collectionViewCache.set(userId, result$);
    return result$;
  }

  defaultUserCollection$(
    userId: UserId,
    orgId: OrganizationId,
  ): Observable<CollectionView | undefined> {
    return this.decryptedCollections$(userId).pipe(
      map((collections) => {
        return collections.find((c) => c.isDefaultCollection && c.organizationId === orgId);
      }),
    );
  }

  private initializeDecryptedState(userId: UserId): Observable<CollectionView[]> {
    return combineLatest([
      this.encryptedCollections$(userId),
      this.keyService.orgKeys$(userId).pipe(filter((orgKeys) => !!orgKeys)),
    ]).pipe(
      switchMap(([collections]) =>
        from(this.collectionEncryptionService.decryptMany(collections ?? [], userId)).pipe(
          map((views) => views.sort(Utils.getSortFunction(this.i18nService, "name"))),
          // Cache successful decryptions (delayWhen only runs on emitted values, so a failure
          // is never cached), then drop this emission - the value is delivered to subscribers
          // when the cache re-emits, which avoids emitting the same value twice.
          delayWhen((decrypted: CollectionView[]) =>
            this.setDecryptedCollections(decrypted, userId),
          ),
          ignoreElements(),
          // A failed batch emits an empty list without caching it, so decryption is retried
          // on the next input emission rather than serving a stale empty list.
          catchError(() => {
            return of([]);
          }),
        ),
      ),
    );
  }

  async upsert(toUpdate: CollectionData, userId: UserId): Promise<void> {
    if (toUpdate == null) {
      return;
    }
    await this.encryptedState(userId).update((collections) => {
      if (collections == null) {
        collections = {};
      }

      collections[toUpdate.id] = toUpdate;
      return collections;
    });

    const decryptedCollections = await this.collectionEncryptionService.decryptMany(
      [Collection.fromCollectionData(toUpdate)],
      userId,
    );

    await this.decryptedState(userId).update((collections) => {
      if (collections == null) {
        collections = [];
      }

      if (!decryptedCollections?.length) {
        return collections;
      }

      const decryptedCollection = decryptedCollections[0];
      const existingIndex = collections.findIndex((collection) => collection.id == toUpdate.id);
      if (existingIndex >= 0) {
        collections[existingIndex] = decryptedCollection;
      } else {
        collections.push(decryptedCollection);
      }

      return collections;
    });
  }

  async replace(collections: Record<CollectionId, CollectionData>, userId: UserId): Promise<void> {
    await this.encryptedState(userId).update(() => collections);
    await this.decryptedState(userId).update(() => null);
  }

  async delete(ids: CollectionId[], userId: UserId): Promise<any> {
    await this.encryptedState(userId).update((collections) => {
      if (collections == null) {
        collections = {};
      }
      ids.forEach((i) => {
        delete collections[i];
      });
      return collections;
    });

    await this.decryptedState(userId).update((collections) => {
      if (collections == null) {
        collections = [];
      }
      ids.forEach((i) => {
        if (collections?.length) {
          collections = collections.filter((c) => c.id != i) ?? [];
        }
      });
      return collections;
    });
  }

  async encrypt(model: CollectionView, userId: UserId): Promise<Collection> {
    const key = await firstValueFrom(
      this.keyService.orgKeys$(userId).pipe(
        filter((orgKeys) => !!orgKeys),
        map((k) => k[model.organizationId as OrganizationId]),
      ),
    );

    return await model.encrypt(key, this.encryptService);
  }

  // TODO: this should be private.
  // See https://bitwarden.atlassian.net/browse/PM-12375
  decryptMany$(
    collections: Collection[] | null,
    orgKeys: Record<OrganizationId, OrgKey>,
  ): Observable<CollectionView[]> {
    if (collections === null || collections.length == 0 || orgKeys === null) {
      return of([]);
    }

    const decCollections: Observable<CollectionView>[] = [];

    collections.forEach((collection) => {
      decCollections.push(
        from(
          collection.decrypt(
            orgKeys[collection.organizationId as OrganizationId],
            this.encryptService,
          ),
        ),
      );
    });

    return combineLatest(decCollections).pipe(
      map((collections) => collections.sort(Utils.getSortFunction(this.i18nService, "name"))),
    );
  }

  // Transforms the input CollectionViews into TreeNodes
  getAllNested(collections: CollectionView[]): TreeNode<CollectionView>[] {
    const groupedByOrg = this.groupByOrganization(collections);

    const all: TreeNode<CollectionView>[] = [];
    for (const group of groupedByOrg.values()) {
      const nodes: TreeNode<CollectionView>[] = [];
      for (const c of group) {
        const collectionCopy = Object.assign(new CollectionView({ ...c, name: c.name }), c);
        const parts = c.name ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
        ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, undefined, NestingDelimiter);
      }
      all.push(...nodes);
    }
    return all;
  }

  groupByOrganization(collections: CollectionView[]): Map<OrganizationId, CollectionView[]> {
    const groupedByOrg = new Map<OrganizationId, CollectionView[]>();
    collections.map((c) => {
      const key = c.organizationId;
      (groupedByOrg.get(key) ?? groupedByOrg.set(key, []).get(key)!).push(c);
    });
    return groupedByOrg;
  }

  /**
   * @deprecated August 30 2022: Moved to new Vault Filter Service
   * Remove when Desktop and Browser are updated
   */
  getNested(collections: CollectionView[], id: string): TreeNode<CollectionView> {
    const nestedCollections = this.getAllNested(collections);
    return ServiceUtils.getTreeNodeObjectFromList(
      nestedCollections,
      id,
    ) as TreeNode<CollectionView>;
  }

  /**
   * Sets the decrypted collections state for a user.
   * @param collections the decrypted collections
   * @param userId the user id
   */
  private async setDecryptedCollections(
    collections: CollectionView[],
    userId: UserId,
  ): Promise<void> {
    await this.stateProvider.setUserState(DECRYPTED_COLLECTION_DATA_KEY, collections, userId);
  }
}
