var Tray = (function () {
    var _open          = false;
    // Fallback timeout (ms) after close() in placeCard in case transitionend
    // never fires (e.g. CSS transitions disabled).  Zeroed by reset() for tests.
    var _closeTransitionMs = 600;
    var _dragStartX    = null;
    var _dragStartY    = null;
    var _dragStartLeft = null;
    var _dragStartTop  = null;
    var _dragHandled   = false;

    var _wrap   = null;
    var _handle = null;
    var _btn    = null;
    var _tray   = null;
    var _grid   = null;

    // Role code → scrawl SVG name mapping for tray card display.
    var _ROLE_SCRAWL = {
        PC: 'Player', NC: 'Narrator', EC: 'Economist',
        SC: 'Shepherd', AC: 'Alchemist', BC: 'Builder'
    };
    var _roleIconsUrl = null;

    // Portrait bounds (X axis)
    var _minLeft = 0;
    var _maxLeft = 0;

    // Landscape bounds (Y axis): _maxTop = closed (more negative), _minTop = open
    var _minTop = 0;
    var _maxTop = 0;

    // Stored so reset() can remove them
    var _onDocMove      = null;
    var _onDocUp        = null;
    var _onBtnClick     = null;
    var _closePendingHide = null;  // portrait: pending display:none after slide

    function _cancelPendingHide() {
        if (_closePendingHide && _wrap) {
            _wrap.removeEventListener('transitionend', _closePendingHide);
        }
        _closePendingHide = null;
    }

    // Testing hook — null means use real window dimensions
    var _landscapeOverride = null;

    function _isLandscape() {
        if (_landscapeOverride !== null) return _landscapeOverride;
        return window.innerWidth > window.innerHeight;
    }

    // Compute the square cell size from the tray's interior dimension and set
    // --tray-cell-size on #id_tray so SCSS grid tracks pick it up.
    // Portrait: divide height / 8.  Landscape: divide width / 8.
    // The tray's bevel ring (--tray-bevel, an inset box-shadow) eats into the
    // felt area but is invisible to clientHeight/clientWidth — subtract it
    // explicitly on both axes so cells stay square inside the bevel.
    // In portrait the tray may be display:none; we show it with visibility:hidden
    // briefly so clientHeight returns a real value, then restore display:none.
    function _bevelPx() {
        if (!_tray) return 0;
        var raw = getComputedStyle(_tray).getPropertyValue('--tray-bevel').trim();
        if (!raw) return 0;
        if (raw.endsWith('rem')) {
            var rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
            return parseFloat(raw) * rem;
        }
        return parseFloat(raw) || 0;
    }
    function _computeCellSize() {
        if (!_tray) return;
        var size;
        var bevel = _bevelPx();
        if (_isLandscape()) {
            size = Math.floor((_tray.clientWidth - 2 * bevel) / 8);
        } else {
            var wasHidden = (_tray.style.display === 'none' || !_tray.style.display);
            if (wasHidden) {
                _tray.style.visibility = 'hidden';
                _tray.style.display    = 'grid';
            }
            size = Math.floor((_tray.clientHeight - 2 * bevel) / 8);
            if (wasHidden) {
                _tray.style.display    = 'none';
                _tray.style.visibility = '';
            }
        }
        if (size > 0) {
            _tray.style.setProperty('--tray-cell-size', size + 'px');
        }
    }

    function _computeBounds() {
        if (_isLandscape()) {
            // Landscape: the wrap slides on the Y axis.
            // Structure (column-reverse): tray above, handle below.
            // Wrap height is fixed to gearBtnTop so the handle bottom always
            // meets the gear button when open. Tray is flex:1 and fills the rest.
            // Open:   wrap top = 0 (pinned to viewport top).
            // Closed: wrap top = -(gearBtnTop - handleH) = tray fully above viewport.
            // Anchor: id_gear_btn historically; id_kit_btn is the live fallback so
            // the open-state handle bottom lands at the bottom-right anchor instead
            // of overlapping it (no id_gear_btn renders on the room page today).
            var gearBtn    = document.getElementById('id_gear_btn')
                          || document.getElementById('id_kit_btn');
            var gearBtnTop = window.innerHeight;
            if (gearBtn) {
                gearBtnTop = Math.round(gearBtn.getBoundingClientRect().top);
            }
            // handleH = #id_tray_handle's height (48px CSS), NOT _btn.offsetHeight.
            // The 3rem btn can be larger than the 48px handle on big-rem viewports
            // (clamp(14px, 2.4vmin, 22px) → btn=66 at rem=22); using btn here would
            // push the handle's bottom that far below y=0 in closed state.
            var handleH = (_handle && _handle.offsetHeight) || 48;

            // Pin wrap height so handle bottom = gear btn top when open.
            if (_wrap) _wrap.style.height = gearBtnTop + 'px';

            // Open: wrap pinned to viewport top.
            _minTop = 0;

            // Closed: tray hidden above viewport, handle visible at y=0.
            _maxTop = -(gearBtnTop - handleH);
        } else {
            // Portrait: wrap width = full viewport; handle parks at right edge.
            // handleW = #id_tray_handle's width (48px CSS), NOT _btn.offsetWidth —
            // same rationale as landscape: btn can be wider than handle on big-rem
            // viewports, which would leave a visible gap between the handle's right
            // edge and the viewport right edge in closed state.
            var handleW = (_handle && _handle.offsetWidth) || 48;
            if (_wrap) _wrap.style.width = window.innerWidth + 'px';
            _minLeft = 0;
            _maxLeft = window.innerWidth - handleW;
        }
    }

    function _applyVerticalBounds() {
        // Portrait only: nudge wrap top/bottom to avoid overlapping nav/footer bars.
        var nav    = document.querySelector('nav');
        var footer = document.querySelector('footer');
        var rem    = parseFloat(getComputedStyle(document.documentElement).fontSize);
        var inset  = Math.round(rem * 0.125);
        if (nav) {
            var nb = nav.getBoundingClientRect();
            if (nb.width > nb.height && nb.bottom < window.innerHeight * 0.4) {
                _wrap.style.top = (Math.round(nb.bottom) + inset) + 'px';
            }
        }
        if (footer) {
            var fb = footer.getBoundingClientRect();
            if (fb.width > fb.height && fb.top > window.innerHeight * 0.6) {
                _wrap.style.bottom = (window.innerHeight - Math.round(fb.top) + inset) + 'px';
            }
        }
    }

    function open() {
        if (_open) return;
        _cancelPendingHide();  // abort any in-flight portrait close animation
        _open = true;
        // Portrait only: toggle tray display.
        // Landscape: tray is always display:block; wrap position controls visibility.
        if (!_isLandscape() && _tray) _tray.style.display = 'grid';
        if (_btn)  _btn.classList.add('open');
        if (_wrap) {
            _wrap.classList.remove('tray-dragging');
            if (_isLandscape()) {
                _wrap.style.top = _minTop + 'px';
            } else {
                _wrap.style.left = _minLeft + 'px';
            }
        }
    }

    function close() {
        if (!_open) return;
        _open = false;
        if (_btn)  _btn.classList.remove('open');
        if (_wrap) {
            _wrap.classList.remove('tray-dragging');
            if (_isLandscape()) {
                _wrap.style.top = _maxTop + 'px';
                // Snap after the slide completes.
                _closePendingHide = function (e) {
                    if (e.propertyName !== 'top') return;
                    if (_wrap) _wrap.removeEventListener('transitionend', _closePendingHide);
                    _closePendingHide = null;
                    _snapWrap();
                };
                _wrap.addEventListener('transitionend', _closePendingHide);
            } else {
                _wrap.style.left = _maxLeft + 'px';
                // Snap first (tray still visible so it peeks), then hide tray.
                _closePendingHide = function (e) {
                    if (e.propertyName !== 'left') return;
                    if (_wrap) _wrap.removeEventListener('transitionend', _closePendingHide);
                    _closePendingHide = null;
                    _snapWrap(function () {
                        if (!_open && _tray) _tray.style.display = 'none';
                    });
                };
                _wrap.addEventListener('transitionend', _closePendingHide);
            }
        }
    }

    function isOpen() { return _open; }

    // forceClose() — instant, no animation.  Used by server-driven events
    // (e.g. turn_changed) where the tray must be out of the way immediately.
    function forceClose() {
        _cancelPendingHide();
        _open = false;
        if (_btn)  _btn.classList.remove('open');
        if (_wrap) _wrap.classList.remove('wobble', 'snap');
        if (_isLandscape()) {
            if (_wrap) {
                _wrap.classList.add('tray-dragging');
                _wrap.style.top = _maxTop + 'px';
                void _wrap.offsetWidth;
                _wrap.classList.remove('tray-dragging');
            }
        } else {
            if (_tray) _tray.style.display = 'none';
            if (_wrap) {
                _wrap.classList.add('tray-dragging');
                _wrap.style.left = _maxLeft + 'px';
                void _wrap.offsetWidth;
                _wrap.classList.remove('tray-dragging');
            }
        }
    }

    function _snapWrap(onDone) {
        if (!_wrap) return;
        _wrap.classList.add('snap');
        _wrap.addEventListener('animationend', function handler() {
            if (_wrap) _wrap.classList.remove('snap');
            _wrap.removeEventListener('animationend', handler);
            if (onDone) onDone();
        });
    }

    function _wobble() {
        if (!_wrap) return;
        // Portrait: show tray so it peeks in during the translateX animation,
        // then re-hide it if the tray is still closed when the animation ends.
        if (!_isLandscape() && _tray) _tray.style.display = 'grid';
        _wrap.classList.add('wobble');
        _wrap.addEventListener('animationend', function handler() {
            _wrap.classList.remove('wobble');
            _wrap.removeEventListener('animationend', handler);
            if (!_isLandscape() && !_open && _tray) _tray.style.display = 'none';
        });
    }

    // _fadeIn — add .fade-in to cardEl, wait for animationend, remove it, call onComplete.
    function _fadeIn(cardEl, onComplete) {
        cardEl.classList.add('fade-in');
        cardEl.addEventListener('animationend', function handler() {
            cardEl.removeEventListener('animationend', handler);
            cardEl.classList.remove('fade-in');
            if (onComplete) onComplete();
        });
    }

    // placeCard(roleCode, onComplete) — mark the first tray cell with the role,
    // open the tray, fade-in the cell, then animated-close.  Calls onComplete after
    // the close slide finishes (transitionend), with a fallback timeout in case
    // CSS transitions are disabled (e.g. test environments).
    // The grid always contains exactly 8 .tray-cell elements (from the template);
    // the first one receives .tray-role-card and data-role instead of a new element
    // being inserted, so the cell count never changes.
    function placeCard(roleCode, onComplete) {
        if (!_grid) { if (onComplete) onComplete(); return; }
        var firstCell = _grid.querySelector('.tray-cell');
        if (!firstCell) { if (onComplete) onComplete(); return; }

        firstCell.classList.add('tray-role-card');
        firstCell.tabIndex = 0;  // enable :focus persistence for the hover-tilt
        firstCell.dataset.role = roleCode;
        firstCell.textContent  = '';
        if (_roleIconsUrl) {
            var img = document.createElement('img');
            img.src = _roleIconsUrl + 'starter-role-' + (_ROLE_SCRAWL[roleCode] || 'Blank') + '.svg';
            img.alt = roleCode;
            firstCell.appendChild(img);
        }

        _runFadeInSequence(firstCell, onComplete);
    }

    // placeSig(sourceEl, onComplete) — analogue of placeCard, applied to the
    // second tray cell. Copies the user's selected sig-stage-card data + inner
    // markup so the tray sig card renders identically to the page-load case
    // (room.html: <div class="sig-stage-card sea-sig-card" aria-label data-energies
    // data-operations><span class="fan-corner-rank">…</span><i class="fa-solid …"></i></div>).
    // The grid keeps exactly 8 .tray-cell elements; the second cell is mutated
    // in place rather than inserting a new element.
    function placeSig(sourceEl, onComplete) {
        if (!_grid) { if (onComplete) onComplete(); return; }
        var secondCell = _grid.children[1];
        if (!secondCell) { if (onComplete) onComplete(); return; }

        secondCell.classList.add('tray-sig-card');
        secondCell.tabIndex = 0;
        secondCell.textContent = '';

        var stage = document.createElement('div');
        stage.className = 'sig-stage-card sea-sig-card';
        if (sourceEl) {
            var aria = sourceEl.getAttribute && sourceEl.getAttribute('aria-label');
            if (aria) stage.setAttribute('aria-label', aria);
            if (sourceEl.dataset && sourceEl.dataset.energies) {
                stage.dataset.energies = sourceEl.dataset.energies;
            }
            if (sourceEl.dataset && sourceEl.dataset.operations) {
                stage.dataset.operations = sourceEl.dataset.operations;
            }
            // Carry over rank + suit-icon markup from the source. We copy
            // those two specific children rather than the whole innerHTML so
            // the source's larger fan-card-face-* / qualifier markup doesn't
            // leak into the tray card layout.
            var rank = sourceEl.querySelector && sourceEl.querySelector('.fan-corner-rank');
            if (rank) stage.appendChild(rank.cloneNode(true));
            var icon = sourceEl.querySelector && sourceEl.querySelector('i.fa-solid');
            if (icon) stage.appendChild(icon.cloneNode(true));
        }
        secondCell.appendChild(stage);

        _runFadeInSequence(secondCell, onComplete);
    }

    // Shared open → fade-in → close → onComplete sequence for placeCard /
    // placeSig. cardEl is the .tray-cell that just received the role / sig
    // class + content.
    function _runFadeInSequence(cardEl, onComplete) {
        open();
        _fadeIn(cardEl, function () {
            close();
            if (!onComplete) return;
            if (!_wrap) { onComplete(); return; }
            var propName = _isLandscape() ? 'top' : 'left';
            var done = false;
            function finish() {
                if (done) return;
                done = true;
                if (_wrap) _wrap.removeEventListener('transitionend', onCloseEnd);
                onComplete();
            }
            function onCloseEnd(e) {
                if (e.propertyName === propName) finish();
            }
            _wrap.addEventListener('transitionend', onCloseEnd);
            setTimeout(finish, _closeTransitionMs);
        });
    }

    function _startDrag(clientX, clientY) {
        _dragHandled = false;
        if (_wrap) _wrap.classList.add('tray-dragging');
        if (_isLandscape()) {
            _dragStartY    = clientY;
            _dragStartX    = null;
            _dragStartTop  = _wrap ? (parseInt(_wrap.style.top,  10) || _maxTop) : _maxTop;
            _dragStartLeft = null;
        } else {
            _dragStartX    = clientX;
            _dragStartY    = null;
            _dragStartLeft = _wrap ? (parseInt(_wrap.style.left, 10) || _maxLeft) : _maxLeft;
            _dragStartTop  = null;
        }
    }

    // Force-close and reposition to settled bounds.  Called on both 'resize'
    // (snap without transition to avoid flicker during continuous events) and
    // 'resize:end' (re-measures after the viewport has stopped moving).
    function _reposition() {
        _cancelPendingHide();
        _open = false;
        if (_btn) _btn.classList.remove('open');
        if (_wrap) _wrap.classList.remove('wobble', 'snap', 'tray-dragging');

        if (_isLandscape()) {
            // Ensure tray is visible before measuring bounds.
            if (_tray) _tray.style.display = 'grid';
            if (_wrap) { _wrap.style.left = ''; _wrap.style.bottom = ''; _wrap.style.width = ''; }
            _computeBounds();
            _computeCellSize();
            if (_wrap) {
                _wrap.classList.add('tray-dragging');
                _wrap.style.top = _maxTop + 'px';
                void _wrap.offsetWidth;  // flush reflow so position lands before transition restored
                _wrap.classList.remove('tray-dragging');
            }
        } else {
            if (_tray) _tray.style.display = 'none';
            if (_wrap) { _wrap.style.top = ''; _wrap.style.height = ''; _wrap.style.width = ''; }
            _computeBounds();
            _applyVerticalBounds();
            _computeCellSize();
            if (_wrap) {
                _wrap.classList.add('tray-dragging');
                _wrap.style.left = _maxLeft + 'px';
                void _wrap.offsetWidth;  // flush reflow
                _wrap.classList.remove('tray-dragging');
            }
        }
    }

    function init() {
        _wrap   = document.getElementById('id_tray_wrap');
        _handle = document.getElementById('id_tray_handle');
        _btn    = document.getElementById('id_tray_btn');
        _tray   = document.getElementById('id_tray');
        _grid   = document.getElementById('id_tray_grid');
        _roleIconsUrl = (_grid && _grid.dataset.roleIconsUrl) || null;
        if (!_btn) return;

        // Make occupied tray cells focusable so :focus persists the hover-tilt
        // after a click. Template-rendered .tray-sig-card / .tray-role-card
        // cells need this at init; cells assigned later (e.g. placeCard) set
        // tabIndex inline at assignment time.
        if (_grid) {
            _grid.querySelectorAll('.tray-role-card, .tray-sig-card').forEach(function (cell) {
                cell.tabIndex = 0;
            });
        }

        if (_isLandscape()) {
            // Show tray before measuring so offsetHeight includes it.
            if (_tray) _tray.style.display = 'grid';
            _computeBounds();
            // Clear portrait's inline left/bottom so media-query CSS applies.
            if (_wrap) { _wrap.style.left = ''; _wrap.style.bottom = ''; }
            if (_wrap) _wrap.style.top = _maxTop + 'px';
            _computeCellSize();
        } else {
            // Clear landscape's inline top/height/width so portrait CSS applies.
            if (_wrap) { _wrap.style.top = ''; _wrap.style.width = ''; }
            _applyVerticalBounds();
            _computeCellSize();  // wrap has correct height after _applyVerticalBounds
            _computeBounds();
            if (_wrap) _wrap.style.left = _maxLeft + 'px';
        }

        // Drag start — pointer and mouse variants so Selenium W3C actions
        // and synthetic Jasmine PointerEvents both work.
        _btn.addEventListener('pointerdown', function (e) {
            _startDrag(e.clientX, e.clientY);
            try { _btn.setPointerCapture(e.pointerId); } catch (_) {}
        });
        _btn.addEventListener('mousedown', function (e) {
            if (e.button !== 0) return;
            if (_dragStartX !== null || _dragStartY !== null) return;
            _startDrag(e.clientX, e.clientY);
        });

        // Drag move / end — on document so events that land elsewhere during
        // the drag (no capture, or Selenium pointer quirks) still bubble here.
        _onDocMove = function (e) {
            if (!_wrap) return;
            if (_isLandscape()) {
                if (_dragStartY === null) return;
                var newTop = _dragStartTop + (e.clientY - _dragStartY);
                newTop = Math.max(_maxTop, Math.min(_minTop, newTop));
                _wrap.style.top = newTop + 'px';
                // Open when dragged below closed position; update state + class only.
                // Tray display is not toggled in landscape — position controls visibility.
                if (newTop > _maxTop) {
                    if (!_open) {
                        _open = true;
                        if (_btn) _btn.classList.add('open');
                    }
                } else {
                    if (_open) {
                        _open = false;
                        if (_btn) _btn.classList.remove('open');
                    }
                }
            } else {
                if (_dragStartX === null) return;
                var newLeft = _dragStartLeft + (e.clientX - _dragStartX);
                newLeft = Math.max(_minLeft, Math.min(_maxLeft, newLeft));
                _wrap.style.left = newLeft + 'px';
                if (newLeft < _maxLeft) {
                    if (!_open) {
                        _open = true;
                        if (_tray) _tray.style.display = 'grid';
                        if (_btn)  _btn.classList.add('open');
                    }
                } else {
                    if (_open) {
                        _open = false;
                        if (_tray) _tray.style.display = 'none';
                        if (_btn)  _btn.classList.remove('open');
                    }
                }
            }
        };
        document.addEventListener('pointermove', _onDocMove);
        document.addEventListener('mousemove',   _onDocMove);

        _onDocUp = function (e) {
            if (_isLandscape()) {
                if (_dragStartY !== null && Math.abs(e.clientY - _dragStartY) > 10) {
                    _dragHandled = true;
                }
                _dragStartY   = null;
                _dragStartTop = null;
            } else {
                if (_dragStartX !== null && Math.abs(e.clientX - _dragStartX) > 10) {
                    _dragHandled = true;
                }
                _dragStartX    = null;
                _dragStartLeft = null;
            }
            if (_wrap) _wrap.classList.remove('tray-dragging');
        };
        document.addEventListener('pointerup', _onDocUp);
        document.addEventListener('mouseup',   _onDocUp);

        _onBtnClick = function () {
            if (_dragHandled) {
                _dragHandled = false;
                return;
            }
            if (_open) {
                close();
            } else {
                _wobble();
            }
        };
        _btn.addEventListener('click', _onBtnClick);

        window.addEventListener('resize', _reposition);
        window.addEventListener('resize:end', _reposition);
    }

    // reset() — restores module state; used by Jasmine afterEach
    function reset() {
        _open              = false;
        _closeTransitionMs = 0;
        _dragStartX        = null;
        _dragStartY        = null;
        _dragStartLeft     = null;
        _dragStartTop      = null;
        _dragHandled       = false;
        _landscapeOverride = null;
        // Restore portrait default (display:none); landscape init() will show it.
        if (_tray) {
            _tray.style.display = 'none';
            _tray.style.removeProperty('--tray-cell-size');
        }
        if (_btn)  _btn.classList.remove('open');
        if (_wrap) {
            _wrap.classList.remove('wobble', 'snap', 'tray-dragging');
            _wrap.style.left   = '';
            _wrap.style.top    = '';
            _wrap.style.height = '';
            _wrap.style.width  = '';
        }
        if (_onDocMove) {
            document.removeEventListener('pointermove', _onDocMove);
            document.removeEventListener('mousemove',   _onDocMove);
            _onDocMove = null;
        }
        if (_onDocUp) {
            document.removeEventListener('pointerup', _onDocUp);
            document.removeEventListener('mouseup',   _onDocUp);
            _onDocUp = null;
        }
        if (_onBtnClick && _btn) {
            _btn.removeEventListener('click', _onBtnClick);
            _onBtnClick = null;
        }
        _cancelPendingHide();
        // Clear any role-card state from tray cells (Jasmine afterEach)
        if (_grid) {
            _grid.querySelectorAll('.tray-cell').forEach(function (el) {
                el.classList.remove('tray-role-card', 'tray-sig-card', 'fade-in');
                el.removeAttribute('tabindex');
                el.textContent = '';
                delete el.dataset.role;
            });
        }
        _wrap   = null;
        _handle = null;
        _btn    = null;
        _tray   = null;
        _grid   = null;
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

    return {
        init:        init,
        open:        open,
        close:       close,
        forceClose:  forceClose,
        isOpen:      isOpen,
        placeCard:   placeCard,
        placeSig:    placeSig,
        reset:       reset,
        _testSetLandscape: function (v) { _landscapeOverride = v; },
    };
}());
