/**
 * UI Display Handler Module
 * Manages the display of text and UI elements
 */
import { BaseModule } from './base-module.js';

const PAGE_LINE_COUNT = 25;

class UIDisplayHandlerModule extends BaseModule {
    constructor() {
        super('ui-display-handler', 'UI Display Handler');
        
        // Module dependencies
        this.dependencies = ['layout-renderer', 'playback-coordinator', 'game-config', 'localization', 'story-history', 'sentence-queue', 'persistence-manager'];
        
        // DOM elements
        this.container = null;
        this.pageLeft = null;
        this.pageRight = null;
        this.paragraphContainer = null;
        this.renderedItems = [];
        this.resizeTimer = null;
        this.storyResizeObserver = null;
        this.lastStoryMetrics = null;
        this.visibleBlockLimit = 41;
        this.historyBufferBlocks = 20;
        this.pageLineCount = PAGE_LINE_COUNT;
        this.historyWindowStartId = 1;
        this.historyWindowEndId = 0;
        this.draggingStoryScrollbar = false;
        this.storyTopLine = 0;
        this.storyOffsetPx = 0;
        this.windowOriginLine = 0;
        this.storyScrollAnimation = null;
        this.scrollAnimationFrameId = null;
        this.scrollAnimationPromise = null;
        this.scrollAnimationResolve = null;
        this.scrollTargetLine = null;
        this.scrollRequestId = 0;
        this.renderWindowToken = 0;
        this.wheelLineAccumulator = 0;
        this.viewportLineCount = 1;
        this.lineHeightPx = 24;
        this.activeCenterBlockId = null;
        this.maxTraversalBlocks = 190;
        this.scrollbarPreviewLine = null;
        this.storyScrollbarReleaseHandler = null;
        this.lastManualScrollAt = 0;
        this.layoutFlowLine = 0;
        this.layoutExclusions = [];
        this.notificationQueue = [];
        this.notificationActive = false;
        this.pendingTerminalNotifications = [];
        this.latestInputMode = 'text';
               
        // Resources to preload
        this.cssPath = '/css/style.css';
        this.imagesToPreload = [
            '/images/book_detailed.png',
            '/images/mat.png'
        ];
                
        // Bind methods using parent's bindMethods utility
        this.bindMethods([
            'initializeContainers',
            'applyGameConfig',
            'applyTranslations',
            'renderSentence',
            'renderStoryBlock',
            'prepareRenderableBlock',
            'prepareTextRenderable',
            'prepareImageRenderable',
            'createImageExclusion',
            'addImageExclusion',
            'rebuildLayoutExclusions',
            'getActiveExclusions',
            'buildLineGeometry',
            'makeRenderedWordsVisible',
            'markBlockRendered',
            'restoreFromHistory',
            'renderHistoryWindow',
            'renderIncrementalWindow',
            'setWindowOriginLine',
            'removeRenderedBlocksOutside',
            'removeRenderedElement',
            'dedupeRenderedWindow',
            'reflowTextBlocksForActiveExclusions',
            'blockIntersectsExclusions',
            'insertStoredElement',
            'handleHistoryWheel',
            'handleManualScrollStart',
            'getActiveLineForTopLine',
            'getTopLineForActiveLine',
            'getRenderedBlockForLine',
            'getWindowBoundsForTraversal',
            'renderWindowForBounds',
            'disableAutoplayForManualScroll',
            'updateStoryScrollbar',
            'handleStoryScrollbarPointer',
            'renderImageBlock',
            'revealImageBlock',
            'resolveImageUrl',
            'calculateImageMetrics',
            'measureStoryLineHeight',
            'measureBlockLines',
            'recordRenderedMetrics',
            'setVirtualPadding',
            'setStoryOffset',
            'scrollUp',
            'scrollDown',
            'scrollTo',
            'getCurrentScrollLine',
            'getLiveEndLine',
            'ensureScrollRangeForTarget',
            'animateToTopLine',
            'getMaxStoryTopLine',
            'ensureLiveTailWindow',
            'readFirstFiniteNumber',
            'waitForSkippablePause',
            'focusTurn',
            'handleStoryScroll',
            'rerenderStory',
            'clear',
            'scheduleRerender',
            'measureText',
            'loadCSS',
            'showChoices',
            'preloadImages',
            'createCreditsDialog',
            'openCreditsDialog',
            'closeCreditsDialog',
            'loadCreditsText',
            'createNotificationDialog',
            'handleStoryTag',
            'getTagMessage',
            'showNotification',
            'displayNextNotification',
            'queueTerminalNotification',
            'flushTerminalNotifications',
            'closeNotification'
        ]);
        
        console.log('UIDisplayHandler: Constructor initialized');
    }

    t(key, params = {}) {
        return this.localization?.translate?.(key, params) || key;
    }
    
    async initialize() {
        try {
            this.reportProgress(10, "Initializing UI Display Handler");
            
            // Load CSS and preload images
            this.reportProgress(20, "Loading CSS and preloading images");
            await this.loadCSS(this.cssPath);
            await this.preloadImages(this.imagesToPreload);
            
            this.reportProgress(30, "Getting module dependencies");
            
            // Get references to required modules using parent's getModule method
            this.layoutRenderer = this.getModule('layout-renderer');
            this.playbackCoordinator = this.getModule('playback-coordinator');
            this.gameConfig = this.getModule('game-config');
            this.localization = this.getModule('localization');
            this.storyHistory = this.getModule('story-history');
            this.persistenceManager = this.getModule('persistence-manager');
                        
            this.reportProgress(50, "Initializing display containers");
            
            // Initialize container elements
            this.initializeContainers();
            
            this.reportProgress(70, "Setting up typography");
                        
            this.reportProgress(90, "Setting up event listeners");
            this.addEventListener(document, 'book:resized', () => {
                this.scheduleRerender();
            });
            this.addEventListener(document, 'game:config', (event) => {
                this.applyGameConfig(event.detail);
            });
            this.addEventListener(document, 'localization:languageChanged', () => {
                this.applyTranslations();
            });
            this.addEventListener(document, 'story:scroll-to-turn', (event) => {
                this.focusTurn(event.detail?.turnId);
            });
            this.addEventListener(document, 'story:history-updated', (event) => {
                this.updateStoryScrollbar(event.detail || {});
            });
            this.addEventListener(document, 'story:tag', (event) => {
                this.handleStoryTag(event.detail);
            });
            this.addEventListener(document, 'story:turn-start', () => {
                this.latestInputMode = 'text';
            });
            this.addEventListener(document, 'story:input-mode', (event) => {
                this.latestInputMode = event.detail || 'text';
            });
            this.addEventListener(document, 'wheel', this.handleHistoryWheel, { passive: false });
            this.addEventListener(document, 'keydown', (event) => {
                const tagName = String(event.target?.tagName || '').toLowerCase();
                if (['input', 'textarea', 'select'].includes(tagName) || event.altKey || event.ctrlKey || event.metaKey) {
                    return;
                }
                if (event.key === 'ArrowUp') {
                    event.preventDefault();
                    this.handleManualScrollStart('arrow-up');
                    this.scrollUp(1);
                } else if (event.key === 'ArrowDown') {
                    event.preventDefault();
                    this.handleManualScrollStart('arrow-down');
                    this.scrollDown(1);
                } else if (event.key === 'PageUp') {
                    event.preventDefault();
                    this.handleManualScrollStart('page-up');
                    this.scrollUp(24);
                } else if (event.key === 'PageDown') {
                    event.preventDefault();
                    this.handleManualScrollStart('page-down');
                    this.scrollDown(24);
                } else if (event.key === 'Home') {
                    event.preventDefault();
                    this.handleManualScrollStart('home');
                    this.scrollTo(0, { mode: 'home' });
                } else if (event.key === 'End') {
                    event.preventDefault();
                    this.handleManualScrollStart('end');
                    this.scrollTo(this.getLiveEndLine(), { mode: 'end' });
                }
            });
            this.addEventListener(document, 'story:process-state', (event) => {
                const state = event.detail?.state || 'ready';
                const remark = document.getElementById('remark_text');
                if (remark) {
                    remark.textContent = state === 'paused'
                        ? this.t('title.continueHint')
                        : this.t('title.fastForwardHint');
                }
                if (state === 'ready' && this.latestInputMode === 'end') {
                    this.flushTerminalNotifications();
                }
            });

            if (window.ResizeObserver && this.paragraphContainer) {
                this.storyResizeObserver = new ResizeObserver((entries) => {
                    const entry = entries[0];
                    if (!entry) {
                        return;
                    }

                    const computedStyle = window.getComputedStyle(this.paragraphContainer);
                    const metrics = {
                        width: Math.round(entry.contentRect.width),
                        fontSize: computedStyle.fontSize,
                        lineHeight: computedStyle.lineHeight
                    };

                    if (!this.lastStoryMetrics) {
                        this.lastStoryMetrics = metrics;
                        return;
                    }

                    const changed = metrics.width !== this.lastStoryMetrics.width ||
                        metrics.fontSize !== this.lastStoryMetrics.fontSize ||
                        metrics.lineHeight !== this.lastStoryMetrics.lineHeight;

                    this.lastStoryMetrics = metrics;
                    if (changed) {
                        this.scheduleRerender();
                    }
                });
                this.storyResizeObserver.observe(this.paragraphContainer);
            }
                        
            this.reportProgress(100, "UI Display Handler ready");
            return true;
        } catch (error) {
            console.error("Error initializing UI Display Handler:", error);
            return false;
        }
    }

    scheduleRerender() {
        clearTimeout(this.resizeTimer);
        this.resizeTimer = setTimeout(() => this.rerenderStory(), 80);
    }

    
    /**
     * Load CSS file asynchronously and wait for it to be applied
     * @param {string} cssPath - Path to CSS file
     * @returns {Promise<void>}
     */
    loadCSS(cssPath) {
        return new Promise((resolve, reject) => {
            // Create link element
            const link = document.createElement('link');
            link.rel = 'stylesheet';
            link.href = cssPath;
            
            // Set up load and error handlers
            link.onload = () => {
                console.log(`UIDisplayHandler: CSS ${cssPath} loaded successfully`);
                resolve();
            };
            
            link.onerror = (error) => {
                console.error(`UIDisplayHandler: Failed to load CSS ${cssPath}:`, error);
                reject(error);
            };
            
            // Add to document head
            document.head.appendChild(link);
        });
    }
    
    /**
     * Preload images to ensure they're in the cache
     * @param {Array<string>} imagePaths - Array of image paths to preload
     * @returns {Promise<void>}
     */
    preloadImages(imagePaths) {
        if (!imagePaths || imagePaths.length === 0) {
            return Promise.resolve();
        }
        
        const promises = imagePaths.map(path => {
            return new Promise((resolve) => {
                const img = new Image();
                img.onload = () => resolve();
                img.onerror = () => {
                    console.warn(`UIDisplayHandler: Failed to preload image ${path}`);
                    resolve(); // Resolve anyway to not block loading
                };
                img.src = path;
            });
        });
        
        return Promise.all(promises);
    }

    /**
     * Initialize the UI containers
     */
    initializeContainers() {
        // Check if the book container already exists
        let bookContainer = document.getElementById('book');
        if (!bookContainer) {
            console.log('UIDisplayHandler: Book container not found, creating it');
            bookContainer = document.createElement('div');
            bookContainer.id = 'book';
            document.body.appendChild(bookContainer);
        }
        
        // Create or find page_left
        this.pageLeft = document.getElementById('page_left');
        if (!this.pageLeft) {
            console.log('UIDisplayHandler: Left page not found, creating it');
            this.pageLeft = document.createElement('div');
            this.pageLeft.id = 'page_left';
            
            // Create header content
            const header = document.createElement('div');
            header.className = 'header';
            header.innerHTML = `
                <h2 class="byline" id="game_author"></h2>
                <h1 class="title" id="game_title"></h1>
                <h3 class="subtitle" id="game_subtitle"></h3>
                <div class="separator"><double>❦</double></div>
            `;
            this.pageLeft.appendChild(header);
            
            // Create controls
            const controls = document.createElement('div');
            controls.id = 'controls';
            controls.className = 'buttons';
            controls.innerHTML = `
                <a id="speech"></a>
                <a id="autoplay"></a>
                <span><a id="speed_reset"><span id="speed_label"></span><sup>*</sup></a><input type="range" min="50" max="150" value="100" id="speed" name="speed" /></span>
                <a id="rewind"></a>
                <a id="save"></a>
                <a id="reload" disabled="disabled"></a>
                <a id="options"></a>
            `;
            this.pageLeft.appendChild(controls);
            
            // Create choices container
            const choicesContainer = document.createElement('div');
            choicesContainer.id = 'choices';
            choicesContainer.className = 'container';
            
            // Create command history container
            const commandHistory = document.createElement('div');
            commandHistory.id = 'command_history';
            choicesContainer.appendChild(commandHistory);
            
            // Create command input container
            const commandInput = document.createElement('div');
            commandInput.id = 'command_input';
            commandInput.innerHTML = `
                <div class="input-wrapper">
                    <textarea id="player_input" rows="1" autofocus autocomplete="off" autocorrect="off" autocapitalize="sentences" spellcheck="true" aria-autocomplete="none" data-form-type="other" data-1p-ignore="true" data-lpignore="true" data-bwignore="true"></textarea>
                    <span id="cursor"></span>
                </div>
            `;
            choicesContainer.appendChild(commandInput);
            
            this.pageLeft.appendChild(choicesContainer);
            
            // Create remark
            const remark = document.createElement('div');
            remark.id = 'remark';
            remark.innerHTML = `
                <div id="remark_hint"><i><sup>*</sup><span id="remark_text"></span></i></div>
                <div id="game_legal"></div>
            `;
            this.pageLeft.appendChild(remark);
            
            bookContainer.appendChild(this.pageLeft);
        }
        
        // Create or find page_right
        this.pageRight = document.getElementById('page_right');
        if (!this.pageRight) {
            console.log('UIDisplayHandler: Right page not found, creating it');
            this.pageRight = document.createElement('div');
            this.pageRight.id = 'page_right';
            bookContainer.appendChild(this.pageRight);
        }
        if (!document.getElementById('story_scrollbar')) {
            const storyScrollbar = document.createElement('div');
            storyScrollbar.id = 'story_scrollbar';
            storyScrollbar.innerHTML = '<div id="story_scrollbar_thumb"></div>';
            this.pageRight.appendChild(storyScrollbar);
        }
        const storyScrollbar = document.getElementById('story_scrollbar');
        if (storyScrollbar && !storyScrollbar.dataset.historyScrollBound) {
            storyScrollbar.dataset.historyScrollBound = 'true';
            ['pointerdown', 'pointerup', 'mousedown', 'mouseup', 'click', 'dblclick'].forEach((type) => {
                storyScrollbar.addEventListener(type, (event) => {
                    event.preventDefault();
                    event.stopPropagation();
                    if (type === 'pointerdown') {
                        this.handleStoryScrollbarPointer(event);
                    } else if ((type === 'pointerup' || type === 'mouseup') && typeof this.storyScrollbarReleaseHandler === 'function') {
                        this.storyScrollbarReleaseHandler(event);
                    }
                    if (typeof event.stopImmediatePropagation === 'function') {
                        event.stopImmediatePropagation();
                    }
                }, true);
            });
            storyScrollbar.addEventListener('wheel', (event) => {
                event.preventDefault();
                event.stopPropagation();
                this.handleHistoryWheel(event);
            }, { passive: false });
        }
        
        // Create or find story container
        this.container = document.getElementById('story');
        if (!this.container) {
            console.log('UIDisplayHandler: Story container not found, creating it');
            this.container = document.createElement('div');
            this.container.id = 'story';
            this.container.className = 'container';
            this.pageRight.appendChild(this.container);
        }

        if (!document.getElementById('start_prompt')) {
            const startPrompt = document.createElement('div');
            startPrompt.id = 'start_prompt';
            this.pageRight.appendChild(startPrompt);
        }
        
        // Create paragraph container inside story container
        this.paragraphContainer = document.getElementById('paragraphs');
        if (!this.paragraphContainer) {
            console.log('UIDisplayHandler: Paragraphs container not found, creating it');
            this.paragraphContainer = document.createElement('div');
            this.paragraphContainer.id = 'paragraphs';
            this.container.appendChild(this.paragraphContainer);
        }
        
        // Create ruler for text measurements
        let ruler = document.getElementById('ruler');
        if (!ruler) {
            ruler = document.createElement('div');
            ruler.id = 'ruler';
            document.body.appendChild(ruler);
        }
        
        // Create lighting effect
        let lighting = document.getElementById('lighting');
        if (!lighting) {
            lighting = document.createElement('div');
            lighting.id = 'lighting';
            document.body.appendChild(lighting);
        }

        this.createCreditsDialog();
        this.createNotificationDialog();
        
        console.log('UIDisplayHandler: All containers initialized');
        this.applyGameConfig(this.gameConfig?.getConfig?.());
        this.applyTranslations();
        this.measureStoryLineHeight();
        this.setStoryOffset(0);
    }

    applyGameConfig(config) {
        const metadata = config?.metadata || this.gameConfig?.getMetadata?.() || {};
        const titleElement = document.getElementById('game_title');
        const authorElement = document.getElementById('game_author');
        const subtitleElement = document.getElementById('game_subtitle');
        const legalElement = document.getElementById('game_legal');
        document.getElementById('game_version')?.remove();
        document.getElementById('game_copyright')?.remove();

        if (titleElement) titleElement.textContent = metadata.title || '';
        if (authorElement) authorElement.textContent = metadata.author ? this.t('title.byAuthor', { author: metadata.author }) : '';
        if (subtitleElement) subtitleElement.textContent = metadata.subtitle || '';
        if (legalElement) {
            const items = [
                metadata.version ? this.t('title.version', { version: metadata.version }) : '',
                metadata.copyright || ''
            ].filter(Boolean);
            legalElement.innerHTML = '';
            const legalText = document.createElement('span');
            legalText.id = 'game_legal_text';
            legalText.textContent = items.join(' | ');
            legalElement.appendChild(legalText);

            const creditsButton = document.createElement('button');
            creditsButton.id = 'credits_button';
            creditsButton.type = 'button';
            creditsButton.textContent = this.t('credits.button');
            creditsButton.title = this.t('credits.buttonTitle');
            creditsButton.addEventListener('click', (event) => {
                event.preventDefault();
                event.stopPropagation();
                this.openCreditsDialog();
            });

            if (items.length > 0) {
                legalElement.appendChild(document.createTextNode(' | '));
            }
            legalElement.appendChild(creditsButton);
        }
    }

    applyTranslations() {
        this.localization = this.getModule('localization') || this.localization;

        const setText = (id, key) => {
            const element = document.getElementById(id);
            if (element) element.textContent = this.t(key);
        };
        const setTitle = (id, key) => {
            const element = document.getElementById(id);
            if (element) element.setAttribute('title', this.t(key));
        };

        setText('speech', 'topbar.speech');
        setText('autoplay', 'topbar.autoplay');
        setText('speed_label', 'topbar.speed');
        setText('rewind', 'topbar.newGame');
        setText('save', 'topbar.save');
        setText('reload', 'topbar.load');
        setText('options', 'topbar.options');
        setText('remark_text', 'title.fastForwardHint');
        setText('start_prompt', 'title.startPrompt');
        setText('credits_dialog_title', 'credits.title');
        setText('credits_close', 'credits.close');
        setText('story_popup_ok', 'popup.ok');
        setTitle('speech', 'topbar.speechTitle');
        setTitle('autoplay', 'topbar.autoplayTitle');
        setTitle('rewind', 'topbar.newGameTitle');
        setTitle('save', 'topbar.saveTitle');
        setTitle('reload', 'topbar.loadTitle');
        setTitle('options', 'topbar.optionsTitle');

        const input = document.getElementById('player_input');
        if (input) input.setAttribute('placeholder', this.t('input.placeholder'));
        this.applyGameConfig(this.gameConfig?.getConfig?.());
    }

    createCreditsDialog() {
        if (document.getElementById('credits_modal')) {
            return;
        }

        const modal = document.createElement('div');
        modal.id = 'credits_modal';
        modal.className = 'credits-modal';
        modal.setAttribute('aria-hidden', 'true');
        modal.innerHTML = `
            <div class="credits-dialog" role="dialog" aria-modal="true" aria-labelledby="credits_dialog_title">
                <div class="credits-dialog-header">
                    <h2 id="credits_dialog_title"></h2>
                    <button type="button" id="credits_close"></button>
                </div>
                <div class="credits-logo-row" aria-label="Credits links">
                    <a href="https://openai.com/" target="_blank" rel="noreferrer"><img src="https://cdn.simpleicons.org/openai/2b2218" alt="OpenAI"></a>
                    <a href="https://www.inklestudios.com/ink/" target="_blank" rel="noreferrer" class="credits-wordmark">ink</a>
                    <a href="https://mnater.github.io/Hyphenopoly/" target="_blank" rel="noreferrer" class="credits-wordmark">Hyphenopoly</a>
                    <a href="https://github.com/hexgrad/kokoro" target="_blank" rel="noreferrer" class="credits-wordmark">Kokoro</a>
                    <a href="https://suno.com/" target="_blank" rel="noreferrer" class="credits-wordmark">Suno</a>
                </div>
                <pre id="credits_content" class="credits-content"></pre>
            </div>
        `;

        document.body.appendChild(modal);

        modal.addEventListener('click', (event) => {
            if (event.target === modal) {
                this.closeCreditsDialog();
            }
        });

        const closeButton = document.getElementById('credits_close');
        if (closeButton) {
            closeButton.addEventListener('click', () => this.closeCreditsDialog());
        }
    }

    async openCreditsDialog() {
        const modal = document.getElementById('credits_modal');
        const content = document.getElementById('credits_content');
        if (!modal || !content) {
            return;
        }

        modal.classList.add('visible');
        modal.setAttribute('aria-hidden', 'false');

        if (!content.dataset.loaded) {
            content.textContent = this.t('credits.loading');
            content.textContent = await this.loadCreditsText();
            content.dataset.loaded = 'true';
        }
    }

    closeCreditsDialog() {
        const modal = document.getElementById('credits_modal');
        if (!modal) {
            return;
        }
        modal.classList.remove('visible');
        modal.setAttribute('aria-hidden', 'true');
    }

    async loadCreditsText() {
        try {
            const response = await fetch('/THIRD_PARTY_NOTICES.md', { cache: 'no-cache' });
            if (!response.ok) {
                throw new Error(`HTTP ${response.status}`);
            }
            return await response.text();
        } catch (error) {
            console.warn('UIDisplayHandler: Failed to load credits notices', error);
            return this.t('credits.loadFailed');
        }
    }

    createNotificationDialog() {
        if (document.getElementById('story_popup_modal')) {
            return;
        }

        const modal = document.createElement('div');
        modal.id = 'story_popup_modal';
        modal.className = 'story-popup-modal';
        modal.setAttribute('aria-hidden', 'true');
        modal.innerHTML = `
            <div class="story-popup-dialog" role="dialog" aria-modal="true" aria-labelledby="story_popup_title">
                <h2 id="story_popup_title"></h2>
                <div id="story_popup_message"></div>
                <button type="button" id="story_popup_ok"></button>
            </div>
        `;

        document.body.appendChild(modal);
        modal.addEventListener('click', (event) => {
            event.preventDefault();
            event.stopPropagation();
            if (event.target === modal) {
                this.closeNotification();
            }
        });
        modal.addEventListener('pointerdown', (event) => {
            event.stopPropagation();
        });

        const okButton = document.getElementById('story_popup_ok');
        if (okButton) {
            okButton.addEventListener('click', (event) => {
                event.preventDefault();
                event.stopPropagation();
                this.closeNotification();
            });
        }
    }

    handleStoryTag(tag) {
        const key = String(tag?.key || '').toLowerCase();
        if (!['score', 'error', 'achievement', 'alert'].includes(key)) {
            return;
        }

        const message = this.getTagMessage(tag);
        if (key === 'score') {
            this.queueTerminalNotification(
                'ending',
                this.t('popup.endingTitle'),
                message || this.t('popup.defaultEnding')
            );
        } else if (key === 'error') {
            this.queueTerminalNotification(
                'error',
                this.t('popup.errorTitle'),
                message || this.t('popup.defaultError')
            );
        } else if (key === 'achievement') {
            this.showNotification(
                'achievement',
                this.t('popup.achievementTitle'),
                message || this.t('popup.defaultAchievement')
            );
        } else if (key === 'alert') {
            this.showNotification(
                'alert',
                this.t('popup.alertTitle'),
                message || this.t('popup.defaultAlert')
            );
        }
    }

    getTagMessage(tag) {
        return [tag?.value, tag?.param]
            .map((part) => String(part || '').trim())
            .filter(Boolean)
            .join('\n');
    }

    showNotification(kind, title, message) {
        this.notificationQueue.push({ kind, title, message });
        this.displayNextNotification();
    }

    queueTerminalNotification(kind, title, message) {
        this.pendingTerminalNotifications.push({ kind, title, message });
        if (this.latestInputMode === 'end') {
            this.flushTerminalNotifications();
        }
    }

    flushTerminalNotifications() {
        if (this.pendingTerminalNotifications.length === 0) {
            return;
        }
        this.pendingTerminalNotifications.splice(0).forEach((notification) => {
            this.showNotification(notification.kind, notification.title, notification.message);
        });
    }

    displayNextNotification() {
        if (this.notificationActive || this.notificationQueue.length === 0) {
            return;
        }

        const next = this.notificationQueue.shift();
        const modal = document.getElementById('story_popup_modal');
        const title = document.getElementById('story_popup_title');
        const message = document.getElementById('story_popup_message');
        const okButton = document.getElementById('story_popup_ok');
        if (!modal || !title || !message) {
            return;
        }

        modal.dataset.kind = next.kind;
        title.textContent = next.title;
        message.textContent = next.message;
        if (okButton) {
            okButton.textContent = this.t('popup.ok');
            setTimeout(() => okButton.focus(), 0);
        }
        this.notificationActive = true;
        modal.classList.add('visible');
        modal.setAttribute('aria-hidden', 'false');
    }

    closeNotification() {
        const modal = document.getElementById('story_popup_modal');
        if (!modal) {
            this.notificationActive = false;
            return;
        }
        modal.classList.remove('visible');
        modal.setAttribute('aria-hidden', 'true');
        this.notificationActive = false;
        setTimeout(() => this.displayNextNotification(), 0);
    }
    
    /**
     * Measure text width using canvas
     * @param {string} text - Text to measure
     * @returns {number} - Width of the text
     */
    measureText(text) {
        // Use ParagraphLayout's measureText function instead of implementing our own
        if (this.paragraphLayout && typeof this.paragraphLayout.measureText === 'function') {
            return this.paragraphLayout.measureText(text);
        }
        
        // Fallback measuring if paragraph layout is not available
        if (!this.canvas) {
            this.canvas = document.createElement('canvas');
            this.context = this.canvas.getContext('2d');
            this.context.font = `${this.config.typography.fontSize} ${this.config.typography.fontFamily}`;
        }
        
        return this.context.measureText(text).width;
    }
    
    
    async renderSentence(sentence) {
        if (!sentence) {
            console.error('UIDisplayHandler: Invalid sentence object');
            return null;
        }

        try {
            await this.ensureLiveTailWindow();
            await this.scrollTo(this.getLiveEndLine(), { mode: 'enter-live-tail', smooth: false });
            this.layoutFlowLine = Math.max(0, Number(this.storyHistory?.renderedLineCount || 0));
            this.rebuildLayoutExclusions(this.renderedItems);
            const element = await this.renderStoryBlock(sentence, { animate: true, playback: true, placement: 'append' });
            if (!element) return null;
            sentence.element = element;
            await this.scrollTo(this.getLiveEndLine(), { mode: 'append-live' });

            if (sentence.kind === 'image') {
                this.revealImageBlock(element);
            } else if (sentence.kind === 'paragraph' || sentence.kind === 'heading') {
                await this.playbackCoordinator.play(sentence);
            } else if (sentence.kind === 'music') {
                console.log('UIDisplayHandler: Music block started', sentence.metadata || {});
            }

            if (sentence.onComplete) {
                sentence.onComplete();
            }

            return element;

        } catch (error) {
            console.error('UIDisplayHandler: Error rendering sentence:', error);
            throw error;
        }
    }

    async rerenderStory() {
        if (!this.paragraphContainer || this.renderedItems.length === 0) return;
        console.log('UIDisplayHandler: Re-typesetting story after page resize');
        const activeLine = this.getCurrentScrollLine();
        await this.renderHistoryWindow([...this.renderedItems]);
        await this.scrollTo(activeLine, { mode: 'rerender-preserve', smooth: false });
    }

    async restoreFromHistory(saveRecord = {}) {
        if (!this.paragraphContainer || !this.storyHistory || !saveRecord?.gameId) return;
        const latestRenderedBlockId = Math.max(0, Number(saveRecord.latestRenderedBlockId || 0));
        if (!this.storyHistory.renderedLineCount) {
            await this.storyHistory.getRenderedLineCount(saveRecord.gameId, latestRenderedBlockId);
        }
        const targetLine = Math.max(0, latestRenderedBlockId > 0 ? this.storyHistory.renderedLineCount - 1 : 0);
        if (latestRenderedBlockId > 0) {
            const targetBlock = await this.storyHistory.findBlockForLine(saveRecord.gameId, targetLine, latestRenderedBlockId);
            const targetBlockId = Math.max(1, Number(targetBlock?.blockId || latestRenderedBlockId));
            await this.renderWindowForBounds({
                start: Math.max(1, targetBlockId - this.historyBufferBlocks),
                end: Math.min(latestRenderedBlockId, targetBlockId + this.historyBufferBlocks),
                targetBlockId,
                windowOriginLine: this.getTopLineForActiveLine(targetLine)
            });
        } else {
            await this.renderHistoryWindow([], { windowOriginLine: 0 });
        }
        await this.scrollTo(targetLine, {
            mode: 'restore-bottom',
            smooth: false
        });
        this.updateStoryScrollbar({ latestBlockId: saveRecord.latestBlockId || latestRenderedBlockId || 1 });
    }

    insertStoredElement(element, placement = 'append', targetContainer = this.paragraphContainer) {
        if (!targetContainer || !element) return;
        if (placement === 'prepend') {
            targetContainer.insertBefore(element, targetContainer.firstChild);
        } else {
            targetContainer.appendChild(element);
        }
    }

    async renderStoryBlock(item, options = {}) {
        const {
            animate = false,
            playback = false,
            placement = 'append',
            targetContainer = this.paragraphContainer,
            renderedItemsTarget = this.renderedItems,
            token = null,
            recordMetrics = true
        } = options;
        if (!item || !this.paragraphContainer) return null;
        const renderable = await this.prepareRenderableBlock(item);
        if (token != null && token !== this.renderWindowToken) return null;
        if (!renderable) return null;

        const type = renderable.type;
        let element = null;
        if (type === 'image') {
            element = this.renderImageBlock(renderable, animate, placement, targetContainer);
        } else if (type === 'paragraph' || type === 'heading') {
            element = this.layoutRenderer.renderParagraph(renderable.layout, { id: renderable.id });
            this.insertStoredElement(element, placement, targetContainer);
            if (!animate) {
                this.makeRenderedWordsVisible(element);
                element.dataset.playbackComplete = 'true';
            }
            if (playback) {
                const sentenceQueue = this.getModule('sentence-queue');
                const words = sentenceQueue?.extractWords?.(renderable.layout.nodes) || [];
                item.animation = sentenceQueue?.calculateAnimationTiming?.(words, item.tts?.duration || 0, renderable.metadata.cueMarkers || [])
                    || { wordTimings: [], cueTimings: [], totalDuration: 0 };
                item.element = element;
            }
        } else {
            element = document.createElement('div');
            element.style.display = 'none';
            this.insertStoredElement(element, placement, targetContainer);
            if (playback) {
                document.dispatchEvent(new CustomEvent('story:media-block', {
                    detail: {
                        id: item.id,
                        type,
                        ...(item.metadata || {})
                    }
                }));
            }
        }

        if (item.turnId != null) {
            element.dataset.turnId = String(item.turnId);
            element.classList.add('story-turn-block');
        }
        if (item.blockId != null) {
            element.dataset.storyBlockId = String(item.blockId);
            this.markBlockRendered(item.blockId);
        }
        element.dataset.lineStart = String(renderable.lineStart);
        element.dataset.lineCount = String(renderable.lineCount);
        element.dataset.heightLines = String(renderable.lineCount);

        const renderedItem = {
            ...item,
            type,
            lineStart: renderable.lineStart,
            lineCount: renderable.lineCount,
            metadata: {
                ...(item.metadata || {}),
                ...renderable.metadata,
                lineStart: renderable.lineStart,
                lineCount: renderable.lineCount
            }
        };
        if (placement === 'prepend') {
            renderedItemsTarget.unshift(renderedItem);
        } else {
            renderedItemsTarget.push(renderedItem);
        }

        if (recordMetrics && item.blockId != null) {
            const updated = await this.recordRenderedMetrics(item.blockId, element, renderable.lineCount, renderable.lineStart);
            if (token != null && token !== this.renderWindowToken) {
                element?.remove();
                return null;
            }
            if (updated) {
                renderedItem.lineStart = updated.lineStart;
                renderedItem.lineCount = updated.lineCount;
                renderedItem.metadata.lineStart = updated.lineStart;
                renderedItem.metadata.lineCount = updated.lineCount;
            }
        }
        this.historyWindowStartId = renderedItemsTarget.find(entry => entry.blockId != null)?.blockId || this.historyWindowStartId;
        this.historyWindowEndId = [...renderedItemsTarget].reverse().find(entry => entry.blockId != null)?.blockId || this.historyWindowEndId;
        this.setVirtualPadding();
        this.updateStoryScrollbar();
        return element;
    }

    async renderHistoryWindow(blocks = [], options = {}) {
        if (!this.paragraphContainer) return;
        const token = ++this.renderWindowToken;
        const orderedBlocks = Array.isArray(blocks)
            ? [...blocks].sort((left, right) => Number(left?.blockId || 0) - Number(right?.blockId || 0))
            : [];
        const lineStarts = orderedBlocks
            .map(block => Number(block?.lineStart ?? block?.metadata?.lineStart))
            .filter(Number.isFinite);
        const nextOrigin = lineStarts.length ? Math.max(0, Math.min(...lineStarts)) : 0;
        const fragment = document.createDocumentFragment();
        const nextRenderedItems = [];

        this.windowOriginLine = nextOrigin;
        this.layoutFlowLine = nextOrigin;
        this.rebuildLayoutExclusions(orderedBlocks);

        for (const item of orderedBlocks) {
            await this.renderStoryBlock(item, {
                animate: false,
                playback: false,
                placement: 'append',
                targetContainer: fragment,
                renderedItemsTarget: nextRenderedItems,
                token
            });
            if (token !== this.renderWindowToken) return;
        }

        if (token !== this.renderWindowToken) return;
        this.paragraphContainer.replaceChildren(fragment);
        this.renderedItems = nextRenderedItems;
        this.historyWindowStartId = orderedBlocks[0]?.blockId || 1;
        this.historyWindowEndId = orderedBlocks.at(-1)?.blockId || 0;
        this.setVirtualPadding();
        this.setStoryOffset(-((this.storyTopLine - (this.windowOriginLine || 0)) * this.lineHeightPx));
        this.updateStoryScrollbar();
    }

    setWindowOriginLine(originLine = 0) {
        const nextOrigin = Math.max(0, Math.round(Number(originLine || 0)));
        this.windowOriginLine = nextOrigin;
        const lineHeight = this.measureStoryLineHeight();
        this.paragraphContainer?.querySelectorAll?.('[data-story-block-id][data-line-start]')?.forEach(element => {
            const lineStart = Number(element.dataset.lineStart);
            if (Number.isFinite(lineStart)) {
                element.style.top = `${(lineStart - nextOrigin) * lineHeight}px`;
            }
        });
        this.setStoryOffset(-((this.storyTopLine - nextOrigin) * lineHeight));
        this.setVirtualPadding();
    }

    removeRenderedElement(item) {
        if (!item?.blockId || !this.paragraphContainer) return;
        const element = this.paragraphContainer.querySelector(`[data-story-block-id="${CSS.escape(String(item.blockId))}"]`);
        element?.remove();
    }

    removeRenderedBlocksOutside(startBlockId, endBlockId) {
        const start = Math.max(1, Number(startBlockId || 1));
        const end = Math.max(start, Number(endBlockId || start));
        this.renderedItems = this.renderedItems.filter(item => {
            const blockId = Number(item?.blockId || 0);
            const keep = blockId >= start && blockId <= end;
            if (!keep) {
                this.removeRenderedElement(item);
            }
            return keep;
        });
        this.historyWindowStartId = this.renderedItems.find(entry => entry.blockId != null)?.blockId || 1;
        this.historyWindowEndId = [...this.renderedItems].reverse().find(entry => entry.blockId != null)?.blockId || 0;
    }

    dedupeRenderedWindow() {
        const seenItems = new Set();
        this.renderedItems = this.renderedItems
            .sort((left, right) => Number(left?.blockId || 0) - Number(right?.blockId || 0))
            .filter(item => {
                const blockId = Number(item?.blockId || 0);
                if (!blockId) return true;
                if (seenItems.has(blockId)) return false;
                seenItems.add(blockId);
                return true;
            });

        const seenElements = new Set();
        this.paragraphContainer?.querySelectorAll?.('[data-story-block-id]')?.forEach(element => {
            const blockId = element.dataset.storyBlockId;
            if (!blockId) return;
            if (seenElements.has(blockId)) {
                element.remove();
            } else {
                seenElements.add(blockId);
            }
        });

        if (this.paragraphContainer) {
            const ordered = document.createDocumentFragment();
            this.renderedItems.forEach(item => {
                if (item?.blockId == null) return;
                const element = this.paragraphContainer.querySelector(`[data-story-block-id="${CSS.escape(String(item.blockId))}"]`);
                if (element) ordered.appendChild(element);
            });
            this.paragraphContainer.appendChild(ordered);
        }
    }

    blockIntersectsExclusions(item = {}) {
        if (!this.layoutExclusions.length) return false;
        const type = item.kind || item.type || 'paragraph';
        if (type !== 'paragraph' && type !== 'heading') return false;
        const start = Number(item.lineStart ?? item.metadata?.lineStart);
        const count = Math.max(0, Number(item.lineCount ?? item.metadata?.lineCount ?? 0));
        if (!Number.isFinite(start) || count <= 0) return false;
        const end = start + count;
        return this.layoutExclusions.some(exclusion => start < exclusion.endLine && end > exclusion.startLine);
    }

    async reflowTextBlocksForActiveExclusions(token = this.renderWindowToken) {
        if (!this.layoutExclusions.length || !this.paragraphContainer) return;
        const candidates = this.renderedItems.filter(item => this.blockIntersectsExclusions(item));
        for (const item of candidates) {
            if (token !== this.renderWindowToken) return;
            const oldElement = this.paragraphContainer.querySelector(`[data-story-block-id="${CSS.escape(String(item.blockId))}"]`);
            if (!oldElement) continue;
            const previousFlowLine = this.layoutFlowLine;
            const previousExclusions = [...this.layoutExclusions];
            const renderable = await this.prepareTextRenderable(item, (item.kind || item.type) === 'heading' ? 'heading' : 'paragraph');
            this.layoutFlowLine = previousFlowLine;
            this.layoutExclusions = previousExclusions;
            if (token !== this.renderWindowToken) return;
            renderable.layout.lineCount = Math.max(1, Number(item.lineCount ?? item.metadata?.lineCount ?? renderable.lineCount));
            renderable.lineCount = renderable.layout.lineCount;
            const replacement = this.layoutRenderer.renderParagraph(renderable.layout, { id: renderable.id });
            replacement.dataset.lineStart = String(renderable.lineStart);
            replacement.dataset.lineCount = String(renderable.lineCount);
            replacement.dataset.heightLines = String(renderable.lineCount);
            if (item.turnId != null) {
                replacement.dataset.turnId = String(item.turnId);
                replacement.classList.add('story-turn-block');
            }
            if (item.blockId != null) {
                replacement.dataset.storyBlockId = String(item.blockId);
            }
            this.makeRenderedWordsVisible(replacement);
            oldElement.replaceWith(replacement);
        }
    }

    async renderIncrementalWindow(bounds = {}, requestId = null) {
        if (!this.storyHistory || !this.paragraphContainer) return;
        if (!this.renderedItems.length) {
            await this.renderWindowForBounds(bounds, requestId);
            return;
        }
        const token = ++this.renderWindowToken;

        const start = Math.max(1, Number(bounds.start || 1));
        const end = Math.max(start, Number(bounds.end || start));
        const keptItems = this.renderedItems.filter(item => {
            const blockId = Number(item?.blockId || 0);
            return blockId >= start && blockId <= end;
        });
        const existingIds = new Set(keptItems.map(item => Number(item?.blockId || 0)).filter(Boolean));
        const missingBeforeEnd = Math.min(end, (this.historyWindowStartId || start) - 1);
        const missingAfterStart = Math.max(start, (this.historyWindowEndId || 0) + 1);
        const missingBeforeBlocks = start <= missingBeforeEnd
            ? await this.storyHistory.getBlocksRange(this.storyHistory.currentGameId, start, missingBeforeEnd)
            : [];
        if (requestId != null && requestId !== this.scrollRequestId) return;
        if (token !== this.renderWindowToken) return;
        const missingAfterBlocks = missingAfterStart <= end
            ? await this.storyHistory.getBlocksRange(this.storyHistory.currentGameId, missingAfterStart, end)
            : [];
        if (requestId != null && requestId !== this.scrollRequestId) return;
        if (token !== this.renderWindowToken) return;

        const uniqueBeforeBlocks = missingBeforeBlocks.filter(block => {
            const id = Number(block?.blockId || 0);
            if (!id || existingIds.has(id)) return false;
            existingIds.add(id);
            return true;
        });
        const uniqueAfterBlocks = missingAfterBlocks.filter(block => {
            const id = Number(block?.blockId || 0);
            if (!id || existingIds.has(id)) return false;
            existingIds.add(id);
            return true;
        });
        const stagedItems = [...uniqueBeforeBlocks, ...keptItems, ...uniqueAfterBlocks]
            .sort((left, right) => Number(left?.blockId || 0) - Number(right?.blockId || 0));
        const originCandidates = stagedItems
            .map(item => Number(item?.lineStart ?? item?.metadata?.lineStart))
            .filter(Number.isFinite);
        const finalOrigin = originCandidates.length ? Math.min(...originCandidates) : 0;
        const previousOrigin = this.windowOriginLine;
        const previousFlowLine = this.layoutFlowLine;
        const previousExclusions = [...this.layoutExclusions];
        this.windowOriginLine = finalOrigin;
        this.layoutFlowLine = finalOrigin;
        this.rebuildLayoutExclusions(stagedItems);

        const beforeFragment = document.createDocumentFragment();
        const beforeItems = [];
        for (const block of uniqueBeforeBlocks) {
            await this.renderStoryBlock(block, {
                animate: false,
                playback: false,
                placement: 'append',
                targetContainer: beforeFragment,
                renderedItemsTarget: beforeItems,
                token,
                recordMetrics: false
            });
            if (token !== this.renderWindowToken || (requestId != null && requestId !== this.scrollRequestId)) {
                if (token === this.renderWindowToken) {
                    this.windowOriginLine = previousOrigin;
                    this.layoutFlowLine = previousFlowLine;
                    this.layoutExclusions = previousExclusions;
                    this.setWindowOriginLine(previousOrigin);
                }
                return;
            }
        }

        const afterFragment = document.createDocumentFragment();
        const afterItems = [];
        for (const block of uniqueAfterBlocks) {
            await this.renderStoryBlock(block, {
                animate: false,
                playback: false,
                placement: 'append',
                targetContainer: afterFragment,
                renderedItemsTarget: afterItems,
                token,
                recordMetrics: false
            });
            if (token !== this.renderWindowToken || (requestId != null && requestId !== this.scrollRequestId)) {
                if (token === this.renderWindowToken) {
                    this.windowOriginLine = previousOrigin;
                    this.layoutFlowLine = previousFlowLine;
                    this.layoutExclusions = previousExclusions;
                    this.setWindowOriginLine(previousOrigin);
                }
                return;
            }
        }

        this.setWindowOriginLine(finalOrigin);
        this.paragraphContainer.insertBefore(beforeFragment, this.paragraphContainer.firstChild);
        this.paragraphContainer.appendChild(afterFragment);
        this.renderedItems = [...beforeItems, ...keptItems, ...afterItems]
            .sort((left, right) => Number(left?.blockId || 0) - Number(right?.blockId || 0));
        this.removeRenderedBlocksOutside(start, end);
        this.dedupeRenderedWindow();
        this.setWindowOriginLine(finalOrigin);
        this.rebuildLayoutExclusions(this.renderedItems);
        await this.reflowTextBlocksForActiveExclusions(token);
        if (token !== this.renderWindowToken) return;
        this.setVirtualPadding();
        this.updateStoryScrollbar();
    }

    handleHistoryWheel(event) {
        if (!event.target?.closest?.('#page_right') || !this.pageRight) return;
        event.preventDefault();
        event.stopPropagation();
        this.handleManualScrollStart('wheel');
        let lineDelta = 0;
        const rawDelta = Number(event.deltaY || 0);
        if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
            lineDelta = rawDelta;
        } else if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
            lineDelta = rawDelta * (this.viewportLineCount || this.pageLineCount || 25);
        } else {
            lineDelta = rawDelta / Math.max(8, this.lineHeightPx || 24);
        }
        this.wheelLineAccumulator += lineDelta;
        const wholeLines = Math.trunc(this.wheelLineAccumulator);
        this.wheelLineAccumulator -= wholeLines;
        if (wholeLines < 0) {
            this.scrollUp(Math.abs(wholeLines), { mode: 'wheel' });
        } else if (wholeLines > 0) {
            this.scrollDown(wholeLines, { mode: 'wheel' });
        }
    }

    handleManualScrollStart(source = 'manual-scroll') {
        this.lastManualScrollAt = performance.now();
        this.disableAutoplayForManualScroll();
        if (this.playbackCoordinator && this.playbackCoordinator.isPlaying && typeof this.playbackCoordinator.fastForward === 'function') {
            this.playbackCoordinator.fastForward();
        }
        document.dispatchEvent(new CustomEvent('story:manual-scroll', {
            detail: { source }
        }));
    }

    async prepareRenderableBlock(item) {
        const type = item.kind || item.type || 'paragraph';
        if (type === 'music' || type === 'sfx') {
            return {
                type,
                id: item.id || `${type}-${item.blockId || Date.now()}`,
                lineStart: this.layoutFlowLine,
                lineCount: 0,
                metadata: { ...(item.metadata || {}) }
            };
        }
        if (type === 'image') {
            return this.prepareImageRenderable(item);
        }
        return this.prepareTextRenderable(item, type === 'heading' ? 'heading' : 'paragraph');
    }

    async prepareTextRenderable(item, type = 'paragraph') {
        const sentenceQueue = this.getModule('sentence-queue');
        if (!sentenceQueue || typeof sentenceQueue.prepareLayout !== 'function') {
            throw new Error('UIDisplayHandler: sentence-queue layout calculator unavailable.');
        }
        if (document.fonts && document.fonts.ready) {
            await document.fonts.ready;
        }

        const metadata = {
            ...(item.metadata || {}),
            type,
            role: item.role || item.metadata?.role || (type === 'heading' ? 'chapter-heading' : 'body'),
            layoutText: item.layoutText || item.metadata?.layoutText || item.text,
            isFirstParagraphInChapter: Boolean(item.isFirstParagraphInChapter ?? item.metadata?.isFirstParagraphInChapter),
            dropCap: Boolean(item.dropCap ?? item.metadata?.dropCap),
            addTopSpace: Boolean(item.addTopSpace ?? item.metadata?.addTopSpace),
            paragraphIndex: item.paragraphIndex ?? item.metadata?.paragraphIndex,
            cueMarkers: item.cueMarkers || item.metadata?.cueMarkers || [],
            turnId: item.turnId ?? item.metadata?.turnId,
            blockId: item.blockId ?? item.metadata?.blockId,
            gameId: item.gameId ?? item.metadata?.gameId
        };

        const role = metadata.role;
        const isHeading = type === 'heading' || role === 'chapter-heading' || role === 'section-heading';
        if (isHeading) {
            this.layoutFlowLine = Math.max(this.layoutFlowLine, this.getExclusionEndLine());
            this.layoutExclusions = this.layoutExclusions.filter(exclusion => exclusion.endLine > this.layoutFlowLine);
        }

        const topSpace = role === 'chapter-heading' ? 2 : role === 'section-heading' ? 1 : metadata.addTopSpace ? 1 : 0;
        const bottomSpace = role === 'chapter-heading' ? 1 : role === 'section-heading' ? 1 : 0;
        const lineStart = Number.isFinite(Number(item.lineStart)) ? Number(item.lineStart) : this.layoutFlowLine;
        const contentStartLine = lineStart + topSpace;
        const geometry = this.buildLineGeometry(metadata, contentStartLine);
        const layout = await sentenceQueue.prepareLayout(item.text || '', {
            ...metadata,
            measures: geometry.measures,
            lineOffsets: geometry.lineOffsets,
            imageWrap: geometry.imageWrap
        });
        const contentLines = Math.max(1, (layout.breaks?.length || 2) - 1);
        const lineCount = Math.max(1, topSpace + contentLines + bottomSpace);
        layout.lineStart = lineStart;
        layout.lineCount = lineCount;
        layout.contentTopLines = topSpace;
        layout.measures = geometry.measures;
        layout.lineOffsets = geometry.lineOffsets;
        layout.pageWidth = geometry.pageWidth;
        layout.windowOriginLine = this.windowOriginLine || 0;

        this.layoutFlowLine = Math.max(this.layoutFlowLine, contentStartLine + contentLines + bottomSpace);
        this.layoutExclusions = this.layoutExclusions.filter(exclusion => exclusion.endLine > this.layoutFlowLine);

        return {
            type,
            id: item.id || `${type}-${item.blockId || Date.now()}`,
            lineStart,
            lineCount,
            layout,
            metadata
        };
    }

    prepareImageRenderable(item) {
        const metadata = { ...(item.metadata || {}), ...item };
        const metrics = this.calculateImageMetrics(metadata);
        const lineStart = Number.isFinite(Number(item.lineStart)) ? Number(item.lineStart) : this.layoutFlowLine;
        const lineCount = Math.max(1, Math.round(metrics.lineCount || 1));
        const renderMetadata = {
            ...metadata,
            imageLayout: { ...metrics, lineStart, lineCount },
            lineStart,
            lineCount,
            windowOriginLine: this.windowOriginLine || 0
        };

        if ((metrics.size || metadata.size) === 'portrait') {
            this.addImageExclusion({ ...item, metadata: renderMetadata, lineStart, lineCount });
        } else {
            this.layoutFlowLine = Math.max(this.layoutFlowLine, this.getExclusionEndLine());
            this.layoutExclusions = [];
            this.layoutFlowLine = Math.max(this.layoutFlowLine, lineStart + lineCount);
        }

        return {
            type: 'image',
            id: item.id || `image-${item.blockId || Date.now()}`,
            lineStart,
            lineCount,
            metadata: renderMetadata
        };
    }

    createImageExclusion(item = {}) {
        const type = item.kind || item.type;
        const metadata = { ...(item.metadata || {}), ...item };
        const layout = metadata.imageLayout || {};
        const size = String(layout.size || metadata.size || '').toLowerCase();
        if (type !== 'image' || size !== 'portrait') return null;

        const lineStart = Number(metadata.lineStart ?? layout.lineStart ?? item.lineStart);
        const lineCount = Math.max(1, Number(metadata.lineCount ?? layout.lineCount ?? item.lineCount ?? 1));
        const width = Number(layout.width ?? metadata.width ?? 0);
        const gap = Number(layout.gap ?? metadata.gap ?? this.measureStoryLineHeight());
        if (!Number.isFinite(lineStart) || !Number.isFinite(width) || width <= 0) return null;

        return {
            blockId: item.blockId ?? metadata.blockId,
            startLine: Math.max(0, lineStart),
            endLine: Math.max(0, lineStart) + lineCount,
            width: width + Math.max(0, gap),
            side: layout.floatSide || metadata.floatSide || 'right'
        };
    }

    addImageExclusion(item = {}) {
        const exclusion = this.createImageExclusion(item);
        if (!exclusion) return;
        const key = String(exclusion.blockId ?? `${exclusion.startLine}:${exclusion.endLine}:${exclusion.side}`);
        const existingIndex = this.layoutExclusions.findIndex(entry => {
            const entryKey = String(entry.blockId ?? `${entry.startLine}:${entry.endLine}:${entry.side}`);
            return entryKey === key;
        });
        if (existingIndex >= 0) {
            this.layoutExclusions[existingIndex] = exclusion;
        } else {
            this.layoutExclusions.push(exclusion);
        }
    }

    rebuildLayoutExclusions(items = this.renderedItems) {
        this.layoutExclusions = [];
        const source = Array.isArray(items) ? items : [];
        source.forEach(item => this.addImageExclusion(item));
    }

    getExclusionEndLine() {
        return this.layoutExclusions.reduce((max, exclusion) => Math.max(max, exclusion.endLine || 0), this.layoutFlowLine || 0);
    }

    getActiveExclusions(line) {
        return this.layoutExclusions.filter(exclusion => line >= exclusion.startLine && line < exclusion.endLine);
    }

    buildLineGeometry(metadata = {}, contentStartLine = 0) {
        const pageWidth = this.container?.clientWidth || this.paragraphContainer?.clientWidth || 600;
        const lineHeight = this.measureStoryLineHeight();
        const isHeading = metadata.type === 'heading' || metadata.role === 'chapter-heading' || metadata.role === 'section-heading';
        const dropCapLines = metadata.dropCap ? 2 : 0;
        const dropCapWidth = metadata.dropCap ? lineHeight * 1.45 : 0;
        const indentWidth = (isHeading || metadata.isFirstParagraphInChapter || metadata.addTopSpace) ? 0 : lineHeight * 1.5;
        const maxConsideredLines = Math.max(80, this.pageLineCount * 4);
        const measures = [];
        const lineOffsets = [];

        for (let index = 0; index < maxConsideredLines; index += 1) {
            const line = contentStartLine + index;
            const active = isHeading ? [] : this.getActiveExclusions(line);
            const leftExclusion = active.filter(exclusion => exclusion.side !== 'right')
                .reduce((sum, exclusion) => sum + Number(exclusion.width || 0), 0);
            const rightExclusion = active.filter(exclusion => exclusion.side === 'right')
                .reduce((sum, exclusion) => sum + Number(exclusion.width || 0), 0);
            const available = Math.max(120, pageWidth - leftExclusion - rightExclusion);
            const firstLineInset = metadata.dropCap && index < dropCapLines
                ? dropCapWidth
                : index === 0
                    ? indentWidth
                    : 0;
            measures.push(Math.max(120, available - firstLineInset));
            lineOffsets.push(isHeading ? 0 : leftExclusion + firstLineInset);
        }

        return {
            pageWidth,
            measures,
            lineOffsets,
            imageWrap: this.layoutExclusions.length > 0 ? [...this.layoutExclusions] : null
        };
    }

    makeRenderedWordsVisible(element) {
        element?.querySelectorAll?.('.word')?.forEach(word => {
            word.style.transition = 'none';
            word.style.animation = 'none';
            word.style.visibility = 'visible';
            word.style.opacity = '1';
            word.style.transform = 'translateY(0)';
            word.style.clipPath = 'inset(0 0 0 0)';
        });
    }

    markBlockRendered(blockId) {
        if (this.storyHistory && typeof this.storyHistory.markRendered === 'function') {
            const latestRenderedBlockId = this.storyHistory.markRendered(blockId);
            document.dispatchEvent(new CustomEvent('story:history-updated', {
                detail: {
                    gameId: this.storyHistory.currentGameId || null,
                    latestBlockId: this.getLatestHistoryBlockId(),
                    latestRenderedBlockId
                }
            }));
        }
    }

    getLatestHistoryBlockId() {
        return Math.max(0, Number(this.storyHistory?.latestRenderedBlockId || 0));
    }

    updateStoryScrollbar(detail = {}) {
        const track = document.getElementById('story_scrollbar');
        const thumb = document.getElementById('story_scrollbar_thumb');
        if (!thumb) return;
        this.measureStoryLineHeight();
        const totalLines = Math.max(1, Number(detail.renderedLineCount || this.storyHistory?.renderedLineCount || 0));
        const viewportLines = Math.max(1, this.viewportLineCount || 1);
        const visibleLines = Math.min(viewportLines, totalLines);
        const maxTopLine = Math.max(0, totalLines - visibleLines);
        const previewTop = this.scrollbarPreviewLine == null
            ? null
            : this.getTopLineForActiveLine(this.scrollbarPreviewLine);
        const currentTop = Math.max(0, Math.min(maxTopLine, previewTop ?? (this.storyTopLine || 0)));
        const heightPercent = Math.max(8, Math.min(100, (visibleLines / totalLines) * 100));
        const topPercent = maxTopLine <= 0 ? 0 : (currentTop / maxTopLine) * (100 - heightPercent);
        if (track) {
            track.dataset.totalLines = String(totalLines);
            track.dataset.viewportLines = String(viewportLines);
            track.dataset.topLine = String(currentTop);
            track.hidden = totalLines <= viewportLines;
        }
        thumb.style.height = `${heightPercent}%`;
        thumb.style.top = `${topPercent}%`;
    }

    handleStoryScrollbarPointer(event) {
        event.preventDefault();
        event.stopPropagation();
        this.handleManualScrollStart('scrollbar');
        const track = event.currentTarget;
        if (!track) return;
        const thumb = document.getElementById('story_scrollbar_thumb');
        const pointerId = event.pointerId;
        if (typeof track.setPointerCapture === 'function' && pointerId != null) {
            try {
                track.setPointerCapture(pointerId);
            } catch (error) {
                console.warn('UIDisplayHandler: Story scrollbar pointer capture unavailable', error);
            }
        }
        this.draggingStoryScrollbar = true;
        track.dataset.dragging = 'true';
        const trackRect = track.getBoundingClientRect();
        const thumbRect = thumb?.getBoundingClientRect?.();
        const grabOffset = thumb && thumb.contains(event.target)
            ? Math.max(0, event.clientY - (thumbRect?.top || trackRect.top))
            : Math.max(0, (thumbRect?.height || 0) / 2);
        const previewToPointer = (pointerEvent) => {
            const rect = track.getBoundingClientRect();
            const thumbHeight = thumb?.getBoundingClientRect?.().height || 0;
            const travel = Math.max(1, rect.height - thumbHeight);
            const thumbTop = Math.max(0, Math.min(travel, pointerEvent.clientY - rect.top - grabOffset));
            const ratio = Math.max(0, Math.min(1, thumbTop / travel));
            const maxTopLine = this.getMaxStoryTopLine();
            const targetTopLine = Math.round(maxTopLine * ratio);
            this.scrollbarPreviewLine = this.getActiveLineForTopLine(targetTopLine);
            if (thumb) {
                thumb.style.transition = 'none';
                const totalLines = Math.max(1, Number(this.storyHistory?.renderedLineCount || 0));
                const viewportLines = Math.max(1, this.viewportLineCount || 1);
                const heightPercent = Math.max(8, Math.min(100, (Math.min(viewportLines, totalLines) / totalLines) * 100));
                thumb.style.height = `${heightPercent}%`;
                thumb.style.top = `${ratio * (100 - heightPercent)}%`;
            }
        };

        previewToPointer(event);
        const onMove = (moveEvent) => previewToPointer(moveEvent);
        const cleanup = () => {
            this.storyScrollbarReleaseHandler = null;
            document.removeEventListener('pointermove', onMove);
            document.removeEventListener('pointerup', onRelease);
            document.removeEventListener('pointercancel', onRelease);
            document.removeEventListener('mouseup', onRelease);
            window.removeEventListener('blur', onRelease);
            if (typeof track.releasePointerCapture === 'function' && pointerId != null) {
                try {
                    track.releasePointerCapture(pointerId);
                } catch (error) {
                    // The browser may already have released capture; cleanup can continue.
                }
            }
            this.draggingStoryScrollbar = false;
            delete track.dataset.dragging;
            if (thumb) {
                thumb.style.transition = '';
            }
        };
        const onRelease = async (releaseEvent) => {
            releaseEvent?.preventDefault?.();
            releaseEvent?.stopPropagation?.();
            cleanup();
            const targetLine = this.scrollbarPreviewLine;
            this.scrollbarPreviewLine = null;
            if (Number.isFinite(Number(targetLine))) {
                await this.scrollTo(targetLine, { mode: 'scrollbar-release' });
            } else {
                this.updateStoryScrollbar();
            }
        };
        this.storyScrollbarReleaseHandler = onRelease;
        document.addEventListener('pointermove', onMove);
        document.addEventListener('pointerup', onRelease);
        document.addEventListener('pointercancel', onRelease);
        document.addEventListener('mouseup', onRelease);
        window.addEventListener('blur', onRelease);
    }

    disableAutoplayForManualScroll() {
        if (!this.persistenceManager || typeof this.persistenceManager.updatePreference !== 'function') {
            console.error('UIDisplayHandler: Cannot disable autoplay; persistence-manager dependency is unavailable.');
            return;
        }
        this.persistenceManager.updatePreference('app', 'autoplay', false);
        document.dispatchEvent(new CustomEvent('app:autoplay:change', {
            detail: { enabled: false, autoplay: false, source: 'manual-story-scroll' }
        }));
    }

    measureStoryLineHeight() {
        const pageHeight = this.pageRight?.clientHeight || 0;
        const lineHeight = pageHeight > 0
            ? pageHeight / this.pageLineCount
            : this.lineHeightPx || 24;
        this.lineHeightPx = lineHeight;
        this.viewportLineCount = this.pageLineCount;
        document.documentElement.style.setProperty('--page-line-count', String(this.pageLineCount));
        document.documentElement.style.setProperty('--story-line-height', `${lineHeight}px`);
        document.documentElement.style.setProperty('--story-font-size', `${lineHeight / 1.45}px`);
        return this.lineHeightPx;
    }

    measureBlockLines(element, fallbackLineCount = 1) {
        const lineHeight = this.measureStoryLineHeight();
        const declaredLines = Number(element?.dataset?.heightLines);
        if (Number.isFinite(declaredLines) && declaredLines >= 0) {
            const lineCount = Math.max(0, Math.round(declaredLines));
            return { lineCount, heightPx: lineCount * lineHeight, lineHeightPx: lineHeight };
        }
        throw new Error(`UIDisplayHandler: Rendered story block ${element?.id || '(unknown)'} has no data-height-lines declaration.`);
    }

    async recordRenderedMetrics(blockId, element, fallbackLineCount = 1, lineStart = null) {
        if (!this.storyHistory || typeof this.storyHistory.updateBlockMetrics !== 'function' || blockId == null) return null;
        const metrics = this.measureBlockLines(element, fallbackLineCount);
        if (Number.isFinite(Number(lineStart))) {
            metrics.lineStart = Math.max(0, Number(lineStart));
        }
        const updated = await this.storyHistory.updateBlockMetrics(blockId, metrics);
        if (updated && element) {
            element.dataset.lineStart = String(updated.lineStart || 0);
            element.dataset.lineCount = String(updated.lineCount || metrics.lineCount);
        }
        this.setVirtualPadding();
        this.updateStoryScrollbar();
        return updated;
    }

    setVirtualPadding() {
        if (!this.paragraphContainer) return;
        const renderedEndLine = this.renderedItems.reduce((max, item) => {
            const start = Number(item?.lineStart ?? item?.metadata?.lineStart);
            const count = Math.max(0, Number(item?.lineCount ?? item?.metadata?.lineCount ?? 0));
            return Number.isFinite(start) ? Math.max(max, start + count) : max;
        }, this.windowOriginLine || 0);
        const totalLines = Math.max(0, renderedEndLine - Math.max(0, this.windowOriginLine || 0));
        const lineHeight = this.measureStoryLineHeight();
        this.paragraphContainer.style.paddingTop = '0';
        this.paragraphContainer.style.paddingBottom = '0';
        this.paragraphContainer.style.height = `${totalLines * lineHeight}px`;
    }

    setStoryOffset(offsetPx) {
        this.storyOffsetPx = Number(offsetPx) || 0;
        if (this.container) {
            this.container.style.transform = `translateY(${this.storyOffsetPx}px)`;
        }
        this.handleStoryScroll();
        this.updateStoryScrollbar();
    }

    getMaxStoryTopLine() {
        this.measureStoryLineHeight();
        const totalLines = Math.max(0, Number(this.storyHistory?.renderedLineCount || 0));
        return Math.max(0, totalLines - Math.max(1, this.viewportLineCount || 1));
    }

    getActiveLineForTopLine(topLine = this.storyTopLine) {
        const totalLines = Math.max(0, Number(this.storyHistory?.renderedLineCount || 0));
        if (totalLines <= 0) return 0;
        const requested = Math.round(Number(topLine || 0)) + Math.max(1, this.viewportLineCount || 1) - 1;
        return Math.max(0, Math.min(totalLines - 1, requested));
    }

    getTopLineForActiveLine(activeLine = 0) {
        const maxTopLine = this.getMaxStoryTopLine();
        const requested = Math.round(Number(activeLine || 0)) - Math.max(1, this.viewportLineCount || 1) + 1;
        return Math.max(0, Math.min(maxTopLine, requested));
    }

    getRenderedBlockForLine(line = 0) {
        const target = Math.max(0, Number(line || 0));
        return this.renderedItems.find(item => {
            const start = Number(item.lineStart ?? item.metadata?.lineStart);
            const count = Math.max(0, Number(item.lineCount ?? item.metadata?.lineCount ?? 0));
            return Number.isFinite(start) && count > 0 && target >= start && target < start + count;
        }) || null;
    }

    getCurrentScrollLine() {
        if (Number.isFinite(Number(this.scrollTargetLine))) {
            return Math.max(0, Number(this.scrollTargetLine));
        }
        return this.getActiveLineForTopLine(this.storyTopLine);
    }

    getLiveEndLine() {
        return Math.max(0, Number(this.storyHistory?.renderedLineCount || 0) - 1);
    }

    scrollUp(numberOfLines = 1, options = {}) {
        const lines = Math.max(0, Math.round(Number(numberOfLines || 0)));
        return this.scrollTo(this.getCurrentScrollLine() - lines, { ...options, direction: -1 });
    }

    scrollDown(numberOfLines = 1, options = {}) {
        const lines = Math.max(0, Math.round(Number(numberOfLines || 0)));
        return this.scrollTo(this.getCurrentScrollLine() + lines, { ...options, direction: 1 });
    }

    async scrollTo(lineNumber = 0, options = {}) {
        this.measureStoryLineHeight();
        const totalLines = Math.max(0, Number(this.storyHistory?.renderedLineCount || 0));
        const targetLine = Math.max(0, Math.min(Math.max(0, totalLines - 1), Math.round(Number(lineNumber || 0))));
        const previousLine = this.getCurrentScrollLine();
        const requestId = ++this.scrollRequestId;
        this.scrollTargetLine = targetLine;

        if (this.storyHistory && totalLines > 0) {
            await this.ensureScrollRangeForTarget(previousLine, targetLine, { ...options, requestId });
            if (requestId !== this.scrollRequestId) return;
        }

        const targetTopLine = this.getTopLineForActiveLine(targetLine);
        await this.animateToTopLine(targetTopLine, options.smooth !== false, options);
        this.scrollTargetLine = this.getActiveLineForTopLine(this.storyTopLine);
    }

    async ensureScrollRangeForTarget(previousLine = 0, targetLine = 0, options = {}) {
        if (!this.storyHistory || !this.paragraphContainer) return;
        const latest = Math.max(0, Number(this.storyHistory.latestRenderedBlockId || 0));
        if (latest <= 0) return;

        const bounds = await this.getWindowBoundsForTraversal(previousLine, targetLine, latest);
        if (!bounds) return;
        if (options.mode === 'append-live' && this.getRenderedBlockForLine(targetLine)) {
            this.activeCenterBlockId = bounds.targetBlockId;
            this.updateStoryScrollbar();
            return;
        }

        const currentWindowCoversBounds = bounds.start >= this.historyWindowStartId && bounds.end <= this.historyWindowEndId;
        const exactWindowAlreadyLoaded = bounds.start === this.historyWindowStartId && bounds.end === this.historyWindowEndId;

        if (bounds.teleport) {
            this.paragraphContainer.classList.add('story-history-fading');
            await new Promise(resolve => setTimeout(resolve, 220));
            await this.renderWindowForBounds(bounds, options.requestId);
            this.paragraphContainer.classList.remove('story-history-fading');
        } else if (!currentWindowCoversBounds || !exactWindowAlreadyLoaded) {
            await this.renderIncrementalWindow(bounds, options.requestId);
        }

        this.activeCenterBlockId = bounds.targetBlockId;
        this.updateStoryScrollbar();
    }

    animateToTopLine(targetLine, smooth = true, options = {}) {
        this.measureStoryLineHeight();
        const maxTopLine = this.getMaxStoryTopLine();
        const target = Math.round(Math.max(0, Math.min(maxTopLine, Number(targetLine || 0))));
        if (!smooth) {
            if (this.scrollAnimationFrameId != null) {
                cancelAnimationFrame(this.scrollAnimationFrameId);
                this.scrollAnimationFrameId = null;
            }
            if (this.scrollAnimationResolve) {
                this.scrollAnimationResolve();
                this.scrollAnimationResolve = null;
                this.scrollAnimationPromise = null;
            }
            this.storyTopLine = target;
            this.setStoryOffset(-((target - (this.windowOriginLine || 0)) * this.lineHeightPx));
            return Promise.resolve();
        }

        const now = performance.now();
        const distance = Math.abs(target - (this.storyTopLine || 0));
        this.storyScrollAnimation = {
            startTopLine: this.storyTopLine || 0,
            targetTopLine: target,
            startedAt: now,
            duration: Math.max(180, Math.min(700, 160 + (distance * 35)))
        };

        if (!this.scrollAnimationPromise) {
            this.scrollAnimationPromise = new Promise(resolve => {
                this.scrollAnimationResolve = resolve;
            });
        }

        if (this.scrollAnimationFrameId == null) {
            const step = (now) => {
                const animation = this.storyScrollAnimation;
                if (!animation) {
                    this.scrollAnimationFrameId = null;
                    const resolve = this.scrollAnimationResolve;
                    this.scrollAnimationResolve = null;
                    this.scrollAnimationPromise = null;
                    resolve?.();
                    return;
                }

                const progress = Math.min(1, Math.max(0, (now - animation.startedAt) / Math.max(1, animation.duration)));
                const eased = progress < 0.5
                    ? 4 * progress * progress * progress
                    : 1 - Math.pow(-2 * progress + 2, 3) / 2;
                this.storyTopLine = animation.startTopLine + ((animation.targetTopLine - animation.startTopLine) * eased);
                this.setStoryOffset(-((this.storyTopLine - (this.windowOriginLine || 0)) * this.lineHeightPx));
                if (progress >= 1 || Math.abs(animation.targetTopLine - this.storyTopLine) < 0.02) {
                    this.storyTopLine = animation.targetTopLine;
                    this.setStoryOffset(-((this.storyTopLine - (this.windowOriginLine || 0)) * this.lineHeightPx));
                    this.storyScrollAnimation = null;
                    this.scrollAnimationFrameId = null;
                    const resolve = this.scrollAnimationResolve;
                    this.scrollAnimationResolve = null;
                    this.scrollAnimationPromise = null;
                    resolve?.();
                    return;
                }
                this.scrollAnimationFrameId = requestAnimationFrame(step);
            };
            this.scrollAnimationFrameId = requestAnimationFrame(step);
        }

        return this.scrollAnimationPromise;
    }

    async ensureLiveTailWindow() {
        if (!this.storyHistory || !this.paragraphContainer) return;
        const latestRendered = Math.max(0, Number(this.storyHistory.latestRenderedBlockId || 0));
        if (latestRendered > 0) {
            const start = Math.max(1, latestRendered - (this.visibleBlockLimit - 2));
            const end = latestRendered;
            if (this.historyWindowStartId !== start || this.historyWindowEndId !== end) {
                const liveEndLine = Math.max(0, Number(this.storyHistory.renderedLineCount || 0) - 1);
                await this.renderWindowForBounds({
                    start,
                    end,
                    targetBlockId: latestRendered,
                    windowOriginLine: this.getTopLineForActiveLine(liveEndLine)
                });
            }
        } else if (this.renderedItems.length) {
            this.paragraphContainer.innerHTML = '';
            this.renderedItems = [];
            this.historyWindowStartId = 1;
            this.historyWindowEndId = 0;
            this.windowOriginLine = 0;
        }
        this.layoutFlowLine = Math.max(0, Number(this.storyHistory.renderedLineCount || 0));
        this.activeCenterBlockId = latestRendered || null;
    }

    async getWindowBoundsForTraversal(previousLine = 0, targetLine = 0, latest = null) {
        const latestRendered = Math.max(0, Number(latest ?? this.storyHistory?.latestRenderedBlockId ?? 0));
        if (!this.storyHistory || latestRendered <= 0) return null;
        const targetBlock = this.getRenderedBlockForLine(targetLine) || await this.storyHistory.findBlockForLine(
            this.storyHistory.currentGameId,
            targetLine,
            latestRendered
        );
        if (!targetBlock?.blockId) return null;

        const previousBlock = this.getRenderedBlockForLine(previousLine) || await this.storyHistory.findBlockForLine(
            this.storyHistory.currentGameId,
            previousLine,
            latestRendered
        ) || targetBlock;

        const targetBlockId = Math.max(1, Number(targetBlock.blockId));
        const previousBlockId = Math.max(1, Number(previousBlock.blockId || targetBlockId));
        const rangeStart = Math.min(previousBlockId, targetBlockId);
        const rangeEnd = Math.max(previousBlockId, targetBlockId);
        const expandedStart = Math.max(1, rangeStart - this.historyBufferBlocks);
        const expandedEnd = Math.min(latestRendered, rangeEnd + this.historyBufferBlocks);
        const expandedCount = expandedEnd - expandedStart + 1;

        if (expandedCount > this.maxTraversalBlocks) {
            return {
                start: Math.max(1, targetBlockId - this.historyBufferBlocks),
                end: Math.min(latestRendered, targetBlockId + this.historyBufferBlocks),
                targetBlockId,
                windowOriginLine: this.getTopLineForActiveLine(targetLine),
                teleport: true
            };
        }

        return {
            start: expandedStart,
            end: expandedEnd,
            targetBlockId,
            windowOriginLine: Math.min(
                this.getTopLineForActiveLine(previousLine),
                this.getTopLineForActiveLine(targetLine)
            ),
            teleport: false
        };
    }

    async renderWindowForBounds(bounds = {}, requestId = null) {
        const start = Math.max(1, Number(bounds.start || 1));
        const end = Math.max(start, Number(bounds.end || start));
        const blocks = await this.storyHistory.getBlocksRange(this.storyHistory.currentGameId, start, end);
        if (requestId != null && requestId !== this.scrollRequestId) return;
        await this.renderHistoryWindow(blocks, { windowOriginLine: bounds.windowOriginLine });
    }

    focusTurn(turnId) {
        if (!this.pageRight || turnId == null) return;
        const escapedTurnId = CSS.escape(String(turnId));
        const scrollToLiveTarget = () => {
            const target = this.paragraphContainer?.querySelector(`[data-turn-id="${escapedTurnId}"]`);
            if (!target) return false;
            const targetLine = Number(target.dataset.lineStart);
            if (Number.isFinite(targetLine)) {
                this.scrollTo(targetLine, { mode: 'jump-to-turn' });
            }
            return true;
        };

        if (scrollToLiveTarget()) return;
        this.storyHistory?.getFirstBlockForTurn?.(this.storyHistory.currentGameId, turnId).then((block) => {
            const targetLine = Number(block?.lineStart);
            if (Number.isFinite(targetLine)) {
                this.scrollTo(targetLine, { mode: 'jump-to-turn' });
            }
        });
    }

    handleStoryScroll() {
        if (!this.pageRight || !this.paragraphContainer) return;

        const blocks = Array.from(this.paragraphContainer.querySelectorAll('[data-turn-id]'));
        if (blocks.length === 0) return;

        const viewportMiddle = (this.storyTopLine * this.measureStoryLineHeight()) + (this.pageRight.clientHeight / 2);
        let best = null;
        let bestDistance = Infinity;

        blocks.forEach((block) => {
            const lineStart = Number(block.dataset.lineStart);
            const lineCount = Number(block.dataset.lineCount);
            if (!Number.isFinite(lineStart) || !Number.isFinite(lineCount)) {
                return;
            }
            const blockMiddle = (lineStart + (lineCount / 2)) * this.lineHeightPx;
            const distance = Math.abs(blockMiddle - viewportMiddle);
            if (distance < bestDistance) {
                bestDistance = distance;
                best = block;
            }
        });

        if (best?.dataset?.turnId && this.activeTurnId !== best.dataset.turnId) {
            this.activeTurnId = best.dataset.turnId;
            document.dispatchEvent(new CustomEvent('story:visible-turn', {
                detail: { turnId: Number(best.dataset.turnId) }
            }));
        }
    }

    readFirstFiniteNumber(...values) {
        for (const value of values) {
            const number = Number(value);
            if (Number.isFinite(number)) {
                return Math.max(0, number);
            }
        }

        return 0;
    }

    waitForSkippablePause(seconds, kind = 'media') {
        const duration = Math.max(0, Number(seconds) || 0) * 1000;
        if (duration <= 0) return Promise.resolve(false);

        document.documentElement.dataset.skippablePause = 'true';
        document.dispatchEvent(new CustomEvent('story:process-state', {
            detail: { state: 'playing-ready', reason: `${kind}-pause-start`, duration }
        }));

        return new Promise(resolve => {
            let finished = false;
            let timeoutId = null;

            const finish = (skipped) => {
                if (finished) return;
                finished = true;
                clearTimeout(timeoutId);
                document.removeEventListener('ui:command', onCommand);
                delete document.documentElement.dataset.skippablePause;
                document.dispatchEvent(new CustomEvent('story:process-state', {
                    detail: { state: 'playing-ready', reason: `${kind}-pause-${skipped ? 'skipped' : 'complete'}` }
                }));
                resolve(skipped);
            };

            const onCommand = (event) => {
                if (event.detail?.type === 'continue') {
                    finish(true);
                }
            };

            document.addEventListener('ui:command', onCommand);
            timeoutId = setTimeout(() => finish(false), duration);
        });
    }

    renderImageBlock(renderableOrMetadata = {}, animate = true, placement = 'append', targetContainer = this.paragraphContainer) {
        if (!this.paragraphContainer) return null;

        const metadata = renderableOrMetadata.metadata || renderableOrMetadata;
        const metrics = metadata.imageLayout || this.calculateImageMetrics(metadata.size);
        const lineStart = Math.max(0, Number(renderableOrMetadata.lineStart ?? metadata.lineStart ?? metrics.lineStart ?? 0));
        const lineCount = Math.max(1, Number(renderableOrMetadata.lineCount ?? metadata.lineCount ?? metrics.lineCount ?? 1));
        const windowOriginLine = Math.max(0, Number(renderableOrMetadata.windowOriginLine ?? metadata.windowOriginLine ?? this.windowOriginLine ?? 0));
        const lineHeight = this.measureStoryLineHeight();
        const pageWidth = this.container?.clientWidth || this.paragraphContainer?.clientWidth || metrics.pageWidth || 600;
        const figure = document.createElement('figure');
        if (metadata.id) {
            figure.id = metadata.id;
        }
        figure.className = [
            'story-image-block',
            `story-image-${metrics.size || 'landscape'}`,
            animate ? 'story-image-pending' : 'story-image-visible'
        ].filter(Boolean).join(' ');
        figure.style.position = 'absolute';
        figure.style.top = `${(lineStart - windowOriginLine) * lineHeight}px`;
        figure.style.height = `${lineCount * lineHeight}px`;
        figure.style.width = `${metrics.width}px`;
        figure.style.margin = '0';
        figure.style.padding = '0';
        const side = metrics.floatSide || metadata.floatSide || 'right';
        if ((metrics.size || metadata.size) === 'portrait' && side === 'right') {
            figure.style.left = `${Math.max(0, pageWidth - metrics.width)}px`;
        } else if ((metrics.size || metadata.size) === 'portrait') {
            figure.style.left = '0px';
        } else {
            figure.style.left = `${Math.max(0, (pageWidth - metrics.width) / 2)}px`;
        }
        figure.dataset.heightLines = String(lineCount);
        figure.dataset.lineStart = String(lineStart);
        figure.dataset.lineCount = String(lineCount);
        figure.dataset.animationMs = '2000';
        if (metadata.turnId != null) {
            figure.dataset.turnId = String(metadata.turnId);
            figure.classList.add('story-turn-block');
        }

        const img = document.createElement('img');
        img.src = this.resolveImageUrl(metadata);
        img.alt = metadata.alt || '';
        img.decoding = 'async';
        img.loading = 'eager';
        img.style.position = 'absolute';
        img.style.left = '0';
        img.style.top = `${metrics.verticalMargin || 0}px`;
        img.style.width = `${metrics.width}px`;
        img.style.height = `${metrics.height}px`;
        figure.appendChild(img);

        this.insertStoredElement(figure, placement, targetContainer);

        if (animate && metadata.revealImmediately !== false) {
            window.requestAnimationFrame(() => this.revealImageBlock(figure));
        } else {
            if (!animate) {
                figure.classList.remove('story-image-pending');
                figure.classList.add('story-image-visible');
            }
        }

        return figure;
    }

    revealImageBlock(figure) {
        if (!figure) return;
        figure.classList.remove('story-image-pending');
        figure.classList.add('story-image-visible');
    }

    resolveImageUrl(metadata = {}) {
        const explicit = String(metadata.url || '').trim();
        if (explicit) return explicit;

        const filename = String(metadata.filename || '').trim();
        if (!filename) return '';
        if (/^(https?:|data:|blob:|\/)/i.test(filename)) return filename;
        return `/images/${filename.replace(/^images[\\/]/i, '').replace(/\\/g, '/')}`;
    }

    calculateImageMetrics(metadataOrSize = 'landscape') {
        const storyElement = document.getElementById('story');
        const pageWidth = storyElement?.clientWidth || 600;
        const lineHeight = this.measureStoryLineHeight();

        const metadata = typeof metadataOrSize === 'object' && metadataOrSize !== null ? metadataOrSize : { size: metadataOrSize };
        const normalizedSize = String(metadata.size || 'landscape').toLowerCase() === 'widescreen'
            ? 'landscape'
            : String(metadata.size || 'landscape').toLowerCase();
        const aspect = normalizedSize === 'portrait' ? (9 / 16) : normalizedSize === 'square' ? 1 : (16 / 9);
        const isPortrait = normalizedSize === 'portrait';
        const imageGap = lineHeight;
        const maxOuterWidth = isPortrait ? pageWidth * 0.5 : pageWidth;
        const maxImageWidth = isPortrait
            ? Math.max(lineHeight * 4, maxOuterWidth - imageGap)
            : maxOuterWidth;
        const naturalHeight = maxImageWidth / aspect;
        const imageLineCount = Math.max(1, Math.floor(naturalHeight / lineHeight));
        const verticalMargin = isPortrait ? lineHeight / 2 : 0;
        const lineCount = isPortrait ? imageLineCount + 1 : imageLineCount;
        const height = isPortrait
            ? Math.max(lineHeight, (lineCount * lineHeight) - (verticalMargin * 2))
            : imageLineCount * lineHeight;
        const width = Math.min(maxImageWidth, height * aspect);

        return {
            size: normalizedSize,
            aspect,
            width,
            height,
            gap: imageGap,
            lineCount,
            imageLineCount,
            lineHeight,
            verticalMargin,
            floatSide: metadata.floatSide || 'right',
            pageWidth
        };
    }

    clear() {
        if (document.documentElement.dataset.skippablePause === 'true') {
            document.dispatchEvent(new CustomEvent('ui:command', {
                detail: { moduleId: this.id, type: 'continue', source: 'display-clear' }
            }));
            delete document.documentElement.dataset.skippablePause;
        }

        if (this.container) {
            this.container.innerHTML = '';
            this.paragraphContainer = document.createElement('div');
            this.paragraphContainer.id = 'paragraphs';
            this.container.appendChild(this.paragraphContainer);
        }
        this.renderedItems = [];
        this.notificationQueue = [];
        this.pendingTerminalNotifications = [];
        this.notificationActive = false;
        document.getElementById('story_popup_modal')?.classList.remove('visible');
        document.getElementById('story_popup_modal')?.setAttribute('aria-hidden', 'true');
        this.historyWindowStartId = 1;
        this.historyWindowEndId = 0;
        this.storyTopLine = 0;
        this.activeCenterBlockId = null;
        this.layoutFlowLine = 0;
        this.layoutExclusions = [];
        this.setVirtualPadding();
        this.setStoryOffset(0);
        this.updateStoryScrollbar({ latestBlockId: this.getLatestHistoryBlockId() });
    }
    
    /**
     * Show choices in the UI
     * @param {Array<Object>} choices - Array of choice objects
     * @param {Function} callback - Callback function for choice selection
     * @returns {Promise<HTMLElement>} - Promise resolving to the choices container
     */
    showChoices(choices, callback) {
        if (!choices || choices.length === 0) {
            return Promise.resolve(null);
        }
        
        return new Promise((resolve) => {
            // Find or create choices container
            let choicesContainer = document.getElementById('choices');
            if (!choicesContainer) {
                // UI Input Handler should create this, but if it doesn't exist yet, create it
                choicesContainer = document.createElement('div');
                choicesContainer.id = 'choices';
                choicesContainer.className = 'container';
                this.pageLeft.appendChild(choicesContainer);
            }
            
            // Create a dedicated container for this set of choices
            const choicesGroup = document.createElement('div');
            choicesGroup.className = 'choices-group';
            choicesContainer.appendChild(choicesGroup);
            
            // Create each choice button
            choices.forEach((choice, index) => {
                const choiceButton = document.createElement('button');
                choiceButton.className = 'choice-button';
                choiceButton.textContent = choice.text;
                
                // Add index as data attribute
                choiceButton.dataset.index = index;
                
                // Add event listener
                choiceButton.addEventListener('click', () => {
                    // Disable all buttons in this group
                    Array.from(choicesGroup.querySelectorAll('button')).forEach(btn => {
                        btn.disabled = true;
                        btn.classList.add('selected');
                    });
                    
                    // Highlight the selected button
                    choiceButton.classList.add('selected');
                    
                    // Call the callback
                    if (typeof callback === 'function') {
                        callback(index, choice);
                    }
                });
                
                choicesGroup.appendChild(choiceButton);
            });
            
            window.requestAnimationFrame(() => {
                choicesGroup.classList.add('visible');
                resolve(choicesGroup);
            });
        });
    }
}

// Create the singleton instance
const uiDisplayHandler = new UIDisplayHandlerModule();

// Export the module
export { uiDisplayHandler as UIDisplayHandler };

// Register with the module registry
if (window.moduleRegistry) {
    window.moduleRegistry.register(uiDisplayHandler);
}

// Keep a reference in window for loader system
window.UIDisplayHandler = uiDisplayHandler;
