# rftools/client.py
"""
Sync and async clients for the rftools.io API.

Usage:
    import rftools
    result = rftools.calculate("vswr-return-loss", {"vswr": 2.5})

    client = rftools.Client(api_key="rfc_live_xxx")
    result = client.calculate("free-space-path-loss", {"frequency": 2400, "distance": 100})
"""
from __future__ import annotations

import os
import time
import warnings
from typing import Any, Sequence

import httpx

from rftools.exceptions import (
    APIError, AuthError, NotFoundError, RateLimitError, ValidationError,
)
from rftools.types import (
    BatchResultItem, CalculatorInfo, CalculatorResult,
    InputField, OutputField,
)

_DEFAULT_BASE_URL = "https://rftools.io/api/py/v1"
_RETRY_STATUSES = {500, 502, 503, 504}


class FreeClientWarning(UserWarning):
    pass


def _build_calculator_info(data: dict) -> CalculatorInfo:
    """Convert raw API JSON to a CalculatorInfo dataclass."""
    inputs = tuple(
        InputField(
            key=f["key"],
            label=f.get("label", f["key"]),
            unit=f.get("unit", ""),
            default=float(f.get("defaultValue", 0)),
            min=float(f["min"]) if f.get("min") is not None else None,
            max=float(f["max"]) if f.get("max") is not None else None,
            description=f.get("description", ""),
        )
        for f in data.get("inputs", [])
    )
    outputs = tuple(
        OutputField(
            key=o["key"],
            label=o.get("label", o["key"]),
            unit=o.get("unit", ""),
            description=o.get("description", ""),
        )
        for o in data.get("outputs", [])
    )
    return CalculatorInfo(
        slug=data["slug"],
        title=data.get("title", data["slug"]),
        category=data.get("category", ""),
        description=data.get("description", ""),
        inputs=inputs,
        outputs=outputs,
        keywords=tuple(data.get("keywords", [])),
    )


class Client:
    """
    Synchronous rftools.io API client.

    Parameters
    ----------
    api_key : str or None
        API key (format: ``rfc_...``). If None, reads ``RFTOOLS_API_KEY``
        env var. An API key is required — free tier keys include 5 calls/month.
    base_url : str
        Override the API base URL (useful for testing).
    timeout : float
        Request timeout in seconds (default 30).
    max_retries : int
        Number of retries on 5xx errors (default 3).
    """

    def __init__(
        self,
        api_key: str | None = None,
        base_url: str = _DEFAULT_BASE_URL,
        timeout: float = 30.0,
        max_retries: int = 3,
    ) -> None:
        self._api_key = api_key or os.environ.get("RFTOOLS_API_KEY")
        self._base_url = base_url.rstrip("/")
        self._timeout = timeout
        self._max_retries = max_retries
        self._warned_free = False
        self._http = httpx.Client(
            base_url=self._base_url,
            timeout=httpx.Timeout(connect=5.0, read=timeout, write=10.0, pool=5.0),
            headers=self._auth_headers(),
        )

    def _auth_headers(self) -> dict[str, str]:
        if self._api_key:
            return {
                "Authorization": f"Bearer {self._api_key}",
                "X-API-Key": self._api_key,
            }
        return {}

    def _warn_free(self) -> None:
        if not self._warned_free and not self._api_key:
            warnings.warn(
                "No API key set. Get a free key at https://rftools.io/pricing "
                "(5 calls/month free, 10,000/month on the API tier).",
                FreeClientWarning,
                stacklevel=3,
            )
            self._warned_free = True

    def _request(self, method: str, path: str, **kwargs: Any) -> dict:
        """Make an HTTP request with retry logic. Raises typed exceptions."""
        last_exc: Exception | None = None
        for attempt in range(self._max_retries + 1):
            try:
                resp = self._http.request(method, path, **kwargs)
            except httpx.TimeoutException as exc:
                raise APIError(f"Request timed out: {exc}", status_code=0) from exc
            except httpx.RequestError as exc:
                raise APIError(f"Network error: {exc}", status_code=0) from exc

            if resp.status_code == 200:
                return resp.json()
            if resp.status_code == 401:
                detail = resp.json().get("detail", "")
                if "quota" in detail.lower():
                    raise RateLimitError(
                        "Monthly API quota exceeded. "
                        "Upgrade at https://rftools.io/pricing or wait for month reset."
                    )
                raise AuthError(f"Authentication failed: {detail}")
            if resp.status_code == 403:
                raise AuthError(f"Forbidden: {resp.json().get('detail', 'Access denied')}")
            if resp.status_code == 404:
                raise NotFoundError(resp.json().get("detail", "Resource not found"))
            if resp.status_code == 422:
                body = resp.json()
                raise ValidationError("Input validation failed", detail=body.get("detail", []))
            if resp.status_code == 429:
                retry_after = int(resp.headers.get("Retry-After", 0)) or None
                raise RateLimitError("Rate limit exceeded", retry_after=retry_after)
            if resp.status_code in _RETRY_STATUSES:
                if attempt < self._max_retries:
                    time.sleep(2 ** attempt)
                    last_exc = APIError(
                        f"Server error {resp.status_code}", status_code=resp.status_code
                    )
                    continue
                raise APIError(
                    f"Server error after {self._max_retries} retries: {resp.status_code}",
                    status_code=resp.status_code,
                )
            raise APIError(
                f"Unexpected HTTP {resp.status_code}: {resp.text[:200]}",
                status_code=resp.status_code,
            )
        raise last_exc or APIError("Unknown error", status_code=0)

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def calculate(self, slug: str, inputs: dict[str, float]) -> CalculatorResult:
        """
        Run a single calculator.

        Parameters
        ----------
        slug : str
            Calculator slug, e.g. ``"vswr-return-loss"``.
        inputs : dict[str, float]
            Input values keyed by their field key.

        Returns
        -------
        CalculatorResult
            Contains ``values`` dict, ``warnings`` list, ``errors`` list.

        Examples
        --------
        >>> import rftools
        >>> result = rftools.calculate("vswr-return-loss", {"vswr": 2.5})
        >>> result["returnLoss"]
        9.54
        """
        self._warn_free()
        data = self._request("POST", "/calculate", json={"slug": slug, "inputs": inputs})
        return CalculatorResult(
            slug=data["slug"],
            values=data.get("values", {}),
            warnings=data.get("warnings", []),
            errors=data.get("errors", []),
        )

    def batch(
        self,
        calculations: Sequence[tuple[str, dict[str, float]]],
    ) -> list[BatchResultItem]:
        """
        Run multiple calculators in one request (API tier only).

        Parameters
        ----------
        calculations : sequence of (slug, inputs) tuples
            Up to 50 items per call.

        Returns
        -------
        list[BatchResultItem]
            One item per input. Items where ``.ok`` is False contain ``.error``.

        Examples
        --------
        >>> results = client.batch([
        ...     ("vswr-return-loss", {"vswr": 1.5}),
        ...     ("vswr-return-loss", {"vswr": 2.0}),
        ... ])
        >>> [r["returnLoss"] for r in results if r.ok]
        [13.98, 9.54]
        """
        if not self._api_key:
            raise AuthError("batch() requires an API key. Get one at https://rftools.io/pricing")
        if len(calculations) > 50:
            raise ValidationError("Batch limit is 50 calculations per request")
        payload = {
            "calculations": [
                {"slug": slug, "inputs": inputs}
                for slug, inputs in calculations
            ]
        }
        data = self._request("POST", "/calculate/batch", json=payload)
        return [
            BatchResultItem(
                slug=item["slug"],
                values=item.get("values"),
                warnings=item.get("warnings", []),
                error=item.get("error"),
            )
            for item in data["results"]
        ]

    def list_calculators(self, category: str | None = None) -> list[CalculatorInfo]:
        """
        Return metadata for all (or filtered) calculators.

        Parameters
        ----------
        category : str or None
            Filter by category slug (e.g. ``"rf"``, ``"pcb"``). None = all.

        Returns
        -------
        list[CalculatorInfo]
        """
        from rftools._catalog import CALCULATORS
        result = list(CALCULATORS.values())
        if category:
            result = [c for c in result if c.category == category]
        return result

    def get_calculator(self, slug: str) -> CalculatorInfo:
        """
        Return metadata for a single calculator.

        Raises
        ------
        NotFoundError
            If the slug is not in the local catalog.
        """
        from rftools._catalog import CALCULATORS
        if slug not in CALCULATORS:
            raise NotFoundError(f"Calculator '{slug}' not found in local catalog")
        return CALCULATORS[slug]

    def close(self) -> None:
        self._http.close()

    def __enter__(self) -> "Client":
        return self

    def __exit__(self, *args: Any) -> None:
        self.close()


class AsyncClient(Client):
    """
    Asynchronous rftools.io API client.

    Usage::

        async with rftools.AsyncClient(api_key="rfc_...") as client:
            result = await client.calculate("vswr-return-loss", {"vswr": 2.5})
    """

    def __init__(self, **kwargs: Any) -> None:
        # Do NOT call super().__init__ — httpx.Client is sync-only
        self._api_key = kwargs.get("api_key") or os.environ.get("RFTOOLS_API_KEY")
        self._base_url = kwargs.get("base_url", _DEFAULT_BASE_URL).rstrip("/")
        self._timeout = kwargs.get("timeout", 30.0)
        self._max_retries = kwargs.get("max_retries", 3)
        self._warned_free = False
        self._http = httpx.AsyncClient(
            base_url=self._base_url,
            timeout=httpx.Timeout(connect=5.0, read=self._timeout, write=10.0, pool=5.0),
            headers=self._auth_headers(),
        )

    async def _request_async(self, method: str, path: str, **kwargs: Any) -> dict:
        """Async version of _request with the same retry/error logic."""
        import asyncio
        last_exc: Exception | None = None
        for attempt in range(self._max_retries + 1):
            try:
                resp = await self._http.request(method, path, **kwargs)
            except httpx.TimeoutException as exc:
                raise APIError(f"Request timed out: {exc}", status_code=0) from exc
            except httpx.RequestError as exc:
                raise APIError(f"Network error: {exc}", status_code=0) from exc

            if resp.status_code == 200:
                return resp.json()
            if resp.status_code == 401:
                detail = resp.json().get("detail", "")
                if "quota" in detail.lower():
                    raise RateLimitError("Monthly API quota exceeded.")
                raise AuthError(f"Authentication failed: {detail}")
            if resp.status_code == 403:
                raise AuthError(resp.json().get("detail", "Access denied"))
            if resp.status_code == 404:
                raise NotFoundError(resp.json().get("detail", "Resource not found"))
            if resp.status_code == 422:
                body = resp.json()
                raise ValidationError("Input validation failed", detail=body.get("detail", []))
            if resp.status_code == 429:
                retry_after = int(resp.headers.get("Retry-After", 0)) or None
                raise RateLimitError("Rate limit exceeded", retry_after=retry_after)
            if resp.status_code in _RETRY_STATUSES:
                if attempt < self._max_retries:
                    await asyncio.sleep(2 ** attempt)
                    last_exc = APIError(f"Server error {resp.status_code}", resp.status_code)
                    continue
                raise APIError(f"Server error after retries: {resp.status_code}", resp.status_code)
            raise APIError(f"Unexpected HTTP {resp.status_code}", resp.status_code)
        raise last_exc or APIError("Unknown error", status_code=0)

    async def calculate(self, slug: str, inputs: dict[str, float]) -> CalculatorResult:
        self._warn_free()
        data = await self._request_async("POST", "/calculate", json={"slug": slug, "inputs": inputs})
        return CalculatorResult(
            slug=data["slug"],
            values=data.get("values", {}),
            warnings=data.get("warnings", []),
            errors=data.get("errors", []),
        )

    async def batch(self, calculations: Sequence[tuple[str, dict[str, float]]]) -> list[BatchResultItem]:
        if not self._api_key:
            raise AuthError("batch() requires an API key.")
        if len(calculations) > 50:
            raise ValidationError("Batch limit is 50 calculations per request")
        payload = {"calculations": [{"slug": s, "inputs": i} for s, i in calculations]}
        data = await self._request_async("POST", "/calculate/batch", json=payload)
        return [
            BatchResultItem(slug=it["slug"], values=it.get("values"),
                            warnings=it.get("warnings", []), error=it.get("error"))
            for it in data["results"]
        ]

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

    async def __aenter__(self) -> "AsyncClient":
        return self

    async def __aexit__(self, *args: Any) -> None:
        await self.close()
