from typing import Dict, Optional, Tuple, Union
from urllib.parse import parse_qsl

from flask import Blueprint, Response, request
from urllib3.util import parse_url

from fittrackee import db
from fittrackee.responses import (
    HttpResponse,
    InvalidPayloadErrorResponse,
    NotFoundErrorResponse,
)
from fittrackee.users.models import User

from .client import create_oauth2_client
from .exceptions import InvalidOAuth2Scopes
from .models import OAuth2Client, OAuth2Token
from .server import authorization_server, require_auth

oauth2_blueprint = Blueprint('oauth2', __name__)

EXPECTED_METADATA_KEYS = [
    'client_name',
    'client_uri',
    'redirect_uris',
    'scope',
]
DEFAULT_PER_PAGE = 5


def is_errored(url: str) -> Optional[str]:
    query = dict(parse_qsl(parse_url(url).query))
    if query.get('error'):
        return query.get('error_description', 'invalid payload')
    return None


@oauth2_blueprint.route('/oauth/apps', methods=['GET'])
@require_auth()
def get_clients(auth_user: User) -> Dict:
    """
    Get OAuth2 clients (apps) for authenticated user with pagination
    (5 clients/page).

    This endpoint is only accessible by FitTrackee client (first-party
    application).

    **Example request**:

    - without parameters

    .. sourcecode:: http

      GET /api/oauth/apps HTTP/1.1
      Content-Type: application/json

    - with 'page' parameter

    .. sourcecode:: http

      GET /api/oauth/apps?page=2 HTTP/1.1
      Content-Type: application/json

    **Example response**:

    .. sourcecode:: http

      HTTP/1.1 200 SUCCESS
      Content-Type: application/json

      {
        "data": {
          "clients": [
            {
              "client_description": "",
              "client_id": "o22a27s2aBPUoxJbxV3UjDOx",
              "id": 1,
              "issued_at": "Thu, 14 July 2022 06:27:53 GMT",
              "name": "GPX Importer",
              "redirect_uris": [
                " https://example.com/callback"
              ],
              "scope": "profile:read workouts:write",
              "website": "https://example.com"
            }
          ]
        },
        "pagination": {
          "has_next": false,
          "has_prev": false,
          "page": 1,
          "pages": 1,
          "total": 1
        },
        "status": "success"
      }

    :query integer page: page for pagination (default: 1)

    :reqheader Authorization: OAuth 2.0 Bearer Token

    :statuscode 200: success
    :statuscode 401:
        - provide a valid auth token
        - signature expired, please log in again
        - invalid token, please log in again
    """
    params = request.args.copy()
    page = int(params.get('page', 1))
    per_page = DEFAULT_PER_PAGE
    clients_pagination = (
        OAuth2Client.query.filter_by(user_id=auth_user.id)
        .order_by(OAuth2Client.id.desc())
        .paginate(page=page, per_page=per_page, error_out=False)
    )
    clients = clients_pagination.items
    return {
        'status': 'success',
        'data': {
            'clients': [
                client.serialize(with_secret=False) for client in clients
            ]
        },
        'pagination': {
            'has_next': clients_pagination.has_next,
            'has_prev': clients_pagination.has_prev,
            'page': clients_pagination.page,
            'pages': clients_pagination.pages,
            'total': clients_pagination.total,
        },
    }


@oauth2_blueprint.route('/oauth/apps', methods=['POST'])
@require_auth()
def create_client(auth_user: User) -> Union[HttpResponse, Tuple[Dict, int]]:
    """
    Create an OAuth2 client (app) for the authenticated user.

    This endpoint is only accessible by FitTrackee client (first-party
    application).

    **Example request**:

    .. sourcecode:: http

      POST /api/oauth/apps HTTP/1.1
      Content-Type: application/json

    **Example response**:

    .. sourcecode:: http

      HTTP/1.1 200 SUCCESS
      Content-Type: application/json

      {
        "data": {
          "client": {
            "client_description": "",
            "client_id": "o22a27s2aBPUoxJbxV3UjDOx",
            "client_secret": "<CLIENT SECRET>",
            "id": 1,
            "issued_at": "Thu, 14 July 2022 06:27:53 GMT",
            "name": "GPX Importer",
            "redirect_uris": [
              "https://example.com/callback"
            ],
            "scope": "profile:read workouts:write",
            "website": "https://example.com"
          }
        },
        "status": "created"
      }

    :json string client_name: client name
    :json string client_uri: client URL
    :json array  redirect_uri: list of client redirect URLs (string)
    :json string scope: client scopes
    :json string client_description: client description (`OPTIONAL`)

    :reqheader Authorization: OAuth 2.0 Bearer Token

    :statuscode 200: success
    :statuscode 400:
        - invalid payload
    :statuscode 401:
        - provide a valid auth token
        - signature expired, please log in again
        - invalid token, please log in again
    """
    client_metadata = request.get_json()
    if not client_metadata:
        return InvalidPayloadErrorResponse(
            message='OAuth2 client metadata missing'
        )

    missing_keys = [
        key
        for key in EXPECTED_METADATA_KEYS
        if key not in client_metadata.keys()
    ]
    if missing_keys:
        return InvalidPayloadErrorResponse(
            message=(
                'OAuth2 client metadata missing keys: '
                f'{", ".join(missing_keys)}'
            )
        )

    try:
        new_client = create_oauth2_client(client_metadata, auth_user)
    except InvalidOAuth2Scopes:
        return InvalidPayloadErrorResponse(
            message='OAuth2 client invalid scopes'
        )

    db.session.add(new_client)
    db.session.commit()
    return (
        {
            'status': 'created',
            'data': {'client': new_client.serialize(with_secret=True)},
        },
        201,
    )


def get_client(
    auth_user: User,
    client_id: Optional[int],
    client_client_id: Optional[str],
) -> Union[Dict, HttpResponse]:
    key = 'id' if client_id else 'client_id'
    value = client_id if client_id else client_client_id
    client = OAuth2Client.query.filter_by(
        **{key: value, 'user_id': auth_user.id}
    ).first()

    if not client:
        return NotFoundErrorResponse('OAuth2 client not found')

    return {
        'status': 'success',
        'data': {'client': client.serialize(with_secret=False)},
    }


@oauth2_blueprint.route(
    '/oauth/apps/<string:client_client_id>', methods=['GET']
)
@require_auth()
def get_client_by_client_id(
    auth_user: User, client_client_id: str
) -> Union[Dict, HttpResponse]:
    """
    Get an OAuth2 client (app) by 'client_id'.

    This endpoint is only accessible by FitTrackee client (first-party
    application).

    **Example request**:

    .. sourcecode:: http

      GET /api/oauth/apps/o22a27s2aBPUoxJbxV3UjDOx HTTP/1.1
      Content-Type: application/json

    **Example responses**:

    - success

    .. sourcecode:: http

      HTTP/1.1 200 SUCCESS
      Content-Type: application/json

      {
        "data": {
          "client": {
            "client_description": "",
            "client_id": "o22a27s2aBPUoxJbxV3UjDOx",
            "id": 1,
            "issued_at": "Thu, 14 July 2022 06:27:53 GMT",
            "name": "GPX Importer",
            "redirect_uris": [
              "https://example.com/callback"
            ],
            "scope": "profile:read workouts:write",
            "website": "https://example.com"
          }
        },
        "status": "success"
      }

    - not found

    .. sourcecode:: http

      HTTP/1.1 404 NOT FOUND
      Content-Type: application/json

      {
        "status": "not found",
        "message": "OAuth2 client not found"
      }

    :param string client_client_id: OAuth2 client client_id

    :reqheader Authorization: OAuth 2.0 Bearer Token

    :statuscode 200: success
    :statuscode 401:
        - provide a valid auth token
        - signature expired, please log in again
        - invalid token, please log in again
    :statuscode 404: OAuth2 client not found
    """
    return get_client(
        auth_user, client_id=None, client_client_id=client_client_id
    )


@oauth2_blueprint.route('/oauth/apps/<int:client_id>/by_id', methods=['GET'])
@require_auth()
def get_client_by_id(
    auth_user: User, client_id: int
) -> Union[Dict, HttpResponse]:
    """
    Get an OAuth2 client (app) by id (integer value).

    This endpoint is only accessible by FitTrackee client (first-party
    application).

    **Example request**:

    .. sourcecode:: http

      GET /api/oauth/apps/1/by_id HTTP/1.1
      Content-Type: application/json

    **Example responses**:

    - success

    .. sourcecode:: http

      HTTP/1.1 200 SUCCESS
      Content-Type: application/json

      {
        "data": {
          "client": {
            "client_description": "",
            "client_id": "o22a27s2aBPUoxJbxV3UjDOx",
            "id": 1,
            "issued_at": "Thu, 14 July 2022 06:27:53 GMT",
            "name": "GPX Importer",
            "redirect_uris": [
              "https://example.com/callback"
            ],
            "scope": "profile:read workouts:write",
            "website": "https://example.com"
          }
        },
        "status": "success"
      }

    - not found

    .. sourcecode:: http

      HTTP/1.1 404 NOT FOUND
      Content-Type: application/json

      {
        "status": "not found",
        "message": "OAuth2 client not found"
      }

    :param integer client_id: OAuth2 client id

    :reqheader Authorization: OAuth 2.0 Bearer Token

    :statuscode 200: success
    :statuscode 401:
        - provide a valid auth token
        - signature expired, please log in again
        - invalid token, please log in again
    :statuscode 404: OAuth2 client not found
    """
    return get_client(auth_user, client_id=client_id, client_client_id=None)


@oauth2_blueprint.route('/oauth/apps/<int:client_id>', methods=['DELETE'])
@require_auth()
def delete_client(
    auth_user: User, client_id: int
) -> Union[Tuple[Dict, int], HttpResponse]:
    """
    Delete an OAuth2 client (app).

    This endpoint is only accessible by FitTrackee client (first-party
    application).

    **Example request**:

    .. sourcecode:: http

      DELETE /api/oauth/apps/1 HTTP/1.1
      Content-Type: application/json

    **Example response**:

    .. sourcecode:: http

      HTTP/1.1 204 NO CONTENT
      Content-Type: application/json

    :param integer client_id: OAuth2 client id

    :reqheader Authorization: OAuth 2.0 Bearer Token

    :statuscode 204: OAuth2 client deleted
    :statuscode 401:
        - provide a valid auth token
        - signature expired, please log in again
        - invalid token, please log in again
    :statuscode 404: OAuth2 client not found
    """
    client = OAuth2Client.query.filter_by(
        id=client_id,
        user_id=auth_user.id,
    ).first()

    if not client:
        return NotFoundErrorResponse('OAuth2 client not found')

    db.session.delete(client)
    db.session.commit()
    return {'status': 'no content'}, 204


@oauth2_blueprint.route('/oauth/apps/<int:client_id>/revoke', methods=['POST'])
@require_auth()
def revoke_client_tokens(
    auth_user: User, client_id: int
) -> Union[Dict, HttpResponse]:
    """
    Revoke all tokens associated to an OAuth2 client (app).

    This endpoint is only accessible by FitTrackee client (first-party
    application).

    **Example request**:

    .. sourcecode:: http

      POST /api/oauth/apps/1/revoke HTTP/1.1
      Content-Type: application/json

    **Example response**:

    .. sourcecode:: http

      HTTP/1.1 200 SUCCESS
      Content-Type: application/json

      {
        "status": "success"
      }

    :param integer client_id: OAuth2 client id

    :reqheader Authorization: OAuth 2.0 Bearer Token

    :statuscode 200: success
    :statuscode 401:
        - provide a valid auth token
        - signature expired, please log in again
        - invalid token, please log in again
    :statuscode 404: OAuth2 client not found
    """
    client = OAuth2Client.query.filter_by(id=client_id).first()

    if not client:
        return NotFoundErrorResponse('OAuth2 client not found')

    OAuth2Token.revoke_client_tokens(client.client_id)
    return {'status': 'success'}


@oauth2_blueprint.route('/oauth/authorize', methods=['POST'])
@require_auth()
def authorize(auth_user: User) -> Union[HttpResponse, Dict]:
    """
    Authorize an OAuth2 client (app).
    If successful, it redirects to the client callback URL with the code to
    issue a token.

    This endpoint is only accessible by FitTrackee client (first-party
    application).

    **Example request**:

    .. sourcecode:: http

      POST /api/oauth/authorize HTTP/1.1
      Content-Type: multipart/form-data

    **Example response**:

    .. sourcecode:: http

      HTTP/1.1 200 SUCCESS
      Content-Type: application/json

      {
        "status": "success"
      }

    :form string  client_id: OAuth2 client 'client_id'
    :form string  response_type: client response type (only 'code' is supported
                                by FitTrackee)
    :form string  scopes: OAuth2 client scopes
    :form boolean confirm: confirmation (must be 'true')
    :form string  state: unique value to prevent cross-site request forgery
                         (not mandatory but recommended)
    :form string  code_challenge: string generated from a code verifier
                         (for PKCE, not mandatory but recommended)
    :form string  code_challenge_method: method used to create challenge,
                         for instance "S256" (mandatory if `code_challenge`
                         provided)

    :reqheader Authorization: OAuth 2.0 Bearer Token

    :statuscode 200: success
    :statuscode 400:
        - invalid payload
        - errors returned by Authlib library
    :statuscode 401:
        - provide a valid auth token
        - signature expired, please log in again
        - invalid token, please log in again
    """
    data = request.form
    if (
        not data
        or 'client_id' not in data
        or 'response_type' not in data
        or data.get('response_type') != 'code'
    ):
        return InvalidPayloadErrorResponse()

    confirm = data.get('confirm', 'false')
    grant_user = auth_user if confirm.lower() == 'true' else None
    response = authorization_server.create_authorization_response(
        grant_user=grant_user
    )
    error_message = is_errored(url=response.location)
    if error_message:
        return InvalidPayloadErrorResponse(error_message)
    return {'redirect_url': response.location}


@oauth2_blueprint.route('/oauth/token', methods=['POST'])
def issue_token() -> Response:
    """
    Issue or refresh token for a given OAuth2 client (app).

    **Example request**:

    .. sourcecode:: http

      POST /api/oauth/token HTTP/1.1
      Content-Type: multipart/form-data

    **Example response**:

    .. sourcecode:: http

      HTTP/1.1 200 SUCCESS
      Content-Type: application/json

      {
        "access_token": "rOEHv64THCG28WcewZHRnVLUsOdUvw8NVnHKCmL57e",
        "expires_in": 864000,
        "refresh_token": "NuV9cY8VQOnrQKHTZ5pQAq2Zw7mSH0MorNPJr14AmSwD6f6I",
        "scope": ["profile:read", "workouts:write"],
        "token_type": "Bearer",
        "expires_at": 1658660147.0667062
      }

    :form string  client_id: OAuth2 client 'client_id'
    :form string  client_secret: OAuth2 client secret
    :form string  grant_type: OAuth2 client grant type
                         (only 'authorization_code' (for token issue)
                         and 'refresh_token' (for token refresh)
                         are supported by FitTrackee)
    :form string  code: code generated after authorizing the client
                         (for token issue)
    :form string  code_verifier: code verifier
                         (for token issue with PKCE, not mandatory)
    :form string  refresh_token: refresh token (for token refresh)

    :statuscode 200: success
    :statuscode 400:
        - errors returned by Authlib library
    :statuscode 401:
        - provide a valid auth token
        - signature expired, please log in again
        - invalid token, please log in again
    """
    return authorization_server.create_token_response()


@oauth2_blueprint.route('/oauth/revoke', methods=['POST'])
def revoke_token() -> Response:
    """
    Revoke a token for a given OAuth2 client (app).

    **Example request**:

    .. sourcecode:: http

      POST /api/oauth/revoke HTTP/1.1
      Content-Type: multipart/form-data

    **Example response**:

    .. sourcecode:: http

      HTTP/1.1 200 SUCCESS
      Content-Type: application/json

      {}

    :form string client_id: OAuth2 client 'client_id'
    :form string client_secret: OAuth2 client secret
    :form string token: access token to revoke

    :statuscode 200: success
    :statuscode 400:
        - errors returned by Authlib library
    :statuscode 401:
        - provide a valid auth token
        - signature expired, please log in again
        - invalid token, please log in again
    """
    return authorization_server.create_endpoint_response('revocation')
