import { screen, waitFor, within } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { mockAnimationsApi } from 'jsdom-testing-mocks';

import { render, mockMatchMedia, mockResizeObserver } from '../../test-utils';

import {
  SelectInput,
  SelectInputOptionContent,
  type SelectInputOptionItem,
  type SelectInputProps,
} from '.';
import { Field } from '../../field/Field';

mockMatchMedia();
mockResizeObserver();
mockAnimationsApi();

describe('SelectInput', () => {
  it('renders placeholder', () => {
    render(
      <SelectInput
        placeholder="Currency"
        items={[
          { type: 'option', value: 'USD' },
          { type: 'option', value: 'EUR' },
        ]}
      />,
    );

    expect(screen.getByText('Currency')).toBeInTheDocument();
  });

  it('renders footer', async () => {
    render(
      <SelectInput
        items={[
          { type: 'option', value: 'USD' },
          { type: 'option', value: 'EUR' },
        ]}
        renderFooter={({ queryNormalized: normalizedQuery }) =>
          normalizedQuery != null ? (
            <>Showing results for &apos;{normalizedQuery}&apos;</>
          ) : (
            <>All items shown</>
          )
        }
        filterable
      />,
    );

    await userEvent.tab();
    await userEvent.keyboard(' ');

    const footer = screen.getByText('All items shown');
    expect(footer).toBeInTheDocument();

    await userEvent.keyboard('u');
    expect(footer).toHaveTextContent(/'u'$/);

    await userEvent.keyboard('r');
    expect(footer).toHaveTextContent(/'ur'$/);

    await userEvent.keyboard('x');
    expect(footer).toHaveTextContent(/'urx'$/);

    await userEvent.keyboard('{Backspace}');
    expect(footer).toHaveTextContent(/'ur'$/);
  });

  it('allows navigating the listbox with cursors', async () => {
    render(
      <SelectInput
        items={[
          { type: 'option', value: 'GBP' },
          { type: 'option', value: 'EUR' },
          { type: 'option', value: 'USD' },
        ]}
        renderFooter={() => <button type="button">Footer button</button>}
        filterable
      />,
    );

    // opened the dropbox, with search focused
    await userEvent.tab();
    await userEvent.keyboard(' ');
    expect(screen.getByRole('combobox')).toHaveFocus();

    // search still focused but listbox can be navigated via keyboard
    await userEvent.keyboard('{ArrowDown}');
    expect(screen.getByRole('combobox')).toHaveFocus();
    expect(screen.getByRole('option', { name: 'EUR' })).toHaveClass(
      'np-select-input-option-container--active',
    );

    // tab moves focus to listbox
    await userEvent.tab();
    expect(screen.getByRole('listbox')).toHaveFocus();
    expect(screen.getByRole('combobox')).not.toHaveFocus();

    // arrows still navigate within listbox
    await userEvent.keyboard('{ArrowDown}');
    expect(screen.getByRole('option', { name: 'USD' })).toHaveClass(
      'np-select-input-option-container--active',
    );

    // tab moves focus to footer but highlighted option is retained
    await userEvent.tab();
    expect(screen.getByRole('listbox')).not.toHaveFocus();
    expect(screen.getByRole('combobox')).not.toHaveFocus();
    expect(screen.getByText('Footer button')).toHaveFocus();
    expect(screen.getByRole('option', { name: 'USD' })).toHaveClass(
      'np-select-input-option-container--active',
    );

    // shift+tab moves focus back to listbox
    await userEvent.tab({ shift: true });
    expect(screen.getByRole('listbox')).toHaveFocus();
    expect(screen.getByRole('combobox')).not.toHaveFocus();
    expect(screen.getByText('Footer button')).not.toHaveFocus();

    // previously highlighted option is still active within listbox
    expect(screen.getByRole('option', { name: 'USD' })).toHaveClass(
      'np-select-input-option-container--active',
    );

    // arrows continue to navigate within listbox
    await userEvent.keyboard('{ArrowUp}');
    expect(screen.getByRole('option', { name: 'EUR' })).toHaveClass(
      'np-select-input-option-container--active',
    );

    // shift+tab moves focus back to search input
    await userEvent.tab({ shift: true });
    expect(screen.getByRole('combobox')).toHaveFocus();

    // arrows continue to navigate within listbox
    await userEvent.keyboard('{ArrowUp}');
    expect(screen.getByRole('option', { name: 'GBP' })).toHaveClass(
      'np-select-input-option-container--active',
    );
  });

  it('shows item selected via mouse', async () => {
    const handleClose = jest.fn();

    render(
      <SelectInput
        items={[
          { type: 'option', value: 'USD' },
          { type: 'option', value: 'EUR' },
        ]}
        onClose={handleClose}
      />,
    );

    expect(screen.queryByText('EUR')).not.toBeInTheDocument();

    const trigger = screen.getByRole('combobox');
    await userEvent.click(trigger);

    expect(handleClose).not.toHaveBeenCalled();

    const listbox = screen.getByRole('listbox');
    const option = within(listbox).getByRole('option', { name: 'EUR' });
    await userEvent.click(option);

    expect(handleClose).toHaveBeenCalledTimes(1);
    expect(trigger).toHaveTextContent('EUR');
  });

  it('filters items via keyboard', async () => {
    const handleClose = jest.fn();

    render(
      <SelectInput
        items={[
          {
            type: 'group',
            label: 'Popular currencies',
            options: [
              { type: 'option', value: 'USD' },
              { type: 'option', value: 'EUR' },
              { type: 'option', value: 'GBP' },
            ],
          },
        ]}
        filterable
        onClose={handleClose}
      />,
    );

    const trigger = screen.getByRole('combobox');
    await userEvent.tab();
    await userEvent.keyboard(' ');

    expect(handleClose).not.toHaveBeenCalled();

    const listbox = screen.getByRole('listbox');
    expect(within(listbox).getAllByRole('option')).toHaveLength(3);

    await userEvent.keyboard('u');
    expect(within(listbox).getAllByRole('option')).toHaveLength(2);

    await userEvent.keyboard('r');
    expect(within(listbox).getByRole('option')).toBeInTheDocument();

    await userEvent.keyboard('x');
    expect(within(listbox).queryByRole('option')).not.toBeInTheDocument();

    await userEvent.keyboard('{Backspace}');
    expect(within(listbox).getByRole('option')).toBeInTheDocument();

    const option = within(listbox).getAllByRole('option')[0];
    await userEvent.click(option);

    expect(handleClose).toHaveBeenCalledTimes(1);
    expect(trigger).toHaveTextContent('EUR');
  });

  it('clears filter query on close', async () => {
    const handleFilterChange = jest.fn();

    render(
      <SelectInput
        items={[
          { type: 'option', value: 'USD' },
          { type: 'option', value: 'EUR' },
        ]}
        filterable
        onFilterChange={handleFilterChange}
      />,
    );

    const trigger = screen.getByRole('combobox');
    await userEvent.tab();
    await userEvent.keyboard(' ');

    expect(handleFilterChange).not.toHaveBeenCalled();

    await userEvent.keyboard(' x');
    expect(handleFilterChange).toHaveBeenLastCalledWith({
      query: ' x',
      queryNormalized: 'x',
    });

    await userEvent.keyboard('{Escape}');
    await waitFor(() => {
      expect(handleFilterChange).toHaveBeenLastCalledWith({
        query: '',
        queryNormalized: null,
      });
    });

    await userEvent.click(trigger);

    const listbox = screen.getByRole('listbox');
    expect(within(listbox).getAllByRole('option')).toHaveLength(2);
  });

  it('filters items ignoring diacritics/accents', async () => {
    render(
      <SelectInput
        items={[
          { type: 'option', value: 'AX', filterMatchers: ['Åland Islands'] },
          { type: 'option', value: 'AL', filterMatchers: ['Albania'] },
          { type: 'option', value: 'DZ', filterMatchers: ['Algeria'] },
          { type: 'option', value: 'RE', filterMatchers: ['Réunion'] },
        ]}
        filterable
      />,
    );

    await userEvent.tab();
    await userEvent.keyboard(' ');

    const listbox = screen.getByRole('listbox');
    expect(within(listbox).getAllByRole('option')).toHaveLength(4);

    await userEvent.keyboard('aland');
    expect(within(listbox).getAllByRole('option')).toHaveLength(1);
    expect(within(listbox).getByRole('option')).toHaveTextContent('AX');

    const searchInput = screen.getByRole('combobox', { expanded: true });
    await userEvent.clear(searchInput);
    await userEvent.keyboard('reunion');
    expect(within(listbox).getAllByRole('option')).toHaveLength(1);
    expect(within(listbox).getByRole('option')).toHaveTextContent('RE');

    await userEvent.clear(searchInput);
    await userEvent.keyboard('Åland');
    expect(within(listbox).getAllByRole('option')).toHaveLength(1);
    expect(within(listbox).getByRole('option')).toHaveTextContent('AX');

    await userEvent.clear(searchInput);
    await userEvent.keyboard('Rèunion');
    expect(within(listbox).getAllByRole('option')).toHaveLength(1);
    expect(within(listbox).getByRole('option')).toHaveTextContent('RE');
  });

  it('selects multiple options', async () => {
    render(
      <SelectInput
        multiple
        items={[
          { type: 'option', value: 'USD' },
          { type: 'option', value: 'EUR' },
        ]}
      />,
    );

    const trigger = screen.getByRole('combobox');
    await userEvent.click(trigger);

    const listbox = screen.getByRole('listbox');
    const options = within(listbox).getAllByRole('option');
    for (const option of options) {
      await userEvent.click(option);
    }

    expect(trigger).toHaveTextContent('USD, EUR');
  });

  it('supports custom `id` attribute', () => {
    render(<SelectInput id="custom" items={[]} />);

    const trigger = screen.getByRole('combobox');
    expect(trigger).toHaveAttribute('id', 'custom');
  });

  it('supports `Field` for labeling', () => {
    render(
      <Field label="Currency">
        <SelectInput items={[{ type: 'option', value: 'USD' }]} value="USD" />
      </Field>,
    );
    expect(screen.getByLabelText(/Currency/)).toHaveAttribute('aria-haspopup');
  });

  it('deduplicates search results across groups using compareValues as key', async () => {
    interface Currency {
      code: string;
      name: string;
    }

    const usdInGroup1: Currency = { code: 'USD', name: 'US Dollar' };
    const usdInGroup2: Currency = { code: 'USD', name: 'US Dollar' };
    const eur: Currency = { code: 'EUR', name: 'Euro' };
    const gbp: Currency = { code: 'GBP', name: 'British Pound' };

    render(
      <SelectInput<Currency>
        items={[
          {
            type: 'group',
            label: 'Popular',
            options: [
              { type: 'option', value: usdInGroup1 },
              { type: 'option', value: eur },
            ],
          },
          {
            type: 'group',
            label: 'All currencies',
            options: [
              { type: 'option', value: usdInGroup2 },
              { type: 'option', value: gbp },
            ],
          },
        ]}
        compareValues="code"
        renderValue={(currency) => currency.name}
        filterable
      />,
    );

    const trigger = screen.getByRole('combobox');
    await userEvent.click(trigger);

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

    // Before filtering, should show all 4 options (no deduplication yet)
    let options = within(listbox).getAllByRole('option');
    expect(options).toHaveLength(4);

    const usdOptions = within(listbox).getAllByText('US Dollar');
    expect(usdOptions).toHaveLength(2);

    // Start filtering - type a search query to trigger deduplication
    const searchInput = screen.getByRole('combobox', { expanded: true });
    await userEvent.type(searchInput, 'u');

    // After filtering with 'u', should show 3 unique options (USD deduplicated, EUR, GBP)
    options = within(listbox).getAllByRole('option');
    expect(options).toHaveLength(3);
    expect(within(listbox).getByText('Euro')).toBeInTheDocument();
    expect(within(listbox).getByText('British Pound')).toBeInTheDocument();

    // Filter more specifically for 'dollar'
    await userEvent.clear(searchInput);
    await userEvent.type(searchInput, 'dollar');

    const filteredOptions = within(listbox).getAllByRole('option');
    // Should only show 1 USD option, not 2
    expect(filteredOptions).toHaveLength(1);
    expect(within(listbox).getByText('US Dollar')).toBeInTheDocument();
  });

  it('deduplicates search results across groups using compareValues as function', async () => {
    interface Item {
      id: number;
      label: string;
    }

    const item1Group1: Item = { id: 1, label: 'Item One' };
    const item2Group1: Item = { id: 2, label: 'Item Two' };

    const item1Group2: Item = { id: 1, label: 'Item One' };

    render(
      <SelectInput<Item>
        items={[
          {
            type: 'group',
            label: 'Group A',
            options: [
              { type: 'option', value: item1Group1 },
              { type: 'option', value: item2Group1 },
            ],
          },
          {
            type: 'group',
            label: 'Group B',
            options: [{ type: 'option', value: item1Group2 }],
          },
        ]}
        compareValues={(a, b) => a?.id === b?.id}
        renderValue={(item) => item.label}
        filterable
      />,
    );

    const trigger = screen.getByRole('combobox');
    await userEvent.click(trigger);

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

    // Before filtering, should show all 3 options (no deduplication yet)
    let options = within(listbox).getAllByRole('option');
    expect(options).toHaveLength(3);

    // Start filtering - type a search query to trigger deduplication
    const searchInput = screen.getByRole('combobox', { expanded: true });
    await userEvent.type(searchInput, 'item');

    // After filtering, should show 2 unique options (item with id:1 deduplicated, item with id:2)
    options = within(listbox).getAllByRole('option');
    expect(options).toHaveLength(2);
    expect(within(listbox).getByText('Item One')).toBeInTheDocument();
    expect(within(listbox).getByText('Item Two')).toBeInTheDocument();
  });

  it('sorts filtered options using sortFilteredOptions prop', async () => {
    interface Country {
      code: string;
      name: string;
      keywords: string[];
    }

    const countries: Country[] = [
      { code: 'AD', name: 'Andorra', keywords: ['united states dollar'] },
      { code: 'DE', name: 'Germany', keywords: ['EUR'] },
      { code: 'US', name: 'United States', keywords: ['United States dollar', 'USD'] },
      { code: 'ZM', name: 'Zambia', keywords: ['USD', 'united states dollar'] },
    ];

    render(
      <SelectInput<Country>
        items={countries.map((country) => ({
          type: 'option',
          value: country,
          filterMatchers: country.keywords,
        }))}
        renderValue={(country) => country.name}
        filterable
        sortFilteredOptions={(a, b, searchQuery) => {
          const query = searchQuery.toLowerCase();
          const nameA = a.value.name.toLowerCase();
          const nameB = b.value.name.toLowerCase();

          const aMatch = nameA.includes(query);
          const bMatch = nameB.includes(query);

          if (aMatch && !bMatch) return -1;
          if (!aMatch && bMatch) return 1;

          return nameA.localeCompare(nameB);
        }}
      />,
    );

    const trigger = screen.getByRole('combobox');
    await userEvent.click(trigger);

    const searchInput = screen.getByRole('combobox', { expanded: true });
    await userEvent.type(searchInput, 'united');

    const listbox = screen.getByRole('listbox');
    const options = within(listbox).getAllByRole('option');

    expect(options).toHaveLength(3);
    expect(options[0]).toHaveTextContent('United States');
    expect(options[1]).toHaveTextContent('Andorra');
    expect(options[2]).toHaveTextContent('Zambia');
  });

  it('adds class to description wrapper when description is present', () => {
    interface Currency {
      code: string;
      name: string;
      description: string;
    }

    const usd: Currency = { code: 'USD', name: 'US Dollar', description: 'United States Dollar' };
    const eur: Currency = { code: 'EUR', name: 'Euro', description: 'European Currency' };

    render(
      <SelectInput<Currency>
        items={[
          { type: 'option', value: usd },
          { type: 'option', value: eur },
        ]}
        renderValue={(currency) => (
          <SelectInputOptionContent title={currency.name} description={currency.description} />
        )}
        value={usd}
      />,
    );

    const descriptionElement = screen.getByText('United States Dollar');
    expect(descriptionElement).toHaveClass('np-select-input-option-description-in-trigger');
  });

  describe('listbox label', () => {
    const fieldLabel = 'Fruits';
    const triggerLabel = 'Select fruit';
    const options: SelectInputOptionItem[] = [
      { type: 'option', value: 'Banana' },
      { type: 'option', value: 'Orange' },
      { type: 'option', value: 'Olive' },
    ];
    const requiredTriggerButtonProps = {
      id: undefined,
      'aria-labelledby': undefined,
      'aria-describedby': undefined,
      'aria-invalid': undefined,
      'aria-label': undefined,
    };

    const renderSelectInput = (props: Omit<SelectInputProps<string | null>, 'items'> = {}) =>
      render(
        <Field label={fieldLabel} id="selectId">
          <SelectInput {...props} items={options} />
        </Field>,
      );

    it("should propagate trigger's label if nothing is selected", async () => {
      renderSelectInput({
        UNSAFE_triggerButtonProps: {
          ...requiredTriggerButtonProps,
          'aria-label': triggerLabel,
        },
      });
      const trigger = screen.getByRole('combobox');
      await userEvent.click(trigger);
      expect(screen.getByRole('listbox', { name: triggerLabel })).toBeInTheDocument();
    });

    it("should propagate trigger's label if an option is selected", async () => {
      renderSelectInput({
        UNSAFE_triggerButtonProps: {
          ...requiredTriggerButtonProps,
          'aria-label': triggerLabel,
        },
        value: options[1].value,
      });
      const trigger = screen.getByRole('combobox');
      await userEvent.click(trigger);
      expect(screen.getByRole('listbox', { name: triggerLabel })).toBeInTheDocument();
    });

    it("should propagate trigger's label by id", async () => {
      const customLabelId = 'customLabelId';
      renderSelectInput({
        UNSAFE_triggerButtonProps: {
          ...requiredTriggerButtonProps,
          'aria-labelledby': customLabelId,
        },
      });
      const trigger = screen.getByRole('combobox');
      await userEvent.click(trigger);
      expect(screen.getByRole('listbox')).toHaveAttribute('aria-labelledby', customLabelId);
    });

    it("should propagate input's label by id", async () => {
      renderSelectInput();
      const trigger = screen.getByRole('combobox');
      await userEvent.click(trigger);
      expect(screen.getByRole('listbox', { name: fieldLabel })).toBeInTheDocument();
    });

    it('should prefer explicit label over label ids', async () => {
      const customLabelId = 'customLabelId';
      renderSelectInput({
        UNSAFE_triggerButtonProps: {
          ...requiredTriggerButtonProps,
          'aria-labelledby': customLabelId,
          'aria-label': triggerLabel,
        },
      });
      const trigger = screen.getByRole('combobox');
      await userEvent.click(trigger);
      expect(screen.getByRole('listbox', { name: triggerLabel })).toBeInTheDocument();
      expect(screen.getByRole('listbox')).not.toHaveAttribute('aria-labelledby');
    });

    it('should have no label if none of the above are provided', async () => {
      render(<SelectInput items={options} />);
      const trigger = screen.getByRole('combobox');
      await userEvent.click(trigger);
      const listBox = screen.getByRole('listbox');
      expect(listBox).not.toHaveAttribute('aria-label');
      expect(listBox).not.toHaveAttribute('aria-labelledby');
    });
  });
});
