"""Synchronous KyroDB gRPC client."""

from __future__ import annotations

import ipaddress
import logging
import socket
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from dataclasses import dataclass
from functools import partial
from math import isfinite
from threading import RLock
from typing import Any, TypeVar

import grpc

from ._converters import (
    to_batch_delete_result,
    to_bulk_load_result,
    to_bulk_query_result,
    to_config_result,
    to_delete_result,
    to_flush_result,
    to_health_result,
    to_insert_ack,
    to_metrics_result,
    to_query_result,
    to_search_response,
    to_snapshot_result,
    to_update_metadata_result,
)
from ._generated import kyrodb_pb2 as pb2
from ._generated import kyrodb_pb2_grpc as pb2_grpc
from .auth import Metadata, api_key_metadata, merge_metadata, validate_api_key
from .errors import CircuitOpenError, DeadlineExceededError, map_rpc_error
from .models import (
    BatchDeleteResult,
    BulkLoadResult,
    BulkQueryResult,
    ConfigResult,
    DeleteResult,
    FlushResult,
    HealthResult,
    InsertAck,
    InsertItem,
    MetadataFilter,
    MetricsResult,
    QueryResult,
    SearchQuery,
    SearchResponse,
    SnapshotResult,
    UpdateMetadataResult,
)
from .retry import CircuitBreaker, CircuitBreakerPolicy, RetryPolicy, run_with_retry

T = TypeVar("T")
ApiKeyProvider = Callable[[], str | None]
logger = logging.getLogger("kyrodb")

try:
    import numpy as _np
except Exception:  # pragma: no cover - optional dependency
    _np = None

_DEFAULT_MAX_MESSAGE_BYTES = 64 * 1024 * 1024
_DEFAULT_CHANNEL_OPTIONS: tuple[tuple[str, int], ...] = (
    ("grpc.keepalive_time_ms", 30_000),
    ("grpc.keepalive_timeout_ms", 10_000),
    ("grpc.keepalive_permit_without_calls", 1),
    ("grpc.http2.max_pings_without_data", 0),
    ("grpc.max_receive_message_length", _DEFAULT_MAX_MESSAGE_BYTES),
    ("grpc.max_send_message_length", _DEFAULT_MAX_MESSAGE_BYTES),
)


@dataclass(slots=True, frozen=True)
class TLSConfig:
    root_certificates: bytes | None = None
    private_key: bytes | None = None
    certificate_chain: bytes | None = None


def _host_from_target(target: str) -> str:
    host, *_ = target.rsplit(":", maxsplit=1)
    return host.strip("[]")


def _resolve_host_ips(host: str) -> tuple[ipaddress.IPv4Address | ipaddress.IPv6Address, ...]:
    try:
        infos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
    except socket.gaierror:
        return ()

    addresses: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
    for _, _, _, _, sockaddr in infos:
        try:
            addresses.append(ipaddress.ip_address(sockaddr[0]))
        except ValueError:
            continue
    return tuple(addresses)


def _is_loopback(target: str) -> bool:
    host = _host_from_target(target)
    try:
        return ipaddress.ip_address(host).is_loopback
    except ValueError:
        resolved = _resolve_host_ips(host)
        return bool(resolved) and all(addr.is_loopback for addr in resolved)


def _validate_tls_config(tls: TLSConfig) -> None:
    for name, value in (
        ("root_certificates", tls.root_certificates),
        ("private_key", tls.private_key),
        ("certificate_chain", tls.certificate_chain),
    ):
        if value is not None and len(value) == 0:
            raise ValueError(f"{name} must not be empty bytes")
    has_private_key = tls.private_key is not None
    has_certificate_chain = tls.certificate_chain is not None
    if has_private_key != has_certificate_chain:
        raise ValueError("private_key and certificate_chain must be provided together")


def _merge_channel_options(
    overrides: Sequence[tuple[str, int | str]] | None,
) -> list[tuple[str, int | str]]:
    merged: dict[str, int | str] = {key: value for key, value in _DEFAULT_CHANNEL_OPTIONS}
    for key, value in overrides or ():
        merged[key] = value
    return list(merged.items())


def _validate_embedding(embedding: Sequence[float], name: str) -> list[float]:
    if _np is not None and isinstance(embedding, _np.ndarray):
        try:
            array: Any = _np.asarray(embedding, dtype=_np.float32)
        except Exception:
            array = None
        else:
            if array.ndim != 1:
                raise ValueError(f"{name} must be a 1D vector")
            if int(array.size) == 0:
                raise ValueError(f"{name} must be non-empty")
            if not bool(_np.isfinite(array).all()):
                raise ValueError(f"{name} contains non-finite values")
            return [float(v) for v in array]

    values = [float(v) for v in embedding]
    if not values:
        raise ValueError(f"{name} must be non-empty")
    if any(not isfinite(v) for v in values):
        raise ValueError(f"{name} contains non-finite values")
    return values


def _validate_doc_id(doc_id: int) -> None:
    if doc_id <= 0:
        raise ValueError("doc_id must be > 0")
    if doc_id > 0xFFFFFFFFFFFFFFFF:
        raise ValueError("doc_id exceeds uint64 range")


def _freeze_call_metadata(metadata: Iterable[tuple[str, str]] | None) -> Metadata | None:
    if metadata is None:
        return None
    return tuple(metadata)


def _chunked_ids(doc_ids: Sequence[int], chunk_size: int) -> Iterator[list[int]]:
    for index in range(0, len(doc_ids), chunk_size):
        yield list(doc_ids[index : index + chunk_size])


class _UseClientDefaultTimeout:
    __slots__ = ()


_USE_CLIENT_DEFAULT_TIMEOUT = _UseClientDefaultTimeout()
TimeoutArg = float | None | _UseClientDefaultTimeout


class KyroDBClient:
    """Production-grade sync client for KyroDB gRPC API."""

    def __init__(
        self,
        target: str = "127.0.0.1:50051",
        *,
        api_key: str | None = None,
        tls: TLSConfig | None = None,
        default_namespace: str = "",
        default_timeout_s: float | None = 30.0,
        retry_policy: RetryPolicy | None = None,
        circuit_breaker_policy: CircuitBreakerPolicy | None = None,
        channel_options: Sequence[tuple[str, int | str]] | None = None,
        lb_policy_name: str | None = None,
        compression: grpc.Compression | None = None,
        max_unary_batch_size: int = 10_000,
    ) -> None:
        if default_timeout_s is not None and default_timeout_s <= 0:
            raise ValueError("default_timeout_s must be > 0 when set")
        if max_unary_batch_size <= 0:
            raise ValueError("max_unary_batch_size must be > 0")
        self._target = target
        self._default_timeout_s = default_timeout_s
        self._default_namespace = default_namespace
        self._retry_policy = retry_policy or RetryPolicy()
        self._circuit_breaker = (
            CircuitBreaker(circuit_breaker_policy) if circuit_breaker_policy is not None else None
        )
        self._max_unary_batch_size = max_unary_batch_size
        self._auth_lock = RLock()
        self._dim_lock = RLock()
        self._insert_embedding_dim: int | None = None
        self._api_key: str | None = None
        self._api_key_provider: ApiKeyProvider | None = None
        self.set_api_key(api_key)

        if lb_policy_name is not None and not lb_policy_name.strip():
            raise ValueError("lb_policy_name must be non-empty when set")

        options = _merge_channel_options(channel_options)
        if lb_policy_name is not None:
            options.append(("grpc.lb_policy_name", lb_policy_name))
        if tls is None:
            if not _is_loopback(target):
                raise ValueError("TLS is required for non-loopback targets")
            self._channel = grpc.insecure_channel(
                target,
                options=options,
                compression=compression,
            )
        else:
            _validate_tls_config(tls)
            credentials = grpc.ssl_channel_credentials(
                root_certificates=tls.root_certificates,
                private_key=tls.private_key,
                certificate_chain=tls.certificate_chain,
            )
            self._channel = grpc.secure_channel(
                target,
                credentials,
                options=options,
                compression=compression,
            )

        self._stub = pb2_grpc.KyroDBServiceStub(self._channel)
        logger.debug(
            "Initialized KyroDBClient target=%s tls=%s default_timeout_s=%s",
            target,
            tls is not None,
            default_timeout_s,
        )

    def close(self) -> None:
        """Close the underlying gRPC channel."""
        self._channel.close()
        logger.debug("Closed KyroDBClient target=%s", self._target)

    def __enter__(self) -> KyroDBClient:
        return self

    def __exit__(self, exc_type, exc, tb) -> None:  # type: ignore[no-untyped-def]
        self.close()

    def set_api_key(self, api_key: str | None) -> None:
        """Set or clear static API key used for subsequent calls."""
        validate_api_key(api_key)
        with self._auth_lock:
            self._api_key_provider = None
            self._api_key = api_key

    def set_api_key_provider(self, provider: ApiKeyProvider) -> None:
        """Set dynamic API key provider for per-call key rotation."""
        if not callable(provider):
            raise TypeError("provider must be callable")
        probe_key = provider()
        validate_api_key(probe_key)
        with self._auth_lock:
            self._api_key_provider = provider
            self._api_key = probe_key

    def _current_api_key(self) -> str | None:
        with self._auth_lock:
            provider = self._api_key_provider
            static_key = self._api_key
        if provider is None:
            return static_key
        current = provider()
        validate_api_key(current)
        return current

    def _record_insert_embedding_dim(self, dim: int) -> None:
        with self._dim_lock:
            previous = self._insert_embedding_dim
            self._insert_embedding_dim = dim
        if previous is not None and previous != dim:
            logger.debug(
                "Insert embedding dimension changed target=%s previous=%d current=%d",
                self._target,
                previous,
                dim,
            )

    def _log_search_embedding_dim_if_mismatch(self, dim: int) -> None:
        with self._dim_lock:
            insert_dim = self._insert_embedding_dim
        if insert_dim is not None and insert_dim != dim:
            logger.debug(
                "Search embedding dimension differs from last insert "
                "target=%s insert_dim=%d search_dim=%d",
                self._target,
                insert_dim,
                dim,
            )

    def wait_for_ready(
        self,
        *,
        timeout_s: TimeoutArg = _USE_CLIENT_DEFAULT_TIMEOUT,
    ) -> None:
        """Block until channel is ready or deadline is exceeded."""
        timeout = self._timeout(timeout_s)
        try:
            grpc.channel_ready_future(self._channel).result(timeout=timeout)
            logger.debug("Channel ready target=%s timeout_s=%s", self._target, timeout)
        except grpc.FutureTimeoutError as exc:
            logger.warning(
                "Channel readiness timed out target=%s timeout_s=%s",
                self._target,
                timeout,
            )
            raise DeadlineExceededError(
                operation="WaitForReady",
                code=grpc.StatusCode.DEADLINE_EXCEEDED,
                details="channel did not become ready before timeout",
                target=self._target,
            ) from exc

    def _timeout(self, timeout_s: TimeoutArg) -> float | None:
        if isinstance(timeout_s, _UseClientDefaultTimeout):
            return self._default_timeout_s
        if timeout_s is not None and timeout_s <= 0:
            raise ValueError("timeout_s must be > 0 when set, or None for unbounded")
        return timeout_s

    def _metadata(self, metadata: Iterable[tuple[str, str]] | None) -> Metadata:
        return merge_metadata(api_key_metadata(self._current_api_key()), metadata)

    def _ensure_circuit_allows_call(self, operation: str) -> None:
        if self._circuit_breaker is None:
            return
        allowed, retry_after_s = self._circuit_breaker.allow_call()
        if not allowed:
            raise CircuitOpenError(
                operation=operation,
                code=grpc.StatusCode.UNAVAILABLE,
                details=(
                    "circuit breaker is open"
                    if retry_after_s is None
                    else f"circuit breaker is open; retry after {retry_after_s:.2f}s"
                ),
                target=self._target,
            )

    def _request_metadata(
        self,
        metadata: Iterable[tuple[str, str]] | None,
        *,
        operation: str,
    ) -> Metadata:
        self._ensure_circuit_allows_call(operation)
        return self._metadata(_freeze_call_metadata(metadata))

    def _safe_call(self, fn: Callable[[], T], operation: str, retry: bool) -> T:
        self._ensure_circuit_allows_call(operation)
        try:
            response = run_with_retry(fn, self._retry_policy) if retry else fn()
            if self._circuit_breaker is not None:
                self._circuit_breaker.record_success()
            return response
        except grpc.RpcError as exc:
            code = exc.code() if hasattr(exc, "code") else grpc.StatusCode.UNKNOWN
            if self._circuit_breaker is not None:
                self._circuit_breaker.record_failure(code)
            safe_auth_metadata = (("x-api-key", "<redacted>"),)
            logger.warning(
                "RPC failed operation=%s target=%s code=%s retry=%s metadata=%s",
                operation,
                self._target,
                code.name if hasattr(code, "name") else str(code),
                retry,
                safe_auth_metadata,
            )
            raise map_rpc_error(exc, operation=operation, target=self._target) from exc

    def insert(
        self,
        *,
        doc_id: int,
        embedding: Sequence[float],
        metadata: Mapping[str, str] | None = None,
        namespace: str | None = None,
        timeout_s: TimeoutArg = _USE_CLIENT_DEFAULT_TIMEOUT,
        call_metadata: Iterable[tuple[str, str]] | None = None,
    ) -> InsertAck:
        """Insert a single document vector into KyroDB."""
        _validate_doc_id(doc_id)
        vector = _validate_embedding(embedding, "embedding")
        self._record_insert_embedding_dim(len(vector))
        request = pb2.InsertRequest(
            doc_id=doc_id,
            embedding=vector,
            metadata=dict(metadata or {}),
            namespace=namespace if namespace is not None else self._default_namespace,
        )
        request_metadata = self._request_metadata(call_metadata, operation="Insert")

        response = self._safe_call(
            lambda: self._stub.Insert(
                request,
                timeout=self._timeout(timeout_s),
                metadata=request_metadata,
            ),
            operation="Insert",
            retry=False,
        )
        return to_insert_ack(response)

    def bulk_insert(
        self,
        items: Iterable[InsertItem],
        *,
        timeout_s: TimeoutArg = _USE_CLIENT_DEFAULT_TIMEOUT,
        call_metadata: Iterable[tuple[str, str]] | None = None,
    ) -> InsertAck:
        """Stream-insert multiple documents."""
        request_metadata = self._request_metadata(call_metadata, operation="BulkInsert")

        def req_stream() -> Iterator[pb2.InsertRequest]:
            for item in items:
                _validate_doc_id(item.doc_id)
                vector = _validate_embedding(item.embedding, "embedding")
                self._record_insert_embedding_dim(len(vector))
                yield pb2.InsertRequest(
                    doc_id=item.doc_id,
                    embedding=vector,
                    metadata=dict(item.metadata),
                    namespace=item.namespace or self._default_namespace,
                )

        response = self._safe_call(
            lambda: self._stub.BulkInsert(
                req_stream(),
                timeout=self._timeout(timeout_s),
                metadata=request_metadata,
            ),
            operation="BulkInsert",
            retry=False,
        )
        return to_insert_ack(response)

    def bulk_load_hnsw(
        self,
        items: Iterable[InsertItem],
        *,
        timeout_s: TimeoutArg = _USE_CLIENT_DEFAULT_TIMEOUT,
        call_metadata: Iterable[tuple[str, str]] | None = None,
    ) -> BulkLoadResult:
        """Bulk-load vectors directly into HNSW."""
        request_metadata = self._request_metadata(call_metadata, operation="BulkLoadHnsw")

        def req_stream() -> Iterator[pb2.InsertRequest]:
            for item in items:
                _validate_doc_id(item.doc_id)
                vector = _validate_embedding(item.embedding, "embedding")
                self._record_insert_embedding_dim(len(vector))
                yield pb2.InsertRequest(
                    doc_id=item.doc_id,
                    embedding=vector,
                    metadata=dict(item.metadata),
                    namespace=item.namespace or self._default_namespace,
                )

        response = self._safe_call(
            lambda: self._stub.BulkLoadHnsw(
                req_stream(),
                timeout=self._timeout(timeout_s),
                metadata=request_metadata,
            ),
            operation="BulkLoadHnsw",
            retry=False,
        )
        return to_bulk_load_result(response)

    def delete(
        self,
        *,
        doc_id: int,
        namespace: str | None = None,
        timeout_s: TimeoutArg = _USE_CLIENT_DEFAULT_TIMEOUT,
        call_metadata: Iterable[tuple[str, str]] | None = None,
    ) -> DeleteResult:
        """Delete a document by ID."""
        _validate_doc_id(doc_id)
        request = pb2.DeleteRequest(
            doc_id=doc_id,
            namespace=namespace if namespace is not None else self._default_namespace,
        )
        request_metadata = self._request_metadata(call_metadata, operation="Delete")
        response = self._safe_call(
            lambda: self._stub.Delete(
                request,
                timeout=self._timeout(timeout_s),
                metadata=request_metadata,
            ),
            operation="Delete",
            retry=False,
        )
        return to_delete_result(response)

    def update_metadata(
        self,
        *,
        doc_id: int,
        metadata: Mapping[str, str],
        merge: bool = True,
        namespace: str | None = None,
        timeout_s: TimeoutArg = _USE_CLIENT_DEFAULT_TIMEOUT,
        call_metadata: Iterable[tuple[str, str]] | None = None,
    ) -> UpdateMetadataResult:
        """Update metadata for an existing document."""
        _validate_doc_id(doc_id)
        request = pb2.UpdateMetadataRequest(
            doc_id=doc_id,
            metadata=dict(metadata),
            merge=merge,
            namespace=namespace if namespace is not None else self._default_namespace,
        )
        request_metadata = self._request_metadata(call_metadata, operation="UpdateMetadata")
        response = self._safe_call(
            lambda: self._stub.UpdateMetadata(
                request,
                timeout=self._timeout(timeout_s),
                metadata=request_metadata,
            ),
            operation="UpdateMetadata",
            retry=False,
        )
        return to_update_metadata_result(response)

    def query(
        self,
        *,
        doc_id: int,
        include_embedding: bool = False,
        namespace: str | None = None,
        timeout_s: TimeoutArg = _USE_CLIENT_DEFAULT_TIMEOUT,
        call_metadata: Iterable[tuple[str, str]] | None = None,
    ) -> QueryResult:
        """Fetch a document by ID."""
        _validate_doc_id(doc_id)
        request = pb2.QueryRequest(
            doc_id=doc_id,
            include_embedding=include_embedding,
            namespace=namespace if namespace is not None else self._default_namespace,
        )
        request_metadata = self._request_metadata(call_metadata, operation="Query")
        response = self._safe_call(
            lambda: self._stub.Query(
                request,
                timeout=self._timeout(timeout_s),
                metadata=request_metadata,
            ),
            operation="Query",
            retry=True,
        )
        return to_query_result(response)

    def search(
        self,
        *,
        query_embedding: Sequence[float],
        k: int,
        min_score: float = 0.0,
        namespace: str | None = None,
        include_embeddings: bool = False,
        filter: MetadataFilter | None = None,
        ef_search: int = 0,
        timeout_s: TimeoutArg = _USE_CLIENT_DEFAULT_TIMEOUT,
        call_metadata: Iterable[tuple[str, str]] | None = None,
    ) -> SearchResponse:
        """Run k-NN search against KyroDB."""
        if k <= 0 or k > 1000:
            raise ValueError("k must be between 1 and 1000")
        if ef_search < 0:
            raise ValueError("ef_search must be >= 0")
        vector = _validate_embedding(query_embedding, "query_embedding")
        self._log_search_embedding_dim_if_mismatch(len(vector))
        request = pb2.SearchRequest(
            query_embedding=vector,
            k=k,
            min_score=min_score,
            namespace=namespace if namespace is not None else self._default_namespace,
            include_embeddings=include_embeddings,
            ef_search=ef_search,
        )
        if filter is not None:
            request.filter.CopyFrom(filter.to_proto())
        request_metadata = self._request_metadata(call_metadata, operation="Search")

        response = self._safe_call(
            lambda: self._stub.Search(
                request,
                timeout=self._timeout(timeout_s),
                metadata=request_metadata,
            ),
            operation="Search",
            retry=True,
        )
        return to_search_response(response)

    def bulk_search(
        self,
        requests: Iterable[SearchQuery],
        *,
        timeout_s: TimeoutArg = _USE_CLIENT_DEFAULT_TIMEOUT,
        call_metadata: Iterable[tuple[str, str]] | None = None,
    ) -> Iterator[SearchResponse]:
        """Run streaming batch search and yield responses."""
        request_metadata = self._request_metadata(call_metadata, operation="BulkSearch")

        def req_stream() -> Iterator[pb2.SearchRequest]:
            for query in requests:
                if query.k <= 0 or query.k > 1000:
                    raise ValueError("k must be between 1 and 1000")
                if query.ef_search < 0:
                    raise ValueError("ef_search must be >= 0")
                vector = _validate_embedding(query.query_embedding, "query_embedding")
                self._log_search_embedding_dim_if_mismatch(len(vector))
                request = pb2.SearchRequest(
                    query_embedding=vector,
                    k=query.k,
                    min_score=query.min_score,
                    namespace=query.namespace or self._default_namespace,
                    include_embeddings=query.include_embeddings,
                    ef_search=query.ef_search,
                )
                if query.filter is not None:
                    request.filter.CopyFrom(query.filter.to_proto())
                yield request

        try:
            response_stream = self._stub.BulkSearch(
                req_stream(),
                timeout=self._timeout(timeout_s),
                metadata=request_metadata,
            )
            for response in response_stream:
                yield to_search_response(response)
            if self._circuit_breaker is not None:
                self._circuit_breaker.record_success()
        except grpc.RpcError as exc:
            code = exc.code() if hasattr(exc, "code") else grpc.StatusCode.UNKNOWN
            if self._circuit_breaker is not None:
                self._circuit_breaker.record_failure(code)
            raise map_rpc_error(exc, operation="BulkSearch", target=self._target) from exc

    def bulk_query(
        self,
        *,
        doc_ids: Sequence[int],
        include_embeddings: bool = False,
        namespace: str | None = None,
        timeout_s: TimeoutArg = _USE_CLIENT_DEFAULT_TIMEOUT,
        call_metadata: Iterable[tuple[str, str]] | None = None,
    ) -> BulkQueryResult:
        """Fetch multiple documents by ID in one request."""
        if not doc_ids:
            raise ValueError("doc_ids must be non-empty")
        for doc_id in doc_ids:
            _validate_doc_id(doc_id)
        request_metadata = self._request_metadata(call_metadata, operation="BulkQuery")
        if len(doc_ids) <= self._max_unary_batch_size:
            request = pb2.BulkQueryRequest(
                doc_ids=list(doc_ids),
                include_embeddings=include_embeddings,
                namespace=namespace if namespace is not None else self._default_namespace,
            )
            response = self._safe_call(
                lambda: self._stub.BulkQuery(
                    request,
                    timeout=self._timeout(timeout_s),
                    metadata=request_metadata,
                ),
                operation="BulkQuery",
                retry=True,
            )
            return to_bulk_query_result(response)

        results: list[QueryResult] = []
        total_found = 0
        total_requested = 0
        errors: list[str] = []
        for chunk in _chunked_ids(doc_ids, self._max_unary_batch_size):
            request = pb2.BulkQueryRequest(
                doc_ids=chunk,
                include_embeddings=include_embeddings,
                namespace=namespace if namespace is not None else self._default_namespace,
            )
            call = partial(
                self._stub.BulkQuery,
                request,
                timeout=self._timeout(timeout_s),
                metadata=request_metadata,
            )
            response = self._safe_call(
                call,
                operation="BulkQuery",
                retry=True,
            )
            converted = to_bulk_query_result(response)
            results.extend(converted.results)
            total_found += converted.total_found
            total_requested += converted.total_requested
            if converted.error:
                errors.append(converted.error)

        return BulkQueryResult(
            results=tuple(results),
            total_found=total_found,
            total_requested=total_requested,
            error=" | ".join(errors),
        )

    def batch_delete_ids(
        self,
        *,
        doc_ids: Sequence[int],
        namespace: str | None = None,
        timeout_s: TimeoutArg = _USE_CLIENT_DEFAULT_TIMEOUT,
        call_metadata: Iterable[tuple[str, str]] | None = None,
    ) -> BatchDeleteResult:
        """Delete multiple documents by explicit IDs."""
        if not doc_ids:
            raise ValueError("doc_ids must be non-empty")
        for doc_id in doc_ids:
            _validate_doc_id(doc_id)
        request_metadata = self._request_metadata(call_metadata, operation="BatchDelete")

        if len(doc_ids) <= self._max_unary_batch_size:
            request = pb2.BatchDeleteRequest(
                ids=pb2.IdList(doc_ids=list(doc_ids)),
                namespace=namespace if namespace is not None else self._default_namespace,
            )
            response = self._safe_call(
                lambda: self._stub.BatchDelete(
                    request,
                    timeout=self._timeout(timeout_s),
                    metadata=request_metadata,
                ),
                operation="BatchDelete",
                retry=False,
            )
            return to_batch_delete_result(response)

        deleted_total = 0
        errors: list[str] = []
        all_success = True
        for chunk in _chunked_ids(doc_ids, self._max_unary_batch_size):
            request = pb2.BatchDeleteRequest(
                ids=pb2.IdList(doc_ids=chunk),
                namespace=namespace if namespace is not None else self._default_namespace,
            )
            call = partial(
                self._stub.BatchDelete,
                request,
                timeout=self._timeout(timeout_s),
                metadata=request_metadata,
            )
            response = self._safe_call(
                call,
                operation="BatchDelete",
                retry=False,
            )
            converted = to_batch_delete_result(response)
            deleted_total += converted.deleted_count
            all_success = all_success and converted.success
            if converted.error:
                errors.append(converted.error)

        return BatchDeleteResult(
            success=all_success,
            deleted_count=deleted_total,
            error=" | ".join(errors),
        )

    def health(
        self,
        *,
        component: str = "",
        timeout_s: TimeoutArg = _USE_CLIENT_DEFAULT_TIMEOUT,
        call_metadata: Iterable[tuple[str, str]] | None = None,
    ) -> HealthResult:
        """Fetch service health status."""
        request = pb2.HealthRequest(component=component)
        request_metadata = self._request_metadata(call_metadata, operation="Health")
        response = self._safe_call(
            lambda: self._stub.Health(
                request,
                timeout=self._timeout(timeout_s),
                metadata=request_metadata,
            ),
            operation="Health",
            retry=True,
        )
        return to_health_result(response)

    def metrics(
        self,
        *,
        categories: Sequence[str] | None = None,
        timeout_s: TimeoutArg = _USE_CLIENT_DEFAULT_TIMEOUT,
        call_metadata: Iterable[tuple[str, str]] | None = None,
    ) -> MetricsResult:
        """Fetch service metrics snapshot."""
        request = pb2.MetricsRequest(categories=list(categories or ()))
        request_metadata = self._request_metadata(call_metadata, operation="Metrics")
        response = self._safe_call(
            lambda: self._stub.Metrics(
                request,
                timeout=self._timeout(timeout_s),
                metadata=request_metadata,
            ),
            operation="Metrics",
            retry=True,
        )
        return to_metrics_result(response)

    def flush_hot_tier(
        self,
        *,
        force: bool = True,
        timeout_s: TimeoutArg = _USE_CLIENT_DEFAULT_TIMEOUT,
        call_metadata: Iterable[tuple[str, str]] | None = None,
    ) -> FlushResult:
        """Force flush hot tier to cold tier."""
        request = pb2.FlushRequest(force=force)
        request_metadata = self._request_metadata(call_metadata, operation="FlushHotTier")
        response = self._safe_call(
            lambda: self._stub.FlushHotTier(
                request,
                timeout=self._timeout(timeout_s),
                metadata=request_metadata,
            ),
            operation="FlushHotTier",
            retry=False,
        )
        return to_flush_result(response)

    def create_snapshot(
        self,
        *,
        path: str = "",
        timeout_s: TimeoutArg = _USE_CLIENT_DEFAULT_TIMEOUT,
        call_metadata: Iterable[tuple[str, str]] | None = None,
    ) -> SnapshotResult:
        """Create a snapshot on the server."""
        request = pb2.SnapshotRequest(path=path)
        request_metadata = self._request_metadata(call_metadata, operation="CreateSnapshot")
        response = self._safe_call(
            lambda: self._stub.CreateSnapshot(
                request,
                timeout=self._timeout(timeout_s),
                metadata=request_metadata,
            ),
            operation="CreateSnapshot",
            retry=False,
        )
        return to_snapshot_result(response)

    def get_config(
        self,
        *,
        timeout_s: TimeoutArg = _USE_CLIENT_DEFAULT_TIMEOUT,
        call_metadata: Iterable[tuple[str, str]] | None = None,
    ) -> ConfigResult:
        """Fetch effective server configuration."""
        request = pb2.ConfigRequest()
        request_metadata = self._request_metadata(call_metadata, operation="GetConfig")
        response = self._safe_call(
            lambda: self._stub.GetConfig(
                request,
                timeout=self._timeout(timeout_s),
                metadata=request_metadata,
            ),
            operation="GetConfig",
            retry=True,
        )
        return to_config_result(response)
