"""Shared logic: validation, retry, error mapping, result formatting."""

from __future__ import annotations

import random
import time
from typing import Any

import httpx

from baponi._constants import (
    DEFAULT_CONNECT_TIMEOUT,
    EXECUTE_PATH,
    EXECUTION_READ_BUFFER,
    MAX_CODE_SIZE,
    MAX_METADATA_KEY_LENGTH,
    MAX_METADATA_KEYS,
    MAX_METADATA_VALUE_LENGTH,
    MAX_THREAD_ID_LENGTH,
    MAX_TIMEOUT,
    METADATA_KEY_PATTERN,
    MIN_TIMEOUT,
    SUPPORTED_LANGUAGES,
    THREAD_ID_PATTERN,
    USER_AGENT,
)
from baponi.exceptions import (
    APITimeoutError,
    APIValidationError,
    AuthenticationError,
    BaponiError,
    ForbiddenError,
    RateLimitError,
    ServerError,
    ThreadBusyError,
)
from baponi.types import SandboxResult


def validate_execute_params(
    code: str,
    language: str,
    timeout: int,
    thread_id: str | None,
    metadata: dict[str, str] | None,
) -> None:
    """Validate execute parameters locally before sending the request."""
    if not code:
        raise ValueError("code must not be empty")
    if len(code.encode("utf-8")) > MAX_CODE_SIZE:
        raise ValueError(f"code exceeds maximum size of {MAX_CODE_SIZE} bytes")

    if language not in SUPPORTED_LANGUAGES:
        raise ValueError(
            f"unsupported language {language!r}, "
            f"must be one of: {', '.join(sorted(SUPPORTED_LANGUAGES))}"
        )

    if not isinstance(timeout, int):
        raise ValueError(f"timeout must be an integer, got {type(timeout).__name__}")
    if timeout < MIN_TIMEOUT or timeout > MAX_TIMEOUT:
        raise ValueError(f"timeout must be between {MIN_TIMEOUT} and {MAX_TIMEOUT} seconds")

    if thread_id is not None:
        if not thread_id:
            raise ValueError("thread_id must not be empty when provided")
        if len(thread_id) > MAX_THREAD_ID_LENGTH:
            raise ValueError(
                f"thread_id exceeds maximum length of {MAX_THREAD_ID_LENGTH} characters"
            )
        if not THREAD_ID_PATTERN.match(thread_id):
            raise ValueError(
                "thread_id must start with alphanumeric and contain only "
                "alphanumeric, hyphens, or underscores"
            )

    if metadata is not None:
        if len(metadata) > MAX_METADATA_KEYS:
            raise ValueError(f"metadata exceeds maximum of {MAX_METADATA_KEYS} keys")
        for key, value in metadata.items():
            if not key or len(key) > MAX_METADATA_KEY_LENGTH:
                raise ValueError(
                    f"metadata key must be 1-{MAX_METADATA_KEY_LENGTH} characters, "
                    f"got {len(key)}"
                )
            if not METADATA_KEY_PATTERN.match(key):
                raise ValueError(
                    f"metadata key {key!r} must start with alphanumeric and contain "
                    "only alphanumeric, underscores, hyphens, or dots"
                )
            if not isinstance(value, str):
                raise ValueError(
                    f"metadata value for key {key!r} must be a string, "
                    f"got {type(value).__name__}"
                )
            if len(value) > MAX_METADATA_VALUE_LENGTH:
                raise ValueError(
                    f"metadata value for key {key!r} exceeds maximum of "
                    f"{MAX_METADATA_VALUE_LENGTH} characters"
                )


def build_execute_body(
    code: str,
    language: str,
    timeout: int,
    thread_id: str | None,
    metadata: dict[str, str] | None,
) -> dict[str, Any]:
    """Build the JSON request body, only including known fields."""
    body: dict[str, Any] = {
        "code": code,
        "language": language,
        "timeout": timeout,
    }
    if thread_id is not None:
        body["thread_id"] = thread_id
    if metadata is not None:
        body["metadata"] = metadata
    return body


def build_request_timeout(execution_timeout: int) -> httpx.Timeout:
    """Build httpx timeout with connect and read timeouts."""
    return httpx.Timeout(
        connect=DEFAULT_CONNECT_TIMEOUT,
        read=execution_timeout + EXECUTION_READ_BUFFER,
        write=DEFAULT_CONNECT_TIMEOUT,
        pool=DEFAULT_CONNECT_TIMEOUT,
    )


def build_execute_url(base_url: str) -> str:
    return base_url + EXECUTE_PATH


def default_headers(api_key: str) -> dict[str, str]:
    return {
        "Authorization": f"Bearer {api_key}",
        "User-Agent": USER_AGENT,
        "Content-Type": "application/json",
    }


def _parse_error_body(response: httpx.Response) -> tuple[str | None, str]:
    """Extract error_type and message from response body.

    Handles multiple response shapes:
    1. Standard: {"error": "...", "message": "..."}
    2. Legacy:   {"type": "...", "message": "..."}
    3. Fallback: raw response text
    """
    try:
        data = response.json()
    except Exception:
        return None, response.text or f"HTTP {response.status_code}"

    if isinstance(data, dict):
        error_type = data.get("error") or data.get("type")
        message = data.get("message") or data.get("error") or str(data)
        return error_type, message

    return None, str(data)


def _parse_retry_after(response: httpx.Response) -> float | None:
    """Parse Retry-After header if present."""
    value = response.headers.get("retry-after")
    if value is None:
        return None
    try:
        return float(value)
    except ValueError:
        return None


def raise_for_status(response: httpx.Response) -> None:
    """Map HTTP error responses to typed exceptions."""
    if response.is_success:
        return

    error_type, message = _parse_error_body(response)
    status = response.status_code

    if status == 401:
        raise AuthenticationError(
            message, status_code=status, error_type=error_type
        )
    if status == 403:
        raise ForbiddenError(
            message, status_code=status, error_type=error_type
        )
    if status == 409:
        raise ThreadBusyError(
            message, status_code=status, error_type=error_type
        )
    if status == 429:
        raise RateLimitError(
            message,
            status_code=status,
            error_type=error_type,
            retry_after=_parse_retry_after(response),
        )
    if status == 504:
        raise APITimeoutError(
            message, status_code=status, error_type=error_type
        )
    if status == 400:
        raise APIValidationError(
            message, status_code=status, error_type=error_type
        )
    if status >= 500:
        raise ServerError(
            message, status_code=status, error_type=error_type
        )
    # Any other 4xx
    raise BaponiError(
        message, status_code=status, error_type=error_type
    )


def parse_execute_response(response: httpx.Response) -> SandboxResult:
    """Parse a successful execute response into SandboxResult."""
    raise_for_status(response)
    return SandboxResult.model_validate(response.json())


def is_retryable(exc: Exception) -> bool:
    """Determine if an error is safe to retry."""
    if isinstance(exc, httpx.ConnectError | httpx.ReadError):
        return True
    if isinstance(exc, RateLimitError):
        return True
    if isinstance(exc, ServerError) and exc.status_code == 503:
        return True
    return False


def retry_delay(attempt: int, retry_after: float | None = None) -> float:
    """Calculate retry delay with exponential backoff and jitter.

    Base delay: 0.5s * 2^attempt
    Jitter: +/-25%
    """
    if retry_after is not None and retry_after > 0:
        return retry_after

    base = 0.5 * (2**attempt)
    jitter = base * 0.25 * (2 * random.random() - 1)  # noqa: S311
    return max(0, base + jitter)


def execute_with_retry(
    client: httpx.Client,
    *,
    url: str,
    headers: dict[str, str],
    body: dict[str, Any],
    timeout: httpx.Timeout,
    max_retries: int,
) -> SandboxResult:
    """Execute request with retry logic (sync)."""
    last_exc: Exception | None = None

    for attempt in range(max_retries + 1):
        try:
            response = client.post(url, json=body, headers=headers, timeout=timeout)
            return parse_execute_response(response)
        except (httpx.ConnectError, httpx.ReadError) as exc:
            last_exc = exc
            if attempt >= max_retries:
                raise BaponiError(
                    f"Connection failed after {max_retries + 1} attempts: {exc}",
                    status_code=0,
                ) from exc
        except (RateLimitError, ServerError) as exc:
            last_exc = exc
            if not is_retryable(exc) or attempt >= max_retries:
                raise
            ra = exc.retry_after if isinstance(exc, RateLimitError) else None
            time.sleep(retry_delay(attempt, ra))
            continue

        time.sleep(retry_delay(attempt))

    # Should not reach here, but satisfy type checker
    assert last_exc is not None  # noqa: S101
    raise last_exc


async def async_execute_with_retry(
    client: httpx.AsyncClient,
    *,
    url: str,
    headers: dict[str, str],
    body: dict[str, Any],
    timeout: httpx.Timeout,
    max_retries: int,
) -> SandboxResult:
    """Execute request with retry logic (async)."""
    import asyncio

    last_exc: Exception | None = None

    for attempt in range(max_retries + 1):
        try:
            response = await client.post(url, json=body, headers=headers, timeout=timeout)
            return parse_execute_response(response)
        except (httpx.ConnectError, httpx.ReadError) as exc:
            last_exc = exc
            if attempt >= max_retries:
                raise BaponiError(
                    f"Connection failed after {max_retries + 1} attempts: {exc}",
                    status_code=0,
                ) from exc
        except (RateLimitError, ServerError) as exc:
            last_exc = exc
            if not is_retryable(exc) or attempt >= max_retries:
                raise
            ra = exc.retry_after if isinstance(exc, RateLimitError) else None
            await asyncio.sleep(retry_delay(attempt, ra))
            continue

        await asyncio.sleep(retry_delay(attempt))

    assert last_exc is not None  # noqa: S101
    raise last_exc


def result_to_llm_text(result: SandboxResult) -> str:
    """Format a SandboxResult as text for LLM tool responses.

    Rules:
    - Include stdout if non-empty
    - Include stderr if non-empty (prefixed with [stderr])
    - Include exit code only when non-zero
    - Include error field if present
    - Omit empty sections
    """
    parts: list[str] = []

    if result.stdout:
        parts.append(result.stdout)
    if result.stderr:
        parts.append(f"[stderr]\n{result.stderr}")
    if result.exit_code != 0:
        parts.append(f"[exit code: {result.exit_code}]")
    if result.error:
        parts.append(f"[error]\n{result.error}")

    return "\n\n".join(parts) if parts else "(no output)"
