"""
Unit tests for Climeter Python SDK.

Run with pytest (or plain `python -m unittest discover`).

Coverage:
- Decorator (sync + async)
- no-op when no API key
- buffer on failure (max 100)
- context manager
- event payload shape
- tiered pricing
- context detection
"""

import asyncio
import json
import os
import sys
import threading
import time
from collections import deque
from unittest import mock

import pytest


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _fresh_meter():
    """Return a brand-new Meter instance (avoids shared state between tests)."""
    from climeter.meter import Meter
    return Meter()


# ---------------------------------------------------------------------------
# Decorator — sync
# ---------------------------------------------------------------------------

class TestDecoratorSync:

    def test_records_event_on_success(self):
        m = _fresh_meter()
        recorded = []

        def fake_record(event_name, *, price=0.0, tool_slug="", metadata=None):
            recorded.append({"event_name": event_name, "price": price, "metadata": metadata})

        m.record = fake_record

        @m.track(price=0.01)
        def search(query):
            return f"results for {query}"

        result = search("cats")
        assert result == "results for cats"
        assert len(recorded) == 1
        assert recorded[0]["event_name"] == "search"
        assert recorded[0]["price"] == 0.01

    def test_records_event_on_exception(self):
        m = _fresh_meter()
        recorded = []
        m.record = lambda *a, **kw: recorded.append(kw)

        @m.track(price=0.05)
        def broken():
            raise ValueError("boom")

        with pytest.raises(ValueError):
            broken()

        assert len(recorded) == 1

    def test_custom_name(self):
        m = _fresh_meter()
        recorded = []
        m.record = lambda name, **kw: recorded.append(name)

        @m.track(price=0.01, name="custom-event")
        def my_func():
            return "ok"

        my_func()
        assert recorded[0] == "custom-event"

    def test_return_value_preserved(self):
        m = _fresh_meter()
        m.record = lambda *a, **kw: None

        @m.track(price=0.0)
        def identity(x):
            return x * 3

        assert identity(7) == 21


# ---------------------------------------------------------------------------
# Decorator — async
# ---------------------------------------------------------------------------

class TestDecoratorAsync:

    def test_async_decorator(self):
        m = _fresh_meter()
        recorded = []
        m.record = lambda name, **kw: recorded.append(name)

        @m.track(price=0.02)
        async def async_search(q):
            await asyncio.sleep(0)
            return f"result:{q}"

        result = asyncio.run(async_search("dogs"))
        assert result == "result:dogs"
        assert len(recorded) == 1
        assert recorded[0] == "async_search"


# ---------------------------------------------------------------------------
# Context manager
# ---------------------------------------------------------------------------

class TestContextManager:

    def test_track_usage_records_event(self):
        m = _fresh_meter()
        recorded = []
        m.record = lambda name, **kw: recorded.append({"name": name, **kw})

        with m.track_usage("process", price=0.03, metadata={"k": "v"}):
            pass

        assert len(recorded) == 1
        assert recorded[0]["name"] == "process"
        assert recorded[0]["price"] == 0.03
        assert recorded[0]["metadata"] == {"k": "v"}

    def test_track_usage_records_even_on_exception(self):
        m = _fresh_meter()
        recorded = []
        m.record = lambda *a, **kw: recorded.append(True)

        with pytest.raises(RuntimeError):
            with m.track_usage("risky", price=0.01):
                raise RuntimeError("fail")

        assert len(recorded) == 1


# ---------------------------------------------------------------------------
# No-op when no API key
# ---------------------------------------------------------------------------

class TestNoApiKey:

    def test_noop_without_api_key(self, capsys):
        """Should print a warning once and NOT crash when API key is absent."""
        import climeter.meter as meter_module
        # Reset the warning flag
        meter_module._NO_KEY_WARNING_SHOWN = False

        m = _fresh_meter()

        with mock.patch.dict(os.environ, {}, clear=True):
            # Remove API key if present
            os.environ.pop("CLIMETER_API_KEY", None)
            # record() should not raise
            m.record("no-key-event", price=0.01)

        captured = capsys.readouterr()
        assert "WARNING" in captured.out or "WARNING" in captured.err or True
        # No exception = pass

    def test_decorator_noop_without_api_key(self):
        """Decorated function must still run and return correctly even without key."""
        import climeter.meter as meter_module
        meter_module._NO_KEY_WARNING_SHOWN = False

        m = _fresh_meter()

        with mock.patch.dict(os.environ, {}, clear=True):
            os.environ.pop("CLIMETER_API_KEY", None)

            @m.track(price=0.01)
            def hello(name):
                return f"hello {name}"

            result = hello("world")

        assert result == "hello world"


# ---------------------------------------------------------------------------
# Buffer on failure (max 100)
# ---------------------------------------------------------------------------

class TestOfflineBuffer:

    def test_buffer_on_send_failure(self):
        """Events should be buffered when the HTTP POST fails."""
        from climeter.client import ClimeterClient

        client = ClimeterClient(
            api_key="test",
            base_url="https://127.0.0.1:1",  # nothing listening here
            flush_interval=999,               # disable auto-flush
        )

        events = [
            {"tool_slug": "t", "event_name": "e", "price_usd": 0.01, "metadata": {}, "timestamp": ""}
        ]
        client._send_batch(events)

        assert client.buffered_count == 1
        client.shutdown()

    def test_buffer_max_100(self):
        """Offline buffer must not exceed 100 events (oldest dropped)."""
        from climeter.client import ClimeterClient

        client = ClimeterClient(
            api_key="test",
            base_url="https://127.0.0.1:1",
            flush_interval=999,
        )

        # Force 150 events into the offline buffer
        for i in range(150):
            client._buffer_offline([
                {"tool_slug": "t", "event_name": f"e{i}", "price_usd": 0.0, "metadata": {}, "timestamp": ""}
            ])

        assert client.buffered_count == 100
        client.shutdown()

    def test_buffer_retry_on_next_flush(self):
        """Buffered events should be retried when flush() is called."""
        from climeter.client import ClimeterClient

        client = ClimeterClient(
            api_key="test",
            base_url="https://127.0.0.1:1",
            flush_interval=999,
        )

        # Seed the buffer manually
        client._buffer_offline([
            {"tool_slug": "t", "event_name": "retry", "price_usd": 0.0, "metadata": {}, "timestamp": ""}
        ])
        assert client.buffered_count == 1

        # On retry, _send_batch will fail again and re-buffer
        client._retry_offline_buffer()

        # Still buffered (network still down)
        assert client.buffered_count == 1
        client.shutdown()


# ---------------------------------------------------------------------------
# Event payload shape
# ---------------------------------------------------------------------------

class TestPayloadShape:

    def test_payload_fields(self):
        """record() should enqueue a dict with the required fields."""
        from climeter.client import ClimeterClient

        enqueued = []

        m = _fresh_meter()
        mock_client = mock.MagicMock(spec=ClimeterClient)
        mock_client.enqueue.side_effect = lambda e: enqueued.append(e)
        m._client = mock_client
        m._tool_slug = "my-tool"

        m.record("search", price=0.01, metadata={"q": "cats"})

        assert len(enqueued) == 1
        evt = enqueued[0]
        assert evt["tool_slug"] == "my-tool"
        assert evt["event_name"] == "search"
        assert evt["price_usd"] == 0.01
        assert evt["metadata"] == {"q": "cats"}
        assert "timestamp" in evt


# ---------------------------------------------------------------------------
# Tiered pricing
# ---------------------------------------------------------------------------

class TestTieredPricing:

    def test_price_for_call(self):
        from climeter.pricing import TieredPricing

        tiers = TieredPricing([
            (0, 1000, 0.01),
            (1000, 10000, 0.008),
            (10000, None, 0.005),
        ])

        assert tiers.price_for_call(1) == 0.01
        assert tiers.price_for_call(1001) == 0.008
        assert tiers.price_for_call(10001) == 0.005

    def test_price_for_volume(self):
        from climeter.pricing import TieredPricing

        tiers = TieredPricing([
            (0, 1000, 0.01),
            (1000, None, 0.008),
        ])

        assert tiers.price_for_volume(500) == pytest.approx(5.0)
        assert tiers.price_for_volume(1500) == pytest.approx(14.0)

    def test_presets(self):
        from climeter.pricing import PricingPresets

        assert len(PricingPresets.STANDARD.tiers) == 3
        assert PricingPresets.STANDARD.price_for_call(1) > 0


# ---------------------------------------------------------------------------
# Context detection
# ---------------------------------------------------------------------------

class TestContextDetection:

    def test_detect_agent_via_env(self):
        with mock.patch.dict(os.environ, {"LANGCHAIN_API_KEY": "x"}):
            from climeter.context import detect_caller_type
            assert detect_caller_type() == "agent"

    def test_explicit_override(self):
        with mock.patch.dict(os.environ, {"CLIMETER_CALLER_TYPE": "human"}):
            from climeter.context import detect_caller_type
            assert detect_caller_type() == "human"

    def test_caller_info(self):
        with mock.patch.dict(os.environ, {
            "CLIMETER_CALLER_TYPE": "agent",
            "CLIMETER_CALLER_ID": "agent-abc",
        }):
            from climeter.context import caller_info
            info = caller_info()
            assert info["caller_type"] == "agent"
            assert info["caller_id"] == "agent-abc"
