"""Tests for the synchronous Baponi client."""

from __future__ import annotations

import os
import warnings
from unittest.mock import patch

import httpx
import pytest
import respx

from baponi import Baponi, BaponiError, SandboxResult
from baponi.exceptions import AuthenticationError, ServerError

FAKE_KEY = "sk-test-key-for-unit-tests"
EXECUTE_URL = "https://api.baponi.ai/v1/sandbox/execute"

MOCK_SUCCESS_RESPONSE = {
    "success": True,
    "stdout": "Hello!\n",
    "stderr": "",
    "exit_code": 0,
    "error": None,
}

MOCK_FAILURE_RESPONSE = {
    "success": False,
    "stdout": "",
    "stderr": "NameError: name 'x' is not defined\n",
    "exit_code": 1,
    "error": "execution failed",
}


class TestClientInit:
    def test_explicit_api_key(self):
        client = Baponi(api_key=FAKE_KEY)
        assert client._api_key == FAKE_KEY
        client.close()

    def test_env_var_api_key(self):
        with patch.dict(os.environ, {"BAPONI_API_KEY": FAKE_KEY}):
            client = Baponi()
            assert client._api_key == FAKE_KEY
            client.close()

    def test_missing_api_key_raises(self):
        with patch.dict(os.environ, {}, clear=True):
            # Ensure BAPONI_API_KEY is not set
            os.environ.pop("BAPONI_API_KEY", None)
            with pytest.raises(BaponiError, match="API key is required"):
                Baponi()

    def test_custom_base_url_strips_slash(self):
        client = Baponi(api_key=FAKE_KEY, base_url="https://custom.api.com/")
        assert client._base_url == "https://custom.api.com"
        client.close()

    def test_http_url_warns(self):
        with warnings.catch_warnings(record=True) as w:
            warnings.simplefilter("always")
            client = Baponi(api_key=FAKE_KEY, base_url="http://localhost:8000")
            assert len(w) == 1
            assert "HTTPS" in str(w[0].message)
            client.close()

    def test_context_manager(self):
        with Baponi(api_key=FAKE_KEY) as client:
            assert client._api_key == FAKE_KEY

    def test_default_client_id_header(self):
        client = Baponi(api_key=FAKE_KEY)
        assert client._headers["X-Client-Id"] == "baponi-python/direct"
        client.close()

    def test_custom_client_id_header(self):
        client = Baponi(api_key=FAKE_KEY, _client_id="baponi-python/langchain")
        assert client._headers["X-Client-Id"] == "baponi-python/langchain"
        client.close()

    def test_external_client_not_closed(self):
        external = httpx.Client()
        client = Baponi(api_key=FAKE_KEY, http_client=external)
        assert not client._owns_client
        client.close()  # Should not close external client
        # External client should still be usable
        assert not external.is_closed
        external.close()


class TestExecute:
    @respx.mock
    def test_successful_execution(self):
        respx.post(EXECUTE_URL).mock(
            return_value=httpx.Response(200, json=MOCK_SUCCESS_RESPONSE)
        )
        with Baponi(api_key=FAKE_KEY) as client:
            result = client.execute("print('Hello!')")

        assert isinstance(result, SandboxResult)
        assert result.success is True
        assert result.stdout == "Hello!\n"
        assert result.exit_code == 0

    @respx.mock
    def test_execution_failure_returns_result(self):
        """Non-zero exit code returns SandboxResult, does NOT raise."""
        respx.post(EXECUTE_URL).mock(
            return_value=httpx.Response(200, json=MOCK_FAILURE_RESPONSE)
        )
        with Baponi(api_key=FAKE_KEY) as client:
            result = client.execute("x")

        assert result.success is False
        assert result.exit_code == 1
        assert "NameError" in result.stderr

    @respx.mock
    def test_sends_correct_body(self):
        route = respx.post(EXECUTE_URL).mock(
            return_value=httpx.Response(200, json=MOCK_SUCCESS_RESPONSE)
        )
        with Baponi(api_key=FAKE_KEY) as client:
            client.execute(
                "print(1)",
                language="node",
                timeout=60,
                thread_id="my-thread",
                metadata={"user": "test"},
            )

        request = route.calls[0].request
        body = request.read()
        import json
        data = json.loads(body)
        assert data["code"] == "print(1)"
        assert data["language"] == "node"
        assert data["timeout"] == 60
        assert data["thread_id"] == "my-thread"
        assert data["metadata"] == {"user": "test"}

    @respx.mock
    def test_sends_env_vars_in_body(self):
        route = respx.post(EXECUTE_URL).mock(
            return_value=httpx.Response(200, json=MOCK_SUCCESS_RESPONSE)
        )
        with Baponi(api_key=FAKE_KEY) as client:
            client.execute(
                "print(1)",
                env_vars={"MY_VAR": "test", "OTHER": "val"},
            )

        import json
        data = json.loads(route.calls[0].request.read())
        assert data["env_vars"] == {"MY_VAR": "test", "OTHER": "val"}

    @respx.mock
    def test_omits_env_vars_when_none(self):
        route = respx.post(EXECUTE_URL).mock(
            return_value=httpx.Response(200, json=MOCK_SUCCESS_RESPONSE)
        )
        with Baponi(api_key=FAKE_KEY) as client:
            client.execute("print(1)")

        import json
        data = json.loads(route.calls[0].request.read())
        assert "env_vars" not in data

    @respx.mock
    def test_omits_optional_fields_when_none(self):
        route = respx.post(EXECUTE_URL).mock(
            return_value=httpx.Response(200, json=MOCK_SUCCESS_RESPONSE)
        )
        with Baponi(api_key=FAKE_KEY) as client:
            client.execute("print(1)")

        import json
        data = json.loads(route.calls[0].request.read())
        assert "thread_id" not in data
        assert "metadata" not in data

    @respx.mock
    def test_sends_auth_header(self):
        route = respx.post(EXECUTE_URL).mock(
            return_value=httpx.Response(200, json=MOCK_SUCCESS_RESPONSE)
        )
        with Baponi(api_key=FAKE_KEY) as client:
            client.execute("x")

        assert route.calls[0].request.headers["authorization"] == f"Bearer {FAKE_KEY}"

    @respx.mock
    def test_sends_user_agent(self):
        route = respx.post(EXECUTE_URL).mock(
            return_value=httpx.Response(200, json=MOCK_SUCCESS_RESPONSE)
        )
        with Baponi(api_key=FAKE_KEY) as client:
            client.execute("x")

        assert route.calls[0].request.headers["user-agent"].startswith("baponi-python/")

    @respx.mock
    def test_401_raises_authentication_error(self):
        respx.post(EXECUTE_URL).mock(
            return_value=httpx.Response(
                401, json={"error": "unauthorized", "message": "Invalid API key"}
            )
        )
        with Baponi(api_key=FAKE_KEY, max_retries=0) as client:
            with pytest.raises(AuthenticationError):
                client.execute("x")

    @respx.mock
    def test_validation_error_not_retried(self):
        """Client-side ValueError should not trigger retries."""
        with Baponi(api_key=FAKE_KEY) as client:
            with pytest.raises(ValueError, match="must not be empty"):
                client.execute("")


class TestResultToLlmText:
    def test_stdout_only(self):
        from baponi._base import result_to_llm_text

        result = SandboxResult(
            success=True, stdout="hello\n", stderr="", exit_code=0,
        )
        assert result_to_llm_text(result) == "hello\n"

    def test_stderr_included(self):
        from baponi._base import result_to_llm_text

        result = SandboxResult(
            success=False, stdout="", stderr="NameError: x\n", exit_code=1,
        )
        text = result_to_llm_text(result)
        assert "[stderr]" in text
        assert "NameError: x" in text
        assert "[exit code: 1]" in text

    def test_no_output(self):
        from baponi._base import result_to_llm_text

        result = SandboxResult(
            success=True, stdout="", stderr="", exit_code=0,
        )
        assert result_to_llm_text(result) == "(no output)"

    def test_exit_code_zero_omitted(self):
        from baponi._base import result_to_llm_text

        result = SandboxResult(
            success=True, stdout="ok\n", stderr="", exit_code=0,
        )
        assert "exit code" not in result_to_llm_text(result)

    def test_error_field_included(self):
        from baponi._base import result_to_llm_text

        result = SandboxResult(
            success=False, stdout="", stderr="", exit_code=1,
            error="execution failed",
        )
        text = result_to_llm_text(result)
        assert "[error]" in text
        assert "execution failed" in text

    def test_all_sections(self):
        from baponi._base import result_to_llm_text

        result = SandboxResult(
            success=False, stdout="partial\n", stderr="warn\n", exit_code=2,
            error="killed",
        )
        text = result_to_llm_text(result)
        assert "partial\n" in text
        assert "[stderr]\nwarn\n" in text
        assert "[exit code: 2]" in text
        assert "[error]\nkilled" in text


class TestRetry:
    @respx.mock
    def test_retries_on_503(self):
        route = respx.post(EXECUTE_URL).mock(
            side_effect=[
                httpx.Response(
                    503, json={"error": "service_unavailable", "message": "No capacity"}
                ),
                httpx.Response(200, json=MOCK_SUCCESS_RESPONSE),
            ]
        )
        with Baponi(api_key=FAKE_KEY, max_retries=1) as client:
            result = client.execute("x")

        assert result.success is True
        assert len(route.calls) == 2

    @respx.mock
    def test_no_retry_on_401(self):
        route = respx.post(EXECUTE_URL).mock(
            return_value=httpx.Response(
                401, json={"error": "unauthorized", "message": "Bad key"}
            )
        )
        with Baponi(api_key=FAKE_KEY, max_retries=2) as client:
            with pytest.raises(AuthenticationError):
                client.execute("x")

        assert len(route.calls) == 1

    @respx.mock
    def test_no_retry_on_504(self):
        """504 is not retried because execution may have already happened."""
        route = respx.post(EXECUTE_URL).mock(
            return_value=httpx.Response(
                504, json={"error": "gateway_timeout", "message": "Timeout"}
            )
        )
        with Baponi(api_key=FAKE_KEY, max_retries=2) as client:
            with pytest.raises(Exception):
                client.execute("x")

        assert len(route.calls) == 1

    @respx.mock
    def test_retries_on_connect_error(self):
        route = respx.post(EXECUTE_URL).mock(
            side_effect=[
                httpx.ConnectError("Connection refused"),
                httpx.Response(200, json=MOCK_SUCCESS_RESPONSE),
            ]
        )
        with Baponi(api_key=FAKE_KEY, max_retries=1) as client:
            result = client.execute("x")

        assert result.success is True
        assert len(route.calls) == 2

    @respx.mock
    def test_max_retries_zero_disables(self):
        route = respx.post(EXECUTE_URL).mock(
            return_value=httpx.Response(
                503, json={"error": "service_unavailable", "message": "No capacity"}
            )
        )
        with Baponi(api_key=FAKE_KEY, max_retries=0) as client:
            with pytest.raises(ServerError):
                client.execute("x")

        assert len(route.calls) == 1


class TestSandboxResult:
    def test_frozen_model(self):
        result = SandboxResult(
            success=True,
            stdout="hi",
            stderr="",
            exit_code=0,
        )
        with pytest.raises(Exception):
            result.stdout = "changed"  # type: ignore[misc]

    def test_model_dump(self):
        result = SandboxResult(
            success=True,
            stdout="hi",
            stderr="",
            exit_code=0,
        )
        d = result.model_dump()
        assert d["success"] is True
        assert d["stdout"] == "hi"
