"""Unit tests for the iurban-auth FastAPI SDK.

Tokens are signed locally with an ephemeral RSA keypair and validated against a
mocked JWKS, so the suite never reaches a live Keycloak (that is the E2E dogfood).
"""

import json
import time

import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from fastapi import HTTPException
from fastapi.security import HTTPAuthorizationCredentials
from jwt.algorithms import RSAAlgorithm

from iurban_auth import dependencies
from iurban_auth.config import OIDCSettings
from iurban_auth.dependencies import current_user, require_roles
from iurban_auth.jwks import JWKSCache

ISSUER = "https://keycloak.tools.iurban-lab.es/realms/iurban"
AUDIENCE = "test-client"
KID = "test-key-1"


@pytest.fixture(scope="module")
def keypair() -> rsa.RSAPrivateKey:
    return rsa.generate_private_key(public_exponent=65537, key_size=2048)


@pytest.fixture(scope="module")
def jwks(keypair: rsa.RSAPrivateKey) -> dict:
    pub_jwk = json.loads(RSAAlgorithm.to_jwk(keypair.public_key()))
    pub_jwk.update({"kid": KID, "alg": "RS256", "use": "sig"})
    return {"keys": [pub_jwk]}


@pytest.fixture
def settings() -> OIDCSettings:
    return OIDCSettings(issuer=ISSUER, audience=AUDIENCE)


@pytest.fixture(autouse=True)
def _preloaded_cache(jwks: dict):
    """Inject a pre-populated JWKS cache so current_user never hits the network."""
    cache = JWKSCache(f"{ISSUER}/protocol/openid-connect/certs", ttl=3600)
    cache._jwks = jwks
    cache._fetched_at = time.monotonic()
    dependencies._jwks_cache = cache
    yield
    dependencies._jwks_cache = None


def _make_token(keypair: rsa.RSAPrivateKey, **overrides) -> str:
    now = int(time.time())
    claims = {
        "sub": "user-123",
        "email": "a.pastor@iurban.es",
        "iss": ISSUER,
        "aud": AUDIENCE,
        "iat": now,
        "exp": now + 300,
        "tenant_id": "pidm",
        "realm_access": {"roles": ["admin", "editor"]},
    }
    claims.update(overrides)
    return jwt.encode(claims, keypair, algorithm="RS256", headers={"kid": KID})


def _creds(token: str) -> HTTPAuthorizationCredentials:
    return HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)


async def test_valid_token_returns_user(keypair, settings):
    user = await current_user(_creds(_make_token(keypair)), settings)
    assert user.sub == "user-123"
    assert user.email == "a.pastor@iurban.es"
    assert user.tenant_id == "pidm"
    assert set(user.roles) == {"admin", "editor"}


async def test_require_roles_allows_when_present(keypair, settings):
    user = await current_user(_creds(_make_token(keypair)), settings)
    guard = require_roles("admin")
    assert await guard(user) is user


async def test_require_roles_forbids_when_missing(keypair, settings):
    user = await current_user(_creds(_make_token(keypair)), settings)
    guard = require_roles("superadmin")
    with pytest.raises(HTTPException) as exc:
        await guard(user)
    assert exc.value.status_code == 403


async def test_expired_token_rejected(keypair, settings):
    now = int(time.time())
    token = _make_token(keypair, iat=now - 600, exp=now - 300)
    with pytest.raises(HTTPException) as exc:
        await current_user(_creds(token), settings)
    assert exc.value.status_code == 401


async def test_wrong_audience_rejected(keypair, settings):
    token = _make_token(keypair, aud="someone-else")
    with pytest.raises(HTTPException) as exc:
        await current_user(_creds(token), settings)
    assert exc.value.status_code == 401


async def test_wrong_issuer_rejected(keypair, settings):
    token = _make_token(keypair, iss="https://evil.example.com/realms/iurban")
    with pytest.raises(HTTPException) as exc:
        await current_user(_creds(token), settings)
    assert exc.value.status_code == 401


async def test_unsigned_token_rejected(keypair, settings):
    # "alg": none style — a token without a valid RS256 signature must not pass.
    bad = jwt.encode({"sub": "x"}, "x" * 32, algorithm="HS256", headers={"kid": KID})
    with pytest.raises(HTTPException) as exc:
        await current_user(_creds(bad), settings)
    assert exc.value.status_code == 401


async def test_custom_tenant_claim(keypair):
    settings = OIDCSettings(issuer=ISSUER, audience=AUDIENCE, tenant_claim="org")
    token = _make_token(keypair, org="burgos")
    user = await current_user(_creds(token), settings)
    assert user.tenant_id == "burgos"


async def test_missing_roles_yield_empty_list(keypair, settings):
    token = _make_token(keypair, realm_access={})
    user = await current_user(_creds(token), settings)
    assert user.roles == []


async def test_jwks_refreshes_on_unknown_kid(jwks):
    cache = JWKSCache("https://unused", ttl=3600)
    calls = {"n": 0}

    async def fake_refresh():
        calls["n"] += 1
        cache._jwks = jwks
        cache._fetched_at = time.monotonic()

    cache._refresh = fake_refresh  # type: ignore[method-assign]
    key = await cache.get_key(KID)
    assert key is not None
    assert calls["n"] == 1  # had to fetch once (cache started empty)
