"""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.

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

## 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",
    "client_icon": null,
    "lifespan": 365
}

Result will be a long-lived access token:

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

"""
import logging
import uuid
from datetime import timedelta

from aiohttp import web
import voluptuous as vol

from homeassistant.auth.models import User, Credentials, \
    TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN
from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_REAL_IP
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 callback, HomeAssistant
from homeassistant.util import dt as dt_util

from . import indieauth
from . import login_flow
from . import mfa_setup_flow

DOMAIN = 'auth'
DEPENDENCIES = ['http']

WS_TYPE_CURRENT_USER = 'auth/current_user'
SCHEMA_WS_CURRENT_USER = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
    vol.Required('type'): WS_TYPE_CURRENT_USER,
})

WS_TYPE_LONG_LIVED_ACCESS_TOKEN = 'auth/long_lived_access_token'
SCHEMA_WS_LONG_LIVED_ACCESS_TOKEN = \
    websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
        vol.Required('type'): WS_TYPE_LONG_LIVED_ACCESS_TOKEN,
        vol.Required('lifespan'): int,  # days
        vol.Required('client_name'): str,
        vol.Optional('client_icon'): str,
    })

WS_TYPE_REFRESH_TOKENS = 'auth/refresh_tokens'
SCHEMA_WS_REFRESH_TOKENS = \
    websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
        vol.Required('type'): WS_TYPE_REFRESH_TOKENS,
    })

WS_TYPE_DELETE_REFRESH_TOKEN = 'auth/delete_refresh_token'
SCHEMA_WS_DELETE_REFRESH_TOKEN = \
    websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
        vol.Required('type'): WS_TYPE_DELETE_REFRESH_TOKEN,
        vol.Required('refresh_token_id'): str,
    })

RESULT_TYPE_CREDENTIALS = 'credentials'
RESULT_TYPE_USER = 'user'

_LOGGER = logging.getLogger(__name__)


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

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

    hass.components.websocket_api.async_register_command(
        WS_TYPE_CURRENT_USER, websocket_current_user,
        SCHEMA_WS_CURRENT_USER
    )
    hass.components.websocket_api.async_register_command(
        WS_TYPE_LONG_LIVED_ACCESS_TOKEN,
        websocket_create_long_lived_access_token,
        SCHEMA_WS_LONG_LIVED_ACCESS_TOKEN
    )
    hass.components.websocket_api.async_register_command(
        WS_TYPE_REFRESH_TOKENS,
        websocket_refresh_tokens,
        SCHEMA_WS_REFRESH_TOKENS
    )
    hass.components.websocket_api.async_register_command(
        WS_TYPE_DELETE_REFRESH_TOKEN,
        websocket_delete_refresh_token,
        SCHEMA_WS_DELETE_REFRESH_TOKEN
    )

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

    return True


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

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

    def __init__(self, retrieve_user):
        """Initialize the token view."""
        self._retrieve_user = retrieve_user

    @log_invalid_auth
    async def post(self, request):
        """Grant a token."""
        hass = request.app['hass']
        data = 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':
            return await self._async_handle_revoke_token(hass, data)

        if grant_type == 'authorization_code':
            return await self._async_handle_auth_code(
                hass, data, str(request[KEY_REAL_IP]))

        if grant_type == 'refresh_token':
            return await self._async_handle_refresh_token(
                hass, data, str(request[KEY_REAL_IP]))

        return self.json({
            'error': 'unsupported_grant_type',
        }, status_code=400)

    async def _async_handle_revoke_token(self, hass, data):
        """Handle revoke token request."""
        # 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.
        token = data.get('token')

        if token is None:
            return web.Response(status=200)

        refresh_token = await hass.auth.async_get_refresh_token_by_token(token)

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

        await hass.auth.async_remove_refresh_token(refresh_token)
        return web.Response(status=200)

    async def _async_handle_auth_code(self, hass, data, remote_addr):
        """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=400)

        code = data.get('code')

        if code is None:
            return self.json({
                'error': 'invalid_request',
                'error_description': 'Invalid code',
            }, status_code=400)

        user = self._retrieve_user(client_id, RESULT_TYPE_USER, code)

        if user is None or not isinstance(user, User):
            return self.json({
                'error': 'invalid_request',
                'error_description': 'Invalid code',
            }, status_code=400)

        # refresh user
        user = await hass.auth.async_get_user(user.id)

        if not user.is_active:
            return self.json({
                'error': 'access_denied',
                'error_description': 'User is not active',
            }, status_code=403)

        refresh_token = await hass.auth.async_create_refresh_token(user,
                                                                   client_id)
        access_token = hass.auth.async_create_access_token(
            refresh_token, remote_addr)

        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()),
        })

    async def _async_handle_refresh_token(self, hass, data, remote_addr):
        """Handle authorization code 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=400)

        token = data.get('refresh_token')

        if token is None:
            return self.json({
                'error': 'invalid_request',
            }, status_code=400)

        refresh_token = await hass.auth.async_get_refresh_token_by_token(token)

        if refresh_token is None:
            return self.json({
                'error': 'invalid_grant',
            }, status_code=400)

        if refresh_token.client_id != client_id:
            return self.json({
                'error': 'invalid_request',
            }, status_code=400)

        access_token = hass.auth.async_create_access_token(
            refresh_token, remote_addr)

        return self.json({
            'access_token': access_token,
            'token_type': 'Bearer',
            'expires_in':
                int(refresh_token.access_token_expiration.total_seconds()),
        })


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):
        """Initialize the link user view."""
        self._retrieve_credentials = retrieve_credentials

    @RequestDataValidator(vol.Schema({
        'code': str,
        'client_id': str,
    }))
    async def post(self, request, data):
        """Link a user."""
        hass = request.app['hass']
        user = request['hass_user']

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

        if credentials is None:
            return self.json_message('Invalid code', status_code=400)

        await hass.auth.async_link_user(user, credentials)
        return self.json_message('User linked')


@callback
def _create_auth_code_store():
    """Create an in memory store."""
    temp_results = {}

    @callback
    def store_result(client_id, result):
        """Store flow result and return a code to retrieve it."""
        if isinstance(result, User):
            result_type = RESULT_TYPE_USER
        elif isinstance(result, Credentials):
            result_type = RESULT_TYPE_CREDENTIALS
        else:
            raise ValueError('result has to be either User or Credentials')

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

    @callback
    def retrieve_result(client_id, result_type, code):
        """Retrieve flow result."""
        key = (client_id, result_type, 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.ws_require_user()
@callback
def websocket_current_user(
        hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg):
    """Return the current user."""
    async def async_get_current_user(user):
        """Get current user."""
        enabled_modules = await hass.auth.async_get_enabled_mfa(user)

        connection.send_message_outside(
            websocket_api.result_message(msg['id'], {
                'id': user.id,
                'name': user.name,
                'is_owner': user.is_owner,
                '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],
            }))

    hass.async_create_task(async_get_current_user(connection.user))


@websocket_api.ws_require_user()
@callback
def websocket_create_long_lived_access_token(
        hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg):
    """Create or a long-lived access token."""
    async def async_create_long_lived_access_token(user):
        """Create or a long-lived access token."""
        refresh_token = await hass.auth.async_create_refresh_token(
            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']))

        access_token = hass.auth.async_create_access_token(
            refresh_token)

        connection.send_message_outside(
            websocket_api.result_message(msg['id'], access_token))

    hass.async_create_task(
        async_create_long_lived_access_token(connection.user))


@websocket_api.ws_require_user()
@callback
def websocket_refresh_tokens(
        hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg):
    """Return metadata of users refresh tokens."""
    current_id = connection.request.get('refresh_token_id')
    connection.to_write.put_nowait(websocket_api.result_message(msg['id'], [{
        'id': refresh.id,
        'client_id': refresh.client_id,
        'client_name': refresh.client_name,
        'client_icon': refresh.client_icon,
        'type': refresh.token_type,
        'created_at': refresh.created_at,
        'is_current': refresh.id == current_id,
        'last_used_at': refresh.last_used_at,
        'last_used_ip': refresh.last_used_ip,
    } for refresh in connection.user.refresh_tokens.values()]))


@websocket_api.ws_require_user()
@callback
def websocket_delete_refresh_token(
        hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg):
    """Handle a delete refresh token request."""
    async def async_delete_refresh_token(user, refresh_token_id):
        """Delete a refresh token."""
        refresh_token = connection.user.refresh_tokens.get(refresh_token_id)

        if refresh_token is None:
            return websocket_api.error_message(
                msg['id'], 'invalid_token_id', 'Received invalid token')

        await hass.auth.async_remove_refresh_token(refresh_token)

        connection.send_message_outside(
            websocket_api.result_message(msg['id'], {}))

    hass.async_create_task(
        async_delete_refresh_token(connection.user, msg['refresh_token_id']))
