import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { provideNoopAnimations } from "@angular/platform-browser/animations";
import { ActivatedRoute, Router } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";

import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { EventCollectionService, EventType } from "@bitwarden/common/dirt/event-logs";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { TaskService } from "@bitwarden/common/vault/tasks";
import { AddEditCipherInfo } from "@bitwarden/common/vault/types/add-edit-cipher-info";
import { DialogService } from "@bitwarden/components";
import {
  ArchiveCipherUtilitiesService,
  CipherFormConfig,
  CipherFormConfigService,
  CipherFormMode,
  OptionalInitialValues,
} from "@bitwarden/vault";

import { BrowserFido2UserInterfaceSession } from "../../../../../autofill/fido2/services/browser-fido2-user-interface.service";
import { BrowserApi } from "../../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils";
import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service";
import { PopupCloseWarningService } from "../../../../../popup/services/popup-close-warning.service";
import { VaultPopupAfterDeletionNavigationService } from "../../../services/vault-popup-after-deletion-navigation.service";
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";

import { AddEditComponent } from "./add-edit.component";

// 'qrcode-parser' is used by `BrowserTotpCaptureService` but is an es6 module that jest can't compile.
// Mock the entire module here to prevent jest from throwing an error. I wasn't able to find a way to mock the
// `BrowserTotpCaptureService` where jest would not load the file in the first place.
jest.mock("qrcode-parser", () => {});

describe("AddEditComponent", () => {
  let component: AddEditComponent;
  let fixture: ComponentFixture<AddEditComponent>;
  let addEditCipherInfo$: BehaviorSubject<AddEditCipherInfo | null>;
  let cipherServiceMock: MockProxy<CipherService>;
  let vaultPopupAutofillService: MockProxy<VaultPopupAutofillService>;

  const buildConfigResponse = { originalCipher: {} } as CipherFormConfig;
  const buildConfig = jest.fn((mode) => Promise.resolve({ ...buildConfigResponse, mode }));
  const queryParams$ = new BehaviorSubject({});
  const disable = jest.fn();
  const navigate = jest.fn();
  const back = jest.fn().mockResolvedValue(null);
  const setHistory = jest.fn();
  const collect = jest.fn().mockResolvedValue(null);
  const navigateAfterDeletion = jest.fn().mockResolvedValue(undefined);
  const openSimpleDialog = jest.fn().mockResolvedValue(true);
  const cipherArchiveService = mock<CipherArchiveService>();

  beforeEach(async () => {
    buildConfig.mockClear();
    disable.mockClear();
    navigate.mockClear();
    back.mockClear();
    collect.mockClear();
    navigateAfterDeletion.mockClear();
    openSimpleDialog.mockClear();

    cipherArchiveService.userCanArchive$.mockReturnValue(of(false));

    addEditCipherInfo$ = new BehaviorSubject<AddEditCipherInfo | null>(null);
    cipherServiceMock = mock<CipherService>({
      addEditCipherInfo$: jest.fn().mockReturnValue(addEditCipherInfo$),
    });
    vaultPopupAutofillService = {
      currentAutofillTab$: of({ id: 1 } as chrome.tabs.Tab),
      doAutofill: jest.fn().mockResolvedValue(undefined),
    } as unknown as MockProxy<VaultPopupAutofillService>;

    await TestBed.configureTestingModule({
      imports: [AddEditComponent],
      providers: [
        provideNoopAnimations(),
        { provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
        {
          provide: ConfigService,
          useValue: mock<ConfigService>({
            getFeatureFlag$: jest.fn().mockReturnValue(of(false)),
          }),
        },
        { provide: PopupRouterCacheService, useValue: { back, setHistory } },
        { provide: PopupCloseWarningService, useValue: { disable } },
        { provide: Router, useValue: { navigate } },
        { provide: ActivatedRoute, useValue: { queryParams: queryParams$ } },
        { provide: I18nService, useValue: { t: (key: string) => key } },
        { provide: CipherService, useValue: cipherServiceMock },
        { provide: EventCollectionService, useValue: { collect } },
        { provide: LogService, useValue: mock<LogService>() },
        {
          provide: CipherAuthorizationService,
          useValue: {
            canDeleteCipher$: jest.fn().mockReturnValue(of(true)),
          },
        },
        { provide: AccountService, useValue: mockAccountServiceWith("UserId" as UserId) },
        {
          provide: TaskService,
          useValue: mock<TaskService>(),
        },
        {
          provide: ViewCacheService,
          useValue: { signal: jest.fn(() => (): any => null) },
        },
        {
          provide: BillingAccountProfileStateService,
          useValue: mock<BillingAccountProfileStateService>(),
        },
        {
          provide: CipherArchiveService,
          useValue: cipherArchiveService,
        },
        {
          provide: ArchiveCipherUtilitiesService,
          useValue: {
            archiveCipher: jest.fn().mockResolvedValue(null),
            unarchiveCipher: jest.fn().mockResolvedValue(null),
          },
        },
        {
          provide: VaultPopupAfterDeletionNavigationService,
          useValue: { navigateAfterDeletion },
        },
        {
          provide: VaultPopupAutofillService,
          useValue: vaultPopupAutofillService,
        },
      ],
    })
      .overrideProvider(CipherFormConfigService, {
        useValue: {
          buildConfig,
        },
      })
      .overrideProvider(DialogService, {
        useValue: {
          openSimpleDialog,
        },
      })
      .compileComponents();

    fixture = TestBed.createComponent(AddEditComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  describe("query params", () => {
    describe("mode", () => {
      it("sets mode to `add` when no `cipherId` is provided", fakeAsync(() => {
        queryParams$.next({});

        tick();

        expect(buildConfig.mock.lastCall![0]).toBe("add");
        expect(component.config.mode).toBe("add");
      }));

      it("sets mode to `edit` when `params.clone` is not provided", fakeAsync(() => {
        queryParams$.next({ cipherId: "222-333-444-5555", clone: "true" });

        tick();

        expect(buildConfig.mock.lastCall![0]).toBe("clone");
        expect(component.config.mode).toBe("clone");
      }));

      it("sets mode to `edit` when `params.clone` is not provided", fakeAsync(() => {
        buildConfigResponse.originalCipher = { edit: true } as Cipher;
        queryParams$.next({ cipherId: "222-333-444-5555" });

        tick();

        expect(buildConfig.mock.lastCall![0]).toBe("edit");
        expect(component.config.mode).toBe("edit");
      }));

      it("sets mode to `partial-edit` when `config.originalCipher.edit` is false", fakeAsync(() => {
        buildConfigResponse.originalCipher = { edit: false } as Cipher;
        queryParams$.next({ cipherId: "222-333-444-5555" });

        tick();

        expect(buildConfig.mock.lastCall![0]).toBe("edit");
        expect(component.config.mode).toBe("partial-edit");
      }));
    });
  });

  describe("analytics", () => {
    it("does not log viewed event when mode is add", fakeAsync(() => {
      queryParams$.next({});

      tick();

      expect(collect).not.toHaveBeenCalled();
    }));

    it("does not log viewed event when mode is clone", fakeAsync(() => {
      queryParams$.next({ cipherId: "222-333-444-5555", clone: "true" });
      buildConfigResponse.originalCipher = {} as Cipher;

      tick();

      expect(collect).not.toHaveBeenCalled();
    }));

    it("logs viewed event when mode is edit", fakeAsync(() => {
      buildConfigResponse.originalCipher = {
        edit: true,
        id: "222-333-444-5555",
        organizationId: "444-555-666",
      } as Cipher;
      queryParams$.next({ cipherId: "222-333-444-5555" });

      tick();

      expect(collect).toHaveBeenCalledWith(
        EventType.Cipher_ClientViewed,
        "222-333-444-5555",
        false,
        "444-555-666",
      );
    }));

    it("logs viewed event when mode is partial-edit", fakeAsync(() => {
      buildConfigResponse.originalCipher = { edit: false } as Cipher;
      queryParams$.next({ cipherId: "222-333-444-5555", orgId: "444-555-666" });

      tick();

      expect(collect).toHaveBeenCalledWith(
        EventType.Cipher_ClientViewed,
        "222-333-444-5555",
        false,
        "444-555-666",
      );
    }));
  });

  describe("addEditCipherInfo initialization", () => {
    it("populates config.initialValues with `addEditCipherInfo` values", fakeAsync(() => {
      const addEditCipherInfo = {
        cipher: {
          name: "test",
          folderId: "folder1",
          organizationId: "org1",
          type: CipherType.Login,
          login: {
            password: "password",
            username: "username",
            uris: [{ uri: "https://example.com" }],
          },
        },
        collectionIds: ["col1", "col2"],
      } as AddEditCipherInfo;
      addEditCipherInfo$.next(addEditCipherInfo);
      queryParams$.next({});

      tick();

      expect(component.config.initialValues).toEqual({
        name: "test",
        folderId: "folder1",
        organizationId: "org1",
        password: "password",
        username: "username",
        loginUri: "https://example.com",
        collectionIds: ["col1", "col2"],
      } as OptionalInitialValues);
    }));

    it("populates config.initialValues.username when `addEditCipherInfo` is an Identity", fakeAsync(() => {
      addEditCipherInfo$.next({
        cipher: { type: CipherType.Identity, identity: { username: "identity-username" } },
      } as AddEditCipherInfo);
      queryParams$.next({});

      tick();

      expect(component.config.initialValues!.username).toBe("identity-username");
    }));

    it("overrides query params with `addEditCipherInfo` values", fakeAsync(() => {
      addEditCipherInfo$.next({
        cipher: { name: "AddEditCipherName" },
      } as AddEditCipherInfo);
      queryParams$.next({
        name: "QueryParamName",
      });

      tick();

      expect(component.config.initialValues!.name).toBe("AddEditCipherName");
    }));

    it("clears `addEditCipherInfo` after initialization", fakeAsync(() => {
      addEditCipherInfo$.next({ cipher: { name: "test" } } as AddEditCipherInfo);
      queryParams$.next({});

      tick();

      expect(cipherServiceMock.setAddEditCipherInfo).toHaveBeenCalledTimes(1);
    }));
  });

  describe("onCipherSaved", () => {
    it("disables warning when in popout", async () => {
      jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValueOnce(true);

      await component.onCipherSaved({ id: "123-456-789" } as CipherView);

      expect(disable).toHaveBeenCalled();
    });

    it("calls `confirmNewCredentialResponse` when in fido2 popout", async () => {
      // @ts-expect-error - `inFido2PopoutWindow` is a private getter, mock the response here
      // for the test rather than setting up the dependencies.
      jest.spyOn(component, "inFido2PopoutWindow", "get").mockReturnValueOnce(true);

      await component.onCipherSaved({ id: "123-456-789" } as CipherView);

      expect(BrowserPopupUtils.inPopout).toHaveBeenCalled();
      expect(navigate).not.toHaveBeenCalled();
    });

    it("closes single action popout without notification when save and fill is disabled", async () => {
      jest.spyOn(BrowserPopupUtils, "inSingleActionPopout").mockReturnValueOnce(true);
      jest.spyOn(BrowserPopupUtils, "closeSingleActionPopout").mockResolvedValue();
      const sendMessageSpy = jest.spyOn(BrowserApi, "sendMessage").mockResolvedValue(undefined);
      (component as any).saveAndFillEnabled = false;

      await component.onCipherSaved({ id: "123-456-789", name: "Test Cipher" } as CipherView);

      expect(sendMessageSpy).not.toHaveBeenCalledWith(
        "showLoginSavedNotification",
        expect.anything(),
      );
      expect(BrowserPopupUtils.closeSingleActionPopout).toHaveBeenCalledWith(
        "vault_AddEditVaultItem",
        1000,
      );
      expect(navigate).not.toHaveBeenCalled();
    });

    it("shows the saved notification after successful save and fill", async () => {
      jest.spyOn(BrowserPopupUtils, "inSingleActionPopout").mockReturnValueOnce(true);
      jest.spyOn(BrowserPopupUtils, "closeSingleActionPopout").mockResolvedValue();
      const sendMessageSpy = jest.spyOn(BrowserApi, "sendMessage").mockResolvedValue(undefined);
      vaultPopupAutofillService.doAutofill.mockResolvedValue(true);
      (component as any).fillOnSuccessfulSave = true;
      (component as any).saveAndFillEnabled = true;

      await component.onCipherSaved({ id: "123-456-789", name: "Test Cipher" } as CipherView);

      expect(vaultPopupAutofillService.doAutofill).toHaveBeenCalledWith(
        expect.objectContaining({ id: "123-456-789" }),
        false,
        true,
      );
      expect(sendMessageSpy).toHaveBeenCalledWith("showLoginSavedNotification", {
        cipherId: "123-456-789",
        itemName: "Test Cipher",
        senderTabId: 1,
      });
      expect(BrowserPopupUtils.closeSingleActionPopout).toHaveBeenCalledWith(
        "vault_AddEditVaultItem",
      );
    });

    it("navigates to view-cipher for new ciphers", async () => {
      component.config.mode = "add";

      await component.onCipherSaved({ id: "123-456-789" } as CipherView);

      expect(navigate).toHaveBeenCalledWith(["/view-cipher"], {
        replaceUrl: true,
        queryParams: { cipherId: "123-456-789" },
      });
      expect(back).not.toHaveBeenCalled();
    });

    it("navigates to view-cipher for edit ciphers", async () => {
      component.config.mode = "edit";

      await component.onCipherSaved({ id: "123-456-789" } as CipherView);

      expect(navigate).not.toHaveBeenCalled();
      expect(back).toHaveBeenCalled();
    });

    it.each<CipherFormMode>(["add", "edit", "partial-edit"])(
      "sends the addEditCipherSubmitted message when a cipher is edited, added or partially edited",
      async (mode) => {
        const sendMessageSpy = jest.spyOn(BrowserApi, "sendMessage");
        component.config.mode = mode;

        await component.onCipherSaved({ id: "123-456-789" } as CipherView);

        expect(sendMessageSpy).toHaveBeenCalled();
        expect(sendMessageSpy).toHaveBeenCalledWith("addEditCipherSubmitted");
      },
    );
  });

  describe("submitAndFill", () => {
    it("resets the fill intent when the form does not save", async () => {
      const submit = jest.fn().mockResolvedValue(undefined);
      (component as any).cipherFormComponent = jest.fn(() => ({ submit }));

      await component.submitAndFill();

      expect(submit).toHaveBeenCalled();
      expect((component as any).fillOnSuccessfulSave).toBe(false);
    });
  });

  describe("handleBackButton", () => {
    it("disables warning and aborts fido2 popout", async () => {
      // @ts-expect-error - `inFido2PopoutWindow` is a private getter, mock the response here
      // for the test rather than setting up the dependencies.
      jest.spyOn(component, "inFido2PopoutWindow", "get").mockReturnValueOnce(true);
      jest.spyOn(BrowserFido2UserInterfaceSession, "abortPopout");

      await component.handleBackButton();

      expect(disable).toHaveBeenCalled();
      expect(BrowserFido2UserInterfaceSession.abortPopout).toHaveBeenCalled();
      expect(back).not.toHaveBeenCalled();
    });

    it("closes single action popout", async () => {
      jest.spyOn(BrowserPopupUtils, "inSingleActionPopout").mockReturnValueOnce(true);
      jest.spyOn(BrowserPopupUtils, "closeSingleActionPopout").mockResolvedValue();

      await component.handleBackButton();

      expect(BrowserPopupUtils.closeSingleActionPopout).toHaveBeenCalled();
      expect(back).not.toHaveBeenCalled();
    });

    it("navigates the user backwards", async () => {
      await component.handleBackButton();

      expect(back).toHaveBeenCalled();
    });
  });

  describe("submit button text", () => {
    beforeEach(() => {
      // prevent form from rendering
      jest.spyOn(component as any, "loading", "get").mockReturnValue(true);
    });

    it("sets it to 'save' by default", fakeAsync(() => {
      buildConfigResponse.originalCipher = {} as Cipher;

      queryParams$.next({});

      tick();

      const submitBtn = fixture.debugElement.query(By.css("button[type=submit]"));
      expect(submitBtn.nativeElement.textContent.trim()).toBe("save");
    }));

    it("sets it to 'save' when the user is able to archive the item", fakeAsync(() => {
      buildConfigResponse.originalCipher = { isArchived: false } as any;

      queryParams$.next({});

      tick();

      const submitBtn = fixture.debugElement.query(By.css("button[type=submit]"));
      expect(submitBtn.nativeElement.textContent.trim()).toBe("save");
    }));

    it("sets it to 'unarchiveAndSave' when the user cannot archive and the item is archived", fakeAsync(() => {
      cipherArchiveService.userCanArchive$.mockReturnValue(of(false));
      buildConfigResponse.originalCipher = { isArchived: true } as any;

      queryParams$.next({});
      tick();

      const submitBtn = fixture.debugElement.query(By.css("button[type=submit]"));
      expect(submitBtn.nativeElement.textContent.trim()).toBe("save");
    }));
  });

  describe("delete", () => {
    it("dialogService openSimpleDialog called when deleteBtn is hit", async () => {
      const dialogSpy = jest
        .spyOn(component["dialogService"], "openSimpleDialog")
        .mockResolvedValue(true);

      await component.delete();
      expect(dialogSpy).toHaveBeenCalled();
    });

    it("should call deleteCipher when user confirms deletion", async () => {
      const deleteCipherSpy = jest.spyOn(component as any, "deleteCipher");
      jest.spyOn(component["dialogService"], "openSimpleDialog").mockResolvedValue(true);

      await component.delete();
      expect(deleteCipherSpy).toHaveBeenCalled();
    });

    it("navigates to vault tab after deletion by default", async () => {
      jest.spyOn(component["dialogService"], "openSimpleDialog").mockResolvedValue(true);
      await component.delete();

      expect(navigateAfterDeletion).toHaveBeenCalledWith("/tabs/vault");
    });

    it("navigates to custom route after deletion", fakeAsync(() => {
      buildConfigResponse.originalCipher = { edit: true, id: "123" } as Cipher;
      queryParams$.next({
        cipherId: "123",
        routeAfterDeletion: "/archive",
      });

      tick();

      jest.spyOn(component["dialogService"], "openSimpleDialog").mockResolvedValue(true);

      void component.delete();
      tick();

      expect(navigateAfterDeletion).toHaveBeenCalledWith("/archive");
    }));

    it("uses default /tabs/vault route when routeAfterDeletion is not set", fakeAsync(() => {
      buildConfigResponse.originalCipher = { edit: true, id: "456" } as Cipher;
      component.routeAfterDeletion = "/tabs/vault";

      queryParams$.next({
        cipherId: "456",
      });

      tick();

      jest.spyOn(component["dialogService"], "openSimpleDialog").mockResolvedValue(true);

      void component.delete();
      tick();

      expect(navigateAfterDeletion).toHaveBeenCalledWith("/tabs/vault");
    }));

    it("ignores invalid routeAfterDeletion query param and uses default route", fakeAsync(() => {
      // Reset the component's routeAfterDeletion to default before this test
      component.routeAfterDeletion = "/tabs/vault";

      buildConfigResponse.originalCipher = { edit: true, id: "456" } as Cipher;
      queryParams$.next({
        cipherId: "456",
        routeAfterDeletion: "/invalid/route",
      });

      tick();

      // The invalid route should be ignored, routeAfterDeletion should remain default
      expect(component.routeAfterDeletion).toBe("/tabs/vault");
    }));
  });

  describe("reloadAddEditCipherData", () => {
    beforeEach(fakeAsync(() => {
      addEditCipherInfo$.next({
        cipher: {
          name: "InitialName",
          type: CipherType.Login,
          login: {
            password: "initialPassword",
            username: "initialUsername",
            uris: [{ uri: "https://initial.com" }],
          },
        },
      } as AddEditCipherInfo);
      queryParams$.next({});
      tick();

      cipherServiceMock.setAddEditCipherInfo.mockClear();
    }));

    it("replaces all initialValues with new data, clearing stale fields", fakeAsync(() => {
      const newCipherInfo = {
        cipher: {
          name: "UpdatedName",
          type: CipherType.Login,
          login: {
            password: "updatedPassword",
            uris: [{ uri: "https://updated.com" }],
          },
        },
      } as AddEditCipherInfo;

      addEditCipherInfo$.next(newCipherInfo);

      const messageListener = component["messageListener"];
      messageListener({ command: "reloadAddEditCipherData" });
      tick();

      expect(component.config.initialValues).toEqual({
        name: "UpdatedName",
        password: "updatedPassword",
        loginUri: "https://updated.com",
      } as OptionalInitialValues);

      expect(cipherServiceMock.setAddEditCipherInfo).toHaveBeenCalledWith(null, "UserId");
    }));

    it("does not reload data if config is not set", fakeAsync(() => {
      component.config = null as any;

      const messageListener = component["messageListener"];
      messageListener({ command: "reloadAddEditCipherData" });
      tick();

      expect(cipherServiceMock.setAddEditCipherInfo).not.toHaveBeenCalled();
    }));

    it("does not reload data if latestCipherInfo is null", fakeAsync(() => {
      addEditCipherInfo$.next(null);

      const messageListener = component["messageListener"];
      messageListener({ command: "reloadAddEditCipherData" });
      tick();

      expect(component.config.initialValues).toEqual({
        name: "InitialName",
        password: "initialPassword",
        username: "initialUsername",
        loginUri: "https://initial.com",
      } as OptionalInitialValues);

      expect(cipherServiceMock.setAddEditCipherInfo).not.toHaveBeenCalled();
    }));

    it("ignores messages with different commands", fakeAsync(() => {
      const initialValues = component.config.initialValues;

      const messageListener = component["messageListener"];
      messageListener({ command: "someOtherCommand" });
      tick();

      expect(component.config.initialValues).toBe(initialValues);
    }));
  });
});
