# 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 folder LICENSES or go to <https://www.gnu.org/licenses/#GPL>.
"""Collection of elements of on org file.

In the concept of Hyperorg an element is a vertical run. See module `textruns`
for further details.
"""

from __future__ import annotations
import logging
import uuid
import re
from typing import Optional, Union
from dataclasses import dataclass
from hyperorg import textruns


_log = logging.getLogger(__name__)


class Paragraph(textruns.Run):  # pylint: disable=too-few-public-methods
    """A paragraph element."""

    def __init__(self, lines: str | list[str] | None = None):
        # Workaround: See #191
        if isinstance(lines, list):
            content = ' '.join(lines)
        else:
            content = lines

        runs = textruns.cut_into_runs(content) if content else []

        super().__init__(runs=runs)

# Dev note (2025-10, buhtz): Why do we use @dataclass? The reasons is
# that the methods __repr__() and __eq__() are automatically generated.
# Generation of __init__() is omitted because of "init=False"


@dataclass(init=False)
class Heading(textruns.Run):
    """A heading element."""

    __slots__ = ('level', 'anchor', )

    level: int
    anchor: str | None

    def __init__(self, label: str):

        stripped_label = label.lstrip()

        # lstrip from the second blank
        stripped_label = stripped_label[stripped_label.index(' '):].lstrip()

        runs = textruns.cut_into_runs(stripped_label)
        super().__init__(runs=runs)

        self.anchor = None
        """See `generate_anchor()` for details."""

        # enum symbol
        idx_stop = len(label) - len(stripped_label)
        heading_symbol = label[:idx_stop].strip()

        # The title of a node is the first heading level. Because of
        # that the first content heading (one * bullet) is the second
        # level.
        """Heading level."""
        self.level = len(heading_symbol) + 1

    def generate_anchor(self):
        """Set a random and unique anchor string for that heading.

        That anchor is later use to link to that heading (e.g.
        ``<a href="page.html#Heading_anchor">Heading</a>``.
        """
        self.anchor = str(uuid.uuid4())


@dataclass(init=False)
class ListElement(textruns.Run):
    """A list element including its entries.

    The list items are represented by `self.runs` (from `textruns.Run`). Each
    list item is of type `list()` even if it is only a `str`. One exception is
    a sub-list.

    This is an example of a nested list ::

        - one
        - two
          - three

    Representing with this class ::

        ListElement(
            runs=[
                ['one'],
                ['two'],
                ListElement(
                    runs=[
                        ['three']
                    ],
                    ordered=False,
                    _indention=2)
            ],
            ordered=False,
            _indention=0
        )

    """

    __slots__ = ('ordered', '_indention', )

    ordered: bool
    _indention: int | None

    def __init__(
        self,
        item: str,
        ordered: bool,
        indentation: int | None = None
    ):
        super().__init__(runs=[])

        self.ordered = ordered
        """Switch for ordered list."""

        self._indention = indentation
        """Indentation. From begin of a line until its bullet."""

        self.add_item(item)

    def add_item(self, item: str):
        """Add list entry based on string in org syntax.

        Args:
            item: The complete line in org content incl. indentation
                whitespaces and item marker (e.g. bullet).
        """

        if isinstance(item, list):
            self.add_item(item[0])

            for item_part in item[1:]:
                self.append_to_last_item(item_part)

            return

        try:
            # split at whitespaces
            item_parts = item.split()

        except AttributeError:
            # Other than None is an error
            if item:
                raise

            # None is a valid value
            return

        # position of bullet
        indentation = item.index(item_parts[0])

        # set indentation if not set yet
        if self._indention is None:
            # Indentation (to bullet).
            self._indention = indentation

        # higher indentation?
        if indentation > self._indention:

            # Active child?
            if isinstance(self[-1], ListElement):
                # add to existing child
                self[-1].add_item(item)

            else:
                # Check if item is (un)ordered and set this explicit
                child_ordered = item_parts[0][0].isdigit()

                # create new child list
                child = ListElement(item, child_ordered, indentation)
                self.runs.append(child)
        else:

            try:
                # lstrip from the second blank
                item_content = item[item.index(item_parts[1]):]
            except IndexError:
                # item without content e.g. "- "
                item_content = ''

            runs = textruns.cut_into_runs(item_content)
            self.runs.append(runs)

    def belongs_to_me(self, line: str) -> bool:
        """Checks if the indentation of 'line' belongs to this list element.
        """
        try:
            return self.runs[-1].belongs_to_me(line)
        except AttributeError:
            pass

        # count whitespace
        blank_count = len(line) - len(line.lstrip())

        # calculate content indentation
        if self.ordered:
            # e.g. '1. content'
            content_indention = 3 + self._indention
        else:
            # e.g. '- content'
            content_indention = 2 + self._indention

        if content_indention == blank_count:
            return True

        return False

    def append_to_last_item(self, line: str):
        """Append the 'line' to the last list item.
        """

        if isinstance(self[-1], type(self)):
            # a child list
            self.runs[-1].append_to_last_item(line)

        elif isinstance(line, Block):
            # a block element
            self.runs[-1].append(line)

        else:
            blank = '' if isinstance(self.runs[-1][-1], Block) else ' '
            new_runs = textruns.cut_into_runs(blank + line.lstrip())
            self.runs[-1].extend(new_runs)


@dataclass(init=False)
class Block(textruns.Run):
    """Org syntax reference does name this a block.

    It does start with a line beginning with ``#+begin_NAME`` and end with a
    line ``#+end_NAME`` where ``NAME`` can be ``src``, ``example`` or
    something else. In org not all blocks are verbatim
    (e.g. ``begin_quote``). But this block here is meant to be verbatim. Open
    an issue please if you need support for other blocks.
    """
    __slots__ = ('name', 'language')

    name: str
    language: str

    def __init__(self,
                 name: str,
                 content_lines: list[str],
                 language: str = ''):
        super().__init__(runs=content_lines)

        self.language = language
        self.name = name


@dataclass(init=False)
class TableBlock(Block):
    """An Org table or table.el table displayed as a regular code block.

    It is a quick n dirty solution to handle tables. See Issue #143 about
    render tables as HTML. See https://orgmode.org/worg/org-syntax.html#Tables
    about syntax reference.
    """

    def __init__(self, content_lines: list[str]):
        # Language "text" is used because the "org" lexer in pygments is known
        # not to support org- or tables.el style tables.
        # See: https://github.com/pygments/pygments/discussions/2688
        super().__init__(
            name='src', content_lines=content_lines, language='text')


@dataclass(init=False)
class DisplayMath(textruns.Run):  # pylint: disable=too-few-public-methods
    """Class for math (display) elements"""

    def __init__(self, content: str) -> None:
        super().__init__(runs=[content])


@dataclass(init=False)
class Figure(textruns.Run):
    """A Figure contain an image and optionally a link."""
    _REX_PROPERTY = re.compile(r'^#\+(.*?): *(.*)$')

    _REX_LABEL_TARGET = re.compile(r'^\[\[((?:(?!]\[).)*)(?:]\[(.*))?]]$')
    """Credits: https://stackoverflow.com/a/75996345/4865723"""

    __slots__ = ('attributes', 'unknown')
    attributes: dict[str, str]
    unknown: list[str]

    @staticmethod
    def to_properties_dict(lines: list) -> dict:
        """Extract information from lines beginning with `#+CAPTION`,
        `#+ATTR_HTML` or other org properties. Empty lines not allowed.

        Args:
            dash_plus: List of lines taken from an org file.

        Raises:
            ValueError: If lines are empty.

        Returns:
            A dictionary containing the extracted information.
        """
        # pylint: disable=invalid-name

        result = {'attr': {}, 'caption': None, 'unknown': []}

        try:
            properties = [
                Figure._REX_PROPERTY.match(line).groups() for line in lines]
        except AttributeError as exc:
            raise ValueError('Not all lines are org property lines!\n'
                             '{}'.format('\n'.join(lines))) from exc

        for loop_prop, val in properties:

            prop = loop_prop.lower()

            # Caption
            if prop == 'caption':
                result['caption'] = val
                continue

            # Attributes
            if prop == 'attr_html':
                attr = ' ' + val
                attr = attr.split(' :')[1:]  # first element is always empty

                for a in attr:
                    k, v = a.split(' ', 1)
                    result['attr'][k] = v

                continue

            # Something else
            result['unknown'].append(f'#+{prop.upper()}: {val}')

        return result

    def __init__(
        self,
        image_or_link: Union(textruns.Image, textruns.Link),
        properties: Optional[dict] = None
    ):
        """
        The `line` is the full line (e.g. "[[image.png]]") from the org file
        without modification. The `properties` are all previous
        lines in the org file beginning (case insensitive) with `#+CAPTION`
        or `#+ATTR_HTML` and other org properties.
        """
        if properties is None:
            properties = {}

        runs = [image_or_link]

        # Attributes
        self.attributes = properties.get('attr', {})

        # Unknown org properties (e.g. '#+DOWNLOAD: xxx')
        self.unknown = properties.get('unknown', None)

        # Caption
        caption = properties.get('caption', None)
        if caption:
            caption = textruns.cut_into_runs(caption)
            runs.append(caption)

        super().__init__(runs=runs)

        self.attributes = self.image.set_attributes(self.attributes)

    @property
    def link(self) -> bool:
        """Return the link instance of that figure otherwise `None`."""
        if isinstance(self[0], textruns.Link):
            return self[0]

        return None

    @property
    def image(self) -> textruns.Image:
        """Return the image instance of the figure."""
        try:
            # first run of the Link if its label (the image)
            return self.link[0]
        except TypeError:
            # It is not a link so it must be an Image
            return self[0]

    @property
    def caption(self) -> list[textruns.RunsType]:
        """The figures caption as a list of runs.

        Returns:
            A list of runs.
        """
        try:
            return self[1]
        except IndexError:
            return None


class Backlinks(ListElement):
    """The backlinks of a node.

    Most of the time the backlinks are located in a section at the end of
    a node.
    """

    def __init__(self):
        super().__init__(item=None, ordered=False, indentation=None)

    def add_link(self, link: textruns.Link) -> None:
        """Add a backlink.
        """
        self.runs.append([link])

    def add_sub_link(self, link: textruns.Link) -> None:
        """Add a backlink linking to a subheading instead of a node.

        That link is indented to the next level.
        """

        # Add a sublist element if not present
        if not isinstance(self.runs[-1], ListElement):
            if not self._indention:
                indent = 0
            else:
                indent = self._indention

            indent += 2

            self.runs.append(ListElement(
                item=None,
                ordered=False,
                indentation=indent
            ))

        # Add link to the sublist
        self.runs[-1].runs.append([link])
