from __future__ import annotations

"""
HTTP JSON-RPC client (sync).

- Uses httpx if available (preferred), otherwise falls back to requests.
- Minimal dependencies, friendly to unit tests and mocks.
- Retries idempotent RPC calls on transient transport failures and 5xx HTTP.

Example:
    from omni_sdk.rpc.http import RpcClient
    rpc = RpcClient("http://localhost:8545")
    head = rpc.request("chain_getHead")
    print(head["height"])
"""

import json
import os
import random
import time
from dataclasses import dataclass, field
from itertools import count
from typing import (Any, Dict, Iterable, List, Mapping, MutableMapping,
                    Optional, Sequence, Tuple, Union)

try:  # preferred
    import httpx  # type: ignore

    _HAVE_HTTPX = True
except Exception:  # pragma: no cover
    httpx = None  # type: ignore
    _HAVE_HTTPX = False

try:  # fallback
    import requests  # type: ignore

    _HAVE_REQUESTS = True
except Exception:  # pragma: no cover
    requests = None  # type: ignore
    _HAVE_REQUESTS = False

from ..errors import RpcError  # type: ignore
from ..version import __version__ as SDK_VERSION  # type: ignore

JSON = Union[dict, list, str, int, float, bool, None]
Params = Union[Sequence[Any], Mapping[str, Any], None]


def _now_ms() -> int:
    return int(time.time() * 1000)


def _is_retriable_http(status: int) -> bool:
    # Typical transient HTTP statuses: 429/502/503/504
    return status in (429, 502, 503, 504)


def _jitter_backoff(base: float, factor: float, attempt: int, jitter: float) -> float:
    # Exponential backoff with jitter in [0, jitter]
    return base * (factor ** max(attempt - 1, 0)) + random.random() * jitter


def to_jsonable(obj: Any) -> Any:
    """
    Recursively convert Python objects to JSON-serializable forms.
    
    Handles:
    - bytes/bytearray/memoryview -> "0x" + hex string
    - dict -> recursively process values
    - list/tuple -> recursively process items (preserves list)
    - other types -> pass through (int, str, float, bool, None, etc.)
    
    This ensures no raw bytes objects reach json.dumps(), preventing
    "Object of type bytes is not JSON serializable" errors.
    
    Note: The node RPC expects raw transaction data as 0x-prefixed hex strings.
    """
    if isinstance(obj, (bytes, bytearray, memoryview)):
        # Convert bytes to 0x-prefixed hex string
        return "0x" + bytes(obj).hex()
    elif isinstance(obj, dict):
        # Recursively process dictionary values
        return {k: to_jsonable(v) for k, v in obj.items()}
    elif isinstance(obj, (list, tuple)):
        # Recursively process sequences (return list for both)
        return [to_jsonable(item) for item in obj]
    else:
        # Pass through JSON-native types: str, int, float, bool, None
        return obj


@dataclass
class RpcClient:
    """Synchronous JSON-RPC 2.0 client over HTTP."""

    url: str
    timeout: Optional[float] = 3600.0
    max_retries: int = 3
    backoff_base: float = 0.15
    backoff_factor: float = 1.8
    backoff_jitter: float = 0.2
    headers: Optional[Mapping[str, str]] = None
    _id_counter: Iterable[int] = field(default_factory=lambda: count(start=_now_ms()))
    _client: Any = field(init=False, default=None)
    _use_httpx: bool = field(init=False, default=_HAVE_HTTPX)

    def __post_init__(self) -> None:
        ua = f"omni-sdk-python/{SDK_VERSION}"
        merged_headers: Dict[str, str] = {
            "Content-Type": "application/json",
            "Accept": "application/json",
            "User-Agent": ua,
        }
        if self.headers:
            merged_headers.update(dict(self.headers))

        if self._use_httpx:
            if not _HAVE_HTTPX:  # pragma: no cover - defensive
                raise RuntimeError("httpx not available")
            # None timeout means no timeout (wait indefinitely)
            httpx_timeout = None if self.timeout is None else self.timeout
            self._client = httpx.Client(
                timeout=httpx_timeout,
                headers=merged_headers,
                follow_redirects=True,  # Handle HTTP 307 redirects
            )
        else:
            if not _HAVE_REQUESTS:
                raise RuntimeError("Neither httpx nor requests is available")
            self._client = requests.Session()
            self._client.headers.update(merged_headers)

    # --- context manager -------------------------------------------------

    def __enter__(self) -> "RpcClient":
        return self

    def __exit__(self, exc_type, exc, tb) -> None:  # noqa: ANN001
        self.close()

    def close(self) -> None:
        if self._client is not None:
            try:
                self._client.close()
            except Exception:  # pragma: no cover
                pass

    # --- public API ------------------------------------------------------

    def request(
        self,
        method: str,
        params: Params = None,
        *,
        id: Optional[Union[int, str]] = None,
    ) -> JSON:
        """Perform a single JSON-RPC request and return `result` or raise RpcError."""
        payload = self._make_payload(method, params, id)
        resp = self._send_with_retries(payload)
        return resp

    def batch(self, calls: Sequence[Tuple[str, Params]]) -> List[JSON]:
        """Perform a JSON-RPC batch; returns list of results in the same order as `calls`."""
        batch_payload: List[Dict[str, Any]] = []
        id_list: List[int] = []
        for method, params in calls:
            p = self._make_payload(method, params)
            batch_payload.append(p)
            id_list.append(p["id"])
        resp = self._send_with_retries(batch_payload)
        # Response is an array of objects with id/result or id/error (order not guaranteed)
        if not isinstance(resp, list):
            raise RpcError(
                code=-32603,
                message="Invalid batch response (not a list)",
                method="batch",
                data=resp,
            )

        by_id: Dict[int, JSON] = {}
        for item in resp:
            if not isinstance(item, dict) or "id" not in item:
                raise RpcError(
                    code=-32603,
                    message="Malformed item in batch response",
                    method="batch",
                    data=item,
                )
            rid = item["id"]
            if "error" in item:
                err = item["error"]
                raise RpcError(
                    code=err.get("code", -32603),
                    message=err.get("message", "Unknown error"),
                    method="batch",
                    data=err.get("data"),
                )
            by_id[int(rid)] = item.get("result")

        ordered: List[JSON] = []
        for rid in id_list:
            if rid not in by_id:
                raise RpcError(
                    code=-32603,
                    message=f"Missing result for id {rid}",
                    method="batch",
                    data=resp,
                )
            ordered.append(by_id[rid])
        return ordered

    # --- internals -------------------------------------------------------

    def _make_payload(
        self, method: str, params: Params, id: Optional[Union[int, str]] = None
    ) -> Dict[str, Any]:
        if id is None:
            id = next(self._id_counter)
        if params is None:
            params = []
        elif isinstance(params, Mapping):
            params = dict(params)
        elif isinstance(params, Sequence) and not isinstance(
            params, (str, bytes, bytearray)
        ):
            params = list(params)
        else:
            # Coerce single param into positional list
            params = [params]  # type: ignore[list-item]
        return {"jsonrpc": "2.0", "id": id, "method": method, "params": params}

    def _extract_method(
        self, payload: Union[Dict[str, Any], List[Dict[str, Any]]]
    ) -> Optional[str]:
        """Extract method name from payload for error reporting."""
        if isinstance(payload, dict):
            return payload.get("method")
        return None

    def _send_with_retries(
        self, payload: Union[Dict[str, Any], List[Dict[str, Any]]]
    ) -> JSON:
        last_exc: Optional[Exception] = None
        method = self._extract_method(payload)
        for attempt in range(1, self.max_retries + 2):  # N retries -> N+1 attempts
            try:
                return self._send_once(payload)
            except RpcError as e:
                # Server indicated an application error: do not retry
                raise e
            except Exception as e:  # network/timeout/protocol errors
                last_exc = e
                if attempt > self.max_retries:
                    break
                # Backoff
                delay = _jitter_backoff(
                    self.backoff_base, self.backoff_factor, attempt, self.backoff_jitter
                )
                time.sleep(delay)
        # If we fall through, raise a generic RpcError wrapping last_exc
        raise RpcError(
            code=-32098,
            message="RPC transport failed",
            method=method,
            data=str(last_exc),
        )

    def _send_once(self, payload: Union[Dict[str, Any], List[Dict[str, Any]]]) -> JSON:
        # Normalize payload to ensure all values are JSON-serializable
        # (converts bytes to hex strings, recursively processes nested structures)
        jsonable_payload = to_jsonable(payload)
        body = json.dumps(jsonable_payload, separators=(",", ":"), ensure_ascii=False)
        method = self._extract_method(payload)
        
        if self._use_httpx:
            assert _HAVE_HTTPX
            try:
                r = self._client.post(self.url, content=body)
            except httpx.TimeoutException as e:  # type: ignore[attr-defined]
                # Provide clear timeout error with duration
                timeout_msg = f"RPC operation timed out after {self.timeout}s" if self.timeout is not None else "RPC operation timed out"
                raise RpcError(
                    code=-32098,
                    message=timeout_msg,
                    method=method,
                    data=str(e)
                )
            except httpx.NetworkError as e:  # type: ignore[attr-defined]
                raise RpcError(
                    code=-32098, message="Network error", method=method, data=str(e)
                )
            if _is_retriable_http(r.status_code):
                raise RuntimeError(f"HTTP {r.status_code}")
            # Avoid httpx.raise_for_status() to keep error body visible below
            try:
                resp = r.json()
            except Exception as e:
                raise RpcError(
                    code=-32603,
                    message="Non-JSON response from RPC",
                    method=method,
                    data=f"HTTP {r.status_code}: {r.text[:256]}",
                ) from e
        else:
            assert _HAVE_REQUESTS
            try:
                r = self._client.post(self.url, data=body, timeout=self.timeout)
            except requests.exceptions.Timeout as e:  # type: ignore[attr-defined]
                # Provide clear timeout error with duration
                timeout_msg = f"RPC operation timed out after {self.timeout}s" if self.timeout is not None else "RPC operation timed out"
                raise RpcError(
                    code=-32098,
                    message=timeout_msg,
                    method=method,
                    data=str(e)
                )
            except requests.exceptions.RequestException as e:  # type: ignore[attr-defined]
                raise RpcError(
                    code=-32098, message="Network error", method=method, data=str(e)
                )
            if _is_retriable_http(r.status_code):
                raise RuntimeError(f"HTTP {r.status_code}")
            try:
                resp = r.json()
            except Exception as e:
                raise RpcError(
                    code=-32603,
                    message="Non-JSON response from RPC",
                    method=method,
                    data=f"HTTP {r.status_code}: {r.text[:256]}",
                ) from e

        # Single vs batch
        if isinstance(resp, dict):
            if "error" in resp:
                err = resp["error"] or {}
                raise RpcError(
                    code=err.get("code", -32603),
                    message=err.get("message", "Unknown error"),
                    method=method,
                    data=err.get("data"),
                )
            if "result" not in resp:
                raise RpcError(
                    code=-32603,
                    message="Malformed JSON-RPC response",
                    method=method,
                    data=resp,
                )
            return resp["result"]
        elif isinstance(resp, list):
            # Batch returns list-of-objects; let caller handle structure.
            return resp
        else:
            raise RpcError(
                code=-32603,
                message="Invalid JSON-RPC response type",
                method=method,
                data=type(resp).__name__,
            )


__all__ = ["RpcClient", "to_jsonable"]
