"""Async Reasoners service client.

This module provides the async-first API exposed off the root `Client` (e.g.
`client.reasoners`). Backend-specific behavior is delegated to a `ReasonersGateway`.
"""

from __future__ import annotations

import asyncio
import logging
import re
from types import TracebackType
from typing import Any, TYPE_CHECKING, TypeVar, cast

from relationalai.util.asyncio import raise_if_running_event_loop
from relationalai.util.docutils import include_in_docs
from relationalai.util.polling import poll_until

from . import constants as c
from .errors import (
    ReasonerConflictError,
    ReasonerInvalidRequestError,
    ReasonerNotFoundError,
    ReasonerNotReadyError,
    ReasonerTimeoutError,
)
from .models import Operation, Reasoner, ReasonerType
from .operations import ReasonerOperation, _EnsureContext, _EnsureRequested
from .protocol import ReasonersGateway
from .util import _pick_reasoner_match, sanitize_user_name

if TYPE_CHECKING:
    # Import only for static typing (keeps runtime import-light).
    from relationalai.config.config import Config

T = TypeVar("T")

_LOG = logging.getLogger(__name__)

__include_in_docs__ = True


@include_in_docs
class ReasonersClient:
    """Async-first reasoners client.

    This client exposes a single set of `async def` methods (no `*_async` suffixes).
    Long-running lifecycle actions return a `ReasonerOperation` handle immediately.
    """

    def __init__(
        self,
        gateway: ReasonersGateway,
        *,
        config: Config | None = None,
        execution_metrics: Any | None = None,
    ):
        """Create an async reasoners client.

        Args:
            gateway: Backend adapter implementing `ReasonersGateway`.
            config: Optional config used for defaults (name/size/settings) and polling knobs.
        """
        self._gw = gateway
        self._config = config
        self._execution_metrics = execution_metrics
        self._closed = False

    @include_in_docs
    async def aclose(self) -> None:
        """Close the client (best-effort).

        Returns
        -------
        None
            Always returns `None`.
        """
        gw = self._gw
        await gw.aclose()
        self._closed = True

    def __del__(self) -> None:
        # Best-effort warning for unclosed async Direct Access clients.
        try:
            if getattr(self, "_closed", True):
                return
            gw = getattr(self, "_gw", None)
            session = getattr(gw, "session", None)
            closed = getattr(session, "closed", None)
            if closed is False:
                _LOG.warning(
                    "ReasonersClient was not closed (Direct Access aiohttp session still open). "
                    "Use `async with await connect(...) as client:` or call `await client.aclose()`."
                )
        except Exception:
            return

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

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None:
        await self.aclose()

    @property
    def execution_metrics(self) -> Any | None:
        """Optional execution metrics sink (if enabled by provider/config)."""
        return self._execution_metrics
    def _normalize_reasoner_type(self, reasoner_type: str) -> str:
        """Validate and normalize a reasoner type string."""
        if not isinstance(reasoner_type, str) or not reasoner_type.strip():
            raise ReasonerInvalidRequestError(
                "Reasoner `reasoner_type` is required (Logic, Prescriptive, or Predictive)."
            )
        try:
            return ReasonerType.normalize(reasoner_type)
        except ValueError as e:
            raise ReasonerInvalidRequestError(str(e)) from e

    _NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")

    def _normalize_name_optional(self, name: str | None) -> str | None:
        """Normalize a reasoner name, allowing `None` (meaning: use defaults)."""
        if name is None:
            return None
        if not isinstance(name, str):
            raise ReasonerInvalidRequestError("Reasoner `reasoner_name` must be a string.")
        s = name.strip()
        if not s:
            return None
        # Keep validation conservative: reasoner names are used as identifiers and may
        # be embedded in URLs (Direct Access) or validation layers (SQL app).
        if not self._NAME_RE.match(s):
            raise ReasonerInvalidRequestError(
                "Invalid reasoner `name`. Use letters/digits plus '_' or '-', "
                "starting with a letter or digit (e.g. 'my_solver', 'user-1')."
            )
        return s

    def _mismatch_message(
        self,
        *,
        reasoner_name: str,
        reasoner_type: str,
        requested: _EnsureRequested,
        existing: Reasoner,
    ) -> str | None:
        """Return a human-friendly mismatch message, or None if no mismatches."""
        diffs: list[str] = []
        if requested.size is not None and existing.size != requested.size:
            diffs.append(f"size (requested={requested.size!r}, existing={existing.size!r})")
        if requested.auto_suspend_mins is not None and existing.auto_suspend_mins != requested.auto_suspend_mins:
            diffs.append(
                f"auto_suspend_mins (requested={requested.auto_suspend_mins!r}, existing={existing.auto_suspend_mins!r})"
            )
        existing_await_storage_vacuum = None
        if isinstance(existing.settings, dict) and "await_storage_vacuum" in existing.settings:
            existing_await_storage_vacuum = existing.settings.get("await_storage_vacuum")
        if (
            requested.await_storage_vacuum is not None
            and isinstance(existing.settings, dict)
            and "await_storage_vacuum" in existing.settings
            and existing_await_storage_vacuum != requested.await_storage_vacuum
        ):
            diffs.append(
                "await_storage_vacuum "
                f"(requested={requested.await_storage_vacuum!r}, existing={existing_await_storage_vacuum!r})"
            )
        if requested.settings is not None and existing.settings != requested.settings:
            diffs.append(f"settings (requested={requested.settings!r}, existing={existing.settings!r})")
        if not diffs:
            return None
        return (
            "Reasoner already exists but does not match requested configuration: "
            f"name={reasoner_name!r} type={reasoner_type!r}. Mismatches: " + ", ".join(diffs)
        )

    def _raise_on_mismatch(
        self,
        *,
        reasoner_name: str,
        reasoner_type: str,
        requested: _EnsureRequested,
        existing: Reasoner,
    ) -> None:
        msg = self._mismatch_message(
            reasoner_name=reasoner_name,
            reasoner_type=reasoner_type,
            requested=requested,
            existing=existing,
        )
        if msg is not None:
            raise ReasonerConflictError(msg)

    async def _ensure_existing_ready_now(
        self,
        *,
        reasoner_name: str,
        reasoner_type: str,
        requested: _EnsureRequested,
        resume: bool,
        timeout_s: float,
    ) -> Reasoner:
        """Ensure an existing reasoner is READY *now* (resume/wait), or raise."""
        existing = await self.get(reasoner_type, reasoner_name)
        if existing is None:
            raise ReasonerNotFoundError(f"Reasoner not found: name={reasoner_name} type={reasoner_type}")

        # Validate user-requested configuration against existing metadata.
        self._raise_on_mismatch(
            reasoner_name=reasoner_name,
            reasoner_type=reasoner_type,
            requested=requested,
            existing=existing,
        )

        state = (existing.state or "").upper()
        if state == c.REASONER_STATE_READY:
            return existing
        if state in c.REASONER_FAILED_STATES:
            raise ReasonerNotReadyError(f"Reasoner exists but is not READY: state={existing.state}")
        if state == c.REASONER_STATE_SUSPENDED:
            if not resume:
                raise ReasonerNotReadyError(f"Reasoner exists but is not READY: state={existing.state}")
            try:
                op = await self.resume(reasoner_type, reasoner_name)
            except ReasonerConflictError as e:
                # Another caller already started the resume — fall through to wait.
                _LOG.debug("ensure: resume conflict for %s/%s, polling: %s", reasoner_type, reasoner_name, e)
            else:
                r = await op.wait(timeout_s=timeout_s)
                self._raise_on_mismatch(
                    reasoner_name=reasoner_name,
                    reasoner_type=reasoner_type,
                    requested=requested,
                    existing=r,
                )
                return r

        r = await self.wait_until_ready(reasoner_type, reasoner_name, timeout_s=timeout_s)
        self._raise_on_mismatch(
            reasoner_name=reasoner_name,
            reasoner_type=reasoner_type,
            requested=requested,
            existing=r,
        )
        return r

    async def _ensure_wait_finalize(self, ctx: _EnsureContext, *, timeout_s: float) -> Reasoner:
        """Finalize an `ensure()` operation at await-time (READY now, not stale)."""
        return await self._ensure_existing_ready_now(
            reasoner_name=ctx.reasoner_name,
            reasoner_type=ctx.reasoner_type,
            requested=ctx.requested,
            resume=ctx.resume,
            timeout_s=timeout_s,
        )

    def _normalize_reasoner_type_optional(self, reasoner_type: str | None) -> str | None:
        """Normalize a reasoner type string, allowing `None`."""
        if reasoner_type is None:
            return None
        return self._normalize_reasoner_type(reasoner_type)
    def _check_reasoner_ready(
        self, r: Reasoner | None, reasoner_name: str, reasoner_type: str, context: str
    ) -> Reasoner:
        """Validate a reasoner exists and is READY; raise otherwise."""
        if r is None:
            raise ReasonerNotFoundError(
                f"Reasoner not found {context}: reasoner_name={reasoner_name} reasoner_type={reasoner_type}"
            )
        if (r.state or "").upper() != c.REASONER_STATE_READY:
            raise ReasonerNotReadyError(f"Reasoner is not READY {context}: state={r.state}")
        return r

    def _default_username(self) -> str:
        """Best-effort default reasoner name when not provided."""
        cfg = self._config
        if cfg is None:
            return "default"
        try:
            conn = cfg.get_default_connection()
        except Exception:
            return "default"
        user = getattr(conn, "user", None)
        if isinstance(user, str) and user.strip():
            sanitized = sanitize_user_name(user)
            return sanitized or "default"
        return "default"

    def _reasoner_defaults(
        self, t: str
    ) -> tuple[str | None, str | None, int | None, bool | None, dict[str, Any] | None]:
        """Return config defaults for the given reasoner type."""
        cfg = self._config
        if cfg is None:
            return None, None, None, None, None

        if t == c.REASONER_TYPE_LOGIC:
            section = cfg.reasoners.logic
        elif t == c.REASONER_TYPE_PREDICTIVE:
            section = cfg.reasoners.predictive
        elif t == c.REASONER_TYPE_PRESCRIPTIVE:
            section = cfg.reasoners.prescriptive
        else:
            section = cfg.reasoners.logic

        name = section.name
        size = section.size
        auto_suspend_mins = section.auto_suspend_mins
        await_storage_vacuum = section.await_storage_vacuum
        settings = section.settings

        return (
            name,
            cast(str | None, size),
            cast(int | None, auto_suspend_mins),
            cast(bool | None, await_storage_vacuum),
            settings,
        )

    def _resolve_identity_and_defaults(
        self,
        reasoner_type: str,
        reasoner_name: str | None,
        *,
        size: str | None,
        auto_suspend_mins: int | None,
        await_storage_vacuum: bool | None,
        settings: dict[str, Any] | None,
    ) -> tuple[str, str | None, int | None, bool | None, dict[str, Any] | None]:
        """Resolve name/create options using explicit args then config defaults."""
        t = self._normalize_reasoner_type(reasoner_type)
        cfg_name, cfg_size, cfg_auto, cfg_await_storage_vacuum, cfg_settings = self._reasoner_defaults(t)

        norm_name = self._normalize_name_optional(reasoner_name)
        norm_cfg_name = self._normalize_name_optional(cfg_name)
        norm_default = self._normalize_name_optional(self._default_username())
        resolved_name = norm_name or norm_cfg_name or (norm_default or "default")
        resolved_size = size if size is not None else cfg_size
        resolved_auto = auto_suspend_mins if auto_suspend_mins is not None else cfg_auto
        resolved_await_storage_vacuum = (
            await_storage_vacuum if await_storage_vacuum is not None else cfg_await_storage_vacuum
        )
        resolved_settings = settings if settings is not None else cfg_settings
        return resolved_name, resolved_size, resolved_auto, resolved_await_storage_vacuum, resolved_settings

    def _polling_cfg(self) -> tuple[float, float, float]:
        """Return `(initial_delay, overhead_rate, max_delay)` for polling."""
        cfg = self._config
        if cfg is None:
            return 0.01, 0.1, 0.5
        r = cfg.reasoners
        return float(r.poll_initial_delay_s), float(r.poll_overhead_rate), float(r.poll_max_delay_s)

    async def _lookup_existing_reasoner(self, reasoner_type: str, reasoner_name: str) -> Reasoner | None:
        """Resolve a reasoner even when backend get/list filters are case-sensitive."""
        direct = await self._gateway.get(reasoner_type, reasoner_name)
        if direct is not None:
            return direct

        attempts: list[tuple[str | None, str | None]] = [
            (reasoner_type, reasoner_name),
            (reasoner_type, None),
            (None, None),
        ]
        seen: set[tuple[str | None, str | None]] = set()
        for list_type, list_name in attempts:
            if (list_type, list_name) in seen:
                continue
            seen.add((list_type, list_name))
            candidates = await self._gateway.list(reasoner_type=list_type, reasoner_name=list_name)
            matched = _pick_reasoner_match(candidates, reasoner_type, reasoner_name)
            if matched is not None:
                return matched
        return None

    # --- lifecycle (async-first) ---
    @include_in_docs
    async def list(
        self,
        *,
        state: str | None = None,
        reasoner_name: str | None = None,
        reasoner_type: str | None = None,
        reasoner_size: str | None = None,
        created_by: str | None = None,
    ) -> list[Reasoner]:
        """List reasoners, optionally filtering by attributes.

        Returns
        -------
        list[Reasoner]
            Matching reasoners.
        """
        t = self._normalize_reasoner_type_optional(reasoner_type)
        return await self._gateway.list(
            state=state,
            reasoner_name=reasoner_name,
            reasoner_type=t,
            reasoner_size=reasoner_size,
            created_by=created_by,
        )

    @include_in_docs
    async def get(self, reasoner_type: str, reasoner_name: str) -> Reasoner | None:
        """Get a reasoner by `(type, name)`, returning `None` if not found.

        Returns
        -------
        Reasoner | None
            The reasoner, or `None` if not found.
        """
        return await self._lookup_existing_reasoner(self._normalize_reasoner_type(reasoner_type), reasoner_name)

    @include_in_docs
    async def create(
        self,
        reasoner_type: str,
        reasoner_name: str | None = None,
        *,
        reasoner_size: str | None = None,
        auto_suspend_mins: int | None = None,
        await_storage_vacuum: bool | None = None,
        settings: dict[str, Any] | None = None,
    ) -> ReasonerOperation:
        """Create a reasoner and return an operation handle.

        Returns
        -------
        ReasonerOperation
            An operation handle you can `await op.wait(...)` on.
        """
        resolved_name, resolved_size, resolved_auto, resolved_await_storage_vacuum, resolved_settings = (
            self._resolve_identity_and_defaults(
                reasoner_type,
                reasoner_name,
                size=reasoner_size,
                auto_suspend_mins=auto_suspend_mins,
                await_storage_vacuum=await_storage_vacuum,
                settings=settings,
            )
        )
        t = self._normalize_reasoner_type(reasoner_type)
        existing = await self._lookup_existing_reasoner(t, resolved_name)
        if existing is not None:
            raise ReasonerConflictError(
                f"Reasoner already exists: name={resolved_name} type={t} state={existing.state}"
            )

        op = await self._gateway.create(
            t,
            resolved_name,
            reasoner_size=resolved_size,
            auto_suspend_mins=resolved_auto,
            await_storage_vacuum=resolved_await_storage_vacuum,
            settings=resolved_settings,
        )
        return ReasonerOperation(client=self, id=op.id, metadata={"reasoner_name": resolved_name, "reasoner_type": t})

    @include_in_docs
    async def create_ready(
        self,
        reasoner_type: str,
        reasoner_name: str | None = None,
        *,
        reasoner_size: str | None = None,
        auto_suspend_mins: int | None = None,
        await_storage_vacuum: bool | None = None,
        settings: dict[str, Any] | None = None,
        timeout_s: int = 900,
    ) -> Reasoner:
        """Create a reasoner and block until it is READY.

        Returns
        -------
        Reasoner
            The created reasoner in READY state.
        """
        op = await self.create(
            reasoner_type,
            reasoner_name,
            reasoner_size=reasoner_size,
            auto_suspend_mins=auto_suspend_mins,
            await_storage_vacuum=await_storage_vacuum,
            settings=settings,
        )
        return await op.wait(timeout_s=timeout_s)

    @include_in_docs
    async def delete(self, reasoner_type: str, reasoner_name: str | None = None) -> None:
        """Delete a reasoner.

        Args:
            reasoner_type: Reasoner type (Logic, Prescriptive, Predictive).
            reasoner_name: Optional name (defaults to config/user-derived).
        Returns
        -------
        None
            Always returns `None`.
        """
        t = self._normalize_reasoner_type(reasoner_type)
        resolved_name, _size, _auto, _await_storage_vacuum, _settings = self._resolve_identity_and_defaults(
            t, reasoner_name, size=None, auto_suspend_mins=None, await_storage_vacuum=None, settings=None
        )
        existing = await self._lookup_existing_reasoner(t, resolved_name)
        return await self._gateway.delete(t, existing.name if existing is not None else resolved_name)

    @include_in_docs
    async def suspend(self, reasoner_type: str, reasoner_name: str | None = None) -> None:
        """Suspend a reasoner.

        Returns
        -------
        None
            Always returns `None`.
        """
        resolved_name, _size, _auto, _await_storage_vacuum, _settings = self._resolve_identity_and_defaults(
            reasoner_type,
            reasoner_name,
            size=None,
            auto_suspend_mins=None,
            await_storage_vacuum=None,
            settings=None,
        )
        t = self._normalize_reasoner_type(reasoner_type)
        existing = await self._lookup_existing_reasoner(t, resolved_name)
        return await self._gateway.suspend(t, existing.name if existing is not None else resolved_name)

    @include_in_docs
    async def resume(self, reasoner_type: str, reasoner_name: str | None = None) -> ReasonerOperation:
        """Resume a reasoner and return an operation handle.

        Returns
        -------
        ReasonerOperation
            An operation handle you can `await op.wait(...)` on.
        """
        resolved_name, _size, _auto, _await_storage_vacuum, _settings = self._resolve_identity_and_defaults(
            reasoner_type,
            reasoner_name,
            size=None,
            auto_suspend_mins=None,
            await_storage_vacuum=None,
            settings=None,
        )
        t = self._normalize_reasoner_type(reasoner_type)
        existing = await self._lookup_existing_reasoner(t, resolved_name)
        actual_name = existing.name if existing is not None else resolved_name
        op = await self._gateway.resume(t, actual_name)
        return ReasonerOperation(client=self, id=op.id, metadata={"reasoner_name": actual_name, "reasoner_type": t})

    @include_in_docs
    async def resume_ready(
        self, reasoner_type: str, reasoner_name: str | None = None, *, timeout_s: int = 900
    ) -> Reasoner:
        """Resume a reasoner and block until it is READY.

        Returns
        -------
        Reasoner
            The reasoner in READY state.
        """
        op = await self.resume(reasoner_type, reasoner_name)
        return await op.wait(timeout_s=timeout_s)

    @include_in_docs
    def ensure(
        self,
        reasoner_type: str,
        reasoner_name: str | None = None,
        *,
        reasoner_size: str | None = None,
        auto_suspend_mins: int | None = None,
        await_storage_vacuum: bool | None = None,
        settings: dict[str, Any] | None = None,
        create: bool = True,
        resume: bool = True,
        timeout_s: int = 900,
    ) -> ReasonerOperation:
        """Ensure a reasoner exists and is READY, returning an operation handle.

        This method requires a running event loop and schedules an internal task.
        Prefer `await ensure_ready(...)` when you just want the final `Reasoner`.
        Returns
        -------
        ReasonerOperation
            An operation handle representing the ensure workflow.
        """
        try:
            loop = asyncio.get_running_loop()
        except RuntimeError as e:
            raise RuntimeError("ensure() requires a running event loop.") from e

        resolved_name, resolved_size, resolved_auto, resolved_await_storage_vacuum, resolved_settings = (
            self._resolve_identity_and_defaults(
                reasoner_type,
                reasoner_name,
                size=reasoner_size,
                auto_suspend_mins=auto_suspend_mins,
                await_storage_vacuum=await_storage_vacuum,
                settings=settings,
            )
        )
        t = self._normalize_reasoner_type(reasoner_type)

        async def _run() -> Reasoner:
            """Create/resume/wait until the reasoner is READY (task body)."""
            existing = await self._lookup_existing_reasoner(t, resolved_name)
            if existing is None:
                if not create:
                    raise ReasonerNotFoundError(f"Reasoner not found: name={resolved_name} type={t}")
                try:
                    op = await self.create(
                        t,
                        resolved_name,
                        reasoner_size=resolved_size,
                        auto_suspend_mins=resolved_auto,
                        await_storage_vacuum=resolved_await_storage_vacuum,
                        settings=resolved_settings,
                    )
                except ReasonerConflictError as e:
                    # Another caller created the engine between our get() and create().
                    # Fall through to _ensure_existing_ready_now which re-fetches.
                    _LOG.debug("ensure(%s, %s): create conflict, falling through: %s", t, resolved_name, e)
                else:
                    r = await op.wait(timeout_s=timeout_s)
                    self._raise_on_mismatch(
                        reasoner_name=resolved_name,
                        reasoner_type=t,
                        requested=_EnsureRequested(
                            size=reasoner_size,
                            auto_suspend_mins=auto_suspend_mins,
                            await_storage_vacuum=await_storage_vacuum,
                            settings=settings,
                        ),
                        existing=r,
                    )
                    return r

            # Existing reasoner: only proceed if requested details match; otherwise raise conflict.
            return await self._ensure_existing_ready_now(
                reasoner_name=resolved_name,
                reasoner_type=t,
                requested=_EnsureRequested(
                    size=reasoner_size,
                    auto_suspend_mins=auto_suspend_mins,
                    await_storage_vacuum=await_storage_vacuum,
                    settings=settings,
                ),
                resume=resume,
                timeout_s=timeout_s,
            )

        task = loop.create_task(_run())
        op_id = f"reasoner:ensure:{t}:{resolved_name}"
        return ReasonerOperation(
            client=self,
            id=op_id,
            metadata={"reasoner_name": resolved_name, "reasoner_type": t},
            _task=task,
            _ensure_ctx=_EnsureContext(
                reasoner_name=resolved_name,
                reasoner_type=t,
                resume=resume,
                requested=_EnsureRequested(
                    size=reasoner_size,
                    auto_suspend_mins=auto_suspend_mins,
                    await_storage_vacuum=await_storage_vacuum,
                    settings=settings,
                ),
            ),
        )

    @include_in_docs
    async def ensure_ready(
        self,
        reasoner_type: str,
        reasoner_name: str | None = None,
        *,
        reasoner_size: str | None = None,
        auto_suspend_mins: int | None = None,
        await_storage_vacuum: bool | None = None,
        settings: dict[str, Any] | None = None,
        create: bool = True,
        resume: bool = True,
        timeout_s: int = 900,
    ) -> Reasoner:
        """Ensure a reasoner exists and is READY, returning the `Reasoner`.

        Returns
        -------
        Reasoner
            The reasoner in READY state.
        """
        op = self.ensure(
            reasoner_type,
            reasoner_name,
            reasoner_size=reasoner_size,
            auto_suspend_mins=auto_suspend_mins,
            await_storage_vacuum=await_storage_vacuum,
            settings=settings,
            create=create,
            resume=resume,
            timeout_s=timeout_s,
        )
        return await op.wait(timeout_s=timeout_s)

    async def _wait_until_ready(self, *, reasoner_name: str, reasoner_type: str, timeout_s: float) -> None:
        """Internal polling helper used by operations and `wait_until_ready()`."""
        async def ready() -> bool:
            """Return True when the reasoner reaches READY state."""
            r = await self._lookup_existing_reasoner(reasoner_type, reasoner_name)
            return (r or Reasoner(name=reasoner_name, type=reasoner_type)).state == c.REASONER_STATE_READY

        initial_delay, overhead_rate, max_delay = self._polling_cfg()
        await poll_until(
            ready,
            timeout_s=timeout_s,
            initial_delay=initial_delay,
            overhead_rate=overhead_rate,
            max_delay=max_delay,
            timeout_error=lambda t: ReasonerTimeoutError(f"Timed out after {t}s waiting for completion"),
        )

    @include_in_docs
    async def wait_until_ready(self, reasoner_type: str, reasoner_name: str, *, timeout_s: float = 900) -> Reasoner:
        """Poll until the reasoner is READY (or raise on timeout/not-found).

        Returns
        -------
        Reasoner
            The reasoner in READY state.
        """
        t = self._normalize_reasoner_type(reasoner_type)
        await self._wait_until_ready(reasoner_name=reasoner_name, reasoner_type=t, timeout_s=timeout_s)
        r = await self._lookup_existing_reasoner(t, reasoner_name)
        return self._check_reasoner_ready(r, reasoner_name, t, "after wait")

    @include_in_docs
    async def sizes(self) -> list[str]:
        """Return supported reasoner sizes for the configured backend.

        Returns
        -------
        list[str]
            Allowed size identifiers.
        """
        return await self._gateway.sizes()

    @include_in_docs
    async def validate_size(self, size: str | None) -> tuple[bool, list[str]]:
        """Validate a size, returning `(is_valid, allowed_sizes)`.

        Returns
        -------
        tuple[bool, list[str]]
            A `(is_valid, allowed_sizes)` pair.
        """
        return await self._gateway.validate_size(size)

    # --- operations (async-first) ---
    @include_in_docs
    async def get_operation(self, operation_id: str) -> Operation:
        """Fetch an operation by id.

        Returns
        -------
        Operation
            The operation status.
        """
        return await self._gateway.get_operation(operation_id)

    @include_in_docs
    async def wait_for_operation(self, operation_id: str, *, timeout_s: int = 900) -> Operation:
        """Wait for an operation to complete (or fail on timeout).

        Returns
        -------
        Operation
            The final operation status.
        """
        return await self._gateway.wait_for_operation(operation_id, timeout_s=timeout_s)

    @property
    def _gateway(self) -> ReasonersGateway:
        """Internal gateway accessor (keeps the public API surface focused)."""
        # Keep `ReasonersClient` focused on domain API; gateway is internal.
        return self._gw


# Sync-only helper retained for compatibility with older call sites/tests.
def ensure_no_running_loop() -> None:
    """Raise when called inside a running event loop.

    This exists for older sync call sites/tests that relied on this guard.
    """
    raise_if_running_event_loop("ReasonersClient", use_in_async="Use the async client methods.")

