# 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>.
# pylint: disable=too-many-lines
"""Exporting runs and elements to HTML"""
import sys
import logging
import pathlib
import datetime
import locale
import os
import copy
import secrets
from collections import Counter
from contextlib import contextmanager
from typing import Union, Any, Dict, Iterable
from html import escape as html_escape
from packaging import version
import hyperorg
from hyperorg.content import Node
from hyperorg import elements
from hyperorg import textruns
from hyperorg import stylecss
from hyperorg import LoggingContext

try:
    import pygments
    import pygments.lexers
    import pygments.formatters
except ImportError:
    pygments = False

_log = logging.getLogger(__name__)
_TAB = ' ' * 4

_MSG_INSECURE_HTTP = 'For security reasons, HTTP URLs are not supported. ' \
                     'Find an alternative HTTPS source.'


@contextmanager
def node_context(node_id: str):
    """Just a workaround. Will be replaced when there is a final decision about
    new design elements."""
    Exporter.current_processing_node_id = node_id

    yield

    Exporter.current_processing_node_id = None


class Exporter:  # pylint: disable=too-few-public-methods
    """Class handling exporting runs and elements to HTML.

    Dev node: Consider to refactor and move most of it into module level. See
    Issue #111.
    """
    _PLACEHOLDER_MATHJAX = '{{ mathjax }}'
    _PLACEHOLDER_TITLE = '{{ title }}'
    _PLACEHOLDER_BODY = '{{ body }}'
    _PLACEHOLDER_FOOTER = '{{ footer }}'
    _PLACEHOLDER_LANG = '{{ language }}'
    _PLACEHOLDER_INDEX_LETTER_NAV = '{{ index letter nav }}'
    _PLACEHOLDER_NODE_NAV = '{{ node nav }}'

    output_dir: pathlib.Path
    """Output directory where the HTML files are written into."""

    output_dir = None
    use_hardlinks = False

    current_processing_node_id = None
    """Workaround"""

    def __init__(self, output_dir: pathlib.Path, use_hardlinks: bool = False):

        if use_hardlinks:
            _log.info('Using hardlinks instead of symlinks.')

        if not pygments:
            _log.info('Verbatim blocks without colors and syntax highlighting'
                      ' because can not import package "pygments".')

        _log.info(f'Output to: {output_dir}')

        try:
            output_dir.mkdir(parents=True, exist_ok=True)
        except FileExistsError:
            _log.error(f'The output folder {output_dir} is an existing file. '
                       'Use a folder instead.')
            sys.exit(1)

        Exporter.output_dir = output_dir
        Exporter.use_hardlinks = use_hardlinks

        self._footer_string = None
        self._hyperorg_css_content = None
        self._pygments_css_content = None

    @staticmethod
    def _as_html_filename(org_filename: str) -> str:
        """Translate an org-filename to its html pendant."""
        return pathlib.Path(org_filename).with_suffix('.html')

    @staticmethod
    def _as_index_filename(base_name: str, mode: str) -> pathlib.Path:
        """Generate the name for an index HTML file.

        Args:
            base_name: Basis for the name (e.g. "MyTag")
            mode: Supplemental specifier (e.g. "filetag" or "year")

        Returns:
            The file path.
        """
        return pathlib.Path(f'index__{mode}_{base_name}.html')

    def _clean_output_dir(self):
        """Remove all files from the output directory.
        """
        logging.info('Delete all files and folders in the output directory.')

        out_dir = Exporter.output_dir

        # credits: https://stackoverflow.com/a/10886685/4865723
        if list(out_dir.rglob('**/*.[oO][rR][gG]')):
            raise FileExistsError(
                f'There are ORG files in the output folder {out_dir}'
                '. Maybe you have confused this with the input folder?')

        # credits to myself
        # https://stackoverflow.com/a/72333396/4865723
        file_objects = sorted(out_dir.rglob('**/*'),
                              key=lambda v: (v.is_dir(), -len(v.parts)))
        logging.debug(f'{len(file_objects)} file objects')

        for fp in file_objects:
            if fp.is_file() or fp.is_symlink():
                fp.unlink()
            elif fp.is_dir():
                fp.rmdir()

    def _set_css_content(self):  # noqa: PLR0915
        """Create CSS content in the output directory.

        A ``pygments.css`` is created using `pygments`. A ``style.css`` is
        created if not present from the previous run of Hyperorg. If present
        but outdated it is replaced also by a new default ``style.css``. It
        is outdated if the version of current running Hyperorg is equal or
        higher than `stylecss.LAST_MODIFICATION`.

        Dev note: A refactor candidate!
        """
        hyperorg_css_file_path = Exporter.output_dir / 'style.css'
        pygments_css_file_path = Exporter.output_dir / 'pygments.css'

        def _current_css_content(fp: pathlib.Path) -> str:
            """Read content of a CSS file if it exists. Otherwise return
            `None`.
            """
            try:
                # use style from last hyperorg run
                with fp.open('r', encoding='utf-8') as handle:
                    result = handle.read()

            except FileNotFoundError:
                result = None

            return result

        def _css_generated_by_previous_hyperorg_version(css_content: str
                                                        ) -> str:
            """Extract from css file the version of Hyperorg used to generate
            the CSS content."""

            if not css_content:
                return False

            try:
                # extract name and version of css file
                app, ver = stylecss.REX_GENERATOR.search(css_content).groups()
            except AttributeError:
                return False

            if app != hyperorg.__name__:
                _log.warning(
                    'Extracted application name from style.css '
                    f'was "{app}" but expected "{hyperorg.__name__}".')
                return None

            return ver

        self._hyperorg_css_content \
            = _current_css_content(hyperorg_css_file_path)

        previous_version = _css_generated_by_previous_hyperorg_version(
                self._hyperorg_css_content)

        # WORKAROUND
        # Reason: The version check of style.css was not introduced in
        # version '0.1.0'.
        if not previous_version and hyperorg.__version__ == '0.1.1':
            previous_version = '0.1.0'

        try:
            previous_version = version.parse(previous_version)
        except TypeError:
            # No previous_version available. Assume the style.css is user
            # defined and don't touch it.
            if self._hyperorg_css_content:
                logging.info('It is assumed that the "style.css" was created '
                             'by the user.')

        else:  # no exception
            if previous_version < version.parse(stylecss.LAST_MODIFICATION):

                backup_fp = pathlib.Path(hyperorg_css_file_path)
                backup_fp = backup_fp.with_suffix(
                    f'.{hyperorg.__name__}.{previous_version}'
                    f'{backup_fp.suffix}')

                _log.warning(
                    'Found "style.css" generated by outdated version '
                    f'{previous_version} of {hyperorg.__name__}. It is renamed'
                    f' to {backup_fp.name} and replaced by a fresh default '
                    f'"{hyperorg_css_file_path.name}".'
                )

                hyperorg_css_file_path.rename(backup_fp)

                self._hyperorg_css_content = None

        if self._hyperorg_css_content:
            logging.info(f'Reuse "{hyperorg_css_file_path.name}".')

        else:
            logging.info(
                f'Generate fresh default "{hyperorg_css_file_path.name}".')
            self._hyperorg_css_content = stylecss.CSS_CONTENT

        # Pygments CSS
        if pygments:
            self._pygments_css_content \
                = _current_css_content(pygments_css_file_path)

            if self._pygments_css_content:
                logging.info(
                    f'Reuse "{pygments_css_file_path.name}".')

            else:
                logging.info('Generate Pygments CSS.')

                css = pygments.formatters.HtmlFormatter() \
                                         .get_style_defs('pre')

                # remove <pre> tag style definitions
                css = css.split('\n')
                css = filter(lambda line: not line.startswith('pre { '), css)
                css = '\n'.join(css)

                # add comment to header
                css = stylecss.PYGMENTS_CSS_HEADER + css

                self._pygments_css_content = css

        else:
            self._pygments_css_content = None

    def _write_css_content(self):
        """
        # Create a CSS file in the empty output directory
        """
        def _write_css_helper(fp: pathlib.Path, content: str):
            with fp.open('w', encoding='utf-8') as handle:
                logging.debug(f'Write "{fp}".')
                handle.write(content)

        _write_css_helper(Exporter.output_dir / 'style.css',
                          self._hyperorg_css_content)

        if self._pygments_css_content:
            _write_css_helper(Exporter.output_dir / 'pygments.css',
                              self._pygments_css_content)

    def export_to_html(self):
        """Convert all content (nodes) to HTLM files.

        The whole content in 'Content.nodes' is converted to HTML and then
        stored as HTML files into the output directory ('self._output_dir').
        """

        # Eventually read the existing CSS file's content (into RAM)
        self._set_css_content()

        # Wipe the whole output directory from previous generated files
        self._clean_output_dir()

        # Create a CSS file in the empty output directory
        self._write_css_content()

        idx_items = Exporter._compute_indexes()

        logging.info(f'Start processing {len(Node.nodes)} nodes...')

        # each node
        for orgid, node in Node.nodes.items():
            with node_context(orgid):  # WORKAROUND
                with LoggingContext(f'"{node.title_org}"'):
                    # generate HTML content
                    html = self._as_html(node, idx_items)

                    # output filename
                    # html_filename = Exporter.generate_href_value(orgid)
                    # fp_out = Exporter.output_dir / html_filename
                    html_filename = Exporter._as_html_filename(node.filename)
                    fp_out = Exporter.output_dir / html_filename

                    fp_out.parent.mkdir(parents=True, exist_ok=True)
                    with fp_out.open('w', encoding='utf-8') as out_file:
                        out_file.write(html)

        def write_index_file(idx, fp):
            _log.debug(f'Index: {fp}')
            html_code = self._node_index_to_html(idx, idx_items)
            fp.write_text(html_code, encoding='utf-8')

        # generate the index files

        # main index
        write_index_file(
            idx=Node.index_data[Node.IDX_ALL],
            fp=Exporter.output_dir / 'index.html')

        # filetag based indexes
        for label, loop_fn, _ in idx_items[1:]:

            fn = Exporter._as_index_filename(loop_fn, Node.IDX_FILETAG)

            write_index_file(
                idx=Node.index_data[Node.IDX_FILETAG][label],
                fp=Exporter.output_dir / fn
            )

        logging.info(f'Wrote {len(Node.nodes)} nodes as HTML files.')

    def _node_index_to_html(self, index_dict: Dict, index_items: list):
        """Generates index files like ``index.html``.

        Args:
            index_dict: Filenames and titles of the index entries.
            index_items: Items used to create navigation bar.
        """

        letter_nav, body = self._node_index_to_html_body(index_dict)

        # full HTML structure
        html = Exporter._get_html_template_index()

        # Language
        lang = Exporter._get_html_lang_from_locale()
        html = html.replace(Exporter._PLACEHOLDER_LANG, lang)

        # Navigation bar
        node_nav = Exporter._index_items_as_html(index_items)
        html = html.replace(Exporter._PLACEHOLDER_NODE_NAV, node_nav)

        # Index letters
        html = html.replace(Exporter._PLACEHOLDER_INDEX_LETTER_NAV, letter_nav)

        # Body
        html = html.replace(Exporter._PLACEHOLDER_BODY, body)

        # Footer
        html = html.replace(Exporter._PLACEHOLDER_FOOTER,
                            self._get_footer())

        return html

    def _node_index_to_html_body(self, index_dict: Dict) -> str:
        """Create a list with titles and links to all nodes in HTML.

        Args:
            index_dict: A dictionary index by alphabet letters with lists of
                tuples each with title and filename.

        Return:
            The HTML content (body).
        """
        body = ''
        nav = ''

        # sort dict by key
        index_dict = {key: index_dict[key] for key in sorted(index_dict)}

        # each index letter
        for letter in index_dict:
            anchor = secrets.token_hex(8)
            # open letter-sec
            body += (f'{_TAB*3}<hr />\n'
                     f'{_TAB*3}<article class="hyperorg-index" '
                     f'id="{anchor}">\n'
                     # f'{_TAB*4}<div class="hyperorg-index__sec-letter">'
                     f'{_TAB*4}<h2>{letter}</h2>\n'
                     f'{_TAB*4}<div>\n')

            # Back to Top - Link
            body += (f'{_TAB*5}<a class="hyperorg-index__toplink" '
                     'href="#top">Back to Top</a>\n')

            # open list of titles
            body += f'{_TAB*5}<ul>\n'

            # node (title) reference
            for title, orgid, is_alias in sorted(index_dict[letter]):

                title_in_html = _html_from_runs(title)

                if is_alias:
                    title_in_html = f'<em>{title_in_html}</em>'

                filename = Exporter.generate_href_value(
                    orgid, start_node_id=None)

                body += (f'{_TAB*6}<li><a href="{filename}">'
                         f'{title_in_html}</a></li>\n')

            # close list and letter-sec
            body += (f'{_TAB*5}</ul>\n'
                     f'{_TAB*4}</div>\n'
                     f'{_TAB*3}</article>\n')

            # add letter to index navigation
            nav += f'{_TAB*4}<a href="#{anchor}">{letter}</a>\n'

        return nav, body

    @staticmethod
    def _subtree_to_html(content: Node) -> str:
        """
        """
        return Exporter._body_and_meta(content, True)

    @staticmethod
    def _paragraph_to_html(paragraph: elements.Paragraph) -> str:
        """Generate HTML representation of a `Content.Paragraph` element.

        Args:
            p: The paragraph element.

        Returns:
            The paragraph as HTML using <p> tag.
        """
        result = _html_from_runs(paragraph.runs)

        return f'<p>{result}</p>'

    @staticmethod
    def _figure_to_html(figure: elements.Figure) -> str:

        # linked image?
        link = figure.link
        if link:
            src = _html_from_link_run(link)

        # image
        else:
            src = _html_from_image_run(figure.image)
            src = f'{_TAB*3}{src}\n'

        # caption
        if figure.caption:
            cap = _html_from_runs(figure.caption)
            cap = f'{_TAB*3}<figcaption>{cap}</figcaption>\n'
        else:
            cap = ''

        # unknown properties ...
        if figure.unknown:
            tip = '\n'.join(figure.unknown)
        else:
            tip = ''

        # ... and unknown attributes (#+ATTR_HTML) ...
        if figure.attributes:
            if tip:
                tip = f'{tip}\n{figure.attributes}'
            else:
                tip = f'{figure.attributes}'

        # ... as tooltip
        if tip:
            tip = f' title="{tip}"'

        result = f'{_TAB*2}<figure{tip}>\n{src}{cap}{_TAB*2}</figure>'

        return result

    @staticmethod
    def _block_to_html(block: elements.Block) -> str:
        """Wrapper for `_html_from_block_run()`."""
        return _html_from_block_run(block)

    @staticmethod
    def _displaymath_to_html(displaymath: elements.DisplayMath) -> str:
        content = html_escape(displaymath[0])
        return f'<div class="math">\\[{content}\\]</div>'

    @staticmethod
    def _heading_to_html(heading: elements.Heading) -> str:
        """
        """
        if heading.anchor:
            open_tag = f'<h{heading.level} id="{heading.anchor}">'
        else:
            open_tag = f'<h{heading.level}>'

        label = _html_from_runs(heading.runs)

        return f'{open_tag}{label}</h{heading.level}>'

    @staticmethod
    def _list_to_html(alist: elements.ListElement) -> str:
        """Generate HTML representation of a `Content.List` element.

        The argument `replace_links` is used to deactivate links-to-html
        conversion in sub-lists in case of a nested list. That links are
        converted by the parent list object only.

        Args:
            alist: The `Content.List` element.
            replace_links: Only for internal use.

        Returns:
            The list as HTML source (``<ol>``  or ``<ul>``).
        """
        tag = 'ol' if alist.ordered else 'ul'

        items_html = ''
        for item in alist:
            # child list?
            if isinstance(item, elements.ListElement):
                # remove closing li-tag from item before
                items_html = items_html[:-5]
                # child list as html
                child_html = Exporter._list_to_html(item)
                # glue together
                items_html += f'\n{child_html}\n</li>'
            else:
                item = _html_from_runs(item)
                items_html += f'\n<li>{item}</li>'

        return f'<{tag}>{items_html}\n</{tag}>'

    @staticmethod
    def _backlinks_to_html(backlinks: elements.Backlinks) -> str:
        html = Exporter._list_to_html(backlinks)

        return '\n'.join([
            f'{_TAB*2}</section>',
            f'{_TAB*2}<section>',
            f'{_TAB*3}<hr />',
            f'{_TAB*3}<h2>Backlinks</h2>',
            f'{html}'
        ])

    @staticmethod
    def _meta_other_fields_to_html(val, label):
        """See `Exporter._meta_to_html()`."""
        if isinstance(val, list):
            val = [f'"{v}"' if ' ' in v else v for v in val]
            val = ' '.join(val)
            val = val.strip()

        return ('<div><span class="label">'
                f'{label}:</span>&nbsp;{val}</div>')

    @staticmethod
    def _as_locale_conform_date_string(
            val: Union[datetime.datetime, Any]) -> str:
        """Return `val` as a datetime string conform to 'locale'.

        Args:
            val: Can be a `datetime.datetime` instance or anything else.

        Returns:
            A string representation of `val`.
        """
        # When Emacs org(mode) generates the date value via
        # %U they are enclosed by brackets
        try:
            if val.startswith('[') and val.endswith(']'):
                # remove brackets
                val = val[1:-1]
        except AttributeError:
            pass

        # locale time string if it is a datetime object
        try:
            val = val.strftime('%c')
        except AttributeError:
            pass

        return val

    @staticmethod
    def _mdate_cdate_html_string(val, date_key, date_label) -> str:
        return (f'<div id="hyperorg-{date_key}">\n'
                '\t<span class="label">'
                f'{date_label}:</span>&nbsp;<time>{val}</time>\n'
                '</div>')

    @staticmethod
    def _meta_to_html(content: Node, exclude_fields: list[str] = None) -> str:
        """Use the meta fields (`Content._meta`) and convert them to HTML.

        Args:
            content: A `Content` object.

        Returns:
            The HTML output.
        """
        if exclude_fields is None:
            exclude_fields = []

        # no meta data?
        if not content._meta:  # pylint: disable=protected-access
            return ''

        result = []

        meta = copy.deepcopy(content._meta)  # pylint: disable=protected-access

        # exclude fields
        for exf in exclude_fields:
            meta.pop(exf, None)

        # Modification and creation date.
        mcdate_present = False
        for date_key, date_label in [('mtime', 'Modified'),
                                     ('ctime', 'Created')]:
            try:
                val = meta[date_key]
                mcdate_present = True
                val = Exporter._as_locale_conform_date_string(val)
                result.append(
                    Exporter._mdate_cdate_html_string(
                        val, date_key, date_label))

            except KeyError:
                pass

        if result:
            result.append('<br />')

        # Title aliases
        for alias_runs in meta.get('aliases_runs', []):
            alias = _html_from_runs(alias_runs)
            result.append(
                f'{_TAB*3}<div>\n'
                f'{_TAB*4}<span class="label">Alias:</span>&nbsp;{alias}\n'
                f'{_TAB*3}</div>'
            )

        # ignore modification, creation date and aliases
        exclude_names = ['mtime', 'ctime', 'aliases_runs', 'aliases_org']

        # each meta entry
        for name in filter(lambda val: val not in exclude_names, meta):

            # current value
            val = meta[name]

            # <time> tag?
            if isinstance(val, datetime.datetime):
                val = Exporter._as_locale_conform_date_string(val)

                if mcdate_present:
                    val = f'<time>{val}</time>'
                    # later added to `result` (see "other types")

                else:
                    # This date is treated as creation date because no usual
                    # modification or creation date is present.
                    result.insert(0, '<br />')
                    result.insert(
                        0,
                        Exporter._mdate_cdate_html_string(val, 'ctime', name)
                    )

                    # next meta entry
                    continue

            # other types
            result.append(Exporter._meta_other_fields_to_html(val, name))

        return '\n'.join(result)

    @staticmethod
    def generate_href_value(node_id: str, start_node_id: str) -> str:
        """Generate the href-value for the given node.

        Example output could be ``nodeA.html`` or ``nodeA.html#subtreeid``.

        Args:
            node_id: ID of an Org node or a subtree (heading) inside a node.

        Returns:
            str: Link to a file that can be used in a ``href`` attribute.
        """

        # Try usual node
        try:
            # filename for the node
            node_filename = Node.nodes[node_id].filename

        except KeyError:
            # Nothing found
            pass

        else:
            if start_node_id:
                start_filename = Node.nodes[start_node_id].filename
                start_path = str(pathlib.Path(start_filename).parent)
            else:
                start_path = '.'

            # get link path relative to the linking node (start)
            rel_path = os.path.relpath(node_filename, start_path)

            # convert org-filename to html-filename
            href_value = str(Exporter._as_html_filename(rel_path))

            return href_value

        # Try as subheading
        try:
            parent_id, subnode_idx = Node.heading_ids[node_id]
        except KeyError:
            # no subheading found
            return None

        # parent of the subheading
        try:
            parent_node = Node.nodes[parent_id]
        except KeyError:
            # no parent found
            return None

        href_value = Exporter.generate_href_value(parent_id, start_node_id)

        # anchor string for the heading (sub node)
        anchor = parent_node[subnode_idx][0].anchor

        return f'{href_value}#{anchor}'

    def _get_footer(self) -> str:
        """
        """

        if self._footer_string:
            return self._footer_string

        appstring = hyperorg.get_full_application_string(name_as_url=True)
        timestamp = datetime.datetime.now().strftime('%c')
        self._footer_string = f'Generated with {appstring} on {timestamp}'

        return self._get_footer()

    @staticmethod
    def _get_html_meta_info_generator(indentation: int) -> list[str]:
        """
        """
        gname = hyperorg.__name__
        gvers = hyperorg.__version__
        gurl = hyperorg.meta['Project-URL']['homepage']

        return '\n'.join([
            f'{_TAB*indentation}<meta name="generator"'
            f' content="{gname}" />',
            f'{_TAB*indentation}<meta name="generator_version"'
            f' content="{gvers}" />',
            f'{_TAB*indentation}<meta name="generator_website"'
            f' content="{gurl}" />'
        ])

    @staticmethod
    def _get_html_template_node() -> str:
        """
        """
        try:
            return Exporter._template_node
        except AttributeError:
            style_links = ['    <link rel="stylesheet" href="style.css" />']
            if pygments:
                style_links.append(
                    '    <link rel="stylesheet" href="pygments.css" />')

            html = '\n'.join([
                '<!DOCTYPE html>',
                f'<html lang="{Exporter._PLACEHOLDER_LANG}">',
                '<head>',
                Exporter._get_html_meta_info_generator(1),
                '    <meta charset="UTF-8" />',
                *style_links,
                (
                    f'{Exporter._PLACEHOLDER_MATHJAX}'
                    f'    <title>{Exporter._PLACEHOLDER_TITLE}</title>'),
                '</head>',
                '<body>',
                f'    <nav>{Exporter._PLACEHOLDER_NODE_NAV}</nav>',
                '    <main>',
                f'        <h1>{Exporter._PLACEHOLDER_TITLE}</h1>',
                # '        <header>',
                # f'            {Exporter._PLACEHOLDER_META}',
                # '        </header>',
                '        <section>',
                f'            {Exporter._PLACEHOLDER_BODY}',
                '        </section>',
                '    </main>',
                '    <footer>',
                f'        {Exporter._PLACEHOLDER_FOOTER}',
                '    </footer>',
                '</body>',
                '</html>'
            ])

            # language tag,  e.g. 'de_DE' -> 'de'
            html = html.replace(
                Exporter._PLACEHOLDER_LANG,
                Exporter._get_html_lang_from_locale()
            )

            Exporter._template_node = html

            return Exporter._get_html_template_node()

    @staticmethod
    def _get_html_lang_from_locale() -> str:
        lang = locale.getlocale()[0]

        if lang:
            return lang.split('_')[0]

        logging.debug('Unable to determine language via getlocale()')
        return ''

    @staticmethod
    def _get_html_template_index() -> str:
        """
        """
        try:
            return Exporter._template_index
        except AttributeError:
            html = '\n'.join([
                '<!DOCTYPE html>',
                f'<html lang="{Exporter._PLACEHOLDER_LANG}">',
                f'{_TAB*1}<head>',
                Exporter._get_html_meta_info_generator(2),
                f'{_TAB*2}<meta charset="UTF-8" />',
                f'{_TAB*2}<link rel="stylesheet" href="style.css" />',
                f'{_TAB*2}<title>Node index</title>',
                f'{_TAB*1}</head>',
                f'{_TAB*1}<body id="top">',
                f'{_TAB*2}<nav>{Exporter._PLACEHOLDER_NODE_NAV}</nav>',
                f'{_TAB*2}<main id="hyperorg-index">',
                f'{_TAB*3}<h1>Node index</h1>',
                f'{_TAB*3}<div class="hyperorg-index__letter-nav">',
                f'{Exporter._PLACEHOLDER_INDEX_LETTER_NAV}',
                f'{_TAB*3}</div>',
                f'{Exporter._PLACEHOLDER_BODY}',
                f'{_TAB*2}</main>',
                f'{_TAB*2}<footer>',
                f'{Exporter._PLACEHOLDER_FOOTER}',
                f'{_TAB*2}</footer>',
                f'{_TAB*1}</body>',
                '</html>'
            ])

            # language tag,  e.g. 'de_DE' -> 'de'
            html = html.replace(
                Exporter._PLACEHOLDER_LANG,
                Exporter._get_html_lang_from_locale()
            )

            Exporter._template_index = html

            return Exporter._get_html_template_index()

    @staticmethod
    def _get_mathjax_code() -> str:
        lines = [
            '    <script>',
            # MathJax configuration
            '       window.MathJax = {',
            '         tex: {',
            r"           inlineMath: [['\\(', '\\)'], ['$', '$']],",
            r"           displayMath: [['\\[', '\\]'], ['$$', '$$']]",
            '         },',
            '         options: { skipHtmlTags: '
            "['script','noscript','style','textarea','pre','code'] }",
            '       };',
            '    </script>',
            (
                '   <script defer '
                'src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/'
                'tex-chtml.js">'
                '</script>'
            ),
            '',
        ]

        return '\n'.join(lines)

    @staticmethod
    def _body_and_meta(content: Node, is_subtree: bool = False) -> str:
        # for the element iterator
        start_idx = 0

        # meta content (Date, Filetags, alias titles, ...)
        meta = Exporter._meta_to_html(
            content,
            exclude_fields=Node.META_FIELDS_INHERIT if is_subtree else [])

        body = meta

        if meta:
            body = f'<header>{meta}{_TAB*3}</header>'

        # Is this Content instance a subheading/subtree?
        if is_subtree:
            # add heading (h2) before the meta/header
            body = f'{_TAB*3}' \
                + Exporter._heading_to_html(content[0]) \
                + body

            # ignore the first Heading while iteration
            start_idx = 1

        if body:
            body = body + '\n'

        body = body + Exporter._elements_as_html(content, start_idx)

        return body

    @staticmethod
    def _index_items_as_html(index_items: list) -> str:
        """
        """
        html_code = f'\n{_TAB*3}<ul>'

        for item in index_items:
            # pylint: disable-next=consider-using-f-string
            html_code += '\n{}<li><a href="{}{}.html">{}</a> ({})</li>' \
                .format(_TAB * 4,
                        '' if item[0] == 'Index' else 'index__filetag_',
                        item[1],
                        item[0],
                        item[2])

        html_code += f'\n{_TAB*3}</ul>'

        return html_code

    @staticmethod
    def _compute_indexes() -> list:
        """Compute a list of items used in navigation bar.

        The `Content.index_data` dictionary is used as data source.

        Returns:
            A list of 3-item-tuple items of name/label (e.g. MyTag),
        the filename (e.g. index__filetag_MyTag.html") and number of entries
        (4).
        """
        the_tags = Node.index_data[Node.IDX_FILETAG]

        # Count number of entries per filetag
        count_per_filetag = [
            (tag, sum(len(the_tags[tag][letter]) for letter in the_tags[tag]))
            for tag in the_tags
        ]

        # Sort them descending
        # Credits: https://stackoverflow.com/a/75086202/4865723
        # nav_ent ries = sorted(count_per_filetag,
        #                       key=lambda e: (e[0], -e[1]))

        # create filenames
        nav_entries = [(val[0],
                        hyperorg.simplify_string(val[0]),
                        val[1]) for val in count_per_filetag]

        # Sort first by count then lexicographic if count is equal
        # Credits: https://stackoverflow.com/a/75086202/4865723
        nav_entries = sorted(nav_entries, key=lambda x: (-x[2], x[0]))

        # Check for duplicates in the filenames which can occur because of
        # simplification of the tag labels into filenames. (e.g. "Tag:" &
        # "Tag<")
        def make_unique_strings(one_string: str, number: int) -> Iterable[str]:
            """
            """
            digits_n = len(str(number))
            format_string = '{:0' + str(digits_n) + '}'

            unique_numbers = [
                format_string.format(num) for num in range(1, number + 1)]

            return [(one_string + un) for un in unique_numbers]

        # check duplicate filenames
        counter = Counter([e[1] for e in nav_entries])
        duplicates = {s: n for s, n in counter.items() if n > 1}

        for dup, val in duplicates.items():
            ustring = make_unique_strings(
                one_string=dup, number=val)

            for idx, entry in enumerate(nav_entries[:]):
                # check if filename is one of the duplicates
                if entry[1] == dup:
                    # replace the duplicate filename with the unique one
                    nav_entries[idx] = (entry[0], ustring.pop(0), entry[2])

        # full index entry
        count_all = sum(
            len(Node.index_data[Node.IDX_ALL][key])
            for key in Node.index_data[Node.IDX_ALL]
        )
        nav_entries = [('Index', 'index', count_all)] + nav_entries

        return nav_entries

    @staticmethod
    def _elements_as_html(content: Node, start_idx: int = 0) -> str:
        """
        """

        # Content type specific to-html converters
        call_it = {
            elements.Paragraph: Exporter._paragraph_to_html,
            elements.Heading: Exporter._heading_to_html,
            elements.ListElement: Exporter._list_to_html,
            elements.Backlinks: Exporter._backlinks_to_html,
            elements.Block: Exporter._block_to_html,
            elements.TableBlock: Exporter._block_to_html,
            elements.Figure: Exporter._figure_to_html,
            elements.DisplayMath: Exporter._displaymath_to_html,
            Node: Exporter._subtree_to_html,
        }

        body = []

        # call the converter for each content element
        for elem in content[start_idx:]:

            # convert content element into HTML string
            html_element = call_it[elem.__class__](elem)

            body.append(html_element)

        return '\n'.join(body)

    def _as_html(self, content: Node, idx_items: list) -> str:
        """Create a full HTML pendant of 'content'.

        It is a complete HTML file with everything needed between and including
        <html> and </html>.

        Args:
            content: A `Node` object
            index_item: Use to create navigation bar entries.

        Returns:
            The HTML representation of `content`.
        """
        html = Exporter._get_html_template_node()

        # TITLE
        title_as_html = _html_from_runs(content.title_runs)
        html = html.replace(Exporter._PLACEHOLDER_TITLE, title_as_html)

        # navigation bar (on top of the site)
        nav = Exporter._index_items_as_html(idx_items)
        html = html.replace(Exporter._PLACEHOLDER_NODE_NAV, nav)

        # each element to its HTML
        body = Exporter._body_and_meta(content)

        # insert the body
        html = html.replace(Exporter._PLACEHOLDER_BODY, body)

        # insert footer
        html = html.replace(Exporter._PLACEHOLDER_FOOTER,
                            self._get_footer())

        # insert mathjax script in header if necessary
        html = html.replace(
            Exporter._PLACEHOLDER_MATHJAX,
            self._get_mathjax_code() if 'class="math">' in html else ''
        )

        return html


_MARKUP_TAGS = {
    textruns.Markup.Type.IMPORTANT: ('<strong>', '</strong>'),
    textruns.Markup.Type.VERBATIM: ('<code class="verbatim">', '</code>'),
    textruns.Markup.Type.CODE: ('<code>', '</code>'),
    textruns.Markup.Type.EMPHASIZED: ('<em>', '</em>'),
    textruns.Markup.Type.UNDERLINED: ('<u>', '</u>'),
    textruns.Markup.Type.STRIKETHROUGH: ('<del>', '</del>'),
}


def _html_from_image_run(image: textruns.Image) -> str:
    """Generate HTML representation of an image run.

    Args:
        image: The image instance.

    Returns:
        HTML code as a string.
    """

    attr = ''

    if image.width:
        attr = f' {attr}width="{image.width}"'

    if image.height:
        attr = f'{attr} height="{image.height}"'

    if image.cssstyle:
        attr = f'{attr} style="{image.cssstyle}"'

    if image.title:
        attr = f'{attr} title="{html_escape(image.title)}"'

    result = f'<img src="{image.target}" ' \
             f'alt="{image.alternative}"{attr} />'

    if image.target_is_locale:
        _establish_file_link(
            target=pathlib.Path(image.target),
            target_is_in=Node.input_dir,
            link_is_in=Exporter.output_dir,
            use_hardlinks=Exporter.use_hardlinks
        )

    return result


def _establish_file_link(target: pathlib.Path,
                         target_is_in: pathlib.Path,
                         link_is_in: pathlib.Path,
                         use_hardlinks: bool):
    # No need to link
    if target.is_absolute() or '~' in str(target):
        return

    link_file = link_is_in / target
    target_file = target_is_in / target

    # File exists (maybe because a parent folder is a symlink)
    if link_file.exists():
        return

    # subfolders involved
    if len(target.parts) > 1 and not link_file.parent.exists():
        link_file.parent.mkdir(parents=True)

    if use_hardlinks:

        if not link_file.exists():

            try:
                os.link(target_file, link_file)

            except FileNotFoundError as err:
                _log.warning(f'Unable to create hardlink to "{err.filename}"! '
                             'File does not exist.')

                link_parent = link_file.parent

                # Delete empty parent folders
                while not list(link_parent.iterdir()):
                    link_parent.rmdir()

                    if len(link_parent.parts) == 1:
                        break

                    link_parent = link_parent.parent

                    if link_parent == link_is_in:
                        break

    elif not link_file.is_symlink():
        link_file.symlink_to(target_file.resolve())


def _invalid_orgid_link_as_html(target: str, label: str) -> str:
    """Create HTML from an OrgID link which target ID is unknown."""

    result = '<span title="The link destination could not be found.&#xA;' \
             f'Unknown OrgID &quot;{target}&quot;.&#xA;' \
             f'Original link: [[id:{target}]'

    if label:
        result = result + f'[{label}'
    else:
        label = target

    result = f'{result}]">' \
             f'⚠<span class="invalid-link">{label}</span>\n</span>'

    return result


def _invalid_roam_link_as_html(target: str, label: str) -> str:
    """Create HTML from a ROAM link."""
    result = '<span title="The link destination could not be found.&#xA;' \
             f'Unknown ROAM &quot;{target}&quot;.&#xA;' \
             f'Original link: [[roam:{target}]'

    if label:
        result = result + f'[{label}'
    else:
        label = target

    result = f'{result}]">' \
             f'👾<span class="invalid-link">{label}</span>\n</span>'

    return result


def _unknown_link_type_as_html(target: str, link_type: str, label: str) -> str:
    """Create HTML from a link of unknown type."""
    result = f'<span title="The link type &quot;{link_type}&quot; is ' \
             f'unknown.&#xA;Original link: [[{link_type}:{target}]'

    if label:
        result = f'{result}[{label}]]">'
    else:
        result = f'{result}]">'

    result = f'{result}❓<span class="invalid-link">'

    if label:
        result = f'{result}{link_type}:{label}</span></span>'
    else:
        result = f'{result}{link_type}:{target}</span></span>'

    return result


def _unsecure_http_link_as_html(target: str, label: str) -> str:
    result = f'<span title="{_MSG_INSECURE_HTTP}&#xA;http://{target}">🚫'

    if label:
        result = f'{result}&nbsp;<span class="insecure-link">{label} ' \
                 '(<del>http</del>)</span></span>'

    else:
        result = f'{result}<span class="insecure-link">' \
                 f'<del>http</del>://{target}</span></span>'

    return result


# pylint: disable-next=too-many-branches,too-many-return-statements
def _html_from_link_run(link: textruns.Link) -> str:  # noqa: PLR0911
    """Convert one link run into HTML.

    It is a helper function for `_html_from_runs()`.

    Args:
        link: The link run.

    Returns:
        The HTML code as string.
    """

    # ORGID
    if link.link_type == textruns.Link.Type.ORGID:
        label = _html_from_runs(link) if link else html_escape(link.target)
        href_value = Exporter.generate_href_value(
            link.target, start_node_id=Exporter.current_processing_node_id)

        if href_value:
            return f'<a href="{href_value}">{label}</a>'

        # invalid OrgID link
        return _invalid_orgid_link_as_html(link.target, label if link else '')

    # ROAM
    if link.link_type == textruns.Link.Type.ROAM:
        return _invalid_roam_link_as_html(
            link.target,
            _html_from_runs(link) if link else '')

    # HTTPS
    if link.link_type == textruns.Link.Type.HTTPS:

        label = _html_from_runs(link) if link \
            else html_escape('https://' + link.target)

        return '<a target="_blank" rel="noopener ' \
               f'noreferrer" href="https://{link.target}">{label}</a>'

    # DOI
    if link.link_type == textruns.Link.Type.DOI:

        if link:
            label = _html_from_runs(link)
            prefix = ''
        else:
            label = html_escape(link.target)
            prefix = 'DOI: '

        return f'{prefix}<a target="_blank" rel="noopener ' \
               f'noreferrer" href="https://doi.org/{link.target}">{label}</a>'

    # MAILTO
    if link.link_type == textruns.Link.Type.MAILTO:
        label = _html_from_runs(link) if link else html_escape(link.target)

        return '<a target="_blank" rel="noopener ' \
               f'noreferrer" href="mailto:{link.target}">{label}</a>'

    # FILE or NONE
    if link.link_type in (textruns.Link.Type.FILE, textruns.Link.Type.NONE):

        label = _html_from_runs(link) if link else html_escape(link.target)

        _establish_file_link(
            target=pathlib.Path(link.target),
            target_is_in=Node.input_dir,
            link_is_in=Exporter.output_dir,
            use_hardlinks=Exporter.use_hardlinks
        )

        return '<a target="_blank" rel="noopener ' \
               f'noreferrer" href="{link.target}">{label}</a>'

    # insecure HTTP
    if link.link_type == textruns.Link.Type.HTTP:
        return _unsecure_http_link_as_html(
            link.target,
            _html_from_runs(link) if link else '')

    # unknown link type (protocol)
    if link.link_type == textruns.Link.Type.UNKNOWN:
        return _unknown_link_type_as_html(
            target=link.target,
            link_type=link[0],
            label=_html_from_runs(link[1:]) if len(link) > 1 else '')

    raise RuntimeError(f'Undefined situation. Current link is {link}.')


def _html_from_block_run(block: elements.Block) -> str:
    """Generate HTML representation.

    Args:
        block: The block element.

    Returns:
        The HTML.
    """
    tooltip = block.name
    if block.language:
        tooltip = tooltip + f' {block.language}'

    codeblock = '\n'.join(block.runs)

    if pygments:
        try:
            lexer = pygments.lexers.get_lexer_by_name(block.language)
        except pygments.util.ClassNotFound:
            lexer = pygments.lexers.guess_lexer(codeblock)

        codeblock = pygments.highlight(
            code=codeblock,
            lexer=lexer,
            formatter=pygments.formatters.HtmlFormatter(cssclass='')
        )

        # remove leading <div><pre> and trailing </pre></div>
        codeblock = codeblock[10:-13]

    else:

        # Even in <code> or <pre> blocks some characters are not taken
        # verbatim but interpreted like html. For example > < &.
        # They need to get escaped (HTML like). > becomes &gt; for
        # example.
        codeblock = html_escape(codeblock)

    return f'<pre title="{tooltip}">\n{codeblock}</pre>'


def _html_from_math_run(math_run: textruns.InlineMath) -> str:
    """Generate HTML representation.

    Args:
        math_run: The math run.

    Returns:
        The HTML.
    """
    content = html_escape(math_run[0])
    return f'<span class="math">\\({content}\\)</span>'


def _html_from_runs(runs: list[textruns.RunsType]) -> str:
    """Convert a list of runs into HTML.

    This function is used by `Exporter` when elements are converted into
    HTML. The elements do consist of runs.

    Args:
        runs: A list of runs.

    Returns:
        The HTML code as string.

    """
    result = ''

    for run in runs:

        if isinstance(run, str):
            result = result + html_escape(run)

        elif isinstance(run, textruns.Markup):
            open_tag, close_tag = _MARKUP_TAGS[run.markup_type]

            result = result + open_tag + _html_from_runs(run.runs) + close_tag

        elif isinstance(run, textruns.Link):
            result = result + _html_from_link_run(run)

        elif isinstance(run, textruns.Image):
            result = result + _html_from_image_run(run)

        elif isinstance(run, elements.Block):
            result = result + _html_from_block_run(run)

        elif isinstance(run, textruns.InlineMath):
            result = result + _html_from_math_run(run)

        else:
            raise TypeError(f'Unknown run type "{run}"!')

    return result
