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


def is_non_empty_text_node(node):
    return (node.nodeType is Node.TEXT_NODE or node.nodeType is Node.CDATA_SECTION_NODE) and node.nodeValue.length > 0


def is_element_visible(elem):
    s = window.getComputedStyle(elem)
    return s.display is not 'none' and s.visibility is not 'hidden'


def is_node_visible(node):
    if node.nodeType is not Node.ELEMENT_NODE:
        node = node.parentElement
        if not node:
            return False
    current = node
    while current:
        if not is_element_visible(current):
            return False
        current = current.parentElement
    return True


def select_nodes_from_range(r, predicate):
    parent = r.commonAncestorContainer
    doc = parent.ownerDocument or document
    iterator = doc.createNodeIterator(parent)
    in_range = False
    ans = []
    check_for_end = not (r.startContainer.isSameNode(r.endContainer) and r.startContainer.isSameNode(parent))
    while True:
        node = iterator.nextNode()
        if not node:
            break
        if not in_range and node.isSameNode(r.startContainer):
            in_range = True
        if in_range:
            if predicate(node):
                ans.push(node)
            if check_for_end and node.isSameNode(r.endContainer):
                break
    return ans


def select_first_node_from_range(r, predicate):
    parent = r.commonAncestorContainer
    doc = parent.ownerDocument or document
    iterator = doc.createNodeIterator(parent)
    in_range = False
    check_for_end = not (r.startContainer.isSameNode(r.endContainer) and r.startContainer.isSameNode(parent))
    while True:
        node = iterator.nextNode()
        if not node:
            break
        if not in_range and node.isSameNode(r.startContainer):
            in_range = True
        if in_range:
            if predicate(node):
                return node
            if check_for_end and node.isSameNode(r.endContainer):
                break


def text_nodes_in_range(r):
    return select_nodes_from_range(r, is_non_empty_text_node)


def all_annots_in_range(r, annot_id_uuid_map, ans):
    parent = r.commonAncestorContainer
    doc = parent.ownerDocument or document
    iterator = doc.createNodeIterator(parent)
    is_full_tree = parent is doc.documentElement
    in_range = is_full_tree
    check_for_end = not is_full_tree and not (r.startContainer.isSameNode(r.endContainer) and r.startContainer.isSameNode(parent))
    while True:
        node = iterator.nextNode()
        if not node:
            break
        if not in_range and node.isSameNode(r.startContainer):
            in_range = True
        if in_range:
            if node.dataset and node.dataset.calibreRangeWrapper:
                annot_id = annot_id_uuid_map[node.dataset.calibreRangeWrapper]
                if annot_id:
                    if not ans:
                        return annot_id
                    ans[annot_id] = True
            if check_for_end and node.isSameNode(r.endContainer):
                break
    return ans


def first_annot_in_range(r, annot_id_uuid_map):
    return all_annots_in_range(r, annot_id_uuid_map)


def all_annots_in_selection(sel, annot_id_uuid_map):
    ans = v'{}'
    for i in range(sel.rangeCount):
        all_annots_in_range(sel.getRangeAt(i), annot_id_uuid_map, ans)
    return Object.keys(ans)


def remove(node):
    if node.parentNode:
        node.parentNode.removeChild(node)


def replace_node(replacement, node):
    p = node.parentNode
    p.insertBefore(replacement, node)
    remove(node)
    return p


def unwrap(node):
    r = (node.ownerDocument or document).createRange()
    r.selectNodeContents(node)
    p = replace_node(r.extractContents(), node)
    if p:
        p.normalize()


def unwrap_crw(crw):
    for node in document.querySelectorAll(f'span[data-calibre-range-wrapper="{crw}"]'):
        unwrap(node)


def unwrap_all_crw():
    for node in document.querySelectorAll('span[data-calibre-range-wrapper]'):
        unwrap(node)


def select_crw(crw):
    nodes = document.querySelectorAll(f'span[data-calibre-range-wrapper="{crw}"]')
    if nodes and nodes.length:
        r = document.createRange()
        r.setStart(nodes[0].firstChild, 0)
        r.setEnd(nodes[-1].lastChild, nodes[-1].lastChild.nodeValue.length)
        sel = window.getSelection()
        sel.removeAllRanges()
        sel.addRange(r)
        return True
    print(f'range-wrapper: {crw} does not exist')
    return False


def wrap_range(r, wrapper):
    try:
        r.surroundContents(wrapper)
    except:
        wrapper.appendChild(r.extractContents())
        r.insertNode(wrapper)


def create_wrapper_function(wrapper_elem, r, intersecting_wrappers, process_wrapper, all_wrappers):
    start_node = r.startContainer
    end_node = r.endContainer
    start_offset = r.startOffset
    end_offset = r.endOffset

    def wrap_node(node):
        nonlocal start_node, end_node, start_offset, end_offset
        current_range = (node.ownerDocument or document).createRange()
        current_wrapper = wrapper_elem.cloneNode()
        current_range.selectNodeContents(node)
        # adjust start and end in case the current node is one of the
        # boundaries of the original range
        if node.isSameNode(start_node):
            current_range.setStart(node, start_offset)
            start_node = current_wrapper
            start_offset = 0
        if node.isSameNode(end_node):
            current_range.setEnd(node, end_offset)
            end_node = current_wrapper
            end_offset = 1
        if current_range.collapsed:
            # Don't wrap empty ranges. This is needed otherwise two adjacent
            # selections of text will incorrectly be detected as overlapping.
            # For example: highlight abc then def in the word abcdef here the
            # second highlight's first range is the collapsed range at the end
            # of <span wrapper-for-first-highlight>abc<span wrapper-for-2nd></span></span>
            return
        crw = node.parentNode?.dataset?.calibreRangeWrapper
        if crw:
            intersecting_wrappers[crw] = True
        wrap_range(current_range, current_wrapper)
        if process_wrapper:
            process_wrapper(current_wrapper)
        all_wrappers.push(current_wrapper)

    return wrap_node


wrapper_counter = 0


def wrap_text_in_range(styler, r, class_to_add_to_last, process_wrapper):
    if not r:
        sel = window.getSelection()
        if not sel or not sel.rangeCount:
            return None, []
        r = sel.getRangeAt(0)
    if r.isCollapsed:
        return None, []

    wrapper_elem = document.createElement('span')
    wrapper_elem.dataset.calibreRangeWrapper = v'++wrapper_counter' + ''
    if styler:
        styler(wrapper_elem)

    intersecting_wrappers = {}
    all_wrappers = []
    wrap_node = create_wrapper_function(wrapper_elem, r, intersecting_wrappers, process_wrapper, all_wrappers)
    text_nodes_in_range(r).map(wrap_node)
    ancestor = r.commonAncestorContainer
    if ancestor.nodeType is Node.TEXT_NODE:
        ancestor = ancestor.parentNode
    # remove any empty text nodes created by surroundContents() on either
    # side of the wrapper. This happens for instance on Chrome when
    # wrapping all text inside <i>some text</i>
    ancestor.normalize()
    crw = wrapper_elem.dataset.calibreRangeWrapper
    v'delete intersecting_wrappers[crw]'
    if class_to_add_to_last and all_wrappers.length:
        all_wrappers[-1].classList.add(class_to_add_to_last)
    return crw, Object.keys(intersecting_wrappers)


def last_span_for_crw(crw):
    nodes = document.querySelectorAll(f'span[data-calibre-range-wrapper="{crw}"]')
    if nodes and nodes.length:
        return nodes[-1]


def reset_highlight_counter():
    nonlocal wrapper_counter
    wrapper_counter = 0


def get_annot_id_for(node, offset, annot_id_uuid_map):
    if not node:
        return
    if node.nodeType is Node.ELEMENT_NODE:
        if node.dataset?.calibreRangeWrapper:
            return annot_id_uuid_map[node.dataset.calibreRangeWrapper]
        if offset is 0:
            if node.firstChild?.nodeType is Node.ELEMENT_NODE and node.firstChild.dataset?.calibreRangeWrapper:
                return annot_id_uuid_map[node.firstChild.dataset.calibreRangeWrapper]
        elif offset < node.childNodes.length:
            node = node.childNodes[offset]
            return get_annot_id_for(node, 0, annot_id_uuid_map)
    elif node.nodeType is Node.TEXT_NODE:
        if node.parentNode?.nodeType is Node.ELEMENT_NODE and node.parentNode.dataset?.calibreRangeWrapper:
            return annot_id_uuid_map[node.parentNode.dataset.calibreRangeWrapper]


def highlight_associated_with_selection(sel, annot_id_uuid_map):
    # Return the annotation id for a highlight intersecting the selection
    if sel.rangeCount:
        annot_id = get_annot_id_for(sel.focusNode, sel.focusOffset, annot_id_uuid_map) or get_annot_id_for(sel.anchorNode, sel.anchorOffset, annot_id_uuid_map)
        if annot_id:
            return annot_id

        for v'var i = 0; i < sel.rangeCount; i++':
            r = sel.getRangeAt(i)
            annot_id = first_annot_in_range(r, annot_id_uuid_map)
            if annot_id:
                return annot_id
