import { mock } from "jest-mock-extended";
import { BehaviorSubject, filter, firstValueFrom, map, of } from "rxjs";

import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CipherResponse } from "@bitwarden/common/vault/models/response/cipher.response";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CipherDecryptionKeys, KeyService } from "@bitwarden/key-management";
import { MessageSender } from "@bitwarden/messaging";
import { CipherListView } from "@bitwarden/sdk-internal";

import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
import { FakeStateProvider } from "../../../spec/fake-state-provider";
import { makeStaticByteArray, makeSymmetricCryptoKey } from "../../../spec/utils";
import { ApiService } from "../../abstractions/api.service";
import { AutofillSettingsService } from "../../autofill/services/autofill-settings.service";
import { DomainSettingsService } from "../../autofill/services/domain-settings.service";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
import { EncString } from "../../key-management/crypto/models/enc-string";
import { UriMatchStrategy } from "../../models/domain/domain-service";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { Utils } from "../../platform/misc/utils";
import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { ContainerService } from "../../platform/services/container.service";
import { CipherId, UserId, OrganizationId, CollectionId } from "../../types/guid";
import { CipherKey, OrgKey, UserKey } from "../../types/key";
import { CipherEncryptionService } from "../abstractions/cipher-encryption.service";
import { CipherSdkService } from "../abstractions/cipher-sdk.service";
import { EncryptionContext } from "../abstractions/cipher.service";
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
import { SearchService } from "../abstractions/search.service";
import { FieldType } from "../enums";
import { CipherRepromptType } from "../enums/cipher-reprompt-type";
import { CipherType } from "../enums/cipher-type";
import { CipherPermissionsApi } from "../models/api/cipher-permissions.api";
import { CipherData } from "../models/data/cipher.data";
import { Cipher } from "../models/domain/cipher";
import { CipherCreateRequest } from "../models/request/cipher-create.request";
import { CipherPartialRequest } from "../models/request/cipher-partial.request";
import { CipherRequest } from "../models/request/cipher.request";
import { AttachmentView } from "../models/view/attachment.view";
import { CipherView } from "../models/view/cipher.view";
import { LoginUriView } from "../models/view/login-uri.view";

import { CipherService } from "./cipher.service";
import { ENCRYPTED_CIPHERS } from "./key-state/ciphers.state";

const ENCRYPTED_TEXT = "This data has been encrypted";
function encryptText(clearText: string | Uint8Array) {
  return Promise.resolve(new EncString(`${clearText} has been encrypted`));
}
const ENCRYPTED_BYTES = mock<EncArrayBuffer>();

const cipherData: CipherData = {
  id: "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId,
  organizationId: "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b21" as OrganizationId,
  folderId: "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23",
  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: null,
  permissions: new CipherPermissionsApi(),
  key: "EncKey",
  archivedDate: null,
  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" },
  ],
  fields: [
    { name: "EncryptedString", value: "EncryptedString", type: FieldType.Text, linkedId: null },
    { name: "EncryptedString", value: "EncryptedString", type: FieldType.Hidden, linkedId: null },
  ],
};
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;

describe("Cipher Service", () => {
  const keyService = mock<KeyService>();
  const autofillSettingsService = mock<AutofillSettingsService>();
  const domainSettingsService = mock<DomainSettingsService>();
  const apiService = mock<ApiService>();
  const cipherFileUploadService = mock<CipherFileUploadService>();
  const i18nService = mock<I18nService>();
  const searchService = mock<SearchService>();
  const encryptService = mock<EncryptService>();
  const configService = mock<ConfigService>();
  accountService = mockAccountServiceWith(mockUserId);
  const logService = mock<LogService>();
  const stateProvider = new FakeStateProvider(accountService);
  const cipherEncryptionService = mock<CipherEncryptionService>();
  const messageSender = mock<MessageSender>();
  const cipherSdkService = mock<CipherSdkService>();

  const userId = "TestUserId" as UserId;
  const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b21" as OrganizationId;

  let cipherService: CipherService;
  let encryptionContext: EncryptionContext;
  // BehaviorSubject for SDK feature flag - allows tests to change the value after service instantiation
  let sdkCrudFeatureFlag$: BehaviorSubject<boolean>;

  beforeEach(() => {
    encryptService.encryptFileData.mockReturnValue(Promise.resolve(ENCRYPTED_BYTES));
    encryptService.encryptString.mockReturnValue(Promise.resolve(new EncString(ENCRYPTED_TEXT)));
    keyService.orgKeys$.mockReturnValue(of({ [orgId]: makeSymmetricCryptoKey(32) as OrgKey }));
    keyService.userKey$.mockReturnValue(of(makeSymmetricCryptoKey(64) as UserKey));

    // Mock i18nService collator
    i18nService.collator = {
      compare: jest.fn().mockImplementation((a: string, b: string) => a.localeCompare(b)),
      resolvedOptions: jest.fn().mockReturnValue({}),
    } as any;

    (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);

    // Create BehaviorSubject for SDK feature flag - tests can update this to change behavior
    sdkCrudFeatureFlag$ = new BehaviorSubject<boolean>(false);
    configService.getFeatureFlag$.mockReturnValue(sdkCrudFeatureFlag$.asObservable());

    cipherService = new CipherService(
      keyService,
      domainSettingsService,
      apiService,
      i18nService,
      searchService,
      autofillSettingsService,
      encryptService,
      cipherFileUploadService,
      configService,
      stateProvider,
      accountService,
      logService,
      cipherEncryptionService,
      messageSender,
      cipherSdkService,
    );

    encryptionContext = { cipher: new Cipher(cipherData), encryptedFor: userId };
  });

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

  describe("saveAttachmentRawWithServer()", () => {
    it("should upload encrypted file contents with save attachments", async () => {
      const fileName = "filename";
      const fileData = new Uint8Array(10);
      keyService.getOrgKey.mockReturnValue(
        Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
      );
      keyService.makeDataEncKey.mockReturnValue(
        Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32))),
      );

      configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(false));
      configService.getFeatureFlag
        .calledWith(FeatureFlag.CipherKeyEncryption)
        .mockResolvedValue(false);

      const spy = jest.spyOn(cipherFileUploadService, "upload");

      await cipherService.saveAttachmentRawWithServer(new Cipher(), fileName, fileData, userId);

      expect(spy).toHaveBeenCalled();
    });

    it("should include lastKnownRevisionDate in the upload request", async () => {
      const fileName = "filename";
      const fileData = new Uint8Array(10);
      const testCipher = new Cipher(cipherData);
      const expectedRevisionDate = "2022-01-31T12:00:00.000Z";

      keyService.makeDataEncKey.mockReturnValue(
        Promise.resolve([
          new SymmetricCryptoKey(new Uint8Array(32)),
          new EncString("encrypted-key"),
        ] as any),
      );

      configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(false));
      configService.getFeatureFlag
        .calledWith(FeatureFlag.CipherKeyEncryption)
        .mockResolvedValue(false);

      const uploadSpy = jest.spyOn(cipherFileUploadService, "upload").mockResolvedValue({} as any);

      await cipherService.saveAttachmentRawWithServer(testCipher, fileName, fileData, userId);

      // Verify upload was called with cipher that has revisionDate
      expect(uploadSpy).toHaveBeenCalled();
      const cipherArg = uploadSpy.mock.calls[0][0];
      expect(cipherArg.revisionDate).toEqual(new Date(expectedRevisionDate));
    });
  });

  describe("createWithServer()", () => {
    beforeEach(() => {
      jest.spyOn(cipherService, "encrypt").mockResolvedValue(encryptionContext);
      jest.spyOn(cipherService, "decrypt").mockImplementation(async (cipher) => {
        return new CipherView(cipher);
      });
    });

    it("should call apiService.postCipherAdmin when orgAdmin param is true and the cipher orgId != null", async () => {
      configService.getFeatureFlag
        .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
        .mockResolvedValue(false);
      const spy = jest
        .spyOn(apiService, "postCipherAdmin")
        .mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
      const cipherView = new CipherView(encryptionContext.cipher);
      await cipherService.createWithServer(cipherView, userId, true);
      const expectedObj = new CipherCreateRequest(encryptionContext);

      expect(spy).toHaveBeenCalled();
      expect(spy).toHaveBeenCalledWith(expectedObj);
    });

    it("should call apiService.postCipher when orgAdmin param is true and the cipher orgId is null", async () => {
      configService.getFeatureFlag
        .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
        .mockResolvedValue(false);
      encryptionContext.cipher.organizationId = null!;
      const spy = jest
        .spyOn(apiService, "postCipher")
        .mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
      const cipherView = new CipherView(encryptionContext.cipher);
      await cipherService.createWithServer(cipherView, userId, true);
      const expectedObj = new CipherRequest(encryptionContext);

      expect(spy).toHaveBeenCalled();
      expect(spy).toHaveBeenCalledWith(expectedObj);
    });

    it("should call apiService.postCipherCreate if collectionsIds != null", async () => {
      configService.getFeatureFlag
        .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
        .mockResolvedValue(false);
      encryptionContext.cipher.collectionIds = ["123"];
      const spy = jest
        .spyOn(apiService, "postCipherCreate")
        .mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
      const cipherView = new CipherView(encryptionContext.cipher);
      await cipherService.createWithServer(cipherView, userId);
      const expectedObj = new CipherCreateRequest(encryptionContext);

      expect(spy).toHaveBeenCalled();
      expect(spy).toHaveBeenCalledWith(expectedObj);
    });

    it("should call apiService.postCipher when orgAdmin and collectionIds logic is false", async () => {
      configService.getFeatureFlag
        .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
        .mockResolvedValue(false);
      const spy = jest
        .spyOn(apiService, "postCipher")
        .mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
      const cipherView = new CipherView(encryptionContext.cipher);
      await cipherService.createWithServer(cipherView, userId);
      const expectedObj = new CipherRequest(encryptionContext);

      expect(spy).toHaveBeenCalled();
      expect(spy).toHaveBeenCalledWith(expectedObj);
    });

    it("should delegate to cipherSdkService when feature flag is enabled", async () => {
      sdkCrudFeatureFlag$.next(true);

      const cipherView = new CipherView(encryptionContext.cipher);
      const expectedResult = new CipherView(encryptionContext.cipher);

      const cipherSdkServiceSpy = jest
        .spyOn(cipherSdkService, "createWithServer")
        .mockResolvedValue(expectedResult);

      const clearCacheSpy = jest.spyOn(cipherService, "clearCache");
      const apiSpy = jest.spyOn(apiService, "postCipher");

      const result = await cipherService.createWithServer(cipherView, userId);

      expect(cipherSdkServiceSpy).toHaveBeenCalledWith(cipherView, userId, undefined);
      expect(apiSpy).not.toHaveBeenCalled();
      expect(clearCacheSpy).toHaveBeenCalledWith(userId);
      expect(result).toBeInstanceOf(CipherView);
    });
  });

  describe("updateWithServer()", () => {
    beforeEach(() => {
      jest.spyOn(cipherService, "encrypt").mockResolvedValue(encryptionContext);
      jest.spyOn(cipherService, "decrypt").mockImplementation(async (cipher) => {
        return new CipherView(cipher);
      });
      jest.spyOn(cipherService, "upsert").mockResolvedValue({
        [cipherData.id as CipherId]: cipherData,
      });
    });

    it("should call apiService.putCipherAdmin when orgAdmin param is true", async () => {
      configService.getFeatureFlag$
        .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
        .mockReturnValue(of(false));

      const testCipher = new Cipher(cipherData);
      testCipher.organizationId = orgId;
      const testContext = { cipher: testCipher, encryptedFor: userId };
      jest.spyOn(cipherService, "encrypt").mockResolvedValue(testContext);

      const spy = jest
        .spyOn(apiService, "putCipherAdmin")
        .mockImplementation(() => Promise.resolve<any>(testCipher.toCipherData()));
      const cipherView = new CipherView(testCipher);
      await cipherService.updateWithServer(cipherView, userId, undefined, true);
      const expectedObj = new CipherRequest(testContext);

      expect(spy).toHaveBeenCalled();
      expect(spy).toHaveBeenCalledWith(testCipher.id, expectedObj);
    });

    it("should call apiService.putCipher if cipher.edit is true", async () => {
      configService.getFeatureFlag
        .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
        .mockResolvedValue(false);
      encryptionContext.cipher.edit = true;
      const spy = jest
        .spyOn(apiService, "putCipher")
        .mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
      const cipherView = new CipherView(encryptionContext.cipher);
      await cipherService.updateWithServer(cipherView, userId);
      const expectedObj = new CipherRequest(encryptionContext);

      expect(spy).toHaveBeenCalled();
      expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj);
    });

    it("should call apiService.putPartialCipher when orgAdmin, and edit are false", async () => {
      configService.getFeatureFlag
        .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
        .mockResolvedValue(false);
      encryptionContext.cipher.edit = false;
      const spy = jest
        .spyOn(apiService, "putPartialCipher")
        .mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
      const cipherView = new CipherView(encryptionContext.cipher);
      await cipherService.updateWithServer(cipherView, userId);
      const expectedObj = new CipherPartialRequest(encryptionContext.cipher);

      expect(spy).toHaveBeenCalled();
      expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj);
    });

    it("should delegate to cipherSdkService when feature flag is enabled", async () => {
      sdkCrudFeatureFlag$.next(true);

      const testCipher = new Cipher(cipherData);
      const cipherView = new CipherView(testCipher);
      const expectedResult = new CipherView(testCipher);

      const cipherSdkServiceSpy = jest
        .spyOn(cipherSdkService, "updateWithServer")
        .mockResolvedValue(expectedResult);

      const clearCacheSpy = jest.spyOn(cipherService, "clearCache");
      const apiSpy = jest.spyOn(apiService, "putCipher");

      const result = await cipherService.updateWithServer(cipherView, userId);

      expect(cipherSdkServiceSpy).toHaveBeenCalledWith(cipherView, userId, undefined, undefined);
      expect(apiSpy).not.toHaveBeenCalled();
      expect(clearCacheSpy).toHaveBeenCalledWith(userId);
      expect(result).toBeInstanceOf(CipherView);
    });

    it("should delegate to cipherSdkService with orgAdmin when feature flag is enabled", async () => {
      sdkCrudFeatureFlag$.next(true);

      const testCipher = new Cipher(cipherData);
      const cipherView = new CipherView(testCipher);
      const originalCipherView = new CipherView(testCipher);
      const expectedResult = new CipherView(testCipher);

      const cipherSdkServiceSpy = jest
        .spyOn(cipherSdkService, "updateWithServer")
        .mockResolvedValue(expectedResult);

      const clearCacheSpy = jest.spyOn(cipherService, "clearCache");
      const apiSpy = jest.spyOn(apiService, "putCipherAdmin");

      const result = await cipherService.updateWithServer(
        cipherView,
        userId,
        originalCipherView,
        true,
      );

      expect(cipherSdkServiceSpy).toHaveBeenCalledWith(
        cipherView,
        userId,
        originalCipherView,
        true,
      );
      expect(apiSpy).not.toHaveBeenCalled();
      expect(clearCacheSpy).toHaveBeenCalledWith(userId);
      expect(result).toBeInstanceOf(CipherView);
    });
  });

  describe("encrypt", () => {
    let cipherView: CipherView;

    beforeEach(() => {
      cipherView = new CipherView();
      cipherView.type = CipherType.Login;

      encryptService.unwrapSymmetricKey.mockResolvedValue(
        new SymmetricCryptoKey(makeStaticByteArray(64)),
      );
      configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(true));
      keyService.makeCipherKey.mockReturnValue(
        Promise.resolve(new SymmetricCryptoKey(makeStaticByteArray(64)) as CipherKey),
      );
      encryptService.encryptString.mockImplementation(encryptText);
      encryptService.wrapSymmetricKey.mockResolvedValue(new EncString("Re-encrypted Cipher Key"));

      jest.spyOn(cipherService as any, "getAutofillOnPageLoadDefault").mockResolvedValue(true);
    });

    it("should call encrypt method of CipherEncryptionService when feature flag is true", async () => {
      configService.getFeatureFlag
        .calledWith(FeatureFlag.PM22136_SdkCipherEncryption)
        .mockResolvedValue(true);
      cipherEncryptionService.encrypt.mockResolvedValue(encryptionContext);

      const result = await cipherService.encrypt(cipherView, userId);

      expect(result).toEqual(encryptionContext);
      expect(cipherEncryptionService.encrypt).toHaveBeenCalledWith(cipherView, userId);
    });

    it("should call legacy encrypt when feature flag is false", async () => {
      configService.getFeatureFlag
        .calledWith(FeatureFlag.PM22136_SdkCipherEncryption)
        .mockResolvedValue(false);

      jest.spyOn(cipherService as any, "encryptCipher").mockResolvedValue(encryptionContext.cipher);

      const result = await cipherService.encrypt(cipherView, userId);

      expect(result).toEqual(encryptionContext);
      expect(cipherEncryptionService.encrypt).not.toHaveBeenCalled();
    });

    it("should call legacy encrypt when keys are provided", async () => {
      configService.getFeatureFlag
        .calledWith(FeatureFlag.PM22136_SdkCipherEncryption)
        .mockResolvedValue(true);

      jest.spyOn(cipherService as any, "encryptCipher").mockResolvedValue(encryptionContext.cipher);

      const encryptKey = new SymmetricCryptoKey(new Uint8Array(32));
      const decryptKey = new SymmetricCryptoKey(new Uint8Array(32));

      let result = await cipherService.encrypt(cipherView, userId, encryptKey);

      expect(result).toEqual(encryptionContext);
      expect(cipherEncryptionService.encrypt).not.toHaveBeenCalled();

      result = await cipherService.encrypt(cipherView, userId, undefined, decryptKey);
      expect(result).toEqual(encryptionContext);
      expect(cipherEncryptionService.encrypt).not.toHaveBeenCalled();

      result = await cipherService.encrypt(cipherView, userId, encryptKey, decryptKey);
      expect(result).toEqual(encryptionContext);
      expect(cipherEncryptionService.encrypt).not.toHaveBeenCalled();
    });

    it("should return the encrypting user id", async () => {
      keyService.getOrgKey.mockReturnValue(
        Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
      );

      const { encryptedFor } = await cipherService.encrypt(cipherView, userId);
      expect(encryptedFor).toEqual(userId);
    });

    describe("login encryption", () => {
      it("should add a uri hash to login uris", async () => {
        encryptService.hash.mockImplementation((value) => Promise.resolve(`${value} hash`));
        cipherView.login.uris = [
          { uri: "uri", match: UriMatchStrategy.RegularExpression } as LoginUriView,
        ];

        keyService.getOrgKey.mockReturnValue(
          Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
        );

        const { cipher } = await cipherService.encrypt(cipherView, userId);

        expect(cipher.login.uris).toEqual([
          {
            uri: new EncString("uri has been encrypted"),
            uriChecksum: new EncString("uri hash has been encrypted"),
            match: UriMatchStrategy.RegularExpression,
          },
        ]);
      });
    });

    describe("cipher.key", () => {
      beforeEach(() => {
        keyService.getOrgKey.mockReturnValue(
          Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
        );
      });

      it("is null when feature flag is false", async () => {
        configService.getFeatureFlag
          .calledWith(FeatureFlag.CipherKeyEncryption)
          .mockResolvedValue(false);
        const { cipher } = await cipherService.encrypt(cipherView, userId);

        expect(cipher.key).toBeNull();
      });

      describe("when feature flag is true", () => {
        beforeEach(() => {
          configService.getFeatureFlag
            .calledWith(FeatureFlag.CipherKeyEncryption)
            .mockResolvedValue(true);
        });

        it("is null when the cipher is not viewPassword", async () => {
          cipherView.viewPassword = false;

          const { cipher } = await cipherService.encrypt(cipherView, userId);

          expect(cipher.key).toBeNull();
        });

        it("is defined when the cipher is viewPassword", async () => {
          cipherView.viewPassword = true;

          const { cipher } = await cipherService.encrypt(cipherView, userId);

          expect(cipher.key).toBeDefined();
        });
      });
    });

    describe("encryptCipherForRotation", () => {
      beforeEach(() => {
        jest.spyOn<any, string>(cipherService, "encryptCipherWithCipherKey");
        keyService.getOrgKey.mockReturnValue(
          Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
        );
      });

      it("is not called when feature flag is false", async () => {
        configService.getFeatureFlag
          .calledWith(FeatureFlag.CipherKeyEncryption)
          .mockResolvedValue(false);

        await cipherService.encrypt(cipherView, userId);

        expect(cipherService["encryptCipherWithCipherKey"]).not.toHaveBeenCalled();
      });

      describe("when feature flag is true", () => {
        beforeEach(() => {
          configService.getFeatureFlag
            .calledWith(FeatureFlag.CipherKeyEncryption)
            .mockResolvedValue(true);
        });

        it("is called when cipher viewPassword is true", async () => {
          cipherView.viewPassword = true;

          await cipherService.encrypt(cipherView, userId);

          expect(cipherService["encryptCipherWithCipherKey"]).toHaveBeenCalled();
        });

        it("is not called when cipher viewPassword is false and original cipher has no key", async () => {
          cipherView.viewPassword = false;

          await cipherService.encrypt(cipherView, userId, undefined, undefined, new Cipher());

          expect(cipherService["encryptCipherWithCipherKey"]).not.toHaveBeenCalled();
        });

        it("is called when cipher viewPassword is false and original cipher has a key", async () => {
          cipherView.viewPassword = false;

          await cipherService.encrypt(
            cipherView,
            userId,
            undefined,
            undefined,
            encryptionContext.cipher,
          );

          expect(cipherService["encryptCipherWithCipherKey"]).toHaveBeenCalled();
        });
      });
    });
  });

  describe("getRotatedData", () => {
    const originalUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
    const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
    let decryptedCiphers: BehaviorSubject<Record<CipherId, CipherView>>;
    let failedCiphers: BehaviorSubject<CipherView[]>;
    let encryptedKey: EncString;

    beforeEach(() => {
      configService.getFeatureFlag
        .calledWith(FeatureFlag.CipherKeyEncryption)
        .mockResolvedValue(true);
      configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(true));

      searchService.indexedEntityId$.mockReturnValue(of(null));

      const keys = { userKey: originalUserKey } as CipherDecryptionKeys;
      keyService.cipherDecryptionKeys$.mockReturnValue(of(keys));

      const cipher1 = new CipherView(encryptionContext.cipher);
      cipher1.id = "Cipher 1" as CipherId;
      cipher1.organizationId = null;
      const cipher2 = new CipherView(encryptionContext.cipher);
      cipher2.id = "Cipher 2" as CipherId;
      cipher2.organizationId = null;

      decryptedCiphers = new BehaviorSubject({ [cipher1.id]: cipher1, [cipher2.id]: cipher2 });
      jest
        .spyOn(cipherService, "cipherViews$")
        .mockImplementation((userId: UserId) =>
          decryptedCiphers.pipe(map((ciphers) => Object.values(ciphers))),
        );

      failedCiphers = new BehaviorSubject<CipherView[]>([]);
      jest
        .spyOn(cipherService, "failedToDecryptCiphers$")
        .mockImplementation((userId: UserId) => failedCiphers);

      encryptService.unwrapSymmetricKey.mockResolvedValue(
        new SymmetricCryptoKey(new Uint8Array(32)),
      );
      encryptedKey = new EncString("Re-encrypted Cipher Key");
      encryptService.wrapSymmetricKey.mockResolvedValue(encryptedKey);

      keyService.makeCipherKey.mockResolvedValue(
        new SymmetricCryptoKey(new Uint8Array(32)) as CipherKey,
      );
    });

    it("returns re-encrypted user ciphers", async () => {
      const result = await cipherService.getRotatedData(originalUserKey, newUserKey, mockUserId);

      expect(result[0]).toMatchObject({ id: "Cipher 1", key: "Re-encrypted Cipher Key" });
      expect(result[1]).toMatchObject({ id: "Cipher 2", key: "Re-encrypted Cipher Key" });
    });

    it("throws if the original user key is null", async () => {
      await expect(cipherService.getRotatedData(null!, newUserKey, mockUserId)).rejects.toThrow(
        "Original user key is required to rotate ciphers",
      );
    });

    it("throws if the new user key is null", async () => {
      await expect(
        cipherService.getRotatedData(originalUserKey, null!, mockUserId),
      ).rejects.toThrow("New user key is required to rotate ciphers");
    });

    it("throws if the user has any failed to decrypt ciphers", async () => {
      const badCipher = new CipherView(encryptionContext.cipher);
      badCipher.id = "Cipher 3";
      badCipher.organizationId = null;
      badCipher.decryptionFailure = true;
      failedCiphers.next([badCipher]);
      await expect(
        cipherService.getRotatedData(originalUserKey, newUserKey, mockUserId),
      ).rejects.toThrow("Cannot rotate ciphers when decryption failures are present");
    });

    it("uses the sdk to re-encrypt ciphers when feature flag is enabled", async () => {
      configService.getFeatureFlag
        .calledWith(FeatureFlag.PM22136_SdkCipherEncryption)
        .mockResolvedValue(true);

      cipherEncryptionService.encryptCipherForRotation.mockResolvedValue({
        cipher: encryptionContext.cipher,
        encryptedFor: mockUserId,
      });

      const result = await cipherService.getRotatedData(originalUserKey, newUserKey, mockUserId);

      expect(result).toHaveLength(2);
      expect(cipherEncryptionService.encryptCipherForRotation).toHaveBeenCalledWith(
        expect.any(CipherView),
        mockUserId,
        newUserKey,
      );
    });

    it("sends overlay update when cipherViews$ emits", async () => {
      (cipherService.cipherViews$ as jest.Mock)?.mockRestore();

      const decryptedView = new CipherView(encryptionContext.cipher);
      jest.spyOn(cipherService, "getAllDecrypted").mockResolvedValue([decryptedView]);

      const sendSpy = jest.spyOn(messageSender, "send");

      await firstValueFrom(
        cipherService
          .cipherViews$(mockUserId)
          .pipe(filter((cipherViews): cipherViews is CipherView[] => cipherViews != null)),
      );
      expect(sendSpy).toHaveBeenCalledWith("updateOverlayCiphers");
      expect(sendSpy).toHaveBeenCalledTimes(1);
    });
  });

  describe("decrypt", () => {
    it("should call decrypt method of CipherEncryptionService when feature flag is true", async () => {
      configService.getFeatureFlag
        .calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk)
        .mockResolvedValue(true);
      cipherEncryptionService.decrypt.mockResolvedValue(new CipherView(encryptionContext.cipher));

      const result = await cipherService.decrypt(encryptionContext.cipher, userId);

      expect(result).toEqual(new CipherView(encryptionContext.cipher));
      expect(cipherEncryptionService.decrypt).toHaveBeenCalledWith(
        encryptionContext.cipher,
        userId,
      );
    });

    it("should call legacy decrypt when feature flag is false", async () => {
      const mockUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
      configService.getFeatureFlag
        .calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk)
        .mockResolvedValue(false);
      cipherService.getKeyForCipherKeyDecryption = jest.fn().mockResolvedValue(mockUserKey);
      jest
        .spyOn(encryptionContext.cipher, "decrypt")
        .mockResolvedValue(new CipherView(encryptionContext.cipher));

      const result = await cipherService.decrypt(encryptionContext.cipher, userId);

      expect(result).toEqual(new CipherView(encryptionContext.cipher));
      expect(encryptionContext.cipher.decrypt).toHaveBeenCalledWith(mockUserKey);
    });
  });

  describe("getDecryptedAttachmentBuffer", () => {
    const mockEncryptedContent = new Uint8Array([1, 2, 3]);
    const mockDecryptedContent = new Uint8Array([4, 5, 6]);

    it("should use SDK when feature flag is enabled", async () => {
      const cipher = new Cipher(cipherData);
      const attachment = new AttachmentView(cipher.attachments![0]);
      configService.getFeatureFlag
        .calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk)
        .mockResolvedValue(true);

      jest.spyOn(cipherService, "ciphers$").mockReturnValue(of({ [cipher.id]: cipherData }));
      cipherEncryptionService.decryptAttachmentContent.mockResolvedValue(mockDecryptedContent);
      const mockResponse = {
        arrayBuffer: jest.fn().mockResolvedValue(mockEncryptedContent.buffer),
      } as unknown as Response;

      const result = await cipherService.getDecryptedAttachmentBuffer(
        cipher.id as CipherId,
        attachment,
        mockResponse,
        userId,
      );

      expect(result).toEqual(mockDecryptedContent);
      expect(cipherEncryptionService.decryptAttachmentContent).toHaveBeenCalledWith(
        cipher,
        attachment,
        mockEncryptedContent,
        userId,
      );
    });

    it("should use legacy decryption when feature flag is enabled", async () => {
      configService.getFeatureFlag
        .calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk)
        .mockResolvedValue(false);
      const cipher = new Cipher(cipherData);
      const attachment = new AttachmentView(cipher.attachments![0]);
      attachment.key = makeSymmetricCryptoKey(64);

      const mockResponse = {
        arrayBuffer: jest.fn().mockResolvedValue(mockEncryptedContent.buffer),
      } as unknown as Response;
      const mockEncBuf = {} as EncArrayBuffer;
      EncArrayBuffer.fromResponse = jest.fn().mockResolvedValue(mockEncBuf);
      encryptService.decryptFileData.mockResolvedValue(mockDecryptedContent);

      const result = await cipherService.getDecryptedAttachmentBuffer(
        cipher.id as CipherId,
        attachment,
        mockResponse,
        userId,
      );

      expect(result).toEqual(mockDecryptedContent);
      expect(encryptService.decryptFileData).toHaveBeenCalledWith(mockEncBuf, attachment.key);
    });
  });

  describe("shareWithServer()", () => {
    it("should use cipherEncryptionService to move the cipher when feature flag enabled", async () => {
      configService.getFeatureFlag
        .calledWith(FeatureFlag.PM22136_SdkCipherEncryption)
        .mockResolvedValue(true);

      apiService.putShareCipher.mockResolvedValue(new CipherResponse(cipherData));

      const expectedCipher = new Cipher(cipherData);
      expectedCipher.organizationId = orgId;
      const cipherView = new CipherView(expectedCipher);
      const collectionIds = ["collection1", "collection2"] as CollectionId[];

      cipherView.organizationId = undefined; // Ensure organizationId is undefined for this test

      cipherEncryptionService.moveToOrganization.mockResolvedValue({
        cipher: expectedCipher,
        encryptedFor: userId,
      });

      await cipherService.shareWithServer(cipherView, orgId, collectionIds, userId);

      // Expect SDK usage
      expect(cipherEncryptionService.moveToOrganization).toHaveBeenCalledWith(
        cipherView,
        orgId,
        userId,
      );
      // Expect collectionIds to be assigned
      expect(apiService.putShareCipher).toHaveBeenCalledWith(
        cipherView.id,
        expect.objectContaining({
          cipher: expect.objectContaining({ organizationId: orgId }),
          collectionIds: collectionIds,
        }),
      );
    });

    it("should use legacy encryption when feature flag disabled", async () => {
      configService.getFeatureFlag
        .calledWith(FeatureFlag.PM22136_SdkCipherEncryption)
        .mockResolvedValue(false);

      apiService.putShareCipher.mockResolvedValue(new CipherResponse(cipherData));

      const expectedCipher = new Cipher(cipherData);
      expectedCipher.organizationId = orgId;
      const cipherView = new CipherView(expectedCipher);
      const collectionIds = ["collection1", "collection2"] as CollectionId[];

      cipherView.organizationId = undefined; // Ensure organizationId is undefined for this test

      const oldEncryptSharedSpy = jest
        .spyOn(cipherService as any, "encryptSharedCipher")
        .mockResolvedValue({
          cipher: expectedCipher,
          encryptedFor: userId,
        });

      await cipherService.shareWithServer(cipherView, orgId, collectionIds, userId);

      // Expect no SDK usage
      expect(cipherEncryptionService.moveToOrganization).not.toHaveBeenCalled();
      expect(oldEncryptSharedSpy).toHaveBeenCalledWith(
        expect.objectContaining({
          organizationId: orgId,
          collectionIds: collectionIds,
        } as unknown as CipherView),
        userId,
      );
    });
  });

  describe("decryptCiphers", () => {
    let mockCiphers: Cipher[];
    const cipher1_id = "11111111-1111-1111-1111-111111111111";
    const cipher2_id = "22222222-2222-2222-2222-222222222222";

    beforeEach(() => {
      const originalUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
      const orgKey = new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey;
      const keys = {
        userKey: originalUserKey,
        orgKeys: { [orgId]: orgKey },
      } as CipherDecryptionKeys;
      keyService.cipherDecryptionKeys$.mockReturnValue(of(keys));

      mockCiphers = [
        new Cipher({ ...cipherData, id: cipher1_id }),
        new Cipher({ ...cipherData, id: cipher2_id }),
      ];

      //// Mock the SDK response
      cipherEncryptionService.decryptManyWithFailures.mockResolvedValue([
        [{ id: mockCiphers[0].id, name: "Success 1" } as unknown as CipherListView],
        [mockCiphers[1]], // Mock failed cipher
      ]);
    });

    it("should use the SDK for decryption when SDK feature flag is enabled", async () => {
      configService.getFeatureFlag
        .calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk)
        .mockResolvedValue(true);

      // Set up expected results
      const expectedSuccessCipherViews = [
        { id: mockCiphers[0].id, name: "Success 1", decryptionFailure: false } as CipherView,
      ];

      const expectedFailedCipher = new CipherView(mockCiphers[1]);
      expectedFailedCipher.name = "[error: cannot decrypt]";
      expectedFailedCipher.decryptionFailure = true;
      const expectedFailedCipherViews = [expectedFailedCipher];

      cipherEncryptionService.decryptManyLegacy.mockResolvedValue([
        expectedSuccessCipherViews,
        expectedFailedCipherViews,
      ]);

      // Execute
      const [successes, failures] = await (cipherService as any).decryptCiphers(
        mockCiphers,
        userId,
      );

      // Verify the SDK was used for decryption
      expect(cipherEncryptionService.decryptManyLegacy).toHaveBeenCalledWith(mockCiphers, userId);

      expect(successes).toEqual(expectedSuccessCipherViews);
      expect(failures).toEqual(expectedFailedCipherViews);
    });

    it("should use legacy decryption when SDK feature flag is disabled", async () => {
      configService.getFeatureFlag
        .calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk)
        .mockResolvedValue(false);

      // Execute
      const [successes, failures] = await (cipherService as any).decryptCiphers(
        mockCiphers,
        userId,
      );

      // Verify the SDK was not used for decryption
      expect(cipherEncryptionService.decryptManyWithFailures).toHaveBeenCalledTimes(0);

      expect(successes).toHaveLength(2);
      expect(failures).toHaveLength(0);
    });
  });

  describe("softDelete", () => {
    it("clears archivedDate when soft deleting", async () => {
      const cipherId = "cipher-id-1" as CipherId;
      const archivedCipher = {
        ...cipherData,
        id: cipherId,
        archivedDate: "2024-01-01T12:00:00.000Z",
      } as CipherData;

      const ciphers = { [cipherId]: archivedCipher } as Record<CipherId, CipherData>;
      stateProvider.singleUser.getFake(mockUserId, ENCRYPTED_CIPHERS).nextState(ciphers);

      await cipherService.softDelete(cipherId, mockUserId);

      const result = await firstValueFrom(
        stateProvider.singleUser.getFake(mockUserId, ENCRYPTED_CIPHERS).state$,
      );
      expect(result[cipherId].archivedDate).toEqual("2024-01-01T12:00:00.000Z");
      expect(result[cipherId].deletedDate).toBeDefined();
    });
  });

  describe("deleteWithServer()", () => {
    const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId;

    it("should call apiService.deleteCipher when feature flag is disabled", async () => {
      configService.getFeatureFlag$
        .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
        .mockReturnValue(of(false));

      const apiSpy = jest.spyOn(apiService, "deleteCipher").mockResolvedValue(undefined);

      await cipherService.deleteWithServer(testCipherId, userId);

      expect(apiSpy).toHaveBeenCalledWith(testCipherId);
    });

    it("should call apiService.deleteCipherAdmin when feature flag is disabled and asAdmin is true", async () => {
      configService.getFeatureFlag$
        .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
        .mockReturnValue(of(false));

      const apiSpy = jest.spyOn(apiService, "deleteCipherAdmin").mockResolvedValue(undefined);

      await cipherService.deleteWithServer(testCipherId, userId, true);

      expect(apiSpy).toHaveBeenCalledWith(testCipherId);
    });

    it("should use SDK to delete cipher when feature flag is enabled", async () => {
      sdkCrudFeatureFlag$.next(true);

      const sdkServiceSpy = jest
        .spyOn(cipherSdkService, "deleteWithServer")
        .mockResolvedValue(undefined);
      const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");

      await cipherService.deleteWithServer(testCipherId, userId, false);

      expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, false);
      expect(clearCacheSpy).toHaveBeenCalledWith(userId);
    });

    it("should use SDK admin delete when feature flag is enabled and asAdmin is true", async () => {
      sdkCrudFeatureFlag$.next(true);

      const sdkServiceSpy = jest
        .spyOn(cipherSdkService, "deleteWithServer")
        .mockResolvedValue(undefined);
      const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");

      await cipherService.deleteWithServer(testCipherId, userId, true);

      expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, true);
      expect(clearCacheSpy).toHaveBeenCalledWith(userId);
    });
  });

  describe("deleteManyWithServer()", () => {
    const testCipherIds = [
      "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId,
      "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId,
    ];

    it("should call apiService.deleteManyCiphers when feature flag is disabled", async () => {
      configService.getFeatureFlag$
        .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
        .mockReturnValue(of(false));

      const apiSpy = jest.spyOn(apiService, "deleteManyCiphers").mockResolvedValue(undefined);

      await cipherService.deleteManyWithServer(testCipherIds, userId);

      expect(apiSpy).toHaveBeenCalled();
    });

    it("should call apiService.deleteManyCiphersAdmin when feature flag is disabled and asAdmin is true", async () => {
      configService.getFeatureFlag$
        .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
        .mockReturnValue(of(false));

      const apiSpy = jest.spyOn(apiService, "deleteManyCiphersAdmin").mockResolvedValue(undefined);

      await cipherService.deleteManyWithServer(testCipherIds, userId, true, orgId);

      expect(apiSpy).toHaveBeenCalledWith({ ids: testCipherIds, organizationId: orgId });
    });

    it("should use SDK to delete multiple ciphers when feature flag is enabled", async () => {
      sdkCrudFeatureFlag$.next(true);

      const sdkServiceSpy = jest
        .spyOn(cipherSdkService, "deleteManyWithServer")
        .mockResolvedValue(undefined);
      const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");

      await cipherService.deleteManyWithServer(testCipherIds, userId, false);

      expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, false, undefined);
      expect(clearCacheSpy).toHaveBeenCalledWith(userId);
    });

    it("should use SDK admin delete many when feature flag is enabled and asAdmin is true", async () => {
      sdkCrudFeatureFlag$.next(true);

      const sdkServiceSpy = jest
        .spyOn(cipherSdkService, "deleteManyWithServer")
        .mockResolvedValue(undefined);
      const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");

      await cipherService.deleteManyWithServer(testCipherIds, userId, true, orgId);

      expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, true, orgId);
      expect(clearCacheSpy).toHaveBeenCalledWith(userId);
    });
  });

  describe("softDeleteWithServer()", () => {
    const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId;

    it("should call apiService.putDeleteCipher when feature flag is disabled", async () => {
      configService.getFeatureFlag$
        .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
        .mockReturnValue(of(false));

      const apiSpy = jest.spyOn(apiService, "putDeleteCipher").mockResolvedValue(undefined);

      await cipherService.softDeleteWithServer(testCipherId, userId);

      expect(apiSpy).toHaveBeenCalledWith(testCipherId);
    });

    it("should call apiService.putDeleteCipherAdmin when feature flag is disabled and asAdmin is true", async () => {
      configService.getFeatureFlag$
        .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
        .mockReturnValue(of(false));

      const apiSpy = jest.spyOn(apiService, "putDeleteCipherAdmin").mockResolvedValue(undefined);

      await cipherService.softDeleteWithServer(testCipherId, userId, true);

      expect(apiSpy).toHaveBeenCalledWith(testCipherId);
    });

    it("should use SDK to soft delete cipher when feature flag is enabled", async () => {
      sdkCrudFeatureFlag$.next(true);

      const sdkServiceSpy = jest
        .spyOn(cipherSdkService, "softDeleteWithServer")
        .mockResolvedValue(undefined);
      const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");

      await cipherService.softDeleteWithServer(testCipherId, userId, false);

      expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, false);
      expect(clearCacheSpy).toHaveBeenCalledWith(userId);
    });

    it("should use SDK admin soft delete when feature flag is enabled and asAdmin is true", async () => {
      sdkCrudFeatureFlag$.next(true);

      const sdkServiceSpy = jest
        .spyOn(cipherSdkService, "softDeleteWithServer")
        .mockResolvedValue(undefined);
      const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");

      await cipherService.softDeleteWithServer(testCipherId, userId, true);

      expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, true);
      expect(clearCacheSpy).toHaveBeenCalledWith(userId);
    });
  });

  describe("softDeleteManyWithServer()", () => {
    const testCipherIds = [
      "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId,
      "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId,
    ];

    it("should call apiService.putDeleteManyCiphers when feature flag is disabled", async () => {
      configService.getFeatureFlag$
        .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
        .mockReturnValue(of(false));

      const apiSpy = jest.spyOn(apiService, "putDeleteManyCiphers").mockResolvedValue(undefined);

      await cipherService.softDeleteManyWithServer(testCipherIds, userId);

      expect(apiSpy).toHaveBeenCalled();
    });

    it("should call apiService.putDeleteManyCiphersAdmin when feature flag is disabled and asAdmin is true", async () => {
      configService.getFeatureFlag$
        .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
        .mockReturnValue(of(false));

      const apiSpy = jest
        .spyOn(apiService, "putDeleteManyCiphersAdmin")
        .mockResolvedValue(undefined);

      await cipherService.softDeleteManyWithServer(testCipherIds, userId, true, orgId);

      expect(apiSpy).toHaveBeenCalledWith({ ids: testCipherIds, organizationId: orgId });
    });

    it("should use SDK to soft delete multiple ciphers when feature flag is enabled", async () => {
      sdkCrudFeatureFlag$.next(true);

      const sdkServiceSpy = jest
        .spyOn(cipherSdkService, "softDeleteManyWithServer")
        .mockResolvedValue(undefined);
      const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");

      await cipherService.softDeleteManyWithServer(testCipherIds, userId, false);

      expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, false, undefined);
      expect(clearCacheSpy).toHaveBeenCalledWith(userId);
    });

    it("should use SDK admin soft delete many when feature flag is enabled and asAdmin is true", async () => {
      sdkCrudFeatureFlag$.next(true);

      const sdkServiceSpy = jest
        .spyOn(cipherSdkService, "softDeleteManyWithServer")
        .mockResolvedValue(undefined);
      const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache");

      await cipherService.softDeleteManyWithServer(testCipherIds, userId, true, orgId);

      expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, true, orgId);
      expect(clearCacheSpy).toHaveBeenCalledWith(userId);
    });
  });

  describe("replace (no upsert)", () => {
    // In order to set up initial state we need to manually update the encrypted state
    // which will result in an emission. All tests will have this baseline emission.
    const TEST_BASELINE_EMISSIONS = 1;

    const makeCipher = (id: string): CipherData =>
      ({
        ...cipherData,
        id,
        name: `Enc ${id}`,
      }) as CipherData;

    const tick = async () => new Promise((r) => setTimeout(r, 0));

    const setEncryptedState = async (data: Record<CipherId, CipherData>, uid = userId) => {
      // Directly set the encrypted state, this will result in a single emission
      await stateProvider.getUser(uid, ENCRYPTED_CIPHERS).update(() => data);
      // match service’s “next tick” behavior so subscribers see it
      await tick();
    };

    it("emits and calls updateEncryptedCipherState when current state is empty and replace({}) is called", async () => {
      // Ensure empty state
      await setEncryptedState({});

      const emissions: Array<Record<CipherId, CipherData>> = [];
      const sub = cipherService.ciphers$(userId).subscribe((v) => emissions.push(v));
      await tick();

      const spy = jest.spyOn<any, any>(cipherService, "updateEncryptedCipherState");

      // Calling replace with empty object MUST still update to trigger init emissions
      await cipherService.replace({}, userId);
      await tick();

      expect(spy).toHaveBeenCalledTimes(1);
      expect(emissions.length).toBeGreaterThanOrEqual(TEST_BASELINE_EMISSIONS + 1);

      sub.unsubscribe();
    });

    it("does NOT emit or call updateEncryptedCipherState when state is non-empty and identical", async () => {
      const A = makeCipher("A");
      await setEncryptedState({ [A.id as CipherId]: A });

      const emissions: Array<Record<CipherId, CipherData>> = [];
      const sub = cipherService.ciphers$(userId).subscribe((v) => emissions.push(v));
      await tick();

      const spy = jest.spyOn<any, any>(cipherService, "updateEncryptedCipherState");

      // identical snapshot → short-circuit path
      await cipherService.replace({ [A.id as CipherId]: A }, userId);
      await tick();

      expect(spy).not.toHaveBeenCalled();
      expect(emissions.length).toBe(TEST_BASELINE_EMISSIONS);

      sub.unsubscribe();
    });

    it("emits and calls updateEncryptedCipherState when the provided state differs from current", async () => {
      const A = makeCipher("A");
      await setEncryptedState({ [A.id as CipherId]: A });

      const emissions: Array<Record<CipherId, CipherData>> = [];
      const sub = cipherService.ciphers$(userId).subscribe((v) => emissions.push(v));
      await tick();

      const spy = jest.spyOn<any, any>(cipherService, "updateEncryptedCipherState");

      const B = makeCipher("B");
      await cipherService.replace({ [B.id as CipherId]: B }, userId);
      await tick();

      expect(spy).toHaveBeenCalledTimes(1);

      expect(emissions.length).toBeGreaterThanOrEqual(TEST_BASELINE_EMISSIONS + 1);

      sub.unsubscribe();
    });
  });

  describe("getCipherForUrl localData application", () => {
    beforeEach(() => {
      Object.defineProperty(autofillSettingsService, "autofillOnPageLoadDefault$", {
        value: of(true),
        writable: true,
      });
    });

    it("should apply localData to ciphers when getCipherForUrl is called via getLastLaunchedForUrl", async () => {
      const testUrl = "https://test-url.com";
      const cipherId = "test-cipher-id" as CipherId;
      const testLocalData = {
        lastLaunched: Date.now().valueOf(),
        lastUsedDate: Date.now().valueOf() - 1000,
      };

      jest.spyOn(cipherService, "localData$").mockReturnValue(of({ [cipherId]: testLocalData }));

      const mockCipherView = new CipherView();
      mockCipherView.id = cipherId;
      mockCipherView.localData = null;

      jest.spyOn(cipherService, "getAllDecryptedForUrl").mockResolvedValue([mockCipherView]);

      const result = await cipherService.getLastLaunchedForUrl(testUrl, userId, true);

      expect(result.localData).toEqual(testLocalData);
    });

    it("should apply localData to ciphers when getCipherForUrl is called via getLastUsedForUrl", async () => {
      const testUrl = "https://test-url.com";
      const cipherId = "test-cipher-id" as CipherId;
      const testLocalData = { lastUsedDate: Date.now().valueOf() - 1000 };

      jest.spyOn(cipherService, "localData$").mockReturnValue(of({ [cipherId]: testLocalData }));

      const mockCipherView = new CipherView();
      mockCipherView.id = cipherId;
      mockCipherView.localData = null;

      jest.spyOn(cipherService, "getAllDecryptedForUrl").mockResolvedValue([mockCipherView]);

      const result = await cipherService.getLastUsedForUrl(testUrl, userId, true);

      expect(result.localData).toEqual(testLocalData);
    });

    it("should not modify localData if it already matches in getCipherForUrl", async () => {
      const testUrl = "https://test-url.com";
      const cipherId = "test-cipher-id" as CipherId;
      const existingLocalData = {
        lastLaunched: Date.now().valueOf(),
        lastUsedDate: Date.now().valueOf() - 1000,
      };

      jest
        .spyOn(cipherService, "localData$")
        .mockReturnValue(of({ [cipherId]: existingLocalData }));

      const mockCipherView = new CipherView();
      mockCipherView.id = cipherId;
      mockCipherView.localData = existingLocalData;

      jest.spyOn(cipherService, "getAllDecryptedForUrl").mockResolvedValue([mockCipherView]);

      const result = await cipherService.getLastLaunchedForUrl(testUrl, userId, true);

      expect(result.localData).toBe(existingLocalData);
    });
  });
});
