"""Component to allow users to login and get tokens.

# POST /auth/token

This is an OAuth2 endpoint for granting tokens. We currently support the grant
types "authorization_code" and "refresh_token". Because we follow the OAuth2
spec, data should be send in formatted as x-www-form-urlencoded. Examples will
be in JSON as it's more readable.

## Grant type authorization_code

Exchange the authorization code retrieved from the login flow for tokens.

{
    "client_id": "https://hassbian.local:8123/",
    "grant_type": "authorization_code",
    "code": "411ee2f916e648d691e937ae9344681e"
}

Return value will be the access and refresh tokens. The access token will have
a limited expiration. New access tokens can be requested using the refresh
token. The value ha_auth_provider will contain the auth provider type that was
used to authorize the refresh token.

{
    "access_token": "ABCDEFGH",
    "expires_in": 1800,
    "refresh_token": "IJKLMNOPQRST",
    "token_type": "Bearer",
    "ha_auth_provider": "homeassistant"
}

## Grant type refresh_token

Request a new access token using a refresh token.

{
    "client_id": "https://hassbian.local:8123/",
    "grant_type": "refresh_token",
    "refresh_token": "IJKLMNOPQRST"
}

Return value will be a new access token. The access token will have
a limited expiration.

{
    "access_token": "ABCDEFGH",
    "expires_in": 1800,
    "token_type": "Bearer"
}

## Revoking a refresh token

It is also possible to revoke a refresh token and all access tokens that have
ever been granted by that refresh token. Response code will ALWAYS be 200.

{
    "token": "IJKLMNOPQRST",
    "action": "revoke"
}

# Websocket API

## Get current user

Send websocket command `auth/current_user` will return current user of the
active websocket connection.

{
    "id": 10,
    "type": "auth/current_user",
}

The result payload likes

{
    "id": 10,
    "type": "result",
    "success": true,
    "result": {
        "id": "USER_ID",
        "name": "John Doe",
        "is_owner": true,
        "credentials": [{
            "auth_provider_type": "homeassistant",
            "auth_provider_id": null
        }],
        "mfa_modules": [{
            "id": "totp",
            "name": "TOTP",
            "enabled": true
        }]
    }
}

## Create a long-lived access token

Send websocket command `auth/long_lived_access_token` will create
a long-lived access token for current user. Access token will not be saved in
Home Assistant. User need to record the token in secure place.

{
    "id": 11,
    "type": "auth/long_lived_access_token",
    "client_name": "GPS Logger",
    "lifespan": 365
}

Result will be a long-lived access token:

{
    "id": 11,
    "type": "result",
    "success": true,
    "result": "ABCDEFGH"
}


# POST /auth/external/callback

This is an endpoint for OAuth2 Authorization callbacks used by integrations
that link accounts with other cloud providers using LocalOAuth2Implementation
as part of a config flow.
"""

import asyncio
from collections.abc import Callable
from datetime import datetime, timedelta
from http import HTTPStatus
from logging import getLogger
from typing import Any, cast
import uuid

from aiohttp import web
from multidict import MultiDictProxy
import voluptuous as vol

from homeassistant.auth import InvalidAuthError
from homeassistant.auth.models import (
    TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN,
    Credentials,
    RefreshToken,
    User,
)
from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_HASS
from homeassistant.components.http.auth import (
    async_sign_path,
    async_user_not_allowed_do_auth,
)
from homeassistant.components.http.ban import log_invalid_auth
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.http.view import HomeAssistantView
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2AuthorizeCallbackView
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from homeassistant.util.hass_dict import HassKey

from . import indieauth, login_flow, mfa_setup_flow

DOMAIN = "auth"

type StoreResultType = Callable[[str, Credentials], str]
type RetrieveResultType = Callable[[str, str], Credentials | None]
DATA_STORE: HassKey[StoreResultType] = HassKey(DOMAIN)
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)

DELETE_CURRENT_TOKEN_DELAY = 2


def create_auth_code(
    hass: HomeAssistant, client_id: str, credential: Credentials
) -> str:
    """Create an authorization code to fetch tokens."""
    return hass.data[DATA_STORE](client_id, credential)


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
    """Component to allow users to login."""
    store_result, retrieve_result = _create_auth_code_store()

    hass.data[DATA_STORE] = store_result

    hass.http.register_view(TokenView(retrieve_result))
    hass.http.register_view(RevokeTokenView())
    hass.http.register_view(LinkUserView(retrieve_result))
    hass.http.register_view(OAuth2AuthorizeCallbackView())

    websocket_api.async_register_command(hass, websocket_current_user)
    websocket_api.async_register_command(hass, websocket_create_long_lived_access_token)
    websocket_api.async_register_command(hass, websocket_refresh_tokens)
    websocket_api.async_register_command(hass, websocket_delete_refresh_token)
    websocket_api.async_register_command(hass, websocket_delete_all_refresh_tokens)
    websocket_api.async_register_command(hass, websocket_sign_path)
    websocket_api.async_register_command(hass, websocket_refresh_token_set_expiry)

    login_flow.async_setup(hass, store_result)
    mfa_setup_flow.async_setup(hass)

    return True


class RevokeTokenView(HomeAssistantView):
    """View to revoke tokens."""

    url = "/auth/revoke"
    name = "api:auth:revocation"
    requires_auth = False
    cors_allowed = True

    async def post(self, request: web.Request) -> web.Response:
        """Revoke a token."""
        hass = request.app[KEY_HASS]
        data = cast(MultiDictProxy[str], await request.post())

        # OAuth 2.0 Token Revocation [RFC7009]
        # 2.2 The authorization server responds with HTTP status code 200
        # if the token has been revoked successfully or if the client
        # submitted an invalid token.
        if (token := data.get("token")) is None:
            return web.Response(status=HTTPStatus.OK)

        refresh_token = hass.auth.async_get_refresh_token_by_token(token)

        if refresh_token is None:
            return web.Response(status=HTTPStatus.OK)

        hass.auth.async_remove_refresh_token(refresh_token)
        return web.Response(status=HTTPStatus.OK)


class TokenView(HomeAssistantView):
    """View to issue tokens."""

    url = "/auth/token"
    name = "api:auth:token"
    requires_auth = False
    cors_allowed = True

    def __init__(self, retrieve_auth: RetrieveResultType) -> None:
        """Initialize the token view."""
        self._retrieve_auth = retrieve_auth

    @log_invalid_auth
    async def post(self, request: web.Request) -> web.Response:
        """Grant a token."""
        hass = request.app[KEY_HASS]
        data = cast(MultiDictProxy[str], await request.post())

        grant_type = data.get("grant_type")

        # IndieAuth 6.3.5
        # The revocation endpoint is the same as the token endpoint.
        # The revocation request includes an additional parameter,
        # action=revoke.
        if data.get("action") == "revoke":
            # action=revoke is deprecated. Use /auth/revoke instead.
            # Keep here for backwards compat
            return await RevokeTokenView.post(self, request)  # type: ignore[arg-type]

        if grant_type == "authorization_code":
            return await self._async_handle_auth_code(hass, data, request)

        if grant_type == "refresh_token":
            return await self._async_handle_refresh_token(hass, data, request)

        return self.json(
            {"error": "unsupported_grant_type"}, status_code=HTTPStatus.BAD_REQUEST
        )

    async def _async_handle_auth_code(
        self,
        hass: HomeAssistant,
        data: MultiDictProxy[str],
        request: web.Request,
    ) -> web.Response:
        """Handle authorization code request."""
        client_id = data.get("client_id")
        if client_id is None or not indieauth.verify_client_id(client_id):
            return self.json(
                {"error": "invalid_request", "error_description": "Invalid client id"},
                status_code=HTTPStatus.BAD_REQUEST,
            )

        if (code := data.get("code")) is None:
            return self.json(
                {"error": "invalid_request", "error_description": "Invalid code"},
                status_code=HTTPStatus.BAD_REQUEST,
            )

        credential = self._retrieve_auth(client_id, code)

        if credential is None or not isinstance(credential, Credentials):
            return self.json(
                {"error": "invalid_request", "error_description": "Invalid code"},
                status_code=HTTPStatus.BAD_REQUEST,
            )

        user = await hass.auth.async_get_or_create_user(credential)

        if user_access_error := async_user_not_allowed_do_auth(hass, user):
            return self.json(
                {
                    "error": "access_denied",
                    "error_description": user_access_error,
                },
                status_code=HTTPStatus.FORBIDDEN,
            )

        refresh_token = await hass.auth.async_create_refresh_token(
            user, client_id, credential=credential
        )
        try:
            access_token = hass.auth.async_create_access_token(
                refresh_token, request.remote
            )
        except InvalidAuthError as exc:
            return self.json(
                {"error": "access_denied", "error_description": str(exc)},
                status_code=HTTPStatus.FORBIDDEN,
            )

        return self.json(
            {
                "access_token": access_token,
                "token_type": "Bearer",
                "refresh_token": refresh_token.token,
                "expires_in": int(
                    refresh_token.access_token_expiration.total_seconds()
                ),
                "ha_auth_provider": credential.auth_provider_type,
            },
            headers={
                "Cache-Control": "no-store",
                "Pragma": "no-cache",
            },
        )

    async def _async_handle_refresh_token(
        self,
        hass: HomeAssistant,
        data: MultiDictProxy[str],
        request: web.Request,
    ) -> web.Response:
        """Handle refresh token request."""
        client_id = data.get("client_id")
        if client_id is not None and not indieauth.verify_client_id(client_id):
            return self.json(
                {"error": "invalid_request", "error_description": "Invalid client id"},
                status_code=HTTPStatus.BAD_REQUEST,
            )

        if (token := data.get("refresh_token")) is None:
            return self.json(
                {"error": "invalid_request"}, status_code=HTTPStatus.BAD_REQUEST
            )

        refresh_token = hass.auth.async_get_refresh_token_by_token(token)

        if refresh_token is None:
            return self.json(
                {"error": "invalid_grant"}, status_code=HTTPStatus.BAD_REQUEST
            )

        if refresh_token.client_id != client_id:
            return self.json(
                {"error": "invalid_request"}, status_code=HTTPStatus.BAD_REQUEST
            )

        if user_access_error := async_user_not_allowed_do_auth(
            hass, refresh_token.user
        ):
            return self.json(
                {
                    "error": "access_denied",
                    "error_description": user_access_error,
                },
                status_code=HTTPStatus.FORBIDDEN,
            )

        try:
            access_token = hass.auth.async_create_access_token(
                refresh_token, request.remote
            )
        except InvalidAuthError as exc:
            return self.json(
                {"error": "access_denied", "error_description": str(exc)},
                status_code=HTTPStatus.FORBIDDEN,
            )

        return self.json(
            {
                "access_token": access_token,
                "token_type": "Bearer",
                "expires_in": int(
                    refresh_token.access_token_expiration.total_seconds()
                ),
            },
            headers={
                "Cache-Control": "no-store",
                "Pragma": "no-cache",
            },
        )


class LinkUserView(HomeAssistantView):
    """View to link existing users to new credentials."""

    url = "/auth/link_user"
    name = "api:auth:link_user"

    def __init__(self, retrieve_credentials: RetrieveResultType) -> None:
        """Initialize the link user view."""
        self._retrieve_credentials = retrieve_credentials

    @RequestDataValidator(vol.Schema({"code": str, "client_id": str}))
    async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
        """Link a user."""
        hass = request.app[KEY_HASS]
        user: User = request["hass_user"]

        credentials = self._retrieve_credentials(data["client_id"], data["code"])

        if credentials is None:
            return self.json_message("Invalid code", status_code=HTTPStatus.BAD_REQUEST)

        linked_user = await hass.auth.async_get_user_by_credentials(credentials)
        if linked_user != user and linked_user is not None:
            return self.json_message(
                "Credential already linked", status_code=HTTPStatus.BAD_REQUEST
            )

        # No-op if credential is already linked to the user it will be linked to
        if linked_user != user:
            await hass.auth.async_link_user(user, credentials)
        return self.json_message("User linked")


@callback
def _create_auth_code_store() -> tuple[StoreResultType, RetrieveResultType]:
    """Create an in memory store."""
    temp_results: dict[tuple[str, str], tuple[datetime, Credentials]] = {}

    @callback
    def store_result(client_id: str, result: Credentials) -> str:
        """Store flow result and return a code to retrieve it."""
        if not isinstance(result, Credentials):
            raise TypeError("result has to be a Credentials instance")

        code = uuid.uuid4().hex
        temp_results[(client_id, code)] = (
            dt_util.utcnow(),
            result,
        )
        return code

    @callback
    def retrieve_result(client_id: str, code: str) -> Credentials | None:
        """Retrieve flow result."""
        key = (client_id, code)

        if key not in temp_results:
            return None

        created, result = temp_results.pop(key)

        # OAuth 4.2.1
        # The authorization code MUST expire shortly after it is issued to
        # mitigate the risk of leaks.  A maximum authorization code lifetime of
        # 10 minutes is RECOMMENDED.
        if dt_util.utcnow() - created < timedelta(minutes=10):
            return result

        return None

    return store_result, retrieve_result


@websocket_api.websocket_command({vol.Required("type"): "auth/current_user"})
@websocket_api.ws_require_user()
@websocket_api.async_response
async def websocket_current_user(
    hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
    """Return the current user."""
    user = connection.user
    enabled_modules = await hass.auth.async_get_enabled_mfa(user)

    connection.send_message(
        websocket_api.result_message(
            msg["id"],
            {
                "id": user.id,
                "name": user.name,
                "is_owner": user.is_owner,
                "is_admin": user.is_admin,
                "credentials": [
                    {
                        "auth_provider_type": c.auth_provider_type,
                        "auth_provider_id": c.auth_provider_id,
                    }
                    for c in user.credentials
                ],
                "mfa_modules": [
                    {
                        "id": module.id,
                        "name": module.name,
                        "enabled": module.id in enabled_modules,
                    }
                    for module in hass.auth.auth_mfa_modules
                ],
            },
        )
    )


@websocket_api.websocket_command(
    {
        vol.Required("type"): "auth/long_lived_access_token",
        vol.Required("lifespan"): int,  # days
        vol.Required("client_name"): str,
        vol.Optional("client_icon"): str,
    }
)
@websocket_api.ws_require_user()
@websocket_api.async_response
async def websocket_create_long_lived_access_token(
    hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
    """Create or a long-lived access token."""
    refresh_token = await hass.auth.async_create_refresh_token(
        connection.user,
        client_name=msg["client_name"],
        client_icon=msg.get("client_icon"),
        token_type=TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN,
        access_token_expiration=timedelta(days=msg["lifespan"]),
    )

    try:
        access_token = hass.auth.async_create_access_token(refresh_token)
    except InvalidAuthError as exc:
        connection.send_error(msg["id"], websocket_api.ERR_UNAUTHORIZED, str(exc))
        return

    connection.send_result(msg["id"], access_token)


@websocket_api.websocket_command({vol.Required("type"): "auth/refresh_tokens"})
@websocket_api.ws_require_user()
@callback
def websocket_refresh_tokens(
    hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
    """Return metadata of users refresh tokens."""
    current_id = connection.refresh_token_id

    tokens: list[dict[str, Any]] = []
    for refresh in connection.user.refresh_tokens.values():
        if refresh.credential:
            auth_provider_type = refresh.credential.auth_provider_type
        else:
            auth_provider_type = None

        expire_at = None
        if refresh.expire_at:
            expire_at = dt_util.utc_from_timestamp(refresh.expire_at)

        tokens.append(
            {
                "auth_provider_type": auth_provider_type,
                "client_icon": refresh.client_icon,
                "client_id": refresh.client_id,
                "client_name": refresh.client_name,
                "created_at": refresh.created_at,
                "expire_at": expire_at,
                "id": refresh.id,
                "is_current": refresh.id == current_id,
                "last_used_at": refresh.last_used_at,
                "last_used_ip": refresh.last_used_ip,
                "type": refresh.token_type,
            }
        )

    connection.send_result(msg["id"], tokens)


@callback
@websocket_api.websocket_command(
    {
        vol.Required("type"): "auth/delete_refresh_token",
        vol.Required("refresh_token_id"): str,
    }
)
@websocket_api.ws_require_user()
def websocket_delete_refresh_token(
    hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
    """Handle a delete refresh token request."""
    refresh_token = connection.user.refresh_tokens.get(msg["refresh_token_id"])

    if refresh_token is None:
        connection.send_error(msg["id"], "invalid_token_id", "Received invalid token")
        return

    hass.auth.async_remove_refresh_token(refresh_token)

    connection.send_result(msg["id"], {})


@callback
@websocket_api.websocket_command(
    {
        vol.Required("type"): "auth/delete_all_refresh_tokens",
        vol.Optional("token_type"): cv.string,
        vol.Optional("delete_current_token", default=True): bool,
    }
)
@websocket_api.ws_require_user()
def websocket_delete_all_refresh_tokens(
    hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
    """Handle delete all refresh tokens request."""
    current_refresh_token: RefreshToken | None = None
    remove_failed = False
    token_type = msg.get("token_type")
    delete_current_token = msg.get("delete_current_token")
    limit_token_types = token_type is not None

    for token in list(connection.user.refresh_tokens.values()):
        if token.id == connection.refresh_token_id:
            # Skip the current refresh token as it has revoke_callback,
            # which cancels/closes the connection.
            # It will be removed after sending the result.
            current_refresh_token = token
            continue
        if limit_token_types and token_type != token.token_type:
            continue
        try:
            hass.auth.async_remove_refresh_token(token)
        except Exception:
            getLogger(__name__).exception("Error during refresh token removal")
            remove_failed = True

    if remove_failed:
        connection.send_error(
            msg["id"], "token_removing_error", "During removal, an error was raised."
        )
    else:
        connection.send_result(msg["id"], {})

    async def _delete_current_token_soon(current_refresh_token: RefreshToken) -> None:
        """Delete the current token after a delay.

        We do not want to delete the current token immediately as it will
        close the connection.

        This is implemented as a tracked task to ensure the token
        is still deleted if Home Assistant is shut down during
        the delay.

        It should not be refactored to use a call_later as that
        would not be tracked and the token would not be deleted
        if Home Assistant was shut down during the delay.
        """
        try:
            await asyncio.sleep(DELETE_CURRENT_TOKEN_DELAY)
        finally:
            # If the task is cancelled because we are shutting down, delete
            # the token right away.
            hass.auth.async_remove_refresh_token(current_refresh_token)

    if (
        delete_current_token
        and current_refresh_token
        and (not limit_token_types or current_refresh_token.token_type == token_type)
    ):
        # Deleting the token will close the connection so we need
        # to do it with a delay in a tracked task to ensure it still
        # happens if Home Assistant is shutting down.
        hass.async_create_task(_delete_current_token_soon(current_refresh_token))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "auth/sign_path",
        vol.Required("path"): str,
        vol.Optional("expires", default=30): int,
    }
)
@websocket_api.ws_require_user()
@callback
def websocket_sign_path(
    hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
    """Handle a sign path request."""
    connection.send_message(
        websocket_api.result_message(
            msg["id"],
            {
                "path": async_sign_path(
                    hass,
                    msg["path"],
                    timedelta(seconds=msg["expires"]),
                )
            },
        )
    )


@callback
@websocket_api.websocket_command(
    {
        vol.Required("type"): "auth/refresh_token_set_expiry",
        vol.Required("refresh_token_id"): str,
        vol.Required("enable_expiry"): bool,
    }
)
@websocket_api.ws_require_user()
def websocket_refresh_token_set_expiry(
    hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
    """Handle a set expiry of a refresh token request."""
    refresh_token = connection.user.refresh_tokens.get(msg["refresh_token_id"])

    if refresh_token is None:
        connection.send_error(msg["id"], "invalid_token_id", "Received invalid token")
        return

    hass.auth.async_set_expiry(refresh_token, enable_expiry=msg["enable_expiry"])
    connection.send_result(msg["id"], {})
