# 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>.
"""Parsing text and creating runs out of it.

A run is piece of a longer string representing a unformatted text, markup or a
link. The module belongs to the content layer. The classes in `elements` do
use runs.

Credits:
    The Regex here is based on several answers on StackOverflow_ and
    the `German Debian Forum`_. Also regex101_ was very useful.
    The final approach about identifying tokens and convert them to runs
    was inspired by an answer_ on the `Emacs-orgmode`_ mailing list.

.. _regex101:
    https://regex101.com
.. _StackOverflow:
    https://stackoverflow.com
.. _German Debian Forum:
    https://debianforum.de
.. _answer:
    https://lists.gnu.org/archive/html/emacs-orgmode/2023-01/msg00974.html
.. _Emacs-orgmode:
    https://lists.gnu.org/mailman/listinfo/emacs-orgmode
"""
from __future__ import annotations
from dataclasses import dataclass
import typing
import enum
import re
from hyperorg.event import Event


@dataclass
class Run:
    """Abstract class as base class for all run classes.
    """
    __slots__ = ('runs', )

    # DevNote: Because of @dataclass this is an instance attribute with its
    # type annotation.
    runs: list[RunsType]

    def __new__(cls, *args, **kwargs):  # pylint: disable=unused-argument
        """Make sure this class is not instaniated.

        Credits: https://stackoverflow.com/a/7990308/4865723"""
        if cls is __class__:
            raise TypeError('It is an abstract class. Inherit from it.')

        return super().__new__(cls)

    def __len__(self) -> int:
        """Count of runs."""
        return len(self.runs)

    def __getitem__(self, idx: int):
        """Return run at given index."""

        return self.runs[idx]


RunsType = typing.Union[str, Run]


@dataclass(init=False)
class Markup(Run):
    """A markup run."""

    class Kind(enum.Enum):
        """Enum to specify the type of a markup run."""
        IMPORTANT = '*'
        VERBATIM = '='
        CODE = '~'
        EMPHASIZED = '/'
        UNDERLINED = '_'
        STRIKETHROUGH = '+'

    __slots__ = ('kind', )

    kind: Markup.Kind

    def __init__(self, text: str, kind: Markup.Kind):
        """Initialize the inline markup run.

        Args:
            text: The string without the markers (e.g. ``*``).
            marker: The type of inline markup.
        """
        self.kind = kind

        if self.kind in [Markup.Kind.VERBATIM, Markup.Kind.CODE]:
            runs = [text]
        else:
            runs = cut_into_runs(text)

        super().__init__(runs=runs)


@dataclass(init=False)
class Link(Run):
    """A link run."""

    event_link_created = Event()

    class Kind(enum.Enum):
        """Enum to specify the type of link."""
        ORGID = 'id'
        ROAM = 'roam'
        HTTPS = 'https'
        HTTP = 'http'
        FILE = 'file'
        MAILTO = 'mailto'
        DOI = 'doi'
        NONE = ''

    _REX_KIND_TARGET_LABEL = re.compile(
        r"^(?:((?:(?!]\[)[^:])*):)?(?://)?(.*?)(?:]\[(.*))?$", re.DOTALL)
    """Compiled Regex pattern to find links.

    The order of entries in that dictionary do distinct the order of
    parsing. Especially in the case of the different link types that order is
    important an shouldn't be modified without a very good reason.

    Examples for links:

        - OrgID ``[[id:0000][name]]``
        - Roam link with a label ``[[roam:0000][name]]``
        - Roam link without a label ``[[roam:0000]]``
        - Hyperlink (HTTPS) with label ``[[https://new.world][New World]]``
        - Hyperlink (HTTPS) without label ``[[https://new.world]]``

    Credits:

        https://stackoverflow.com/a/75941376/4865723
        https://stackoverflow.com/a/75334268
        https://stackoverflow.com/q/75412763
    """

    __slots__ = ('kind', 'target', )

    kind: Link.Kind
    target: str

    @staticmethod
    def extract_parts(id_target_label: str) -> tuple[Link.Kind, str, str]:
        """Helper function to separate an org link into its three elements.

        Args:
            id_target_label: The link in org syntax.

        Returns:
            A 3-item tuple with kind, target and label.
        """
        kind, target, label = Link._REX_KIND_TARGET_LABEL.match(
            id_target_label).groups()

        return (kind, target, label)

    def __init__(self, id_target_label: str):
        """The link kind, its target and if present the label is parsed and
        extracted from ``id_target_label``.

        Args:
            id_target_label: The string between the opening (``[[``) and
                closing (``]]``) link tokens (e.g. ``id:1234][label``.
        """
        kind, target, label = Link.extract_parts(id_target_label)

        if kind:
            kind = kind.lower()

        try:
            if not kind:
                kind = ''
            # self.kind = Link.Kind._value2member_map_[kind]
            self.kind = Link.Kind(kind)

        except KeyError as exc:
            raise ValueError(f'Unknown link type "{kind}" '
                             f'in "{id_target_label}".') from exc

        self.target = target

        if label:
            # Could the label be an image?
            if Image.is_image_string_for_link_label(label):
                label_runs = [Image(label)]
            else:
                label_runs = cut_into_runs(label)

        else:
            label_runs = []

        super().__init__(runs=label_runs)

        #
        self.__class__.event_link_created.notify(self)


@dataclass(init=False)
class Image(Run):
    """An image referencing an image file."""
    EXTENSIONS_ALLOWED = [
        # Org defined extensions
        'png', 'jpeg', 'jpg', 'gif', 'tiff', 'tif',
        'xbm', 'xpm', 'pbm', 'pgm', 'ppm',
        # Additional extensions
        'jxl',   # JPEG XL
        'jp2', 'jpx',  # JPEG 2000
        'jxr', 'hdp', 'wdp',  # JPEG XR
        'webp',
        'apng',
        'svg', 'svgz',
        'bmp',
        'bpg',  # Better Portable Graphics
        'flif',  # Free Lossless Image Format
        'avif',  # AV1 Image File Format,
        'ico', 'cur'
        ]

    __slots__ = ('width', 'height', 'cssstyle', )

    width: typing.Union[str, int]
    """Width of the image."""

    height: typing.Union[str, int]
    """Height of the image."""

    cssstyle: str
    """CSS style attribute"""

    _REX_FILE_PREFIX = re.compile(r'^file:')

    @staticmethod
    def is_image_string_for_link_label(text: str) -> bool:
        """Check if the string can be an image used as label for a link.

        The Org syntax for images is very similar to links. That is why such
        a check is needed. An image can be use as a link's label only when it
        has a "file:" or "https:" suffix and the right extension (see
        `has_allowed_extension()`).

        Please see also `is_image_string_standalone()` for more infos and a
        different set of rules.
        """

        # Only with prefix
        if ':' not in text:
            return False

        kind = text.split(':')[0].lower()

        # Allowed is only https or file
        if kind not in ('https', 'file'):
            return False

        # Extensions of an image file?
        if Image.has_allowed_extension(text):
            return True

        return False

    @staticmethod
    def is_image_string_standalone(text: str) -> bool:
        """Check if the string can be an image.

        The Org syntax for images is very similar to links. That is why such
        a check is needed.
        A standalone image can't have a label; it don't need a suffix but if
        so only "https:" or "file:" is allowed. At last the file extension
        is checked via `has_allowed_extension()`.

        Keep in mind that in Org the rules for images are different depending
        on if they are used regular image or as a label for a link (an image
        you can click on). See `is_image_string_for_link_label()` as an
        alternative set of rules.
        """

        # It is a Link with label
        if '][' in text:
            return False

        # Is there something like a Link Kind?
        if ':' in text:
            kind = text.split(':')[0].lower()

            # Allowed is only https or file
            if kind not in ('https', 'file'):
                return False

        # Extensions of an image file?
        if Image.has_allowed_extension(text):
            return True

        return False

    @staticmethod
    def has_allowed_extension(target: str) -> bool:
        """Check if there is one of the allowed file extensions in the target
        string.

        Allowed exstensions are listed in `Image.EXTENSIONS_ALLOWED`. The
        check is performed case insensitive. The extension need be separated
        by a dot.

        Args:
            target: The filename like string.

        Returns:
            True or False.
        """

        # Does the target has a file extension?
        if '.' in target:

            # It is an image when it has an image file extension
            extension = target.split('.')[-1]

            if extension.lower() in Image.EXTENSIONS_ALLOWED:
                return True

        return False

    @staticmethod
    def to_image(text: str) -> Image:
        """Convert the string in `text` into an `Image` instance."""
        if Image.is_image_string_standalone(text):
            return Image(text)

        return None

    @staticmethod
    def to_image_or_linked_image(text: str) -> typing.Union[Image, Link]:
        """Generate an Image or Link with Image as label from a givin string.

        Args:
            text: A link like string but without trailing `[[` and ending `]]`.

        Returns:
            An `Image` or `Link`.
        """

        # Image that is not a link
        img = Image.to_image(text)

        if img:
            return img

        # It is a Link but can it's label be an Image?

        label = Link.extract_parts(text)[2]

        if label and Image.is_image_string_for_link_label(label):
            return Link(text)

        return None

    def __init__(self, target):

        # remove "file:" prefix
        target = Image._REX_FILE_PREFIX.sub('', target)

        # Alternative text ("alt" attribute in <img> tag)
        alt = target
        idx = max(alt.rfind('/'), alt.rfind(':'))

        if idx > -1:
            # remove link kind, protocol and path
            alt = alt[idx+1:]

        super().__init__(runs=[target, alt])

        self.width = None
        self.height = None
        self.cssstyle = None

    def set_attributes(self, attributes: dict) -> dict:
        """Set image relevant attributes.

        Image relevant attributes are width, height and style. If present they
        are removed from `attributes`.

        Args:
             attributes: A dictionary with several values.

        Returns:
             The rest of attributes.
        """

        # "alt" attribute
        alternative = attributes.pop('alt', None)
        if alternative:
            self.alternative = alternative

        self.width = attributes.pop('width', None)
        self.height = attributes.pop('height', None)
        self.cssstyle = attributes.pop('style', None)

        return attributes

    @property
    def target(self) -> Image:
        """Return the image."""
        return self.runs[0]

    @property
    def target_is_locale(self) -> bool:
        """Link to a local file?"""
        return not self.runs[0].startswith('https:')

    @property
    def alternative(self) -> str:
        """Return value of alternative name.

        In HTML it is the 'alt' attribute in an '<img>' tag."""
        return self.runs[1]

    @alternative.setter
    def alternative(self, val):
        if len(self.runs) < 2:
            self.runs[1].append(val)
        else:
            self.runs[1] = val


class Token(enum.Enum):
    """Enum to describe types of tokens."""
    IMPORTANT = '*'
    VERBATIM = '='
    CODE = '~'
    EMPHASIZED = '/'
    UNDERLINED = '_'
    STRIKETHROUGH = '+'
    LINK = '§'


class TokenState(enum.Enum):
    """Enum to identify the open or close state of a token (see `Tocken`) """
    OPEN = enum.auto()
    CLOSE = enum.auto()


_REX = {
    (Token.LINK, TokenState.OPEN):
        re.compile(r'\[\['),
    (Token.LINK, TokenState.CLOSE):
        re.compile(r'\]\]')
}
"""
        https://stackoverflow.com/a/73519697/4865723
"""

_REX_BASE_TOKEN_OPEN = (
    r"(^|(?<=[\s.,;:\-?!({\"']))",
    r"(?!\s|$)"
)
r"""
    Basis for the regex pattern to match open tokens. The markdown character
    (e.g. * or / ) is inserted between these to parts.

    Description in details ... ::

        ^    Start of the line
        |    or
        (?<= Positive lookbehind
        []   one of the characters in that list.

        (?!  Negative lookahad
        \s   Any whitespace
        |    or
        $    end of line
"""

_REX_BASE_TOKEN_CLOSE = (r"(?:(?<!^)(?<!\s))", r"(?=[\s.,;:\-?!)}\"']|$)")
r"""Basis for the regex pattern to match close tokens. The markdown character
    (e.g. * or / ) is inserted between these to parts.

    Description in details ... ::

        (?:        Non-capturing group
            (?<!   Negative Lookbehind
                ^  Start of line
            )
            (?<!   Negative Lookbehind
                \s Any whitespace
            )
        )

        (?=    Positive Lookahead
            [  One of that characters list.
                \s.,;:\-?!)}\"'
            ]
            |  or
            $  End of line
        )

"""

# Construct regex patterns for all markdown tokens.
for one_token in Token:
    # ignore link
    if one_token is Token.LINK:
        continue

    val_of_one_token = one_token.value

    # Escape
    if one_token in [Token.STRIKETHROUGH, Token.IMPORTANT]:
        val_of_one_token = "\\" + val_of_one_token

    pattern_open = _REX_BASE_TOKEN_OPEN[0] \
        + val_of_one_token + _REX_BASE_TOKEN_OPEN[1]
    pattern_close = _REX_BASE_TOKEN_CLOSE[0] \
        + val_of_one_token + _REX_BASE_TOKEN_CLOSE[1]

    _REX[(one_token, TokenState.OPEN)] = re.compile(pattern_open)
    _REX[(one_token, TokenState.CLOSE)] = re.compile(pattern_close)


def cut_into_runs(text: str) -> list[RunsType]:
    """Cut a given string into runs.

    This function is an entry point calling the following three functions:

        1. `_token_positions()`
        2. `_token_pairs()`
        3. `_runs_from_token_pairs()`

    Args:
        text: The text as string to cut.

    Returns:
        A list of runs.
    """
    positions = _token_positions(text)
    pairs = _token_pairs(token_list=positions)
    runs = _runs_from_token_pairs(text, pairs)

    return runs


def _token_positions(text: str) -> list:
    """Identify position of tokens like markup markers or links.

    Here is an example to illustrate what is recognized as a token::

        This *is* a [[id:1234][link]].
             ^  ^   ^^             ^^

    The result returned from a string like this would be::

        [
            (5, <Token.IMPORTANT: '*'>, <TokenState.OPEN: 1>),
            (8, <Token.IMPORTANT: '*'>, <TokenState.CLOSE: 2>),
            (12, <Token.LINK: 1>, <TokenState.OPEN: 1>),
            (27, <Token.LINK: 1>, <TokenState.CLOSE: 2>)
        ]

    This function don't care about if the tokens are correct paired.

    Args:
        text: The original text as string.

    Returns:
        A list of 3-tuple elements with position, type and open-close
        indicator of a marker
    """
    result = {}

    for token, state in _REX:
        rex = _REX[(token, state)]

        for match in rex.finditer(text):
            pos = match.start()

            # Deactivated that code because of improved regex there is
            # no need for it anymore.
            # # Adjust the position according to the previous character
            # # before an open markup marker.
            # # Only the markup regex do have catch groups for that case.
            # if state == TokenState.OPEN and match.groups():
            #     pos += len(match.groups()[0])

            result[pos] = (token, state)

    # Transform dict into 3-entry tuple list
    return [(pos, result[pos][0], result[pos][1])
            for pos in sorted(result.keys())]


def _token_pairs(token_list: list) -> list:
    """Find pairs in a list of markup markers.

    The input data is should come from `_token_positions()`. That list of
    unpaired tokens is checked by `_token_pairs()` of valid pairs.
    An exceptional situation is to have nested/enclosed token pairs. The inner
    token pair is ignored and treated as invalid. That make sense because the
    string between the two outer tokens will get parsed again in a later step.

    Args:
        token_list: A list of 3-element-tuples (taken from
            `_token_positions()`).

    Returns:
        A list of 3-element tuples indicating the token type and its closing
        and starting position.
    """
    result = []

    opens = {}

    for pos, token, state in token_list:

        # opening token
        if state == TokenState.OPEN:

            # the first one?
            if token not in opens:
                opens[token] = pos

        # closing token
        elif state == TokenState.CLOSE:

            # ignore if no corresponding open token
            if token not in opens:
                continue

            token_pair = (token, opens.pop(token), pos)

            # remove now irrelevant openers
            for t in list(opens.keys()):
                if token_pair[1] < opens[t] < token_pair[2]:
                    opens.pop(t)

            # remove previous pairs if they are enclosed by current one
            result_len = len(result)
            for idx, prev_pair in enumerate(reversed(result[:]), start=1):

                if prev_pair[1] > token_pair[1]:
                    del result[result_len-idx]
                else:
                    break

            result.append(token_pair)

        else:
            raise ValueError(f'\n{pos=}, {token=}, {state=}')

    return result


def _runs_from_token_pairs(text: str, pairs: list) -> list[RunsType]:
    """Pairs of tokens are transformed into a list of runs.

    The list of token pairs usually comes form `_token_pairs()`.

    Args:
        test: The original string that will get cut/transformed in to runs.
        pairs: List of 3-element-tuples with token type, open and close
            position in the string.

    Returns:
        A list of runs.
    """

    if not pairs:
        return [text]

    result = []

    # text before first pair
    begin = 0
    end = pairs[0][1]

    if end > begin:
        run = text[begin:end]
        result.append(run)

    # each pair
    for idx, e in enumerate(pairs):
        token, begin, end = e

        close_char_offset = 0

        # Link (or Image)
        if token == Token.LINK:

            sub_text = text[begin+2:end]

            # Is it an Image (not as Link!)
            img = Image.to_image(sub_text)
            if img:
                run = img
            else:
                run = Link(sub_text)

            close_char_offset = 1

        # Markup
        else:
            run = Markup(
                text=text[begin+1:end],
                # kind=Markup.Kind._value2member_map_[token.value]
                kind=Markup.Kind(token.value)
            )

        result.append(run)

        if idx < (len(pairs) - 1):
            next_begin = pairs[idx+1][1]
        else:
            next_begin = None

        txt_after = text[end+1+close_char_offset:next_begin]

        if txt_after:
            result.append(txt_after)

    return result
