# 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 logging
import re
import shlex
from datetime import datetime
from typing import Union
import dateutil.parser
from hyperorg import elements
from hyperorg import textruns

_log = logging.getLogger(__name__)


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 inline comments
    RE_PATTERN_INLINE_COMMENT = re.compile(r'^\s*#(\s+|$)')

    # 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'(?:'
            # noqa: E131
            # group: ordered items
            r'(?:'
                # noqa: E131
                # optional whitespace(s) followed by one or more digits
                r'\s*\d+'
                # "." or ")"
                r'[\.)]'
            r')'
            # OR
            r'|'
            # group: unordered items
            r'(?:'
                # group:
                r'(?:'
                    # noqa: E131
                    # 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:{})'

    # Pattern to find tables in Org or tables.el style.
    RE_PATTERN_TABLE = re.compile(
        # noqa: E131
        # group: (tables.el style)
        r'(?:'
            # noqa: E131
            # line beginning
            r'^'
            # String "+-"
            r'\+-'
            # followed by 0 or more "+" or "-" but
            r'[+-]*'
            # nothing else until line ends
            r'$'
        r')'
        # OR
        r'|'
        # group: (org style tables)
        r'(?:'
            # line beginning
            r'^'
            # char "|"
            r'\|'
        r')'
    )

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

    # Match (latex) display math variants double dollar and square brackets
    RE_PATTERN_DISPLAY_MATH = re.compile(
        # noqa: E131
        # start of display math (double dollar)
        r'\$\$'
            # noqa: E131
            # capturing group (the formula)
            r'(.*?)'
        # end of display math
        r'\$\$'
        # OR
        r'|'
        # start display math (square brackets)
        r'\\\['
            # noqa: E131
            # capturing group (the formula)
            r'(.*?)'
        # end of display math
        r'\\\]',
        # Considre multiline math blocks
        re.DOTALL
    )

    def __init__(self, content: list[str]):
        # The raw content
        self.content = tuple(content)
        # The "cursors" current index
        self._idx = 0

        # Elements parsed
        self.elements = []
        # Meta data parsed
        self.meta = {}

        # Helper variables
        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:
            splitted_elements = self._split_in_paragraphs_and_display_math(
                self._paragraph_lines)
            self._paragraph_lines = []

            for one_element in splitted_elements:
                self._append_element(one_element)

    def _split_in_paragraphs_and_display_math(
        self,
        lines: list[str],
    ) -> list[elements.Paragraph, elements.DisplayMath]:
        """
        Note about display math (aka math blocks): Hyperorg's concept of
        vertical and horizontal components does not fit to how OrgRoam/OrgDown
        handle display math. The following example of org content consists
        of one org line but would be rendered as three paragraphs in HTML.

        .. Example ::

            first paragraph $$e = mc^2$$ second one

        Because of that the paragraph-line-parsing code need to be clear if
        the line does contain display math or not.
        """
        result = []
        content = ' '.join(lines)

        # Position of end-delemiter found in latest match iteration
        prev_end = 0

        # Each display math element
        for matched in self.RE_PATTERN_DISPLAY_MATH.finditer(content):
            formula = matched.group(1) or matched.group(2)

            # Text before the formula found is a paragraph
            if matched.start() > 0:
                result.append(
                    elements.Paragraph(content[prev_end:matched.start()])
                )

            # The math formula itself
            result.append(elements.DisplayMath(formula))

            prev_end = matched.end()

        if prev_end < len(content):
            if result:
                # Last text after the last math formula
                result.append(elements.Paragraph(content[prev_end:]))
            else:
                # no match: Everything is a paragraph
                result.append(elements.Paragraph(content))

        return result

    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[dict, tuple[textruns.Run]]:
        """Start parsing and return the result.

        Returns:
            A 2-item tuple with a dictionary of parsed meta data and list
            of `textruns.Run`.

        """
        while not self.eof:
            current_idx = self.idx

            # inline comment
            self._parse_inline_comment()

            # block (including comment blocks)
            self._parse_block()

            # table (org and table.el)
            self._parse_table()

            # 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:
                # Note: Display math (blocks) also recognized
                self._parse_paragraph_line()

        self._append_paragraph()

        return (self.meta, 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))

    # pylint: disable-next=too-many-branches
    def _parse_list(self, ordered: bool):  # noqa: PLR0912
        """
        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
            elif (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_table(self):
        def end_condition():
            return (
                self.eof
                or not self.RE_PATTERN_TABLE.match(self.current_line)
            )

        result = []

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

        if not result:
            return

        elem_block = elements.TableBlock(content_lines=result)
        self._append_element(elem_block)

    def _parse_inline_comment(self):
        """Lines starting with zero or more whitespace characters followed by
        one ‘#’ and a whitespace.
        """

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

        while not _end_condition():
            # ignore comment
            self.forward()

    def _parse_block(self):
        # begin condition
        if (self.eof
                or 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()

        if block_name.upper() != 'COMMENT':
            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

        try:
            # Extract field name and value
            name, val = re.findall(r'#\+(.+?):\s(.+?)$', self.current_line)[0]

        except IndexError:
            name, val = None, None
            self._property_lines.append(self.current_line)

        else:  # no exception
            val = val.strip()

            if name.lower() == 'title':
                self.meta['title'] = val

            elif name.lower() == 'filetags':
                # shlex: see https://stackoverflow.com/a/79985/4865723
                # re is a bit faster but harder to read.
                # see: https://stackoverflow.com/q/79968/4865723
                self.meta['filetags'] = shlex.split(val)

            elif name.lower() in ['date', 'time']:
                self.meta[name.lower()] = self._parse_date_time_property(val)

            elif len(self.elements) == 0 and len(self._paragraph_lines) == 0:
                # No element parsed means we are still in the head of the org
                # content. Because of that it is treated node meta data.
                self.meta[name] = val.strip()

            else:
                self._property_lines.append(self.current_line)

        self.forward()

    def _parse_date_time_property(self, value: str) -> Union[datetime,
                                                             str]:
        """Parse date or time from org property into a `datetime`.


        Args:
            value: The date/time as a string to parse.

        Returns:
            The parsed `datetime` or in case parsing is not possible the
            result is returned unmodified as a string and a warning
            message is logged.
        """
        # Remove brackets for (in)active dates
        stripped_value = value.strip('<>[]')

        # Try regular parsing and if not possible use fuzzy parsing
        for fuzzy in [False, True]:
            try:
                return dateutil.parser.parse(stripped_value, fuzzy=fuzzy)

            except dateutil.parser.ParserError as err:
                _log.debug(err)

        _log.warning('Error while parsing date or time string. '
                     f'String was "{stripped_value}".')

        return value
