"""Tests for ``mgf.common.observability.AsyncJsonHandler``."""

from __future__ import annotations

import io
import json
import logging
import time

import pytest

from mgf.common.observability import (
    AsyncJsonHandler,
    JsonFormatter,
    Redactor,
)


def _make_handler(
    sink: io.StringIO,
    *,
    max_queue_size: int = 100,
    on_overflow: str = "drop",
) -> AsyncJsonHandler:
    target = logging.StreamHandler(sink)
    target.setFormatter(JsonFormatter(redactor=Redactor.default()))
    return AsyncJsonHandler(
        target=target,
        max_queue_size=max_queue_size,
        on_overflow=on_overflow,  # type: ignore[arg-type]
    )


class TestBasicEmission:
    def test_record_reaches_target(self) -> None:
        sink = io.StringIO()
        handler = _make_handler(sink)
        logger = logging.getLogger("mgf.test.async.basic")
        logger.handlers = [handler]
        logger.setLevel(logging.INFO)
        logger.propagate = False

        try:
            logger.info("hello world")
            handler.close()  # drains queue, joins worker.
        finally:
            logger.handlers = []

        output = sink.getvalue().strip()
        assert output, "no output produced"
        payload = json.loads(output)
        assert payload["msg"] == "hello world"
        assert payload["level"] == "INFO"

    def test_multiple_records_in_order(self) -> None:
        sink = io.StringIO()
        handler = _make_handler(sink, max_queue_size=1000)
        logger = logging.getLogger("mgf.test.async.order")
        logger.handlers = [handler]
        logger.setLevel(logging.INFO)
        logger.propagate = False

        try:
            for i in range(50):
                logger.info("msg-%d", i)
            handler.close()
        finally:
            logger.handlers = []

        lines = [line for line in sink.getvalue().splitlines() if line]
        assert len(lines) == 50
        for i, line in enumerate(lines):
            payload = json.loads(line)
            assert payload["msg"] == f"msg-{i}"


class TestOverflowDrop:
    def test_drop_mode_increments_counter(self) -> None:
        sink = io.StringIO()
        # Tiny queue so we can force overflow without flooding the bench.
        handler = _make_handler(sink, max_queue_size=2, on_overflow="drop")

        # Stop the drain thread so the queue actually fills (otherwise
        # the worker drains too fast to overflow). We do this by
        # tearing down the worker via .close() — but close drains; we
        # need a different approach. Instead, push records faster than
        # the worker can drain by submitting in a tight loop.
        logger = logging.getLogger("mgf.test.async.overflow")
        logger.handlers = [handler]
        logger.setLevel(logging.INFO)
        logger.propagate = False

        try:
            # Hold the worker by acquiring its thread lock indirectly
            # — rather than fight scheduling, just push lots of records
            # immediately. With queue size 2, dropped_count > 0 is
            # almost certain even on fast machines; assert >= 1.
            for i in range(200):
                logger.info("burst-%d", i)
            # Don't close yet — close drains; we want to assert overflow
            # state mid-flight.
            time.sleep(0.05)  # let some records drain.
            assert handler.dropped_count >= 1
        finally:
            handler.close()
            logger.handlers = []

    def test_overflow_warning_emitted_once(self) -> None:
        # The overflow warning fires once per handler lifetime.
        # Counting it requires inspecting the target output.
        sink = io.StringIO()
        handler = _make_handler(sink, max_queue_size=2, on_overflow="drop")
        logger = logging.getLogger("mgf.test.async.warn")
        logger.handlers = [handler]
        logger.setLevel(logging.INFO)
        logger.propagate = False

        try:
            for i in range(500):
                logger.info("burst-%d", i)
            handler.close()  # drain everything we kept.
        finally:
            logger.handlers = []

        # The warning text contains "AsyncJsonHandler overflow:"; count
        # how many times it shows up.
        warnings = sink.getvalue().count("AsyncJsonHandler overflow")
        # It should fire at most once (one-shot), even though many records
        # were dropped.
        assert warnings <= 1


class TestOverflowBlock:
    def test_block_mode_no_drops(self) -> None:
        # In block mode, the calling thread waits for queue space.
        # We assert no drops AND every record makes it through.
        sink = io.StringIO()
        handler = _make_handler(sink, max_queue_size=5, on_overflow="block")
        logger = logging.getLogger("mgf.test.async.block")
        logger.handlers = [handler]
        logger.setLevel(logging.INFO)
        logger.propagate = False

        try:
            for i in range(50):
                logger.info("msg-%d", i)
            handler.close()
        finally:
            logger.handlers = []

        lines = [line for line in sink.getvalue().splitlines() if line]
        assert len(lines) == 50
        assert handler.dropped_count == 0


class TestLifecycle:
    def test_close_is_idempotent(self) -> None:
        sink = io.StringIO()
        handler = _make_handler(sink)
        handler.close()
        handler.close()  # second close — no-op, no exception.

    def test_emit_after_close_is_silent(self) -> None:
        sink = io.StringIO()
        handler = _make_handler(sink)
        handler.close()
        # Direct emit after close should NOT raise. The record is
        # dropped silently.
        record = logging.LogRecord(
            name="post-close",
            level=logging.INFO,
            pathname=__file__,
            lineno=1,
            msg="should be discarded",
            args=(),
            exc_info=None,
        )
        handler.emit(record)  # no exception
        # Queue was closed; no new emission to sink.
        # (The pre-close output may or may not be there depending on
        # timing; the assertion is that emit() didn't crash.)

    def test_dropped_count_starts_at_zero(self) -> None:
        sink = io.StringIO()
        handler = _make_handler(sink)
        try:
            assert handler.dropped_count == 0
        finally:
            handler.close()


class TestRedactionPropagates:
    def test_secrets_scrubbed_through_async_path(self) -> None:
        sink = io.StringIO()
        handler = _make_handler(sink)
        logger = logging.getLogger("mgf.test.async.redact")
        logger.handlers = [handler]
        logger.setLevel(logging.INFO)
        logger.propagate = False

        try:
            logger.info("login", extra={"password": "supersecret"})
            handler.close()
        finally:
            logger.handlers = []

        output = sink.getvalue()
        assert "supersecret" not in output
        # Field name is preserved; value is scrubbed.
        payload = json.loads(output.strip())
        assert payload.get("password") != "supersecret"


@pytest.mark.parametrize("size", [1, 100, 10_000])
def test_handler_constructs_with_various_sizes(size: int) -> None:
    sink = io.StringIO()
    handler = _make_handler(sink, max_queue_size=size)
    handler.close()
