"""Tests for the LangChain Deep Agents integration."""

from __future__ import annotations

import json
from unittest.mock import patch

import httpx
import pytest
import respx

from baponi.deepagents import (
    BaponiSandbox,
    BaponiSandboxProvider,
    _to_relative_path,
    create_sandbox,
)

API_KEY = "sk-test-key"
BASE_URL = "https://api.baponi.ai"
EXECUTE_URL = f"{BASE_URL}/v1/sandbox/execute"
UPLOAD_URL_ENDPOINT = f"{BASE_URL}/v1/files/upload_url"
DOWNLOAD_URL_ENDPOINT = f"{BASE_URL}/v1/files/download_url"

FAKE_SIGNED_UPLOAD = "https://storage.googleapis.com/bucket/upload?sig=abc"
FAKE_SIGNED_DOWNLOAD = "https://storage.googleapis.com/bucket/download?sig=xyz"


def _ok_response(
    stdout: str = "", stderr: str = "", exit_code: int = 0
) -> httpx.Response:
    return httpx.Response(
        200,
        json={
            "success": exit_code == 0,
            "stdout": stdout,
            "stderr": stderr,
            "exit_code": exit_code,
        },
    )


def _error_response(status: int, message: str = "error") -> httpx.Response:
    return httpx.Response(status, json={"error": "test_error", "message": message})


def _signed_url_response(url: str, method: str = "PUT") -> httpx.Response:
    return httpx.Response(
        200,
        json={
            "url": url,
            "method": method,
            "headers": [{"name": "Content-Type", "value": "application/octet-stream"}],
            "expires_at": "2026-12-31T00:00:00Z",
        },
    )


class TestInit:
    def test_auto_generated_thread_id(self):
        sandbox = BaponiSandbox(api_key=API_KEY)
        assert sandbox.id.startswith("deep-")
        assert len(sandbox.id) == 21  # "deep-" + 16 hex chars

    def test_explicit_thread_id(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="my-session")
        assert sandbox.id == "my-session"

    def test_id_property_returns_thread_id(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="test-123")
        assert sandbox.id == "test-123"

    def test_sub_paths_stored(self):
        sandbox = BaponiSandbox(
            api_key=API_KEY, sub_paths=["/data/shared"]
        )
        assert sandbox._sub_paths == ["/data/shared"]

    def test_default_timeout(self):
        from baponi._constants import DEFAULT_EXECUTION_TIMEOUT

        sandbox = BaponiSandbox(api_key=API_KEY)
        assert sandbox._timeout == DEFAULT_EXECUTION_TIMEOUT


class TestExecute:
    @respx.mock
    def test_sends_bash_and_thread_id(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="t1")
        route = respx.post(EXECUTE_URL).mock(return_value=_ok_response(stdout="hello"))

        result = sandbox.execute("echo hello")

        assert route.called
        body = json.loads(route.calls[0].request.content)
        assert body["language"] == "bash"
        assert body["thread_id"] == "t1"
        assert body["code"] == "echo hello"
        assert result.output == "hello"
        assert result.exit_code == 0

    @respx.mock
    def test_combines_stdout_and_stderr(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="t1")
        respx.post(EXECUTE_URL).mock(
            return_value=_ok_response(stdout="out", stderr="err", exit_code=1)
        )

        result = sandbox.execute("cmd")

        assert result.output == "out\nerr"
        assert result.exit_code == 1

    @respx.mock
    def test_stdout_only(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="t1")
        respx.post(EXECUTE_URL).mock(return_value=_ok_response(stdout="hello"))

        result = sandbox.execute("cmd")
        assert result.output == "hello"

    @respx.mock
    def test_stderr_only(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="t1")
        respx.post(EXECUTE_URL).mock(return_value=_ok_response(stderr="warn"))

        result = sandbox.execute("cmd")
        assert result.output == "warn"

    @respx.mock
    def test_truncation_flag(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="t1")
        big_output = "x" * (1_048_576 + 100)
        respx.post(EXECUTE_URL).mock(return_value=_ok_response(stdout=big_output))

        result = sandbox.execute("cmd")
        assert result.truncated is True

    @respx.mock
    def test_no_truncation_for_small_output(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="t1")
        respx.post(EXECUTE_URL).mock(return_value=_ok_response(stdout="small"))

        result = sandbox.execute("cmd")
        assert result.truncated is False

    @respx.mock
    def test_default_timeout(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="t1", timeout=120)
        route = respx.post(EXECUTE_URL).mock(return_value=_ok_response())

        sandbox.execute("cmd")

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

    @respx.mock
    def test_overridden_timeout(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="t1", timeout=120)
        route = respx.post(EXECUTE_URL).mock(return_value=_ok_response())

        sandbox.execute("cmd", timeout=60)

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

    @respx.mock
    def test_metadata_passed(self):
        sandbox = BaponiSandbox(
            api_key=API_KEY, thread_id="t1", metadata={"env": "test"}
        )
        route = respx.post(EXECUTE_URL).mock(return_value=_ok_response())

        sandbox.execute("cmd")

        body = json.loads(route.calls[0].request.content)
        assert body["metadata"] == {"env": "test"}

    @respx.mock
    def test_env_vars_passed(self):
        sandbox = BaponiSandbox(
            api_key=API_KEY, thread_id="t1", env_vars={"MY_VAR": "test_val"}
        )
        route = respx.post(EXECUTE_URL).mock(return_value=_ok_response())

        sandbox.execute("cmd")

        body = json.loads(route.calls[0].request.content)
        assert body["env_vars"] == {"MY_VAR": "test_val"}

    @respx.mock
    def test_env_vars_none_omitted(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="t1")
        route = respx.post(EXECUTE_URL).mock(return_value=_ok_response())

        sandbox.execute("cmd")

        body = json.loads(route.calls[0].request.content)
        assert "env_vars" not in body


class TestRetry409:
    @respx.mock
    def test_succeeds_after_one_retry(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="t1")
        route = respx.post(EXECUTE_URL).mock(
            side_effect=[
                _error_response(409, "thread busy"),
                _ok_response(stdout="ok"),
            ]
        )

        with patch("baponi.deepagents.time.sleep"):
            result = sandbox.execute("cmd")

        assert result.output == "ok"
        assert route.call_count == 2

    @respx.mock
    def test_raises_after_max_retries(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="t1")
        respx.post(EXECUTE_URL).mock(
            side_effect=[
                _error_response(409, "thread busy"),
                _error_response(409, "thread busy"),
                _error_response(409, "thread busy"),
                _error_response(409, "thread busy"),
            ]
        )

        with patch("baponi.deepagents.time.sleep"):
            from baponi.exceptions import ThreadBusyError as TBE

            with pytest.raises(TBE):
                sandbox.execute("cmd")

    @respx.mock
    def test_no_retry_on_401(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="t1")
        route = respx.post(EXECUTE_URL).mock(
            return_value=_error_response(401, "unauthorized")
        )

        from baponi.exceptions import AuthenticationError

        with pytest.raises(AuthenticationError):
            sandbox.execute("cmd")

        assert route.call_count == 1


class TestUploadFiles:
    @respx.mock
    def test_upload_single_file(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="t1")
        # Mock the upload_url endpoint
        url_route = respx.post(UPLOAD_URL_ENDPOINT).mock(
            return_value=_signed_url_response(FAKE_SIGNED_UPLOAD)
        )
        # Mock the actual GCS upload
        put_route = respx.put(FAKE_SIGNED_UPLOAD).mock(
            return_value=httpx.Response(200)
        )

        result = sandbox.upload_files([("/home/baponi/test.txt", b"hello world")])

        assert len(result) == 1
        assert result[0].path == "/home/baponi/test.txt"
        assert result[0].error is None
        # Verify upload_url was called with stripped path
        body = json.loads(url_route.calls[0].request.content)
        assert body["source"] == "thread"
        assert body["id"] == "t1"
        assert body["path"] == "test.txt"
        assert body["content_length"] == 11
        # Verify the PUT to signed URL happened
        assert put_route.called

    @respx.mock
    def test_upload_gcs_failure(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="t1")
        respx.post(UPLOAD_URL_ENDPOINT).mock(
            return_value=_signed_url_response(FAKE_SIGNED_UPLOAD)
        )
        respx.put(FAKE_SIGNED_UPLOAD).mock(
            return_value=httpx.Response(403)
        )

        result = sandbox.upload_files([("/root/test.txt", b"data")])

        assert result[0].error == "permission_denied"

    @respx.mock
    def test_upload_api_failure(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="t1")
        respx.post(UPLOAD_URL_ENDPOINT).mock(
            return_value=_error_response(400, "invalid path")
        )

        result = sandbox.upload_files([("bad-path", b"data")])

        assert result[0].error == "invalid_path"


class TestDownloadFiles:
    @respx.mock
    def test_download_single_file(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="t1")
        content = b"file contents here"
        # Mock the download_url endpoint
        url_route = respx.post(DOWNLOAD_URL_ENDPOINT).mock(
            return_value=_signed_url_response(FAKE_SIGNED_DOWNLOAD, method="GET")
        )
        # Mock the actual GCS download
        respx.get(FAKE_SIGNED_DOWNLOAD).mock(
            return_value=httpx.Response(200, content=content)
        )

        result = sandbox.download_files(["/home/baponi/test.txt"])

        assert len(result) == 1
        assert result[0].path == "/home/baponi/test.txt"
        assert result[0].content == content
        assert result[0].error is None
        # Verify download_url was called with stripped path
        body = json.loads(url_route.calls[0].request.content)
        assert body["source"] == "thread"
        assert body["id"] == "t1"
        assert body["path"] == "test.txt"

    @respx.mock
    def test_download_missing_file(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="t1")
        respx.post(DOWNLOAD_URL_ENDPOINT).mock(
            return_value=_signed_url_response(FAKE_SIGNED_DOWNLOAD, method="GET")
        )
        respx.get(FAKE_SIGNED_DOWNLOAD).mock(
            return_value=httpx.Response(404)
        )

        result = sandbox.download_files(["/tmp/missing.txt"])

        assert result[0].content is None
        assert result[0].error == "file_not_found"

    @respx.mock
    def test_download_api_failure(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="t1")
        respx.post(DOWNLOAD_URL_ENDPOINT).mock(
            return_value=_error_response(404, "not found")
        )

        result = sandbox.download_files(["/tmp/missing.txt"])

        assert result[0].content is None
        assert result[0].error == "file_not_found"


class TestExecutePerCallOverrides:
    @respx.mock
    def test_env_vars_override(self):
        sandbox = BaponiSandbox(
            api_key=API_KEY, thread_id="t1", env_vars={"A": "1"}
        )
        route = respx.post(EXECUTE_URL).mock(return_value=_ok_response())

        sandbox.execute("cmd", env_vars={"B": "2"})

        body = json.loads(route.calls[0].request.content)
        assert body["env_vars"] == {"B": "2"}

    @respx.mock
    def test_none_uses_constructor_default(self):
        sandbox = BaponiSandbox(
            api_key=API_KEY, thread_id="t1", env_vars={"A": "1"}
        )
        route = respx.post(EXECUTE_URL).mock(return_value=_ok_response())

        sandbox.execute("cmd")  # env_vars=None → use constructor

        body = json.loads(route.calls[0].request.content)
        assert body["env_vars"] == {"A": "1"}

    @respx.mock
    def test_metadata_override(self):
        sandbox = BaponiSandbox(
            api_key=API_KEY, thread_id="t1", metadata={"env": "prod"}
        )
        route = respx.post(EXECUTE_URL).mock(return_value=_ok_response())

        sandbox.execute("cmd", metadata={"env": "test"})

        body = json.loads(route.calls[0].request.content)
        assert body["metadata"] == {"env": "test"}

    @respx.mock
    def test_sub_paths_override(self):
        sandbox = BaponiSandbox(
            api_key=API_KEY, thread_id="t1", sub_paths=["/data/a"]
        )
        route = respx.post(EXECUTE_URL).mock(return_value=_ok_response())

        sandbox.execute("cmd", sub_paths=["/data/b"])

        body = json.loads(route.calls[0].request.content)
        assert body["sub_paths"] == ["/data/b"]

    @respx.mock
    def test_sub_paths_from_constructor(self):
        sandbox = BaponiSandbox(
            api_key=API_KEY, thread_id="t1", sub_paths=["/data/shared"]
        )
        route = respx.post(EXECUTE_URL).mock(return_value=_ok_response())

        sandbox.execute("cmd")

        body = json.loads(route.calls[0].request.content)
        assert body["sub_paths"] == ["/data/shared"]


class TestContextManager:
    def test_sync_with(self):
        with BaponiSandbox(api_key=API_KEY, thread_id="t1") as sandbox:
            assert sandbox.id == "t1"
        # After exit, client should be None (never created) -no error

    @respx.mock
    def test_sync_with_closes_client(self):
        with BaponiSandbox(api_key=API_KEY, thread_id="t1") as sandbox:
            respx.post(EXECUTE_URL).mock(return_value=_ok_response())
            sandbox.execute("cmd")
            assert sandbox._sync_client is not None
        # close() was called -verify no exception

    async def test_async_with(self):
        async with BaponiSandbox(api_key=API_KEY, thread_id="t1") as sandbox:
            assert sandbox.id == "t1"

    def test_close_idempotent(self):
        sandbox = BaponiSandbox(api_key=API_KEY)
        sandbox.close()
        sandbox.close()  # no error on double close

    def test_close_warns_if_async_client_exists(self):
        sandbox = BaponiSandbox(api_key=API_KEY)
        sandbox._async_client = object()  # type: ignore[assignment]

        import warnings

        with warnings.catch_warnings(record=True) as w:
            warnings.simplefilter("always")
            sandbox.close()
            assert len(w) == 1
            assert issubclass(w[0].category, ResourceWarning)
            assert "aclose()" in str(w[0].message)
        assert sandbox._async_client is None


class TestExecuteStream:
    def test_delegates_to_client_with_resolved_params(self):
        sandbox = BaponiSandbox(
            api_key=API_KEY,
            thread_id="t1",
            metadata={"k": "v"},
            env_vars={"E": "1"},
            sub_paths=["/data/x"],
        )
        mock_stream = object()
        captured: dict[str, object] = {}

        def fake_execute_stream(code, **kwargs):  # type: ignore[no-untyped-def]
            captured.update(kwargs)
            captured["code"] = code
            return mock_stream

        mock_client = type("MockClient", (), {
            "execute_stream": lambda self, *a, **kw: fake_execute_stream(*a, **kw),
        })()

        sandbox._sync_client = mock_client  # type: ignore[assignment]

        result = sandbox.execute_stream("cmd", timeout=45)
        assert result is mock_stream
        # Verify constructor defaults forwarded
        assert captured["code"] == "cmd"
        assert captured["language"] == "bash"
        assert captured["thread_id"] == "t1"
        assert captured["timeout"] == 45
        assert captured["metadata"] == {"k": "v"}
        assert captured["env_vars"] == {"E": "1"}
        assert captured["sub_paths"] == ["/data/x"]

    def test_per_call_overrides_forwarded(self):
        sandbox = BaponiSandbox(
            api_key=API_KEY,
            thread_id="t1",
            env_vars={"A": "1"},
        )
        captured: dict[str, object] = {}

        def fake_execute_stream(code, **kwargs):  # type: ignore[no-untyped-def]
            captured.update(kwargs)
            return object()

        mock_client = type("MockClient", (), {
            "execute_stream": lambda self, *a, **kw: fake_execute_stream(*a, **kw),
        })()
        sandbox._sync_client = mock_client  # type: ignore[assignment]

        sandbox.execute_stream("cmd", env_vars={"B": "2"})
        assert captured["env_vars"] == {"B": "2"}  # replaced, not merged

    @respx.mock
    def test_retries_on_thread_busy(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="t1")
        # First call returns 409, second succeeds with streaming response
        respx.post(EXECUTE_URL).mock(
            side_effect=[
                _error_response(409, "thread busy"),
                # Streaming responses go through client.execute_stream which
                # raises ThreadBusyError on 409 before opening the stream.
                # After retry, the real stream would open. For this test we
                # just verify the retry happens by counting attempts.
                _error_response(409, "thread busy"),
                _error_response(409, "thread busy"),
                _error_response(409, "thread busy"),
            ]
        )

        from baponi.exceptions import ThreadBusyError as TBE

        with patch("baponi.deepagents.time.sleep"):
            with pytest.raises(TBE):
                sandbox.execute_stream("cmd")


class TestCreateSandbox:
    def test_returns_configured_instance(self):
        sandbox = create_sandbox(
            api_key=API_KEY, thread_id="custom", timeout=600
        )
        assert isinstance(sandbox, BaponiSandbox)
        assert sandbox.id == "custom"

    def test_defaults(self):
        sandbox = create_sandbox(api_key=API_KEY)
        assert sandbox.id.startswith("deep-")

    def test_sub_paths_passed(self):
        sandbox = create_sandbox(
            api_key=API_KEY, sub_paths=["/data/shared"]
        )
        assert sandbox._sub_paths == ["/data/shared"]


class TestBaponiSandboxProvider:
    def test_get_or_create_new(self):
        provider = BaponiSandboxProvider(api_key=API_KEY)
        sandbox = provider.get_or_create(sandbox_id="s1")
        assert sandbox.id == "s1"
        assert isinstance(sandbox, BaponiSandbox)

    def test_get_or_create_existing(self):
        provider = BaponiSandboxProvider(api_key=API_KEY)
        s1 = provider.get_or_create(sandbox_id="s1")
        s2 = provider.get_or_create(sandbox_id="s1")
        assert s1 is s2

    def test_get_or_create_auto_id(self):
        provider = BaponiSandboxProvider(api_key=API_KEY)
        sandbox = provider.get_or_create()
        assert sandbox.id.startswith("deep-")

    def test_delete_removes(self):
        provider = BaponiSandboxProvider(api_key=API_KEY)
        provider.get_or_create(sandbox_id="s1")
        provider.delete(sandbox_id="s1")
        # Should create a new one now
        s2 = provider.get_or_create(sandbox_id="s1")
        assert s2.id == "s1"

    def test_delete_nonexistent_noop(self):
        provider = BaponiSandboxProvider(api_key=API_KEY)
        provider.delete(sandbox_id="nonexistent")  # no error

    async def test_aget_or_create(self):
        provider = BaponiSandboxProvider(api_key=API_KEY)
        sandbox = await provider.aget_or_create(sandbox_id="async-s1")
        assert sandbox.id == "async-s1"

    async def test_adelete(self):
        provider = BaponiSandboxProvider(api_key=API_KEY)
        provider.get_or_create(sandbox_id="s1")
        await provider.adelete(sandbox_id="s1")
        # Should create a new one now
        s2 = provider.get_or_create(sandbox_id="s1")
        assert s2.id == "s1"

    def test_env_var_fallback(self):
        import os

        with patch.dict(os.environ, {"BAPONI_API_KEY": "sk-env", "BAPONI_BASE_URL": "https://custom.api"}):
            provider = BaponiSandboxProvider()
            assert provider._api_key == "sk-env"
            assert provider._base_url == "https://custom.api"

    def test_config_passed_to_sandboxes(self):
        provider = BaponiSandboxProvider(
            api_key=API_KEY,
            timeout=120,
            metadata={"env": "test"},
            env_vars={"K": "V"},
            sub_paths=["/data/shared"],
        )
        sandbox = provider.get_or_create(sandbox_id="cfg")
        assert sandbox._timeout == 120
        assert sandbox._metadata == {"env": "test"}
        assert sandbox._env_vars == {"K": "V"}
        assert sandbox._sub_paths == ["/data/shared"]


class TestAsync:
    @respx.mock
    async def test_aexecute(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="t1")
        respx.post(EXECUTE_URL).mock(return_value=_ok_response(stdout="async-ok"))

        result = await sandbox.aexecute("echo hello")

        assert result.output == "async-ok"
        assert result.exit_code == 0

    @respx.mock
    async def test_async_409_retry(self):
        sandbox = BaponiSandbox(api_key=API_KEY, thread_id="t1")
        respx.post(EXECUTE_URL).mock(
            side_effect=[
                _error_response(409, "thread busy"),
                _ok_response(stdout="ok"),
            ]
        )

        with patch("baponi.deepagents.asyncio.sleep", return_value=None):
            result = await sandbox.aexecute("cmd")

        assert result.output == "ok"


class TestPathMapping:
    def test_strips_baponi_home(self):
        assert _to_relative_path("/home/baponi/foo.txt") == "foo.txt"

    def test_other_home_stripped_via_lstrip(self):
        assert _to_relative_path("/other/path/file.csv") == "other/path/file.csv"

    def test_strips_leading_slash_for_other_paths(self):
        assert _to_relative_path("/tmp/output.log") == "tmp/output.log"

    def test_relative_path_unchanged(self):
        assert _to_relative_path("foo.txt") == "foo.txt"

    def test_nested_path(self):
        assert _to_relative_path("/home/baponi/src/main.py") == "src/main.py"

    def test_home_prefix_without_trailing_slash(self):
        # /home/baponi without trailing slash should strip leading slash
        assert _to_relative_path("/home/baponi") == "home/baponi"
