/**
 * Vanilla DOM helpers for the public front-end.
 *
 * Not a generic kitchen-sink wrapper. Only add a helper here when the same
 * boilerplate repeats many times across files. Prefer native APIs directly
 * everywhere else (querySelector, classList, addEventListener, etc.).
 *
 * Zero external dependencies — this file must work with no imports.
 */

/**
 * Runs `fn` when the page is ready for vanilla listeners.
 *
 * Most of the public front-end is rendered inside `#vue_environment`, and
 * Vue 2 replaces those DOM nodes during compile. The `mounted` hook (which
 * dispatches the `vue:mounted` event) fires after the first render, but
 * pending reactive updates from child components / store hydration can
 * trigger a *second* patch immediately after — which discards nodes the
 * caller may have just attached listeners to. So we wait for `vue:mounted`,
 * then defer one macrotask (`setTimeout(_, 0)`) so the microtask queue
 * (Vue's `$nextTick`) flushes before listeners are bound.
 *
 * Pages without a `#vue_environment` (e.g. admin) fire immediately after
 * `DOMContentLoaded`. Pages where Vue has already mounted by the time this
 * runs (`window.vue_environment` is set) defer the same single macrotask
 * to stay consistent.
 *
 * @param {() => void} fn
 */
export function ready(fn) {
    const fire = () => {
        const hasVueRoot = !!document.getElementById('vue_environment');
        const vueMounted = !!window.vue_environment;

        if (hasVueRoot && !vueMounted) {
            document.addEventListener('vue:mounted', () => {
                // One macrotask after the mounted hook so Vue's $nextTick
                // queue (and any post-mount reactive patches) has flushed.
                setTimeout(fn, 0);
            }, { once: true });
        } else if (hasVueRoot) {
            // Vue already mounted — still defer so the timing matches the
            // pre-mount branch (callers can rely on a stable post-mount DOM).
            setTimeout(fn, 0);
        } else {
            fn();
        }
    };

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', fire, { once: true });
    } else {
        fire();
    }
}

/**
 * Delegates `event` on `parent` to descendants matching `selector`. Returns
 * a cleanup function that removes the listener.
 *
 * Uses a single listener on `parent` and resolves the match with
 * `event.target.closest(selector)`.
 *
 * @example
 * const off = delegate(document.body, '.btn-add', 'click', (e, target) => { … });
 * // later: off();
 *
 * @param {Element | Document} parent
 * @param {string} selector
 * @param {string} event
 * @param {(event: Event, matchedTarget: Element) => void} handler
 * @returns {() => void}
 */
export function delegate(parent, selector, event, handler) {
    const listener = (e) => {
        const target = e.target instanceof Element ? e.target : null;
        if (!target) {
            return;
        }
        const match = target.closest(selector);
        if (match && parent.contains(match)) {
            handler(e, match);
        }
    };
    parent.addEventListener(event, listener);
    return () => parent.removeEventListener(event, listener);
}

/**
 * Expands `el` from zero height to its natural scrollHeight.
 *
 * Removes `display:none` before animating, then clears the inline styles
 * once the transition ends so the element behaves naturally afterwards.
 *
 * @param {HTMLElement} el
 * @param {number} [duration=300]
 */
/**
 * Wires up a transitionend listener that:
 *   - fires only for the watched property,
 *   - is paired with a setTimeout fallback (duration + 50ms) so cleanup
 *     still runs if the event is dropped (zero-duration, display:none
 *     mid-flight, CSS override), and
 *   - cannot run twice (fired flag).
 *
 * @returns {() => void} A canceller that finalises cleanup immediately.
 */
function watchTransitionEnd(el, property, duration, cleanupFn) {
    let fired = false;
    let timeoutId = null;
    const finalize = () => {
        if (fired) return;
        fired = true;
        el.removeEventListener('transitionend', onTransitionEnd);
        if (timeoutId !== null) clearTimeout(timeoutId);
        cleanupFn();
    };
    const onTransitionEnd = (e) => {
        if (e && e.propertyName !== property) return;
        finalize();
    };
    el.addEventListener('transitionend', onTransitionEnd);
    timeoutId = setTimeout(finalize, duration + 50);
    return finalize;
}

export function slideDown(el, duration = 300) {
    if (el.style.display === 'none') {
        el.style.display = '';
    }
    el.classList.remove('is-hidden');
    // If a CSS rule (e.g. a media query) is still hiding the element, force
    // a visible display value before reading scrollHeight — otherwise the
    // animation runs invisibly and the element never actually appears.
    if (getComputedStyle(el).display === 'none') {
        el.style.display = 'block';
    }

    const target = el.scrollHeight;
    el.style.overflow = 'hidden';
    el.style.height = '0px';
    el.style.transition = `height ${duration}ms ease`;

    // Two rAFs guarantee the browser paints the start frame before transitioning.
    requestAnimationFrame(() => {
        requestAnimationFrame(() => {
            el.style.height = `${target}px`;
        });
    });

    watchTransitionEnd(el, 'height', duration, () => {
        el.style.height = '';
        el.style.overflow = '';
        el.style.transition = '';
    });
}

/**
 * Collapses `el` to zero height, then sets `display:none` and toggles the
 * `is-hidden` class so CSS can target hidden state.
 *
 * @param {HTMLElement} el
 * @param {number} [duration=300]
 */
export function slideUp(el, duration = 300) {
    const start = el.offsetHeight;
    el.style.overflow = 'hidden';
    el.style.height = `${start}px`;
    el.style.transition = `height ${duration}ms ease`;

    requestAnimationFrame(() => {
        requestAnimationFrame(() => {
            el.style.height = '0px';
        });
    });

    watchTransitionEnd(el, 'height', duration, () => {
        el.style.display = 'none';
        el.style.height = '';
        el.style.overflow = '';
        el.style.transition = '';
        el.classList.add('is-hidden');
    });
}

/**
 * Toggles an element's visibility with a height transition. Slides down if
 * currently hidden (via `is-hidden` class or `display:none`), slides up
 * otherwise.
 *
 * @param {HTMLElement} el
 * @param {number} [duration=300]
 */
export function slideToggle(el, duration = 300) {
    const isHidden = el.classList.contains('is-hidden')
        || getComputedStyle(el).display === 'none';

    if (isHidden) {
        slideDown(el, duration);
    } else {
        slideUp(el, duration);
    }
}

/**
 * Like `slideToggle`, but also fades opacity. Matches the visual effect of
 * the legacy `slideFadeToggle` extension used across menus / accordions.
 *
 * @param {HTMLElement} el
 * @param {number} [duration=300]
 */
export function slideFadeToggle(el, duration = 300) {
    const isHidden = el.classList.contains('is-hidden')
        || getComputedStyle(el).display === 'none';

    if (isHidden) {
        slideFadeDown(el, duration);
    } else {
        slideFadeUp(el, duration);
    }
}

/**
 * Slides an element down with combined height + opacity transitions.
 * Same CSS-hidden override as `slideDown` — works whether the element is
 * hidden via inline style, an `is-hidden` class, or a CSS rule.
 *
 * @param {HTMLElement} el
 * @param {number} [duration=300]
 */
export function slideFadeDown(el, duration = 300) {
    if (el.style.display === 'none') {
        el.style.display = '';
    }
    el.classList.remove('is-hidden');
    // Same CSS-hidden override as `slideDown` — required when the element
    // is hidden by a media query (e.g. .userDetailsWrap on mobile).
    if (getComputedStyle(el).display === 'none') {
        el.style.display = 'block';
    }

    const target = el.scrollHeight;
    el.style.overflow = 'hidden';
    el.style.height = '0px';
    el.style.opacity = '0';
    el.style.transition = `height ${duration}ms ease, opacity ${duration}ms ease`;

    requestAnimationFrame(() => {
        requestAnimationFrame(() => {
            el.style.height = `${target}px`;
            el.style.opacity = '1';
        });
    });

    watchTransitionEnd(el, 'height', duration, () => {
        el.style.height = '';
        el.style.overflow = '';
        el.style.transition = '';
        el.style.opacity = '';
    });
}

/**
 * Slides an element up with combined height + opacity transitions, then
 * sets `display:none` and adds `is-hidden`.
 *
 * @param {HTMLElement} el
 * @param {number} [duration=300]
 */
export function slideFadeUp(el, duration = 300) {
    const start = el.offsetHeight;
    el.style.overflow = 'hidden';
    el.style.height = `${start}px`;
    el.style.opacity = '1';
    el.style.transition = `height ${duration}ms ease, opacity ${duration}ms ease`;

    requestAnimationFrame(() => {
        requestAnimationFrame(() => {
            el.style.height = '0px';
            el.style.opacity = '0';
        });
    });

    watchTransitionEnd(el, 'height', duration, () => {
        el.style.display = 'none';
        el.style.height = '';
        el.style.overflow = '';
        el.style.transition = '';
        el.style.opacity = '';
        el.classList.add('is-hidden');
    });
}
