# SPDX-FileCopyrightText: © 2023 Christian BUHTZ <c.buhtz@posteo.jp>
#
# SPDX-License-Identifier: GPL-3.0-only
#
# This file is part of the program "Hyperorg" which is released under GNU
# General Public License v3 (GPLv3).
# See folder LICENSES or go to <https://www.gnu.org/licenses/#GPL>.
"""To parse org file content."""
import re
from hyperorg import elements
from hyperorg import textruns


class OrgContentParser:
    """To parse org file (body lines).

    The result is a list of `elements`. Note that this parser do parse the so
    called body lines of an org file not its meta data (properties). The latter
    is still done by `orgparse` package one step earlier.
    """
    # RegEx to find headings
    # one or more asterix bullets followed by a blank
    RE_PATTERN_HEADING = re.compile(r'^\*+ ')

    # RegEx to find ordered list items
    # one or more digits followed by . or ) followed with blank
    RE_PATTERN_ORDERED_LIST = re.compile(r'^\s*\d+[\.|)]\s+')

    # RegEx to find unordered list items
    # zero or more blanks followed by followed by bullets (+-*),
    # followed by blank.
    # Attention: Check for heading pattern first!
    # RE_PATTERN_UNORDERED_LIST = re.compile(r'^\s*[\+\-\*]\s+')
    RE_PATTERN_UNORDERED_LIST = re.compile(r'^(?:\s*[\+\-])| \*\s+')

    # Combination of ordered and unordered list
    # RE_PATTERN_LIST = re.compile(r'^\s*(?:\d+[\.)])|[\-\+\*]\s+')
    RE_PATTERN_LIST = re.compile(
        # noqa: E131
        # begin of line
        r'^'
        # group: (types of bullets)
        r'(?:'
            # group: ordered items
            r'(?:'
                # optional whitespace(s) followed by one or more digits
                r'\s*\d+'
                # "." or ")"
                r'[\.)]'
            r')'
            # OR
            r'|'
            # group: unordered items
            r'(?:'
                # group:
                r'(?:'
                    # optional whitespace(s) followed by "-" or "+"
                    r'\s*[-+]'
                r')'
                # OR
                r'|'
                # one or more whitespace followed by "*"
                r'\s+\*'
            r')'
        r')'
        # optional whitespace(s)
        r'\s+'
    )

    RE_PATTERN_BLOCK_BEGIN = re.compile(r'\s*#\+(?i:begin)_(\S+)(?:\s(.+))?')

    RE_PATTERN_BLOCK_END = r'\s*#\+(?i:end)_(?i:{})'

    RE_PATTERN_LINK_LIKE = re.compile(r'^\[\[.*\]\]$')

    def __init__(self, content: list[str]):
        self.content = tuple(content)
        self._idx = 0
        self.elements = []
        self._paragraph_lines = []
        self._property_lines = []

    @property
    def idx(self) -> int:
        """Index of current line."""
        return self._idx

    @property
    def eof(self) -> bool:
        """Reached end of file?"""
        return self.idx == len(self.content)

    @property
    def current_line(self) -> str:
        """Return the current line content."""
        return self.content[self.idx]

    @property
    def previous_line(self) -> str:
        """Return the line before the current line."""
        return self.content[self.idx-1]

    def forward(self):
        """Move to next line."""
        self._idx = self.idx + 1

    def backwards(self):
        """Move to previous line."""
        self._idx = self.idx - 1

    def _append_paragraph(self):
        """Append remembered lines as a paragraph element"""
        if self._paragraph_lines:
            paragraph = elements.Paragraph(self._paragraph_lines)
            self._paragraph_lines = []
            self._append_element(paragraph)

    def _append_element(self, element: textruns.Run):
        """Append one element but take care of paragraphs also."""
        self._append_paragraph()
        self.elements.append(element)

    def result(self) -> tuple[textruns.Run]:
        """Start parsing and return the result.

        Returns:
            A list of `textruns.Run`.
        """

        while not self.eof:
            current_idx = self.idx

            # block
            self._parse_block()

            # empty line
            self._parse_empty_lines()

            # heading
            self._parse_heading()

            # unordered enumeration
            self._parse_list_unordered()

            # ordered enumeration
            self._parse_list_ordered()

            # link-like
            self._parse_figure()

            # properties
            self._parse_org_property()

            # anything else if nothing was parsed
            if self.idx == current_idx:
                self._parse_paragraph_line()

        self._append_paragraph()

        return self.elements

    def _parse_empty_lines(self):
        """
        Definition:
        End condition:
        """
        def _end_condition():
            return self.eof or self.current_line != ''

        while not _end_condition():
            self._append_paragraph()
            self.forward()

    def _parse_paragraph_line(self):
        """
        Definition:
        End condition:
        """
        def _end_condition():
            return self.eof or self.current_line == ''

        if not _end_condition():
            self._paragraph_lines.append(self.current_line)
            self.forward()

    def _parse_heading(self):
        """
        Definition:
        End condition:
        """

        def _end_condition():
            return (self.eof
                    or not self.RE_PATTERN_HEADING.findall(self.current_line))

        while not _end_condition():
            self._append_element(elements.Heading(self.current_line))
            self.forward()

    def _parse_list_unordered(self):
        """
        Definition:
        End condition:
        """
        self._parse_list(ordered=False)

    def _parse_list_ordered(self):
        """
        Definition:
        End condition:
        """
        self._parse_list(ordered=True)

    @staticmethod
    def _indention_fits_to_previous(indent: int, prev: list[tuple]) -> bool:
        """A helper for `_parse_list()` to find out if a specific indentation
        does fit to an indentation of one of the previous list items.

        Args:
            indent: Indentation in number of characters.
            prev: List of 4-item-tuples. Second item is the indentation.

        Returns:
            ``True`` if one of the list items in `prev` has an equal or
            greater indentation as indicated by `indent`.
        """

        # As an exception some list entries are not tuples but one
        # `elements.Block`.
        prev = filter(lambda item: isinstance(item, tuple), prev)

        # unique list of available indentions (the 2nd tuple item)
        prev = {item[1] for item in prev}

        return any(filter(lambda val: indent >= val, prev))

    def _parse_list(self, ordered: bool):  # pylint: disable=too-many-branches
        """
        Definition:
        End condition:
        """
        result = []

        # Regex pattern for a specific type of list
        rex_specific = self.RE_PATTERN_ORDERED_LIST if ordered \
            else self.RE_PATTERN_UNORDERED_LIST

        def end_condition():
            if self.eof:
                return True

            if self.idx == 0:
                if self.current_line.strip() == '':
                    return True
            else:
                if (self.current_line.strip() == ''
                        and self.previous_line.strip() == ''):
                    return True

            return False

        # first item
        # DevNote: Not sure why "end_condition()" is involved here!
        if not end_condition() and not rex_specific.findall(self.current_line):
            return

        while not end_condition():
            line_elements = None

            # line with bullet (ordered or unordered doesn't matter)
            if self.RE_PATTERN_LIST.findall(self.current_line):
                line_elements = self._classify_current_list_line()

            # maybe a code block
            elif self.RE_PATTERN_BLOCK_BEGIN.match(self.current_line):
                self._parse_block()
                if isinstance(self.elements[-1], elements.Block):
                    block = self.elements[-1]
                    self.elements = self.elements[:-1]
                    line_elements = block
                    self.backwards()

            # empty line
            elif self.current_line.strip() == '':
                self.forward()
                continue

            # maybe line broken list item
            else:
                line_content = self.current_line.lstrip()
                line_elements = (
                    None,  # no bullet
                    len(self.current_line) - len(line_content),
                    line_content,
                    None
                )

                # Indentation of current line fits to any of the
                # previous lines?
                if not self._indention_fits_to_previous(
                        line_elements[1], result):
                    line_elements = None

            # Parsed something?
            if line_elements:

                # a regular item
                if line_elements[0] is not None:
                    result.append(line_elements)

                # part of an item separated by linebreak
                else:
                    # add content to previous result
                    prev_line_elements = result[-1]
                    result = result[:-1]
                    joined_line_elements = (
                        prev_line_elements[0],
                        prev_line_elements[1],
                        prev_line_elements[2] + ' ' + line_elements[2],
                        prev_line_elements[3]
                    )
                    result.append(joined_line_elements)

                self.forward()
            else:
                break

        # Nothing found
        if not result:
            return

        self._append_element(
            self._create_list_element_from_list_lines(result, ordered)
        )

    def _classify_current_list_line(self) -> tuple[int, int, str, bool]:
        """Convert that list line into its components and return them as a
        4-item tuple. The content of the items as follows.

            1. Index of the beginning of the bullet character.
            2. Index of the beginning of the content.
            3. The content (excluding leading space and bullet).
            4. Ordered (``True``) or Unordered (``False``).

        """
        bullet = self.current_line.split()[0]
        bullet_start_idx = self.current_line.index(bullet)
        content_start_idx = bullet_start_idx + len(bullet) + 1

        return (
            bullet_start_idx,
            content_start_idx,
            self.current_line[content_start_idx:].lstrip(),
            bullet[0].isdigit()
        )

    @staticmethod
    def _create_list_element_from_list_lines(lines: list[tuple],
                                             ordered: bool
                                             ) -> elements.ListElement:
        """DOCSTRING NOT UP-2-DATE Remove line breaks and indentation links
        from list lines.

        Args:
            lines: A list of 3 item tuples.

        Returns:
            (list): Of what?

        Each tuple in ``lines`` consists of three items. The first is the index
        of the beginning of the bullet character (includes numbers). The
        seocnd item is the beginning index of the content after the bullet.
        The third is the content itself.

        TODO DevNote: Emacs org does handle it the same. A code block can only
        be a list entry if it has a string as prefix. Org (to html) do not
        convert a standalone block in a list entry.

        See also `Node._classify_list_line()`.

        .. Example ::
            - items
              with new line
            - next

        Becomes .. Example ::
            - items with new line
            - next

        Without this step it is not possible to correctly parse for runs.
        """
        def _item_string(one_line, bullet) -> str:
            # blank-prefix + bullet + content
            return f'{" "*one_line[0]}{bullet} {one_line[2]}'

        the_list = elements.ListElement(
            _item_string(lines[0], '.1' if ordered else '-'), ordered)

        for one_line in lines[1:]:

            # has bullet (item start)
            if isinstance(one_line[0], int):
                the_list.add_item(
                    _item_string(one_line, '1.' if one_line[3] else '-'))

            # item is a code block
            elif isinstance(one_line, elements.Block):
                the_list.append_to_last_item(one_line)

            # next line of the same item
            else:
                the_list.append_to_last_item(one_line[2])

        return the_list

    @staticmethod
    def _join_list_lines_to_item_lines(lines: list[tuple],
                                       ordered: bool) -> list:
        """Remove line breaks and indentation links from list lines.

        Args:
            lines: A list of 3 item tuples.

        Returns:
            (list): Of what?

        Each tuple in ``lines`` consists of three items. The first is the index
        of the beginning of the bullet character (includes numbers). The
        seocnd item is the beginning index of the content after the bullet.
        The third is the content itself.

        TODO DevNote: Emacs org does handle it the same. A code block can only
        be a list entry if it has a string as prefix. Org (to html) do not
        convert a standalone block in a list entry.

        See also `Node._classify_list_line()`.

        .. Example ::
            - items
              with new line
            - next

        Becomes .. Example ::
            - items with new line
            - next

        Without this step it is not possible to correctly parse for runs.
        """
        result = []

        for one_line in lines:

            # has bullet (item start)
            if one_line[0] is not None:
                # pylint: disable=consider-using-f-string
                result.append(
                    (
                        # (un)ordered
                        ordered,
                        # string incl. bullet
                        '{blank}{bullet} {content}'.format(
                            blank=' ' * one_line[0],
                            bullet='1.' if ordered else '-',
                            content=one_line[2]
                        )
                    )
                )

            else:
                # refresh last item
                result[-1] = (
                    # (un)ordered
                    result[-1][0],
                    f'{result[-1][1]} {one_line[2]}'
                )

        return result

    def _parse_block(self):
        """
        Definition:
        End condition:
        """

        # begin condition
        if not self.RE_PATTERN_BLOCK_BEGIN.match(self.current_line):
            return

        # name and language
        block_name, block_language \
            = self.RE_PATTERN_BLOCK_BEGIN.findall(self.current_line)[0]

        # Indentation, can happen when this block is a in a list element
        indentation = self.current_line.lower().index('#+begin')

        # define block-end pattern
        rex_block_end = re.compile(
            self.RE_PATTERN_BLOCK_END.format(block_name))

        self.forward()

        def end_condition():
            return (self.eof
                    or rex_block_end.findall(self.current_line))

        result = []

        while not end_condition():
            result.append(self.current_line[indentation:])
            self.forward()

        elem_block = elements.Block(
            name=block_name,
            content_lines=result,
            language=block_language
        )
        self._append_element(elem_block)

        # Step over the block end line "#+end_src"
        self.forward()

    def _parse_figure(self):
        """Stand alone figure."""

        # end condition
        if (self.eof
                or not self.RE_PATTERN_LINK_LIKE.findall(self.current_line)):
            return

        # Result in an instance of Image or a Link with a Figure as "label"
        img_or_link = textruns.Image.to_image_or_linked_image(
            self.current_line[2:-2])

        if not img_or_link:
            return

        props = elements.Figure.to_properties_dict(self._property_lines)
        self._property_lines = []

        figure = elements.Figure(img_or_link, props)
        self._append_element(figure)

        self.forward()

    def _parse_org_property(self):
        if (self.eof
                or not self.current_line.startswith('#+')
                or self.current_line.lower().startswith('#+begin_')
                or self.current_line.lower().startswith('#+end_')):
            return

        self._property_lines.append(self.current_line)
        self.forward()
