"""Error classification for circuit breaker and retry decisions."""

from __future__ import annotations

from enum import Enum


class ErrorClass(Enum):
    """Classifies an error for circuit breaker and retry decisions."""

    TRANSIENT = "TRANSIENT"
    PERMANENT = "PERMANENT"
    UNKNOWN = "UNKNOWN"


class PermanentError(Exception):
    """Wraps an exception to mark it as non-retriable.

    The circuit breaker will pass these through without incrementing the
    failure counter.
    """

    def __init__(self, cause: Exception | str | None = None) -> None:
        self.cause = cause
        if isinstance(cause, Exception):
            super().__init__(f"permanent: {cause}")
        elif isinstance(cause, str):
            super().__init__(f"permanent: {cause}")
        else:
            super().__init__("permanent error")


class CircuitBreakerOpenError(Exception):
    """Raised when the circuit breaker is open and rejecting all calls."""

    pass


class PayloadTooLargeError(Exception):
    """Raised when a message payload exceeds the configured limit."""

    pass


class MalformedProtobufError(PermanentError):
    """Raised when protobuf deserialization fails or panics.

    Inherits from PermanentError so it does not trip the circuit breaker.
    """

    def __init__(self, cause: Exception | str | None = None) -> None:
        super().__init__(cause or "neonlink: malformed protobuf")


# Keywords that indicate a permanent, non-retriable error.
_PERMANENT_KEYWORDS = (
    "validation", "invalid", "permanent", "schema", "constraint",
    "auth", "forbidden", "permission", "malformed", "syntax",
)

# Keywords that indicate a transient, retriable error.
_TRANSIENT_KEYWORDS = (
    "timeout", "connection", "network", "temporary", "rate",
    "limit", "overload", "busy", "unavailable", "retry",
)


# Kafka error codes that are definitively non-retriable.
_PERMANENT_KAFKA_CODES = frozenset({
    10,  # MSG_SIZE_TOO_LARGE
    2,   # INVALID_MSG / CORRUPT_MESSAGE
    29,  # TOPIC_AUTHORIZATION_FAILED
    30,  # GROUP_AUTHORIZATION_FAILED
    31,  # CLUSTER_AUTHORIZATION_FAILED
    17,  # INVALID_TOPIC_EXCEPTION (UNKNOWN_TOPIC_OR_PART)
    87,  # INVALID_RECORD
})


def _classify_kafka_error(err: Exception) -> ErrorClass | None:
    """Attempt typed classification of confluent-kafka errors.

    Returns ErrorClass if the error is a KafkaException/KafkaError, None otherwise.
    """
    try:
        from confluent_kafka import KafkaException, KafkaError
    except ImportError:
        return None

    kafka_err = None
    if isinstance(err, KafkaException):
        args = err.args
        if args and isinstance(args[0], KafkaError):
            kafka_err = args[0]
    elif isinstance(err, KafkaError):
        kafka_err = err

    if kafka_err is None:
        return None

    code = kafka_err.code()
    if code in _PERMANENT_KAFKA_CODES:
        return ErrorClass.PERMANENT
    if kafka_err.retriable():
        return ErrorClass.TRANSIENT
    if kafka_err.fatal():
        return ErrorClass.PERMANENT

    # Non-retriable, non-fatal Kafka errors default to permanent.
    return ErrorClass.PERMANENT


def classify(err: Exception) -> ErrorClass:
    """Determine whether an error is transient or permanent.

    Uses type checks first, then typed Kafka error classification,
    then falls back to keyword matching on the error message.
    """
    if err is None:
        return ErrorClass.UNKNOWN

    # Type assertion: PermanentError is always permanent.
    if isinstance(err, PermanentError):
        return ErrorClass.PERMANENT

    # Sentinel types.
    if isinstance(err, PayloadTooLargeError):
        return ErrorClass.PERMANENT

    # Typed Kafka error classification (before keyword fallback).
    kafka_class = _classify_kafka_error(err)
    if kafka_class is not None:
        return kafka_class

    # Keyword matching on the error message (last resort for non-Kafka errors).
    msg = str(err).lower()

    for kw in _PERMANENT_KEYWORDS:
        if kw in msg:
            return ErrorClass.PERMANENT

    for kw in _TRANSIENT_KEYWORDS:
        if kw in msg:
            return ErrorClass.TRANSIENT

    return ErrorClass.UNKNOWN


def is_transient(err: Exception) -> bool:
    """Return True if the error is classified as transient (retriable)."""
    return classify(err) == ErrorClass.TRANSIENT


def is_permanent(err: Exception) -> bool:
    """Return True if the error is classified as permanent (non-retriable)."""
    return classify(err) == ErrorClass.PERMANENT


def safe_decode(fn):
    """Wrap a protobuf decode callable with exception containment.

    Calls ``fn()`` and catches any exception, re-raising as
    MalformedProtobufError (a PermanentError) to prevent malformed payloads
    from crashing the consumer.

    Usage::

        safe_decode(lambda: job_dispatch.ParseFromString(record.value))
    """
    try:
        fn()
    except MalformedProtobufError:
        raise
    except Exception as exc:
        raise MalformedProtobufError(exc) from exc
