"""HTTP client wrapping the ActionLayer REST API.

A small async wrapper kept separate from server.py so it's reusable
(future SDK can import it). All network IO lives here; server.py is
pure protocol translation.

Auth: bearer token from ACTIONLAYER_API_KEY env var. Errors are mapped
to ApiError with the upstream status + JSON detail string when present.
"""

from __future__ import annotations

import os
import uuid
from typing import Any, Optional

import httpx

DEFAULT_API_URL = "https://api.actionlayer.io"
USER_AGENT = "actionlayer-mcp/0.3.4"
DEFAULT_TIMEOUT = httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0)


class ApiError(RuntimeError):
    """Raised when the ActionLayer API returns a non-2xx response."""

    def __init__(self, status: int, detail: str):
        super().__init__(f"ActionLayer API {status}: {detail}")
        self.status = status
        self.detail = detail


class ActionLayerClient:
    """Thin async wrapper. One instance per MCP server lifetime."""

    def __init__(
        self,
        *,
        api_key: Optional[str] = None,
        api_url: Optional[str] = None,
    ):
        self.api_key = api_key or os.environ.get("ACTIONLAYER_API_KEY", "")
        self.api_url = (
            api_url
            or os.environ.get("ACTIONLAYER_API_URL")
            or DEFAULT_API_URL
        ).rstrip("/")
        if not self.api_key:
            raise RuntimeError(
                "ACTIONLAYER_API_KEY env var not set. Run "
                "`actionlayer-mcp configure --api-key ak_...` to set it up."
            )
        self._client = httpx.AsyncClient(
            base_url=self.api_url,
            headers={
                "Authorization": f"Bearer {self.api_key}",
                "User-Agent": USER_AGENT,
            },
            timeout=DEFAULT_TIMEOUT,
        )

    async def aclose(self) -> None:
        await self._client.aclose()

    # --- Tasks --------------------------------------------------------

    async def start_task(
        self,
        *,
        goal: str,
        max_budget_usd: Optional[float] = None,
        webhook_url: Optional[str] = None,
        idempotency_key: Optional[str] = None,
    ) -> dict[str, Any]:
        # An auto-generated idempotency key gives the API a stable replay
        # surface, but each call still gets a fresh ticket — true
        # deduplication requires the agent to pass its own key, which is
        # the right design (only the agent knows whether two same-arg
        # calls are "retry" or "do it again").
        key = idempotency_key or f"mcp-{uuid.uuid4()}"
        body: dict[str, Any] = {"goal": goal}
        if max_budget_usd is not None:
            body["max_budget_usd"] = max_budget_usd
        if webhook_url is not None:
            body["webhook_url"] = webhook_url
        return await self._post("/tasks", json=body, headers={"Idempotency-Key": key})

    async def get_task(self, ticket_id: str) -> dict[str, Any]:
        return await self._get(f"/tasks/{ticket_id}")

    async def reply_task(
        self,
        ticket_id: str,
        *,
        message: Optional[str] = None,
        field: Optional[str] = None,
        value: Optional[str] = None,
        sensitive: bool = False,
        info: Optional[dict[str, Any]] = None,
    ) -> dict[str, Any]:
        """Forward a reply to /tasks/{id}/reply.

        `info` is the info-required payload — pass-through to the
        `info` field on ReplyTaskRequest. Required when the ticket
        carries an `info_request` (operator triggered require-info);
        the API 422s otherwise.
        """
        body: dict[str, Any] = {"sensitive": sensitive}
        if message is not None:
            body["message"] = message
        if field is not None:
            body["field"] = field
        if value is not None:
            body["value"] = value
        if info is not None:
            body["info"] = info
        return await self._post(f"/tasks/{ticket_id}/reply", json=body)

    async def cancel_task(self, ticket_id: str) -> dict[str, Any]:
        return await self._post(f"/tasks/{ticket_id}/cancel", json=None)

    # --- Skills -------------------------------------------------------

    async def list_skills(
        self, *, source_format: Optional[str] = None
    ) -> list[dict[str, Any]]:
        params: dict[str, Any] = {}
        if source_format:
            params["source_format"] = source_format
        result = await self._get("/skills", params=params)
        # /skills returns either a bare list or {"items": [...]} —
        # accept either to stay forward-compatible.
        if isinstance(result, dict) and "items" in result:
            return result["items"]
        return result  # type: ignore[return-value]

    # --- Actions (P10) ------------------------------------------------

    async def list_actions(
        self,
        *,
        provider: Optional[str] = None,
        category: Optional[str] = None,
    ) -> dict[str, Any]:
        """GET /v1/actions — returns the full catalog response shape
        ({count, actions, providers, categories}). The MCP server
        decides what subset to surface to the agent host."""
        params: dict[str, Any] = {}
        if provider:
            params["provider"] = provider
        if category:
            params["category"] = category
        return await self._get("/v1/actions", params=params)

    async def get_action(self, action_id: str) -> dict[str, Any]:
        """GET /v1/actions/{id} — full ActionDefinition with the
        input/output schemas."""
        return await self._get(f"/v1/actions/{action_id}")

    async def invoke_action(
        self,
        action_id: str,
        *,
        inputs: dict[str, Any],
        executor: Optional[str] = None,
    ) -> dict[str, Any]:
        """POST /v1/actions/{id}.

        Two response shapes depending on the action's executor:
          * API actions (gmail, slack, github, ...): run sync. Outcome
            is one of 'succeeded' | 'blocked' | 'failed' with the
            output inline.
          * Browser actions (resy, partiful, usps, ...): return 202
            with outcome='queued' + payload.ticket_id + payload.poll_path.
            Caller polls get_action_ticket(ticket_id) until is_terminal.

        Same shape either way (ActionInvokeResponse). Caller branches
        on outcome to decide whether to poll.
        """
        body: dict[str, Any] = {"inputs": inputs}
        if executor:
            body["executor"] = executor
        return await self._post(f"/v1/actions/{action_id}", json=body)

    async def get_action_ticket(self, ticket_id: str) -> dict[str, Any]:
        """GET /v1/actions/tickets/{id} — poll an async action ticket.

        Returns ActionTicketResponse: state, outcome, output, error,
        blocked_reason, live_view_url, is_terminal, creds_prompt. Poll
        every 3-5s until is_terminal=True. When creds_prompt is set
        the ticket is waiting for the user to submit credentials via
        provide_credentials.
        """
        return await self._get(f"/v1/actions/tickets/{ticket_id}")

    async def provide_credentials(
        self,
        ticket_id: str,
        *,
        fields: dict[str, str],
        remember_for_session: bool = False,
    ) -> dict[str, Any]:
        """POST /v1/actions/tickets/{id}/credentials — submit creds
        for a ticket blocked with creds_required.

        Credentials are KMS-encrypted in transit + at rest in Redis
        with a 15-minute TTL (single-use GETDEL by the worker). Never
        written to Postgres or logs.
        """
        body: dict[str, Any] = {"fields": fields}
        if remember_for_session:
            body["remember_for_session"] = True
        return await self._post(
            f"/v1/actions/tickets/{ticket_id}/credentials", json=body
        )

    # --- Jobs (P6) ----------------------------------------------------

    async def create_job(
        self,
        *,
        goal: str,
        budget_usd: Optional[float] = None,
        deadline: Optional[str] = None,
        webhook_url: Optional[str] = None,
    ) -> dict[str, Any]:
        """POST /v1/jobs — kick off a multi-step planner-driven job.

        `deadline` is ISO 8601 (e.g. '2026-05-15T19:00:00-07:00').
        Returns the job in 'planning' state; caller polls get_job.
        """
        body: dict[str, Any] = {"goal": goal}
        if budget_usd is not None:
            body["budget_usd"] = budget_usd
        if deadline is not None:
            body["deadline"] = deadline
        if webhook_url is not None:
            body["webhook_url"] = webhook_url
        return await self._post("/v1/jobs", json=body)

    async def get_job(self, job_id: str) -> dict[str, Any]:
        """GET /v1/jobs/{id} — returns the full Job + items array."""
        return await self._get(f"/v1/jobs/{job_id}")

    async def cancel_job(self, job_id: str) -> dict[str, Any]:
        return await self._post(f"/v1/jobs/{job_id}/cancel", json=None)

    async def resume_job(self, job_id: str) -> dict[str, Any]:
        return await self._post(f"/v1/jobs/{job_id}/resume", json=None)

    # --- Internals ----------------------------------------------------

    async def _get(
        self, path: str, *, params: Optional[dict[str, Any]] = None
    ) -> Any:
        resp = await self._client.get(path, params=params)
        return _parse(resp)

    async def _post(
        self,
        path: str,
        *,
        json: Optional[dict[str, Any]],
        headers: Optional[dict[str, str]] = None,
    ) -> dict[str, Any]:
        resp = await self._client.post(path, json=json, headers=headers)
        return _parse(resp)


def _parse(resp: httpx.Response) -> Any:
    if resp.is_success:
        if not resp.content:
            return {}
        return resp.json()
    detail: str
    try:
        body = resp.json()
        detail = body.get("detail") if isinstance(body, dict) else str(body)
        if not isinstance(detail, str):
            detail = str(detail)
    except Exception:
        detail = resp.text or resp.reason_phrase
    raise ApiError(resp.status_code, detail)


