import json
import os
import types

import attr
import six

from ein import _flask, utils

try:
    from shlex import quote
except ImportError:
    from pipes import quote

DEFAULT_ENV_FILE = '.env'
DEFAULT_CONFIG_MODULE_NAME = '{app_name}.default_config'
APP_NAME_ENVVAR = 'APP_NAME'


@attr.s(repr=False)
class Config(_flask.Config):
    """
    Configuration object.
    """

    app_name = attr.ib(
        default=attr.Factory(
            lambda self: os.environ[APP_NAME_ENVVAR],
            takes_self=True,
        )
    )
    root_path = attr.ib(
        default=attr.Factory(
            lambda: os.path.abspath(os.curdir),
        )
    )
    defaults = attr.ib(default=attr.Factory(dict))

    def __attrs_post_init__(self):
        super(Config, self).__init__(self.root_path, self.defaults)

    """ Generic """

    def configure(
            self,
            configfile=None,
            envfile=True,
            envvar=True,
            default_config=True,
            from_envvars=True,
            from_envvars_prefix=None
    ):
        """
        Loads the configuration in a multitude of configurable manners
        into this configuration object.

        :param str|unicode configfile: The configuration file to use.
            The configuration should be a python file if specified.
            All items in the file will be loaded as configuration
            items
        :param bool|str|unicode envfile: Loads environment variables from
            the ``.env`` file in the ``root_path`` if that
            file is available.  If it is a string, then that file will
            be loaded in the same manner
        :param bool envvar: Indicates if configuration should should look for
            <app_name>_CONFIG to load as a configuration file.
        :param bool|str|unicode default_config: If true, then a ``app_name.default_config.py``
            will be loaded into the environment. If a default_config is a
            string then that python module will be loaded as the
            default settings.  Additionally, a format string can be loaded,
            in which case the app_name will be the argument, e.g. ``"{0}.default_config"``
        :param bool from_envvars: Load environment variables with ``APPNAME_KEY``
            as a ``KEY`` configuration item.
        :param bool from_envvars_prefix: The prefix for environment variables
            that should be considered configuration when ``from_envvars=True``.
            Defaults to ``app_name.upper()``
        """
        if envfile:
            self._apply_envfile(envfile)

        app_name_upper = self.app_name.upper()

        if from_envvars_prefix is None:
            from_envvars_prefix = app_name_upper

        if default_config is True:
            # ignore import errors if settings were autogenerated
            self.from_default_config(silent=True)
        elif default_config:
            self.from_default_config(default_config)

        # load supplied configuration file
        if configfile:
            self.from_pyfile(configfile)

        # load configuration file from environment
        if envvar is True:
            envvar = '%s_CONFIG' % app_name_upper

        if envvar and envvar in os.environ:
            self.from_envvar(envvar)

        # load environment variables
        if from_envvars:
            self.from_envvars(from_envvars_prefix)

    def from_default_config(self, module_name=None, silent=False):
        if not module_name:
            module_name = DEFAULT_CONFIG_MODULE_NAME
        module_name = module_name.format(app_name=self.app_name)
        try:
            self.from_object(module_name)
        except ImportError:
            if not silent:
                raise
            else:
                return

    def from_any(
            self, obj, as_json=True, as_py=True, as_file=True, silent=False
    ):
        """
        Magic function that tries everything it can to get configuration out
        of ``obj``, whether that's a JSON or Python string or filename.

        :param str|unicode obj: JSON or Python string/filename
        :param bool as_json: If we should try to treat it as a JSON string
        :param bool as_py: If we should try to treat it as a Python string
        :param bool as_file: If we should try to treat it as a JSON filename
        :param bool silent: If we should silence exceptions on bad JSON or inability to open file.
        :return: False if unable to perform and silent.
        :rtype: bool
        """
        if as_json:
            if as_file and self._is_path(obj):
                meth = self.from_jsonfile
            else:
                meth = self.from_json

            ret = meth(obj, silent=True)
            if ret:
                return ret

        if as_py:
            if as_file and self._is_path(obj):
                meth = self.from_pyfile
            else:
                meth = self.from_py

            ret = meth(obj, silent=True)
            if ret:
                return ret

        if silent:
            return False
        else:
            raise RuntimeError(
                "Could not parse obj=%s as_json=%s as_py=%s as_file=%s" %
                (obj, as_json, as_py, as_file)
            )

    """ Get/set item by attribute lookup """

    def __getattr__(self, name):
        """
        Override to treat the string `name` first as a dict key,
        then as an attribute name if `KeyError` is raised.
        """
        try:
            # ugh gross
            try:
                object.__getattribute__(self, name)
                raise ValueError(name)
            except AttributeError:
                pass

            return self.__getitem__(name)
        except KeyError:
            return object.__getattribute__(self, name)

    def __setattr__(self, name, value):
        """
        Override to treat the pair `(name, value)` first as a dict item,
        then as an attribute if `ValueError` is raised.
        """
        try:
            if hasattr(self, name):
                raise ValueError(name)
            self.__setitem__(name, value)
        except ValueError:
            object.__setattr__(self, name, value)

    """ Methods to update contents """

    def from_json(self, obj, silent=False):
        """
        Updates the values in the config from a string of JSON.

        :param obj: JSON string
        :type obj: str
        :param silent: If we should silence exceptions on bad JSON or inability to open file.
        :type silent: bool
        :return: False if unable to perform and silent.
        :rtype: bool
        """
        try:
            mapping = json.loads(obj)
        except ValueError:
            if silent:
                return False
            else:
                raise
        return self.from_mapping(mapping)

    def from_jsonfile(self, filename, silent=False):
        """
        Updates the values in the config from a JSON file.

        :param filename: JSON filename or string
        :type filename: str
        :param silent: If we should silence exceptions on bad JSON or inability to open file.
        :type silent: bool
        :return: False if unable to perform and silent.
        :rtype: bool
        """
        return super(Config, self).from_json(filename, silent=silent)

    def from_py(self, obj, silent=False):
        """
        Loads config from python code contained in a string.

        :param str|unicode obj: The python file name e.g. ``"myapp.config"``
        :param silent: If we should silence exceptions on bad JSON or inability to open file.
        :type silent: bool
        :return: False if unable to perform and silent.
        :rtype: bool
        :raises SyntaxError: If the file could not be read as a python file
        """
        m = types.ModuleType('config')
        m.__file__ = '<string>'
        try:
            exec(compile(obj, m.__file__, 'exec'), m.__dict__)
        except SyntaxError:
            if silent:
                return False
            else:
                raise
        return self.from_object(m)

    def from_envvar(
            self, key, as_json=True, as_py=True, as_file=True, silent=False
    ):
        """
        Loads configuration from environment variable. This is handed into from_any.

        :param key: Env variable name
        :type key: str
        :param as_json: If we should try to treat it as a JSON string
        :type as_json: bool
        :param as_py: If we should try to treat it as a Python string
        :type as_py: bool
        :param as_file: If we should try to treat it as a JSON filename
        :type as_file: bool
        :param silent: If we should silence exceptions on bad JSON or inability to open file.
        :type silent: bool
        :return: False if unable to perform and silent.
        :rtype: bool
        """
        try:
            val = os.environ[key]
        except KeyError as e:
            if silent:
                return False
            else:
                raise e

        return self.from_any(
            val, as_json=as_json, as_file=as_file, as_py=as_py, silent=silent
        )

    def from_envvars(self, prefix=None, envvars=None, as_json=True):
        """
        Load environment variables as Flask configuration settings.
        Values are parsed as JSON. If parsing fails with a ValueError,
        values are instead used as verbatim strings.

        :param prefix: If ``None`` is passed as envvars, all variables from
                       ``environ`` starting with this prefix are imported. The
                       prefix is stripped upon import.
        :param envvars: A dictionary of mappings of environment-variable-names
                        to configuration names. If a list is passed
                        instead, names are mapped 1:1. If ``None``, see prefix
                        argument.
        :param as_json: If False, values will not be parsed as JSON first.
        """
        if prefix is None and envvars is None:
            raise RuntimeError('Must either give prefix or envvars argument')

        if prefix:
            prefix = '%s_' % prefix

        # if it's a list, convert to dict
        if isinstance(envvars, list):
            envvars = {k: None for k in envvars}

        if not envvars:
            envvars = {
                k: k[len(prefix):]
                for k in os.environ.keys()
                if k.startswith(prefix)
            }

        mapping = {}
        for env_name, name in six.iteritems(envvars):
            if name is None:
                name = env_name

            if not env_name in os.environ:
                continue

            value = os.environ[env_name]
            if as_json:
                try:
                    value = json.loads(value)
                except ValueError:
                    pass
            mapping[name] = value

        return self.from_mapping(mapping)

    """ Misc """

    def _copy_without_data(self):
        return self.__class__(
            app_name=self.app_name,
            root_path=self.root_path,
            defaults=self.defaults,
        )

    def changed_from_defaults(self, module_name=None):
        """
        Compare self to your default_config and return a dictionary of changed values.
        """
        defaults = self._copy_without_data()
        defaults.from_default_config(module_name=module_name)

        ret = {}
        for k, v in six.iteritems(self):
            if defaults[k] == v:
                continue
            ret[k] = v
        return ret

    def dump(self, format='env', keys=None, skip_defaults=True):
        """
        Dumps configuration values
        """
        indent = 2

        if skip_defaults:
            obj = self.changed_from_defaults()
        else:
            obj = self

        if keys:
            obj = {k: v for k, v in six.iteritems(obj) if k in keys}

        ret = []
        if format in ['env', 'shell']:
            obj_keys = list(obj)
            obj_keys.sort()

            if format == 'env':
                indent = None
            for k in obj_keys:
                v = obj[k]
                k = '%s_%s' % (self.app_name.upper(), k)
                if not isinstance(v, six.string_types):
                    v = json.dumps(v, indent=indent)
                v = quote(v)
                ret.append('%s=%s' % (k, v))
        elif format == 'json':
            out = json.dumps(obj, indent=indent)
            ret = out.splitlines()
        else:
            raise ValueError("Format %s not available" % format)

        return '\n'.join(ret)

    def _is_path(self, obj):
        # TODO This should not test if it's a file but instead test if it's a path.
        # TODO root_path?
        return os.path.isfile(obj)

    def _apply_envfile(self, envfile=None):
        # This is True garbage is to easily support defaults from callers
        if not envfile or envfile is True:
            envfile = os.path.join(self.root_path, DEFAULT_ENV_FILE)
        if not os.path.isfile(envfile):
            return
        with open(envfile) as fh:
            buf = fh.read()
        os.environ.update(utils.honcho_parse_env(buf))

    def as_dict(self):
        return {k: v for k, v in self.items() if k.isupper()}
