"""HTTP JSON telemetry sink for northbound platforms."""

from __future__ import annotations

import base64
import dataclasses
import logging
import asyncio
import math
from collections.abc import Mapping, Sequence
from enum import Enum
from typing import Any

from cmvr_edge_ai.contracts import EncodedImage
from cmvr_edge_ai.core.component import ComponentContext, Sink
from cmvr_edge_ai.core.envelope import Envelope
from cmvr_edge_ai.transports.http import HttpClientPool

from .._context import http_client_settings, resolve_endpoint


_LOGGER = logging.getLogger(__name__)
_DEFAULT_RETRY_STATUSES = frozenset({408, 425, 429, 500, 502, 503, 504})


class PlatformHttpJsonSink(Sink):
    def __init__(self, node_id: str, params: Mapping[str, Any]) -> None:
        allowed_params = {
            "endpoint",
            "path",
            "failure_mode",
            "max_attempts",
            "retry_initial_s",
            "retry_max_s",
            "retry_statuses",
        }
        unknown = set(params) - allowed_params
        if unknown:
            raise ValueError(
                "unknown HTTP sink parameter(s): "
                + ", ".join(sorted(str(value) for value in unknown))
            )
        self._node_id = node_id
        self._endpoint_id = str(params["endpoint"])
        path = params.get("path", "/")
        if not isinstance(path, str) or not path.startswith("/"):
            raise ValueError("HTTP sink path must be an absolute path starting with /")
        self._path = path
        failure_mode = params.get("failure_mode", "raise")
        if not isinstance(failure_mode, str) or failure_mode not in {
            "raise",
            "log_and_drop",
        }:
            raise ValueError("failure_mode must be 'raise' or 'log_and_drop'")
        self._failure_mode = failure_mode
        self._max_attempts = _positive_int(params, "max_attempts", default=3)
        self._retry_initial_s = _non_negative_float(
            params, "retry_initial_s", default=0.25
        )
        self._retry_max_s = _non_negative_float(params, "retry_max_s", default=5.0)
        if self._retry_max_s < self._retry_initial_s:
            raise ValueError("retry_max_s must be >= retry_initial_s")
        retry_statuses = params.get("retry_statuses", _DEFAULT_RETRY_STATUSES)
        if isinstance(retry_statuses, (str, bytes, bytearray, Mapping)):
            raise ValueError("retry_statuses must be a sequence of HTTP status codes")
        try:
            parsed_statuses = frozenset(int(value) for value in retry_statuses)
        except (TypeError, ValueError) as exc:
            raise ValueError(
                "retry_statuses must be a sequence of HTTP status codes"
            ) from exc
        if any(status < 100 or status > 599 for status in parsed_statuses):
            raise ValueError("retry_statuses values must be between 100 and 599")
        self._retry_statuses = parsed_statuses
        self._client: Any = None

    async def setup(self, context: ComponentContext) -> None:
        endpoint = resolve_endpoint(context, self._endpoint_id)
        pool = context.metadata.get("http_client_pool")
        if not isinstance(pool, HttpClientPool):
            raise TypeError("component context is missing HttpClientPool")
        self._client = await pool.get(self._endpoint_id, http_client_settings(endpoint))

    async def consume(self, envelope: Envelope[Any], input_port: str = "input") -> None:
        if self._client is None:
            raise RuntimeError("HTTP sink has not been set up")
        body = {
            "schema": envelope.schema,
            "source_id": envelope.source_id,
            "sequence": envelope.sequence,
            "captured_at_ns": envelope.captured_at_ns,
            "received_at_ns": envelope.received_at_ns,
            "trace_id": envelope.trace_id,
            "session_id": envelope.session_id,
            "input_port": input_port,
            "payload": _jsonable(envelope.payload),
            "attributes": _jsonable(envelope.attributes),
        }
        idempotency_key = _event_id(envelope.payload) or envelope.trace_id
        retry_delay_s = self._retry_initial_s
        for attempt in range(1, self._max_attempts + 1):
            try:
                response = await self._client.post(
                    self._path,
                    json=body,
                    headers={"Idempotency-Key": idempotency_key},
                )
            except asyncio.CancelledError:
                raise
            except Exception as exc:
                retryable = _retryable_exception(exc)
                if attempt >= self._max_attempts or not retryable:
                    if self._failure_mode == "log_and_drop" and retryable:
                        self._log_dropped_report(
                            envelope,
                            input_port=input_port,
                            attempts=attempt,
                            error=exc,
                        )
                        return
                    raise
                _LOGGER.warning(
                    "HTTP report retry node=%s path=%s attempt=%s/%s error=%s",
                    self._node_id,
                    self._path,
                    attempt,
                    self._max_attempts,
                    exc,
                )
                await asyncio.sleep(retry_delay_s)
                retry_delay_s = min(
                    self._retry_max_s,
                    max(retry_delay_s * 2.0, self._retry_initial_s),
                )
                continue

            if (
                response.status_code in self._retry_statuses
                and attempt < self._max_attempts
            ):
                _LOGGER.warning(
                    "HTTP report retry node=%s path=%s status=%s attempt=%s/%s",
                    self._node_id,
                    self._path,
                    response.status_code,
                    attempt,
                    self._max_attempts,
                )
                await asyncio.sleep(retry_delay_s)
                retry_delay_s = min(
                    self._retry_max_s,
                    max(retry_delay_s * 2.0, self._retry_initial_s),
                )
                continue
            # Status errors outside the explicit retry allowlist are never
            # retried, even though httpx represents them as HTTP exceptions.
            try:
                response.raise_for_status()
            except asyncio.CancelledError:
                raise
            except Exception as exc:
                if self._failure_mode == "log_and_drop":
                    self._log_dropped_report(
                        envelope,
                        input_port=input_port,
                        attempts=attempt,
                        error=exc,
                        status_code=response.status_code,
                    )
                    return
                raise
            return

    def _log_dropped_report(
        self,
        envelope: Envelope[Any],
        *,
        input_port: str,
        attempts: int,
        error: Exception,
        status_code: int | None = None,
    ) -> None:
        _LOGGER.warning(
            "HTTP report dropped node=%s endpoint=%s path=%s input_port=%s "
            "failure_mode=log_and_drop action=drop attempts=%s status=%s error_type=%s "
            "error=%s trace_id=%s",
            self._node_id,
            self._endpoint_id,
            self._path,
            input_port,
            attempts,
            status_code if status_code is not None else "none",
            type(error).__name__,
            error,
            envelope.trace_id,
        )


def _jsonable(value: Any) -> Any:
    if value is None or isinstance(value, (str, int, float, bool)):
        return value
    if isinstance(value, EncodedImage):
        return {
            "media_type": value.media_type,
            "width": value.width,
            "height": value.height,
            "encoding": "base64",
            "data": base64.b64encode(value.data).decode("ascii"),
        }
    if isinstance(value, bytes):
        return {"encoding": "base64", "data": base64.b64encode(value).decode("ascii")}
    if isinstance(value, Enum):
        return value.value
    if dataclasses.is_dataclass(value) and not isinstance(value, type):
        return {
            field.name: _jsonable(getattr(value, field.name))
            for field in dataclasses.fields(value)
            if not field.metadata.get("transient", False)
        }
    if isinstance(value, Mapping):
        return {str(key): _jsonable(item) for key, item in value.items()}
    if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
        return [_jsonable(item) for item in value]
    raise TypeError(f"value is not JSON serializable: {type(value).__name__}")


def _event_id(payload: Any) -> str | None:
    value = getattr(payload, "event_id", None)
    return value if isinstance(value, str) and value else None


def _retryable_exception(exc: Exception) -> bool:
    return isinstance(exc, (OSError, TimeoutError)) or type(exc).__module__.startswith(
        "httpx"
    )


def _positive_int(params: Mapping[str, Any], name: str, *, default: int) -> int:
    value = params.get(name, default)
    if isinstance(value, bool) or not isinstance(value, int) or value < 1:
        raise ValueError(f"{name} must be a positive integer")
    return value


def _non_negative_float(
    params: Mapping[str, Any], name: str, *, default: float
) -> float:
    value = params.get(name, default)
    if isinstance(value, bool):
        raise ValueError(f"{name} must be a non-negative number")
    try:
        parsed = float(value)
    except (TypeError, ValueError) as exc:
        raise ValueError(f"{name} must be a non-negative number") from exc
    if not math.isfinite(parsed) or parsed < 0:
        raise ValueError(f"{name} must be a non-negative number")
    return parsed


__all__ = ["PlatformHttpJsonSink"]
