"""Tests for fw_server.tracer — deep execution tracing infrastructure.

Validates:
  - Tracer singleton lifecycle (init / get / shutdown)
  - @trace decorator (call_start/call_end events, arg/result capture)
  - record() intermediate value capture
  - Call-chain tracking via contextvars (parent_id / depth)
  - Disabled mode (zero-cost no-op when FW_TRACE_DIR unset)
  - JSONL output format and flush behaviour
  - Error handling (exceptions traced, then re-raised)
  - _safe_serialize truncation
"""

from __future__ import annotations

import json
import os
import tempfile
from pathlib import Path
from typing import List

import pytest

from fw_server.tracer import (
    Tracer,
    TraceConfig,
    _build_arg_dict,
    init_tracing,
    record,
    shutdown_tracing,
    trace,
)


# ── Helpers ───────────────────────────────────────────────────────────

def _read_events(trace_dir: Path) -> List[dict]:
    """Read all JSONL events from the first trace_*.jsonl in *trace_dir*."""
    files = sorted(trace_dir.glob("trace_*.jsonl"))
    assert files, "No trace file found"
    events = []
    with open(files[0], encoding="utf-8") as fh:
        for line in fh:
            line = line.strip()
            if line:
                events.append(json.loads(line))
    return events


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

@pytest.fixture()
def trace_dir(tmp_path: Path):
    """Provide a temporary trace directory and ensure cleanup."""
    d = tmp_path / "traces"
    d.mkdir()
    yield d
    # Always clean up the singleton even if a test fails
    Tracer.shutdown()


@pytest.fixture()
def tracer(trace_dir: Path):
    """Initialise and yield a Tracer, shut down after test."""
    config = TraceConfig(trace_dir=trace_dir, session_id="test_session")
    t = Tracer.initialize(config)
    yield t
    Tracer.shutdown()


# ── Singleton lifecycle ───────────────────────────────────────────────

class TestLifecycle:
    def test_get_returns_none_before_init(self):
        """Before initialisation Tracer.get() returns None."""
        Tracer.shutdown()  # ensure clean state
        assert Tracer.get() is None

    def test_initialize_and_get(self, tracer: Tracer):
        assert Tracer.get() is tracer

    def test_shutdown_clears_singleton(self, tracer: Tracer):
        Tracer.shutdown()
        assert Tracer.get() is None

    def test_double_init_replaces(self, trace_dir: Path):
        c1 = TraceConfig(trace_dir=trace_dir, session_id="s1")
        t1 = Tracer.initialize(c1)
        c2 = TraceConfig(trace_dir=trace_dir, session_id="s2")
        t2 = Tracer.initialize(c2)
        assert Tracer.get() is t2
        assert t1 is not t2
        Tracer.shutdown()

    def test_session_start_event(self, trace_dir: Path, tracer: Tracer):
        # Close to flush
        Tracer.shutdown()
        events = _read_events(trace_dir)
        starts = [e for e in events if e["event"] == "session_start"]
        assert len(starts) == 1
        assert starts[0]["session"] == "test_session"

    def test_session_end_event(self, trace_dir: Path, tracer: Tracer):
        Tracer.shutdown()
        events = _read_events(trace_dir)
        ends = [e for e in events if e["event"] == "session_end"]
        assert len(ends) == 1


# ── @trace decorator ─────────────────────────────────────────────────

class TestTraceDecorator:
    def test_basic_call_events(self, trace_dir: Path, tracer: Tracer):
        @trace
        def add(a: int, b: int) -> int:
            return a + b

        result = add(2, 3)
        assert result == 5

        Tracer.shutdown()
        events = _read_events(trace_dir)

        starts = [e for e in events if e["event"] == "call_start" and e["func"].endswith("add")]
        ends = [e for e in events if e["event"] == "call_end" and e["func"].endswith("add")]
        assert len(starts) == 1
        assert len(ends) == 1
        assert starts[0]["args"]["a"] == 2
        assert starts[0]["args"]["b"] == 3
        assert ends[0]["result_summary"] == 5
        assert ends[0]["duration_ms"] >= 0

    def test_preserves_function_metadata(self):
        @trace
        def documented(x: int) -> int:
            """My docstring."""
            return x * 2

        assert documented.__name__ == "documented"
        assert documented.__doc__ == "My docstring."

    def test_exception_traced_and_reraised(self, trace_dir: Path, tracer: Tracer):
        @trace
        def boom():
            raise ValueError("kaboom")

        with pytest.raises(ValueError, match="kaboom"):
            boom()

        Tracer.shutdown()
        events = _read_events(trace_dir)
        ends = [e for e in events if e["event"] == "call_end" and e["func"].endswith("boom")]
        assert len(ends) == 1
        assert "ValueError: kaboom" in ends[0]["error"]

    def test_nested_call_chain(self, trace_dir: Path, tracer: Tracer):
        @trace
        def outer():
            return inner()

        @trace
        def inner():
            return 42

        result = outer()
        assert result == 42

        Tracer.shutdown()
        events = _read_events(trace_dir)

        outer_start = [e for e in events if e["event"] == "call_start" and "outer" in e["func"]][0]
        inner_start = [e for e in events if e["event"] == "call_start" and "inner" in e["func"]][0]

        # Inner call should reference outer's call_id as parent
        assert inner_start["parent_id"] == outer_start["call_id"]
        assert inner_start["depth"] == 1
        assert outer_start["depth"] == 0


# ── record() intermediate values ─────────────────────────────────────

class TestRecord:
    def test_record_returns_value(self):
        """record() is an identity function when tracing is disabled."""
        Tracer.shutdown()
        assert record("x", 42) == 42
        assert record("name", "hello") == "hello"

    def test_record_emits_event(self, trace_dir: Path, tracer: Tracer):
        @trace
        def compute():
            x = record("step_a", 10)
            y = record("step_b", x + 5)
            return y

        result = compute()
        assert result == 15

        Tracer.shutdown()
        events = _read_events(trace_dir)
        intermediates = [e for e in events if e["event"] == "intermediate"]
        labels = {e["label"] for e in intermediates}
        assert "step_a" in labels
        assert "step_b" in labels

        step_a = [e for e in intermediates if e["label"] == "step_a"][0]
        assert step_a["value"] == 10

    def test_record_identity_when_disabled(self):
        Tracer.shutdown()
        obj = {"key": [1, 2, 3]}
        assert record("test", obj) is obj


# ── Disabled mode ─────────────────────────────────────────────────────

class TestDisabledMode:
    def test_trace_is_noop_when_disabled(self):
        Tracer.shutdown()

        @trace
        def noop_fn(x):
            return x * 2

        # Should work fine — no files created
        assert noop_fn(5) == 10

    def test_init_tracing_without_env(self):
        """init_tracing returns None when no dir is set."""
        old = os.environ.pop("FW_TRACE_DIR", None)
        try:
            result = init_tracing()
            assert result is None
            assert Tracer.get() is None
        finally:
            if old is not None:
                os.environ["FW_TRACE_DIR"] = old

    def test_init_tracing_with_dir(self, tmp_path: Path):
        d = tmp_path / "trace_init"
        t = init_tracing(str(d))
        assert t is not None
        assert Tracer.get() is t
        assert d.exists()
        Tracer.shutdown()


# ── JSONL format ──────────────────────────────────────────────────────

class TestJSONLFormat:
    def test_each_line_is_valid_json(self, trace_dir: Path, tracer: Tracer):
        @trace
        def dummy():
            return "ok"

        dummy()
        Tracer.shutdown()

        files = sorted(trace_dir.glob("trace_*.jsonl"))
        assert files
        with open(files[0], encoding="utf-8") as fh:
            for line in fh:
                line = line.strip()
                if line:
                    obj = json.loads(line)
                    assert "event" in obj
                    assert "ts" in obj

    def test_log_file_created_when_capture_logs(self, trace_dir: Path, tracer: Tracer):
        Tracer.shutdown()
        logs = sorted(trace_dir.glob("logs_*.log"))
        assert logs, "Log file should be created when capture_logs=True"


# ── _build_arg_dict helper ────────────────────────────────────────────

class TestBuildArgDict:
    def test_positional_and_keyword(self):
        def fn(a, b, c=10):
            pass

        result = _build_arg_dict(fn, (1, 2), {"c": 30})
        assert result == {"a": 1, "b": 2, "c": 30}

    def test_strips_self(self):
        class Dummy:
            def method(self, x):
                pass

        result = _build_arg_dict(Dummy.method, (Dummy(), 42), {})
        assert "self" not in result
        assert result["x"] == 42

    def test_fallback_on_error(self):
        # *args function — can't bind nicely
        def varargs(*args, **kwargs):
            pass

        result = _build_arg_dict(varargs, (1, 2), {"z": 3})
        # Should still return something useful
        assert isinstance(result, dict)


# ── Serialisation edge cases ──────────────────────────────────────────

class TestSerialisation:
    def test_large_string_truncated(self, trace_dir: Path, tracer: Tracer):
        @trace
        def big_return():
            return "x" * 20000

        big_return()
        Tracer.shutdown()
        events = _read_events(trace_dir)
        ends = [e for e in events if e["event"] == "call_end" and "big_return" in e["func"]]
        assert len(ends) == 1
        summary = ends[0]["result_summary"]
        assert "truncated" in str(summary)

    def test_none_result(self, trace_dir: Path, tracer: Tracer):
        @trace
        def void_fn():
            return None

        void_fn()
        Tracer.shutdown()
        events = _read_events(trace_dir)
        ends = [e for e in events if e["event"] == "call_end" and "void_fn" in e["func"]]
        assert ends[0]["result_summary"] is None

    def test_path_serialised_as_string(self, trace_dir: Path, tracer: Tracer):
        @trace
        def path_fn(p: Path):
            return p

        path_fn(Path("/tmp/test"))
        Tracer.shutdown()
        events = _read_events(trace_dir)
        starts = [e for e in events if e["event"] == "call_start" and "path_fn" in e["func"]]
        # Path should be converted to string
        assert isinstance(starts[0]["args"]["p"], str)
