# 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>.

"""The Hyperorg package.

See README.md for further details.
"""
import os
import itertools
import shutil
import logging
import pathlib
import re
from collections import Counter
from importlib.metadata import metadata


def _package_metadata_as_dict(exclude_keys: list = None) -> dict:
    """Get package metadata and return it as a dict."""

    if not exclude_keys:
        exclude_keys = ['Classifier']

    mdata = metadata(__name__)

    result = {}

    for key, count in Counter(mdata.keys()).items():

        if key in exclude_keys:
            continue

        # single value key
        if count == 1:
            result[key] = mdata[key]
            continue

        # multi value key
        result[key] = []
        for val in mdata.get_all(key):
            result[key].append(val)

    # postprocess project URLs
    urls = result['Project-URL']
    result['Project-URL'] = {}
    for entry in urls:
        key, val = entry.split(',')
        result['Project-URL'][key.strip()] = val.strip()

    # post process license data
    try:
        result['License'] = result['License-Expression']
    except KeyError:
        result['License'] = '(See LICENSES directory)'

    return result


meta = _package_metadata_as_dict()
"""Package meta data"""

__version__ = meta['Version']
"""The full version"""

__version_major_minor_patch__ = '.'.join(__version__.split('.')[:3])
"""Version up to the patch level, without pre-release and git identifier"""


def get_full_application_string(name_as_url: bool = False) -> str:
    """Build a string representing the application name and its version.

    If a git repo is present information about its current state are
    added also.
    """

    if name_as_url:
        url = meta['Project-URL']['homepage']
        name = f'<a href="{url}">{__name__}</a>'
    else:
        name = __name__

    return f'{name} {__version__}{get_git_string()}'


def get_git_string() -> str:
    """Build a string containing the applications git related
    information."""

    try:
        git = get_git_repository_info()
        return f' (branch: {git["branch"]} at {git["hash"]})'
    except FileNotFoundError:
        return ''


def get_git_repository_info(hash_length: int = 8) -> dict:
    """Return the current branch and last commit hash.

    A special case is when that script runs on a Read The Docs instance. In
    that case the branch name is extracted from environment variables.

    Credits: https://stackoverflow.com/a/51224861/4865723
    """

    git_folder = pathlib.Path.cwd() / '.git'
    result = {}

    # If run on a Read The Docs instance
    if 'READTHEDOCS' in os.environ:
        if os.environ['READTHEDOCS_VERSION_TYPE'] == 'branch':
            result['branch'] = os.environ['READTHEDOCS_VERSION']
    else:
        # branch name
        with (git_folder / 'HEAD').open('r') as handle:
            result['branch'] = '/'.join(handle.read().split('/')[2:]).strip()

    # commit hash
    with (git_folder / 'refs' / 'heads' / result['branch']) \
         .open('r') as handle:
        result['hash'] = handle.read().strip()

    if hash_length is not None:
        result['hash'] = result['hash'][:hash_length]

    return result


_RE_SIMPLIFY_STRING = re.compile(r'[^\w-]')
"""Used in `simplify_string()`."""


def simplify_string(string: str) -> str:
    """Simplify the string.

    While word characters and `-` are kept the rest is replaced by underscore
    `_`. This can be used for filenames.

    Args:
        string: The string to simplify.

    Returns:
        The simplified string.
    """

    return _RE_SIMPLIFY_STRING.sub('_', string)


# default log format
# see: https://stackoverflow.com/q/7771912/4865723
_LOG_FORMAT_DEFAULT = '%(levelname)s : %(message)s'
_LOG_FORMAT_DEBUG = '[%(levelname)s] ' \
                    '%(filename)s:%(funcName)s() ► %(message)s'

_LOG_FORMAT_DEFAULT_CTX = '%(levelname)s : {context} : %(message)s'
_LOG_FORMAT_DEBUG_CTX = '[%(levelname)s] ' \
                        '%(filename)s:%(funcName)s() : {context} ' \
                        '► %(message)s'


def init_logging(info: bool, debug: bool):
    """Configure logging.

    Parameters:
        info (bool): Set 'True' for a more verbose logging.
        debug (bool): Set 'True' for full/debug logging.
    """
    log_format = _LOG_FORMAT_DEFAULT

    # log level
    if debug:
        log_level = logging.DEBUG
        log_format = _LOG_FORMAT_DEBUG
    elif info:
        log_level = logging.INFO
    else:
        log_level = logging.WARNING  # python default

    logging.basicConfig(level=log_level, format=log_format)


class LoggingContext:
    """A context manager temporary modifying the current logging format with
    adding a separate string giving information about the current context.

    A context can be a specific org file that is currently parsed.

    Dev note: It seems that `pytest` and `unittest` do somehow hack the
        `logging` module and also ignoring `basicConfig()` and `setFormater()`.
        Because of that there is no known way to test this behavior.
    """
    def __init__(self, context: str):
        # Save the original formatter
        self.org_formatter = logging.root.handlers[0].formatter

        # Calculate maximum with of the context string based on
        # terminal size
        max_length = int(shutil.get_terminal_size().columns / 3)
        half_length = int(max_length / 2)

        if len(context) > max_length:
            context = context[:half_length] + '…' + context[-half_length:]

        self.context = context
        """String describing the context."""

    def __enter__(self):
        """Set a new Formatter with formatter string using context"""
        if logging.root.level == logging.DEBUG:
            node_formatter = logging.Formatter(
                _LOG_FORMAT_DEBUG_CTX.format(context=self.context))
        else:
            node_formatter = logging.Formatter(
                _LOG_FORMAT_DEFAULT_CTX.format(context=self.context))

        logging.root.handlers[0].setFormatter(node_formatter)

    def __exit__(self, *args):
        # Reset to the original formatter
        logging.root.handlers[0].setFormatter(self.org_formatter)


def collect_minimal_diagnostics():
    """Collect minimal information about the application, dependencies and
    the operating system.

    Returns:
       dict: A nested dictionary.
    """
    return {
        'Vers': get_full_application_string(),
        # Workaround because orgparse does not provide its own version.
        # See: https://github.com/karlicoss/orgparse/issues/68
        'OrgParse': metadata('orgparse')['Version'],
        'OS': _get_os_release(),
    }


def _get_os_release():
    """Try to get the name and version of the operating system used.

    First it extract infos from the file ``/etc/os-release``. Because not all
    GNU Linux distributions follow the standards it will also look for
    alternative release files (pattern: ``/etc/*release``).
    See http://linuxmafia.com/faq/Admin/release-files.html for examples.

    Returns:
        A string with the name of the operating system, e.g. "Debian
        GNU/Linux 11 (bullseye)" or a dictionary if alternative release
        files where found.
    """

    def _get_pretty_name_or_content(fp):
        """Return value of PRETTY_NAME from a release file or return the whole
        file content."""

        try:
            # Read content from file
            with fp.open('r') as handle:
                content = handle.read().strip()

        except FileNotFoundError:
            return f'({fp.name} file not found)'

        try:
            # Extract the pretty name
            return re.findall('PRETTY_NAME=\"(.*)\"', content)[0]

        except IndexError:
            # Return full content when no PRETTY_NAME was found
            return content

    etc_path = pathlib.Path('/etc')
    os_files = list(filter(lambda p: p.is_file(),
                           itertools.chain(
                               etc_path.glob('*release*'),
                               etc_path.glob('*version*'))
                           ))

    # "os-release" is standard and should be on top of the list
    fp_osrelease = etc_path / 'os-release'
    try:
        os_files.remove(fp_osrelease)
    except ValueError:
        pass
    else:
        os_files = [fp_osrelease] + os_files

    # each release/version file found
    # osrelease = {str(fp): _get_pretty_name_or_content(fp) for fp in os_files}
    osrelease = ' | '.join(_get_pretty_name_or_content(fp) for fp in os_files)

    return osrelease
