"""Comprehensive tests for snaplog."""

import io
import json
import logging
import threading
import time
from datetime import datetime, timezone

import pytest

from snaplog import get_logger, JsonFormatter, HumanFormatter, SnaplogHandler, context


# ─── Fixtures ────────────────────────────────────────────────────

@pytest.fixture
def output():
    return io.StringIO()


@pytest.fixture(autouse=True)
def clear_loggers():
    """Clear logger cache between tests."""
    from snaplog.logger import _loggers
    _loggers.clear()
    yield
    _loggers.clear()


@pytest.fixture
def log(output):
    return get_logger("test", output=output)


# ─── Basic Logging ───────────────────────────────────────────────

class TestBasicLogging:
    def test_info(self, log, output):
        log.info("hello")
        record = json.loads(output.getvalue())
        assert record["level"] == "info"
        assert record["msg"] == "hello"

    def test_debug(self, log, output):
        log.debug("details", x=1)
        record = json.loads(output.getvalue())
        assert record["level"] == "debug"
        assert record["x"] == 1

    def test_warning(self, log, output):
        log.warning("careful")
        record = json.loads(output.getvalue())
        assert record["level"] == "warning"

    def test_warn_alias(self, log, output):
        log.warn("careful")
        record = json.loads(output.getvalue())
        assert record["level"] == "warning"

    def test_error(self, log, output):
        log.error("oops", code=500)
        record = json.loads(output.getvalue())
        assert record["level"] == "error"
        assert record["code"] == 500

    def test_critical(self, log, output):
        log.critical("fire!")
        record = json.loads(output.getvalue())
        assert record["level"] == "critical"

    def test_trace_level(self, log, output):
        log.trace("tracing")
        record = json.loads(output.getvalue())
        assert record["level"] == "trace"

    def test_extra_fields(self, log, output):
        log.info("test", user_id="u1", order_id="abc", total=49.99)
        record = json.loads(output.getvalue())
        assert record["user_id"] == "u1"
        assert record["order_id"] == "abc"
        assert record["total"] == 49.99

    def test_logger_name(self, log, output):
        log.info("test")
        record = json.loads(output.getvalue())
        assert record["logger"] == "test"

    def test_timestamp_iso(self, log, output):
        log.info("test")
        record = json.loads(output.getvalue())
        # Should be valid ISO 8601 ending in Z
        assert record["ts"].endswith("Z")
        datetime.fromisoformat(record["ts"].replace("Z", "+00:00"))

    def test_auto_context(self, log, output):
        log.info("test")
        record = json.loads(output.getvalue())
        assert "func" in record
        assert "file" in record
        assert "line" in record

    def test_no_output_below_min_level(self, output):
        log = get_logger("min_test", min_level="warning", output=output)
        log.debug("hidden")
        log.info("also hidden")
        log.warning("visible")
        lines = output.getvalue().strip().split("\n")
        assert len(lines) == 1
        assert json.loads(lines[0])["level"] == "warning"


# ─── Formatter ───────────────────────────────────────────────────

class TestFormatter:
    def test_json_default(self, log, output):
        log.info("test")
        record = json.loads(output.getvalue())
        assert record["msg"] == "test"

    def test_json_sorted_keys(self, output):
        log = get_logger("sorted", output=output, formatter=JsonFormatter(sort_keys=True))
        log.info("test", z=3, a=1)
        line = output.getvalue().strip()
        # Keys should be sorted in JSON
        idx_a = line.index('"a"')
        idx_z = line.index('"z"')
        assert idx_a < idx_z

    def test_json_include(self, output):
        log = get_logger("inc", output=output, formatter=JsonFormatter(include=["msg", "level"]))
        log.info("test", user_id="u1", secret="hidden")
        record = json.loads(output.getvalue())
        assert "msg" in record
        assert "level" in record
        assert "user_id" not in record
        assert "secret" not in record

    def test_json_exclude(self, output):
        log = get_logger("exc", output=output, formatter=JsonFormatter(exclude=["file", "line"]))
        log.info("test", secret="hidden")
        record = json.loads(output.getvalue())
        assert "file" not in record
        assert "line" not in record

    def test_human_formatter(self, output):
        log = get_logger("human", output=output, formatter=HumanFormatter())
        log.info("Hello world", user_id="u1", count=42)
        line = output.getvalue().strip()
        assert "Hello world" in line
        assert "user_id=u1" in line
        assert "count=42" in line

    def test_human_formatter_color(self, output):
        log = get_logger("color", output=output, formatter=HumanFormatter(color=True))
        log.info("test")
        line = output.getvalue().strip()
        assert "\033[" in line  # ANSI escape code

    def test_human_formatter_trace(self, output):
        log = get_logger("htrace", output=output, formatter=HumanFormatter())
        log.trace("testing", val=1)
        line = output.getvalue().strip()
        assert "[TRACE]" in line

    def test_json_serialization_exception(self, output):
        log = get_logger("ser", output=output)
        log.info("test", error=ValueError("bad"))
        record = json.loads(output.getvalue())
        assert record["error"]["error"] == "ValueError"
        assert record["error"]["message"] == "bad"

    def test_json_serialization_datetime(self, output):
        log = get_logger("dt", output=output)
        dt = datetime(2026, 3, 24, tzinfo=timezone.utc)
        log.info("test", created=dt)
        record = json.loads(output.getvalue())
        assert "2026-03-24" in record["created"]

    def test_json_serialization_custom_object(self, output):
        log = get_logger("custom", output=output)
        class Thing:
            def __str__(self):
                return "thingy"
        log.info("test", obj=Thing())
        record = json.loads(output.getvalue())
        assert record["obj"] == "thingy"


# ─── Function Tracing ────────────────────────────────────────────

class TestTrace:
    def test_basic_trace(self, log, output):
        @log.trace_fn
        def add(a, b):
            return a + b
        result = add(1, 2)
        assert result == 3
        lines = [json.loads(l) for l in output.getvalue().strip().split("\n")]
        assert len(lines) == 2
        assert "enter: add" in lines[0]["msg"]
        assert "exit: add" in lines[1]["msg"]
        assert lines[1]["result"] == 3
        assert "elapsed_ms" in lines[1]

    def test_trace_with_error(self, log, output):
        @log.trace_fn
        def fail():
            raise ValueError("boom")
        with pytest.raises(ValueError):
            fail()
        lines = [json.loads(l) for l in output.getvalue().strip().split("\n")]
        assert len(lines) == 2
        assert "error: fail" in lines[1]["msg"]
        assert lines[1]["error"] == "ValueError"

    def test_trace_no_args(self, log, output):
        @log.trace_fn(log_args=False)
        def secret(x, y):
            return x + y
        secret(1, 2)
        lines = [json.loads(l) for l in output.getvalue().strip().split("\n")]
        assert "args" not in lines[0]

    def test_trace_no_result(self, log, output):
        @log.trace_fn(log_result=False)
        def compute(x):
            return x * 2
        compute(5)
        lines = [json.loads(l) for l in output.getvalue().strip().split("\n")]
        assert "result" not in lines[1]

    def test_trace_elapsed_positive(self, log, output):
        @log.trace_fn
        def slow():
            time.sleep(0.01)
        slow()
        lines = [json.loads(l) for l in output.getvalue().strip().split("\n")]
        assert lines[1]["elapsed_ms"] >= 5.0  # at least 5ms

    def test_trace_method_skips_self(self, log, output):
        class Service:
            def __init__(self):
                self.log = log
            @log.trace_fn
            def process(self, item):
                return item.upper()
        svc = Service()
        svc.process("hello")
        lines = [json.loads(l) for l in output.getvalue().strip().split("\n")]
        assert lines[0].get("args", {}).get("item") == "hello"
        # 'self' should not be in args
        assert "self" not in lines[0].get("args", {})

    def test_trace_preserves_name(self, log):
        @log.trace_fn
        def my_function():
            pass
        assert my_function.__name__ == "my_function"

    def test_trace_no_error(self, log, output):
        @log.trace_fn(log_error=False)
        def fail():
            raise RuntimeError("oops")
        with pytest.raises(RuntimeError):
            fail()
        lines = [json.loads(l) for l in output.getvalue().strip().split("\n")]
        assert "error" not in lines[1]


# ─── Context ─────────────────────────────────────────────────────

class TestContext:
    def test_context_manager(self, log, output):
        with context(request_id="r1", user_id="u42"):
            log.info("inside")
        lines = [json.loads(l) for l in output.getvalue().strip().split("\n")]
        assert lines[0]["request_id"] == "r1"
        assert lines[0]["user_id"] == "u42"

    def test_context_is_removed(self, log, output):
        with context(request_id="r1"):
            log.info("inside")
        log.info("outside")
        lines = [json.loads(l) for l in output.getvalue().strip().split("\n")]
        assert "request_id" in lines[0]
        assert "request_id" not in lines[1]

    def test_context_nesting(self, log, output):
        with context(request_id="r1"):
            log.info("outer")
            with context(user_id="u1"):
                log.info("inner")
            log.info("after_inner")
        lines = [json.loads(l) for l in output.getvalue().strip().split("\n")]
        assert lines[0]["request_id"] == "r1"
        assert "user_id" not in lines[0]
        assert lines[1]["request_id"] == "r1"
        assert lines[1]["user_id"] == "u1"
        assert lines[2]["request_id"] == "r1"
        assert "user_id" not in lines[2]

    def test_logger_context_method(self, log, output):
        with log.context(request_id="r1"):
            log.info("inside")
        lines = [json.loads(l) for l in output.getvalue().strip().split("\n")]
        assert lines[0]["request_id"] == "r1"

    def test_with_context(self, log, output):
        scoped = log.with_context(service="api")
        scoped.info("hello")
        record = json.loads(output.getvalue())
        assert record["service"] == "api"
        assert record["msg"] == "hello"

    def test_with_context_chain(self, log, output):
        scoped = log.with_context(service="api").with_context(version="v2")
        scoped.info("hello")
        record = json.loads(output.getvalue())
        assert record["service"] == "api"
        assert record["version"] == "v2"

    def test_context_thread_isolation(self, output):
        log = get_logger("thread_ctx", output=output)
        results = {}
        def worker(name, val):
            with context(worker=name):
                log.info("working", val=val)
                time.sleep(0.01)
                log.info("done", val=val)

        t1 = threading.Thread(target=worker, args=("a", 1))
        t2 = threading.Thread(target=worker, args=("b", 2))
        t1.start()
        t2.start()
        t1.join()
        t2.join()

        lines = [json.loads(l) for l in output.getvalue().strip().split("\n")]
        for line in lines:
            if line["val"] == 1:
                assert line["worker"] == "a"
            else:
                assert line["worker"] == "b"


# ─── Extra Fields ────────────────────────────────────────────────

class TestExtra:
    def test_static_extra(self, output):
        log = get_logger("extra", output=output, extra={"env": "prod", "version": "1.0"})
        log.info("test")
        record = json.loads(output.getvalue())
        assert record["env"] == "prod"
        assert record["version"] == "1.0"

    def test_extra_overridden_by_call(self, output):
        log = get_logger("override", output=output, extra={"env": "prod"})
        log.info("test", env="dev")
        record = json.loads(output.getvalue())
        assert record["env"] == "dev"


# ─── Custom Output ───────────────────────────────────────────────

class TestOutput:
    def test_callable_output(self):
        collected = []
        log = get_logger("custom", output=collected.append)
        log.info("test", x=1)
        assert len(collected) == 1
        record = json.loads(collected[0])
        assert record["x"] == 1

    def test_string_io_output(self):
        buf = io.StringIO()
        log = get_logger("sio", output=buf)
        log.info("test")
        record = json.loads(buf.getvalue())
        assert record["msg"] == "test"


# ─── Stdlib Handler ──────────────────────────────────────────────

class TestSnaplogHandler:
    def test_basic_bridge(self, output):
        handler = SnaplogHandler("bridge")
        handler._log.output = output
        logger = logging.getLogger("test_bridge")
        logger.addHandler(handler)
        logger.setLevel(logging.INFO)
        logger.info("stdlib message", extra={"user_id": "u1"})
        record = json.loads(output.getvalue())
        assert record["msg"] == "stdlib message"
        assert record["logger"] == "test_bridge"

    def test_error_level_bridge(self, output):
        handler = SnaplogHandler("bridge2")
        handler._log.output = output
        logger = logging.getLogger("test_bridge2")
        logger.addHandler(handler)
        logger.error("something failed")
        record = json.loads(output.getvalue())
        assert record["level"] == "error"

    def test_warning_level_bridge(self, output):
        handler = SnaplogHandler("bridge3")
        handler._log.output = output
        logger = logging.getLogger("test_bridge3")
        logger.addHandler(handler)
        logger.warning("careful now")
        record = json.loads(output.getvalue())
        assert record["level"] == "warning"


# ─── Edge Cases ──────────────────────────────────────────────────

class TestEdgeCases:
    def test_unicode_message(self, log, output):
        log.info("Hello 🌍", emoji="🚀")
        record = json.loads(output.getvalue())
        assert record["msg"] == "Hello 🌍"
        assert record["emoji"] == "🚀"

    def test_empty_message(self, log, output):
        log.info("")
        record = json.loads(output.getvalue())
        assert record["msg"] == ""

    def test_none_values(self, log, output):
        log.info("test", val=None)
        record = json.loads(output.getvalue())
        assert record["val"] is None

    def test_bool_values(self, log, output):
        log.info("test", active=True, deleted=False)
        record = json.loads(output.getvalue())
        assert record["active"] is True
        assert record["deleted"] is False

    def test_list_values(self, log, output):
        log.info("test", tags=["a", "b", "c"])
        record = json.loads(output.getvalue())
        assert record["tags"] == ["a", "b", "c"]

    def test_dict_values(self, log, output):
        log.info("test", meta={"key": "value"})
        record = json.loads(output.getvalue())
        assert record["meta"] == {"key": "value"}

    def test_large_payload(self, log, output):
        big_data = {f"key_{i}": i for i in range(100)}
        log.info("big", data=big_data)
        record = json.loads(output.getvalue())
        assert len(record["data"]) == 100

    def test_special_characters_in_keys(self, log, output):
        log.info("test", **{"key.with.dots": 1, "key-with-dashes": 2})
        record = json.loads(output.getvalue())
        assert record["key.with.dots"] == 1
        assert record["key-with-dashes"] == 2

    def test_many_loggers(self, output):
        l1 = get_logger("l1", output=output)
        l2 = get_logger("l2", output=output)
        l1.info("from l1")
        l2.info("from l2")
        lines = output.getvalue().strip().split("\n")
        assert len(lines) == 2
        r1 = json.loads(lines[0])
        r2 = json.loads(lines[1])
        assert r1["logger"] == "l1"
        assert r2["logger"] == "l2"

    def test_concurrent_logging(self, output):
        log = get_logger("concurrent", output=output)
        def worker():
            for _ in range(50):
                log.info("msg")

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

        lines = output.getvalue().strip().split("\n")
        assert len(lines) == 500
        for line in lines:
            record = json.loads(line)
            assert record["msg"] == "msg"

    def test_invalid_min_level(self):
        with pytest.raises(ValueError):
            get_logger("bad", min_level="nonexistent")

    def test_nested_list_serialization(self, log, output):
        log.info("test", data=[[1, 2], [3, 4]])
        record = json.loads(output.getvalue())
        assert record["data"] == [[1, 2], [3, 4]]

    def test_nested_dict_serialization(self, log, output):
        log.info("test", data={"outer": {"inner": "value"}})
        record = json.loads(output.getvalue())
        assert record["data"]["outer"]["inner"] == "value"

    def test_integer_types(self, log, output):
        log.info("test", small=1, big=9999999999, neg=-42)
        record = json.loads(output.getvalue())
        assert record["small"] == 1
        assert record["big"] == 9999999999
        assert record["neg"] == -42

    def test_float_types(self, log, output):
        log.info("test", pi=3.14159, neg=-0.5, zero=0.0)
        record = json.loads(output.getvalue())
        assert record["pi"] == 3.14159
        assert record["neg"] == -0.5
        assert record["zero"] == 0.0
