"""evohomeasync provides an async client for the v0 Resideo TCC API."""

from __future__ import annotations

import logging
from abc import ABC, abstractmethod
from datetime import UTC, datetime as dt, timedelta as td
from typing import TYPE_CHECKING, Any, Final, TypedDict

import voluptuous as vol

from _evohome.auth import AbstractAuth
from _evohome.const import HEADERS_BASE, HEADERS_CRED, HINT_BAD_CREDS
from _evohome.credentials import CredentialsManagerBase
from _evohome.helpers import convert_keys_to_snake_case

from . import exceptions as exc
from .schemas import TCC_POST_USR_SESSION

if TYPE_CHECKING:
    import aiohttp
    from aiohttp.typedefs import StrOrURL

    from .schemas import EvoSessionDictT, EvoUserAccountDictT, TccSessionResponseT


# For API docs, enter this App ID on the following website under 'Session login':
#  - https://mytotalconnectcomfort.com/WebApi/Help/LogIn
_APPLICATION_ID: Final = "91db1612-73fd-4500-91b2-e63b069b185c"
#

SZ_SESSION_ID: Final = "session_id"
SZ_SESSION_ID_EXPIRES: Final = "session_id_expires"
SZ_USER_INFO: Final = "user_info"

# POST authentication url (i.e. /WebAPI/api/session)
URL_CRED: Final = "WebAPI/api/session"

# GET/PUT resource url (i.e. /WebAPI/api/...)
URL_BASE: Final = "WebAPI/api"


class SessionIdEntryT(TypedDict):
    session_id: str
    session_id_expires: str  # dt.isoformat()  # TZ-aware


class AbstractSessionManager(CredentialsManagerBase, ABC):
    """An ABC for managing the session id used for HTTP authentication."""

    _session_id: str
    _session_id_expires: dt
    _user_info: EvoUserAccountDictT | None

    def __init__(
        self,
        client_id: str,
        secret: str,
        websession: aiohttp.ClientSession,
        /,
        *,
        logger: logging.Logger | None = None,
        _hostname: str | None = None,
    ) -> None:
        """Initialise the session manager."""

        logger = logger or logging.getLogger(__name__)

        super().__init__(
            client_id, secret, websession, logger=logger, _hostname=_hostname
        )
        self._clear_session_id()  # initialise the attrs

    def _clear_session_id(self) -> None:
        """Clear the session id attrs (set to falsey state)."""

        self._session_id = ""
        self._session_id_expires = dt.min.replace(tzinfo=UTC)  # don't need local TZ

    @property
    def session_id(self) -> str:
        """Return the session id."""
        return self._session_id

    @property
    def session_id_expires(self) -> dt:
        """Return the expiration datetime of the session id."""
        return self._session_id_expires

    #
    #

    async def get_session_id(self) -> str:  # convenience wrapper
        """Return a valid session id.

        If required, fetch (and save) a new session id via the vendor's web API.
        """

        if not self.is_session_valid():  # although may be rejected for other reasons
            await self.fetch_session_id()
            await self.save_session_id()

        return self.session_id

    def is_session_valid(self) -> bool:
        """Return True if the session id is valid (the server may still reject it)."""
        return self._session_id_expires > dt.now(tz=UTC) + td(seconds=15)

    async def fetch_session_id(self) -> None:
        self._logger.debug("Fetching session_id...")

        self._logger.debug(" - authenticating with client_id/secret")

        credentials = {
            "applicationId": _APPLICATION_ID,
            "username": self._client_id,
            "password": self._secret,
        }

        try:  # check here if the user credentials are invalid...
            await self._fetch_session_id(credentials)

        except exc.AuthenticationFailedError as err:
            if "EmailOrPasswordIncorrect" not in err.message:
                raise
            self._logger.error(HINT_BAD_CREDS)  # noqa: TRY400
            raise exc.BadUserCredentialsError(f"{err}", status=err.status) from err

        self._was_authenticated = True

        # session_id is short-lived (but not safe to log self._user_info here)...
        self._logger.debug(f" - session_id = {self.session_id}")
        self._logger.debug(f" - session_id_expires = {self.session_id_expires}")

    async def _fetch_session_id(self, credentials: dict[str, str]) -> None:
        """Obtain an session id using the supplied credentials.

        The credentials are the user's client_id/secret.
        Will raise AuthenticationFailedError if unable to obtain a session id.
        """

        url = f"https://{self.hostname}/{URL_CRED}"

        response: TccSessionResponseT = await self._post_session_id_request(
            url,
            headers=HEADERS_CRED,
            data=credentials,  # NOTE: is camelCase
        )

        try:  # the dict _should_ be the expected schema...
            self._logger.debug(
                f"POST {url}: {TCC_POST_USR_SESSION(response)}"  # secrets are redacted via validator
            )

        except vol.Invalid as err:
            self._logger.warning(f"POST {url}: payload may be invalid: {err}")

        session: EvoSessionDictT = convert_keys_to_snake_case(response)  # type:ignore[assignment]

        try:
            self._session_id: str = session[SZ_SESSION_ID]
            self._session_id_expires = dt.now(tz=UTC) + td(minutes=15)
            self._user_info = session[SZ_USER_INFO]

        except (KeyError, TypeError) as err:
            raise exc.AuthenticationFailedError(
                f"Authenticator response is invalid: {response}: {err.__class__.__name__}: {err}"
            ) from err

        self._was_authenticated = True  # i.e. the credentials are valid

        if session.get("latest_eula_accepted"):
            self._logger.warning("The latest EULA has not been accepted by the user")

    async def _post_session_id_request(  # dev/test wrapper (also typing)
        self, url: StrOrURL, /, **kwargs: Any
    ) -> TccSessionResponseT:
        """Wrap the POST request to the vendor's TCC RESTful API."""
        return await self._post_request(url, **kwargs)  # type: ignore[return-value]

    @abstractmethod
    async def save_session_id(self) -> None:
        """Save the (serialized) session id (and expiry dtm).

        Should ideally confirm the session id is valid before saving.
        """

    def _import_session_id(self, session: SessionIdEntryT) -> None:
        """Extract the session id from a (serialized) dictionary."""

        self._session_id = session[SZ_SESSION_ID]
        self._session_id_expires = dt.fromisoformat(session[SZ_SESSION_ID_EXPIRES])

    def _export_session_id(self) -> SessionIdEntryT:
        """Convert the session id to a (serialized) dictionary."""

        return {
            SZ_SESSION_ID: self._session_id,
            SZ_SESSION_ID_EXPIRES: self._session_id_expires.isoformat(),
        }


class Auth(AbstractAuth):
    """A class to provide to access the v0 Resideo TCC API."""

    def __init__(
        self,
        session_manager: AbstractSessionManager,
        websession: aiohttp.ClientSession,
        /,
        *,
        logger: logging.Logger | None = None,
        _hostname: str | None = None,
    ) -> None:
        """A class for interacting with the v0 Resideo TCC API."""

        logger = logger or logging.getLogger(__name__)

        super().__init__(websession, logger=logger, _hostname=_hostname)

        self._session_id = session_manager.get_session_id
        self._url_base = f"https://{self.hostname}/{URL_BASE}"

    async def _headers(self, headers: dict[str, str] | None = None) -> dict[str, str]:
        """Ensure the authorization header has a valid session id."""

        headers = HEADERS_BASE | (headers or {})
        return headers | {
            "sessionId": await self._session_id(),
        }
