"""Tests for SDK modules — transport, validators, display, skill, memory, photos, tasks, packages, secrets."""

from __future__ import annotations

import io
import json
import sys
from unittest.mock import MagicMock, patch

import pytest

from boxbot.sdk import _validators as v
from boxbot.sdk._transport import _MARKER, collect_response, emit_action


# ---------------------------------------------------------------------------
# Transport layer
# ---------------------------------------------------------------------------


class TestTransport:
    """Test the SDK <-> main process transport mechanism."""

    def test_marker_constant(self):
        assert _MARKER == "__BOXBOT_SDK_ACTION__:"

    def test_emit_action_writes_to_stdout(self):
        fake_stdout = io.StringIO()
        with patch("sys.stdout", fake_stdout):
            emit_action("memory.save", {"content": "test data"})

        output = fake_stdout.getvalue()
        assert _MARKER in output
        # Parse the JSON after the marker
        json_str = output.split(_MARKER, 1)[1].strip()
        data = json.loads(json_str)
        assert data["_sdk"] == "memory.save"
        assert data["content"] == "test data"

    def test_emit_action_includes_sdk_type(self):
        fake_stdout = io.StringIO()
        with patch("sys.stdout", fake_stdout):
            emit_action("display.save", {"name": "weather"})

        output = fake_stdout.getvalue()
        data = json.loads(output.split(_MARKER, 1)[1].strip())
        assert data["_sdk"] == "display.save"
        assert data["name"] == "weather"

    def test_collect_response_reads_json_from_stdin(self):
        response_data = {"approved": True, "reason": "User approved"}
        fake_stdin = io.StringIO(json.dumps(response_data) + "\n")
        with patch("sys.stdin", fake_stdin):
            # We need to also mock select.select to indicate stdin is ready
            with patch("select.select", return_value=([fake_stdin], [], [])):
                result = collect_response(timeout=5)
        assert result["approved"] is True
        assert result["reason"] == "User approved"

    def test_collect_response_timeout_raises(self):
        with patch("select.select", return_value=([], [], [])):
            with pytest.raises(TimeoutError, match="No response"):
                collect_response(timeout=1)

    def test_collect_response_malformed_json_raises(self):
        fake_stdin = io.StringIO("not valid json\n")
        with patch("sys.stdin", fake_stdin):
            with patch("select.select", return_value=([fake_stdin], [], [])):
                with pytest.raises(RuntimeError, match="Malformed response"):
                    collect_response(timeout=5)


# ---------------------------------------------------------------------------
# Validators
# ---------------------------------------------------------------------------


class TestValidators:
    """Test input validation functions and constants."""

    def test_require_raises_on_none(self):
        with pytest.raises(ValueError, match="required"):
            v.require(None, "test_field")

    def test_require_passes_on_value(self):
        v.require("something", "test_field")  # should not raise

    def test_require_str_validates_type(self):
        with pytest.raises(ValueError, match="must be a string"):
            v.require_str(123, "field")

    def test_require_str_rejects_empty(self):
        with pytest.raises(ValueError, match="must not be empty"):
            v.require_str("  ", "field")

    def test_require_str_allows_empty_when_flagged(self):
        result = v.require_str("", "field", allow_empty=True)
        assert result == ""

    def test_require_int_validates_type(self):
        with pytest.raises(ValueError, match="must be an integer"):
            v.require_int("5", "field")

    def test_require_int_validates_range(self):
        with pytest.raises(ValueError, match="must be >="):
            v.require_int(-1, "field", min_val=0)

    def test_require_int_rejects_bool(self):
        with pytest.raises(ValueError, match="must be an integer"):
            v.require_int(True, "field")

    def test_require_float_validates_range(self):
        with pytest.raises(ValueError, match="must be >="):
            v.require_float(-0.5, "field", min_val=0.0)

    def test_require_bool_validates_type(self):
        with pytest.raises(ValueError, match="must be a boolean"):
            v.require_bool(1, "field")

    def test_require_list_validates_type(self):
        with pytest.raises(ValueError, match="must be a list"):
            v.require_list("not a list", "field")

    def test_require_list_accepts_tuple(self):
        result = v.require_list((1, 2, 3), "field")
        assert result == [1, 2, 3]

    def test_require_dict_validates_type(self):
        with pytest.raises(ValueError, match="must be a dict"):
            v.require_dict([1, 2], "field")

    def test_validate_one_of_accepts_valid(self):
        result = v.validate_one_of("boxbot", "theme", v.VALID_THEMES)
        assert result == "boxbot"

    def test_validate_one_of_rejects_invalid(self):
        with pytest.raises(ValueError, match="must be one of"):
            v.validate_one_of("neon", "theme", v.VALID_THEMES)

    def test_validate_padding_int(self):
        assert v.validate_padding(10) == 10

    def test_validate_padding_list_of_two(self):
        assert v.validate_padding([10, 20]) == [10, 20]

    def test_validate_padding_list_of_four(self):
        assert v.validate_padding([10, 20, 30, 40]) == [10, 20, 30, 40]

    def test_validate_padding_rejects_list_of_three(self):
        with pytest.raises(ValueError, match="2 or 4"):
            v.validate_padding([10, 20, 30])

    def test_validate_padding_rejects_negative(self):
        with pytest.raises(ValueError, match="must be >= 0"):
            v.validate_padding(-1)

    def test_validate_ratios_requires_at_least_two(self):
        with pytest.raises(ValueError, match="at least 2"):
            v.validate_ratios([1])

    def test_validate_ratios_rejects_zero(self):
        with pytest.raises(ValueError, match="positive integer"):
            v.validate_ratios([1, 0])

    def test_validate_name_accepts_valid(self):
        assert v.validate_name("my-display_1", "display name") == "my-display_1"

    def test_validate_name_rejects_special_chars(self):
        with pytest.raises(ValueError, match="alphanumeric"):
            v.validate_name("my display!", "display name")

    def test_validate_data_source_builtin(self):
        # Only the live-in-process sources are built-ins now. Weather
        # and calendar moved to ``type: "integration"``.
        config = v.validate_data_source_config("tasks")
        assert config["name"] == "tasks"

    def test_validate_data_source_unknown_builtin_raises(self):
        with pytest.raises(ValueError, match="Unknown built-in"):
            v.validate_data_source_config("unknown_source")

    def test_validate_data_source_weather_no_longer_builtin(self):
        """Regression guard: weather/calendar must NOT validate as
        builtins. Forces specs to use the unified integration path."""
        with pytest.raises(ValueError, match="Unknown built-in"):
            v.validate_data_source_config("weather")
        with pytest.raises(ValueError, match="Unknown built-in"):
            v.validate_data_source_config("calendar")

    def test_validate_data_source_integration(self):
        config = v.validate_data_source_config(
            "weather", source_type="integration",
            inputs={"forecast_days": 7}, refresh=3600,
        )
        assert config["type"] == "integration"
        assert config["inputs"] == {"forecast_days": 7}
        assert config["refresh"] == 3600

    def test_validate_data_source_integration_with_override(self):
        config = v.validate_data_source_config(
            "solar_today", source_type="integration",
            integration="solar", inputs={"date": "2026-05-15"},
        )
        assert config["integration"] == "solar"
        assert config["inputs"]["date"] == "2026-05-15"

    def test_validate_data_source_http_json(self):
        config = v.validate_data_source_config(
            "stock_data", source_type="http_json", url="https://api.example.com"
        )
        assert config["type"] == "http_json"
        assert config["url"] == "https://api.example.com"

    def test_validate_data_source_http_json_requires_url(self):
        with pytest.raises(ValueError, match="requires 'url'"):
            v.validate_data_source_config("bad_source", source_type="http_json")

    def test_valid_themes_contains_all_four(self):
        assert v.VALID_THEMES == {"boxbot", "midnight", "daylight", "classic"}


# ---------------------------------------------------------------------------
# Display builder
# ---------------------------------------------------------------------------


class TestDisplaySDK:
    """Test the JSON-first display SDK.

    The SDK is now a thin wrapper over ``_transport.request``. Each
    test mocks the transport and verifies the SDK forwards the right
    action + payload, raises on errors, and unwraps successful
    responses correctly.
    """

    @pytest.fixture
    def fake_request(self, monkeypatch):
        """Replace ``_transport.request`` with a configurable stub.

        Yields a ``recorder`` dict the test can populate with
        ``{action_type: response_dict}`` to script behavior.
        """
        from boxbot.sdk import _transport

        recorder = {"calls": [], "responses": {}}

        def stub(action_type, payload, *, timeout=30):
            recorder["calls"].append((action_type, payload))
            resp = recorder["responses"].get(action_type)
            if resp is None:
                return {"status": "ok"}
            return resp

        monkeypatch.setattr(_transport, "request", stub)
        return recorder

    def test_list_returns_displays(self, fake_request):
        from boxbot.sdk import display

        fake_request["responses"]["display.list"] = {
            "status": "ok",
            "displays": [{"name": "clock", "source": "builtin"}],
        }
        result = display.list()
        assert result == [{"name": "clock", "source": "builtin"}]

    def test_load_returns_spec_dict(self, fake_request):
        from boxbot.sdk import display

        spec = {"name": "demo", "theme": "boxbot",
                "layout": {"type": "text", "content": "hi"}}
        fake_request["responses"]["display.load"] = {
            "status": "ok", "spec": spec,
        }
        result = display.load("demo")
        assert result == spec
        action, payload = fake_request["calls"][-1]
        assert action == "display.load"
        assert payload == {"name": "demo"}

    def test_save_forwards_spec_and_returns_path(self, fake_request):
        from boxbot.sdk import display

        spec = {"name": "demo", "layout": {"type": "text", "content": "hi"}}
        fake_request["responses"]["display.save"] = {
            "status": "ok", "path": "/data/displays/demo.json",
            "registered": True, "warnings": [],
        }
        result = display.save(spec)
        assert result["path"] == "/data/displays/demo.json"
        assert result["registered"] is True
        action, payload = fake_request["calls"][-1]
        assert action == "display.save"
        assert payload == {"spec": spec}

    def test_save_raises_on_validation_error(self, fake_request):
        from boxbot.sdk import display

        fake_request["responses"]["display.save"] = {
            "status": "error",
            "errors": ["Chart block requires 'data' or 'series'",
                       "Unknown theme 'pastel'"],
        }
        with pytest.raises(RuntimeError) as excinfo:
            display.save({"name": "broken"})
        # Both errors land in the message — the agent sees every problem
        # at once instead of having to fix them serially.
        assert "Chart block" in str(excinfo.value)
        assert "Unknown theme" in str(excinfo.value)

    def test_preview_returns_path_and_warnings(self, fake_request):
        from boxbot.sdk import display

        fake_request["responses"]["display.preview"] = {
            "status": "ok",
            "path": "/tmp/p.png",
            "attached": True,
            "warnings": ["binding '{weather.tempurature}' did not resolve"],
        }
        result = display.preview({"name": "demo"})
        assert result["path"] == "/tmp/p.png"
        assert result["attached"] is True
        assert len(result["warnings"]) == 1

    def test_preview_passes_data_override(self, fake_request):
        from boxbot.sdk import display

        spec = {"name": "demo"}
        override = {"stocks": {"price": "184.20"}}
        display.preview(spec, data=override)
        action, payload = fake_request["calls"][-1]
        assert action == "display.preview"
        assert payload == {"spec": spec, "data": override}

    def test_preview_omits_data_when_none(self, fake_request):
        from boxbot.sdk import display

        display.preview({"name": "demo"})
        _, payload = fake_request["calls"][-1]
        # No ``data`` key in payload when caller didn't pass one — the
        # dispatcher then uses live + static + placeholder data.
        assert "data" not in payload

    def test_delete(self, fake_request):
        from boxbot.sdk import display

        display.delete("demo")
        action, payload = fake_request["calls"][-1]
        assert action == "display.delete"
        assert payload == {"name": "demo"}

    def test_delete_raises_on_error(self, fake_request):
        from boxbot.sdk import display

        fake_request["responses"]["display.delete"] = {
            "status": "error", "error": "is a built-in",
        }
        with pytest.raises(RuntimeError, match="built-in"):
            display.delete("clock")

    def test_describe_source(self, fake_request):
        from boxbot.sdk import display

        fake_request["responses"]["display.describe_source"] = {
            "status": "ok",
            "fields": {"temp": "string"},
            "example": {"temp": "72"},
        }
        result = display.describe_source("weather")
        assert result["fields"]["temp"] == "string"
        assert result["example"]["temp"] == "72"

    def test_schema_returns_block_reference(self, fake_request):
        from boxbot.sdk import display

        fake_request["responses"]["display.schema"] = {
            "status": "ok",
            "blocks": {"text": {"kind": "content", "fields": {}}},
            "themes": ["boxbot", "midnight"],
            "data_source_types": ["builtin", "http_json"],
            "transitions": ["crossfade"],
        }
        result = display.schema()
        assert "text" in result["blocks"]
        assert "boxbot" in result["themes"]

    def test_update_data(self, fake_request):
        from boxbot.sdk import display

        display.update_data("focus", "session",
                            value={"task": "writing", "minutes": 12})
        action, payload = fake_request["calls"][-1]
        assert action == "display.update_data"
        assert payload == {
            "display": "focus",
            "source": "session",
            "value": {"task": "writing", "minutes": 12},
        }

    def test_update_data_raises_on_dispatcher_error(self, fake_request):
        from boxbot.sdk import display

        fake_request["responses"]["display.update_data"] = {
            "status": "error",
            "error": "source 'session' is type 'builtin', not 'static'",
        }
        with pytest.raises(RuntimeError, match="not 'static'"):
            display.update_data("focus", "session", value={})


class TestDisplaySchemaBuilder:
    """The dispatcher's _build_display_schema dumps the block registry.

    Drives ``bb.display.schema()``. Lives in
    ``boxbot.tools._sandbox_actions``; we exercise it directly so the
    test doesn't need a running DisplayManager or transport.
    """

    def test_schema_lists_every_block_in_registry(self):
        from boxbot.displays.blocks import BLOCK_REGISTRY
        from boxbot.tools._sandbox_actions import _build_display_schema

        schema = _build_display_schema()
        assert schema["status"] == "ok"
        # Every block type the parser knows about must show up — if a
        # new block lands without a schema entry, this catches it.
        for block_type in BLOCK_REGISTRY:
            assert block_type in schema["blocks"], (
                f"{block_type} missing from schema"
            )

    def test_schema_text_block_has_valid_size_values(self):
        from boxbot.tools._sandbox_actions import _build_display_schema

        schema = _build_display_schema()
        text = schema["blocks"]["text"]
        assert text["kind"] == "content"
        assert "size" in text["fields"]
        # Semantic taxonomy — distinct from icon/clock t-shirt sizes.
        sizes = text["fields"]["size"]["valid_values"]
        assert "title" in sizes
        assert "body" in sizes
        assert "lg" not in sizes  # t-shirt sizes belong to other blocks

    def test_schema_clock_block_uses_t_shirt_sizes(self):
        from boxbot.tools._sandbox_actions import _build_display_schema

        schema = _build_display_schema()
        clock = schema["blocks"]["clock"]
        sizes = clock["fields"]["size"]["valid_values"]
        assert "lg" in sizes
        assert "title" not in sizes  # semantic sizes belong to text

    def test_schema_marks_containers(self):
        from boxbot.tools._sandbox_actions import _build_display_schema

        schema = _build_display_schema()
        assert schema["blocks"]["row"]["kind"] == "container"
        assert schema["blocks"]["row"]["accepts_children"] is True
        assert schema["blocks"]["text"]["kind"] == "content"
        assert schema["blocks"]["text"]["accepts_children"] is False

    def test_schema_includes_themes_and_transitions(self):
        from boxbot.tools._sandbox_actions import _build_display_schema

        schema = _build_display_schema()
        assert "boxbot" in schema["themes"]
        assert "midnight" in schema["themes"]
        assert "crossfade" in schema["transitions"]
        assert "builtin" in schema["data_source_types"]
        assert "http_json" in schema["data_source_types"]


class TestBlockRoundTrip:
    """Save → load round-trip preserves caller-provided fields.

    Regression test for the bug where ``__post_init__`` stripped values
    matching the dataclass default. ``_construct_block`` now overlays
    JSON-provided params verbatim.
    """

    def test_clock_round_trip_preserves_all_props(self):
        from boxbot.displays.blocks import parse_block

        original = {
            "type": "clock",
            "format": "12h",
            "show_date": False,
            "show_seconds": False,
            "size": "lg",
        }
        block = parse_block(original)
        assert block.to_dict() == original

    @pytest.mark.parametrize("spec", [
        {"type": "row", "gap": 0, "align": "start", "padding": 0},
        {"type": "text", "content": "hi", "size": "body", "color": "default"},
        {"type": "icon", "name": "x", "size": "md"},
        {"type": "card", "padding": 16, "color": "muted"},
    ])
    def test_default_matching_values_survive_round_trip(self, spec):
        from boxbot.displays.blocks import parse_block

        block = parse_block(spec)
        assert block.to_dict() == spec


# ---------------------------------------------------------------------------
# Skill builder
# ---------------------------------------------------------------------------


class TestSkillBuilder:
    """Test the declarative skill builder API.

    Skills are structured prompt data: a SKILL.md (frontmatter + body)
    plus optional bundled scripts and Level 3 sub-docs. Parameters,
    env vars, and scheduling belong to integrations, not skills.
    """

    def test_create_returns_skill_builder(self):
        from boxbot.sdk.skill import create
        s = create("test-skill")
        assert s.name == "test-skill"

    def test_create_rejects_uppercase(self):
        from boxbot.sdk.skill import create
        with pytest.raises(ValueError, match="lowercase"):
            create("TestSkill")

    def test_create_rejects_reserved_names(self):
        from boxbot.sdk.skill import create
        with pytest.raises(ValueError, match="reserved"):
            create("anthropic")
        with pytest.raises(ValueError, match="reserved"):
            create("claude")

    def test_create_rejects_overlong_name(self):
        from boxbot.sdk.skill import create
        with pytest.raises(ValueError, match="≤64"):
            create("a" * 65)

    def test_save_without_description_raises(self):
        from boxbot.sdk.skill import create
        s = create("incomplete")
        s.body = "# Hi"
        with pytest.raises(ValueError, match="description is required"):
            s.save()

    def test_save_without_body_raises(self):
        from boxbot.sdk.skill import create
        s = create("no-body")
        s.description = "A skill without a body"
        with pytest.raises(ValueError, match="body is required"):
            s.save()

    def test_description_rejects_overlong(self):
        from boxbot.sdk.skill import create
        s = create("longdesc")
        with pytest.raises(ValueError, match="≤1024"):
            s.description = "x" * 1025

    def test_description_rejects_xml(self):
        from boxbot.sdk.skill import create
        s = create("xmldesc")
        with pytest.raises(ValueError, match="XML"):
            s.description = "Use this <evil>tag</evil>"

    def test_save_emits_minimal_payload(self):
        from boxbot.sdk.skill import create
        s = create("weather")
        s.description = "Get weather forecasts. Use when asked about weather."
        s.body = "# Weather\n\nUse bb.weather.forecast()."

        fake_stdout = io.StringIO()
        with patch("sys.stdout", fake_stdout):
            s.save()
        output = fake_stdout.getvalue()
        data = json.loads(output.split(_MARKER, 1)[1].strip())
        assert data["_sdk"] == "skill.save"
        assert data["name"] == "weather"
        assert data["description"].startswith("Get weather")
        assert data["body"].startswith("# Weather")
        assert "scripts" not in data
        assert "resources" not in data

    def test_add_script_emits_in_payload(self):
        from boxbot.sdk.skill import create
        s = create("with-script")
        s.description = "Skill bundling a helper script."
        s.body = "# With script\n\nSee scripts/helper.py."
        s.add_script("helper.py", "def go():\n    return 42\n")

        fake_stdout = io.StringIO()
        with patch("sys.stdout", fake_stdout):
            s.save()
        data = json.loads(fake_stdout.getvalue().split(_MARKER, 1)[1].strip())
        assert len(data["scripts"]) == 1
        assert data["scripts"][0]["filename"] == "helper.py"
        assert "def go" in data["scripts"][0]["content"]

    def test_add_script_rejects_path_traversal(self):
        from boxbot.sdk.skill import create
        s = create("badpath")
        s.description = "x" * 30
        s.body = "# x"
        with pytest.raises(ValueError, match="bare basename"):
            s.add_script("../escape.py", "x")

    def test_add_script_requires_py_extension(self):
        from boxbot.sdk.skill import create
        s = create("ext")
        s.description = "x" * 30
        s.body = "# x"
        with pytest.raises(ValueError, match=r"\.py"):
            s.add_script("helper.txt", "x")

    def test_add_resource_emits_in_payload(self):
        from boxbot.sdk.skill import create
        s = create("with-ref")
        s.description = "Skill with a Level 3 sub-doc."
        s.body = "# Skill\n\nSee REFERENCE.md."
        s.add_resource("REFERENCE.md", "# Reference\n\n…")

        fake_stdout = io.StringIO()
        with patch("sys.stdout", fake_stdout):
            s.save()
        data = json.loads(fake_stdout.getvalue().split(_MARKER, 1)[1].strip())
        assert data["resources"][0]["filename"] == "REFERENCE.md"

    def test_add_resource_rejects_skill_md(self):
        from boxbot.sdk.skill import create
        s = create("res-skill-md")
        s.description = "x" * 30
        s.body = "# x"
        with pytest.raises(ValueError, match="reserved"):
            s.add_resource("SKILL.md", "x")

    def test_delete_emits_action_and_returns_response(self, monkeypatch):
        from boxbot.sdk import _transport, skill

        calls: list[tuple[str, dict]] = []

        def stub(action_type, payload, *, timeout=30):
            calls.append((action_type, payload))
            return {
                "status": "ok",
                "name": payload["name"],
                "path": f"/tmp/skills/{payload['name']}",
            }

        monkeypatch.setattr(_transport, "request", stub)
        result = skill.delete("doomed")
        assert calls == [("skill.delete", {"name": "doomed"})]
        assert result["status"] == "ok"
        assert result["name"] == "doomed"

    def test_delete_validates_name(self):
        from boxbot.sdk import skill

        with pytest.raises(ValueError):
            skill.delete("../escape")

    def test_delete_raises_on_dispatcher_error(self, monkeypatch):
        from boxbot.sdk import _transport, skill

        def stub(action_type, payload, *, timeout=30):
            return {
                "status": "forbidden",
                "message": "skill 'bb' is not agent-authored",
            }

        monkeypatch.setattr(_transport, "request", stub)
        with pytest.raises(RuntimeError, match="not agent-authored"):
            skill.delete("bb")


# ---------------------------------------------------------------------------
# SDK memory module
# ---------------------------------------------------------------------------


class TestSDKMemory:
    """Test the SDK memory module functions."""

    def test_save_emits_action_and_returns_id(self):
        from boxbot.sdk import memory
        fake_stdout = io.StringIO()
        # save() now uses request() — main process replies with {ok, id}.
        with patch("sys.stdout", fake_stdout), \
             patch("boxbot.sdk._transport.collect_response",
                   return_value={"status": "ok", "id": "mem-001"}):
            memory_id = memory.save(
                content="Jacob likes coffee",
                memory_type="person",
                people=["Jacob"],
                importance=0.8,
            )
        output = fake_stdout.getvalue()
        data = json.loads(output.split(_MARKER, 1)[1].strip())
        assert data["_sdk"] == "memory.save"
        assert data["content"] == "Jacob likes coffee"
        assert data["importance"] == 0.8
        assert data["_expects_response"] is True
        assert memory_id == "mem-001"

    def test_save_raises_on_dispatcher_error(self):
        from boxbot.sdk import memory
        fake_stdout = io.StringIO()
        with patch("sys.stdout", fake_stdout), \
             patch("boxbot.sdk._transport.collect_response",
                   return_value={"status": "error",
                                 "message": "no handler"}):
            with pytest.raises(MemoryError, match="no handler"):
                memory.save(content="x", memory_type="household")

    def test_save_validates_memory_type(self):
        from boxbot.sdk import memory
        with pytest.raises(ValueError, match="must be one of"):
            memory.save(content="test", memory_type="invalid")

    def test_delete_emits_action(self):
        from boxbot.sdk import memory
        fake_stdout = io.StringIO()
        with patch("sys.stdout", fake_stdout), \
             patch("boxbot.sdk._transport.collect_response",
                   return_value={"status": "ok"}):
            memory.delete("mem-123")
        output = fake_stdout.getvalue()
        data = json.loads(output.split(_MARKER, 1)[1].strip())
        assert data["_sdk"] == "memory.delete"
        assert data["id"] == "mem-123"
        assert data["_expects_response"] is True

    def test_memory_record_properties(self):
        from boxbot.sdk.memory import MemoryRecord
        record = MemoryRecord({
            "id": "m1",
            "content": "Test",
            "type": "person",
            "people": ["Alice"],
            "tags": ["health"],
            "importance": 0.9,
            "created_at": "2025-01-01",
        })
        assert record.id == "m1"
        assert record.content == "Test"
        assert record.memory_type == "person"
        assert record.importance == 0.9
        assert "Alice" in record.people


# ---------------------------------------------------------------------------
# SDK secrets module
# ---------------------------------------------------------------------------


class TestSDKSecrets:
    """Test the SDK secrets module.

    Each call now round-trips through ``_transport.request`` — the SDK
    is a thin wrapper that forwards the action and unwraps the
    response. We stub the transport so tests don't need real stdin/stdout.
    """

    @pytest.fixture
    def fake_request(self, monkeypatch):
        from boxbot.sdk import _transport

        recorder = {"calls": [], "responses": {}}

        def stub(action_type, payload, *, timeout=30):
            recorder["calls"].append((action_type, payload))
            return recorder["responses"].get(action_type, {"status": "ok"})

        monkeypatch.setattr(_transport, "request", stub)
        return recorder

    def test_store_round_trips(self, fake_request):
        from boxbot.sdk import secrets

        fake_request["responses"]["secrets.store"] = {
            "status": "ok", "name": "POLYGON_API_KEY", "previous": "created",
        }
        result = secrets.store("POLYGON_API_KEY", "pk_live_abc")
        assert result["status"] == "ok"
        assert result["previous"] == "created"
        assert fake_request["calls"][0] == (
            "secrets.store",
            {"name": "POLYGON_API_KEY", "value": "pk_live_abc"},
        )

    def test_delete_round_trips(self, fake_request):
        from boxbot.sdk import secrets

        secrets.delete("POLYGON_API_KEY")
        assert fake_request["calls"][0] == (
            "secrets.delete", {"name": "POLYGON_API_KEY"}
        )

    def test_list_returns_names(self, fake_request):
        from boxbot.sdk import secrets

        fake_request["responses"]["secrets.list"] = {
            "status": "ok",
            "secrets": [{"name": "ALPHA", "stored_at": "2026-05-02T00:00:00Z"}],
        }
        result = secrets.list()
        assert result["status"] == "ok"
        assert result["secrets"][0]["name"] == "ALPHA"

    def test_has_returns_true_when_present(self, fake_request):
        from boxbot.sdk import secrets

        fake_request["responses"]["secrets.use"] = {
            "status": "ok", "env_var": "BOXBOT_SECRET_POLYGON_API_KEY",
        }
        assert secrets.has("POLYGON_API_KEY") is True

    def test_has_returns_false_when_missing(self, fake_request):
        from boxbot.sdk import secrets

        fake_request["responses"]["secrets.use"] = {"status": "missing"}
        assert secrets.has("POLYGON_API_KEY") is False

    def test_use_returns_env_var_iff_present(self, fake_request):
        from boxbot.sdk import secrets

        fake_request["responses"]["secrets.use"] = {
            "status": "ok", "env_var": "BOXBOT_SECRET_POLYGON_API_KEY",
        }
        assert secrets.use("POLYGON_API_KEY") == "BOXBOT_SECRET_POLYGON_API_KEY"

    def test_use_returns_none_when_missing(self, fake_request):
        from boxbot.sdk import secrets

        fake_request["responses"]["secrets.use"] = {"status": "missing"}
        assert secrets.use("POLYGON_API_KEY") is None


# ---------------------------------------------------------------------------
# SDK packages module
# ---------------------------------------------------------------------------


class TestSDKPackages:
    """Test the SDK packages module."""

    def test_package_result_repr(self):
        from boxbot.sdk.packages import PackageResult
        approved = PackageResult(approved=True)
        assert "approved" in repr(approved)
        denied = PackageResult(approved=False, reason="Not needed")
        assert "denied" in repr(denied)
        assert "Not needed" in repr(denied)


# ---------------------------------------------------------------------------
# SDK audio module
# ---------------------------------------------------------------------------


class TestSDKAudio:
    """bb.audio.play forwards to ``_transport.request("audio.play", ...)``
    and surfaces the dispatcher response or raises ``AudioError``."""

    @pytest.fixture
    def fake_request(self, monkeypatch):
        from boxbot.sdk import _transport

        recorder = {"calls": [], "responses": {}}

        def stub(action_type, payload, *, timeout=30):
            recorder["calls"].append((action_type, payload, timeout))
            resp = recorder["responses"].get(action_type)
            if resp is None:
                return {"status": "ok", "duration_ms": 0,
                        "elapsed_ms": 0, "format": "wav",
                        "sample_rate": 24000, "channels": 1}
            return resp

        monkeypatch.setattr(_transport, "request", stub)
        return recorder

    def test_play_forwards_path(self, fake_request):
        from boxbot.sdk import audio

        result = audio.play("audio/chime.wav")
        action, payload, _timeout = fake_request["calls"][0]
        assert action == "audio.play"
        assert payload == {"path": "audio/chime.wav"}
        assert result["status"] == "ok"

    def test_play_includes_volume_when_set(self, fake_request):
        from boxbot.sdk import audio

        audio.play("audio/chime.wav", volume=0.5)
        _, payload, _ = fake_request["calls"][0]
        assert payload["volume"] == 0.5

    def test_play_omits_volume_when_default(self, fake_request):
        from boxbot.sdk import audio

        audio.play("audio/chime.wav")
        _, payload, _ = fake_request["calls"][0]
        assert "volume" not in payload

    def test_play_validates_volume_range(self, fake_request):
        from boxbot.sdk import audio

        with pytest.raises(ValueError):
            audio.play("audio/chime.wav", volume=2.0)
        with pytest.raises(ValueError):
            audio.play("audio/chime.wav", volume=-0.1)

    def test_play_requires_non_empty_path(self, fake_request):
        from boxbot.sdk import audio

        with pytest.raises(ValueError):
            audio.play("")

    def test_play_raises_audio_error_on_dispatcher_error(self, fake_request):
        from boxbot.sdk import audio

        fake_request["responses"]["audio.play"] = {
            "status": "error", "error": "audio file not found: x"
        }
        with pytest.raises(audio.AudioError, match="not found"):
            audio.play("missing.wav")

    def test_play_returns_interrupted_status(self, fake_request):
        from boxbot.sdk import audio

        fake_request["responses"]["audio.play"] = {
            "status": "interrupted", "duration_ms": 30000,
            "elapsed_ms": 4200, "format": "mp3",
            "sample_rate": 44100, "channels": 2,
        }
        result = audio.play("music/song.mp3")
        assert result["status"] == "interrupted"
        assert result["elapsed_ms"] == 4200
        assert result["format"] == "mp3"

    def test_play_raises_on_unexpected_status(self, fake_request):
        from boxbot.sdk import audio

        fake_request["responses"]["audio.play"] = {"status": "wat"}
        with pytest.raises(audio.AudioError, match="unexpected"):
            audio.play("audio/chime.wav")
