// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ready, delegate, slideDown, slideUp, slideToggle, slideFadeToggle } from './dom.js';

describe('ready()', () => {
    it('runs immediately when document is already ready', () => {
        // jsdom defaults to 'complete' in tests
        expect(document.readyState).not.toBe('loading');
        const fn = vi.fn();
        ready(fn);
        expect(fn).toHaveBeenCalledOnce();
    });

    it('defers until DOMContentLoaded when document is still loading', () => {
        // Override on the instance so we can delete it cleanly afterwards
        // (Document.prototype's descriptor is non-configurable and shouldn't be
        // mutated — the previous version of this test leaked into later ones).
        Object.defineProperty(document, 'readyState', {
            configurable: true,
            get: () => 'loading',
        });

        try {
            const fn = vi.fn();
            ready(fn);
            expect(fn).not.toHaveBeenCalled();

            document.dispatchEvent(new Event('DOMContentLoaded'));
            expect(fn).toHaveBeenCalledOnce();
        } finally {
            // Remove the instance-level override; jsdom's prototype getter resumes.
            delete document.readyState;
        }
    });

    it('waits for vue:mounted (plus one macrotask) when #vue_environment exists but Vue has not mounted', async () => {
        const vueRoot = document.createElement('div');
        vueRoot.id = 'vue_environment';
        document.body.appendChild(vueRoot);
        // `window.vue_environment` is only assigned after Vue mounts — leave it undefined.

        const fn = vi.fn();
        ready(fn);
        expect(fn).not.toHaveBeenCalled();

        document.dispatchEvent(new Event('vue:mounted'));
        // ready() defers one macrotask after vue:mounted so Vue's nextTick
        // queue can flush — wait for that tick.
        expect(fn).not.toHaveBeenCalled();
        await new Promise((resolve) => setTimeout(resolve, 0));
        expect(fn).toHaveBeenCalledOnce();

        document.body.removeChild(vueRoot);
    });

    it('defers one macrotask when #vue_environment exists and Vue has already mounted', async () => {
        const vueRoot = document.createElement('div');
        vueRoot.id = 'vue_environment';
        document.body.appendChild(vueRoot);
        window.vue_environment = { stub: true };

        const fn = vi.fn();
        ready(fn);
        // Same single-macrotask defer as the not-yet-mounted branch.
        expect(fn).not.toHaveBeenCalled();
        await new Promise((resolve) => setTimeout(resolve, 0));
        expect(fn).toHaveBeenCalledOnce();

        delete window.vue_environment;
        document.body.removeChild(vueRoot);
    });
});

describe('delegate()', () => {
    let parent;

    beforeEach(() => {
        parent = document.createElement('div');
        parent.innerHTML = `
            <button class="btn-add">add</button>
            <button class="btn-other">other</button>
            <div class="wrap"><span class="btn-add">nested</span></div>
        `;
        document.body.appendChild(parent);
    });

    afterEach(() => {
        parent.remove();
    });

    it('fires the handler on matching descendants only', () => {
        const handler = vi.fn();
        delegate(parent, '.btn-add', 'click', handler);

        parent.querySelector('.btn-other').click();
        expect(handler).not.toHaveBeenCalled();

        parent.querySelector('.btn-add').click();
        expect(handler).toHaveBeenCalledOnce();
    });

    it('passes (event, matchedTarget) to the handler', () => {
        const handler = vi.fn();
        delegate(parent, '.btn-add', 'click', handler);

        const target = parent.querySelector('.btn-add');
        target.click();

        const [event, matched] = handler.mock.calls[0];
        expect(event.type).toBe('click');
        expect(matched).toBe(target);
    });

    it('resolves the closest matching ancestor when a descendant is clicked', () => {
        const handler = vi.fn();
        delegate(parent, '.btn-add', 'click', handler);

        const nested = parent.querySelector('.wrap .btn-add');
        nested.click();

        expect(handler).toHaveBeenCalledOnce();
        expect(handler.mock.calls[0][1]).toBe(nested);
    });

    it('returns a cleanup function that removes the listener', () => {
        const handler = vi.fn();
        const off = delegate(parent, '.btn-add', 'click', handler);

        off();
        parent.querySelector('.btn-add').click();
        expect(handler).not.toHaveBeenCalled();
    });
});

describe('slideDown()', () => {
    let el;

    beforeEach(() => {
        el = document.createElement('div');
        el.classList.add('is-hidden');
        el.style.display = 'none';
        document.body.appendChild(el);
    });

    afterEach(() => {
        el.remove();
    });

    it('removes the is-hidden class and clears display:none', () => {
        slideDown(el, 100);
        expect(el.classList.contains('is-hidden')).toBe(false);
        expect(el.style.display).not.toBe('none');
    });

    it('applies a height transition for the requested duration', () => {
        slideDown(el, 250);
        expect(el.style.transition).toContain('height');
        expect(el.style.transition).toContain('250ms');
    });

    it('forces a visible display when the element is CSS-hidden (e.g. media query)', () => {
        // Simulate a CSS-driven hide by injecting a stylesheet rule.
        const styleTag = document.createElement('style');
        styleTag.textContent = '.css-hidden-test { display: none; }';
        document.head.appendChild(styleTag);

        const cssHidden = document.createElement('div');
        cssHidden.className = 'css-hidden-test';
        document.body.appendChild(cssHidden);

        slideDown(cssHidden, 100);
        expect(cssHidden.style.display).toBe('block');

        cssHidden.remove();
        styleTag.remove();
    });
});

describe('slideUp()', () => {
    let el;

    beforeEach(() => {
        el = document.createElement('div');
        document.body.appendChild(el);
    });

    afterEach(() => {
        el.remove();
    });

    it('applies a height transition for the requested duration', () => {
        slideUp(el, 400);
        expect(el.style.transition).toContain('height');
        expect(el.style.transition).toContain('400ms');
    });

    it('sets display:none and adds is-hidden once the transition ends', () => {
        slideUp(el, 100);
        el.dispatchEvent(new TransitionEvent('transitionend', { propertyName: 'height' }));
        expect(el.style.display).toBe('none');
        expect(el.classList.contains('is-hidden')).toBe(true);
    });

    it('ignores transitionend for unrelated properties', () => {
        slideUp(el, 100);
        el.dispatchEvent(new TransitionEvent('transitionend', { propertyName: 'opacity' }));
        // Cleanup should not have run, so transition is still set.
        expect(el.style.transition).toContain('height');
    });
});

describe('slideToggle()', () => {
    let el;

    beforeEach(() => {
        el = document.createElement('div');
        document.body.appendChild(el);
    });

    afterEach(() => {
        el.remove();
    });

    it('slides down when hidden via is-hidden class', () => {
        el.classList.add('is-hidden');
        slideToggle(el, 100);
        expect(el.classList.contains('is-hidden')).toBe(false);
    });

    it('slides up when currently visible', () => {
        slideToggle(el, 100);
        // Transition should be applied (slideUp path).
        expect(el.style.transition).toContain('height');
    });
});

describe('slideFadeToggle()', () => {
    let el;

    beforeEach(() => {
        el = document.createElement('div');
        document.body.appendChild(el);
    });

    afterEach(() => {
        el.remove();
    });

    it('animates height AND opacity when sliding up', () => {
        slideFadeToggle(el, 200);
        expect(el.style.transition).toContain('height');
        expect(el.style.transition).toContain('opacity');
        expect(el.style.transition).toContain('200ms');
    });

    it('removes is-hidden when fading in', () => {
        el.classList.add('is-hidden');
        slideFadeToggle(el, 100);
        expect(el.classList.contains('is-hidden')).toBe(false);
    });
});
