"""Tests for the HTTP API gateway (cyberbrain26.control_plane.api).

These tests lock down the security-critical behaviour of ``APIGateway`` and the
bundled ``SimpleAPIKeyAuth``:

* D1 - fail-closed authentication (no bypass when no provider is configured)
* D2 - raw bearer tokens never reach handler claims or any response body
* D4 - handler exception text is never echoed to the client
* D3 - rate limiting is keyed on the authenticated principal, not a per-request UUID
* D6 - rate limiting is never silently skipped when no principal and no
      ``client_addr`` are available (regression: fail-open throttling hole)
* D5 - route matching has no prefix-collision foot-gun; OpenAPI leaks nothing
"""
from __future__ import annotations

import time

import pytest

from cyberbrain26.control_plane.api import (
    UNIDENTIFIED_RATE_LIMIT_KEY,
    APIGateway,
    APIRequest,
    HTTPMethod,
    SimpleAPIKeyAuth,
)

VALID_KEYS = {"secret-token-1": "principal-1", "secret-token-2": "principal-2"}
BAD_SECRET = "SUPER_SECRET_DB_PASSWORD=p@s"


def _auth() -> SimpleAPIKeyAuth:
    return SimpleAPIKeyAuth(VALID_KEYS)


def _req(
    path: str = "/",
    method: HTTPMethod = HTTPMethod.GET,
    *,
    authorization: str | None = None,
    client_addr: str | None = None,
    request_id: str = "req-1",
) -> APIRequest:
    headers: dict[str, str] = {"authorization": authorization} if authorization is not None else {}
    return APIRequest(
        method=method,
        path=path,
        headers=headers,
        query_params={},
        body=None,
        path_params={},
        client_addr=client_addr,
        request_id=request_id,
    )


@pytest.fixture
def auth() -> SimpleAPIKeyAuth:
    return _auth()


@pytest.fixture
def gateway(auth: SimpleAPIKeyAuth) -> APIGateway:
    return APIGateway(auth_provider=auth)


async def _ok(request: APIRequest, user_id: str | None, claims: dict, path_params: dict) -> dict:
    return {"ok": True}


# --------------------------------------------------------------------------- #
# D1 - fail-closed authentication                                              #
# --------------------------------------------------------------------------- #


async def test_requires_auth_with_no_provider_is_denied() -> None:
    """Regression test for D1: a requires_auth route with NO auth provider must
    be denied rather than silently allowing unauthenticated access."""
    gw = APIGateway()  # no provider, allow_unauthenticated defaults False
    gw.add_route("/secure", HTTPMethod.GET, _ok, requires_auth=True)
    resp = await gw.handle(_req("/secure"))
    assert resp.status_code == 401


async def test_anonymous_route_denied_without_opt_in() -> None:
    """Anonymous routes require an explicit allow_unauthenticated opt-in."""
    gw = APIGateway()
    gw.add_route("/public", HTTPMethod.GET, _ok, requires_auth=False)
    resp = await gw.handle(_req("/public"))
    assert resp.status_code == 401


async def test_anonymous_route_works_when_opted_in() -> None:
    """A route explicitly opted into anonymous access is served."""
    gw = APIGateway(allow_unauthenticated=True)
    gw.add_route("/public", HTTPMethod.GET, _ok, requires_auth=False)
    resp = await gw.handle(_req("/public"))
    assert resp.status_code == 200
    assert resp.body == {"ok": True}


async def test_authenticated_route_with_provider_runs_handler(gateway: APIGateway) -> None:
    async def handler(request, user_id, claims, path_params):
        return {"user_id": user_id}

    gateway.add_route("/secure", HTTPMethod.GET, handler, requires_auth=True)
    resp = await gateway.handle(_req("/secure", authorization="Bearer secret-token-1"))
    assert resp.status_code == 200
    assert resp.body == {"user_id": "principal-1"}


# --------------------------------------------------------------------------- #
# D2 - no credential leakage                                                   #
# --------------------------------------------------------------------------- #


async def test_valid_bearer_runs_handler_no_leak(gateway: APIGateway) -> None:
    captured: dict = {}

    async def handler(request, user_id, claims, path_params):
        captured["user_id"] = user_id
        captured["claims"] = dict(claims)
        return {"user_id": user_id, "claims": dict(claims)}

    gateway.add_route("/secure", HTTPMethod.GET, handler, requires_auth=True)
    token = "secret-token-1"
    resp = await gateway.handle(_req("/secure", authorization=f"Bearer {token}"))
    assert resp.status_code == 200
    assert captured["user_id"] == "principal-1"
    # The raw bearer token must not appear in claims handed to the handler...
    assert token not in captured["claims"]
    assert "api_key" not in captured["claims"]
    # ...nor anywhere in the response body.
    assert token not in str(resp.body)


def test_simple_api_key_auth_hashes_keys_at_rest() -> None:
    """No plaintext key survives construction (D2)."""
    provider = _auth()
    for digest in provider._keys:
        assert isinstance(digest, bytes)
    for plaintext in VALID_KEYS:
        assert plaintext.encode("utf-8") not in provider._keys


async def test_simple_api_key_claims_exclude_raw_key() -> None:
    provider = _auth()
    user_id, claims = await provider.authenticate(
        _req("/x", authorization="Bearer secret-token-1")
    )
    assert user_id == "principal-1"
    assert "api_key" not in claims
    assert "secret-token-1" not in claims
    assert "secret-token-1" not in str(claims)


@pytest.mark.parametrize(
    "authorization",
    [
        None,  # missing header
        "Bearer ",  # empty key
        "Bearer nope",  # unknown key
        "Basic secret-token-1",  # wrong scheme
        "Bearer \tsecret-token-1",  # malformed (tab where key expected)
        "Bearer  secret-token-1",  # double space -> key is " secret-token-1"
    ],
)
async def test_invalid_or_missing_auth_returns_401(
    gateway: APIGateway, authorization: str | None
) -> None:
    gateway.add_route("/secure", HTTPMethod.GET, _ok, requires_auth=True)
    resp = await gateway.handle(_req("/secure", authorization=authorization))
    assert resp.status_code == 401


# --------------------------------------------------------------------------- #
# D5 - routing & OpenAPI                                                       #
# --------------------------------------------------------------------------- #


async def test_unknown_route_returns_404(gateway: APIGateway) -> None:
    gateway.add_route("/known", HTTPMethod.GET, _ok, requires_auth=True)
    resp = await gateway.handle(_req("/unknown", authorization="Bearer secret-token-1"))
    assert resp.status_code == 404


async def test_no_prefix_collision_in_route_matching() -> None:
    """D5: /admin must NOT match /administrator (segment equality guard)."""
    gw = APIGateway(allow_unauthenticated=True)
    gw.add_route("/admin", HTTPMethod.GET, _ok, requires_auth=False)
    assert (await gw.handle(_req("/administrator"))).status_code == 404
    assert (await gw.handle(_req("/admin"))).status_code == 200


async def test_path_params_extracted() -> None:
    async def handler(request, user_id, claims, path_params):
        return {"id": path_params["id"]}

    gw = APIGateway(allow_unauthenticated=True)
    gw.add_route("/items/{id}", HTTPMethod.GET, handler, requires_auth=False)
    resp = await gw.handle(_req("/items/42"))
    assert resp.status_code == 200
    assert resp.body == {"id": "42"}


def test_openapi_generation_exposes_no_secrets(gateway: APIGateway) -> None:
    async def handler(request, user_id, claims, path_params):
        return {"ok": True}

    gateway.add_route("/secure", HTTPMethod.GET, handler, requires_auth=True)
    schema = gateway.generate_openapi()
    assert schema["openapi"] == "3.0.0"
    assert "/secure" in schema["paths"]
    serialized = str(schema)
    for token in VALID_KEYS:
        assert token not in serialized
    assert schema["paths"]["/secure"]["get"]["security"] == [{"bearerAuth": []}]


def test_openapi_anonymous_route_has_no_security() -> None:
    gw = APIGateway(allow_unauthenticated=True)
    gw.add_route("/public", HTTPMethod.GET, _ok, requires_auth=False)
    schema = gw.generate_openapi()
    assert schema["paths"]["/public"]["get"]["security"] == []


# --------------------------------------------------------------------------- #
# D3 - rate limiting keyed on the principal                                    #
# --------------------------------------------------------------------------- #


async def test_rate_limit_same_principal_within_window(gateway: APIGateway) -> None:
    async def handler(request, user_id, claims, path_params):
        return {"ok": True}

    gateway.add_route("/limited", HTTPMethod.GET, handler, requires_auth=True, rate_limit=2)
    assert (
        await gateway.handle(_req("/limited", authorization="Bearer secret-token-1"))
    ).status_code == 200
    assert (
        await gateway.handle(_req("/limited", authorization="Bearer secret-token-1"))
    ).status_code == 200
    resp = await gateway.handle(_req("/limited", authorization="Bearer secret-token-1"))
    assert resp.status_code == 429
    assert resp.headers.get("Retry-After") == "60"


async def test_rate_limit_different_principals_are_independent(gateway: APIGateway) -> None:
    async def handler(request, user_id, claims, path_params):
        return {"ok": True}

    gateway.add_route("/limited", HTTPMethod.GET, handler, requires_auth=True, rate_limit=2)
    # principal-1 exhausts its bucket
    assert (
        await gateway.handle(_req("/limited", authorization="Bearer secret-token-1"))
    ).status_code == 200
    assert (
        await gateway.handle(_req("/limited", authorization="Bearer secret-token-1"))
    ).status_code == 200
    assert (
        await gateway.handle(_req("/limited", authorization="Bearer secret-token-1"))
    ).status_code == 429
    # principal-2 is unaffected
    assert (
        await gateway.handle(_req("/limited", authorization="Bearer secret-token-2"))
    ).status_code == 200
    assert (
        await gateway.handle(_req("/limited", authorization="Bearer secret-token-2"))
    ).status_code == 200


async def test_rate_limit_keyed_on_client_addr(monkeypatch) -> None:
    """Anonymous requests are keyed on the explicit client_addr, not a UUID."""
    fake_now = [1000.0]
    monkeypatch.setattr(time, "time", lambda: fake_now[0])
    gw = APIGateway(allow_unauthenticated=True, default_rate_limit=2)

    async def handler(request, user_id, claims, path_params):
        return {"ok": True}

    gw.add_route("/limited", HTTPMethod.GET, handler, requires_auth=False, rate_limit=2)
    for _ in range(2):
        resp = await gw.handle(_req("/limited", client_addr="1.1.1.1"))
        assert resp.status_code == 200
    assert (await gw.handle(_req("/limited", client_addr="1.1.1.1"))).status_code == 429
    # A different client stays independent
    assert (await gw.handle(_req("/limited", client_addr="2.2.2.2"))).status_code == 200


# --------------------------------------------------------------------------- #
# D6 - rate limiting never silently skipped (fail-open regression)             #
# --------------------------------------------------------------------------- #


async def test_rate_limit_fires_without_client_addr_or_user_id(monkeypatch) -> None:
    """D6: no principal AND no ``client_addr`` must still be throttled.

    Regression test for the fail-open hole: the key derivation used to be
    ``user_id or request.client_addr`` guarded by ``and rate_key``, so an
    unauthenticated request with ``client_addr=None`` skipped the limiter
    entirely - five requests, five 200s, zero buckets created. Throttling must
    fire instead of being skipped.
    """
    fake_now = [1000.0]
    monkeypatch.setattr(time, "time", lambda: fake_now[0])
    gw = APIGateway(allow_unauthenticated=True)

    async def handler(request, user_id, claims, path_params):
        return {"ok": True}

    gw.add_route("/limited", HTTPMethod.GET, handler, requires_auth=False, rate_limit=2)
    for _ in range(2):
        # client_addr defaults to None in APIRequest, and no auth provider is
        # consulted for an anonymous route, so user_id is None as well.
        resp = await gw.handle(_req("/limited"))
        assert resp.status_code == 200
    resp = await gw.handle(_req("/limited"))
    assert resp.status_code == 429
    assert resp.headers.get("Retry-After") == "60"
    # A bucket really was created - the limiter ran rather than being bypassed.
    assert len(gw._rate_limit_buckets) == 1


async def test_rate_limit_key_is_always_derivable() -> None:
    """The key derivation is total: it never reports "no key"."""
    gw = APIGateway(allow_unauthenticated=True)
    key = gw._derive_rate_limit_key(None, None)
    assert key == UNIDENTIFIED_RATE_LIMIT_KEY
    assert key


async def test_unidentified_bucket_is_shared_and_separate_from_principals(
    monkeypatch,
) -> None:
    """The fallback bucket is shared among unidentified callers, and principal
    buckets stay independent of it (and of each other)."""
    fake_now = [1000.0]
    monkeypatch.setattr(time, "time", lambda: fake_now[0])
    gw = APIGateway(auth_provider=_auth(), allow_unauthenticated=True)

    async def handler(request, user_id, claims, path_params):
        return {"ok": True}

    gw.add_route("/anon", HTTPMethod.GET, handler, requires_auth=False, rate_limit=2)
    gw.add_route("/auth", HTTPMethod.GET, handler, requires_auth=True, rate_limit=2)

    # Two unidentified callers exhaust the single shared bucket...
    assert (await gw.handle(_req("/anon"))).status_code == 200
    assert (await gw.handle(_req("/anon"))).status_code == 200
    assert (await gw.handle(_req("/anon"))).status_code == 429

    # ...while an authenticated principal is unaffected by that exhaustion.
    for _ in range(2):
        resp = await gw.handle(_req("/auth", authorization="Bearer secret-token-1"))
        assert resp.status_code == 200
    assert (
        await gw.handle(_req("/auth", authorization="Bearer secret-token-1"))
    ).status_code == 429

    keys = set(gw._rate_limit_buckets)
    assert gw._derive_rate_limit_key(None, None) in keys
    assert gw._derive_rate_limit_key("principal-1", "1.1.1.1") in keys
    # An address on an anonymous request is namespaced apart from the shared
    # bucket, so it cannot be consumed by unidentified traffic.
    assert gw._derive_rate_limit_key(None, "9.9.9.9") not in keys
    # A principal and an address with the same literal value stay independent.
    assert gw._derive_rate_limit_key("principal-1", None) != gw._derive_rate_limit_key(
        None, "principal-1"
    )


# --------------------------------------------------------------------------- #
# D4 - no exception text leak                                                  #
# --------------------------------------------------------------------------- #


async def test_handler_exception_not_leaked(gateway: APIGateway) -> None:
    async def handler(request, user_id, claims, path_params):
        raise RuntimeError(BAD_SECRET)

    gateway.add_route("/boom", HTTPMethod.GET, handler, requires_auth=True)
    resp = await gateway.handle(_req("/boom", authorization="Bearer secret-token-1"))
    assert resp.status_code == 500
    assert BAD_SECRET not in str(resp.body)
    assert "detail" not in resp.body
    assert resp.body["error"] == "Internal server error"
    assert resp.body["correlation_id"] == "req-1"


async def test_debug_mode_discloses_detail_only_when_opted_in() -> None:
    gw = APIGateway(auth_provider=_auth(), debug=True)

    async def handler(request, user_id, claims, path_params):
        raise ValueError("boom-detail")

    gw.add_route("/boom", HTTPMethod.GET, handler, requires_auth=True)
    resp = await gw.handle(_req("/boom", authorization="Bearer secret-token-1"))
    assert resp.status_code == 500
    assert resp.body["detail"] == "boom-detail"
