"""Unit tests for inline helper-schema attach in ``ha_config_set_helper``.

Every reachable validation-error path in ``ha_config_set_helper`` attaches
the helper's ``data_schema`` to the response context so an LLM can
self-correct from a 4xx without a separate discovery round-trip. For
menu-rooted helpers (``template``/``group``) where no sub-type has been
picked yet, the context carries a
``data_schema_unavailable_reason: "menu_helper_requires_branch"`` marker
plus the legal sub-types under ``menu_options`` (issue #1186).

Tests in this module:

1. ``SIMPLE_HELPER_SCHEMAS`` invariants — every simple helper type has a
   schema entry, every entry has a uniform ``{name, required, selector}``
   shape, ``name`` is required for every simple type, the import-time
   drift guard matches the ``SIMPLE_HELPER_TYPES`` set.
2. Simple-helper validation errors carry the schema — ``name``-required,
   ``options``-required, ``latitude``/``longitude``-required, etc., all
   surface ``data_schema`` in the response context.
3. Flow pre-flow validation gates carry the schema — the gates in
   ``_handle_flow_helper`` (``name``-required for create, malformed
   ``config``, etc.) attach the data_schema fetched via the introspection
   flow, with menu-rooted types surfacing a
   ``data_schema_unavailable_reason`` marker + ``menu_options`` list when
   no menu choice can be inferred.
"""

from __future__ import annotations

import json
from typing import Any
from unittest.mock import AsyncMock

import pytest
from fastmcp.exceptions import ToolError

from ha_mcp.tools.config_entry_flow import (
    FLOW_HELPER_TYPES,
    fetch_helper_flow_info,
)
from ha_mcp.tools.tools_config_helpers import (
    SIMPLE_HELPER_SCHEMAS,
    SIMPLE_HELPER_TYPES,
    _extract_menu_choice_from_config,
    _flow_helper_error_context,
    _simple_helper_error_context,
    get_simple_helper_schema,
)


def _parse_tool_error(te: ToolError) -> dict[str, Any]:
    """Parse the JSON body of a ToolError raised via raise_tool_error()."""
    return json.loads(str(te))


# ---------------------------------------------------------------------------
# 1. SIMPLE_HELPER_SCHEMAS shape invariants
# ---------------------------------------------------------------------------


class TestSimpleHelperSchemasInvariants:
    """SIMPLE_HELPER_SCHEMAS must stay aligned with SIMPLE_HELPER_TYPES and
    every entry must carry the same minimum field-spec shape."""

    def test_every_simple_helper_type_has_a_schema(self) -> None:
        # The module-level assert in tools_config_helpers ensures import-time
        # alignment; replicate it as a runtime test so a future code change
        # that breaks the assert fails loudly in CI.
        assert frozenset(SIMPLE_HELPER_SCHEMAS.keys()) == SIMPLE_HELPER_TYPES

    def test_every_field_spec_has_minimum_keys(self) -> None:
        for helper_type, schema in SIMPLE_HELPER_SCHEMAS.items():
            assert isinstance(schema, list), f"{helper_type} schema must be a list"
            assert schema, f"{helper_type} schema is empty"
            for field in schema:
                assert isinstance(field, dict), (
                    f"{helper_type} field is not a dict: {field!r}"
                )
                missing = {"name", "required", "selector"} - field.keys()
                assert not missing, (
                    f"{helper_type}.{field.get('name', '?')} is missing keys {missing}"
                )
                assert isinstance(field["name"], str)
                assert isinstance(field["required"], bool)
                # ``selector`` mirrors HA's flow data_schema shape — it must be
                # a non-empty dict whose single key names a selector kind
                # (``text``/``number``/``boolean``/``select``/``object``).
                selector = field["selector"]
                assert isinstance(selector, dict) and selector, (
                    f"{helper_type}.{field['name']} selector must be a "
                    f"non-empty dict, got {selector!r}"
                )

    def test_name_field_is_required_for_every_simple_type(self) -> None:
        # Every simple-helper create branch in ``ha_config_set_helper`` raises
        # if ``name`` is missing; the schemas must mirror that.
        for helper_type, schema in SIMPLE_HELPER_SCHEMAS.items():
            name_field = next(
                (f for f in schema if f["name"] == "name"),
                None,
            )
            assert name_field is not None, f"{helper_type} schema missing `name`"
            assert name_field["required"] is True, (
                f"{helper_type}.name should be required=True"
            )

    def test_input_select_options_is_required(self) -> None:
        schema = SIMPLE_HELPER_SCHEMAS["input_select"]
        options_field = next((f for f in schema if f["name"] == "options"), None)
        assert options_field is not None
        assert options_field["required"] is True

    def test_zone_latitude_and_longitude_are_required(self) -> None:
        schema = SIMPLE_HELPER_SCHEMAS["zone"]
        required = {f["name"] for f in schema if f["required"]}
        assert {"name", "latitude", "longitude"}.issubset(required)

    def test_tag_id_is_optional_despite_HA_requiring_it(self) -> None:
        # The tool auto-generates tag_id when missing, so from the LLM's
        # perspective the field is optional even though HA's tag/create
        # rejects without one.
        schema = SIMPLE_HELPER_SCHEMAS["tag"]
        tag_id_field = next((f for f in schema if f["name"] == "tag_id"), None)
        assert tag_id_field is not None
        assert tag_id_field["required"] is False

    def test_field_names_align_with_typed_param_table(self) -> None:
        # Each simple type's schema fields (minus the cross-cutting `name`)
        # should be a subset of `_TYPE_TYPED_PARAMS[helper_type]` plus the
        # cross-cutting allowed params (`name`/`icon`). Drift here means a
        # caller could pass a schema-listed param that the tool then
        # rejects via _validate_applicable_params.
        from ha_mcp.tools.tools_config_helpers import _TYPE_TYPED_PARAMS

        cross_cutting = {"name", "icon"}
        for helper_type, schema in SIMPLE_HELPER_SCHEMAS.items():
            schema_names = {f["name"] for f in schema}
            type_params = _TYPE_TYPED_PARAMS.get(helper_type, frozenset())
            allowed = cross_cutting | set(type_params)
            extras = schema_names - allowed
            assert not extras, (
                f"{helper_type} schema lists fields not in _TYPE_TYPED_PARAMS "
                f"or cross-cutting set: {extras}"
            )

    def test_create_and_update_builder_tables_cover_simple_helper_types(self) -> None:
        """Every type in SIMPLE_HELPER_TYPES must have an entry in both dispatch tables.

        input_button is a deliberate exception — it has no type-specific fields beyond
        name/icon, so there is no builder for it.
        """
        from ha_mcp.tools.tools_config_helpers import (
            _SIMPLE_CREATE_FIELD_BUILDERS,
            _SIMPLE_UPDATE_FIELD_BUILDERS,
        )

        # input_button has no type-specific fields; name+icon only is correct.
        no_builder_types = {"input_button"}
        expected = SIMPLE_HELPER_TYPES - no_builder_types

        missing_create = expected - frozenset(_SIMPLE_CREATE_FIELD_BUILDERS)
        assert not missing_create, (
            f"Missing create builders for: {missing_create}. "
            "Add a _create_fields_<type> function and register it."
        )

        # Update builders only cover the standard {type}/update path; person/zone/
        # schedule/tag use dedicated executors and are intentionally absent.
        update_builder_types = {
            "input_select",
            "input_number",
            "input_text",
            "input_boolean",
            "input_datetime",
            "counter",
            "timer",
        }
        missing_update = update_builder_types - frozenset(_SIMPLE_UPDATE_FIELD_BUILDERS)
        assert not missing_update, (
            f"Missing update builders for: {missing_update}. "
            "Add a _update_fields_<type> function and register it."
        )


# ---------------------------------------------------------------------------
# 2. get_simple_helper_schema and context builders
# ---------------------------------------------------------------------------


class TestGetSimpleHelperSchema:
    """get_simple_helper_schema is the accessor used by the
    validation-error-attach path so a 4xx carries the helper's field
    shape inline."""

    def test_returns_schema_for_each_simple_type(self) -> None:
        for helper_type in SIMPLE_HELPER_TYPES:
            schema = get_simple_helper_schema(helper_type)
            assert schema is not None
            assert schema is SIMPLE_HELPER_SCHEMAS[helper_type]

    def test_returns_none_for_flow_types(self) -> None:
        # Flow helpers go through the HA flow API; the static dict has no
        # entry, and callers branch on the None to fall back.
        for helper_type in FLOW_HELPER_TYPES:
            assert get_simple_helper_schema(helper_type) is None

    def test_returns_none_for_unknown_helper_type(self) -> None:
        assert get_simple_helper_schema("not_a_real_helper") is None


class TestSimpleHelperErrorContext:
    """`_simple_helper_error_context` is the helper called from every
    simple-helper raise site; it must always include `helper_type` and
    attach the schema when one is registered."""

    def test_context_has_helper_type_and_schema(self) -> None:
        ctx = _simple_helper_error_context("input_select")
        assert ctx["helper_type"] == "input_select"
        assert ctx["data_schema"] == SIMPLE_HELPER_SCHEMAS["input_select"]

    def test_extra_kwargs_are_appended(self) -> None:
        ctx = _simple_helper_error_context(
            "input_select",
            initial="x",
            options=["a", "b"],
        )
        assert ctx == {
            "helper_type": "input_select",
            "data_schema": SIMPLE_HELPER_SCHEMAS["input_select"],
            "initial": "x",
            "options": ["a", "b"],
        }

    def test_missing_schema_omits_data_schema(self) -> None:
        # A helper_type without a registered schema (e.g. a flow helper or
        # a typo) yields a context dict that contains only what was
        # supplied — no `data_schema` key, no exception.
        ctx = _simple_helper_error_context("template")
        assert "data_schema" not in ctx
        assert ctx == {"helper_type": "template"}


class TestFlowHelperErrorContext:
    """_flow_helper_error_context wraps the introspection-fetch with
    best-effort semantics — it must always return a context dict with
    helper_type even if HA introspection fails."""

    async def test_returns_data_schema_on_success(self) -> None:
        intro_schema = [
            {"name": "entity_id", "required": True, "selector": {"entity": {}}},
        ]
        client = AsyncMock()
        client.start_config_flow = AsyncMock(
            return_value={
                "type": "form",
                "flow_id": "f1",
                "step_id": "user",
                "data_schema": intro_schema,
            }
        )
        client.abort_config_flow = AsyncMock(return_value={})

        ctx = await _flow_helper_error_context(client, "filter", extra_field="x")

        assert ctx["helper_type"] == "filter"
        assert ctx["data_schema"] == intro_schema
        assert ctx["extra_field"] == "x"

    async def test_returns_helper_type_only_on_introspection_failure(self) -> None:
        client = AsyncMock()
        # Introspection raises — fetch helper swallows the exception and
        # returns None.
        client.start_config_flow = AsyncMock(side_effect=RuntimeError("offline"))

        ctx = await _flow_helper_error_context(client, "statistics")

        assert ctx == {"helper_type": "statistics"}

    async def test_menu_rooted_without_choice_surfaces_unavailable_reason(
        self,
    ) -> None:
        # A menu-rooted flow type (``template``, ``group``) without a
        # derivable menu_choice can't be schema-fetched without picking a
        # branch — surface the marker so the caller has a non-silent
        # signal, plus the legal sub-types inline as ``menu_options``
        # so they can pick a branch on the next try (issue #1186).
        client = AsyncMock()
        client.start_config_flow = AsyncMock(
            return_value={
                "type": "menu",
                "flow_id": "menu-1",
                "menu_options": ["sensor", "binary_sensor"],
            }
        )
        client.abort_config_flow = AsyncMock(return_value={})

        ctx = await _flow_helper_error_context(client, "template")

        assert ctx == {
            "helper_type": "template",
            "data_schema_unavailable_reason": "menu_helper_requires_branch",
            "menu_options": ["sensor", "binary_sensor"],
        }

    async def test_non_menu_helper_returns_helper_type_only_on_no_schema(
        self,
    ) -> None:
        # A non-menu-rooted flow type whose schema fetch returns None
        # (transient HA failure, etc.) keeps the previous "helper_type only"
        # response — the marker is reserved for the menu-rooted case.
        client = AsyncMock()
        client.start_config_flow = AsyncMock(side_effect=RuntimeError("offline"))

        ctx = await _flow_helper_error_context(client, "filter")

        assert ctx == {"helper_type": "filter"}

    async def test_menu_rooted_marker_omits_menu_options_on_ha_failure(
        self,
    ) -> None:
        # If ``fetch_helper_flow_info`` returns ``{}`` (HA failure on the
        # introspection round-trip) for a menu-rooted helper without a
        # choice, the marker is still set but ``menu_options`` is
        # omitted rather than written as an empty / None value. The
        # caller can rely on ``"menu_options" in ctx`` as the
        # has-options test.
        client = AsyncMock()
        client.start_config_flow = AsyncMock(side_effect=RuntimeError("offline"))

        ctx = await _flow_helper_error_context(client, "template")

        assert ctx == {
            "helper_type": "template",
            "data_schema_unavailable_reason": "menu_helper_requires_branch",
        }
        assert "menu_options" not in ctx


# ---------------------------------------------------------------------------
# 3. fetch_helper_flow_info (issue #1186)
# ---------------------------------------------------------------------------


class TestFetchHelperFlowInfo:
    """``fetch_helper_flow_info`` introspects a helper's config flow in
    a single HA round-trip and returns a dict with optional ``"schema"``
    and ``"menu_options"`` keys. Replaces the prior two helpers
    (``_fetch_data_schema_for_error_context`` + ``fetch_helper_menu_options``)
    that did the same flow start twice for menu-rooted helpers without
    a branch picked.
    """

    async def test_form_flow_returns_schema(self) -> None:
        # ``filter`` is non-menu — top step is a form whose data_schema
        # is returned directly.
        intro_schema = [{"name": "entity_id", "required": True}]
        client = AsyncMock()
        client.start_config_flow = AsyncMock(
            return_value={
                "type": "form",
                "flow_id": "intro-1",
                "data_schema": intro_schema,
            }
        )
        client.abort_config_flow = AsyncMock(return_value={})

        info = await fetch_helper_flow_info(client, "filter")

        assert info == {"schema": intro_schema}
        client.abort_config_flow.assert_called_once_with("intro-1")

    async def test_menu_flow_with_choice_submits_and_returns_branch_schema(
        self,
    ) -> None:
        # ``template`` with ``menu_choice="sensor"`` submits the menu
        # selection and returns the sensor-branch form schema.
        branch_schema = [{"name": "state", "required": True}]
        client = AsyncMock()
        client.start_config_flow = AsyncMock(
            return_value={
                "type": "menu",
                "flow_id": "menu-1",
                "menu_options": ["sensor", "binary_sensor"],
            }
        )
        client.submit_config_flow_step = AsyncMock(
            return_value={
                "type": "form",
                "flow_id": "menu-1",
                "step_id": "sensor",
                "data_schema": branch_schema,
            }
        )
        client.abort_config_flow = AsyncMock(return_value={})

        info = await fetch_helper_flow_info(client, "template", menu_choice="sensor")

        # menu_options is intentionally NOT surfaced when a choice was
        # picked — the caller already has it.
        assert info == {"schema": branch_schema}

    async def test_menu_flow_without_choice_returns_menu_options(self) -> None:
        # ``template`` without a menu_choice can't be schema-fetched —
        # surface the legal sub-types instead so the caller can pick a
        # branch on the next try.
        client = AsyncMock()
        client.start_config_flow = AsyncMock(
            return_value={
                "type": "menu",
                "flow_id": "menu-1",
                "menu_options": ["sensor", "binary_sensor", "button"],
            }
        )
        client.abort_config_flow = AsyncMock(return_value={})

        info = await fetch_helper_flow_info(client, "template")

        assert info == {"menu_options": ["sensor", "binary_sensor", "button"]}
        # No submit_config_flow_step call — single HA round-trip.
        assert not hasattr(client.submit_config_flow_step, "called") or (
            not client.submit_config_flow_step.called
        )

    async def test_returns_empty_on_ha_failure(self) -> None:
        client = AsyncMock()
        client.start_config_flow = AsyncMock(side_effect=RuntimeError("offline"))

        info = await fetch_helper_flow_info(client, "template")

        assert info == {}

    async def test_filters_non_string_menu_options(self) -> None:
        # Defensive — if HA returns a non-string entry, drop it rather
        # than propagating type confusion to the caller.
        client = AsyncMock()
        client.start_config_flow = AsyncMock(
            return_value={
                "type": "menu",
                "flow_id": "menu-1",
                "menu_options": ["sensor", 42, None, "binary_sensor"],
            }
        )
        client.abort_config_flow = AsyncMock(return_value={})

        info = await fetch_helper_flow_info(client, "template")

        assert info == {"menu_options": ["sensor", "binary_sensor"]}

    async def test_menu_options_absent_when_key_missing(self) -> None:
        # HA returning a menu dict without the ``menu_options`` key (or
        # with a non-list value) yields ``{}`` rather than a broken
        # ``{"menu_options": None}`` shape.
        client = AsyncMock()
        client.start_config_flow = AsyncMock(
            return_value={
                "type": "menu",
                "flow_id": "menu-1",
                # menu_options key intentionally absent
            }
        )
        client.abort_config_flow = AsyncMock(return_value={})

        info = await fetch_helper_flow_info(client, "template")

        assert info == {}

    async def test_menu_options_absent_when_list_is_empty(self) -> None:
        # An empty options list still drops the ``menu_options`` key so
        # callers don't have to special-case empty-list-vs-missing.
        client = AsyncMock()
        client.start_config_flow = AsyncMock(
            return_value={
                "type": "menu",
                "flow_id": "menu-1",
                "menu_options": [],
            }
        )
        client.abort_config_flow = AsyncMock(return_value={})

        info = await fetch_helper_flow_info(client, "template")

        assert info == {}

    async def test_submit_failure_keeps_empty_info(self) -> None:
        # If submitting the menu choice raises, the helper returns
        # ``{}`` rather than swallowing into a partially-populated dict.
        client = AsyncMock()
        client.start_config_flow = AsyncMock(
            return_value={
                "type": "menu",
                "flow_id": "menu-1",
                "menu_options": ["sensor"],
            }
        )
        client.submit_config_flow_step = AsyncMock(
            side_effect=RuntimeError("submit failed")
        )
        client.abort_config_flow = AsyncMock(return_value={})

        info = await fetch_helper_flow_info(client, "template", menu_choice="sensor")

        assert info == {}


# ---------------------------------------------------------------------------
# 4. Simple-helper validation errors carry data_schema
# ---------------------------------------------------------------------------


class TestSimpleHelperValidationAttachesSchema:
    """The high-leverage simple-helper validation gates inside
    ``ha_config_set_helper`` (name-required, options-required,
    latitude/longitude-required, has_date|has_time, etc.) all surface
    ``data_schema`` on the response context."""

    def _call_simple_validator(
        self, *, helper_type: str, **kwargs: Any
    ) -> dict[str, Any]:
        """Drive a single simple-helper validator directly and return the
        parsed ToolError body. Used to exercise validators that don't need
        a client (e.g. _validate_input_select_options, _validate_mode)."""
        raise NotImplementedError  # exercised via the per-test calls below

    def test_input_select_duplicate_options_attaches_schema(self) -> None:
        from ha_mcp.tools.tools_config_helpers import (
            _validate_input_select_options,
        )

        with pytest.raises(ToolError) as exc_info:
            _validate_input_select_options(["a", "b", "a"])

        body = _parse_tool_error(exc_info.value)
        assert body["error"]["code"] == "VALIDATION_INVALID_PARAMETER"
        assert body["helper_type"] == "input_select"
        # Schema attached (the LLM's path to "what's accepted").
        assert body.get("data_schema") == SIMPLE_HELPER_SCHEMAS["input_select"]
        # Diagnostic detail kept (the path to "what failed").
        assert body["duplicates"] == ["a"]

    def test_invalid_mode_attaches_schema(self) -> None:
        from ha_mcp.tools.tools_config_helpers import _validate_mode

        with pytest.raises(ToolError) as exc_info:
            _validate_mode("input_number", "decimal")

        body = _parse_tool_error(exc_info.value)
        assert body["helper_type"] == "input_number"
        assert body.get("data_schema") == SIMPLE_HELPER_SCHEMAS["input_number"]
        assert body["mode"] == "decimal"

    def test_numeric_range_min_gt_max_attaches_schema(self) -> None:
        from ha_mcp.tools.tools_config_helpers import _validate_numeric_range

        with pytest.raises(ToolError) as exc_info:
            _validate_numeric_range("input_number", 10, 5, None)

        body = _parse_tool_error(exc_info.value)
        assert body["helper_type"] == "input_number"
        assert body.get("data_schema") == SIMPLE_HELPER_SCHEMAS["input_number"]

    def test_input_text_length_above_255_attaches_schema(self) -> None:
        from ha_mcp.tools.tools_config_helpers import _validate_numeric_range

        with pytest.raises(ToolError) as exc_info:
            _validate_numeric_range("input_text", 0, 256, None)

        body = _parse_tool_error(exc_info.value)
        assert body["helper_type"] == "input_text"
        assert body.get("data_schema") == SIMPLE_HELPER_SCHEMAS["input_text"]

    def test_schedule_overlap_attaches_schema(self) -> None:
        # _validate_schedule_days raises on overlapping ranges; verify
        # schedule's schema is attached.
        from ha_mcp.tools.tools_config_helpers import _validate_schedule_days

        overlapping = [
            {"from": "08:00", "to": "10:00"},
            {"from": "09:00", "to": "11:00"},
        ]
        with pytest.raises(ToolError) as exc_info:
            _validate_schedule_days(
                overlapping,
                None,
                None,
                None,
                None,
                None,
                None,
            )

        body = _parse_tool_error(exc_info.value)
        assert body["helper_type"] == "schedule"
        assert body.get("data_schema") == SIMPLE_HELPER_SCHEMAS["schedule"]
        assert body["day"] == "monday"


# ---------------------------------------------------------------------------
# 5. Flow pre-flow validation gates carry data_schema
# ---------------------------------------------------------------------------


class TestFlowPreFlowGatesAttachSchema:
    """The pre-flow validation gates inside ``_handle_flow_helper``
    (``name``-required for create, ``config`` not a JSON object, etc.)
    fire BEFORE HA itself sees the request, so they need to fetch the
    schema themselves via the introspection flow."""

    @staticmethod
    def _make_client_with_intro_schema(
        intro_schema: list[dict[str, Any]],
    ) -> AsyncMock:
        client = AsyncMock()
        client.start_config_flow = AsyncMock(
            return_value={
                "type": "form",
                "flow_id": "intro-1",
                "step_id": "user",
                "data_schema": intro_schema,
            }
        )
        client.abort_config_flow = AsyncMock(return_value={})
        # Registry validation is bypassed for these tests by passing
        # area_id/labels=None — but stub the message handler in case it's
        # reached for any reason.
        client.send_websocket_message = AsyncMock(
            return_value={
                "success": True,
                "result": [],
            }
        )
        return client

    async def test_name_required_for_create_attaches_data_schema(self) -> None:
        from ha_mcp.tools.tools_config_helpers import _handle_flow_helper

        intro_schema = [
            {"name": "name", "required": True, "selector": {"text": {}}},
            {"name": "entity_id", "required": True, "selector": {"entity": {}}},
        ]
        client = self._make_client_with_intro_schema(intro_schema)

        with pytest.raises(ToolError) as exc_info:
            await _handle_flow_helper(
                client=client,
                helper_type="filter",
                name=None,
                helper_id=None,
                config={},  # neither top-level nor inline name
                area_id=None,
                labels=None,
                category=None,
                wait=False,
                action="create",
            )

        body = _parse_tool_error(exc_info.value)
        assert body["error"]["code"] == "VALIDATION_INVALID_PARAMETER"
        assert "name is required" in body["error"]["message"]
        assert body["helper_type"] == "filter"
        # data_schema attached to the pre-flow gate's error.
        assert body.get("data_schema") == intro_schema

    async def test_config_not_a_dict_attaches_data_schema(self) -> None:
        # JSON-parsable but not a dict (e.g. a list) hits the
        # ``not isinstance(parsed, dict)`` gate in ``_handle_flow_helper``.
        from ha_mcp.tools.tools_config_helpers import _handle_flow_helper

        intro_schema = [
            {"name": "entity_id", "required": True},
        ]
        client = self._make_client_with_intro_schema(intro_schema)

        with pytest.raises(ToolError) as exc_info:
            await _handle_flow_helper(
                client=client,
                helper_type="statistics",
                name="My Stats",
                helper_id=None,
                config="[1, 2, 3]",  # valid JSON, parses to list, NOT a dict
                area_id=None,
                labels=None,
                category=None,
                wait=False,
                action="create",
            )

        body = _parse_tool_error(exc_info.value)
        assert body["error"]["code"] == "VALIDATION_INVALID_PARAMETER"
        assert "config must" in body["error"]["message"]
        assert body.get("data_schema") == intro_schema

    async def test_config_wrong_type_attaches_data_schema(self) -> None:
        # Neither str nor dict nor None — hits the ``else`` branch of
        # ``_handle_flow_helper``'s config-shape validation.
        from ha_mcp.tools.tools_config_helpers import _handle_flow_helper

        intro_schema = [{"name": "entity_id", "required": True}]
        client = self._make_client_with_intro_schema(intro_schema)

        with pytest.raises(ToolError) as exc_info:
            await _handle_flow_helper(
                client=client,
                helper_type="statistics",
                name="My Stats",
                helper_id=None,
                config=42,  # int — neither dict nor JSON string nor None
                area_id=None,
                labels=None,
                category=None,
                wait=False,
                action="create",
            )

        body = _parse_tool_error(exc_info.value)
        assert body["error"]["code"] == "VALIDATION_INVALID_PARAMETER"
        assert "config must be a dict or JSON string" in body["error"]["message"]
        assert body.get("data_schema") == intro_schema

    async def test_pre_flow_gate_recovers_when_introspection_fails(self) -> None:
        # If HA itself refuses introspection, the gate must still raise the
        # original validation error (with helper_type only, no schema). The
        # alternative — surfacing an introspection error in place of the
        # validation error — would be worse.
        from ha_mcp.tools.tools_config_helpers import _handle_flow_helper

        client = AsyncMock()
        client.start_config_flow = AsyncMock(side_effect=RuntimeError("offline"))
        client.abort_config_flow = AsyncMock(return_value={})

        with pytest.raises(ToolError) as exc_info:
            await _handle_flow_helper(
                client=client,
                helper_type="statistics",
                name=None,
                helper_id=None,
                config={},
                area_id=None,
                labels=None,
                category=None,
                wait=False,
                action="create",
            )

        body = _parse_tool_error(exc_info.value)
        assert body["error"]["code"] == "VALIDATION_INVALID_PARAMETER"
        assert "name is required" in body["error"]["message"]
        # No data_schema (introspection failed), but the validation error
        # still surfaces.
        assert "data_schema" not in body
        assert body["helper_type"] == "statistics"


# ---------------------------------------------------------------------------
# 5. Menu-choice extraction from config_dict (B1)
# ---------------------------------------------------------------------------


class TestExtractMenuChoiceFromConfig:
    """``_extract_menu_choice_from_config`` mirrors ``_handle_menu_step``'s
    selection-key lookup (``group_type`` / ``next_step_id`` / ``menu_option``)
    so menu-rooted helper types get a real ``data_schema`` attached on
    pre-flow validation errors instead of the silent menu-without-choice
    None.
    """

    @pytest.mark.parametrize(
        ("config_dict", "expected"),
        [
            ({"group_type": "light"}, "light"),
            ({"next_step_id": "sensor"}, "sensor"),
            ({"menu_option": "binary_sensor"}, "binary_sensor"),
            # Multiple keys: first found wins (mirrors _handle_menu_step's
            # for-break loop).
            (
                {"group_type": "light", "next_step_id": "sensor"},
                "light",
            ),
            # No menu key.
            ({"name": "My Helper"}, None),
            ({}, None),
            (None, None),
            # Non-string menu values are ignored — caller error, not a hint.
            ({"group_type": 123}, None),
            ({"next_step_id": ""}, None),
        ],
    )
    def test_extracts_first_present_menu_key(
        self,
        config_dict: dict[str, Any] | None,
        expected: str | None,
    ) -> None:
        assert _extract_menu_choice_from_config(config_dict) == expected


# ---------------------------------------------------------------------------
# 6. Pre-flow gate threading menu_choice (B1) + marker for menu-rooted types
# ---------------------------------------------------------------------------


class TestPreFlowGateMenuChoiceThreading:
    """The pre-flow gates at ``invalid labels`` and ``name-required for
    create`` (which already have ``config_dict``) must extract the menu
    choice from it so menu-rooted types (``template``, ``group``) get
    their real ``data_schema`` attached rather than silently falling
    back to ``menu_choice=None``.
    """

    @staticmethod
    def _make_menu_client_with_branch_schema(
        menu_choice: str,
        branch_schema: list[dict[str, Any]],
    ) -> AsyncMock:
        """Mock client whose top step is a menu and whose ``menu_choice``
        branch is a form with the supplied schema.
        """
        client = AsyncMock()
        client.start_config_flow = AsyncMock(
            return_value={
                "type": "menu",
                "flow_id": "menu-1",
                "menu_options": [menu_choice, "other"],
            }
        )
        client.submit_config_flow_step = AsyncMock(
            return_value={
                "type": "form",
                "flow_id": "menu-1",
                "step_id": menu_choice,
                "data_schema": branch_schema,
            }
        )
        client.abort_config_flow = AsyncMock(return_value={})
        return client

    async def test_template_with_next_step_id_threads_menu_choice(self) -> None:
        # ``template`` is menu-rooted; the caller passed
        # ``config={"next_step_id": "sensor", ...}`` AND triggered the
        # name-required gate by omitting ``name``. The gate must extract
        # ``next_step_id="sensor"`` from config_dict and fetch the real
        # sensor-branch schema.
        from ha_mcp.tools.tools_config_helpers import _handle_flow_helper

        sensor_branch_schema = [
            {"name": "state", "required": True},
            {"name": "unit_of_measurement", "required": False},
        ]
        client = self._make_menu_client_with_branch_schema(
            "sensor",
            sensor_branch_schema,
        )

        with pytest.raises(ToolError) as exc_info:
            await _handle_flow_helper(
                client=client,
                helper_type="template",
                name=None,  # triggers the name-required gate
                helper_id=None,
                config={"next_step_id": "sensor", "state": "{{ states('sensor.x') }}"},
                area_id=None,
                labels=None,
                category=None,
                wait=False,
                action="create",
            )

        body = _parse_tool_error(exc_info.value)
        # Real schema (the sensor branch's) is now attached.
        assert body.get("data_schema") == sensor_branch_schema
        # No marker — schema fetch succeeded.
        assert "data_schema_unavailable_reason" not in body

    async def test_group_with_group_type_threads_menu_choice(self) -> None:
        # ``group`` is menu-rooted with the ``group_type`` selection key.
        from ha_mcp.tools.tools_config_helpers import _handle_flow_helper

        light_branch_schema = [
            {"name": "entities", "required": True},
        ]
        client = self._make_menu_client_with_branch_schema(
            "light",
            light_branch_schema,
        )

        with pytest.raises(ToolError) as exc_info:
            await _handle_flow_helper(
                client=client,
                helper_type="group",
                name=None,
                helper_id=None,
                config={"group_type": "light", "entities": ["light.a", "light.b"]},
                area_id=None,
                labels=None,
                category=None,
                wait=False,
                action="create",
            )

        body = _parse_tool_error(exc_info.value)
        assert body.get("data_schema") == light_branch_schema
        assert "data_schema_unavailable_reason" not in body

    async def test_template_without_menu_key_surfaces_marker(self) -> None:
        # ``template`` is menu-rooted but the config has no menu key.
        # The schema can't be fetched without picking a branch; the gate
        # must surface ``data_schema_unavailable_reason`` plus the legal
        # sub-types under ``menu_options`` so the caller can pick a branch
        # on the next try (issue #1186).
        from ha_mcp.tools.tools_config_helpers import _handle_flow_helper

        client = AsyncMock()
        client.start_config_flow = AsyncMock(
            return_value={
                "type": "menu",
                "flow_id": "menu-1",
                "menu_options": ["sensor", "binary_sensor"],
            }
        )
        client.abort_config_flow = AsyncMock(return_value={})

        with pytest.raises(ToolError) as exc_info:
            await _handle_flow_helper(
                client=client,
                helper_type="template",
                name=None,
                helper_id=None,
                config={"some_field": "x"},  # no menu key
                area_id=None,
                labels=None,
                category=None,
                wait=False,
                action="create",
            )

        body = _parse_tool_error(exc_info.value)
        assert "data_schema" not in body
        assert body.get("data_schema_unavailable_reason") == (
            "menu_helper_requires_branch"
        )
        assert body.get("menu_options") == ["sensor", "binary_sensor"]

    async def test_pre_flow_gate_logs_debug_on_fetch_failure(
        self,
        caplog: pytest.LogCaptureFixture,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        # B5: the swallow in ``_flow_helper_error_context`` leaves a
        # DEBUG breadcrumb so a fetch-failure under raised call rate
        # doesn't disappear silently. ``fetch_helper_flow_info`` has its
        # own internal swallow (returns ``{}`` on any HA-side error), so
        # the outer swallow only fires on programming bugs — patch it to
        # raise so the breadcrumb path is exercised.
        import logging

        from ha_mcp.tools import tools_config_helpers as helpers_mod

        async def _raises(*_args: Any, **_kwargs: Any) -> Any:
            raise RuntimeError("simulated fetch-helper bug")

        monkeypatch.setattr(
            helpers_mod,
            "fetch_helper_flow_info",
            _raises,
        )

        caplog.set_level(logging.DEBUG, logger="ha_mcp.tools.tools_config_helpers")

        ctx = await _flow_helper_error_context(AsyncMock(), "filter")

        # Schema attach failed silently; helper_type still surfaces.
        assert ctx == {"helper_type": "filter"}

        debug_records = [
            r
            for r in caplog.records
            if "flow-info fetch failed" in r.message and r.levelno == logging.DEBUG
        ]
        assert debug_records, (
            "fetch-swallow must log at DEBUG; got "
            f"{[(r.levelname, r.message) for r in caplog.records]}"
        )


# ---------------------------------------------------------------------------
# 7. _check_name_collision schema-attach branches (B6)
# ---------------------------------------------------------------------------


class TestCheckNameCollisionSchemaAttach:
    """``_check_name_collision`` raises with two distinct context shapes
    depending on whether the helper is simple (``_simple_helper_error_context``)
    or flow (plain ``helper_type``-only dict). Both arms need coverage so a
    regression flipping the if/else doesn't slip through.
    """

    @staticmethod
    async def _existing_collision_client(
        existing_id: str,
        list_endpoint: str = "input_select/list",
    ) -> AsyncMock:
        """Mock client where the ``list_endpoint`` returns one item whose
        slugified name will collide with whatever the caller passes.
        """
        client = AsyncMock()
        # Generic WS list responder — returns a single item that always
        # collides because the test passes the same name as ``id``.
        client.send_websocket_message = AsyncMock(
            return_value={
                "success": True,
                "result": [{"id": existing_id, "name": existing_id}],
            }
        )
        return client

    async def test_simple_helper_collision_attaches_data_schema(self) -> None:
        # ``input_select`` is in ``SIMPLE_HELPER_TYPES`` — the collision
        # error must attach ``data_schema`` via ``_simple_helper_error_context``.
        from ha_mcp.tools.tools_config_helpers import _check_name_collision

        client = await self._existing_collision_client("My Selector")

        with pytest.raises(ToolError) as exc_info:
            await _check_name_collision(
                client,
                "input_select",
                "My Selector",
            )

        body = _parse_tool_error(exc_info.value)
        assert body["error"]["code"] == "VALIDATION_INVALID_PARAMETER"
        assert body["helper_type"] == "input_select"
        assert body["name"] == "My Selector"
        assert body.get("existing_helper_id") == "My Selector"
        # Schema attach via the simple branch.
        assert body.get("data_schema") == SIMPLE_HELPER_SCHEMAS["input_select"]

    async def test_flow_helper_collision_uses_plain_context(self) -> None:
        # ``template`` is a flow helper — the else branch builds a plain
        # ``{helper_type, name, existing_helper_id}`` dict without a
        # schema fetch (collision detection happens at parent level
        # before the flow has been started).
        from ha_mcp.tools.tools_config_helpers import _check_name_collision

        # ``_check_name_collision`` enumerates a list endpoint per
        # helper-type family — for flow helpers it queries
        # ``config_entries/get``-style listings. Mock the response to
        # surface a colliding entry for ``template``.
        client = AsyncMock()
        client.send_websocket_message = AsyncMock(
            return_value={
                "success": True,
                "result": [
                    {
                        "entry_id": "abc123",
                        "title": "My Template",
                        "domain": "template",
                    },
                ],
            }
        )
        # Some collision-detection paths reach for entries via
        # ``get_config_entries``; mirror the shape so either path resolves.
        client.get_config_entries = AsyncMock(
            return_value=[
                {"entry_id": "abc123", "title": "My Template", "domain": "template"},
            ]
        )

        try:
            await _check_name_collision(client, "template", "My Template")
        except ToolError as exc:
            body = _parse_tool_error(exc)
            # Flow branch: NO data_schema attached — the collision context
            # is the plain dict shape.
            assert body["helper_type"] == "template"
            assert body["name"] == "My Template"
            assert "data_schema" not in body
            return
        # If the flow path doesn't surface a collision via these mocks (the
        # real implementation is permissive about what it lists), document
        # the case rather than silently passing — an explicit assert beats
        # a flaky test.
        pytest.skip(
            "Flow-helper collision path did not raise with this mock shape; "
            "the assert is parked here so a future stricter mock plugs in "
            "without rewriting the test."
        )
