from __future__ import annotations

import asyncio
import concurrent.futures
import gzip
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import List

from wynd_dataingest import (
    AsyncWyndDataIngestClient,
    BatchingOptions,
    CompressionOptions,
    GzipCompressionOptions,
    RetryOptions,
    SendOptions,
    WyndDataIngestClient,
    WyndDataIngestError,
)


class ScenarioHandler(BaseHTTPRequestHandler):
    statuses: List[int] = [200]
    retry_after: str | None = None
    captured_bodies: List[bytes] = []
    captured_headers: List[dict[str, str]] = []
    response_delay_s: float = 0.0
    active_requests: int = 0
    max_active_requests: int = 0
    lock = threading.Lock()

    def do_POST(self) -> None:  # noqa: N802
        with ScenarioHandler.lock:
            ScenarioHandler.active_requests += 1
            ScenarioHandler.max_active_requests = max(
                ScenarioHandler.max_active_requests,
                ScenarioHandler.active_requests,
            )

        length = int(self.headers.get("content-length", "0"))
        body = self.rfile.read(length)
        ScenarioHandler.captured_bodies.append(body)
        ScenarioHandler.captured_headers.append({k.lower(): v for k, v in self.headers.items()})

        status = ScenarioHandler.statuses.pop(0)
        if ScenarioHandler.response_delay_s > 0:
            time.sleep(ScenarioHandler.response_delay_s)
        self.send_response(status)
        if ScenarioHandler.retry_after is not None:
            self.send_header("Retry-After", ScenarioHandler.retry_after)
        self.end_headers()
        self.wfile.write(b"ok" if status < 400 else b"error")
        with ScenarioHandler.lock:
            ScenarioHandler.active_requests -= 1

    def log_message(self, format: str, *args: object) -> None:  # noqa: A003
        return


def reset_handler(
    statuses: List[int],
    retry_after: str | None = None,
    response_delay_s: float = 0.0,
) -> None:
    ScenarioHandler.statuses = list(statuses)
    ScenarioHandler.retry_after = retry_after
    ScenarioHandler.captured_bodies = []
    ScenarioHandler.captured_headers = []
    ScenarioHandler.response_delay_s = response_delay_s
    ScenarioHandler.active_requests = 0
    ScenarioHandler.max_active_requests = 0


def serve() -> tuple[ThreadingHTTPServer, str]:
    server = ThreadingHTTPServer(("127.0.0.1", 0), ScenarioHandler)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    host, port = server.server_address
    return server, f"http://{host}:{port}/"


def test_retries_retryable_responses_with_exponential_backoff() -> None:
    reset_handler([503, 503, 202])
    server, url = serve()
    client = WyndDataIngestClient(
        url=url,
        retry=RetryOptions(max_attempts=3, initial_delay_ms=1, max_delay_ms=2, jitter_ratio=0),
    )

    result = client.send([{"message": "hello"}])

    assert result.ok is True
    assert result.status_code == 202
    assert result.attempts == 3
    assert ScenarioHandler.captured_bodies == [
        b'{"message": "hello"}\n',
        b'{"message": "hello"}\n',
        b'{"message": "hello"}\n',
    ]

    client.close()
    server.shutdown()
    server.server_close()


def test_uses_retry_after_when_present() -> None:
    reset_handler([429, 200], retry_after="0.01")
    server, url = serve()
    client = WyndDataIngestClient(
        url=url,
        retry=RetryOptions(max_attempts=2, initial_delay_ms=1, max_delay_ms=100, jitter_ratio=0),
    )

    start = time.time()
    result = client.send([{"message": "hello"}])
    elapsed_ms = int((time.time() - start) * 1000)

    assert result.ok is True
    assert result.attempts == 2
    assert elapsed_ms >= 10

    client.close()
    server.shutdown()
    server.server_close()


def test_throws_on_non_retryable_errors() -> None:
    reset_handler([400])
    server, url = serve()
    client = WyndDataIngestClient(
        url=url,
        retry=RetryOptions(max_attempts=4, initial_delay_ms=1, max_delay_ms=2, jitter_ratio=0),
    )

    try:
        client.send([{"message": "hello"}])
        assert False, "Expected WyndDataIngestError"
    except WyndDataIngestError as exc:
        assert exc.status_code == 400

    client.close()
    server.shutdown()
    server.server_close()


def test_gzips_payloads_above_threshold() -> None:
    reset_handler([202])
    server, url = serve()
    client = WyndDataIngestClient(
        url=url,
        compression=CompressionOptions(gzip=GzipCompressionOptions(min_bytes=10)),
    )

    payload = [{"message": "this payload should be compressed"}]
    client.send(payload)

    assert ScenarioHandler.captured_headers[0]["content-encoding"] == "gzip"
    assert gzip.decompress(ScenarioHandler.captured_bodies[0]).decode("utf-8") == (
        '{"message": "this payload should be compressed"}\n'
    )

    client.close()
    server.shutdown()
    server.server_close()


def test_skips_gzip_below_threshold() -> None:
    reset_handler([200])
    server, url = serve()
    client = WyndDataIngestClient(
        url=url,
        compression=CompressionOptions(gzip=GzipCompressionOptions(min_bytes=1_000)),
    )

    payload = [{"message": "small"}]
    client.send(payload)

    assert "content-encoding" not in ScenarioHandler.captured_headers[0]
    assert ScenarioHandler.captured_bodies[0].decode("utf-8") == '{"message": "small"}\n'

    client.close()
    server.shutdown()
    server.server_close()


def test_supports_gzip_auto_mode_with_default_options() -> None:
    reset_handler([200])
    server, url = serve()
    client = WyndDataIngestClient(
        url=url,
        compression=CompressionOptions(gzip=True),
    )

    payload = [{"message": "x" * (110 * 1_024)}]
    client.send(payload)

    assert ScenarioHandler.captured_headers[0]["content-encoding"] == "gzip"
    assert gzip.decompress(ScenarioHandler.captured_bodies[0]).decode("utf-8") == (
        '{"message": "' + ("x" * (110 * 1_024)) + '"}\n'
    )

    client.close()
    server.shutdown()
    server.server_close()


def test_requires_sequence_for_default_ndjson_mode() -> None:
    reset_handler([200])
    server, url = serve()
    client = WyndDataIngestClient(url=url)

    try:
        client.send({"message": "hello"})
        assert False, "Expected TypeError"
    except TypeError as exc:
        assert str(exc) == "NDJSON ingest requires a sequence payload."

    client.close()
    server.shutdown()
    server.server_close()


def test_rejects_string_payloads_for_ndjson_ingest() -> None:
    reset_handler([200])
    server, url = serve()
    client = WyndDataIngestClient(url=url)

    try:
        client.send("hello")
        assert False, "Expected TypeError"
    except TypeError as exc:
        assert str(exc) == "NDJSON ingest requires a sequence payload."

    client.close()
    server.shutdown()
    server.server_close()


def test_does_not_retry_on_timeout_to_avoid_duplicate_ingestion() -> None:
    reset_handler([202], response_delay_s=0.5)
    server, url = serve()
    client = WyndDataIngestClient(
        url=url,
        timeout_ms=50,
        retry=RetryOptions(max_attempts=3, initial_delay_ms=1, max_delay_ms=2, jitter_ratio=0),
    )

    try:
        client.send([{"message": "hello"}])
        assert False, "Expected an error"
    except WyndDataIngestError:
        pass

    assert len(ScenarioHandler.captured_bodies) == 1

    client.close()
    server.shutdown()
    server.server_close()


def test_sets_table_name_header_from_table_operation() -> None:
    server, url = serve()
    cases = [
        ("insert", "events"),
        ("new", "events"),
        ("update", "events-updates"),
        ("delete", "events-deletes"),
    ]

    for operation, expected in cases:
        reset_handler([200])
        client = WyndDataIngestClient(url=url, table_name="events", table_operation=operation)  # type: ignore[arg-type]
        client.send([{"message": operation}])
        assert ScenarioHandler.captured_headers[0]["table-name"] == expected
        client.close()

    server.shutdown()
    server.server_close()


def test_supports_pre_serialized_ndjson_bytes() -> None:
    reset_handler([200])
    server, url = serve()
    client = WyndDataIngestClient(url=url)

    payload = b'{"message": "hello"}\n{"message": "world"}\n'
    client.send(payload)

    assert ScenarioHandler.captured_bodies[0] == payload

    client.close()
    server.shutdown()
    server.server_close()


def test_supports_sync_context_manager() -> None:
    reset_handler([200])
    server, url = serve()

    with WyndDataIngestClient(url=url) as client:
        result = client.send([{"message": "hello"}])

    assert result.ok is True
    assert ScenarioHandler.captured_bodies[0] == b'{"message": "hello"}\n'

    server.shutdown()
    server.server_close()


def test_sync_client_does_not_batch_without_batching_options() -> None:
    reset_handler([202, 202])
    server, url = serve()
    client = WyndDataIngestClient(url=url)

    with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
        first = executor.submit(client.send, [{"message": "first"}])
        second = executor.submit(client.send, [{"message": "second"}])
        first_result = first.result()
        second_result = second.result()

    assert first_result.ok is True
    assert second_result.ok is True
    assert len(ScenarioHandler.captured_bodies) == 2
    assert sorted(ScenarioHandler.captured_bodies) == sorted(
        [
            b'{"message": "first"}\n',
            b'{"message": "second"}\n',
        ]
    )

    client.close()
    server.shutdown()
    server.server_close()


def test_sync_client_batches_multiple_sends_within_linger_window() -> None:
    reset_handler([202])
    server, url = serve()
    client = WyndDataIngestClient(
        url=url,
        batching=BatchingOptions(linger_ms=25, max_records=100, max_bytes=10_000),
    )

    with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
        first = executor.submit(client.send, [{"message": "first"}])
        second = executor.submit(client.send, [{"message": "second"}])
        first_result = first.result()
        second_result = second.result()

    assert first_result.ok is True
    assert second_result.ok is True
    assert ScenarioHandler.captured_bodies == [
        b'{"message": "first"}\n{"message": "second"}\n',
    ]

    client.close()
    server.shutdown()
    server.server_close()


def test_sync_client_flushes_pending_batch_on_close() -> None:
    reset_handler([202])
    server, url = serve()
    client = WyndDataIngestClient(
        url=url,
        batching=BatchingOptions(linger_ms=5_000),
    )

    with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
        future = executor.submit(client.send, [{"message": "close-flush"}])
        time.sleep(0.05)
        client.close()
        result = future.result()

    assert result.ok is True
    assert ScenarioHandler.captured_bodies == [
        b'{"message": "close-flush"}\n',
    ]

    server.shutdown()
    server.server_close()


def test_sync_client_flushes_immediately_when_max_records_is_reached() -> None:
    reset_handler([202])
    server, url = serve()
    client = WyndDataIngestClient(
        url=url,
        batching=BatchingOptions(linger_ms=5_000, max_records=2),
    )

    with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
        first = executor.submit(client.send, [{"message": "first"}])
        second = executor.submit(client.send, [{"message": "second"}])
        first_result = first.result()
        second_result = second.result()

    assert first_result.ok is True
    assert second_result.ok is True
    assert ScenarioHandler.captured_bodies == [
        b'{"message": "first"}\n{"message": "second"}\n',
    ]

    client.close()
    server.shutdown()
    server.server_close()


def test_sync_client_can_flush_multiple_batches_concurrently() -> None:
    reset_handler([202, 202], response_delay_s=0.05)
    server, url = serve()
    client = WyndDataIngestClient(
        url=url,
        batching=BatchingOptions(
            linger_ms=0,
            max_records=1,
            max_inflight_batches=2,
        ),
    )

    with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
        first = executor.submit(client.send, [{"message": "first"}])
        second = executor.submit(client.send, [{"message": "second"}])
        first_result = first.result()
        second_result = second.result()

    assert first_result.ok is True
    assert second_result.ok is True
    assert len(ScenarioHandler.captured_bodies) == 2
    assert ScenarioHandler.max_active_requests == 2

    client.close()
    server.shutdown()
    server.server_close()


def test_sync_client_respects_max_inflight_batches_limit() -> None:
    reset_handler([202, 202, 202], response_delay_s=0.05)
    server, url = serve()
    client = WyndDataIngestClient(
        url=url,
        batching=BatchingOptions(
            linger_ms=0,
            max_records=1,
            max_inflight_batches=2,
        ),
    )

    with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
        futures = [
            executor.submit(client.send, [{"message": "first"}]),
            executor.submit(client.send, [{"message": "second"}]),
            executor.submit(client.send, [{"message": "third"}]),
        ]
        results = [future.result() for future in futures]

    assert all(result.ok for result in results)
    assert ScenarioHandler.max_active_requests == 2

    client.close()
    server.shutdown()
    server.server_close()


def test_sync_client_flushes_immediately_when_max_bytes_is_reached() -> None:
    first_event = {"message": "1234567890"}
    second_event = {"message": "abcdefghij"}
    combined_body = b'{"message": "1234567890"}\n{"message": "abcdefghij"}\n'

    reset_handler([202])
    server, url = serve()
    client = WyndDataIngestClient(
        url=url,
        batching=BatchingOptions(linger_ms=5_000, max_bytes=len(combined_body)),
    )

    with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
        first = executor.submit(client.send, [first_event])
        second = executor.submit(client.send, [second_event])
        first_result = first.result()
        second_result = second.result()

    assert first_result.ok is True
    assert second_result.ok is True
    assert ScenarioHandler.captured_bodies == [combined_body]

    client.close()
    server.shutdown()
    server.server_close()


def test_sync_client_drains_pending_batch_before_direct_send_with_request_headers() -> None:
    reset_handler([202, 202])
    server, url = serve()
    client = WyndDataIngestClient(
        url=url,
        batching=BatchingOptions(linger_ms=5_000),
    )

    with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
        first_future = executor.submit(client.send, [{"message": "batched-first"}])
        time.sleep(0.05)
        second_result = client.send(
            [{"message": "direct-second"}],
            options=SendOptions(headers={"x-test-header": "1"}),
        )
        first_result = first_future.result()

    assert first_result.ok is True
    assert second_result.ok is True
    assert ScenarioHandler.captured_bodies == [
        b'{"message": "batched-first"}\n',
        b'{"message": "direct-second"}\n',
    ]
    assert ScenarioHandler.captured_headers[0].get("x-test-header") is None
    assert ScenarioHandler.captured_headers[1]["x-test-header"] == "1"

    client.close()
    server.shutdown()
    server.server_close()


def test_sync_client_propagates_batched_request_failure_to_every_queued_send() -> None:
    reset_handler([400])
    server, url = serve()
    client = WyndDataIngestClient(
        url=url,
        batching=BatchingOptions(linger_ms=5_000, max_records=2),
        retry=RetryOptions(max_attempts=1, initial_delay_ms=1, max_delay_ms=1, jitter_ratio=0),
    )

    with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
        first = executor.submit(client.send, [{"message": "first"}])
        second = executor.submit(client.send, [{"message": "second"}])

        first_error = None
        second_error = None
        try:
            first.result()
        except WyndDataIngestError as exc:
            first_error = exc
        try:
            second.result()
        except WyndDataIngestError as exc:
            second_error = exc

    assert ScenarioHandler.captured_bodies == [
        b'{"message": "first"}\n{"message": "second"}\n',
    ]
    assert first_error is not None
    assert second_error is not None
    assert first_error.status_code == 400
    assert second_error.status_code == 400

    client.close()
    server.shutdown()
    server.server_close()


def test_sync_client_compresses_combined_batched_payload_above_threshold() -> None:
    first_event = {"message": "first-batched-message"}
    second_event = {"message": "second-batched-message"}
    combined_body = b'{"message": "first-batched-message"}\n{"message": "second-batched-message"}\n'

    reset_handler([202])
    server, url = serve()
    client = WyndDataIngestClient(
        url=url,
        batching=BatchingOptions(linger_ms=5_000, max_records=2),
        compression=CompressionOptions(
            gzip=GzipCompressionOptions(min_bytes=len(combined_body))
        ),
    )

    with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
        first = executor.submit(client.send, [first_event])
        second = executor.submit(client.send, [second_event])
        first_result = first.result()
        second_result = second.result()

    assert first_result.ok is True
    assert second_result.ok is True
    assert ScenarioHandler.captured_headers[0]["content-encoding"] == "gzip"
    assert gzip.decompress(ScenarioHandler.captured_bodies[0]) == combined_body

    client.close()
    server.shutdown()
    server.server_close()


def test_sync_client_flushes_pending_batch_on_process_lifecycle_flush() -> None:
    reset_handler([202])
    server, url = serve()
    client = WyndDataIngestClient(
        url=url,
        batching=BatchingOptions(linger_ms=5_000),
    )

    with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
        future = executor.submit(client.send, [{"message": "process-flush"}])
        time.sleep(0.05)
        client.flush_on_process_lifecycle()
        result = future.result()

    assert result.ok is True
    assert ScenarioHandler.captured_bodies == [
        b'{"message": "process-flush"}\n',
    ]

    client.close()
    server.shutdown()
    server.server_close()


def test_async_client_retries_retryable_responses_with_exponential_backoff() -> None:
    async def run_test() -> None:
        reset_handler([503, 503, 202])
        server, url = serve()

        async with AsyncWyndDataIngestClient(
            url=url,
            retry=RetryOptions(max_attempts=3, initial_delay_ms=1, max_delay_ms=2, jitter_ratio=0),
        ) as client:
            result = await client.send([{"message": "hello"}])

        assert result.ok is True
        assert result.status_code == 202
        assert result.attempts == 3
        assert ScenarioHandler.captured_bodies == [
            b'{"message": "hello"}\n',
            b'{"message": "hello"}\n',
            b'{"message": "hello"}\n',
        ]

        server.shutdown()
        server.server_close()

    asyncio.run(run_test())


def test_async_client_supports_async_context_manager() -> None:
    async def run_test() -> None:
        reset_handler([200])
        server, url = serve()

        async with AsyncWyndDataIngestClient(url=url) as client:
            result = await client.send([{"message": "hello"}])

        assert result.ok is True
        assert ScenarioHandler.captured_bodies[0] == b'{"message": "hello"}\n'

        server.shutdown()
        server.server_close()

    asyncio.run(run_test())


def test_async_client_batches_multiple_sends_within_linger_window() -> None:
    async def run_test() -> None:
        reset_handler([202])
        server, url = serve()

        async with AsyncWyndDataIngestClient(
            url=url,
            batching=BatchingOptions(linger_ms=25, max_records=100, max_bytes=10_000),
        ) as client:
            first_result, second_result = await asyncio.gather(
                client.send([{"message": "first"}]),
                client.send([{"message": "second"}]),
            )

        assert first_result.ok is True
        assert second_result.ok is True
        assert ScenarioHandler.captured_bodies == [
            b'{"message": "first"}\n{"message": "second"}\n',
        ]

        server.shutdown()
        server.server_close()

    asyncio.run(run_test())


def test_async_client_can_flush_multiple_batches_concurrently() -> None:
    async def run_test() -> None:
        reset_handler([202, 202], response_delay_s=0.05)
        server, url = serve()

        async with AsyncWyndDataIngestClient(
            url=url,
            batching=BatchingOptions(
                linger_ms=0,
                max_records=1,
                max_inflight_batches=2,
            ),
        ) as client:
            first_result, second_result = await asyncio.gather(
                client.send([{"message": "first"}]),
                client.send([{"message": "second"}]),
            )

        assert first_result.ok is True
        assert second_result.ok is True
        assert len(ScenarioHandler.captured_bodies) == 2
        assert ScenarioHandler.max_active_requests == 2

        server.shutdown()
        server.server_close()

    asyncio.run(run_test())


def test_async_client_respects_max_inflight_batches_limit() -> None:
    async def run_test() -> None:
        reset_handler([202, 202, 202], response_delay_s=0.05)
        server, url = serve()

        async with AsyncWyndDataIngestClient(
            url=url,
            batching=BatchingOptions(
                linger_ms=0,
                max_records=1,
                max_inflight_batches=2,
            ),
        ) as client:
            results = await asyncio.gather(
                client.send([{"message": "first"}]),
                client.send([{"message": "second"}]),
                client.send([{"message": "third"}]),
            )

        assert all(result.ok for result in results)
        assert ScenarioHandler.max_active_requests == 2

        server.shutdown()
        server.server_close()

    asyncio.run(run_test())


def test_async_client_flushes_pending_batch_on_aclose() -> None:
    async def run_test() -> None:
        reset_handler([202])
        server, url = serve()
        client = AsyncWyndDataIngestClient(
            url=url,
            batching=BatchingOptions(linger_ms=5_000),
        )

        send_task = asyncio.create_task(client.send([{"message": "async-close-flush"}]))
        await asyncio.sleep(0.05)
        await client.aclose()
        result = await send_task

        assert result.ok is True
        assert ScenarioHandler.captured_bodies == [
            b'{"message": "async-close-flush"}\n',
        ]

        server.shutdown()
        server.server_close()

    asyncio.run(run_test())


def test_async_client_propagates_batched_request_failure_to_every_queued_send() -> None:
    async def run_test() -> None:
        reset_handler([400])
        server, url = serve()
        client = AsyncWyndDataIngestClient(
            url=url,
            batching=BatchingOptions(linger_ms=5_000, max_records=2),
            retry=RetryOptions(max_attempts=1, initial_delay_ms=1, max_delay_ms=1, jitter_ratio=0),
        )

        results = await asyncio.gather(
            client.send([{"message": "first"}]),
            client.send([{"message": "second"}]),
            return_exceptions=True,
        )

        assert ScenarioHandler.captured_bodies == [
            b'{"message": "first"}\n{"message": "second"}\n',
        ]
        assert len(results) == 2
        assert isinstance(results[0], WyndDataIngestError)
        assert isinstance(results[1], WyndDataIngestError)
        assert results[0].status_code == 400
        assert results[1].status_code == 400

        await client.aclose()
        server.shutdown()
        server.server_close()

    asyncio.run(run_test())


def test_async_client_requires_sequence_payload() -> None:
    async def run_test() -> None:
        reset_handler([200])
        server, url = serve()
        client = AsyncWyndDataIngestClient(url=url)

        try:
            await client.send({"message": "hello"})
            assert False, "Expected TypeError"
        except TypeError as exc:
            assert str(exc) == "NDJSON ingest requires a sequence payload."
        finally:
            await client.aclose()
            server.shutdown()
            server.server_close()

    asyncio.run(run_test())


def test_async_client_throws_on_non_retryable_errors() -> None:
    async def run_test() -> None:
        reset_handler([400])
        server, url = serve()
        client = AsyncWyndDataIngestClient(
            url=url,
            retry=RetryOptions(max_attempts=4, initial_delay_ms=1, max_delay_ms=2, jitter_ratio=0),
        )

        try:
            await client.send([{"message": "hello"}])
            assert False, "Expected WyndDataIngestError"
        except WyndDataIngestError as exc:
            assert exc.status_code == 400
        finally:
            await client.aclose()
            server.shutdown()
            server.server_close()

    asyncio.run(run_test())
