"""In-process tests that exercise the server's auth middleware via ASGI.

Uses ``httpx.AsyncClient`` with ``ASGITransport`` so no uvicorn / network is
required. Validates that a request forged with the real token-minting
pipeline reaches the FastMCP layer, and that tampering with any of the
pieces is rejected with 4xx.
"""

from __future__ import annotations

import json

import httpx
import pytest

from poc.http_sig import sign_request
from poc.server import build_app
from poc.tokens import mint_ect, mint_mandate


pytestmark = pytest.mark.asyncio


def _headers_for(
    identities,
    *,
    body: bytes,
    audience: str,
    exec_act: str,
    tamper_body: bool = False,
    tamper_aud: bool = False,
) -> tuple[dict[str, str], bytes]:
    """Build a full set of ACT+ECT+signature headers for one request."""
    agent = identities["agent"]
    user = identities["user"]
    mandate = mint_mandate(
        user=user, agent=agent, audience=audience, purpose="test"
    )
    ect = mint_ect(
        agent=agent,
        audience=audience,
        exec_act=exec_act,
        pred_jtis=[mandate.mandate.jti],
        inp_body=body,
    )
    sign_body = b"tampered" if tamper_body else body
    sign_aud = "wrong-audience" if tamper_aud else audience
    signed = sign_request(
        method="POST",
        target_uri="http://testserver/mcp",
        body=sign_body,
        wimse_ect=ect.compact,
        wimse_aud=sign_aud,
        keyid=agent.kid,
        private_key=agent.private_key,
    )
    headers = {
        "content-type": "application/json",
        "accept": "application/json, text/event-stream",
        "authorization": f"Bearer {mandate.compact}",
        "wimse-ect": ect.compact,
        "content-digest": signed.content_digest,
        "signature-input": signed.signature_input,
        "signature": signed.signature,
    }
    return headers, body


from contextlib import asynccontextmanager


@asynccontextmanager
async def _client_for(identities):
    """Return an httpx client wired to the ASGI app with lifespan started.

    FastMCP's streamable-HTTP transport allocates a task group during
    ``lifespan.startup``; ``ASGITransport`` does not run lifespan by
    default, so we manage it explicitly here.
    """
    app = build_app(identities)
    async with app.router.lifespan_context(app):
        transport = httpx.ASGITransport(app=app)
        async with httpx.AsyncClient(
            transport=transport, base_url="http://testserver"
        ) as client:
            yield client


async def test_no_auth_headers_returns_401(identities):
    async with _client_for(identities) as c:
        r = await c.post("/mcp", content=b'{"jsonrpc":"2.0","method":"initialize"}')
        assert r.status_code == 401
        assert "Authorization" in r.text


async def test_valid_initialize_request_is_accepted(identities, tmp_path, monkeypatch):
    monkeypatch.setenv("POC_AUDIT_LOG", str(tmp_path / "audit.jsonl"))
    body = json.dumps({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "initialize",
        "params": {
            "protocolVersion": "2025-03-26",
            "capabilities": {},
            "clientInfo": {"name": "poc-test", "version": "0"},
        },
    }).encode("utf-8")
    headers, _ = _headers_for(
        identities, body=body, audience="mcp-server",
        exec_act="mcp.session.initialize",
    )
    async with _client_for(identities) as c:
        r = await c.post("/mcp", content=body, headers=headers)
        # FastMCP may respond 200 with a session id, or 202, or stream SSE.
        assert r.status_code < 400, f"unexpected status {r.status_code}: {r.text}"


async def test_tampered_body_is_rejected(identities):
    body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize"}).encode()
    headers, _ = _headers_for(
        identities, body=body, audience="mcp-server",
        exec_act="mcp.session.initialize", tamper_body=True,
    )
    async with _client_for(identities) as c:
        r = await c.post("/mcp", content=body, headers=headers)
        assert r.status_code == 401
        assert "content-digest" in r.text.lower() or "http-signature" in r.text.lower()


async def test_wrong_wimse_aud_is_rejected(identities):
    body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize"}).encode()
    headers, _ = _headers_for(
        identities, body=body, audience="mcp-server",
        exec_act="mcp.session.initialize", tamper_aud=True,
    )
    async with _client_for(identities) as c:
        r = await c.post("/mcp", content=body, headers=headers)
        assert r.status_code == 401


async def test_unauthorised_tool_is_rejected(identities):
    """A tools/call whose exec_act is not in mandate.cap → 403."""
    body = json.dumps({
        "jsonrpc": "2.0", "id": 1, "method": "tools/call",
        "params": {"name": "search", "arguments": {"query": "x"}},
    }).encode()
    # Craft a mandate where we strip search out of cap by going through
    # the token API: we fabricate headers with the right exec_act but a
    # mandate whose cap doesn't contain it.
    from poc.keys import Identity  # noqa
    from poc.tokens import MCP_CAPS, mint_mandate
    agent = identities["agent"]
    user = identities["user"]

    # Replace MCP_CAPS monkey-patch-free: build mandate directly.
    from act.token import ACTMandate, Capability, TaskClaim
    from act.crypto import sign as act_sign
    from act.token import encode_jws
    import time, uuid
    iat = int(time.time())
    mandate = ACTMandate(
        alg="ES256", kid=user.kid, iss="user", sub="agent",
        aud="mcp-server", iat=iat, exp=iat + 600, jti=str(uuid.uuid4()),
        wid="agent",
        task=TaskClaim(purpose="p", created_by="user"),
        cap=[Capability(action="mcp.summarize")],  # search MISSING
    )
    mandate.validate()
    mandate_compact = encode_jws(mandate, act_sign(user.private_key, mandate.signing_input()))

    ect = mint_ect(
        agent=agent, audience="mcp-server",
        exec_act="mcp.search", pred_jtis=[mandate.jti], inp_body=body,
    )
    signed = sign_request(
        method="POST", target_uri="http://testserver/mcp",
        body=body, wimse_ect=ect.compact, wimse_aud="mcp-server",
        keyid=agent.kid, private_key=agent.private_key,
    )
    headers = {
        "content-type": "application/json",
        "authorization": f"Bearer {mandate_compact}",
        "wimse-ect": ect.compact,
        "content-digest": signed.content_digest,
        "signature-input": signed.signature_input,
        "signature": signed.signature,
    }
    async with _client_for(identities) as c:
        r = await c.post("/mcp", content=body, headers=headers)
        assert r.status_code == 403
        assert "exec_act" in r.text or "cap" in r.text
