"""Configaro Python configuration library."""

import ast
import os
import sys
from importlib import import_module
from importlib.abc import FileLoader, SourceLoader
from types import CodeType, ModuleType
from typing import Any, List, Tuple, Union

from munch import Munch, munchify

__all__ = [
    'ConfigError',
    'ConfigModuleNotFoundError',
    'ConfigModuleNotValidError',
    'ConfigObjectNotInitializedError',
    'ConfigPropertyNotFoundError',
    'ConfigPropertyNotScalarError',
    'ConfigUpdateNotValidError',
    'get',
    'init',
    'put',
]


DEFAULTS_CONFIG_MODULE_NAME = 'defaults'
LOCALS_CONFIG_MODULE_NAME = 'locals'

_CONFIG_DATA = munchify({})


class ConfigError(BaseException):
    """Configaro base configuration error class."""

    def __init__(self, message: str):
        """Initialize new ConfigError object.

        Args:
            message: error message

        """
        super().__init__()
        self.message = message


class ConfigObjectNotInitializedError(ConfigError):
    """Config object not initialized error."""

    def __init__(self):
        """Initialize new ConfigObjectNotInitializedError object."""
        super().__init__('config object not initialized')


class ConfigModuleNotFoundError(ConfigError):
    """Config module not found error."""

    def __init__(self, path: str):
        """Initialize new ConfigModuleNotFoundError object.

        Args:
            path: config module path

        """
        super().__init__(f'config module not found: {path}')
        self.path = path


class ConfigModuleNotValidError(ConfigError):
    """Config module does not contain a 'config' attribute of 'dict' type error."""

    def __init__(self, path):
        """Initialize new ConfigModuleNotValidError object.

        Args:
            path: config module path

        """
        super().__init__(f'config module not valid: {path}')
        self.path = path


class ConfigPropertyNotFoundError(ConfigError):
    """Config property not found error."""

    def __init__(self, data: Munch, prop_name: str):
        """Initialize new ConfigPropertyNotFoundError object.

        Args:
            data: config object data
            prop_name: config property name

        """
        super().__init__(f'config property not found: {prop_name}')
        self.data = data
        self.prop_name = prop_name


class ConfigPropertyNotScalarError(ConfigError):
    """Config property not scalar error."""

    def __init__(self, data: Munch, prop_name: str):
        """Initialize new ConfigPropertyNotScalarError object.

        Args:
            data: config object data
            prop_name: config property name

        """
        super().__init__(f'config property not scalar: {prop_name}')
        self.data = data
        self.prop_name = prop_name


class ConfigUpdateNotValidError(ConfigError):
    """Config update not valid error."""

    def __init__(self, update: str):
        """Initialize new ConfigUpdateNotValidError object.

        Args:
            update: config update

        """
        super().__init__(f'config update not valid: {update}')
        self.update = update


def init(config_package: str, locals_path: str=None, locals_env_var: str=None):
    """Initialize the config object.

    The config object must be initialized before use and is built from one or
    two config modules.  A config module is simply any Python module with a
    module attribute named **config**.  The config object loaded from the
    **defaults** config module, as well as any **locals** config module found.

    The **defaults** config module is always loaded.  The required *config_package*
    argument is used to define the package in which the **defaults** config
    module, named ``defaults.py``, is loaded from::

        init('my_project.config')

    The **locals** config module is loaded, next if it exists, from the following
    locations, in precedence order from highest to lowest:

    - one found at path specified by locals path
    - one found at path specified by locals env var
    - one found in config package

    If no other options are provided, the **locals** config module will be loaded,
    if it exists, from the *config_package*.

    If the optional *locals_path* argument is provided it will be used, if it
    exists, instead of any ``locals.py`` config module in the config package::

        init('my_project.config', locals_path='/path/to/my/alternatively_named_locals.py')

    If the optional *locals_env_var* argument is provided it will be used as a
    an environment variable configuring the path of the locals config module to
    load, if the module exists::

        init('my_project.config', locals_env_var='MY_PROJECT_CONFIG_LOCALS')

    Repeated initialization has no effect.  You can not re-initialize with
    different values.

    Args:
        config_package: package to search for config modules
        locals_path: path to locals config module
        locals_env_var: name of environment variable providing path to locals config module

    """
    global _CONFIG_DATA
    if _CONFIG_DATA:
        return

    for path in _config_module_paths(config_package, locals_path, locals_env_var):
        deltas = _load(path)
        merged = dict(_merge(_CONFIG_DATA, deltas))
        _CONFIG_DATA = merged
    _CONFIG_DATA = munchify(_CONFIG_DATA)


def get(*prop_names: str, **kwargs: str) -> Any:
    """Query config values in config object.

    The config object must be initialized with :meth:`configaro.init` before use.

    If no *prop_names* are provided, returns the config root config object::

        config = get()

    If one property name is provided, returns that sub config object::

        prop = get('prop')

    If multiple property names are provided, returns a tuple of sub config objects::

        prop1, prop2 = get('prop1', 'prop2')

    Multiple property names can also be provided in a single string argument as well::

        prop1, prop2 = get('prop1 prop2')

    If a property name is not found, configaro.ConfigPropertyNotFoundError is raised,
    unless a *default* keyword argument is provided::

        prop = get('prop', default=None)

    Args:
        prop_names: config property names
        kwargs: config property names and values keyword args

    Returns:
        property values

    Raises:
        configaro.ConfigObjectNotInitializedError: if config object has not been initialized
        configaro.ConfigPropertyNotFoundError: if a config property in *prop_names* is not found

    """
    if not _CONFIG_DATA:
        raise ConfigObjectNotInitializedError()
    if not prop_names or len(prop_names) == 1 and prop_names[0] is None:
        return _CONFIG_DATA
    if len(prop_names) == 1:
        return _get(_CONFIG_DATA, prop_names[0], **kwargs)
    else:
        return tuple([_get(_CONFIG_DATA, prop_name, **kwargs) for prop_name in prop_names])


def put(*args: str, **kwargs: str):
    """Modify config values in config object.

    The config object must be initialized with :meth:`configaro.init` before use.

    This function supports many expressive styles of usage.

    The entire config object can be updated with a single dict data argument::

        put({'prop_a': True, 'prop_b': 23})

    Similarly, any sub config object can be updated with a string property and
    dict data argument::

        put(prop={'prop_a': True, 'prop_b': 23})
        put('nested.prop', {'prop_a': True, 'prop_b': 23})

    Updates can also be specified by ``name=value`` update strings.  If one or
    more string arguments are passed, the updates described by those update
    strings will be applied to the config object::

        put('prop_a=True')
        put('prop_b=23')

    Update strings allow hierarchical configs to be updated::

        put('prop.nested=awesome')

    You can also batch up multiple updates in a single update string::

        put('prop_a=True prop.nested=awesome')

    If you are updating root config properties you can simply use keyword
    arguments::

        put(prop_a=True, prop_d={'greeting': 'Hello', 'subject': 'world'})

    Args:
        args: config dict object or one or more 'some.knob=value' update strings
        kwargs: config property names and values keyword args

    Raises:
        configaro.ConfigObjectNotInitializedError: if config object has not been initialized
        configaro.ConfigPropertyNotScalarError: if config property is not a scalar
        configaro.ConfigUpdateNotValidError: if config update string is not valid

    """
    if not _CONFIG_DATA:
        raise ConfigObjectNotInitializedError()

    # Handle passing in a single dict arg.
    if len(args) == 1 and isinstance(args[0], dict):
        _CONFIG_DATA.update(args[0])
        return

    # Handle passing in a prop name and an update value of any sort other than string.
    if len(args) == 2 and isinstance(args[0], str) and not isinstance(args[1], str):
        _put(_CONFIG_DATA, args[0], args[1])
        return

    # Handle positional string arguments.  If the caller wishes to modify
    # nested properties, they must be passed in as update strings, such as
    # 'log.level=INFO'.  Values will be cast from strings to their appropriate
    # type.  Multiple updates can be specified in a single string separated by
    # whitespace.
    if len(args) == 1 and isinstance(args[0], str):
        args = args[0].split()
    for arg in args:
        try:
            prop_name, value = arg.split('=')
            prop_value = _cast(value)
            _put(_CONFIG_DATA, prop_name, prop_value)
        except ValueError:
            raise ConfigUpdateNotValidError(arg)

    # Handle any keyword arguments.  If the caller doesn't care about nested
    # property updates, property names and values may be passed in keyword args.
    for prop_name, prop_value in kwargs.items():
        _put(_CONFIG_DATA, prop_name, prop_value)


def _config_module_paths(config_package: str, locals_path: str=None, locals_env_var: str=None) -> List[str]:
    """Config module paths accessor.

    Returns:
        config module paths

    Raises:
        configaro.ConfigModuleNotFoundError: if config module not found

    """
    config_paths = []
    package_dir = _config_package_dir(config_package)

    # Start by adding the 'defaults' config module in the config package.
    defaults_path = os.path.join(package_dir, f'{DEFAULTS_CONFIG_MODULE_NAME}.py')
    if not os.path.exists(defaults_path):
        raise ConfigModuleNotFoundError(defaults_path)
    config_paths.append(defaults_path)

    # Continue by adding the 'locals' config module from.
    if not locals_path and locals_env_var:
        locals_path = os.environ.get(locals_env_var)
    if not locals_path:
        locals_path = os.path.join(package_dir, f'{LOCALS_CONFIG_MODULE_NAME}.py')
    if os.path.exists(locals_path):
        config_paths.append(locals_path)
    return config_paths


def _config_package_dir(config_package: str) -> str:
    """Config package directory accessor.

    Returns:
        config package directory

    Raises:
        ImportError: if config package defaults cannot be loaded.

    """
    module = import_module(f'{config_package}.defaults')
    return os.path.dirname(module.__file__)


def _cast(value: str) -> Union[None, bool, int, float, str]:
    """Cast string property value to real type.

    Args:
        value: property value to cast

    Returns:
        casted property value

    """
    # Handle None type values
    if value == 'None':
        return None
    # Handle Boolean type values
    if value == 'False':
        return False
    elif value == 'True':
        return True
    # Handle numeric type values.
    types = [int, float]
    for type_ in types:
        try:
            return type_(value)
        except ValueError:
            pass
    # Must be a string.
    return value


def _get(data: Munch, prop_name: str, **kwargs: str) -> Union[Munch, Any]:
    """Get config value identified by config property in config data.


    Arg:
        data: config data
        prop_name: config property name
        kwargs: keyword arguments

    Returns:
        config value

    Raises:
        configaro.ConfigPropertyNotFoundError: if property is not found and *default* keyword arg is not present

    """
    try:
        return eval(f'data.{prop_name}')  # NOTE: do not remove the 'data' parameter in function as it is used here!
    except AttributeError:
        try:
            return kwargs['default']
        except KeyError:
            raise ConfigPropertyNotFoundError(data, prop_name)


def _put(data: Munch, prop_name: str, prop_value=Any):
    """Put config value identified by config property in config data.

    Arg:
        data: config data
        prop_name: config property name
        prop_value: config value

    Raises:
        configaro.ConfigPropertyNotFoundError: if config property is not found
        configaro.ConfigPropertyNotScalarError: if config property is not scalar and non-dict value is provided

    """
    prop_parts = prop_name.split('.')
    if len(prop_parts) > 1:
        parent_prop_name = '.'.join(prop_parts[:-1])
        prop_name = prop_parts[-1]
        config = _get(data, parent_prop_name)
    else:
        config = data
    if isinstance(config[prop_name], Munch) and not isinstance(prop_value, dict):
        raise ConfigPropertyNotScalarError(config, prop_name)
    config[prop_name] = prop_value


def _load(path: str) -> dict:
    """Load config values from file.

    Args:
        path: config file path

    Returns:
        config data

    Raises:
        ImportError: if module cannot be imported
        configaro.ConfigModuleNotValidError is module does not contain a 'config' dict attribute.

    """
    module_dir = os.path.dirname(path)
    module_name = os.path.basename(path).replace('.py', '')
    module = _import_module(module_dir, module_name)
    try:
        if not isinstance(module.config, dict):
            raise ConfigModuleNotValidError(path)
        return module.config
    except AttributeError:
        raise ConfigModuleNotValidError(path)


def _merge(original: dict, deltas: dict) -> Tuple[str, Any]:
    """Merge two dictionaries.

    Args:
        original: original data
        deltas: deltas data

    Yields:
        merged keys and values

    """
    for k in set(original.keys()).union(deltas.keys()):
        if k in original and k in deltas:
            if isinstance(original[k], dict) and isinstance(deltas[k], dict):
                yield k, dict(_merge(original[k], deltas[k]))
            else:
                # If one of the values is not a dict, you can't continue merging it.
                # Value from second dict overrides one in first and we move on.
                yield k, deltas[k]
                # Alternatively, replace this with exception raiser to alert you of value conflicts
        elif k in original:
            yield k, original[k]
        else:
            yield k, deltas[k]


def _import_module(module_dir: str, module_name: str) -> ModuleType:
    """Import module from directory.

    Args:
        module_dir: module directory path
        module_name: name of module to import from *module_dir*

    Returns:
         imported module

    Raises:
        ImportError: if module cannot be imported

    """
    filename = _module_path(module_dir, module_name)
    if module_name in sys.modules:
        return sys.modules[module_name]
    return _ConfigLoader(module_name, filename).load_module(module_name)


def _module_path(module_dir: str, module_name: str) -> str:
    """Get module path..

    Args:
        module_dir: module directory
        module_name: module name

    Returns:
         module file path

    """
    filename = os.path.join(module_dir, *module_name.split('.'))
    return os.path.join(filename, '__init__.py') if os.path.isdir(filename) else f'{filename}.py'


class _ConfigLoader(FileLoader, SourceLoader):
    """Config module loader class."""

    def get_code(self, fullname: str) -> CodeType:
        source = self.get_source(fullname)
        path = self.get_filename(fullname)
        parsed = ast.parse(source)
        return compile(parsed, path, 'exec', dont_inherit=True)

    def module_repr(self, module: ModuleType):
        return f'<config module {module.__name__} at {module.__file__}>'
