"""
ScopeBlind — API abuse protection using cryptographic device proofs (VOPRF).

Replaces CAPTCHAs. No PII, no friction, ~20ms at the edge.
Works with browsers, AI agents, and CLIs.

Quick start:
    pip install scopeblind

1. Add the client script to your HTML <head>:
    <script async src="https://api.scopeblind.com/sb/{slug}.js"></script>

2. Verify the proof server-side:
    from scopeblind import verify_token
    claims = verify_token(token)
    device_id = claims["sub"]  # unique, non-PII device hash

3. That's it. Bots get blocked. Real users pass through.

Get your slug: POST https://api.scopeblind.com/provision
Full docs: https://www.scopeblind.com/llms.txt
"""

__version__ = "1.1.0"
__all__ = [
    "verify_token",
    "provision",
    "ScopeBlindError",
    "require_scopeblind",
    "require_scopeblind_flask",
    "dbsc_routes_flask",
    "dbsc_routes_fastapi",
    "verify_dpop",
    "dpop_middleware_flask",
    "dpop_middleware_fastapi",
]

import jwt
from jwt import PyJWKClient
import requests
import hashlib
import json
import uuid
import time
import base64
import math
from functools import wraps
from typing import Optional, Dict, Any

# ============================================================
# Constants
# ============================================================

DEFAULT_JWKS_URL = "https://api.scopeblind.com/.well-known/jwks.json"
DEFAULT_COOKIE_NAME = "sb_token"
DEFAULT_HEADER_NAME = "x-scopeblind-token"

# JWKS client (cached automatically by PyJWKClient)
_jwks_clients: Dict[str, PyJWKClient] = {}


def _get_jwks_client(url: str = DEFAULT_JWKS_URL) -> PyJWKClient:
    if url not in _jwks_clients:
        _jwks_clients[url] = PyJWKClient(url)
    return _jwks_clients[url]


# ============================================================
# Exceptions
# ============================================================


class ScopeBlindError(Exception):
    """Raised when ScopeBlind verification fails."""
    pass


# ============================================================
# Core Verification
# ============================================================


def verify_token(
    token: str,
    *,
    jwks_url: str = DEFAULT_JWKS_URL,
) -> Dict[str, Any]:
    """
    Verify a ScopeBlind JWT token and return the decoded claims.

    Args:
        token: The JWT string from the sb_token cookie or x-scopeblind-token header.
        jwks_url: Custom JWKS URL (defaults to ScopeBlind production).

    Returns:
        Dict with claims including 'sub' (unique device hash), 'slug', 'iat', 'exp'.

    Raises:
        ScopeBlindError: If the token is invalid, expired, or verification fails.

    Example::

        from scopeblind import verify_token

        token = request.cookies.get("sb_token")
        try:
            claims = verify_token(token)
            device_id = claims["sub"]  # unique, non-PII device hash
        except ScopeBlindError:
            # Handle unverified device
            pass
    """
    try:
        client = _get_jwks_client(jwks_url)
        signing_key = client.get_signing_key_from_jwt(token)
        claims = jwt.decode(
            token,
            signing_key.key,
            algorithms=["EdDSA"],
            issuer="scopeblind",
        )
        return claims
    except Exception as e:
        raise ScopeBlindError(f"Token verification failed: {e}") from e


# ============================================================
# FastAPI Dependency
# ============================================================


def require_scopeblind(
    on_fail: str = "block",
    cookie_name: str = DEFAULT_COOKIE_NAME,
    header_name: str = DEFAULT_HEADER_NAME,
    jwks_url: str = DEFAULT_JWKS_URL,
):
    """
    FastAPI dependency that verifies ScopeBlind device proofs.

    Args:
        on_fail: What to do when verification fails.
            - 'block': Raise 403 (default, recommended for signup/trial)
            - 'flag': Continue but set verified=False
            - 'allow': Skip verification entirely
        cookie_name: Cookie name containing the JWT (default: 'sb_token').
        header_name: Header name for JWT fallback (default: 'x-scopeblind-token').
        jwks_url: Custom JWKS endpoint URL.

    Returns:
        A FastAPI dependency function.

    Example::

        from fastapi import FastAPI, Depends
        from scopeblind import require_scopeblind

        app = FastAPI()

        @app.post("/api/signup")
        async def signup(sb=Depends(require_scopeblind())):
            if sb["verified"]:
                device_id = sb["device_id"]
                # This request is from a verified, unique device
    """

    async def _dependency(request):
        from fastapi import HTTPException

        # Extract token from cookie or header
        token = request.cookies.get(cookie_name) or request.headers.get(header_name)

        # Check for DBSC hardware-attested device ID
        dbsc_device_id = request.cookies.get("sb_dbsc_id")

        # Check for DPoP proof-of-possession (RFC 9449)
        dpop_result = _verify_dpop_header(request.headers.get("dpop"), request.method, str(request.url))
        dpop_device_id = dpop_result["device_id"] if dpop_result else None
        dpop_verified = dpop_result is not None

        if not token:
            if on_fail == "block":
                raise HTTPException(
                    status_code=403,
                    detail={
                        "error": "device_not_verified",
                        "message": "ScopeBlind device proof required. See https://www.scopeblind.com/llms.txt",
                    },
                )
            return {
                "verified": False, "payload": None, "device_id": None,
                "dbsc_device_id": dbsc_device_id, "hardware_attested": bool(dbsc_device_id),
                "dpop_device_id": dpop_device_id, "dpop_verified": dpop_verified,
            }

        try:
            claims = verify_token(token, jwks_url=jwks_url)
            return {
                "verified": True,
                "payload": claims,
                "device_id": claims.get("sub"),
                "dbsc_device_id": dbsc_device_id,
                "hardware_attested": bool(dbsc_device_id),
                "dpop_device_id": dpop_device_id,
                "dpop_verified": dpop_verified,
            }
        except ScopeBlindError:
            if on_fail == "block":
                raise HTTPException(
                    status_code=403,
                    detail={
                        "error": "invalid_proof",
                        "message": "ScopeBlind device proof invalid. See https://www.scopeblind.com/llms.txt",
                    },
                )
            return {
                "verified": False, "payload": None, "device_id": None,
                "dbsc_device_id": dbsc_device_id, "hardware_attested": bool(dbsc_device_id),
                "dpop_device_id": dpop_device_id, "dpop_verified": dpop_verified,
            }

    return _dependency


# ============================================================
# Flask Decorator
# ============================================================


def require_scopeblind_flask(
    on_fail: str = "block",
    cookie_name: str = DEFAULT_COOKIE_NAME,
    header_name: str = DEFAULT_HEADER_NAME,
    jwks_url: str = DEFAULT_JWKS_URL,
):
    """
    Flask decorator that verifies ScopeBlind device proofs.

    Args:
        on_fail: 'block' (403), 'flag' (continue with verified=False), or 'allow'.
        cookie_name: Cookie name containing the JWT (default: 'sb_token').
        header_name: Header name for JWT fallback (default: 'x-scopeblind-token').
        jwks_url: Custom JWKS endpoint URL.

    Returns:
        A decorator function.

    Example::

        from flask import Flask, g
        from scopeblind import require_scopeblind_flask

        app = Flask(__name__)

        @app.route("/api/signup", methods=["POST"])
        @require_scopeblind_flask()
        def signup():
            if g.scopeblind["verified"]:
                device_id = g.scopeblind["device_id"]
                # This request is from a verified, unique device
    """

    def decorator(f):
        @wraps(f)
        def decorated_function(*args, **kwargs):
            from flask import request, jsonify, g

            token = request.cookies.get(cookie_name) or request.headers.get(
                header_name
            )

            # Check for DBSC hardware-attested device ID
            dbsc_device_id = request.cookies.get("sb_dbsc_id")

            # Check for DPoP proof-of-possession (RFC 9449)
            dpop_result = _verify_dpop_header(
                request.headers.get("DPoP"), request.method, request.url
            )
            dpop_device_id = dpop_result["device_id"] if dpop_result else None
            dpop_verified = dpop_result is not None

            if not token:
                if on_fail == "block":
                    return (
                        jsonify(
                            {
                                "error": "device_not_verified",
                                "message": "ScopeBlind device proof required. See https://www.scopeblind.com/llms.txt",
                            }
                        ),
                        403,
                    )
                g.scopeblind = {
                    "verified": False, "payload": None, "device_id": None,
                    "dbsc_device_id": dbsc_device_id, "hardware_attested": bool(dbsc_device_id),
                    "dpop_device_id": dpop_device_id, "dpop_verified": dpop_verified,
                }
                return f(*args, **kwargs)

            try:
                claims = verify_token(token, jwks_url=jwks_url)
                g.scopeblind = {
                    "verified": True,
                    "payload": claims,
                    "device_id": claims.get("sub"),
                    "dbsc_device_id": dbsc_device_id,
                    "hardware_attested": bool(dbsc_device_id),
                    "dpop_device_id": dpop_device_id,
                    "dpop_verified": dpop_verified,
                }
            except ScopeBlindError:
                if on_fail == "block":
                    return (
                        jsonify(
                            {
                                "error": "invalid_proof",
                                "message": "ScopeBlind device proof invalid. See https://www.scopeblind.com/llms.txt",
                            }
                        ),
                        403,
                    )
                g.scopeblind = {
                    "verified": False, "payload": None, "device_id": None,
                    "dbsc_device_id": dbsc_device_id, "hardware_attested": bool(dbsc_device_id),
                    "dpop_device_id": dpop_device_id, "dpop_verified": dpop_verified,
                }

            return f(*args, **kwargs)

        return decorated_function

    return decorator


# ============================================================
# Provision Helper
# ============================================================


def provision(
    target_url: str,
    email: Optional[str] = None,
    api_url: str = "https://api.scopeblind.com",
) -> Dict[str, str]:
    """
    Provision a new ScopeBlind tenant programmatically.

    Args:
        target_url: The API endpoint URL you want to protect.
        email: Optional contact email for dashboard and abuse reports.
        api_url: Custom API URL (defaults to production).

    Returns:
        Dict with keys: slug, mgmt_token, verifier_url, script_tag, dashboard_url.

    Raises:
        ScopeBlindError: If provisioning fails.

    Example::

        from scopeblind import provision

        tenant = provision(
            target_url="https://myapp.com/api/signup",
            email="dev@myapp.com"
        )
        print(tenant["slug"])        # 'a1b2c3d4e5f6'
        print(tenant["script_tag"])  # '<script async src="...">'
    """
    payload: Dict[str, Any] = {"target_url": target_url}
    if email:
        payload["email"] = email

    try:
        resp = requests.post(
            f"{api_url}/provision",
            json=payload,
            headers={"Content-Type": "application/json"},
            timeout=30,
        )
        resp.raise_for_status()
        data = resp.json()

        return {
            "slug": data["slug"],
            "mgmt_token": data["mgmt_token"],
            "verifier_url": data["verifier_url"],
            "script_tag": f'<script async src="{api_url}/sb/{data["slug"]}.js"></script>',
            "dashboard_url": f"https://scopeblind.com/t/{data['slug']}",
        }
    except requests.RequestException as e:
        raise ScopeBlindError(f"Provisioning failed: {e}") from e


# ============================================================
# DBSC (Device Bound Session Credentials) — Chrome 135+
# TPM-backed hardware device attestation
# ============================================================

# In-memory challenge store with TTL
_dbsc_challenges: Dict[str, float] = {}


def _hash_jwk(jwk: Dict[str, str]) -> str:
    """Hash a DBSC JWK public key deterministically. Returns 16 hex chars."""
    canonical = json.dumps(
        {"crv": jwk["crv"], "kty": jwk["kty"], "x": jwk["x"], "y": jwk["y"]},
        separators=(",", ":"),
        sort_keys=False,
    )
    return hashlib.sha256(canonical.encode()).hexdigest()[:16]


def _cleanup_challenges():
    """Remove expired challenges (>2 min old)."""
    now = time.time()
    expired = [k for k, v in _dbsc_challenges.items() if now - v > 120]
    for k in expired:
        del _dbsc_challenges[k]


def dbsc_routes_flask(
    app,
    slug: str,
    api_url: str = "https://api.scopeblind.com",
    path_prefix: str = "/sb-dbsc",
):
    """
    Add DBSC (Device Bound Session Credentials) routes to a Flask app.

    These endpoints MUST be on the same origin as your pages
    (DBSC has a same-site restriction).

    Chrome 135+ on Windows will automatically create a TPM-bound key pair
    when it receives the Sec-Session-Registration header. Other browsers
    silently ignore it (progressive enhancement).

    Args:
        app: Flask app instance.
        slug: Your ScopeBlind tenant slug.
        api_url: ScopeBlind API URL.
        path_prefix: URL prefix for DBSC endpoints (default: /sb-dbsc).

    Example::

        from flask import Flask
        from scopeblind import dbsc_routes_flask

        app = Flask(__name__)
        dbsc_routes_flask(app, slug="a1b2c3d4e5f6")
    """
    from flask import jsonify, request, make_response
    import base64

    @app.route(f"{path_prefix}/start", methods=["GET"])
    def _dbsc_start():
        _cleanup_challenges()
        challenge = str(uuid.uuid4())
        _dbsc_challenges[challenge] = time.time()
        resp = make_response(jsonify({"dbsc": "registration_started"}))
        resp.headers["Sec-Session-Registration"] = (
            f'(ES256);path="{path_prefix}/refresh";challenge="{challenge}"'
        )
        return resp

    @app.route(f"{path_prefix}/refresh", methods=["POST"])
    def _dbsc_refresh():
        proof = request.headers.get("Sec-Session-Response") or (
            request.headers.get("Authorization", "").removeprefix("Bearer ").strip()
            or None
        )

        if not proof:
            _cleanup_challenges()
            challenge = str(uuid.uuid4())
            _dbsc_challenges[challenge] = time.time()
            resp = make_response(jsonify({"error": "proof_required"}), 401)
            resp.headers["Sec-Session-Challenge"] = (
                f'(ES256);challenge="{challenge}"'
            )
            return resp

        try:
            # Decode JWT header to extract JWK (TPM public key)
            header_b64 = proof.split(".")[0]
            # Add padding
            padding = 4 - len(header_b64) % 4
            if padding != 4:
                header_b64 += "=" * padding
            header_json = base64.urlsafe_b64decode(header_b64).decode("utf-8")
            header = json.loads(header_json)

            jwk = header.get("jwk", {})
            if jwk.get("kty") != "EC" or jwk.get("crv") != "P-256":
                return jsonify({"error": "invalid_dbsc_key"}), 400

            device_id = _hash_jwk(jwk)

            # Report to ScopeBlind API (non-blocking best-effort)
            try:
                requests.post(
                    f"{api_url}/sb/{slug}/dbsc",
                    json={"keyHash": device_id, "attestation": "tpm"},
                    timeout=5,
                )
            except Exception:
                pass

            resp = make_response(jsonify({"dbsc": "bound", "deviceId": device_id}))
            resp.set_cookie(
                "sb_dbsc_id", device_id,
                secure=True, samesite="Strict", max_age=30 * 86400,
            )
            resp.set_cookie(
                "sb_dbsc_session", str(uuid.uuid4()),
                httponly=True, secure=True, samesite="Strict", max_age=600,
            )
            return resp
        except Exception:
            return jsonify({"error": "invalid_dbsc_proof"}), 400


def dbsc_routes_fastapi(
    app,
    slug: str,
    api_url: str = "https://api.scopeblind.com",
    path_prefix: str = "/sb-dbsc",
):
    """
    Add DBSC (Device Bound Session Credentials) routes to a FastAPI app.

    Same-origin restriction applies — these endpoints must be on your domain.

    Args:
        app: FastAPI app instance.
        slug: Your ScopeBlind tenant slug.
        api_url: ScopeBlind API URL.
        path_prefix: URL prefix for DBSC endpoints (default: /sb-dbsc).

    Example::

        from fastapi import FastAPI
        from scopeblind import dbsc_routes_fastapi

        app = FastAPI()
        dbsc_routes_fastapi(app, slug="a1b2c3d4e5f6")
    """
    from fastapi import Request
    from fastapi.responses import JSONResponse
    import base64
    import httpx

    @app.get(f"{path_prefix}/start")
    async def _dbsc_start():
        _cleanup_challenges()
        challenge = str(uuid.uuid4())
        _dbsc_challenges[challenge] = time.time()
        return JSONResponse(
            content={"dbsc": "registration_started"},
            headers={
                "Sec-Session-Registration": (
                    f'(ES256);path="{path_prefix}/refresh";challenge="{challenge}"'
                )
            },
        )

    @app.post(f"{path_prefix}/refresh")
    async def _dbsc_refresh(request: Request):
        proof = request.headers.get("sec-session-response") or (
            request.headers.get("authorization", "").removeprefix("Bearer ").strip()
            or None
        )

        if not proof:
            _cleanup_challenges()
            challenge = str(uuid.uuid4())
            _dbsc_challenges[challenge] = time.time()
            return JSONResponse(
                content={"error": "proof_required"},
                status_code=401,
                headers={
                    "Sec-Session-Challenge": f'(ES256);challenge="{challenge}"'
                },
            )

        try:
            header_b64 = proof.split(".")[0]
            padding = 4 - len(header_b64) % 4
            if padding != 4:
                header_b64 += "=" * padding
            header_json = base64.urlsafe_b64decode(header_b64).decode("utf-8")
            header = json.loads(header_json)

            jwk = header.get("jwk", {})
            if jwk.get("kty") != "EC" or jwk.get("crv") != "P-256":
                return JSONResponse(
                    content={"error": "invalid_dbsc_key"}, status_code=400
                )

            device_id = _hash_jwk(jwk)

            # Report to ScopeBlind API (non-blocking)
            try:
                async with httpx.AsyncClient() as client:
                    await client.post(
                        f"{api_url}/sb/{slug}/dbsc",
                        json={"keyHash": device_id, "attestation": "tpm"},
                        timeout=5,
                    )
            except Exception:
                pass

            response = JSONResponse(
                content={"dbsc": "bound", "deviceId": device_id}
            )
            response.set_cookie(
                "sb_dbsc_id", device_id,
                secure=True, samesite="strict", max_age=30 * 86400,
            )
            response.set_cookie(
                "sb_dbsc_session", str(uuid.uuid4()),
                httponly=True, secure=True, samesite="strict", max_age=600,
            )
            return response
        except Exception:
            return JSONResponse(
                content={"error": "invalid_dbsc_proof"}, status_code=400
            )


# ============================================================
# DPoP (Demonstration of Proof-of-Possession) — RFC 9449
# Cryptographic device identity for AI agents, CLIs, and SDKs
# ============================================================


def _verify_dpop_header(
    proof: Optional[str],
    expected_method: Optional[str] = None,
    expected_url: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
    """
    Verify a DPoP proof JWT (self-signed ES256).
    Returns dict with device_id if valid, or None if invalid.
    """
    if not proof or not isinstance(proof, str):
        return None

    try:
        parts = proof.split(".")
        if len(parts) != 3:
            return None

        # Decode header
        header_b64 = parts[0]
        padding = 4 - len(header_b64) % 4
        if padding != 4:
            header_b64 += "=" * padding
        header_json = base64.urlsafe_b64decode(header_b64).decode("utf-8")
        header = json.loads(header_json)

        # Validate header
        if header.get("typ") != "dpop+jwt" or header.get("alg") != "ES256":
            return None
        jwk = header.get("jwk", {})
        if jwk.get("kty") != "EC" or jwk.get("crv") != "P-256":
            return None

        # Decode payload
        payload_b64 = parts[1]
        padding = 4 - len(payload_b64) % 4
        if padding != 4:
            payload_b64 += "=" * padding
        payload_json = base64.urlsafe_b64decode(payload_b64).decode("utf-8")
        payload = json.loads(payload_json)

        # Validate required claims
        if not payload.get("jti") or not payload.get("iat"):
            return None

        # Check timestamp (allow 5 minute window)
        now = int(time.time())
        if abs(now - payload["iat"]) > 300:
            return None

        # Check method binding (optional)
        if expected_method and payload.get("htm"):
            if payload["htm"].upper() != expected_method.upper():
                return None

        # Hash the JWK to get device ID
        device_id = _hash_jwk(jwk)

        return {"device_id": device_id, "jwk": jwk}
    except Exception:
        return None


def verify_dpop(
    proof: str,
    method: Optional[str] = None,
    url: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
    """
    Verify a DPoP proof JWT and return the agent's device ID.

    Args:
        proof: The DPoP JWT string from the DPoP header.
        method: Expected HTTP method (e.g., 'POST').
        url: Expected request URL.

    Returns:
        Dict with 'device_id' (16 hex chars) if valid, or None if invalid.

    Example::

        from scopeblind import verify_dpop

        dpop_header = request.headers.get("DPoP")
        result = verify_dpop(dpop_header, method="POST", url=request.url)
        if result:
            agent_id = result["device_id"]  # unique per agent key pair
    """
    return _verify_dpop_header(proof, method, url)


def dpop_middleware_flask(
    slug: str,
    api_url: str = "https://api.scopeblind.com",
):
    """
    Flask middleware that auto-reports DPoP agent keys to ScopeBlind.

    Example::

        from scopeblind import dpop_middleware_flask

        app.before_request(dpop_middleware_flask(slug="a1b2c3d4e5f6"))
    """
    registered_keys: set = set()

    def _middleware():
        from flask import request

        dpop = request.headers.get("DPoP")
        if not dpop:
            return None

        result = _verify_dpop_header(dpop, request.method, request.url)
        if not result:
            return None

        if result["device_id"] not in registered_keys:
            registered_keys.add(result["device_id"])
            try:
                requests.post(
                    f"{api_url}/sb/{slug}/dpop",
                    json={"keyHash": result["device_id"], "attestation": "dpop"},
                    timeout=5,
                )
            except Exception:
                pass

        return None

    return _middleware


def dpop_middleware_fastapi(
    app,
    slug: str,
    api_url: str = "https://api.scopeblind.com",
):
    """
    FastAPI middleware that auto-reports DPoP agent keys to ScopeBlind.

    Example::

        from scopeblind import dpop_middleware_fastapi

        dpop_middleware_fastapi(app, slug="a1b2c3d4e5f6")
    """
    import httpx

    registered_keys: set = set()

    @app.middleware("http")
    async def _dpop_middleware(request, call_next):
        dpop = request.headers.get("dpop")
        if dpop:
            result = _verify_dpop_header(dpop, request.method, str(request.url))
            if result and result["device_id"] not in registered_keys:
                registered_keys.add(result["device_id"])
                try:
                    async with httpx.AsyncClient() as client:
                        await client.post(
                            f"{api_url}/sb/{slug}/dpop",
                            json={"keyHash": result["device_id"], "attestation": "dpop"},
                            timeout=5,
                        )
                except Exception:
                    pass
        return await call_next(request)
