from __future__ import annotations

import httpx
import pytest
import respx

from rungate import AsyncRungate, Run, Rungate
from rungate.types.budget import BudgetStatus
from tests.conftest import MOCK_BASE_URL, make_openai_response, make_rungate_headers


class TestRunCreation:
    def test_run_has_generated_uuid(self, client: Rungate):
        run = client.run(name="test")
        assert run.id
        assert len(run.id) == 36  # UUID format

    def test_run_has_name_and_metadata(self, client: Rungate):
        run = client.run(name="research", metadata={"customer": "abc"})
        assert run.name == "research"
        assert run.metadata == {"customer": "abc"}

    def test_runs_create_returns_run(self, client: Rungate):
        run = client.runs.create(name="test")
        assert isinstance(run, Run)
        assert run.id

    def test_run_has_chat_resource(self, client: Rungate):
        run = client.run(name="test")
        assert hasattr(run, "chat")
        assert hasattr(run.chat, "completions")

    def test_runs_resume_returns_run_with_existing_id(self, client: Rungate):
        run = client.runs.resume("existing-run-id-123")
        assert run.id == "existing-run-id-123"
        assert isinstance(run, Run)
        assert run.budget.total_cost == 0.0  # fresh session budget

    def test_run_has_budget(self, client: Rungate):
        run = client.run(name="test")
        assert isinstance(run.budget, BudgetStatus)
        assert run.budget.total_cost == 0.0
        assert run.budget.at_limit is False


class TestRunContextManager:
    def test_auto_completes_on_clean_exit(self, client: Rungate, mock_router: respx.Router):
        complete_route = mock_router.post(url__regex=r".*/complete$").mock(
            return_value=httpx.Response(200, json={"data": {"id": "run-1", "status": "completed"}})
        )

        with client.run(name="test") as _run:
            pass

        assert len(complete_route.calls) == 1
        request = complete_route.calls[0].request
        import json

        body = json.loads(request.read())
        assert body["ended_reason"] == "agent_signaled"

    def test_auto_fails_on_exception(self, client: Rungate, mock_router: respx.Router):
        complete_route = mock_router.post(url__regex=r".*/complete$").mock(
            return_value=httpx.Response(200, json={"data": {"id": "run-1", "status": "completed"}})
        )

        with pytest.raises(ValueError):
            with client.run(name="test") as _run:
                raise ValueError("something broke")

        assert len(complete_route.calls) == 1
        import json

        body = json.loads(complete_route.calls[0].request.read())
        assert "exception: ValueError" in body["ended_reason"]

    def test_auto_fail_swallows_server_error(self, client: Rungate, mock_router: respx.Router):
        """If server is unreachable during auto-fail, original exception propagates."""
        mock_router.post(url__regex=r".*/complete$").mock(side_effect=httpx.ConnectError("server down"))

        with pytest.raises(ValueError, match="original error"):
            with client.run(name="test") as _run:
                raise ValueError("original error")

    def test_sends_metadata_on_complete(self, client: Rungate, mock_router: respx.Router):
        complete_route = mock_router.post(url__regex=r".*/complete$").mock(
            return_value=httpx.Response(200, json={"data": {"id": "run-1", "status": "completed"}})
        )

        with client.run(name="test", metadata={"key": "value"}) as _run:
            pass

        import json

        body = json.loads(complete_route.calls[0].request.read())
        assert body["metadata"] == {"key": "value"}


class TestRunComplete:
    def test_explicit_complete(self, client: Rungate, mock_router: respx.Router):
        mock_router.post(url__regex=r".*/complete$").mock(
            return_value=httpx.Response(200, json={"data": {"id": "run-1", "status": "completed"}})
        )

        run = client.run(name="test")
        result = run.complete()
        assert result["status"] == "completed"
        assert run._completed is True

    def test_double_complete_is_noop(self, client: Rungate, mock_router: respx.Router):
        route = mock_router.post(url__regex=r".*/complete$").mock(
            return_value=httpx.Response(200, json={"data": {"id": "run-1", "status": "completed"}})
        )

        run = client.run(name="test")
        run.complete()
        run.complete()  # second call
        assert len(route.calls) == 1  # only one HTTP call

    def test_handles_409_gracefully(self, client: Rungate, mock_router: respx.Router):
        mock_router.post(url__regex=r".*/complete$").mock(
            return_value=httpx.Response(409, json={"error": {"type": "conflict", "message": "Already completed"}})
        )

        run = client.run(name="test")
        result = run.complete()
        assert result == {}
        assert run._completed is True

    def test_fail_calls_complete_with_reason(self, client: Rungate, mock_router: respx.Router):
        route = mock_router.post(url__regex=r".*/complete$").mock(
            return_value=httpx.Response(200, json={"data": {"id": "run-1", "status": "completed"}})
        )

        run = client.run(name="test")
        run.fail(reason="timeout")

        import json

        body = json.loads(route.calls[0].request.read())
        assert body["ended_reason"] == "timeout"


class TestBudgetTracking:
    def test_budget_updated_from_response(self, client: Rungate, mock_router: respx.Router):
        mock_router.post(f"{MOCK_BASE_URL}/v1/chat/completions").mock(
            return_value=httpx.Response(
                200,
                json=make_openai_response(),
                headers=make_rungate_headers(
                    cost_usd="0.005000",
                    tokens_used="100",
                    budget_remaining_usd="9.995000",
                    budget_remaining_tokens="9900",
                ),
            )
        )

        run = client.run(name="test")
        run.chat.completions.create(model="gpt-4o", messages=[{"role": "user", "content": "Hi"}])

        assert run.budget.total_cost == 0.005
        assert run.budget.total_tokens == 100
        assert run.budget.remaining_usd == 9.995
        assert run.budget.remaining_tokens == 9900
        assert run.budget.at_limit is False

    def test_budget_accumulates_across_calls(self, client: Rungate, mock_router: respx.Router):
        mock_router.post(f"{MOCK_BASE_URL}/v1/chat/completions").mock(
            return_value=httpx.Response(
                200,
                json=make_openai_response(),
                headers=make_rungate_headers(cost_usd="0.001000", tokens_used="50"),
            )
        )

        run = client.run(name="test")
        run.chat.completions.create(model="gpt-4o", messages=[{"role": "user", "content": "Hi"}])
        run.chat.completions.create(model="gpt-4o", messages=[{"role": "user", "content": "Hi again"}])

        assert run.budget.total_cost == 0.002
        assert run.budget.total_tokens == 100

    def test_budget_at_limit_when_remaining_zero(self, client: Rungate, mock_router: respx.Router):
        mock_router.post(f"{MOCK_BASE_URL}/v1/chat/completions").mock(
            return_value=httpx.Response(
                200,
                json=make_openai_response(),
                headers=make_rungate_headers(budget_remaining_usd="0.000000"),
            )
        )

        run = client.run(name="test")
        run.chat.completions.create(model="gpt-4o", messages=[{"role": "user", "content": "Hi"}])

        assert run.budget.at_limit is True


class TestToolCallLogging:
    def test_log_tool_call(self, client: Rungate, mock_router: respx.Router):
        route = mock_router.post(url__regex=r".*/steps$").mock(
            return_value=httpx.Response(201, json={"data": {"id": "step-1", "type": "tool_call"}})
        )

        run = client.run(name="test")
        result = run.log_tool_call(
            name="web_search",
            request={"query": "rungate"},
            response={"results": [{"title": "Rungate"}]},
            latency_ms=145,
        )

        assert result["type"] == "tool_call"
        import json

        body = json.loads(route.calls[0].request.read())
        assert body["type"] == "tool_call"
        assert body["name"] == "web_search"
        assert body["provider"] == "sdk"
        assert body["request"]["query"] == "rungate"
        assert "model" not in body  # server maps name → model

    def test_log_tool_call_with_error(self, client: Rungate, mock_router: respx.Router):
        mock_router.post(url__regex=r".*/steps$").mock(
            return_value=httpx.Response(201, json={"data": {"id": "step-1", "type": "tool_call", "status": "error"}})
        )

        run = client.run(name="test")
        result = run.log_tool_call(
            name="api_call",
            status="error",
            error_message="Connection refused",
            latency_ms=5000,
        )

        assert result["status"] == "error"

    def test_log_tool_call_with_cost(self, client: Rungate, mock_router: respx.Router):
        route = mock_router.post(url__regex=r".*/steps$").mock(
            return_value=httpx.Response(201, json={"data": {"id": "step-1", "type": "tool_call"}})
        )

        run = client.run(name="test")
        run.log_tool_call(name="paid_api", cost_usd=0.01, provider="stripe")

        import json

        body = json.loads(route.calls[0].request.read())
        assert body["cost_usd"] == 0.01
        assert body["provider"] == "stripe"

    def test_log_tool_call_on_nonexistent_run_raises_404(self, client: Rungate, mock_router: respx.Router):
        """log_tool_call before any LLM call → run doesn't exist → 404."""
        from rungate._exceptions import NotFoundError

        mock_router.post(url__regex=r".*/steps$").mock(
            return_value=httpx.Response(404, json={"error": {"type": "not_found", "message": "Run not found"}})
        )

        run = client.run(name="test")
        with pytest.raises(NotFoundError):
            run.log_tool_call(name="web_search")

    def test_log_tool_call_with_tokens(self, client: Rungate, mock_router: respx.Router):
        route = mock_router.post(url__regex=r".*/steps$").mock(
            return_value=httpx.Response(201, json={"data": {"id": "step-1", "type": "tool_call"}})
        )

        run = client.run(name="test")
        run.log_tool_call(name="llm_tool", tokens={"input": 100, "output": 50})

        import json

        body = json.loads(route.calls[0].request.read())
        assert body["tokens"] == {"input": 100, "output": 50}

    def test_log_tool_call_default_tokens(self, client: Rungate, mock_router: respx.Router):
        route = mock_router.post(url__regex=r".*/steps$").mock(
            return_value=httpx.Response(201, json={"data": {"id": "step-1", "type": "tool_call"}})
        )

        run = client.run(name="test")
        run.log_tool_call(name="simple_tool")

        import json

        body = json.loads(route.calls[0].request.read())
        assert body["tokens"] == {"input": 0, "output": 0}


class TestRunScopedChat:
    def test_auto_injects_run_id(self, client: Rungate, mock_router: respx.Router):
        route = mock_router.post(f"{MOCK_BASE_URL}/v1/chat/completions").mock(
            return_value=httpx.Response(200, json=make_openai_response(), headers=make_rungate_headers())
        )

        run = client.run(name="test")
        run.chat.completions.create(model="gpt-4o", messages=[{"role": "user", "content": "Hi"}])

        request = route.calls[0].request
        assert request.headers.get("x-rungate-run-id") == run.id

    def test_streaming_create_does_not_update_budget(self, client: Rungate, mock_router: respx.Router):
        """stream=True through create() should NOT update budget (use stream() instead)."""

        from tests.test_streaming import _chunk_event, _done_event, _rungate_event, _streaming_response

        mock_router.post(f"{MOCK_BASE_URL}/v1/chat/completions").mock(
            return_value=_streaming_response(
                _chunk_event("Hi"),
                _done_event(),
                _rungate_event(cost_usd=0.005),
            )
        )

        run = client.run(name="test")
        result = run.chat.completions.create(model="gpt-4o", messages=[{"role": "user", "content": "Hi"}], stream=True)

        # Budget should NOT be updated yet (stream not consumed)
        assert run.budget.total_cost == 0.0

        # Consume the stream
        with result:
            list(result)

        # Budget still not updated — create() doesn't wrap with budget update
        # Users should use stream() for proper budget tracking
        assert run.budget.total_cost == 0.0


class TestAsyncRun:
    @pytest.mark.asyncio
    async def test_async_run_context_manager(self, async_client: AsyncRungate, mock_router: respx.Router):
        mock_router.post(url__regex=r".*/complete$").mock(
            return_value=httpx.Response(200, json={"data": {"id": "run-1", "status": "completed"}})
        )

        async with async_client.run(name="async-test") as run:
            assert run.id

    @pytest.mark.asyncio
    async def test_async_run_budget_update(self, async_client: AsyncRungate, mock_router: respx.Router):
        mock_router.post(f"{MOCK_BASE_URL}/v1/chat/completions").mock(
            return_value=httpx.Response(
                200,
                json=make_openai_response(),
                headers=make_rungate_headers(cost_usd="0.003000"),
            )
        )
        mock_router.post(url__regex=r".*/complete$").mock(
            return_value=httpx.Response(200, json={"data": {"status": "completed"}})
        )

        async with async_client.run(name="test") as run:
            await run.chat.completions.create(model="gpt-4o", messages=[{"role": "user", "content": "Hi"}])
            assert run.budget.total_cost == 0.003
