""" Utilities """
import logging
import os
import re
import time
import unicodedata
from typing import (
    Any,
    Callable,
    Dict,
    ItemsView,
    Iterator,
    KeysView,
    List,
    Optional,
    Tuple,
    Union,
)

from distlib.locators import Locator
from distlib.util import split_filename
from distlib.wheel import Wheel

LOG = logging.getLogger(__name__)
ALL_EXTENSIONS = Locator.source_extensions + Locator.binary_extensions
SENTINEL = object()


class PackageParseError(ValueError):
    pass


def parse_filename(filename: str, name: Optional[str] = None) -> Tuple[str, str]:
    """Parse a name and version out of a filename"""
    version = None
    for ext in ALL_EXTENSIONS:
        if filename.endswith(ext):
            if ext == ".whl":
                wheel = Wheel(filename)
                return wheel.name, wheel.version
            trimmed = filename[: -len(ext)]
            parsed = split_filename(trimmed, name)
            if parsed is None:
                break
            else:
                parsed_name, version = parsed[:2]
            break
    if version is None:
        raise PackageParseError("Cannot parse package file '%s'" % filename)
    if name is None:
        name = parsed_name
    return normalize_name(name), version


def get_packagetype(name: str) -> str:
    """Get package type out of a filename"""
    if name.endswith(".tar.gz"):
        return "sdist"
    elif name.endswith(".egg"):
        return "bdist_egg"
    elif name.endswith(".whl"):
        return "bdist_wheel"
    else:
        return ""


def normalize_name(name: str) -> str:
    """Normalize a python package name"""
    # Lifted directly from PEP503:
    # https://www.python.org/dev/peps/pep-0503/#id4
    return re.sub(r"[-_.]+", "-", name).lower()


def normalize_metadata_value(value: Union[str, bytes]) -> str:
    """Strip non-ASCII characters from metadata values"""
    if isinstance(value, bytes):
        value = value.decode("utf-8")
    if isinstance(value, str):
        value = "".join(c for c in unicodedata.normalize("NFKD", value) if ord(c) < 128)
    return value


def normalize_metadata(metadata: Dict[str, Union[str, bytes]]) -> Dict[str, str]:
    """
    Strip non-ASCII characters from metadata values
    and replace "_" in metadata keys to "-"
    """
    return {
        key.replace("_", "-"): normalize_metadata_value(value)
        for key, value in metadata.items()
    }


def create_matcher(queries: List[str], query_type: str) -> Callable[[str], bool]:
    """
    Create a matcher for a list of queries

    Parameters
    ----------
    queries : list
        List of queries

    query_type: str
        Type of query to run: ["or"|"and"]

    Returns
    -------
        Matcher function

    """
    queries = [query.lower() for query in queries]
    if query_type == "or":
        return lambda x: any((q in x.lower() for q in queries))
    else:
        return lambda x: all((q in x.lower() for q in queries))


class EnvironSettings:
    def __init__(self, settings: Dict[str, Any], env: Dict[str, str] = None):
        self._settings = settings
        if env is None:
            self._env = dict(os.environ)
        else:
            self._env = env

    @staticmethod
    def _get_environ_key(key: str) -> str:
        return "PPC_" + key.upper().replace(".", "_")

    def __iter__(self) -> Iterator[str]:
        return iter(self._settings)

    def keys(self) -> KeysView[str]:
        return self._settings.keys()

    def items(self) -> ItemsView[str, Any]:
        return self._settings.items()

    def __contains__(self, key: str) -> bool:
        env_key = self._get_environ_key(key)
        return env_key in self._env or key in self._settings

    def __setitem__(self, key: str, value: str) -> None:
        env_key = self._get_environ_key(key)
        self._env.pop(env_key, None)
        self._settings[key] = value

    def __getitem__(self, key: str) -> str:
        env_key = self._get_environ_key(key)
        if env_key in self._env:
            return self._env[env_key]
        return self._settings[key]

    def __str__(self) -> str:
        return str(self._settings)

    def __repr__(self) -> str:
        return "EnvironSettings(%s)" % self._settings

    def read_prefix_from_environ(self, prefix: str) -> None:
        if not prefix:
            return
        # Make sure the prefix has a '.' at the end (e.g. "sqlalchemy.")
        if prefix[-1] == "_":
            prefix = prefix[:-1] + "."
        elif prefix[-1] != ".":
            prefix = prefix + "."
        env_prefix = self._get_environ_key(prefix)
        for key, val in self._env.items():
            if key.startswith(env_prefix):
                setting_key = prefix + key[len(env_prefix) :].lower()
                self._settings[setting_key] = val

    def get(self, key: str, default: Any = None) -> Any:
        try:
            return self[key]
        except KeyError:
            return default

    def pop(self, key: str, default: Any = SENTINEL) -> Any:
        env_key = self._get_environ_key(key)
        if env_key in self._env:
            self._settings.pop(key, None)
            return self._env.pop(env_key)
        try:
            return self._settings.pop(key)
        except KeyError:
            if default is SENTINEL:
                raise
            else:
                return default

    def setdefault(self, key: str, value: Any) -> Any:
        try:
            return self[key]
        except KeyError:
            self._settings[key] = value
            return value

    def get_as_dict(
        self, prefix: str, **kwargs: Callable[[Any], Any]
    ) -> Dict[str, Any]:
        """
        Convenience method for fetching settings

        Returns a dict; any settings that were missing from the config file will
        not be present in the returned dict (as opposed to being present with a
        None value)

        Parameters
        ----------
        prefix : str
            String to prefix all keys with when fetching value from settings
        **kwargs : dict
            Mapping of setting name to conversion function (e.g. str or asbool)

        """
        computed = {}
        for name, fxn in kwargs.items():
            val = self.get(prefix + name)
            if val is not None:
                computed[name] = fxn(val)
        return computed

    def clone(self) -> "EnvironSettings":
        return EnvironSettings(dict(self._settings), dict(self._env))


class TimedCache(dict):
    """
    Dict that will store entries for a given time, then evict them

    Parameters
    ----------
    cache_time : int or None
        The amount of time to cache entries for, in seconds. 0 will not cache.
        None will cache forever.
    factory : callable, optional
        If provided, when the TimedCache is accessed and has no value, it will
        attempt to populate itself by calling this function with the key it was
        accessed with. This function should return a value to cache, or None if
        no value is found.

    """

    def __init__(
        self, cache_time: Optional[int], factory: Optional[Callable[[Any], Any]] = None
    ):
        super(TimedCache, self).__init__()
        if cache_time is not None and cache_time < 0:
            raise ValueError("cache_time cannot be negative")
        self._cache_time = cache_time
        self._factory = factory
        self._times = {}  # type: Dict[str, float]

    def _has_expired(self, key):
        """Check if a key is both present and expired"""
        if key not in self._times or self._cache_time is None:
            return False
        updated = self._times[key]
        return updated is not None and time.time() - updated > self._cache_time

    def _evict(self, key):
        """Remove a key if it has expired"""
        if self._has_expired(key):
            del self[key]

    def __contains__(self, key):
        self._evict(key)
        return super(TimedCache, self).__contains__(key)

    def __delitem__(self, key):
        del self._times[key]
        super(TimedCache, self).__delitem__(key)

    def __setitem__(self, key, value):
        if self._cache_time == 0:
            return
        self._times[key] = time.time()
        super(TimedCache, self).__setitem__(key, value)

    def __getitem__(self, key):
        self._evict(key)
        try:
            value = super(TimedCache, self).__getitem__(key)
        except KeyError:
            if self._factory:
                value = self._factory(key)
                if value is None:
                    raise
                self[key] = value
            else:
                raise
        return value

    def get(self, key, default=None):
        self._evict(key)
        value = super(TimedCache, self).get(key, SENTINEL)
        if value is SENTINEL:
            if self._factory is not None:
                value = self._factory(key)
                if value is not None:
                    self[key] = value
                    return value
                else:
                    return default
            else:
                return default
        else:
            return value

    def set_expire(self, key, value, expiration):
        """
        Set a value in the cache with a specific expiration

        Parameters
        ----------
        key : str
        value : value
        expiration : int or None
            Sets the value to expire this many seconds from now. If None, will
            never expire.

        """
        if expiration is not None:
            if expiration <= 0:
                try:
                    del self[key]
                except KeyError:
                    pass
                return
            expiration = time.time() + expiration - self._cache_time

        self._times[key] = expiration
        super(TimedCache, self).__setitem__(key, value)
