"""HTTP adapter that turns any :class:`A2AAgent` into a service.

This is intentionally minimal: it covers the surface needed to plug into
the wider A2A ecosystem and the platform's control plane.

Endpoints:
  GET  /healthz                     -> liveness
  GET  /.well-known/agent-card      -> Agent Card JSON
  POST /invoke/{skill}              -> invoke skill (JSON in, JSON out)

Auth: a single bearer token is read from the ``A2A_API_KEY`` env var. If set,
all routes except ``/healthz`` and the card require ``Authorization: Bearer
<key>``. The bearer token is materialized into the agent's declared
``auth_model`` (best-effort: APIKeyAuth, otherwise NoAuth).
"""
from __future__ import annotations

import asyncio
import json
import logging
import os
from typing import Any, AsyncIterator

_log = logging.getLogger(__name__)
SSE_HEARTBEAT_SECONDS = 15.0
SSE_HEARTBEAT = object()

from fastapi import FastAPI, Header, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel

from ..agent import A2AAgent, SkillInputError, SkillNotFound
from ..auth import APIKeyAuth, NoAuth
from ..context import AgentEvent, LocalRunContext, MissingScopes, RunContext
from ..grants import Grant, GrantInvalid, verify_grant


class _LLMCredsIn(BaseModel):
    base_url: str
    api_key: str
    model: str


class _InvokeIn(BaseModel):
    arguments: dict[str, Any] = {}
    grant: str | None = None
    llm_creds: _LLMCredsIn | None = None
    # The caller's CP JWT, forwarded by the orchestrator when this
    # agent's Card declares ``wants_cp_jwt=True``. Lets the skill call
    # back into /v1/me/* endpoints on the user's behalf.
    cp_jwt: str | None = None
    cp_url: str | None = None


class _AnswerIn(BaseModel):
    answer: str


class _InputResponseIn(BaseModel):
    value: dict[str, Any]


class _ScopeGrantIn(BaseModel):
    grant: str


class _ScopeDenyIn(BaseModel):
    reason: str


def sse_comment(comment: str = "ping") -> bytes:
    return f": {comment}\n\n".encode()


async def queue_get_or_heartbeat(
    queue: asyncio.Queue[dict[str, Any] | None],
    timeout: float = SSE_HEARTBEAT_SECONDS,
) -> dict[str, Any] | None | object:
    try:
        return await asyncio.wait_for(queue.get(), timeout=timeout)
    except asyncio.TimeoutError:
        return SSE_HEARTBEAT


def build_app(agent: A2AAgent) -> FastAPI:
    """Build a FastAPI app for the given agent instance."""
    app = FastAPI(title=type(agent).name, version=type(agent).version)
    api_key = os.environ.get("A2A_API_KEY")

    # Expose the same skills over MCP at POST /mcp so the agent is
    # callable from Claude Code, Cursor, and any other MCP client.
    from ..mcp import mount_http as _mount_mcp
    _mount_mcp(app, agent)

    def _build_auth(provided_key: str | None) -> Any:
        auth_cls = type(agent).auth_model
        if auth_cls is APIKeyAuth:
            return APIKeyAuth(api_key_id=provided_key or "anonymous")
        if auth_cls is NoAuth:
            return NoAuth()
        # Unknown auth model: try default-construct, else fail loudly.
        try:
            return auth_cls()
        except Exception as exc:  # pragma: no cover - depends on user model
            raise HTTPException(
                status_code=500,
                detail=f"cannot materialize auth_model {auth_cls.__name__}: {exc}",
            ) from exc

    def _check_key(authorization: str | None) -> str | None:
        if api_key is None:
            return None
        if not authorization or not authorization.lower().startswith("bearer "):
            raise HTTPException(401, "missing bearer token")
        token = authorization.split(None, 1)[1].strip()
        if token != api_key:
            raise HTTPException(401, "invalid bearer token")
        return token

    @app.get("/healthz")
    async def healthz() -> dict[str, Any]:
        ok = await agent.health()
        return {"ok": ok, "agent": type(agent).name, "version": type(agent).version}

    @app.get("/.well-known/agent-card")
    async def agent_card() -> dict[str, Any]:
        return agent.card().model_dump(mode="json")

    def _prepare_invoke(
        body: _InvokeIn, token: str | None, skill_name: str
    ) -> tuple[Grant | None, LocalRunContext[Any]]:
        granted_workspace = None
        grant_obj: Grant | None = None
        if body.grant is not None:
            try:
                grant_obj = verify_grant(body.grant)
            except GrantInvalid as exc:
                raise HTTPException(403, f"invalid grant: {exc}") from exc
            granted_workspace = _grant_to_workspace(grant_obj, agent)
        ctx: LocalRunContext[Any] = LocalRunContext(
            auth=_build_auth(token),
            task_id=f"http-{skill_name}",
            workspace=granted_workspace,
            sandbox=_sandbox_client(grant_obj),
        )
        if grant_obj is not None:
            _bind_minio_artifacts(ctx, grant_obj)
        # JWT forwarding is gated by the agent's own declaration. Agents
        # that didn't opt in via ``wants_cp_jwt`` never see the token.
        if body.cp_jwt is not None and type(agent).wants_cp_jwt:
            object.__setattr__(ctx, "_cp_jwt", body.cp_jwt)
            object.__setattr__(
                ctx, "_cp_url",
                body.cp_url
                or os.environ.get(
                    "A2A_CP_URL",
                    "http://control-plane.control-plane.svc.cluster.local",
                ),
            )

        # Forwarded LLM creds are only honored when THIS agent explicitly
        # declared ``llm_provisioning=caller_provided``. Other modes
        # silently ignore the body field — an agent that doesn't opt in
        # never sees the caller's keys.
        if body.llm_creds is not None:
            from ..runtime import LLMProvisioning
            wants_caller = (
                type(agent).llm_provisioning == LLMProvisioning.CALLER_PROVIDED
            )
            if wants_caller:
                from ..context import LLMCreds
                object.__setattr__(
                    ctx, "_llm_creds",
                    LLMCreds(
                        base_url=body.llm_creds.base_url,
                        api_key=body.llm_creds.api_key,
                        model=body.llm_creds.model,
                        source="caller",
                    ),
                )
        return grant_obj, ctx

    @app.post("/invoke/{skill_name}")
    async def invoke(
        skill_name: str,
        body: _InvokeIn,
        authorization: str | None = Header(default=None),
        accept: str | None = Header(default=None),
    ) -> Any:
        token = _check_key(authorization)
        # Stream when caller asks for SSE — control-plane sets this so the
        # dashboard can show progress events live.
        if accept and "text/event-stream" in accept.lower():
            return _invoke_sse(skill_name, body, token)
        grant_obj, ctx = _prepare_invoke(body, token, skill_name)
        try:
            result = await agent.invoke_json(skill_name, ctx, body.arguments)
        except SkillNotFound:
            raise HTTPException(404, f"unknown skill: {skill_name}")
        except SkillInputError as exc:
            raise HTTPException(400, str(exc))
        except MissingScopes as exc:
            raise HTTPException(403, str(exc))
        except Exception as exc:  # noqa: BLE001
            _log.exception("skill %r raised on /invoke (json path)", skill_name)
            raise HTTPException(
                500,
                f"skill {skill_name!r} raised {type(exc).__name__}: {exc}",
            ) from exc
        return {
            "result": result,
            "events": [
                {"kind": e.kind, "payload": e.payload} for e in ctx.events
            ],
            "artifacts": [
                {"name": name, "size_bytes": len(data)}
                for name, data in (ctx.artifacts or {}).items()
            ],
            "grant_id": grant_obj.grant_id if grant_obj else None,
        }

    def _invoke_sse(
        skill_name: str, body: _InvokeIn, token: str | None
    ) -> StreamingResponse:
        queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue()

        async def on_event(ev: AgentEvent) -> None:
            await queue.put({"type": "event", "kind": ev.kind, "payload": ev.payload})

        # Build context with the live callback wired in.
        try:
            grant_obj, base_ctx = _prepare_invoke(body, token, skill_name)
        except HTTPException as exc:
            async def err() -> AsyncIterator[bytes]:
                yield b"data: " + json.dumps(
                    {"type": "error", "status": exc.status_code, "detail": exc.detail}
                ).encode() + b"\n\n"
                yield b"data: [DONE]\n\n"
            return StreamingResponse(err(), media_type="text/event-stream")
        # Mutate the context to use our callback.
        base_ctx._on_event = on_event  # noqa: SLF001

        async def run_skill() -> None:
            try:
                result = await agent.invoke_json(skill_name, base_ctx, body.arguments)
                await queue.put({"type": "result", "result": result,
                                  "grant_id": grant_obj.grant_id if grant_obj else None})
            except SkillNotFound:
                await queue.put({"type": "error", "status": 404,
                                  "detail": f"unknown skill: {skill_name}"})
            except SkillInputError as exc:
                await queue.put({"type": "error", "status": 400, "detail": str(exc)})
            except MissingScopes as exc:
                await queue.put({"type": "error", "status": 403, "detail": str(exc)})
            except Exception as exc:  # noqa: BLE001
                _log.exception("skill %r raised on /invoke", skill_name)
                await queue.put({
                    "type": "error", "status": 500,
                    "detail": f"{type(exc).__name__}: {exc}",
                })
            finally:
                await queue.put(None)

        async def gen() -> AsyncIterator[bytes]:
            task = asyncio.create_task(run_skill())
            try:
                while True:
                    ev = await queue_get_or_heartbeat(queue)
                    if ev is SSE_HEARTBEAT:
                        yield sse_comment()
                        continue
                    if ev is None:
                        break
                    yield b"data: " + json.dumps(ev).encode() + b"\n\n"
                yield b"data: [DONE]\n\n"
            finally:
                if not task.done():
                    task.cancel()

        return StreamingResponse(
            gen(),
            media_type="text/event-stream",
            headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
        )

    @app.post("/answers/{question_id}")
    async def answer_question(
        question_id: str,
        body: _AnswerIn,
        authorization: str | None = Header(default=None),
    ) -> dict[str, Any]:
        _check_key(authorization)
        ok = RunContext.answer(question_id, body.answer)
        if not ok:
            raise HTTPException(404, "no pending question with that id")
        return {"ok": True, "question_id": question_id}

    @app.post("/input-requests/{request_id}")
    async def submit_input_request(
        request_id: str,
        body: _InputResponseIn,
        authorization: str | None = Header(default=None),
    ) -> dict[str, Any]:
        _check_key(authorization)
        ok = RunContext.submit_input(request_id, body.value)
        if not ok:
            raise HTTPException(404, "no pending input request with that id")
        return {"ok": True, "request_id": request_id}

    @app.post("/scope-grants/{request_id}")
    async def deliver_scope_grant(
        request_id: str,
        body: _ScopeGrantIn,
        authorization: str | None = Header(default=None),
    ) -> dict[str, Any]:
        """Platform delivers an approved superseding grant for ``request_id``."""
        _check_key(authorization)
        # verify_grant runs again inside resolve_scope_grant; we surface a
        # 403 here on bad signature so the platform sees the failure cleanly.
        try:
            verify_grant(body.grant)
        except GrantInvalid as exc:
            raise HTTPException(403, f"invalid grant: {exc}") from exc
        ok = RunContext.resolve_scope_grant(request_id, body.grant)
        if not ok:
            raise HTTPException(404, "no pending scope request with that id")
        return {"ok": True, "request_id": request_id}

    @app.post("/scope-denials/{request_id}")
    async def deliver_scope_denial(
        request_id: str,
        body: _ScopeDenyIn,
        authorization: str | None = Header(default=None),
    ) -> dict[str, Any]:
        """Platform refused the scope request; deliver the reason."""
        _check_key(authorization)
        ok = RunContext.deny_scope(request_id, body.reason)
        if not ok:
            raise HTTPException(404, "no pending scope request with that id")
        return {"ok": True, "request_id": request_id}

    return app


def _grant_to_workspace(grant: Grant, agent: A2AAgent) -> Any:
    """Build a :class:`WorkspaceClient` bounded by the grant.

    When the agent pod has MinIO env, return a real MinIO-backed client
    scoped to the caller's bucket. Local/dev runtimes without those env vars
    keep the in-memory fallback used by tests.
    """
    from ..workspace import (
        LocalWorkspaceClient,
        MinIOWorkspaceClient,
        WorkspaceAccess,
        WorkspaceMode,
    )

    access = WorkspaceAccess.dynamic(
        max_files=64,
        allowed_modes=(WorkspaceMode.READ_ONLY, WorkspaceMode.READ_WRITE_OVERLAY),
        require_reason=False,
        deny_patterns=tuple(grant.deny_patterns),
        require_human_approval=False,
    )
    endpoint = os.environ.get("A2A_MINIO_ENDPOINT")
    if endpoint:
        client = MinIOWorkspaceClient(
            bucket=grant.bucket,
            endpoint_url=endpoint,
            access_key_id=os.environ.get("A2A_MINIO_ACCESS_KEY", "minioadmin"),
            secret_access_key=os.environ.get("A2A_MINIO_SECRET_KEY", "minioadmin"),
            access=access,
            issuer=grant.audience,
        )
    else:
        client = LocalWorkspaceClient(
            files={}, access=access, bucket=grant.bucket, issuer=grant.audience
        )
    # Seed the client's capability state from the original grant so a later
    # ctx.request_scope() call sees the right baseline before installing the
    # superseding grant.
    client.install_grant(grant)
    return client


def _bind_minio_artifacts(ctx: LocalRunContext[Any], grant: Grant) -> None:
    """Persist ``ctx.write_artifact`` outputs into the granted MinIO bucket."""
    endpoint = os.environ.get("A2A_MINIO_ENDPOINT")
    if not endpoint:
        return
    try:
        import boto3
        from botocore.config import Config as _BotoConfig
    except Exception:  # noqa: BLE001
        return
    from ..context import ArtifactRef

    s3 = boto3.client(
        "s3",
        endpoint_url=endpoint,
        aws_access_key_id=os.environ.get("A2A_MINIO_ACCESS_KEY", "minioadmin"),
        aws_secret_access_key=os.environ.get("A2A_MINIO_SECRET_KEY", "minioadmin"),
        region_name="us-east-1",
        config=_BotoConfig(s3={"addressing_style": "path"}),
    )
    prefix = (grant.outputs_prefix or "outputs/").strip("/")
    task_id = getattr(ctx, "task_id", "task")

    async def write_artifact(name: str, data: bytes, mime_type: str) -> ArtifactRef:
        clean_name = name.strip("/").replace("..", "_") or "artifact"
        key = f"{prefix}/{task_id}/{clean_name}" if prefix else f"{task_id}/{clean_name}"
        s3.put_object(Bucket=grant.bucket, Key=key, Body=data, ContentType=mime_type)
        try:
            ctx.artifacts[name] = data
        except Exception:  # noqa: BLE001
            pass
        return ArtifactRef(
            name=name,
            uri=f"s3://{grant.bucket}/{key}",
            mime_type=mime_type,
            size_bytes=len(data),
        )

    ctx.write_artifact = write_artifact  # type: ignore[assignment]


def _sandbox_client(grant: Grant | None) -> Any:
    url = os.environ.get("A2A_SANDBOX_URL")
    if not url:
        return None
    from ..sandbox import HttpSandboxClient

    return HttpSandboxClient(
        url,
        default_workspace=grant.bucket if grant is not None else None,
        timeout_seconds=float(os.environ.get("A2A_SANDBOX_TIMEOUT_S", "180")),
    )


def serve(agent: A2AAgent, *, host: str = "0.0.0.0", port: int = 8000) -> None:
    """Run the agent's HTTP server with uvicorn (blocking)."""
    import uvicorn

    uvicorn.run(build_app(agent), host=host, port=port, log_level="info")
