"""
Tests for runtime/sandbox/ — Agent Sandbox + Agent Protocol.

Covers:
    - Agent installation (download, unpack, manifest, path traversal)
    - Agent lifecycle (start, stop, health check, uninstall)
    - Path safety (sandbox escape prevention, deny-list enforcement)
    - Environment scrubbing (no API key leaks to agent processes)
    - Agent ID validation (rejects path separators, dots, etc.)
    - Protocol data models (request/response serialization)
    - Protocol client (HTTP calls with error handling)
"""

import json
import os
import platform
import tarfile
import tempfile
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from threading import Thread
from unittest.mock import MagicMock, patch

import pytest

from runtime.sandbox.agent_sandbox import (
    AgentSandbox,
    InstalledAgent,
    RunningAgent,
    SandboxError,
    _build_safe_env,
    _is_path_denied,
    _is_path_safe,
    _validate_env_scrubbed,
)
from runtime.sandbox.agent_protocol import (
    AGENT_PROTOCOL_VERSION,
    AgentExecuteRequest,
    AgentExecuteResponse,
    AgentHealthResponse,
    AgentInfoResponse,
    AgentProtocolClient,
    AgentProtocolError,
)


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


@pytest.fixture
def sandbox_dir(tmp_path):
    """Isolated base directory for agent installations."""
    base = tmp_path / "agents"
    base.mkdir()
    return base


@pytest.fixture
def sandbox(sandbox_dir):
    """Fresh AgentSandbox instance pointing at a temp directory."""
    return AgentSandbox(base_dir=sandbox_dir)


@pytest.fixture
def fake_package(tmp_path):
    """Create a fake agent package (tar.gz) with a manifest and entrypoint."""
    pkg_dir = tmp_path / "pkg_src"
    pkg_dir.mkdir()
    (pkg_dir / "__main__.py").write_text("print('agent running')")
    (pkg_dir / "agent.py").write_text("print('hello from agent')")

    archive_path = tmp_path / "agent.tar.gz"
    with tarfile.open(archive_path, "w:gz") as tar:
        tar.add(pkg_dir / "__main__.py", arcname="__main__.py")
        tar.add(pkg_dir / "agent.py", arcname="agent.py")

    return archive_path


def _make_file_url(path: Path) -> str:
    """Convert a local path to a file:// URL for testing."""
    return path.as_uri()


class _FakeAgentHandler(BaseHTTPRequestHandler):
    """Minimal HTTP handler that implements the agent protocol."""

    def do_GET(self):
        if self.path == "/health":
            self._json_response(200, {"status": "ok", "uptime_seconds": 1.0})
        elif self.path == "/info":
            self._json_response(200, {
                "name": "test-agent",
                "version": "1.0.0",
                "capabilities": ["test"],
                "protocol_version": AGENT_PROTOCOL_VERSION,
            })
        else:
            self._json_response(404, {"error": "not found"})

    def do_POST(self):
        if self.path == "/execute":
            length = int(self.headers.get("Content-Length", 0))
            body = json.loads(self.rfile.read(length)) if length else {}
            self._json_response(200, {
                "output": f"Processed: {body.get('prompt', '')}",
                "tokens_used": 42,
                "elapsed_ms": 10.5,
            })
        else:
            self._json_response(404, {"error": "not found"})

    def _json_response(self, code: int, data: dict):
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(json.dumps(data).encode())

    def log_message(self, format, *args):
        pass  # Suppress request logging in tests.


@pytest.fixture
def fake_agent_server():
    """Start a fake agent HTTP server on a random port. Yields (host, port)."""
    server = HTTPServer(("127.0.0.1", 0), _FakeAgentHandler)
    port = server.server_address[1]
    thread = Thread(target=server.serve_forever, daemon=True)
    thread.start()
    yield ("127.0.0.1", port)
    server.shutdown()


# ── 1. Path Safety ───────────────────────────────────────────────────────


class TestPathSafety:
    def test_path_inside_sandbox(self, tmp_path):
        inner = tmp_path / "subdir" / "file.txt"
        assert _is_path_safe(inner, tmp_path) is True

    def test_path_equals_sandbox(self, tmp_path):
        assert _is_path_safe(tmp_path, tmp_path) is True

    def test_path_outside_sandbox(self, tmp_path):
        assert _is_path_safe("/etc/passwd", tmp_path) is False

    def test_path_traversal_blocked(self, tmp_path):
        evil = tmp_path / ".." / ".." / "etc" / "passwd"
        assert _is_path_safe(evil, tmp_path) is False

    def test_deny_list_ssh(self):
        home = str(Path.home())
        assert _is_path_denied(os.path.join(home, ".ssh", "id_rsa")) is True

    def test_deny_list_vault(self):
        home = str(Path.home())
        assert _is_path_denied(os.path.join(home, ".brynq", "vault.json")) is True

    def test_deny_list_aws(self):
        home = str(Path.home())
        assert _is_path_denied(os.path.join(home, ".aws", "credentials")) is True

    def test_normal_path_not_denied(self, tmp_path):
        assert _is_path_denied(str(tmp_path / "safe.txt")) is False

    def test_validate_path_method(self, sandbox, sandbox_dir):
        """Sandbox.validate_path delegates correctly."""
        agent_dir = sandbox_dir / "test-agent"
        agent_dir.mkdir()
        assert sandbox.validate_path("test-agent", agent_dir / "file.txt") is True
        assert sandbox.validate_path("test-agent", "/etc/passwd") is False


# ── 2. Agent ID Validation ───────────────────────────────────────────────


class TestAgentIdValidation:
    def test_valid_ids(self):
        for aid in ["my-agent", "agent123", "a1b2c3", "fin-analyzer-v2"]:
            AgentSandbox._validate_agent_id(aid)  # Should not raise.

    def test_empty_id_rejected(self):
        with pytest.raises(SandboxError, match="cannot be empty"):
            AgentSandbox._validate_agent_id("")

    def test_path_separator_rejected(self):
        with pytest.raises(SandboxError, match="alphanumeric"):
            AgentSandbox._validate_agent_id("../escape")

    def test_dots_rejected(self):
        with pytest.raises(SandboxError, match="alphanumeric"):
            AgentSandbox._validate_agent_id("my.agent")

    def test_spaces_rejected(self):
        with pytest.raises(SandboxError, match="alphanumeric"):
            AgentSandbox._validate_agent_id("my agent")

    def test_leading_hyphen_rejected(self):
        with pytest.raises(SandboxError, match="hyphen"):
            AgentSandbox._validate_agent_id("-badstart")

    def test_trailing_hyphen_rejected(self):
        with pytest.raises(SandboxError, match="hyphen"):
            AgentSandbox._validate_agent_id("badend-")


# ── 3. Environment Scrubbing ────────────────────────────────────────────


class TestEnvironmentScrubbing:
    def test_sensitive_vars_stripped(self, tmp_path):
        """API keys must never leak to agent processes."""
        os.environ["ANTHROPIC_API_KEY"] = "sk-test-secret"
        os.environ["OPENAI_API_KEY"] = "sk-openai-secret"
        os.environ["AWS_SECRET_ACCESS_KEY"] = "aws-secret"
        try:
            env = _build_safe_env(str(tmp_path), 9100, [])
            assert "ANTHROPIC_API_KEY" not in env
            assert "OPENAI_API_KEY" not in env
            assert "AWS_SECRET_ACCESS_KEY" not in env
        finally:
            del os.environ["ANTHROPIC_API_KEY"]
            del os.environ["OPENAI_API_KEY"]
            del os.environ["AWS_SECRET_ACCESS_KEY"]

    def test_sandbox_metadata_injected(self, tmp_path):
        env = _build_safe_env(str(tmp_path), 9100, ["https://api.example.com"])
        assert env["BRYNQ_AGENT_SANDBOX"] == "1"
        assert env["BRYNQ_AGENT_DIR"] == str(tmp_path)
        assert env["BRYNQ_AGENT_PORT"] == "9100"
        assert "api.example.com" in env["BRYNQ_NETWORK_WHITELIST"]

    def test_pythonpath_restricted(self, tmp_path):
        env = _build_safe_env(str(tmp_path), 9100, [])
        assert env["PYTHONPATH"] == str(tmp_path)

    def test_normal_vars_preserved(self, tmp_path):
        # _build_safe_env uses an allowlist — only vars matching allowed prefixes
        # pass through. Use PATH which is always allowed.
        env = _build_safe_env(str(tmp_path), 9100, [])
        assert env.get("PATH") is not None

    def test_validate_env_scrubbed_clean(self):
        """A clean env dict passes validation."""
        _validate_env_scrubbed({"PATH": "/usr/bin", "HOME": "/home/user"})

    def test_validate_env_scrubbed_catches_key(self):
        with pytest.raises(SandboxError, match="sensitive vars remain"):
            _validate_env_scrubbed({"PATH": "/usr/bin", "ANTHROPIC_API_KEY": "sk-xxx"})

    def test_validate_env_scrubbed_catches_secret(self):
        with pytest.raises(SandboxError, match="sensitive vars remain"):
            _validate_env_scrubbed({"AWS_SECRET_ACCESS_KEY": "s3cr3t"})

    def test_validate_env_scrubbed_catches_token(self):
        with pytest.raises(SandboxError, match="sensitive vars remain"):
            _validate_env_scrubbed({"HUGGINGFACE_TOKEN": "hf_xxx"})

    def test_validate_env_scrubbed_catches_password(self):
        with pytest.raises(SandboxError, match="sensitive vars remain"):
            _validate_env_scrubbed({"DB_PASSWORD": "hunter2"})

    def test_validate_env_scrubbed_case_insensitive(self):
        """Detection is case-insensitive."""
        with pytest.raises(SandboxError, match="sensitive vars remain"):
            _validate_env_scrubbed({"my_Secret_var": "oops"})

    def test_build_safe_env_rejects_leaked_sensitive_var(self, tmp_path):
        """If the allowlist accidentally passes a KEY/SECRET/TOKEN/PASSWORD var, validation catches it."""
        os.environ["TEMP_TOKEN_STORE"] = "leaked"
        try:
            # TEMP is an allowed prefix, so TEMP_TOKEN_STORE would pass the
            # allowlist — but the post-scrub validation must catch TOKEN.
            with pytest.raises(SandboxError, match="sensitive vars remain"):
                _build_safe_env(str(tmp_path), 9100, [])
        finally:
            del os.environ["TEMP_TOKEN_STORE"]


# ── 4. Installation ─────────────────────────────────────────────────────


class TestInstallAgent:
    def test_install_creates_directory(self, sandbox, sandbox_dir):
        """install_agent with a mock download creates the agent dir and manifest."""
        with patch("runtime.sandbox.agent_sandbox._download_package") as mock_dl:
            mock_dl.return_value = "abc123"
            agent = sandbox.install_agent(
                agent_id="test-agent",
                package_url="https://example.com/agent.whl",
                name="Test Agent",
                version="1.0.0",
            )

        assert agent.agent_id == "test-agent"
        assert agent.name == "Test Agent"
        assert agent.version == "1.0.0"
        assert agent.checksum == "abc123"
        assert (sandbox_dir / "test-agent" / "manifest.json").exists()
        assert (sandbox_dir / "test-agent" / "data").is_dir()

    def test_install_overwrites_existing(self, sandbox, sandbox_dir):
        """Reinstalling an agent wipes the old directory."""
        with patch("runtime.sandbox.agent_sandbox._download_package") as mock_dl:
            mock_dl.return_value = "v1"
            sandbox.install_agent("test-agent", "https://example.com/v1.whl")
            mock_dl.return_value = "v2"
            agent = sandbox.install_agent("test-agent", "https://example.com/v2.whl")

        assert agent.checksum == "v2"

    def test_install_persists_manifest(self, sandbox, sandbox_dir):
        """Manifest JSON can be read back."""
        with patch("runtime.sandbox.agent_sandbox._download_package") as mock_dl:
            mock_dl.return_value = "checksum123"
            sandbox.install_agent(
                agent_id="persist-test",
                package_url="https://example.com/pkg.whl",
                name="Persisted",
                version="2.0.0",
            )

        manifest = json.loads((sandbox_dir / "persist-test" / "manifest.json").read_text())
        assert manifest["agent_id"] == "persist-test"
        assert manifest["version"] == "2.0.0"
        assert manifest["checksum"] == "checksum123"

    def test_install_invalid_id_rejected(self, sandbox):
        with pytest.raises(SandboxError, match="alphanumeric"):
            sandbox.install_agent("../../evil", "https://example.com/pkg.whl")

    def test_install_network_whitelist(self, sandbox, sandbox_dir):
        with patch("runtime.sandbox.agent_sandbox._download_package") as mock_dl:
            mock_dl.return_value = "abc"
            agent = sandbox.install_agent(
                "whitelist-test",
                "https://example.com/pkg.whl",
                network_whitelist=["https://api.custom.com"],
            )

        # Default whitelist + custom.
        assert "pypi.org" in agent.network_whitelist
        assert "https://api.custom.com" in agent.network_whitelist


# ── 5. List Agents ──────────────────────────────────────────────────────


class TestListAgents:
    def test_empty_base(self, sandbox):
        assert sandbox.list_agents() == []

    def test_lists_installed(self, sandbox):
        with patch("runtime.sandbox.agent_sandbox._download_package", return_value="x"):
            sandbox.install_agent("agent-a", "https://example.com/a.whl", name="A")
            sandbox.install_agent("agent-b", "https://example.com/b.whl", name="B")

        agents = sandbox.list_agents()
        assert len(agents) == 2
        ids = {a.agent_id for a in agents}
        assert ids == {"agent-a", "agent-b"}

    def test_skips_corrupt_manifest(self, sandbox, sandbox_dir):
        """Directories with missing/corrupt manifests are silently skipped."""
        bad_dir = sandbox_dir / "corrupt-agent"
        bad_dir.mkdir()
        (bad_dir / "manifest.json").write_text("not json{{{")

        with patch("runtime.sandbox.agent_sandbox._download_package", return_value="x"):
            sandbox.install_agent("good-agent", "https://example.com/g.whl")

        agents = sandbox.list_agents()
        assert len(agents) == 1
        assert agents[0].agent_id == "good-agent"


# ── 6. Uninstall ────────────────────────────────────────────────────────


class TestUninstallAgent:
    def test_uninstall_removes_directory(self, sandbox, sandbox_dir):
        with patch("runtime.sandbox.agent_sandbox._download_package", return_value="x"):
            sandbox.install_agent("remove-me", "https://example.com/r.whl")

        assert (sandbox_dir / "remove-me").exists()
        sandbox.uninstall_agent("remove-me")
        assert not (sandbox_dir / "remove-me").exists()

    def test_uninstall_not_installed(self, sandbox):
        with pytest.raises(SandboxError, match="not installed"):
            sandbox.uninstall_agent("ghost-agent")


# ── 7. Health Check ─────────────────────────────────────────────────────


class TestHealthCheck:
    def test_not_running_returns_false(self, sandbox):
        assert sandbox.health_check("nobody") is False

    def test_healthy_agent(self, sandbox, fake_agent_server):
        """Manually register a running agent and verify health check passes."""
        host, port = fake_agent_server
        sandbox.running["fake-agent"] = RunningAgent(
            agent_id="fake-agent",
            pid=os.getpid(),  # Use our own PID (known alive).
            port=port,
            started_at=time.time(),
            endpoint=f"http://{host}:{port}",
        )
        assert sandbox.health_check("fake-agent") is True


# ── 8. InstalledAgent Serialization ─────────────────────────────────────


class TestInstalledAgentSerialization:
    def test_round_trip(self):
        agent = InstalledAgent(
            agent_id="rt-test",
            name="Round Trip",
            version="3.1.0",
            install_dir="/tmp/agents/rt-test",
            entrypoint="python -m agent",
            network_whitelist=["https://api.example.com"],
            installed_at=1234567890.0,
            checksum="deadbeef",
        )
        d = agent.to_dict()
        restored = InstalledAgent.from_dict(d)
        assert restored.agent_id == "rt-test"
        assert restored.version == "3.1.0"
        assert restored.checksum == "deadbeef"
        assert "https://api.example.com" in restored.network_whitelist


# ── 9. Protocol Data Models ─────────────────────────────────────────────


class TestProtocolModels:
    def test_execute_request_json(self):
        req = AgentExecuteRequest(
            prompt="Summarize this",
            context={"step_1_output": "Some text"},
            config={"temperature": 0.5},
        )
        parsed = json.loads(req.to_json())
        assert parsed["prompt"] == "Summarize this"
        assert parsed["context"]["step_1_output"] == "Some text"

    def test_execute_response_from_dict(self):
        resp = AgentExecuteResponse.from_dict({
            "output": "Summary here",
            "tokens_used": 100,
            "elapsed_ms": 42.5,
            "metadata": {"confidence": 0.95},
        })
        assert resp.output == "Summary here"
        assert resp.tokens_used == 100
        assert resp.metadata["confidence"] == 0.95

    def test_health_response_from_dict(self):
        resp = AgentHealthResponse.from_dict({"status": "ok", "uptime_seconds": 60.0})
        assert resp.status == "ok"
        assert resp.uptime_seconds == 60.0

    def test_info_response_from_dict(self):
        resp = AgentInfoResponse.from_dict({
            "name": "My Agent",
            "version": "2.0.0",
            "capabilities": ["code", "analysis"],
            "protocol_version": 1,
        })
        assert resp.name == "My Agent"
        assert "code" in resp.capabilities

    def test_execute_response_defaults(self):
        resp = AgentExecuteResponse.from_dict({"output": "hello"})
        assert resp.tokens_used == 0
        assert resp.elapsed_ms == 0.0
        assert resp.metadata == {}


# ── 10. Protocol Client ─────────────────────────────────────────────────


class TestProtocolClient:
    def test_execute(self, fake_agent_server):
        host, port = fake_agent_server
        client = AgentProtocolClient(f"http://{host}:{port}")
        resp = client.execute(AgentExecuteRequest(prompt="Hello"))
        assert "Processed: Hello" in resp.output
        assert resp.tokens_used == 42

    def test_health(self, fake_agent_server):
        host, port = fake_agent_server
        client = AgentProtocolClient(f"http://{host}:{port}")
        resp = client.health()
        assert resp.status == "ok"

    def test_info(self, fake_agent_server):
        host, port = fake_agent_server
        client = AgentProtocolClient(f"http://{host}:{port}")
        resp = client.info()
        assert resp.name == "test-agent"
        assert resp.version == "1.0.0"
        assert resp.protocol_version == AGENT_PROTOCOL_VERSION

    def test_unreachable_raises(self):
        client = AgentProtocolClient("http://127.0.0.1:1", timeout_seconds=1.0)
        with pytest.raises(AgentProtocolError, match="Cannot reach"):
            client.health()

    def test_trailing_slash_stripped(self, fake_agent_server):
        host, port = fake_agent_server
        client = AgentProtocolClient(f"http://{host}:{port}/")
        resp = client.health()
        assert resp.status == "ok"


# ── 11. Windows-specific ────────────────────────────────────────────────


class TestWindowsSpecific:
    def test_fortran_env_on_windows(self, tmp_path):
        """On Windows, FOR_DISABLE_CONSOLE_CTRL_HANDLER passes through if set."""
        os.environ["FOR_DISABLE_CONSOLE_CTRL_HANDLER"] = "1"
        try:
            env = _build_safe_env(str(tmp_path), 9100, [])
            if platform.system() == "Windows":
                assert env.get("FOR_DISABLE_CONSOLE_CTRL_HANDLER") == "1"
        finally:
            os.environ.pop("FOR_DISABLE_CONSOLE_CTRL_HANDLER", None)
