import { DOMWrapper, mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const routerGet = vi.hoisted(() => vi.fn());

vi.mock('@inertiajs/vue3', async () => {
    const { defineComponent, h } = await import('vue');

    return {
        Link: defineComponent({
            name: 'InertiaLinkStub',
            inheritAttrs: false,
            props: {
                href: {
                    type: String,
                    required: true,
                },
            },
            emits: ['click'],
            setup(props, { attrs, emit, slots }) {
                return () => h('a', {
                    ...attrs,
                    href: props.href,
                    'data-inertia-link': 'true',
                    onClick: (event: MouseEvent) => {
                        event.preventDefault();
                        emit('click', event);
                    },
                }, slots.default?.());
            },
        }),
        router: {
            get: routerGet,
        },
    };
});

import ApplicationGlobalSearch from '@/components/application/ApplicationGlobalSearch.vue';
import type { GlobalSearchSuggestionPayload } from '@/types/search';

const presets = [
    { label: 'Funds', query: 'fund' },
    { label: 'GPs', query: 'gp' },
    { label: 'Investors', query: 'investor' },
];

const defaultActions = [
    {
        title: 'Create new fund',
        eyebrow: 'Action',
        kind: 'create' as const,
        meta: 'Funds',
        icon: 'plus',
        url: '/funds/create',
        inertia: true,
    },
    {
        title: 'Create new GP',
        eyebrow: 'Action',
        kind: 'create' as const,
        meta: 'General Partners',
        icon: 'building',
        url: '/gps/create',
        inertia: true,
    },
    {
        title: 'Create new investor',
        eyebrow: 'Action',
        kind: 'create' as const,
        meta: 'Investors',
        icon: 'user',
        url: '/investors/create',
        inertia: true,
    },
];

const payload = (query: string, title: string): GlobalSearchSuggestionPayload => ({
    query,
    groups: [
        {
            label: 'General Partners',
            items: [
                {
                    title,
                    eyebrow: 'GP',
                    meta: 'Northstar',
                    icon: 'building',
                    url: '/gps/7',
                },
            ],
        },
    ],
    actions: [
        {
            title: `Search all records for "${query}"`,
            eyebrow: 'Search',
            kind: 'search',
            meta: 'GPs, funds, investors',
            icon: 'search',
            url: `/search?q=${query}`,
            inertia: true,
        },
        {
            title: 'Create new fund',
            eyebrow: 'Action',
            kind: 'create',
            meta: 'Funds',
            icon: 'plus',
            url: '/funds/create',
            inertia: true,
        },
        {
            title: 'Create new GP',
            eyebrow: 'Action',
            kind: 'create',
            meta: 'General Partners',
            icon: 'building',
            url: '/gps/create',
            inertia: true,
        },
        {
            title: 'Create new investor',
            eyebrow: 'Action',
            kind: 'create',
            meta: 'Investors',
            icon: 'user',
            url: '/investors/create',
            inertia: true,
        },
    ],
});

const mountSearch = (overrides: { useInertia?: boolean } = {}) => mount(ApplicationGlobalSearch, {
    attachTo: document.body,
    props: {
        searchUrl: '/search',
        suggestUrl: '/search/suggest',
        presets,
        defaultActions,
        ...overrides,
    },
});

const teleportedPanel = (): HTMLElement => {
    const element = document.body.querySelector<HTMLElement>('.application-global-search__panel');

    if (!element) {
        throw new Error('Expected the global search panel to be teleported to body.');
    }

    return element;
};

const searchInput = (): DOMWrapper<HTMLInputElement> => {
    const element = document.body.querySelector<HTMLInputElement>('#application-search');

    if (!element) {
        throw new Error('Expected the global search input to be teleported inside the panel.');
    }

    return new DOMWrapper(element);
};

const searchForm = (): DOMWrapper<HTMLFormElement> => {
    const element = teleportedPanel().querySelector<HTMLFormElement>('form[role="search"]');

    if (!element) {
        throw new Error('Expected the global search form to be teleported inside the panel.');
    }

    return new DOMWrapper(element);
};

beforeEach(() => {
    document.body.innerHTML = '';
    routerGet.mockReset();
    vi.restoreAllMocks();
});

afterEach(() => {
    vi.useRealTimers();
    vi.unstubAllGlobals();
});

describe('ApplicationGlobalSearch', () => {
    it('owns an accessible teleported combobox lifecycle and cleans it up', async () => {
        const wrapper = mountSearch({ useInertia: true });

        try {
            const trigger = wrapper.get<HTMLButtonElement>('.application-shell__search');
            const input = searchInput();
            const panel = teleportedPanel();
            const listbox = panel.querySelector<HTMLElement>('[role="listbox"]');

            expect(trigger.attributes('type')).toBe('button');
            expect(trigger.attributes('aria-haspopup')).toBe('dialog');
            expect(trigger.attributes('aria-expanded')).toBe('false');
            expect(trigger.attributes('aria-controls')).toBe(panel.id);
            expect(trigger.text()).toBe('');
            expect(wrapper.find('.application-shell__search-label').exists()).toBe(false);
            expect(wrapper.find('.application-shell__search-shortcut').exists()).toBe(false);
            expect(input.attributes('role')).toBe('combobox');
            expect(input.attributes('aria-autocomplete')).toBe('list');
            expect(input.attributes('aria-expanded')).toBe('false');
            expect(input.attributes('aria-controls')).toBe(listbox?.id);
            expect(panel.getAttribute('role')).toBe('dialog');
            expect(panel.getAttribute('aria-hidden')).toBe('true');
            expect(panel.hasAttribute('inert')).toBe(true);
            expect(panel.style.display).toBe('none');
            expect(panel.parentElement).toBe(document.body);

            await trigger.trigger('click');
            await vi.waitFor(() => {
                expect(document.activeElement).toBe(input.element);
            });

            expect(trigger.attributes('aria-expanded')).toBe('true');
            expect(input.attributes('aria-expanded')).toBe('true');
            expect(document.activeElement).toBe(input.element);
            expect(panel.getAttribute('aria-hidden')).toBe('false');
            expect(panel.hasAttribute('inert')).toBe(false);
            expect(panel.style.display).not.toBe('none');
            expect(panel.querySelectorAll('[role="option"]')).toHaveLength(6);
            expect(panel.textContent).toContain('Suggested searches');
            expect(panel.textContent).not.toContain('Open full search');
            expect(panel.querySelectorAll('[data-inertia-link="true"]')).toHaveLength(3);
            expect(panel.querySelector('[data-inertia-link="true"]')?.textContent).toContain('Create new fund');
            expect(panel.querySelector('a[href="/funds/create"]')?.hasAttribute('data-inertia-link')).toBe(true);
            expect(panel.querySelector('a[href="/investors/create"]')?.hasAttribute('data-inertia-link')).toBe(true);

            panel.querySelector<HTMLElement>('[data-inertia-link="true"]')?.click();
            await nextTick();
            expect(trigger.attributes('aria-expanded')).toBe('false');
            expect(input.attributes('aria-expanded')).toBe('false');

            await trigger.trigger('click');
            await nextTick();

            const outside = document.createElement('button');
            document.body.append(outside);
            outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
            await nextTick();

            expect(input.attributes('aria-expanded')).toBe('false');
            expect(document.activeElement).toBe(input.element);

            await trigger.trigger('click');
            await vi.waitFor(() => {
                expect(document.activeElement).toBe(input.element);
            });
            outside.focus();
            await nextTick();

            expect(input.attributes('aria-expanded')).toBe('false');
            expect(document.activeElement).toBe(outside);

            await trigger.trigger('click');
            await vi.waitFor(() => {
                expect(document.activeElement).toBe(input.element);
            });
            document.dispatchEvent(new KeyboardEvent('keydown', {
                key: 'Escape',
                bubbles: true,
                cancelable: true,
            }));
            await nextTick();

            expect(trigger.attributes('aria-expanded')).toBe('false');
            expect(input.attributes('aria-expanded')).toBe('false');
            expect(document.activeElement).toBe(trigger.element);
        } finally {
            wrapper.unmount();
        }

        expect(document.body.querySelector('.application-global-search__panel')).toBeNull();
    });

    it('debounces requests, aborts stale work and exposes loading and failure states', async () => {
        vi.useFakeTimers();

        interface PendingRequest {
            resolve: (response: Response) => void;
            signal: AbortSignal;
            url: string;
        }

        const pending: PendingRequest[] = [];
        const fetchMock = vi.fn((url: string | URL | Request, init?: RequestInit) => (
            new Promise<Response>((resolve) => {
                pending.push({
                    resolve,
                    signal: init?.signal as AbortSignal,
                    url: String(url),
                });
            })
        ));
        vi.stubGlobal('fetch', fetchMock);
        const wrapper = mountSearch();
        let cleanupSignal: AbortSignal | undefined;

        try {
            const input = searchInput();
            const panel = teleportedPanel();
            const listbox = panel.querySelector<HTMLElement>('[role="listbox"]');

            await wrapper.get<HTMLButtonElement>('.application-shell__search').trigger('click');

            await input.setValue('a');
            await vi.advanceTimersByTimeAsync(200);
            expect(fetchMock).not.toHaveBeenCalled();

            await input.setValue('first');
            await vi.advanceTimersByTimeAsync(160);
            await nextTick();

            expect(fetchMock).toHaveBeenCalledOnce();
            expect(pending[0]?.url).toContain('/search/suggest?q=first');
            expect(listbox?.getAttribute('aria-busy')).toBe('true');

            await input.setValue('second');
            expect(pending[0]?.signal.aborted).toBe(true);
            expect(listbox?.getAttribute('aria-busy')).toBe('true');
            await vi.advanceTimersByTimeAsync(160);

            expect(fetchMock).toHaveBeenCalledTimes(2);
            pending[1]?.resolve(new Response(JSON.stringify(payload('second', 'Second result')), {
                status: 200,
                headers: { 'Content-Type': 'application/json' },
            }));
            await vi.waitFor(() => {
                expect(panel.textContent).toContain('Second result');
            });

            pending[0]?.resolve(new Response(JSON.stringify(payload('first', 'Stale result')), {
                status: 200,
                headers: { 'Content-Type': 'application/json' },
            }));
            await Promise.resolve();
            await nextTick();

            expect(panel.textContent).not.toContain('Stale result');
            expect(listbox?.hasAttribute('aria-busy')).toBe(false);

            await input.trigger('keydown', { key: 'ArrowDown' });

            const selectedId = input.attributes('aria-activedescendant');
            const staleResult = selectedId ? document.getElementById(selectedId) : null;
            const staleActivation = vi.fn((event: Event) => event.preventDefault());
            staleResult?.addEventListener('click', staleActivation);
            expect(staleResult?.textContent).toContain('Second result');

            await input.setValue('failure');
            expect(input.attributes('aria-activedescendant')).toBeUndefined();
            expect(panel.textContent).not.toContain('Second result');
            await input.trigger('keydown', { key: 'Enter' });
            expect(staleActivation).not.toHaveBeenCalled();
            await vi.advanceTimersByTimeAsync(160);
            pending[2]?.resolve(new Response('', { status: 503 }));
            await vi.waitFor(() => {
                expect(panel.textContent).toContain('Search suggestions are unavailable.');
            });

            expect(input.attributes('aria-activedescendant')).toBeUndefined();
            expect(listbox?.hasAttribute('aria-busy')).toBe(false);

            await input.setValue('cleanup');
            await vi.advanceTimersByTimeAsync(160);
            cleanupSignal = pending[3]?.signal;
            expect(cleanupSignal?.aborted).toBe(false);
        } finally {
            wrapper.unmount();
        }

        expect(cleanupSignal?.aborted).toBe(true);
    });

    it('prioritizes matching records, hides presets and keeps no-match actions compact', async () => {
        vi.useFakeTimers();
        vi.stubGlobal('fetch', vi.fn(async (request: string | URL | Request) => {
            const url = new URL(String(request), window.location.href);
            const value = url.searchParams.get('q') ?? '';
            const responsePayload = value === 'missing'
                ? { ...payload(value, ''), groups: [] }
                : payload(value, 'Demo Northstar Partners');

            return new Response(JSON.stringify(responsePayload), {
                status: 200,
                headers: { 'Content-Type': 'application/json' },
            });
        }));
        const wrapper = mountSearch();

        try {
            const input = searchInput();
            const panel = teleportedPanel();

            await wrapper.get<HTMLButtonElement>('.application-shell__search').trigger('click');
            await input.setValue('demo');
            expect(panel.textContent).toContain('Searching records…');
            await vi.advanceTimersByTimeAsync(160);
            await vi.waitFor(() => {
                expect(panel.textContent).toContain('Demo Northstar Partners');
            });

            expect(panel.textContent).not.toContain('Suggested searches');
            expect(Array.from(panel.querySelectorAll('.application-global-search__title')).map(
                (element) => element.textContent?.trim(),
            )).toEqual(['General Partners', 'Actions']);

            const optionTitles = Array.from(panel.querySelectorAll<HTMLElement>('[role="option"]')).map(
                (element) => element.querySelector('.application-global-search__item-title')?.textContent?.trim()
                    ?? element.textContent?.trim(),
            );
            expect(optionTitles).toEqual([
                'Demo Northstar Partners',
                'Search all records for "demo"',
                'Create new fund',
                'Create new GP',
                'Create new investor',
            ]);
            expect(panel.querySelectorAll('.application-global-search__item--action')).toHaveLength(4);
            expect(panel.querySelector('.application-global-search__item--search-action')?.textContent)
                .toContain('Search all records for "demo"');

            await input.trigger('keydown', { key: 'ArrowDown' });
            const firstActiveId = input.attributes('aria-activedescendant');
            expect(firstActiveId ? document.getElementById(firstActiveId)?.textContent : '')
                .toContain('Demo Northstar Partners');

            await input.setValue('missing');
            await vi.advanceTimersByTimeAsync(160);
            await vi.waitFor(() => {
                expect(panel.textContent).toContain('No matching records. Try another name or alias.');
            });

            expect(panel.textContent).not.toContain('Suggested searches');
            expect(panel.textContent).toContain('Create');
            const statusId = input.attributes('aria-describedby');
            expect(statusId ? document.getElementById(statusId)?.textContent : '')
                .toContain('No matching records. 4 quick actions available.');
            await input.trigger('keydown', { key: 'ArrowDown' });
            const noMatchActiveId = input.attributes('aria-activedescendant');
            expect(noMatchActiveId ? document.getElementById(noMatchActiveId)?.textContent : '')
                .toContain('Search all records for "missing"');
        } finally {
            wrapper.unmount();
        }
    });

    it('keeps typing focus while presets and native actions are keyboard operable', async () => {
        vi.useFakeTimers();
        vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(payload('fund', 'Fund result')), {
            status: 200,
            headers: { 'Content-Type': 'application/json' },
        })));
        const wrapper = mountSearch();

        try {
            const input = searchInput();
            const panel = teleportedPanel();

            await wrapper.get<HTMLButtonElement>('.application-shell__search').trigger('click');
            await input.trigger('keydown', { key: 'ArrowUp' });
            await nextTick();

            const options = panel.querySelectorAll<HTMLElement>('[role="option"]');
            expect(panel.querySelectorAll('[data-inertia-link="true"]')).toHaveLength(0);
            const last = options.item(options.length - 1);
            expect(input.attributes('aria-activedescendant')).toBe(last.id);
            expect(last.getAttribute('aria-selected')).toBe('true');
            expect(last.textContent).toContain('Create new investor');
            expect(document.activeElement).toBe(input.element);

            const nativeActivation = vi.fn((event: Event) => event.preventDefault());
            last.addEventListener('click', nativeActivation);
            await input.trigger('keydown', { key: 'Enter' });
            expect(nativeActivation).toHaveBeenCalledOnce();

            await input.trigger('keydown', { key: 'ArrowDown' });
            expect(input.attributes('aria-activedescendant')).toBe(options.item(0).id);
            await input.trigger('keydown', { key: 'Enter' });
            await nextTick();

            expect(input.element.value).toBe('fund');
            expect(document.activeElement).toBe(input.element);
            await vi.advanceTimersByTimeAsync(160);
            await vi.waitFor(() => {
                expect(panel.textContent).toContain('Fund result');
            });
        } finally {
            wrapper.unmount();
        }
    });

    it('closes and cancels suggestions before the optional Inertia submission', async () => {
        vi.useFakeTimers();
        const fetchMock = vi.fn();
        vi.stubGlobal('fetch', fetchMock);
        const wrapper = mountSearch({ useInertia: true });

        try {
            const trigger = wrapper.get<HTMLButtonElement>('.application-shell__search');
            const input = searchInput();

            await trigger.trigger('click');
            await input.setValue('demo');
            expect(trigger.attributes('aria-expanded')).toBe('true');
            expect(input.attributes('aria-expanded')).toBe('true');

            await searchForm().trigger('submit');

            expect(routerGet).toHaveBeenCalledWith(
                '/search',
                { q: 'demo' },
                expect.objectContaining({
                    preserveScroll: false,
                    preserveState: true,
                    viewTransition: true,
                }),
            );
            expect(trigger.attributes('aria-expanded')).toBe('false');
            expect(input.attributes('aria-expanded')).toBe('false');

            await vi.advanceTimersByTimeAsync(200);
            expect(fetchMock).not.toHaveBeenCalled();
        } finally {
            wrapper.unmount();
        }
    });
});
