# SPDX-FileCopyrightText: © 2023 Christian BUHTZ <c.buhtz@posteo.jp>
# SPDX-FileCopyrightText: © 2025 Michael Olie <aolie1794@gmail.com>
#
# 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 LICENSES directory
# or go to <https://spdx.org/licenses/GPL-3.0-only.html>.
"""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
"""
# pylint: disable=import-outside-toplevel
# pylint: disable=cyclic-import
# pylint: disable=too-many-lines
from __future__ import annotations
from dataclasses import dataclass
import logging
import typing
import enum
import re
from hyperorg.event import Event

_log = logging.getLogger(f'hyperog.{__name__}')


@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)  # auto-generate __repr__() and __eq__()
class Markup(Run):
    """A markup run."""

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

    __slots__ = ('markup_type', )

    markup_type: Markup.Type

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

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

        if self.markup_type in [Markup.Type.VERBATIM, Markup.Type.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 Type(enum.Enum):
        """Enum to specify the type of link."""
        ORGID = 'id'
        ROAM = 'roam'
        HTTPS = 'https'
        HTTP = 'http'
        FILE = 'file'
        MAILTO = 'mailto'
        DOI = 'doi'
        NONE = ''
        UNKNOWN = False

    _REX_TYPE_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__ = ('link_type', 'target', )

    link_type: Link.Type
    target: str

    @staticmethod
    def extract_parts(id_target_label: str) -> tuple[Link.Type, 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 type, target and label.
        """
        link_type, target, label = Link._REX_TYPE_TARGET_LABEL.match(
            id_target_label).groups()

        return (link_type, target, label)

    def __init__(self,
                 target: str,
                 link_type: Link.Type,
                 label_runs: list[Run]):
        super().__init__(runs=label_runs)
        self.link_type = link_type
        self.target = target
        self.__class__.event_link_created.notify(self)

    @classmethod
    def from_org_string(cls, id_target_label: str) -> Link:
        """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``.
        """
        link_type, target, label = Link.extract_parts(id_target_label)

        if link_type:
            link_type = link_type.lower()
        else:
            link_type = ''

        try:
            link_type = Link.Type(link_type)

        except ValueError:
            unknown_link_type = link_type
            link_type = Link.Type.UNKNOWN  # None
            _log.warning(f'Link type "{unknown_link_type}" is not supported. '
                         f'Original link was "{id_target_label}".')

        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 = []

        # WORKAROUND: Store unknown link kind as the first run element
        if link_type is Link.Type.UNKNOWN:
            label_runs = [unknown_link_type] + label_runs

        return Link(target=target, link_type=link_type, label_runs=label_runs)


@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', 'title')

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

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

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

    title: typing.Optional[str]
    """Tooltip/title of the image, rendered as HTML 'title' 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

        link_type = text.split(':', 1)[0].lower()

        # Allowed is only https or file
        if link_type 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 Type?
        if ':' in text:
            link_type = text.split(':', 1)[0].lower()

            # Allowed is only https or file
            if link_type 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 extensions 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.rsplit('.', 1)[-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 given 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.from_org_string(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 type, protocol and path
            alt = alt[idx+1:]

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

        self.width = None
        self.height = None
        self.cssstyle = None
        self.title = 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)
        self.title = attributes.pop('title', 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):
        self.runs = self.runs[:1] + [val]


@dataclass(init=False)
class InlineMath(Run):  # pylint: disable=too-few-public-methods
    """Class for inline math runs"""

    def __init__(self, content: str):
        """Initialize an inline math run"""
        super().__init__(runs=[content])


class Token(enum.Enum):
    """Enum to describe types of tokens."""
    IMPORTANT = '*'
    VERBATIM = '='
    CODE = '~'
    EMPHASIZED = '/'
    UNDERLINED = '_'
    STRIKETHROUGH = '+'
    LINK = '§'
    MATH_DOLLAR = '$'  # Inline (latex) math
    MATH_PARENS = '\\('  # Inline (latex) math


class TokenState(enum.Enum):
    """Enum to identify the open or close state of a token (see `Token`) """
    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[(Token.MATH_PARENS, TokenState.OPEN)] = re.compile(r'\\\(')
_REX[(Token.MATH_PARENS, TokenState.CLOSE)] = re.compile(r'\\\)')

_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 lookahead
        \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
        )

"""

# Helper variable
_already_definied_regex_tokens = {key[0] for key in _REX}

# Construct regex patterns for all markdown tokens.
for one_token in Token:
    if one_token in _already_definied_regex_tokens:
        continue

    val_of_one_token = one_token.value

    # Escape
    if one_token in [Token.STRIKETHROUGH, Token.IMPORTANT, Token.MATH_DOLLAR]:
        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 _position_in_ranges(
    pos: int, ranges: list[tuple[int, int]]
) -> bool:
    """Check if position falls within any of the given ranges"""
    return any(start <= pos < end for start, end in ranges)


def _process_regular_tokens(
    text: str, result: dict,
    excluded_ranges: list[tuple[int, int]]
) -> None:
    """Check if position falls within any of the given ranges
    """

    for (token, state), rex in _REX.items():

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

            if _position_in_ranges(pos, excluded_ranges):
                continue

            result[pos] = (token, state)


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 = {}

    _process_regular_tokens(text, result, [])

    # Transform dict into 3-entry tuple list
    # <POSITION>, <TOKEN>, <STATE>
    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 val in list(opens.keys()):
                if token_pair[1] < opens[val] < token_pair[2]:
                    opens.pop(val)

            # 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 _create_link_or_image_run(
    text: str, begin: int, end: int
) -> tuple[RunsType, int]:
    """Create a link or image run from text positions"""
    sub_text = text[begin+2:end]
    img = Image.to_image(sub_text)
    run = img if img else Link.from_org_string(sub_text)
    return run, 1


def _create_math_dollar_run(
    text: str, begin: int, end: int
) -> tuple[RunsType, int]:
    """Create an inline math run with single dollar delimiters"""
    return InlineMath(content=text[begin+1:end]), 0


def _create_math_parens_run(
    text: str, begin: int, end: int
) -> tuple[RunsType, int]:
    """Create an inline math run with parenthesis delimiters"""
    return InlineMath(content=text[begin+2:end]), 1


def _create_markup_run(
    text: str, begin: int, end: int, token: 'Token'
) -> tuple[RunsType, int]:
    """Create a markup run."""
    return Markup(text=text[begin+1:end],
                  markup_type=Markup.Type(token.value)), 0


def _get_token_handler(token: 'Token'):
    """Get the appropriate handler function for a token type."""
    handlers = {
        Token.LINK: _create_link_or_image_run,
        Token.MATH_DOLLAR: _create_math_dollar_run,
        Token.MATH_PARENS: _create_math_parens_run,
    }
    return handlers.get(token)


def _create_run_from_token(
    text: str,
    token: 'Token',
    begin: int,
    end: int
) -> tuple[RunsType, int]:
    """
    Create a run from a token,
    returning the run and close character offset
    """
    handler = _get_token_handler(token)

    if handler:
        return handler(text, begin, end)

    return _create_markup_run(text, begin, end, token)


def _extract_text_before_first_pair(
    text: str, pairs: list
) -> str:
    """Extract text that appears before the first token pair"""
    if not pairs:
        return ""

    begin = 0
    end = pairs[0][1]

    if end <= begin:
        return ""

    run_text = text[begin:end]

    return run_text


def _extract_text_after_pair(
    text: str, end: int,
    close_char_offset: int, next_begin: int
) -> str:
    """Extract text that appears after a token pair."""
    start_pos = end + 1 + close_char_offset
    return text[start_pos:next_begin] if text[start_pos:next_begin] else ""


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 = _extract_text_before_first_pair(text, pairs)
    if text_before:
        result.append(text_before)

    for idx, (token, begin, end) in enumerate(pairs):
        run, close_char_offset = _create_run_from_token(
            text, token, begin, end
        )
        result.append(run)

        next_begin = pairs[idx + 1][1] if idx < len(pairs) - 1 else None
        text_after = _extract_text_after_pair(
            text, end, close_char_offset, next_begin
        )

        if text_after:
            result.append(text_after)

    return result
