"""Kafka producer with circuit breaker and synchronous delivery."""

from __future__ import annotations

import logging
import threading
import time
from typing import Optional

from confluent_kafka import KafkaException, Producer as ConfluentProducer

from neonlink.config import Config
from neonlink.circuit_breaker import CircuitBreaker
from neonlink.errors import PayloadTooLargeError, is_permanent
from neonlink.metrics import NoopMetrics
from neonlink.record import Record
from neonlink.tracing import end_span_with_error, start_producer_span

logger = logging.getLogger(__name__)


class Producer:
    """Publishes records to Kafka topics.

    Uses synchronous delivery (flush per message) with circuit breaker
    protection. For batch publishing, use ``publish_batch`` which flushes
    once after all records are enqueued.
    """

    def __init__(self, cfg: Config) -> None:
        cfg.validate()
        self._producer = ConfluentProducer(cfg.to_producer_config())
        self._max_message_bytes = cfg.max_message_bytes
        self._metrics = cfg.metrics if cfg.metrics is not None else NoopMetrics()
        self._cb = CircuitBreaker(
            name="producer",
            max_failures=cfg.cb_max_failures,
            reset_timeout_sec=cfg.cb_reset_timeout_sec,
            on_state_change=lambda n, f, t: self._metrics.circuit_breaker_state_change(n, f, t),
        )
        self._lock = threading.Lock()
        self._closed = False

    def publish(
        self,
        topic: str,
        key: Optional[bytes],
        value: Optional[bytes],
        headers: Optional[dict[str, str | bytes]] = None,
        *,
        cancel_event: Optional[threading.Event] = None,
        timeout_sec: float = 30.0,
    ) -> None:
        """Publish a single record synchronously.

        Args:
            cancel_event: Optional threading.Event that, when set, aborts the publish.
            timeout_sec: Maximum time to wait for delivery confirmation.

        Raises:
            KafkaException: on delivery failure.
            CircuitBreakerOpen: if the circuit breaker is open.
            RuntimeError: if cancelled via cancel_event.
        """
        with self._lock:
            if self._closed:
                raise RuntimeError("neonlink: producer is closed")
        if cancel_event is not None and cancel_event.is_set():
            raise RuntimeError("neonlink: publish cancelled")

        self._check_record_size(key, value, headers)
        kafka_headers = _headers_to_list(headers) if headers else None
        span = start_producer_span(topic)

        def _do_publish() -> None:
            delivery_err: list[Optional[Exception]] = [None]

            def _on_delivery(err, _msg):
                if err is not None:
                    delivery_err[0] = KafkaException(err)

            self._producer.produce(
                topic=topic,
                key=key,
                value=value,
                headers=kafka_headers,
                on_delivery=_on_delivery,
            )
            self._producer.flush(timeout=timeout_sec)

            if cancel_event is not None and cancel_event.is_set():
                raise RuntimeError("neonlink: publish cancelled")

            if delivery_err[0] is not None:
                raise delivery_err[0]

        start = time.monotonic()
        try:
            self._cb.execute_with_classifier(
                _do_publish,
                should_trip=lambda err: not is_permanent(err),
            )
        except Exception as exc:
            self._metrics.publish_error(topic, exc)
            end_span_with_error(span, exc)
            raise
        self._metrics.publish_success(topic, time.monotonic() - start)
        end_span_with_error(span)

    def publish_record(self, record: Record) -> None:
        """Publish a Record to its topic."""
        self.publish(record.topic, record.key, record.value, record.headers)

    def publish_batch(
        self,
        records: list[Record],
        *,
        cancel_event: Optional[threading.Event] = None,
        timeout_sec: float = 30.0,
    ) -> None:
        """Publish multiple records, flushing once after all are enqueued.

        Args:
            cancel_event: Optional threading.Event that, when set, aborts the batch.
            timeout_sec: Maximum time to wait for delivery confirmation.

        Raises the first delivery error encountered, if any.
        """
        with self._lock:
            if self._closed:
                raise RuntimeError("neonlink: producer is closed")
        if cancel_event is not None and cancel_event.is_set():
            raise RuntimeError("neonlink: publish cancelled")

        for rec in records:
            self._check_record_size(rec.key, rec.value, rec.headers)

        def _do_batch() -> None:
            errors: list[Exception] = []

            def _on_delivery(err, _msg):
                if err is not None:
                    errors.append(KafkaException(err))

            for rec in records:
                kafka_headers = _headers_to_list(rec.headers) if rec.headers else None
                self._producer.produce(
                    topic=rec.topic,
                    key=rec.key,
                    value=rec.value,
                    headers=kafka_headers,
                    on_delivery=_on_delivery,
                )

            self._producer.flush(timeout=timeout_sec)

            if cancel_event is not None and cancel_event.is_set():
                raise RuntimeError("neonlink: publish cancelled")

            if errors:
                raise errors[0]

        batch_topic = records[0].topic if records else ""
        start = time.monotonic()
        try:
            self._cb.execute_with_classifier(
                _do_batch,
                should_trip=lambda err: not is_permanent(err),
            )
        except Exception as exc:
            self._metrics.publish_error(batch_topic, exc)
            raise
        self._metrics.publish_success(batch_topic, time.monotonic() - start)

    def close(self) -> None:
        """Flush pending records and close the producer."""
        with self._lock:
            if self._closed:
                return
            self._closed = True

        remaining = self._producer.flush(timeout=10.0)
        if remaining > 0:
            logger.warning("neonlink: %d messages not delivered on close", remaining)

    def _check_payload_size(self, value: Optional[bytes]) -> None:
        """Raise PayloadTooLargeError if value exceeds configured limit."""
        if value is not None and self._max_message_bytes > 0 and len(value) > self._max_message_bytes:
            raise PayloadTooLargeError(
                f"payload {len(value)} bytes exceeds limit {self._max_message_bytes}"
            )

    def _check_record_size(
        self,
        key: Optional[bytes],
        value: Optional[bytes],
        headers: Optional[dict[str, str | bytes]],
    ) -> None:
        """Raise PayloadTooLargeError if full record size exceeds configured limit."""
        if self._max_message_bytes <= 0:
            return
        record_size = _encoded_record_size(key, value, headers)
        if record_size > self._max_message_bytes:
            raise PayloadTooLargeError(
                f"record {record_size} bytes exceeds limit {self._max_message_bytes}"
            )


_HEADER_FRAMING_OVERHEAD = 6
_PROTOCOL_OVERHEAD = 128


def _encoded_record_size(
    key: Optional[bytes],
    value: Optional[bytes],
    headers: Optional[dict[str, str | bytes]],
) -> int:
    """Estimate the total wire size of a Kafka record."""
    size = (len(key) if key else 0) + (len(value) if value else 0) + _PROTOCOL_OVERHEAD
    if headers:
        for k, v in headers.items():
            size += len(k) + (len(v) if v else 0) + _HEADER_FRAMING_OVERHEAD
    return size


def _headers_to_list(headers: dict[str, str | bytes]) -> list[tuple[str, bytes]]:
    """Convert header dict to confluent-kafka format."""
    return [(k, v if isinstance(v, bytes) else v.encode("utf-8")) for k, v in headers.items()]
