/**
 * Modal base class — eliminates open/close/dirty-check boilerplate.
 *
 * Simple modals:   const modal = new Modal('my-modal-id');
 * Dirty-check:     class MyModal extends Modal { snapshotValues() { ... } }
 */

import { t } from './i18n.ts';
import { lockBody, unlockBody, setupBackdropClose, showConfirm, trapFocus, releaseFocus, isTouchDevice } from './ui.ts';

export class Modal {
    static _stack: Modal[] = [];

    el: HTMLElement | null;
    errorEl: HTMLElement | null;
    _lock: boolean;
    _backdrop: boolean;
    _initialValues: Record<string, any>;
    _closing: boolean;
    _previousFocus: Element | null = null;

    constructor(elementId: string, { backdrop = true, lock = true } = {}) {
        this.el = document.getElementById(elementId);
        this.errorEl = this.el?.querySelector('.modal-error') as HTMLElement | null;
        this._lock = lock;
        this._backdrop = backdrop;
        this._initialValues = {};
        this._closing = false;
    }

    get isOpen() {
        // While the exit animation runs, display is still 'flex' but the modal
        // is non-interactive — treat it as closed.
        return this.el?.style.display === 'flex' && !this.el?.classList.contains('closing');
    }

    open() {
        this._previousFocus = document.activeElement;
        // If a close animation is in flight, we still own the body lock — it
        // gets released in finalize(). Skip the lockBody() call in that case
        // so the lock count stays balanced.
        const hadInFlightClose = this._exitCleanup !== null;
        this.el!.classList.remove('closing');
        this._cancelExitAnim();
        this.el!.style.display = 'flex';
        if (this._lock && !hadInFlightClose) lockBody();
        if (this._backdrop) setupBackdropClose(this.el!, () => this.close());
        trapFocus(this.el!);
        Modal._stack = Modal._stack.filter(m => m !== this);
        Modal._stack.push(this);
        // Auto-focus first visible input (skip on touch to avoid virtual keyboard)
        if (!isTouchDevice()) {
            requestAnimationFrame(() => {
                const input = this.el!.querySelector(
                    '.modal-body input:not([type="hidden"]):not([disabled]):not([style*="display:none"]):not([style*="display: none"]), .modal-body select:not([disabled]), .modal-body textarea:not([disabled])'
                ) as HTMLElement | null;
                if (input && input.offsetParent !== null) input.focus();
            });
        }
    }

    /** Pending exit-animation cleanup, so a re-open during the animation can cancel it. */
    _exitCleanup: (() => void) | null = null;

    _cancelExitAnim() {
        if (this._exitCleanup) {
            this._exitCleanup();
            this._exitCleanup = null;
        }
    }

    forceClose() {
        if (!this.el) return;
        // Already closing → don't restart the animation.
        if (this.el.classList.contains('closing')) return;

        // Modal-state cleanup happens immediately so subsequent code that
        // queries Modal._stack / focus sees a "closed" state. Body-lock
        // release AND subclass cleanup (onForceClose) are deferred to
        // finalize() — running them now would toggle html.modal-open or
        // mutate DOM (widget destruction clears innerHTML) before the exit
        // animation, causing a visible layout shift inside the modal.
        releaseFocus(this.el);
        this._initialValues = {};
        this.hideError();
        Modal._stack = Modal._stack.filter(m => m !== this);
        if (this._previousFocus && typeof (this._previousFocus as HTMLElement).focus === 'function') {
            (this._previousFocus as HTMLElement).focus({ preventScroll: true });
            this._previousFocus = null;
        }

        // Run the exit animation, then hide. The animation is owned by the
        // .modal-content child (the visually larger movement); we listen for
        // its `animationend` and fall back to a timeout in case the user has
        // prefers-reduced-motion or the element is detached mid-flight.
        const el = this.el;
        const content = el.querySelector('.modal-content') as HTMLElement | null;
        const EXIT_MS = 220;

        const finalize = () => {
            this._exitCleanup = null;
            // Guard against re-open: if open() was called during animation,
            // the .closing class is gone and display is already 'flex' — the
            // body lock is still held by the re-opened modal, so don't release.
            if (!el.classList.contains('closing')) return;
            el.classList.remove('closing');
            el.style.display = 'none';
            this.onForceClose();
            if (this._lock) unlockBody();
        };

        let timer: number | null = null;
        const onEnd = () => {
            if (timer !== null) { clearTimeout(timer); timer = null; }
            content?.removeEventListener('animationend', onEnd);
            finalize();
        };
        this._exitCleanup = () => {
            if (timer !== null) { clearTimeout(timer); timer = null; }
            content?.removeEventListener('animationend', onEnd);
            // Re-open during animation: still run subclass cleanup so the
            // fresh open re-initializes from a clean slate (widgets etc.).
            this.onForceClose();
        };

        el.classList.add('closing');
        if (content) {
            content.addEventListener('animationend', onEnd, { once: false });
        }
        timer = window.setTimeout(onEnd, EXIT_MS + 80);
    }

    async close() {
        if (this._closing) return;
        if (this.isDirty()) {
            this._closing = true;
            const confirmed = await showConfirm(t('modal.discard_changes'));
            this._closing = false;
            if (!confirmed) return;
        }
        this.forceClose();
    }

    /** Override in subclass to define tracked fields for dirty checking. */
    snapshotValues() { return {}; }

    /** Override if current values differ from snapshot format. */
    currentValues() { return this.snapshotValues(); }

    snapshot() {
        this._initialValues = this.snapshotValues();
    }

    isDirty() {
        const cur = this.currentValues();
        return Object.keys(this._initialValues).some(k => this._initialValues[k] !== cur[k]);
    }

    showError(msg: string) {
        if (this.errorEl) {
            this.errorEl.textContent = msg;
            this.errorEl.style.display = 'block';
        }
    }

    hideError() {
        if (this.errorEl) this.errorEl.style.display = 'none';
    }

    /**
     * Hook for subclass cleanup on force-close (canvas, observers, widget
     * .destroy() calls, etc.). Runs AFTER the exit animation completes (or
     * when an in-flight close is canceled by reopen), so DOM mutations
     * here do not cause a layout shift visible during the animation.
     */
    onForceClose() {}

    $(id: string) {
        return document.getElementById(id);
    }

    static closeTopmost() {
        const top = Modal._stack[Modal._stack.length - 1];
        if (top) { top.close(); return true; }
        return false;
    }
}
