"""BPA API async HTTP client with retry logic.

This module provides the main BPAClient class for interacting with the
BPA REST API. It features:

- Async HTTP operations using httpx
- Exponential backoff retry for transient errors (429, 502, 503, 504)
- No retry for client errors (400, 401, 403, 404)
- Token-based authorization via auth module
- AI-friendly error translation

Usage:
    from mcp_eregistrations_bpa.bpa_client import BPAClient

    async with BPAClient.for_instance("jamaica") as client:
        services = await client.get("/service")
"""

from __future__ import annotations

import asyncio
import logging
from typing import TYPE_CHECKING, Any, cast

import httpx

from mcp_eregistrations_bpa.bpa_client.errors import (
    BPAClientError,
    BPAConnectionError,
    BPATimeoutError,
    translate_http_error,
)
from mcp_eregistrations_bpa.config import (
    _generate_instance_slug,
    load_config,
    resolve_instance_config,
)

if TYPE_CHECKING:
    from types import TracebackType

    from mcp_eregistrations_bpa.config import Config

__all__ = [
    "BPAClient",
    "MAX_RETRIES",
    "BASE_DELAY",
    "MAX_DELAY",
    "DEFAULT_TIMEOUT",
    "RETRYABLE_STATUS_CODES",
    "NON_RETRYABLE_STATUS_CODES",
]

logger = logging.getLogger(__name__)

# Retry configuration
MAX_RETRIES = 3
BASE_DELAY = 1.0  # seconds
MAX_DELAY = 10.0  # seconds

# Timeout configuration
DEFAULT_TIMEOUT = 30.0  # seconds

# Health check timeout (short, used for diagnostics on failure)
HEALTH_CHECK_TIMEOUT = 3.0  # seconds

# Status codes that should trigger retry
RETRYABLE_STATUS_CODES = frozenset({429, 502, 503, 504})

# Status codes that should NOT be retried (client errors)
NON_RETRYABLE_STATUS_CODES = frozenset({400, 401, 403, 404})


def calculate_backoff_delay(attempt: int) -> float:
    """Calculate exponential backoff delay with jitter.

    Uses exponential backoff: delay = min(BASE_DELAY * 2^attempt, MAX_DELAY)
    Adds small jitter to prevent thundering herd.

    Args:
        attempt: The retry attempt number (0-indexed).

    Returns:
        The delay in seconds before next retry.
    """
    import random

    delay = min(BASE_DELAY * (2**attempt), MAX_DELAY)
    # Add up to 10% jitter
    jitter = delay * 0.1 * random.random()
    return float(delay + jitter)


class BPAClient:
    """Async HTTP client for BPA API with retry logic.

    Features:
    - Token-based authorization header injection
    - Exponential backoff retry for transient errors
    - AI-friendly error translation
    - Async context manager support

    Attributes:
        base_url: The BPA instance base URL.
        timeout: Request timeout in seconds.
        max_retries: Maximum retry attempts for transient errors.

    Example:
        async with BPAClient.for_instance("jamaica") as client:
            # GET request
            services = await client.get("/service")

            # GET with path parameters
            service = await client.get("/service/{id}", path_params={"id": 123})

            # POST with body
            result = await client.post("/service", json={"name": "New Service"})
    """

    def __init__(
        self,
        *,
        base_url: str | None = None,
        instance_id: str | None = None,
        config: Config | None = None,
        timeout: float = DEFAULT_TIMEOUT,
        max_retries: int = MAX_RETRIES,
    ) -> None:
        """Initialize BPA client.

        Args:
            base_url: BPA instance base URL. If None, uses config.
            instance_id: Instance slug for token isolation. Derived from base_url if
                None.
            config: Pre-resolved Config for auth. Avoids slug-vs-profile mismatch.
            timeout: Request timeout in seconds.
            max_retries: Maximum retry attempts for transient errors.
        """
        if base_url is None:
            resolved = load_config()
            base_url = str(resolved.bpa_instance_url)
            self.instance_id = instance_id or resolved.instance_id
            self._config: Config | None = config or resolved
        else:
            self.instance_id = instance_id or _generate_instance_slug(base_url)
            self._config = config

        # Ensure base URL ends with API path (v2016.06, not /v3)
        if not base_url.endswith("/bparest/bpa/v2016/06"):
            base_url = base_url.rstrip("/") + "/bparest/bpa/v2016/06"

        self.base_url = base_url
        self.timeout = timeout
        self.max_retries = max_retries
        self._client: httpx.AsyncClient | None = None

    @classmethod
    def for_instance(cls, instance: str | None) -> BPAClient:
        """Create BPAClient for a named profile or None (env var default).

        Args:
            instance: Named profile from profiles.json, or None to use env var.

        Returns:
            Configured BPAClient for the given instance.
        """
        config = resolve_instance_config(instance)
        return cls(
            base_url=config.bpa_instance_url,
            instance_id=config.instance_id,
            config=config,
        )

    async def __aenter__(self) -> BPAClient:
        """Enter async context manager."""
        self._client = httpx.AsyncClient(
            base_url=self.base_url,
            timeout=httpx.Timeout(self.timeout),
        )
        return self

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: TracebackType | None,
    ) -> None:
        """Exit async context manager."""
        if self._client:
            await self._client.aclose()
            self._client = None

    def _get_client(self) -> httpx.AsyncClient:
        """Get the HTTP client, ensuring it's initialized.

        Returns:
            The httpx async client.

        Raises:
            RuntimeError: If client is not initialized (use async with).
        """
        if self._client is None:
            msg = "BPAClient must be used as an async context manager"
            raise RuntimeError(msg)
        return self._client

    async def _get_auth_header(self) -> dict[str, str]:
        """Get authorization header with current access token.

        Returns:
            Headers dict with Authorization header.

        Raises:
            ToolError: If not authenticated or token expired.
        """
        from mcp_eregistrations_bpa.auth.permissions import ensure_authenticated

        token = await ensure_authenticated(
            instance_id=self.instance_id, config=self._config
        )
        return {"Authorization": f"Bearer {token}"}

    def _format_url(self, endpoint: str, path_params: dict[str, Any] | None) -> str:
        """Format URL endpoint with path parameters.

        Args:
            endpoint: URL endpoint template (e.g., "/service/{id}").
            path_params: Dictionary of path parameters.

        Returns:
            Formatted URL path.
        """
        if path_params:
            return endpoint.format(**path_params)
        return endpoint

    async def _check_health(self) -> str:
        """Probe the BPA health endpoint for diagnostics.

        Uses a separate httpx client with a short timeout and no auth.
        base_url always ends with /bparest/bpa/v2016/06 (enforced by __init__).

        Returns:
            Human-readable diagnostic string (no trailing period).
        """
        health_url = f"{self.base_url}/health"

        try:
            async with httpx.AsyncClient(
                timeout=httpx.Timeout(HEALTH_CHECK_TIMEOUT),
            ) as hc:
                resp = await hc.get(health_url)
                if resp.status_code == 200:
                    return "Backend is reachable (health check OK)"
                return (
                    f"Backend responded with HTTP {resp.status_code} "
                    f"(reachable but health check returned non-200)"
                )
        except httpx.TimeoutException:
            return (
                "Backend is UNREACHABLE (health check timed out). "
                "Check instance URL and network connectivity"
            )
        except httpx.ConnectError:
            return (
                "Backend is UNREACHABLE (connection refused). "
                "Check instance URL and network connectivity"
            )
        except Exception as e:
            return f"Health check failed: {e}"

    async def _request_with_retry(
        self,
        method: str,
        endpoint: str,
        *,
        path_params: dict[str, Any] | None = None,
        params: dict[str, Any] | None = None,
        json: dict[str, Any] | str | None = None,
        content: str | bytes | None = None,
        resource_type: str | None = None,
        resource_id: str | int | None = None,
    ) -> httpx.Response:
        """Execute HTTP request with retry logic.

        Retries on transient errors (429, 502, 503, 504) with exponential backoff.
        Does NOT retry on client errors (400, 401, 403, 404).

        Args:
            method: HTTP method (GET, POST, PUT, DELETE).
            endpoint: URL endpoint template.
            path_params: Path parameters for URL formatting.
            params: Query parameters.
            json: JSON body for POST/PUT.
            resource_type: Resource type for error context.
            resource_id: Resource ID for error context.

        Returns:
            httpx Response object.

        Raises:
            BPAClientError: On non-retryable errors or after max retries.
        """
        client = self._get_client()
        url = self._format_url(endpoint, path_params)
        headers = await self._get_auth_header()

        last_error: Exception | None = None

        for attempt in range(self.max_retries + 1):
            try:
                # Log request details (DEBUG level for verbose output)
                logger.debug(
                    "BPA API Request: %s %s%s",
                    method,
                    self.base_url,
                    url,
                )
                if params:
                    logger.debug("  Query params: %s", params)
                if json:
                    logger.debug("  Request body (json): %s", json)
                if content:
                    logger.debug("  Request body (content): %s", content)

                # When sending raw content (not json=), set
                # Content-Type explicitly (httpx defaults to text/plain).
                req_headers = dict(headers) if headers else {}
                if content is not None and json is None:
                    req_headers.setdefault("Content-Type", "application/json")

                response = await client.request(
                    method,
                    url,
                    headers=req_headers,
                    params=params,
                    json=json,
                    content=content,
                )

                # Log response (INFO for success, DEBUG for body)
                logger.info(
                    "BPA API Response: %s %s → %d %s",
                    method,
                    url,
                    response.status_code,
                    response.reason_phrase,
                )

                # Check for HTTP errors
                if response.status_code >= 400:
                    # Don't retry client errors
                    if response.status_code in NON_RETRYABLE_STATUS_CODES:
                        error = httpx.HTTPStatusError(
                            f"HTTP {response.status_code}",
                            request=response.request,
                            response=response,
                        )
                        raise translate_http_error(
                            error,
                            resource_type=resource_type,
                            resource_id=resource_id,
                        )

                    # Check if retryable
                    if response.status_code in RETRYABLE_STATUS_CODES:
                        if attempt < self.max_retries:
                            delay = calculate_backoff_delay(attempt)
                            logger.warning(
                                "Retryable error %d on %s %s (attempt %d/%d), "
                                "retrying in %.2fs",
                                response.status_code,
                                method,
                                url,
                                attempt + 1,
                                self.max_retries + 1,
                                delay,
                            )
                            await asyncio.sleep(delay)
                            continue

                    # Non-retryable or out of retries
                    error = httpx.HTTPStatusError(
                        f"HTTP {response.status_code}",
                        request=response.request,
                        response=response,
                    )
                    raise translate_http_error(
                        error,
                        resource_type=resource_type,
                        resource_id=resource_id,
                    )

                return response

            except httpx.ConnectError as e:
                last_error = e
                if attempt < self.max_retries:
                    delay = calculate_backoff_delay(attempt)
                    logger.warning(
                        "Connection error on %s %s (attempt %d/%d), "
                        "retrying in %.2fs: %s",
                        method,
                        url,
                        attempt + 1,
                        self.max_retries + 1,
                        delay,
                        e,
                    )
                    await asyncio.sleep(delay)
                    continue
                health_info = await self._check_health()
                raise BPAConnectionError(
                    f"Failed to connect to BPA API after "
                    f"{self.max_retries + 1} attempts. {health_info}."
                ) from e

            except httpx.TimeoutException as e:
                # Don't retry timeouts — a slow endpoint won't get faster.
                # Run health check to give the user actionable diagnostics.
                logger.warning(
                    "Timeout on %s %s after %ss",
                    method,
                    url,
                    self.timeout,
                )
                health_info = await self._check_health()
                raise BPATimeoutError(
                    f"BPA API timed out on {method} {url} after "
                    f"{self.timeout:.0f}s. {health_info}."
                ) from e

            except BPAClientError:
                # Re-raise BPA errors without wrapping
                raise

        # Should not reach here, but just in case
        if last_error:
            raise BPAClientError(f"Request failed: {last_error}")
        raise BPAClientError("Request failed for unknown reason")

    async def get(
        self,
        endpoint: str,
        *,
        path_params: dict[str, Any] | None = None,
        params: dict[str, Any] | None = None,
        resource_type: str | None = None,
        resource_id: str | int | None = None,
    ) -> dict[str, Any]:
        """Execute GET request.

        Args:
            endpoint: URL endpoint template.
            path_params: Path parameters for URL formatting.
            params: Query parameters.
            resource_type: Resource type for error context.
            resource_id: Resource ID for error context.

        Returns:
            JSON response as dictionary.

        Raises:
            BPAClientError: On API errors.
        """
        response = await self._request_with_retry(
            "GET",
            endpoint,
            path_params=path_params,
            params=params,
            resource_type=resource_type,
            resource_id=resource_id,
        )
        # Handle empty response body
        # (API returns 200 with no content for some missing resources)
        if not response.content:
            from mcp_eregistrations_bpa.bpa_client.errors import BPANotFoundError

            raise BPANotFoundError(
                "Resource not found (empty response)",
                status_code=200,
            )
        return cast(dict[str, Any], response.json())

    async def get_text(
        self,
        endpoint: str,
        *,
        path_params: dict[str, Any] | None = None,
        params: dict[str, Any] | None = None,
        resource_type: str | None = None,
        resource_id: str | int | None = None,
    ) -> str:
        """Execute GET request expecting a text response (e.g. CSV).

        Args:
            endpoint: URL endpoint template.
            path_params: Path parameters for URL formatting.
            params: Query parameters.
            resource_type: Resource type for error context.
            resource_id: Resource ID for error context.

        Returns:
            Response body as text string.

        Raises:
            BPAClientError: On API errors.
        """
        response = await self._request_with_retry(
            "GET",
            endpoint,
            path_params=path_params,
            params=params,
            resource_type=resource_type,
            resource_id=resource_id,
        )
        if not response.content:
            from mcp_eregistrations_bpa.bpa_client.errors import BPANotFoundError

            raise BPANotFoundError(
                "Resource not found (empty response)",
                status_code=200,
            )
        return response.text

    async def get_list(
        self,
        endpoint: str,
        *,
        path_params: dict[str, Any] | None = None,
        params: dict[str, Any] | None = None,
        resource_type: str | None = None,
    ) -> list[dict[str, Any]]:
        """Execute GET request expecting a list response.

        Args:
            endpoint: URL endpoint template.
            path_params: Path parameters for URL formatting.
            params: Query parameters.
            resource_type: Resource type for error context.

        Returns:
            JSON response as list of dictionaries.

        Raises:
            BPAClientError: On API errors.
        """
        response = await self._request_with_retry(
            "GET",
            endpoint,
            path_params=path_params,
            params=params,
            resource_type=resource_type,
        )
        # Handle empty response body - return empty list
        if not response.content:
            return []
        result = response.json()
        if isinstance(result, list):
            return cast(list[dict[str, Any]], result)
        # Some APIs wrap lists in an object
        if isinstance(result, dict) and "items" in result:
            return cast(list[dict[str, Any]], result["items"])
        return [cast(dict[str, Any], result)] if result else []

    async def post(
        self,
        endpoint: str,
        *,
        path_params: dict[str, Any] | None = None,
        params: dict[str, Any] | None = None,
        json: dict[str, Any] | str | None = None,
        content: str | bytes | None = None,
        resource_type: str | None = None,
    ) -> dict[str, Any]:
        """Execute POST request.

        Args:
            endpoint: URL endpoint template.
            path_params: Path parameters for URL formatting.
            params: Query parameters.
            json: JSON body (will be serialized).
            content: Raw body content (not serialized).
            resource_type: Resource type for error context.

        Returns:
            JSON response as dictionary.

        Raises:
            BPAClientError: On API errors.
        """
        response = await self._request_with_retry(
            "POST",
            endpoint,
            path_params=path_params,
            params=params,
            json=json,
            content=content,
            resource_type=resource_type,
        )
        # Handle empty response body
        if not response.content:
            return {}
        return cast(dict[str, Any], response.json())

    async def put(
        self,
        endpoint: str,
        *,
        path_params: dict[str, Any] | None = None,
        params: dict[str, Any] | None = None,
        json: dict[str, Any] | None = None,
        content: str | bytes | None = None,
        resource_type: str | None = None,
        resource_id: str | int | None = None,
    ) -> dict[str, Any]:
        """Execute PUT request.

        Args:
            endpoint: URL endpoint template.
            path_params: Path parameters for URL formatting.
            params: Query parameters.
            json: JSON body (dict or list).
            content: Raw body content (for endpoints expecting String).
            resource_type: Resource type for error context.
            resource_id: Resource ID for error context.

        Returns:
            JSON response as dictionary.

        Raises:
            BPAClientError: On API errors.
        """
        response = await self._request_with_retry(
            "PUT",
            endpoint,
            path_params=path_params,
            params=params,
            json=json,
            content=content,
            resource_type=resource_type,
            resource_id=resource_id,
        )
        # Handle empty response body
        if not response.content:
            return {}
        return cast(dict[str, Any], response.json())

    async def delete(
        self,
        endpoint: str,
        *,
        path_params: dict[str, Any] | None = None,
        params: dict[str, Any] | None = None,
        resource_type: str | None = None,
        resource_id: str | int | None = None,
    ) -> dict[str, Any] | None:
        """Execute DELETE request.

        Args:
            endpoint: URL endpoint template.
            path_params: Path parameters for URL formatting.
            params: Query parameters.
            resource_type: Resource type for error context.
            resource_id: Resource ID for error context.

        Returns:
            JSON response as dictionary, or None if no content.

        Raises:
            BPAClientError: On API errors.
        """
        response = await self._request_with_retry(
            "DELETE",
            endpoint,
            path_params=path_params,
            params=params,
            resource_type=resource_type,
            resource_id=resource_id,
        )
        # Handle empty response (204 No Content or empty body)
        if response.status_code == 204 or not response.content:
            return None
        return cast(dict[str, Any], response.json())

    async def get_all_pageable(
        self,
        endpoint: str,
        *,
        path_params: dict[str, Any] | None = None,
        page_size: int = 100,
        sort: str = "customOrder,asc",
        resource_type: str | None = None,
        resource_id: str | int | None = None,
    ) -> dict[str, Any]:
        """Fetch all items from a pageable endpoint, iterating pages.

        Args:
            endpoint: URL endpoint template.
            path_params: Path parameters for URL formatting.
            page_size: Items per page (default: 100).
            sort: Sort parameter for Spring Data (default: customOrder,asc).
            resource_type: Resource type for error context.
            resource_id: Resource ID for error context.

        Returns:
            dict with content (all items), totalElements, totalPages.
        """
        all_content: list[dict[str, Any]] = []
        page = 0
        first_response: dict[str, Any] | None = None

        while True:
            response = await self.get(
                endpoint,
                path_params=path_params,
                params={
                    "page": page,
                    "size": page_size,
                    "sort": sort,
                },
                resource_type=resource_type,
                resource_id=resource_id,
            )

            if first_response is None:
                first_response = response

            content = response.get("content") or []
            all_content.extend(content)

            if response.get("last", True):
                break

            page += 1

        # Merge: return first page metadata with all content
        result = dict(first_response) if first_response else {}
        result["content"] = all_content
        result["totalElements"] = len(all_content)
        return result

    async def download_service(
        self,
        service_id: str,
        *,
        options: dict[str, Any] | None = None,
        timeout: float | None = None,
    ) -> tuple[dict[str, Any], int]:
        """Download complete service definition.

        This endpoint can return large payloads (5-15MB) and may take
        longer than the default timeout. Uses retry logic for transient errors.

        Args:
            service_id: The BPA service UUID.
            options: Export selection options. Defaults to all-inclusive.
            timeout: Custom timeout in seconds (default: 120s for exports).

        Returns:
            Tuple of (export_data, size_bytes) where:
            - export_data: The complete service definition JSON
            - size_bytes: Size of the response in bytes

        Raises:
            BPAClientError: On API errors.
        """
        # Default options: include all components
        default_options = {
            "serviceSelected": True,
            "costsSelected": True,
            "requirementsSelected": True,
            "resultsSelected": True,
            "activityConditionsSelected": True,
            "registrationLawsSelected": True,
            "serviceLocationsSelected": True,
            "serviceTutorialsSelected": True,
            "serviceTranslationsSelected": True,
            "guideFormSelected": True,
            "applicantFormSelected": True,
            "sendFileFormSelected": True,
            "paymentFormSelected": True,
            "catalogsSelected": True,
            "rolesSelected": True,
            "registrationsSelected": True,
            "determinantsSelected": True,
            "printDocumentsSelected": True,
            "botsSelected": True,
            "copyService": False,
        }

        # Merge with provided options
        export_options = {**default_options, **(options or {})}

        # Use longer timeout for exports (default 120s)
        export_timeout = timeout or 120.0

        client = self._get_client()
        url = f"/download_service/{service_id}"
        headers = await self._get_auth_header()

        logger.info("BPA Export Request: POST %s%s", self.base_url, url)

        last_error: Exception | None = None

        for attempt in range(self.max_retries + 1):
            try:
                response = await client.post(
                    url,
                    headers=headers,
                    json=export_options,
                    timeout=httpx.Timeout(export_timeout),
                )

                if response.status_code >= 400:
                    # Don't retry client errors
                    if response.status_code in NON_RETRYABLE_STATUS_CODES:
                        error = httpx.HTTPStatusError(
                            f"HTTP {response.status_code}",
                            request=response.request,
                            response=response,
                        )
                        raise translate_http_error(
                            error,
                            resource_type="service",
                            resource_id=service_id,
                        )

                    # Check if retryable
                    if response.status_code in RETRYABLE_STATUS_CODES:
                        if attempt < self.max_retries:
                            delay = calculate_backoff_delay(attempt)
                            logger.warning(
                                "Retryable error %d on export (attempt %d/%d), "
                                "retrying in %.2fs",
                                response.status_code,
                                attempt + 1,
                                self.max_retries + 1,
                                delay,
                            )
                            await asyncio.sleep(delay)
                            continue

                    # Non-retryable or out of retries
                    error = httpx.HTTPStatusError(
                        f"HTTP {response.status_code}",
                        request=response.request,
                        response=response,
                    )
                    raise translate_http_error(
                        error,
                        resource_type="service",
                        resource_id=service_id,
                    )

                # Get response size
                size_bytes = len(response.content)
                logger.info(
                    "BPA Export Response: POST %s → %d %s (%.2f MB)",
                    url,
                    response.status_code,
                    response.reason_phrase,
                    size_bytes / (1024 * 1024),
                )

                # Parse JSON with error handling
                try:
                    export_data = response.json()
                except ValueError as e:
                    raise BPAClientError(
                        f"Failed to parse export response as JSON: {e}"
                    ) from e

                return cast(dict[str, Any], export_data), size_bytes

            except httpx.ConnectError as e:
                last_error = e
                if attempt < self.max_retries:
                    delay = calculate_backoff_delay(attempt)
                    logger.warning(
                        "Connection error on export (attempt %d/%d), "
                        "retrying in %.2fs: %s",
                        attempt + 1,
                        self.max_retries + 1,
                        delay,
                        e,
                    )
                    await asyncio.sleep(delay)
                    continue
                health_info = await self._check_health()
                raise BPAConnectionError(
                    f"Failed to connect to BPA API after "
                    f"{self.max_retries + 1} attempts. {health_info}."
                ) from e

            except httpx.TimeoutException as e:
                logger.warning("Timeout on export after %ss", export_timeout)
                health_info = await self._check_health()
                raise BPATimeoutError(
                    f"BPA export timed out after {export_timeout:.0f}s. {health_info}."
                ) from e

            except BPAClientError:
                # Re-raise BPA errors without wrapping
                raise

        # Should not reach here, but just in case
        if last_error:
            raise BPAClientError(f"Export failed: {last_error}")
        raise BPAClientError("Export failed for unknown reason")

    async def validate_and_import_service(
        self,
        service_data: dict[str, Any],
        *,
        target_service_id: str | None = None,
        timeout: float | None = None,
    ) -> dict[str, Any]:
        """Upload service JSON via /validate_catalogs for import.

        This is the correct import flow matching the BPA frontend:
        1. POST /validate_catalogs (multipart file) — validates catalogs
        2. If no catalogs: backend imports directly, returns new service ID
        3. If catalogs: returns {serviceCopyId, catalogs} for confirmation
           → then POST /upload_service with {serviceCopyId, catalogs}

        Args:
            service_data: Complete service definition JSON (from download_service).
            target_service_id: If provided, merges into existing service.
            timeout: Custom timeout in seconds (default: 120s for imports).

        Returns:
            dict with:
            - service_id: The new/updated service ID
            - had_catalogs: Whether catalog confirmation was needed

        Raises:
            BPAClientError: On API errors.
        """
        import json as json_mod
        from urllib.parse import urlencode

        import_timeout = timeout or 120.0
        client = self._get_client()
        headers = await self._get_auth_header()

        # Step 1: POST /validate_catalogs as multipart file
        url = "/validate_catalogs"
        if target_service_id:
            url = f"/validate_catalogs?{urlencode({'serviceId': target_service_id})}"

        json_bytes = json_mod.dumps(service_data).encode("utf-8")
        files = {"file": ("service.json", json_bytes, "application/json")}

        logger.info("BPA Validate+Import: POST %s%s", self.base_url, url)

        response = await self._import_request(
            client, url, headers, files=files, timeout=import_timeout
        )

        # Parse response: either service ID (plain text) or {serviceCopyId, catalogs}
        response_text = response.text.strip()

        # Try JSON parse first (catalog validation response)
        try:
            response_json = response.json()
            if isinstance(response_json, dict) and "serviceCopyId" in response_json:
                # Has catalogs — auto-confirm all with overwrite=true
                service_copy_id = response_json["serviceCopyId"]
                catalogs = response_json.get("catalogs", [])
                for cat in catalogs:
                    if isinstance(cat, dict):
                        cat["overwrite"] = True

                logger.info(
                    "Catalogs detected (%d), auto-confirming via /upload_service",
                    len(catalogs),
                )

                # Step 2: POST /upload_service with {serviceCopyId, catalogs}
                upload_url = "/upload_service"
                if target_service_id:
                    upload_url = (
                        f"/upload_service?{urlencode({'serviceId': target_service_id})}"
                    )
                upload_payload = {
                    "serviceCopyId": service_copy_id,
                    "catalogs": catalogs,
                }
                upload_response = await self._import_request(
                    client,
                    upload_url,
                    headers,
                    json=upload_payload,
                    timeout=import_timeout,
                )
                upload_text = upload_response.text.strip()
                logger.info("Upload response: %s", upload_text)

                # /upload_service returns "Service imported!" — no service ID
                # Try to find the new service by listing all services
                new_id = None
                try:
                    all_services = await self.get_list(
                        "/service", resource_type="service"
                    )
                    # Find most recently created service (last in list)
                    if all_services:
                        new_id = all_services[-1].get("id")
                except Exception:
                    pass  # Best-effort

                return {
                    "service_id": new_id or target_service_id,
                    "had_catalogs": True,
                    "catalog_count": len(catalogs),
                }
        except (ValueError, KeyError):
            pass

        # No catalogs — backend imported directly, response is service ID
        # Response could be plain text service ID (UUID) or octet-stream blob
        service_id = response_text.strip('"')
        if not service_id or service_id.startswith("Service"):
            raise BPAClientError(f"Import failed: unexpected response '{service_id}'")

        return {
            "service_id": service_id,
            "had_catalogs": False,
        }

    async def _import_request(
        self,
        client: httpx.AsyncClient,
        url: str,
        headers: dict[str, str],
        *,
        files: dict[str, Any] | None = None,
        json: dict[str, Any] | None = None,
        timeout: float = 120.0,
    ) -> httpx.Response:
        """Execute an import request with retry logic.

        Supports both multipart file upload and JSON body.
        """
        last_error: Exception | None = None

        for attempt in range(self.max_retries + 1):
            try:
                if files:
                    response = await client.post(
                        url,
                        headers=headers,
                        files=files,
                        timeout=httpx.Timeout(timeout),
                    )
                else:
                    response = await client.post(
                        url,
                        headers=headers,
                        json=json,
                        timeout=httpx.Timeout(timeout),
                    )

                if response.status_code >= 400:
                    if response.status_code in NON_RETRYABLE_STATUS_CODES:
                        error = httpx.HTTPStatusError(
                            f"HTTP {response.status_code}",
                            request=response.request,
                            response=response,
                        )
                        raise translate_http_error(error, resource_type="service")

                    if response.status_code in RETRYABLE_STATUS_CODES:
                        if attempt < self.max_retries:
                            delay = calculate_backoff_delay(attempt)
                            logger.warning(
                                "Retryable error %d on import (attempt %d/%d), "
                                "retrying in %.2fs",
                                response.status_code,
                                attempt + 1,
                                self.max_retries + 1,
                                delay,
                            )
                            await asyncio.sleep(delay)
                            continue

                    error = httpx.HTTPStatusError(
                        f"HTTP {response.status_code}",
                        request=response.request,
                        response=response,
                    )
                    raise translate_http_error(error, resource_type="service")

                logger.info(
                    "BPA Import Response: POST %s → %d %s",
                    url,
                    response.status_code,
                    response.reason_phrase,
                )
                return response

            except httpx.ConnectError as e:
                last_error = e
                if attempt < self.max_retries:
                    delay = calculate_backoff_delay(attempt)
                    await asyncio.sleep(delay)
                    continue
                health_info = await self._check_health()
                raise BPAConnectionError(
                    f"Failed to connect to BPA API after "
                    f"{self.max_retries + 1} attempts. {health_info}."
                ) from e

            except httpx.TimeoutException as e:
                logger.warning("Timeout on import after %ss", timeout)
                health_info = await self._check_health()
                raise BPATimeoutError(
                    f"BPA import timed out after {timeout:.0f}s. {health_info}."
                ) from e

            except BPAClientError:
                raise

        if last_error:
            raise BPAClientError(f"Import failed: {last_error}")
        raise BPAClientError("Import failed for unknown reason")
