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

import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import {
  AUTOFILL_ID,
  AUTOFILL_TRIAGE_ID,
  COPY_IDENTIFIER_ID,
  COPY_PASSWORD_ID,
  COPY_USERNAME_ID,
  COPY_VERIFICATION_CODE_ID,
  GENERATE_PASSWORD_ID,
  NOOP_COMMAND_SUFFIX,
} from "@bitwarden/common/autofill/constants";
import { EventCollectionService } from "@bitwarden/common/dirt/event-logs";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";

import { BrowserApi } from "../../platform/browser/browser-api";
import BrowserPopupUtils from "../../platform/browser/browser-popup-utils";
import AutofillPageDetails from "../models/autofill-page-details";
import { AutofillTriageService } from "../services/abstractions/autofill-triage.service";
import { AutofillTriageFieldResult } from "../types/autofill-triage";

import {
  CopyToClipboardAction,
  ContextMenuClickedHandler,
  CopyToClipboardOptions,
  GeneratePasswordToClipboardAction,
  AutofillAction,
} from "./context-menu-clicked-handler";

describe("ContextMenuClickedHandler", () => {
  const createData = (
    menuItemId: chrome.contextMenus.OnClickData["menuItemId"],
    parentMenuItemId?: chrome.contextMenus.OnClickData["parentMenuItemId"],
  ): chrome.contextMenus.OnClickData => {
    return {
      menuItemId: menuItemId,
      parentMenuItemId: parentMenuItemId,
      editable: false,
      pageUrl: "something",
    };
  };

  const createCipher = (data?: {
    id?: CipherView["id"];
    username?: CipherView["login"]["username"];
    password?: CipherView["login"]["password"];
    totp?: CipherView["login"]["totp"];
  }): CipherView => {
    const { id, username, password, totp } = data || {};
    const cipherView = new CipherView(
      new Cipher({
        id: id ?? "1",
        type: CipherType.Login,
      } as any),
    );

    cipherView.login.username = username ?? "USERNAME";
    cipherView.login.password = password ?? "PASSWORD";
    cipherView.login.totp = totp ?? "TOTP";
    return cipherView;
  };

  const mockUserId = "UserId" as UserId;

  let copyToClipboard: CopyToClipboardAction;
  let generatePasswordToClipboard: GeneratePasswordToClipboardAction;
  let autofill: AutofillAction;
  let authService: MockProxy<AuthService>;
  let cipherService: MockProxy<CipherService>;
  let accountService: FakeAccountService;
  let totpService: MockProxy<TotpService>;
  let eventCollectionService: MockProxy<EventCollectionService>;
  let userVerificationService: MockProxy<UserVerificationService>;
  let triageService: MockProxy<AutofillTriageService>;

  let sut: ContextMenuClickedHandler;

  beforeEach(() => {
    copyToClipboard = jest.fn<void, [CopyToClipboardOptions]>();
    generatePasswordToClipboard = jest.fn<Promise<void>, [tab: chrome.tabs.Tab]>();
    autofill = jest.fn<Promise<void>, [tab: chrome.tabs.Tab, cipher: CipherView]>();
    authService = mock();
    cipherService = mock();
    accountService = mockAccountServiceWith(mockUserId as UserId);
    totpService = mock();
    eventCollectionService = mock();
    userVerificationService = mock();
    triageService = mock();

    sut = new ContextMenuClickedHandler(
      copyToClipboard,
      generatePasswordToClipboard,
      autofill,
      authService,
      cipherService,
      totpService,
      eventCollectionService,
      userVerificationService,
      accountService,
      triageService,
    );
  });

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

  describe("run", () => {
    beforeEach(() => {
      authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked);
      userVerificationService.hasMasterPassword.mockResolvedValue(false);
    });

    const runWithUrl = (data: chrome.contextMenus.OnClickData) =>
      sut.run(data, { url: "https://test.com" } as any);

    describe("early returns", () => {
      it.each([
        {
          name: "tab id is missing",
          data: createData(COPY_IDENTIFIER_ID),
          tab: { url: "https://test.com" } as any,
          expectNotCalled: () => expect(copyToClipboard).not.toHaveBeenCalled(),
        },
        {
          name: "tab url is missing",
          data: createData(`${COPY_USERNAME_ID}_${NOOP_COMMAND_SUFFIX}`, COPY_USERNAME_ID),
          tab: {} as any,
          expectNotCalled: () => {
            expect(cipherService.getAllDecryptedForUrl).not.toHaveBeenCalled();
            expect(copyToClipboard).not.toHaveBeenCalled();
          },
        },
      ])("returns early when $name", async ({ data, tab, expectNotCalled }) => {
        await expect(sut.run(data, tab)).resolves.toBeUndefined();
        expectNotCalled();
      });
    });

    describe("missing cipher", () => {
      it.each([
        {
          label: "AUTOFILL",
          parentId: AUTOFILL_ID,
          extra: () => expect(autofill).not.toHaveBeenCalled(),
        },
        { label: "username", parentId: COPY_USERNAME_ID, extra: () => {} },
        { label: "password", parentId: COPY_PASSWORD_ID, extra: () => {} },
        {
          label: "totp",
          parentId: COPY_VERIFICATION_CODE_ID,
          extra: () => expect(totpService.getCode$).not.toHaveBeenCalled(),
        },
      ])("breaks silently when cipher is missing for $label", async ({ parentId, extra }) => {
        cipherService.getAllDecrypted.mockResolvedValue([]);

        await expect(runWithUrl(createData(`${parentId}_1`, parentId))).resolves.toBeUndefined();

        expect(copyToClipboard).not.toHaveBeenCalled();
        extra();
      });
    });

    describe("missing login properties", () => {
      it.each([
        {
          label: "username",
          parentId: COPY_USERNAME_ID,
          unset: (c: CipherView): void => (c.login.username = undefined),
        },
        {
          label: "password",
          parentId: COPY_PASSWORD_ID,
          unset: (c: CipherView): void => (c.login.password = undefined),
        },
        {
          label: "totp",
          parentId: COPY_VERIFICATION_CODE_ID,
          unset: (c: CipherView): void => (c.login.totp = undefined),
          isTotp: true,
        },
      ])("breaks silently when $label property is missing", async ({ parentId, unset, isTotp }) => {
        const cipher = createCipher();
        unset(cipher);
        cipherService.getAllDecrypted.mockResolvedValue([cipher]);

        await expect(runWithUrl(createData(`${parentId}_1`, parentId))).resolves.toBeUndefined();

        expect(copyToClipboard).not.toHaveBeenCalled();
        if (isTotp) {
          expect(totpService.getCode$).not.toHaveBeenCalled();
        }
      });
    });

    it("can generate password", async () => {
      await sut.run(createData(GENERATE_PASSWORD_ID), { id: 5 } as any);

      expect(generatePasswordToClipboard).toHaveBeenCalledTimes(1);

      expect(generatePasswordToClipboard).toHaveBeenCalledWith({
        id: 5,
      });
    });

    it("attempts to autofill the correct cipher", async () => {
      const cipher = createCipher();
      cipherService.getAllDecrypted.mockResolvedValue([cipher]);

      await sut.run(createData(`${AUTOFILL_ID}_1`, AUTOFILL_ID), { id: 5 } as any);

      expect(autofill).toHaveBeenCalledTimes(1);

      expect(autofill).toHaveBeenCalledWith({ id: 5 }, cipher);
    });

    it("copies username to clipboard", async () => {
      cipherService.getAllDecrypted.mockResolvedValue([
        createCipher({ username: "TEST_USERNAME" }),
      ]);

      await sut.run(createData(`${COPY_USERNAME_ID}_1`, COPY_USERNAME_ID), {
        url: "https://test.com",
      } as any);

      expect(copyToClipboard).toHaveBeenCalledTimes(1);

      expect(copyToClipboard).toHaveBeenCalledWith({
        text: "TEST_USERNAME",
        tab: { url: "https://test.com" },
      });
    });

    it("copies password to clipboard", async () => {
      cipherService.getAllDecrypted.mockResolvedValue([
        createCipher({ password: "TEST_PASSWORD" }),
      ]);

      await sut.run(createData(`${COPY_PASSWORD_ID}_1`, COPY_PASSWORD_ID), {
        url: "https://test.com",
      } as any);

      expect(copyToClipboard).toHaveBeenCalledTimes(1);

      expect(copyToClipboard).toHaveBeenCalledWith({
        text: "TEST_PASSWORD",
        tab: { url: "https://test.com" },
      });
    });

    it("copies totp code to clipboard", async () => {
      cipherService.getAllDecrypted.mockResolvedValue([createCipher({ totp: "TEST_TOTP_SEED" })]);

      jest.spyOn(totpService, "getCode$").mockImplementation((seed: string) => {
        if (seed === "TEST_TOTP_SEED") {
          return of({
            code: "123456",
            period: 30,
          });
        }

        return of({
          code: "654321",
          period: 30,
        });
      });

      await sut.run(createData(`${COPY_VERIFICATION_CODE_ID}_1`, COPY_VERIFICATION_CODE_ID), {
        url: "https://test.com",
      } as any);

      expect(totpService.getCode$).toHaveBeenCalledTimes(1);

      expect(copyToClipboard).toHaveBeenCalledWith({
        text: "123456",
        tab: { url: "https://test.com" },
      });
    });

    it("attempts to find a cipher when noop but unlocked", async () => {
      cipherService.getAllDecryptedForUrl.mockResolvedValue([
        {
          ...createCipher({ username: "NOOP_USERNAME" }),
          reprompt: CipherRepromptType.None,
        } as any,
      ]);

      await sut.run(createData(`${COPY_USERNAME_ID}_${NOOP_COMMAND_SUFFIX}`, COPY_USERNAME_ID), {
        url: "https://test.com",
      } as any);

      expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledTimes(1);

      expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith(
        "https://test.com",
        mockUserId,
        [],
      );

      expect(copyToClipboard).toHaveBeenCalledTimes(1);

      expect(copyToClipboard).toHaveBeenCalledWith({
        text: "NOOP_USERNAME",
        tab: { url: "https://test.com" },
      });
    });

    it("attempts to find a cipher when noop but unlocked", async () => {
      cipherService.getAllDecryptedForUrl.mockResolvedValue([
        {
          ...createCipher({ username: "NOOP_USERNAME" }),
          reprompt: CipherRepromptType.Password,
        } as any,
      ]);

      await sut.run(createData(`${COPY_USERNAME_ID}_${NOOP_COMMAND_SUFFIX}`, COPY_USERNAME_ID), {
        url: "https://test.com",
      } as any);

      expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledTimes(1);

      expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith(
        "https://test.com",
        mockUserId,
        [],
      );
    });

    describe("autofill triage", () => {
      const mockTab = { id: 42, url: "https://example.com", windowId: 1 } as chrome.tabs.Tab;
      const mockPageDetails = { fields: [], forms: {} } as unknown as AutofillPageDetails;
      const mockTriageResult: AutofillTriageFieldResult = {
        htmlId: "test-field",
        eligible: true,
        qualifiedAs: "login",
        conditions: [],
      };

      beforeEach(() => {
        jest
          .spyOn(BrowserApi, "sendTabsMessage")
          .mockImplementation((_tabId, _message, _options, callback?: (response: any) => void) => {
            callback?.({ pageDetails: mockPageDetails });
          });
        jest.spyOn(BrowserPopupUtils, "openPopout").mockResolvedValue(undefined);
        jest.spyOn(BrowserApi, "setSidePanelOptions").mockResolvedValue(undefined);
        jest.spyOn(BrowserApi, "openSidePanel").mockResolvedValue(undefined);
        jest.spyOn(BrowserApi, "isSidePanelApiSupported", "get").mockReturnValue(false);
        triageService.triageField.mockReturnValue(mockTriageResult);
        // Mock chrome.runtime.getManifest
        (global as any).chrome = {
          ...((global as any).chrome || {}),
          runtime: {
            ...((global as any).chrome?.runtime || {}),
            getManifest: jest.fn().mockReturnValue({ version: "1.0.0" }),
          },
        };
      });

      it("sends collectAutofillTriage to the tab and stores the result", async () => {
        await sut.run(createData(AUTOFILL_TRIAGE_ID), mockTab);

        expect(BrowserApi.sendTabsMessage).toHaveBeenCalledWith(
          mockTab.id,
          { command: "collectAutofillTriage", targetElementId: undefined },
          { frameId: undefined },
          expect.any(Function),
        );
        expect(sut.triageResult).not.toBeUndefined();
        expect(sut.triageResult?.tabId).toBe(mockTab.id);
        expect(sut.triageResult?.pageUrl).toBe(mockTab.url);
      });

      it("opens the autofill triage popout when side panel API is not supported", async () => {
        await sut.run(createData(AUTOFILL_TRIAGE_ID), mockTab);

        expect(BrowserPopupUtils.openPopout).toHaveBeenCalledWith(
          "popup/index.html#/autofill-triage",
          expect.objectContaining({ singleActionKey: AUTOFILL_TRIAGE_ID }),
        );
        expect(BrowserApi.setSidePanelOptions).not.toHaveBeenCalled();
        expect(BrowserApi.openSidePanel).not.toHaveBeenCalled();
      });

      it("opens the Chrome side panel when side panel API is supported", async () => {
        jest.spyOn(BrowserApi, "isSidePanelApiSupported", "get").mockReturnValue(true);

        await sut.run(createData(AUTOFILL_TRIAGE_ID), mockTab);

        expect(BrowserApi.setSidePanelOptions).toHaveBeenCalledWith(
          expect.objectContaining({
            path: expect.stringContaining("uilocation=sidepanel"),
            tabId: mockTab.id,
            enabled: true,
          }),
        );
        expect(BrowserApi.openSidePanel).toHaveBeenCalledWith({ tabId: mockTab.id });
        expect(BrowserPopupUtils.openPopout).not.toHaveBeenCalled();
      });

      it("sends triageResultReady message with tabId after collecting results", async () => {
        const sendMessageSpy = jest.spyOn(BrowserApi, "sendMessage").mockResolvedValue(undefined);

        await sut.run(createData(AUTOFILL_TRIAGE_ID), mockTab);

        expect(sendMessageSpy).toHaveBeenCalledWith("triageResultReady", { tabId: mockTab.id });
      });

      it("does not open popout when tab has no id", async () => {
        const tabWithoutId = { url: "https://example.com" } as chrome.tabs.Tab;

        await sut.run(createData(AUTOFILL_TRIAGE_ID), tabWithoutId);

        expect(BrowserPopupUtils.openPopout).not.toHaveBeenCalled();
        expect(BrowserApi.openSidePanel).not.toHaveBeenCalled();
      });

      it("sends triageResultReady when page details collection fails so the component exits the loading state", async () => {
        const sendMessageSpy = jest.spyOn(BrowserApi, "sendMessage").mockResolvedValue(undefined);
        jest
          .spyOn(BrowserApi, "sendTabsMessage")
          .mockImplementation((_tabId, _message, _options, callback?: (response: any) => void) => {
            callback?.(null);
          });

        await sut.run(createData(AUTOFILL_TRIAGE_ID), mockTab);

        expect(sendMessageSpy).toHaveBeenCalledWith("triageResultReady", { tabId: mockTab.id });
      });
    });
  });
});
