const NS = {
    XML: 'http://www.w3.org/XML/1998/namespace',
    SSML: 'http://www.w3.org/2001/10/synthesis',
}

const blockTags = new Set([
    'article', 'aside', 'audio', 'blockquote', 'caption',
    'details', 'dialog', 'div', 'dl', 'dt', 'dd',
    'figure', 'footer', 'form', 'figcaption',
    'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'li',
    'main', 'math', 'nav', 'ol', 'p', 'pre', 'section', 'tr',
])

const getLang = el => {
    const x = el.lang || el?.getAttributeNS?.(NS.XML, 'lang')
    return x ? x : el.parentElement ? getLang(el.parentElement) : null
}

const getAlphabet = el => {
    const x = el?.getAttributeNS?.(NS.XML, 'lang')
    return x ? x : el.parentElement ? getAlphabet(el.parentElement) : null
}

const getSegmenter = (lang, granularity = 'word') => {
    const segmenter = new Intl.Segmenter(lang || undefined, { granularity })
    const granularityIsWord = granularity === 'word'
    return function* (strs, makeRange) {
        const str = strs.join('').replace(/\r\n/g, '  ').replace(/\r/g, ' ').replace(/\n/g, ' ')
        let name = 0
        let strIndex = -1
        let sum = 0
        const rawSegments = Array.from(segmenter.segment(str))
        const mergedSegments = []
        for (let i = 0, j = 0; i < rawSegments.length; i++) {
            const current = rawSegments[i]
            const segment = ' ' + current.segment
            const endsWithAbbr = /\s([A-Z]{1,2}[a-z]{0,5}|[a-z]{1,3})\.\s*$/.test(segment)
            if (!endsWithAbbr || i >= (rawSegments.length-1)) {
                const mergedSegment = {
                    index: rawSegments[j].index,
                    segment: '',
                    isWordLike: (i == j) ? current.isWordLike : true,
                }
                while (j <= i) {
                    mergedSegment.segment += rawSegments[j++].segment
                }
                mergedSegments.push(mergedSegment)
            }
        }

        for (const { index, segment, isWordLike } of mergedSegments) {
            if (granularityIsWord && !isWordLike) continue
            while (sum <= index) sum += strs[++strIndex].length
            const startIndex = strIndex
            const startOffset = index - (sum - strs[strIndex].length)
            const end = index + segment.length - 1
            if (end < str.length) while (sum <= end) sum += strs[++strIndex].length
            const endIndex = strIndex
            const endOffset = end - (sum - strs[strIndex].length) + 1
            yield [(name++).toString(),
                makeRange(startIndex, startOffset, endIndex, endOffset)]
        }
    }
}

const fragmentToSSML = (fragment, nodeFilter, inherited) => {
    const ssml = document.implementation.createDocument(NS.SSML, 'speak')
    const { lang } = inherited
    if (lang) ssml.documentElement.setAttributeNS(NS.XML, 'lang', lang)

    const convert = (node, parent, inheritedAlphabet) => {
        if (!node) return
        // Text nodes go through the filter too: the text walker that produces
        // the marks already honours it, and content that is skipped there (a
        // bare ruby base inside <ruby>, say) must not reach the speech either,
        // or the two disagree about what is being read.
        if (node.nodeType === 3 || node.nodeType === 4) {
            if (nodeFilter && nodeFilter(node) === NodeFilter.FILTER_REJECT) return
            return node.nodeType === 3
                ? ssml.createTextNode(node.textContent)
                : ssml.createCDATASection(node.textContent)
        }
        if (node.nodeType !== 1 && node.nodeType !== 11) return
        if (nodeFilter && nodeFilter(node) === NodeFilter.FILTER_REJECT) return

        let el
        const nodeName = node.nodeName.toLowerCase()
        if (nodeName === 'foliate-mark') {
            el = ssml.createElementNS(NS.SSML, 'mark')
            el.setAttribute('name', node.dataset.name)
        }
        else if (nodeName === 'br')
            el = ssml.createElementNS(NS.SSML, 'break')
        else if (nodeName === 'em' || nodeName === 'strong')
            el = ssml.createElementNS(NS.SSML, 'emphasis')

        const lang = node.lang || node.getAttributeNS?.(NS.XML, 'lang')
        if (lang) {
            if (!el) el = ssml.createElementNS(NS.SSML, 'lang')
            el.setAttributeNS(NS.XML, 'lang', lang)
        }

        const alphabet = node.getAttributeNS?.(NS.SSML, 'alphabet') || inheritedAlphabet
        if (!el) {
            const ph = node.getAttributeNS?.(NS.SSML, 'ph')
            if (ph) {
                el = ssml.createElementNS(NS.SSML, 'phoneme')
                if (alphabet) el.setAttribute('alphabet', alphabet)
                el.setAttribute('ph', ph)
            }
        }

        if (!el) el = parent

        let child = node.firstChild
        while (child) {
            const childEl = convert(child, el, alphabet)
            if (childEl && el !== childEl) el.append(childEl)
            child = child.nextSibling
        }
        return el
    }
    convert(fragment, ssml.documentElement, inherited.alphabet)
    return ssml
}

const getFragmentWithMarks = (range, textWalker, nodeFilter, granularity) => {
    const lang = getLang(range.commonAncestorContainer)
    const alphabet = getAlphabet(range.commonAncestorContainer)

    const segmenter = getSegmenter(lang, granularity)
    const fragment = range.cloneContents()

    // we need ranges on both the original document (for highlighting)
    // and the document fragment (for inserting marks)
    // so unfortunately need to do it twice, as you can't copy the ranges
    const entries = [...textWalker(range, segmenter, nodeFilter)]
    const fragmentEntries = [...textWalker(fragment, segmenter, nodeFilter)]

    for (const [name, range] of fragmentEntries) {
        const mark = document.createElement('foliate-mark')
        mark.dataset.name = name
        range.insertNode(mark)
    }
    const ssml = fragmentToSSML(fragment, nodeFilter, { lang, alphabet })
    return { entries, ssml }
}

const rangeIsEmpty = range => !range.toString().trim()

// For PDF text layers, split content into sentence-level blocks so TTS
// reads one sentence at a time instead of the whole page in one block.
// Text nodes are split at sentence boundaries so that every block range
// aligns with node edges — this prevents the text walker from including
// text outside the sentence in word marks.
function* getPDFSentenceBlocks(doc, textLayer) {
    const collectNodes = () => {
        const w = doc.createTreeWalker(textLayer, NodeFilter.SHOW_TEXT)
        const res = []
        for (let n = w.nextNode(); n; n = w.nextNode()) res.push(n)
        return res
    }

    let nodes = collectNodes()
    if (!nodes.length) return

    const fullText = nodes.map(n => n.nodeValue).join('')
    if (!fullText.trim()) return

    // Find sentence boundary positions
    const lang = getLang(textLayer) || undefined
    const segmenter = new Intl.Segmenter(lang, { granularity: 'sentence' })
    const boundaries = new Set()
    for (const { index } of segmenter.segment(fullText))
        if (index > 0) boundaries.add(index)

    // Split text nodes at sentence boundaries so ranges align with node edges.
    // Process in reverse order to preserve earlier character positions.
    let cum = 0
    const nodeStarts = nodes.map(n => { const s = cum; cum += n.nodeValue.length; return s })

    for (const pos of [...boundaries].sort((a, b) => b - a)) {
        for (let i = 0; i < nodes.length; i++) {
            const start = nodeStarts[i]
            const end = start + nodes[i].nodeValue.length
            if (pos > start && pos < end) {
                nodes[i].splitText(pos - start)
                break
            }
        }
    }

    // Re-collect nodes after splits and group into sentence blocks
    nodes = collectNodes()
    cum = 0
    let groupStart = 0
    let blockCount = 0

    for (let i = 0; i < nodes.length; i++) {
        cum += nodes[i].nodeValue.length
        const isEnd = i === nodes.length - 1 || boundaries.has(cum)
        if (isEnd) {
            const range = doc.createRange()
            range.setStart(nodes[groupStart], 0)
            range.setEnd(nodes[i], nodes[i].nodeValue.length)
            if (!rangeIsEmpty(range)) {
                blockCount++
                yield range
            }
            groupStart = i + 1
        }
    }
}

function* getBlocks(doc, nodeFilter) {
    const root = doc.body
        ?? doc.querySelector('body')
        ?? doc.documentElement

    // For PDF text layers, yield sentence-level blocks
    const textLayer = root.querySelector?.('.textLayer')
    if (textLayer) {
        yield* getPDFSentenceBlocks(doc, textLayer)
        return
    }

    let last
    let sawBlock = false
    let sawSkipped = false
    const walker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)
    let node = walker.nextNode()
    while (node) {
        const name = node.tagName.toLowerCase()
        // A rejected block element (e.g. a footnote/endnote aside) must not be
        // read: skip its whole subtree and end the preceding block before it
        // so its text doesn't leak into the adjacent block. Inline rejects are
        // left to the text walker in getFragmentWithMarks().
        if (blockTags.has(name)
                && nodeFilter?.(node) === NodeFilter.FILTER_REJECT) {
            sawSkipped = true
            if (last) {
                last.setEndBefore(node)
                if (!rangeIsEmpty(last)) yield last
                last = null
            }
            const skipped = node
            do node = walker.nextNode()
            while (node && (skipped.compareDocumentPosition(node)
                & Node.DOCUMENT_POSITION_CONTAINED_BY))
            continue
        }
        if (blockTags.has(name)) {
            if (last) {
                last.setEndBefore(node)
                if (!rangeIsEmpty(last)) yield last
            }
            last = doc.createRange()
            last.setStart(node, 0)
            sawBlock = true
        }
        node = walker.nextNode()
    }
    if (last) {
        last.setEndAfter(root.lastChild ?? root)
        if (!rangeIsEmpty(last)) yield last
    } else if (!sawBlock && !sawSkipped) {
        last = doc.createRange()
        last.setStart(root.firstChild ?? root, 0)
        last.setEndAfter(root.lastChild ?? root)
        if (!rangeIsEmpty(last)) yield last
    }
}

// Enumerate every TTS segment of the document in order without touching any
// TTS instance state. blockIndex/markName match what a TTS instance produces
// for the same granularity, so callers (e.g. a playback timeline) can
// correlate the enumeration with live marks and use each range with from().
export function* getSentences(doc, textWalker, nodeFilter, granularity = 'sentence') {
    let blockIndex = 0
    for (const range of getBlocks(doc, nodeFilter)) {
        const lang = getLang(range.commonAncestorContainer)
        const segmenter = getSegmenter(lang, granularity)
        for (const [name, segRange] of textWalker(range, segmenter, nodeFilter))
            yield { blockIndex, markName: name, range: segRange }
        blockIndex++
    }
}

class ListIterator {
    #arr = []
    #iter
    #index = -1
    #f
    constructor(iter, f = x => x) {
        this.#iter = iter
        this.#f = f
    }
    current() {
        if (this.#arr[this.#index]) return this.#f(this.#arr[this.#index])
    }
    first() {
        const newIndex = 0
        if (this.#arr[newIndex]) {
            this.#index = newIndex
            return this.#f(this.#arr[newIndex])
        }
    }
    prev() {
        const newIndex = this.#index - 1
        if (this.#arr[newIndex]) {
            this.#index = newIndex
            return this.#f(this.#arr[newIndex])
        }
    }
    next() {
        const newIndex = this.#index + 1
        if (this.#arr[newIndex]) {
            this.#index = newIndex
            return this.#f(this.#arr[newIndex])
        }
        while (true) {
            const { done, value } = this.#iter.next()
            if (done) break
            this.#arr.push(value)
            if (this.#arr[newIndex]) {
                this.#index = newIndex
                return this.#f(this.#arr[newIndex])
            }
        }
    }
    find(f) {
        const index = this.#arr.findIndex(x => f(x))
        if (index > -1) {
            this.#index = index
            return this.#f(this.#arr[index])
        }
        while (true) {
            const { done, value } = this.#iter.next()
            if (done) break
            this.#arr.push(value)
            if (f(value)) {
                this.#index = this.#arr.length - 1
                return this.#f(value)
            }
        }
    }
}

export class TTS {
    #list
    #ranges
    #lastMark
    #serializer = new XMLSerializer()
    constructor(doc, textWalker, nodeFilter, highlight, granularity) {
        this.doc = doc
        this.highlight = highlight
        this.#list = new ListIterator(getBlocks(doc, nodeFilter), range => {
            const { entries, ssml } = getFragmentWithMarks(range, textWalker, nodeFilter, granularity)
            this.#ranges = new Map(entries)
            return [ssml, range]
        })
    }
    #getMarkElement(doc, mark) {
        if (!mark) return null
        return doc.querySelector(`mark[name="${CSS.escape(mark)}"`)
    }
    #speak(doc, getNode) {
        if (!doc) return
        if (!getNode) return this.#serializer.serializeToString(doc)
        const ssml = document.implementation.createDocument(NS.SSML, 'speak')
        ssml.documentElement.replaceWith(ssml.importNode(doc.documentElement, true))
        let node = getNode(ssml)?.previousSibling
        while (node) {
            const next = node.previousSibling ?? node.parentNode?.previousSibling
            node.parentNode.removeChild(node)
            node = next
        }
        const ssmlStr = this.#serializer.serializeToString(ssml)
        return ssmlStr
    }
    start() {
        this.#lastMark = null
        const [doc] = this.#list.first() ?? []
        if (!doc) return this.next()
        return this.#speak(doc, ssml => this.#getMarkElement(ssml, this.#lastMark))
    }
    resume() {
        const [doc] = this.#list.current() ?? []
        if (!doc) return this.next()
        return this.#speak(doc, ssml => this.#getMarkElement(ssml, this.#lastMark))
    }
    prev(paused) {
        this.#lastMark = null
        const [doc, range] = this.#list.prev() ?? []
        if (paused && range) this.highlight(range.cloneRange())
        return this.#speak(doc)
    }
    next(paused) {
        this.#lastMark = null
        const [doc, range] = this.#list.next() ?? []
        if (paused && range) this.highlight(range.cloneRange())
        return this.#speak(doc)
    }
    prevMark(paused) {
        const marks = Array.from(this.#ranges.keys())
        if (marks.length === 0) return

        const currentIndex = this.#lastMark ? marks.indexOf(this.#lastMark) : -1
        if (currentIndex > 0) {
            const prevMarkName = marks[currentIndex - 1]
            const range = this.#ranges.get(prevMarkName)
            if (range) {
                this.#lastMark = prevMarkName
                if (paused) this.highlight(range.cloneRange())

                const [doc] = this.#list.current() ?? []
                return this.#speak(doc, ssml => this.#getMarkElement(ssml, prevMarkName))
            }
        } else {
            const [doc, range] = this.#list.prev() ?? []
            if (doc && range) {
                const prevMarks = Array.from(this.#ranges.keys())
                if (prevMarks.length > 0) {
                    const lastMarkName = prevMarks[prevMarks.length - 1]
                    const lastMarkRange = this.#ranges.get(lastMarkName)
                    if (lastMarkRange) {
                        this.#lastMark = lastMarkName
                        if (paused) this.highlight(lastMarkRange.cloneRange())
                        return this.#speak(doc, ssml => this.#getMarkElement(ssml, lastMarkName))
                    }
                } else {
                    this.#lastMark = null
                    if (paused) this.highlight(range.cloneRange())
                    return this.#speak(doc)
                }
            }
        }
    }
    nextMark(paused) {
        const marks = Array.from(this.#ranges.keys())
        if (marks.length === 0) return

        const currentIndex = this.#lastMark ? marks.indexOf(this.#lastMark) : -1
        if (currentIndex >= 0 && currentIndex < marks.length - 1) {
            const nextMarkName = marks[currentIndex + 1]
            const range = this.#ranges.get(nextMarkName)
            if (range) {
                this.#lastMark = nextMarkName
                if (paused) this.highlight(range.cloneRange())
                const [doc] = this.#list.current() ?? []
                return this.#speak(doc, ssml => this.#getMarkElement(ssml, nextMarkName))
            }
        } else {
            const [doc, range] = this.#list.next() ?? []
            if (doc && range) {
                const nextMarks = Array.from(this.#ranges.keys())
                if (nextMarks.length > 0) {
                    const firstMarkName = nextMarks[0]
                    const firstMarkRange = this.#ranges.get(firstMarkName)
                    if (firstMarkRange) {
                        this.#lastMark = firstMarkName
                        if (paused) this.highlight(firstMarkRange.cloneRange())
                        return this.#speak(doc, ssml => this.#getMarkElement(ssml, firstMarkName))
                    }
                } else {
                    this.#lastMark = null
                    if (paused) this.highlight(range.cloneRange())
                    return this.#speak(doc)
                }
            }
        }
    }
    from(range) {
        this.#lastMark = null
        const [doc] = this.#list.find(range_ =>
            range.compareBoundaryPoints(Range.END_TO_START, range_) <= 0)
        // Pick the mark whose sentence contains the selection: the last mark
        // that begins at or before it. Taking the first mark beginning at or
        // after the selection skipped to the next sentence whenever the
        // selected word was not its sentence's first word.
        let mark
        for (const [name, range_] of this.#ranges.entries()) {
            if (range.compareBoundaryPoints(Range.START_TO_START, range_) < 0) break
            mark = name
        }
        return this.#speak(doc, ssml => this.#getMarkElement(ssml, mark))
    }
    getLastRange() {
        if (this.#lastMark) {
            const range = this.#ranges.get(this.#lastMark)
            if (range) return range.cloneRange()
        }
    }
    setMark(mark) {
        const range = this.#ranges.get(mark)
        if (range) {
            this.#lastMark = mark
            this.highlight(range.cloneRange())
            return range
        }
    }
}
