"""Tests for structured logging configuration."""

from __future__ import annotations

import json
import logging
import sys
from contextvars import copy_context
from pathlib import Path

import pytest

from oclawma.logging_config import (
    CorrelationIdFilter,
    JSONFormatter,
    TraceContext,
    TraceContextFilter,
    _correlation_id_var,
    _trace_context_var,
    correlation_id,
    get_correlation_id,
    get_logger,
    get_trace_context,
    log_job_end,
    log_job_start,
    setup_logging,
    trace_context,
)


class TestJSONFormatter:
    """Tests for JSONFormatter."""

    def test_basic_formatting(self) -> None:
        """Test basic JSON log formatting."""
        formatter = JSONFormatter()
        record = logging.LogRecord(
            name="test.logger",
            level=logging.INFO,
            pathname="test.py",
            lineno=10,
            msg="Test message",
            args=(),
            exc_info=None,
        )
        record.funcName = "test_function"
        record.module = "test"

        output = formatter.format(record)
        parsed = json.loads(output)

        assert parsed["message"] == "Test message"
        assert parsed["level"] == "INFO"
        assert parsed["logger"] == "test.logger"
        assert parsed["module"] == "test"
        assert parsed["function"] == "test_function"
        assert parsed["line"] == 10
        assert "timestamp" in parsed
        assert "hostname" in parsed
        assert "pid" in parsed

    def test_correlation_id_in_output(self) -> None:
        """Test that correlation ID appears in JSON output."""
        formatter = JSONFormatter()
        record = logging.LogRecord(
            name="test.logger",
            level=logging.INFO,
            pathname="test.py",
            lineno=1,
            msg="Test",
            args=(),
            exc_info=None,
        )
        record.correlation_id = "test-correlation-id"

        output = formatter.format(record)
        parsed = json.loads(output)

        assert parsed["correlation_id"] == "test-correlation-id"

    def test_trace_context_in_output(self) -> None:
        """Test that trace context appears in JSON output."""
        formatter = JSONFormatter()
        record = logging.LogRecord(
            name="test.logger",
            level=logging.INFO,
            pathname="test.py",
            lineno=1,
            msg="Test",
            args=(),
            exc_info=None,
        )
        record.trace_id = "trace-123"
        record.span_id = "span-456"
        record.parent_span_id = "parent-789"

        output = formatter.format(record)
        parsed = json.loads(output)

        assert parsed["trace_id"] == "trace-123"
        assert parsed["span_id"] == "span-456"
        assert parsed["parent_span_id"] == "parent-789"

    def test_exception_formatting(self) -> None:
        """Test exception formatting in JSON output."""
        formatter = JSONFormatter()

        try:
            raise ValueError("Test exception")
        except ValueError:
            exc_info = sys.exc_info()
            record = logging.LogRecord(
                name="test.logger",
                level=logging.ERROR,
                pathname="test.py",
                lineno=1,
                msg="Error occurred",
                args=(),
                exc_info=exc_info,
            )

        output = formatter.format(record)
        parsed = json.loads(output)

        assert "exception" in parsed
        assert "ValueError" in parsed["exception"]
        assert "Test exception" in parsed["exception"]

    def test_extra_fields(self) -> None:
        """Test that extra fields are included in output."""
        formatter = JSONFormatter()
        record = logging.LogRecord(
            name="test.logger",
            level=logging.INFO,
            pathname="test.py",
            lineno=1,
            msg="Test",
            args=(),
            exc_info=None,
        )
        record.custom_field = "custom_value"
        record.number_field = 42

        output = formatter.format(record)
        parsed = json.loads(output)

        assert parsed["custom_field"] == "custom_value"
        assert parsed["number_field"] == 42

    def test_indent_option(self) -> None:
        """Test pretty-printing with indentation."""
        formatter = JSONFormatter(indent=2)
        record = logging.LogRecord(
            name="test.logger",
            level=logging.INFO,
            pathname="test.py",
            lineno=1,
            msg="Test",
            args=(),
            exc_info=None,
        )

        output = formatter.format(record)
        # Check that output contains newlines for pretty printing
        assert "\n" in output

    def test_non_serializable_values(self) -> None:
        """Test handling of non-serializable values."""
        formatter = JSONFormatter()
        record = logging.LogRecord(
            name="test.logger",
            level=logging.INFO,
            pathname="test.py",
            lineno=1,
            msg="Test",
            args=(),
            exc_info=None,
        )
        # Set a non-serializable object
        record.object_field = object()

        output = formatter.format(record)
        parsed = json.loads(output)

        # Should be converted to string
        assert "object_field" in parsed
        assert isinstance(parsed["object_field"], str)


class TestCorrelationIdFilter:
    """Tests for CorrelationIdFilter."""

    def test_adds_correlation_id(self) -> None:
        """Test that filter adds correlation ID from context."""
        filter_ = CorrelationIdFilter()
        record = logging.LogRecord(
            name="test",
            level=logging.INFO,
            pathname="test.py",
            lineno=1,
            msg="Test",
            args=(),
            exc_info=None,
        )

        with correlation_id("test-id"):
            result = filter_.filter(record)

        assert result is True
        assert record.correlation_id == "test-id"

    def test_no_correlation_id_when_not_set(self) -> None:
        """Test that filter doesn't add correlation ID when not in context."""
        filter_ = CorrelationIdFilter()
        record = logging.LogRecord(
            name="test",
            level=logging.INFO,
            pathname="test.py",
            lineno=1,
            msg="Test",
            args=(),
            exc_info=None,
        )

        result = filter_.filter(record)

        assert result is True
        assert not hasattr(record, "correlation_id")


class TestTraceContext:
    """Tests for TraceContext."""

    def test_default_generation(self) -> None:
        """Test that IDs are generated by default."""
        ctx = TraceContext()

        assert ctx.trace_id is not None
        assert ctx.span_id is not None
        assert len(ctx.trace_id) == 16
        assert len(ctx.span_id) == 16
        assert ctx.parent_span_id is None

    def test_custom_values(self) -> None:
        """Test custom trace context values."""
        ctx = TraceContext(
            trace_id="custom-trace",
            span_id="custom-span",
            parent_span_id="custom-parent",
        )

        assert ctx.trace_id == "custom-trace"
        assert ctx.span_id == "custom-span"
        assert ctx.parent_span_id == "custom-parent"

    def test_to_dict(self) -> None:
        """Test conversion to dictionary."""
        ctx = TraceContext(
            trace_id="trace-123",
            span_id="span-456",
            parent_span_id="parent-789",
        )

        result = ctx.to_dict()

        assert result == {
            "trace_id": "trace-123",
            "span_id": "span-456",
            "parent_span_id": "parent-789",
        }

    def test_to_dict_without_parent(self) -> None:
        """Test conversion to dictionary without parent."""
        ctx = TraceContext(
            trace_id="trace-123",
            span_id="span-456",
        )

        result = ctx.to_dict()

        assert result == {
            "trace_id": "trace-123",
            "span_id": "span-456",
        }
        assert "parent_span_id" not in result

    def test_child_span(self) -> None:
        """Test creating child span."""
        parent = TraceContext(trace_id="trace-123", span_id="span-456")
        child = parent.child_span()

        assert child.trace_id == "trace-123"  # Same trace
        assert child.span_id != "span-456"  # Different span
        assert child.parent_span_id == "span-456"  # Parent is previous span


class TestTraceContextFilter:
    """Tests for TraceContextFilter."""

    def test_adds_trace_context(self) -> None:
        """Test that filter adds trace context from context variable."""
        filter_ = TraceContextFilter()
        record = logging.LogRecord(
            name="test",
            level=logging.INFO,
            pathname="test.py",
            lineno=1,
            msg="Test",
            args=(),
            exc_info=None,
        )

        with trace_context(trace_id="trace-123", span_id="span-456"):
            result = filter_.filter(record)

        assert result is True
        assert record.trace_id == "trace-123"
        assert record.span_id == "span-456"

    def test_adds_parent_span_id(self) -> None:
        """Test that filter adds parent span ID when present."""
        filter_ = TraceContextFilter()
        record = logging.LogRecord(
            name="test",
            level=logging.INFO,
            pathname="test.py",
            lineno=1,
            msg="Test",
            args=(),
            exc_info=None,
        )

        with trace_context(
            trace_id="trace-123",
            span_id="span-456",
            parent_span_id="parent-789",
        ):
            filter_.filter(record)

        assert record.parent_span_id == "parent-789"

    def test_no_trace_context_when_not_set(self) -> None:
        """Test that filter doesn't add trace context when not set."""
        filter_ = TraceContextFilter()
        record = logging.LogRecord(
            name="test",
            level=logging.INFO,
            pathname="test.py",
            lineno=1,
            msg="Test",
            args=(),
            exc_info=None,
        )

        result = filter_.filter(record)

        assert result is True
        assert not hasattr(record, "trace_id")


class TestCorrelationIdContextManager:
    """Tests for correlation_id context manager."""

    def test_generates_id_when_not_provided(self) -> None:
        """Test that context manager generates ID when not provided."""
        with correlation_id() as cid:
            assert cid is not None
            assert len(cid) > 0
            assert get_correlation_id() == cid

    def test_uses_provided_id(self) -> None:
        """Test that context manager uses provided ID."""
        with correlation_id("custom-id") as cid:
            assert cid == "custom-id"
            assert get_correlation_id() == "custom-id"

    def test_nested_contexts(self) -> None:
        """Test nested correlation ID contexts."""
        with correlation_id("outer"):
            assert get_correlation_id() == "outer"
            with correlation_id("inner"):
                assert get_correlation_id() == "inner"
            assert get_correlation_id() == "outer"

    def test_context_isolation(self) -> None:
        """Test that contexts are isolated across async boundaries."""
        ctx = copy_context()

        def in_context():
            with correlation_id("async-id"):
                return get_correlation_id()

        result = ctx.run(in_context)
        assert result == "async-id"
        # Original context should not have the ID
        assert get_correlation_id() is None


class TestTraceContextManager:
    """Tests for trace_context context manager."""

    def test_generates_ids_when_not_provided(self) -> None:
        """Test that context manager generates IDs when not provided."""
        with trace_context() as ctx:
            assert ctx.trace_id is not None
            assert ctx.span_id is not None
            assert get_trace_context() == ctx

    def test_uses_provided_ids(self) -> None:
        """Test that context manager uses provided IDs."""
        with trace_context(
            trace_id="custom-trace",
            span_id="custom-span",
        ) as ctx:
            assert ctx.trace_id == "custom-trace"
            assert ctx.span_id == "custom-span"

    def test_nested_spans(self) -> None:
        """Test nested trace contexts."""
        with trace_context(trace_id="trace-1", span_id="span-1") as outer:
            assert get_trace_context() == outer
            with trace_context(parent_span_id=outer.span_id) as inner:
                assert get_trace_context() == inner
                assert inner.parent_span_id == outer.span_id
            assert get_trace_context() == outer


class TestSetupLogging:
    """Tests for setup_logging function."""

    def test_setup_with_string_level(self, tmp_path: Path) -> None:
        """Test setup with string log level."""
        log_file = tmp_path / "debug.log"
        setup_logging(level="DEBUG", log_file=str(log_file), enable_console=False)
        logger = logging.getLogger("test_string_level")
        logger.debug("Test debug message")

        # Force flush and close handlers
        for handler in logging.getLogger().handlers:
            handler.flush()
            handler.close()

        content = log_file.read_text()
        assert "Test debug message" in content

    def test_setup_with_int_level(self, tmp_path: Path) -> None:
        """Test setup with integer log level."""
        log_file = tmp_path / "warning.log"
        setup_logging(level=logging.WARNING, log_file=str(log_file), enable_console=False)
        logger = logging.getLogger("test_int_level")
        logger.warning("Test warning")
        logger.info("This should not appear")

        # Force flush and close handlers
        for handler in logging.getLogger().handlers:
            handler.flush()
            handler.close()

        content = log_file.read_text()
        assert "Test warning" in content
        assert "This should not appear" not in content

    def test_setup_with_json_format(self, tmp_path: Path) -> None:
        """Test setup with JSON formatting to file."""
        log_file = tmp_path / "test.log"
        setup_logging(
            level="INFO",
            json_format=True,
            log_file=str(log_file),
            enable_console=False,
        )

        logger = logging.getLogger("test_json")
        logger.info("JSON test message")

        # Force flush and close handlers
        for handler in logging.getLogger().handlers:
            handler.flush()
            handler.close()

        content = log_file.read_text()
        parsed = json.loads(content.strip())

        assert parsed["message"] == "JSON test message"
        assert parsed["level"] == "INFO"

    def test_setup_with_plain_format(self, tmp_path: Path) -> None:
        """Test setup with plain text formatting."""
        log_file = tmp_path / "test_plain.log"
        setup_logging(
            level="INFO",
            json_format=False,
            log_file=str(log_file),
            enable_console=False,
        )

        logger = logging.getLogger("test_plain")
        logger.info("Plain text message")

        # Force flush and close handlers
        for handler in logging.getLogger().handlers:
            handler.flush()
            handler.close()

        content = log_file.read_text()
        assert "Plain text message" in content
        assert "INFO" in content

    def test_log_rotation(self, tmp_path: Path) -> None:
        """Test log file rotation."""
        log_file = tmp_path / "rotating.log"
        setup_logging(
            level="INFO",
            log_file=str(log_file),
            max_bytes=100,  # Small size to trigger rotation
            backup_count=2,
            enable_console=False,
        )

        logger = logging.getLogger("test_rotation")
        # Write enough to trigger rotation
        for i in range(20):
            logger.info(f"Message {i}: " + "x" * 50)

        # Force flush and close handlers
        for handler in logging.getLogger().handlers:
            handler.flush()
            handler.close()

        # Check that backup files exist
        assert log_file.exists()
        backup_files = list(tmp_path.glob("rotating.log.*"))
        assert len(backup_files) > 0

    def test_cleanup_old_logs(self, tmp_path: Path) -> None:
        """Test cleanup of old log files."""
        # Create an old log file
        old_log = tmp_path / "old.log.1"
        old_log.write_text("old content")

        # Set modification time to be old
        import time

        old_time = time.time() - (40 * 24 * 60 * 60)  # 40 days ago
        import os

        os.utime(old_log, (old_time, old_time))

        setup_logging(
            level="INFO",
            log_file=str(tmp_path / "current.log"),
            retention_days=30,
            enable_console=False,
        )

        # Old log should be deleted
        assert not old_log.exists()

    def test_console_disabled(self, capsys) -> None:
        """Test that console output can be disabled."""
        setup_logging(level="INFO", enable_console=False)
        logger = logging.getLogger("test_no_console")
        logger.info("This should not go to console")

        captured = capsys.readouterr()
        # Note: pytest's caplog captures log output, but capsys captures stdout
        # With enable_console=False, nothing should go to stdout
        assert captured.out == ""


class TestGetLogger:
    """Tests for get_logger function."""

    def test_returns_logger(self) -> None:
        """Test that get_logger returns a logger."""
        logger = get_logger("test_module")
        assert isinstance(logger, logging.Logger)
        assert logger.name == "test_module"

    def test_adds_correlation_filter(self) -> None:
        """Test that get_logger adds correlation ID filter."""
        logger = get_logger("test_correlation")
        filter_types = [type(f) for f in logger.filters]
        assert CorrelationIdFilter in filter_types

    def test_adds_trace_filter(self) -> None:
        """Test that get_logger adds trace context filter."""
        logger = get_logger("test_trace")
        filter_types = [type(f) for f in logger.filters]
        assert TraceContextFilter in filter_types

    def test_does_not_duplicate_filters(self) -> None:
        """Test that calling get_logger multiple times doesn't duplicate filters."""
        logger1 = get_logger("test_no_duplicate")
        initial_filter_count = len(logger1.filters)

        logger2 = get_logger("test_no_duplicate")
        final_filter_count = len(logger2.filters)

        assert logger1 is logger2  # Same logger instance
        assert final_filter_count == initial_filter_count


class TestJobLifecycleLogging:
    """Tests for job lifecycle logging functions."""

    def test_log_job_start(self, tmp_path: Path) -> None:
        """Test log_job_start function."""
        log_file = tmp_path / "job_start.log"
        setup_logging(level="INFO", log_file=str(log_file), enable_console=False)
        logger = logging.getLogger("test_job_start")

        log_job_start(
            logger,
            job_id="job-123",
            job_type="test_job",
            extra_field="extra_value",
        )

        # Force flush and close handlers
        for handler in logging.getLogger().handlers:
            handler.flush()
            handler.close()

        content = log_file.read_text()
        assert "Job started: test_job" in content
        # Check that the correlation ID was set
        assert get_correlation_id() is None  # Should be reset after context

    def test_log_job_end_success(self, tmp_path: Path) -> None:
        """Test log_job_end with success."""
        log_file = tmp_path / "job_success.log"
        setup_logging(level="INFO", log_file=str(log_file), enable_console=False)
        logger = logging.getLogger("test_job_end_success")

        log_job_end(
            logger,
            job_id="job-123",
            job_type="test_job",
            success=True,
            duration_ms=1000.5,
        )

        # Force flush and close handlers
        for handler in logging.getLogger().handlers:
            handler.flush()
            handler.close()

        content = log_file.read_text()
        assert "Job completed: test_job" in content

    def test_log_job_end_failure(self, tmp_path: Path) -> None:
        """Test log_job_end with failure."""
        log_file = tmp_path / "job_failure.log"
        setup_logging(level="ERROR", log_file=str(log_file), enable_console=False)
        logger = logging.getLogger("test_job_end_failure")

        log_job_end(
            logger,
            job_id="job-456",
            job_type="test_job",
            success=False,
            duration_ms=500.0,
            error="Something went wrong",
        )

        # Force flush and close handlers
        for handler in logging.getLogger().handlers:
            handler.flush()
            handler.close()

        content = log_file.read_text()
        assert "Job failed: test_job" in content
        assert "ERROR" in content

    def test_job_correlation_id_isolation(self) -> None:
        """Test that job logging doesn't leak correlation IDs."""
        setup_logging(level="INFO", enable_console=False)
        logger = logging.getLogger("test_job_isolation")

        # Start first job
        log_job_start(logger, job_id="job-1", job_type="job_type_1")
        assert get_correlation_id() is None

        # Start second job
        log_job_start(logger, job_id="job-2", job_type="job_type_2")
        assert get_correlation_id() is None


class TestIntegration:
    """Integration tests for the logging system."""

    def test_full_logging_flow(self, tmp_path: Path) -> None:
        """Test a complete logging flow with correlation IDs and trace context."""
        log_file = tmp_path / "integration.log"
        setup_logging(
            level="INFO",
            json_format=True,
            log_file=str(log_file),
            enable_console=False,
        )

        logger = get_logger("integration_test")

        # Simulate a job with tracing
        with (
            correlation_id("request-123"),
            trace_context(trace_id="trace-abc", span_id="span-1") as parent_ctx,
        ):
            logger.info("Request started")

            with trace_context(trace_id=parent_ctx.trace_id, parent_span_id=parent_ctx.span_id):
                logger.info("Sub-operation started")
                logger.info("Sub-operation completed")

            logger.info("Request completed")

        # Force flush
        for handler in logging.getLogger().handlers:
            handler.flush()
            handler.close()

        # Parse log lines
        lines = log_file.read_text().strip().split("\n")
        logs = [json.loads(line) for line in lines]

        # Verify structure
        assert len(logs) == 4
        for log in logs:
            assert log["correlation_id"] == "request-123"
            assert log["trace_id"] == "trace-abc"
            assert "span_id" in log

        # Check parent-child relationship in middle logs
        assert logs[1]["parent_span_id"] == "span-1"
        assert logs[2]["parent_span_id"] == "span-1"

    def test_async_context_propagation(self) -> None:
        """Test that context propagates correctly in async scenarios."""
        import asyncio

        async def async_operation():
            logger = get_logger("async_test")
            with correlation_id("async-req-1"):
                logger.info("Async operation")
                return get_correlation_id()

        async def main():
            # Run concurrent operations
            results = await asyncio.gather(
                async_operation(),
                async_operation(),
            )
            return results

        # Each concurrent operation should have its own context
        results = asyncio.run(main())
        assert results[0] == "async-req-1"
        assert results[1] == "async-req-1"


@pytest.fixture(autouse=True)
def reset_logging():
    """Reset logging state after each test."""
    yield
    # Clean up handlers
    root = logging.getLogger()
    for handler in root.handlers[:]:
        handler.close()
        root.removeHandler(handler)
    root.setLevel(logging.WARNING)

    # Reset context variables
    _correlation_id_var.set(None)
    _trace_context_var.set(None)
