import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { SIDE_PANEL_DEFAULT_WIDTH } from "@/lib/ui-layout";
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";

import { GlobalSidePanel } from "./global-side-panel";

const { isCloudMock } = vi.hoisted(() => ({ isCloudMock: vi.fn(() => true) }));

const navigationMocks = vi.hoisted(() => ({ pathname: "/findings" }));

// jsdom has no matchMedia: emulate the push (>= sm) viewport per test.
const mediaMocks = vi.hoisted(() => ({ isPushViewport: false }));

// Toggles a render failure to exercise the panel's local error boundary.
const chatMocks = vi.hoisted(() => ({ shouldThrow: false }));

// Adds a second registry tab on demand: the real registry only holds one
// today, but tab-switch isolation must already hold for when cloud adds more.
const registryMocks = vi.hoisted(() => ({ extraTab: false }));

vi.mock("@/lib/shared/env", () => ({ isCloud: isCloudMock }));

vi.mock("./side-panel-tabs", async (importOriginal) => {
  const original = await importOriginal<typeof import("./side-panel-tabs")>();
  return {
    ...original,
    getVisibleSidePanelTabs: () => {
      const tabs = original.getVisibleSidePanelTabs();
      if (!registryMocks.extraTab) return tabs;
      return [
        ...tabs,
        {
          id: "second-tab",
          label: "Second tab",
          Icon: () => null,
          loadContent: () =>
            Promise.resolve({
              default: () => (
                <div data-testid="second-tab-content">second content</div>
              ),
            }),
          Fallback: () => null,
          isAvailable: () => true,
        },
      ];
    },
  };
});

vi.mock("next/navigation", () => ({
  usePathname: () => navigationMocks.pathname,
}));

vi.mock("@/hooks/use-media-query", () => ({
  useMediaQuery: () => mediaMocks.isPushViewport,
}));

// The AI tab's real content pulls in the whole chat (server actions,
// streamdown); the shell test only cares that the registry's content mounts.
vi.mock(
  "@/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat",
  () => ({
    LighthousePanelChat: () => {
      if (chatMocks.shouldThrow) throw new Error("chunk load failed");
      return <div data-testid="panel-chat-content">chat content</div>;
    },
  }),
);

describe("GlobalSidePanel", () => {
  afterEach(() => {
    vi.unstubAllGlobals();
  });

  beforeEach(() => {
    isCloudMock.mockReturnValue(true);
    navigationMocks.pathname = "/findings";
    mediaMocks.isPushViewport = false;
    chatMocks.shouldThrow = false;
    registryMocks.extraTab = false;
    localStorage.clear();
    useSidePanelStore.setState({
      isOpen: false,
      selectedTab: SIDE_PANEL_TAB.AI_CHAT,
      hasBeenOpened: false,
      width: SIDE_PANEL_DEFAULT_WIDTH,
      isResizing: false,
      contextTab: null,
      contextOwnerToken: 0,
      contextOutlet: null,
    });
  });

  it("renders closed and empty by default (lazy-mount)", () => {
    // Given / When
    render(<GlobalSidePanel />);

    // Then: panel exists but is off-screen, inert, and mounts no content
    const panel = screen.getByTestId("global-side-panel");
    expect(panel).toHaveClass("translate-x-full");
    expect(screen.queryByTestId("panel-chat-content")).not.toBeInTheDocument();
  });

  it("slides in and mounts the AI tab content when opened via the store", async () => {
    // Given
    render(<GlobalSidePanel />);

    // When
    act(() => useSidePanelStore.getState().openPanel(SIDE_PANEL_TAB.AI_CHAT));

    // Then
    expect(screen.getByTestId("global-side-panel")).toHaveClass(
      "translate-x-0",
    );
    expect(await screen.findByTestId("panel-chat-content")).toBeInTheDocument();
  });

  it("keeps the content mounted (hidden) after closing", async () => {
    // Given
    render(<GlobalSidePanel />);
    act(() => useSidePanelStore.getState().openPanel());
    await screen.findByTestId("panel-chat-content");

    // When
    act(() => useSidePanelStore.getState().closePanel());

    // Then: off-screen but the chat DOM survives (scroll/draft preservation)
    expect(screen.getByTestId("global-side-panel")).toHaveClass(
      "translate-x-full",
    );
    expect(screen.getByTestId("panel-chat-content")).toBeInTheDocument();
  });

  it("closes on Escape", async () => {
    // Given
    const user = userEvent.setup();
    render(<GlobalSidePanel />);
    act(() => useSidePanelStore.getState().openPanel());

    // When
    await user.keyboard("{Escape}");

    // Then
    await waitFor(() =>
      expect(useSidePanelStore.getState().isOpen).toBe(false),
    );
  });

  it("toggles with Cmd/Ctrl+.", async () => {
    // Given
    const user = userEvent.setup();
    render(<GlobalSidePanel />);

    // When / Then
    await user.keyboard("{Meta>}.{/Meta}");
    expect(useSidePanelStore.getState().isOpen).toBe(true);

    await user.keyboard("{Meta>}.{/Meta}");
    expect(useSidePanelStore.getState().isOpen).toBe(false);
  });

  it("closes via the close button", async () => {
    // Given
    const user = userEvent.setup();
    render(<GlobalSidePanel />);
    act(() => useSidePanelStore.getState().openPanel());

    // When
    await user.click(screen.getByRole("button", { name: "Close side panel" }));

    // Then
    expect(useSidePanelStore.getState().isOpen).toBe(false);
  });

  it("groups the chat actions beside close in the existing header", async () => {
    // Given
    render(<GlobalSidePanel />);

    // When
    act(() => useSidePanelStore.getState().openPanel());

    // Then: no extra toolbar; both actions are adjacent in the panel header
    const newChat = await screen.findByRole("button", { name: "New chat" });
    const fullPage = screen.getByRole("link", {
      name: "Open Lighthouse AI full page",
    });
    const close = screen.getByRole("button", { name: "Close side panel" });
    const actionGroup = close.parentElement;
    expect(actionGroup).toContainElement(newChat);
    expect(actionGroup).toContainElement(fullPage);
    expect(actionGroup).toHaveClass("ml-auto");
    expect(newChat).not.toHaveClass("ml-auto");
    expect(close).not.toHaveClass("ml-auto");
  });

  it("renders nothing in OSS while no detail view is registered", () => {
    // Given
    isCloudMock.mockReturnValue(false);

    // When
    const { container } = render(<GlobalSidePanel />);

    // Then
    expect(container).toBeEmptyDOMElement();
  });

  it("hosts a registered detail view in OSS, without the AI tab", () => {
    // Given
    isCloudMock.mockReturnValue(false);
    render(<GlobalSidePanel />);

    // When: a detail view registers its context tab
    act(() =>
      useSidePanelStore.getState().registerContextTab({
        label: "Details",
        onRequestClose: vi.fn(),
      }),
    );

    // Then: the panel appears with only the Details surface (no tab strip)
    expect(screen.getByTestId("global-side-panel")).toHaveClass(
      "translate-x-0",
    );
    expect(screen.getByText("Details")).toBeInTheDocument();
    expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
    expect(screen.getByTestId("side-panel-context-outlet")).toBeInTheDocument();
  });

  it("shows Details and Lighthouse AI as switchable tabs in cloud", async () => {
    // Given
    const user = userEvent.setup();
    render(<GlobalSidePanel />);
    act(() =>
      useSidePanelStore.getState().registerContextTab({
        label: "Details",
        onRequestClose: vi.fn(),
      }),
    );

    // Then: the context tab opens selected, beside the AI tab
    const detailsTab = screen.getByRole("tab", { name: "Details" });
    expect(detailsTab).toHaveAttribute("aria-selected", "true");
    expect(detailsTab).toHaveClass("aria-selected:after:scale-x-100");
    expect(detailsTab).not.toHaveClass("rounded-[8px]");
    expect(screen.getByTestId("side-panel-context-outlet")).toBeVisible();

    const lighthouseTab = screen.getByRole("tab", { name: "Lighthouse AI" });
    expect(lighthouseTab.querySelector("svg")?.parentElement).toHaveClass(
      "flex",
    );

    // When: switching to the AI tab
    await user.click(lighthouseTab);

    // Then: the chat mounts and the detail outlet stays mounted, hidden
    expect(await screen.findByTestId("panel-chat-content")).toBeInTheDocument();
    expect(screen.getByTestId("side-panel-context-outlet")).not.toBeVisible();

    // When: switching back
    await user.click(screen.getByRole("tab", { name: "Details" }));

    // Then
    expect(screen.getByTestId("side-panel-context-outlet")).toBeVisible();
  });

  it("contains a lazy tab failure inside the panel and recovers via Retry", async () => {
    // Given: the AI tab's content throws on render (e.g. chunk-load failure)
    const user = userEvent.setup();
    const consoleError = vi
      .spyOn(console, "error")
      .mockImplementation(() => {});
    chatMocks.shouldThrow = true;
    render(<GlobalSidePanel />);

    // When
    act(() => useSidePanelStore.getState().openPanel(SIDE_PANEL_TAB.AI_CHAT));

    // Then: the failure stays inside the panel body; the shell survives
    expect(
      await screen.findByText("This panel failed to load."),
    ).toBeInTheDocument();
    expect(
      screen.getByRole("button", { name: "Close side panel" }),
    ).toBeInTheDocument();

    // When: the failure clears and the user retries
    chatMocks.shouldThrow = false;
    await user.click(screen.getByRole("button", { name: "Retry" }));

    // Then: the content mounts normally
    expect(await screen.findByTestId("panel-chat-content")).toBeInTheDocument();
    consoleError.mockRestore();
  });

  it("renders the next tab fresh after another tab failed", async () => {
    // Given: two registry tabs, with the first one failing to render
    registryMocks.extraTab = true;
    chatMocks.shouldThrow = true;
    const user = userEvent.setup();
    const consoleError = vi
      .spyOn(console, "error")
      .mockImplementation(() => {});
    render(<GlobalSidePanel />);
    act(() => useSidePanelStore.getState().openPanel(SIDE_PANEL_TAB.AI_CHAT));
    await screen.findByText("This panel failed to load.");

    // When: the user switches to the healthy second tab
    await user.click(screen.getByRole("tab", { name: "Second tab" }));

    // Then: its content mounts instead of the previous tab's error state
    expect(await screen.findByTestId("second-tab-content")).toBeInTheDocument();
    expect(
      screen.queryByText("This panel failed to load."),
    ).not.toBeInTheDocument();
    consoleError.mockRestore();
  });

  it("keeps the context outlet attached across store-driven re-renders", () => {
    // Given: a registered detail view with its portal outlet attached
    render(<GlobalSidePanel />);
    act(() =>
      useSidePanelStore.getState().registerContextTab({
        label: "Details",
        onRequestClose: vi.fn(),
      }),
    );
    const outlet = useSidePanelStore.getState().contextOutlet;
    expect(outlet).not.toBeNull();

    // When: an unrelated update re-renders the panel (a resize tick)
    const seen: (HTMLElement | null)[] = [];
    const unsubscribe = useSidePanelStore.subscribe((state) =>
      seen.push(state.contextOutlet),
    );
    act(() => useSidePanelStore.getState().setWidth(500));
    unsubscribe();

    // Then: the outlet never detached (a null flicker remounts the portal)
    expect(seen).not.toContain(null);
    expect(useSidePanelStore.getState().contextOutlet).toBe(outlet);
  });

  it("re-clamps a persisted oversized width to the current viewport", () => {
    // Given: a huge width rehydrated raw from a larger monitor
    mediaMocks.isPushViewport = true;
    useSidePanelStore.setState({ width: 5000 });
    render(<GlobalSidePanel />);

    // When
    act(() => useSidePanelStore.getState().openPanel());

    // Then: applied width is capped at 85% of this viewport
    expect(screen.getByTestId("global-side-panel")).toHaveStyle({
      width: `${Math.floor(window.innerWidth * 0.85)}px`,
    });
  });

  it("resizes with arrow keys on the handle (left widens, right narrows)", async () => {
    // Given
    mediaMocks.isPushViewport = true;
    const user = userEvent.setup();
    render(<GlobalSidePanel />);
    act(() => useSidePanelStore.getState().openPanel());
    const handle = screen.getByRole("separator", { name: "Resize panel" });
    expect(handle).toHaveAttribute(
      "aria-valuenow",
      String(SIDE_PANEL_DEFAULT_WIDTH),
    );

    // When / Then
    act(() => handle.focus());
    await user.keyboard("{ArrowLeft}");
    expect(useSidePanelStore.getState().width).toBe(
      SIDE_PANEL_DEFAULT_WIDTH + 24,
    );

    await user.keyboard("{ArrowRight}");
    expect(useSidePanelStore.getState().width).toBe(SIDE_PANEL_DEFAULT_WIDTH);
  });

  it("exposes half the viewport as the resize maximum on ultra-wide screens", () => {
    // Given
    vi.stubGlobal("innerWidth", 2560);
    mediaMocks.isPushViewport = true;
    useSidePanelStore.setState({ width: 5000 });

    // When
    render(<GlobalSidePanel />);

    // Then
    expect(screen.getByTestId("global-side-panel")).toHaveStyle({
      width: "1280px",
    });
    expect(
      screen.getByRole("separator", { name: "Resize panel" }),
    ).toHaveAttribute("aria-valuemax", "1280");
  });

  it("does not exist on the full-page chat route (one place or the other)", () => {
    // Given: the user is on the agentic chat page with the panel open
    navigationMocks.pathname = "/lighthouse";
    useSidePanelStore.setState({ isOpen: true, hasBeenOpened: true });

    // When
    const { container } = render(<GlobalSidePanel />);

    // Then: no panel DOM at all — the chat lives in the page there
    expect(container).toBeEmptyDOMElement();
  });

  it("remains available on Lighthouse settings", () => {
    // Given: settings must coexist with the panel, unlike the chat page
    navigationMocks.pathname = "/lighthouse/settings";
    useSidePanelStore.setState({ isOpen: true, hasBeenOpened: true });

    // When
    render(<GlobalSidePanel />);

    // Then
    expect(screen.getByTestId("global-side-panel")).toHaveClass(
      "translate-x-0",
    );
  });
});
