# -*- coding: utf-8 -*-
"""
Note that this duplicates anaconda_platform.common (without type annotations) because it needs to
support Python 2.7
"""
import copy
import logging
import os
import os.path

import jsonschema

from anaconda_enterprise.cli.internal import yaml

log = logging.getLogger(__name__)

variables = {
    'system_dir': '/etc',
    'config_dir': 'anaconda-platform',
}

platform_defaults = (
    '{source_dir}/schemas/v{version}/defaults/{component_file}', )

system_wide = ('{system_dir}/{config_dir}/{component_file}', )

user_local_component = '{home_dir}/{config_dir}/{component_file}'
user_local = (user_local_component, )

conda_env = ('{env_dir}/{config_dir}/{component_file}', )

# When the config schema changes, we need to be able
# to convert configuration in older schemas, so that the code
# base can refer strictly to the latest version

# These constants represent the schema versions we are aware of
SCHEMA_0 = '0'
SCHEMA_1 = '1'
SCHEMA_LATEST = SCHEMA_1
# Dictionary of functions that transform configuration dicts
# of one version to another.
UPGRADES = {}


def register_upgrade(from_version, to_version):
    """
    A decorator to add a conversion function that
    upgrades a configuration dict from version.

    upgrade function should have signature: Dict -> Dict

    The conversion function may mutate the passed in config.
    The conversion function does not need to mutate the `version` field of the dict.
    """
    def decorator(fn):
        def inner(cfg, *args, **kwargs):
            from_cfg = copy.deepcopy(cfg)
            to_cfg = fn(from_cfg, *args, **kwargs)
            to_cfg[ConfigManager.VERSION] = to_version
            return to_cfg

        # This may need to be smarter in the future with versions...
        # but it's probably easiest to treat them like migrations with a linear history.

        # We won't run into any issues with the dictionary keys if we write it that way.
        assert from_version not in UPGRADES, \
            'Upgrade from_version {} already exists'.format(from_version)
        UPGRADES[from_version] = inner

        return inner

    return decorator


class ConfigurationError(Exception):
    """A problem exists with the configuration"""


class ConfigManager(object):

    # Configuration Path Template Variables
    COMPONENT_FILE = 'component_file'
    SOURCE_DIR = 'source_dir'
    ENV_DIR = 'env_dir'
    HOME_DIR = 'home_dir'
    VERSION = 'version'

    def __init__(self, component, version):
        self.component = component.split('.')
        self.version = version
        self._data = {}  # current configuration
        self.schema = None
        self.variables = variables.copy()
        self.variables[self.VERSION] = version
        self.variables[self.COMPONENT_FILE] = '{}.yml'.format(component)
        self.variables[self.SOURCE_DIR] = self.source_dir
        self.variables[self.HOME_DIR] = os.getenv(
            'BINSTAR_CONFIG_DIR') or os.path.expanduser('~/.anaconda')

        prefix = os.environ.get('CONDA_PREFIX')
        if prefix is not None:
            self.variables[self.ENV_DIR] = '{}/etc'.format(prefix)

    def validate(self, config=None):
        if config is None:
            config = self._data

        if self.schema:
            log.debug('Validating configuration: %s', config)
            try:
                jsonschema.validate(config,
                                    self.schema,
                                    format_checker=jsonschema.FormatChecker())
            except jsonschema.exceptions.ValidationError as exc:
                msg = "'%s', %s" % ('.'.join(exc.path), exc.message)
                err = 'validating configuration %s' % msg
                log.debug(err)
                raise ConfigurationError(err)

    @property
    def source_dir(self):
        """Return the directory where this source file lives."""
        return os.path.dirname(os.path.abspath(__file__))

    @property
    def schema_path(self):
        """Return the path to the configuration schema file shipped with the software."""
        return '{source_dir}/schemas/v{version}/config.yml'.format(
            **self.variables)

    @property
    def token_path(self):
        return '{home_dir}/{config_dir}/tokens.json'.format(**self.variables)

    @property
    def user_local_save_path(self):
        return user_local_component.format(**self.variables)

    def load_yaml_string(self, config_str, path, conf_dict=None):
        """Parse a YAML string and validate it against our configuration schema."""
        try:
            result = yaml.load(config_str)
        except Exception:
            log.error('Error parsing YAML configuration file %s', path)
            raise

        if not result:
            log.warning('Empty config YAML in file %s', path)
            return

        try:
            upgraded_result = self.upgrade(result)
            if result != upgraded_result:
                result['version'] = upgraded_result['version']
                if result != upgraded_result:
                    log.warning(
                        "File: %s has deprecated configuration.\n"
                        "Configuration was updated on the fly but not modified.",
                        path)
            result = upgraded_result
            self.validate(result)
        except ConfigurationError as exc:
            log.error('Invalid configuration in file %s: %s', path, exc)
            raise
        conf = conf_dict if conf_dict is not None else self._data
        recursive_update(conf, result)

    def load_yaml_file(self, path, conf_dict=None):
        try:
            log.info('Reading configuration from %s', path)
            with open(path) as f:
                config_str = f.read()
        except Exception:
            # This will catch problems with opening or reading the file.
            log.error('Error reading configuration file %s', path)
            raise

        self.load_yaml_string(config_str, path, conf_dict)
        return conf_dict

    def load_dict(self, d):
        log.info('Loading configuration from dict:')
        for key, value in d.items():
            log.info('  %s: %r', key, value)
            self[key] = value
        self.validate()

    def load_files(self):
        """Load all the YAML config files in the search path."""
        for path in self.find_config_files():
            self.load_yaml_file(path)

    def load_schema(self):
        """
        Load the config file schema.

        The schema is a YAML file with JSONSchema semantics.
        If the file does not exist, schema validation will be bypassed.
        """

        path = self.schema_path

        if os.path.exists(path):
            log.info('Loading config schema file %s', path)
            with open(path) as f:
                self.schema = yaml.load(f)
        else:
            log.warning("Can't find config schema file %s", path)

    def get_site(self):
        site = self['default_site']
        if site not in self['sites']:
            raise ValueError(
                'Site: {site} not listed in sites option'.format(site=site))
        return site

    def get_user_config(self):
        path = self.user_local_save_path
        if os.path.exists(path):
            return self.load_yaml_file(path, {})
        else:
            return {'version': SCHEMA_LATEST}

    def new_config(self, key, value):
        # If key is `sites.example.url` and value `'http://repo:8089/api/`
        # This will create something like: {sites: {'example': {'url': 'http://repo:8089/api/}}}
        conf = {}
        self._update_tree(key, value, conf)
        self.validate(conf)
        return conf

    def merge_configs(self, target, new_):
        recursive_update(target, new_)
        return target

    def dump(self, conf_dict=None, file_=None):
        """Dumps the configuration to disk.

        If `file_` should be an object that has the `.write()`
        interface. If None is provided, this will write to the
        `user_local` path.

        """
        if conf_dict is None:
            conf_dict = self._data

        if file_ is None:
            path = self.user_local_save_path
            log.info('Saving configuration to %s', path)
            dirname = os.path.dirname(path)
            if not os.path.exists(dirname):
                os.makedirs(dirname)
            with open(path, 'w') as file_:
                yaml.dump(conf_dict, file_)
        else:
            yaml.dump(conf_dict, file_)

    def delete_token(self):
        """Delete the token file at self.token_path.

        Output:

        bool: True if files exists (and has been removed); false otherwise
        """
        if os.path.exists(self.token_path):
            os.remove(self.token_path)
            return True
        else:
            return False

    def __getitem__(self, key):
        """
        Return a single configuration value or part of the tree.

        Given a configuration key as a dot-separated path,
        traverse the configuration tree to find the value.
        This will first check in the component-specific config,
        followed by the global config.

        If the key can't be found, raises KeyError.
        """
        for prefix in self.prefixes(self.component):
            try:
                return self.find_config_value(self._data, prefix + key)
            except KeyError:
                continue

        # Not found anywhere in the hierarchy.
        # re-raise with the full original key
        raise KeyError(key)

    def _update_tree(self, key, value, root):
        keypath = key.split('.')
        cfg = root

        for k in keypath[:-1]:
            cfg = cfg.setdefault(k, {})

        cfg[keypath[-1]] = value

    def __setitem__(self, key, value):
        """
        Set a single configuration value or part of the tree.

        Given a configuration key as a dot-separated path,
        this will place `value` in the config variable specified
        by that path. Intervening path components will be created
        if necessary. Existing values will remain in place, except for
        the final (leaf) value which will be replaced by `value`.
        """
        self._update_tree(key, value, self._data)

    #
    # INTERNALS
    #
    @property
    def config_path_templates(self):
        """Return a tuple of templates which should be used to generate the search paths"""
        paths = platform_defaults + system_wide
        if self.ENV_DIR in self.variables:
            paths = paths + conda_env
        paths = paths + user_local
        return paths

    def get_search_path(self):
        """
        Return the list of eligible config file paths.

        Files in this list are the ones that will be read, if they exist.
        """
        return [
            path.format(**self.variables)
            for path in self.config_path_templates
        ]

    def find_config_files(self):
        """Return the list of config files that will be read."""
        return filter(os.path.exists, self.get_search_path())

    @staticmethod
    def prefixes(keypath):
        for i in reversed(range(len(keypath) + 1)):
            prefix = '.'.join(keypath[:i])
            yield prefix + '.' if prefix else ''

    @staticmethod
    def find_config_value(config, key):
        keypath = key.split('.')

        for k in keypath:
            config = config[k]
        return config

    def upgrade(self, conf):
        """Convert a configuration dict *conf* to the version of this config manager"""
        # We default to an unspecified schema version
        version = conf.get('version', SCHEMA_0)
        if version == self.version:
            return conf
        elif version in UPGRADES:
            old_config_manager = ConfigManager('.'.join(self.component),
                                               version)
            old_config_manager.load_schema()
            old_config_manager.validate(conf)
            conf = UPGRADES[version](conf)
            # recursively upgrade, if the version is multiple versions old
            # TODO: test this more thoroughly when there's more than a single upgrade
            return self.upgrade(conf)
        else:
            raise ConfigurationError(
                "Unrecognized configuration version: {0}".format(version))

    @staticmethod
    @register_upgrade(SCHEMA_0, SCHEMA_1)
    def upgrade_v0_v1(cfg):
        warn = False
        if 'verify_ssl' in cfg:
            cfg['ssl_verify'] = cfg.pop('verify_ssl')
            warn = True
        for site in cfg.get('sites', []):
            if 'verify_ssl' in cfg['sites'][site]:
                cfg['sites'][site]['ssl_verify'] = cfg['sites'][site].pop(
                    'verify_ssl')
                warn = True

        if warn:
            log.warning(
                'Configuration option "verify_ssl" deprecated as of 5.1.0;'
                ' use "ssl_verify" instead')

        return cfg


def recursive_update(target, new):
    """Recursively update one dictionary using another.

    None values will delete their keys.
    """
    # This portion:
    # Copyright (c) IPython Development Team.
    # Distributed under the terms of the Modified BSD License.
    for k, v in new.items():
        if isinstance(v, dict):
            if k not in target:
                target[k] = {}
            recursive_update(target[k], v)
            if not target[k]:
                # Prune empty subdicts
                del target[k]

        elif v is None:
            target.pop(k, None)

        else:
            target[k] = v


config = ConfigManager('cli', SCHEMA_LATEST)


def load_config():
    config.load_schema()
    config.load_files()
    return config
