# 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>.
"""Read org files from the file system and initiate parsing."""
import sys
import logging
import pathlib
import glob
import re
import unicodedata
from typing import Hashable, Iterable, Dict
import shlex
import orgparse
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 = []

        # # `glob.glob()` behave different then `pathlib.Path.glob()` in case
        # # of hidden files and folders. The former ignore hidden files and
        # # folders, the latter not.
        # input_glob = glob.glob(str(Content.input_dir) + '/' + '**/*.org',
        #                        recursive=True)
        # for fp in input_glob:

        for fp in self._glob_from_input_dir():
            _log.debug(f'Reading "{fp}" via orgparse.')
            result.append(orgparse.load(fp))

        _log.debug('Finished reading all org files.')

        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 p in [pathlib.Path(s) for s in glob_all]:

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

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

        return result

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

        Returns:
            The number of nodes read.
        """

        #
        all_backlinks = {}

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

        # each orgparse node
        for n in orgparse_nodes_list:
            # Org-roam ID
            orgid = n.properties['ID']

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

            # 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 ft in filetags:
                    Node.index_data[Node.IDX_FILETAG][
                        ft][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 in new_backlinks:
            if linked_id not in all_backlinks:
                all_backlinks[linked_id] = new_backlinks[linked_id]
                continue

            for orgid in new_backlinks[linked_id]:
                if orgid in all_backlinks[linked_id]:
                    all_backlinks[linked_id][orgid].extend(
                        new_backlinks[linked_id][orgid]
                    )
                else:
                    all_backlinks[linked_id][orgid] \
                        = new_backlinks[linked_id][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) -> Node:
        """Convert an orgparse node to a content object.

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

        # pylint: disable=protected-access

        _log.debug(f'TITLE="{orgobj._special_comments["TITLE"]}" '
                   f' {orgobj.properties=}')

        # 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 others (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)

        # 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
