/**
 * Tests for the BranchDropdown component.
 */

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/svelte";
import { tick } from "svelte";
import type { BaseInfo } from "@shared/api/types";

// Mock branches data
const mockLocalBranches: BaseInfo[] = [
  { name: "main", isRemote: false },
  { name: "develop", isRemote: false },
];

const mockRemoteBranches: BaseInfo[] = [
  { name: "origin/main", isRemote: true },
  { name: "origin/feature", isRemote: true },
];

const allBranches = [...mockLocalBranches, ...mockRemoteBranches];

// Use vi.hoisted to create mocks that can be referenced in vi.mock factory
const { mockFetchBases, basesUpdatedHandlers } = vi.hoisted(() => ({
  mockFetchBases: vi.fn(),
  // Store handlers to trigger bases-updated events in tests
  basesUpdatedHandlers: new Set<(event: { projectPath: string; bases: BaseInfo[] }) => void>(),
}));

// Mock $lib/api module
vi.mock("$lib/api", () => ({
  openProject: vi.fn().mockResolvedValue(undefined),
  closeProject: vi.fn().mockResolvedValue(undefined),
  listProjects: vi.fn().mockResolvedValue({ projects: [], activeWorkspacePath: null }),
  createWorkspace: vi.fn().mockResolvedValue(undefined),
  removeWorkspace: vi.fn().mockResolvedValue(undefined),
  switchWorkspace: vi.fn().mockResolvedValue(undefined),
  listBases: vi.fn().mockResolvedValue([]),
  updateBases: vi.fn().mockResolvedValue(undefined),
  onProjectOpened: vi.fn(() => vi.fn()),
  onProjectClosed: vi.fn(() => vi.fn()),
  onWorkspaceCreated: vi.fn(() => vi.fn()),
  onWorkspaceRemoved: vi.fn(() => vi.fn()),
  onWorkspaceSwitched: vi.fn(() => vi.fn()),
  // Flat API structure
  projects: {
    fetchBases: mockFetchBases,
  },
  // Event subscription mock - directly return the implementation
  on: (event: string, handler: (event: { projectPath: string; bases: BaseInfo[] }) => void) => {
    if (event === "project:bases-updated") {
      basesUpdatedHandlers.add(handler);
      return () => basesUpdatedHandlers.delete(handler);
    }
    return () => {};
  },
}));

// Helper to emit bases-updated event in tests
function emitBasesUpdated(projectPath: string, bases: BaseInfo[]): void {
  basesUpdatedHandlers.forEach((handler) => handler({ projectPath, bases }));
}

// Helper to complete loading: run timers then emit bases-updated event
async function completeLoading(
  projectPath: string = testProjectPath,
  bases: BaseInfo[] = allBranches
): Promise<void> {
  await vi.runAllTimersAsync();
  emitBasesUpdated(projectPath, bases);
  await tick();
}

// Import component after mock setup
import BranchDropdown from "./BranchDropdown.svelte";
import { projects } from "$lib/api";

// Test project path
const testProjectPath = "/test/project";

describe("BranchDropdown component", () => {
  const defaultProps = {
    projectPath: testProjectPath,
    value: "",
    onSelect: vi.fn(),
  };

  beforeEach(() => {
    vi.clearAllMocks();
    vi.useFakeTimers();
    basesUpdatedHandlers.clear();
    // Reset the mock implementation for each test (v2 API returns { bases: [...] })
    mockFetchBases.mockResolvedValue({ bases: allBranches });
  });

  afterEach(() => {
    vi.useRealTimers();
    document.body.innerHTML = "";
  });

  describe("accessibility", () => {
    it("renders with combobox role and aria attributes", async () => {
      render(BranchDropdown, { props: defaultProps });

      await completeLoading();

      const combobox = screen.getByRole("combobox");
      expect(combobox).toBeInTheDocument();
      expect(combobox).toHaveAttribute("aria-expanded");
      expect(combobox).toHaveAttribute("aria-controls");
    });

    it("aria-expanded reflects dropdown open state", async () => {
      render(BranchDropdown, { props: defaultProps });

      await completeLoading();

      const input = screen.getByRole("combobox");
      expect(input).toHaveAttribute("aria-expanded", "false");

      await fireEvent.focus(input);
      expect(input).toHaveAttribute("aria-expanded", "true");
    });

    it("selected option has aria-selected true", async () => {
      render(BranchDropdown, { props: defaultProps });

      await completeLoading();

      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);
      await fireEvent.keyDown(input, { key: "ArrowDown" });

      const options = screen.getAllByRole("option");
      expect(options[0]).toHaveAttribute("aria-selected", "true");
    });

    it("aria-activedescendant updates on navigation", async () => {
      render(BranchDropdown, { props: defaultProps });

      await completeLoading();

      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);

      // Initial state - no active descendant
      expect(input.getAttribute("aria-activedescendant")).toBeFalsy();

      // Navigate down
      await fireEvent.keyDown(input, { key: "ArrowDown" });

      // Should have an active descendant now
      const activedescendant = input.getAttribute("aria-activedescendant");
      expect(activedescendant).toBeTruthy();
      // The ID is now generated by FilterableDropdown with format: branch-dropdown-{projectPath}-option-{value}
      expect(activedescendant).toContain("option");
    });
  });

  describe("loading", () => {
    it("loads branches using projects.fetchBases(projectPath) on mount", async () => {
      render(BranchDropdown, { props: defaultProps });

      await completeLoading();

      expect(projects.fetchBases).toHaveBeenCalledWith(testProjectPath);
    });

    it("shows spinner while loading", async () => {
      render(BranchDropdown, { props: defaultProps });

      // Spinner should be visible immediately
      expect(screen.getByRole("status", { name: /loading branches/i })).toBeInTheDocument();
      // Input should also be rendered immediately (not hidden during loading)
      expect(screen.getByRole("combobox")).toBeInTheDocument();

      // Run timers to let fetchBases resolve (cached data arrives)
      await vi.runAllTimersAsync();

      // Spinner should STILL be visible (waiting for bases-updated event)
      expect(screen.getByRole("status", { name: /loading branches/i })).toBeInTheDocument();

      // Emit bases-updated event (simulating background refresh completing)
      emitBasesUpdated(testProjectPath, allBranches);
      await tick();

      // Spinner should be gone after bases-updated event
      expect(screen.queryByRole("status", { name: /loading branches/i })).not.toBeInTheDocument();
    });

    it("handles fetchBases error gracefully", async () => {
      mockFetchBases.mockRejectedValue(new Error("Network error"));

      render(BranchDropdown, { props: defaultProps });

      // Run timers to let the error propagate
      await vi.runAllTimersAsync();

      // Should show error state, not crash (loading stops on error)
      expect(screen.queryByRole("alert")).toBeInTheDocument();
      // Spinner should be gone when error occurs
      expect(screen.queryByRole("status", { name: /loading branches/i })).not.toBeInTheDocument();
    });
  });

  describe("display", () => {
    it("displays Local and Remote branch groups", async () => {
      render(BranchDropdown, { props: defaultProps });

      await completeLoading();

      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);

      expect(screen.getByText("Local Branches")).toBeInTheDocument();
      expect(screen.getByText("Remote Branches")).toBeInTheDocument();
    });

    it('shows "No matches found" when filter has no matches', async () => {
      render(BranchDropdown, { props: defaultProps });

      await completeLoading();

      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);
      await fireEvent.input(input, { target: { value: "nonexistent" } });

      // Wait for debounce
      await vi.advanceTimersByTimeAsync(300);
      // Flush Svelte's microtask queue
      await tick();

      expect(screen.getByText(/no matches found/i)).toBeInTheDocument();
    });
  });

  describe("debounce", () => {
    it("typing doesn't filter immediately", async () => {
      render(BranchDropdown, { props: defaultProps });

      await completeLoading();

      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);
      await fireEvent.input(input, { target: { value: "dev" } });

      // All branches should still be visible immediately
      expect(screen.getByText("main")).toBeInTheDocument();
    });

    it("filter applies after 200ms debounce", async () => {
      render(BranchDropdown, { props: defaultProps });

      await completeLoading();

      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);
      await fireEvent.input(input, { target: { value: "dev" } });

      // Advance past debounce
      await vi.advanceTimersByTimeAsync(250);
      // Flush Svelte's microtask queue
      await tick();

      // Now only "develop" should match
      expect(screen.getByText("develop")).toBeInTheDocument();
      expect(screen.queryByText("main")).not.toBeInTheDocument();
    });

    it("rapid typing resets debounce timer", async () => {
      render(BranchDropdown, { props: defaultProps });

      await completeLoading();

      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);

      // Type rapidly
      await fireEvent.input(input, { target: { value: "m" } });
      await vi.advanceTimersByTimeAsync(100);
      await fireEvent.input(input, { target: { value: "ma" } });
      await vi.advanceTimersByTimeAsync(100);
      await fireEvent.input(input, { target: { value: "mai" } });

      // Not enough time has passed since last input
      expect(screen.getByText("develop")).toBeInTheDocument();

      // Now wait for debounce
      await vi.advanceTimersByTimeAsync(250);
      // Flush Svelte's microtask queue
      await tick();

      // Should filter to "main" and "origin/main"
      expect(screen.getByText("main")).toBeInTheDocument();
      expect(screen.queryByText("develop")).not.toBeInTheDocument();
    });
  });

  describe("navigation order", () => {
    it("navigates local branches then remote branches", async () => {
      render(BranchDropdown, { props: defaultProps });

      await completeLoading();

      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);

      const expectedOrder = ["main", "develop", "origin/main", "origin/feature"];

      for (let i = 0; i < expectedOrder.length; i++) {
        await fireEvent.keyDown(input, { key: "ArrowDown" });

        const options = screen.getAllByRole("option");
        expect(options[i]).toHaveAttribute("aria-selected", "true");
        expect(options[i]).toHaveTextContent(expectedOrder[i]!);
      }
    });

    it("ArrowDown from last local branch goes to first remote branch (skipping header)", async () => {
      render(BranchDropdown, { props: defaultProps });

      await completeLoading();

      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);

      // Navigate to "develop" (2nd local branch)
      await fireEvent.keyDown(input, { key: "ArrowDown" });
      await fireEvent.keyDown(input, { key: "ArrowDown" });

      // One more ArrowDown should skip the "Remote Branches" header
      await fireEvent.keyDown(input, { key: "ArrowDown" });

      const options = screen.getAllByRole("option");
      expect(options[2]).toHaveAttribute("aria-selected", "true");
      expect(options[2]).toHaveTextContent("origin/main");
    });
  });

  describe("selection", () => {
    it("mousedown on option prevents blur and selects branch", async () => {
      const onSelect = vi.fn();
      render(BranchDropdown, { props: { ...defaultProps, onSelect } });

      await completeLoading();

      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);

      const option = screen.getByText("develop");

      // Create mousedown event to verify preventDefault is called
      const mousedownEvent = new MouseEvent("mousedown", {
        bubbles: true,
        cancelable: true,
      });
      const preventDefaultSpy = vi.spyOn(mousedownEvent, "preventDefault");

      option.dispatchEvent(mousedownEvent);

      // Wait for any async effects
      await tick();

      // Verify preventDefault was called (prevents input blur)
      expect(preventDefaultSpy).toHaveBeenCalled();

      // Verify selection occurred
      expect(onSelect).toHaveBeenCalledWith("develop");
    });

    // Note: Selection is handled via onmousedown, not onclick.
    // In real browsers, clicking triggers: mousedown -> blur -> mouseup -> click
    // The blur happens BEFORE click, closing the dropdown before onclick fires.
    // We use onmousedown with preventDefault() to prevent blur and select in one action.
    // fireEvent.click() doesn't replicate this blur-before-click timing issue,
    // so we test with fireEvent.mouseDown() which matches our actual handler.
    it("selecting an option via mousedown works correctly", async () => {
      const onSelect = vi.fn();
      render(BranchDropdown, { props: { ...defaultProps, onSelect } });

      await completeLoading();

      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);

      const option = screen.getByText("develop");
      await fireEvent.mouseDown(option);

      expect(onSelect).toHaveBeenCalledWith("develop");
    });

    it("displays selected value in input", async () => {
      render(BranchDropdown, { props: { ...defaultProps, value: "main" } });

      await completeLoading();

      const input = screen.getByRole("combobox") as HTMLInputElement;
      expect(input.value).toBe("main");
    });
  });

  describe("initial value validation", () => {
    it("displays initial value prop in input when it exists in loaded branches", async () => {
      render(BranchDropdown, { props: { ...defaultProps, value: "main" } });

      await completeLoading();

      const input = screen.getByRole("combobox") as HTMLInputElement;
      expect(input.value).toBe("main");
    });

    it("clears value and calls onSelect empty when initial value not in branch list", async () => {
      const onSelect = vi.fn();
      render(BranchDropdown, {
        props: { ...defaultProps, value: "deleted-branch", onSelect },
      });

      await completeLoading();

      // Should have called onSelect with empty string to clear invalid value
      expect(onSelect).toHaveBeenCalledWith("");
    });

    it("does not clear value when branches are still loading", async () => {
      const onSelect = vi.fn();

      // Delay the response to keep loading state
      mockFetchBases.mockImplementation(
        () => new Promise((resolve) => setTimeout(() => resolve({ bases: allBranches }), 1000))
      );

      render(BranchDropdown, {
        props: { ...defaultProps, value: "nonexistent", onSelect },
      });

      // Advance only partially - should still be loading
      await vi.advanceTimersByTimeAsync(500);
      await tick();

      // Should NOT have called onSelect while still loading
      expect(onSelect).not.toHaveBeenCalled();

      // Now finish loading
      await completeLoading();
    });

    it("user can override initial value by selecting different branch", async () => {
      const onSelect = vi.fn();
      render(BranchDropdown, { props: { ...defaultProps, value: "main", onSelect } });

      await completeLoading();

      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);

      // Clear filter to show all branches
      await fireEvent.input(input, { target: { value: "" } });
      await vi.advanceTimersByTimeAsync(250);
      await tick();

      // Select a different branch
      const option = screen.getByText("develop");
      await fireEvent.mouseDown(option);

      expect(onSelect).toHaveBeenCalledWith("develop");
    });
  });

  describe("positioning", () => {
    it("dropdown uses fixed positioning when open", async () => {
      render(BranchDropdown, { props: defaultProps });

      await completeLoading();

      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);

      const listbox = screen.getByRole("listbox");

      // Verify fixed positioning via inline styles (set by component)
      // Note: JSDOM doesn't apply CSS from <style> blocks, so we verify
      // the inline styles that the component sets for fixed positioning
      expect(listbox.style.top).toBeTruthy();
      expect(listbox.style.left).toBeTruthy();
      expect(listbox.style.width).toBeTruthy();
    });

    it("dropdown position updates on window resize", async () => {
      render(BranchDropdown, { props: defaultProps });

      await completeLoading();

      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);

      const listbox = screen.getByRole("listbox");
      const initialTop = listbox.style.top;

      // Mock getBoundingClientRect to simulate position change
      const originalGetBoundingClientRect = input.getBoundingClientRect;
      input.getBoundingClientRect = () => ({
        top: 200,
        left: 50,
        width: 300,
        height: 30,
        right: 350,
        bottom: 230,
        x: 50,
        y: 200,
        toJSON: () => ({}),
      });

      // Trigger resize event
      window.dispatchEvent(new Event("resize"));
      await tick();

      // Position should have been recalculated
      const newTop = listbox.style.top;

      // Restore original
      input.getBoundingClientRect = originalGetBoundingClientRect;

      // The top position should be different after resize
      // (Since we changed getBoundingClientRect to return a different value)
      expect(newTop).not.toBe(initialTop);
    });
  });

  describe("wrapper behavior (FilterableDropdown integration)", () => {
    it("transforms branches to DropdownOption array", async () => {
      render(BranchDropdown, { props: defaultProps });

      await completeLoading();

      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);

      // Verify options are rendered (4 branches = 4 options)
      const options = screen.getAllByRole("option");
      expect(options).toHaveLength(4);

      // Verify option values
      expect(options[0]).toHaveTextContent("main");
      expect(options[1]).toHaveTextContent("develop");
      expect(options[2]).toHaveTextContent("origin/main");
      expect(options[3]).toHaveTextContent("origin/feature");
    });

    it("adds Local Branches header before local branches", async () => {
      render(BranchDropdown, { props: defaultProps });

      await completeLoading();

      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);

      // Headers should have role="presentation" (not selectable)
      const localHeader = screen.getByText("Local Branches");
      expect(localHeader).toBeInTheDocument();
      expect(localHeader.closest("[role='presentation']")).toBeInTheDocument();
    });

    it("adds Remote Branches header before remote branches", async () => {
      render(BranchDropdown, { props: defaultProps });

      await completeLoading();

      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);

      const remoteHeader = screen.getByText("Remote Branches");
      expect(remoteHeader).toBeInTheDocument();
      expect(remoteHeader.closest("[role='presentation']")).toBeInTheDocument();
    });

    it("headers are non-interactive (skipped in navigation)", async () => {
      render(BranchDropdown, { props: defaultProps });

      await completeLoading();

      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);

      // First ArrowDown should highlight first branch "main", not the header
      await fireEvent.keyDown(input, { key: "ArrowDown" });

      const options = screen.getAllByRole("option");
      expect(options[0]).toHaveAttribute("aria-selected", "true");
      expect(options[0]).toHaveTextContent("main");
    });

    it("renders only local header when no remote branches", async () => {
      mockFetchBases.mockResolvedValue({ bases: mockLocalBranches });

      render(BranchDropdown, { props: defaultProps });

      await completeLoading(testProjectPath, mockLocalBranches);

      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);

      expect(screen.getByText("Local Branches")).toBeInTheDocument();
      expect(screen.queryByText("Remote Branches")).not.toBeInTheDocument();
    });

    it("renders only remote header when no local branches", async () => {
      mockFetchBases.mockResolvedValue({ bases: mockRemoteBranches });

      render(BranchDropdown, { props: defaultProps });

      await completeLoading(testProjectPath, mockRemoteBranches);

      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);

      expect(screen.queryByText("Local Branches")).not.toBeInTheDocument();
      expect(screen.getByText("Remote Branches")).toBeInTheDocument();
    });

    it("full async load, filter, and select flow works", async () => {
      const onSelect = vi.fn();
      render(BranchDropdown, { props: { ...defaultProps, onSelect } });

      // Wait for async load
      await completeLoading();

      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);

      // Verify branches loaded
      expect(screen.getByText("main")).toBeInTheDocument();
      expect(screen.getByText("develop")).toBeInTheDocument();

      // Filter
      await fireEvent.input(input, { target: { value: "orig" } });
      await vi.advanceTimersByTimeAsync(250);
      await tick();

      // Should only show remote branches matching "orig"
      expect(screen.queryByText("main")).not.toBeInTheDocument();
      expect(screen.getByText("origin/main")).toBeInTheDocument();
      expect(screen.getByText("origin/feature")).toBeInTheDocument();

      // Select via keyboard
      await fireEvent.keyDown(input, { key: "ArrowDown" });
      await fireEvent.keyDown(input, { key: "Enter" });

      expect(onSelect).toHaveBeenCalledWith("origin/main");
    });
  });

  describe("bases-updated event", () => {
    it("subscribes to project:bases-updated event on mount", async () => {
      render(BranchDropdown, { props: defaultProps });
      await vi.runAllTimersAsync();

      // Handler should be registered in basesUpdatedHandlers set
      expect(basesUpdatedHandlers.size).toBeGreaterThan(0);
    });

    it("updates branches when project:bases-updated event is received for matching projectPath", async () => {
      render(BranchDropdown, { props: defaultProps });
      await completeLoading();

      // Verify initial branches
      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);
      expect(screen.getByText("main")).toBeInTheDocument();
      expect(screen.getByText("origin/feature")).toBeInTheDocument();

      // Simulate bases-updated event with pruned branches
      const updatedBases: BaseInfo[] = [
        { name: "main", isRemote: false },
        { name: "origin/main", isRemote: true },
      ];
      emitBasesUpdated(testProjectPath, updatedBases);
      await tick();

      // origin/feature should be gone
      expect(screen.queryByText("origin/feature")).not.toBeInTheDocument();
      expect(screen.getByText("main")).toBeInTheDocument();
    });

    it("ignores project:bases-updated event for different projectPath", async () => {
      render(BranchDropdown, { props: defaultProps });
      await completeLoading();

      const input = screen.getByRole("combobox");
      await fireEvent.focus(input);

      // Simulate event for different project
      emitBasesUpdated("/different/project", []);
      await tick();

      // Should still have original branches
      expect(screen.getByText("main")).toBeInTheDocument();
    });

    it("unsubscribes from event on cleanup", async () => {
      const { unmount } = render(BranchDropdown, { props: defaultProps });
      await vi.runAllTimersAsync();

      const handlersBeforeUnmount = basesUpdatedHandlers.size;
      unmount();

      // Handler should be removed
      expect(basesUpdatedHandlers.size).toBeLessThan(handlersBeforeUnmount);
    });
  });
});
