"""Signed reward tokens for the rewarded-advertisement flow.

Security model
--------------
A reward token proves that the server legitimately issued a rewarded-ad session
to *this* user for *this* advertisement. It is **not** proof that the ad was
watched — the countdown is a client-side UX gate and the frontend timer is never
trusted. The token exists purely to stop **forged** and **replayed** reward
claims, without moving completion logic to the backend or changing the reward
architecture.

Token format
------------
The payload is signed with :class:`itsdangerous.URLSafeTimedSerializer` keyed by
the application ``SECRET_KEY`` (the same secret Flask uses for sessions) and a
dedicated salt, so it cannot be tampered with and is namespaced away from other
signed values. No secret is ever sent to the client; the token is opaque::

    {
        "v": 1,                              # schema version
        "purpose": "rewarded_chat_unlock",  # reward purpose (guards against reuse)
        "uid": <user id>,                    # bound to the authenticated claimer
        "aid": <advertisement public_id|None>,  # bound to the served ad (None = placeholder)
        "jti": <random nonce>,               # unique per token → single-use key
        "iat": <issued unix timestamp>,
        "exp": <expiry unix timestamp>,      # iat + TTL (default 5 minutes)
    }

Expiry is enforced twice (defence in depth): ``URLSafeTimedSerializer`` stamps
its own timestamp so ``loads(max_age=...)`` rejects old tokens, and the explicit
``exp`` field is checked as well.

Replay protection
-----------------
A successfully verified token's ``jti`` nonce is consumed in a short-lived,
thread-safe in-memory store (:class:`_ReplayGuard`); a second claim with the same
token is rejected. Entries auto-expire at the token's own ``exp`` so the store
stays tiny and self-cleaning — no database table, no new dependency. A shared
cache (Redis/Memcached) can replace the store later without touching callers.
"""

from __future__ import annotations

import secrets
import threading
import time

from flask import current_app
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer

# Reward purpose embedded in every token; verified on claim so a token minted for
# one purpose can never be replayed against a different reward flow.
PURPOSE = "rewarded_chat_unlock"
SALT = "rewarded-ad-unlock"
DEFAULT_TTL_SECONDS = 300  # 5 minutes


class RewardTokenError(Exception):
    """Raised when a reward token is missing, invalid, expired or replayed.

    ``reason`` is a coarse machine code (``missing`` / ``expired`` / ``tampered`` /
    ``wrong_user`` / ``replay``) used for audit logging and analytics breakdowns —
    it never carries cryptographic detail.
    """

    def __init__(self, message: str, reason: str = "invalid") -> None:
        super().__init__(message)
        self.reason = reason


def _serializer() -> URLSafeTimedSerializer:
    return URLSafeTimedSerializer(current_app.config["SECRET_KEY"], salt=SALT)


def _ttl() -> int:
    return int(current_app.config.get("REWARDED_AD_TOKEN_TTL_SECONDS", DEFAULT_TTL_SECONDS))


class _ReplayGuard:
    """Thread-safe, self-expiring set of consumed token nonces (per process).

    Lightweight, dependency-free single-use enforcement. ``consume`` returns
    ``False`` when a nonce was already spent. Expired entries are pruned on every
    access so memory stays bounded.
    """

    def __init__(self) -> None:
        self._used: dict[str, int] = {}  # jti -> exp (unix ts)
        self._lock = threading.Lock()

    def consume(self, jti: str, exp: int) -> bool:
        now = int(time.time())
        with self._lock:
            self._prune(now)
            if jti in self._used:
                return False
            self._used[jti] = int(exp)
            return True

    def _prune(self, now: int) -> None:
        for key in [k for k, e in self._used.items() if e <= now]:
            del self._used[key]

    def reset(self) -> None:
        with self._lock:
            self._used.clear()


_REPLAY = _ReplayGuard()


def reset_replay_guard() -> None:
    """Clear the consumed-token store (used by tests)."""
    _REPLAY.reset()


def issue_reward_token(user_id: int, ad_public_id: str | None, *, ttl: int | None = None) -> str:
    """Mint a signed, short-lived reward token bound to ``user_id`` and the ad.

    ``ad_public_id`` is the served advertisement's public id, or ``None`` for the
    built-in placeholder. ``ttl`` overrides the configured lifetime (seconds).
    """
    now = int(time.time())
    lifetime = _ttl() if ttl is None else int(ttl)
    payload = {
        "v": 1,
        "purpose": PURPOSE,
        "uid": int(user_id),
        "aid": ad_public_id,
        "jti": secrets.token_urlsafe(12),
        "iat": now,
        "exp": now + lifetime,
    }
    return _serializer().dumps(payload)


def verify_reward_token(token, *, user_id: int) -> dict:
    """Verify a reward token for a claim, returning its payload.

    Raises :class:`RewardTokenError` on any failure: missing/malformed token,
    bad signature, expiry (via both ``max_age`` and the explicit ``exp``), wrong
    purpose, wrong user, or replay. On success the token is consumed (single-use).
    """
    if not token or not isinstance(token, str):
        raise RewardTokenError("Missing reward token. Please watch the advertisement again.", "missing")
    try:
        payload = _serializer().loads(token, max_age=_ttl())
    except SignatureExpired:
        raise RewardTokenError("This reward has expired. Please watch the advertisement again.", "expired")
    except BadSignature:
        raise RewardTokenError("Invalid reward token.", "tampered")
    except Exception:  # any other deserialisation failure → treat as malformed
        raise RewardTokenError("Malformed reward token.", "tampered")

    if not isinstance(payload, dict):
        raise RewardTokenError("Malformed reward token.", "tampered")
    if payload.get("purpose") != PURPOSE:
        raise RewardTokenError("Invalid reward purpose.", "tampered")
    if payload.get("uid") != int(user_id):
        raise RewardTokenError("This reward was issued for a different account.", "wrong_user")
    exp = payload.get("exp")
    if not isinstance(exp, int) or exp < int(time.time()):
        raise RewardTokenError("This reward has expired. Please watch the advertisement again.", "expired")
    jti = payload.get("jti")
    if not jti or not isinstance(jti, str):
        raise RewardTokenError("Malformed reward token.", "tampered")

    # Single-use: consume the nonce. A replay (already-spent nonce) is rejected.
    if not _REPLAY.consume(jti, exp):
        raise RewardTokenError("This reward has already been claimed.", "replay")
    return payload
