"""Tests for the batch transport."""

import json
import gzip
import logging
import time
import threading
from unittest.mock import patch

import requests
import responses

from llm_observatory.config import configure
import llm_observatory.transport as transport_mod
from llm_observatory.transport import Transport, shutdown_transport


INGEST_URL = "http://localhost:8080/v1/ingest"


def _make_transport(**overrides):
    defaults = {
        "api_key": "sk-test-key",
        "base_url": "http://localhost:8080",
        "flush_interval": 0.5,
        "flush_batch_size": 3,
        "max_queue_size": 100,
        "enabled": True,
    }
    defaults.update(overrides)
    configure(**defaults)
    t = Transport()
    return t


def _fake_event(i=0):
    return {"event_id": f"evt-{i}", "model": "gpt-4o"}


@responses.activate
def test_flush_on_batch_size():
    received = []

    def callback(request):
        body = gzip.decompress(request.body)
        received.extend(json.loads(body))
        return (202, {}, '{"accepted": 3}')

    responses.add_callback(responses.POST, INGEST_URL, callback=callback)

    t = _make_transport(flush_batch_size=3, flush_interval=60)
    t.start()

    for i in range(3):
        t.enqueue(_fake_event(i))

    # Wait for flush to trigger
    time.sleep(1.0)
    t.shutdown()

    assert len(received) == 3


@responses.activate
def test_flush_on_interval():
    received = []

    def callback(request):
        body = gzip.decompress(request.body)
        received.extend(json.loads(body))
        return (202, {}, '{"accepted": 1}')

    responses.add_callback(responses.POST, INGEST_URL, callback=callback)

    t = _make_transport(flush_batch_size=100, flush_interval=0.3)
    t.start()

    t.enqueue(_fake_event(0))

    # Wait for interval-based flush
    time.sleep(1.0)
    t.shutdown()

    assert len(received) >= 1


@responses.activate
def test_explicit_flush():
    received = []

    def callback(request):
        body = gzip.decompress(request.body)
        received.extend(json.loads(body))
        return (202, {}, '{"accepted": 2}')

    responses.add_callback(responses.POST, INGEST_URL, callback=callback)

    t = _make_transport(flush_batch_size=100, flush_interval=60)
    # Don't start background thread — test explicit flush only
    t.enqueue(_fake_event(0))
    t.enqueue(_fake_event(1))
    t.flush()

    assert len(received) == 2


@responses.activate
def test_retry_on_server_error():
    attempts = []

    def callback(request):
        attempts.append(1)
        if len(attempts) <= 2:
            return (503, {}, "service unavailable")
        gzip.decompress(request.body)  # validate payload is valid gzip
        return (202, {}, '{"accepted": 1}')

    responses.add_callback(responses.POST, INGEST_URL, callback=callback)

    t = _make_transport(flush_batch_size=100, flush_interval=60)
    t.enqueue(_fake_event(0))
    t.flush()

    assert len(attempts) == 3  # 2 failures + 1 success


def test_queue_overflow_drops_events():
    """When queue is full, new events are dropped (not oldest)."""
    configure(
        api_key="sk-test", base_url="http://localhost:8080",
        max_queue_size=3, flush_interval=60, flush_batch_size=100,
    )
    t = Transport()

    # Fill queue
    for i in range(5):
        t.enqueue(_fake_event(i))

    # Should have 3 events (max_queue_size) — extras dropped
    assert t._queue.qsize() == 3
    # Dropped count should be 2
    assert transport_mod._dropped_count == 2
    transport_mod._dropped_count = 0


def test_connection_error_does_not_raise():
    """A ConnectionError from the ingest endpoint should not propagate to the caller."""
    configure(
        api_key="sk-test-key",
        base_url="http://localhost:8080",
        flush_interval=60,
        flush_batch_size=100,
        max_queue_size=100,
        enabled=True,
    )
    t = Transport()
    t.enqueue(_fake_event(0))

    with patch("llm_observatory.transport.requests.post") as mock_post:
        mock_post.side_effect = requests.exceptions.ConnectionError("Connection refused")
        # flush() must not raise even when the ingest endpoint is unreachable
        t.flush()


def test_connection_error_retries_then_gives_up():
    """A persistent ConnectionError should be retried the configured number of times then give up silently."""
    configure(
        api_key="sk-test-key",
        base_url="http://localhost:8080",
        flush_interval=60,
        flush_batch_size=100,
        max_queue_size=100,
        enabled=True,
    )
    t = Transport()
    t.enqueue(_fake_event(0))

    call_count = 0

    def raise_connection_error(*args, **kwargs):
        nonlocal call_count
        call_count += 1
        raise requests.exceptions.ConnectionError("Connection refused")

    # Patch time.sleep so the retry back-offs don't slow the test
    with patch("llm_observatory.transport.requests.post", side_effect=raise_connection_error), \
         patch("llm_observatory.transport.time.sleep"):
        t.flush()

    # Transport retries 3 times (delays = [0, 1.0, 2.0]) before giving up
    assert call_count == 3


def test_no_send_without_config():
    configure(api_key=None, base_url=None, flush_interval=60, flush_batch_size=100)
    t = Transport()
    t.enqueue(_fake_event(0))
    # Should not raise — silently skips
    t.flush()


def test_missing_config_logs_warning(caplog):
    """First send with missing config should log a warning, subsequent sends should be silent."""
    # Reset the module-level warning flag
    transport_mod._missing_config_warned = False
    configure(api_key=None, base_url=None, flush_interval=60, flush_batch_size=100)
    t = Transport()

    t.enqueue(_fake_event(0))
    with caplog.at_level(logging.WARNING, logger="llm_observatory"):
        t.flush()

    assert len(caplog.records) == 1
    assert "dropping" in caplog.records[0].message
    assert "base_url" in caplog.records[0].message
    assert "api_key" in caplog.records[0].message

    # Second flush should NOT log again
    caplog.clear()
    t.enqueue(_fake_event(1))
    with caplog.at_level(logging.WARNING, logger="llm_observatory"):
        t.flush()

    assert len(caplog.records) == 0
    # Clean up
    transport_mod._missing_config_warned = False


def test_missing_config_warns_only_missing_fields(caplog):
    """Warning should only mention the missing field(s)."""
    transport_mod._missing_config_warned = False
    configure(api_key="sk-test", base_url=None, flush_interval=60, flush_batch_size=100)
    t = Transport()

    t.enqueue(_fake_event(0))
    with caplog.at_level(logging.WARNING, logger="llm_observatory"):
        t.flush()

    assert len(caplog.records) == 1
    assert "base_url" in caplog.records[0].message
    assert "api_key" not in caplog.records[0].message
    transport_mod._missing_config_warned = False


@responses.activate
def test_graceful_shutdown_flushes_pending():
    """shutdown() should flush all pending events before stopping."""
    received = []

    def callback(request):
        body = gzip.decompress(request.body)
        received.extend(json.loads(body))
        return (202, {}, '{"accepted": 5}')

    responses.add_callback(responses.POST, INGEST_URL, callback=callback)

    t = _make_transport(flush_batch_size=100, flush_interval=60)
    t.start()

    for i in range(5):
        t.enqueue(_fake_event(i))

    # Shutdown should flush all 5 events
    t.shutdown()

    assert len(received) == 5, f"expected 5 events flushed on shutdown, got {len(received)}"


@responses.activate
def test_concurrent_enqueue():
    """Multiple threads enqueuing simultaneously should not lose events."""
    received = []

    def callback(request):
        body = gzip.decompress(request.body)
        received.extend(json.loads(body))
        return (202, {}, '{"accepted": 1}')

    responses.add_callback(responses.POST, INGEST_URL, callback=callback)

    t = _make_transport(flush_batch_size=100, flush_interval=60, max_queue_size=200)
    threads = []
    events_per_thread = 20
    num_threads = 5

    for tid in range(num_threads):
        def enqueue_batch(start):
            for i in range(events_per_thread):
                t.enqueue(_fake_event(start + i))
        th = threading.Thread(target=enqueue_batch, args=(tid * events_per_thread,))
        threads.append(th)

    for th in threads:
        th.start()
    for th in threads:
        th.join()

    # All events should be in the queue
    total_expected = events_per_thread * num_threads
    assert t._queue.qsize() == total_expected, (
        f"expected {total_expected} events in queue, got {t._queue.qsize()}"
    )

    # Flush and verify all events are sent
    t.flush()
    assert len(received) == total_expected, (
        f"expected {total_expected} events sent, got {len(received)}"
    )


@responses.activate
def test_gzip_compression():
    """Verify that payloads sent to ingest are gzip compressed."""
    raw_body = None

    def callback(request):
        nonlocal raw_body
        raw_body = request.body
        return (202, {}, '{"accepted": 1}')

    responses.add_callback(responses.POST, INGEST_URL, callback=callback)

    t = _make_transport(flush_batch_size=100, flush_interval=60)
    t.enqueue(_fake_event(0))
    t.flush()

    assert raw_body is not None
    decompressed = gzip.decompress(raw_body)
    events = json.loads(decompressed)
    assert len(events) == 1


@responses.activate
def test_x_request_id_header_present():
    """Verify that each batch sends an X-Request-ID header."""
    import uuid as uuid_mod

    captured_headers = []

    def callback(request):
        captured_headers.append(dict(request.headers))
        return (202, {}, '{"accepted": 1}')

    responses.add_callback(responses.POST, INGEST_URL, callback=callback)

    t = _make_transport(flush_batch_size=100, flush_interval=60)
    t.enqueue(_fake_event(0))
    t.flush()

    assert len(captured_headers) == 1
    rid = captured_headers[0].get("X-Request-ID")
    assert rid is not None, "X-Request-ID header must be present"
    # Should be a valid UUID
    uuid_mod.UUID(rid)


@responses.activate
def test_x_request_id_unique_per_batch():
    """Each batch should get a unique X-Request-ID."""
    captured_rids = []

    def callback(request):
        captured_rids.append(request.headers.get("X-Request-ID"))
        return (202, {}, '{"accepted": 1}')

    responses.add_callback(responses.POST, INGEST_URL, callback=callback)

    t = _make_transport(flush_batch_size=1, flush_interval=60)
    t.enqueue(_fake_event(0))
    t.flush()
    t.enqueue(_fake_event(1))
    t.flush()

    assert len(captured_rids) == 2
    assert captured_rids[0] != captured_rids[1], "each batch should have a unique request ID"


def test_dropped_count_logged_on_send(caplog):
    """Dropped events should be logged when _send is called."""
    transport_mod._dropped_count = 0
    configure(
        api_key="sk-test-key",
        base_url="http://localhost:8080",
        max_queue_size=2, flush_interval=60, flush_batch_size=100,
    )
    t = Transport()

    # Fill queue beyond capacity to increment dropped count
    for i in range(5):
        t.enqueue(_fake_event(i))

    assert transport_mod._dropped_count == 3

    with caplog.at_level(logging.WARNING, logger="llm_observatory"):
        with patch("llm_observatory.transport.requests.post") as mock_post:
            mock_post.return_value.status_code = 202
            t.flush()

    # Should have logged the dropped count
    drop_msgs = [r for r in caplog.records if "dropped due to full queue" in r.message]
    assert len(drop_msgs) == 1
    assert "3 event(s) dropped" in drop_msgs[0].message

    # Counter should be reset after logging
    assert transport_mod._dropped_count == 0


@responses.activate
def test_send_in_batches_chunks_correctly():
    """_send_in_batches should send events in chunks of flush_batch_size."""
    received_batches = []

    def callback(request):
        body = gzip.decompress(request.body)
        batch = json.loads(body)
        received_batches.append(len(batch))
        return (202, {}, '{"accepted": 1}')

    responses.add_callback(responses.POST, INGEST_URL, callback=callback)

    # batch_size=3, enqueue 7 events
    t = _make_transport(flush_batch_size=3, flush_interval=60)
    for i in range(7):
        t.enqueue(_fake_event(i))

    t.flush()

    # Should have sent 3 batches: [3, 3, 1]
    assert received_batches == [3, 3, 1]


def test_shutdown_transport_resets_flags():
    """shutdown_transport should reset _missing_config_warned and _dropped_count."""
    transport_mod._missing_config_warned = True
    transport_mod._dropped_count = 42

    # Create a transport to shut down
    configure(api_key="sk-test", base_url="http://localhost:8080")
    transport_mod._transport = Transport()

    shutdown_transport()

    assert transport_mod._missing_config_warned is False
    assert transport_mod._dropped_count == 0
    assert transport_mod._transport is None


def test_shutdown_transport_race_condition():
    """shutdown_transport is thread-safe — concurrent calls should not error."""
    configure(api_key="sk-test", base_url="http://localhost:8080")

    # Set up transport
    transport_mod._transport = Transport()

    errors = []

    def shutdown_worker():
        try:
            shutdown_transport()
        except Exception as e:
            errors.append(e)

    threads = [threading.Thread(target=shutdown_worker) for _ in range(10)]
    for th in threads:
        th.start()
    for th in threads:
        th.join()

    assert not errors, f"shutdown_transport raised errors: {errors}"
    assert transport_mod._transport is None


@responses.activate
def test_executor_used_in_background_loop():
    """Background _run loop should use executor to submit sends."""
    received = []

    def callback(request):
        body = gzip.decompress(request.body)
        received.extend(json.loads(body))
        return (202, {}, '{"accepted": 1}')

    responses.add_callback(responses.POST, INGEST_URL, callback=callback)

    t = _make_transport(flush_batch_size=2, flush_interval=0.2)
    t.start()

    assert t._executor is not None

    t.enqueue(_fake_event(0))
    t.enqueue(_fake_event(1))

    time.sleep(1.0)
    t.shutdown()

    assert len(received) >= 2
