// Game-views carousel — the "reelhouse" (the fivefold applet-scroll carousel)
// in the table-hex aperture's 2nd vertical snap pane.
// Five horizontal views — ATLAS | SCROLL | YARN | POST | PULSE — reached by
// scrolling DOWN from the hex, landing on SCROLL (the 2nd). This module owns
// the HORIZONTAL axis; room-scroll.js still owns the vertical hex<->views
// `.is-scroll` title toggle + the feed gear/filter. Responsibilities:
//   1. land the carousel on SCROLL + keep one icon/title in sync as the
//      active view (goToView is authoritative; an IntersectionObserver backs
//      native swipe/drag so a finger-flick updates the glow too),
//   2. horizontal mouse-wheel (deltaX / shift+wheel) advances a view; vertical
//      wheel is left alone for feed scroll; icon-click snaps,
//   3. show the root icon strip only while the views pane is on screen,
//   4. the burger Text sub-btn swipe machine — DOWN to the views pane then
//      RIGHT to the Post view,
//   5. the Post composer (AJAX append to #id_post_table, no reload), and
//   6. ATLAS — a live merge of the SCROLL + POST DOM, time-ordered.
// All guards no-op on surfaces without a carousel (gate phase, room_gate).
(function () {
    'use strict';

    var VIEW_ORDER = ['atlas', 'scroll', 'yarn', 'post', 'pulse'];
    var DEFAULT_VIEW = 'scroll';

    // ── pure helpers (exposed for Jasmine) ─────────────────────────────────
    // Stable time-merge of atlas rows: ISO `ts` strings sort lexicographically
    // == chronologically; missing ts sinks to the top. Array#sort isn't
    // guaranteed stable across every engine for equal keys, so we decorate
    // with the original index and break ties on it.
    function mergeAtlasRows(rows) {
        return rows
            .map(function (r, i) { return { r: r, i: i }; })
            .sort(function (a, b) {
                var c = String(a.r.ts || '').localeCompare(String(b.r.ts || ''));
                return c !== 0 ? c : a.i - b.i;
            })
            .map(function (x) { return x.r; });
    }

    function escapeHtml(s) {
        return String(s).replace(/[&<>"']/g, function (ch) {
            return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;',
                      '"': '&quot;', "'": '&#39;' })[ch];
        });
    }

    function renderAtlasRow(r) {
        var who = r.whoText
            ? '<span class="atlas-row-who">' + escapeHtml(r.whoText) + '</span>'
            : '';
        var time = r.timeHtml || '';
        // Carry the source row's struck state (a retracted/redacted SCROLL log)
        // so the merged copy reads the same strikethrough it does in SCROLL.
        var bodyCls = 'atlas-row-body' + (r.struck ? ' struck' : '');
        return '<div class="atlas-row atlas-row--' + r.source + '" data-source="'
            + r.source + '">' + who
            + '<span class="' + bodyCls + '">' + r.bodyHtml + '</span>' + time
            + '</div>';
    }

    window.RoomViews = window.RoomViews || {};
    window.RoomViews.mergeAtlasRows = mergeAtlasRows;
    window.RoomViews.renderAtlasRow = renderAtlasRow;

    // ── boot ───────────────────────────────────────────────────────────────
    function init() {
        var viewsEl = document.getElementById('id_room_views');
        if (!viewsEl) return;   // no carousel on this surface

        var strip = document.getElementById('id_room_views_strip');
        var aperture = document.getElementById('id_room_aperture');
        var title = document.querySelector('.row .col-lg-6 h2');
        var current = DEFAULT_VIEW;
        // While a programmatic (smooth) snap is in flight, the active view is
        // already known (goToView set it), so suppress the IntersectionObserver
        // — else the views the snap PASSES THROUGH would each fire, jittering
        // the icon glow + the h2 reel. The IO is left live only for genuine
        // native touch/drag swipes (no goToView).
        var suppressIO = false;
        var suppressTimer = null;

        // ── per-view gear menu ──────────────────────────────────────────────
        // Each reelhouse view has its OWN gear: the single #id_room_menu swaps
        // panes by the active view (room-scroll.js no longer drives this). SCROLL
        // → the Frame/Redact log filter; ATLAS → its source checkboxes; the hex
        // → the default NVM/DEL/BYE; YARN/POST/PULSE → no menu yet (the gear goes
        // .gear-disabled and an active click flashes a --priRd fa-ban instead).
        var gearBtn = document.querySelector(".gear-btn[data-menu-target='id_room_menu']");
        var roomMenu = document.getElementById('id_room_menu');
        var paneDefault = roomMenu && roomMenu.querySelector('.room-menu-default');
        var paneScroll = roomMenu && roomMenu.querySelector('.room-menu-scroll');
        var paneAtlas = roomMenu && roomMenu.querySelector('.room-menu-atlas');
        var paneSky = roomMenu && roomMenu.querySelector('.room-menu-sky');
        var paneSea = roomMenu && roomMenu.querySelector('.room-menu-sea');
        var GEARLESS = { yarn: true, post: true, pulse: true };
        var onReelhouse = false;

        function showPane(pane) {
            // .room-menu-default is display:contents (so wrapping doesn't disturb
            // its controls' layout); the swap panes are plain block.
            if (paneDefault) paneDefault.style.display = pane === paneDefault ? 'contents' : 'none';
            if (paneScroll) paneScroll.style.display = pane === paneScroll ? '' : 'none';
            if (paneAtlas) paneAtlas.style.display = pane === paneAtlas ? '' : 'none';
            if (paneSky) paneSky.style.display = pane === paneSky ? '' : 'none';
            if (paneSea) paneSea.style.display = pane === paneSea ? '' : 'none';
        }
        function closeRoomMenu() {
            if (roomMenu) roomMenu.style.display = 'none';
            if (gearBtn) gearBtn.classList.remove('active');
        }
        function updateGear() {
            if (!gearBtn) return;
            // CAST SKY felt open → the gear is the sky NVM pane (returns to the
            // hex), regardless of scroll position: the aperture is pinned to the
            // hex pane while the felt is up, so onReelhouse stays false anyway.
            if (document.documentElement.classList.contains('sky-open')) {
                gearBtn.classList.remove('gear-disabled');
                showPane(paneSky);
                return;
            }
            // DRAW SEA felt open → the gear is the sea NVM pane (returns to the
            // hex), same as the sky felt. The aperture is scroll-pinned to the
            // hex while the felt is up, so onReelhouse stays false regardless.
            if (document.documentElement.classList.contains('sea-open')) {
                gearBtn.classList.remove('gear-disabled');
                showPane(paneSea);
                return;
            }
            var disabled = onReelhouse && GEARLESS[current];
            gearBtn.classList.toggle('gear-disabled', !!disabled);
            if (disabled) { closeRoomMenu(); return; }
            if (!onReelhouse) showPane(paneDefault);
            else if (current === 'scroll') showPane(paneScroll);
            else if (current === 'atlas') showPane(paneAtlas);
        }
        // Exposed so the CAST SKY felt (sky overlay JS) can re-sync the gear pane
        // the moment it opens/closes (html.sky-open toggles outside any scroll/
        // view event that would otherwise drive updateGear).
        window.RoomViews.syncGear = updateGear;

        function setActiveView(view) {
            current = view;
            if (strip) {
                strip.querySelectorAll('.room-view-icon').forEach(function (ic) {
                    ic.classList.toggle('is-active', ic.dataset.view === view);
                });
            }
            if (title) title.dataset.activeView = view;
            updateGear();
            // ATLAS is a client-side merge of the live SCROLL + POST DOM, so it
            // must be (re)built on EVERY path that activates it. setActiveView is
            // the one chokepoint all three share — placeView (initial land),
            // goToView (icon-click / wheel), AND the IntersectionObserver (a
            // native finger-swipe, which reaches here with no goToView). Building
            // only in the first two left the swipe path blank on touch devices
            // ("The atlas gathers . . .") until a gear-OK re-ran the merge.
            if (view === 'atlas') buildAtlasFeed();
        }

        // Instant placement (initial land / layout re-assert) — no slide.
        function placeView(view) {
            var idx = VIEW_ORDER.indexOf(view);
            if (idx === -1) return;
            var w = viewsEl.clientWidth;
            if (w > 0) viewsEl.scrollLeft = idx * w;
            setActiveView(view);   // builds ATLAS itself when view === 'atlas'
        }

        // Animated nav — the card SLIDES horizontally (smooth scroll) while the
        // h2 reel slides its word the matching direction (CSS, on data-active-
        // view). setActiveView fires immediately so the title leads in lockstep;
        // the card's native smooth scroll trails just behind.
        function goToView(view) {
            var idx = VIEW_ORDER.indexOf(view);
            if (idx === -1) return;
            var w = viewsEl.clientWidth;
            if (w > 0) {
                suppressIO = true;
                clearTimeout(suppressTimer);
                suppressTimer = setTimeout(function () { suppressIO = false; }, 700);
                viewsEl.scrollTo({ left: idx * w, behavior: 'smooth' });
            }
            setActiveView(view);   // builds ATLAS itself when view === 'atlas'
        }
        window.RoomViews.goToView = goToView;

        // Land on SCROLL (the default) instantly + sync the strip/title glow.
        placeView(DEFAULT_VIEW);
        // Re-assert once layout has fully settled (clientWidth may be 0 if the
        // pane hadn't been measured at DOMContentLoaded).
        if (document.readyState !== 'complete') {
            window.addEventListener('load', function () { placeView(current); });
        }

        // 1 ── active-view IntersectionObserver (native swipe/drag) ──────────
        var io = new IntersectionObserver(function (entries) {
            if (suppressIO) return;
            entries.forEach(function (e) {
                if (e.intersectionRatio >= 0.6 && e.target.dataset.view) {
                    setActiveView(e.target.dataset.view);
                }
            });
        }, { root: viewsEl, threshold: [0, 0.6, 1] });
        viewsEl.querySelectorAll('.room-view').forEach(function (v) { io.observe(v); });

        // 2 ── horizontal wheel → next/prev view ─────────────────────────────
        var wheelLock = false;
        viewsEl.addEventListener('wheel', function (e) {
            var dx = e.deltaX;
            // shift+wheel maps a vertical tick onto the horizontal axis.
            if (e.shiftKey && Math.abs(e.deltaY) > Math.abs(dx)) dx = e.deltaY;
            // Vertical-dominant or tiny tick → leave it for the feed scroll.
            if (Math.abs(dx) <= Math.abs(e.deltaY) || Math.abs(dx) < 8) return;
            e.preventDefault();
            if (wheelLock) return;
            wheelLock = true;
            setTimeout(function () { wheelLock = false; }, 400);
            var idx = VIEW_ORDER.indexOf(current);
            var next = idx + (dx > 0 ? 1 : -1);
            if (next >= 0 && next < VIEW_ORDER.length) goToView(VIEW_ORDER[next]);
        }, { passive: false });

        // 3 ── icon-click snaps ──────────────────────────────────────────────
        if (strip) {
            strip.addEventListener('click', function (e) {
                var icon = e.target.closest('.room-view-icon');
                if (icon && icon.dataset.view) goToView(icon.dataset.view);
            });

            // Strip is shown only while the views pane is on screen — vertical
            // IO on the scroll pane (mirrors room-scroll.js's title toggle). The
            // same on/off-the-reelhouse signal drives the gear pane swap (hex →
            // default menu; reelhouse → the active view's menu / disabled).
            var scrollPane = aperture && aperture.querySelector('.room-scroll-pane');
            if (aperture && scrollPane) {
                var vio = new IntersectionObserver(function (entries) {
                    entries.forEach(function (e) {
                        if (e.target !== scrollPane) return;
                        onReelhouse = e.intersectionRatio >= 0.5;
                        strip.classList.toggle('is-visible', onReelhouse);
                        updateGear();
                    });
                }, { root: aperture, threshold: [0, 0.5, 1] });
                vio.observe(scrollPane);
            }
        }

        // 3b ── disabled gear: swallow the click + flash a --priRd fa-ban ─────
        // On YARN/POST/PULSE the gear is .gear-disabled. A capture-phase listener
        // intercepts the click BEFORE applets.js's bubble handler so the menu
        // never opens; instead the gear flashes twice (the burger inactive-flash
        // cadence). Enabled gears fall through to applets.js untouched.
        function flashGearBan(gear) {
            var pulses = 2, onMs = 180, offMs = 120;
            (function pulse(n) {
                if (n <= 0) return;
                gear.classList.add('gear-flash-ban');
                setTimeout(function () {
                    gear.classList.remove('gear-flash-ban');
                    setTimeout(function () { pulse(n - 1); }, offMs);
                }, onMs);
            }(pulses));
        }
        document.addEventListener('click', function (e) {
            var hit = e.target.closest(".gear-btn[data-menu-target='id_room_menu']");
            if (hit && hit.classList.contains('gear-disabled')) {
                e.stopImmediatePropagation();
                e.preventDefault();
                flashGearBan(hit);
            }
        }, true);

        // 3c ── ATLAS gear: source checkboxes → which feeds merge ─────────────
        var atlasForm = document.getElementById('id_atlas_source_form');
        if (atlasForm) {
            var page = document.querySelector('.room-page');
            var ATLAS_KEY = 'room-atlas-sources-' + (page ? page.dataset.roomId : '');
            try {
                var savedSrc = JSON.parse(localStorage.getItem(ATLAS_KEY) || 'null');
                if (savedSrc) {
                    atlasForm.querySelectorAll('input[name="views"]').forEach(function (cb) {
                        if (!cb.disabled) cb.checked = savedSrc.indexOf(cb.value) !== -1;
                    });
                }
            } catch (_) { /* localStorage unavailable — non-fatal */ }
            atlasForm.addEventListener('submit', function (e) {
                e.preventDefault();
                var on = Array.prototype.slice.call(
                    atlasForm.querySelectorAll('input[name="views"]:checked')
                ).map(function (cb) { return cb.value; });
                try { localStorage.setItem(ATLAS_KEY, JSON.stringify(on)); } catch (_) {}
                buildAtlasFeed();   // re-merge ATLAS with the new source set
                closeRoomMenu();
            });
        }

        // Sync the gear to the initial (hex) state.
        updateGear();

        // 4 ── Text sub-btn swipe machine (burger fan → GAME POST) ───────────
        // Drives the reelhouse (the fivefold applet-scroll carousel) to the POST
        // view. Two cases:
        //   • Already reel'd DOWN onto the reelhouse → just slide horizontally to
        //     POST (the plain smooth goToView, which reads great on its own).
        //   • Starting UP in the room (at the hex) → run straight DOWN to the
        //     reelhouse, HOLD 0.5s, then check whether we landed on POST already
        //     and — only if not — slide horizontally to it. The hold beats the
        //     two motions apart so it reads as DOWN-then-OVER, never a diagonal.
        // Inactive clicks fall through to the burger's 2-pulse flash (guard on
        // `.active`); the burger's delegated handler closes the fan after this.
        var textBtn = document.getElementById('id_text_btn');
        if (textBtn) {
            var SWIPE_HOLD_MS = 500;

            // Past the halfway mark of the vertical snap → the reelhouse pane is
            // the one on screen (hex = 1st pane, reelhouse = 2nd).
            function onReelhouse() {
                return aperture && aperture.scrollTop > aperture.clientHeight / 2;
            }
            // Fire cb once the aperture's vertical scroll settles — `scrollend`
            // where supported (Firefox 109+), a timed ceiling otherwise.
            function afterDescent(cb) {
                var done = false;
                function fire() {
                    if (done) return;
                    done = true;
                    clearTimeout(fallback);
                    aperture.removeEventListener('scrollend', fire);
                    cb();
                }
                var fallback = setTimeout(fire, 700);
                aperture.addEventListener('scrollend', fire);
            }

            textBtn.addEventListener('click', function () {
                if (!textBtn.classList.contains('active')) return;
                if (!aperture || onReelhouse()) {
                    goToView('post');          // already down → straight over
                    return;
                }
                // From the hex: DOWN, hold, then OVER to POST unless already on it.
                aperture.scrollTo({ top: aperture.scrollHeight, behavior: 'smooth' });
                afterDescent(function () {
                    setTimeout(function () {
                        if (current !== 'post') goToView('post');
                    }, SWIPE_HOLD_MS);
                });
            });
        }

        // 5 ── Post composer — optimistic AJAX append (no reload) ────────────
        // The line is appended SYNCHRONOUSLY on submit (so the POST + ATLAS
        // views reflect it the instant OK is clicked — no dependence on the
        // round-trip landing first), then reconciled with the server's
        // authoritative render (real @handle + timestamp) on response. A
        // rejection (duplicate / not seated) rolls the optimistic line back
        // and restores the input.
        var form = document.getElementById('id_post_line_form');
        if (form && form.dataset.roomPost) {
            var input = document.getElementById('id_post_line_text');
            var feedback = document.getElementById('id_post_line_feedback');

            function makeOptimisticLine(text) {
                var li = document.createElement('li');
                li.className = 'post-line';
                li.dataset.pending = '1';
                var who = document.createElement('span');
                who.className = 'post-line-author';
                who.textContent = form.dataset.author || '';
                var body = document.createElement('span');
                body.className = 'post-line-text';
                body.textContent = text;
                var time = document.createElement('time');
                time.className = 'post-line-time';
                li.appendChild(who); li.appendChild(body); li.appendChild(time);
                return li;
            }

            form.addEventListener('submit', function (e) {
                e.preventDefault();
                if (!input || !input.value.trim()) return;
                var text = input.value.trim();
                // Snapshot the form payload BEFORE clearing the input — else
                // FormData(form) would capture the emptied text field (400).
                var payload = new FormData(form);
                var table = document.getElementById('id_post_table');
                var buffer = table && table.querySelector('.post-line-buffer');
                var optimistic = makeOptimisticLine(text);
                if (table) {
                    if (buffer) table.insertBefore(optimistic, buffer);
                    else table.appendChild(optimistic);
                }
                input.value = '';
                if (feedback) feedback.style.display = 'none';
                buildAtlasFeed();

                fetch(form.action, {
                    method: 'POST',
                    credentials: 'same-origin',
                    headers: { 'X-Requested-With': 'XMLHttpRequest' },
                    body: payload,
                }).then(function (r) {
                    return r.json().then(function (d) { return { ok: r.ok, d: d }; });
                }).then(function (res) {
                    if (!res.ok || !res.d.ok) {
                        // Roll back the optimistic line + restore the input.
                        if (optimistic.parentNode) optimistic.parentNode.removeChild(optimistic);
                        input.value = text;
                        if (feedback) {
                            var errs = res.d.errors || {};
                            feedback.textContent =
                                (errs.text && errs.text[0]) || 'Could not post that line.';
                            feedback.style.display = 'block';
                        }
                        buildAtlasFeed();
                        return;
                    }
                    // Swap the optimistic line for the authoritative render.
                    var tpl = document.createElement('template');
                    tpl.innerHTML = (res.d.line_html || '').trim();
                    var li = tpl.content.firstElementChild;
                    if (li && optimistic.parentNode) {
                        optimistic.parentNode.replaceChild(li, optimistic);
                    } else {
                        delete optimistic.dataset.pending;
                    }
                    buildAtlasFeed();
                }).catch(function () {
                    // Network failure — keep the optimistic line (offline-ish);
                    // the next load reconciles from the server.
                    delete optimistic.dataset.pending;
                });
            });
        }

        // 6 ── ATLAS — live merge of the SCROLL + POST DOM ───────────────────
        function nodeTime(el, sel) {
            var t = el.querySelector(sel);
            return t ? (t.getAttribute('datetime') || '') : '';
        }
        function nodeTimeHtml(el, sel) {
            var t = el.querySelector(sel);
            return t ? t.outerHTML : '';
        }

        // Which sources the ATLAS gear has enabled (its source checkboxes map
        // each reelhouse view → a row source: scroll→provenance, post→post).
        // Defaults to both when the gear form isn't on the page yet.
        function atlasSources() {
            var form = document.getElementById('id_atlas_source_form');
            if (!form) return { provenance: true, post: true };
            var VIEW_SOURCE = { scroll: 'provenance', post: 'post' };
            var on = {};
            form.querySelectorAll('input[name="views"]:checked').forEach(function (cb) {
                var src = VIEW_SOURCE[cb.value];
                if (src) on[src] = true;
            });
            return on;
        }

        function buildAtlasFeed() {
            var atlas = document.getElementById('id_room_atlas');
            if (!atlas) return;
            var srcOn = atlasSources();
            var rows = [];
            if (srcOn.provenance) {
                document.querySelectorAll('.room-view--scroll .drama-event').forEach(function (ev) {
                    // Honour the SCROLL redact/frame filter: a log hidden there
                    // (display:none) must not surface in the merged ATLAS either.
                    if (getComputedStyle(ev).display === 'none') return;
                    var body = ev.querySelector('.drama-event-body');
                    rows.push({
                        source: 'provenance',
                        ts: nodeTime(ev, '.drama-event-time'),
                        whoText: '',
                        bodyHtml: body ? body.innerHTML : ev.innerHTML,
                        // Struck (retracted/redacted) carries over from SCROLL.
                        struck: !!(body && body.classList.contains('struck')),
                        timeHtml: nodeTimeHtml(ev, '.drama-event-time'),
                    });
                });
            }
            if (srcOn.post) {
                document.querySelectorAll('.room-view--post #id_post_table .post-line').forEach(function (li) {
                    var who = li.querySelector('.post-line-author');
                    var txt = li.querySelector('.post-line-text');
                    rows.push({
                        source: 'post',
                        ts: nodeTime(li, '.post-line-time'),
                        whoText: who ? who.textContent.trim() : '',
                        bodyHtml: txt ? txt.innerHTML : '',
                        timeHtml: nodeTimeHtml(li, '.post-line-time'),
                    });
                });
            }
            // No empty-state: the ATLAS is never reached before SCROLL has its
            // game-creation "Welcome to <game>!" event, so `rows` is never empty
            // in practice — and an empty render is preferable to a stale
            // placeholder lingering if a merge ever does come back bare.
            atlas.innerHTML = mergeAtlasRows(rows).map(renderAtlasRow).join('');
        }
        window.RoomViews.buildAtlasFeed = buildAtlasFeed;
    }

    // Exposed so the Jasmine swipe-machine spec can (re-)bind against a fixture
    // DOM — the from-hex swipe's delayed programmatic scroll can't be verified
    // end-to-end in headless Selenium (the browser drops it), so the nav
    // DECISION is unit-tested here instead.
    window.RoomViews.init = init;

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