"""Personal API token (PAT) authentication shim.

A request may send `Authorization: Bearer shopdb_pat_...`. This is recognized
BEFORE any JWT decode: a before_request hook validates the PAT (hash lookup,
active, not expired, active owner) and, on success, mints a short internal
request-scoped JWT for the token's user and swaps it into the request's
Authorization header.

Why mint a JWT instead of only stashing the user on g: every write route in
this app stacks a mandatory @jwt_required() ABOVE @require_permission /
@require_role. That mandatory decorator decodes the Authorization header
itself, so the ONLY way a PAT reaches the whole existing auth+authz stack
(jwt_required, require_permission, require_role, import_mode, current_user,
get_jwt_identity) unchanged is to present a genuine JWT downstream. The minted
token lives only in this request's environ and is never returned to the client.

Result: a PAT authenticates any route a login JWT would, acting as its owner,
with zero changes to the authz decorators or import-mode helpers.
"""

from datetime import datetime, timezone

from flask import g, request
from flask_jwt_extended import create_access_token

from shopdb.extensions import db
from shopdb.core.models.apitoken import ApiToken, TOKEN_SECRET_PREFIX
from shopdb.utils.responses import error_response, ErrorCodes


# Only rewrite lastusedat when it is older than this, to avoid a DB write on
# every single request a busy integration makes.
_LASTUSED_THROTTLE_SECONDS = 60


def _utcnow():
    return datetime.now(timezone.utc).replace(tzinfo=None)


def _extract_pat_secret():
    """Return the PAT secret from the Authorization header, or None."""
    header = request.headers.get('Authorization', '')
    parts = header.split()
    if len(parts) == 2 and parts[0] == 'Bearer' \
            and parts[1].startswith(TOKEN_SECRET_PREFIX):
        return parts[1]
    return None


def resolve_api_token(secret):
    """Validate a PAT secret. Return (token, user) or None.

    Shared validator: hash lookup, active token, unexpired, active owner. The
    before_request shim and the collector API (which does not decode JWT) both
    call this so the checks live in one place.
    """
    from shopdb.core.models import User

    token = ApiToken.query.filter_by(
        tokenhash=ApiToken.hash_secret(secret), isactive=True).first()
    if token is None or token.is_expired:
        return None
    user = db.session.get(User, token.userid)
    if user is None or not user.isactive:
        return None
    return token, user


def touch_apitoken_lastused(token):
    """Throttled lastusedat write. Independent commit; nothing else is pending
    this early in the request, so it cannot clobber route work."""
    now = _utcnow()
    if token.lastusedat is None \
            or (now - token.lastusedat).total_seconds() > _LASTUSED_THROTTLE_SECONDS:
        token.lastusedat = now
        db.session.commit()


def authorized_service_token(scope):
    """Return the ApiToken authorizing this request for `scope`, or None.

    Same checks as service_token_authorized (managed token scoped for `scope`,
    active owner holding the permission), but hands back the token itself so a
    caller can read its resource binding (token.resourcescopelist) without
    reaching into core token internals. Touches lastusedat on success.
    """
    from shopdb.core.models import User

    api_key = request.headers.get('X-API-Key')
    token = None
    if api_key and api_key.startswith(TOKEN_SECRET_PREFIX):
        resolved = resolve_api_token(api_key)
        token = resolved[0] if resolved else None
    else:
        tokenid = getattr(g, 'apitokenid', None)
        if tokenid is not None:
            token = db.session.get(ApiToken, tokenid)
    if token is None:
        return None
    scopelist = token.scopelist
    if not scopelist or scope not in scopelist:
        return None
    user = db.session.get(User, token.userid)
    if user is None or not user.isactive or not user.haspermission(scope):
        return None
    touch_apitoken_lastused(token)
    return token


def service_token_authorized(scope):
    """True when the current request carries a managed token scoped for `scope`
    whose owner is active and holds that permission. Accepts X-API-Key or a
    Bearer PAT (the before_request shim resolves Bearer into g.apitokenid).
    Touches lastusedat on success.

    The single contract-surface entry point for unattended SERVICE tokens
    (collector.ingest, geenforce.fetch, ...), so plugins authorize a service
    token without reaching into core token internals. Returns False on any
    miss; the caller returns its own 401.
    """
    return authorized_service_token(scope) is not None


def install_apitoken_auth(app):
    """Register the before_request PAT shim on the app."""

    @app.before_request
    def _apitoken_before_request():
        secret = _extract_pat_secret()
        if secret is None:
            return
        resolved = resolve_api_token(secret)
        if resolved is None:
            # The caller clearly meant to use a PAT (shopdb_pat_ prefix) but it
            # is unknown, revoked, or expired. Reject with a clear 401 instead
            # of letting the JWT decoder emit a confusing 422 on the non-JWT.
            return error_response(
                ErrorCodes.UNAUTHORIZED,
                'Invalid, revoked, or expired API token',
                http_code=401)
        token, user = resolved

        # Read claim inputs before the (possible) commit expires the instance.
        claims = {
            'username': user.username,
            'roles': [role.rolename for role in user.roles],
        }
        # A scoped token carries a patscopes claim; authz reads it to grant ONLY
        # the listed permissions and to deny role gates + import mode. Unscoped
        # tokens carry no such claim and mint exactly as a login JWT would.
        scopelist = token.scopelist
        if scopelist is not None:
            claims['patscopes'] = scopelist
        # Expose the token/user for audit and introspection if a handler wants it.
        g.apitokenid = token.tokenid
        g.apitokenuser = user

        touch_apitoken_lastused(token)

        # Mint a request-scoped JWT for the owner and swap it into the header
        # so the whole downstream auth stack authenticates as that user.
        access_token = create_access_token(
            identity=str(user.userid), additional_claims=claims)
        request.environ['HTTP_AUTHORIZATION'] = f'Bearer {access_token}'
