# The MIT License (MIT)
# Copyright (c) 2023 ESA Climate Change Initiative
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

__author__ = "Norman Fomferra (Brockmann Consult GmbH)"

import logging
import os.path
import sys
from typing import Any, Optional, Sequence, Union

from .defaults import DEFAULT_DATA_PATH
from .defaults import DEFAULT_RES_PATTERN
from .defaults import DEFAULT_VERSION_DATA_PATH
from .defaults import GLOBAL_CONF_FILE
from .defaults import LOCAL_CONF_FILE
from .defaults import LOCATION_FILE
from .defaults import USER_PREFERENCES_FILE
from .defaults import VERSION_CONF_FILE

_CONFIG = None

_LOG = logging.getLogger('ect')


def get_config_path(name: str, default=None) -> str:
    """
    Get the ``str`` value of the configuration parameter *name* which is
    expected to be a path.
    Any tilde character '~' in the value will be expanded
    to the current user's home directory.

    :param name: The name of the configuration parameter.
    :param default: The default value, if *name* is not defined.
    :return: The value
    """
    value = get_config_value(name, default=default)
    return os.path.expanduser(str(value)) if value is not None else None


def get_config_value(name: str, default=None) -> Any:
    """
    Get the value of the configuration parameter *name*.

    :param name: The name of the configuration parameter.
    :param default: The default value, if *name* is not defined.
    :return: The value
    """
    if not name:
        raise ValueError('name argument must be given')
    return get_config().get(name, default)


def get_data_stores_path() -> str:
    """
    Get the default path to where the ESA Climate Toolbox stores local data
    store information and stores data files synchronized with their remote
    versions.

    :return: Effectively reads the value of the configuration parameter
    ``data_stores_path``, if any. Otherwise return the default value
    ``~/.ect/data_stores``.
    """
    return get_config_path('data_stores_path',
                           os.path.join(DEFAULT_DATA_PATH, 'data_stores'))


def get_default_res_pattern() -> str:
    """
    Get the default prefix for names generated for new workspace resources
    originating from opening data sources or executing workflow steps.
    This prefix is used only if no specific prefix is defined for a given
    operation.
    :return: default resource name prefix.
    """
    default_res_pattern = get_config().get('default_res_pattern')
    if default_res_pattern:
        default_res_pattern = default_res_pattern.strip()
    if not default_res_pattern:
        default_res_pattern = DEFAULT_RES_PATTERN
    return default_res_pattern


def get_http_proxy() -> Optional[str]:
    """
    Get a valid 'http_proxy' value. Log a warning if it is invalid.
    :return: URL string or None.
    """
    http_proxy = get_config().get('http_proxy')

    if http_proxy is None:
        return None

    if isinstance(http_proxy, str):
        if http_proxy == '':
            return None
        if http_proxy.startswith('https:') or http_proxy.startswith('http:'):
            return http_proxy

    _LOG.warning('invalid configuration: http_proxy = %r' % http_proxy)

    return http_proxy


def get_config() -> dict:
    """
    Get the global configuration dictionary of the ESA Climate Toolbox.

    :return: A mutable dictionary containing any Python objects.
    """
    global _CONFIG
    if _CONFIG is None:
        _init_config([GLOBAL_CONF_FILE,
                      VERSION_CONF_FILE,
                      LOCAL_CONF_FILE,
                      USER_PREFERENCES_FILE],
                     [DEFAULT_DATA_PATH,
                      DEFAULT_VERSION_DATA_PATH])

    return _CONFIG


def _init_config(
        config_files: Sequence[str], config_dirs,
        template_module: str = 'esa_climate_toolbox.conf.template'
) -> None:
    """
    Initialize the ESA Climate Toolbox configuration.

    :param config_files: A non-empty sequence of configuration files,
        for example ``["~/.ect/conf.py", "~/.ect/<version>/conf.py",
        "./ect-conf.py"]``. Configuration files are read in the given order.
        If the first file does not exist, it will be created and its content
        will be taken from the given *template_module*.
    :param config_dirs: list of directories in which a location file will be
        written.
    :param template_module: Qualified name of a Python module that serves as a
        configuration template file.
        If given, this file will be copied into the parent directory
        of *default_config_file*.
    """
    _write_location_files(config_dirs)
    new_config = _read_config_files(
        config_files, template_module=template_module
    )
    set_config(new_config if new_config is not None else {})


def _read_config_files(
        config_files: Sequence[str],
        template_module: str = 'esa_climate_toolbox.conf.template'
) -> Union[str, None]:
    """
    Initialize the configuration of the ESA Climate Toolbox.

    :param config_files: A non-empty sequence of configuration files,
        for example ``["~/.ect/conf.py", "~/.ect/<version>/conf.py",
        "./ect-conf.py"]``. Configuration files are read in the given order.
        If the first file does not exist, it will be created and its content
        will be taken from the given *template_module*.
    :param template_module: Qualified name of a Python module that serves as a
        configuration template file. If given, this file will be copied into
        the parent directory of *default_config_file*.
    """
    default_config_file = config_files[0]
    if default_config_file and template_module:
        # Always write a 'conf.py.template' so users can rename and
        # use that instead.
        default_config_dir = os.path.dirname(default_config_file)
        config_template_file = os.path.join(default_config_dir,
                                            'conf.py.template')
        try:
            _write_default_config_file(config_template_file, template_module)
        except (IOError, OSError) as error:
            _LOG.warning(
                'failed writing %s: %s' % (config_template_file, str(error))
            )

        if not os.path.exists(default_config_file):
            try:
                _write_default_config_file(
                    default_config_file, template_module
                )
            except (IOError, OSError) as error:
                _LOG.error(
                    'failed writing %s: %s' % (default_config_file, str(error))
                )

    new_config = None
    for config_file in config_files:
        if config_file and os.path.isfile(config_file):
            config = None
            try:
                config = _read_python_config(config_file)
            except Exception as error:
                _LOG.warning(
                    'failed reading %s: %s' % (config_file, str(error))
                )
            if config is not None:
                if new_config is None:
                    new_config = config
                else:
                    new_config.update(config)
    return new_config


def _write_location_files(config_dirs: Sequence[str]):
    """
    Write a location file into the given directories.

    :param config_dirs: Sequence of directories paths.
    """
    new_config = None
    for config_dir in config_dirs:
        if not os.path.isdir(config_dir):
            try:
                os.makedirs(config_dir, exist_ok=True)
            except Exception as error:
                _LOG.warning(
                    'failed creating %s: %s' % (config_dir, str(error))
                )
        location_file = os.path.join(config_dir, LOCATION_FILE)
        try:
            with open(location_file, 'w') as fp:
                fp.write(sys.prefix)
        except Exception as error:
            _LOG.warning('failed writing %s: %s' % (location_file, str(error)))
    return new_config


def _read_python_config(file):
    """
    Reads a configuration *file* which may contain any Python code.

    :param file: Either a configuration file path or a file pointer.
    :return: A dictionary with all the variable assignments made in the configuration file.
    """

    fp = open(file, 'r') if isinstance(file, str) else file
    try:
        config = {}
        code = compile(
            fp.read(), file if isinstance(file, str) else '<NO FILE>', 'exec'
        )
        exec(code, None, config)
        return config
    finally:
        if fp is not file:
            fp.close()


def _write_default_config_file(config_file: str, template_module: str) -> str:
    config_file = os.path.expanduser(config_file)
    config_dir = os.path.dirname(config_file)
    if config_dir and not os.path.exists(config_dir):
        os.makedirs(config_dir, exist_ok=True)

    with open(config_file, 'w', newline='') as fp:
        import pkgutil
        parts = template_module.split('.')
        template_package = '.'.join(parts[:-1])
        template_file = parts[-1] + '.py'
        template_data = pkgutil.get_data(template_package, template_file)
        text = template_data.decode('utf-8')
        fp.write(text)

    return config_file


def set_config(config: dict, update=False) -> None:
    """
    Direct modification of the configuration.

    Should be used for testing only.

    :param config: The new configuration values
    :param update: Whether to update or replace the configuration.
    """
    global _CONFIG
    if _CONFIG is None:
        if update:
            raise RuntimeError('configuration not initialized')
        else:
            _CONFIG = config
    else:
        _CONFIG.update(config)
