"""
Docling Serve HTTP Client — Enterprise-Grade SDK v2.0.

Design principles
-----------------
* **Immutable configuration**: client settings are frozen after ``__init__``.
* **Context-manager lifecycle**: ``with DoclingClient(...) as c:`` ensures
  deterministic ``httpx.Client`` teardown.
* **Structured retries**: transient errors (5xx, timeouts, connection failures)
  are retried with exponential back-off + jitter; client errors (4xx) fail
  immediately.
* **Strict typing**: every public method is fully typed and documented.
* **Security**: the API key is transmitted in the ``X-Api-Key`` HTTP header
  (per the OpenAPI ``securitySchemes``), never in query strings or logs.
  ``__repr__`` redacts the key.
* **Observability**: every request carries a unique ``X-Request-ID`` header
  for distributed tracing.  Pluggable event hooks (``on_request``,
  ``on_response``, ``on_error``) allow custom metrics, logging, or alerting.

Thread safety
-------------
Each method creates its *own* ``httpx.Client`` (or ``AsyncClient``) per call
so the ``DoclingClient`` instance itself is safe to share across threads.
For high-throughput use, prefer the context-manager form which reuses a
single underlying connection pool.
"""

from __future__ import annotations

import asyncio
import json
import logging
import random
import time
import uuid
from io import IOBase
from pathlib import Path
from types import TracebackType
from typing import Any, BinaryIO, Callable, Dict, List, Optional, Tuple, Type, Union, cast

import httpx

# ---------------------------------------------------------------------------
# Hook type aliases
# ---------------------------------------------------------------------------
RequestHook = Callable[[str, str, str, Optional[Dict[str, Any]]], None]
"""Signature: ``(request_id, method, path, body_or_none) -> None``"""

ResponseHook = Callable[[str, str, str, int, float], None]
"""Signature: ``(request_id, method, path, status_code, duration_s) -> None``"""

ErrorHook = Callable[[str, str, str, Exception], None]
"""Signature: ``(request_id, method, path, exception) -> None``"""

from .models import (
    ChunkDocumentResponse,
    ClearResponse,
    ConvertDocumentResponse,
    ConvertDocumentsRequest,
    ConvertDocumentsRequestOptions,
    HealthCheckResponse,
    HierarchicalChunkerOptions,
    HierarchicalChunkerOptionsDocumentsRequest,
    HybridChunkerOptions,
    HybridChunkerOptionsDocumentsRequest,
    InBodyTarget,
    PresignedUrlConvertDocumentResponse,
    TargetRequest,
    TaskStatusResponse,
)

logger = logging.getLogger("docling_serve_sdk")

# ---------------------------------------------------------------------------
# Default retry jitter range (fraction of the base delay)
# ---------------------------------------------------------------------------
_JITTER_FRACTION = 0.5  # adds 0–50 % random jitter

# ---------------------------------------------------------------------------
# Exceptions (hierarchical)
# ---------------------------------------------------------------------------


class DoclingError(Exception):
    """Base exception for every SDK error."""


class DoclingAPIError(DoclingError):
    """Raised when the server returns an HTTP error (4xx / 5xx)."""

    def __init__(
        self,
        message: str,
        *,
        status_code: Optional[int] = None,
        response_body: Optional[Dict[str, Any]] = None,
    ) -> None:
        super().__init__(message)
        self.status_code: Optional[int] = status_code
        self.response_body: Optional[Dict[str, Any]] = response_body


class DoclingTimeoutError(DoclingError):
    """Raised when a request or a polling loop exceeds the configured timeout."""


class DoclingConnectionError(DoclingError):
    """Raised when the SDK cannot reach the server at all."""


class DoclingValidationError(DoclingError):
    """Raised when the server returns 422 (validation error)."""

    def __init__(
        self,
        message: str,
        *,
        detail: Optional[List[Dict[str, Any]]] = None,
    ) -> None:
        super().__init__(message)
        self.detail: List[Dict[str, Any]] = detail or []


# ---------------------------------------------------------------------------
# Client
# ---------------------------------------------------------------------------


class DoclingClient:
    """Enterprise-grade client for Docling Serve API v1.14.

    Parameters
    ----------
    base_url:
        Root URL of the Docling Serve instance (e.g. ``http://localhost:5001``).
    api_key:
        Optional API key sent in the ``X-Api-Key`` header.
    timeout:
        Default request timeout in seconds.
    max_retries:
        Maximum number of retries for transient failures (5xx, timeouts).
    retry_backoff:
        Base delay in seconds between retries (doubled each attempt).
    verify_ssl:
        Whether to verify server TLS certificates.
    on_request:
        Optional hook called **before** each HTTP request.
        Signature: ``(request_id, method, path, body) -> None``
    on_response:
        Optional hook called **after** each successful HTTP response.
        Signature: ``(request_id, method, path, status_code, duration_s) -> None``
    on_error:
        Optional hook called when a request raises an exception.
        Signature: ``(request_id, method, path, exception) -> None``

    Usage
    -----
    >>> with DoclingClient("http://localhost:5001", api_key="secret") as client:
    ...     health = client.health_check()
    ...     print(health.status)
    """

    def __init__(
        self,
        base_url: str = "http://localhost:5001",
        *,
        api_key: Optional[str] = None,
        timeout: float = 300.0,
        max_retries: int = 3,
        retry_backoff: float = 1.0,
        verify_ssl: bool = True,
        on_request: Optional[RequestHook] = None,
        on_response: Optional[ResponseHook] = None,
        on_error: Optional[ErrorHook] = None,
    ) -> None:
        self._base_url: str = base_url.rstrip("/")
        self._api_key: Optional[str] = api_key
        self._timeout: float = timeout
        self._max_retries: int = max_retries
        self._retry_backoff: float = retry_backoff
        self._verify_ssl: bool = verify_ssl

        # Enterprise: pluggable lifecycle hooks
        self._on_request: Optional[RequestHook] = on_request
        self._on_response: Optional[ResponseHook] = on_response
        self._on_error: Optional[ErrorHook] = on_error

        self._headers: Dict[str, str] = {
            "User-Agent": "docling-serve-sdk/1.14.0",
        }
        if self._api_key is not None:
            self._headers["X-Api-Key"] = self._api_key

        # Lazily created connection pools (one for sync, one for async)
        self._sync_client: Optional[httpx.Client] = None
        self._async_client: Optional[httpx.AsyncClient] = None

    # -- public read-only properties ------------------------------------------

    @property
    def base_url(self) -> str:
        """Root URL of the Docling Serve instance."""
        return self._base_url

    @property
    def timeout(self) -> float:
        """Default request timeout in seconds."""
        return self._timeout

    @property
    def max_retries(self) -> int:
        """Maximum number of retries for transient failures."""
        return self._max_retries

    @property
    def verify_ssl(self) -> bool:
        """Whether server TLS certificates are verified."""
        return self._verify_ssl

    @property
    def headers(self) -> Dict[str, str]:
        """A copy of the default HTTP headers (read-only)."""
        return self._headers.copy()

    # -- repr (redacts api key) ---------------------------------------------

    def __repr__(self) -> str:
        key_display = "***" if self._api_key else "None"
        return (
            f"DoclingClient(base_url={self._base_url!r}, "
            f"api_key={key_display}, timeout={self._timeout})"
        )

    # -- context manager (connection pooling) --------------------------------

    def __enter__(self) -> "DoclingClient":
        self._sync_client = httpx.Client(
            base_url=self._base_url,
            headers=self._headers,
            timeout=self._timeout,
            verify=self._verify_ssl,
        )
        return self

    def __exit__(
        self,
        exc_type: Optional[Type[BaseException]],
        exc_val: Optional[BaseException],
        exc_tb: Optional[TracebackType],
    ) -> None:
        if self._sync_client is not None:
            self._sync_client.close()
            self._sync_client = None

    async def __aenter__(self) -> "DoclingClient":
        self._async_client = httpx.AsyncClient(
            base_url=self._base_url,
            headers=self._headers,
            timeout=self._timeout,
            verify=self._verify_ssl,
        )
        return self

    async def __aexit__(
        self,
        exc_type: Optional[Type[BaseException]],
        exc_val: Optional[BaseException],
        exc_tb: Optional[TracebackType],
    ) -> None:
        if self._async_client is not None:
            await self._async_client.aclose()
            self._async_client = None

    # -- explicit cleanup ----------------------------------------

    def close(self) -> None:
        """Explicitly close the underlying sync connection pool.

        Prefer using the context-manager form instead.
        """
        if self._sync_client is not None:
            self._sync_client.close()
            self._sync_client = None

    async def aclose(self) -> None:
        """Explicitly close the underlying async connection pool."""
        if self._async_client is not None:
            await self._async_client.aclose()
            self._async_client = None

    # -- internal helpers ----------------------------------------------------

    def _get_sync_client(self) -> httpx.Client:
        """Return the pooled client or create a one-shot one."""
        if self._sync_client is not None:
            return self._sync_client
        return httpx.Client(
            base_url=self._base_url,
            headers=self._headers,
            timeout=self._timeout,
            verify=self._verify_ssl,
        )

    def _get_async_client(self) -> httpx.AsyncClient:
        """Return the pooled async client or create a one-shot one."""
        if self._async_client is not None:
            return self._async_client
        return httpx.AsyncClient(
            base_url=self._base_url,
            headers=self._headers,
            timeout=self._timeout,
            verify=self._verify_ssl,
        )

    def _should_close_sync(self) -> bool:
        """True when we created a one-shot client (not pooled)."""
        return self._sync_client is None

    def _should_close_async(self) -> bool:
        """True when we created a one-shot async client (not pooled)."""
        return self._async_client is None

    @staticmethod
    def _handle_response(response: httpx.Response) -> Dict[str, Any]:
        """Parse a response or raise a typed exception."""
        if response.status_code == 422:
            err_body: Dict[str, Any] = {}
            try:
                err_body = response.json()
            except Exception:
                err_body = {"detail": response.text}
            raw_detail = err_body.get("detail")
            if isinstance(raw_detail, list):
                detail_list = cast(List[Dict[str, Any]], raw_detail)
            else:
                detail_list: List[Dict[str, Any]] = []
            raise DoclingValidationError(
                f"Validation error (422): {raw_detail}",
                detail=detail_list,
            )

        try:
            response.raise_for_status()
        except httpx.HTTPStatusError as exc:
            try:
                body = exc.response.json()
                message = body.get("detail", str(exc))
            except Exception:
                body = None
                message = str(exc)
            raise DoclingAPIError(
                message,
                status_code=exc.response.status_code,
                response_body=body,
            ) from exc

        # Some endpoints return empty body on success
        if not response.content:
            return {}

        try:
            return response.json()  # type: ignore[no-any-return]
        except json.JSONDecodeError as exc:
            raise DoclingAPIError(
                f"Invalid JSON in response body: {response.text[:200]}"
            ) from exc

    def _sync_request(
        self,
        method: str,
        path: str,
        *,
        json_body: Optional[Dict[str, Any]] = None,
        data: Optional[Dict[str, Any]] = None,
        files: Optional[Any] = None,
        params: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """Execute a synchronous HTTP request with retry logic.

        Enterprise features:
        * Unique ``X-Request-ID`` header per attempt for distributed tracing.
        * Exponential back-off with jitter to avoid thundering-herd.
        * Pluggable ``on_request``, ``on_response``, ``on_error`` hooks.
        * Structured logging of method, path, status, and duration.
        """
        client = self._get_sync_client()
        close_after = self._should_close_sync()
        last_exc: Optional[Exception] = None
        request_id = str(uuid.uuid4())

        # Fire the on_request hook
        if self._on_request is not None:
            self._on_request(request_id, method, path, json_body)

        try:
            for attempt in range(self._max_retries + 1):
                t0 = time.monotonic()
                try:
                    response = client.request(
                        method,
                        path,
                        json=json_body,
                        data=data,
                        files=files,
                        params=params,
                        headers={"X-Request-ID": request_id},
                    )
                    duration = time.monotonic() - t0
                    logger.info(
                        "HTTP %s %s -> %d (%.3fs) [rid=%s]",
                        method, path, response.status_code, duration, request_id,
                    )
                    result = self._handle_response(response)

                    # Fire the on_response hook
                    if self._on_response is not None:
                        self._on_response(
                            request_id, method, path,
                            response.status_code, duration,
                        )
                    return result
                except (DoclingValidationError, DoclingAPIError) as exc:
                    # Client errors (4xx) are not retried
                    if (
                        isinstance(exc, DoclingAPIError)
                        and exc.status_code is not None
                        and exc.status_code < 500
                    ):
                        if self._on_error is not None:
                            self._on_error(request_id, method, path, exc)
                        raise
                    last_exc = exc
                except httpx.TimeoutException as exc:
                    last_exc = DoclingTimeoutError(str(exc))
                except httpx.ConnectError as exc:
                    last_exc = DoclingConnectionError(str(exc))

                if attempt < self._max_retries:
                    delay = self._retry_backoff * (2 ** attempt)
                    jitter = delay * random.uniform(0, _JITTER_FRACTION)
                    delay += jitter
                    logger.warning(
                        "Retrying %s %s (attempt %d/%d) after %.1fs: %s [rid=%s]",
                        method, path, attempt + 1, self._max_retries,
                        delay, last_exc, request_id,
                    )
                    time.sleep(delay)

            # All retries exhausted — fire error hook and raise
            if self._on_error is not None and last_exc is not None:
                self._on_error(request_id, method, path, last_exc)
            if last_exc is not None:
                raise last_exc
            raise DoclingError("Request failed with no retries")  # pragma: no cover
        finally:
            if close_after:
                client.close()

    async def _async_request(
        self,
        method: str,
        path: str,
        *,
        json_body: Optional[Dict[str, Any]] = None,
        data: Optional[Dict[str, Any]] = None,
        files: Optional[Any] = None,
        params: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """Execute an asynchronous HTTP request with retry logic.

        Same enterprise features as ``_sync_request`` (request-ID, jitter,
        hooks, structured logging).
        """
        client = self._get_async_client()
        close_after = self._should_close_async()
        last_exc: Optional[Exception] = None
        request_id = str(uuid.uuid4())

        if self._on_request is not None:
            self._on_request(request_id, method, path, json_body)

        try:
            for attempt in range(self._max_retries + 1):
                t0 = time.monotonic()
                try:
                    response = await client.request(
                        method,
                        path,
                        json=json_body,
                        data=data,
                        files=files,
                        params=params,
                        headers={"X-Request-ID": request_id},
                    )
                    duration = time.monotonic() - t0
                    logger.info(
                        "HTTP %s %s -> %d (%.3fs) [rid=%s]",
                        method, path, response.status_code, duration, request_id,
                    )
                    result = self._handle_response(response)

                    if self._on_response is not None:
                        self._on_response(
                            request_id, method, path,
                            response.status_code, duration,
                        )
                    return result
                except (DoclingValidationError, DoclingAPIError) as exc:
                    if (
                        isinstance(exc, DoclingAPIError)
                        and exc.status_code is not None
                        and exc.status_code < 500
                    ):
                        if self._on_error is not None:
                            self._on_error(request_id, method, path, exc)
                        raise
                    last_exc = exc
                except httpx.TimeoutException as exc:
                    last_exc = DoclingTimeoutError(str(exc))
                except httpx.ConnectError as exc:
                    last_exc = DoclingConnectionError(str(exc))

                if attempt < self._max_retries:
                    delay = self._retry_backoff * (2 ** attempt)
                    jitter = delay * random.uniform(0, _JITTER_FRACTION)
                    delay += jitter
                    logger.warning(
                        "Retrying %s %s (attempt %d/%d) after %.1fs: %s [rid=%s]",
                        method, path, attempt + 1, self._max_retries,
                        delay, last_exc, request_id,
                    )
                    await asyncio.sleep(delay)

            if self._on_error is not None and last_exc is not None:
                self._on_error(request_id, method, path, last_exc)
            if last_exc is not None:
                raise last_exc
            raise DoclingError("Request failed with no retries")  # pragma: no cover
        finally:
            if close_after:
                await client.aclose()

    # -- multipart form builder (DRY helper) ---------------------------------

    # Fields shared by both ``/v1/convert/file`` and ``/v1/chunk/*/file``
    # endpoints.  The *form-data key name* differs: convert endpoints use the
    # option name directly (``from_formats``), while chunk endpoints prefix
    # every option with ``convert_`` (``convert_from_formats``).
    _OPTION_FIELDS: Tuple[str, ...] = (
        "from_formats",
        "image_export_mode",
        "do_ocr",
        "force_ocr",
        "ocr_engine",
        "ocr_lang",
        "pdf_backend",
        "table_mode",
        "table_cell_matching",
        "pipeline",
        "page_range",
        "document_timeout",
        "abort_on_error",
        "do_table_structure",
        "include_images",
        "images_scale",
        "md_page_break_placeholder",
        "do_code_enrichment",
        "do_formula_enrichment",
        "do_picture_classification",
        "do_chart_extraction",
        "do_picture_description",
        "picture_description_area_threshold",
        "vlm_pipeline_preset",
        "picture_description_preset",
        "code_formula_preset",
        "vlm_pipeline_custom_config",
        "picture_description_custom_config",
        "code_formula_custom_config",
        "table_structure_custom_config",
        "layout_custom_config",
        "vlm_pipeline_model",
        "picture_description_local",
        "picture_description_api",
        "vlm_pipeline_model_local",
        "vlm_pipeline_model_api",
    )

    @classmethod
    def _build_form_data(
        cls,
        options: ConvertDocumentsRequestOptions,
        target: TargetRequest,
        *,
        for_chunking: bool = False,
    ) -> Dict[str, Any]:
        """Build the flat form-data dict expected by Docling Serve.

        List-valued fields are kept as Python lists so that httpx's
        multipart encoder expands them into **repeated** form-data
        keys — one per element — which is what FastAPI/Pydantic expects
        for multipart ``List[…]`` parameters.

        Parameters
        ----------
        for_chunking:
            If ``True`` the option keys are prefixed with ``convert_`` and
            ``to_formats`` is omitted (chunk endpoints derive output formats
            internally).  If ``False`` the option keys are sent verbatim and
            ``to_formats`` is included — matching the ``/v1/convert/file``
            OpenAPI ``Body_process_file_*`` schema.
        """
        data: Dict[str, Any] = {
            "target_type": getattr(target, "kind", "inbody"),
        }
        opts = options.model_dump(exclude_none=True, mode="json")
        prefix = "convert_" if for_chunking else ""

        # ``to_formats`` only exists on convert-file endpoints
        if not for_chunking:
            value = opts.get("to_formats")
            if value is not None:
                data["to_formats"] = value

        for field in cls._OPTION_FIELDS:
            value = opts.get(field)
            if value is None:
                continue
            form_key = f"{prefix}{field}"
            # Structured models (e.g. PictureDescriptionLocal) and dicts
            # must be JSON-serialised for multipart form-data fields.
            if isinstance(value, dict):
                data[form_key] = json.dumps(value)
            else:
                data[form_key] = value

        return data

    @staticmethod
    def _prepare_file_upload(
        file_path: Union[str, Path, BinaryIO],
    ) -> Tuple[Dict[str, Any], Optional[IOBase]]:
        """Prepare the ``files`` dict for ``httpx`` multipart upload.

        Returns ``(files_dict, file_handle_to_close_or_None)``.
        """
        if isinstance(file_path, (str, Path)):
            p = Path(file_path)
            if not p.exists():
                raise DoclingError(f"File not found: {p}")
            fh = open(p, "rb")
            return (
                {"files": (p.name, fh, "application/octet-stream")},
                fh,
            )
        else:
            name = getattr(file_path, "name", "document")
            return (
                {"files": (name, file_path, "application/octet-stream")},
                None,
            )

    # ===================================================================
    # PUBLIC API — Health & Info
    # ===================================================================

    def health_check(self) -> HealthCheckResponse:
        """Check service health (``GET /health``)."""
        data = self._sync_request("GET", "/health")
        return HealthCheckResponse.model_validate(data)

    async def health_check_async(self) -> HealthCheckResponse:
        """Check service health asynchronously."""
        data = await self._async_request("GET", "/health")
        return HealthCheckResponse.model_validate(data)

    def version_info(self) -> Dict[str, Any]:
        """Get software version info (``GET /version``)."""
        return self._sync_request("GET", "/version")

    async def version_info_async(self) -> Dict[str, Any]:
        """Get software version info asynchronously."""
        return await self._async_request("GET", "/version")

    # ===================================================================
    # PUBLIC API — Document Conversion
    # ===================================================================

    def convert_source(
        self,
        request: ConvertDocumentsRequest,
    ) -> Union[ConvertDocumentResponse, PresignedUrlConvertDocumentResponse]:
        """Convert documents from URL/S3/base64 sources (``POST /v1/convert/source``)."""
        data = self._sync_request(
            "POST", "/v1/convert/source",
            json_body=request.model_dump(exclude_none=True, mode="json"),
        )
        if "document" in data:
            return ConvertDocumentResponse.model_validate(data)
        return PresignedUrlConvertDocumentResponse.model_validate(data)

    async def convert_source_async(
        self,
        request: ConvertDocumentsRequest,
    ) -> Union[ConvertDocumentResponse, PresignedUrlConvertDocumentResponse]:
        """Convert documents from sources asynchronously."""
        data = await self._async_request(
            "POST", "/v1/convert/source",
            json_body=request.model_dump(exclude_none=True, mode="json"),
        )
        if "document" in data:
            return ConvertDocumentResponse.model_validate(data)
        return PresignedUrlConvertDocumentResponse.model_validate(data)

    def convert_file(
        self,
        file_path: Union[str, Path, BinaryIO],
        *,
        options: Optional[ConvertDocumentsRequestOptions] = None,
        target: Optional[TargetRequest] = None,
    ) -> Union[ConvertDocumentResponse, PresignedUrlConvertDocumentResponse]:
        """Convert a local file (``POST /v1/convert/file``)."""
        if options is None:
            options = ConvertDocumentsRequestOptions()
        if target is None:
            target = InBodyTarget()

        form = self._build_form_data(options, target)
        files, fh = self._prepare_file_upload(file_path)

        try:
            data = self._sync_request(
                "POST", "/v1/convert/file",
                data=form,
                files=files,
            )
        finally:
            if fh is not None:
                fh.close()

        if "document" in data:
            return ConvertDocumentResponse.model_validate(data)
        return PresignedUrlConvertDocumentResponse.model_validate(data)

    async def convert_file_async(
        self,
        file_path: Union[str, Path, BinaryIO],
        *,
        options: Optional[ConvertDocumentsRequestOptions] = None,
        target: Optional[TargetRequest] = None,
    ) -> Union[ConvertDocumentResponse, PresignedUrlConvertDocumentResponse]:
        """Convert a local file asynchronously."""
        if options is None:
            options = ConvertDocumentsRequestOptions()
        if target is None:
            target = InBodyTarget()

        form = self._build_form_data(options, target)
        files, fh = self._prepare_file_upload(file_path)

        try:
            data = await self._async_request(
                "POST", "/v1/convert/file",
                data=form,
                files=files,
            )
        finally:
            if fh is not None:
                fh.close()

        if "document" in data:
            return ConvertDocumentResponse.model_validate(data)
        return PresignedUrlConvertDocumentResponse.model_validate(data)

    # ===================================================================
    # PUBLIC API — Async Task Submission
    # ===================================================================

    def convert_source_async_task(
        self,
        request: ConvertDocumentsRequest,
    ) -> TaskStatusResponse:
        """Submit a source conversion as an async task
        (``POST /v1/convert/source/async``).
        """
        data = self._sync_request(
            "POST", "/v1/convert/source/async",
            json_body=request.model_dump(exclude_none=True, mode="json"),
        )
        return TaskStatusResponse.model_validate(data)

    async def convert_source_async_task_async(
        self,
        request: ConvertDocumentsRequest,
    ) -> TaskStatusResponse:
        """Submit a source conversion as an async task (async variant)."""
        data = await self._async_request(
            "POST", "/v1/convert/source/async",
            json_body=request.model_dump(exclude_none=True, mode="json"),
        )
        return TaskStatusResponse.model_validate(data)

    def convert_file_async_task(
        self,
        file_path: Union[str, Path, BinaryIO],
        *,
        options: Optional[ConvertDocumentsRequestOptions] = None,
        target: Optional[TargetRequest] = None,
    ) -> TaskStatusResponse:
        """Submit a file conversion as an async task
        (``POST /v1/convert/file/async``).
        """
        if options is None:
            options = ConvertDocumentsRequestOptions()
        if target is None:
            target = InBodyTarget()

        form = self._build_form_data(options, target)
        files, fh = self._prepare_file_upload(file_path)

        try:
            data = self._sync_request(
                "POST", "/v1/convert/file/async",
                data=form,
                files=files,
            )
        finally:
            if fh is not None:
                fh.close()

        return TaskStatusResponse.model_validate(data)

    async def convert_file_async_task_async(
        self,
        file_path: Union[str, Path, BinaryIO],
        *,
        options: Optional[ConvertDocumentsRequestOptions] = None,
        target: Optional[TargetRequest] = None,
    ) -> TaskStatusResponse:
        """Submit a file conversion as an async task (async variant)."""
        if options is None:
            options = ConvertDocumentsRequestOptions()
        if target is None:
            target = InBodyTarget()

        form = self._build_form_data(options, target)
        files, fh = self._prepare_file_upload(file_path)

        try:
            data = await self._async_request(
                "POST", "/v1/convert/file/async",
                data=form,
                files=files,
            )
        finally:
            if fh is not None:
                fh.close()

        return TaskStatusResponse.model_validate(data)

    # ===================================================================
    # PUBLIC API — Chunking (Hybrid)
    # ===================================================================

    def chunk_hybrid_source(
        self,
        request: HybridChunkerOptionsDocumentsRequest,
    ) -> ChunkDocumentResponse:
        """Chunk documents with HybridChunker (``POST /v1/chunk/hybrid/source``)."""
        data = self._sync_request(
            "POST", "/v1/chunk/hybrid/source",
            json_body=request.model_dump(exclude_none=True, mode="json"),
        )
        return ChunkDocumentResponse.model_validate(data)

    async def chunk_hybrid_source_async(
        self,
        request: HybridChunkerOptionsDocumentsRequest,
    ) -> ChunkDocumentResponse:
        """Chunk documents with HybridChunker asynchronously."""
        data = await self._async_request(
            "POST", "/v1/chunk/hybrid/source",
            json_body=request.model_dump(exclude_none=True, mode="json"),
        )
        return ChunkDocumentResponse.model_validate(data)

    def chunk_hybrid_file(
        self,
        file_path: Union[str, Path, BinaryIO],
        *,
        options: Optional[ConvertDocumentsRequestOptions] = None,
        chunking_options: Optional[HybridChunkerOptions] = None,
        target: Optional[TargetRequest] = None,
        include_converted_doc: bool = False,
    ) -> ChunkDocumentResponse:
        """Chunk a file with HybridChunker (``POST /v1/chunk/hybrid/file``)."""
        if options is None:
            options = ConvertDocumentsRequestOptions()
        if target is None:
            target = InBodyTarget()

        form = self._build_form_data(options, target, for_chunking=True)
        form["include_converted_doc"] = include_converted_doc

        if chunking_options is not None:
            co = chunking_options.model_dump(exclude_none=True, exclude={"chunker"})
            for k, v in co.items():
                form[f"chunking_{k}"] = json.dumps(v) if isinstance(v, (list, dict)) else v

        files, fh = self._prepare_file_upload(file_path)
        try:
            data = self._sync_request(
                "POST", "/v1/chunk/hybrid/file",
                data=form,
                files=files,
            )
        finally:
            if fh is not None:
                fh.close()

        return ChunkDocumentResponse.model_validate(data)

    async def chunk_hybrid_file_async(
        self,
        file_path: Union[str, Path, BinaryIO],
        *,
        options: Optional[ConvertDocumentsRequestOptions] = None,
        chunking_options: Optional[HybridChunkerOptions] = None,
        target: Optional[TargetRequest] = None,
        include_converted_doc: bool = False,
    ) -> ChunkDocumentResponse:
        """Chunk a file with HybridChunker asynchronously."""
        if options is None:
            options = ConvertDocumentsRequestOptions()
        if target is None:
            target = InBodyTarget()

        form = self._build_form_data(options, target, for_chunking=True)
        form["include_converted_doc"] = include_converted_doc

        if chunking_options is not None:
            co = chunking_options.model_dump(exclude_none=True, exclude={"chunker"})
            for k, v in co.items():
                form[f"chunking_{k}"] = json.dumps(v) if isinstance(v, (list, dict)) else v

        files, fh = self._prepare_file_upload(file_path)
        try:
            data = await self._async_request(
                "POST", "/v1/chunk/hybrid/file",
                data=form,
                files=files,
            )
        finally:
            if fh is not None:
                fh.close()

        return ChunkDocumentResponse.model_validate(data)

    def chunk_hybrid_source_async_task(
        self,
        request: HybridChunkerOptionsDocumentsRequest,
    ) -> TaskStatusResponse:
        """Submit hybrid chunking as async task (``POST /v1/chunk/hybrid/source/async``)."""
        data = self._sync_request(
            "POST", "/v1/chunk/hybrid/source/async",
            json_body=request.model_dump(exclude_none=True, mode="json"),
        )
        return TaskStatusResponse.model_validate(data)

    async def chunk_hybrid_source_async_task_async(
        self,
        request: HybridChunkerOptionsDocumentsRequest,
    ) -> TaskStatusResponse:
        """Submit hybrid chunking as async task (async variant)."""
        data = await self._async_request(
            "POST", "/v1/chunk/hybrid/source/async",
            json_body=request.model_dump(exclude_none=True, mode="json"),
        )
        return TaskStatusResponse.model_validate(data)

    def chunk_hybrid_file_async_task(
        self,
        file_path: Union[str, Path, BinaryIO],
        *,
        options: Optional[ConvertDocumentsRequestOptions] = None,
        chunking_options: Optional[HybridChunkerOptions] = None,
        target: Optional[TargetRequest] = None,
        include_converted_doc: bool = False,
    ) -> TaskStatusResponse:
        """Submit hybrid file chunking as async task (``POST /v1/chunk/hybrid/file/async``)."""
        if options is None:
            options = ConvertDocumentsRequestOptions()
        if target is None:
            target = InBodyTarget()

        form = self._build_form_data(options, target, for_chunking=True)
        form["include_converted_doc"] = include_converted_doc

        if chunking_options is not None:
            co = chunking_options.model_dump(exclude_none=True, exclude={"chunker"})
            for k, v in co.items():
                form[f"chunking_{k}"] = json.dumps(v) if isinstance(v, (list, dict)) else v

        files, fh = self._prepare_file_upload(file_path)
        try:
            data = self._sync_request(
                "POST", "/v1/chunk/hybrid/file/async",
                data=form,
                files=files,
            )
        finally:
            if fh is not None:
                fh.close()

        return TaskStatusResponse.model_validate(data)

    async def chunk_hybrid_file_async_task_async(
        self,
        file_path: Union[str, Path, BinaryIO],
        *,
        options: Optional[ConvertDocumentsRequestOptions] = None,
        chunking_options: Optional[HybridChunkerOptions] = None,
        target: Optional[TargetRequest] = None,
        include_converted_doc: bool = False,
    ) -> TaskStatusResponse:
        """Submit hybrid file chunking as async task (async variant)."""
        if options is None:
            options = ConvertDocumentsRequestOptions()
        if target is None:
            target = InBodyTarget()

        form = self._build_form_data(options, target, for_chunking=True)
        form["include_converted_doc"] = include_converted_doc

        if chunking_options is not None:
            co = chunking_options.model_dump(exclude_none=True, exclude={"chunker"})
            for k, v in co.items():
                form[f"chunking_{k}"] = json.dumps(v) if isinstance(v, (list, dict)) else v

        files, fh = self._prepare_file_upload(file_path)
        try:
            data = await self._async_request(
                "POST", "/v1/chunk/hybrid/file/async",
                data=form,
                files=files,
            )
        finally:
            if fh is not None:
                fh.close()

        return TaskStatusResponse.model_validate(data)

    # ===================================================================
    # PUBLIC API — Chunking (Hierarchical)
    # ===================================================================

    def chunk_hierarchical_source(
        self,
        request: HierarchicalChunkerOptionsDocumentsRequest,
    ) -> ChunkDocumentResponse:
        """Chunk documents with HierarchicalChunker (``POST /v1/chunk/hierarchical/source``)."""
        data = self._sync_request(
            "POST", "/v1/chunk/hierarchical/source",
            json_body=request.model_dump(exclude_none=True, mode="json"),
        )
        return ChunkDocumentResponse.model_validate(data)

    async def chunk_hierarchical_source_async(
        self,
        request: HierarchicalChunkerOptionsDocumentsRequest,
    ) -> ChunkDocumentResponse:
        """Chunk documents with HierarchicalChunker asynchronously."""
        data = await self._async_request(
            "POST", "/v1/chunk/hierarchical/source",
            json_body=request.model_dump(exclude_none=True, mode="json"),
        )
        return ChunkDocumentResponse.model_validate(data)

    def chunk_hierarchical_file(
        self,
        file_path: Union[str, Path, BinaryIO],
        *,
        options: Optional[ConvertDocumentsRequestOptions] = None,
        chunking_options: Optional[HierarchicalChunkerOptions] = None,
        target: Optional[TargetRequest] = None,
        include_converted_doc: bool = False,
    ) -> ChunkDocumentResponse:
        """Chunk a file with HierarchicalChunker (``POST /v1/chunk/hierarchical/file``)."""
        if options is None:
            options = ConvertDocumentsRequestOptions()
        if target is None:
            target = InBodyTarget()

        form = self._build_form_data(options, target, for_chunking=True)
        form["include_converted_doc"] = include_converted_doc

        if chunking_options is not None:
            co = chunking_options.model_dump(exclude_none=True, exclude={"chunker"})
            for k, v in co.items():
                form[f"chunking_{k}"] = json.dumps(v) if isinstance(v, (list, dict)) else v

        files, fh = self._prepare_file_upload(file_path)
        try:
            data = self._sync_request(
                "POST", "/v1/chunk/hierarchical/file",
                data=form,
                files=files,
            )
        finally:
            if fh is not None:
                fh.close()

        return ChunkDocumentResponse.model_validate(data)

    async def chunk_hierarchical_file_async(
        self,
        file_path: Union[str, Path, BinaryIO],
        *,
        options: Optional[ConvertDocumentsRequestOptions] = None,
        chunking_options: Optional[HierarchicalChunkerOptions] = None,
        target: Optional[TargetRequest] = None,
        include_converted_doc: bool = False,
    ) -> ChunkDocumentResponse:
        """Chunk a file with HierarchicalChunker asynchronously."""
        if options is None:
            options = ConvertDocumentsRequestOptions()
        if target is None:
            target = InBodyTarget()

        form = self._build_form_data(options, target, for_chunking=True)
        form["include_converted_doc"] = include_converted_doc

        if chunking_options is not None:
            co = chunking_options.model_dump(exclude_none=True, exclude={"chunker"})
            for k, v in co.items():
                form[f"chunking_{k}"] = json.dumps(v) if isinstance(v, (list, dict)) else v

        files, fh = self._prepare_file_upload(file_path)
        try:
            data = await self._async_request(
                "POST", "/v1/chunk/hierarchical/file",
                data=form,
                files=files,
            )
        finally:
            if fh is not None:
                fh.close()

        return ChunkDocumentResponse.model_validate(data)

    def chunk_hierarchical_source_async_task(
        self,
        request: HierarchicalChunkerOptionsDocumentsRequest,
    ) -> TaskStatusResponse:
        """Submit hierarchical chunking as async task
        (``POST /v1/chunk/hierarchical/source/async``).
        """
        data = self._sync_request(
            "POST", "/v1/chunk/hierarchical/source/async",
            json_body=request.model_dump(exclude_none=True, mode="json"),
        )
        return TaskStatusResponse.model_validate(data)

    async def chunk_hierarchical_source_async_task_async(
        self,
        request: HierarchicalChunkerOptionsDocumentsRequest,
    ) -> TaskStatusResponse:
        """Submit hierarchical chunking as async task (async variant)."""
        data = await self._async_request(
            "POST", "/v1/chunk/hierarchical/source/async",
            json_body=request.model_dump(exclude_none=True, mode="json"),
        )
        return TaskStatusResponse.model_validate(data)

    def chunk_hierarchical_file_async_task(
        self,
        file_path: Union[str, Path, BinaryIO],
        *,
        options: Optional[ConvertDocumentsRequestOptions] = None,
        chunking_options: Optional[HierarchicalChunkerOptions] = None,
        target: Optional[TargetRequest] = None,
        include_converted_doc: bool = False,
    ) -> TaskStatusResponse:
        """Submit hierarchical file chunking as async task
        (``POST /v1/chunk/hierarchical/file/async``).
        """
        if options is None:
            options = ConvertDocumentsRequestOptions()
        if target is None:
            target = InBodyTarget()

        form = self._build_form_data(options, target, for_chunking=True)
        form["include_converted_doc"] = include_converted_doc

        if chunking_options is not None:
            co = chunking_options.model_dump(exclude_none=True, exclude={"chunker"})
            for k, v in co.items():
                form[f"chunking_{k}"] = json.dumps(v) if isinstance(v, (list, dict)) else v

        files, fh = self._prepare_file_upload(file_path)
        try:
            data = self._sync_request(
                "POST", "/v1/chunk/hierarchical/file/async",
                data=form,
                files=files,
            )
        finally:
            if fh is not None:
                fh.close()

        return TaskStatusResponse.model_validate(data)

    async def chunk_hierarchical_file_async_task_async(
        self,
        file_path: Union[str, Path, BinaryIO],
        *,
        options: Optional[ConvertDocumentsRequestOptions] = None,
        chunking_options: Optional[HierarchicalChunkerOptions] = None,
        target: Optional[TargetRequest] = None,
        include_converted_doc: bool = False,
    ) -> TaskStatusResponse:
        """Submit hierarchical file chunking as async task (async variant)."""
        if options is None:
            options = ConvertDocumentsRequestOptions()
        if target is None:
            target = InBodyTarget()

        form = self._build_form_data(options, target, for_chunking=True)
        form["include_converted_doc"] = include_converted_doc

        if chunking_options is not None:
            co = chunking_options.model_dump(exclude_none=True, exclude={"chunker"})
            for k, v in co.items():
                form[f"chunking_{k}"] = json.dumps(v) if isinstance(v, (list, dict)) else v

        files, fh = self._prepare_file_upload(file_path)
        try:
            data = await self._async_request(
                "POST", "/v1/chunk/hierarchical/file/async",
                data=form,
                files=files,
            )
        finally:
            if fh is not None:
                fh.close()

        return TaskStatusResponse.model_validate(data)

    # ===================================================================
    # PUBLIC API — Task Polling & Results
    # ===================================================================

    def task_status_poll(
        self,
        task_id: str,
        *,
        wait: float = 0,
    ) -> TaskStatusResponse:
        """Poll task status (``GET /v1/status/poll/{task_id}``)."""
        params: Dict[str, Any] = {}
        if wait > 0:
            params["wait"] = wait
        data = self._sync_request("GET", f"/v1/status/poll/{task_id}", params=params)
        return TaskStatusResponse.model_validate(data)

    async def task_status_poll_async(
        self,
        task_id: str,
        *,
        wait: float = 0,
    ) -> TaskStatusResponse:
        """Poll task status asynchronously."""
        params: Dict[str, Any] = {}
        if wait > 0:
            params["wait"] = wait
        data = await self._async_request("GET", f"/v1/status/poll/{task_id}", params=params)
        return TaskStatusResponse.model_validate(data)

    def task_result(self, task_id: str) -> Dict[str, Any]:
        """Retrieve the result of a completed task (``GET /v1/result/{task_id}``)."""
        return self._sync_request("GET", f"/v1/result/{task_id}")

    async def task_result_async(self, task_id: str) -> Dict[str, Any]:
        """Retrieve the result of a completed task asynchronously."""
        return await self._async_request("GET", f"/v1/result/{task_id}")

    def wait_for_task(
        self,
        task_id: str,
        *,
        poll_interval: float = 2.0,
        timeout: Optional[float] = None,
    ) -> Dict[str, Any]:
        """Block until an async task completes, then return its result.

        Raises
        ------
        DoclingTimeoutError:
            If the task does not complete within ``timeout`` seconds.
        DoclingAPIError:
            If the task fails on the server.
        """
        effective_timeout = timeout if timeout is not None else self._timeout
        deadline = time.monotonic() + effective_timeout

        while True:
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                raise DoclingTimeoutError(
                    f"Task {task_id} did not complete within {effective_timeout}s"
                )

            status = self.task_status_poll(task_id, wait=min(poll_interval, remaining))

            if status.task_status.lower() in ("success", "completed"):
                return self.task_result(task_id)
            if status.task_status.lower() in ("failure", "failed", "error"):
                raise DoclingAPIError(
                    f"Task {task_id} failed: {status.error_message or 'unknown error'}",
                    response_body=status.model_dump(),
                )

            sleep_time = min(poll_interval, max(0.0, deadline - time.monotonic()))
            time.sleep(sleep_time)

    async def wait_for_task_async(
        self,
        task_id: str,
        *,
        poll_interval: float = 2.0,
        timeout: Optional[float] = None,
    ) -> Dict[str, Any]:
        """Async version of ``wait_for_task``."""
        effective_timeout = timeout if timeout is not None else self._timeout
        deadline = time.monotonic() + effective_timeout

        while True:
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                raise DoclingTimeoutError(
                    f"Task {task_id} did not complete within {effective_timeout}s"
                )

            status = await self.task_status_poll_async(
                task_id, wait=min(poll_interval, remaining),
            )

            if status.task_status.lower() in ("success", "completed"):
                return await self.task_result_async(task_id)
            if status.task_status.lower() in ("failure", "failed", "error"):
                raise DoclingAPIError(
                    f"Task {task_id} failed: {status.error_message or 'unknown error'}",
                    response_body=status.model_dump(),
                )

            sleep_time = min(poll_interval, max(0.0, deadline - time.monotonic()))
            await asyncio.sleep(sleep_time)

    # ===================================================================
    # PUBLIC API — Clear / Management
    # ===================================================================

    def clear_converters(self) -> ClearResponse:
        """Clear cached converters (``GET /v1/clear/converters``)."""
        data = self._sync_request("GET", "/v1/clear/converters")
        return ClearResponse.model_validate(data)

    async def clear_converters_async(self) -> ClearResponse:
        """Clear cached converters asynchronously."""
        data = await self._async_request("GET", "/v1/clear/converters")
        return ClearResponse.model_validate(data)

    def clear_results(self, *, older_than: float = 3600) -> ClearResponse:
        """Clear cached results (``GET /v1/clear/results``)."""
        data = self._sync_request(
            "GET", "/v1/clear/results",
            params={"older_then": older_than},  # sic — API uses "older_then"
        )
        return ClearResponse.model_validate(data)

    async def clear_results_async(self, *, older_than: float = 3600) -> ClearResponse:
        """Clear cached results asynchronously."""
        data = await self._async_request(
            "GET", "/v1/clear/results",
            params={"older_then": older_than},
        )
        return ClearResponse.model_validate(data)

    def memory_stats(self) -> Dict[str, Any]:
        """Get memory stats (``GET /v1/memory/stats``)."""
        return self._sync_request("GET", "/v1/memory/stats")

    async def memory_stats_async(self) -> Dict[str, Any]:
        """Get memory stats asynchronously."""
        return await self._async_request("GET", "/v1/memory/stats")

    def memory_counts(self) -> Dict[str, Any]:
        """Get memory object counts (``GET /v1/memory/counts``)."""
        return self._sync_request("GET", "/v1/memory/counts")

    async def memory_counts_async(self) -> Dict[str, Any]:
        """Get memory object counts asynchronously."""
        return await self._async_request("GET", "/v1/memory/counts")
