import { mock } from "jest-mock-extended";
import { of } from "rxjs";

import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserKey } from "@bitwarden/common/types/key";
import { Fido2Credential } from "@bitwarden/common/vault/models/domain/fido2-credential";
import {
  Fido2Credential as SdkFido2Credential,
  Cipher as SdkCipher,
  CipherType as SdkCipherType,
  CipherView as SdkCipherView,
  CipherListView,
  AttachmentView as SdkAttachmentView,
  Fido2CredentialFullView,
} from "@bitwarden/sdk-internal";

import { mockEnc } from "../../../spec";
import { UriMatchStrategy } from "../../models/domain/domain-service";
import { LogService } from "../../platform/abstractions/log.service";
import { SdkService } from "../../platform/abstractions/sdk/sdk.service";
import { UserId, CipherId, OrganizationId } from "../../types/guid";
import { CipherRepromptType, CipherType } from "../enums";
import { CipherPermissionsApi } from "../models/api/cipher-permissions.api";
import { CipherData } from "../models/data/cipher.data";
import { Cipher } from "../models/domain/cipher";
import { AttachmentView } from "../models/view/attachment.view";
import { CipherView } from "../models/view/cipher.view";
import { Fido2CredentialView } from "../models/view/fido2-credential.view";

import { DefaultCipherEncryptionService } from "./default-cipher-encryption.service";

const cipherId = "bdc4ef23-1116-477e-ae73-247854af58cb" as CipherId;
const orgId = "c5e9654f-6cc5-44c4-8e09-3d323522668c" as OrganizationId;
const folderId = "a3e9654f-6cc5-44c4-8e09-3d323522668c";
const userId = "59fbbb44-8cc8-4279-ab40-afc5f68704f4" as UserId;

const cipherData: CipherData = {
  id: cipherId,
  organizationId: orgId,
  folderId: folderId,
  edit: true,
  viewPassword: true,
  organizationUseTotp: true,
  favorite: false,
  revisionDate: "2022-01-31T12:00:00.000Z",
  type: CipherType.Login,
  name: "EncryptedString",
  notes: "EncryptedString",
  creationDate: "2022-01-01T12:00:00.000Z",
  deletedDate: undefined,
  archivedDate: undefined,
  permissions: new CipherPermissionsApi(),
  key: "EncKey",
  reprompt: CipherRepromptType.None,
  login: {
    uris: [
      { uri: "EncryptedString", uriChecksum: "EncryptedString", match: UriMatchStrategy.Domain },
    ],
    username: "EncryptedString",
    password: "EncryptedString",
    passwordRevisionDate: "2022-01-31T12:00:00.000Z",
    totp: "EncryptedString",
    autofillOnPageLoad: false,
  },
  passwordHistory: [{ password: "EncryptedString", lastUsedDate: "2022-01-31T12:00:00.000Z" }],
  attachments: [
    {
      id: "a1",
      url: "url",
      size: "1100",
      sizeName: "1.1 KB",
      fileName: "file",
      key: "EncKey",
    },
    {
      id: "a2",
      url: "url",
      size: "1100",
      sizeName: "1.1 KB",
      fileName: "file",
      key: "EncKey",
    },
  ],
};

describe("DefaultCipherEncryptionService", () => {
  let cipherEncryptionService: DefaultCipherEncryptionService;
  const sdkService = mock<SdkService>();
  const logService = mock<LogService>();
  let sdkCipherView: SdkCipherView;
  let sdkCipher: SdkCipher;

  const mockSdkClient = {
    vault: jest.fn().mockReturnValue({
      ciphers: jest.fn().mockReturnValue({
        encrypt: jest.fn(),
        encrypt_cipher_for_rotation: jest.fn(),
        set_fido2_credentials: jest.fn(),
        decrypt: jest.fn(),
        decrypt_list: jest.fn(),
        decrypt_list_with_failures: jest.fn(),
        decrypt_fido2_credentials: jest.fn(),
        move_to_organization: jest.fn(),
      }),
      attachments: jest.fn().mockReturnValue({
        decrypt_buffer: jest.fn(),
      }),
    }),
  };
  const mockRef = {
    value: mockSdkClient,
    [Symbol.dispose]: jest.fn(),
  };
  const mockSdk = {
    take: jest.fn().mockReturnValue(mockRef),
  };

  let cipherObj: Cipher;
  let cipherViewObj: CipherView;

  beforeEach(() => {
    sdkService.userClient$ = jest.fn((userId: UserId) => of(mockSdk)) as any;
    cipherEncryptionService = new DefaultCipherEncryptionService(sdkService, logService);
    cipherObj = new Cipher(cipherData);
    cipherViewObj = new CipherView(cipherObj);

    jest.spyOn(cipherObj, "toSdkCipher").mockImplementation(() => {
      return { id: cipherData.id as any } as SdkCipher;
    });

    jest.spyOn(cipherViewObj, "toSdkCipherView").mockImplementation(() => {
      return { id: cipherData.id as any } as SdkCipherView;
    });

    sdkCipherView = {
      id: cipherId as any,
      type: SdkCipherType.Login,
      name: "test-name",
      login: {
        username: "test-username",
        password: "test-password",
      },
    } as SdkCipherView;

    sdkCipher = {
      id: cipherId,
      type: SdkCipherType.Login,
      name: "encrypted-name",
      login: {
        username: "encrypted-username",
        password: "encrypted-password",
      },
    } as unknown as SdkCipher;
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  describe("encrypt", () => {
    it("should encrypt a cipher successfully", async () => {
      const expectedCipher: Cipher = {
        id: cipherId as string,
        type: CipherType.Login,
        name: "encrypted-name",
        login: {
          username: "encrypted-username",
          password: "encrypted-password",
        },
      } as unknown as Cipher;

      mockSdkClient.vault().ciphers().encrypt.mockReturnValue({
        cipher: sdkCipher,
        encryptedFor: userId,
      });
      jest.spyOn(Cipher, "fromSdkCipher").mockReturnValue(expectedCipher);

      const result = await cipherEncryptionService.encrypt(cipherViewObj, userId);

      expect(result).toBeDefined();
      expect(result!.cipher).toEqual(expectedCipher);
      expect(result!.encryptedFor).toBe(userId);
      expect(cipherViewObj.toSdkCipherView).toHaveBeenCalled();
      expect(mockSdkClient.vault().ciphers().encrypt).toHaveBeenCalledWith({ id: cipherData.id });
    });

    it("should encrypt FIDO2 credentials if present", async () => {
      const fidoCredentialView = new Fido2CredentialView();
      fidoCredentialView.credentialId = "credentialId";

      cipherViewObj.login.fido2Credentials = [fidoCredentialView];

      jest.spyOn(fidoCredentialView, "toSdkFido2CredentialFullView").mockImplementation(
        () =>
          ({
            credentialId: "credentialId",
          }) as Fido2CredentialFullView,
      );
      jest.spyOn(cipherViewObj, "toSdkCipherView").mockImplementation(
        () =>
          ({
            id: cipherId as string,
            login: {
              fido2Credentials: undefined,
            },
          }) as unknown as SdkCipherView,
      );

      mockSdkClient
        .vault()
        .ciphers()
        .set_fido2_credentials.mockReturnValue({
          id: cipherId as string,
          login: {
            fido2Credentials: [
              {
                credentialId: "encrypted-credentialId",
              },
            ],
          },
        });

      mockSdkClient.vault().ciphers().encrypt.mockReturnValue({
        cipher: sdkCipher,
        encryptedFor: userId,
      });

      cipherObj.login!.fido2Credentials = [
        { credentialId: "encrypted-credentialId" } as unknown as Fido2Credential,
      ];

      jest.spyOn(Cipher, "fromSdkCipher").mockReturnValue(cipherObj);

      const result = await cipherEncryptionService.encrypt(cipherViewObj, userId);

      expect(result).toBeDefined();
      expect(result!.cipher.login!.fido2Credentials).toHaveLength(1);

      // Ensure set_fido2_credentials was called with correct parameters
      expect(mockSdkClient.vault().ciphers().set_fido2_credentials).toHaveBeenCalledWith(
        expect.objectContaining({ id: cipherId }),
        [{ credentialId: "credentialId" }],
      );

      // Encrypted fido2 credential should be in the cipher passed to encrypt
      expect(mockSdkClient.vault().ciphers().encrypt).toHaveBeenCalledWith(
        expect.objectContaining({
          id: cipherId,
          login: { fido2Credentials: [{ credentialId: "encrypted-credentialId" }] },
        }),
      );
    });
  });

  describe("encryptCipherForRotation", () => {
    it("should call the sdk method to encrypt the cipher with a new key for rotation", async () => {
      mockSdkClient.vault().ciphers().encrypt_cipher_for_rotation.mockReturnValue({
        cipher: sdkCipher,
        encryptedFor: userId,
      });

      const newUserKey: UserKey = new SymmetricCryptoKey(
        Utils.fromUtf8ToArray("00000000000000000000000000000000"),
      ) as UserKey;

      const result = await cipherEncryptionService.encryptCipherForRotation(
        cipherViewObj,
        userId,
        newUserKey,
      );

      expect(result).toBeDefined();
      expect(mockSdkClient.vault().ciphers().encrypt_cipher_for_rotation).toHaveBeenCalledWith(
        expect.objectContaining({ id: cipherId }),
        newUserKey.toBase64(),
      );
    });
  });

  describe("moveToOrganization", () => {
    it("should call the sdk method to move a cipher to an organization", async () => {
      const expectedCipher: Cipher = {
        id: cipherId as string,
        type: CipherType.Login,
        name: "encrypted-name",
        organizationId: orgId,
        login: {
          username: "encrypted-username",
          password: "encrypted-password",
        },
      } as unknown as Cipher;

      mockSdkClient.vault().ciphers().move_to_organization.mockReturnValue({
        id: cipherId,
        organizationId: orgId,
      });
      mockSdkClient.vault().ciphers().encrypt.mockReturnValue({
        cipher: sdkCipher,
        encryptedFor: userId,
      });
      jest.spyOn(Cipher, "fromSdkCipher").mockReturnValue(expectedCipher);

      const result = await cipherEncryptionService.moveToOrganization(cipherViewObj, orgId, userId);

      expect(result).toBeDefined();
      expect(result!.cipher).toEqual(expectedCipher);
      expect(result!.encryptedFor).toBe(userId);
      expect(cipherViewObj.toSdkCipherView).toHaveBeenCalled();
      expect(mockSdkClient.vault().ciphers().move_to_organization).toHaveBeenCalledWith(
        { id: cipherData.id },
        orgId,
      );
    });

    it("should re-encrypt any fido2 credentials when moving to an organization", async () => {
      const mockSdkCredentialView = {
        username: "username",
      } as unknown as Fido2CredentialFullView;
      const mockCredentialView = mock<Fido2CredentialView>();
      mockCredentialView.toSdkFido2CredentialFullView.mockReturnValue(mockSdkCredentialView);
      cipherViewObj.login.fido2Credentials = [mockCredentialView];
      const expectedCipher: Cipher = {
        id: cipherId as string,
        type: CipherType.Login,
        name: "encrypted-name",
        organizationId: orgId,
        login: {
          username: "encrypted-username",
          password: "encrypted-password",
          fido2Credentials: [{ username: "encrypted-username" }],
        },
      } as unknown as Cipher;

      mockSdkClient
        .vault()
        .ciphers()
        .set_fido2_credentials.mockReturnValue({
          id: cipherId as any,
          login: {
            fido2Credentials: [mockSdkCredentialView],
          },
        } as SdkCipherView);
      mockSdkClient.vault().ciphers().move_to_organization.mockReturnValue({
        id: cipherId,
        organizationId: orgId,
      });
      mockSdkClient.vault().ciphers().encrypt.mockReturnValue({
        cipher: sdkCipher,
        encryptedFor: userId,
      });
      jest.spyOn(Cipher, "fromSdkCipher").mockReturnValue(expectedCipher);

      const result = await cipherEncryptionService.moveToOrganization(cipherViewObj, orgId, userId);

      expect(result).toBeDefined();
      expect(result!.cipher).toEqual(expectedCipher);
      expect(result!.encryptedFor).toBe(userId);
      expect(cipherViewObj.toSdkCipherView).toHaveBeenCalled();
      expect(mockSdkClient.vault().ciphers().set_fido2_credentials).toHaveBeenCalledWith(
        expect.objectContaining({ id: cipherId }),
        expect.arrayContaining([mockSdkCredentialView]),
      );
      expect(mockSdkClient.vault().ciphers().move_to_organization).toHaveBeenCalledWith(
        { id: cipherData.id, login: { fido2Credentials: [mockSdkCredentialView] } },
        orgId,
      );
    });
  });

  describe("decrypt", () => {
    it("should decrypt a cipher successfully", async () => {
      const expectedCipherView: CipherView = {
        id: cipherId as string,
        type: CipherType.Login,
        name: "test-name",
        login: {
          username: "test-username",
          password: "test-password",
        },
      } as CipherView;

      mockSdkClient.vault().ciphers().decrypt.mockReturnValue(sdkCipherView);
      jest.spyOn(CipherView, "fromSdkCipherView").mockReturnValue(expectedCipherView);

      const result = await cipherEncryptionService.decrypt(cipherObj, userId);

      expect(result).toEqual(expectedCipherView);
      expect(cipherObj.toSdkCipher).toHaveBeenCalledTimes(1);
      expect(mockSdkClient.vault().ciphers().decrypt).toHaveBeenCalledWith({ id: cipherData.id });
      expect(CipherView.fromSdkCipherView).toHaveBeenCalledWith(sdkCipherView);
      expect(mockSdkClient.vault().ciphers().decrypt_fido2_credentials).not.toHaveBeenCalled();
    });

    it("should decrypt FIDO2 credentials if present", async () => {
      const fido2Credentials = [
        {
          credentialId: mockEnc("credentialId"),
          keyType: mockEnc("keyType"),
          keyAlgorithm: mockEnc("keyAlgorithm"),
          keyCurve: mockEnc("keyCurve"),
          keyValue: mockEnc("keyValue"),
          rpId: mockEnc("rpId"),
          userHandle: mockEnc("userHandle"),
          userName: mockEnc("userName"),
          counter: mockEnc("2"),
          rpName: mockEnc("rpName"),
          userDisplayName: mockEnc("userDisplayName"),
          discoverable: mockEnc("true"),
          creationDate: new Date("2023-01-01T12:00:00.000Z"),
        },
      ] as unknown as SdkFido2Credential[];

      sdkCipherView.login!.fido2Credentials = fido2Credentials;

      const expectedCipherView: CipherView = {
        id: cipherId,
        type: CipherType.Login,
        name: "test-name",
        login: {
          username: "test-username",
          password: "test-password",
          fido2Credentials: [],
        },
      } as unknown as CipherView;

      const fido2CredentialView: Fido2CredentialView = {
        credentialId: "credentialId",
        keyType: "keyType",
        keyAlgorithm: "keyAlgorithm",
        keyCurve: "keyCurve",
        keyValue: "decrypted-key-value",
        rpId: "rpId",
        userHandle: "userHandle",
        userName: "userName",
        counter: 2,
        rpName: "rpName",
        userDisplayName: "userDisplayName",
        discoverable: true,
        creationDate: new Date("2023-01-01T12:00:00.000Z"),
      } as unknown as Fido2CredentialView;

      mockSdkClient.vault().ciphers().decrypt.mockReturnValue(sdkCipherView);
      mockSdkClient.vault().ciphers().decrypt_fido2_credentials.mockReturnValue(fido2Credentials);
      mockSdkClient.vault().ciphers().decrypt_fido2_private_key = jest
        .fn()
        .mockReturnValue("decrypted-key-value");

      jest.spyOn(CipherView, "fromSdkCipherView").mockReturnValue(expectedCipherView);
      jest
        .spyOn(Fido2CredentialView, "fromSdkFido2CredentialView")
        .mockReturnValueOnce(fido2CredentialView);

      const result = await cipherEncryptionService.decrypt(cipherObj, userId);

      expect(result).toBe(expectedCipherView);
      expect(result.login?.fido2Credentials).toEqual([fido2CredentialView]);
      expect(mockSdkClient.vault().ciphers().decrypt_fido2_credentials).toHaveBeenCalledWith(
        sdkCipherView,
      );
      expect(mockSdkClient.vault().ciphers().decrypt_fido2_private_key).toHaveBeenCalledWith(
        sdkCipherView,
      );
      expect(Fido2CredentialView.fromSdkFido2CredentialView).toHaveBeenCalledTimes(1);
    });
  });

  describe("decryptManyLegacy", () => {
    it("should decrypt multiple ciphers successfully", async () => {
      const ciphers = [new Cipher(cipherData), new Cipher(cipherData)];

      const cipherId2 = "bdc4ef23-2222-477e-ae73-247854af58cb" as CipherId;

      const expectedViews = [
        {
          id: cipherId as string,
          name: "test-name-1",
        } as CipherView,
        {
          id: cipherId2 as string,
          name: "test-name-2",
        } as CipherView,
      ];

      mockSdkClient
        .vault()
        .ciphers()
        .decrypt.mockReturnValueOnce({
          id: cipherId,
          name: "test-name-1",
        } as unknown as SdkCipherView)
        .mockReturnValueOnce({ id: cipherId2, name: "test-name-2" } as unknown as SdkCipherView);

      jest
        .spyOn(CipherView, "fromSdkCipherView")
        .mockReturnValueOnce(expectedViews[0])
        .mockReturnValueOnce(expectedViews[1]);

      const result = await cipherEncryptionService.decryptManyLegacy(ciphers, userId);

      expect(result).toEqual(expectedViews);
      expect(mockSdkClient.vault().ciphers().decrypt).toHaveBeenCalledTimes(2);
      expect(CipherView.fromSdkCipherView).toHaveBeenCalledTimes(2);
    });

    it("should throw EmptyError when SDK is not available", async () => {
      sdkService.userClient$ = jest.fn().mockReturnValue(of(null)) as any;

      await expect(
        cipherEncryptionService.decryptManyLegacy([cipherObj], userId),
      ).rejects.toThrow();

      expect(logService.error).toHaveBeenCalledWith(
        expect.stringContaining("Failed to decrypt ciphers"),
      );
    });
  });

  describe("decryptManyWithFailures", () => {
    const cipher1_id = "11111111-1111-1111-1111-111111111111";
    const cipher2_id = "22222222-2222-2222-2222-222222222222";
    it("should decrypt multiple ciphers and return successes and failures", async () => {
      const ciphers = [
        new Cipher({ ...cipherData, id: cipher1_id as CipherId }),
        new Cipher({ ...cipherData, id: cipher2_id as CipherId }),
      ];

      const successCipherList = {
        id: cipher1_id,
        name: "Decrypted Cipher 1",
      } as unknown as CipherListView;
      const failedCipher = { id: cipher2_id, name: "Failed Cipher" } as unknown as SdkCipher;

      const expectedFailedCiphers = [Cipher.fromSdkCipher(failedCipher)];

      const mockResult = {
        successes: [successCipherList],
        failures: [failedCipher],
      };

      mockSdkClient.vault().ciphers().decrypt_list_with_failures.mockReturnValue(mockResult);

      const result = await cipherEncryptionService.decryptManyWithFailures(ciphers, userId);

      expect(result).toEqual([[successCipherList], expectedFailedCiphers]);
      expect(mockSdkClient.vault().ciphers().decrypt_list_with_failures).toHaveBeenCalledWith(
        expect.arrayContaining([
          expect.objectContaining({ id: cipher1_id }),
          expect.objectContaining({ id: cipher2_id }),
        ]),
      );
      expect(Cipher.fromSdkCipher).toHaveBeenCalledWith(failedCipher);
    });
  });

  describe("decryptAttachmentContent", () => {
    it("should decrypt attachment content successfully", async () => {
      const cipher = new Cipher(cipherData);
      const attachment = new AttachmentView(cipher.attachments![0]);
      const encryptedContent = new Uint8Array([1, 2, 3, 4]);
      const expectedDecryptedContent = new Uint8Array([5, 6, 7, 8]);

      jest.spyOn(cipher, "toSdkCipher").mockReturnValue({ id: "id" as any } as SdkCipher);
      jest
        .spyOn(attachment, "toSdkAttachmentView")
        .mockReturnValue({ id: "a1" } as SdkAttachmentView);
      mockSdkClient.vault().attachments().decrypt_buffer.mockReturnValue(expectedDecryptedContent);

      const result = await cipherEncryptionService.decryptAttachmentContent(
        cipher,
        attachment,
        encryptedContent,
        userId,
      );

      expect(result).toEqual(expectedDecryptedContent);
      expect(cipher.toSdkCipher).toHaveBeenCalled();
      expect(attachment.toSdkAttachmentView).toHaveBeenCalled();
      expect(mockSdkClient.vault().attachments().decrypt_buffer).toHaveBeenCalledWith(
        { id: "id" },
        { id: "a1" },
        encryptedContent,
      );
    });
  });
});
