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

from read_book.viewport import scroll_viewport, rem_size

# CFI escaping {{{
escape_pat = /[\[\],^();~@!]/g
unescape_pat = /[\^](.)/g


def escape_for_cfi(raw):
    return (raw or '').replace(escape_pat, '^$&')


def unescape_from_cfi(raw):
    return (raw or '').replace(unescape_pat, '$1')
# }}}


def fstr(d):  # {{{
    # Convert a timestamp floating point number to a string
    ans = ''
    if d < 0:
        ans = '-'
        d = -d
    n = Math.floor(d)
    ans += n
    n = Math.round((d - n) * 100)
    if n is not 0:
        ans += '.' + (n / 10 if (n % 10 is 0) else n)
    return ans
# }}}


def get_current_time(target):  # {{{
    return fstr(target.currentTime or 0)
# }}}


def id_is_unique(idval):  # {{{
    try:
        multiples = document.querySelectorAll('[id=' + window.CSS.escape(idval) + ']')
    except:
        return False
    if multiples and multiples.length < 2:
        return True
    return False
# }}}


# Convert point to character offset {{{
def range_has_point(range_, x, y):
    rects = range_.getClientRects()
    for v'var i = 0; i < rects.length; i++':
        rect = rects[i]
        if (rect.left <= x <= rect.right) and (rect.top <= y <= rect.bottom):
            return True
    return False


def offset_in_text_node(node, range_, x, y):
    limits = [0, node.nodeValue.length]
    while limits[0] is not limits[1]:
        pivot = Math.floor((limits[0] + limits[1]) / 2)
        lr = [limits[0], pivot]
        rr = [pivot + 1, limits[1]]
        range_.setStart(node, pivot)
        range_.setEnd(node, pivot + 1)
        if range_has_point(range_, x, y):
            return pivot
        range_.setStart(node, rr[0])
        range_.setEnd(node, rr[1])
        if range_has_point(range_, x, y):
            limits = rr
            continue
        range_.setStart(node, lr[0])
        range_.setEnd(node, lr[1])
        if range_has_point(range_, x, y):
            limits = lr
            continue
        break
    return limits[0]


def find_offset_for_point(x, y, node, cdoc):
    range_ = cdoc.createRange()
    child = node.firstChild
    while child:
        if Node.TEXT_NODE <= child.nodeType <= Node.ENTITY_NODE and child.nodeValue and child.nodeValue.length:
            range_.setStart(child, 0)
            range_.setEnd(child, child.nodeValue.length)
            if range_has_point(range_, x, y):
                return [child, offset_in_text_node(child, range_, x, y)]
        child = child.nextSibling

    # The point must be after the last bit of text/in the padding/border, we don't know
    # how to get a good point in this case
    return None, None

# }}}


def set_current_time(target, val):  # {{{
    if target.currentTime is undefined:
        return
    if target.readyState is 4 or target.readyState is 'complete':
        target.currentTime = val + 0
    else:
        target.addEventListener('canplay', def (): target.currentTime=val;, False)
# }}}

# def encode(doc, node, offset, tail): {{{


def is_text_node(node):
    return node.nodeType is Node.TEXT_NODE or node.nodeType is Node.CDATA_SECTION_NODE


def text_length_in_range_wrapper(node):
    p = node.firstChild
    ans = 0
    while p:
        if is_text_node(p):
            ans += p.nodeValue.length
        elif p.nodeType is Node.ELEMENT_NODE and p.dataset?.calibreRangeWrapper:
            ans += text_length_in_range_wrapper(p)
        p = p.nextSibling
    return ans


def adjust_node_for_text_offset(node):
    if node.parentNode and node.parentNode.dataset?.calibreRangeWrapper:
        node = node.parentNode
    offset = 0
    adjusted = False
    while True:
        p = node.previousSibling
        if not p or p.nodeType > Node.COMMENT_NODE:
            break
        if is_text_node(p):
            offset += p.nodeValue.length
        elif p.nodeType is Node.ELEMENT_NODE and p.dataset?.calibreRangeWrapper:
            offset += text_length_in_range_wrapper(p)
        node = p
        adjusted = True
    if adjusted and node.nodeType is Node.ELEMENT_NODE and node.dataset?.calibreRangeWrapper:
        if not node.firstChild or node.firstChild.nodeType is not Node.TEXT_NODE:
            node.insertBefore(document.createTextNode(''), node.firstChild or None)
        node = node.firstChild
    return node, offset, adjusted


def unwrapped_nodes(range_wrapper):
    ans = []
    for child in range_wrapper.childNodes:
        if child.nodeType is Node.ELEMENT_NODE and child.dataset?.calibreRangeWrapper:
            ans = ans.concat(unwrapped_nodes(child))
        else:
            ans.push(child)
    return ans


def increment_index_for_child(child, index, sentinel):
    is_element = child.nodeType is Node.ELEMENT_NODE
    if is_element and child.dataset?.calibreRangeWrapper:
        nodes = unwrapped_nodes(child)
        index = increment_index_for_children(nodes, index, sentinel)
    else:
        index |= 1  # increment if even
        if is_element:
            index += 1
    return index


def increment_index_for_children(children, index, sentinel):
    for i in range(children.length):
        child = children[i]
        index = increment_index_for_child(child, index, sentinel)
        if child is sentinel:
            break
    return index


def non_range_wrapper_parent(node):
    p = node.parentNode
    child = node
    while p.dataset?.calibreRangeWrapper:
        child = p
        p = p.parentNode
    return p, child


def encode(doc, node, offset, tail):
    cfi = tail or ''
    # Handle the offset, if any
    if node.nodeType is Node.ELEMENT_NODE:
        if jstype(offset) is 'number':
            q = node.childNodes.item(offset)
            if q:
                if q.nodeType is Node.ELEMENT_NODE:
                    node = q
                    if node.dataset?.calibreRangeWrapper:
                        if not node.firstChild:
                            node.appendChild(document.createTextNode(''))
                        node = node.firstChild
        elif node.dataset?.calibreRangeWrapper:
            if not node.firstChild:
                node.appendChild(document.createTextNode(''))
            node = node.firstChild
    if is_text_node(node):
        offset = offset or 0
        adjusted_node, additional_offset, adjusted = adjust_node_for_text_offset(node)
        if adjusted:
            node = adjusted_node
            offset += additional_offset
        cfi = ':' + offset + cfi
    elif node.nodeType is not Node.ELEMENT_NODE:  # Not handled
        print(f"Offsets for nodes of type {node.nodeType} are not handled")

    # Construct the path to node from root
    is_first = True
    while node is not doc:
        p, sentinel = non_range_wrapper_parent(node)
        if not p:
            if node.nodeType == Node.DOCUMENT_NODE:  # Document node (iframe)
                win = node.defaultView
                if win.frameElement:
                    node = win.frameElement
                    cfi = '!' + cfi
                    continue
            break

        # Find position of node in parent
        index = increment_index_for_children(p.childNodes, 0, sentinel)
        if is_first:
            is_first = False
            if node.nodeType is Node.ELEMENT_NODE and node.dataset?.calibreRangeWrapper:
                index -= 1
        # Add id assertions for robustness where possible
        id = node.id
        idspec = ''
        if id and id_is_unique(id):
            idspec = ('[' + escape_for_cfi(id) + ']')
        cfi = '/' + index + idspec + cfi
        node = p

    return cfi
# }}}

# def decode(cfi, doc):  {{{


def node_at_index(nodes, target, index, iter_text_nodes):
    for i in range(nodes.length):
        node = nodes[i]
        is_element = node.nodeType is Node.ELEMENT_NODE
        if is_element and node.dataset?.calibreRangeWrapper:
            q, index = node_at_index(unwrapped_nodes(node), target, index, iter_text_nodes)
            if q:
                return q, index
            continue
        if (iter_text_nodes and not is_text_node(node)) or (not iter_text_nodes and not is_element):
            continue
        if index is target:
            return node, index
        index += 1
    return None, index


def node_for_path_step(parent, target, assertion):
    if assertion:
        q = document.getElementById(assertion)
        if q and id_is_unique(assertion):
            return q
    is_element = target % 2 == 0
    target //= 2
    if is_element and target > 0:
        target -= 1
    return node_at_index(parent.childNodes, target, 0, not is_element)[0]


def node_for_text_offset(nodes, offset, first_node):
    last_text_node = None
    seen_first = False
    for i in range(nodes.length):
        node = nodes[i]
        if not seen_first:
            if not first_node or node.isSameNode(first_node):
                seen_first = True
            else:
                continue
        if is_text_node(node):
            l = node.nodeValue.length
            if offset <= l:
                return node, offset, True
            last_text_node = node
            offset -= l
        # mathml nodes don't have dataset
        elif node.nodeType is Node.ELEMENT_NODE and node.dataset?.calibreRangeWrapper:
            qn, offset, ok = node_for_text_offset(unwrapped_nodes(node), offset)
            if ok:
                return qn, offset, True
    return last_text_node, offset, False


# Based on a CFI string, returns a decoded CFI, with members:
#
#  node: The node to which the CFI refers.
#  time: If the CFI refers to a video or sound, this is the time within such to which it refers.
#  x, y: If the CFI defines a spatial offset (technically only valid for images and videos),
#        these are the X and Y percentages from the top-left of the image or video.
#        Note that Calibre has a fallback to set CFIs with spatial offset on the HTML document,
#        and interprets them as a position within the Calibre-rendered document.
#  forward: This is set to True if the CFI had a side bias of 'a' (meaning 'after').
#  offset: When the CFI refers to a text node, this is the offset (zero-based index) of the character
#          the CFI refers to. The position is defined as being before the specified character,
#          i.e. an offset of 0 is before the first character and an offset equal to number of characters
#          in the text is after the last character.
#  error: If there was a problem decoding the CFI, this is set to a string describing the error.
def decode(cfi, doc):
    doc = doc or window.document
    simple_node_regex = ///
        ^/(\d+)          # The node count
          (\[[^\]]*\])?  # The optional id assertion
    ///
    error = None
    node = doc

    orig_cfi = cfi
    while cfi.length > 0 and not error:
        r = cfi.match(simple_node_regex)
        if r:  # Path step
            target = parseInt(r[1])
            assertion = r[2]
            if assertion:
                assertion = unescape_from_cfi(assertion.slice(1, assertion.length - 1))
            q = node_for_path_step(node, target, assertion)
            if q:
                node = q
                cfi = cfi.substr(r[0].length)
            else:
                if target:
                    error = f"No matching child found for CFI: {orig_cfi} leftover: {cfi}"
                    break
                else:
                    cfi = cfi.substr(r[0].length)
        else if cfi[0] is '!':  # Indirection
            if node.contentDocument:
                node = node.contentDocument
                cfi = cfi.substr(1)
            else:
                error = 'Cannot reference ' + node.nodeName + "'s content: " + cfi
        else:
            break

    if error:
        print(error)
        return None

    decoded = {}
    error = None
    offset = None

    r = cfi.match(/^:(\d+)/)
    if r:
        # Character offset
        offset = parseInt(r[1])
        cfi = cfi.substr(r[0].length)

    r = cfi.match(/^~(-?\d+(\.\d+)?)/)
    if r:
        # Temporal offset
        decoded.time = r[1] - 0  # Coerce to number
        cfi = cfi.substr(r[0].length)

    r = cfi.match(/^@(-?\d+(\.\d+)?):(-?\d+(\.\d+)?)/)
    if r:
        # Spatial offset
        decoded.x = r[1] - 0  # Coerce to number
        decoded.y = r[3] - 0  # Coerce to number
        cfi = cfi.substr(r[0].length)

    r = cfi.match(/^\[([^\]]+)\]/)
    if r:
        assertion = r[1]
        cfi = cfi.substr(r[0].length)
        r = assertion.match(/;s=([ab])$/)
        if r:
            if r.index > 0 and assertion[r.index - 1] is not '^':
                assertion = assertion.substr(0, r.index)
                decoded.forward = (r[1] is 'a')
            assertion = unescape_from_cfi(assertion)
    # TODO: Handle text assertion

    # Find the text node that contains the offset
    if offset is not None:
        orig_offset = offset
        if node.parentNode?.nodeType is Node.ELEMENT_NODE and node.parentNode.dataset?.calibreRangeWrapper:
            node = node.parentNode
        qnode, offset, ok = node_for_text_offset(node.parentNode.childNodes, offset, node)
        if not ok:
            error = 'Offset out of range: ' + orig_offset
        if qnode:
            node = qnode
        decoded.offset = offset

    decoded.node = node
    if error:
        decoded.error = error
    else if cfi.length > 0:
        decoded.error = 'Undecoded CFI: ' + cfi

    if decoded.error:
        print(decoded.error)

    return decoded
# }}}


def cfi_sort_key(cfi):  # {{{
    simple_node_regex = ///
        ^/(\d+)          # The node count
          (\[[^\]]*\])?  # The optional id assertion
    ///
    steps = []

    while cfi.length > 0:
        r = cfi.match(simple_node_regex)
        if r:  # Path step
            target = parseInt(r[1])
            cfi = cfi.substr(r[0].length)
            steps.push(target)
        else if cfi[0] is '!':  # Indirection
            cfi = cfi.substr(1)
        else:
            break

    ans = {'steps': steps, 'text_offset': 0, 'temporal_offset': 0, 'spatial_offset': [0, 0]}

    r = cfi.match(/^:(\d+)/)
    if r:
        # Character offset
        offset = parseInt(r[1])
        ans['text_offset'] = offset
        cfi = cfi.substr(r[0].length)

    r = cfi.match(/^~(-?\d+(\.\d+)?)/)
    if r:
        # Temporal offset
        ans['temporal_offset'] = r[1] - 0  # Coerce to number
        cfi = cfi.substr(r[0].length)

    r = cfi.match(/^@(-?\d+(\.\d+)?):(-?\d+(\.\d+)?)/)
    if r:
        # Spatial offset
        ans['spatial_offset'] = [r[1] - 0, r[3] - 0]
        cfi = cfi.substr(r[0].length)
    return ans


def create_cfi_cmp(key_map):
    if not key_map:
        key_map = {}

    def cfi_cmp(a_cfi, b_cfi):
        a = key_map[a_cfi]
        if not a:
            a = key_map[a_cfi] = cfi_sort_key(a_cfi)
        b = key_map[b_cfi]
        if not b:
            b = key_map[b_cfi] = cfi_sort_key(b_cfi)
        for i in range(min(a.steps.length, b.steps.length)):
            diff = a.steps[i] - b.steps[i]
            if diff is not 0:
                return diff
        diff = a.steps.length - b.steps.length
        if diff is not 0:
            return diff

        if a.temporal_offset is not b.temporal_offset:
            return a.temporal_offset - b.temporal_offset

        if a.spatial_offset[0] is not b.spatial_offset[0]:
            return a.spatial_offset[0] - b.spatial_offset[0]
        if a.spatial_offset[1] is not b.spatial_offset[1]:
            return a.spatial_offset[1] - b.spatial_offset[1]

        if a.text_offset is not b.text_offset:
            return a.text_offset - b.text_offset

        return 0

    return cfi_cmp


def sort_cfis(array_of_cfis):
    key_map = {cfi: cfi_sort_key(cfi) for cfi in array_of_cfis}
    Array.prototype.sort.call(array_of_cfis, create_cfi_cmp(key_map))
# }}}


def at(x, y, doc):  # {{{
    # x, y are in viewport coordinates
    doc = doc or window.document
    cdoc = doc
    target = None
    tail = ''
    offset = None
    name = None

    # Drill down into iframes, etc.
    while True:
        target = cdoc.elementFromPoint(x, y)
        if not target or target is cdoc.documentElement or target is cdoc.body:
            # We ignore both html and body even though body could
            # have text nodes under it as performance is very poor if body
            # has large margins/padding (e.g. in fullscreen mode)
            # A possible solution for this is to wrap all text node
            # children of body in <span> but that is seriously ugly and
            # might have side effects. Lets do this only if there are lots of
            # books in the wild that actually have text children of body,
            # and even in this case it might be better to change the input
            # plugin to prevent this from happening.
            # log("No element at (#{ x }, #{ y })")
            return None

        name = target.localName
        if name not in {'iframe', 'embed', 'object'}:
            break

        cd = target.contentDocument
        if not cd:
            break

        # We have an embedded document, transforms x, y into the co-prd
        # system of the embedded document's viewport
        rect = target.getBoundingClientRect()
        x -= rect.left
        y -= rect.top
        cdoc = cd

    (target.parentNode or target).normalize()

    if name in {'audio', 'video'}:
        tail = '~' + get_current_time(target)

    if name in {'img', 'video'}:
        rect = target.getBoundingClientRect()
        px = ((x - rect.left) * 100) / target.offsetWidth
        py = ((y - rect.top) * 100) / target.offsetHeight
        tail = str.format('{}@{}:{}', tail, fstr(px), fstr(py))
    else if name is not 'audio':
        # Get the text offset
        # We use a custom function instead of caretRangeFromPoint as
        # caretRangeFromPoint does weird things when the point falls in the
        # padding of the element
        target, offset = find_offset_for_point(x, y, target, cdoc)
        if target is None:
            return None

    return encode(doc, target, offset, tail)
# }}}


# Wrapper for decode(), tries to construct a range from the CFI's
# character offset and include it in the return value.
#
# If the CFI defines a character offset, there are three cases:
# Case 1. If the offset is 0 and the text node's length is zero,
#         the range is set to None. This is a failure case, but
#         later code will try to use the node's bounding box.
# Case 2. Otherwise, if the offset is >= the length of the range,
#         then a range from the previous to the last character is created,
#         and use_range_end_pos is set. This is the special case.
# Case 3. Otherwise, the range is set start at the offset and end at one character past the offset.
#
# In cases 2 and 3, the range is then checked to verify that bounding information can be obtained.
# If not, no range is returned.
#
# If the CFI does not define a character offset, then the spatial offset is set in the return value.
#
# Includes everything that the decode() function does, in addition to:
# range: A text range, as described above.
# use_range_end_pos: If this is True, a position calculated from the range should
#                    use the position after the last character in the range.
#                    (This is set if the offset is equal to the length of the text in the node.)
def decode_with_range(cfi, doc):  # {{{
    doc = doc or window.document
    decoded = decode(cfi, doc)
    if not decoded or not decoded.node:
        return None
    node = decoded.node
    ndoc = node.ownerDocument
    if not ndoc:
        print(str.format('CFI node has no owner document: {} {}', cfi, node))
        return None

    range_ = None
    position_at_end_of_range = None

    if jstype(decoded.offset) is 'number':
        # We can only create a meaningful range if the node length is
        # positive and nonzero.
        node_len = node.nodeValue.length if node.nodeValue else 0
        if not node_len and node.nextSibling?.dataset?.calibreRangeWrapper and node.nextSibling.firstChild?.nodeType is Node.TEXT_NODE:
            node = node.nextSibling.firstChild
            node_len = node.nodeValue.length if node.nodeValue else 0
        if node_len:
            range_ = ndoc.createRange()

            # Check for special case: End of range offset, after the last character
            offset = decoded.offset
            position_at_end_of_range = False
            if offset >= node_len:
                offset = node_len - 1
                position_at_end_of_range = True

            range_.setStart(node, offset)
            range_.setEnd(node, offset + 1)
            rect = range_.getBoundingClientRect()

            if not rect:
                print(str.format('Could not find caret position for {} (offset: {})', cfi, decoded.offset))
                range_ = None
        else:
            print(f'Caret offset pointed to an empty text node or a non-text node for {cfi}, (offset: {decoded.offset})')
            range_ = None

    # Augment decoded with range, if found
    decoded.range = range_
    decoded.use_range_end_pos = position_at_end_of_range

    return decoded

# }}}


# It's only valid to call this if you have a decoded CFI with a range included.
# Call decoded_to_document_position if you're not sure.
def decoded_range_to_document_position(decoded):
    # Character offset
    # Get the bounding rect of the range, in (real) viewport space
    rect = decoded.range.getBoundingClientRect()
    inserted_node = None
    if not rect.width and not rect.height and not rect.left and not rect.right:
        # this happens is range is a text node containing a newline after a
        # <br> https://bugs.launchpad.net/bugs/1944433
        inserted_node = document.createTextNode('\xa0')
        decoded.range.insertNode(inserted_node)
        rect = decoded.range.getBoundingClientRect()

    # Now, get the viewport-space position (vs_pos) we want to scroll to
    # This is a little complicated.
    # The box we are using is a character's bounding box.
    # First, the inline direction.
    # We want to use the beginning of the box per the ePub CFI spec,
    # which states character offset CFIs refer to the beginning of the
    # character, which would be in the inline direction.
    #  Unless the flag is set to use the end of the range:
    #  (because ranges indices may not exceed the range,
    #   but CFI offsets are allowed to be one past the end.)
    if decoded.use_range_end_pos:
        inline_vs_pos = scroll_viewport.rect_inline_end(rect)
    else:
        inline_vs_pos = scroll_viewport.rect_inline_start(rect)

    # Next, the block direction.
    # If the CFI specifies a side bias, we should respect that.
    # Otherwise, we want to use the block start.
    if decoded.forward:
        block_vs_pos = scroll_viewport.rect_block_end(rect)
    else:
        block_vs_pos = scroll_viewport.rect_block_start(rect)

    if inserted_node:
        inserted_node.parentNode.removeChild(inserted_node)
    # Now, we need to convert these to document X and Y coordinates.
    return scroll_viewport.viewport_to_document_inline_block(inline_vs_pos, block_vs_pos, decoded.node.ownerDocument)


# This will work on a decoded CFI that refers to a node or node with spatial offset.
# It will ignore any ranges, so call decoded_to_document_position unless you're sure the decoded CFI has no range.
def decoded_node_or_spatial_offset_to_document_position(decoded):
    node = decoded.node
    rect = node.getBoundingClientRect()
    percentx, percenty = decoded.x, decoded.y
    # If we have a spatial offset, base the CFI position on the offset into the object
    if jstype(percentx) is 'number' and node.offsetWidth and jstype(percenty) is 'number' and node.offsetHeight:
        viewx = rect.left + (percentx * node.offsetWidth) / 100
        viewy = rect.top + (percenty * node.offsetHeight) / 100
        doc_left, doc_top = scroll_viewport.viewport_to_document(viewx, viewy, node.ownerDocument)
        return doc_left, doc_top
    # Otherwise, base it on the inline and block start and end, along with side bias:
    else:
        if decoded.forward:
            block_vs_pos = scroll_viewport.rect_block_end(rect)
        else:
            block_vs_pos = scroll_viewport.rect_block_start(rect)

        inline_vs_pos = scroll_viewport.rect_inline_start(rect)
        return scroll_viewport.viewport_to_document_inline_block(inline_vs_pos, block_vs_pos, node.ownerDocument)


# This will take a decoded CFI and return a document position.
#
def decoded_to_document_position(decoded):
    node = decoded.node
    if decoded.range is not None:
        return decoded_range_to_document_position(decoded)
    else if node is not None and node.getBoundingClientRect:
        return decoded_node_or_spatial_offset_to_document_position(decoded)
    # No range, so we can't use that, and no node, so any spatial offset is meaningless
    return None, None


def scroll_to(cfi, callback, doc):  # {{{
    decoded = decode_with_range(cfi, doc)
    if not decoded:
        print('No location information found for cfi: ' + cfi)
        return
    if jstype(decoded.time) is 'number':
        set_current_time(decoded.node, decoded.time)

    def handle_text_node():
        # Character offset
        r = decoded.range
        so, eo = r.startOffset, r.endOffset
        original_node = r.startContainer

        # Save the node index and its parent so we can get the node back
        # after removing the span and normalizing the parent.
        node_parent = original_node.parentNode
        original_node_index = 0
        for v'var i = 0; i < node_parent.childNodes.length; i++':
            if node_parent.childNodes[i].isSameNode(original_node):
                original_node_index = i
                break

        ndoc = original_node.ownerDocument or document
        span = ndoc.createElement('span')
        span.setAttribute('style', 'border-width: 0; padding: 0; margin: 0')
        r.surroundContents(span)
        scroll_viewport.scroll_into_view(span)
        return def ():
            nonlocal original_node, r
            # Remove the span and get the new position now that scrolling
            # has (hopefully) completed
            p = span.parentNode
            for node in span.childNodes:
                span.removeChild(node)
                p.insertBefore(node, span)
            p.removeChild(span)
            p.normalize()
            original_node = node_parent.childNodes[original_node_index]
            if not original_node:
                # something bad happened, give up
                return

            # Reset the range to what it was before the span was added
            offset = so
            while offset > -1:
                try:
                    r.setStart(original_node, offset)
                    break
                except:
                    offset -= 1

            offset = eo
            while offset > -1:
                try:
                    r.setEnd(original_node, offset)
                    break
                except:
                    offset -= 1

            doc_x, doc_y = decoded_range_to_document_position(decoded)

            # Abort if CFI position is invalid
            if doc_x is None or doc_y is None:
                return

            # Since this position will be used for the upper-left of the viewport,
            # in RTL mode, we need to offset it by the viewport width so the
            # position will be on the right side of the viewport,
            # from where we start reading.
            # (Note that adding moves left in RTL mode because of the viewport X
            #  coordinate reversal in RTL mode.)
            if scroll_viewport.rtl:
                doc_x += scroll_viewport.width()

            if callback:
                callback(doc_x, doc_y)

    def handle_element_node():
        node = decoded.node
        if node.nodeType is Node.TEXT_NODE:
            node = decoded.node = node.parentNode
        if not node.scrollIntoView:
            return
        scroll_viewport.scroll_into_view(node)

        return def ():
            doc_x, doc_y = decoded_node_or_spatial_offset_to_document_position(decoded)

            # Abort if CFI position is invalid
            if doc_x is None or doc_y is None:
                return

            # Since this position will be used for the upper-left of the viewport,
            # in RTL mode, we need to offset it by the viewport width so the
            # position will be on the right side of the viewport,
            # from where we start reading.
            # (Note that adding moves left in RTL mode because of the viewport X
            #  coordinate reversal in RTL mode.)
            if scroll_viewport.rtl:
                doc_x += scroll_viewport.width()
            if callback:
                callback(doc_x, doc_y)

    if decoded.range is not None:
        fn = handle_text_node()
    else:
        fn = handle_element_node()

    setTimeout(fn, 10)

# }}}


def at_point(x, y):  # {{{
    # The CFI at the specified point. Different to at() in that this method
    # returns null if there is an error, and also calculates a point from
    # the CFI and returns null if the calculated point is far from the
    # original point.

    def dist(p1, p2):
        Math.sqrt(Math.pow(p1[0] - p2[0], 2), Math.pow(p1[1] - p2[1], 2))

    try:
        cfi = at(x, y)
    except:
        cfi = None
    if cfi is None:
        return None

    try:
        decoded = decode_with_range(cfi)
    except:
        decoded = None
    if not decoded:
        return None

    if cfi:
        try:
            cfix, cfiy = decoded_to_document_position(decoded)
        except:
            cfix = cfiy = None
        if cfix is None or cfiy is None or dist(scroll_viewport.viewport_to_document(x, y), [cfix, cfiy]) > 50:
            cfi = None

    return cfi
# }}}


def at_current():  # {{{
    # We subtract one because the actual position query for CFI elements is relative to the
    # viewport, and the viewport coordinates actually go from 0 to the size - 1.
    # If we don't do this, the first line of queries will always fail in those cases where we
    # start at the right of the viewport because they'll be outside the viewport
    wini, winb = scroll_viewport.inline_size() - 1, scroll_viewport.block_size() - 1

    # Don't let the deltas go too small or too large, to prevent perverse cases where
    # we don't loop at all, loop too many times, or skip elements because we're
    # looping too fast.
    deltai = deltab = max(8, min(Math.ceil(rem_size() / 2), 32))

    # i.e. English, Hebrew:
    if scroll_viewport.horizontal_writing_mode:
        # Horizontal languages always start at the top
        startb = 0
        endb = winb
        if scroll_viewport.ltr:
            # i.e. English: we scan from the left margin to the right margin
            starti = 0
            endi = wini
        else:
            # i.e. Hebrew: we scan from the right margin to the left margin
            starti = wini
            endi = 0
            deltai = -deltai
    # i.e. Japanese, Mongolian script, traditional Chinese
    else:
        # These languages are only top-to-bottom
        starti = 0
        endi = winb
        # i.e. Japanese: To the next line is from the right margin to the left margin
        if scroll_viewport.rtl:
            startb = winb
            endb = 0
            deltab = -deltab
        # i.e. Mongolian: To the next line is from the left margin to the right margin
        else:
            startb = 0
            endb = winb

    # print(f'{starti} {deltai} {endi} {startb} {deltab} {endb}')
    # Find the bounds
    up_boundb = max(startb, endb)
    up_boundi = max(starti, endi)
    low_boundb = min(startb, endb)
    low_boundi = min(starti, endi)

    # In horizontal mode, X is the inline and Y is the block,
    # but it's reversed for vertical modes, so reverse the lookup.
    def at_point_vertical_mode(i, b):
        return at_point(b, i)

    at_point_conditional = at_point_vertical_mode if scroll_viewport.vertical_writing_mode else at_point

    def i_loop(curb):
        curi = starti
        # The value will be either heading toward the lower or the upper bound,
        # so just check for exceeding either bound
        while low_boundi <= curi <= up_boundi:
            cfi = at_point_conditional(curi, curb)
            if cfi:
                # print(f'found CFI at {curi}, {curb} {cfi}\n\t{starti} {deltai} {endi} {startb} {deltab} {endb}')
                return cfi
            curi += deltai

    curb = startb
    cfi = None
    while low_boundb <= curb <= up_boundb and not cfi:
        cfi = i_loop(curb)
        curb += deltab

    if cfi:
        return cfi

    # Use a spatial offset on the html element, since we could not find a
    # normal CFI
    x, y = scroll_viewport.x() + (0 if scroll_viewport.ltr else scroll_viewport.width()), scroll_viewport.y()
    de = document.documentElement
    rect = de.getBoundingClientRect()
    px = (x * 100) / rect.width
    py = (y * 100) / rect.height
    cfi = str.format('/2@{}:{}', fstr(px), fstr(py))

    return cfi

# }}}


def cfi_for_selection(r):  # {{{
    if not r:
        r = window.getSelection().getRangeAt(0)

    def pos(container, offset):
        nt = container.nodeType
        if nt is Node.TEXT_NODE or nt is Node.CDATA_SECTION_NODE or nt is Node.COMMENT_NODE:
            return container, offset
        if nt is Node.ELEMENT_NODE and offset and container.childNodes.length > offset:
            return container.childNodes[offset], 0
        return container, offset

    return {
        'start': encode(r.startContainer.ownerDocument, *pos(r.startContainer, r.startOffset)),
        'end': encode(r.endContainer.ownerDocument, *pos(r.endContainer, r.endOffset)),
    }

# }}}


def range_from_cfi(start, end):  # {{{
    start = decode(start)
    end = decode(end)
    if not start or start.error or not end or end.error:
        return
    r = document.createRange()
    r.setStart(start.node, start.offset)
    r.setEnd(end.node, end.offset)
    return r
    # }}}
