import '@testing-library/jest-dom/vitest';
import { render, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { describe, expect, test, vi } from 'vitest';
import { ref } from 'vue';
import {
  UiSelect,
  UiSelectTrigger,
  UiSelectContent,
  UiSelectItem,
  UiSelectValue,
} from '../index';

/**
 *
 * @param options
 * @param options.placeholder
 * @param options.items
 * @param options.disabled
 * @param options.defaultValue
 */
function renderSelect(
  options: {
    placeholder?: string;
    items?: Array<{ value: string; label: string; disabled?: boolean }>;
    disabled?: boolean;
    defaultValue?: string;
  } = {},
) {
  const {
    placeholder = 'Select an option',
    items = [
      { value: 'option1', label: 'Option 1' },
      { value: 'option2', label: 'Option 2' },
      { value: 'option3', label: 'Option 3' },
    ],
    disabled = false,
    defaultValue,
  } = options;

  return render({
    components: {
      UiSelect,
      UiSelectTrigger,
      UiSelectContent,
      UiSelectItem,
      UiSelectValue,
    },
    template: `
      <UiSelect :disabled="disabled" :default-value="defaultValue">
        <UiSelectTrigger>
          <UiSelectValue :placeholder="placeholder" />
        </UiSelectTrigger>
        <UiSelectContent>
          <UiSelectItem 
            v-for="item in items" 
            :key="item.value" 
            :value="item.value"
            :disabled="item.disabled"
          >
            {{ item.label }}
          </UiSelectItem>
        </UiSelectContent>
      </UiSelect>
    `,
    setup() {
      return { placeholder, items, disabled, defaultValue };
    },
  });
}

describe('UiSelect', () => {
  test('renders trigger with placeholder', () => {
    const { getByRole } = renderSelect({ placeholder: 'Choose an option' });
    const trigger = getByRole('combobox');
    expect(trigger).toBeInTheDocument();
    expect(trigger).toHaveTextContent('Choose an option');
  });

  test('select is closed by default', () => {
    const { queryByRole } = renderSelect();
    expect(queryByRole('listbox')).not.toBeInTheDocument();
  });

  test('opens dropdown when trigger is clicked', async () => {
    const user = userEvent.setup();
    const { getByRole } = renderSelect();

    await user.click(getByRole('combobox'));

    await waitFor(() => {
      expect(getByRole('listbox')).toBeInTheDocument();
    });
  });

  test('renders all options when open', async () => {
    const user = userEvent.setup();
    const { getByRole, getAllByRole } = renderSelect({
      items: [
        { value: 'a', label: 'Alpha' },
        { value: 'b', label: 'Beta' },
        { value: 'c', label: 'Gamma' },
      ],
    });

    await user.click(getByRole('combobox'));

    await waitFor(() => {
      const options = getAllByRole('option');
      expect(options).toHaveLength(3);
      expect(options[0]).toHaveTextContent('Alpha');
      expect(options[1]).toHaveTextContent('Beta');
      expect(options[2]).toHaveTextContent('Gamma');
    });
  });

  test('selects an option when clicked', async () => {
    const user = userEvent.setup();
    const { getByRole } = renderSelect();

    await user.click(getByRole('combobox'));
    await waitFor(() => expect(getByRole('listbox')).toBeInTheDocument());

    await user.click(getByRole('option', { name: 'Option 2' }));

    await waitFor(() => {
      expect(getByRole('combobox')).toHaveTextContent('Option 2');
    });
  });

  test('closes dropdown after selection', async () => {
    const user = userEvent.setup();
    const { getByRole, queryByRole } = renderSelect();

    await user.click(getByRole('combobox'));
    await waitFor(() => expect(getByRole('listbox')).toBeInTheDocument());

    await user.click(getByRole('option', { name: 'Option 1' }));

    await waitFor(() => {
      expect(queryByRole('listbox')).not.toBeInTheDocument();
    });
  });

  test('closes dropdown when Escape is pressed', async () => {
    const user = userEvent.setup();
    const { getByRole, queryByRole } = renderSelect();

    await user.click(getByRole('combobox'));
    await waitFor(() => expect(getByRole('listbox')).toBeInTheDocument());

    await user.keyboard('{Escape}');

    await waitFor(() => {
      expect(queryByRole('listbox')).not.toBeInTheDocument();
    });
  });

  test('renders with default value', async () => {
    const { getByRole } = render({
      components: {
        UiSelect,
        UiSelectTrigger,
        UiSelectContent,
        UiSelectItem,
        UiSelectValue,
      },
      template: `
        <UiSelect default-value="option2">
          <UiSelectTrigger>
            <UiSelectValue placeholder="Select" />
          </UiSelectTrigger>
          <UiSelectContent>
            <UiSelectItem value="option1">Option 1</UiSelectItem>
            <UiSelectItem value="option2">Option 2</UiSelectItem>
          </UiSelectContent>
        </UiSelect>
      `,
    });
    await waitFor(() => {
      expect(getByRole('combobox')).toHaveTextContent('Option 2');
    });
  });

  test('emits update:modelValue when selection changes', async () => {
    const user = userEvent.setup();
    const onUpdateModelValue = vi.fn();

    const { getByRole } = render({
      components: {
        UiSelect,
        UiSelectTrigger,
        UiSelectContent,
        UiSelectItem,
        UiSelectValue,
      },
      template: `
        <UiSelect @update:modelValue="onUpdateModelValue">
          <UiSelectTrigger>
            <UiSelectValue placeholder="Select" />
          </UiSelectTrigger>
          <UiSelectContent>
            <UiSelectItem value="test">Test Option</UiSelectItem>
          </UiSelectContent>
        </UiSelect>
      `,
      setup() {
        return { onUpdateModelValue };
      },
    });

    await user.click(getByRole('combobox'));
    await waitFor(() => expect(getByRole('listbox')).toBeInTheDocument());

    await user.click(getByRole('option', { name: 'Test Option' }));

    expect(onUpdateModelValue).toHaveBeenCalledWith('test');
  });

  describe('multiple', () => {
    const triggerRoleOptions = { hidden: true } as const;

    function renderMultiSelect(
      options: {
        placeholder?: string;
        items?: Array<{ value: string; label: string; disabled?: boolean }>;
        modelValue?: string[];
        defaultValue?: string[];
      } = {},
    ) {
      const {
        placeholder = 'Select fruits',
        items = [
          { value: 'apple', label: 'Apple' },
          { value: 'banana', label: 'Banana' },
          { value: 'orange', label: 'Orange' },
        ],
        modelValue,
        defaultValue,
      } = options;

      return render({
        components: {
          UiSelect,
          UiSelectTrigger,
          UiSelectContent,
          UiSelectItem,
          UiSelectValue,
        },
        template: `
          <UiSelect
            :multiple="true"
            :model-value="value"
            :default-value="defaultValue"
            @update:modelValue="(v) => { value = v; onUpdate(v); }"
          >
            <UiSelectTrigger>
              <UiSelectValue :placeholder="placeholder" />
            </UiSelectTrigger>
            <UiSelectContent>
              <UiSelectItem
                v-for="item in items"
                :key="item.value"
                :value="item.value"
                :disabled="item.disabled"
              >
                {{ item.label }}
              </UiSelectItem>
            </UiSelectContent>
          </UiSelect>
        `,
        setup() {
          const value = ref<string[] | undefined>(modelValue);
          const onUpdate = vi.fn();
          return { value, onUpdate, placeholder, items, defaultValue };
        },
      });
    }

    test('renders trigger with placeholder and no selection', () => {
      const { getByRole } = renderMultiSelect({
        placeholder: 'Pick some fruits',
      });
      const trigger = getByRole('combobox', triggerRoleOptions);
      expect(trigger).toBeInTheDocument();
      expect(trigger).toHaveTextContent('Pick some fruits');
    });

    test('opens dropdown on trigger click', async () => {
      const user = userEvent.setup();
      const { getByRole } = renderMultiSelect();

      await user.click(getByRole('combobox', triggerRoleOptions));

      await waitFor(() => {
        expect(getByRole('listbox')).toBeInTheDocument();
      });
    });

    test('selects multiple items and stays open', async () => {
      const user = userEvent.setup();
      const { getByRole } = renderMultiSelect();

      await user.click(getByRole('combobox', triggerRoleOptions));
      await waitFor(() => expect(getByRole('listbox')).toBeInTheDocument());

      await user.click(getByRole('option', { name: /apple/i }));
      await user.click(getByRole('option', { name: /orange/i }));

      // multi-select stays open across clicks
      expect(getByRole('listbox')).toBeInTheDocument();

      await waitFor(() => {
        expect(getByRole('combobox', triggerRoleOptions)).toHaveTextContent(
          /apple/i,
        );
        expect(getByRole('combobox', triggerRoleOptions)).toHaveTextContent(
          /orange/i,
        );
      });
    });

    test('toggles a value off when the same item is clicked twice', async () => {
      const user = userEvent.setup();
      const { getByRole } = renderMultiSelect();

      await user.click(getByRole('combobox', triggerRoleOptions));
      await waitFor(() => expect(getByRole('listbox')).toBeInTheDocument());

      await user.click(getByRole('option', { name: /apple/i }));
      await waitFor(() =>
        expect(getByRole('combobox', triggerRoleOptions)).toHaveTextContent(
          /apple/i,
        ),
      );

      await user.click(getByRole('option', { name: /apple/i }));

      await waitFor(() => {
        expect(getByRole('combobox', triggerRoleOptions)).not.toHaveTextContent(
          /apple/i,
        );
        expect(getByRole('combobox', triggerRoleOptions)).toHaveTextContent(
          /select fruits/i,
        );
      });
    });

    test('emits update:modelValue with an array', async () => {
      const user = userEvent.setup();
      const onUpdate = vi.fn();

      const { getByRole } = render({
        components: {
          UiSelect,
          UiSelectTrigger,
          UiSelectContent,
          UiSelectItem,
          UiSelectValue,
        },
        template: `
          <UiSelect :multiple="true" @update:modelValue="onUpdate">
            <UiSelectTrigger>
              <UiSelectValue placeholder="Pick" />
            </UiSelectTrigger>
            <UiSelectContent>
              <UiSelectItem value="a">A</UiSelectItem>
              <UiSelectItem value="b">B</UiSelectItem>
            </UiSelectContent>
          </UiSelect>
        `,
        setup() {
          return { onUpdate };
        },
      });

      await user.click(getByRole('combobox', triggerRoleOptions));
      await waitFor(() => expect(getByRole('listbox')).toBeInTheDocument());

      await user.click(getByRole('option', { name: 'A' }));

      await waitFor(() => {
        expect(onUpdate).toHaveBeenCalled();
        const lastCall = onUpdate.mock.calls.at(-1)?.[0];
        expect(Array.isArray(lastCall)).toBe(true);
        expect(lastCall).toContain('a');
      });
    });

    test('renders one hidden input per selected value when name is set', async () => {
      const user = userEvent.setup();
      const { container, getByRole } = render({
        components: {
          UiSelect,
          UiSelectTrigger,
          UiSelectContent,
          UiSelectItem,
          UiSelectValue,
        },
        template: `
          <form>
            <UiSelect :multiple="true" name="fruits" :default-value="['apple']">
              <UiSelectTrigger>
                <UiSelectValue placeholder="Pick" />
              </UiSelectTrigger>
              <UiSelectContent>
                <UiSelectItem value="apple">Apple</UiSelectItem>
                <UiSelectItem value="banana">Banana</UiSelectItem>
              </UiSelectContent>
            </UiSelect>
          </form>
        `,
      });

      // Initial selection (`apple`) renders one hidden input under `name=fruits`.
      await waitFor(() => {
        const hidden = container.querySelectorAll<HTMLInputElement>(
          'input[type="hidden"][name="fruits"]',
        );
        expect(hidden).toHaveLength(1);
        expect(hidden[0]?.value).toBe('apple');
      });

      // Selecting a second item adds a second hidden input — same name,
      // additional value (mirrors native `<select multiple>` submission).
      await user.click(getByRole('combobox', triggerRoleOptions));
      await waitFor(() => expect(getByRole('listbox')).toBeInTheDocument());
      await user.click(getByRole('option', { name: /banana/i }));

      await waitFor(() => {
        const hidden = container.querySelectorAll<HTMLInputElement>(
          'input[type="hidden"][name="fruits"]',
        );
        expect(hidden).toHaveLength(2);
        expect(Array.from(hidden, (el) => el.value).sort()).toEqual([
          'apple',
          'banana',
        ]);
      });

      // FormData round-trip: the form serializes both selections under
      // the same key, exactly like a native multi-select would.
      const form = container.querySelector('form');
      const formData = new FormData(form as HTMLFormElement);
      expect(formData.getAll('fruits').sort()).toEqual(['apple', 'banana']);
    });

    test('does not render hidden inputs when name is not set', () => {
      const { container } = render({
        components: {
          UiSelect,
          UiSelectTrigger,
          UiSelectContent,
          UiSelectItem,
          UiSelectValue,
        },
        template: `
          <UiSelect :multiple="true" :default-value="['apple']">
            <UiSelectTrigger>
              <UiSelectValue placeholder="Pick" />
            </UiSelectTrigger>
            <UiSelectContent>
              <UiSelectItem value="apple">Apple</UiSelectItem>
            </UiSelectContent>
          </UiSelect>
        `,
      });

      const hidden = container.querySelectorAll('input[type="hidden"]');
      expect(hidden).toHaveLength(0);
    });

    test('blocks form submission when required and empty', async () => {
      const submit = vi.fn((event: Event) => {
        event.preventDefault();
      });

      const { container } = render({
        components: {
          UiSelect,
          UiSelectTrigger,
          UiSelectContent,
          UiSelectItem,
          UiSelectValue,
        },
        template: `
          <form @submit="onSubmit">
            <UiSelect :multiple="true" name="fruits" :required="true">
              <UiSelectTrigger>
                <UiSelectValue placeholder="Pick" />
              </UiSelectTrigger>
              <UiSelectContent>
                <UiSelectItem value="apple">Apple</UiSelectItem>
              </UiSelectContent>
            </UiSelect>
            <button type="submit">Submit</button>
          </form>
        `,
        setup() {
          return { onSubmit: submit };
        },
      });

      const form = container.querySelector('form') as HTMLFormElement;

      // Native constraint validation should fail — the hidden required
      // input is empty, so the form is invalid and submit doesn't fire.
      expect(form.checkValidity()).toBe(false);
      form.requestSubmit();
      expect(submit).not.toHaveBeenCalled();
    });

    test('allows form submission when required and at least one value is selected', async () => {
      const submit = vi.fn((event: Event) => {
        event.preventDefault();
      });

      const { container } = render({
        components: {
          UiSelect,
          UiSelectTrigger,
          UiSelectContent,
          UiSelectItem,
          UiSelectValue,
        },
        template: `
          <form @submit="onSubmit">
            <UiSelect :multiple="true" name="fruits" :required="true" :default-value="['apple']">
              <UiSelectTrigger>
                <UiSelectValue placeholder="Pick" />
              </UiSelectTrigger>
              <UiSelectContent>
                <UiSelectItem value="apple">Apple</UiSelectItem>
              </UiSelectContent>
            </UiSelect>
            <button type="submit">Submit</button>
          </form>
        `,
        setup() {
          return { onSubmit: submit };
        },
      });

      const form = container.querySelector('form') as HTMLFormElement;

      expect(form.checkValidity()).toBe(true);
      form.requestSubmit();
      expect(submit).toHaveBeenCalledTimes(1);
    });

    test('reflects controlled modelValue array on the trigger', async () => {
      const { getByRole } = render({
        components: {
          UiSelect,
          UiSelectTrigger,
          UiSelectContent,
          UiSelectItem,
          UiSelectValue,
        },
        template: `
          <UiSelect :multiple="true" :model-value="value">
            <UiSelectTrigger>
              <UiSelectValue placeholder="Pick" />
            </UiSelectTrigger>
            <UiSelectContent>
              <UiSelectItem value="a">Alpha</UiSelectItem>
              <UiSelectItem value="b">Beta</UiSelectItem>
              <UiSelectItem value="c">Gamma</UiSelectItem>
            </UiSelectContent>
          </UiSelect>
        `,
        setup() {
          const value = ref(['a', 'c']);
          return { value };
        },
      });

      await waitFor(() => {
        const trigger = getByRole('combobox', triggerRoleOptions);
        expect(trigger).toHaveTextContent(/alpha/i);
        expect(trigger).toHaveTextContent(/gamma/i);
        expect(trigger).not.toHaveTextContent(/beta/i);
      });
    });

    test('controlled: stays pinned to modelValue when parent ignores the update', async () => {
      // Parent passes a fixed `modelValue` and intentionally does NOT
      // update it in response to `update:modelValue`. The component
      // should treat this as a strictly controlled prop: the emit
      // happens, but the visible selection never drifts away from the
      // value the parent owns.
      const user = userEvent.setup();
      const onUpdate = vi.fn();

      const { getByRole } = render({
        components: {
          UiSelect,
          UiSelectTrigger,
          UiSelectContent,
          UiSelectItem,
          UiSelectValue,
        },
        template: `
          <UiSelect :multiple="true" :model-value="value" @update:modelValue="onUpdate">
            <UiSelectTrigger>
              <UiSelectValue placeholder="Pick" />
            </UiSelectTrigger>
            <UiSelectContent>
              <UiSelectItem value="a">Alpha</UiSelectItem>
              <UiSelectItem value="b">Beta</UiSelectItem>
            </UiSelectContent>
          </UiSelect>
        `,
        setup() {
          // Frozen on purpose — never reassigned, never mutated.
          const value = ref<string[]>(['a']);
          return { value, onUpdate };
        },
      });

      await waitFor(() => {
        expect(getByRole('combobox', triggerRoleOptions)).toHaveTextContent(
          /alpha/i,
        );
      });

      await user.click(getByRole('combobox', triggerRoleOptions));
      await waitFor(() => expect(getByRole('listbox')).toBeInTheDocument());
      await user.click(getByRole('option', { name: /beta/i }));

      expect(onUpdate).toHaveBeenCalled();

      // Trigger should still reflect ONLY the parent-owned value.
      await waitFor(() => {
        const trigger = getByRole('combobox', triggerRoleOptions);
        expect(trigger).toHaveTextContent(/alpha/i);
        expect(trigger).not.toHaveTextContent(/beta/i);
      });
    });

    test('controlled: hidden form inputs follow modelValue, not internal clicks', async () => {
      const user = userEvent.setup();

      const { container, getByRole } = render({
        components: {
          UiSelect,
          UiSelectTrigger,
          UiSelectContent,
          UiSelectItem,
          UiSelectValue,
        },
        template: `
          <form>
            <UiSelect
              :multiple="true"
              name="fruits"
              :model-value="value"
            >
              <UiSelectTrigger>
                <UiSelectValue placeholder="Pick" />
              </UiSelectTrigger>
              <UiSelectContent>
                <UiSelectItem value="apple">Apple</UiSelectItem>
                <UiSelectItem value="banana">Banana</UiSelectItem>
              </UiSelectContent>
            </UiSelect>
          </form>
        `,
        setup() {
          const value = ref<string[]>(['apple']);
          return { value };
        },
      });

      // Snapshot of hidden inputs before any interaction — should
      // mirror the controlled `modelValue`.
      await waitFor(() => {
        const hidden = container.querySelectorAll<HTMLInputElement>(
          'input[type="hidden"][name="fruits"]',
        );
        expect(Array.from(hidden, (el) => el.value)).toEqual(['apple']);
      });

      // Click another option — parent doesn't update `value`, so
      // hidden inputs must stay frozen on the controlled value.
      await user.click(getByRole('combobox', triggerRoleOptions));
      await waitFor(() => expect(getByRole('listbox')).toBeInTheDocument());
      await user.click(getByRole('option', { name: /banana/i }));

      const hiddenAfter = container.querySelectorAll<HTMLInputElement>(
        'input[type="hidden"][name="fruits"]',
      );
      expect(Array.from(hiddenAfter, (el) => el.value)).toEqual(['apple']);
    });

    test('uncontrolled: clicks update internal state and trigger label', async () => {
      // No `modelValue` prop → uncontrolled mode. Internal state
      // updates on click should drive the trigger label without the
      // parent needing to wire anything up.
      const user = userEvent.setup();
      const { getByRole } = render({
        components: {
          UiSelect,
          UiSelectTrigger,
          UiSelectContent,
          UiSelectItem,
          UiSelectValue,
        },
        template: `
          <UiSelect :multiple="true" :default-value="['apple']">
            <UiSelectTrigger>
              <UiSelectValue placeholder="Pick" />
            </UiSelectTrigger>
            <UiSelectContent>
              <UiSelectItem value="apple">Apple</UiSelectItem>
              <UiSelectItem value="banana">Banana</UiSelectItem>
            </UiSelectContent>
          </UiSelect>
        `,
      });

      await waitFor(() =>
        expect(getByRole('combobox', triggerRoleOptions)).toHaveTextContent(
          /apple/i,
        ),
      );

      await user.click(getByRole('combobox', triggerRoleOptions));
      await waitFor(() => expect(getByRole('listbox')).toBeInTheDocument());
      await user.click(getByRole('option', { name: /banana/i }));

      await waitFor(() => {
        const trigger = getByRole('combobox', triggerRoleOptions);
        expect(trigger).toHaveTextContent(/apple/i);
        expect(trigger).toHaveTextContent(/banana/i);
      });
    });

    test('reactive mode: toggling :multiple keeps the core select branch', async () => {
      const { getByRole, queryByRole, rerender } = render({
        components: {
          UiSelect,
          UiSelectTrigger,
          UiSelectContent,
          UiSelectItem,
          UiSelectValue,
        },
        props: { multiple: { type: Boolean, default: false } },
        template: `
          <UiSelect :multiple="multiple">
            <UiSelectTrigger>
              <UiSelectValue placeholder="Pick" />
            </UiSelectTrigger>
            <UiSelectContent>
              <UiSelectItem value="a">Alpha</UiSelectItem>
              <UiSelectItem value="b">Beta</UiSelectItem>
            </UiSelectContent>
          </UiSelect>
        `,
      });

      // Both modes now render through the same core Select trigger.
      expect(getByRole('combobox')).toBeInTheDocument();
      expect(queryByRole('button')).not.toBeInTheDocument();

      await rerender({ multiple: true });

      // Multi-select keeps the same core Select trigger semantics.
      await waitFor(() => {
        expect(getByRole('combobox')).toBeInTheDocument();
        expect(queryByRole('button')).not.toBeInTheDocument();
      });

      await rerender({ multiple: false });

      // And back the other way — both modes keep the same trigger role.
      await waitFor(() => {
        expect(getByRole('combobox')).toBeInTheDocument();
        expect(queryByRole('button')).not.toBeInTheDocument();
      });
    });

    test('controlled: trigger updates when parent reassigns modelValue', async () => {
      const { getByRole, rerender } = render({
        components: {
          UiSelect,
          UiSelectTrigger,
          UiSelectContent,
          UiSelectItem,
          UiSelectValue,
        },
        props: { value: { type: Array, default: () => ['apple'] } },
        template: `
          <UiSelect :multiple="true" :model-value="value">
            <UiSelectTrigger>
              <UiSelectValue placeholder="Pick" />
            </UiSelectTrigger>
            <UiSelectContent>
              <UiSelectItem value="apple">Apple</UiSelectItem>
              <UiSelectItem value="banana">Banana</UiSelectItem>
            </UiSelectContent>
          </UiSelect>
        `,
      });

      await waitFor(() =>
        expect(getByRole('combobox', triggerRoleOptions)).toHaveTextContent(
          /apple/i,
        ),
      );

      await rerender({ value: ['banana'] });

      await waitFor(() => {
        const trigger = getByRole('combobox', triggerRoleOptions);
        expect(trigger).toHaveTextContent(/banana/i);
        expect(trigger).not.toHaveTextContent(/apple/i);
      });
    });
  });

  test('reflects controlled modelValue', async () => {
    const { getByRole } = render({
      components: {
        UiSelect,
        UiSelectTrigger,
        UiSelectContent,
        UiSelectItem,
        UiSelectValue,
      },
      template: `
        <UiSelect :model-value="value">
          <UiSelectTrigger>
            <UiSelectValue placeholder="Select" />
          </UiSelectTrigger>
          <UiSelectContent>
            <UiSelectItem value="controlled">Controlled Value</UiSelectItem>
          </UiSelectContent>
        </UiSelect>
      `,
      setup() {
        const value = ref('controlled');
        return { value };
      },
    });

    await waitFor(() => {
      expect(getByRole('combobox')).toHaveTextContent('Controlled Value');
    });
  });
});
