"""Tests for genui form builder (Django ORM-style)."""

import json
import pytest
from unittest.mock import Mock

from basion_agent.genui import (
    Option,
    Field,
    Text,
    Number,
    Select,
    MultiSelect,
    Checkbox,
    CheckboxGroup,
    Switch,
    Slider,
    DatePicker,
    Hidden,
    FileField,
    Form,
    MultiStepForm,
    Step,
    Confirmation,
    ValidationResult,
    Card,
    CardSection,
    CardButton,
    Accordion,
    AccordionItem,
)
from basion_agent.streamer import Streamer
from basion_agent.message import Message


# ── Option ──────────────────────────────────────────────────────────


class TestOption:
    def test_to_dict(self):
        opt = Option(value="val", label="Label")
        assert opt.to_dict() == {"value": "val", "label": "Label"}


# ── Field to_dict tests ─────────────────────────────────────────────


class TestText:
    def test_to_dict_minimal(self):
        f = Text(label="Name")
        f._name = "name"
        assert f.to_dict() == {
            "type": "text",
            "name": "name",
            "label": "Name",
            "required": True,
        }

    def test_to_dict_all_options(self):
        f = Text(label="Bio", required=False, placeholder="Tell us...", min_length=10, max_length=500, multiline=True)
        f._name = "bio"
        d = f.to_dict()
        assert d["placeholder"] == "Tell us..."
        assert d["min_length"] == 10
        assert d["max_length"] == 500
        assert d["multiline"] is True
        assert d["required"] is False

    def test_optional_fields_omitted(self):
        f = Text(label="Name")
        f._name = "name"
        d = f.to_dict()
        assert "placeholder" not in d
        assert "min_length" not in d
        assert "multiline" not in d

    def test_parse(self):
        f = Text(label="Name")
        assert f.parse("John") == "John"
        assert f.parse(None) == ""
        assert f.parse(123) == "123"


class TestNumber:
    def test_to_dict(self):
        f = Number(label="Age", min=0, max=150, step=1)
        f._name = "age"
        d = f.to_dict()
        assert d["type"] == "number"
        assert d["min"] == 0
        assert d["max"] == 150
        assert d["step"] == 1

    def test_to_dict_optional_omitted(self):
        f = Number(label="Count")
        f._name = "count"
        d = f.to_dict()
        assert "min" not in d
        assert "max" not in d
        assert "step" not in d

    def test_parse(self):
        f = Number(label="Age")
        assert f.parse(25) == 25.0
        assert f.parse("3.14") == 3.14
        assert f.parse(None) == 0.0


class TestSelect:
    def test_to_dict(self):
        f = Select(label="Color", options=[Option(value="r", label="Red"), Option(value="b", label="Blue")], allow_custom=True)
        f._name = "color"
        d = f.to_dict()
        assert d["type"] == "select"
        assert d["options"] == [{"value": "r", "label": "Red"}, {"value": "b", "label": "Blue"}]
        assert d["allow_custom"] is True
        assert d["is_searchable"] is False

    def test_allow_custom_always_present(self):
        f = Select(label="X")
        f._name = "x"
        assert "allow_custom" in f.to_dict()

    def test_is_searchable_always_present(self):
        f = Select(label="X")
        f._name = "x"
        d = f.to_dict()
        assert "is_searchable" in d
        assert d["is_searchable"] is False

    def test_is_searchable_true(self):
        f = Select(label="Color", is_searchable=True)
        f._name = "color"
        d = f.to_dict()
        assert d["is_searchable"] is True

    def test_parse(self):
        f = Select(label="Color")
        assert f.parse("red") == "red"
        assert f.parse(None) == ""


class TestMultiSelect:
    def test_to_dict(self):
        f = MultiSelect(label="Tags", options=[Option(value="a", label="A")], min_selections=1, max_selections=5, allow_custom=True)
        f._name = "tags"
        d = f.to_dict()
        assert d["type"] == "multi_select"
        assert d["min_selections"] == 1
        assert d["max_selections"] == 5
        assert d["allow_custom"] is True
        assert d["is_searchable"] is False

    def test_allow_custom_always_present(self):
        f = MultiSelect(label="X")
        f._name = "x"
        assert "allow_custom" in f.to_dict()

    def test_is_searchable_always_present(self):
        f = MultiSelect(label="X")
        f._name = "x"
        d = f.to_dict()
        assert "is_searchable" in d
        assert d["is_searchable"] is False

    def test_is_searchable_true(self):
        f = MultiSelect(label="Tags", is_searchable=True)
        f._name = "tags"
        d = f.to_dict()
        assert d["is_searchable"] is True

    def test_parse(self):
        f = MultiSelect(label="Tags")
        assert f.parse(["a", "b"]) == ["a", "b"]
        assert f.parse(None) == []
        assert f.parse("single") == ["single"]


class TestCheckbox:
    def test_to_dict(self):
        f = Checkbox(label="Agree", default=True)
        f._name = "agree"
        d = f.to_dict()
        assert d["type"] == "checkbox"
        assert d["default"] is True

    def test_parse(self):
        f = Checkbox(label="Agree")
        assert f.parse(True) is True
        assert f.parse(False) is False
        assert f.parse(None) is False


class TestCheckboxGroup:
    def test_to_dict(self):
        f = CheckboxGroup(label="Items", options=[Option(value="a", label="A")], min_selections=1, max_selections=3)
        f._name = "items"
        d = f.to_dict()
        assert d["type"] == "checkbox_group"
        assert d["min_selections"] == 1

    def test_parse(self):
        f = CheckboxGroup(label="Items")
        assert f.parse(["a", "b"]) == ["a", "b"]
        assert f.parse(None) == []
        assert f.parse("single") == ["single"]  # Non-list coercion


class TestSwitch:
    def test_to_dict(self):
        f = Switch(label="Notify", default=True)
        f._name = "notify"
        d = f.to_dict()
        assert d["type"] == "switch"
        assert d["default"] is True

    def test_parse(self):
        f = Switch(label="Notify")
        assert f.parse(True) is True
        assert f.parse(None) is False


class TestSlider:
    def test_to_dict(self):
        f = Slider(label="Pain", min=1, max=10, step=1, default=5)
        f._name = "pain"
        d = f.to_dict()
        assert d["type"] == "slider"
        assert d["min"] == 1
        assert d["max"] == 10
        assert d["default"] == 5

    def test_to_dict_defaults(self):
        f = Slider(label="Volume")
        f._name = "volume"
        d = f.to_dict()
        assert d["min"] == 0
        assert d["max"] == 100
        assert d["step"] == 1
        assert "default" not in d

    def test_parse(self):
        f = Slider(label="Pain")
        assert f.parse(5) == 5.0
        assert f.parse(None) == 0.0


class TestDatePicker:
    def test_to_dict(self):
        f = DatePicker(label="DOB", min_date="1900-01-01", max_date="2026-12-31", format="YYYY-MM-DD")
        f._name = "dob"
        d = f.to_dict()
        assert d["type"] == "date_picker"
        assert d["min_date"] == "1900-01-01"

    def test_optional_fields_omitted(self):
        f = DatePicker(label="DOB")
        f._name = "dob"
        d = f.to_dict()
        assert "min_date" not in d

    def test_parse(self):
        f = DatePicker(label="DOB")
        assert f.parse("2024-01-15") == "2024-01-15"
        assert f.parse(None) == ""


class TestHidden:
    def test_to_dict(self):
        f = Hidden(label="Context", value="abc123")
        f._name = "ctx"
        d = f.to_dict()
        assert d["type"] == "hidden"
        assert d["required"] is False
        assert d["value"] == "abc123"

    def test_to_dict_no_value(self):
        f = Hidden(label="Context")
        f._name = "ctx"
        d = f.to_dict()
        assert "value" not in d

    def test_parse(self):
        f = Hidden(label="Context")
        assert f.parse({"key": "val"}) == {"key": "val"}
        assert f.parse(None) is None


class TestFileField:
    def test_to_dict(self):
        f = FileField(label="Upload", accept=["image/*"], max_size=5_000_000)
        f._name = "doc"
        d = f.to_dict()
        assert d["type"] == "file"
        assert d["name"] == "doc"
        assert d["accept"] == ["image/*"]
        assert d["max_size"] == 5_000_000

    def test_to_dict_optional_omitted(self):
        f = FileField(label="Upload")
        f._name = "doc"
        d = f.to_dict()
        assert "accept" not in d
        assert "max_size" not in d

    def test_sync_parse_passthrough(self):
        f = FileField(label="Upload")
        assert f.parse("some-key") == "some-key"
        assert f.parse(None) is None

    @pytest.mark.asyncio
    async def test_async_parse_from_dict(self):
        f = FileField(label="Upload")
        mock_client = Mock()
        result = await f.async_parse(
            {"id": "a1", "url": "/files/a1", "filename": "test.pdf", "content_type": "application/pdf", "size": 1000},
            mock_client,
        )
        assert result.filename == "test.pdf"
        assert result.content_type == "application/pdf"
        assert result.size == 1000

    @pytest.mark.asyncio
    async def test_async_parse_from_string(self):
        f = FileField(label="Upload")
        mock_client = Mock()
        result = await f.async_parse("some-object-key", mock_client)
        assert result.object_key == "some-object-key"
        assert result.url == "some-object-key"

    @pytest.mark.asyncio
    async def test_async_parse_none(self):
        f = FileField(label="Upload")
        mock_client = Mock()
        result = await f.async_parse(None, mock_client)
        assert result is None


# ── Form declaration tests ──────────────────────────────────────────


class TestFormDeclaration:
    def test_fields_collected(self):
        class MyForm(Form):
            name = Text(label="Name")
            age = Number(label="Age")

        assert "name" in MyForm._fields
        assert "age" in MyForm._fields
        assert MyForm._fields["name"]._name == "name"
        assert MyForm._fields["age"]._name == "age"

    def test_any_attribute_name_allowed_as_field(self):
        """title, description, etc. can be used as field names since metadata is in __init__."""

        class MyForm(Form):
            title = Text(label="Title Field")
            description = Text(label="Description Field")

        assert "title" in MyForm._fields
        assert "description" in MyForm._fields
        assert len(MyForm._fields) == 2

    def test_class_attribute_returns_field_object(self):
        class MyForm(Form):
            name = Text(label="Name")

        assert isinstance(MyForm.name, Text)
        assert MyForm.name.label == "Name"

    def test_to_dict(self):
        class MyForm(Form):
            name = Text(label="Full Name", placeholder="John")
            score = Slider(label="Score", min=1, max=5)

        form = MyForm(title="Report", description="Describe")
        d = form.to_dict()
        assert d["type"] == "form"
        assert d["title"] == "Report"
        assert d["description"] == "Describe"
        assert d["submit_label"] == "Submit"
        assert d["allow_cancel"] is False
        assert "cancel_label" not in d  # omitted when allow_cancel is False
        assert "steps" not in d
        assert len(d["fields"]) == 2
        assert d["fields"][0]["name"] == "name"
        assert d["fields"][0]["type"] == "text"
        assert d["fields"][1]["name"] == "score"
        assert d["fields"][1]["type"] == "slider"

    def test_to_dict_no_description(self):
        class MyForm(Form):
            name = Text(label="Name")

        d = MyForm(title="T").to_dict()
        assert "description" not in d

    def test_to_dict_custom_buttons(self):
        class MyForm(Form):
            name = Text(label="Name")

        d = MyForm(title="T", submit_label="Save Changes", allow_cancel=True, cancel_label="Discard").to_dict()
        assert d["submit_label"] == "Save Changes"
        assert d["allow_cancel"] is True
        assert d["cancel_label"] == "Discard"

    def test_to_dict_allow_cancel_default_label(self):
        class MyForm(Form):
            name = Text(label="Name")

        d = MyForm(title="T", allow_cancel=True).to_dict()
        assert d["allow_cancel"] is True
        assert d["cancel_label"] == "Cancel"


# ── Form.parse tests ────────────────────────────────────────────────


class TestFormParse:
    @pytest.mark.asyncio
    async def test_parse_from_json_string(self):
        class MyForm(Form):
            name = Text(label="Name")
            age = Number(label="Age")

        data = await MyForm.parse('{"name": "Alice", "age": 30}')
        assert data.name == "Alice"
        assert data.age == 30.0

    @pytest.mark.asyncio
    async def test_parse_from_dict(self):
        class MyForm(Form):
            name = Text(label="Name")

        data = await MyForm.parse({"name": "Bob"})
        assert data.name == "Bob"

    @pytest.mark.asyncio
    async def test_parse_from_message(self):
        class MyForm(Form):
            name = Text(label="Name")
            confirmed = Switch(label="Confirmed?")

        msg = Message(
            content='{"name": "Carol", "confirmed": true}',
            conversation_id="c1",
            user_id="u1",
            headers={},
            raw_body={},
        )
        data = await MyForm.parse(msg)
        assert data.name == "Carol"
        assert data.confirmed is True

    @pytest.mark.asyncio
    async def test_parse_missing_field_uses_default(self):
        class MyForm(Form):
            name = Text(label="Name")
            score = Slider(label="Score", min=0, max=10)

        data = await MyForm.parse('{"name": "Dan"}')
        assert data.name == "Dan"
        assert data.score == 0.0

    @pytest.mark.asyncio
    async def test_parse_returns_correct_type(self):
        class MyForm(Form):
            name = Text(label="Name")

        data = await MyForm.parse('{"name": "Eve"}')
        assert isinstance(data, MyForm)

    @pytest.mark.asyncio
    async def test_parse_invalid_source_type(self):
        class MyForm(Form):
            name = Text(label="Name")

        with pytest.raises(TypeError, match="Expected Message, str, or dict"):
            await MyForm.parse(42)

    @pytest.mark.asyncio
    async def test_repr(self):
        class MyForm(Form):
            x = Text(label="X")

        data = await MyForm.parse('{"x": "hello"}')
        assert "MyForm" in repr(data)
        assert "hello" in repr(data)

    @pytest.mark.asyncio
    async def test_parse_with_file_field_and_client(self):
        class UploadForm(Form):
            doc = FileField(label="Document")

        mock_client = Mock()
        data = await UploadForm.parse(
            '{"doc": {"id": "a1", "url": "/files/a1", "filename": "test.pdf", "content_type": "application/pdf", "size": 1000}}',
            attachment_client=mock_client,
        )
        assert data.doc.filename == "test.pdf"

    @pytest.mark.asyncio
    async def test_parse_with_file_field_no_client(self):
        class UploadForm(Form):
            doc = FileField(label="Document")

        data = await UploadForm.parse('{"doc": "some-key"}')
        assert data.doc == "some-key"  # Falls back to sync parse


# ── Step tests ──────────────────────────────────────────────────────


class TestStep:
    def test_fields_collected(self):
        class Info(Step):
            name = Text(label="Name")
            age = Number(label="Age")

        assert "name" in Info._fields
        assert "age" in Info._fields

    def test_to_dict_with_label(self):
        class Info(Step):
            name = Text(label="Name")

        step = Info(label="Basic Info")
        step._name = "info"
        d = step.to_dict()
        assert d["id"] == "info"
        assert d["label"] == "Basic Info"
        assert len(d["fields"]) == 1
        assert d["fields"][0]["name"] == "name"

    def test_to_dict_label_fallback_to_class_name(self):
        class Demographics(Step):
            name = Text(label="Name")

        step = Demographics()
        step._name = "demographics"
        d = step.to_dict()
        assert d["label"] == "Demographics"

    def test_to_dict_with_description(self):
        class Info(Step):
            name = Text(label="Name")

        step = Info(label="Info", description="Enter your details")
        step._name = "info"
        d = step.to_dict()
        assert d["description"] == "Enter your details"

    def test_to_dict_no_description_omitted(self):
        class Info(Step):
            name = Text(label="Name")

        step = Info(label="Info")
        step._name = "info"
        d = step.to_dict()
        assert "description" not in d


# ── MultiStepForm tests ────────────────────────────────────────────


class TestMultiStepFormDeclaration:
    def test_steps_collected(self):
        class InfoStep(Step):
            name = Text(label="Name")

        class PainStep(Step):
            level = Slider(label="Level", min=0, max=10)

        class Intake(MultiStepForm):
            info = InfoStep(label="Info")
            pain = PainStep(label="Pain")

        assert "info" in Intake._steps
        assert "pain" in Intake._steps
        assert "name" in Intake._fields
        assert "level" in Intake._fields

    def test_step_name_set_from_attribute(self):
        class InfoStep(Step):
            name = Text(label="Name")

        class Intake(MultiStepForm):
            demographics = InfoStep(label="Info")

        assert Intake._steps["demographics"]._name == "demographics"

    def test_to_dict(self):
        class S1(Step):
            name = Text(label="Name")

        class S2(Step):
            pain = Slider(label="Pain", min=1, max=10)

        class Intake(MultiStepForm):
            step1 = S1(label="Step 1")
            step2 = S2(label="Step 2")

        form = Intake(title="Intake")
        d = form.to_dict()
        assert d["type"] == "multi_step_form"
        assert d["title"] == "Intake"
        assert d["submit_label"] == "Submit"
        assert d["allow_cancel"] is False
        assert "cancel_label" not in d
        assert "fields" not in d
        assert len(d["steps"]) == 2
        assert d["steps"][0]["id"] == "step1"
        assert d["steps"][0]["label"] == "Step 1"
        assert d["steps"][1]["id"] == "step2"
        assert d["steps"][1]["label"] == "Step 2"

    def test_to_dict_step_label_falls_back_to_class_name(self):
        class Demographics(Step):
            name = Text(label="Name")

        class Intake(MultiStepForm):
            demographics = Demographics()

        form = Intake(title="Intake")
        d = form.to_dict()
        assert d["steps"][0]["id"] == "demographics"
        assert d["steps"][0]["label"] == "Demographics"

    def test_to_dict_step_description(self):
        class InfoStep(Step):
            name = Text(label="Name")

        class Intake(MultiStepForm):
            info = InfoStep(label="Info", description="Enter your details")

        form = Intake(title="Intake")
        d = form.to_dict()
        assert d["steps"][0]["description"] == "Enter your details"

    def test_to_dict_step_no_description_omitted(self):
        class InfoStep(Step):
            name = Text(label="Name")

        class Intake(MultiStepForm):
            info = InfoStep(label="Info")

        form = Intake(title="Intake")
        d = form.to_dict()
        assert "description" not in d["steps"][0]

    def test_to_dict_with_description(self):
        class S1(Step):
            name = Text(label="Name")

        class Intake(MultiStepForm):
            s1 = S1(label="S1")

        d = Intake(title="Intake", description="Fill out all steps").to_dict()
        assert d["description"] == "Fill out all steps"

    def test_to_dict_custom_buttons(self):
        class S1(Step):
            name = Text(label="Name")

        class Intake(MultiStepForm):
            s1 = S1(label="S1")

        d = Intake(title="Intake", submit_label="Complete Intake", allow_cancel=True, cancel_label="Skip").to_dict()
        assert d["submit_label"] == "Complete Intake"
        assert d["allow_cancel"] is True
        assert d["cancel_label"] == "Skip"

    def test_duplicate_field_names_raises(self):
        class S1(Step):
            name = Text(label="Name")

        class S2(Step):
            name = Text(label="Also Name")

        with pytest.raises(ValueError, match="Duplicate field name"):
            class Bad(MultiStepForm):
                step1 = S1(label="S1")
                step2 = S2(label="S2")

    @pytest.mark.asyncio
    async def test_parse_flattened(self):
        class S1(Step):
            name = Text(label="Name")

        class S2(Step):
            level = Slider(label="Level", min=0, max=10)

        class Intake(MultiStepForm):
            step1 = S1(label="Step 1")
            step2 = S2(label="Step 2")

        data = await Intake.parse('{"name": "Frank", "level": 7}')
        assert data.name == "Frank"
        assert data.level == 7.0

    @pytest.mark.asyncio
    async def test_parse_returns_correct_type(self):
        class S1(Step):
            x = Text(label="X")

        class Intake(MultiStepForm):
            s1 = S1(label="S1")

        data = await Intake.parse('{"x": "hi"}')
        assert isinstance(data, Intake)

    @pytest.mark.asyncio
    async def test_repr(self):
        class S1(Step):
            x = Text(label="X")

        class Intake(MultiStepForm):
            s1 = S1(label="S1")

        data = await Intake.parse('{"x": "hi"}')
        assert "Intake" in repr(data)
        assert "hi" in repr(data)


# ── Streamer integration tests ──────────────────────────────────────


class TestStreamerIntegration:
    def _make_streamer(self):
        mock_gateway = Mock()
        mock_message = Mock()
        mock_message.conversation_id = "conv-1"
        mock_message.user_id = "user-1"
        return Streamer(
            agent_name="test",
            gateway_client=mock_gateway,
            original_message=mock_message,
        )

    def test_accepts_form_instance(self):
        class MyForm(Form):
            name = Text(label="Name")

        form = MyForm(title="Test")
        s = self._make_streamer()
        result = s.set_response_schema(form)
        assert result is s
        assert isinstance(s._response_schema, dict)
        assert s._response_schema["type"] == "form"
        assert s._response_schema["fields"][0]["name"] == "name"

    def test_accepts_multi_step_form_instance(self):
        class S1(Step):
            name = Text(label="Name")

        class Intake(MultiStepForm):
            s1 = S1(label="Step 1")

        form = Intake(title="Intake")
        s = self._make_streamer()
        s.set_response_schema(form)
        assert isinstance(s._response_schema, dict)
        assert len(s._response_schema["steps"]) == 1

    def test_accepts_raw_dict(self):
        s = self._make_streamer()
        schema = {"type": "object", "properties": {"name": {"type": "string"}}}
        s.set_response_schema(schema)
        assert s._response_schema is schema

    def test_accepts_confirmation_instance(self):
        confirm = Confirmation(title="Delete", message="Are you sure?", variant="destructive")
        s = self._make_streamer()
        result = s.set_response_schema(confirm)
        assert result is s
        assert isinstance(s._response_schema, dict)
        assert s._response_schema["type"] == "confirmation"
        assert s._response_schema["title"] == "Delete"
        assert s._response_schema["variant"] == "destructive"


# ── Field ordering tests ─────────────────────────────────────────


class TestFieldOrdering:
    def test_form_preserves_declaration_order(self):
        class MyForm(Form):
            z_field = Text(label="Z")
            a_field = Text(label="A")
            m_field = Text(label="M")

        field_names = list(MyForm._fields.keys())
        assert field_names == ["z_field", "a_field", "m_field"]

    def test_to_dict_preserves_field_order(self):
        class MyForm(Form):
            z_field = Text(label="Z")
            a_field = Text(label="A")

        fields = MyForm(title="Order Test").to_dict()["fields"]
        assert fields[0]["name"] == "z_field"
        assert fields[1]["name"] == "a_field"

    def test_step_preserves_declaration_order(self):
        class MyStep(Step):
            z = Number(label="Z")
            a = Number(label="A")

        field_names = list(MyStep._fields.keys())
        assert field_names == ["z", "a"]

    def test_multistep_preserves_step_order(self):
        class S1(Step):
            a = Text(label="A")

        class S2(Step):
            b = Text(label="B")

        class S3(Step):
            c = Text(label="C")

        class Intake(MultiStepForm):
            third = S3(label="Third")
            first = S1(label="First")
            second = S2(label="Second")

        step_names = list(Intake._steps.keys())
        assert step_names == ["third", "first", "second"]


# ── Edge cases ───────────────────────────────────────────────────


class TestEdgeCases:
    def test_empty_form(self):
        class EmptyForm(Form):
            pass

        assert len(EmptyForm._fields) == 0
        d = EmptyForm(title="Empty").to_dict()
        assert d["fields"] == []

    @pytest.mark.asyncio
    async def test_empty_form_parse(self):
        class EmptyForm(Form):
            pass

        data = await EmptyForm.parse("{}")
        assert isinstance(data, EmptyForm)

    @pytest.mark.asyncio
    async def test_parse_ignores_extra_keys(self):
        class MyForm(Form):
            name = Text(label="Name")

        data = await MyForm.parse('{"name": "Alice", "unknown_key": "ignored"}')
        assert data.name == "Alice"
        assert not hasattr(data, "unknown_key")

    @pytest.mark.asyncio
    async def test_parse_invalid_json_raises(self):
        class MyForm(Form):
            name = Text(label="Name")

        with pytest.raises(json.JSONDecodeError):
            await MyForm.parse("not valid json{}")

    @pytest.mark.asyncio
    async def test_parse_message_extracts_attachment_client(self):
        """When parsing from Message, _attachment_client is auto-extracted for FileField."""

        class UploadForm(Form):
            doc = FileField(label="Document")

        mock_client = Mock()
        msg = Message(
            content='{"doc": {"id": "f1", "url": "/f1", "filename": "a.pdf", "content_type": "application/pdf", "size": 500}}',
            conversation_id="c1",
            user_id="u1",
            headers={},
            raw_body={},
            _attachment_client=mock_client,
        )
        data = await UploadForm.parse(msg)
        assert data.doc.filename == "a.pdf"
        assert data.doc.size == 500

    @pytest.mark.asyncio
    async def test_parse_explicit_client_overrides_message_client(self):
        """Explicit attachment_client kwarg takes priority over Message's client."""

        class UploadForm(Form):
            doc = FileField(label="Document")

        msg_client = Mock()
        explicit_client = Mock()
        msg = Message(
            content='{"doc": {"id": "f1", "url": "/f1", "filename": "b.pdf", "content_type": "text/plain", "size": 100}}',
            conversation_id="c1",
            user_id="u1",
            headers={},
            raw_body={},
            _attachment_client=msg_client,
        )
        data = await UploadForm.parse(msg, attachment_client=explicit_client)
        # Should still parse correctly — just uses the explicit client path
        assert data.doc.filename == "b.pdf"

    @pytest.mark.asyncio
    async def test_multistep_form_with_file_field(self):
        class InfoStep(Step):
            name = Text(label="Name")

        class DocsStep(Step):
            doc = FileField(label="Upload")

        class Intake(MultiStepForm):
            info = InfoStep(label="Info")
            documents = DocsStep(label="Docs")

        mock_client = Mock()
        data = await Intake.parse(
            '{"name": "Eve", "doc": {"id": "d1", "url": "/d1", "filename": "scan.pdf", "content_type": "application/pdf", "size": 2000}}',
            attachment_client=mock_client,
        )
        assert data.name == "Eve"
        assert data.doc.filename == "scan.pdf"

    def test_multistep_form_no_description_omitted(self):
        class S1(Step):
            name = Text(label="Name")

        class Intake(MultiStepForm):
            s1 = S1(label="S1")

        d = Intake(title="Intake").to_dict()
        assert "description" not in d

    def test_select_placeholder(self):
        f = Select(label="Color", placeholder="Pick one...")
        f._name = "color"
        d = f.to_dict()
        assert d["placeholder"] == "Pick one..."

    def test_select_placeholder_omitted(self):
        f = Select(label="Color")
        f._name = "color"
        d = f.to_dict()
        assert "placeholder" not in d

    def test_multiselect_optional_omitted(self):
        f = MultiSelect(label="Tags")
        f._name = "tags"
        d = f.to_dict()
        assert "min_selections" not in d
        assert "max_selections" not in d

    def test_checkbox_group_optional_omitted(self):
        f = CheckboxGroup(label="Items")
        f._name = "items"
        d = f.to_dict()
        assert "min_selections" not in d
        assert "max_selections" not in d

    def test_step_reuse_in_multiple_forms(self):
        """Same Step class can be used in different MultiStepForms."""

        class SharedStep(Step):
            name = Text(label="Name")

        class FormA(MultiStepForm):
            info = SharedStep(label="Info A")

        class FormB(MultiStepForm):
            info = SharedStep(label="Info B")

        da = FormA(title="A").to_dict()
        db = FormB(title="B").to_dict()
        assert da["steps"][0]["label"] == "Info A"
        assert db["steps"][0]["label"] == "Info B"


# ── Form inheritance ─────────────────────────────────────────────


class TestFormInheritance:
    def test_subclass_form_gets_own_fields(self):
        """Each Form subclass gets its own _fields from its own class body."""

        class FormA(Form):
            x = Text(label="X")

        class FormB(Form):
            y = Number(label="Y")

        assert "x" in FormA._fields
        assert "y" not in FormA._fields
        assert "y" in FormB._fields
        assert "x" not in FormB._fields

    def test_two_forms_same_field_names_independent(self):
        """Two forms can use the same field attribute names without conflict."""

        class FormA(Form):
            name = Text(label="Name A")

        class FormB(Form):
            name = Text(label="Name B")

        assert FormA._fields["name"].label == "Name A"
        assert FormB._fields["name"].label == "Name B"

    @pytest.mark.asyncio
    async def test_two_forms_parse_independently(self):
        class FormA(Form):
            name = Text(label="Name")
            age = Number(label="Age")

        class FormB(Form):
            name = Text(label="Name")
            active = Switch(label="Active?")

        a = await FormA.parse('{"name": "Alice", "age": 25}')
        b = await FormB.parse('{"name": "Bob", "active": true}')

        assert isinstance(a, FormA)
        assert isinstance(b, FormB)
        assert a.name == "Alice"
        assert a.age == 25.0
        assert b.name == "Bob"
        assert b.active is True


# ── Confirmation tests ────────────────────────────────────────────


class TestConfirmation:
    def test_to_dict_defaults(self):
        c = Confirmation(title="Confirm?", message="Are you sure?")
        d = c.to_dict()
        assert d == {
            "type": "confirmation",
            "title": "Confirm?",
            "message": "Are you sure?",
            "confirm_label": "Confirm",
            "cancel_label": "Cancel",
            "variant": "default",
        }

    def test_to_dict_custom_labels(self):
        c = Confirmation(
            title="Delete Account",
            message="This cannot be undone.",
            confirm_label="Delete",
            cancel_label="Keep Account",
            variant="destructive",
        )
        d = c.to_dict()
        assert d["type"] == "confirmation"
        assert d["title"] == "Delete Account"
        assert d["message"] == "This cannot be undone."
        assert d["confirm_label"] == "Delete"
        assert d["cancel_label"] == "Keep Account"
        assert d["variant"] == "destructive"

    def test_to_dict_with_description(self):
        c = Confirmation(title="Confirm", message="Body text", description="Extra context")
        d = c.to_dict()
        assert d["description"] == "Extra context"

    def test_to_dict_no_description_omitted(self):
        c = Confirmation(title="Confirm", message="Body")
        d = c.to_dict()
        assert "description" not in d

    def test_to_dict_warning_variant(self):
        c = Confirmation(title="Warning", message="Proceed with caution.", variant="warning")
        d = c.to_dict()
        assert d["variant"] == "warning"

    def test_to_dict_info_variant(self):
        c = Confirmation(title="Info", message="Just so you know.", variant="info")
        d = c.to_dict()
        assert d["variant"] == "info"


class TestConfirmationParse:
    @pytest.mark.asyncio
    async def test_parse_confirmed_true(self):
        result = await Confirmation.parse('{"confirmed": true}')
        assert result.confirmed is True

    @pytest.mark.asyncio
    async def test_parse_confirmed_false(self):
        result = await Confirmation.parse('{"confirmed": false}')
        assert result.confirmed is False

    @pytest.mark.asyncio
    async def test_parse_from_dict(self):
        result = await Confirmation.parse({"confirmed": True})
        assert result.confirmed is True

    @pytest.mark.asyncio
    async def test_parse_from_message(self):
        msg = Message(
            content='{"confirmed": true}',
            conversation_id="c1",
            user_id="u1",
            headers={},
            raw_body={},
        )
        result = await Confirmation.parse(msg)
        assert result.confirmed is True

    @pytest.mark.asyncio
    async def test_parse_missing_confirmed_defaults_false(self):
        result = await Confirmation.parse("{}")
        assert result.confirmed is False

    @pytest.mark.asyncio
    async def test_parse_returns_confirmation_instance(self):
        result = await Confirmation.parse('{"confirmed": true}')
        assert isinstance(result, Confirmation)

    @pytest.mark.asyncio
    async def test_repr(self):
        result = await Confirmation.parse('{"confirmed": true}')
        assert "Confirmation" in repr(result)
        assert "True" in repr(result)

    @pytest.mark.asyncio
    async def test_parse_extra_keys_ignored(self):
        result = await Confirmation.parse('{"confirmed": true, "extra": "ignored"}')
        assert result.confirmed is True

    @pytest.mark.asyncio
    async def test_parse_invalid_source_type(self):
        with pytest.raises(TypeError, match="Expected Message, str, or dict"):
            await Confirmation.parse(42)


# ── ValidationResult tests ───────────────────────────────────────


class TestValidationResult:
    def test_valid_result(self):
        result = ValidationResult({})
        assert result.is_valid is True
        assert result.errors == {}

    def test_invalid_result(self):
        result = ValidationResult({"name": "Name is required"})
        assert result.is_valid is False
        assert result.errors == {"name": "Name is required"}

    def test_to_dict(self):
        result = ValidationResult({"age": "Age must be at least 0"})
        d = result.to_dict()
        assert d == {"is_valid": False, "errors": {"age": "Age must be at least 0"}}

    def test_to_dict_valid(self):
        d = ValidationResult({}).to_dict()
        assert d == {"is_valid": True, "errors": {}}

    def test_repr_valid(self):
        assert "valid" in repr(ValidationResult({}))

    def test_repr_invalid(self):
        r = repr(ValidationResult({"x": "error"}))
        assert "errors" in r
        assert "x" in r


# ── Field-level validate() tests ─────────────────────────────────


class TestTextValidate:
    def test_valid(self):
        f = Text(label="Name")
        assert f.validate("John") is None

    def test_required_empty_string(self):
        f = Text(label="Name")
        err = f.validate("")
        assert err is not None
        assert "required" in err

    def test_required_whitespace_only(self):
        f = Text(label="Name")
        err = f.validate("   ")
        assert err is not None
        assert "required" in err

    def test_optional_empty_ok(self):
        f = Text(label="Bio", required=False)
        assert f.validate("") is None

    def test_min_length(self):
        f = Text(label="Name", min_length=3)
        assert f.validate("Jo") is not None
        assert "at least 3" in f.validate("Jo")

    def test_min_length_exact(self):
        f = Text(label="Name", min_length=3)
        assert f.validate("Joe") is None

    def test_max_length(self):
        f = Text(label="Name", max_length=5)
        assert f.validate("Jonathan") is not None
        assert "at most 5" in f.validate("Jonathan")

    def test_max_length_exact(self):
        f = Text(label="Name", max_length=5)
        assert f.validate("Alice") is None

    def test_min_and_max_together(self):
        f = Text(label="Code", min_length=2, max_length=5)
        assert f.validate("A") is not None
        assert f.validate("AB") is None
        assert f.validate("ABCDE") is None
        assert f.validate("ABCDEF") is not None

    def test_optional_skips_length_checks_when_empty(self):
        f = Text(label="Bio", required=False, min_length=10)
        assert f.validate("") is None


class TestNumberValidate:
    def test_valid(self):
        f = Number(label="Age", min=0, max=150)
        assert f.validate(25) is None

    def test_required_empty(self):
        f = Number(label="Age")
        # 0.0 is not empty for numbers (it's a valid value)
        assert f.validate(0.0) is None

    def test_below_min(self):
        f = Number(label="Age", min=0)
        err = f.validate(-1)
        assert err is not None
        assert "at least 0" in err

    def test_above_max(self):
        f = Number(label="Age", max=150)
        err = f.validate(200)
        assert err is not None
        assert "at most 150" in err

    def test_exact_min(self):
        f = Number(label="Age", min=0)
        assert f.validate(0) is None

    def test_exact_max(self):
        f = Number(label="Age", max=150)
        assert f.validate(150) is None

    def test_no_constraints(self):
        f = Number(label="Count")
        assert f.validate(999999) is None

    def test_non_numeric_string(self):
        f = Number(label="Age")
        err = f.validate("abc")
        assert err is not None
        assert "number" in err


class TestSelectValidate:
    def test_valid_option(self):
        f = Select(label="Color", options=[
            Option(value="red", label="Red"),
            Option(value="blue", label="Blue"),
        ])
        assert f.validate("red") is None

    def test_invalid_option(self):
        f = Select(label="Color", options=[
            Option(value="red", label="Red"),
            Option(value="blue", label="Blue"),
        ])
        err = f.validate("green")
        assert err is not None
        assert "must be one of" in err

    def test_allow_custom_accepts_any(self):
        f = Select(label="Color", options=[
            Option(value="red", label="Red"),
        ], allow_custom=True)
        assert f.validate("purple") is None

    def test_required_empty(self):
        f = Select(label="Color", options=[Option(value="red", label="Red")])
        err = f.validate("")
        assert err is not None
        assert "required" in err

    def test_optional_empty_ok(self):
        f = Select(label="Color", required=False, options=[Option(value="red", label="Red")])
        assert f.validate("") is None

    def test_no_options_any_value_ok(self):
        f = Select(label="Color")
        assert f.validate("anything") is None


class TestMultiSelectValidate:
    def test_valid(self):
        f = MultiSelect(label="Tags", options=[
            Option(value="a", label="A"),
            Option(value="b", label="B"),
        ], min_selections=1, max_selections=2)
        assert f.validate(["a"]) is None

    def test_required_empty_list(self):
        f = MultiSelect(label="Tags")
        err = f.validate([])
        assert err is not None
        assert "required" in err

    def test_optional_empty_list_ok(self):
        f = MultiSelect(label="Tags", required=False)
        assert f.validate([]) is None

    def test_min_selections(self):
        f = MultiSelect(label="Tags", min_selections=2, options=[
            Option(value="a", label="A"),
            Option(value="b", label="B"),
            Option(value="c", label="C"),
        ])
        err = f.validate(["a"])
        assert err is not None
        assert "at least 2" in err

    def test_max_selections(self):
        f = MultiSelect(label="Tags", max_selections=2, options=[
            Option(value="a", label="A"),
            Option(value="b", label="B"),
            Option(value="c", label="C"),
        ])
        err = f.validate(["a", "b", "c"])
        assert err is not None
        assert "at most 2" in err

    def test_invalid_option_values(self):
        f = MultiSelect(label="Tags", options=[
            Option(value="a", label="A"),
            Option(value="b", label="B"),
        ])
        err = f.validate(["a", "z"])
        assert err is not None
        assert "invalid" in err.lower()
        assert "z" in err

    def test_allow_custom_skips_option_check(self):
        f = MultiSelect(label="Tags", options=[
            Option(value="a", label="A"),
        ], allow_custom=True)
        assert f.validate(["a", "custom_val"]) is None


class TestCheckboxValidate:
    def test_required_checked(self):
        f = Checkbox(label="I agree", required=True)
        assert f.validate(True) is None

    def test_required_unchecked(self):
        f = Checkbox(label="I agree", required=True)
        err = f.validate(False)
        assert err is not None
        assert "must be checked" in err

    def test_optional_unchecked_ok(self):
        f = Checkbox(label="Newsletter", required=False)
        assert f.validate(False) is None


class TestCheckboxGroupValidate:
    def test_valid(self):
        f = CheckboxGroup(label="Languages", options=[
            Option(value="en", label="English"),
            Option(value="es", label="Spanish"),
        ])
        assert f.validate(["en"]) is None

    def test_required_empty(self):
        f = CheckboxGroup(label="Languages", options=[
            Option(value="en", label="English"),
        ])
        err = f.validate([])
        assert err is not None
        assert "required" in err

    def test_optional_empty_ok(self):
        f = CheckboxGroup(label="Languages", required=False)
        assert f.validate([]) is None

    def test_min_selections(self):
        f = CheckboxGroup(label="Languages", min_selections=2, options=[
            Option(value="en", label="English"),
            Option(value="es", label="Spanish"),
            Option(value="fr", label="French"),
        ])
        err = f.validate(["en"])
        assert err is not None
        assert "at least 2" in err

    def test_max_selections(self):
        f = CheckboxGroup(label="Languages", max_selections=1, options=[
            Option(value="en", label="English"),
            Option(value="es", label="Spanish"),
        ])
        err = f.validate(["en", "es"])
        assert err is not None
        assert "at most 1" in err

    def test_invalid_option_values(self):
        f = CheckboxGroup(label="Languages", options=[
            Option(value="en", label="English"),
        ])
        err = f.validate(["en", "xx"])
        assert err is not None
        assert "invalid" in err.lower()
        assert "xx" in err


class TestSwitchValidate:
    def test_always_valid(self):
        f = Switch(label="Notifications")
        assert f.validate(True) is None
        assert f.validate(False) is None


class TestSliderValidate:
    def test_valid(self):
        f = Slider(label="Pain", min=0, max=10)
        assert f.validate(5) is None

    def test_below_min(self):
        f = Slider(label="Pain", min=0, max=10)
        err = f.validate(-1)
        assert err is not None
        assert "at least 0" in err

    def test_above_max(self):
        f = Slider(label="Pain", min=0, max=10)
        err = f.validate(11)
        assert err is not None
        assert "at most 10" in err

    def test_exact_boundaries(self):
        f = Slider(label="Pain", min=0, max=10)
        assert f.validate(0) is None
        assert f.validate(10) is None

    def test_non_numeric(self):
        f = Slider(label="Pain", min=0, max=10)
        err = f.validate("abc")
        assert err is not None
        assert "number" in err


class TestDatePickerValidate:
    def test_valid(self):
        f = DatePicker(label="DOB", min_date="1900-01-01", max_date="2026-12-31")
        assert f.validate("2000-06-15") is None

    def test_before_min(self):
        f = DatePicker(label="DOB", min_date="1900-01-01")
        err = f.validate("1850-01-01")
        assert err is not None
        assert "on or after" in err

    def test_after_max(self):
        f = DatePicker(label="DOB", max_date="2026-12-31")
        err = f.validate("2030-01-01")
        assert err is not None
        assert "on or before" in err

    def test_exact_boundaries(self):
        f = DatePicker(label="DOB", min_date="2000-01-01", max_date="2000-12-31")
        assert f.validate("2000-01-01") is None
        assert f.validate("2000-12-31") is None

    def test_required_empty(self):
        f = DatePicker(label="DOB")
        err = f.validate("")
        assert err is not None
        assert "required" in err

    def test_optional_empty_ok(self):
        f = DatePicker(label="DOB", required=False)
        assert f.validate("") is None

    def test_no_constraints(self):
        f = DatePicker(label="DOB")
        assert f.validate("2024-01-01") is None


class TestHiddenValidate:
    def test_always_valid(self):
        f = Hidden(label="Ctx", value="abc")
        assert f.validate("abc") is None
        assert f.validate(None) is None
        assert f.validate("") is None


class TestFileFieldValidate:
    def test_required_with_value(self):
        f = FileField(label="Upload")
        assert f.validate("some-object-key") is None

    def test_required_empty(self):
        f = FileField(label="Upload")
        err = f.validate(None)
        assert err is not None
        assert "required" in err

    def test_required_empty_string(self):
        f = FileField(label="Upload")
        err = f.validate("")
        assert err is not None
        assert "required" in err

    def test_optional_empty_ok(self):
        f = FileField(label="Upload", required=False)
        assert f.validate(None) is None


# ── Form.validate() tests ────────────────────────────────────────


class TestFormValidate:
    @pytest.mark.asyncio
    async def test_all_valid(self):
        class MyForm(Form):
            name = Text(label="Name")
            age = Number(label="Age", min=0, max=150)

        data = await MyForm.parse('{"name": "Alice", "age": 30}')
        result = MyForm.validate(data)
        assert result.is_valid is True
        assert result.errors == {}

    @pytest.mark.asyncio
    async def test_single_field_invalid(self):
        class MyForm(Form):
            name = Text(label="Name")
            age = Number(label="Age", min=0, max=150)

        data = await MyForm.parse('{"name": "Alice", "age": 200}')
        result = MyForm.validate(data)
        assert result.is_valid is False
        assert "age" in result.errors
        assert "name" not in result.errors

    @pytest.mark.asyncio
    async def test_multiple_fields_invalid(self):
        class MyForm(Form):
            name = Text(label="Name", min_length=2)
            age = Number(label="Age", min=0, max=150)

        data = await MyForm.parse('{"name": "", "age": 200}')
        result = MyForm.validate(data)
        assert result.is_valid is False
        assert "name" in result.errors
        assert "age" in result.errors

    @pytest.mark.asyncio
    async def test_empty_form_always_valid(self):
        class EmptyForm(Form):
            pass

        data = await EmptyForm.parse("{}")
        result = EmptyForm.validate(data)
        assert result.is_valid is True

    @pytest.mark.asyncio
    async def test_optional_fields_pass_when_empty(self):
        class MyForm(Form):
            name = Text(label="Name")
            bio = Text(label="Bio", required=False, min_length=10)

        data = await MyForm.parse('{"name": "Alice", "bio": ""}')
        result = MyForm.validate(data)
        assert result.is_valid is True

    @pytest.mark.asyncio
    async def test_all_field_types_valid(self):
        class FullForm(Form):
            text_f = Text(label="Text", min_length=1)
            num_f = Number(label="Number", min=0, max=100)
            select_f = Select(label="Select", options=[Option(value="a", label="A")])
            multi_f = MultiSelect(label="Multi", options=[Option(value="x", label="X")], min_selections=1)
            cb_f = Checkbox(label="Agree", required=True)
            cbg_f = CheckboxGroup(label="Group", options=[Option(value="en", label="EN")])
            switch_f = Switch(label="Switch")
            slider_f = Slider(label="Slider", min=0, max=10)
            date_f = DatePicker(label="Date", min_date="2000-01-01", max_date="2030-12-31")
            hidden_f = Hidden(label="Hidden", value="h", required=False)

        data = await FullForm.parse(json.dumps({
            "text_f": "hello",
            "num_f": 50,
            "select_f": "a",
            "multi_f": ["x"],
            "cb_f": True,
            "cbg_f": ["en"],
            "switch_f": True,
            "slider_f": 5,
            "date_f": "2024-06-01",
            "hidden_f": "h",
        }))
        result = FullForm.validate(data)
        assert result.is_valid is True
        assert result.errors == {}

    @pytest.mark.asyncio
    async def test_all_field_types_invalid(self):
        class FullForm(Form):
            text_f = Text(label="Text", min_length=5)
            num_f = Number(label="Number", min=10, max=20)
            select_f = Select(label="Select", options=[Option(value="a", label="A")])
            multi_f = MultiSelect(label="Multi", options=[Option(value="x", label="X")], min_selections=2)
            cb_f = Checkbox(label="Agree", required=True)
            cbg_f = CheckboxGroup(label="Group", options=[Option(value="en", label="EN")], min_selections=1)
            slider_f = Slider(label="Slider", min=5, max=10)
            date_f = DatePicker(label="Date", min_date="2024-01-01")
            file_f = FileField(label="Upload")

        data = await FullForm.parse(json.dumps({
            "text_f": "hi",
            "num_f": 5,
            "select_f": "z",
            "multi_f": ["x"],
            "cb_f": False,
            "cbg_f": [],
            "slider_f": 2,
            "date_f": "2020-01-01",
            "file_f": None,
        }))
        result = FullForm.validate(data)
        assert result.is_valid is False
        assert len(result.errors) == 9

    @pytest.mark.asyncio
    async def test_validate_returns_validation_result_type(self):
        class MyForm(Form):
            name = Text(label="Name")

        data = await MyForm.parse('{"name": "Alice"}')
        result = MyForm.validate(data)
        assert isinstance(result, ValidationResult)


# ── MultiStepForm.validate() tests ───────────────────────────────


class TestMultiStepFormValidate:
    @pytest.mark.asyncio
    async def test_all_valid(self):
        class S1(Step):
            name = Text(label="Name")

        class S2(Step):
            age = Number(label="Age", min=0, max=150)

        class Intake(MultiStepForm):
            step1 = S1(label="Step 1")
            step2 = S2(label="Step 2")

        data = await Intake.parse('{"name": "Alice", "age": 25}')
        result = Intake.validate(data)
        assert result.is_valid is True

    @pytest.mark.asyncio
    async def test_errors_across_steps(self):
        class S1(Step):
            name = Text(label="Name", min_length=2)

        class S2(Step):
            age = Number(label="Age", min=0, max=150)

        class Intake(MultiStepForm):
            step1 = S1(label="Step 1")
            step2 = S2(label="Step 2")

        data = await Intake.parse('{"name": "A", "age": 200}')
        result = Intake.validate(data)
        assert result.is_valid is False
        assert "name" in result.errors
        assert "age" in result.errors

    @pytest.mark.asyncio
    async def test_missing_required_fields(self):
        class S1(Step):
            name = Text(label="Name")
            email = Text(label="Email")

        class S2(Step):
            level = Slider(label="Level", min=1, max=10)

        class Intake(MultiStepForm):
            info = S1(label="Info")
            assess = S2(label="Assessment")

        data = await Intake.parse('{}')
        result = Intake.validate(data)
        assert result.is_valid is False
        assert "name" in result.errors
        assert "email" in result.errors

    @pytest.mark.asyncio
    async def test_returns_validation_result_type(self):
        class S1(Step):
            x = Text(label="X")

        class Intake(MultiStepForm):
            s1 = S1(label="S1")

        data = await Intake.parse('{"x": "hi"}')
        result = Intake.validate(data)
        assert isinstance(result, ValidationResult)


# ── _is_empty helper tests ────────────────────────────────────────


class TestIsEmpty:
    def test_none_is_empty(self):
        f = Field(label="Test")
        assert f._is_empty(None) is True

    def test_empty_string_is_empty(self):
        f = Field(label="Test")
        assert f._is_empty("") is True

    def test_whitespace_is_empty(self):
        f = Field(label="Test")
        assert f._is_empty("   ") is True

    def test_empty_list_is_empty(self):
        f = Field(label="Test")
        assert f._is_empty([]) is True

    def test_non_empty_string(self):
        f = Field(label="Test")
        assert f._is_empty("hello") is False

    def test_non_empty_list(self):
        f = Field(label="Test")
        assert f._is_empty(["a"]) is False

    def test_zero_is_not_empty(self):
        f = Field(label="Test")
        assert f._is_empty(0) is False

    def test_false_is_not_empty(self):
        f = Field(label="Test")
        assert f._is_empty(False) is False


# ── Card tests ──────────────────────────────────────────────────────


class TestCardSection:
    def test_to_dict(self):
        s = CardSection(label="Status", value="Active")
        assert s.to_dict() == {"label": "Status", "value": "Active"}


class TestCard:
    def test_to_dict_minimal(self):
        c = Card(title="Hello")
        d = c.to_dict()
        assert d == {
            "type": "card",
            "title": "Hello",
            "body": "",
            "variant": "default",
        }
        assert "sections" not in d

    def test_to_dict_with_body_and_variant(self):
        c = Card(title="Alert", body="Something happened", variant="warning")
        d = c.to_dict()
        assert d["title"] == "Alert"
        assert d["body"] == "Something happened"
        assert d["variant"] == "warning"

    def test_to_dict_with_sections(self):
        c = Card(
            title="User",
            sections=[
                CardSection(label="Name", value="Alice"),
                CardSection(label="Role", value="Admin"),
            ],
        )
        d = c.to_dict()
        assert d["sections"] == [
            {"label": "Name", "value": "Alice"},
            {"label": "Role", "value": "Admin"},
        ]

    def test_all_variants(self):
        for v in ("default", "success", "warning", "error", "info"):
            c = Card(title="T", variant=v)
            assert c.to_dict()["variant"] == v

    def test_invalid_variant_raises(self):
        with pytest.raises(ValueError, match="Invalid variant"):
            Card(title="T", variant="banana")

    def test_to_dict_with_image(self):
        c = Card(title="Photo", image="https://example.com/photo.jpg")
        d = c.to_dict()
        assert d["image"] == "https://example.com/photo.jpg"

    def test_to_dict_without_image(self):
        c = Card(title="No Image")
        d = c.to_dict()
        assert "image" not in d

    def test_to_dict_with_button(self):
        c = Card(
            title="Action",
            button=CardButton(label="Learn More", url="https://example.com"),
        )
        d = c.to_dict()
        assert d["button"] == {"label": "Learn More", "url": "https://example.com"}

    def test_to_dict_without_button(self):
        c = Card(title="No Button")
        d = c.to_dict()
        assert "button" not in d

    def test_to_dict_with_image_and_button(self):
        c = Card(
            title="Full Card",
            body="Everything included.",
            variant="info",
            sections=[CardSection(label="K", value="V")],
            image="https://example.com/img.png",
            button=CardButton(label="Open", url="https://example.com/open"),
        )
        d = c.to_dict()
        assert d["type"] == "card"
        assert d["title"] == "Full Card"
        assert d["image"] == "https://example.com/img.png"
        assert d["button"] == {"label": "Open", "url": "https://example.com/open"}
        assert len(d["sections"]) == 1


class TestCardButton:
    def test_to_dict(self):
        b = CardButton(label="Click Me", url="https://example.com")
        assert b.to_dict() == {"label": "Click Me", "url": "https://example.com"}


# ── Accordion tests ─────────────────────────────────────────────────


class TestAccordionItem:
    def test_to_dict(self):
        item = AccordionItem(title="Q1", body="Answer 1")
        assert item.to_dict() == {"title": "Q1", "body": "Answer 1"}


class TestAccordion:
    def test_to_dict(self):
        a = Accordion(
            title="FAQ",
            items=[
                AccordionItem(title="Q1", body="A1"),
                AccordionItem(title="Q2", body="A2"),
            ],
        )
        d = a.to_dict()
        assert d == {
            "type": "accordion",
            "title": "FAQ",
            "items": [
                {"title": "Q1", "body": "A1"},
                {"title": "Q2", "body": "A2"},
            ],
        }

    def test_empty_items_raises(self):
        with pytest.raises(ValueError, match="at least one item"):
            Accordion(title="Empty", items=[])


# ── Streamer generate_ui tests ──────────────────────────────────────


class TestStreamerGenui:
    def _make_streamer(self):
        mock_gateway = Mock()
        mock_message = Mock()
        mock_message.conversation_id = "conv-1"
        mock_message.user_id = "user-1"
        return Streamer(
            agent_name="test",
            gateway_client=mock_gateway,
            original_message=mock_message,
        )

    def test_generate_ui_accepts_card(self):
        s = self._make_streamer()
        card = Card(title="Test Card", body="Body text", variant="info")
        s.generate_ui(card)
        assert len(s._genui_components) == 1
        assert s._genui_components[0]["type"] == "card"
        assert s._genui_components[0]["title"] == "Test Card"

    def test_generate_ui_accepts_accordion(self):
        s = self._make_streamer()
        acc = Accordion(
            title="FAQ",
            items=[AccordionItem(title="Q", body="A")],
        )
        s.generate_ui(acc)
        assert len(s._genui_components) == 1
        assert s._genui_components[0]["type"] == "accordion"

    def test_generate_ui_accepts_raw_dict(self):
        s = self._make_streamer()
        s.generate_ui({"type": "custom", "data": "value"})
        assert s._genui_components[0] == {"type": "custom", "data": "value"}

    def test_multiple_generate_ui_accumulates(self):
        s = self._make_streamer()
        s.generate_ui(Card(title="Card 1"))
        s.generate_ui(Accordion(title="Acc 1", items=[AccordionItem(title="Q", body="A")]))
        s.generate_ui(Card(title="Card 2", variant="success"))
        assert len(s._genui_components) == 3
        assert s._genui_components[0]["type"] == "card"
        assert s._genui_components[1]["type"] == "accordion"
        assert s._genui_components[2]["variant"] == "success"

    def test_generate_ui_after_stream_raises(self):
        s = self._make_streamer()
        s.stream("hello")
        with pytest.raises(RuntimeError, match="Cannot mix generate_ui"):
            s.generate_ui(Card(title="Nope"))

    def test_stream_after_generate_ui_raises(self):
        s = self._make_streamer()
        s.generate_ui(Card(title="First"))
        with pytest.raises(RuntimeError, match="Cannot mix stream"):
            s.stream("nope")

    def test_write_after_generate_ui_raises(self):
        s = self._make_streamer()
        s.generate_ui(Card(title="First"))
        with pytest.raises(RuntimeError, match="Cannot mix stream"):
            s.write("nope")

    @pytest.mark.asyncio
    async def test_finish_sends_genui_content(self):
        s = self._make_streamer()
        s.generate_ui(Card(title="Card"))
        s.generate_ui(
            Accordion(title="FAQ", items=[AccordionItem(title="Q", body="A")])
        )
        await s.finish()

        # Verify the final _send call
        gateway = s._gateway_client
        calls = gateway.produce.call_args_list
        assert len(calls) == 1
        body = calls[0][1]["body"] if "body" in calls[0][1] else calls[0].kwargs["body"]
        assert body["done"] is True
        assert body["persist"] is True
        assert body["isGenui"] is True
        content = json.loads(body["content"])
        assert len(content) == 2
        assert content[0]["type"] == "card"
        assert content[1]["type"] == "accordion"

    @pytest.mark.asyncio
    async def test_finish_without_genui_no_flag(self):
        s = self._make_streamer()
        s.stream("hello")
        await s.finish()

        gateway = s._gateway_client
        calls = gateway.produce.call_args_list
        # Last call is the done=True call
        done_call = calls[-1]
        body = done_call[1]["body"] if "body" in done_call[1] else done_call.kwargs["body"]
        assert body["done"] is True
        assert "isGenui" not in body

    def test_generate_ui_after_finish_raises(self):
        s = self._make_streamer()
        s._finished = True
        with pytest.raises(RuntimeError, match="already finished"):
            s.generate_ui(Card(title="Late"))

    def test_stream_by_works_alongside_genui(self):
        """Structural events (via stream_by) should still work with genui mode."""
        s = self._make_streamer()
        s.generate_ui(Card(title="Card"))
        # stream_by uses _send directly with persist=False, event_type set
        # It doesn't go through stream() so it shouldn't conflict
        assert s._mode == "genui"
        # Structural events call _send directly, not stream(), so no conflict
