"""
Core Skytells client classes.

Provides :class:`SkytellsClient` (synchronous) and :class:`AsyncSkytellsClient`
(async), plus :class:`Prediction`, :class:`PredictionsAPI`, and :class:`ModelsAPI`
helper objects — mirroring the JavaScript SDK class hierarchy exactly.
"""

from __future__ import annotations

import asyncio
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable, Dict, List, Optional, Union

from ._endpoints import API_BASE_URL, ENDPOINTS
from ._http import HTTP
from .types.models import ModelFieldsOptions
from .types.predict import (
    PredictionRequest,
    PredictionStatus,
    PredictionsListOptions,
    QueueItem,
    RunOptions,
    WaitOptions,
)
from .types.shared import (
    ClientOptions,
    OnProgressCallback,
    PaginatedResponse,
    RetryOptions,
    SkytellsError,
)

DEFAULT_POLL_INTERVAL: int = 5000

TERMINAL_STATUSES = {
    PredictionStatus.SUCCEEDED,
    PredictionStatus.FAILED,
    PredictionStatus.CANCELLED,
}


# ── Prediction ──────────────────────────────────────────────────────────────


class Prediction:
    """
    Represents a completed prediction with convenience accessors and lifecycle methods.

    Returned by :meth:`SkytellsClient.run`. Wraps the raw API response dict and
    exposes ``.output`` for results, plus ``.cancel()`` and ``.delete()`` for
    lifecycle management.

    Example::

        prediction = client.run("flux-pro", input={"prompt": "a cat"})
        image_url = prediction.output           # str or list[str]
        print(prediction.id, prediction.status)
        prediction.delete()
    """

    def __init__(self, http: HTTP, data: Dict[str, Any]) -> None:
        self._http = http
        self._data = data

    # ── Properties ─────────────────────────────────────────────────

    @property
    def response(self) -> Dict[str, Any]:
        """The full raw prediction response dict from the API."""
        return self._data

    @property
    def id(self) -> str:
        """Unique prediction identifier (e.g. ``"pred_abc123"``)."""
        return self._data["id"]

    @property
    def status(self) -> str:
        """Current lifecycle status string."""
        return self._data["status"]

    @property
    def output(self) -> Optional[Union[str, List[str]]]:
        """
        Raw prediction output — a ``str``, ``list[str]``, or ``None`` if not yet complete.
        """
        return self._data.get("output")

    # ── Methods ────────────────────────────────────────────────────

    def outputs(self) -> Optional[Union[str, List[str]]]:
        """
        Returns the output normalised to a single value:

        - ``None`` / missing → ``None``
        - ``"https://..."`` → ``"https://..."``
        - ``["https://..."]`` → ``"https://..."``  (single-element array unwrapped)
        - ``["a", "b"]`` → ``["a", "b"]``  (multi-element array kept as-is)
        """
        out = self._data.get("output")
        if out is None:
            return None
        if isinstance(out, str):
            return out
        if isinstance(out, list):
            if len(out) == 1:
                return out[0]
            return out
        return out

    def raw(self) -> Dict[str, Any]:
        """Returns the full raw prediction response as a plain dict."""
        return self._data

    def cancel(self) -> Dict[str, Any]:
        """
        Cancels this prediction.

        Only works if the prediction is still in progress
        (``pending``, ``starting``, ``started``, or ``processing``).

        Returns:
            Updated prediction response dict with ``status: 'cancelled'``.

        Raises:
            SkytellsError: If the prediction cannot be cancelled.
        """
        return self._http.request("POST", ENDPOINTS.CANCEL_PREDICTION_BY_ID(self._data["id"]))

    def delete(self) -> Dict[str, Any]:
        """
        Deletes this prediction and its associated output/assets from storage.

        Returns:
            Prediction response dict confirming deletion.

        Raises:
            SkytellsError: If the prediction cannot be deleted.
        """
        return self._http.request("DELETE", ENDPOINTS.DELETE_PREDICTION_BY_ID(self._data["id"]))

    def __repr__(self) -> str:
        return f"Prediction(id={self.id!r}, status={self.status!r})"


# ── PredictionsAPI ───────────────────────────────────────────────────────────


class PredictionsAPI:
    """
    Sub-API for managing predictions. Accessible via ``client.predictions``.

    Provides methods to create predictions in the background, fetch them by ID,
    and list them with filters.

    Example::

        prediction = client.predictions.create({
            "model": "flux-pro",
            "input": {"prompt": "A sunset"},
        })
        result = client.predictions.get(prediction["id"])
        page = client.predictions.list(model="flux-pro", since="2026-01-01")
    """

    def __init__(self, http: HTTP) -> None:
        self._http = http

    def create(self, payload: Union[Dict[str, Any], PredictionRequest]) -> Dict[str, Any]:
        """
        Creates a prediction in the background (does not wait for completion).

        Args:
            payload: Prediction request as a dict or :class:`~skytells.types.predict.PredictionRequest`.

        Returns:
            Initial prediction response dict with ``status: 'pending'``.

        Raises:
            SkytellsError: On API errors.
        """
        body = payload.to_dict() if isinstance(payload, PredictionRequest) else dict(payload)
        body["await"] = False
        return self._http.request("POST", ENDPOINTS.PREDICT, body)

    def get(self, id: str) -> Dict[str, Any]:
        """
        Fetches a prediction by its ID.

        Args:
            id: The prediction ID.

        Returns:
            Prediction response dict with current status, output, metrics, etc.

        Raises:
            SkytellsError: If the prediction is not found.
        """
        return self._http.request("GET", ENDPOINTS.PREDICTION_BY_ID(id))

    def list(
        self,
        page: Optional[int] = None,
        since: Optional[str] = None,
        until: Optional[str] = None,
        model: Optional[str] = None,
        options: Optional[Union[Dict[str, Any], PredictionsListOptions]] = None,
    ) -> PaginatedResponse:
        """
        Lists predictions with optional filters and pagination.

        Keyword args can be passed directly or via an ``options`` dict /
        :class:`~skytells.types.predict.PredictionsListOptions`.

        Args:
            page:    Page number (default: 1).
            since:   Only include predictions on or after this date (``YYYY-MM-DD``).
            until:   Only include predictions on or before this date (``YYYY-MM-DD``).
            model:   Filter by model slug.
            options: Alternative way to pass the above as a single object.

        Returns:
            :class:`~skytells.types.shared.PaginatedResponse` with ``data`` and ``pagination``.
        """
        if options is not None:
            if isinstance(options, PredictionsListOptions):
                page = page or options.page
                since = since or options.since
                until = until or options.until
                model = model or options.model
            else:
                page = page or options.get("page")
                since = since or options.get("since")
                until = until or options.get("until")
                model = model or options.get("model")

        params: List[str] = []
        if page is not None:
            params.append(f"page={page}")
        if since is not None:
            params.append(f"from={since}")
        if until is not None:
            params.append(f"to={until}")
        if model is not None:
            params.append(f"model={model}")

        path = ENDPOINTS.PREDICTIONS
        if params:
            path += "?" + "&".join(params)

        raw = self._http.request("GET", path)
        return PaginatedResponse(raw)


# ── ModelsAPI ────────────────────────────────────────────────────────────────


class ModelsAPI:
    """
    Sub-API for browsing and fetching models. Accessible via ``client.models``.

    Example::

        all_models = client.models.list()
        model = client.models.get("flux-pro", fields=["input_schema"])
    """

    def __init__(self, http: HTTP) -> None:
        self._http = http

    def list(
        self,
        fields: Optional[List[str]] = None,
        options: Optional[Union[Dict[str, Any], ModelFieldsOptions]] = None,
    ) -> List[Dict[str, Any]]:
        """
        Lists all available models on the Skytells platform.

        Args:
            fields:  Additional fields to include (e.g. ``["input_schema"]``).
            options: Alternative way to pass fields as a dict or
                     :class:`~skytells.types.models.ModelFieldsOptions`.

        Returns:
            List of model dicts.
        """
        resolved_fields: List[str] = []
        if fields:
            resolved_fields = fields
        elif options is not None:
            if isinstance(options, ModelFieldsOptions):
                resolved_fields = options.fields
            elif isinstance(options, dict):
                resolved_fields = options.get("fields", [])

        path = ENDPOINTS.MODELS
        if resolved_fields:
            path += f"?fields={','.join(resolved_fields)}"

        return self._http.request("GET", path)

    def get(
        self,
        slug: str,
        fields: Optional[List[str]] = None,
        options: Optional[Union[Dict[str, Any], ModelFieldsOptions]] = None,
    ) -> Dict[str, Any]:
        """
        Fetches a single model by its slug.

        Args:
            slug:    Model slug (e.g. ``"flux-pro"``).
            fields:  Additional fields to include.
            options: Alternative way to pass fields.

        Returns:
            Model dict.

        Raises:
            SkytellsError: If the model is not found.
        """
        resolved_fields: List[str] = []
        if fields:
            resolved_fields = fields
        elif options is not None:
            if isinstance(options, ModelFieldsOptions):
                resolved_fields = options.fields
            elif isinstance(options, dict):
                resolved_fields = options.get("fields", [])

        path = ENDPOINTS.MODEL_BY_SLUG(slug)
        if resolved_fields:
            path += f"?fields={','.join(resolved_fields)}"

        return self._http.request("GET", path)


# ── SkytellsClient ───────────────────────────────────────────────────────────


class SkytellsClient:
    """
    The main synchronous Skytells API client.

    Create via :func:`~skytells.Skytells` or instantiate directly.

    Args:
        api_key: Your Skytells API key (starts with ``sk-``).
        options: Client configuration as a :class:`~skytells.types.shared.ClientOptions`
                 instance, dict, or keyword arguments.

    Example::

        import skytells

        client = skytells.Skytells("sk-your-api-key", timeout=30000)

        prediction = client.run("flux-pro", input={"prompt": "A cat"})
        print(prediction.output)

        models = client.models.list()
        bg = client.predictions.create({"model": "flux-pro", "input": {"prompt": "dog"}})
        result = client.wait(bg)
    """

    def __init__(
        self,
        api_key: Optional[str] = None,
        options: Optional[Union[ClientOptions, Dict[str, Any]]] = None,
        *,
        base_url: Optional[str] = None,
        timeout: int = 60_000,
        headers: Optional[Dict[str, str]] = None,
        retry: Optional[Any] = None,
    ) -> None:
        if isinstance(options, ClientOptions):
            opts = options
        elif isinstance(options, dict):
            opts = ClientOptions(
                base_url=options.get("base_url", base_url),
                timeout=options.get("timeout", timeout),
                headers=options.get("headers", headers),
                retry=options.get("retry", retry),
            )
        else:
            opts = ClientOptions(
                base_url=base_url,
                timeout=timeout,
                headers=headers,
                retry=retry,
            )

        self._http = HTTP(
            api_key=api_key,
            base_url=opts.base_url or API_BASE_URL,
            timeout=opts.timeout,
            headers=opts.headers,
            retry=opts.retry,
        )
        self.predictions = PredictionsAPI(self._http)
        self.models = ModelsAPI(self._http)
        self._queue: List[QueueItem] = []

    # ── Core methods ───────────────────────────────────────────────

    def predict(self, payload: Union[Dict[str, Any], PredictionRequest]) -> Dict[str, Any]:
        """
        Sends a prediction request to the Skytells API.

        Low-level method — for most use cases prefer :meth:`run`, which waits for
        completion and returns a :class:`Prediction` with convenience methods.

        Args:
            payload: Prediction request dict or :class:`~skytells.types.predict.PredictionRequest`.

        Returns:
            Prediction response dict.

        Raises:
            SkytellsError: On API errors.
        """
        body = payload.to_dict() if isinstance(payload, PredictionRequest) else dict(payload)
        return self._http.request("POST", ENDPOINTS.PREDICT, body)

    def run(
        self,
        model: str,
        input: Optional[Dict[str, Any]] = None,
        on_progress: Optional[OnProgressCallback] = None,
        *,
        webhook: Optional[Dict[str, Any]] = None,
        stream: bool = False,
        options: Optional[Union[Dict[str, Any], RunOptions]] = None,
    ) -> Prediction:
        """
        Runs a model, waits for completion, and returns a :class:`Prediction`.

        This is the recommended way to generate content. It automatically sets
        ``await: true``, checks for failures, and returns a ``Prediction`` with
        ``.output``, ``.cancel()``, and ``.delete()``.

        If ``on_progress`` is provided, the prediction is created in the background
        and polled every 5 seconds. The callback receives the latest state on each poll.

        Args:
            model:       Model slug (e.g. ``"flux-pro"``).
            input:       Key-value input parameters for the model.
            on_progress: Optional callback invoked on each poll.
            webhook:     Optional webhook configuration dict.
            stream:      Enable streaming (default: ``False``).
            options:     Alternative way to pass ``input``/``webhook``/``stream``
                         as a :class:`~skytells.types.predict.RunOptions` or dict.

        Returns:
            :class:`Prediction` with output and lifecycle methods.

        Raises:
            SkytellsError: On API errors or if the prediction fails.

        Example::

            prediction = client.run("flux-pro", input={"prompt": "A cat"})
            print(prediction.output)

            # With progress tracking
            def on_progress(p):
                print(p["status"], p.get("metrics", {}).get("progress"))

            prediction = client.run("flux-pro", input={"prompt": "..."}, on_progress=on_progress)
        """
        resolved_input, resolved_webhook, resolved_stream = self._resolve_run_args(
            input, webhook, stream, options
        )

        if on_progress is not None:
            data = self.predictions.create({
                "model": model,
                "input": resolved_input,
                **({"webhook": resolved_webhook} if resolved_webhook else {}),
                **({"stream": True} if resolved_stream else {}),
            })
            data = self.wait(data, on_progress=on_progress)
        else:
            body: Dict[str, Any] = {
                "model": model,
                "input": resolved_input,
                "await": True,
            }
            if resolved_webhook:
                body["webhook"] = resolved_webhook
            if resolved_stream:
                body["stream"] = True
            data = self.predict(body)

        if data.get("status") == PredictionStatus.FAILED:
            raise SkytellsError(
                data.get("response") or "Prediction failed",
                "PREDICTION_FAILED",
                f"Prediction {data.get('id')} failed",
            )

        return Prediction(self._http, data)

    def wait(
        self,
        prediction: Dict[str, Any],
        options: Optional[Union[Dict[str, Any], WaitOptions]] = None,
        on_progress: Optional[OnProgressCallback] = None,
    ) -> Dict[str, Any]:
        """
        Polls a prediction until it reaches a terminal status.

        Args:
            prediction:  Prediction response dict (must have an ``id``).
            options:     Polling options — ``interval`` (ms) and ``max_wait`` (ms).
            on_progress: Optional callback invoked on each poll.

        Returns:
            Final prediction response dict.

        Raises:
            SkytellsError: If ``max_wait`` is exceeded (``WAIT_TIMEOUT``).
        """
        if isinstance(options, WaitOptions):
            interval_ms = options.interval
            max_wait = options.max_wait
        elif isinstance(options, dict):
            interval_ms = options.get("interval", DEFAULT_POLL_INTERVAL)
            max_wait = options.get("max_wait")
        else:
            interval_ms = DEFAULT_POLL_INTERVAL
            max_wait = None

        interval_s = interval_ms / 1000.0
        start = time.monotonic()
        current = prediction

        while current.get("status") not in TERMINAL_STATUSES:
            if max_wait is not None:
                elapsed_ms = (time.monotonic() - start) * 1000
                if elapsed_ms >= max_wait:
                    raise SkytellsError(
                        f"Prediction {current.get('id')} did not complete within {max_wait}ms",
                        "WAIT_TIMEOUT",
                        f"Timed out after {max_wait}ms. Last status: {current.get('status')}",
                        408,
                    )

            time.sleep(interval_s)
            current = self.predictions.get(current["id"])

            if on_progress is not None:
                on_progress(current)

        return current

    def queue(self, payload: Union[Dict[str, Any], PredictionRequest]) -> None:
        """
        Adds a prediction request to the local queue.

        Queued items are not sent to the API until :meth:`dispatch` is called.

        Args:
            payload: Prediction request dict or :class:`~skytells.types.predict.PredictionRequest`.
        """
        if isinstance(payload, PredictionRequest):
            self._queue.append(QueueItem(payload))
        else:
            self._queue.append(QueueItem(PredictionRequest.from_dict(payload)))

    def dispatch(self) -> List[Dict[str, Any]]:
        """
        Dispatches all queued predictions, sending them to the API sequentially.

        Clears the queue after dispatching. Each prediction is created in the
        background (non-blocking). Use :meth:`wait` on individual results to poll.

        Returns:
            List of prediction response dicts, one per queued item.

        Raises:
            SkytellsError: If any prediction request fails.
        """
        items = self._queue[:]
        self._queue.clear()
        results: List[Dict[str, Any]] = []
        for item in items:
            results.append(self.predictions.create(item.request))
        return results

    def stream_prediction(self, id: str) -> Dict[str, Any]:
        """
        Retrieves the streaming endpoint for a prediction.

        Args:
            id: Prediction ID.

        Returns:
            Prediction response dict with ``urls.stream``.
        """
        return self._http.request("GET", ENDPOINTS.STREAM_PREDICTION_BY_ID(id))

    def cancel_prediction(self, id: str) -> Dict[str, Any]:
        """
        Cancels a running prediction by ID.

        Args:
            id: Prediction ID to cancel.

        Returns:
            Updated prediction response dict with ``status: 'cancelled'``.
        """
        return self._http.request("POST", ENDPOINTS.CANCEL_PREDICTION_BY_ID(id))

    def delete_prediction(self, id: str) -> Dict[str, Any]:
        """
        Deletes a prediction and its associated output/assets.

        Args:
            id: Prediction ID to delete.

        Returns:
            Prediction response dict confirming deletion.
        """
        return self._http.request("DELETE", ENDPOINTS.DELETE_PREDICTION_BY_ID(id))

    # ── Deprecated aliases ─────────────────────────────────────────

    def list_predictions(self, page: Optional[int] = None) -> PaginatedResponse:
        """
        .. deprecated::
            Use ``client.predictions.list()`` instead.
        """
        import warnings
        warnings.warn(
            "list_predictions() is deprecated — use client.predictions.list() instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.predictions.list(page=page)

    def list_models(
        self,
        fields: Optional[List[str]] = None,
        options: Optional[Union[Dict[str, Any], ModelFieldsOptions]] = None,
    ) -> List[Dict[str, Any]]:
        """
        .. deprecated::
            Use ``client.models.list()`` instead.
        """
        import warnings
        warnings.warn(
            "list_models() is deprecated — use client.models.list() instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.models.list(fields=fields, options=options)

    def get_prediction(self, id: str) -> Dict[str, Any]:
        """
        .. deprecated::
            Use ``client.predictions.get()`` instead.
        """
        import warnings
        warnings.warn(
            "get_prediction() is deprecated — use client.predictions.get() instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.predictions.get(id)

    def get_model(
        self,
        slug: str,
        fields: Optional[List[str]] = None,
        options: Optional[Union[Dict[str, Any], ModelFieldsOptions]] = None,
    ) -> Dict[str, Any]:
        """
        .. deprecated::
            Use ``client.models.get()`` instead.
        """
        import warnings
        warnings.warn(
            "get_model() is deprecated — use client.models.get() instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.models.get(slug, fields=fields, options=options)

    # ── Internal helpers ───────────────────────────────────────────

    @staticmethod
    def _resolve_run_args(
        input: Optional[Dict[str, Any]],
        webhook: Optional[Dict[str, Any]],
        stream: bool,
        options: Optional[Any],
    ):
        if options is not None:
            if isinstance(options, RunOptions):
                return (
                    input if input is not None else options.input,
                    webhook if webhook is not None else options.webhook,
                    stream or options.stream,
                )
            elif isinstance(options, dict):
                return (
                    input if input is not None else options.get("input", {}),
                    webhook if webhook is not None else options.get("webhook"),
                    stream or options.get("stream", False),
                )
        return (input or {}, webhook, stream)


# ── AsyncSkytellsClient ──────────────────────────────────────────────────────


class AsyncSkytellsClient:
    """
    Async variant of :class:`SkytellsClient` for use with :mod:`asyncio`.

    All methods are coroutines (``async def``). HTTP calls run in a thread
    pool executor to avoid blocking the event loop while preserving zero
    external dependencies.

    Args:
        api_key: Your Skytells API key.
        options: Same options as :class:`SkytellsClient`.

    Example::

        import asyncio
        import skytells

        async def main():
            client = skytells.AsyncSkytellsClient("sk-your-api-key")
            prediction = await client.run("flux-pro", input={"prompt": "A cat"})
            print(prediction.output)
            await prediction.delete()

        asyncio.run(main())
    """

    def __init__(
        self,
        api_key: Optional[str] = None,
        options: Optional[Union[ClientOptions, Dict[str, Any]]] = None,
        *,
        base_url: Optional[str] = None,
        timeout: int = 60_000,
        headers: Optional[Dict[str, str]] = None,
        retry: Optional[Any] = None,
    ) -> None:
        self._sync = SkytellsClient(
            api_key=api_key,
            options=options,
            base_url=base_url,
            timeout=timeout,
            headers=headers,
            retry=retry,
        )
        self._executor = ThreadPoolExecutor(max_workers=8, thread_name_prefix="skytells")

        self.predictions = _AsyncPredictionsAPI(self._sync.predictions, self._executor)
        self.models = _AsyncModelsAPI(self._sync.models, self._executor)
        self._queue: List[QueueItem] = []

    async def _run_sync(self, fn: Callable, *args, **kwargs) -> Any:
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(self._executor, lambda: fn(*args, **kwargs))

    async def predict(self, payload: Union[Dict[str, Any], PredictionRequest]) -> Dict[str, Any]:
        """Async version of :meth:`SkytellsClient.predict`."""
        return await self._run_sync(self._sync.predict, payload)

    async def run(
        self,
        model: str,
        input: Optional[Dict[str, Any]] = None,
        on_progress: Optional[Callable[[Dict[str, Any]], None]] = None,
        *,
        webhook: Optional[Dict[str, Any]] = None,
        stream: bool = False,
        options: Optional[Union[Dict[str, Any], RunOptions]] = None,
    ) -> "AsyncPrediction":
        """Async version of :meth:`SkytellsClient.run`."""
        resolved_input, resolved_webhook, resolved_stream = SkytellsClient._resolve_run_args(
            input, webhook, stream, options
        )

        if on_progress is not None:
            data = await self.predictions.create({
                "model": model,
                "input": resolved_input,
                **({"webhook": resolved_webhook} if resolved_webhook else {}),
                **({"stream": True} if resolved_stream else {}),
            })
            data = await self.wait(data, on_progress=on_progress)
        else:
            body: Dict[str, Any] = {
                "model": model,
                "input": resolved_input,
                "await": True,
            }
            if resolved_webhook:
                body["webhook"] = resolved_webhook
            if resolved_stream:
                body["stream"] = True
            data = await self.predict(body)

        if data.get("status") == PredictionStatus.FAILED:
            raise SkytellsError(
                data.get("response") or "Prediction failed",
                "PREDICTION_FAILED",
                f"Prediction {data.get('id')} failed",
            )

        return AsyncPrediction(self._sync._http, data, self._executor)

    async def wait(
        self,
        prediction: Dict[str, Any],
        options: Optional[Union[Dict[str, Any], WaitOptions]] = None,
        on_progress: Optional[Callable[[Dict[str, Any]], None]] = None,
    ) -> Dict[str, Any]:
        """Async version of :meth:`SkytellsClient.wait`."""
        if isinstance(options, WaitOptions):
            interval_ms = options.interval
            max_wait = options.max_wait
        elif isinstance(options, dict):
            interval_ms = options.get("interval", DEFAULT_POLL_INTERVAL)
            max_wait = options.get("max_wait")
        else:
            interval_ms = DEFAULT_POLL_INTERVAL
            max_wait = None

        interval_s = interval_ms / 1000.0
        start = time.monotonic()
        current = prediction

        while current.get("status") not in TERMINAL_STATUSES:
            if max_wait is not None:
                elapsed_ms = (time.monotonic() - start) * 1000
                if elapsed_ms >= max_wait:
                    raise SkytellsError(
                        f"Prediction {current.get('id')} did not complete within {max_wait}ms",
                        "WAIT_TIMEOUT",
                        f"Timed out after {max_wait}ms. Last status: {current.get('status')}",
                        408,
                    )

            await asyncio.sleep(interval_s)
            current = await self.predictions.get(current["id"])

            if on_progress is not None:
                on_progress(current)

        return current

    def queue(self, payload: Union[Dict[str, Any], PredictionRequest]) -> None:
        """Async version of :meth:`SkytellsClient.queue`."""
        if isinstance(payload, PredictionRequest):
            self._queue.append(QueueItem(payload))
        else:
            self._queue.append(QueueItem(PredictionRequest.from_dict(payload)))

    async def dispatch(self) -> List[Dict[str, Any]]:
        """Async version of :meth:`SkytellsClient.dispatch`."""
        items = self._queue[:]
        self._queue.clear()
        tasks = [self.predictions.create(item.request) for item in items]
        return list(await asyncio.gather(*tasks))

    async def stream_prediction(self, id: str) -> Dict[str, Any]:
        """Async version of :meth:`SkytellsClient.stream_prediction`."""
        return await self._run_sync(self._sync.stream_prediction, id)

    async def cancel_prediction(self, id: str) -> Dict[str, Any]:
        """Async version of :meth:`SkytellsClient.cancel_prediction`."""
        return await self._run_sync(self._sync.cancel_prediction, id)

    async def delete_prediction(self, id: str) -> Dict[str, Any]:
        """Async version of :meth:`SkytellsClient.delete_prediction`."""
        return await self._run_sync(self._sync.delete_prediction, id)

    async def list_predictions(self, page: Optional[int] = None) -> PaginatedResponse:
        """.. deprecated:: Use ``client.predictions.list()`` instead."""
        import warnings
        warnings.warn(
            "list_predictions() is deprecated — use client.predictions.list() instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        return await self.predictions.list(page=page)

    async def list_models(self, fields: Optional[List[str]] = None) -> List[Dict[str, Any]]:
        """.. deprecated:: Use ``client.models.list()`` instead."""
        import warnings
        warnings.warn(
            "list_models() is deprecated — use client.models.list() instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        return await self.models.list(fields=fields)

    async def get_prediction(self, id: str) -> Dict[str, Any]:
        """.. deprecated:: Use ``client.predictions.get()`` instead."""
        import warnings
        warnings.warn(
            "get_prediction() is deprecated — use client.predictions.get() instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        return await self.predictions.get(id)

    async def get_model(self, slug: str, fields: Optional[List[str]] = None) -> Dict[str, Any]:
        """.. deprecated:: Use ``client.models.get()`` instead."""
        import warnings
        warnings.warn(
            "get_model() is deprecated — use client.models.get() instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        return await self.models.get(slug, fields=fields)


# ── Async helpers ─────────────────────────────────────────────────────────────


class AsyncPrediction(Prediction):
    """
    Async variant of :class:`Prediction`.

    ``.cancel()`` and ``.delete()`` are async coroutines.
    """

    def __init__(self, http: HTTP, data: Dict[str, Any], executor: ThreadPoolExecutor) -> None:
        super().__init__(http, data)
        self._executor = executor

    async def cancel(self) -> Dict[str, Any]:  # type: ignore[override]
        """Async cancel."""
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(
            self._executor,
            lambda: self._http.request("POST", ENDPOINTS.CANCEL_PREDICTION_BY_ID(self._data["id"])),
        )

    async def delete(self) -> Dict[str, Any]:  # type: ignore[override]
        """Async delete."""
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(
            self._executor,
            lambda: self._http.request("DELETE", ENDPOINTS.DELETE_PREDICTION_BY_ID(self._data["id"])),
        )


class _AsyncPredictionsAPI:
    """Thin async wrapper around :class:`PredictionsAPI`."""

    def __init__(self, sync: PredictionsAPI, executor: ThreadPoolExecutor) -> None:
        self._sync = sync
        self._executor = executor

    async def _run(self, fn: Callable, *args, **kwargs) -> Any:
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(self._executor, lambda: fn(*args, **kwargs))

    async def create(self, payload: Union[Dict[str, Any], PredictionRequest]) -> Dict[str, Any]:
        return await self._run(self._sync.create, payload)

    async def get(self, id: str) -> Dict[str, Any]:
        return await self._run(self._sync.get, id)

    async def list(
        self,
        page: Optional[int] = None,
        since: Optional[str] = None,
        until: Optional[str] = None,
        model: Optional[str] = None,
        options: Optional[Union[Dict[str, Any], PredictionsListOptions]] = None,
    ) -> PaginatedResponse:
        return await self._run(
            self._sync.list,
            page=page,
            since=since,
            until=until,
            model=model,
            options=options,
        )


class _AsyncModelsAPI:
    """Thin async wrapper around :class:`ModelsAPI`."""

    def __init__(self, sync: ModelsAPI, executor: ThreadPoolExecutor) -> None:
        self._sync = sync
        self._executor = executor

    async def _run(self, fn: Callable, *args, **kwargs) -> Any:
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(self._executor, lambda: fn(*args, **kwargs))

    async def list(
        self,
        fields: Optional[List[str]] = None,
        options: Optional[Union[Dict[str, Any], ModelFieldsOptions]] = None,
    ) -> List[Dict[str, Any]]:
        return await self._run(self._sync.list, fields=fields, options=options)

    async def get(
        self,
        slug: str,
        fields: Optional[List[str]] = None,
        options: Optional[Union[Dict[str, Any], ModelFieldsOptions]] = None,
    ) -> Dict[str, Any]:
        return await self._run(self._sync.get, slug, fields=fields, options=options)
