"""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: by default a single bearer token is read from the ``A2A_API_KEY`` env
var. Agents can instead set ``auth_resolver`` to validate caller bearer tokens
against their own API, OIDC userinfo/introspection endpoint, or other identity
bridge. The resolved principal is materialized into the declared
``auth_model``.
"""
from __future__ import annotations

import asyncio
import json
import logging
import os
import secrets
import time
from pathlib import Path
from typing import Any, AsyncIterator

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

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

from ..agent import A2AAgent, SkillInputError, SkillNotFound
from ..auth import (
    APIKeyAuth,
    APIKeyAuthResolver,
    AuthError,
    NoAuth,
    NoAuthResolver,
    resolve_auth,
)
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)

    async def _build_auth(
        provided_key: str | None,
        *,
        bearer_token: str | None,
        headers: dict[str, str],
    ) -> Any:
        resolver = type(agent).auth_resolver
        if resolver is not None:
            try:
                return await resolve_auth(
                    resolver,
                    bearer_token,
                    headers=headers,
                    agent=agent,
                )
            except AuthError as exc:
                raise HTTPException(exc.status_code, exc.message) from exc
        auth_cls = type(agent).auth_model
        if auth_cls is APIKeyAuth:
            return await APIKeyAuthResolver(
                accepted_key=os.environ.get("A2A_API_KEY"),
                api_key_id=os.environ.get("A2A_API_KEY_ID", "configured"),
            ).resolve(provided_key, headers=headers, agent=agent)
        if auth_cls is NoAuth:
            return await NoAuthResolver().resolve(
                bearer_token,
                headers=headers,
                agent=agent,
            )
        # 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

    def _extract_bearer(authorization: str | None) -> str | None:
        if not authorization or not authorization.lower().startswith("bearer "):
            return None
        return authorization.split(None, 1)[1].strip() or None

    def _invoke_token(authorization: str | None) -> str | None:
        if type(agent).auth_resolver is not None:
            return _extract_bearer(authorization)
        return _check_key(authorization)

    def _tracking_id(grant_obj: Grant | None) -> str:
        return grant_obj.grant_id if grant_obj is not None else f"invoke-{secrets.token_hex(8)}"

    def _grant_scopes(grant_obj: Grant | None) -> dict[str, Any]:
        if grant_obj is None:
            return {}
        return {
            "bucket": grant_obj.bucket,
            "mode": str(grant_obj.mode.value if hasattr(grant_obj.mode, "value") else grant_obj.mode),
            "allow_patterns": list(grant_obj.allow_patterns),
            "deny_patterns": list(grant_obj.deny_patterns),
            "outputs_prefix": grant_obj.outputs_prefix,
            "ttl_seconds": max(0, int(grant_obj.expires_at or 0) - int(grant_obj.issued_at or 0)),
        }

    def _args_preview(args: dict[str, Any], limit: int = 200) -> dict[str, Any]:
        out: dict[str, Any] = {}
        for key, value in args.items():
            if isinstance(value, str) and len(value) > limit:
                out[key] = value[:limit] + f"... (+{len(value) - limit} chars)"
            else:
                out[key] = value
        return out

    def _result_preview(result: Any) -> Any:
        if isinstance(result, dict):
            selected = {
                key: value
                for key, value in result.items()
                if key in {"summary", "message", "path", "url", "output_path", "chart_path", "error"}
            }
            if selected:
                return selected
        try:
            raw = json.dumps(result, ensure_ascii=False)
        except TypeError:
            raw = str(result)
        return {"preview": raw[:4000], "truncated": len(raw) > 4000}

    def _summary(result: Any, error: str | None = None) -> str:
        if error:
            return error[:240]
        if isinstance(result, dict):
            if isinstance(result.get("error"), str):
                return f"error: {result['error'][:200]}"
            for key in ("summary", "message", "path", "url", "output_path", "chart_path"):
                value = result.get(key)
                if isinstance(value, str) and value:
                    return f"{key}={value[:200]}"
        return "ok"

    async def _post_tracking(
        body: _InvokeIn,
        event: dict[str, Any],
    ) -> None:
        if not body.cp_jwt:
            return
        cp_url = (
            body.cp_url
            or os.environ.get("A2A_CP_URL")
            or os.environ.get("A2A_CP_URL_INTERNAL")
        )
        if not cp_url:
            return
        try:
            import httpx

            async with httpx.AsyncClient(timeout=3.0) as client:
                await client.post(
                    f"{cp_url.rstrip('/')}/v1/me/subagent-runs/track",
                    json=event,
                    headers={"authorization": f"bearer {body.cp_jwt}"},
                )
        except Exception:  # noqa: BLE001
            _log.debug("failed to post subagent tracking event", exc_info=True)

    async def _track_started(
        tracking_id: str,
        skill_name: str,
        body: _InvokeIn,
        grant_obj: Grant | None,
    ) -> float:
        await _post_tracking(
            body,
            {
                "type": "agent_invoke_started",
                "grant_id": tracking_id,
                "to": type(agent).name,
                "agent": type(agent).name,
                "skill": skill_name,
                "scopes": _grant_scopes(grant_obj),
                "args_preview": _args_preview(body.arguments or {}),
                "args_json": json.dumps(
                    body.arguments or {},
                    separators=(",", ":"),
                    ensure_ascii=False,
                ),
                "source": "agent_server",
            },
        )
        return time.monotonic()

    async def _track_complete(
        tracking_id: str,
        skill_name: str,
        body: _InvokeIn,
        grant_obj: Grant | None,
        started: float,
        *,
        ok: bool,
        result: Any = None,
        error: str | None = None,
    ) -> None:
        await _post_tracking(
            body,
            {
                "type": "agent_invoke_complete" if ok else "agent_invoke_error",
                "grant_id": tracking_id,
                "to": type(agent).name,
                "agent": type(agent).name,
                "skill": skill_name,
                "ok": ok,
                "summary": _summary(result, error),
                "result": _result_preview(result) if ok else {"error": error},
                "elapsed_ms": int((time.monotonic() - started) * 1000),
                "scopes": _grant_scopes(grant_obj),
                "source": "agent_server",
            },
        )

    @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")

    async def _prepare_invoke(
        body: _InvokeIn,
        token: str | None,
        skill_name: str,
        *,
        authorization: str | None,
        request_headers: dict[str, 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=await _build_auth(
                token,
                bearer_token=_extract_bearer(authorization),
                headers=request_headers,
            ),
            task_id=f"http-{skill_name}",
            secrets=_env_secrets(agent),
            workspace=granted_workspace or _local_dev_workspace(agent),
            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,
        request: Request,
        authorization: str | None = Header(default=None),
        accept: str | None = Header(default=None),
    ) -> Any:
        token = _invoke_token(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 await _invoke_sse(skill_name, body, token, authorization, request)
        grant_obj, ctx = await _prepare_invoke(
            body,
            token,
            skill_name,
            authorization=authorization,
            request_headers=dict(request.headers),
        )
        tracking_id = _tracking_id(grant_obj)
        started = await _track_started(tracking_id, skill_name, body, grant_obj)
        try:
            result = await agent.invoke_json(skill_name, ctx, body.arguments)
        except SkillNotFound:
            await _track_complete(
                tracking_id, skill_name, body, grant_obj, started,
                ok=False, error=f"unknown skill: {skill_name}",
            )
            raise HTTPException(404, f"unknown skill: {skill_name}")
        except SkillInputError as exc:
            await _track_complete(
                tracking_id, skill_name, body, grant_obj, started,
                ok=False, error=str(exc),
            )
            raise HTTPException(400, str(exc))
        except MissingScopes as exc:
            await _track_complete(
                tracking_id, skill_name, body, grant_obj, started,
                ok=False, error=str(exc),
            )
            raise HTTPException(403, str(exc))
        except Exception as exc:  # noqa: BLE001
            _log.exception("skill %r raised on /invoke (json path)", skill_name)
            await _track_complete(
                tracking_id, skill_name, body, grant_obj, started,
                ok=False, error=f"{type(exc).__name__}: {exc}",
            )
            raise HTTPException(
                500,
                f"skill {skill_name!r} raised {type(exc).__name__}: {exc}",
            ) from exc
        await _track_complete(
            tracking_id, skill_name, body, grant_obj, started,
            ok=True, result=result,
        )
        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,
        }

    async def _invoke_sse(
        skill_name: str,
        body: _InvokeIn,
        token: str | None,
        authorization: str | None,
        request: Request,
    ) -> 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 = await _prepare_invoke(
                body,
                token,
                skill_name,
                authorization=authorization,
                request_headers=dict(request.headers),
            )
        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
        tracking_id = _tracking_id(grant_obj)
        started = await _track_started(tracking_id, skill_name, body, grant_obj)

        async def run_skill() -> None:
            try:
                result = await agent.invoke_json(skill_name, base_ctx, body.arguments)
                await _track_complete(
                    tracking_id, skill_name, body, grant_obj, started,
                    ok=True, result=result,
                )
                await queue.put({"type": "result", "result": result,
                                  "grant_id": grant_obj.grant_id if grant_obj else None})
            except SkillNotFound:
                await _track_complete(
                    tracking_id, skill_name, body, grant_obj, started,
                    ok=False, error=f"unknown skill: {skill_name}",
                )
                await queue.put({"type": "error", "status": 404,
                                  "detail": f"unknown skill: {skill_name}"})
            except SkillInputError as exc:
                await _track_complete(
                    tracking_id, skill_name, body, grant_obj, started,
                    ok=False, error=str(exc),
                )
                await queue.put({"type": "error", "status": 400, "detail": str(exc)})
            except MissingScopes as exc:
                await _track_complete(
                    tracking_id, skill_name, body, grant_obj, started,
                    ok=False, error=str(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 _track_complete(
                    tracking_id, skill_name, body, grant_obj, started,
                    ok=False, error=f"{type(exc).__name__}: {exc}",
                )
                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 _local_dev_workspace(agent: A2AAgent) -> Any:
    root = os.environ.get("A2A_LOCAL_WORKSPACE_DIR")
    if not root and os.environ.get("A2A_LOCAL_DEV"):
        root = ".a2a/workspace"
    if not root:
        return None
    from ..workspace import FileSystemWorkspaceClient, WorkspaceAccess, WorkspaceMode

    access = type(agent).workspace_access
    if not access.enabled:
        return None
    local_access = WorkspaceAccess(
        enabled=True,
        max_files=access.max_files or 64,
        allowed_modes=access.allowed_modes
        or (WorkspaceMode.READ_ONLY, WorkspaceMode.READ_WRITE_OVERLAY),
        require_reason=access.require_reason,
        deny_patterns=access.deny_patterns,
        require_human_approval=False,
        max_total_size_bytes=access.max_total_size_bytes,
    )
    return FileSystemWorkspaceClient(
        Path(root),
        access=local_access,
        bucket="local-dev",
        issuer=type(agent).name,
        allow_patterns=("**",),
        outputs_prefix="outputs",
        mode=WorkspaceMode.READ_WRITE_OVERLAY,
    )


def _env_secrets(agent: A2AAgent) -> dict[str, str]:
    return {
        key: os.environ[key]
        for key in type(agent).required_secrets
        if key in os.environ
    }


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")
