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

import traceback

from fs_images import fix_fullscreen_svg_images
from iframe_comm import IframeClient
from range_utils import (
    all_annots_in_selection,
    highlight_associated_with_selection,
    last_span_for_crw,
    reset_highlight_counter,
    select_crw,
    unwrap_all_crw,
    unwrap_crw,
    wrap_text_in_range
)
from read_book.anchor_visibility import ensure_page_list_target_is_displayed, invalidate_anchor_position_cache
from read_book.cfi import cfi_for_selection, range_from_cfi
from read_book.extract import get_elements
from read_book.find import reset_find_caches, select_search_result, select_tts_mark, tts_data
from read_book.flow_mode import anchor_funcs as flow_anchor_funcs
from read_book.flow_mode import auto_scroll_action as flow_auto_scroll_action
from read_book.flow_mode import cancel_drag_scroll as cancel_drag_scroll_flow
from read_book.flow_mode import ensure_selection_boundary_visible as ensure_selection_boundary_visible_flow
from read_book.flow_mode import ensure_selection_visible, flow_onwheel, flow_to_scroll_fraction
from read_book.flow_mode import handle_gesture as flow_handle_gesture
from read_book.flow_mode import handle_shortcut as flow_handle_shortcut
from read_book.flow_mode import jump_to_cfi as flow_jump_to_cfi
from read_book.flow_mode import layout as flow_layout
from read_book.flow_mode import scroll_by_page as flow_scroll_by_page
from read_book.flow_mode import scroll_to_extend_annotation as flow_annotation_scroll
from read_book.flow_mode import start_drag_scroll as start_drag_scroll_flow
from read_book.footnotes import is_footnote_link
from read_book.gestures import action_for_gesture
from read_book.globals import (
    annot_id_uuid_map,
    clear_annot_id_uuid_map,
    current_book,
    current_layout_mode,
    current_spine_item,
    runtime,
    set_boss,
    set_current_spine_item,
    set_layout_mode,
    set_toc_anchor_map
)
from read_book.highlights import highlight_style_as_css
from read_book.hints import apply_prefix_to_hints, hint_visible_links, unhint_links
from read_book.mathjax import apply_mathjax
from read_book.paged_mode import anchor_funcs as paged_anchor_funcs
from read_book.paged_mode import auto_scroll_action as paged_auto_scroll_action
from read_book.paged_mode import (
    calc_columns_per_screen,
    current_cfi,
    get_columns_per_screen_data,
    page_counts,
    progress_frac,
    reset_paged_mode_globals,
    scroll_to_elem,
    snap_to_selection,
    will_columns_per_screen_change
)
from read_book.paged_mode import cancel_drag_scroll as cancel_drag_scroll_paged
from read_book.paged_mode import ensure_selection_boundary_visible as ensure_selection_boundary_visible_paged
from read_book.paged_mode import handle_gesture as paged_handle_gesture
from read_book.paged_mode import handle_shortcut as paged_handle_shortcut
from read_book.paged_mode import jump_to_cfi as paged_jump_to_cfi
from read_book.paged_mode import layout as paged_layout
from read_book.paged_mode import onwheel as paged_onwheel
from read_book.paged_mode import prepare_for_resize as paged_prepare_for_resize
from read_book.paged_mode import resize_done as paged_resize_done
from read_book.paged_mode import scroll_by_page as paged_scroll_by_page
from read_book.paged_mode import scroll_to_extend_annotation as paged_annotation_scroll
from read_book.paged_mode import scroll_to_fraction as paged_scroll_to_fraction
from read_book.paged_mode import start_drag_scroll as start_drag_scroll_paged
from read_book.referencing import elem_for_ref, end_reference_mode, start_reference_mode
from read_book.resources import finalize_resources, unserialize_html
from read_book.settings import (
    apply_colors,
    apply_font_size,
    apply_settings,
    apply_stylesheet,
    opts,
    set_color_scheme_class,
    set_selection_style,
    update_settings
)
from read_book.shortcuts import create_shortcut_map, keyevent_as_shortcut, shortcut_for_key_event
from read_book.smil import flatten_smil_map, mark_smil_element, smil_element_at
from read_book.toc import find_anchor_before_range, update_visible_toc_anchors
from read_book.touch import create_handlers as create_touch_handlers
from read_book.touch import reset_handlers as reset_touch_handlers
from read_book.viewport import scroll_viewport
from select import first_visible_word, is_start_closer_to_point, move_end_of_selection, selection_extents, word_at_point
from utils import debounce, is_ios

FORCE_FLOW_MODE = False
CALIBRE_VERSION = '__CALIBRE_VERSION__'
ONSCROLL_DEBOUNCE_TIME = 1000
ERS_SUPPORTED_FEATURES = {'dom-manipulation', 'layout-changes', 'touch-events', 'mouse-events', 'keyboard-events', 'spine-scripting'}


def layout_style():
    return 'scrolling' if current_layout_mode() is 'flow' else 'paginated'


drag_mouse_position = {'x': None, 'y': None}


def cancel_drag_scroll():
    cancel_drag_scroll_flow()
    cancel_drag_scroll_paged()


class EPUBReadingSystem:
    @property
    def name(self):
        return 'calibre'

    @property
    def version(self):
        return CALIBRE_VERSION

    @property
    def layoutStyle(self):
        return layout_style()

    def hasFeature(self, feature, version):
        return feature in ERS_SUPPORTED_FEATURES

    def __repr__(self):
        return f'{{name:{self.name}, version:{self.version}, layoutStyle:{self.layoutStyle}}}'

    def __str__(self):
        return self.__repr__()


class FullBookSearch:
    def __init__(self):
        start_spine_index = current_spine_item()?.index
        if not start_spine_index?:
            start_spine_index = -1
        self.start_spine_index = start_spine_index
        self.progress_frac_at_start = progress_frac()
        self.first_result_shown = False


class IframeBoss:
    def __init__(self):
        window.navigator.epubReadingSystem = EPUBReadingSystem()
        self.last_cfi = None
        self.last_search_at = -1001
        self.reference_mode_enabled = False
        self.replace_history_on_next_cfi_update = True
        self.blob_url_map = {}
        self.content_ready = False
        self.last_window_width = self.last_window_height = -1
        self.forward_keypresses = False
        self.full_book_search_in_progress = None
        set_boss(self)
        handlers = {
            'change_color_scheme': self.change_color_scheme,
            'change_font_size': self.change_font_size,
            'viewer_font_size_changed': self.viewer_font_size_changed,
            'change_number_of_columns': self.change_number_of_columns,
            'number_of_columns_changed': self.number_of_columns_changed,
            'change_scroll_speed': self.change_scroll_speed,
            'display': self.display,
            'gesture_from_margin': self.gesture_from_margin,
            'native_gesture': self.native_gesture,
            'get_current_cfi': self.get_current_cfi,
            'initialize': self.initialize,
            'modify_selection': self.modify_selection,
            'next_screen': self.on_next_screen,
            'scroll_to_anchor': self.on_scroll_to_anchor,
            'scroll_to_frac': self.on_scroll_to_frac,
            'scroll_to_ref': self.on_scroll_to_ref,
            'fake_popup_activation': self.on_fake_popup_activation,
            'set_reference_mode': self.set_reference_mode,
            'toggle_autoscroll': self.toggle_autoscroll,
            'fake_wheel_event': self.fake_wheel_event,
            'window_size': self.received_window_size,
            'overlay_visibility_changed': self.on_overlay_visibility_changed,
            'show_search_result': self.show_search_result,
            'handle_navigation_shortcut': self.on_handle_navigation_shortcut,
            'annotations': self.annotations_msg_received,
            'tts': self.tts_msg_received,
            'audio-ebook': self.audio_ebook_msg_received,
            'hints': self.hints_msg_received,
            'copy_selection': self.copy_selection,
            'replace_highlights': self.replace_highlights,
            'clear_selection': def (): window.getSelection().removeAllRanges();,
        }
        self.comm = IframeClient(handlers, 'main-iframe')
        self.last_window_ypos = 0
        self.length_before = None

    def on_overlay_visibility_changed(self, data):
        cancel_drag_scroll()
        if data.visible:
            self.forward_keypresses = True
            if self.auto_scroll_action:
                self.auto_scroll_active_before_overlay = self.auto_scroll_action('is_active')
                self.auto_scroll_action('stop')
        else:
            self.forward_keypresses = False
            if self.auto_scroll_active_before_overlay:
                self.auto_scroll_active_before_overlay = undefined
                self.auto_scroll_action('start')

    def modify_selection(self, data):
        sel = window.getSelection()
        use_end = False
        if data.granularity is 'all':
            r = document.createRange()
            r.selectNode(document.body)
            sel.removeAllRanges()
            sel.addRange(r)
        else:
            use_end = data.direction is 'forward' or data.direction is 'right'
            try:
                sel.modify('extend', data.direction, data.granularity)
            except:
                if data.granularity is 'paragraph':
                    sel.modify('extend', data.direction, 'line')
        self.ensure_selection_boundary_visible(use_end)

    def initialize(self, data):
        scroll_viewport.update_window_size(data.width, data.height)
        window.addEventListener('error', self.onerror)
        window.addEventListener('scroll', debounce(self.onscroll, ONSCROLL_DEBOUNCE_TIME))
        window.addEventListener('scroll', self.no_latency_onscroll)
        window.addEventListener('resize', debounce(self.onresize, 500))
        window.addEventListener('wheel', self.onwheel, {'passive': False})
        window.addEventListener('keydown', self.onkeydown, {'passive': False})
        window.addEventListener('mousemove', self.onmousemove, {'passive': True})
        window.addEventListener('mouseup', self.onmouseup, {'passive': True})
        window.addEventListener('dblclick', self.ondoubleclick, {'passive': True})
        document.documentElement.addEventListener('contextmenu', self.oncontextmenu, {'passive': False})
        document.addEventListener('selectionchange', self.onselectionchange)
        self.color_scheme = data.color_scheme
        create_touch_handlers()

    def onerror(self, evt):
        msg = evt.message
        script_url = evt.filename
        line_number = evt.lineno
        column_number = evt.colno
        error_object = evt.error
        evt.stopPropagation(), evt.preventDefault()
        if error_object is None:
            # This happens for cross-domain errors (probably javascript injected
            # into the browser via extensions/ userscripts and the like). It also
            # happens all the time when using Chrome on Safari, so ignore this
            # type of error
            console.log(f'Unhandled error from external javascript, ignoring: {msg} {script_url} {line_number}')
            return
        is_internal_error = script_url in ('about:srcdoc', 'userscript:viewer.js')
        if is_internal_error:  # don't report errors from scripts in the book itself
            try:
                (console.error or console.log)(error_object)
                self.send_message('error', errkey='unhandled-error', msg=msg,
                    script_url=script_url, line_number=line_number, column_number=column_number,
                    error_object=traceback.serialize_error_for_formatting(error_object))
            except:
                console.log('There was an error in the iframe unhandled exception handler')
        else:
            (console.error or console.log)('There was an error in the JavaScript from within the book')
            (console.error or console.log)(error_object)

    def display(self, data):
        cancel_drag_scroll()
        drag_mouse_position.x = drag_mouse_position.y = None
        self.length_before = None
        self.content_ready = False
        clear_annot_id_uuid_map()
        reset_highlight_counter()
        set_toc_anchor_map()
        self.replace_history_on_next_cfi_update = True
        self.book = current_book.book = data.book
        self.smil_map = data.smil_map
        self.smil_anchor_map, self.smil_par_list = flatten_smil_map(self.smil_map)
        self.link_attr = 'data-' + self.book.manifest.link_uid
        self.reference_mode_enabled = data.reference_mode_enabled
        self.is_titlepage = data.is_titlepage
        spine = self.book.manifest.spine
        index = spine.indexOf(data.name)
        reset_paged_mode_globals()
        set_layout_mode('flow' if FORCE_FLOW_MODE else data.settings.read_mode)
        if current_layout_mode() is 'flow':
            self.do_layout = flow_layout
            self.handle_wheel = flow_onwheel
            self.handle_navigation_shortcut = flow_handle_shortcut
            self._handle_gesture = flow_handle_gesture
            self.to_scroll_fraction = flow_to_scroll_fraction
            self.jump_to_cfi = flow_jump_to_cfi
            self.anchor_funcs = flow_anchor_funcs
            self.auto_scroll_action = flow_auto_scroll_action
            self.scroll_to_extend_annotation = flow_annotation_scroll
            self.ensure_selection_visible = ensure_selection_visible
            self.ensure_selection_boundary_visible = ensure_selection_boundary_visible_flow
            self.start_drag_scroll = start_drag_scroll_flow
            paged_auto_scroll_action('stop')
        else:
            self.do_layout = paged_layout
            self.handle_wheel = paged_onwheel
            self.handle_navigation_shortcut = paged_handle_shortcut
            self.to_scroll_fraction = paged_scroll_to_fraction
            self.jump_to_cfi = paged_jump_to_cfi
            self._handle_gesture = paged_handle_gesture
            self.anchor_funcs = paged_anchor_funcs
            self.auto_scroll_action = paged_auto_scroll_action
            self.scroll_to_extend_annotation = paged_annotation_scroll
            self.ensure_selection_visible = snap_to_selection
            self.ensure_selection_boundary_visible = ensure_selection_boundary_visible_paged
            self.start_drag_scroll = start_drag_scroll_paged
            flow_auto_scroll_action('stop')
        update_settings(data.settings)
        self.keyboard_shortcut_map = create_shortcut_map(data.settings.keyboard_shortcuts)
        set_current_spine_item({
            'name': data.name,
            'is_first': index is 0,
            'is_last': index is spine.length - 1,
            'index': index,
            'initial_position': data.initial_position
        })
        self.last_cfi = None
        for name in self.blob_url_map:
            window.URL.revokeObjectURL(self.blob_url_map[name])
        document.body.style.removeProperty('font-family')
        root_data, self.mathjax, self.blob_url_map = finalize_resources(self.book, data.name, data.resource_data)
        self.highlights_to_apply = data.highlights
        unserialize_html(root_data, self.content_loaded, None, data.name)

    def on_scroll_to_frac(self, data):
        self.to_scroll_fraction(data.frac, False)

    def toggle_autoscroll(self):
        self.auto_scroll_action('toggle')

    def handle_gesture(self, gesture):
        gesture.resolved_action = action_for_gesture(gesture, current_layout_mode() is 'flow')
        if gesture.resolved_action is 'show_chrome':
            self.send_message('show_chrome')
        elif gesture.resolved_action is 'decrease_font_size':
            self.send_message('bump_font_size', increase=False)
        elif gesture.resolved_action is 'increase_font_size':
            self.send_message('bump_font_size', increase=True)
        elif gesture.resolved_action is 'highlight_or_inspect':
            self.handle_long_tap(gesture)
        else:
            self._handle_gesture(gesture)

    def handle_long_tap(self, gesture):
        elements = get_elements(gesture.viewport_x, gesture.viewport_y)
        if elements.img:
            self.send_message('view_image', calibre_src=elements.img)
            return
        if elements.highlight:
            select_crw(elements.crw)
            return
        r = word_at_point(gesture.viewport_x, gesture.viewport_y)
        if r:
            s = document.getSelection()
            s.removeAllRanges()
            s.addRange(r)

    def gesture_from_margin(self, data):
        self.handle_gesture(data.gesture)

    def native_gesture(self, data):
        self.handle_gesture(data.gesture)

    def fake_wheel_event(self, data):
        # these are wheel events from margin or selection mode
        self.onwheel(data.evt)

    def report_human_scroll(self, scrolled_by_frac):
        self.send_message('human_scroll', scrolled_by_frac=scrolled_by_frac or None)

    def on_scroll_to_anchor(self, data):
        frag = data.frag
        if frag:
            self.replace_history_on_next_cfi_update = False
            self.scroll_to_anchor(frag)
        else:
            self.to_scroll_fraction(0.0, False)

    def on_next_screen(self, data):
        backwards = data.backwards
        if current_layout_mode() is 'flow':
            flow_scroll_by_page(-1 if backwards else 1, data.flip_if_rtl_page_progression)
        else:
            paged_scroll_by_page(backwards, data.all_pages_on_screen, data.flip_if_rtl_page_progression)

    def change_font_size(self, data):
        if data.base_font_size? and data.base_font_size != opts.base_font_size:
            opts.base_font_size = data.base_font_size
            apply_font_size()
            if not runtime.is_standalone_viewer:
                # in the standalone viewer this is a separate event as
                # apply_font_size() is a no-op
                self.relayout_on_font_size_change()

    def viewer_font_size_changed(self, data):
        self.relayout_on_font_size_change()

    def change_number_of_columns(self, data):
        if current_layout_mode() is 'flow':
            self.send_message('error', errkey='changing-columns-in-flow-mode')
            return
        cdata = get_columns_per_screen_data()
        delta = int(data.delta)
        if delta is 0:
            new_val = 0
        else:
            new_val = max(1, cdata.cps + delta)
        opts.columns_per_screen[cdata.which] = new_val
        self.relayout_on_font_size_change()
        self.send_message('columns_per_screen_changed', which=cdata.which, cps=new_val)

    def relayout_on_font_size_change(self):
        if current_layout_mode() is not 'flow' and will_columns_per_screen_change():
            self.do_layout(self.is_titlepage)
            if self.last_cfi:
                cfi = self.last_cfi[len('epubcfi(/'):-1].partition('/')[2]
                if cfi:
                    self.jump_to_cfi('/' + cfi)
            self.update_cfi()
        invalidate_anchor_position_cache()
        self.update_toc_position(True)

    def number_of_columns_changed(self, data):
        opts.columns_per_screen = data.columns_per_screen
        self.relayout_on_font_size_change()

    def change_scroll_speed(self, data):
        if data.lines_per_sec_auto?:
            opts.lines_per_sec_auto = data.lines_per_sec_auto

    def change_stylesheet(self, data):
        opts.user_stylesheet = data.sheet or ''
        apply_stylesheet()

    def change_color_scheme(self, data):
        if data.color_scheme and data.color_scheme.foreground and data.color_scheme.background:
            opts.color_scheme = data.color_scheme
            opts.is_dark_theme = v'!!data.color_scheme.is_dark_theme'
            apply_colors()
            set_color_scheme_class()

    def content_loaded(self):
        document.documentElement.style.overflow = 'hidden'
        if self.is_titlepage and not opts.cover_preserve_aspect_ratio:
            document.body.classList.add('cover-fill')
        document.body.classList.add(f'calibre-viewer-{layout_style()}')
        set_color_scheme_class()
        if self.reference_mode_enabled:
            start_reference_mode()
        self.last_window_width, self.last_window_height = scroll_viewport.width(), scroll_viewport.height()
        if self.highlights_to_apply:
            self.apply_highlights_on_load(self.highlights_to_apply)
            self.highlights_to_apply = None
        apply_settings()
        invalidate_anchor_position_cache()
        fix_fullscreen_svg_images()
        self.do_layout(self.is_titlepage)
        if self.mathjax:
            return apply_mathjax(self.mathjax, self.book.manifest.link_uid, self.content_loaded_stage2)
        # window.setTimeout(self.content_loaded_stage2, 1000)
        self.content_loaded_stage2()

    def content_loaded_stage2(self):
        reset_find_caches()
        self.connect_links()
        if runtime.is_standalone_viewer:
            self.listen_for_image_double_clicks()
        self.content_ready = True
        if document.head?.firstChild:
            # this is the loading styles used to suppress scrollbars during load
            # added in unserialize_html
            document.head.removeChild(document.head.firstChild)

        csi = current_spine_item()
        if csi.initial_position:
            ipos = csi.initial_position
            self.replace_history_on_next_cfi_update = ipos.replace_history or False
            if ipos.type is 'frac':
                self.to_scroll_fraction(ipos.frac, True)
            elif ipos.type is 'anchor':
                self.scroll_to_anchor(ipos.anchor)
            elif ipos.type is 'ref':
                self.scroll_to_ref(ipos.refnum)
            elif ipos.type is 'cfi':
                self.jump_to_cfi(ipos.cfi)
            elif ipos.type is 'search_result':
                self.show_search_result(ipos, True)
            elif ipos.type is 'edit_annotation':
                window.setTimeout(def ():
                    self.annotations_msg_received({'type': 'edit-highlight', 'uuid': ipos.uuid})
                , 5)
            elif ipos.type is 'smil_id':
                self.audio_ebook_msg_received({'type': 'play', 'anchor': ipos.anchor})
            elif ipos.type is 'pagelist_ref':
                self.scroll_to_pagelist_ref(ipos.anchor)
        spine = self.book.manifest.spine
        files = self.book.manifest.files
        spine_index = csi.index
        self.length_before = 0
        if spine_index > -1:
            for i in range(spine_index):
                si = spine[i]
                if si:
                    self.length_before += files[si]?.length or 0
        self.send_message(
            'content_loaded', progress_frac=self.calculate_progress_frac(),
            file_progress_frac=progress_frac(), page_counts=page_counts()
        )
        self.last_cfi = None
        self.auto_scroll_action('resume')
        reset_touch_handlers()  # Needed to mitigate issue https://bugs.chromium.org/p/chromium/issues/detail?id=464579

        window.setTimeout(self.update_cfi, ONSCROLL_DEBOUNCE_TIME)
        window.setTimeout(self.update_toc_position, 0)
        load_event = document.createEvent('Event')
        load_event.initEvent('load', False, False)
        window.dispatchEvent(load_event)

    def calculate_progress_frac(self):
        current_name = current_spine_item().name
        files = self.book.manifest.files
        file_length = files[current_name]?.length or 0
        if self.length_before is None:
            return 0
        frac = progress_frac()
        ans = (self.length_before + (file_length * frac)) / self.book.manifest.spine_length
        return ans

    def get_current_cfi(self, data):
        cfi = current_cfi()
        csi = current_spine_item()

        def epubcfi(cfi):
            return 'epubcfi(/{}{})'.format(2 * (index + 1), cfi) if cfi else None

        if cfi and csi:
            index = csi.index
            if index > -1:
                selection = window.getSelection()
                selcfi = seltext = None
                if selection and not selection.isCollapsed:
                    seltext = selection.toString()
                    selcfi = cfi_for_selection()
                    selcfi.start = epubcfi(selcfi.start)
                    selcfi.end = epubcfi(selcfi.end)
                cfi = epubcfi(cfi)
                self.send_message(
                    'report_cfi', cfi=cfi, progress_frac=self.calculate_progress_frac(),
                    file_progress_frac=progress_frac(), request_id=data.request_id,
                    selected_text=seltext, selection_bounds=selcfi, page_counts=page_counts())
                return
        self.send_message(
            'report_cfi', cfi=None, progress_frac=0, file_progress_frac=0, page_counts=page_counts(), request_id=data.request_id)

    def update_cfi(self, force_update):
        cfi = current_cfi()
        if cfi:
            index = current_spine_item().index
            if index > -1:
                cfi = 'epubcfi(/{}{})'.format(2 * (index + 1), cfi)
                pf = self.calculate_progress_frac()
                fpf = progress_frac()
                if cfi is not self.last_cfi or force_update:
                    self.last_cfi = cfi
                    self.send_message(
                        'update_cfi', cfi=cfi, replace_history=self.replace_history_on_next_cfi_update,
                        progress_frac=pf, file_progress_frac=fpf, page_counts=page_counts())
                    self.replace_history_on_next_cfi_update = True
                else:
                    self.send_message(
                        'update_progress_frac', progress_frac=pf, file_progress_frac=fpf, page_counts=page_counts())

    def update_toc_position(self, recalculate_toc_anchor_positions):
        visible_anchors = update_visible_toc_anchors(
            self.book.manifest.toc_anchor_map,
            self.book.manifest.page_list_anchor_map,
            bool(recalculate_toc_anchor_positions)
        )
        self.send_message('update_toc_position', visible_anchors=visible_anchors)

    def onscroll(self):
        if self.content_ready:
            self.update_cfi()
            self.update_toc_position()

    def no_latency_onscroll(self):
        pf = self.calculate_progress_frac()
        fpf = progress_frac()
        self.send_message(
            'update_progress_frac', progress_frac=pf, file_progress_frac=fpf, page_counts=page_counts())
        sel = window.getSelection()
        if sel and not sel.isCollapsed:
            self.send_message('update_selection_position', selection_extents=selection_extents(current_layout_mode() is 'flow', True))

    def onresize(self):
        self.send_message('request_size')
        if self.content_ready:
            if is_ios:
                # On iOS window.innerWidth/Height are wrong inside the iframe,
                # so we wait for the reply from request_size
                return
            self.onresize_stage2()

    def onselectionchange(self):
        if not self.content_ready:
            return
        sel = window.getSelection()
        text = ''
        annot_id = None
        collapsed = not sel or sel.isCollapsed
        start_is_anchor = True
        if not collapsed:
            text = sel.toString()
            annot_id = highlight_associated_with_selection(sel, annot_id_uuid_map)
            r = sel.getRangeAt(0)
            start_is_anchor = r.startContainer is sel.anchorNode and r.startOffset is sel.anchorOffset
        now = window.performance.now()
        by_search = now - self.last_search_at < 1000
        self.send_message(
            'selectionchange', text=text, empty=v'!!collapsed', annot_id=annot_id,
            drag_mouse_position=drag_mouse_position, selection_change_caused_by_search=by_search,
            selection_extents=selection_extents(current_layout_mode() is 'flow'),
            rtl=scroll_viewport.rtl, vertical=scroll_viewport.vertical_writing_mode,
            start_is_anchor=start_is_anchor
        )

    def onresize_stage2(self):
        if scroll_viewport.width() is self.last_window_width and scroll_viewport.height() is self.last_window_height:
            # Safari at least, generates lots of spurious resize events
            return
        if current_layout_mode() is not 'flow':
            paged_prepare_for_resize(self.last_window_width, self.last_window_height)
            self.do_layout(self.is_titlepage)
        self.last_window_width, self.last_window_height = scroll_viewport.width(), scroll_viewport.height()
        if self.last_cfi:
            cfi = self.last_cfi[len('epubcfi(/'):-1].partition('/')[2]
            if cfi:
                self.jump_to_cfi('/' + cfi)
        if current_layout_mode() is not 'flow':
            paged_resize_done()
        self.update_cfi()
        self.update_toc_position()
        sel = window.getSelection()
        if sel and not sel.isCollapsed:
            # update_selection_position has probably already been called by
            # no_latency_onscroll but make sure
            self.send_message('update_selection_position', selection_extents=selection_extents(current_layout_mode() is 'flow'))

    def received_window_size(self, data):
        scroll_viewport.update_window_size(data.width, data.height)
        if self.content_ready:
            self.onresize_stage2()

    def onwheel(self, evt):
        if self.content_ready:
            if evt.preventDefault:
                evt.preventDefault()
            if evt.deltaY and evt.ctrlKey and not evt.shiftKey and not evt.altKey and not evt.metaKey:
                self.send_message('handle_shortcut', name='increase_font_size' if evt.deltaY < 0 else 'decrease_font_size')
            else:
                self.handle_wheel(evt)

    def onmousemove(self, evt):
        if evt.buttons is not 1:
            return
        drag_mouse_position.x = evt.clientX
        drag_mouse_position.y = evt.clientY
        if 0 <= evt.clientY <= window.innerHeight:
            cancel_drag_scroll()
            return
        sel = window.getSelection()
        if not sel:
            cancel_drag_scroll()
            return
        delta = evt.clientY if evt.clientY < 0 else (evt.clientY - window.innerHeight)
        self.start_drag_scroll(delta)

    def onmouseup(self, evt):
        cancel_drag_scroll()
        drag_mouse_position.x = drag_mouse_position.y = None
        # ensure selection bar is updated
        self.onselectionchange()
        if evt.button is 3 or evt.button is 4:
            self.send_message('handle_shortcut', name='back' if evt.button is 3 else 'forward')

    def ondoubleclick(self, evt):
        self.send_message('annotations', type='double-click')

    def onkeydown(self, evt):
        if current_layout_mode() is not 'flow' and evt.key is 'Tab':
            # Prevent the TAB key from shifting focus as it causes partial scrolling
            evt.preventDefault()
        if self.forward_keypresses:
            self.send_message('handle_keypress', evt=keyevent_as_shortcut(evt))
            evt.preventDefault(), evt.stopPropagation()
            return
        if self.content_ready:
            sc_name = shortcut_for_key_event(evt, self.keyboard_shortcut_map)
            if sc_name:
                evt.preventDefault()
                if not self.handle_navigation_shortcut(sc_name, evt):
                    self.send_message('handle_shortcut', name=sc_name)

    def on_handle_navigation_shortcut(self, data):
        self.handle_navigation_shortcut(data.name, data.key or {
            'key': '', 'altKey': False, 'ctrlKey': False, 'shiftKey': False, 'metaKey': False})

    def oncontextmenu(self, evt):
        if self.content_ready:
            evt.preventDefault()
            self.send_message('show_chrome', elements=get_elements(evt.clientX, evt.clientY))

    def send_message(self, action, **data):
        self.comm.send_message(action, data)

    def connect_links(self):
        for a in document.body.querySelectorAll(f'a[{self.link_attr}],area[{self.link_attr}]'):
            a.addEventListener('click', self.link_activated)
        if runtime.is_standalone_viewer:
            # links with a target get turned into requests to open a new window by Qt
            for a in document.body.querySelectorAll('a[target]'):
                a.removeAttribute('target')

    def listen_for_image_double_clicks(self):
        for img in document.querySelectorAll('img, image'):
            img.addEventListener('dblclick', self.image_double_clicked, {'passive': True})

    def image_double_clicked(self, ev):
        img = ev.currentTarget
        if img.dataset.calibreSrc:
            self.send_message('view_image', calibre_src=img.dataset.calibreSrc)

    def link_activated(self, evt):
        try:
            data = JSON.parse(evt.currentTarget.getAttribute(self.link_attr))
        except:
            print('WARNING: Failed to parse link data {}, ignoring'.format(evt.currentTarget?.getAttribute?(self.link_attr)))
            return
        if data.missing:
            self.send_message('link_to_missing_activated', name=data.name)
        else:
            self.activate_link(data.name, data.frag, evt.currentTarget)

    def activate_link(self, name, frag, target_elem):
        if not name:
            name = current_spine_item().name
        try:
            is_popup = is_footnote_link(target_elem, name, frag, current_spine_item().name, self.book.manifest.link_to_map or {})
        except:
            import traceback
            traceback.print_exc()
            is_popup = False
        if is_popup:
            self.on_fake_popup_activation({'name': name, 'frag': frag, 'title': target_elem.textContent})
            return
        if name is current_spine_item().name:
            self.replace_history_on_next_cfi_update = False
            self.scroll_to_anchor(frag)
        else:
            self.send_message('scroll_to_anchor', name=name, frag=frag)

    def on_fake_popup_activation(self, data):
        self.send_message(
            'show_footnote', name=data.name, frag=data.frag, title=data.title,
            cols_per_screen=calc_columns_per_screen(), rtl=scroll_viewport.rtl,
            vertical_writing_mode=scroll_viewport.vertical_writing_mode
        )

    def scroll_to_anchor(self, frag):
        if frag:
            elem = document.getElementById(frag)
            if not elem:
                c = document.getElementsByName(frag)
                if c and c.length:
                    elem = c[0]
            if elem:
                scroll_to_elem(elem)
        else:
            scroll_viewport.scroll_to(0, 0)

    def scroll_to_pagelist_ref(self, frag):
        elem = document.getElementById(frag)
        if not elem:
            c = document.getElementsByName(frag)
            if c and c.length:
                elem = c[0]
        if elem:
            ensure_page_list_target_is_displayed(elem)
            scroll_to_elem(elem)

    def scroll_to_ref(self, refnum):
        refnum = int(refnum)
        elem = elem_for_ref(refnum)
        if elem:
            scroll_to_elem(elem)

    def on_scroll_to_ref(self, data):
        refnum = data.refnum
        if refnum?:
            self.scroll_to_ref(refnum)

    def show_search_result(self, data, from_load):
        if self.load_search_result_timer:
            window.clearTimeout(self.load_search_result_timer)
            self.load_search_result_timer = None
        sr = data.search_result
        if sr.on_discovery:
            if sr.result_num is 1:
                self.full_book_search_in_progress = FullBookSearch()
            elif self.full_book_search_in_progress?.first_result_shown:
                return
        self.last_search_at = window.performance.now()
        before_select_pos = {'x': scroll_viewport.x(), 'y': scroll_viewport.y()}
        if select_search_result(sr):
            self.ensure_selection_boundary_visible()
            need_workaround = from_load and current_layout_mode() is 'paged'
            if need_workaround:
                # workaround bug in chrome where sizes are incorrect in paged
                # mode on initial load for some books
                self.load_search_result_timer = window.setTimeout(
                    self.ensure_search_result_visible.bind(None, before_select_pos),
                    int(3 * ONSCROLL_DEBOUNCE_TIME / 4)
                )
            if self.full_book_search_in_progress and not self.full_book_search_in_progress.first_result_shown and sr.on_discovery:
                discovered = False
                if (sr.force_jump_to
                        or progress_frac() >= self.full_book_search_in_progress.progress_frac_at_start
                        or current_spine_item().index is not self.full_book_search_in_progress.start_spine_index):
                    self.full_book_search_in_progress.first_result_shown = True
                    discovered = True
                else:
                    scroll_viewport.scroll_to(before_select_pos.x, before_select_pos.y)
                self.send_message('search_result_discovered', search_result=data.search_result, discovered=discovered)
            if not need_workaround:
                self.add_search_result_to_history_stack(before_select_pos)
        else:
            self.send_message('search_result_not_found', search_result=data.search_result)

    def add_search_result_to_history_stack(self, before_select_pos):
        self.replace_history_on_next_cfi_update = False
        self.update_cfi(True)

    def ensure_search_result_visible(self, before_select_pos):
        self.load_search_result_timer = None
        sel = window.getSelection()
        if sel.isCollapsed or sel.rangeCount is 0:
            return
        self.ensure_selection_boundary_visible()
        self.add_search_result_to_history_stack(before_select_pos)

    def set_reference_mode(self, data):
        self.reference_mode_enabled = data.enabled
        if data.enabled:
            start_reference_mode()
        else:
            end_reference_mode()

    def apply_highlight(self, uuid, existing, has_notes, style_defn):
        sel = window.getSelection()
        if not sel.rangeCount:
            return
        anchor_before = find_anchor_before_range(sel.getRangeAt(0), self.book.manifest.toc_anchor_map, self.book.manifest.page_list_anchor_map)
        text = sel.toString()
        bounds = cfi_for_selection()
        style = highlight_style_as_css(style_defn, opts.is_dark_theme, opts.color_scheme.foreground)
        cls = 'crw-has-dot' if has_notes else None
        annot_id, intersecting_wrappers = wrap_text_in_range(style, None, cls, self.add_highlight_listeners)
        removed_highlights = []
        if annot_id is not None:
            intersecting_uuids = {annot_id_uuid_map[x]: True for x in intersecting_wrappers}
            if existing and intersecting_uuids[existing]:
                uuid = existing
            elif intersecting_wrappers.length is 1 and annot_id_uuid_map[intersecting_wrappers[0]]:
                uuid = annot_id_uuid_map[intersecting_wrappers[0]]
                intersecting_wrappers = []
            removed_highlights = {}
            for crw in intersecting_wrappers:
                unwrap_crw(crw)
                if annot_id_uuid_map[crw]:
                    if annot_id_uuid_map[crw] is not uuid:
                        removed_highlights[annot_id_uuid_map[crw]] = True
                    v'delete annot_id_uuid_map[crw]'
            removed_highlights = Object.keys(removed_highlights)
            sel.removeAllRanges()
            annot_id_uuid_map[annot_id] = uuid
        self.send_message(
            'annotations',
            type='highlight-applied',
            uuid=uuid, ok=annot_id is not None,
            bounds=bounds, style=style_defn,
            removed_highlights=removed_highlights,
            highlighted_text=text,
            anchor_before=anchor_before
        )
        reset_find_caches()

    def annotations_msg_received(self, data):
        dtype = data?.type
        if dtype is 'move-end-of-selection':
            move_end_of_selection(data.pos, data.start)
        elif dtype is 'set-highlight-style':
            set_selection_style(data.style)
        elif dtype is 'trigger-shortcut':
            self.on_handle_navigation_shortcut(data)
        elif dtype is 'extend-to-paragraph':
            sel = window.getSelection()
            try:
                try:
                    sel.modify('extend', 'forward', 'paragraphboundary')
                except:
                    sel.modify('extend', 'forward', 'lineboundary')
                end_node, end_offset = sel.focusNode, sel.focusOffset
                try:
                    sel.modify('extend', 'backward', 'paragraphboundary')
                except:
                    sel.modify('extend', 'backward', 'lineboundary')
                sel.setBaseAndExtent(sel.focusNode, sel.focusOffset, end_node, end_offset)
            except:
                (console.error or console.log)('Failed to extend selection to paragraph')
        elif dtype is 'extend-to-point':
            move_end_of_selection(data.pos, is_start_closer_to_point(data.pos))
        elif dtype is 'drag-scroll':
            self.scroll_to_extend_annotation(data.backwards)
        elif dtype is 'edit-highlight':
            found_highlight_to_edit = False
            for qcrw, quuid in Object.entries(annot_id_uuid_map):
                if quuid is data.uuid and select_crw(qcrw):
                    found_highlight_to_edit = True
                    break
            if found_highlight_to_edit:
                self.ensure_selection_visible()
                window.setTimeout(def ():
                    self.send_message('annotations', type='edit-highlight')
                , 50)
            else:
                self.send_message('annotations', type='edit-highlight-failed', uuid=data.uuid)
        elif dtype is 'notes-edited':
            cls = 'crw-has-dot'
            for qcrw, quuid in Object.entries(annot_id_uuid_map):
                if quuid is data.uuid:
                    node = last_span_for_crw(qcrw)
                    if node:
                        if data.has_notes:
                            node.classList.add(cls)
                        else:
                            node.classList.remove(cls)
        elif dtype is 'remove-highlight':
            found_highlight_to_remove = False
            for qcrw, quuid in Object.entries(annot_id_uuid_map):
                if quuid is data.uuid:
                    found_highlight_to_remove = True
                    unwrap_crw(qcrw)
                    v'delete annot_id_uuid_map[qcrw]'
            if found_highlight_to_remove:
                # have to remove selection otherwise selection bar does
                # not hide itself on multiline selections
                window.getSelection().removeAllRanges()
        elif dtype is 'apply-highlight':
            existing = all_annots_in_selection(window.getSelection(), annot_id_uuid_map)
            if existing.length is 0 or (existing.length is 1 and existing[0] is data.existing):
                self.apply_highlight(data.uuid, data.existing, data.has_notes, data.style)
            else:
                self.send_message(
                    'annotations', type='highlight-overlapped',
                    uuid=data.uuid, existing=data.existing, has_notes=data.has_notes, style=data.style)
        elif dtype is 'apply-highlight-overwrite':
            self.apply_highlight(data.uuid, data.existing, data.has_notes, data.style)
        elif dtype is 'cite-current-selection':
            sel = window.getSelection()
            if not sel.rangeCount:
                return
            bounds = cfi_for_selection()
            text = sel.toString()
            self.send_message('annotations', type='cite-data', bounds=bounds, highlighted_text=text)
        else:
            console.log('Ignoring annotations message to iframe with unknown type: ' + dtype)

    def apply_highlights_on_load(self, highlights):
        clear_annot_id_uuid_map()
        reset_highlight_counter()
        strcmp = v'new Intl.Collator().compare'
        highlights.sort(def (a, b): return strcmp(a.timestamp, b.timestamp);)
        for h in highlights:
            r = range_from_cfi(h.start_cfi, h.end_cfi)
            if not r:
                continue
            style = highlight_style_as_css(h.style, opts.is_dark_theme, opts.color_scheme.foreground)
            cls = 'crw-has-dot' if h.notes else None
            annot_id, intersecting_wrappers = wrap_text_in_range(style, r, cls, self.add_highlight_listeners)
            if annot_id is not None:
                annot_id_uuid_map[annot_id] = h.uuid
                for crw in intersecting_wrappers:
                    unwrap_crw(crw)
                    v'delete annot_id_uuid_map[crw]'

    def replace_highlights(self, data):
        highlights = data.highlights
        unwrap_all_crw()
        self.apply_highlights_on_load(highlights or [])

    def add_highlight_listeners(self, wrapper):
        wrapper.addEventListener('dblclick', self.highlight_wrapper_dblclicked)

    def highlight_wrapper_dblclicked(self, ev):
        crw = ev.currentTarget.dataset.calibreRangeWrapper
        if crw:
            ev.preventDefault(), ev.stopPropagation()
            select_crw(crw)

    def copy_selection(self):
        try:
            if document.execCommand('copy'):
                return
        except:
            pass

        s = window.getSelection()
        text = s.toString()
        if text:
            container = document.createElement('div')
            for i in range(s.rangeCount):
                container.appendChild(s.getRangeAt(i).cloneContents())
            self.send_message('copy_text_to_clipboard', text=text, html=container.innerHTML)

    def tts_msg_received(self, data):
        if data.type is 'mark':
            self.mark_word_being_spoken(data.num)
        elif data.type is 'play':
            text_node, offset = None, 0
            if data.pos:
                r = word_at_point(data.pos.x, data.pos.y)
                if not r:
                    return
            else:
                r = first_visible_word()
            if r and r.startContainer?.nodeType is Node.TEXT_NODE:
                text_node, offset = r.startContainer, r.startOffset
            marked_text = tts_data(text_node, offset)
            sel = window.getSelection()
            sel.removeAllRanges()
            self.send_message('tts', type='text-extracted', marked_text=marked_text, pos=data.pos)

    def mark_word_being_spoken(self, x):
        if jstype(x) is 'number':
            x = {'first': x, 'last': x}
        self.last_search_at = window.performance.now()
        if select_tts_mark(x.first, x.last):
            self.ensure_selection_boundary_visible()

    def audio_ebook_msg_received(self, data):
        if data.type is 'mark':
            if data.anchor:
                self.last_search_at = window.performance.now()
                if mark_smil_element(data.anchor):
                    self.ensure_selection_boundary_visible()
                    self.send_message('audio_ebook_message', type='marked', anchor=data.anchor, idx=data.idx)
                else:
                    self.send_message('audio_ebook_message', type='marked')
            else:
                window.getSelection().removeAllRanges()
                self.send_message('audio_ebook_message', type='marked')
        elif data.type is 'play':
            if data.anchor:
                pos = self.smil_anchor_map[data.anchor]
                if pos?:
                    par = self.smil_par_list[pos]
                else:
                    par = None
            else:
                par = smil_element_at(data.pos, self.smil_anchor_map, self.smil_par_list)
            self.send_message('audio_ebook_message', type='start_play_at', par=par or None, anchor=data.anchor or None, pos=data.pos or None)
        else:
            console.error(f'Unknown audio ebook message type from main: {data.type}')

    def hints_msg_received(self, data):
        if data.type is 'show':
            # clear selection so that it does not confuse with the hints which use the same colors
            window.getSelection().removeAllRanges()
            hints_map = hint_visible_links()
            self.send_message('hints', type='shown', hints_map=hints_map)
        elif data.type is 'hide':
            unhint_links()
        elif data.type is 'activate':
            hint = data.hint
            if hint.type is 'link':
                a = document.body.querySelector(f'[data-calibre-hint-value="{hint.value}"]')
                a.removeEventListener('animationend', self.hint_animation_ended)
                a.addEventListener('animationend', self.hint_animation_ended, False)
                a.classList.add('calibre-animated-hint')
        elif data.type is 'apply_prefix':
            apply_prefix_to_hints(data.prefix)

    def hint_animation_ended(self, ev):
        a = ev.currentTarget
        a.classList.remove('calibre-animated-hint')
        a.click()


@no_prune
def main():
    if not main.boss:
        main.boss = IframeBoss()
