"""Cross-language conformance tests (V-16).

These tests verify behavior parity between Go and Python SDKs.
Identical test vectors exist in pkg/neonlink/neonlink_test.go.
"""

import pytest

from neonlink.circuit_breaker import CircuitBreaker, CircuitBreakerState
from neonlink.config import Config
from neonlink.errors import (
    ErrorClass,
    MalformedProtobufError,
    PayloadTooLargeError,
    PermanentError,
    classify,
    is_permanent,
    safe_decode,
)
from neonlink.metrics import NoopMetrics
from neonlink.mock import MockProducer
from neonlink.record import Record
from neonlink.tracing import start_consumer_span, start_producer_span, end_span_with_error


class TestCBDoesNotTripOnPermanentError:
    """V-16: CB must NOT trip on PermanentError (parity with Go)."""

    def test_10_permanent_errors_stay_closed(self):
        cb = CircuitBreaker("test", max_failures=2, reset_timeout_sec=60)
        for _ in range(10):
            with pytest.raises(PermanentError):
                cb.execute_with_classifier(
                    lambda: (_ for _ in ()).throw(PermanentError("bad input")),
                    should_trip=lambda err: not is_permanent(err),
                )
        assert cb.state == CircuitBreakerState.CLOSED


class TestCBTripsOnTransportErrors:
    """V-16: CB must trip on transport errors at max_failures."""

    def test_trips_at_max(self):
        cb = CircuitBreaker("test", max_failures=3, reset_timeout_sec=60)
        for _ in range(3):
            with pytest.raises(ConnectionError):
                cb.execute_with_classifier(
                    lambda: (_ for _ in ()).throw(ConnectionError("connection refused")),
                    should_trip=lambda err: not is_permanent(err),
                )
        assert cb.state == CircuitBreakerState.OPEN


class TestErrorClassificationParity:
    """V-16: Same string inputs must produce same ErrorClass in Go and Python."""

    @pytest.mark.parametrize("msg,expected", [
        ("validation failed", ErrorClass.PERMANENT),
        ("connection refused", ErrorClass.TRANSIENT),
        ("timeout exceeded", ErrorClass.TRANSIENT),
        ("invalid input", ErrorClass.PERMANENT),
        ("something unexpected", ErrorClass.UNKNOWN),
    ])
    def test_keyword_classification(self, msg, expected):
        err = Exception(msg)
        assert classify(err) == expected, f"expected {expected} for {msg!r}"


class TestHeaderBytesRoundTrip:
    """V-16: Binary headers must survive set/get round-trip without corruption."""

    def test_bytes_preserved(self):
        binary = b"\x00\x01\xff\xfe\x80"
        rec = Record(topic="t")
        rec.set_header_bytes("binary-key", binary)
        got = rec.get_header_bytes("binary-key")
        assert got == binary

    def test_str_backward_compat(self):
        rec = Record(topic="t")
        rec.set_header("text-key", "hello")
        assert rec.get_header("text-key") == "hello"

    def test_bytes_decoded_as_str(self):
        """Bytes values are decoded to str via get_header for backward compat."""
        rec = Record(topic="t", headers={"k": b"utf8-value"})
        assert rec.get_header("k") == "utf8-value"

    def test_non_utf8_bytes_replaced(self):
        """Non-UTF-8 bytes get replacement characters via get_header."""
        rec = Record(topic="t", headers={"k": b"\xff\xfe"})
        result = rec.get_header("k")
        assert isinstance(result, str)
        assert len(result) > 0  # Should have replacement chars


class TestSafeDecodePython:
    """V-05/V-16: safe_decode catches exceptions and wraps as MalformedProtobufError."""

    def test_success(self):
        safe_decode(lambda: None)  # Should not raise

    def test_wraps_exception(self):
        with pytest.raises(MalformedProtobufError):
            safe_decode(lambda: (_ for _ in ()).throw(ValueError("bad proto")))

    def test_passthrough_malformed(self):
        """MalformedProtobufError should pass through without double-wrapping."""
        with pytest.raises(MalformedProtobufError, match="already malformed"):
            safe_decode(lambda: (_ for _ in ()).throw(
                MalformedProtobufError("already malformed")
            ))


class TestAcksValidation:
    """V-06: required_acks must be 'all'."""

    def test_rejects_non_all(self):
        cfg = Config(required_acks="1")
        with pytest.raises(ValueError, match="required_acks must be 'all'"):
            cfg.validate()

    def test_accepts_all(self):
        cfg = Config(required_acks="all")
        cfg.validate()  # Should not raise


class TestMaxBufferedRecordsDefault:
    """V-08: MaxBufferedRecords defaults to 500."""

    def test_default(self):
        cfg = Config()
        assert cfg.max_buffered_records == 500

    def test_queued_max_messages_kbytes_default(self):
        cfg = Config()
        assert cfg.queued_max_messages_kbytes == 65536


class TestEncodedRecordSize:
    """V-07: Full record size estimation."""

    def test_key_value_only(self):
        from neonlink.producer import _encoded_record_size
        size = _encoded_record_size(b"key", b"value", None)
        assert size == 3 + 5 + 128  # key + value + protocol overhead

    def test_with_headers(self):
        from neonlink.producer import _encoded_record_size
        headers = {"h1": "v1", "h2": b"v2"}
        size = _encoded_record_size(b"k", b"v", headers)
        # key(1) + value(1) + overhead(128) + h1(2+2+6) + h2(2+2+6) = 150
        assert size == 1 + 1 + 128 + (2 + 2 + 6) + (2 + 2 + 6)

    def test_none_key_value(self):
        from neonlink.producer import _encoded_record_size
        size = _encoded_record_size(None, None, None)
        assert size == 128  # Just protocol overhead


class TestMockProducerSatisfiesDLQPublisher:
    """V-15: MockProducer must satisfy DLQPublisher protocol."""

    def test_publish_signature(self):
        mp = MockProducer()
        # Call with the DLQPublisher signature
        mp.publish("dlq-topic", b"key", b"value", {"h": "v"})
        assert len(mp.records) == 1
        assert mp.records[0].topic == "dlq-topic"


class TestNoopMetricsPoisonPillBlocked:
    """V-04/V-14: NoopMetrics.poison_pill_blocked is callable."""

    def test_callable(self):
        m = NoopMetrics()
        m.poison_pill_blocked("topic", 0, 100)  # Should not raise


class TestTracingNoOp:
    """V-10: Tracing functions work gracefully without OTel."""

    def test_consumer_span(self):
        rec = Record(topic="test-topic", partition=0, offset=42)
        span = start_consumer_span(rec)
        end_span_with_error(span)  # Should not raise

    def test_producer_span(self):
        span = start_producer_span("test-topic")
        end_span_with_error(span)  # Should not raise

    def test_span_with_error(self):
        span = start_consumer_span(Record(topic="t"))
        end_span_with_error(span, ValueError("test"))  # Should not raise
