# 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>.
"""The content module offering the Node class."""
from __future__ import annotations
import logging
import re
import pathlib
from collections import defaultdict
import shlex
from typing import Hashable, Iterable, Dict
import dateutil.parser  # python-dateutil
from hyperorg import elements
from hyperorg import textruns
from hyperorg import parser

_log = logging.getLogger(__name__)


class Node:
    """Org-file body in a generalized and strcutured format.

    The body of an org-file with all its elements (paragraphs, lists, ...) and
    their structure is represented by an object of that clase. On base of one
    of its objects it can easiely transformed to another file-format
    (e.g. HTML). An object Content is the input for the Exporter class.

    The Org syntax reference can be found at
    https://orgmode.org/worg/dev/org-syntax.html. It is not an official
    document but there is not a more official. I was pointed to that document
    asking on the mailinglist.

    Development note:
        A node (with "d") in OrgRoam is an entity with an ID. A note (with
        "t") is something more broad.

        Escaping HTML entities (e.g. "&" into "&amp;") is done here in that
        class (see Issue #47). This is not a clear solution because HTML
        related operations should be done in `Exporter`. The `Content` class
        should be independent from output format. In consequence the current
        solution is not for ever and will be refactored once.
    """

    input_dir: pathlib.Path
    """Input directory where the Org-roam content was read from"""

    nodes: dict = {}
    """List of all Org-roam nodes in `Content` format"""

    heading_ids: dict = {}
    """Org-IDs of headings (subtrees)"""

    IDX_ALL = 'all'

    IDX_FILETAG = 'filetag'

    IDX_NO_TAG = '(no tags)'

    index_data: dict = {
        IDX_ALL: defaultdict(list),
        IDX_FILETAG: defaultdict(lambda: defaultdict(list))
    }
    """Nested dictionary containing glossary like data."""

    META_FIELDS_INHERIT = ['date', 'filetags']
    """See `inherit_meta_fields_from()`. """

    # 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_BLOCK_BEGIN = re.compile(r'^#\+(?i:begin)_(\S+)(?:\s(.+))?')

    _RE_PATTERN_BLOCK_END = r'^#\+(?i:end)_(?i:{})'

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

    __slots__ = (
        '_orgids_linking',
        'filename',
        '_meta',
        # '_previous_properties',
        # '_remembered_paragraph_lines',
        # '_remembered_list_lines',
        'title_org',
        'title_runs',
        '_elements',
        # '_e',
    )

    def __init__(self,
                 body_lines: Iterable[str],
                 filename: str,
                 title_aliases: Iterable[str] = None,
                 anchor_first_heading: bool = False):
        """Content ctor."""

        if title_aliases is None:
            title_aliases = []
        #
        self._orgids_linking = []

        # Name of the original Org-roam file
        self.filename = filename

        # Node title
        self.title_org = '[no title]'
        self.title_runs = textruns.cut_into_runs(self.title_org)

        # Meta data about that Org-roam node
        self._meta = {}

        # # Parsed property lines (beginning with '#+')
        # self._previous_properties = []

        # # Paragraph lines to join later into one
        # self._remembered_paragraph_lines = []

        # # List item lines to join later into one
        # self._remembered_list_lines = []

        # Title aliases (from :ROAM_ALIASES)
        if title_aliases:
            self._meta['aliases_org'] = []
            self._meta['aliases_runs'] = []
            for one_alias in title_aliases:
                self._meta['aliases_org'].append(one_alias)
                self._meta['aliases_runs'].append(
                    textruns.cut_into_runs(one_alias))

        # list of content elements
        self._elements = []

        # # the current active element
        # self._e = None

        self.parse_lines(body_lines)

        # When this Content instance represent a subheading with its own OrgID
        if anchor_first_heading:
            if not isinstance(self._elements[0], elements.Heading):
                raise TypeError('First element need to be a heading.')

            self._elements[0].generate_anchor()

    def _on_link_created(self, linkrun):
        if linkrun.kind == textruns.Link.Kind.ORGID:
            self._orgids_linking.append(linkrun.target)

    def __getitem__(self, idx: int):
        return self._elements[idx]

    def __len__(self):
        return len(self._elements)

    def _parse_meta_lines(self, to_parse: list) -> list:
        """Parse from the beginning lines starting with '#+'.

        Only lines in the beginning of `to_parse` are parsed until a
        none '#+' line appears. The consequence is that later lines beginning
        with '#+' (e.g. '#+src_beginn') are irgnored here.

        Returns:
            The list without the parsed lines.
        """

        for idx, line in enumerate(to_parse):
            try:
                name = re.findall(r'#\+(.+?): ', line)[0]
                self._meta[name] = line.replace(f'#+{name}: ', '')

                if name in ['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
                    tags = shlex.split(self._meta[name])
                    self._meta[name] = tags
                    # next line
                    continue

                if 'date' in name or 'time' in name:
                    try:
                        # remove brackets for (in)active dates
                        val = self._meta[name].strip('<>[]')
                        # parse
                        self._meta[name] = dateutil.parser.parse(val)
                    except dateutil.parser.ParserError:
                        pass
                    continue

            except IndexError:
                # rest of the lines
                return to_parse[idx:]

        # There are two cases when this happen.
        # 1. 'to_parse' was empty and the loop never iterates
        # 2. 'to_parse' iterated to the end without any IndexError. It means
        #    that all lines are property lines.
        return []

    def add_subheading_content(self, content):
        """Add child node to this one.
        """

        content.inherit_meta_fields_from(self)

        # Content element, subtree/heading with own OrgId
        # self._finish_current_element()
        # self._e = content
        self._elements.append(content)
        # self._finish_current_element()

    def inherit_meta_fields_from(self, content: Node):
        """Some meta fields from ``content`` are copied into the own.

        Which fields are copied is specified by `META_FIELDS_INHERIT'.

        Args:
            content: The content object to copy from.
        """

        for field in Node.META_FIELDS_INHERIT:
            try:
                val = content._meta[field]  # pylint: disable=protected-access
            except KeyError:
                pass
            else:
                own_val = self._meta.get(field, None)

                # just copy
                if own_val is None:
                    self._meta[field] = val
                    continue

                if not isinstance(own_val, list):
                    own_val = [own_val]

                if not isinstance(val, list):
                    val = [val]

                self._meta[field].extend(val)

    @classmethod
    def add_all_backlinks(cls,
                          backlinks: Dict[Hashable, Dict[Hashable, Iterable]]):
        """Create backlinks elements for all nodes.
        """

        for orgid in backlinks:
            if orgid in cls.nodes:
                cls.nodes[orgid].add_backlinks(backlinks[orgid])

    def add_backlinks(self, backlinks: Dict[Hashable, Iterable]):
        """Create backlink element for that node."""

        # self._finish_current_element()

        for orgid in backlinks:
            # create org-file line
            item_string = f'- [[id:{orgid}][{Node.nodes[orgid].title_org}]]'

            # create backlink element if needed
            if (not self._elements
                    or not isinstance(self._elements[-1], elements.Backlinks)):
                self._elements.append(
                    elements.Backlinks(item=item_string))

            else:
                self._elements[-1].add_item(item_string)

            # subheadings?
            for subheading_id in backlinks[orgid]:
                idx = Node.heading_ids[subheading_id][1]
                subheading_title = str(Node.nodes[orgid][idx][0])

                # create org-file line
                item_string = f'  - [[id:{subheading_id}][{subheading_title}]]'

                self._elements[-1].add_item(item_string)

        # self._finish_current_element()

    def parse_lines(self, to_parse: list):
        """Create Content elements based on text lines.

        Classify a list of lines and create Content elements
        (e.g. Content.Paragraph) of it.
        To differentiate a list item from a head when an asterix is used as
        bullet it is important to check for heading first.
        """

        textruns.Link.event_link_created.register(self._on_link_created)

        to_parse = self._parse_meta_lines(to_parse)

        # the title was identified from a meta field first
        try:
            # Title as runs is used in the <h1> element.
            # But when handling (back)links it might be easier using the
            # original org-syntax not parsed into runs.
            self.title_org = self._meta.pop('title')
            self.title_runs = textruns.cut_into_runs(self.title_org)
        except KeyError:
            pass

        self._elements.extend(parser.OrgContentParser(to_parse).result())

        textruns.Link.event_link_created.deregister(self._on_link_created)

    # def _is_list_line(self, raw_line: str) -> bool:
    #     """Does ``raw_line`` belongs to the current list or if there is a
    #        list.

    #     """

    #     # If there are no previous lines then this one can no belong to one.
    #     if not self._remembered_list_lines:
    #         return False

    #     content = raw_line.lstrip()

    #     line = (
    #         None,  # no bullet
    #         len(raw_line) - len(content),
    #         content
    #     )

    #     content_start_indices = [
    #         val[1] for val in self._remembered_list_lines]

    #     if any([line[1] >= val for val in content_start_indices]):
    #         self._remembered_list_lines.append(line)
    #         return True

    #     return False

    # def _finish_current_element(self):

    #     # Paragraph lines?
    #     if self._remembered_paragraph_lines:
    #         self._e = elements.Paragraph(self._remembered_paragraph_lines)
    #         self._remembered_paragraph_lines = []

    #     # list
    #     if self._remembered_list_lines:
    #         lines = Node._join_list_lines_to_item_lines(
    #             self._remembered_list_lines)
    #         self._remembered_list_lines = []

    #         self._e = elements.ListElement(
    #             item=lines[0][1], ordered=lines[0][0])

    #         for line in lines[1:]:
    #             self._e.add_item(line[1], ordered=line[0])

    #     # Element available
    #     if self._e is not None:
    #         # store that element
    #         self._elements.append(self._e)
    #         self._e = None

    #     if self._previous_properties:
    #         self._elements.append(
    #             elements.Paragraph(self._previous_properties))
    #         self._previous_properties = []

    # def _add_possible_figure(self, line):
    #     """Try to add the line as a figure otherwise as a link.
    #     """

    #     img_or_link = textruns.Image.to_image_or_linked_image(line[2:-2])

    #     if img_or_link:
    #         if self._previous_properties:
    #             props = elements.Figure.to_properties_dict(
    #                 self._previous_properties)
    #             self._previous_properties = []

    #         else:
    #             props = {}

    #         self._e = elements.Figure(img_or_link, props)

    #     else:
    #         self._remember_paragraph_line(line)

    # def _add_heading(self, line):
    #     """
    #     """
    #     self._finish_current_element()
    #     self._e = elements.Heading(line)

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

    #     Args:
    #         lines: A list of 4 item tuples.

    #     Each tuple in ``lines`` consists of four 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. The fourth indicates if it
    #     is an ordered or unordered list.

    #     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:
    #             result.append(
    #                 (
    #                     # (un)ordered
    #                     one_line[3],
    #                     # string incl. bullet
    #                     '{blank}{bullet} {content}'.format(
    #                         blank=' ' * one_line[0],
    #                         bullet='1.' if one_line[3] else '-',
    #                         content=one_line[2]
    #                     )
    #                 )
    #             )

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

    #     return result

    # @staticmethod
    # def _classify_list_line(raw_line: str, ordered: bool) -> tuple:
    #     """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. ``None`` if no
    #            bullet is present.
    #         2. Index of the beginning of the content.
    #         3. The content (excluding leading space and bullet).
    #         4. Value of ``ordered``.

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

    #     return [
    #         bullet_start_idx,
    #         content_start_idx,
    #         raw_line[content_start_idx:].lstrip(),
    #         ordered
    #     ]

    # def _add_list_item(self, raw_line: str, ordered: bool):
    #     """Lines with a bullet in front.
    #     """

    #     # begin of a list?
    #     if not self._remembered_list_lines:
    #         self._finish_current_element()

    #     line = Node._classify_list_line(raw_line, ordered)

    #     self._remembered_list_lines.append(line)

    # def _remember_paragraph_line(self, line):
    #     """
    #     """

    #     if not self._remembered_paragraph_lines:
    #         self._finish_current_element()

    #     self._remembered_paragraph_lines.append(line)

    def get_orgid_links(self) -> list:
        """Give a list of OrgID's this content instance is linking to.

        Be aware that subheading content objects are ignored.

        Returns:
            List of OrgID's
        """

        return self._orgids_linking
