from PySide6.QtCore import Qt, QUrl
from PySide6.QtWidgets import QTextBrowser
from PySide6.QtGui import QTextCursor, QSyntaxHighlighter, QTextCharFormat

import logging
import re
import base64

from typing import TYPE_CHECKING, Union

from .settings import Settings
from .app_config import AppConfig
from .highlight.view_highlighter import ViewHighlighter
from .lexemes.lexemes import Lexemes

if TYPE_CHECKING:
    from PySide6.QtCore import QObject  # noqa: F401


class ViewProcessor:
    """
    View mode pre-result procession by modifying loaded QTextDocument
    """

    def __init__(self, highlighter: Union[QSyntaxHighlighter, ViewHighlighter]):
        """
        Args:
            highlighter (Union[QSyntaxHighlighter, ViewHighlighter]):
            Highlighter that holds document to apply any modifications.
        """
        self.highlighter = highlighter
        self.doc = self.highlighter.document()

        self.logging = AppConfig().get_logging()
        self.debug = AppConfig().get_debug()

        self.settings = Settings()

        self.logger = logging.getLogger('view_processor')

        # Load lexemes for selected language and scope
        self.lexemes = Lexemes(self.settings.app_language, default_scope='common')

        if self.debug:
            self.logger.info('Characters count %d' % self.doc.characterCount())

        self.blocks = []
        self.blocks_start = []
        self.blocks_end = []

        # self.doc.contentsChanged.connect(self.process)

        cursor = QTextCursor(self.doc)
        self.cursor_pos_orig = cursor.position()

    def restore_cursor_pos(self):
        cursor = QTextCursor(self.doc)
        # Restore original cursor position
        cursor.setPosition(self.cursor_pos_orig, QTextCursor.MoveMode.MoveAnchor)
        parent = self.doc.parent()  # type: QObject
        parent_widget = parent.get_view_widget()  # type: QTextBrowser
        parent_widget.setTextCursor(cursor)

    def process(self):
        self.blocks.clear()
        cursor = QTextCursor(self.doc)
        cursor.setPosition(0)

        def process_block(cursor_internal: QTextCursor) -> None:
            """
            Find open/close tokens in block.
            @param cursor_internal: active cursor
            @return: None
            """
            block = cursor_internal.block()
            pattern = r"<details.*?>"
            # Find all matches of the pattern in provided string
            matches = re.finditer(pattern, block.text())
            for match in matches:
                open_start = match.start()
                open_end = match.end()
                open_length = open_end - open_start
                self.blocks_start.append((block.position() + open_start, open_length))
            pattern = r"</details>"
            # Find all matches of the pattern in provided string
            matches = re.finditer(pattern, block.text())
            for match in matches:
                close_start = match.start()
                close_end = match.end()
                close_length = close_end - close_start
                self.blocks_end.append((block.position() + close_start, close_length))

        cursor.movePosition(QTextCursor.MoveOperation.Start)
        # Iterate through each block
        while not (cursor.atEnd()
                   # cursor.atEnd() not enough as the cursor unable to move next block at the very end
                   or (cursor.block().blockNumber() > 0
                       and cursor.block().blockNumber() == self.doc.blockCount() - 1)):
            # Process block
            process_block(cursor)
            # Move to the next block
            cursor.movePosition(QTextCursor.MoveOperation.NextBlock)

            # To avoid never ending cycle when only one block is available
            if self.doc.blockCount() == 1:
                break

        # Process block at the very end
        process_block(cursor)

        res = []
        self.blocks_start.reverse()
        # Enumerate by end token to find the closest start token
        for index, value in enumerate(self.blocks_end):
            close_start, close_length = value
            if index not in res:
                """
                Extra data allows independent processing of tokens and their content:
                o - open, start position
                ol - open length, start token length, say <details> length is 9
                c - close, close position
                cl - close length, close token length (the same approach as for open length)
                """
                res.insert(index, {'o': None, 'ol': None, 'c': close_start, 'cl': close_length, 'g': None, 'l': 0})
            # Enumerate start token list backward unsetting matched element
            for _index, _value in enumerate(self.blocks_start):
                open_start, open_length = _value
                if _value < value:
                    res[index].update({'o': open_start, 'ol': open_length})
                    del self.blocks_start[_index]
                    break

        if self.debug:
            self.logger.debug('Unsorted open-close mapping %s' % res)

        self.blocks = res

        if self.blocks:
            self.sort_blocks()
            self.collapse_blocks()

        # Restore original cursor position
        self.restore_cursor_pos()

    def sort_blocks(self):
        if self.debug:
            self.logger.debug('Sorting elements by group and nesting level...')

        self.blocks.sort(key=lambda x: x['o'] if 'o' in x and x['o'] is not None else 0)

        if self.debug:
            self.logger.info('Sorted result of the open-close mapping %s' % self.blocks)

        group = 0
        for i, _data in enumerate(self.blocks):
            if not _data['l']:
                # Update group only for each root element
                group = i
                self.blocks[i].update({'g': group})
            # Iterate through remaining elements to find possible nesting
            for j, _other_data in enumerate(self.blocks[i + 1:], start=i + 1):
                if _data['o'] is None or _other_data['o'] is None or _data['c'] is None or _other_data['c'] is None:
                    # TODO show error sign at status bar
                    return
                # Check if other element is nested within the current element
                if _data['o'] < _other_data['o'] and _data['c'] > _other_data['c']:
                    self.blocks[j].update({'l': (_data['l'] + 1 if 'l' in _data else 1), 'g': group})

        """
        Sort elements by:
        1. By group first (nested elements or singles)
        2. Nesting level (more means deeper nesting)
        3. Open token position (DESC order)
        """
        self.blocks.sort(key=lambda x: (-x['g'] if x['g'] is not None else 0,
                                        -x['l'] if x['l'] is not None else 0,
                                        -x['o'] if x['o'] is not None else 0))  # reverse=True

        if self.debug:
            self.logger.info('Final result of the open-close mapping with nesting value %s' % self.blocks)

    def collapse_blocks(self):
        cursor = QTextCursor(self.doc)
        # Keep prev states to check within a loop
        prev_group = None
        prev_level = 0
        replacement_text_len = 0
        replacement_text_lengths = {}
        for pair in self.blocks:
            """
            Blocks being processed in a particular order.
            The sorting applied at the end of the sort_blocks() method.
            """
            pos_group = pair['g']
            pos_level = pair['l']
            # When start processing another group of nested (or single) elements
            if pos_group != prev_group:
                prev_group = pos_group
                replacement_text_len = 0
                replacement_text_lengths = {}
            # Previous processed pair['l'] value have to be set
            if prev_level not in replacement_text_lengths:
                replacement_text_lengths[prev_level] = 0
            """
            Text has been replaced with a new one, different length. Include it in length calculation.
            The value is from the previous iteration but can be modified at this one if the conditions match.
            """
            replacement_text_lengths[prev_level] += replacement_text_len
            # If the previous nesting level is the same as a current one reset the length correction then
            if pos_level == prev_level:
                replacement_text_len = 0

            if pair['o'] is None or pair['c'] is None:
                if self.debug:
                    self.logger.warning('Error occurred! Incomplete open/close tag data %s' % pair)
                return

            """
            The most nested blocks being processed first, thus the close token correction,
            as the open token doesn't change its position.
            """
            pos_open = pair['o']
            pos_close = (pair['c'] + pair['cl']  # text + closing token together to get the very end of the text
                         # Combined length correction of all nested elements inside (previous level), say each ones:
                         # [0]...[1][2][/2][/1]...[1][/1]...[/0]
                         + (replacement_text_lengths[prev_level] if pos_level == 0 else replacement_text_len))

            if self.debug:
                self.logger.debug('Cursor position open: %d, close: %d, level: %d ' % (pos_open, pos_close, pos_level))

            cursor.setPosition(pos_open)
            cursor.setPosition(pos_close, QTextCursor.MoveMode.KeepAnchor)
            selected_text = cursor.selectedText()
            if selected_text:
                if self.debug:
                    self.logger.debug('Block text[%d][%d]: %s' % (pos_group, pos_level, selected_text))

                cursor.removeSelectedText()

                """
                Select extra space characters before and after to strip the cursor's text the way where the summary's
                content starts right after the details tag.
                """
                pattern = r"[\s]*?<summary.*?>(.*?)<\/summary>[\s]*?"
                match = re.search(pattern, selected_text, flags=re.DOTALL | re.MULTILINE | re.UNICODE)
                summary = self.lexemes.get('expandable_block_default_title')
                if match:
                    summary = f"{match.group(1).strip()}"
                    pos_open_summary = match.start()
                    pos_close_summary = match.end()
                    selected_text = selected_text[:pos_open_summary] + selected_text[pos_close_summary:]

                # There is a possible situation where the formatted html may become invalid.
                # Say, "<p>...\n<details> </p>\n\n<summary>".
                # That's because of markdown lib may process paragraph that way.
                # Double check it with a something like exp below, but keep in mind the open tag is also exists:
                # pattern = r"<details.*?>[\s]*?(</p>)"

                selected_text = selected_text.strip()
                encoded_text = base64.b64encode(selected_text.encode('utf-8'))
                """
                Insert as a TEXT here, not as an HTML or tags will be stripped after.
                Place extra spaces on each side to allow smooth anchor detection upon click.
                """
                # Do not use id="..." it makes anchor detection unstable, use data attributes instead
                replacement_text = ('<p class="_ds_expand">'
                                    # <a>...</a> will replaced with table upon click
                                    '<a href="expandable:{}" data-group="{}" data-level="{}">'
                                    '<span class="_ds_expand_pointer">▼</span>&nbsp;{}</a>'
                                    '</p>'
                                    .format(encoded_text.decode('utf-8'), pos_group, pos_level, summary))
                replacement_text_len += len(replacement_text) - (pos_close - pos_open)
                cursor.insertText(replacement_text)
                if self.debug:
                    self.logger.debug('Replacement text: %s' % replacement_text)

                # Save current group and level to check them over again within the next iteration.
                prev_group = pos_group
                prev_level = pos_level

    def mouse_click_event(self, event):
        parent = self.doc.parent()  # type: QObject
        parent_widget = parent.get_view_widget()  # type: QTextBrowser

        # Parent actions, like select text with a cursor
        QTextBrowser.mousePressEvent(parent_widget, event)

        cursor = parent_widget.cursorForPosition(event.pos())

        if event.button() == Qt.MouseButton.LeftButton:
            anchor = parent_widget.anchorAt(event.pos())
            if anchor.startswith('expandable'):
                return self.anchor_click_event(QUrl(anchor), cursor)
            elif anchor.startswith('collapsible'):
                # TODO collapsible block
                if self.debug:
                    self.logger.debug('Collapsible block clicked')
                pass
            else:
                if self.debug:
                    cursor.select(QTextCursor.SelectionType.WordUnderCursor)
                    text = cursor.selectedText()
                    self.logger.debug(f'Click context: {text}')

                cursor.clearSelection()
                parent_widget.setTextCursor(cursor)

    def anchor_click_event(self, url: QUrl, cursor: QTextCursor):
        if self.debug:
            # cursor.charFormat().anchorHref()
            self.logger.debug('Anchor clicked, scheme: %s, path: %s' % (url.scheme(), url.path()))

        # This may cause left-right click issue when either the start or end position of token is clicked
        # cursor.movePosition(QTextCursor.MoveOperation.PreviousCharacter, QTextCursor.MoveMode.KeepAnchor)

        cursor.select(QTextCursor.SelectionType.WordUnderCursor)

        if self.debug:
            self.logger.debug('Cursor data, pos: %d, anchor: %s, text: %s'
                              % (cursor.position(), cursor.anchor(), cursor.selectedText()))

        i = 0
        # Move to the anchor caption's start position
        while cursor.charFormat().isAnchor() and (i := i + 1):
            cursor.movePosition(QTextCursor.MoveOperation.PreviousCharacter, QTextCursor.MoveMode.MoveAnchor)
        # Move to the anchor caption's end position
        while i > 0 or cursor.charFormat().isAnchor():
            i -= 1
            cursor.movePosition(QTextCursor.MoveOperation.NextCharacter, QTextCursor.MoveMode.KeepAnchor)
            # Check it after, not in loop
            if cursor.atBlockEnd():
                break

        anchor_label = cursor.selectedText()
        if self.debug:
            self.logger.debug('Anchor text: %s' % anchor_label)

        cursor.removeSelectedText()
        to_decode = url.path()
        if self.debug:
            self.logger.debug('Text to decode: {%s}', to_decode)

        decoded_text = (base64.b64decode(to_decode.encode('utf-8'))
                        .decode('utf-8')
                        .strip())

        if self.debug:
            self.logger.debug('Decoded text: {%s}' % decoded_text)

        # Set default format for inserted text
        default_format = QTextCharFormat()
        cursor.setCharFormat(default_format)
        """
        Insert decoded text, new line either "\n" or "<br/>" depending on insertText() or insertHtml() correspondingly.
        Allow extra space as space symbols could be stripped during conversion.
        """
        cursor.insertHtml('<table class="_n_details">'
                          '<tr><td class="_n_details_summary"><span class="_ds_collapse">{}</span></td></tr>'
                          '<tr><td class="_n_details_content">{}</td></tr>'
                          '</table>'
                          # TODO make it collapsible with '▲'
                          .format(anchor_label.replace('▼', '≡').strip(), decoded_text))
