"""Code to help with HTTP requests, responses, and events from GitHub's developer API.

This code has been constructed to perform no I/O of its own. This allows you to
use any HTTP library you prefer while not having to implement common details
when working with GitHub's API (e.g. validating webhook events or specifying the
API version you want your request to work against).
"""
import datetime
from email.message import Message
import hmac
import http
import json
import re
from typing import Any, Dict, Mapping, Optional, Tuple, Type, Union
import urllib.parse

import uritemplate
from uritemplate import variable

from . import (
    BadRequest,
    BadRequestUnknownError,
    GitHubBroken,
    HTTPException,
    InvalidField,
    RateLimitExceeded,
    RedirectionException,
    ValidationError,
    ValidationFailure,
)


def _parse_content_type(content_type: Optional[str]) -> Tuple[Optional[str], str]:
    """Tease out the content-type and character encoding.

    A default character encoding of UTF-8 is used, so the content-type
    must be used to determine if any decoding is necessary to begin
    with.
    """
    if not content_type:
        return None, "utf-8"
    else:
        m = Message()
        m["content-type"] = content_type
        type_ = m.get_content_type()
        encoding = m.get_param("charset") or "utf-8"
        return type_, str(encoding)


def _decode_body(
    content_type: Optional[str], body: bytes, *, strict: bool = False
) -> Any:
    """Decode an HTTP body based on the specified content type.

    If 'strict' is true, then raise ValueError if the content type
    is not recognized. Otherwise simply returned the body as a decoded
    string.
    """
    type_, encoding = _parse_content_type(content_type)
    if not len(body) or not content_type:
        return None
    decoded_body = body.decode(encoding)
    if type_ == "application/json":
        return json.loads(decoded_body)
    elif type_ == "application/x-www-form-urlencoded":
        return json.loads(urllib.parse.parse_qs(decoded_body)["payload"][0])
    elif strict:
        raise ValueError(f"unrecognized content type: {type_!r}")
    return decoded_body


def validate_event(payload: bytes, *, signature: str, secret: str) -> None:
    """Validate the signature of a webhook event."""
    # https://docs.github.com/en/developers/webhooks-and-events/securing-your-webhooks#validating-payloads-from-github
    sha256_signature_prefix = "sha256="
    sha1_signature_prefix = "sha1="
    if signature.startswith(sha256_signature_prefix):
        hmac_sig = hmac.new(
            secret.encode("UTF-8"), msg=payload, digestmod="sha256"
        ).hexdigest()
        calculated_sig = sha256_signature_prefix + hmac_sig
    elif signature.startswith(sha1_signature_prefix):
        hmac_sig = hmac.new(
            secret.encode("UTF-8"), msg=payload, digestmod="sha1"
        ).hexdigest()
        calculated_sig = sha1_signature_prefix + hmac_sig
    else:
        raise ValidationFailure(
            f"signature does not start with {repr(sha256_signature_prefix)} or {repr(sha1_signature_prefix)}"
        )
    if not hmac.compare_digest(signature, calculated_sig):
        raise ValidationFailure("payload's signature does not align with the secret")


class Event:

    """Details of a GitHub webhook event."""

    def __init__(self, data: Any, *, event: str, delivery_id: str) -> None:
        # https://docs.github.com/en/free-pro-team@latest/developers/webhooks-and-events/webhook-events-and-payloads
        # https://docs.github.com/en/free-pro-team@latest/developers/webhooks-and-events/webhook-events-and-payloads#delivery-headers
        self.data = data
        # Event is not an enum as GitHub provides the string. This allows them
        # to add new events without having to mirror them here. There's also no
        # direct worry of a user typing in the wrong event name and thus no need
        # for an enum's typing protection.
        self.event = event
        self.delivery_id = delivery_id

    @classmethod
    def from_http(
        cls, headers: Mapping[str, str], body: bytes, *, secret: Optional[str] = None
    ) -> "Event":
        """Construct an event from HTTP headers and JSON body data.

        The mapping providing the headers is expected to support lowercase keys.

        Since this method assumes the body of the HTTP request is JSON, a check
        is performed for a content-type of "application/json" (GitHub does
        support other content-types). If the content-type does not match,
        BadRequest is raised.

        If the appropriate headers are provided for event validation, then it
        will be performed unconditionally. Any failure in validation
        (including not providing a secret) will lead to ValidationFailure being
        raised.
        """
        signature = headers.get("x-hub-signature-256", headers.get("x-hub-signature"))
        if signature is not None:
            if secret is None:
                raise ValidationFailure("secret not provided")
            validate_event(body, signature=signature, secret=secret)
        elif secret is not None:
            raise ValidationFailure("signature is missing")

        try:
            data = _decode_body(headers["content-type"], body, strict=True)
        except (KeyError, ValueError) as exc:
            raise BadRequest(
                http.HTTPStatus(415),
                "expected a content-type of "
                "'application/json' or "
                "'application/x-www-form-urlencoded'",
            ) from exc
        return cls(
            data,
            event=headers["x-github-event"],
            delivery_id=headers["x-github-delivery"],
        )


def accept_format(
    *, version: str = "v3", media: Optional[str] = None, json: bool = True
) -> str:
    """Construct the specification of the format that a request should return.

    The version argument defaults to v3 of the GitHub API and is applicable to
    all requests. The media argument along with 'json' specifies what format
    the request should return, e.g. requesting the rendered HTML of a comment.
    Do note that not all of GitHub's API supports alternative formats.

    The default arguments of this function will always return the latest stable
    version of the GitHub API in the default format that this library is
    designed to support.
    """
    # https://docs.github.com/en/free-pro-team@latest/rest/overview/media-types
    # https://docs.github.com/en/free-pro-team@latest/rest/overview/resources-in-the-rest-api#current-version
    accept = f"application/vnd.github.{version}"
    if media is not None:
        accept += f".{media}"
    if json:
        accept += "+json"
    return accept


def create_headers(
    requester: str,
    *,
    accept: str = accept_format(),
    oauth_token: Optional[str] = None,
    jwt: Optional[str] = None,
) -> Dict[str, str]:
    """Create a dict representing GitHub-specific header fields.

    The user agent is set according to who the requester is. GitHub asks it be
    either a username or project name.

    The 'accept' argument corresponds to the 'accept' field and defaults to the
    default result of accept_format(). You should only need to change this value
    if you are using a different version of the API -- e.g. one that is under
    development -- or if you are looking for a different format return type,
    e.g. wanting the rendered HTML of a Markdown file.

    The 'oauth_token' allows making an authenticated request using a personal access
    token. This can be important if you need the expanded rate limit provided
    by an authenticated request.

    The 'jwt' allows authenticating as a GitHub App by passing in the
    bearer token.

    You can only supply only one of oauth_token or jwt, not both.

    For consistency, all keys in the returned dict will be lowercased.
    """
    # user-agent: https://docs.github.com/en/free-pro-team@latest/rest/overview/resources-in-the-rest-api#user-agent-required
    # accept: https://docs.github.com/en/free-pro-team@latest/rest/overview/resources-in-the-rest-api#current-version
    #         https://docs.github.com/en/free-pro-team@latest/rest/overview/media-types
    # authorization: https://docs.github.com/en/free-pro-team@latest/rest/overview/resources-in-the-rest-api#authentication
    # authenticating as a GitHub App: https://docs.github.com/en/free-pro-team@latest/developers/apps/authenticating-with-github-apps#authenticating-as-a-github-app

    if oauth_token is not None and jwt is not None:
        raise ValueError("Cannot pass both oauth_token and jwt.")
    headers = {"user-agent": requester, "accept": accept}
    if oauth_token is not None:
        headers["authorization"] = f"token {oauth_token}"
    elif jwt is not None:
        headers["authorization"] = f"bearer {jwt}"
    return headers


class RateLimit:

    """The rate limit imposed upon the requester.

    The 'limit' attribute specifies the rate of requests per hour the client is
    limited to.

    The 'remaining' attribute specifies how many requests remain within the
    current rate limit that the client can make.

    The reset_datetime attribute is a datetime object representing when
    effectively 'left' resets to 'rate'. The datetime object is timezone-aware
    and set to UTC.

    The boolean value of an instance whether another request can be made. This
    is determined based on whether there are any remaining requests or if the
    reset datetime has passed.
    """

    # https://docs.github.com/en/free-pro-team@latest/rest/overview/resources-in-the-rest-api#rate-limiting

    def __init__(self, *, limit: int, remaining: int, reset_epoch: float) -> None:
        """Instantiate a RateLimit object.

        The reset_epoch argument should be in seconds since the UTC epoch.
        """
        # Instance attribute names stem from the name GitHub uses in their
        # API documentation.
        self.limit = limit
        self.remaining = remaining
        # Name specifies the type to remind users that the epoch is not stored
        # as an int as the GitHub API returns.
        self.reset_datetime = datetime.datetime.fromtimestamp(
            reset_epoch, datetime.timezone.utc
        )

    def __bool__(self) -> bool:
        """True if requests are remaining or the reset datetime has passed."""
        if self.remaining > 0:
            return True
        else:
            now = datetime.datetime.now(datetime.timezone.utc)
            return now > self.reset_datetime

    def __str__(self) -> str:
        """Provide all details in a reasonable format."""
        return f"< {self.remaining:,}/{self.limit:,} until {self.reset_datetime} >"

    @classmethod
    def from_http(cls, headers: Mapping[str, str]) -> Optional["RateLimit"]:
        """Gather rate limit information from HTTP headers.

        The mapping providing the headers is expected to support lowercase
        keys.  Returns ``None`` if ratelimit info is not found in the headers.
        """
        try:
            limit = int(headers["x-ratelimit-limit"])
            remaining = int(headers["x-ratelimit-remaining"])
            reset_epoch = float(headers["x-ratelimit-reset"])
        except KeyError:
            return None
        else:
            return cls(limit=limit, remaining=remaining, reset_epoch=reset_epoch)


_link_re = re.compile(
    r"\<(?P<uri>[^>]+)\>;\s*" r'(?P<param_type>\w+)="(?P<param_value>\w+)"(,\s*)?'
)


def _next_link(link: Optional[str]) -> Optional[str]:
    # https://docs.github.com/en/free-pro-team@latest/rest/overview/resources-in-the-rest-api#pagination
    # https://tools.ietf.org/html/rfc5988
    if link is None:
        return None
    for match in _link_re.finditer(link):
        if match.group("param_type") == "rel":
            if match.group("param_value") == "next":
                return match.group("uri")
    else:
        return None


def decipher_response(
    status_code: int, headers: Mapping[str, str], body: bytes
) -> Tuple[Any, Optional[RateLimit], Optional[str]]:
    """Decipher an HTTP response for a GitHub API request.

    The mapping providing the headers is expected to support lowercase keys.

    The parameters of this function correspond to the three main parts
    of an HTTP response: the status code, headers, and body. Assuming
    no errors which lead to an exception being raised, a 3-item tuple
    is returned. The first item is the decoded body (typically a JSON
    object, but possibly None or a string depending on the content
    type of the body). The second item is an instance of RateLimit
    based on what the response specified.

    The last item of the tuple is the URL where to request the next
    part of results. If there are no more results then None is
    returned. Do be aware that the URL can be a URI template and so
    may need to be expanded.

    If the status code is anything other than 200, 201, or 204, then
    an HTTPException is raised.
    """
    data = _decode_body(headers.get("content-type"), body)
    if status_code in {200, 201, 202, 204}:
        return data, RateLimit.from_http(headers), _next_link(headers.get("link"))
    else:
        try:
            message = data["message"]
        except (TypeError, KeyError):
            message = None
        exc_type: Type[HTTPException]
        if status_code >= 500:
            exc_type = GitHubBroken
        elif status_code >= 400:
            exc_type = BadRequest
            if status_code == 403:
                rate_limit = RateLimit.from_http(headers)
                if rate_limit and not rate_limit.remaining:
                    raise RateLimitExceeded(rate_limit, message)
            elif status_code == 422:
                try:
                    errors = data.get("errors", None)
                except AttributeError:
                    # Not JSON so don't know why the request failed.
                    raise BadRequestUnknownError(data)
                exc_type = InvalidField
                if errors:
                    if any(
                        e["code"]
                        in ["missing", "missing_field", "invalid", "already_exists"]
                        for e in errors
                    ):
                        error_context = ", ".join(repr(e.get("field")) for e in errors)
                        message = f"{message} for {error_context}"
                    else:
                        exc_type = ValidationError
                        error_context = ", ".join(
                            repr(e.get("message")) for e in errors
                        )
                        message = f"{message}: {error_context}"
                else:
                    message = data["message"]
                raise exc_type(errors, message)
        elif status_code >= 300:
            exc_type = RedirectionException
        else:
            exc_type = HTTPException
        status_code_enum = http.HTTPStatus(status_code)
        args: Union[Tuple[http.HTTPStatus, str], Tuple[http.HTTPStatus]]
        if message:
            args = status_code_enum, message
        else:
            args = (status_code_enum,)
        raise exc_type(*args)


DOMAIN = "https://api.github.com"


def format_url(
    url: str, url_vars: Optional[variable.VariableValueDict], *, base_url: str = DOMAIN
) -> str:
    """Construct a URL for the GitHub API.

    The URL may be absolute or relative. In the latter case the appropriate
    domain will be added. This is to help when copying the relative URL directly
    from the GitHub developer documentation.

    The dict provided in url_vars is used in URI template formatting.
    """

    url = urllib.parse.urljoin(base_url, url)  # Works even if 'url' is fully-qualified.
    expanded_url: str = uritemplate.expand(url, var_dict=url_vars)
    return expanded_url
