"""Authentication library, providing base classes for users of the library.

In order to use `gcal_sync.api`, you need to implement `gcal_sync.AbstractAuth`
which provides an OAuth access token. See Google's
[Authentication and authorization overview][1] for general details on how to
use OAuth, which involves things like redirecting a user to a web flow, and
redirecting back with an access token.

An example implementation of `gcal_sync.AbstractAuth` would need to handle things like
passing in the access token and any other necessary OAuth token refreshes when the
access token has expired.

```python
from gcal_sync.auth import AbstractAuth

class MyAuthImpl(gcal_sync.AbstractAuth):

    def __init__(self, websession: aiohttp.ClientSession) -> None:
        super().__init__(websession)

    async def async_get_access_token(self) -> str:
        return ...
```

[1]: https://developers.google.com/workspace/guides/auth-overview

"""

from __future__ import annotations

import logging
from abc import ABC, abstractmethod
from http import HTTPStatus
from typing import Any, List

import aiohttp
from aiohttp.client_exceptions import ClientError, ClientResponseError

from .exceptions import (
    ApiException,
    ApiForbiddenException,
    AuthException,
    InvalidSyncTokenException,
)

_LOGGER = logging.getLogger(__name__)

AUTHORIZATION_HEADER = "Authorization"
ERROR = "error"
STATUS = "status"
MESSAGE = "message"

API_BASE_URL = "https://www.googleapis.com/calendar/v3"


class AbstractAuth(ABC):  # pylint: disable=too-few-public-methods
    """Library for providing authentication credentials.

    You are expected to implement a subclass and implement the abstract
    methods when using the `gcal_sync.api` classes. The api library will
    invoke this class and ask for the access token on outgoing requests.
    """

    def __init__(self, websession: aiohttp.ClientSession, host: str | None = None):
        """Initialize the auth."""
        self._websession = websession
        self._host = host if host is not None else API_BASE_URL

    @abstractmethod
    async def async_get_access_token(self) -> str:
        """Return a valid access token."""

    async def request(
        self, method: str, url: str, **kwargs: Any
    ) -> aiohttp.ClientResponse:
        """Make a request."""
        try:
            access_token = await self.async_get_access_token()
        except ClientError as err:
            raise AuthException(f"Access token failure: {err}") from err
        headers = {AUTHORIZATION_HEADER: f"Bearer {access_token}"}
        if not (url.startswith("http://") or url.startswith("https://")):
            url = f"{self._host}/{url}"
        _LOGGER.debug("request[%s]=%s %s", method, url, kwargs.get("params"))
        if method != "get" and "json" in kwargs:
            _LOGGER.debug("request[post json]=%s", kwargs["json"])
        return await self._websession.request(method, url, **kwargs, headers=headers)

    async def get(self, url: str, **kwargs: Any) -> aiohttp.ClientResponse:
        """Make a get request."""
        try:
            resp = await self.request("get", url, **kwargs)
        except ClientError as err:
            raise ApiException(f"Error connecting to API: {err}") from err
        return await AbstractAuth._raise_for_status(resp)

    async def get_json(self, url: str, **kwargs: Any) -> dict[str, Any]:
        """Make a get request and return json response."""
        resp = await self.get(url, **kwargs)
        try:
            result = await resp.json()
        except ClientError as err:
            raise ApiException("Server returned malformed response") from err
        if not isinstance(result, dict):
            raise ApiException(f"Server return malformed response: {result}")
        _LOGGER.debug("response=%s", result)
        return result

    async def post(self, url: str, **kwargs: Any) -> aiohttp.ClientResponse:
        """Make a post request."""
        try:
            resp = await self.request("post", url, **kwargs)
        except ClientError as err:
            raise ApiException(f"Error connecting to API: {err}") from err
        return await AbstractAuth._raise_for_status(resp)

    async def post_json(self, url: str, **kwargs: Any) -> dict[str, Any]:
        """Make a post request and return a json response."""
        resp = await self.post(url, **kwargs)
        try:
            result = await resp.json()
        except ClientError as err:
            raise ApiException("Server returned malformed response") from err
        if not isinstance(result, dict):
            raise ApiException(f"Server returned malformed response: {result}")
        _LOGGER.debug("response=%s", result)
        return result

    @staticmethod
    async def _raise_for_status(resp: aiohttp.ClientResponse) -> aiohttp.ClientResponse:
        """Raise exceptions on failure methods."""
        detail = await AbstractAuth._error_detail(resp)
        try:
            resp.raise_for_status()
        except ClientResponseError as err:
            if err.status == HTTPStatus.FORBIDDEN:
                raise ApiForbiddenException(
                    f"Forbidden response from API: {err}"
                ) from err
            if err.status == HTTPStatus.UNAUTHORIZED:
                raise AuthException(f"Unable to authenticate with API: {err}") from err
            if err.status == HTTPStatus.GONE:
                raise InvalidSyncTokenException(
                    "Sync token invalidated by server"
                ) from err
            detail.append(err.message)
            raise ApiException(": ".join(detail)) from err
        except ClientError as err:
            raise ApiException(f"Error from API: {err}") from err
        return resp

    @staticmethod
    async def _error_detail(resp: aiohttp.ClientResponse) -> List[str]:
        """Returns an error message string from the APi response."""
        if resp.status < 400:
            return []
        try:
            result = await resp.json()
            error = result.get(ERROR, {})
        except ClientError:
            return []
        message = ["Error from API", f"{resp.status}"]
        if STATUS in error:
            message.append(f"{error[STATUS]}")
        if MESSAGE in error:
            message.append(error[MESSAGE])
        return message
