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

from __future__ import annotations

import random
import re
import time
from typing import Any

import httpx

import json as _json

from baponi._constants import (
    DEFAULT_CONNECT_TIMEOUT,
    ENV_VAR_KEY_PATTERN,
    EXECUTE_PATH,
    EXECUTION_READ_BUFFER,
    EXECUTIONS_PATH,
    MAX_CODE_SIZE,
    MAX_ENV_VAR_KEY_LENGTH,
    MAX_ENV_VAR_VALUE_LENGTH,
    MAX_ENV_VARS_COUNT,
    MAX_ENV_VARS_TOTAL_SIZE,
    MAX_METADATA_KEY_LENGTH,
    MAX_METADATA_KEYS,
    MAX_METADATA_VALUE_LENGTH,
    MAX_THREAD_ID_LENGTH,
    MAX_TIMEOUT,
    METADATA_KEY_PATTERN,
    MIN_TIMEOUT,
    RESERVED_ENV_VAR_NAMES,
    RESERVED_ENV_VAR_PREFIXES,
    SUPPORTED_LANGUAGES,
    THREAD_ID_PATTERN,
    USER_AGENT,
)
from baponi.exceptions import (
    APITimeoutError,
    APIValidationError,
    AuthenticationError,
    BaponiError,
    ForbiddenError,
    RateLimitError,
    ServerError,
    ThreadBusyError,
)
from baponi._events import (
    KeepaliveEvent,
    OutputEvent,
    ResultEvent,
    StatusEvent,
)
from baponi.types import CancelResponse, ExecutionStatus, SandboxResult


_MAX_SUB_PATHS = 10
_MAX_SUB_PATH_LENGTH = 512
_SUB_PATH_DANGEROUS = re.compile(
    r"(\.\.|%2[eEfF]|%5[cC]|%00|%25|%[cC][01]|\x00|\\)"
)

# Matches backend: trc_ prefix + 1-20 alphanumeric/dash/underscore chars
_TRACE_ID_PATTERN = re.compile(r"^trc_[a-zA-Z0-9_-]{1,20}$")

_MAX_WEBHOOK_URL_LENGTH = 2048


def validate_trace_id(trace_id: str) -> None:
    """Validate a trace_id before using it in URL paths.

    Prevents path injection. Matches the backend format:
    trc_ prefix + 1-20 alphanumeric/dash/underscore characters.
    """
    if not _TRACE_ID_PATTERN.match(trace_id):
        raise ValueError(
            "Invalid trace_id format. Expected: trc_ prefix followed by "
            "1-20 alphanumeric, dash, or underscore characters"
        )


def validate_webhook_url(url: str) -> None:
    """Validate a webhook URL before sending.

    Matches backend webhook.rs: HTTPS required, max 2048 characters.
    """
    if len(url) > _MAX_WEBHOOK_URL_LENGTH:
        raise ValueError(
            f"webhook_url exceeds maximum length of {_MAX_WEBHOOK_URL_LENGTH} characters"
        )
    if not url.startswith("https://"):
        raise ValueError("webhook_url must use HTTPS")
    if len(url) <= len("https://"):
        raise ValueError("webhook_url must include a hostname")


def _validate_env_vars(env_vars: dict[str, str]) -> None:
    """Validate environment variables, mirroring backend env_vars.rs."""
    if len(env_vars) > MAX_ENV_VARS_COUNT:
        raise ValueError(
            f"Too many environment variables (maximum {MAX_ENV_VARS_COUNT})"
        )

    total_size = 0
    for key, value in env_vars.items():
        # ASCII-only keys
        if not key.isascii():
            raise ValueError(
                "Environment variable keys must contain only ASCII characters"
            )

        # Key length
        if not key or len(key) > MAX_ENV_VAR_KEY_LENGTH:
            raise ValueError(
                f"Environment variable keys must be 1-{MAX_ENV_VAR_KEY_LENGTH} characters"
            )

        # Key format: uppercase letters, digits, underscores, starting with letter
        if not ENV_VAR_KEY_PATTERN.match(key):
            raise ValueError(
                "Environment variable keys must start with an uppercase letter "
                "and contain only uppercase letters, digits, and underscores"
            )

        # Value type
        if not isinstance(value, str):
            raise ValueError(
                f"Environment variable value for key {key!r} must be a string, "
                f"got {type(value).__name__}"
            )

        # Value length
        if len(value) > MAX_ENV_VAR_VALUE_LENGTH:
            raise ValueError(
                f"Environment variable values must be at most "
                f"{MAX_ENV_VAR_VALUE_LENGTH} characters"
            )

        # Value content: reject null bytes and control chars (except tab/newline/CR)
        for b in value.encode("utf-8"):
            if b == 0 or (b < 32 and b not in (9, 10, 13)):
                raise ValueError(
                    "Environment variable values cannot contain null bytes "
                    "or control characters"
                )

        # Reserved names (generic error, don't leak blocklist)
        key_upper = key.upper()
        if key_upper in RESERVED_ENV_VAR_NAMES:
            raise ValueError(
                "This environment variable name is reserved by the system"
            )

        # Reserved prefixes
        for prefix in RESERVED_ENV_VAR_PREFIXES:
            if key_upper.startswith(prefix):
                raise ValueError(
                    "This environment variable name is reserved by the system"
                )

        total_size += len(key) + len(value)

    if total_size > MAX_ENV_VARS_TOTAL_SIZE:
        raise ValueError("Total environment variable size exceeds limit (64KB)")


def validate_execute_params(
    code: str,
    language: str,
    timeout: int,
    thread_id: str | None,
    metadata: dict[str, str] | None,
    sub_paths: list[str] | None = None,
    env_vars: dict[str, str] | None = 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"
                )

    if sub_paths is not None:
        if len(sub_paths) > _MAX_SUB_PATHS:
            raise ValueError(f"sub_paths exceeds maximum of {_MAX_SUB_PATHS} entries")
        for path in sub_paths:
            if not isinstance(path, str):
                raise ValueError(
                    f"sub_paths entries must be strings, got {type(path).__name__}"
                )
            if len(path) > _MAX_SUB_PATH_LENGTH:
                raise ValueError(
                    f"sub_path exceeds maximum length of {_MAX_SUB_PATH_LENGTH} characters"
                )
            if not path.startswith("/data/"):
                raise ValueError(
                    f"sub_path must start with /data/, got {path!r}"
                )
            if _SUB_PATH_DANGEROUS.search(path):
                raise ValueError(
                    f"sub_path contains dangerous characters: {path!r}"
                )

    if env_vars is not None:
        _validate_env_vars(env_vars)


def build_execute_body(
    code: str,
    language: str,
    timeout: int,
    thread_id: str | None,
    metadata: dict[str, str] | None,
    sub_paths: list[str] | None = None,
    env_vars: dict[str, str] | None = 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
    if sub_paths is not None:
        body["sub_paths"] = sub_paths
    if env_vars is not None:
        body["env_vars"] = env_vars
    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 build_execution_url(base_url: str, trace_id: str) -> str:
    """Build URL for GET /v1/executions/{trace_id}."""
    return f"{base_url}{EXECUTIONS_PATH}/{trace_id}"


def build_cancel_url(base_url: str, trace_id: str) -> str:
    """Build URL for POST /v1/executions/{trace_id}/cancel."""
    return f"{base_url}{EXECUTIONS_PATH}/{trace_id}/cancel"


def build_output_url(base_url: str, trace_id: str) -> str:
    """Build URL for GET /v1/executions/{trace_id}/output."""
    return f"{base_url}{EXECUTIONS_PATH}/{trace_id}/output"


def default_headers(api_key: str, client_id: str = "baponi-python/direct") -> dict[str, str]:
    return {
        "Authorization": f"Bearer {api_key}",
        "User-Agent": USER_AGENT,
        "Content-Type": "application/json",
        "X-Client-Id": client_id,
    }


def streaming_headers(api_key: str, client_id: str = "baponi-python/direct") -> dict[str, str]:
    """Headers for NDJSON streaming requests."""
    h = default_headers(api_key, client_id)
    h["Accept"] = "application/x-ndjson"
    return h


def webhook_headers(api_key: str, client_id: str = "baponi-python/direct") -> dict[str, str]:
    """Headers for webhook (async) execution requests."""
    h = default_headers(api_key, client_id)
    h["Prefer"] = "respond-async"
    return h


def parse_ndjson_line(line: str) -> Any:
    """Parse a single NDJSON line into a typed ExecutionEvent.

    Returns None for empty/whitespace-only lines.
    Raises BaponiError for malformed JSON or unknown event types.
    """
    stripped = line.strip()
    if not stripped:
        return None

    try:
        data = _json.loads(stripped)
    except _json.JSONDecodeError as exc:
        raise BaponiError(f"Malformed NDJSON line: {exc}") from exc

    event_type = data.get("type")
    if event_type == "status":
        return StatusEvent.model_validate(data)
    if event_type == "output":
        return OutputEvent.model_validate(data)
    if event_type == "keepalive":
        return KeepaliveEvent.model_validate(data)
    if event_type == "result":
        return ResultEvent.model_validate(data)

    raise BaponiError(f"Unknown event type: {event_type!r}")


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 | httpx.TimeoutException):
        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.TimeoutException as exc:
            last_exc = exc
            if attempt >= max_retries:
                raise APITimeoutError(
                    f"Request timed out after {max_retries + 1} attempts: {exc}",
                    status_code=0,
                ) from exc
        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.TimeoutException as exc:
            last_exc = exc
            if attempt >= max_retries:
                raise APITimeoutError(
                    f"Request timed out after {max_retries + 1} attempts: {exc}",
                    status_code=0,
                ) from exc
        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)"
