# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
from __python__ import bound_methods, hash_literals

from elementmaker import E
from gettext import gettext as _

from book_list.globals import get_session_data
from dom import unique_id
from modals import create_custom_dialog, error_dialog
from session import session_defaults
from widgets import create_button


class Tracker:
    def __init__(self):
        self.clear()

    def clear(self):
        self.positions = []
        self.last_pos = 0
        self.queue = []

    def parse_marked_text(self, marked_text):
        self.clear()
        text = []
        text_len = chunk_len = index_in_positions = offset_in_text = 0
        limit = 2048

        def commit():
            self.queue.push({
                'text': ''.join(text), 'index_in_positions': index_in_positions,
                'offset_in_text': offset_in_text, 'reached_offset': 0})

        for x in marked_text:
            if jstype(x) is 'number':
                self.positions.push({'mark': x, 'offset_in_text': text_len})
            else:
                text_len += x.length
                chunk_len += x.length
                text.push(x)
                if chunk_len > limit:
                    commit()
                    chunk_len = 0
                    text = []
                    index_in_positions = max(0, self.positions.length - 1)
                    offset_in_text = text_len
        if text.length:
            commit()
        self.marked_text = marked_text
        return self.current_text()

    def pop_first(self):
        self.queue.splice(0, 1)
        self.last_pos = 0
        if self.queue.length:
            self.last_pos = self.queue[0].index_in_positions

    def current_text(self):
        if self.queue.length:
            return self.queue[0].text
        return ''

    def resume(self):
        self.last_pos = 0
        if self.queue.length:
            self.last_pos = self.queue[0].index_in_positions
            if self.queue[0].reached_offset:
                o = self.queue[0].reached_offset
                # make sure positions remain the same for word tracking
                self.queue[0].text = (' ' * o) + self.queue[0].text[o:]
        return self.current_text()

    def boundary_reached(self, start):
        if self.queue.length:
            self.queue[0].reached_offset = start

    def mark_word(self, start, length):
        if not self.queue.length:
            return
        start += self.queue[0].offset_in_text
        end = start + length
        matches = []
        while self.last_pos < self.positions.length:
            pos = self.positions[self.last_pos]
            if start <= pos.offset_in_text < end:
                matches.push(pos)
            elif pos.offset_in_text >= end:
                break
            self.last_pos += 1
        if matches.length:
            return matches[0].mark, matches[-1].mark
        return None


class Client:
    min_rate = 0.1
    max_rate = 2

    def __init__(self):
        self.stop_requested_at = None
        self.status = {'synthesizing': False, 'paused': False}
        self.tracker = Tracker([])
        self.last_reached_mark = None
        self.onevent = def ():
            pass
        data = get_session_data().get('tts_backend')
        self.current_voice_uri = data.voice or ''
        self.current_rate = data.rate or None

    def create_utterance(self, text):
        ut = new window.SpeechSynthesisUtterance(text)
        ut.onstart = self.utterance_started
        ut.onpause = self.utterance_paused
        ut.onend = self.utterance_ended
        ut.onerror = self.utterance_failed
        ut.onresume = self.utterance_resumed
        ut.addEventListener('boundary', self.utterance_boundary_reached)
        if self.current_voice_uri:
            for voice in window.speechSynthesis.getVoices():
                if voice.voiceURI is self.current_voice_uri:
                    ut.voice = voice
                    break
        if self.current_rate:
            ut.rate = self.current_rate
        return ut

    def utterance_started(self, event):
        self.status = {'synthesizing': True, 'paused': False}
        self.onevent('begin')

    def utterance_paused(self, event):
        self.status = {'synthesizing': True, 'paused': True}
        self.onevent('pause')

    def speak(self, text):
        ut, self.current_utterance = self.current_utterance, None
        if ut:
            ut.onstart = ut.onpause = ut.onend = ut.onerror = ut.onresume = None
            ut.removeEventListener('boundary', self.utterance_boundary_reached)
        if text and text.length:
            self.current_utterance = self.create_utterance(text)
            window.speechSynthesis.speak(self.current_utterance)

    def utterance_ended(self, event):
        self.status = {'synthesizing': False, 'paused': False}
        if self.stop_requested_at? and window.performance.now() - self.stop_requested_at < 1000:
            self.stop_requested_at = None
            return
        self.tracker.pop_first()
        text = self.tracker.current_text()
        if text and text.length:
            self.speak(text)
        else:
            self.onevent('end')

    def utterance_failed(self, event):
        self.status = {'synthesizing': False, 'paused': False}
        self.tracker.clear()
        if event.error is not 'interrupted' and event.error is not 'canceled':
            if event.error is 'synthesis-unavailable':
                msg = _('Text-to-Speech not available in this browser. You may need to install some Text-to-Speech software.')
            else:
                msg = _('An error has occurred with speech synthesis: ') + event.error
            error_dialog(_('Speaking failed'), msg)
        self.onevent('cancel')

    def utterance_boundary_reached(self, event):
        self.tracker.boundary_reached(event.charIndex)
        if event.name is 'word':
            x = self.tracker.mark_word(event.charIndex, event.charLength or 2)
            if x:
                first, last = x[0], x[1]
                self.onevent('mark', {'first': first, 'last': last})

    def utterance_resumed(self, event):
        self.status = {'synthesizing': True, 'paused': False}
        self.onevent('resume')

    def pause(self):
        window.speechSynthesis.pause()

    def resume(self):
        window.speechSynthesis.resume()

    def pause_for_configure(self):
        if self.current_utterance:
            ut = self.current_utterance
            self.current_utterance = None
            ut.onstart = ut.onpause = ut.onend = ut.onerror = ut.onresume = None
            ut.removeEventListener('boundary', self.utterance_boundary_reached)
        window.speechSynthesis.cancel()

    def resume_after_configure(self):
        text = self.tracker.resume()
        if text and text.length:
            self.speak(text)

    def stop(self):
        self.tracker.clear()
        self.stop_requested_at = window.performance.now()
        window.speechSynthesis.cancel()
        self.status = {'synthesizing': False, 'paused': False}

    def speak_simple_text(self, text):
        self.stop()
        text = self.tracker.parse_marked_text([text])
        if text and text.length:
            self.speak(text)

    def speak_marked_text(self, text_segments, onevent):
        self.stop()
        self.onevent = onevent
        text = self.tracker.parse_marked_text(text_segments)
        if text and text.length:
            self.speak(text)

    def faster(self):
        self.change_rate(steps=1)

    def slower(self):
        self.change_rate(steps=-1)

    def save_settings(self):
        sd = get_session_data()
        sd.set('tts_backend', {'voice': self.current_voice_uri, 'rate': self.current_rate})

    def change_rate(self, steps=1):
        rate = current_rate = (self.current_rate or 1) * 10
        rate += steps
        rate /= 10
        rate = max(self.min_rate, min(rate, self.max_rate))
        if rate is not current_rate:
            is_speaking = bool(window.speechSynthesis.speaking)
            if is_speaking:
                self.pause_for_configure()
            self.current_rate = rate
            self.save_settings()
            if is_speaking:
                self.resume_after_configure()

    def configure(self):
        voice_id = unique_id()
        rate_id = unique_id()
        select_id = unique_id()
        sd = get_session_data()
        current_bar_pos = sd.get('tts_bar_position')

        def restore_defaults():
            document.getElementById(voice_id).selectedIndex = -1
            document.getElementById(rate_id).value = 10
            document.getElementById(select_id).value = session_defaults().tts_bar_position

        create_custom_dialog(_('Configure Text-to-Speech'), def (parent_div, close_modal):
                pos_select = E.select(id=select_id)
                def apos(text, name):
                    option = E.option(text, value=name)
                    if name is current_bar_pos:
                        option.setAttribute('selected', 'selected')
                    pos_select.appendChild(option)
                apos(_('Floating with help text'), 'float')
                apos(_('Top'), 'top')
                apos(_('Bottom'), 'bottom')
                apos(_('Top right'), 'top-right')
                apos(_('Top left'), 'top-left')
                apos(_('Bottom right'), 'bottom-right')
                apos(_('Bottom left'), 'bottom-left')
                select = E.select(size='5', id=voice_id, style='display:block')
                voices = window.speechSynthesis.getVoices()
                voices.sort(def (a, b):
                    a = a.name.toLowerCase()
                    b = b.name.toLowerCase()
                    return -1 if a < b else (0 if a is b else 1)
                )
                voice_filter = E.input(type='text', placeholder=_('Filter voices'), style='display: block')
                seen = {}
                for voice in voices:
                    if not seen[voice.voiceURI]:
                        cloud = ''
                        if not voice.localService:
                            cloud = ' -- ' + _('cloud based')
                        option = E.option(f'{voice.name} ({voice.lang}){cloud}', value=voice.voiceURI)
                        if self.current_voice_uri and voice.voiceURI is self.current_voice_uri:
                            option.setAttribute('selected', 'selected')
                        select.appendChild(option)
                        seen[voice.voiceURI] = True
                if len(voices) < 1:
                    select.appendChild(E.option(_('No voices available')))
                    select.lastChild.disabled = True
                else:
                    original = Array.from (select.options).map(def (o): return {'text': o.text, 'value': o.value};)
                    voice_filter.addEventListener('input', def (ev):
                        q = voice_filter.value.trim().toLowerCase()
                        filtered = original.filter(def (item): return item.text.toLowerCase().includes(q);)
                        frag = document.createDocumentFragment()
                        if filtered.length is 0:
                            frag.appendChild(E.option(_('No matches'), disabled=True))
                        else:
                            for item in filtered:
                                frag.appendChild(E.option(item.text, value=item.value))
                        select.replaceChildren(frag)
                        if select.selectedIndex > -1:
                            select.options[select.selectedIndex].scrollIntoView()
                    )
                parent_div.appendChild(E.div(_('Speed of speech:') + '\xa0'))
                parent_div.lastChild.appendChild(
                    E.input(type='range', id=rate_id, min=(self.min_rate * 10) + '', max=(self.max_rate * 10) + '', value=((self.current_rate or 1) * 10) + ''))
                parent_div.appendChild(E.div(_('Voice:') + '\xa0'))
                voice_div = E.div(select, voice_filter, style='display: inline-block; vertical-align: middle')
                parent_div.lastChild.appendChild(voice_div)
                if select.selectedIndex > -1 and select.options[select.selectedIndex]:
                    select.options[select.selectedIndex].scrollIntoView()
                parent_div.appendChild(E.div(_('Position of bar:') + '\xa0', pos_select, style='margin-top: 1rem'))
                parent_div.appendChild(E.div(
                        class_='button-box',
                        style='margin: 1rem',
                        create_button(_('Close'), action=close_modal),
                        create_button(_('Restore defaults'), action=restore_defaults),
                ))
            , on_close = def ():
                voice = document.getElementById(voice_id).value or ''
                bar_pos = document.getElementById(select_id).value
                rate = int(document.getElementById(rate_id).value) / 10
                if rate is 1:
                    rate = None
                changed = voice is not self.current_voice_uri or rate is not self.current_rate or bar_pos is not current_bar_pos
                if changed:
                    self.current_voice_uri = voice
                    self.current_rate = rate
                    if bar_pos is not current_bar_pos:
                        sd.set('tts_bar_position', bar_pos)
                is_speaking = bool(window.speechSynthesis.speaking)
                if is_speaking:
                    self.pause_for_configure()
                self.save_settings()
                if is_speaking:
                    self.resume_after_configure()
                self.onevent('configured')
        )
