"""Core verification of app access tokens (Authorization v2, gate 3.4).

Cloud signs app access tokens with the project's RS256 key. Core accepts one only when every
check passes; any failure rejects it (fail closed):

- header alg is RS256 and kid names one of this project's usable keys (active or rotated);
- the signature verifies with that key's public PEM;
- iss, aud and token_use match the Cloud app-token contract;
- project_id claim equals the project in the route;
- exp is valid (with clock skew), and iat is not in the future;
- sub (Cloud app user) and sid (session) are present. sid is required for the live check.

This module only proves the token is genuine and for this project. The live session check
(revocation, user status) is a separate call, see src/integrations/cloud_session_introspection.py.
"""

from __future__ import annotations

import time
from collections.abc import Mapping
from dataclasses import dataclass

from jose import jwt
from jose.exceptions import ExpiredSignatureError, JWTClaimsError, JWTError

APP_TOKEN_ISSUER = "p2hs-cloud"
APP_TOKEN_AUDIENCE = "p2hs-core-authz"
APP_TOKEN_USE = "access"
CLOCK_SKEW_SECONDS = 60


class AppTokenError(Exception):
    """Rejected token. `code` is a stable, non-sensitive reason."""

    def __init__(self, code: str) -> None:
        super().__init__(code)
        self.code = code


@dataclass(frozen=True)
class VerifiedAppActor:
    cloud_app_user_id: str
    session_id: str
    project_id: str


def verify_app_access_token(
    token: str,
    *,
    project_id: str,
    keys: Mapping[str, str],
    now: float | None = None,
) -> VerifiedAppActor:
    """Verify one app access token for `project_id`. `keys` maps kid to public PEM for this
    project's usable keys. Raises AppTokenError with a stable code on any failure."""
    try:
        header = jwt.get_unverified_header(token)
    except JWTError as exc:
        raise AppTokenError("malformed") from exc
    if header.get("alg") != "RS256":
        raise AppTokenError("bad_alg")
    kid = header.get("kid")
    pem = keys.get(kid) if isinstance(kid, str) else None
    if pem is None:
        raise AppTokenError("unknown_kid")

    try:
        claims = jwt.decode(
            token,
            pem,
            algorithms=["RS256"],
            audience=APP_TOKEN_AUDIENCE,
            issuer=APP_TOKEN_ISSUER,
            options={"leeway": CLOCK_SKEW_SECONDS},
        )
    except ExpiredSignatureError as exc:
        raise AppTokenError("expired") from exc
    except JWTClaimsError as exc:
        raise AppTokenError("bad_claims") from exc
    except JWTError as exc:
        raise AppTokenError("bad_signature") from exc

    if claims.get("token_use") != APP_TOKEN_USE:
        raise AppTokenError("bad_token_use")
    if claims.get("project_id") != project_id:
        raise AppTokenError("project_mismatch")
    issued = claims.get("iat")
    current = now if now is not None else time.time()
    if isinstance(issued, (int, float)) and issued > current + CLOCK_SKEW_SECONDS:
        raise AppTokenError("iat_in_future")

    sub = claims.get("sub")
    sid = claims.get("sid")
    if not isinstance(sub, str) or not sub or not isinstance(sid, str) or not sid:
        raise AppTokenError("missing_subject_or_session")
    return VerifiedAppActor(cloud_app_user_id=sub, session_id=sid, project_id=project_id)
