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

import { BrowserClientVendors } from "@bitwarden/common/autofill/constants";
import { DeviceType } from "@bitwarden/common/enums";
import { LogService } from "@bitwarden/logging";

import { BrowserPlatformUtilsService } from "../services/platform-utils/browser-platform-utils.service";

import { BrowserApi } from "./browser-api";
import { ExtensionInstallType } from "./extension-install-type";

type ChromeSettingsGet = chrome.types.ChromeSetting<boolean>["get"];

describe("BrowserApi", () => {
  const executeScriptResult = ["value"];

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

  describe("isManifestVersion", () => {
    beforeEach(() => {
      jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3);
    });

    it("returns true if the manifest version matches the provided version", () => {
      const result = BrowserApi.isManifestVersion(3);

      expect(result).toBe(true);
    });

    it("returns false if the manifest version does not match the provided version", () => {
      const result = BrowserApi.isManifestVersion(2);

      expect(result).toBe(false);
    });
  });

  describe("getInstallType", () => {
    let originalIsWebExtensionsApi: boolean;
    let originalIsChromeApi: boolean;

    beforeEach(() => {
      originalIsWebExtensionsApi = BrowserApi.isWebExtensionsApi;
      originalIsChromeApi = BrowserApi.isChromeApi;
    });

    afterEach(() => {
      BrowserApi.isWebExtensionsApi = originalIsWebExtensionsApi;
      BrowserApi.isChromeApi = originalIsChromeApi;
      delete (global.chrome as any).management;
      delete (global as any).browser;
    });

    it.each([
      ["admin", ExtensionInstallType.Admin],
      ["development", ExtensionInstallType.Development],
      ["normal", ExtensionInstallType.Normal],
      ["sideload", ExtensionInstallType.Sideload],
      ["other", ExtensionInstallType.Other],
    ])("returns %s when chrome.management.getSelf reports it", async (raw, expected) => {
      (global.chrome as any).management = {
        getSelf: jest.fn().mockResolvedValue({ installType: raw }),
      };

      const result = await BrowserApi.getInstallType();

      expect(result).toBe(expected);
    });

    it("prefers browser.management.getSelf when isWebExtensionsApi is true", async () => {
      BrowserApi.isWebExtensionsApi = true;
      const browserGetSelf = jest.fn().mockResolvedValue({ installType: "admin" });
      const chromeGetSelf = jest.fn().mockResolvedValue({ installType: "normal" });
      (global as any).browser = { management: { getSelf: browserGetSelf } };
      (global.chrome as any).management = { getSelf: chromeGetSelf };

      const result = await BrowserApi.getInstallType();

      expect(result).toBe(ExtensionInstallType.Admin);
      expect(browserGetSelf).toHaveBeenCalled();
      expect(chromeGetSelf).not.toHaveBeenCalled();
    });

    it("returns Unknown when management.getSelf rejects", async () => {
      (global.chrome as any).management = {
        getSelf: jest.fn().mockRejectedValue(new Error("not available")),
      };

      const result = await BrowserApi.getInstallType();

      expect(result).toBe(ExtensionInstallType.Unknown);
    });

    it("returns Unknown when the result has no installType", async () => {
      (global.chrome as any).management = {
        getSelf: jest.fn().mockResolvedValue({}),
      };

      const result = await BrowserApi.getInstallType();

      expect(result).toBe(ExtensionInstallType.Unknown);
    });

    it("returns Unknown when chrome.management is absent", async () => {
      const result = await BrowserApi.getInstallType();

      expect(result).toBe(ExtensionInstallType.Unknown);
    });

    it("returns Unknown when neither chrome nor browser is available", async () => {
      BrowserApi.isWebExtensionsApi = false;
      BrowserApi.isChromeApi = false;

      const result = await BrowserApi.getInstallType();

      expect(result).toBe(ExtensionInstallType.Unknown);
    });
  });

  describe("senderIsInternal", () => {
    const EXTENSION_ORIGIN = "chrome-extension://id";

    beforeEach(() => {
      jest.spyOn(BrowserApi, "getRuntimeURL").mockReturnValue(`${EXTENSION_ORIGIN}/`);
    });

    it("returns false when sender is undefined", () => {
      const result = BrowserApi.senderIsInternal(undefined);

      expect(result).toBe(false);
    });

    it("returns false when sender has no origin", () => {
      const result = BrowserApi.senderIsInternal({ id: "abc" } as any);

      expect(result).toBe(false);
    });

    it("returns false when the extension URL cannot be determined", () => {
      jest.spyOn(BrowserApi, "getRuntimeURL").mockReturnValue("");

      const result = BrowserApi.senderIsInternal({ origin: EXTENSION_ORIGIN });

      expect(result).toBe(false);
    });

    it.each([
      ["an external origin", "https://evil.com"],
      ["a subdomain of the extension origin", "chrome-extension://id.evil.com"],
      ["a file: URL (opaque origin)", "file:///home/user/page.html"],
      ["a data: URL (opaque origin)", "data:text/html,<h1>hi</h1>"],
    ])("returns false when sender origin is %s", (_, senderOrigin) => {
      const result = BrowserApi.senderIsInternal({ origin: senderOrigin });

      expect(result).toBe(false);
    });

    it("returns false when sender is from a non-top-level frame", () => {
      const result = BrowserApi.senderIsInternal({ origin: EXTENSION_ORIGIN, frameId: 5 });

      expect(result).toBe(false);
    });

    it("returns true when sender origin matches and no frameId is present (popup)", () => {
      const result = BrowserApi.senderIsInternal({ origin: EXTENSION_ORIGIN });

      expect(result).toBe(true);
    });

    it("returns true when sender origin matches and frameId is 0 (top-level frame)", () => {
      const result = BrowserApi.senderIsInternal({ origin: EXTENSION_ORIGIN, frameId: 0 });

      expect(result).toBe(true);
    });

    it("calls logger.warning when sender has no origin", () => {
      const logger = mock<LogService>();

      BrowserApi.senderIsInternal({} as any, logger);

      expect(logger.warning).toHaveBeenCalledWith(expect.stringContaining("no origin"));
    });

    it("calls logger.warning when the extension URL cannot be determined", () => {
      jest.spyOn(BrowserApi, "getRuntimeURL").mockReturnValue("");
      const logger = mock<LogService>();

      BrowserApi.senderIsInternal({ origin: EXTENSION_ORIGIN }, logger);

      expect(logger.warning).toHaveBeenCalledWith(expect.stringContaining("extension URL"));
    });

    it("calls logger.warning when origin does not match", () => {
      const logger = mock<LogService>();

      BrowserApi.senderIsInternal({ origin: "https://evil.com" }, logger);

      expect(logger.warning).toHaveBeenCalledWith(expect.stringContaining("does not match"));
    });

    it("calls logger.warning when sender is from a non-top-level frame", () => {
      const logger = mock<LogService>();

      BrowserApi.senderIsInternal({ origin: EXTENSION_ORIGIN, frameId: 5 }, logger);

      expect(logger.warning).toHaveBeenCalledWith(expect.stringContaining("top-level frame"));
    });

    it("calls logger.info when sender is confirmed internal", () => {
      const logger = mock<LogService>();

      BrowserApi.senderIsInternal({ origin: EXTENSION_ORIGIN }, logger);

      expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("internal"));
    });
  });

  describe("getWindow", () => {
    it("will get the current window if a window id is not provided", () => {
      // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
      // eslint-disable-next-line @typescript-eslint/no-floating-promises
      BrowserApi.getWindow();

      expect(chrome.windows.getCurrent).toHaveBeenCalledWith({ populate: true }, expect.anything());
    });

    it("will get the window with the provided id if one is provided", () => {
      const windowId = 1;

      // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
      // eslint-disable-next-line @typescript-eslint/no-floating-promises
      BrowserApi.getWindow(windowId);

      expect(chrome.windows.get).toHaveBeenCalledWith(
        windowId,
        { populate: true },
        expect.anything(),
      );
    });
  });

  describe("getCurrentWindow", () => {
    it("will get the current window", () => {
      // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
      // eslint-disable-next-line @typescript-eslint/no-floating-promises
      BrowserApi.getCurrentWindow();

      expect(chrome.windows.getCurrent).toHaveBeenCalledWith({ populate: true }, expect.anything());
    });
  });

  describe("getWindowById", () => {
    it("will get the window associated with the passed window id", () => {
      const windowId = 1;

      // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
      // eslint-disable-next-line @typescript-eslint/no-floating-promises
      BrowserApi.getWindowById(windowId);

      expect(chrome.windows.get).toHaveBeenCalledWith(
        windowId,
        { populate: true },
        expect.anything(),
      );
    });
  });

  describe("removeWindow", () => {
    it("removes the window based on the passed window id", () => {
      const windowId = 10;

      // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
      // eslint-disable-next-line @typescript-eslint/no-floating-promises
      BrowserApi.removeWindow(windowId);

      expect(chrome.windows.remove).toHaveBeenCalledWith(windowId, expect.anything());
    });
  });

  describe("updateWindowProperties", () => {
    it("will update the window with the provided window options", () => {
      const windowId = 1;
      const windowOptions: chrome.windows.UpdateInfo = {
        focused: true,
      };

      // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
      // eslint-disable-next-line @typescript-eslint/no-floating-promises
      BrowserApi.updateWindowProperties(windowId, windowOptions);

      expect(chrome.windows.update).toHaveBeenCalledWith(
        windowId,
        windowOptions,
        expect.anything(),
      );
    });
  });

  describe("focusWindow", () => {
    it("will focus the window with the provided window id", () => {
      const windowId = 1;

      // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
      // eslint-disable-next-line @typescript-eslint/no-floating-promises
      BrowserApi.focusWindow(windowId);

      expect(chrome.windows.update).toHaveBeenCalledWith(
        windowId,
        { focused: true },
        expect.anything(),
      );
    });
  });

  describe("getTab", () => {
    it("returns `null` if the tabId is a falsy value", async () => {
      const result = await BrowserApi.getTab(null);

      expect(result).toBeNull();
    });

    it("returns the tab within manifest v3", async () => {
      const tabId = 1;
      jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3);
      (chrome.tabs.get as jest.Mock).mockImplementation(
        (tabId) => ({ id: tabId }) as chrome.tabs.Tab,
      );

      const result = await BrowserApi.getTab(tabId);

      expect(result).toEqual({ id: tabId });
    });

    it("returns the tab within manifest v2", async () => {
      const tabId = 1;
      jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(2);
      (chrome.tabs.get as jest.Mock).mockImplementation((tabId, callback) =>
        callback({ id: tabId } as chrome.tabs.Tab),
      );

      const result = BrowserApi.getTab(tabId);

      await expect(result).resolves.toEqual({ id: tabId });
    });
  });

  describe("getBackgroundPage", () => {
    it("returns a null value if the `getBackgroundPage` method is not available", () => {
      chrome.extension.getBackgroundPage = undefined;

      const result = BrowserApi.getBackgroundPage();

      expect(result).toBeNull();
    });

    it("returns the background page if the `getBackgroundPage` method is available", () => {
      chrome.extension.getBackgroundPage = jest.fn().mockReturnValue(window);

      const result = BrowserApi.getBackgroundPage();

      expect(result).toEqual(window);
    });
  });

  describe("isBackgroundPage", () => {
    it("returns false if the passed window is `undefined`", () => {
      const result = BrowserApi.isBackgroundPage(undefined);

      expect(result).toBe(false);
    });

    it("returns false if the current window is not the background page", () => {
      chrome.extension.getBackgroundPage = jest.fn().mockReturnValue(null);

      const result = BrowserApi.isBackgroundPage(window);

      expect(result).toBe(false);
    });

    it("returns true if the current window is the background page", () => {
      chrome.extension.getBackgroundPage = jest.fn().mockReturnValue(window);

      const result = BrowserApi.isBackgroundPage(window);

      expect(result).toBe(true);
    });
  });

  describe("getExtensionViews", () => {
    it("returns an empty array if the `getViews` method is not available", () => {
      chrome.extension.getViews = undefined;

      const result = BrowserApi.getExtensionViews();

      expect(result).toEqual([]);
    });

    it("returns the extension views if the `getViews` method is available", () => {
      const views = [window];
      chrome.extension.getViews = jest.fn().mockReturnValue(views);

      const result = BrowserApi.getExtensionViews();

      expect(result).toEqual(views);
    });
  });

  describe("isPopupOpen", () => {
    describe("when MV3 and chrome.runtime.getContexts is available", () => {
      beforeEach(() => {
        (chrome.runtime as any).getContexts = jest.fn();
        jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3);
      });

      afterEach(() => {
        delete (chrome.runtime as any).getContexts;
      });

      it("returns true when a POPUP context exists", async () => {
        (chrome.runtime as any).getContexts.mockResolvedValue([
          { contextType: "POPUP", documentUrl: "chrome-extension://id/popup/index.html" },
        ]);

        expect(await BrowserApi.isPopupOpen()).toBe(true);
      });

      it("returns false when no POPUP context exists", async () => {
        (chrome.runtime as any).getContexts.mockResolvedValue([
          { contextType: "TAB", documentUrl: "chrome-extension://id/popup/index.html" },
        ]);

        expect(await BrowserApi.isPopupOpen()).toBe(false);
      });

      it("returns false when no contexts exist", async () => {
        (chrome.runtime as any).getContexts.mockResolvedValue([]);

        expect(await BrowserApi.isPopupOpen()).toBe(false);
      });
    });

    describe("when MV2, falls back to getExtensionViews", () => {
      beforeEach(() => {
        jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(2);
      });

      it("returns true if the popup is open", async () => {
        chrome.extension.getViews = jest.fn().mockReturnValue([window]);

        expect(await BrowserApi.isPopupOpen()).toBe(true);
      });

      it("returns false if the popup is not open", async () => {
        chrome.extension.getViews = jest.fn().mockReturnValue([]);

        expect(await BrowserApi.isPopupOpen()).toBe(false);
      });

      it("ignores getContexts even when available (Firefox MV2 background page bug)", async () => {
        // Firefox 128+ exposes getContexts in MV2, but classifies the persistent
        // background page as contextType "POPUP". Without the MV3 guard, this would
        // cause isPopupOpen() to always return true and prevent vault timeout.
        (chrome.runtime as any).getContexts = jest
          .fn()
          .mockResolvedValue([
            { contextType: "POPUP", documentUrl: "chrome-extension://id/background.html" },
          ]);
        chrome.extension.getViews = jest.fn().mockReturnValue([]);

        expect(await BrowserApi.isPopupOpen()).toBe(false);

        delete (chrome.runtime as any).getContexts;
      });
    });
  });

  describe("isAnyPopupOrPopoutOpen", () => {
    describe("when MV3 and chrome.runtime.getContexts is available", () => {
      beforeEach(() => {
        (chrome.runtime as any).getContexts = jest.fn();
        jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3);
      });

      afterEach(() => {
        delete (chrome.runtime as any).getContexts;
      });

      it("returns true when a POPUP context exists", async () => {
        (chrome.runtime as any).getContexts.mockResolvedValue([
          { contextType: "POPUP", documentUrl: "chrome-extension://id/popup/index.html" },
        ]);

        expect(await BrowserApi.isAnyPopupOrPopoutOpen()).toBe(true);
      });

      it("returns true when a TAB context with uilocation=popout exists", async () => {
        (chrome.runtime as any).getContexts.mockResolvedValue([
          {
            contextType: "TAB",
            documentUrl: "chrome-extension://id/popup/index.html?uilocation=popout",
          },
        ]);

        expect(await BrowserApi.isAnyPopupOrPopoutOpen()).toBe(true);
      });

      it("returns false when only non-popout TAB contexts exist", async () => {
        (chrome.runtime as any).getContexts.mockResolvedValue([
          { contextType: "TAB", documentUrl: "chrome-extension://id/popup/index.html" },
        ]);

        expect(await BrowserApi.isAnyPopupOrPopoutOpen()).toBe(false);
      });

      it("returns false when no contexts exist", async () => {
        (chrome.runtime as any).getContexts.mockResolvedValue([]);

        expect(await BrowserApi.isAnyPopupOrPopoutOpen()).toBe(false);
      });
    });

    describe("when MV2, falls back to getExtensionViews", () => {
      beforeEach(() => {
        jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(2);
      });

      it("returns true if the popup is open", async () => {
        chrome.extension.getViews = jest.fn().mockImplementation((props) => {
          return props?.type === "popup" ? [window] : [];
        });

        expect(await BrowserApi.isAnyPopupOrPopoutOpen()).toBe(true);
      });

      it("returns true if a popout tab is open", async () => {
        const popoutWindow = {
          location: { href: "chrome-extension://id/popup/index.html?uilocation=popout" },
        };
        chrome.extension.getViews = jest.fn().mockImplementation((props) => {
          return props?.type === "tab" ? [popoutWindow] : [];
        });

        expect(await BrowserApi.isAnyPopupOrPopoutOpen()).toBe(true);
      });

      it("returns false if neither popup nor popout is open", async () => {
        chrome.extension.getViews = jest.fn().mockReturnValue([]);

        expect(await BrowserApi.isAnyPopupOrPopoutOpen()).toBe(false);
      });

      it("returns false if only a non-popout tab is open", async () => {
        const tabWindow = {
          location: { href: "chrome-extension://id/popup/index.html" },
        };
        chrome.extension.getViews = jest.fn().mockImplementation((props) => {
          return props?.type === "tab" ? [tabWindow] : [];
        });

        expect(await BrowserApi.isAnyPopupOrPopoutOpen()).toBe(false);
      });
    });
  });

  describe("isAnyViewFocused", () => {
    describe("when MV3 and chrome.runtime.getContexts is available", () => {
      beforeEach(() => {
        (chrome.runtime as any).getContexts = jest.fn();
        jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3);
      });

      afterEach(() => {
        delete (chrome.runtime as any).getContexts;
      });

      it("returns true when a POPUP context exists", async () => {
        (chrome.runtime as any).getContexts.mockResolvedValue([
          { contextType: "POPUP", documentUrl: "chrome-extension://id/popup/index.html" },
        ]);

        expect(await BrowserApi.isAnyViewFocused()).toBe(true);
      });

      it("returns true when a SIDE_PANEL context exists", async () => {
        (chrome.runtime as any).getContexts.mockResolvedValue([
          { contextType: "SIDE_PANEL", documentUrl: "chrome-extension://id/popup/index.html" },
        ]);

        expect(await BrowserApi.isAnyViewFocused()).toBe(true);
      });

      it("returns true when a popout TAB context has a focused window", async () => {
        (chrome.runtime as any).getContexts.mockResolvedValue([
          {
            contextType: "TAB",
            documentUrl: "chrome-extension://id/popup/index.html?uilocation=popout",
            windowId: 1,
          },
        ]);
        chrome.windows.get = jest
          .fn()
          .mockImplementation((_id, _opts, cb) => cb({ focused: true }));

        expect(await BrowserApi.isAnyViewFocused()).toBe(true);
      });

      it("returns false when a popout TAB context has an unfocused window", async () => {
        (chrome.runtime as any).getContexts.mockResolvedValue([
          {
            contextType: "TAB",
            documentUrl: "chrome-extension://id/popup/index.html?uilocation=popout",
            windowId: 1,
          },
        ]);
        chrome.windows.get = jest
          .fn()
          .mockImplementation((_id, _opts, cb) => cb({ focused: false }));

        expect(await BrowserApi.isAnyViewFocused()).toBe(false);
      });

      it("returns false when no contexts exist", async () => {
        (chrome.runtime as any).getContexts.mockResolvedValue([]);

        expect(await BrowserApi.isAnyViewFocused()).toBe(false);
      });
    });

    describe("when MV2, falls back to getExtensionViews", () => {
      beforeEach(() => {
        jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(2);
        delete (chrome.runtime as any).getContexts;
      });

      it("ignores getContexts even when available (Firefox MV2 background page bug)", async () => {
        // Firefox 128+ exposes getContexts in MV2, but classifies the persistent
        // background page as contextType "POPUP". Without the MV3 guard, this would
        // cause isAnyViewFocused() to permanently return true and block vault timeout.
        (chrome.runtime as any).getContexts = jest
          .fn()
          .mockResolvedValue([
            { contextType: "POPUP", documentUrl: "chrome-extension://id/background.html" },
          ]);
        chrome.extension.getViews = jest.fn().mockReturnValue([]);

        expect(await BrowserApi.isAnyViewFocused()).toBe(false);

        delete (chrome.runtime as any).getContexts;
      });

      it("returns false if no views are open", async () => {
        chrome.extension.getViews = jest.fn().mockReturnValue([]);

        expect(await BrowserApi.isAnyViewFocused()).toBe(false);
      });

      it("returns true if the main popup is open", async () => {
        const mainPopupView = {
          location: { href: "chrome-extension://id/popup/index.html" },
        };
        chrome.extension.getViews = jest
          .fn()
          .mockReturnValueOnce([mainPopupView])
          .mockReturnValueOnce([]);

        expect(await BrowserApi.isAnyViewFocused()).toBe(true);
      });

      it("returns true if a focused popout tab view is open", async () => {
        const popoutView = {
          location: { href: "chrome-extension://id/popup/index.html?uilocation=popout" },
          document: { hasFocus: jest.fn().mockReturnValue(true) },
        };
        chrome.extension.getViews = jest
          .fn()
          .mockReturnValueOnce([])
          .mockReturnValueOnce([popoutView]);

        expect(await BrowserApi.isAnyViewFocused()).toBe(true);
      });

      it("returns false if only an unfocused popout tab view is open", async () => {
        const popoutView = {
          location: { href: "chrome-extension://id/popup/index.html?uilocation=popout" },
          document: { hasFocus: jest.fn().mockReturnValue(false) },
        };
        chrome.extension.getViews = jest
          .fn()
          .mockReturnValueOnce([])
          .mockReturnValueOnce([popoutView]);

        expect(await BrowserApi.isAnyViewFocused()).toBe(false);
      });

      it("returns true if a sidebar tab view is open", async () => {
        const sidebarView = {
          location: { href: "chrome-extension://id/popup/index.html?uilocation=sidebar" },
          document: { hasFocus: jest.fn().mockReturnValue(false) },
        };
        chrome.extension.getViews = jest
          .fn()
          .mockReturnValueOnce([])
          .mockReturnValueOnce([sidebarView]);

        expect(await BrowserApi.isAnyViewFocused()).toBe(true);
      });

      it("returns true if main popup is open alongside an unfocused popout", async () => {
        const mainPopupView = {
          location: { href: "chrome-extension://id/popup/index.html" },
        };
        const popoutView = {
          location: { href: "chrome-extension://id/popup/index.html?uilocation=popout" },
          document: { hasFocus: jest.fn().mockReturnValue(false) },
        };
        chrome.extension.getViews = jest
          .fn()
          .mockReturnValueOnce([mainPopupView])
          .mockReturnValueOnce([popoutView]);

        expect(await BrowserApi.isAnyViewFocused()).toBe(true);
      });
    });
  });

  describe("getFrameDetails", () => {
    it("returns the frame details of the specified frame", async () => {
      const tabId = 1;
      const frameId = 2;
      const mockFrameDetails = mock<chrome.webNavigation.GetFrameResultDetails>();
      chrome.webNavigation.getFrame = jest
        .fn()
        .mockImplementation((_details, callback) => callback(mockFrameDetails));

      const returnFrame = await BrowserApi.getFrameDetails({ tabId, frameId });

      expect(chrome.webNavigation.getFrame).toHaveBeenCalledWith(
        { tabId, frameId },
        expect.any(Function),
      );
      expect(returnFrame).toEqual(mockFrameDetails);
    });
  });

  describe("getAllFrameDetails", () => {
    it("returns all sub frame details of the specified tab", async () => {
      const tabId = 1;
      const mockFrameDetails1 = mock<chrome.webNavigation.GetAllFrameResultDetails>();
      const mockFrameDetails2 = mock<chrome.webNavigation.GetAllFrameResultDetails>();
      chrome.webNavigation.getAllFrames = jest
        .fn()
        .mockImplementation((_details, callback) =>
          callback([mockFrameDetails1, mockFrameDetails2]),
        );

      const frames = await BrowserApi.getAllFrameDetails(tabId);

      expect(chrome.webNavigation.getAllFrames).toHaveBeenCalledWith(
        { tabId },
        expect.any(Function),
      );
      expect(frames).toEqual([mockFrameDetails1, mockFrameDetails2]);
    });
  });

  describe("reloadExtension", () => {
    it("forwards call to extension runtime", () => {
      BrowserApi.reloadExtension();
      expect(chrome.runtime.reload).toHaveBeenCalled();
    });
  });

  describe("reloadOpenWindows", () => {
    const href = window.location.href;
    const reload = window.location.reload;

    afterEach(() => {
      window.location.href = href;
      window.location.reload = reload;
    });

    it("skips reloading any windows if no views can be found", () => {
      Object.defineProperty(window, "location", {
        value: { reload: jest.fn(), href: "chrome-extension://id-value/background.html" },
        writable: true,
      });
      chrome.extension.getViews = jest.fn().mockReturnValue([]);

      BrowserApi.reloadOpenWindows();

      expect(window.location.reload).not.toHaveBeenCalled();
    });

    it("reloads all open windows", () => {
      Object.defineProperty(window, "location", {
        value: { reload: jest.fn(), href: "chrome-extension://id-value/index.html" },
        writable: true,
      });
      const views = [window];
      chrome.extension.getViews = jest.fn().mockReturnValue(views);

      BrowserApi.reloadOpenWindows();

      expect(window.location.reload).toHaveBeenCalledTimes(views.length);
    });

    it("skips reloading the background page", () => {
      Object.defineProperty(window, "location", {
        value: { reload: jest.fn(), href: "chrome-extension://id-value/background.html" },
        writable: true,
      });
      const views = [window];
      chrome.extension.getViews = jest.fn().mockReturnValue(views);
      chrome.extension.getBackgroundPage = jest.fn().mockReturnValue(window);

      BrowserApi.reloadOpenWindows();

      expect(window.location.reload).toHaveBeenCalledTimes(0);
    });

    it("skips reloading the current href if it is exempt", () => {
      Object.defineProperty(window, "location", {
        value: { reload: jest.fn(), href: "chrome-extension://id-value/index.html" },
        writable: true,
      });
      const mockWindow = mock<Window>({
        location: {
          href: "chrome-extension://id-value/sidebar.html",
          reload: jest.fn(),
        },
      });
      const views = [window, mockWindow];
      chrome.extension.getViews = jest.fn().mockReturnValue(views);
      window.location.href = "chrome-extension://id-value/index.html";

      BrowserApi.reloadOpenWindows(true);

      expect(window.location.reload).toHaveBeenCalledTimes(0);
      expect(mockWindow.location.reload).toHaveBeenCalledTimes(1);
    });
  });

  describe("getBrowserAction", () => {
    it("returns the `chrome.action` API if the extension manifest is for version 3", () => {
      jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3);

      const result = BrowserApi.getBrowserAction();

      expect(result).toEqual(chrome.action);
    });

    it("returns the `chrome.browserAction` API if the extension manifest is for version 2", () => {
      jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(2);

      const result = BrowserApi.getBrowserAction();

      expect(result).toEqual(chrome.browserAction);
    });
  });

  describe("executeScriptInTab", () => {
    it("calls to the extension api to execute a script within the give tabId", async () => {
      const tabId = 1;
      const injectDetails = mock<chrome.extensionTypes.InjectDetails>();
      jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(2);
      (chrome.tabs.executeScript as jest.Mock).mockImplementation(
        (tabId, injectDetails, callback) => callback(executeScriptResult),
      );

      const result = await BrowserApi.executeScriptInTab(tabId, injectDetails);

      expect(chrome.tabs.executeScript).toHaveBeenCalledWith(
        tabId,
        injectDetails,
        expect.any(Function),
      );
      expect(result).toEqual(executeScriptResult);
    });

    it("calls the manifest v3 scripting API if the extension manifest is for v3", async () => {
      const tabId = 1;
      const injectDetails = mock<chrome.extensionTypes.InjectDetails>({
        file: "file.js",
        allFrames: true,
        runAt: "document_start",
        frameId: null,
      });
      jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3);
      (chrome.scripting.executeScript as jest.Mock).mockResolvedValue(executeScriptResult);

      const result = await BrowserApi.executeScriptInTab(tabId, injectDetails);

      expect(chrome.scripting.executeScript).toHaveBeenCalledWith({
        target: {
          tabId: tabId,
          allFrames: injectDetails.allFrames,
        },
        files: [injectDetails.file],
        injectImmediately: true,
        world: "ISOLATED",
      });
      expect(result).toEqual(executeScriptResult);
    });

    it("injects the script into a specified frameId when the extension is built for manifest v3", async () => {
      const tabId = 1;
      const frameId = 2;
      const injectDetails = mock<chrome.extensionTypes.InjectDetails>({
        file: "file.js",
        allFrames: true,
        runAt: "document_start",
        frameId,
      });
      jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3);
      (chrome.scripting.executeScript as jest.Mock).mockResolvedValue(executeScriptResult);

      await BrowserApi.executeScriptInTab(tabId, injectDetails);

      expect(chrome.scripting.executeScript).toHaveBeenCalledWith({
        target: {
          tabId: tabId,
          frameIds: [frameId],
        },
        files: [injectDetails.file],
        injectImmediately: true,
        world: "ISOLATED",
      });
    });

    it("injects the script into the MAIN world context when injecting a script for manifest v3", async () => {
      const tabId = 1;
      const injectDetails = mock<chrome.extensionTypes.InjectDetails>({
        file: null,
        allFrames: true,
        runAt: "document_start",
        frameId: null,
      });
      const scriptingApiDetails = { world: "MAIN" as chrome.scripting.ExecutionWorld };
      jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3);
      (chrome.scripting.executeScript as jest.Mock).mockResolvedValue(executeScriptResult);

      const result = await BrowserApi.executeScriptInTab(tabId, injectDetails, scriptingApiDetails);

      expect(chrome.scripting.executeScript).toHaveBeenCalledWith({
        target: {
          tabId: tabId,
          allFrames: injectDetails.allFrames,
        },
        files: null,
        injectImmediately: true,
        world: "MAIN",
      });
      expect(result).toEqual(executeScriptResult);
    });
  });

  describe("browserAutofillSettingsOverridden", () => {
    it("returns true if the browser autofill settings are overridden", async () => {
      const mockFn = jest.fn<
        void,
        [
          details: chrome.types.ChromeSettingGetDetails,
          callback: (details: chrome.types.ChromeSettingGetResult<boolean>) => void,
        ],
        never
      >((details, callback) => {
        callback({
          value: false,
          levelOfControl: "controlled_by_this_extension",
        });
      });
      chrome.privacy.services.autofillAddressEnabled.get = mockFn as unknown as ChromeSettingsGet;
      chrome.privacy.services.autofillCreditCardEnabled.get =
        mockFn as unknown as ChromeSettingsGet;
      chrome.privacy.services.passwordSavingEnabled.get = mockFn as unknown as ChromeSettingsGet;

      const result = await BrowserApi.browserAutofillSettingsOverridden();

      expect(result).toBe(true);
    });

    it("returns false if the browser autofill settings are not overridden", async () => {
      const mockFn = jest.fn<
        void,
        [
          details: chrome.types.ChromeSettingGetDetails,
          callback: (details: chrome.types.ChromeSettingGetResult<boolean>) => void,
        ],
        never
      >((details, callback) => {
        callback({
          value: true,
          levelOfControl: "controlled_by_this_extension",
        });
      });

      chrome.privacy.services.autofillAddressEnabled.get = mockFn as unknown as ChromeSettingsGet;
      chrome.privacy.services.autofillCreditCardEnabled.get =
        mockFn as unknown as ChromeSettingsGet;
      chrome.privacy.services.passwordSavingEnabled.get = mockFn as unknown as ChromeSettingsGet;

      const result = await BrowserApi.browserAutofillSettingsOverridden();

      expect(result).toBe(false);
    });

    it("returns false if the browser autofill settings are not controlled by the extension", async () => {
      const mockFn = jest.fn<
        void,
        [
          details: chrome.types.ChromeSettingGetDetails,
          callback: (details: chrome.types.ChromeSettingGetResult<boolean>) => void,
        ],
        never
      >((details, callback) => {
        callback({
          value: false,
          levelOfControl: "controlled_by_other_extensions",
        });
      });
      chrome.privacy.services.autofillAddressEnabled.get = mockFn as unknown as ChromeSettingsGet;
      chrome.privacy.services.autofillCreditCardEnabled.get =
        mockFn as unknown as ChromeSettingsGet;
      chrome.privacy.services.passwordSavingEnabled.get = mockFn as unknown as ChromeSettingsGet;

      const result = await BrowserApi.browserAutofillSettingsOverridden();

      expect(result).toBe(false);
    });

    it("returns true if password saving is overridden on Firefox", async () => {
      const originalIsFirefox = BrowserApi.isFirefox;
      BrowserApi.isFirefox = true;

      const mockFn = jest.fn<
        void,
        [
          details: chrome.types.ChromeSettingGetDetails,
          callback: (details: chrome.types.ChromeSettingGetResult<boolean>) => void,
        ],
        never
      >((details, callback) => {
        callback({
          value: false,
          levelOfControl: "controlled_by_this_extension",
        });
      });
      const addressGet = jest.fn();
      chrome.privacy.services.passwordSavingEnabled.get = mockFn as unknown as ChromeSettingsGet;
      chrome.privacy.services.autofillAddressEnabled.get =
        addressGet as unknown as ChromeSettingsGet;

      const result = await BrowserApi.browserAutofillSettingsOverridden();

      expect(result).toBe(true);
      expect(mockFn).toHaveBeenCalled();
      expect(addressGet).not.toHaveBeenCalled();

      BrowserApi.isFirefox = originalIsFirefox;
    });
  });

  describe("updateDefaultBrowserAutofillSettings", () => {
    it("updates the default browser autofill settings", async () => {
      await BrowserApi.updateDefaultBrowserAutofillSettings(false);

      expect(chrome.privacy.services.autofillAddressEnabled.set).toHaveBeenCalledWith({
        value: false,
      });
      expect(chrome.privacy.services.autofillCreditCardEnabled.set).toHaveBeenCalledWith({
        value: false,
      });
      expect(chrome.privacy.services.passwordSavingEnabled.set).toHaveBeenCalledWith({
        value: false,
      });
    });

    it("only sets passwordSavingEnabled on Firefox", async () => {
      const originalIsFirefox = BrowserApi.isFirefox;
      const originalIsWebExtensionsApi = BrowserApi.isWebExtensionsApi;
      BrowserApi.isFirefox = true;
      BrowserApi.isWebExtensionsApi = true;
      globalThis.browser = mock<typeof browser>({
        privacy: { services: { passwordSavingEnabled: { set: jest.fn() } } },
      });

      await BrowserApi.updateDefaultBrowserAutofillSettings(false);

      expect(browser.privacy.services.passwordSavingEnabled.set).toHaveBeenCalledWith({
        value: false,
      });
      expect(chrome.privacy.services.autofillAddressEnabled.set).not.toHaveBeenCalled();
      expect(chrome.privacy.services.autofillCreditCardEnabled.set).not.toHaveBeenCalled();

      BrowserApi.isFirefox = originalIsFirefox;
      BrowserApi.isWebExtensionsApi = originalIsWebExtensionsApi;
      delete (global as any).browser;
    });
  });

  describe("getBrowserClientVendor", () => {
    it.each([
      [DeviceType.FirefoxExtension, BrowserClientVendors.Firefox],
      [DeviceType.FirefoxBrowser, BrowserClientVendors.Firefox],
    ])("returns Firefox for %s device", (deviceType, expected) => {
      jest.spyOn(BrowserPlatformUtilsService, "getDevice").mockReturnValue(deviceType);

      expect(BrowserApi.getBrowserClientVendor(window)).toBe(expected);
    });
  });

  describe("registerContentScriptsMv2", () => {
    const details: browser.contentScripts.RegisteredContentScriptOptions = {
      matches: ["<all_urls>"],
      js: [{ file: "content/fido2/page-script.js" }],
    };

    it("registers content scripts through the `browser.contentScripts` API when the API is available", async () => {
      globalThis.browser = mock<typeof browser>({
        contentScripts: { register: jest.fn() },
      });

      await BrowserApi.registerContentScriptsMv2(details);

      expect(browser.contentScripts.register).toHaveBeenCalledWith(details);
    });

    it("registers content scripts through the `registerContentScriptsPolyfill` when the `browser.contentScripts.register` API is not available", async () => {
      globalThis.browser = mock<typeof browser>({
        contentScripts: { register: undefined },
      });
      jest.spyOn(BrowserApi, "addListener");

      await BrowserApi.registerContentScriptsMv2(details);

      expect(BrowserApi.addListener).toHaveBeenCalledWith(
        chrome.webNavigation.onCommitted,
        expect.any(Function),
      );
    });
  });

  /*
   * Safari sometimes returns >1 tabs unexpectedly even when
   * specificing a `windowId` or `currentWindow: true` query option.
   *
   * For example, when there are >=2 windows with an active pinned tab,
   * the pinned tab will always be included as the first entry in the array,
   * while the correct tab is included as the second entry.
   *
   * These tests can remain as verification when Safari fixes this bug.
   */
  describe.each([{ isSafariApi: true }, { isSafariApi: false }])(
    "SafariTabsQuery %p",
    ({ isSafariApi }) => {
      let originalIsSafariApi = BrowserApi.isSafariApi;
      const expectedWindowId = 10;
      const wrongWindowId = expectedWindowId + 1;
      const raceConditionWindowId = expectedWindowId + 2;
      const mismatchedWindowId = expectedWindowId + 3;

      const resolvedTabsQueryResult = [
        mock<chrome.tabs.Tab>({
          title: "tab[0] is a pinned tab from another window",
          pinned: true,
          windowId: wrongWindowId,
        }),
        mock<chrome.tabs.Tab>({
          title: "tab[1] is the tab with the correct foreground window",
          windowId: expectedWindowId,
        }),
      ];

      function mockCurrentWindowId(id: number | null) {
        jest
          .spyOn(BrowserApi, "getCurrentWindow")
          .mockResolvedValue(mock<chrome.windows.Window>({ id }));
      }

      beforeEach(() => {
        originalIsSafariApi = BrowserApi.isSafariApi;
        BrowserApi.isSafariApi = isSafariApi;
        mockCurrentWindowId(expectedWindowId);
        jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue(resolvedTabsQueryResult);
      });

      afterEach(() => {
        BrowserApi.isSafariApi = originalIsSafariApi;
        jest.restoreAllMocks();
      });

      describe.each([BrowserApi.getTabFromCurrentWindow, BrowserApi.getTabFromCurrentWindowId])(
        "%p",
        (getCurrTabFn) => {
          it("returns the first tab when the query result has one tab", async () => {
            const expectedSingleTab = resolvedTabsQueryResult[0];
            jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([expectedSingleTab]);
            const actualTab = await getCurrTabFn();
            expect(actualTab).toBe(expectedSingleTab);
          });

          it("returns the first tab when the current window ID is mismatched", async () => {
            mockCurrentWindowId(mismatchedWindowId);
            const actualTab = await getCurrTabFn();
            expect(actualTab).toBe(resolvedTabsQueryResult[0]);
          });

          it("returns the first tab when the current window ID is unavailable", async () => {
            mockCurrentWindowId(null);
            const actualTab = await getCurrTabFn();
            expect(actualTab).toBe(resolvedTabsQueryResult[0]);
          });

          if (isSafariApi) {
            it("returns the tab with the current window ID", async () => {
              const actualTab = await getCurrTabFn();
              expect(actualTab.windowId).toBe(expectedWindowId);
            });

            it(`returns the tab with the current window ID at the time of calling [Function ${getCurrTabFn.name}]`, async () => {
              jest.spyOn(BrowserApi, "tabsQuery").mockImplementation(() => {
                /*
                 * Simulate rapid clicking/switching between windows, e.g.
                 * 1. From Window A, call `getCurrTabFn()`
                 * 2. getCurrTabFn() calls `await BrowserApi.tabsQuery()`
                 * 3. Users switches to Window B before the `await` returns
                 * 4. getCurrTabFn() calls `await BrowserApi.getCurrentWindow()`
                 * ^ This now returns Window B and filters the results erroneously
                 */
                mockCurrentWindowId(raceConditionWindowId);

                return Promise.resolve(resolvedTabsQueryResult);
              });

              const actualTab = await getCurrTabFn();
              expect(actualTab.windowId).toBe(expectedWindowId);
            });
          } /* !isSafariApi */ else {
            it("falls back to tabsQueryFirst", async () => {
              const tabsQueryFirstSpy = jest.spyOn(BrowserApi, "tabsQueryFirst");
              const actualTab = await getCurrTabFn();

              expect(tabsQueryFirstSpy).toHaveBeenCalled();
              expect(actualTab).toBe(resolvedTabsQueryResult[0]);
            });
          }
        },
      );
    },
  );

  describe("isSidePanelApiSupported", () => {
    it("returns true when chrome.sidePanel is defined", () => {
      (chrome as any).sidePanel = {};

      expect(BrowserApi.isSidePanelApiSupported).toBe(true);

      delete (chrome as any).sidePanel;
    });

    it("returns false when chrome.sidePanel is undefined", () => {
      const original = (chrome as any).sidePanel;
      delete (chrome as any).sidePanel;

      expect(BrowserApi.isSidePanelApiSupported).toBe(false);

      if (original !== undefined) {
        (chrome as any).sidePanel = original;
      }
    });
  });

  describe("openSidePanel", () => {
    it("calls chrome.sidePanel.open with the provided tabId when the API is supported", async () => {
      jest.spyOn(BrowserApi, "isSidePanelApiSupported", "get").mockReturnValue(true);
      const openSpy = jest.fn().mockResolvedValue(undefined);
      (chrome as any).sidePanel = { open: openSpy };

      await BrowserApi.openSidePanel({ tabId: 42 });

      expect(openSpy).toHaveBeenCalledWith({ tabId: 42 });
    });

    it("returns without calling chrome.sidePanel.open when the API is not supported", async () => {
      jest.spyOn(BrowserApi, "isSidePanelApiSupported", "get").mockReturnValue(false);
      const openSpy = jest.fn();
      (chrome as any).sidePanel = { open: openSpy };

      await BrowserApi.openSidePanel({ tabId: 42 });

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

  describe("setSidePanelOptions", () => {
    it("calls chrome.sidePanel.setOptions with the provided options when the API is supported", async () => {
      jest.spyOn(BrowserApi, "isSidePanelApiSupported", "get").mockReturnValue(true);
      const setOptionsSpy = jest.fn().mockResolvedValue(undefined);
      (chrome as any).sidePanel = { setOptions: setOptionsSpy };
      const options = { path: "sidepanel.html", enabled: true, tabId: 1 };

      await BrowserApi.setSidePanelOptions(options);

      expect(setOptionsSpy).toHaveBeenCalledWith(options);
    });

    it("returns without calling chrome.sidePanel.setOptions when the API is not supported", async () => {
      jest.spyOn(BrowserApi, "isSidePanelApiSupported", "get").mockReturnValue(false);
      const setOptionsSpy = jest.fn();
      (chrome as any).sidePanel = { setOptions: setOptionsSpy };

      await BrowserApi.setSidePanelOptions({ path: "sidepanel.html" });

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