"""
Google and Microsoft sign-in for the WEBSITE, server side.

THE BROWSER NEVER HOLDS A SECRET AND NEVER HOLDS A PROVIDER TOKEN. The page
sends the visitor to `/api/auth/oauth/<provider>/start/`, Django redirects to
the provider, the provider comes back to `/api/auth/oauth/<provider>/callback/`,
and Django does the code-for-token exchange from the server with the client
secret. What returns to the page is a 60-second single-use handoff code in a
URL fragment, which the page trades for Lifey's own JWTs over POST.

Two things this does NOT do, deliberately:

- It does not verify the `id_token` signature. The token was not read off a
  redirect: it came back over TLS from the provider's own token endpoint, in a
  server-to-server request authenticated with the client secret. That is the
  one case OpenID Connect Core §3.1.3.7 lets the signature check be skipped,
  and adding JWKS fetching would mean a key cache and an outbound request per
  sign-in on a one-core box.
- It does not trust an unverified address. Google states `email_verified` and
  Microsoft does not issue a personal-account token without one, so an address
  arriving without that claim is refused rather than merged into an account
  somebody else may own by password.

No new dependency: `urllib.request` for the exchange, PyJWT (already required
by SimpleJWT) for reading the claims.
"""

import json
import logging
import secrets
import urllib.error
import urllib.parse
import urllib.request

import jwt
from django.conf import settings

logger = logging.getLogger(__name__)

TIMEOUT_SECONDS = 10

# `common` covers both work/school and personal Microsoft accounts. A tenant id
# here would lock sign-in to one organisation.
MICROSOFT_TENANT = 'common'

PROVIDERS = {
    'google': {
        'label': 'Google',
        'authorize_url': 'https://accounts.google.com/o/oauth2/v2/auth',
        'token_url': 'https://oauth2.googleapis.com/token',
        'scope': 'openid email profile',
        'client_id_setting': 'GOOGLE_OAUTH_CLIENT_ID',
        'client_secret_setting': 'GOOGLE_OAUTH_CLIENT_SECRET',
        'extra_authorize_params': {
            # Ask for an account every time. Without it a shared machine signs
            # the previous person back in with no visible choice.
            'prompt': 'select_account',
            'access_type': 'online',
        },
    },
    'microsoft': {
        'label': 'Microsoft',
        'authorize_url': f'https://login.microsoftonline.com/{MICROSOFT_TENANT}/oauth2/v2.0/authorize',
        'token_url': f'https://login.microsoftonline.com/{MICROSOFT_TENANT}/oauth2/v2.0/token',
        'scope': 'openid email profile',
        'client_id_setting': 'MICROSOFT_OAUTH_CLIENT_ID',
        'client_secret_setting': 'MICROSOFT_OAUTH_CLIENT_SECRET',
        'extra_authorize_params': {
            'prompt': 'select_account',
            'response_mode': 'query',
        },
    },
}


class OAuthError(Exception):
    """Anything that stops the exchange. The message is shown to the user."""


def provider_config(provider):
    config = PROVIDERS.get(provider)
    if config is None:
        raise OAuthError('Unknown sign-in provider.')
    return config


def is_configured(provider):
    """
    Whether the client id and secret are actually present.

    The website asks this so it can grey the button out instead of sending
    somebody to a provider page that answers `invalid_client`.
    """
    config = PROVIDERS.get(provider)
    if config is None:
        return False
    return bool(
        getattr(settings, config['client_id_setting'], '')
        and getattr(settings, config['client_secret_setting'], '')
    )


def _credentials(config):
    client_id = getattr(settings, config['client_id_setting'], '')
    client_secret = getattr(settings, config['client_secret_setting'], '')

    if not client_id or not client_secret:
        raise OAuthError(f"{config['label']} sign-in is not configured yet.")

    return client_id, client_secret


def new_state():
    return secrets.token_urlsafe(32)


def authorize_url(provider, redirect_uri, state, nonce):
    """Where to send the browser."""
    config = provider_config(provider)
    client_id, _ = _credentials(config)

    params = {
        'client_id': client_id,
        'redirect_uri': redirect_uri,
        'response_type': 'code',
        'scope': config['scope'],
        'state': state,
        'nonce': nonce,
        **config['extra_authorize_params'],
    }
    return f"{config['authorize_url']}?{urllib.parse.urlencode(params)}"


def exchange_code(provider, code, redirect_uri):
    """`code` -> the provider's token response, as a dict."""
    config = provider_config(provider)
    client_id, client_secret = _credentials(config)

    body = urllib.parse.urlencode({
        'code': code,
        'client_id': client_id,
        'client_secret': client_secret,
        'redirect_uri': redirect_uri,
        'grant_type': 'authorization_code',
    }).encode()

    request = urllib.request.Request(
        config['token_url'],
        data=body,
        headers={
            'Content-Type': 'application/x-www-form-urlencoded',
            'Accept': 'application/json',
        },
        method='POST',
    )

    try:
        with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response:
            return json.loads(response.read().decode())
    except urllib.error.HTTPError as error:
        # The provider's body names the fault (bad redirect uri, expired code)
        # and belongs in the log, never on the page.
        logger.warning(
            'OAuth token exchange failed (%s): %s %s',
            provider, error.code, error.read()[:500],
        )
        raise OAuthError('That sign-in could not be completed. Try again.')
    except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error:
        logger.warning('OAuth token endpoint unreachable (%s): %s', provider, error)
        raise OAuthError('Could not reach the sign-in provider. Try again.')


def identity_from(provider, token_response, nonce):
    """
    Pull `{email, first_name, last_name}` out of the token response.

    The nonce is checked here rather than by the caller, because the claim it
    protects is read here: it is what ties this token to the redirect this
    session started, and without it a token minted for another session of the
    same client is accepted.
    """
    id_token = token_response.get('id_token')
    if not id_token:
        raise OAuthError('That sign-in could not be completed. Try again.')

    try:
        claims = jwt.decode(
            id_token,
            options={'verify_signature': False, 'verify_aud': False, 'verify_exp': True},
            algorithms=['RS256'],
        )
    except jwt.PyJWTError as error:
        logger.warning('OAuth id_token unreadable (%s): %s', provider, error)
        raise OAuthError('That sign-in could not be completed. Try again.')

    if nonce and claims.get('nonce') and claims['nonce'] != nonce:
        logger.warning('OAuth nonce mismatch (%s)', provider)
        raise OAuthError('That sign-in could not be completed. Start again.')

    email = (claims.get('email') or claims.get('preferred_username') or '').strip().lower()
    if not email or '@' not in email:
        raise OAuthError(f"{PROVIDERS[provider]['label']} did not share an email address.")

    # Google says so explicitly. Microsoft does not send the claim at all, and
    # an address it returns for a personal or tenant account has been proved to
    # it — so "absent" is allowed and "present and false" is not.
    if claims.get('email_verified') is False:
        raise OAuthError('That email address is not verified with the provider.')

    given = (claims.get('given_name') or '').strip()
    family = (claims.get('family_name') or '').strip()

    if not given and claims.get('name'):
        parts = claims['name'].strip().split(' ', 1)
        given = parts[0]
        family = parts[1] if len(parts) > 1 else ''

    return {
        'email': email,
        'first_name': given[:150],
        'last_name': family[:150],
    }
