# 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 LICENSES directory
# or go to <https://spdx.org/licenses/GPL-3.0-only.html>.

"""Read org files from the file system and initiate parsing."""
import sys
import logging
import pathlib
import glob
import re
import unicodedata
from collections.abc import Hashable
from typing import Iterable, Dict
import shlex
import orgparse
from hyperorg import LoggingContext
from hyperorg.content import Node

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


class Reader:
    """Read the Org-roam files via `orgparse` and convert them to Content
    objects.

    The resulting objects are stored as a list of objects of type
    'hyperorg.content.Content' in 'hyperorg.content.Content.nodes'.
    """

    def __init__(self, input_dir: pathlib.Path):
        """Reader  ctor."""
        _log.info(f'Input from: {input_dir}')

        # inputdir exists?
        if not input_dir.exists():
            _log.error(f'Input directory {input_dir} does not exists.')
            sys.exit(1)

        elif not input_dir.is_dir():
            _log.error(f'{input_dir} is not a directory.')
            sys.exit(1)

        # remember the input directory
        Node.input_dir = input_dir

    def _orgparse_obj_from_input_dir(self):
        # WORKAROUND: Using str(fp) here instead of fp as a Path object itself
        # orgparse.load() can not handle Path objects from pyfakefs

        result = []

        for fp in self._glob_from_input_dir():
            with LoggingContext(fp.name):
                try:
                    node = orgparse.load(fp)

                    if 'ID' in node.properties:
                        result.append(orgparse.load(fp))

                    else:
                        _log.error(
                            'Loading via "orgpase" not possible. The org '
                            'property "ID" is missing. Check the content of '
                            'the org-file.')

                except UnicodeDecodeError as exc:
                    _log.error(
                        f'Loading via "orgpase" not possible because of '
                        f'this error: {str(exc)} ({exc.__class__.__name__})')

                # pylint: disable-next=broad-exception-caught
                except Exception as exc:
                    _log.critical(
                        f'Loading via "orgpase" not possible because of '
                        'this unexpected error: '
                        f'{str(exc)} ({exc.__class__.__name__})')

        return result

    def _glob_from_input_dir(self) -> list[pathlib.Path]:
        """
        Using `glob.glob()`. Give all files and folders not
        hidden. None-hidden files in hidden files are excluded.
        """

        result = []

        # regex pattern: "org" suffix case insensitive
        rex_org = re.compile(r'(?i).*\.org$')

        glob_all = glob.glob(f'{Node.input_dir}/**/*', recursive=True)

        for fp in [pathlib.Path(s) for s in glob_all]:

            # ignore non files
            if not fp.is_file():
                continue

            # org file
            if rex_org.match(str(fp)):
                result.append(fp)

        _log.debug(f'{len(result)} files found.')

        return result

    def read_org_files(self) -> int:
        """Read all org files via `orgparse` package.

        Returns:
            The number of nodes read.
        """

        # Contain backlinks of all existing nodes
        # Example:
        # {
        #     'node-A': {
        #         'node-B': [],
        #         'node-C': ['sub-C'],
        #     },
        #     'other-node': {
        #         'node-X': [],
        #     }
        # }
        # "node-A" with two backlinks. One to "node-B" and one to the
        # sub-heading "sub-C" of "node-C".
        # "other-node" with one backlink to "node-X".
        all_backlinks = {}

        # list of orgparse.node.OrgNode
        orgparse_nodes_list = self._orgparse_obj_from_input_dir()

        # each orgparse node
        for node in orgparse_nodes_list:
            ctx = pathlib.Path(node.env.filename).relative_to(Node.input_dir)
            with LoggingContext(str(ctx)):
                # Org-roam ID
                orgid = node.properties['ID']

                # Create orgparse independent content object
                content, backlinks = self._content_from_orgparse_node(node)

                # stored it indexed by its orgid
                Node.nodes[orgid] = content

                # remember the backlinks of that node
                all_backlinks = self.update_backlinks(all_backlinks, backlinks)

                # add to index data
                Reader._add_to_index_data(content, orgid)

        # Backlinks from subheadings are transformed to their parents
        all_backlinks \
            = self.set_parents_for_subheading_backlinks(all_backlinks)

        # Add backlink elements to the nodes
        Node.add_all_backlinks(all_backlinks)

        _log.info(f'{len(Node.nodes)} nodes loaded.')

        return len(Node.nodes)

    @staticmethod
    def _add_to_index_data(content, orgid):
        """
        Content.index_data

        """

        # pylint: disable=protected-access

        def add_entry_to_index_data(entry: tuple, letter: str, filetags: list):
            Node.index_data[Node.IDX_ALL][letter].append(entry)

            if filetags:
                for tag in filetags:
                    Node.index_data[Node.IDX_FILETAG][tag][letter] \
                        .append(entry)
            else:
                Node.index_data[Node.IDX_FILETAG][
                    Node.IDX_NO_TAG][letter].append(entry)

        filetags = content._meta.get('filetags', [])

        # original title
        title = content.title_runs
        letter = Reader._title_to_index_letter(title[0])
        entry = (title, orgid, False)

        add_entry_to_index_data(entry, letter, filetags)

        # alias titles
        for alias_runs in content._meta.get('aliases_runs', []):
            letter = Reader._title_to_index_letter(alias_runs[0])
            entry = (alias_runs, orgid, True)
            add_entry_to_index_data(entry, letter, filetags)

    @staticmethod
    def _title_to_index_letter(title: str) -> str:
        """Extract index letter from a title string.

        Usually the index letter is the first letter of a title converted to
        upper-case. But here umlaut will taken into account also and converted
        to there non-umlaut pendant.

        Args:
            title: The title string.

        Returns:
            The index letter.
        """

        letter = title[0]
        letter = ''.join(
            [c for c in unicodedata.normalize('NFKD', title[0])
             if not unicodedata.combining(c)]
        )

        # A special case is 'ß'.upper() == 'SS'
        return letter.upper()[0]

    def update_backlinks(self,
                         all_backlinks: Dict[Hashable,
                                             Dict[Hashable, Iterable]],
                         new_backlinks: Dict[Hashable,
                                             Dict[Hashable, Iterable]]
                         ) -> Dict[Hashable, Dict[Hashable, Iterable]]:
        """Update `all_backlinks` with data from `new_backlinks`.

        DevNote: No need to be an instance method.
        """
        for linked_id, val in new_backlinks.items():
            if linked_id not in all_backlinks:
                all_backlinks[linked_id] = val
                continue

            for orgid in val:
                if orgid in all_backlinks[linked_id]:
                    all_backlinks[linked_id][orgid].extend(val[orgid])
                else:
                    all_backlinks[linked_id][orgid] \
                        = val[orgid]

        return all_backlinks

    def set_parents_for_subheading_backlinks(
            self, all_backlinks: Dict[Hashable, Dict[Hashable, Iterable]]
    ) -> Dict[Hashable, Dict[Hashable, Iterable]]:
        """Backlinks from subheadings are replaced by their parent nodes.

        Only after reading all org files this is possible.
        """

        # linking subheading_orgid
        backlinking_subheading_orgids \
            = list(filter(lambda val: val in Node.heading_ids,
                          all_backlinks))

        # each backlink from a subheading
        for subheading_orgid in backlinking_subheading_orgids:

            parent_orgid = Node.heading_ids[subheading_orgid][0]

            if parent_orgid in all_backlinks:
                all_backlinks[parent_orgid].update(
                    all_backlinks[subheading_orgid])
            else:
                all_backlinks[parent_orgid] = all_backlinks[subheading_orgid]

            del all_backlinks[subheading_orgid]

        return all_backlinks

    @staticmethod
    def _aliases_from_orgparseobj(orgobj: orgparse.node.OrgNode) -> Iterable:
        """
        """
        aliases = orgobj.properties.get('ROAM_ALIASES', '')
        aliases = shlex.split(aliases)

        return aliases

    def _content_from_orgparse_node(
            self,
            orgobj: orgparse.node.OrgRootNode) -> tuple[Node, dict]:
        """Convert an orgparse node to a content object.

        The return value is a tuple with is a `Node`. The second entry is
        `dict` containing information about the backlinks.

        Returns:
            A ``content`` and ``backlinks`` object.
        """

        # pylint: disable=protected-access

        # nodes filename relative to input directory
        node_fn = pathlib.Path(orgobj.env.filename)
        node_fn = node_fn.relative_to(Node.input_dir)
        node_fn = str(node_fn)

        # OrgID of that node
        node_orgid = orgobj.properties['ID']

        # alias titles of that node
        aliases = Reader._aliases_from_orgparseobj(orgobj)

        # OrgIDs (keys) that linking to the current node (values)
        backlinks = {}

        # init the content object
        content = Node(
            body_lines=orgobj._body_lines,
            filename=node_fn,
            title_aliases=aliases
        )

        # don't forget the child nodes (headings)
        for child in orgobj.env.nodes[1:]:  # exclude root node

            # heading line
            bullets = '*' * child._level
            heading_line = f'{bullets} {child._heading}'

            # create heading
            try:
                # is there a OrgID for that heading?
                child_orgid = child.properties['ID']

            except KeyError:
                # child node doesn't have its own OrgID
                content.parse_lines([heading_line] + child._body_lines)

            else:
                # heading with its own orgid
                Node.heading_ids[child_orgid] \
                    = (node_orgid, len(content))

                # alias titles of that node
                aliases = Reader._aliases_from_orgparseobj(child)

                body_lines = [
                    # meta: Title of that node
                    f'#+title: {child._heading}',
                    # element Heading:
                    heading_line,
                    # rest of the body
                    *child._body_lines
                ]

                sub_content = Node(
                    body_lines=body_lines,
                    filename=node_fn,
                    title_aliases=aliases,
                    anchor_first_heading=True)

                # remember that node for the index_data
                Reader._add_to_index_data(sub_content, child_orgid)

                # links this content is linking to
                for link_id in sub_content.get_orgid_links():
                    if link_id in backlinks:
                        backlinks[link_id][node_orgid].append(child_orgid)
                    else:
                        backlinks[link_id] = {node_orgid: [child_orgid]}

                content.add_subheading_content(sub_content)

        # Backlinks: OrgIDs that node is linking to
        for link_id in content.get_orgid_links():

            # prevent duplicates
            if link_id not in backlinks:
                backlinks[link_id] = {node_orgid: []}

        return content, backlinks
