(function (global) {
    'use strict';

    const Internal = global.ReaderV2Internal = global.ReaderV2Internal || {};

    function localName(node) {
        return String(node && (node.localName || node.nodeName) || '').toLowerCase().replace(/^.*:/, '');
    }

    function elementChildren(node) {
        return Array.prototype.filter.call(node && node.childNodes || [], function (child) {
            return child.nodeType === Node.ELEMENT_NODE;
        });
    }

    function childrenNamed(node, name) {
        return elementChildren(node).filter(function (child) { return localName(child) === name; });
    }

    function firstNamed(node, name) {
        const all = node && node.getElementsByTagNameNS
            ? node.getElementsByTagNameNS('*', name)
            : [];
        return all && all.length ? all[0] : null;
    }

    function directChild(node, name) {
        return elementChildren(node).find(function (child) { return localName(child) === name; }) || null;
    }

    function normalizedText(node) {
        return String(node && node.textContent || '').replace(/\s+/g, ' ').trim();
    }

    function hrefAttribute(node) {
        if (!node || !node.getAttribute) return '';
        return node.getAttributeNS('http://www.w3.org/1999/xlink', 'href') ||
            node.getAttribute('xlink:href') || node.getAttribute('l:href') || node.getAttribute('href') || '';
    }

    function xmlId(node) {
        if (!node || !node.getAttribute) return '';
        return node.getAttributeNS('http://www.w3.org/XML/1998/namespace', 'id') ||
            node.getAttribute('xml:id') || node.getAttribute('id') || '';
    }

    function safeId(value, fallback) {
        const raw = String(value || '').trim();
        if (!raw) return fallback;
        const encoded = encodeURIComponent(raw).replace(/%/g, '_').replace(/[^A-Za-z0-9_.~-]/g, '-');
        return 'fb2-id-' + encoded;
    }

    function decodeXml(arrayBuffer) {
        const bytes = new Uint8Array(arrayBuffer);
        let encoding = 'utf-8';
        let offset = 0;
        if (bytes[0] === 0xff && bytes[1] === 0xfe) {
            encoding = 'utf-16le';
            offset = 2;
        } else if (bytes[0] === 0xfe && bytes[1] === 0xff) {
            encoding = 'utf-16be';
            offset = 2;
        } else if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
            offset = 3;
        } else {
            let ascii = '';
            const length = Math.min(512, bytes.length);
            for (let index = 0; index < length; index += 1) ascii += String.fromCharCode(bytes[index]);
            const declared = ascii.match(/<\?xml[^>]*encoding\s*=\s*["']([^"']+)["']/i);
            if (declared) encoding = declared[1].trim().toLowerCase();
        }
        try {
            return new TextDecoder(encoding).decode(bytes.subarray(offset));
        } catch (_) {
            return new TextDecoder('utf-8').decode(bytes.subarray(offset));
        }
    }

    function parseXml(arrayBuffer) {
        const xml = decodeXml(arrayBuffer);
        const doc = new DOMParser().parseFromString(xml, 'application/xml');
        const parserErrors = doc.getElementsByTagName('parsererror');
        if (parserErrors && parserErrors.length) throw new Error('FB2 содержит некорректный XML.');
        if (!doc.documentElement || localName(doc.documentElement) !== 'fictionbook') {
            throw new Error('Файл не является документом FictionBook.');
        }
        return doc;
    }

    function textPoint(section, offset) {
        const targetOffset = Math.max(0, Number(offset) || 0);
        const walker = document.createTreeWalker(section, NodeFilter.SHOW_TEXT, null);
        let node;
        let consumed = 0;
        while ((node = walker.nextNode())) {
            const length = node.nodeValue.length;
            if (targetOffset <= consumed + length) {
                return { node: node, offset: Math.max(0, Math.min(length, targetOffset - consumed)) };
            }
            consumed += length;
        }
        return { node: section, offset: section.childNodes.length };
    }

    function offsetWithin(section, node, offset) {
        try {
            const range = document.createRange();
            range.selectNodeContents(section);
            range.setEnd(node, offset);
            return range.toString().length;
        } catch (_) {
            return 0;
        }
    }

    function closestSection(node, root) {
        const element = node && (node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement);
        const section = element && element.closest && element.closest('.fb2-section');
        return section && root.contains(section) ? section : null;
    }

    function unwrap(element) {
        const parent = element && element.parentNode;
        if (!parent) return;
        while (element.firstChild) parent.insertBefore(element.firstChild, element);
        parent.removeChild(element);
        parent.normalize();
    }

    class Fb2Engine {
        constructor(mount, host) {
            this.mount = mount;
            this.host = host;
            this.documentElement = mount.querySelector('#fb2-document');
            this.toc = [];
            this.chapterLabels = new Map();
            this.binaryUrls = new Map();
            this.currentPage = 0;
            this.totalPages = 1;
            this.currentProgress = 0;
            this.ready = false;
            this.destroyed = false;
            this.searchState = { query: '', results: [], currentIndex: -1, generation: 0, searching: false };
            this.userHighlights = new Map();
            this.searchMarks = [];
            this.resizeObserver = null;
            this.resizeTimer = null;
            this.pendingResizeLocator = undefined;
            this.scrollFrame = 0;
            this.selectionTimer = null;
            this.detachGestures = null;
            this.sectionCounter = 0;
            this.usedSectionIds = new Set();
            this._onClick = this._handleClick.bind(this);
            this._onScroll = this._handleScroll.bind(this);
            this._onSelectionChange = this._handleSelectionChange.bind(this);
            this.mount.addEventListener('click', this._onClick, false);
            this.mount.addEventListener('scroll', this._onScroll, { passive: true });
            document.addEventListener('selectionchange', this._onSelectionChange, false);
        }

        async load(arrayBuffer, payload) {
            const xmlDocument = parseXml(arrayBuffer);
            this._loadBinaries(xmlDocument);
            this._renderDocument(xmlDocument);
            await this._waitForImages();
            Internal.post('toc', { format: 'fb2', items: this.toc });
            this.detachGestures = Internal.attachGestures(document, {
                getPreferences: this.host.getPreferences,
                next: this.host.next,
                previous: this.host.previous,
                toggleControls: this.host.toggleControls,
                nativeTapZones: true
            });
            await Internal.waitForPreferredFont(document, this.host.getPreferences());
            await this._layout(false);

            if (payload && payload.locator) await this.goToLocator(payload.locator);
            else if (payload && Number.isFinite(Number(payload.progress))) await this.goToProgress(payload.progress);
            await Internal.nextFrame();
            if (!this.documentElement.childNodes.length) throw new Error('FB2 не содержит отображаемого текста.');

            this.ready = true;
            this._observeResize();
            this._emitProgress();
            const metadata = this._metadata(xmlDocument);
            return {
                format: 'fb2',
                title: metadata.title || payload.title || '',
                author: metadata.author || payload.author || '',
                toc: this.toc,
                locator: this.currentLocator(),
                progress: this.currentProgress,
                totalPages: this.totalPages
            };
        }

        _metadata(xmlDocument) {
            const title = normalizedText(firstNamed(xmlDocument, 'book-title'));
            const titleInfo = firstNamed(xmlDocument, 'title-info');
            const authorElement = titleInfo && firstNamed(titleInfo, 'author');
            const names = ['first-name', 'middle-name', 'last-name'].map(function (name) {
                return normalizedText(authorElement && firstNamed(authorElement, name));
            }).filter(Boolean);
            return { title: title, author: names.join(' ') };
        }

        _loadBinaries(xmlDocument) {
            const binaries = xmlDocument.getElementsByTagNameNS('*', 'binary');
            Array.prototype.forEach.call(binaries || [], function (binary) {
                const id = xmlId(binary);
                if (!id) return;
                const encoded = String(binary.textContent || '').replace(/\s+/g, '');
                if (!encoded) return;
                try {
                    const raw = global.atob(encoded);
                    const bytes = new Uint8Array(raw.length);
                    for (let index = 0; index < raw.length; index += 1) bytes[index] = raw.charCodeAt(index);
                    const type = binary.getAttribute('content-type') || 'application/octet-stream';
                    this.binaryUrls.set(id, URL.createObjectURL(new Blob([bytes], { type: type })));
                } catch (_) {
                    // A broken optional image must not make the whole book unreadable.
                }
            }, this);
        }

        _renderDocument(xmlDocument) {
            this.documentElement.replaceChildren();
            this.toc = [];
            this.chapterLabels.clear();
            this.sectionCounter = 0;
            this.usedSectionIds.clear();
            const fragment = document.createDocumentFragment();
            const bodies = xmlDocument.getElementsByTagNameNS('*', 'body');
            Array.prototype.forEach.call(bodies || [], function (body, bodyIndex) {
                const bodyContainer = document.createElement('div');
                bodyContainer.className = 'fb2-body fb2-section';
                bodyContainer.id = 'fb2-body-' + (bodyIndex + 1);
                bodyContainer.dataset.sectionId = bodyContainer.id;
                this.usedSectionIds.add(bodyContainer.id);
                const bodyName = body.getAttribute('name');
                if (bodyName) {
                    bodyContainer.dataset.bodyName = bodyName;
                    this.chapterLabels.set(bodyContainer.id, bodyName);
                }
                elementChildren(body).forEach(function (child) {
                    const rendered = localName(child) === 'section'
                        ? this._renderSection(child, 0)
                        : this._renderBlock(child, 0, 'fb2-body-' + bodyIndex);
                    if (rendered) bodyContainer.appendChild(rendered);
                }, this);
                if (bodyContainer.childNodes.length) fragment.appendChild(bodyContainer);
            }, this);
            this.documentElement.appendChild(fragment);
        }

        _renderSection(source, depth) {
            this.sectionCounter += 1;
            const baseId = safeId(xmlId(source), 'fb2-section-' + this.sectionCounter);
            let id = baseId;
            let suffix = 2;
            while (this.usedSectionIds.has(id)) {
                id = baseId + '-' + suffix;
                suffix += 1;
            }
            this.usedSectionIds.add(id);
            const section = document.createElement('section');
            section.className = 'fb2-section';
            section.id = id;
            section.dataset.sectionId = id;
            section.dataset.depth = String(depth);
            const titleSource = directChild(source, 'title');
            const label = normalizedText(titleSource);
            if (label) {
                this.toc.push({ label: label, href: id, depth: depth });
                this.chapterLabels.set(id, label);
            }
            elementChildren(source).forEach(function (child) {
                const rendered = localName(child) === 'section'
                    ? this._renderSection(child, depth + 1)
                    : this._renderBlock(child, depth, id);
                if (rendered) section.appendChild(rendered);
            }, this);
            return section;
        }

        _renderBlock(source, depth, sectionId) {
            const tag = localName(source);
            if (tag === 'title') return this._renderTitle(source, depth);
            if (tag === 'p') return this._paragraph(source);
            if (tag === 'subtitle') {
                const p = this._paragraph(source);
                p.className = 'fb2-subtitle';
                return p;
            }
            if (tag === 'empty-line') {
                const spacer = document.createElement('div');
                spacer.className = 'fb2-empty-line';
                spacer.setAttribute('aria-hidden', 'true');
                spacer.style.height = '.72em';
                return spacer;
            }
            if (tag === 'epigraph' || tag === 'cite' || tag === 'annotation') {
                const block = document.createElement(tag === 'annotation' ? 'aside' : 'blockquote');
                block.className = tag === 'epigraph' ? 'fb2-epigraph' : (tag === 'cite' ? 'fb2-cite' : 'fb2-annotation');
                this._appendBlockChildren(block, source, depth, sectionId);
                return block;
            }
            if (tag === 'poem') return this._renderPoem(source, depth, sectionId);
            if (tag === 'image') return this._renderImage(source);
            if (tag === 'table') return this._renderTable(source);
            if (tag === 'code') {
                const pre = document.createElement('pre');
                pre.className = 'fb2-code';
                pre.textContent = source.textContent || '';
                return pre;
            }
            if (tag === 'text-author' || tag === 'date') {
                const p = this._paragraph(source);
                p.className = tag === 'text-author' ? 'fb2-text-author' : 'fb2-date';
                return p;
            }
            if (tag === 'section') return this._renderSection(source, depth + 1);
            if (elementChildren(source).length) {
                const div = document.createElement('div');
                div.className = 'fb2-' + tag.replace(/[^a-z0-9_-]/g, '');
                this._appendBlockChildren(div, source, depth, sectionId);
                return div;
            }
            const text = normalizedText(source);
            if (!text) return null;
            const p = document.createElement('p');
            p.textContent = text;
            return p;
        }

        _appendBlockChildren(target, source, depth, sectionId) {
            Array.prototype.forEach.call(source.childNodes || [], function (child) {
                if (child.nodeType === Node.TEXT_NODE) {
                    if (String(child.nodeValue || '').trim()) target.appendChild(document.createTextNode(child.nodeValue));
                    return;
                }
                if (child.nodeType !== Node.ELEMENT_NODE) return;
                const rendered = this._renderBlock(child, depth, sectionId);
                if (rendered) target.appendChild(rendered);
            }, this);
        }

        _renderTitle(source, depth) {
            const heading = document.createElement('h' + Math.min(6, Math.max(2, depth + 2)));
            heading.className = 'fb2-title';
            const lines = childrenNamed(source, 'p');
            if (!lines.length) {
                heading.textContent = normalizedText(source);
            } else {
                lines.forEach(function (line, index) {
                    if (index) heading.appendChild(document.createElement('br'));
                    this._appendInline(heading, line);
                }, this);
            }
            return heading;
        }

        _paragraph(source) {
            const p = document.createElement('p');
            this._appendInline(p, source);
            return p;
        }

        _appendInline(target, source) {
            Array.prototype.forEach.call(source.childNodes || [], function (node) {
                if (node.nodeType === Node.TEXT_NODE) {
                    target.appendChild(document.createTextNode(node.nodeValue || ''));
                    return;
                }
                if (node.nodeType !== Node.ELEMENT_NODE) return;
                const tag = localName(node);
                if (tag === 'image') {
                    const image = this._renderImage(node);
                    if (image) target.appendChild(image);
                    return;
                }
                if (tag === 'empty-line') {
                    target.appendChild(document.createElement('br'));
                    return;
                }
                const tagMap = {
                    strong: 'strong', bold: 'strong', emphasis: 'em', em: 'em',
                    strikethrough: 's', sub: 'sub', sup: 'sup', code: 'code', style: 'span'
                };
                if (tag === 'a') {
                    const anchor = document.createElement('a');
                    const href = hrefAttribute(node).trim();
                    if (href.charAt(0) === '#') {
                        anchor.href = href;
                        anchor.dataset.internalHref = safeId(href.slice(1), href.slice(1));
                    } else {
                        const external = Internal.validExternalUrl(href);
                        if (external) {
                            anchor.href = external;
                            anchor.dataset.externalUrl = external;
                        }
                    }
                    this._appendInline(anchor, node);
                    target.appendChild(anchor);
                    return;
                }
                const element = document.createElement(tagMap[tag] || 'span');
                if (tag === 'style') {
                    element.className = 'fb2-style';
                    const styleName = node.getAttribute('name');
                    if (styleName) element.dataset.styleName = styleName;
                }
                this._appendInline(element, node);
                target.appendChild(element);
            }, this);
        }

        _renderImage(source) {
            const href = hrefAttribute(source).replace(/^#/, '');
            const url = this.binaryUrls.get(href);
            if (!url) return null;
            const image = document.createElement('img');
            image.className = 'fb2-image';
            image.src = url;
            image.alt = source.getAttribute('title') || 'Иллюстрация';
            image.loading = 'eager';
            image.decoding = 'async';
            return image;
        }

        async _waitForImages() {
            const images = Array.from(this.documentElement.querySelectorAll('img'));
            await Promise.all(images.map(function (image) {
                if (image.complete) return Promise.resolve();
                if (typeof image.decode === 'function') return image.decode().catch(function () {});
                return new Promise(function (resolve) {
                    image.addEventListener('load', resolve, { once: true });
                    image.addEventListener('error', resolve, { once: true });
                });
            }));
        }

        _renderPoem(source, depth, sectionId) {
            const poem = document.createElement('div');
            poem.className = 'fb2-poem';
            elementChildren(source).forEach(function (child) {
                const tag = localName(child);
                if (tag === 'stanza') {
                    const stanza = document.createElement('div');
                    stanza.className = 'fb2-stanza';
                    elementChildren(child).forEach(function (line) {
                        const lineTag = localName(line);
                        if (lineTag === 'v') stanza.appendChild(this._paragraph(line));
                        else {
                            const rendered = this._renderBlock(line, depth, sectionId);
                            if (rendered) stanza.appendChild(rendered);
                        }
                    }, this);
                    poem.appendChild(stanza);
                } else {
                    const rendered = this._renderBlock(child, depth, sectionId);
                    if (rendered) poem.appendChild(rendered);
                }
            }, this);
            return poem;
        }

        _renderTable(source) {
            const wrapper = document.createElement('div');
            wrapper.className = 'fb2-table-wrap';
            const table = document.createElement('table');
            childrenNamed(source, 'tr').forEach(function (rowSource) {
                const row = document.createElement('tr');
                elementChildren(rowSource).forEach(function (cellSource) {
                    const tag = localName(cellSource) === 'th' ? 'th' : 'td';
                    const cell = document.createElement(tag);
                    const colspan = Number(cellSource.getAttribute('colspan'));
                    const rowspan = Number(cellSource.getAttribute('rowspan'));
                    if (Number.isInteger(colspan) && colspan > 1 && colspan <= 50) cell.colSpan = colspan;
                    if (Number.isInteger(rowspan) && rowspan > 1 && rowspan <= 200) cell.rowSpan = rowspan;
                    this._appendInline(cell, cellSource);
                    row.appendChild(cell);
                }, this);
                table.appendChild(row);
            }, this);
            wrapper.appendChild(table);
            return wrapper;
        }

        async _layout(preserve, preservedLocator) {
            const preferences = this.host.getPreferences();
            const locator = preserve ? (preservedLocator || this.currentLocator()) : null;
            const progress = this.currentProgress;
            const width = Math.max(1, this.mount.clientWidth);
            const height = Math.max(1, this.mount.clientHeight);
            const margin = Internal.clamp(preferences.margin, 8, Math.max(8, width * .28));
            const blockPadding = 18;

            this.mount.classList.toggle('is-scrolled', preferences.verticalScroll);
            this.mount.classList.toggle('is-paginated', !preferences.verticalScroll);
            this.documentElement.style.transform = 'none';
            this.documentElement.style.columnWidth = 'auto';
            this.documentElement.style.columnGap = 'normal';
            this.documentElement.style.columnFill = 'auto';
            this.documentElement.style.width = '';
            this.documentElement.style.height = '';
            this.documentElement.style.margin = blockPadding + 'px ' + margin + 'px';
            await Internal.nextFrame();

            if (preferences.verticalScroll) {
                this.totalPages = Math.max(1, Math.ceil(this.mount.scrollHeight / height));
            } else {
                const contentWidth = Math.max(1, width - margin * 2);
                const contentHeight = Math.max(1, height - blockPadding * 2);
                this.documentElement.style.width = contentWidth + 'px';
                this.documentElement.style.height = contentHeight + 'px';
                this.documentElement.style.columnWidth = contentWidth + 'px';
                this.documentElement.style.columnGap = margin * 2 + 'px';
                await Internal.nextFrame();
                this.totalPages = Math.max(1, Math.ceil((this.documentElement.scrollWidth + margin * 2 - 1) / width));
            }

            if (locator) await this.goToLocator(locator);
            else await this.goToProgress(progress);
            this._emitProgress();
        }

        async applyPreferences(next, _previous) {
            await Internal.waitForPreferredFont(document, next);
            await this._layout(true);
        }

        _observeResize() {
            if (!global.ResizeObserver || this.resizeObserver) return;
            const self = this;
            this.resizeObserver = new ResizeObserver(function () {
                if (self.pendingResizeLocator === undefined) {
                    self.pendingResizeLocator = self.currentLocator();
                }
                clearTimeout(self.resizeTimer);
                self.resizeTimer = setTimeout(function () {
                    const locator = self.pendingResizeLocator;
                    self.pendingResizeLocator = undefined;
                    if (!self.destroyed) self._layout(true, locator).catch(function (error) {
                        Internal.reportError(error, 'fb2.resize', true);
                    });
                }, 120);
            });
            this.resizeObserver.observe(this.mount);
        }

        _handleClick(event) {
            const anchor = event.target && event.target.closest && event.target.closest('a');
            if (!anchor || !this.documentElement.contains(anchor)) return;
            const external = anchor.dataset.externalUrl;
            if (external) {
                event.preventDefault();
                event.stopPropagation();
                Internal.post('externalLink', { url: external, format: 'fb2' });
                return;
            }
            const internal = anchor.dataset.internalHref;
            if (internal) {
                event.preventDefault();
                this.goToChapter(internal);
            }
        }

        _handleScroll() {
            if (!this.host.getPreferences().verticalScroll || this.scrollFrame) return;
            const self = this;
            this.scrollFrame = requestAnimationFrame(function () {
                self.scrollFrame = 0;
                self._emitProgress();
            });
        }

        _handleSelectionChange() {
            clearTimeout(this.selectionTimer);
            const self = this;
            this.selectionTimer = setTimeout(function () { self._emitSelection(); }, 160);
        }

        _emitSelection() {
            let selection;
            try { selection = global.getSelection(); } catch (_) { return; }
            if (!selection || selection.isCollapsed || !selection.rangeCount) return;
            const text = String(selection.toString() || '').trim();
            if (!text) return;
            const range = selection.getRangeAt(0);
            const startSection = closestSection(range.startContainer, this.documentElement);
            if (!startSection) return;
            const endSection = closestSection(range.endContainer, this.documentElement) || startSection;
            const startOffset = offsetWithin(startSection, range.startContainer, range.startOffset);
            const endOffset = endSection === startSection
                ? offsetWithin(startSection, range.endContainer, range.endOffset)
                : startSection.textContent.length;
            Internal.post('selection', {
                format: 'fb2',
                text: text,
                locator: {
                    type: 'fb2',
                    sectionId: startSection.id,
                    offset: startOffset,
                    endSectionId: endSection.id,
                    endOffset: Math.max(startOffset, endOffset)
                },
                chapter: this.chapterLabels.get(startSection.id) || this._chapterForSection(startSection)
            });
        }

        _caretAt(x, y) {
            try {
                if (document.caretRangeFromPoint) return document.caretRangeFromPoint(x, y);
                if (document.caretPositionFromPoint) {
                    const point = document.caretPositionFromPoint(x, y);
                    if (!point) return null;
                    const range = document.createRange();
                    range.setStart(point.offsetNode, point.offset);
                    range.collapse(true);
                    return range;
                }
            } catch (_) {}
            return null;
        }

        currentLocator() {
            if (!this.documentElement || !this.documentElement.childNodes.length) return null;
            const bounds = this.mount.getBoundingClientRect();
            const margin = this.host.getPreferences().margin;
            const range = this._caretAt(bounds.left + Math.max(10, margin + 2), bounds.top + 24);
            const section = range && closestSection(range.startContainer, this.documentElement);
            if (section) {
                return {
                    type: 'fb2',
                    sectionId: section.id,
                    offset: offsetWithin(section, range.startContainer, range.startOffset)
                };
            }
            const sections = Array.from(this.documentElement.querySelectorAll('.fb2-section'));
            const visible = sections.find(function (item) {
                const rect = item.getBoundingClientRect();
                return rect.right > bounds.left && rect.left < bounds.right && rect.bottom > bounds.top && rect.top < bounds.bottom;
            }) || sections[0];
            return visible ? { type: 'fb2', sectionId: visible.id, offset: 0 } : null;
        }

        _chapterForSection(section) {
            const chapter = this._chapterElementForSection(section);
            return chapter ? (this.chapterLabels.get(chapter.id) || '') : '';
        }

        _chapterElementForSection(section) {
            let current = section;
            while (current && current !== this.documentElement) {
                if (this.chapterLabels.has(current.id)) return current;
                current = current.parentElement && current.parentElement.closest('.fb2-section');
            }
            return null;
        }

        _chapterMetrics(section) {
            const chapter = this._chapterElementForSection(section);
            if (!chapter) {
                const current = Math.max(1, Math.min(this.totalPages, this.currentPage + 1));
                return {
                    current: current,
                    total: Math.max(1, this.totalPages),
                    remaining: Math.max(0, this.totalPages - current)
                };
            }

            if (this.host.getPreferences().verticalScroll) {
                const viewportHeight = Math.max(1, this.mount.clientHeight);
                const mountRect = this.mount.getBoundingClientRect();
                const rect = chapter.getBoundingClientRect();
                const chapterTop = rect.top - mountRect.top + this.mount.scrollTop;
                const chapterHeight = Math.max(1, rect.height);
                const chapterTotal = Math.max(1, Math.ceil(chapterHeight / viewportHeight));
                const offset = Internal.clamp(
                    this.mount.scrollTop - chapterTop,
                    0,
                    Math.max(0, chapterHeight - 1)
                );
                const chapterCurrent = Math.max(
                    1,
                    Math.min(chapterTotal, Math.floor(offset / viewportHeight) + 1)
                );
                return {
                    current: chapterCurrent,
                    total: chapterTotal,
                    remaining: Math.max(0, chapterTotal - chapterCurrent)
                };
            }

            const pageWidth = Math.max(1, this.mount.clientWidth);
            const mountLeft = this.mount.getBoundingClientRect().left;
            const translation = this.currentPage * pageWidth;
            const rects = Array.from(chapter.getClientRects()).filter(function (rect) {
                return rect.width > 0 && rect.height > 0;
            });
            let firstPage = Number.POSITIVE_INFINITY;
            let lastPage = Number.NEGATIVE_INFINITY;
            rects.forEach(function (rect) {
                const absoluteLeft = rect.left - mountLeft + translation;
                const absoluteRight = rect.right - mountLeft + translation;
                firstPage = Math.min(firstPage, Math.floor(Math.max(0, absoluteLeft) / pageWidth));
                lastPage = Math.max(lastPage, Math.floor(Math.max(0, absoluteRight - 1) / pageWidth));
            });
            if (!Number.isFinite(firstPage) || !Number.isFinite(lastPage)) {
                firstPage = this.currentPage;
                lastPage = this.currentPage;
            }
            firstPage = Math.max(0, Math.min(this.totalPages - 1, firstPage));
            lastPage = Math.max(firstPage, Math.min(this.totalPages - 1, lastPage));
            const chapterTotal = Math.max(1, lastPage - firstPage + 1);
            const chapterCurrent = Math.max(
                1,
                Math.min(chapterTotal, this.currentPage - firstPage + 1)
            );
            return {
                current: chapterCurrent,
                total: chapterTotal,
                remaining: Math.max(0, chapterTotal - chapterCurrent)
            };
        }

        _emitProgress() {
            const preferences = this.host.getPreferences();
            let progress;
            if (preferences.verticalScroll) {
                const maximum = Math.max(0, this.mount.scrollHeight - this.mount.clientHeight);
                progress = maximum > 0 ? this.mount.scrollTop / maximum : 0;
                this.currentPage = Math.min(this.totalPages - 1, Math.floor(progress * this.totalPages));
            } else {
                progress = this.totalPages > 1 ? this.currentPage / (this.totalPages - 1) : 0;
            }
            this.currentProgress = Internal.clamp(progress, 0, 1);
            const locator = this.currentLocator();
            const section = locator && document.getElementById(locator.sectionId);
            const chapterMetrics = this._chapterMetrics(section);
            Internal.post('progress', {
                format: 'fb2',
                progress: this.currentProgress,
                locator: locator,
                chapter: section ? this._chapterForSection(section) : '',
                currentPage: this.currentPage + 1,
                totalPages: this.totalPages,
                chapterPage: chapterMetrics.current,
                chapterTotal: chapterMetrics.total,
                chapterCurrentPage: chapterMetrics.current,
                chapterTotalPages: chapterMetrics.total,
                remainingInChapter: chapterMetrics.remaining,
                atStart: this.currentProgress <= .0001,
                atEnd: this.currentProgress >= .9999
            });
        }

        async next() {
            return this._navigate('next');
        }

        async previous() {
            return this._navigate('previous');
        }

        async _navigate(direction) {
            const vertical = this.host.getPreferences().verticalScroll;
            let handled = false;
            if (vertical) {
                const maximum = Math.max(0, this.mount.scrollHeight - this.mount.clientHeight);
                const before = this.mount.scrollTop;
                const delta = this.mount.clientHeight * .88 * (direction === 'next' ? 1 : -1);
                const target = Internal.clamp(before + delta, 0, maximum);
                this.mount.scrollTo({ top: target, behavior: 'smooth' });
                handled = Math.abs(target - before) > 1;
                await Internal.wait(190);
            } else {
                const targetPage = Internal.clamp(this.currentPage + (direction === 'next' ? 1 : -1), 0, this.totalPages - 1);
                handled = targetPage !== this.currentPage;
                this._showPage(targetPage);
            }
            this._emitProgress();
            Internal.post('navigation', {
                direction: direction,
                handled: handled,
                boundary: handled ? null : (direction === 'next' ? 'end' : 'start'),
                locator: this.currentLocator()
            });
            return handled;
        }

        _showPage(page) {
            this.currentPage = Math.round(Internal.clamp(page, 0, this.totalPages - 1));
            this.documentElement.style.transform = 'translate3d(' + (-this.currentPage * this.mount.clientWidth) + 'px,0,0)';
        }

        async goToProgress(value) {
            const progress = Internal.clamp(value, 0, 1);
            if (this.host.getPreferences().verticalScroll) {
                const maximum = Math.max(0, this.mount.scrollHeight - this.mount.clientHeight);
                this.mount.scrollTop = Math.round(progress * maximum);
            } else {
                this._showPage(Math.round(progress * (this.totalPages - 1)));
            }
            this._emitProgress();
            return true;
        }

        _normalizeLocator(locator) {
            if (locator && typeof locator === 'object') return locator;
            const raw = String(locator || '');
            const match = raw.match(/^fb2:([^:]+):(\d+)(?::(\d+))?$/);
            return match ? {
                type: 'fb2',
                sectionId: decodeURIComponent(match[1]),
                offset: Number(match[2]),
                endOffset: match[3] ? Number(match[3]) : Number(match[2])
            } : null;
        }

        async goToLocator(rawLocator) {
            const locator = this._normalizeLocator(rawLocator);
            if (!locator || !locator.sectionId) return false;
            const section = document.getElementById(locator.sectionId);
            if (!section || !this.documentElement.contains(section)) return false;
            const point = textPoint(section, locator.offset);
            const range = document.createRange();
            try {
                range.setStart(point.node, point.offset);
                range.collapse(true);
            } catch (_) {
                range.selectNode(section);
                range.collapse(true);
            }
            const rect = range.getBoundingClientRect();
            const mountRect = this.mount.getBoundingClientRect();
            if (this.host.getPreferences().verticalScroll) {
                const absoluteTop = rect.top - mountRect.top + this.mount.scrollTop;
                this.mount.scrollTop = Math.max(0, absoluteTop - 18);
            } else {
                const margin = this.host.getPreferences().margin;
                const absoluteLeft = rect.left - mountRect.left - margin + this.currentPage * this.mount.clientWidth;
                this._showPage(Math.floor(Math.max(0, absoluteLeft) / Math.max(1, this.mount.clientWidth)));
            }
            this._emitProgress();
            return true;
        }

        async goToChapter(href) {
            const raw = String(href || '').replace(/^#/, '');
            const id = document.getElementById(raw) ? raw : safeId(raw, raw);
            return this.goToLocator({ type: 'fb2', sectionId: id, offset: 0 });
        }

        async search(query) {
            this.clearSearch(false);
            const normalizedQuery = String(query || '').trim();
            const generation = ++this.searchState.generation;
            this.searchState.query = normalizedQuery;
            this.searchState.searching = Boolean(normalizedQuery);
            this._postSearch();
            if (!normalizedQuery) return [];
            const needle = normalizedQuery.toLocaleLowerCase();
            const blocks = Array.from(this.documentElement.querySelectorAll('p,h1,h2,h3,h4,h5,h6,pre,td,th'));
            const results = [];
            for (let blockIndex = 0; blockIndex < blocks.length && results.length < 500; blockIndex += 1) {
                if (generation !== this.searchState.generation) return [];
                const block = blocks[blockIndex];
                const section = closestSection(block, this.documentElement);
                if (!section) continue;
                const text = block.textContent || '';
                const haystack = text.toLocaleLowerCase();
                let match = haystack.indexOf(needle);
                while (match >= 0 && results.length < 500) {
                    const blockStart = offsetWithin(section, block, 0);
                    const start = blockStart + match;
                    results.push({
                        locator: {
                            type: 'fb2', sectionId: section.id,
                            offset: start, endOffset: start + normalizedQuery.length
                        },
                        excerpt: Internal.makeExcerpt(text, match, normalizedQuery.length),
                        chapter: this._chapterForSection(section)
                    });
                    match = haystack.indexOf(needle, match + Math.max(1, needle.length));
                }
            }
            if (generation !== this.searchState.generation) return [];
            this.searchState.results = results;
            this.searchState.currentIndex = results.length ? 0 : -1;
            this.searchState.searching = false;
            if (results.length) await this._showSearchResult(0);
            else this._postSearch();
            return results;
        }

        async nextSearch() {
            return this._showSearchResult(this.searchState.currentIndex + 1);
        }

        async previousSearch() {
            return this._showSearchResult(this.searchState.currentIndex - 1);
        }

        async _showSearchResult(index) {
            const results = this.searchState.results;
            if (!results.length) {
                this._postSearch();
                return null;
            }
            const normalized = ((index % results.length) + results.length) % results.length;
            this.searchState.currentIndex = normalized;
            this._clearSearchMarks();
            const result = results[normalized];
            this.searchMarks = this._wrapLocator(result.locator, 'reader-search-current', { 'data-search-marker': 'true' });
            await this.goToLocator(result.locator);
            this._postSearch();
            return result;
        }

        _postSearch() {
            const result = this.searchState.currentIndex >= 0
                ? this.searchState.results[this.searchState.currentIndex]
                : null;
            Internal.post('search', {
                query: this.searchState.query,
                total: this.searchState.results.length,
                currentIndex: this.searchState.currentIndex,
                searching: this.searchState.searching,
                truncated: this.searchState.results.length >= 500,
                result: result
            });
        }

        _clearSearchMarks() {
            this.searchMarks.forEach(unwrap);
            this.searchMarks = [];
        }

        clearSearch(emit) {
            this._clearSearchMarks();
            const generation = this.searchState.generation + 1;
            this.searchState = { query: '', results: [], currentIndex: -1, generation: generation, searching: false };
            if (emit !== false) this._postSearch();
        }

        clearSelection() {
            try { global.getSelection().removeAllRanges(); } catch (_) {}
        }

        _wrapLocator(rawLocator, className, attributes) {
            const locator = this._normalizeLocator(rawLocator);
            if (!locator || !locator.sectionId) return [];
            const section = document.getElementById(locator.sectionId);
            if (!section) return [];
            const start = Math.max(0, Number(locator.offset) || 0);
            const end = Math.max(start + 1, Number(locator.endOffset) || start + 1);
            const walker = document.createTreeWalker(section, NodeFilter.SHOW_TEXT, null);
            const candidates = [];
            let node;
            let consumed = 0;
            while ((node = walker.nextNode())) {
                const length = node.nodeValue.length;
                const nodeStart = consumed;
                const nodeEnd = consumed + length;
                if (end > nodeStart && start < nodeEnd && !node.parentElement.closest('script,style')) {
                    candidates.push({
                        node: node,
                        start: Math.max(0, start - nodeStart),
                        end: Math.min(length, end - nodeStart)
                    });
                }
                consumed = nodeEnd;
                if (consumed >= end) break;
            }
            const marks = [];
            candidates.forEach(function (candidate) {
                if (candidate.end <= candidate.start || !candidate.node.parentNode) return;
                const range = document.createRange();
                range.setStart(candidate.node, candidate.start);
                range.setEnd(candidate.node, candidate.end);
                const mark = document.createElement('mark');
                mark.className = className;
                Object.keys(attributes || {}).forEach(function (name) { mark.setAttribute(name, attributes[name]); });
                try {
                    range.surroundContents(mark);
                    marks.push(mark);
                } catch (_) {}
            });
            return marks;
        }

        addHighlight(locator, color) {
            const normalized = this._normalizeLocator(locator);
            if (!normalized) return false;
            const key = Internal.stableLocatorKey(normalized);
            this.removeHighlight(normalized);
            const normalizedColor = typeof color === 'string' && color.trim() ? color.trim() : '#ffd84a';
            const marks = this._wrapLocator(normalized, 'reader-highlight', {
                'data-highlight-key': key,
                'data-highlight-color': normalizedColor
            });
            marks.forEach(function (mark) { mark.style.backgroundColor = normalizedColor; });
            if (!marks.length) return false;
            this.userHighlights.set(key, { locator: normalized, color: normalizedColor });
            return true;
        }

        removeHighlight(locator) {
            const normalized = this._normalizeLocator(locator);
            if (!normalized) return false;
            const key = Internal.stableLocatorKey(normalized);
            Array.from(this.documentElement.querySelectorAll('mark[data-highlight-key]')).forEach(function (mark) {
                if (mark.getAttribute('data-highlight-key') === key) unwrap(mark);
            });
            return this.userHighlights.delete(key);
        }

        setHighlights(list) {
            Array.from(this.documentElement.querySelectorAll('mark[data-highlight-key]')).forEach(unwrap);
            this.userHighlights.clear();
            (Array.isArray(list) ? list : []).forEach(function (item) {
                if (item) this.addHighlight(item.locator || item, item.color);
            }, this);
            return this.userHighlights.size;
        }

        speechPage(maxCharacters) {
            return {
                text: Internal.visibleSpeechText(document, maxCharacters),
                locator: this.currentLocator(),
                canAdvance: this.currentPage + 1 < this.totalPages
            };
        }

        state() {
            return {
                format: 'fb2',
                ready: this.ready,
                locator: this.currentLocator(),
                progress: this.currentProgress,
                currentPage: this.currentPage + 1,
                totalPages: this.totalPages,
                tocCount: this.toc.length,
                search: {
                    query: this.searchState.query,
                    total: this.searchState.results.length,
                    currentIndex: this.searchState.currentIndex,
                    searching: this.searchState.searching
                },
                highlightCount: this.userHighlights.size
            };
        }

        destroy() {
            this.destroyed = true;
            clearTimeout(this.resizeTimer);
            this.pendingResizeLocator = undefined;
            clearTimeout(this.selectionTimer);
            if (this.scrollFrame) cancelAnimationFrame(this.scrollFrame);
            if (this.resizeObserver) this.resizeObserver.disconnect();
            this.resizeObserver = null;
            this.mount.removeEventListener('click', this._onClick);
            this.mount.removeEventListener('scroll', this._onScroll);
            document.removeEventListener('selectionchange', this._onSelectionChange);
            if (this.detachGestures) this.detachGestures();
            this.detachGestures = null;
            this.clearSearch(false);
            this.binaryUrls.forEach(function (url) { URL.revokeObjectURL(url); });
            this.binaryUrls.clear();
            this.userHighlights.clear();
            this.documentElement.replaceChildren();
        }
    }

    Internal.Fb2Engine = Fb2Engine;
})(window);
