"""
APIs for storing and retrieving HTTP headers and cookies.

"""

from bisect import insort

from cached_property import cached_property as calculated_once
from characteristic import Attribute, attributes
from future.moves.urllib.parse import (
    parse_qs, unquote, unquote_plus, urlencode,
)
from future.utils import iteritems, raise_with_traceback, viewkeys


_CANONICAL_HEADER_NAMES = {
    b"content-md5": b"Content-MD5",
    b"dnt": b"DNT",
    b"etag": b"ETag",
    b"p3p": b"P3P",
    b"te": b"TE",
    b"www-authenticate": b"WWW-Authenticate",
    b"x-xss-protection": b"X-XSS-Protection",
}
DEFAULT_PORTS = {b"http" : 80, b"https" : 443}


class NoSuchHeader(LookupError):
    """
    An operation on a non-present or -existing HTTP header was performed.

    """


class InvalidURL(LookupError):
    """
    Parsing resulted in an invalid URL.

    """


@attributes(
    [
        Attribute(name="scheme", default_value=b""),
        Attribute(name="username", default_value=b""),
        Attribute(name="password", default_value=b""),
        Attribute(name="host", default_value=b""),
        Attribute(name="port", default_value=None),
        Attribute(name="path", default_value=b""),
        Attribute(name="query", default_factory=dict),
        Attribute(name="fragment", default_value=b""),
        Attribute(
            name="_unnormalized",
            default_value=None,
            exclude_from_cmp=True,
            exclude_from_repr=True,
        ),
        Attribute(
            name="unnormalized_scheme",
            default_value=None,
            exclude_from_cmp=True,
            exclude_from_repr=True,
        ),
        Attribute(
            name="unnormalized_host",
            default_value=None,
            exclude_from_cmp=True,
            exclude_from_repr=True,
        ),
        Attribute(
            name="unnormalized_userinfo",
            default_value=None,
            exclude_from_cmp=True,
            exclude_from_repr=True,
        ),
        Attribute(
            name="unnormalized_authority",
            default_value=None,
            exclude_from_cmp=True,
            exclude_from_repr=True,
        ),
        Attribute(
            name="unnormalized_port",
            default_value=None,
            exclude_from_cmp=True,
            exclude_from_repr=True,
        ),
    ],
)
class URL(object):
    @classmethod
    def from_bytes(cls, bytes):
        """
        Parse a URL from some bytes.

        """

        try:  # this belongs on the first thing likely to cause a (Type)Error
            scheme, _, rest = bytes.strip().partition(b":")
        except Exception:
            exception = InvalidURL("{!r} is not a valid URL".format(bytes))
            raise_with_traceback(exception)

        if scheme and not rest.startswith(b"//"):
            raise InvalidURL(
                "{!r} is not a valid URL without initial '//'".format(bytes),
            )

        authority, slash, rest = rest[2:].partition(b"/")
        userinfo, _, host_and_port = authority.rpartition(b"@")
        username, _, password = userinfo.partition(b":")

        if host_and_port.startswith(b"["):  # IPv6 Host
            host, delimiter, port_str = host_and_port.partition(b"]:")
            host += b"]" if delimiter else b""
        else:
            host, _, port_str = host_and_port.partition(b":")

        if not port_str:
            port = None
        else:
            try:
                port = int(unquote(port_str))
            except ValueError:
                raise InvalidURL("{!r} is not a valid port".format(port_str))

        path, _, rest = rest.partition(b"?")
        query, _, fragment = rest.partition(b"#")

        return cls.normalized(
            scheme=scheme,
            username=username,
            password=password,
            host=host,
            port=port,
            path=unquote(slash + path),
            query=parse_qs(query, keep_blank_values=True),
            fragment=unquote_plus(fragment),
            unnormalized=bytes,
            unnormalized_authority=authority,
            unnormalized_userinfo=userinfo,
        )

    @classmethod
    def normalized(cls, **kwargs):
        scheme = unnormalized_scheme = kwargs.pop("scheme", None)
        if scheme is not None:
            scheme = scheme.lower()
            kwargs.update(
                scheme=scheme, unnormalized_scheme=unnormalized_scheme,
            )

        host = unnormalized_host = kwargs.pop("host", None)
        if unnormalized_host is not None:
            host = unnormalized_host.lower()
            kwargs.update(host=host, unnormalized_host=unnormalized_host)

        port = unnormalized_port = kwargs.pop("port", None)
        if port is not None:
            kwargs.update(
                port=None if DEFAULT_PORTS.get(scheme) == port else port,
                unnormalized_port=unnormalized_port,
            )

        return cls(**kwargs)

    @calculated_once
    def authority(self):
        port, userinfo = self.port, self.userinfo
        if userinfo:
            if port is not None:
                return b"{}@{}:{}".format(userinfo, self.host, port)
            return b"{}@{}".format(userinfo, self.host)
        if port:
            return b"{}:{}".format(self.host, self.port)
        return self.host

    @calculated_once
    def userinfo(self):
        password = self.password
        if password:
            return b"{}:{}".format(self.username, password)
        return self.username

    @calculated_once
    def query_string(self):
        return urlencode(
            [
                (key, value)
                for key, values in iteritems(self.query)
                for value in values
            ]
        )

    @calculated_once
    def is_absolute(self):
        return bool(self.scheme)

    def to_bytes(self):
        url = self._unnormalized
        if url is not None:
            return url

        scheme, authority, path = self.scheme, self.authority, self.path
        if scheme:
            url = b"{scheme}://{authority}{path}".format(
                scheme=scheme, authority=authority, path=path,
            )
        elif authority:
            url = b"//{authority}{path}".format(
                authority=authority, path=self.path,
            )
        else:
            url = path

        query_string = self.query_string
        if query_string:
            url += b"?" + query_string

        fragment = self.fragment
        if fragment:
            url += "#" + fragment

        self._unnormalized = url
        return url

    def child(self, name, query=None, fragment=b"", **kwargs):
        """
        Retrieve the URL of a "child" resource with the given name.

        .. note::

            Does not add a trailing slash.

            Use ``url.child("foo").child("")`` if you want one.

        Unless specified explicitly (i.e. as a new query string or
        fragment), will clear the query string and fragment off of the
        child URL.

        """

        if b"/" in name:
            raise ValueError("Child names cannot contain separators.")

        if query is None:
            query = {}

        path = self.path
        if path.endswith(b"/"):
            path += name
        else:
            path += b"/" + name

        return _replace(
            self,
            unnormalized=None,
            path=path,
            query=query,
            fragment=fragment,
            **kwargs
        )


class Headers(object):
    def __init__(self, contents=()):
        self._contents = dict(
            (name.lower(), values) for name, values in contents
        )

    def __contains__(self, name):
        return name.lower() in self._contents

    def __getitem__(self, name):
        try:
            return self._contents[name.lower()]
        except KeyError:
            raise NoSuchHeader(name)

    def __eq__(self, other):
        if not isinstance(other, self.__class__):
            return NotImplemented
        return self._contents == other._contents

    def __ne__(self, other):
        if not isinstance(other, self.__class__):
            return NotImplemented
        return not self == other

    def __hash__(self):
        calculated = getattr(self, "_hash", None)
        if calculated is None:
            calculated = 0
            for name, values in iteritems(self._contents):
                calculated ^= hash((name, tuple(values)))
            self._hash = calculated
        return calculated

    def __repr__(self):
        contents = " ".join(
            "{0}={1!r}".format(name, values)
            for name, values in sorted(self.canonicalized())
        )
        return "<{.__class__.__name__} contents={{{}}}>".format(self, contents)

    def canonicalized(self):
        for name, values in iteritems(self._contents):
            canonical_name = _CANONICAL_HEADER_NAMES.get(name)
            if canonical_name is None:
                canonical_name = b"-".join(
                    word.capitalize() for word in name.split(b"-")
                )
            yield canonical_name, values

    def get(self, name, default=None):
        return self._contents.get(name.lower(), default)


class MutableHeaders(Headers):

    __hash__ = None

    def __setitem__(self, name, values):
        self._contents[name.lower()] = values

    def add_value(self, name, value):
        existing = self.get(name)
        if existing is None:
            self[name] = [value]
        else:
            existing.append(value)

    def get(self, name, default=None):
        return self._contents.get(name.lower(), default)

    def pop(self, name, *args, **kwargs):
        try:
            return self._contents.pop(name.lower(), *args, **kwargs)
        except KeyError:
            raise NoSuchHeader(name)


@attributes(
    [
        Attribute(name="media_types"),
    ],
)
class Accept(object):
    """
    A parsed representation of an HTTP Accept header (see :rfc:`7231#5.3.2`\ ).

    """

    @classmethod
    def from_header(cls, header):
        """
        Parse out an Accept header.

        """

        if header is None:
            return cls.ALL

        media_types = []
        for range_and_parameters in header.split(b","):
            raw_range, _, raw_parameters = range_and_parameters.partition(b";")

            quality = 1.0
            media_parameters = {}
            if raw_parameters:
                for raw_parameter in raw_parameters.split(b";"):
                    key, _, value = raw_parameter.partition(b"=")
                    key = key.strip()
                    value = value.strip()
                    if key == b"q":
                        quality = float(value)
                    else:
                        media_parameters[key] = value

            raw_type, _, raw_subtype = raw_range.partition(b"/")

            type = raw_type.strip()
            subtype = raw_subtype.strip()

            insort(
                media_types,
                MediaRange(
                    type=type if type != b"*" else STAR,
                    subtype=subtype if subtype != b"*" else STAR,
                    quality=quality,
                    parameters=media_parameters,
                ),
            )
        return cls(media_types=tuple(media_types))


class _Star(object):
    """
    An open media range value.

    """

    def __lt__(self, other):
        return True

    def __repr__(self):
        return "*"


STAR = _Star()


@attributes(
    [
        Attribute(name="type", default_value=STAR),
        Attribute(name="subtype", default_value=STAR),
        Attribute(name="parameters", default_factory=dict),
        Attribute(name="quality", default_value=1.0),
    ],
    apply_with_cmp=False,
)
class MediaRange(object):
    """
    A media range.

    Open ranges (e.g. ``text/*`` or ``*/*``\ ) are represented by
    :attr:`type` and / or :attr:`subtype` being ``None``\ .

    """

    def __eq__(self, other):
        if not isinstance(other, self.__class__):
            return NotImplemented
        return all(
            getattr(self, attribute.name) == getattr(other, attribute.name)
            for attribute in self.characteristic_attributes
        )

    def __ne__(self, other):
        if not isinstance(other, self.__class__):
            return NotImplemented
        return not self == other

    def __lt__(self, other):
        if not isinstance(other, self.__class__):
            return NotImplemented

        if self.quality < other.quality:
            return True
        elif self.quality > other.quality:
            return False

        if self.type != other.type:
            return self.type is STAR

        if self.subtype != other.subtype:
            return self.subtype is STAR

        return viewkeys(self.parameters) < viewkeys(other.parameters)

    def __hash__(self):
        values = tuple(
            getattr(self, attr.name)
            for attr in self.characteristic_attributes
            if attr.name != b"parameters"
        )
        return hash(values + tuple(self.parameters.items()))


Accept.ALL = Accept(media_types=(MediaRange(),))


def _vars(instance):
    for attr in instance.characteristic_attributes:
        name = attr.name
        yield attr.init_aliaser(name), getattr(instance, name)


def _replace(instance, **new):
    for name, value in _vars(instance):
        if name not in new:
            new[name] = value
    return instance.__class__(**new)
