"""Common sidecar runtime for DSL-compiled agents.

The sidecar owns the wire protocols and delegates native handler execution to
language workers. Target-language SDKs only need to compile declarations to
``AgentDsl`` and expose the small worker invoke endpoint.
"""
from __future__ import annotations

import json
import mimetypes
from pathlib import Path
from typing import Any, Protocol

import httpx
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, RedirectResponse, Response
from pydantic import BaseModel, Field

from .dsl import AgentDsl, AgentDslSkill
from .frontend import PackedFrontend, frontend_auth_metadata, packed_frontend_from_env
from .mcp import MCP_PROTOCOL_VERSION

_SIDECAR_FRONTEND_CLIENT_JS = """\
export async function createA2AClient(configUrl = "./config.json") {
  const response = await fetch(configUrl, { credentials: "same-origin" });
  if (!response.ok) throw new Error(`failed to load A2A config: ${response.status}`);
  const config = await response.json();
  return {
    config,
    async callSkill(skillName, args = {}) {
      const url = `${config.endpoints.invoke}/${encodeURIComponent(skillName)}`;
      const invoke = await fetch(url, {
        method: "POST",
        credentials: "same-origin",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ arguments: args }),
      });
      const text = await invoke.text();
      const payload = text ? JSON.parse(text) : null;
      if (!invoke.ok) {
        const detail = payload && (payload.detail || payload.message);
        throw new Error(detail || `skill invoke failed: ${invoke.status}`);
      }
      return payload;
    },
  };
}
"""


class SidecarInvokeIn(BaseModel):
    arguments: dict[str, Any] = Field(default_factory=dict)
    grant: str | None = None
    llm_creds: dict[str, Any] | None = None
    composition: dict[str, Any] | None = None
    consumer_config: dict[str, Any] | None = None
    consumer_secrets: dict[str, str] | None = None
    cp_jwt: str | None = None
    cp_url: str | None = None
    auth: dict[str, Any] | None = None


class SidecarWorkerRequest(BaseModel):
    agent: str
    skill: str
    handler: str
    arguments: dict[str, Any] = Field(default_factory=dict)
    grant: str | None = None
    llm_creds: dict[str, Any] | None = None
    composition: dict[str, Any] | None = None
    consumer_config: dict[str, Any] | None = None
    consumer_secrets: dict[str, str] | None = None
    cp_jwt: str | None = None
    cp_url: str | None = None
    auth: dict[str, Any] | None = None


class SidecarWorkerResponse(BaseModel):
    result: Any
    events: list[dict[str, Any]] = Field(default_factory=list)
    artifacts: list[dict[str, Any]] = Field(default_factory=list)


class SidecarWorker(Protocol):
    async def call(self, skill: AgentDslSkill, request: SidecarWorkerRequest) -> Any:
        ...


class HttpSidecarWorker:
    """HTTP worker adapter used by native language SDK shims."""

    def __init__(self, base_url: str, *, timeout: float = 120.0) -> None:
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout

    async def call(self, skill: AgentDslSkill, request: SidecarWorkerRequest) -> Any:
        async with httpx.AsyncClient(timeout=self.timeout) as client:
            response = await client.post(
                f"{self.base_url}/_a2a/invoke/{skill.handler}",
                json=request.model_dump(mode="json"),
            )
        if response.status_code >= 400:
            raise HTTPException(
                response.status_code,
                f"worker invoke failed: {response.text[:500]}",
            )
        try:
            payload = SidecarWorkerResponse.model_validate(response.json())
        except Exception as exc:  # noqa: BLE001
            raise HTTPException(
                502,
                f"worker response must match SidecarWorkerResponse: {exc}",
            ) from exc
        return payload.result


def load_agent_dsl(path: Path) -> AgentDsl:
    return AgentDsl.model_validate_json(path.read_text(encoding="utf-8"))


def build_sidecar_app(
    dsl: AgentDsl,
    *,
    worker: SidecarWorker | None = None,
    worker_url: str | None = None,
) -> FastAPI:
    """Build a FastAPI sidecar app from a compiled Agent DSL."""

    resolved_worker = worker or HttpSidecarWorker(
        worker_url or "http://127.0.0.1:9001"
    )
    app = FastAPI(title=dsl.name, version=dsl.version)
    skills = {skill.name: skill for skill in dsl.skills}

    @app.get("/healthz")
    async def healthz() -> dict[str, Any]:
        return {
            "ok": True,
            "agent": dsl.name,
            "version": dsl.version,
            "sidecar": True,
        }

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

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

    @app.get("/.well-known/a2a-skills.json")
    async def a2a_skills(request: Request) -> dict[str, Any]:
        return _skills_payload(dsl, request)

    @app.post("/invoke/{skill_name}")
    async def invoke(skill_name: str, body: SidecarInvokeIn) -> dict[str, Any]:
        skill = _skill_or_404(skills, skill_name)
        _validate_arguments(skill, body.arguments)
        worker_request = SidecarWorkerRequest(
            agent=dsl.name,
            skill=skill.name,
            handler=skill.handler,
            arguments=body.arguments,
            grant=body.grant,
            llm_creds=body.llm_creds,
            composition=body.composition,
            consumer_config=body.consumer_config,
            consumer_secrets=body.consumer_secrets,
            cp_jwt=body.cp_jwt,
            cp_url=body.cp_url,
            auth=body.auth,
        )
        result = await resolved_worker.call(skill, worker_request)
        return {"result": result}

    @app.post("/mcp")
    async def mcp(body: dict[str, Any]) -> dict[str, Any]:
        request_id = body.get("id")
        method = body.get("method")
        params = body.get("params") or {}
        if method == "initialize":
            return _jsonrpc_result(request_id, _mcp_initialize(dsl, params))
        if method == "notifications/initialized":
            return _jsonrpc_result(request_id, {})
        if method == "tools/list":
            return _jsonrpc_result(request_id, {"tools": _mcp_tools(dsl)})
        if method == "tools/call":
            try:
                result = await _mcp_tool_call(dsl, skills, resolved_worker, params)
            except HTTPException as exc:
                return _jsonrpc_error(request_id, -32602, str(exc.detail))
            except Exception as exc:  # noqa: BLE001
                return _jsonrpc_error(request_id, -32603, str(exc))
            return _jsonrpc_result(request_id, result)
        return _jsonrpc_error(request_id, -32601, f"method not found: {method}")

    frontend = packed_frontend_from_env()
    if frontend is not None:
        _mount_sidecar_frontend(app, dsl, frontend)

    return app


def serve_sidecar(
    *,
    dsl_path: Path,
    worker_url: str,
    host: str = "0.0.0.0",
    port: int = 8000,
) -> None:
    import uvicorn

    app = build_sidecar_app(load_agent_dsl(dsl_path), worker_url=worker_url)
    uvicorn.run(app, host=host, port=port, log_level="info")


def _skill_or_404(skills: dict[str, AgentDslSkill], name: str) -> AgentDslSkill:
    skill = skills.get(name)
    if skill is None:
        raise HTTPException(404, f"unknown skill: {name}")
    return skill


def _validate_arguments(skill: AgentDslSkill, arguments: dict[str, Any]) -> None:
    schema = skill.input_schema
    properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
    required = schema.get("required") if isinstance(schema.get("required"), list) else []
    missing = [name for name in required if name not in arguments]
    if missing:
        raise HTTPException(422, f"missing required arguments: {missing}")
    if schema.get("additionalProperties") is False:
        unknown = sorted(set(arguments) - set(properties))
        if unknown:
            raise HTTPException(422, f"unknown arguments: {unknown}")


def _skills_payload(dsl: AgentDsl, request: Request | None = None) -> dict[str, Any]:
    card = dsl.to_agent_card().model_dump(mode="json")
    return {
        "agent": {"name": dsl.name, "version": dsl.version},
        "skills": card.get("skills") if isinstance(card.get("skills"), list) else [],
        "invokeUrl": _request_url(request, "/invoke"),
        "mcpUrl": _request_url(request, "/mcp"),
    }


def _request_url(request: Request | None, path: str) -> str:
    if request is None:
        return path
    return str(request.url_for("healthz")).removesuffix("/healthz") + path


def _mount_sidecar_frontend(app: FastAPI, dsl: AgentDsl, frontend: PackedFrontend) -> None:
    app.state.a2a_frontend = frontend
    root = frontend.dist_dir.resolve()

    @app.get(frontend.config_path, include_in_schema=False, response_model=None)
    async def sidecar_frontend_config(request: Request) -> dict[str, Any]:
        return _sidecar_frontend_config_payload(dsl, frontend, request)

    @app.get(frontend.client_path, include_in_schema=False)
    async def sidecar_frontend_client() -> Response:
        return Response(_SIDECAR_FRONTEND_CLIENT_JS, media_type="text/javascript; charset=utf-8")

    async def sidecar_frontend_index() -> Response:
        return _sidecar_index_response(root)

    async def sidecar_frontend_redirect() -> Response:
        return RedirectResponse(f"{frontend.mount}/", status_code=307)

    async def sidecar_frontend_asset(path: str) -> Response:
        if path in {"", "/", "index.html"}:
            return _sidecar_index_response(root)
        target = _safe_frontend_asset_path(root, path)
        if target.is_file():
            return FileResponse(
                target,
                media_type=mimetypes.guess_type(target.name)[0]
                or "application/octet-stream",
            )
        return _sidecar_index_response(root)

    if frontend.mount == "/":
        app.add_api_route(
            "/",
            sidecar_frontend_index,
            methods=["GET"],
            include_in_schema=False,
            response_model=None,
        )
    else:
        app.add_api_route(
            frontend.mount,
            sidecar_frontend_redirect,
            methods=["GET"],
            include_in_schema=False,
            response_model=None,
        )
        app.add_api_route(
            f"{frontend.mount}/",
            sidecar_frontend_index,
            methods=["GET"],
            include_in_schema=False,
            response_model=None,
        )
    catchall = "/{path:path}" if frontend.mount == "/" else f"{frontend.mount}/{{path:path}}"
    app.add_api_route(
        catchall,
        sidecar_frontend_asset,
        methods=["GET"],
        include_in_schema=False,
        response_model=None,
    )


def _sidecar_frontend_config_payload(
    dsl: AgentDsl,
    frontend: PackedFrontend,
    request: Request,
) -> dict[str, Any]:
    card = dsl.to_agent_card().model_dump(mode="json")
    skills = card.get("skills") if isinstance(card.get("skills"), list) else []
    return {
        "agent": {
            "name": dsl.name,
            "description": dsl.description,
            "version": dsl.version,
        },
        "mount": frontend.mount,
        "ui": {
            "url": _request_url(request, frontend.mount),
            "type": "static-spa",
            "entry": _request_url(request, f"{frontend.mount}/index.html"),
            "config": _request_url(request, frontend.config_path),
            "client": _request_url(request, frontend.client_path),
            "requiresAuth": False,
            "auth": frontend_auth_metadata(frontend, request),
        },
        "auth": {
            **frontend_auth_metadata(frontend, request),
            "invokeRequiresSession": dsl.auth.required,
        },
        "endpoints": {
            "agentCard": _request_url(request, "/.well-known/agent-card.json"),
            "legacyAgentCard": _request_url(request, "/.well-known/agent-card"),
            "skills": _request_url(request, "/.well-known/a2a-skills.json"),
            "invoke": _request_url(request, "/invoke"),
            "mcp": _request_url(request, "/mcp"),
            "session": _request_url(request, "/auth/session"),
        },
        "skills": skills,
        "docs": {"base": frontend.docs_url, "install": f"{frontend.docs_url.rstrip('/')}/"},
    }


def _sidecar_index_response(root: Path) -> Response:
    index = root / "index.html"
    if not index.is_file():
        raise HTTPException(404, "frontend index.html not found")
    return FileResponse(index, media_type="text/html; charset=utf-8")


def _safe_frontend_asset_path(root: Path, relpath: str) -> Path:
    clean = relpath.replace("\\", "/").lstrip("/")
    target = (root / clean).resolve()
    if root != target and root not in target.parents:
        raise HTTPException(404, "asset not found")
    return target


def _mcp_initialize(dsl: AgentDsl, params: dict[str, Any]) -> dict[str, Any]:
    return {
        "protocolVersion": params.get("protocolVersion") or MCP_PROTOCOL_VERSION,
        "capabilities": {"tools": {"listChanged": False}},
        "serverInfo": {"name": dsl.name, "version": dsl.version},
        "instructions": dsl.description,
    }


def _mcp_tools(dsl: AgentDsl) -> list[dict[str, Any]]:
    tools: list[dict[str, Any]] = []
    for skill in dsl.skills:
        tool: dict[str, Any] = {
            "name": skill.name,
            "description": skill.description or f"{dsl.name}.{skill.name}",
            "inputSchema": skill.input_schema,
        }
        if skill.output_schema:
            tool["outputSchema"] = _ensure_object_schema(skill.output_schema)
        tools.append(tool)
    return tools


async def _mcp_tool_call(
    dsl: AgentDsl,
    skills: dict[str, AgentDslSkill],
    worker: SidecarWorker,
    params: dict[str, Any],
) -> dict[str, Any]:
    name = params.get("name")
    if not isinstance(name, str):
        raise HTTPException(400, "tools/call: missing name")
    arguments = params.get("arguments") or {}
    if not isinstance(arguments, dict):
        raise HTTPException(400, "tools/call: arguments must be object")
    skill = _skill_or_404(skills, name)
    _validate_arguments(skill, arguments)
    result = await worker.call(
        skill,
        SidecarWorkerRequest(
            agent=dsl.name,
            skill=skill.name,
            handler=skill.handler,
            arguments=arguments,
            auth=params.get("auth") if isinstance(params.get("auth"), dict) else None,
        ),
    )
    structured = result if skill.output_schema.get("type") == "object" else {"result": result}
    return {
        "content": [{"type": "text", "text": json.dumps(result, default=str)}],
        "structuredContent": structured,
        "isError": False,
    }


def _ensure_object_schema(schema: dict[str, Any]) -> dict[str, Any]:
    if schema.get("type") == "object":
        return schema
    return {
        "type": "object",
        "properties": {"result": schema},
        "required": ["result"],
    }


def _jsonrpc_result(request_id: Any, result: dict[str, Any]) -> dict[str, Any]:
    return {"jsonrpc": "2.0", "id": request_id, "result": result}


def _jsonrpc_error(request_id: Any, code: int, message: str) -> dict[str, Any]:
    return {
        "jsonrpc": "2.0",
        "id": request_id,
        "error": {"code": code, "message": message},
    }
