/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { act } from 'react';
import { renderWithProviders } from '../../../test-utils/render.js';
import {
  BaseSelectionList,
  type BaseSelectionListProps,
  type RenderItemContext,
} from './BaseSelectionList.js';
import { useSelectionList } from '../../hooks/useSelectionList.js';
import { Text } from 'ink';
import type { theme } from '../../semantic-colors.js';
import { useMouseClick } from '../../hooks/useMouseClick.js';

vi.mock('../../hooks/useSelectionList.js');
vi.mock('../../hooks/useMouseClick.js');

const mockTheme = {
  text: { primary: 'COLOR_PRIMARY', secondary: 'COLOR_SECONDARY' },
  ui: { focus: 'COLOR_FOCUS' },
  background: { focus: 'COLOR_FOCUS_BG' },
} as typeof theme;

vi.mock('../../semantic-colors.js', () => ({
  theme: {
    text: { primary: 'COLOR_PRIMARY', secondary: 'COLOR_SECONDARY' },
    ui: { focus: 'COLOR_FOCUS' },
    background: { focus: 'COLOR_FOCUS_BG' },
  },
}));

describe('BaseSelectionList', () => {
  const mockOnSelect = vi.fn();
  const mockOnHighlight = vi.fn();
  const mockRenderItem = vi.fn();
  const mockSetActiveIndex = vi.fn();

  const items = [
    { value: 'A', label: 'Item A', key: 'A' },
    { value: 'B', label: 'Item B', disabled: true, key: 'B' },
    { value: 'C', label: 'Item C', key: 'C' },
  ];

  // Helper to render the component with default props
  const renderComponent = async (
    props: Partial<
      BaseSelectionListProps<
        string,
        { value: string; label: string; disabled?: boolean; key: string }
      >
    > = {},
    activeIndex: number = 0,
  ) => {
    vi.mocked(useSelectionList).mockReturnValue({
      activeIndex,
      setActiveIndex: mockSetActiveIndex,
    });

    mockRenderItem.mockImplementation(
      (
        item: { value: string; label: string; disabled?: boolean; key: string },
        context: RenderItemContext,
      ) => <Text color={context.titleColor}>{item.label}</Text>,
    );

    const defaultProps: BaseSelectionListProps<
      string,
      { value: string; label: string; disabled?: boolean; key: string }
    > = {
      items,
      onSelect: mockOnSelect,
      onHighlight: mockOnHighlight,
      renderItem: mockRenderItem,
      ...props,
    };

    const result = await renderWithProviders(
      <BaseSelectionList {...defaultProps} />,
    );
    return result;
  };

  beforeEach(() => {
    vi.clearAllMocks();
  });

  describe('Rendering and Structure', () => {
    it('should render all items using the renderItem prop', async () => {
      const { lastFrame, unmount } = await renderComponent();

      expect(lastFrame()).toContain('Item A');
      expect(lastFrame()).toContain('Item B');
      expect(lastFrame()).toContain('Item C');

      expect(mockRenderItem).toHaveBeenCalledTimes(3);
      expect(mockRenderItem).toHaveBeenCalledWith(items[0], expect.any(Object));
      unmount();
    });

    it('should render the selection indicator (● or space) and layout', async () => {
      const { lastFrame, unmount } = await renderComponent({}, 0);
      const output = lastFrame();

      // Use regex to assert the structure: Indicator + Whitespace + Number + Label
      expect(output).toMatch(/●\s+1\.\s+Item A/);
      expect(output).toMatch(/\s+2\.\s+Item B/);
      expect(output).toMatch(/\s+3\.\s+Item C/);
      unmount();
    });

    it('should handle an empty list gracefully', async () => {
      const { lastFrame, unmount } = await renderComponent({ items: [] });
      expect(mockRenderItem).not.toHaveBeenCalled();
      expect(lastFrame({ allowEmpty: true })).toBe('');
      unmount();
    });
  });

  describe('useSelectionList Integration', () => {
    it('should pass props correctly to useSelectionList', async () => {
      const initialIndex = 1;
      const isFocused = false;
      const showNumbers = false;

      const { unmount } = await renderComponent({
        initialIndex,
        isFocused,
        showNumbers,
      });

      expect(useSelectionList).toHaveBeenCalledWith({
        items,
        initialIndex,
        onSelect: mockOnSelect,
        onHighlight: mockOnHighlight,
        isFocused,
        showNumbers,
        wrapAround: true,
      });
      unmount();
    });

    it('should use the activeIndex returned by the hook', async () => {
      const { unmount } = await renderComponent({}, 2); // Active index is C

      expect(mockRenderItem).toHaveBeenCalledWith(
        items[0],
        expect.objectContaining({ isSelected: false }),
      );
      expect(mockRenderItem).toHaveBeenCalledWith(
        items[2],
        expect.objectContaining({ isSelected: true }),
      );
      unmount();
    });
  });

  describe('Styling and Colors', () => {
    it('should apply success color to the selected item', async () => {
      const { unmount } = await renderComponent({}, 0); // Item A selected

      // Check renderItem context colors against the mocked theme
      expect(mockRenderItem).toHaveBeenCalledWith(
        items[0],
        expect.objectContaining({
          titleColor: mockTheme.ui.focus,
          numberColor: mockTheme.ui.focus,
          isSelected: true,
        }),
      );
      unmount();
    });

    it('should apply primary color to unselected, enabled items', async () => {
      const { unmount } = await renderComponent({}, 0); // Item A selected, Item C unselected/enabled

      // Check renderItem context colors for Item C
      expect(mockRenderItem).toHaveBeenCalledWith(
        items[2],
        expect.objectContaining({
          titleColor: mockTheme.text.primary,
          numberColor: mockTheme.text.primary,
          isSelected: false,
        }),
      );
      unmount();
    });

    it('should apply secondary color to disabled items (when not selected)', async () => {
      const { unmount } = await renderComponent({}, 0); // Item A selected, Item B disabled

      // Check renderItem context colors for Item B
      expect(mockRenderItem).toHaveBeenCalledWith(
        items[1],
        expect.objectContaining({
          titleColor: mockTheme.text.secondary,
          numberColor: mockTheme.text.secondary,
          isSelected: false,
        }),
      );
      unmount();
    });

    it('should apply success color to disabled items if they are selected', async () => {
      // The component should visually reflect the selection even if the item is disabled.
      const { unmount } = await renderComponent({}, 1); // Item B (disabled) selected

      // Check renderItem context colors for Item B
      expect(mockRenderItem).toHaveBeenCalledWith(
        items[1],
        expect.objectContaining({
          titleColor: mockTheme.ui.focus,
          numberColor: mockTheme.ui.focus,
          isSelected: true,
        }),
      );
      unmount();
    });
  });

  describe('Numbering (showNumbers)', () => {
    it('should show numbers by default with correct formatting', async () => {
      const { lastFrame, unmount } = await renderComponent();
      const output = lastFrame();

      expect(output).toContain('1.');
      expect(output).toContain('2.');
      expect(output).toContain('3.');
      unmount();
    });

    it('should hide numbers when showNumbers is false', async () => {
      const { lastFrame, unmount } = await renderComponent({
        showNumbers: false,
      });
      const output = lastFrame();

      expect(output).not.toContain('1.');
      expect(output).not.toContain('2.');
      expect(output).not.toContain('3.');
      unmount();
    });

    it('should apply correct padding for alignment in long lists', async () => {
      const longList = Array.from({ length: 15 }, (_, i) => ({
        value: `Item ${i + 1}`,
        label: `Item ${i + 1}`,
        key: `Item ${i + 1}`,
      }));

      // We must increase maxItemsToShow (default 10) to see the 10th item and beyond
      const { lastFrame, unmount } = await renderComponent({
        items: longList,
        maxItemsToShow: 15,
      });
      const output = lastFrame();

      // Check formatting for single and double digits.
      // The implementation uses padStart, resulting in " 1." and "10.".
      expect(output).toContain(' 1.');
      expect(output).toContain('10.');
      unmount();
    });

    it('should apply secondary color to numbers if showNumbers is false (internal logic check)', async () => {
      const { unmount } = await renderComponent({ showNumbers: false }, 0);

      expect(mockRenderItem).toHaveBeenCalledWith(
        items[0],
        expect.objectContaining({
          isSelected: true,
          titleColor: mockTheme.ui.focus,
          numberColor: mockTheme.text.secondary,
        }),
      );
      unmount();
    });
  });

  describe('Scrolling and Pagination (maxItemsToShow)', () => {
    const longList = Array.from({ length: 10 }, (_, i) => ({
      value: `Item ${i + 1}`,
      label: `Item ${i + 1}`,
      key: `Item ${i + 1}`,
    }));
    const MAX_ITEMS = 3;

    const renderScrollableList = async (initialActiveIndex: number = 0) => {
      // Define the props used for the initial render and subsequent rerenders
      const componentProps: BaseSelectionListProps<
        string,
        { value: string; label: string; key: string }
      > = {
        items: longList,
        maxItemsToShow: MAX_ITEMS,
        onSelect: mockOnSelect,
        onHighlight: mockOnHighlight,
        renderItem: mockRenderItem,
      };

      vi.mocked(useSelectionList).mockReturnValue({
        activeIndex: initialActiveIndex,
        setActiveIndex: vi.fn(),
      });

      mockRenderItem.mockImplementation(
        (item: (typeof longList)[0], context: RenderItemContext) => (
          <Text color={context.titleColor}>{item.label}</Text>
        ),
      );

      const { rerender, lastFrame, waitUntilReady, unmount } =
        await renderWithProviders(<BaseSelectionList {...componentProps} />);

      // Function to simulate the activeIndex changing over time
      const updateActiveIndex = async (newIndex: number) => {
        vi.mocked(useSelectionList).mockReturnValue({
          activeIndex: newIndex,
          setActiveIndex: vi.fn(),
        });

        rerender(<BaseSelectionList {...componentProps} />);
        await waitUntilReady();
      };

      return { updateActiveIndex, lastFrame, unmount };
    };

    it('should only show maxItemsToShow items initially', async () => {
      const { lastFrame, unmount } = await renderScrollableList(0);
      const output = lastFrame();

      expect(output).toContain('Item 1');
      expect(output).toContain('Item 3');
      expect(output).not.toContain('Item 4');
      unmount();
    });

    it('should scroll down when activeIndex moves beyond the visible window', async () => {
      const { updateActiveIndex, lastFrame, unmount } =
        await renderScrollableList(0);

      // Move to index 3 (Item 4). Should trigger scroll.
      // New visible window should be Items 2, 3, 4 (scroll offset 1).
      await updateActiveIndex(3);

      const output = lastFrame();
      expect(output).not.toContain('Item 1');
      expect(output).toContain('Item 2');
      expect(output).toContain('Item 4');
      expect(output).not.toContain('Item 5');
      unmount();
    });

    it('should scroll up when activeIndex moves before the visible window', async () => {
      const { updateActiveIndex, lastFrame, unmount } =
        await renderScrollableList(0);

      await updateActiveIndex(4);

      let output = lastFrame();
      expect(output).toContain('Item 3'); // Should see items 3, 4, 5
      expect(output).toContain('Item 5');
      expect(output).not.toContain('Item 2');

      // Now test scrolling up: move to index 1 (Item 2)
      // This should trigger scroll up to show items 2, 3, 4
      await updateActiveIndex(1);

      output = lastFrame();
      expect(output).toContain('Item 2');
      expect(output).toContain('Item 4');
      expect(output).not.toContain('Item 5'); // Item 5 should no longer be visible
      unmount();
    });

    it('should pin the scroll offset to the end if selection starts near the end', async () => {
      // List length 10. Max items 3. Active index 9 (last item).
      // Scroll offset should be 10 - 3 = 7.
      // Visible items: 8, 9, 10.
      const { lastFrame, unmount } = await renderScrollableList(9);

      const output = lastFrame();
      expect(output).toContain('Item 10');
      expect(output).toContain('Item 8');
      expect(output).not.toContain('Item 7');
      unmount();
    });

    it('should handle dynamic scrolling through multiple activeIndex changes', async () => {
      const { updateActiveIndex, lastFrame, unmount } =
        await renderScrollableList(0);

      expect(lastFrame()).toContain('Item 1');
      expect(lastFrame()).toContain('Item 3');

      // Scroll down gradually
      await updateActiveIndex(2); // Still within window
      expect(lastFrame()).toContain('Item 1');

      await updateActiveIndex(3); // Should trigger scroll
      let output = lastFrame();
      expect(output).toContain('Item 2');
      expect(output).toContain('Item 4');
      expect(output).not.toContain('Item 1');

      await updateActiveIndex(5); // Scroll further
      output = lastFrame();
      expect(output).toContain('Item 4');
      expect(output).toContain('Item 6');
      expect(output).not.toContain('Item 3');
      unmount();
    });

    it('should correctly identify the selected item within the visible window', async () => {
      const { unmount } = await renderScrollableList(1); // activeIndex 1 = Item 2

      expect(mockRenderItem).toHaveBeenCalledTimes(MAX_ITEMS);

      expect(mockRenderItem).toHaveBeenCalledWith(
        expect.objectContaining({ value: 'Item 1' }),
        expect.objectContaining({ isSelected: false }),
      );

      expect(mockRenderItem).toHaveBeenCalledWith(
        expect.objectContaining({ value: 'Item 2' }),
        expect.objectContaining({ isSelected: true }),
      );
      unmount();
    });

    it('should correctly identify the selected item when scrolled (high index)', async () => {
      const { unmount } = await renderScrollableList(5);

      // Item 6 (index 5) should be selected
      expect(mockRenderItem).toHaveBeenCalledWith(
        expect.objectContaining({ value: 'Item 6' }),
        expect.objectContaining({ isSelected: true }),
      );

      // Item 4 (index 3) should not be selected
      expect(mockRenderItem).toHaveBeenCalledWith(
        expect.objectContaining({ value: 'Item 4' }),
        expect.objectContaining({ isSelected: false }),
      );
      unmount();
    });

    it('should correctly calculate scroll offset during the initial render phase', async () => {
      // Verify that the component correctly calculates the scroll offset during the
      // initial render pass when starting with a high activeIndex.
      // List length 10, max items 3, activeIndex 9 (last item).
      const { unmount } = await renderScrollableList(9);

      const renderedItemValues = mockRenderItem.mock.calls.map(
        (call) => call[0].value,
      );

      // Item 1 (index 0) should not be rendered if the scroll offset is correctly
      // synchronized with the activeIndex from the start.
      expect(renderedItemValues).not.toContain('Item 1');

      // The items at the end of the list should be rendered.
      expect(renderedItemValues).toContain('Item 8');
      expect(renderedItemValues).toContain('Item 9');
      expect(renderedItemValues).toContain('Item 10');

      unmount();
    });

    it('should handle maxItemsToShow larger than the list length', async () => {
      const { lastFrame, unmount } = await renderComponent(
        { items: longList, maxItemsToShow: 15 },
        0,
      );
      const output = lastFrame();

      // Should show all available items (10 items)
      expect(output).toContain('Item 1');
      expect(output).toContain('Item 10');
      expect(mockRenderItem).toHaveBeenCalledTimes(10);
      unmount();
    });
  });

  describe('Mouse Interaction', () => {
    it('should register mouse click handler for each item', async () => {
      const { unmount } = await renderComponent();

      // items are A, B (disabled), C
      expect(useMouseClick).toHaveBeenCalledTimes(3);
      unmount();
    });

    it('should update activeIndex on first click and call onSelect on second click', async () => {
      const { unmount, waitUntilReady } = await renderComponent();
      await waitUntilReady();

      // items[0] is 'A' (enabled)
      // items[1] is 'B' (disabled)
      // items[2] is 'C' (enabled)

      // Get the mouse click handler for the third item (index 2)
      const mouseClickHandler = (useMouseClick as Mock).mock.calls[2][1];

      // First click on item C
      act(() => {
        mouseClickHandler();
      });

      expect(mockSetActiveIndex).toHaveBeenCalledWith(2);
      expect(mockOnSelect).not.toHaveBeenCalled();

      // Now simulate being on item C (isSelected = true)
      // Rerender or update mocks for the next check
      await renderComponent({}, 2);

      // Get the updated mouse click handler for item C
      // useMouseClick is called 3 more times on rerender
      const updatedMouseClickHandler = (useMouseClick as Mock).mock.calls[5][1];

      // Second click on item C
      act(() => {
        updatedMouseClickHandler();
      });

      expect(mockOnSelect).toHaveBeenCalledWith('C');
      unmount();
    });

    it('should not call onSelect when a disabled item is clicked', async () => {
      const { unmount, waitUntilReady } = await renderComponent();
      await waitUntilReady();

      // items[1] is 'B' (disabled)
      const mouseClickHandler = (useMouseClick as Mock).mock.calls[1][1];

      act(() => {
        mouseClickHandler();
      });

      expect(mockSetActiveIndex).not.toHaveBeenCalled();
      expect(mockOnSelect).not.toHaveBeenCalled();
      unmount();
    });

    it('should pass isActive: isFocused to useMouseClick', async () => {
      const { unmount } = await renderComponent({ isFocused: false });

      expect(useMouseClick).toHaveBeenCalledWith(
        expect.any(Object),
        expect.any(Function),
        { isActive: false },
      );
      unmount();
    });
  });

  describe('Scroll Arrows (showScrollArrows)', () => {
    const longList = Array.from({ length: 10 }, (_, i) => ({
      value: `Item ${i + 1}`,
      label: `Item ${i + 1}`,
      key: `Item ${i + 1}`,
    }));
    const MAX_ITEMS = 3;

    it('should not show arrows by default', async () => {
      const { lastFrame, unmount } = await renderComponent({
        items: longList,
        maxItemsToShow: MAX_ITEMS,
      });
      const output = lastFrame();

      expect(output).not.toContain('▲');
      expect(output).not.toContain('▼');
      unmount();
    });

    it('should show arrows with correct colors when enabled (at the top)', async () => {
      const { lastFrame, unmount } = await renderComponent(
        {
          items: longList,
          maxItemsToShow: MAX_ITEMS,
          showScrollArrows: true,
        },
        0,
      );

      expect(lastFrame()).toMatchSnapshot();
      unmount();
    });

    it('should show arrows and correct items when scrolled to the middle', async () => {
      const { lastFrame, unmount } = await renderComponent(
        { items: longList, maxItemsToShow: MAX_ITEMS, showScrollArrows: true },
        5,
      );

      expect(lastFrame()).toMatchSnapshot();
      unmount();
    });

    it('should show arrows and correct items when scrolled to the end', async () => {
      const { lastFrame, unmount } = await renderComponent(
        { items: longList, maxItemsToShow: MAX_ITEMS, showScrollArrows: true },
        9,
      );

      expect(lastFrame()).toMatchSnapshot();
      unmount();
    });

    it('should not show arrows when list fits entirely', async () => {
      const { lastFrame, unmount } = await renderComponent({
        items,
        maxItemsToShow: 5,
        showScrollArrows: true,
      });

      expect(lastFrame()).toMatchSnapshot();
      unmount();
    });
  });
});
