"""Shared HTTP + JSON helpers for the provider clients.

Keeps the four AI provider files small and uniformly secured:
  - secrets read from settings only; never inline.
  - one httpx.AsyncClient per provider (process-lifetime).
  - all providers accept the configured *_MAX_CONCURRENCY bound.
"""

from __future__ import annotations

import asyncio
import json
import logging
import re
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Optional

import httpx

from app.logging import redact_secrets

logger = logging.getLogger(__name__)


class JsonExtractionError(ValueError):
    """Raised when a provider response could not be parsed as JSON."""


class ProviderHttpError(RuntimeError):
    """Raised when a provider returns a non-2xx HTTP response."""

    def __init__(self, provider: str, status: int, body: str) -> None:
        self.provider = provider
        self.status = status
        self.body = redact_secrets(body)[:1000]
        super().__init__(f"{provider} -> HTTP {status}")


class RetryableProviderError(ProviderHttpError):
    """5xx / 429 / timeout — retried by the pipeline retry layer."""
    pass


class NonRetryableProviderError(ProviderHttpError):
    """4xx (other than 429) — fail-fast, do not retry."""
    pass


@asynccontextmanager
async def bounded(semaphore: asyncio.Semaphore) -> AsyncIterator[None]:
    async with semaphore:
        yield


def _build_semaphore(limit: int) -> asyncio.Semaphore:
    return asyncio.Semaphore(max(1, limit))


async def http_post_json(
    *,
    client: httpx.AsyncClient,
    url: str,
    headers: dict[str, str],
    payload: dict[str, Any],
    timeout: float,
    provider_name: str,
) -> dict[str, Any]:
    """POST JSON; on success return the decoded JSON body.

    Raises:
      RetryableProviderError on network error, 429, or any 5xx.
      NonRetryableProviderError on any other non-2xx status.
    """
    try:
        response = await client.post(url, json=payload, headers=headers, timeout=timeout)
    except (httpx.TimeoutException, httpx.NetworkError) as exc:
        logger.warning("%s HTTP error: %s", provider_name, exc)
        raise RetryableProviderError(provider_name, 0, f"network: {exc}")
    status = response.status_code
    body = response.text
    if status == 429 or status >= 500:
        raise RetryableProviderError(provider_name, status, body)
    if status >= 400:
        raise NonRetryableProviderError(provider_name, status, body)
    try:
        return response.json()
    except ValueError as exc:
        raise JsonExtractionError(
            f"{provider_name} returned non-JSON body (HTTP {status})"
        ) from exc


def extract_json(text: str) -> Any:
    """Extract a JSON value from a provider response that may contain prose.

    Tries (in order):
      1. strict JSON parse of the whole text (when the model was called with
         response_format=json_schema / json_object).
      2. The first ``` fenced JSON block.
      3. The first {...} or [...] substring.
    """
    text = (text or "").strip()
    if not text:
        raise JsonExtractionError("empty response")
    try:
        return json.loads(text)
    except ValueError:
        pass
    fence = re.search(r"```(?:json)?\s*(.*?)\s*```", text, re.DOTALL | re.IGNORECASE)
    if fence:
        try:
            return json.loads(fence.group(1))
        except ValueError:
            pass
    for opener, closer in (("{", "}"), ("[", "]")):
        start = text.find(opener)
        end = text.rfind(closer)
        if start != -1 and end > start:
            try:
                return json.loads(text[start:end + 1])
            except ValueError:
                continue
    raise JsonExtractionError("no JSON found in response")


async def complete_chat(
    *,
    client: httpx.AsyncClient,
    url: str,
    headers: dict[str, str],
    payload: dict[str, Any],
    timeout: float,
    provider_name: str,
    choices_path: tuple[str, ...] = ("choices", 0, "message", "content"),
    dedent_response: bool = True,
) -> str:
    """Run a chat-completions-style call and return the assistant text.

    `choices_path` is the dotted path into the response body for the assistant
    text. Adjust it for providers that nest it differently.
    """
    data = await http_post_json(
        client=client,
        url=url,
        headers=headers,
        payload=payload,
        timeout=timeout,
        provider_name=provider_name,
    )
    node: Any = data
    for key in choices_path:
        if isinstance(key, int):
            if not isinstance(node, list) or key >= len(node):
                raise JsonExtractionError(f"{provider_name}: choices path missing at {key}")
            node = node[key]
        else:
            if not isinstance(node, dict) or key not in node:
                raise JsonExtractionError(f"{provider_name}: choices path missing at {key!r}")
            node = node[key]
    if not isinstance(node, str):
        raise JsonExtractionError(f"{provider_name}: assistant content is not a string")
    return node


# Re-export redact_secrets so providers can scrub their error payloads without
# an extra import path.
__all__ = [
    "bounded",
    "complete_chat",
    "extract_json",
    "http_post_json",
    "JsonExtractionError",
    "NonRetryableProviderError",
    "ProviderHttpError",
    "RetryableProviderError",
    "_build_semaphore",
]