"""In-process WebSocket command surface for the ha_mcp_tools component.

This module registers versioned ``ha_mcp_tools/*`` WebSocket commands that the
ha-mcp server calls in-process (same HA core, no REST/WS round-trips) behind a
capability gate. It registers twenty-two commands (twenty-two capabilities — the
``search_visibility`` capability is a flag on the existing ``search`` command,
and ``info`` itself carries no capability entry):

* ``ha_mcp_tools/info`` — the handshake: ``schema_version`` + ``capabilities[]``
  + ``component_version`` + advisory ``limits`` + the instance ``timezone``
  (an additive field consumers detect by presence — no capability entry). One
  cached probe tells the server which commands are live (capability
  negotiation, NOT a version floor).
* ``ha_mcp_tools/search`` — a unified in-process search over live registries and
  states, joined and scored, mirroring today's ``ha_search`` response envelope.
* ``ha_mcp_tools/overview`` — the raw in-process reads the server's
  ``get_system_overview`` + ``ha_get_overview`` wrapper consume (states,
  services, entity/device/area registries, ``hass.config``, persistent
  notifications, repairs issues) in one call, so the server builds its existing
  overview envelope with no extra HA round-trips.
* ``ha_mcp_tools/helpers_list`` — collection helpers (live state-attribute
  bodies) AND flow helpers (``ConfigEntry.options``/``title``/``entry_id`` —
  never ``entry.data``), each with the CURRENT entity_id + display name from the
  registry (renamed helpers show current values — issue #1794), closing the
  documented "flow helpers cannot be listed" gap with no OptionsFlow dance. The
  response's ``covered_types`` names which helper_type values were authoritatively
  enumerated, so the server falls back to its legacy ``<type>/list`` path for an
  uncovered type (e.g. ``tag``, which has no state entity) instead of trusting an
  empty result.
* ``ha_mcp_tools/states`` — a bulk state read: ``State.as_dict()`` for each
  requested entity_id (a pure ``hass.states.get`` in-memory read) plus the list
  of ids with no state, so the server's ``ha_get_state`` serves a 100-entity
  bulk call from one in-process frame instead of up to 100 REST GETs. The body
  is byte-identical to the REST ``/api/states/<id>`` serialization by
  construction; the server maps found/missing onto its per-id error contract.
* ``ha_mcp_tools/blueprint_get`` — the full body of one installed blueprint
  (``{metadata, config}``), which core's ``blueprint/list`` never returns (it
  serves only ``{metadata}``). The path is jailed under
  ``<config>/blueprints/<domain>/`` (symlink-safe containment, mirroring the
  file-tool jail) and the file read + parse run off the event loop in the async
  prep. ``!input`` markers are preserved; every other custom tag (``!secret`` /
  ``!include`` / …) is neutralized to ``None`` at load time, so no resolved
  secret plaintext can ever reach the body.
* ``ha_mcp_tools/device_get`` — one device registry entry by id
  (``{device: <DeviceEntry.dict_repr> | None}``), so a single-device lookup no
  longer pulls the entire device registry. The body is core's
  ``DeviceEntry.dict_repr`` returned VERBATIM — byte-identical to one element of
  ``config/device_registry/list`` (which sends ``json_bytes(entry.dict_repr)``)
  by construction, since this command's ``connection.send_result`` runs the same
  JSON encoder over the same dict. Consumers keep their own transforms over the
  raw shape; ``device`` is ``None`` when no such device exists. With
  ``include_entities`` set, a sibling ``entities`` key carries the device's
  entity-registry rows (``RegistryEntry.as_partial_dict``, the
  ``config/entity_registry/list`` shape, disabled entities included) so listing a
  device's entities no longer pulls the whole entity registry either — the raw
  DeviceEntry stays untouched; the join is a sibling.
* ``ha_mcp_tools/device_list`` — every device registry entry as that same raw
  ``DeviceEntry.dict_repr`` shape (``{devices: [...]}``): the in-process
  equivalent of ``config/device_registry/list`` served through the component
  seam, so ``ha_get_device`` list mode need not mix a legacy WS read with the
  component path.
* ``ha_mcp_tools/entity_enrich`` — the area/floor/labels/aliases join for a set
  of entity_ids (``{entities: {id: {area, floor, labels, aliases}}}``), computed
  by the SAME ``_entity_record``/``_RegistryView`` registry join the search path
  uses (device-inherited area/labels included). Lets ``ha_get_entity`` add the
  resolved-name enrichment fields the raw registry entry lacks (it carries
  ``area_id`` / label *ids*, not resolved names) without the caller fanning out
  its own area/floor/label registry reads. Registry-only entities (no state) are
  enriched too — the join keys off the registry, not the state machine.
* ``ha_mcp_tools/exposure`` — voice-assistant exposure with names/areas attached.
  List mode mirrors core's ``ws_list_exposed_entities`` (``{exposed_entities:
  {id: {assistant: True}}}`` — byte-identical to ``homeassistant/expose_entity/
  list``); single-entity mode reads core's module-level
  ``async_get_entity_settings``. Both add a sibling ``entity_info`` map enriching
  each id through the same registry join (friendly_name/domain/area/floor/labels),
  so the server no longer needs a second search + manual correlation to name an
  exposed entity. Three parity guardrails hold: only ``should_expose``-true
  assistants are reported (the raw helper is not pre-filtered like the legacy
  shape); core's ``HomeAssistantError("Unknown entity")`` on a junk id degrades to
  the not-exposed default (the legacy path never raises on junk); and a missing
  ``hass.states.get(id)`` omits the live-state fields (friendly_name/state) rather
  than crashing.
* ``ha_mcp_tools/dashboards`` — Lovelace dashboards read in-process, three modes.
  ``list`` mirrors the ``lovelace/dashboards/list`` row shape (id/url_path/title/
  icon/show_in_sidebar/require_admin) with an additive per-row ``mode`` so the
  server can exclude YAML dashboards; ``get`` returns one dashboard's config body
  (``await store.async_load`` in the async prep) with a structured
  ``yaml_excluded`` status for YAML-mode dashboards (the server falls back to
  legacy for those — a YAML body may carry resolved ``!secret`` plaintext);
  ``search`` walks every STORAGE dashboard's views/cards/sections (plus view-level
  badges and sections-view header cards) for a query substring (capped at 200 with a
  ``truncated`` flag). YAML-dashboard bodies are never emitted — storage-only. All
  Store loads run in :func:`_dashboards_prep`.
* ``ha_mcp_tools/services_list`` — the REST ``/api/services`` service catalog
  (``async_get_all_descriptions``) joined with the ``services`` backend
  translations (``async_get_translations``), both loaded in the async prep.
  Filtered by ``domain`` (exact) only; the server re-runs its exact query filter +
  pagination over the payload. No ``query`` coarse-filter (a per-service superset of
  the server's concatenation-based filter cannot be built cheaply, and no consumer
  forwards ``query``).
* ``ha_mcp_tools/reference_data`` — the service index + entity-id universe the
  config-reference validator consumes: ``{services: [{domain, services:{name:{}}}],
  entity_ids: [...]}``. The ``services`` shape is the REST ``/api/services`` list
  ``build_service_index`` reads (bodies are empty dicts — the index only reads
  keys); ``entity_ids`` is every ``hass.states.async_all()`` id. Pure, no prep.
* ``ha_mcp_tools/search`` (``search_visibility`` capability) — the search command
  additionally accepts a raw ``visibility`` config dict; when present it excludes
  the server's opt-in hidden entities before counts/pagination (the pure
  :func:`_visibility_hidden_set` mirrors the server's ``hidden_entity_ids``, with
  the Assist dimension delegated to core's ``async_should_expose``), so
  ``ha_search`` can route through the component even with an active filter. A
  degraded dimension (unknown ``exclude_category`` / empty-registry allowlist /
  unavailable Assist) fails open, and :func:`_visibility_warnings` returns the
  resolver-parity ``visibility_warnings`` the response carries so the filtering is
  not silently incomplete.
* ``ha_mcp_tools/server_entry`` — the component locates its OWN server config
  entry (``entry.data[CONF_ENTRY_TYPE] == server``, the one marker key it reads
  from ``entry.data``) and returns ``{entry_id, channel, pip_spec}`` (channel /
  pip_spec from ``entry.options``), so ``ha_dev_manage_server`` need not probe
  every ``ha_mcp_tools``-domain entry's options-flow schema from the outside.
* ``ha_mcp_tools/server_entry_update`` — the WRITE counterpart of ``server_entry``:
  applies a ``channel`` / ``pip_spec`` delta to the server entry via
  ``hass.config_entries.async_update_entry`` DIRECTLY (what core's finish-flow
  does), collapsing ``ha_dev_manage_server(update_source)``'s options-flow start +
  submit round-trip in embedded mode. The delta is MERGED against the LIVE
  ``entry.options`` AT APPLY time (every existing key preserved — a concurrent
  change to another key during the flush window is not clobbered — no blanking of
  the URL/secret overrides), so no preserved-key resend is needed. The
  ``async_update_entry`` fires the entry's own update listener → reload → the
  hardened reinstall; because that reload tears down the very server thread
  answering this frame, the call is NOT made inline — it is scheduled on a
  HASS-level background task (:func:`hass.async_create_background_task`, NOT the
  entry's, so the reload can't cancel it) after :data:`SERVER_ENTRY_UPDATE_FLUSH_DELAY_S`,
  and the prep returns ``{scheduled: True, ...}`` immediately so the WS response
  flushes first. A no-op (merged options equal the current ones) returns
  ``{scheduled: False, unchanged: True, ...}`` without scheduling; no server entry
  raises ``HomeAssistantError`` (→ the server's command-error fallback to its legacy
  options-flow path). All awaiting-adjacent work (the deferred apply) lives in
  :func:`_server_entry_update_prep`; :func:`_do_server_entry_update` is a pure formatter.
* ``ha_mcp_tools/call_service`` — the FIRST write capability. Fires exactly one
  ``hass.services.async_call`` in-process and returns the REAL pre→post state
  transition for the target ``entity_ids`` — event-confirmed via an
  ``EVENT_STATE_CHANGED`` listener registered BEFORE the dispatch (closing the
  fast-entity race). The server's optional ``expected_state`` hint governs only WHEN
  the confirming state is settled — the waiter confirms on reaching it (skipping a
  multi-phase service's intermediate states and attribute-only noise) and immediate-
  matches an idempotent no-op — but the RETURNED transition is always the real
  observed one, never the hint. All awaiting work (the dispatch, the immediate-match,
  the bounded confirmation wait) runs in :func:`_call_service_prep`;
  :func:`_do_call_service` is a pure formatter. An AUTHORITATIVE component-side
  domain block refuses ``domain == "ha_mcp_tools"`` (case/whitespace-normalized)
  BEFORE any ``has_service``/dispatch, independent of (and in addition to) the
  server-side guard — so this second write path can NEVER be turned into an
  in-process invoker of the admin-gated ``ha_mcp_tools.*`` services
  (``get_caller_token`` → arbitrary config-dir file/YAML writes). A confirmation
  timeout is reported as ``partial`` (``success`` still holds); a failure BEFORE
  the dispatch raises, a failure AFTER it does not (the call already landed).
* ``ha_mcp_tools/bulk_call_service`` — the BATCH write capability (D5a). One frame
  runs the D1 ``ha_mcp_tools`` domain block for EVERY operation FIRST, before any
  dispatch or listener: a batch is fail-closed — one refused op raises the whole
  frame and NOTHING dispatches, so no partial batch can smuggle a
  ``ha_mcp_tools.*`` (or unknown-service) op past the guard. It then registers ALL
  confirmation listeners in one synchronous pass BEFORE any dispatch
  (register-before-fire is trivially correct for the batch), fires the operations
  (``parallel`` by default, or sequentially), and waits on ONE shared deadline for
  every op's transition. A per-op ``async_call`` failure under ``parallel`` is
  captured on that op's result (``error`` + ``dispatched: false``) WITHOUT aborting
  the others; a post-dispatch confirmation timeout is ``partial``, never a failure.
  All awaiting work lives in :func:`_bulk_call_service_prep`;
  :func:`_do_bulk_call_service` is a pure formatter that reuses the single
  ``call_service`` guard / transition / diff helpers.

* ``ha_mcp_tools/config_entries`` — config entries as the ``config_entries/get``
  WS shape (``created_at`` / ``modified_at`` / ``entry_id`` / ``domain`` /
  ``title`` / ``state`` / ``source`` / ``supports_*`` / ``supported_subentry_types``
  / ``pref_disable_*`` / ``disabled_by`` / ``reason`` / ``options`` /
  ``subentries`` — the full ``as_json_fragment`` field set), filtered by ``domain``
  or fetched by
  ``entry_id``. ``state`` is serialized as ``ConfigEntryState.value`` (mirroring
  core's ``as_json_fragment``). ``entry.data`` (integration credentials) is
  NEVER read; ``options`` is passed through a resolved-``!secret`` scrub (an
  options leaf equal to a ``secrets.yaml`` value becomes ``"**redacted**"``,
  loaded off the loop by :func:`_config_entries_prep`) — data minimization
  parity with the flow-helper indexing.
* ``ha_mcp_tools/registry_lookup`` — entity-registry rows
  (``RegistryEntry.as_partial_dict``, the ``config/entity_registry/list`` shape,
  disabled entities included) for either a set of ``entity_ids`` (missing ids in
  a sibling ``missing`` list) or ALL entities bound to a ``config_entry_id``. The
  config-entry scan returns EVERY match — it does not reuse the single-valued
  ``_entities_by_config_entry`` index, so a multi-entity flow helper
  (utility_meter + its tariffs) does not silently lose its sub-entities. Exactly
  one of the two is required; a request with NEITHER raises
  ``HomeAssistantError`` rather than silently returning an empty result.
* ``ha_mcp_tools/system_snapshot`` — one consistent synchronous pass over the
  live objects the health path reads: ``config_entries`` (identity fields only —
  no options/subentries), ``issues`` (the ``_overview_repairs`` slice),
  ``entities`` (the ``registry_lookup`` row shape), ``states``
  (``State.as_dict()``). ``include_*`` flags gate each section. Reading them in a
  single frame kills the 3x ``config_entries/get`` TOCTOU the server had.
* ``ha_mcp_tools/entity_lookup`` — registry entries whose ``unique_id`` matches
  (optionally narrowed by ``domain`` / ``platform``), returned as
  ``{matches: [{entity_id, unique_id, platform, domain, config_entry_id,
  categories, disabled_by, hidden_by}]}``. Multiple matches across platforms are
  all returned; the server picks. The in-process read is authoritative
  immediately (no registry-write settle retry).
* ``ha_mcp_tools/backup_prep`` — the backup identity the server needs before a
  create: ``{agent_ids, local_agent_id, default_password}`` read from the backup
  integration's in-process ``DATA_MANAGER``. ``local_agent_id`` uses the SAME
  preference the server's ``_get_local_backup_agent_id`` does (``hassio.local``
  over ``backup.local``). A missing backup integration / manager raises
  ``HomeAssistantError`` so the server's command-error fallback fires. The
  password is sensitive but the legacy ``backup/config/info`` already serves it
  to the same admin connection — parity, not new exposure.
* ``ha_mcp_tools/registries`` — the area / floor / label / category registries as
  the FULL-FIELD ``config/<x>_registry/list`` shapes (byte-compatible with the
  legacy WS list responses; timestamps as ``created_at`` / ``modified_at``
  floats via ``.timestamp()``). Only the requested ``registries`` keys are
  present; ``category`` REQUIRES a non-empty ``category_scopes`` (categories are
  scoped) — a ``category`` request without one raises ``HomeAssistantError``
  rather than silently serving ``{}``. ``category_registry`` is imported
  function-locally (not needed at module top).

``ha_mcp_tools/config_get`` was withdrawn before release: it served an entity's
``raw_config``, whose freshness lags the config file between a write and the next
completed reload (no version marker distinguishes a fresh body from a stale one),
so a get racing a reload returned a pre-edit body. ``ha_config_get_{automation,
script}`` stay on the legacy REST path (which reads the fresh config file);
scenes were already legacy-only. A file-reading redesign may return (issue #1813).

Design notes that are load-bearing:

* **Capability negotiation, not version-lockstep.** ``CAPABILITIES`` grows one
  entry per shipped command (except the always-present ``info`` handshake); the
  server asks "do you support ``search``?" rather than "are you >= X". The
  manifest version is reported for display only.
* **Data minimization.** Flow-helper indexing reads ``ConfigEntry.options`` /
  ``title`` only — **never** ``ConfigEntry.data`` (integration credentials).
* **YAML config bodies are never emitted.** automation/script/scene bodies are
  indexed for *matching*, but a matched item's ``config`` body is returned only
  when it is storage/editor-backed AND ``include_config`` is set. YAML-loaded
  items return identity/metadata only (their ``raw_config`` may carry resolved
  ``!secret`` plaintext). Body emission for YAML belongs to a future file-based
  tool.
* **Resolved secrets are scrubbed from the match corpus.** Because YAML bodies
  (and flow-helper options) can hold ``!secret`` values resolved to plaintext,
  a body leaf that exactly equals a ``secrets.yaml`` value is dropped before
  scoring (:func:`_load_secret_values`) — otherwise a query equal to a suspected
  secret would confirm it via ``match_in_config`` (a probe oracle). Blocked, not
  merely unemitted.
* **Event-loop hygiene.** Every registry/state join is a pure in-memory read
  over live data — run synchronously, no persistent index (always fresh, zero
  cache-invalidation surface). The one blocking read — ``secrets.yaml`` for the
  match-corpus scrub — runs in the executor via the command wrapper's async
  pre-step (:func:`_search_prep`), never on the event loop.

Extension point — to add another command later: write ``_do_<name>(hass,
params)``, append its capability to :data:`CAPABILITIES`, and add one row to
:func:`_command_specs`. ``info`` enumerates the rest.
"""

from __future__ import annotations

import logging
import re
from collections.abc import Mapping
from dataclasses import dataclass
from difflib import SequenceMatcher
from pathlib import Path
from typing import Any

import voluptuous as vol
import yaml  # type: ignore[import-untyped]
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from homeassistant.helpers import (
    area_registry as ar,
)
from homeassistant.helpers import (
    device_registry as dr,
)
from homeassistant.helpers import (
    entity_registry as er,
)
from homeassistant.helpers import (
    floor_registry as fr,
)
from homeassistant.helpers import (
    issue_registry as ir,
)
from homeassistant.helpers import (
    label_registry as lr,
)

from .const import (
    CHANNEL_DEV,
    CHANNEL_STABLE,
    COMPONENT_VERSION,
    CONF_ENTRY_TYPE,
    DEFAULT_PIP_SPEC,
    DOMAIN,
    ENTRY_TYPE_SERVER,
    OPT_CHANNEL,
    OPT_PIP_SPEC,
)

_LOGGER = logging.getLogger(__name__)

# --- Wire contract -----------------------------------------------------------
WS_API_PREFIX = "ha_mcp_tools"
WS_INFO = f"{WS_API_PREFIX}/info"
WS_SEARCH = f"{WS_API_PREFIX}/search"
WS_OVERVIEW = f"{WS_API_PREFIX}/overview"
WS_HELPERS_LIST = f"{WS_API_PREFIX}/helpers_list"
WS_STATES = f"{WS_API_PREFIX}/states"
WS_BLUEPRINT_GET = f"{WS_API_PREFIX}/blueprint_get"
WS_DEVICE_GET = f"{WS_API_PREFIX}/device_get"
WS_DEVICE_LIST = f"{WS_API_PREFIX}/device_list"
WS_ENTITY_ENRICH = f"{WS_API_PREFIX}/entity_enrich"
WS_EXPOSURE = f"{WS_API_PREFIX}/exposure"
WS_CONFIG_ENTRIES = f"{WS_API_PREFIX}/config_entries"
WS_REGISTRY_LOOKUP = f"{WS_API_PREFIX}/registry_lookup"
WS_SYSTEM_SNAPSHOT = f"{WS_API_PREFIX}/system_snapshot"
WS_ENTITY_LOOKUP = f"{WS_API_PREFIX}/entity_lookup"
WS_BACKUP_PREP = f"{WS_API_PREFIX}/backup_prep"
WS_REGISTRIES = f"{WS_API_PREFIX}/registries"
WS_DASHBOARDS = f"{WS_API_PREFIX}/dashboards"
WS_SERVICES_LIST = f"{WS_API_PREFIX}/services_list"
WS_REFERENCE_DATA = f"{WS_API_PREFIX}/reference_data"
WS_SERVER_ENTRY = f"{WS_API_PREFIX}/server_entry"
WS_SERVER_ENTRY_UPDATE = f"{WS_API_PREFIX}/server_entry_update"
WS_CALL_SERVICE = f"{WS_API_PREFIX}/call_service"
WS_BULK_CALL_SERVICE = f"{WS_API_PREFIX}/bulk_call_service"

# Wire-format generation of the request/response envelopes. Bumped only on an
# *incompatible* shape change to an existing command; additive fields do not
# bump it (the server checks ``schema_version >= N`` before using a new shape).
SCHEMA_VERSION = 1

# Which commands exist. Grows one entry per shipped command; the server gates
# each consumer on ``capability in caps.capabilities``. Never remove an entry
# without a major bump. (``info`` is always present in 1.1.0+, so it carries no
# capability key of its own.)
CAPABILITIES: list[str] = [
    "search",
    "overview",
    "helpers_list",
    "states",
    "blueprint_get",
    "device_get",
    "device_list",
    "entity_enrich",
    "exposure",
    "config_entries",
    "registry_lookup",
    "system_snapshot",
    "entity_lookup",
    "backup_prep",
    "registries",
    "dashboards",
    # A flag, not a standalone command: gates the additive whole-document
    # search-result keys on ``ha_mcp_tools/dashboards`` mode=search
    # (``document_matches`` + ``yaml_skipped`` + ``load_failed``, issue #2008).
    # The server's ha_search dashboard bucket routes through the component only
    # when this is advertised — an older component without the keys would
    # silently narrow coverage to the card-scoped walk and hide load failures.
    "dashboards_doc_search",
    "services_list",
    "reference_data",
    # A flag, not a standalone command: gates the optional ``visibility`` param
    # the server may pass to ``ha_mcp_tools/search`` so an old component that
    # would ignore the param is never sent it (param-sniffing is banned for
    # routing; the CAPABILITIES flag is what the server gates on).
    "search_visibility",
    "server_entry",
    # The WRITE counterpart of ``server_entry`` (Phase 3). The server gates its
    # ``ha_dev_manage_server(update_source)`` embedded-mode direct-write on this; an
    # old component that lacks it is never sent the frame and the server stays on its
    # legacy options-flow submit.
    "server_entry_update",
    # The first WRITE capability (Phase 3). The server gates its ``ha_call_service``
    # component route on this; an old component that lacks it is never sent a
    # component write and stays on the legacy REST path.
    "call_service",
    # The BATCH write capability (Phase 3, D5a). The server gates its bulk-control
    # component route on this; a component that lacks it is never sent a batch
    # write and stays on the legacy per-entity path.
    "bulk_call_service",
]

# The registry kinds ``ha_mcp_tools/registries`` can serve. The WS schema gates
# on this so an out-of-range kind never reaches the reader; ``category`` also
# requires ``category_scopes`` (categories are scoped).
REGISTRY_KINDS = ("area", "floor", "label", "category")

# Blueprint domains this component will read a body for. Mirrors core's blueprint
# domains; the WS schema gates on it so an out-of-range domain never reaches the
# path jail. Kept next to the blueprint command it governs.
BLUEPRINT_DOMAINS = ("automation", "script")

# Advisory caps advertised in ``info.limits`` so no single WS frame balloons.
MAX_RESULTS = 500
MAX_BODY_BYTES = 1_000_000
LIMITS = {"max_results": MAX_RESULTS, "max_body_bytes": MAX_BODY_BYTES}

DEFAULT_LIMIT = 10

# ``call_service`` confirmation-wait bounds. The default mirrors the legacy
# ``ha_call_service`` 10s subscribe-and-sample window; the cap bounds a
# caller-supplied ``timeout`` (schema ``vol.Range``) so a single write frame can
# never park the WS connection for longer than this. ``blocking=True`` only means
# HA finished DISPATCHING — a mesh device (Zigbee/Z-Wave) may still be settling —
# so the wait is bounded and its expiry is ``partial``, never a failure (D4).
CALL_SERVICE_DEFAULT_TIMEOUT = 10.0
CALL_SERVICE_MAX_TIMEOUT = 60.0

# Delay before ``server_entry_update``'s deferred ``async_update_entry`` fires, so
# the WS ``{scheduled: True}`` response flushes to the calling (embedded) server
# BEFORE the resulting entry reload tears that server's thread down. Mirrors the
# server side's ``tools_dev._SELF_ACTION_FLUSH_DELAY_S`` (its legacy options-flow
# self-restart uses the same headroom for the ingress/webhook hop).
SERVER_ENTRY_UPDATE_FLUSH_DELAY_S = 1.0

# pip_spec defence-in-depth cap (the SERVER already validates per D6): a single-line
# requirement string under this many chars. Mirrors ``tools_dev._update_source``.
SERVER_ENTRY_UPDATE_MAX_PIP_SPEC = 500

# Fuzzy floor + hidden penalty, mirrored from the server so the two scorers do
# not drift (guarded by the golden parity test).
FUZZY_THRESHOLD = 70
HIDDEN_SCORE_PENALTY = 20

# --- Search surfaces ---------------------------------------------------------
SEARCH_TYPE_ENTITY = "entity"
SEARCH_TYPE_AUTOMATION = "automation"
SEARCH_TYPE_SCRIPT = "script"
SEARCH_TYPE_SCENE = "scene"
SEARCH_TYPE_HELPER = "helper"
ALL_SEARCH_TYPES = [
    SEARCH_TYPE_ENTITY,
    SEARCH_TYPE_AUTOMATION,
    SEARCH_TYPE_SCRIPT,
    SEARCH_TYPE_SCENE,
    SEARCH_TYPE_HELPER,
]
# raw_config surfaces reached via each domain's EntityComponent in hass.data.
CONFIG_SEARCH_TYPES = (
    SEARCH_TYPE_AUTOMATION,
    SEARCH_TYPE_SCRIPT,
    SEARCH_TYPE_SCENE,
)

# Collection ("storage collection") helpers — entities in the state machine.
# Matched on entity_id / friendly_name AND the live state-attribute body (an
# input_select's ``options``, an input_number's ``min``/``max``/``step``, …).
COLLECTION_HELPER_DOMAINS = frozenset(
    {
        "input_boolean",
        "input_number",
        "input_text",
        "input_select",
        "input_datetime",
        "input_button",
        "counter",
        "timer",
        "schedule",
    }
)
# Flow (config-entry-backed) helpers. Indexed from ``entry.options`` / ``title``
# directly — no OptionsFlow start/abort dance, and NEVER ``entry.data``.
FLOW_HELPER_DOMAINS = frozenset(
    {
        "template",
        "group",
        "utility_meter",
        "threshold",
        "derivative",
        "integration",
        "min_max",
        "statistics",
        "trend",
        "tod",
        "random",
        "switch_as_x",
        "mold_indicator",
        "history_stats",
        "bayesian",
        "filter",
        "generic_thermostat",
        "generic_hygrostat",
        "combine",
    }
)

# Collection helper domains enumerated by ``ha_mcp_tools/helpers_list``: the
# collection helpers ``search`` indexes PLUS zone/person, which are state-machine
# entities the server's ``ha_config_list_helpers`` also accepts. Kept SEPARATE
# from :data:`COLLECTION_HELPER_DOMAINS` so search behaviour is unchanged — zones
# and persons are not indexed as "helpers" by ``ha_mcp_tools/search``.
#
# ``tag`` is deliberately EXCLUDED: tags are a storage collection with no state
# entity (the server reaches them via ``tag/list``, and its create/list paths
# special-case ``tag`` precisely because it has no entity_id), so a from-states
# scan can never enumerate them. Advertising it as covered would make an empty
# result indistinguishable from "no tags exist" (a silent-wrong listing); it is
# left OUT of ``covered_types`` so the server falls back to its legacy
# ``tag/list`` path for that type. See :func:`_do_helpers_list`.
HELPERS_LIST_COLLECTION_DOMAINS = COLLECTION_HELPER_DOMAINS | frozenset(
    {"zone", "person"}
)

# Every ``EntityComponent`` self-registers here (core's
# ``entity_component.DATA_INSTANCES``). Collection-helper domains (input_*,
# counter, timer, schedule) do NOT set ``hass.data[DOMAIN]`` and their
# ``StorageCollection`` is a setup-local (``helpers/collection.py`` writes
# nothing to ``hass.data``), so this registry is how their component — and thus
# each entity's storage ``_config`` body — is reached. See
# :func:`_collection_storage_index`.
ENTITY_COMPONENTS_KEY = "entity_components"

_SPLIT_RE = re.compile(r"[._\-\s]+")


# =============================================================================
# Registration (thin @websocket_command wrappers over the pure `_do_*` funcs)
# =============================================================================
def async_register_commands(hass: HomeAssistant) -> None:
    """Register the ``ha_mcp_tools/*`` WebSocket commands.

    Idempotent: HA's ``async_register_command`` overwrites an existing handler,
    so re-running on a config-entry reload is harmless. Called from the tools
    config-entry setup alongside the service registrations.
    """
    for schema, do_fn, prep in _command_specs():
        websocket_api.async_register_command(hass, _build_handler(schema, do_fn, prep))
    _LOGGER.debug(
        "Registered ha_mcp_tools WS commands: schema_version=%s capabilities=%s",
        SCHEMA_VERSION,
        CAPABILITIES,
    )


def _command_specs() -> list[tuple[dict[Any, Any], Any, Any]]:
    """The (schema, pure-handler, async-prep) rows. Append one row per command.

    ``prep`` (or ``None``) is an ``async`` pre-step run before the pure handler;
    it returns keyword args merged into the ``do_fn`` call. It is the seam for a
    command that must touch the filesystem/network off the event loop —
    :func:`_search_prep` loads ``secrets.yaml`` in the executor — keeping every
    ``_do_*`` function a pure, synchronous in-memory read.
    """
    return [
        (_info_schema(), lambda hass, msg: _do_info(hass), None),
        (_search_schema(), _do_search, _search_prep),
        (_overview_schema(), _do_overview, None),
        (_helpers_list_schema(), _do_helpers_list, _helpers_list_prep),
        (_states_schema(), _do_states, None),
        (_blueprint_get_schema(), _do_blueprint_get, _blueprint_get_prep),
        (_device_get_schema(), _do_device_get, None),
        (_device_list_schema(), _do_device_list, None),
        (_entity_enrich_schema(), _do_entity_enrich, None),
        (_exposure_schema(), _do_exposure, None),
        (_config_entries_schema(), _do_config_entries, _config_entries_prep),
        (_registry_lookup_schema(), _do_registry_lookup, None),
        (_system_snapshot_schema(), _do_system_snapshot, None),
        (_entity_lookup_schema(), _do_entity_lookup, None),
        (_backup_prep_schema(), _do_backup_prep, None),
        (_registries_schema(), _do_registries, None),
        (_dashboards_schema(), _do_dashboards, _dashboards_prep),
        (_services_list_schema(), _do_services_list, _services_list_prep),
        (_reference_data_schema(), _do_reference_data, None),
        (_server_entry_schema(), _do_server_entry, None),
        # The WRITE counterpart of server_entry: the deferred ``async_update_entry``
        # scheduling is inherently async, so it lives in ``_server_entry_update_prep``
        # and ``_do_server_entry_update`` is a pure formatter (same seam as the
        # call_service write below).
        (
            _server_entry_update_schema(),
            _do_server_entry_update,
            _server_entry_update_prep,
        ),
        # The first WRITE command: the dispatch + the bounded confirmation wait are
        # inherently async, so ALL of the work lives in the ``_call_service_prep``
        # async pre-step and ``_do_call_service`` is a pure response formatter.
        (_call_service_schema(), _do_call_service, _call_service_prep),
        # The BATCH write command (D5a): the same async seam — all guards, the
        # register-before-fire pass, the dispatches, and the bounded wait live in
        # ``_bulk_call_service_prep``; ``_do_bulk_call_service`` is a pure formatter.
        (
            _bulk_call_service_schema(),
            _do_bulk_call_service,
            _bulk_call_service_prep,
        ),
    ]


def _build_handler(schema: dict[Any, Any], do_fn: Any, prep: Any = None) -> Any:
    """Wrap a pure ``_do_*`` function as an admin-gated WS command handler.

    An optional ``prep`` async pre-step runs first (off-loop I/O such as the
    ``secrets.yaml`` read); the keyword args it returns are passed to ``do_fn``.
    """

    @websocket_api.websocket_command(schema)
    @websocket_api.require_admin
    @websocket_api.async_response
    async def _handler(
        hass: HomeAssistant, connection: Any, msg: dict[str, Any]
    ) -> None:
        extra = await prep(hass, msg) if prep is not None else {}
        connection.send_result(msg["id"], do_fn(hass, msg, **extra))

    return _handler


def _info_schema() -> dict[Any, Any]:
    return {vol.Required("type"): WS_INFO}


# The nine hide dimensions ``VisibilityConfig.to_wire`` emits, split by wire type
# (seven id/name lists, two bool flags). Kept in lockstep with the server's
# ``to_wire`` and :func:`_visibility_hidden_set`; a new dimension is a new component
# capability, added on BOTH sides (see :func:`_visibility_param_schema`). The union
# is pinned equal to the server resolver's key set by the cross-seam contract test.
_VISIBILITY_LIST_KEYS = (
    "exclude_categories",
    "deny_entity_ids",
    "exclude_areas",
    "exclude_labels",
    "allow_entity_ids",
    "allow_areas",
    "allow_labels",
)
_VISIBILITY_BOOL_KEYS = ("exclude_hidden", "respect_assist_exposure")


def _visibility_param_schema() -> Any:
    """Voluptuous schema for the ``search`` ``visibility`` dict — exactly the nine keys.

    Enumerating the known dimensions (PREVENT_EXTRA is voluptuous' default for a
    nested ``Schema``) makes an unknown key a loud ``invalid_format`` command error
    rather than a silent drop. If a newer server emits a tenth dimension to this
    1.2.0 component, the server's error taxonomy converts that into a legacy fallback
    with the filter STILL correctly applied — structural fail-closed for free —
    instead of partial, unwarned filtering. Built at call time so it honors the
    monkeypatched ``vol`` in the unit suite.
    """
    schema: dict[Any, Any] = {vol.Optional(key): [str] for key in _VISIBILITY_LIST_KEYS}
    schema.update({vol.Optional(key): bool for key in _VISIBILITY_BOOL_KEYS})
    return vol.Schema(schema)


def _search_schema() -> dict[Any, Any]:
    return {
        vol.Required("type"): WS_SEARCH,
        vol.Optional("query"): vol.Any(str, None),
        vol.Optional("search_types"): [vol.In(ALL_SEARCH_TYPES)],
        vol.Optional("domain_filter"): str,
        vol.Optional("area_filter"): str,
        vol.Optional("state_filter"): str,
        vol.Optional("exact", default=True): bool,
        vol.Optional("include_hidden", default=True): bool,
        vol.Optional("include_config", default=False): bool,
        vol.Optional("limit", default=DEFAULT_LIMIT): vol.All(
            int, vol.Range(min=1, max=MAX_RESULTS)
        ),
        vol.Optional("offset", default=0): vol.All(int, vol.Range(min=0)),
        # Opt-in entity-visibility filter (``search_visibility`` capability): the
        # server's raw VisibilityConfig hide dimensions. When present and
        # non-empty, hidden entities are excluded from the entity results before
        # counts/pagination, mirroring the legacy ``load_hidden_set`` hard-exclude
        # so ``ha_search`` can drop its "filter active -> legacy only" gate. Gated
        # by the CAPABILITIES flag, so an old component never receives it. The nine
        # known keys are enumerated so an unknown dimension fails loudly (the server
        # then falls back to legacy with the filter applied) — see
        # :func:`_visibility_param_schema`.
        vol.Optional("visibility"): _visibility_param_schema(),
    }


def _overview_schema() -> dict[Any, Any]:
    return {
        vol.Required("type"): WS_OVERVIEW,
        vol.Optional("include_notifications", default=True): bool,
        vol.Optional("include_repairs", default=True): bool,
    }


def _helpers_list_schema() -> dict[Any, Any]:
    return {
        vol.Required("type"): WS_HELPERS_LIST,
        vol.Optional("helper_types"): [str],
        vol.Optional("include_flow_helpers", default=True): bool,
    }


def _states_schema() -> dict[Any, Any]:
    return {
        vol.Required("type"): WS_STATES,
        vol.Required("entity_ids"): [str],
    }


def _blueprint_get_schema() -> dict[Any, Any]:
    return {
        vol.Required("type"): WS_BLUEPRINT_GET,
        vol.Required("domain"): vol.In(BLUEPRINT_DOMAINS),
        vol.Required("path"): str,
    }


def _device_get_schema() -> dict[Any, Any]:
    return {
        vol.Required("type"): WS_DEVICE_GET,
        vol.Required("device_id"): str,
        vol.Optional("include_entities", default=False): bool,
    }


def _device_list_schema() -> dict[Any, Any]:
    return {vol.Required("type"): WS_DEVICE_LIST}


def _entity_enrich_schema() -> dict[Any, Any]:
    return {
        vol.Required("type"): WS_ENTITY_ENRICH,
        vol.Required("entity_ids"): [str],
    }


def _exposure_schema() -> dict[Any, Any]:
    return {
        vol.Required("type"): WS_EXPOSURE,
        vol.Optional("entity_id"): vol.Any(str, None),
    }


def _config_entries_schema() -> dict[Any, Any]:
    return {
        vol.Required("type"): WS_CONFIG_ENTRIES,
        vol.Optional("entry_id"): vol.Any(str, None),
        vol.Optional("domain"): vol.Any(str, None),
    }


def _registry_lookup_schema() -> dict[Any, Any]:
    # Exactly one of entity_ids / config_entry_id is meaningful; ``vol.Exclusive``
    # rejects a request carrying BOTH (the two share the ``target`` group).
    # Voluptuous has no clean way to express "at least one of" in a flat schema,
    # so a request with NEITHER present is caught in ``_do_registry_lookup``
    # instead (raises ``HomeAssistantError`` rather than a silent empty result).
    return {
        vol.Required("type"): WS_REGISTRY_LOOKUP,
        vol.Exclusive("entity_ids", "target"): [str],
        vol.Exclusive("config_entry_id", "target"): str,
    }


def _system_snapshot_schema() -> dict[Any, Any]:
    return {
        vol.Required("type"): WS_SYSTEM_SNAPSHOT,
        vol.Optional("include_states", default=True): bool,
        vol.Optional("include_entities", default=True): bool,
        vol.Optional("include_issues", default=True): bool,
        vol.Optional("include_config_entries", default=True): bool,
    }


def _entity_lookup_schema() -> dict[Any, Any]:
    return {
        vol.Required("type"): WS_ENTITY_LOOKUP,
        vol.Required("unique_id"): str,
        vol.Optional("domain"): vol.Any(str, None),
        vol.Optional("platform"): vol.Any(str, None),
    }


def _backup_prep_schema() -> dict[Any, Any]:
    return {vol.Required("type"): WS_BACKUP_PREP}


def _registries_schema() -> dict[Any, Any]:
    return {
        vol.Required("type"): WS_REGISTRIES,
        vol.Required("registries"): [vol.In(REGISTRY_KINDS)],
        vol.Optional("category_scopes"): [str],
    }


def _dashboards_schema() -> dict[Any, Any]:
    return {
        vol.Required("type"): WS_DASHBOARDS,
        vol.Optional("mode", default="list"): vol.In(("list", "get", "search")),
        # ``None``/absent url_path = the default dashboard (``get`` mode).
        vol.Optional("url_path"): vol.Any(str, None),
        vol.Optional("query"): vol.Any(str, None),
    }


def _services_list_schema() -> dict[Any, Any]:
    return {
        vol.Required("type"): WS_SERVICES_LIST,
        vol.Optional("domain"): vol.Any(str, None),
        vol.Optional("language", default="en"): str,
    }


def _reference_data_schema() -> dict[Any, Any]:
    return {
        vol.Required("type"): WS_REFERENCE_DATA,
        vol.Optional("include_states", default=True): bool,
    }


def _server_entry_schema() -> dict[Any, Any]:
    return {vol.Required("type"): WS_SERVER_ENTRY}


def _single_line_pip_spec(value: str) -> str:
    """Reject a multi-line / control-char ``pip_spec`` (defence-in-depth over D6)."""
    if any(ord(c) < 32 for c in value):
        raise vol.Invalid("pip_spec must be a single-line string")
    return value


def _server_entry_update_schema() -> dict[Any, Any]:
    # Both fields are optional at the schema level (voluptuous cannot cleanly express
    # "at least one of"); ``_server_entry_update_prep`` raises when NEITHER is present.
    # ``channel`` is gated to the known set and ``pip_spec`` gets the length +
    # single-line cap — schema-level defence-in-depth over (and symmetric with) the
    # server's own channel validation (D6) and pip-spec normalization.
    return {
        vol.Required("type"): WS_SERVER_ENTRY_UPDATE,
        vol.Optional("channel"): vol.In((CHANNEL_STABLE, CHANNEL_DEV)),
        vol.Optional("pip_spec"): vol.All(
            str,
            vol.Length(max=SERVER_ENTRY_UPDATE_MAX_PIP_SPEC),
            _single_line_pip_spec,
        ),
    }


def _call_service_schema() -> dict[Any, Any]:
    # ``entity_ids`` is the set of targets to CONFIRM (the pre/post transition is
    # built for these), not the service target itself — a caller may pass an empty
    # list for a non-entity service (``automation.trigger`` etc.) and still get the
    # dispatch result. ``timeout`` is capped at ``CALL_SERVICE_MAX_TIMEOUT`` so a
    # single write frame cannot park the connection. Mutable defaults use the
    # callable form so each validation produces a fresh ``{}`` / ``[]``.
    return {
        vol.Required("type"): WS_CALL_SERVICE,
        vol.Required("domain"): str,
        vol.Required("service"): str,
        vol.Optional("service_data", default=dict): dict,
        vol.Optional("entity_ids", default=list): [str],
        vol.Optional("wait", default=True): bool,
        vol.Optional("timeout", default=CALL_SERVICE_DEFAULT_TIMEOUT): vol.All(
            vol.Any(int, float), vol.Range(min=0, max=CALL_SERVICE_MAX_TIMEOUT)
        ),
        vol.Optional("return_response", default=False): bool,
        # Optional confirmation HINT (the server's ``_SERVICE_TO_STATE.get(service)``,
        # or None): the expected primary state the waiter confirms on REACHING —
        # skipping a multi-phase service's intermediate states and attribute-only
        # noise — and immediate-matches for an idempotent no-op. Optional/default-None
        # so a server that does not send it (or a non-mapped service) keeps today's
        # any-first-event confirmation. It governs confirmation TIMING only; the
        # returned transition is always the REAL observed one.
        vol.Optional("expected_state"): vol.Any(str, None),
    }


def _bulk_call_service_schema() -> dict[Any, Any]:
    # A batch of fully-resolved operations, each the single ``call_service`` row
    # minus its own ``wait`` / ``timeout`` / ``return_response`` (those are batch
    # scoped: one ``wait`` flag, one shared ``timeout`` deadline, and no per-op
    # ``return_response`` — bulk stays simple, the single call covers that need).
    # ``operations`` must be non-empty (an empty batch is a caller error, not a
    # no-op). ``timeout`` is capped at ``CALL_SERVICE_MAX_TIMEOUT`` so a single
    # batch frame cannot park the connection past that bound. Mutable per-op
    # defaults use the callable form so each validation yields a fresh ``{}`` /
    # ``[]``.
    operation = {
        vol.Required("domain"): str,
        vol.Required("service"): str,
        vol.Optional("service_data", default=dict): dict,
        vol.Optional("entity_ids", default=list): [str],
        # Per-op confirmation HINT (see ``_call_service_schema``): optional/default-
        # None so an older server (or a non-mapped service) keeps any-first-event
        # confirmation for that op.
        vol.Optional("expected_state"): vol.Any(str, None),
    }
    return {
        vol.Required("type"): WS_BULK_CALL_SERVICE,
        vol.Required("operations"): vol.All([operation], vol.Length(min=1)),
        vol.Optional("parallel", default=True): bool,
        vol.Optional("wait", default=True): bool,
        vol.Optional("timeout", default=CALL_SERVICE_DEFAULT_TIMEOUT): vol.All(
            vol.Any(int, float), vol.Range(min=0, max=CALL_SERVICE_MAX_TIMEOUT)
        ),
    }


# =============================================================================
# ha_mcp_tools/info
# =============================================================================
def _do_info(hass: HomeAssistant | None = None) -> dict[str, Any]:
    """Return the handshake payload.

    ``timezone`` is an additive field (``hass.config.time_zone``) consumers detect
    by presence — it carries NO capability entry and does NOT bump
    ``schema_version``. ``hass`` is optional (defaulting to ``None`` so a direct
    ``_do_info()`` still works for callers that only need the static handshake);
    when absent, ``timezone`` degrades to ``None``.
    """
    return {
        "schema_version": SCHEMA_VERSION,
        "component_version": COMPONENT_VERSION,
        "capabilities": list(CAPABILITIES),
        "limits": dict(LIMITS),
        "timezone": _config_time_zone(hass),
    }


def _config_time_zone(hass: HomeAssistant | None) -> str | None:
    """``hass.config.time_zone`` as a string, guarded against a hass-less call."""
    config = getattr(hass, "config", None)
    tz = getattr(config, "time_zone", None)
    return tz if isinstance(tz, str) and tz else None


# =============================================================================
# ha_mcp_tools/search
# =============================================================================
@dataclass
class _RegistryView:
    """Bundle of the five HA registries (any may be ``None`` if unavailable)."""

    entity: Any = None
    area: Any = None
    floor: Any = None
    label: Any = None
    device: Any = None


def _resolve_registries(hass: HomeAssistant) -> _RegistryView:
    """Snapshot the five registries. Test seam — monkeypatched in unit tests."""
    return _RegistryView(
        entity=_safe(er.async_get, hass),
        area=_safe(ar.async_get, hass),
        floor=_safe(fr.async_get, hass),
        label=_safe(lr.async_get, hass),
        device=_safe(dr.async_get, hass),
    )


def _safe(fn: Any, hass: HomeAssistant) -> Any:
    try:
        return fn(hass)
    except Exception:  # pragma: no cover - defensive; core drift
        return None


def _substrate_unavailable(name: str) -> Exception:
    """Build a ``HomeAssistantError`` for a drifted / unavailable core substrate.

    A core registry / service / state accessor that RAISED or was renamed comes
    back as ``None`` (registries, via ``_safe``) or a non-``Mapping``
    (services / descriptions) from the guarded readers. For the reads whose WHOLE
    answer is that substrate — ``entity_lookup``, ``registries``,
    ``reference_data``, ``services_list`` — returning a well-formed EMPTY would let
    the server trust an authoritative-negative it should instead fall back to legacy
    for (mistaking core DRIFT for "no such entry" / "empty catalog"). Raising routes
    the server's command-error path to its legacy WS/REST read, mirroring
    :func:`_backup_unavailable` / :func:`_registries_missing_category_scopes`. A
    genuinely-EMPTY-but-present substrate (a real empty registry) is NOT drift and
    keeps returning its empty result — the guards below key off unavailability
    (``None`` / non-``Mapping``), never off an empty-but-valid collection.
    """
    from homeassistant.exceptions import HomeAssistantError

    err: Exception = HomeAssistantError(
        f"ha_mcp_tools: the {name} is unavailable (core drift); the server should "
        "fall back to its legacy read"
    )
    return err


def _do_search(
    hass: HomeAssistant,
    params: dict[str, Any],
    *,
    secret_values: frozenset[str] = frozenset(),
) -> dict[str, Any]:
    """Unified in-process search. Pure over ``hass`` — the WS wrapper is thin.

    Joins live registries + states, scores per the server's tiers, paginates
    per surface, and returns the ``ha_search``-shaped envelope.

    ``secret_values`` is the resolved-``!secret`` scrub set, loaded off the event
    loop by :func:`_search_prep` and passed in (default empty — the loader is
    skipped for an entity-only search, and direct callers/tests supply it
    explicitly). It keeps this function a pure, synchronous in-memory read.
    """
    query_lower = (params.get("query") or "").strip().lower()
    match_all = not query_lower
    exact = params.get("exact", True)
    include_hidden = params.get("include_hidden", True)
    include_config = params.get("include_config", False)
    limit = params.get("limit", DEFAULT_LIMIT)
    offset = params.get("offset", 0)
    search_types = params.get("search_types") or ALL_SEARCH_TYPES
    domain_filter = params.get("domain_filter")
    area_filter = params.get("area_filter")
    state_filter = params.get("state_filter")
    # Opt-in visibility filter (search_visibility capability). A non-empty dict of
    # the server's raw VisibilityConfig fields; applied as a hard entity exclude.
    visibility = params.get("visibility")

    view = _resolve_registries(hass)
    diagnostics: dict[str, int] = {}
    partial_reasons: list[str] = []
    # Visibility degradation warnings (unknown category / empty-registry allowlist /
    # Assist unavailable), collected in the entity block below when a visibility
    # filter is applied. Surfaced additively so the fast path isn't silent about
    # incomplete filtering (parity with the server's load_hidden_set warnings).
    visibility_warnings: list[str] = []

    # ``secret_values`` (loaded off-loop by _search_prep) scrubs resolved-!secret
    # plaintext from the config-body match corpus: a YAML-loaded automation/script/
    # scene body (or a flow-helper's options) can carry a secret resolved to
    # plaintext, and matching inside it would make ha_search a probe oracle (query
    # a suspected secret, confirm via match_in_config). See _load_secret_values.

    # --- Entities ------------------------------------------------------------
    entities: list[dict[str, Any]] = []
    entity_total = 0
    entity_has_more = False
    if SEARCH_TYPE_ENTITY in search_types:
        scored_entities = _search_entities(
            hass,
            view,
            query_lower,
            match_all=match_all,
            exact=exact,
            include_hidden=include_hidden,
            domain_filter=domain_filter,
            area_filter=area_filter,
            state_filter=state_filter,
        )
        # Opt-in visibility filter: a hard exclude applied BEFORE counts/pagination,
        # exactly where the legacy path drops ``visibility_hidden`` entities at the
        # top of ``_match_exact_search_entity`` (independent of ``include_hidden``,
        # which the ``_search_entities`` join already applied). See
        # :func:`_visibility_hidden_set`.
        if isinstance(visibility, Mapping) and visibility:
            states_list = _iter_states(hass)
            # Probe Assist availability once (only when the config asks for it) so a
            # requested-but-unavailable Assist dimension both skips its hiding and
            # surfaces the resolver-parity degradation warning.
            assist_available = (
                _assist_exposure_available(hass)
                if visibility.get("respect_assist_exposure")
                else True
            )
            hidden = _visibility_hidden_set(
                view,
                states_list,
                visibility,
                lambda eid: _assist_should_expose(hass, eid),
                assist_available=assist_available,
            )
            visibility_warnings = _visibility_warnings(
                view, states_list, visibility, assist_available=assist_available
            )
            if hidden:
                scored_entities = [
                    r for r in scored_entities if r["entity_id"] not in hidden
                ]
        scored_entities.sort(key=lambda r: (-r["score"], r["entity_id"]))
        entity_total = len(scored_entities)
        page = scored_entities[offset : offset + limit]
        entity_has_more = offset + len(page) < entity_total
        entities = [_project_entity(r) for r in page]

    # --- Config surfaces (automations + scripts + scenes + helpers) ----------
    # One combined pagination window, mirroring the server's config branch.
    combined: list[tuple[str, dict[str, Any]]] = []
    for domain in CONFIG_SEARCH_TYPES:
        if domain in search_types:
            combined.extend(
                (domain, rec)
                for rec in _search_config_surface(
                    hass,
                    view,
                    domain,
                    query_lower,
                    match_all=match_all,
                    exact=exact,
                    include_config=include_config,
                    partial_reasons=partial_reasons,
                    diagnostics=diagnostics,
                    secret_values=secret_values,
                )
            )
    if SEARCH_TYPE_HELPER in search_types:
        combined.extend(
            ("helper", rec)
            for rec in _search_helpers(
                hass,
                query_lower,
                match_all=match_all,
                exact=exact,
                include_config=include_config,
                secret_values=secret_values,
            )
        )

    combined.sort(key=lambda item: (-item[1]["score"], _sort_key(item[1])))
    config_total = len(combined)
    config_page = combined[offset : offset + limit]
    config_has_more = offset + len(config_page) < config_total

    buckets: dict[str, list[dict[str, Any]]] = {
        "automations": [],
        "scripts": [],
        "scenes": [],
        "helpers": [],
    }
    bucket_of = {
        SEARCH_TYPE_AUTOMATION: "automations",
        SEARCH_TYPE_SCRIPT: "scripts",
        SEARCH_TYPE_SCENE: "scenes",
        "helper": "helpers",
    }
    for surface, rec in config_page:
        buckets[bucket_of[surface]].append(rec)

    result: dict[str, Any] = {
        "entities": entities,
        "entity_total_matches": entity_total,
        "entity_has_more": entity_has_more,
        "automations": buckets["automations"],
        "scripts": buckets["scripts"],
        "scenes": buckets["scenes"],
        "helpers": buckets["helpers"],
        "config_total_matches": config_total,
        "config_has_more": config_has_more,
        "partial": bool(partial_reasons),
        "partial_reason": " ; ".join(partial_reasons) if partial_reasons else None,
    }
    if diagnostics:
        result["diagnostics"] = diagnostics
    # Additive (present only when non-empty, no schema_version bump): the server's
    # ha_search consumer merges these into the response's top-level warnings.
    if visibility_warnings:
        result["visibility_warnings"] = visibility_warnings
    return result


def _sort_key(rec: dict[str, Any]) -> str:
    """Stable tiebreak for combined config sorting."""
    return str(rec.get("entity_id") or rec.get("id") or rec.get("name") or "")


async def _search_prep(hass: HomeAssistant, msg: dict[str, Any]) -> dict[str, Any]:
    """Async pre-step for ``search``: load the secret-scrub set off the loop.

    The scrub only applies to config/helper surfaces, so an entity-only search
    skips the ``secrets.yaml`` read entirely (perf gate). When a scrubbed surface
    is requested, the blocking ``open()`` + ``yaml.safe_load`` runs in the
    executor via :meth:`hass.async_add_executor_job` so the WS handler never
    blocks the event loop. The loaded set is handed to :func:`_do_search`.
    """
    search_types = msg.get("search_types") or ALL_SEARCH_TYPES
    scrub_surfaces = (*CONFIG_SEARCH_TYPES, SEARCH_TYPE_HELPER)
    if not any(st in search_types for st in scrub_surfaces):
        return {"secret_values": frozenset()}
    values = await hass.async_add_executor_job(_load_secret_values, hass)
    return {"secret_values": values}


def _load_secret_scrub(hass: HomeAssistant) -> tuple[frozenset[str], bool]:
    """Load the ``secrets.yaml`` scrub values, plus a ``degraded`` flag.

    Returns ``(values, degraded)``. ``values`` are the plaintext string forms of the
    instance's ``secrets.yaml`` scalars; they scrub resolved ``!secret`` plaintext
    out of two surfaces: the config-body match corpus (so ``ha_search`` cannot be a
    probe oracle — a query equal to a suspected secret confirmed via
    ``match_in_config``) and the ``options`` emitted by ``config_entries`` /
    ``helpers_list`` (so a resolved secret never leaves the component).

    Both string AND numeric scalars are collected as their ``str()`` form: an
    unquoted ``alarm_code: 1234`` is a YAML int, and a config-entry option can carry
    that secret back as an int leaf, so the scrub must be able to match it whether it
    arrives as ``1234`` or ``"1234"``. bool scalars are excluded ("True"/"False" are
    never credentials and would over-redact).

    ``degraded`` is True ONLY when a ``secrets.yaml`` is PRESENT but could not be
    read/parsed: the scrub then silently turns OFF, and a caller emitting options can
    surface ``degraded`` so an unredacted response is not mistaken for a cleanly
    scrubbed one. An ABSENT ``secrets.yaml`` (the common case) is NOT degraded —
    there is simply nothing to scrub.

    Defensive by design — never raises into the WS handler. Loaded off the event loop
    by the async preps once per call, never cached, so an edited ``secrets.yaml``
    applies on the next call. ``secrets.yaml`` is a flat ``key: value`` mapping with
    no custom tags, so the plain ``yaml.safe_load`` (not HA's ``!secret``/
    ``!include`` loader) reads it correctly.
    """
    config = getattr(hass, "config", None)
    path_fn = getattr(config, "path", None)
    if not callable(path_fn):
        return frozenset(), False
    try:
        path = path_fn("secrets.yaml")
        if not path:
            return frozenset(), False
        with open(path, encoding="utf-8") as handle:
            raw = yaml.safe_load(handle)
    except FileNotFoundError:
        # Expected: many instances have no secrets.yaml — nothing to scrub.
        return frozenset(), False
    except Exception:
        # Present-but-unreadable / malformed / permission error: unexpected, so warn
        # once (this runs once per call) AND report degraded so the emission callers
        # can signal that options were NOT redacted, rather than raising into the WS
        # handler.
        _LOGGER.warning(
            "Could not read secrets.yaml for the secret-scrub; continuing WITHOUT "
            "redaction (emitted options may be unredacted)",
            exc_info=True,
        )
        return frozenset(), True
    if not isinstance(raw, dict):
        return frozenset(), False
    return _collect_secret_strings(raw), False


def _collect_secret_strings(raw: dict[Any, Any]) -> frozenset[str]:
    """Plaintext ``str()`` forms of ``secrets.yaml`` scalars (str/int/float).

    bool scalars are excluded ("True"/"False" are never credentials and would
    over-redact); empty strings are dropped. See :func:`_load_secret_scrub`.
    """
    values: set[str] = set()
    for v in raw.values():
        if isinstance(v, bool):
            continue
        if isinstance(v, str):
            if v:
                values.add(v)
        elif isinstance(v, (int, float)):
            values.add(str(v))
    return frozenset(values)


def _load_secret_values(hass: HomeAssistant) -> frozenset[str]:
    """The ``secrets.yaml`` scrub set (see :func:`_load_secret_scrub`); degraded dropped.

    The ``search`` corpus scrub is best-effort and does not surface the degraded
    signal (its filtering degrading open is the pre-PR behaviour); the
    ``config_entries`` / ``helpers_list`` emission preps call
    :func:`_load_secret_scrub` directly so they can surface it.
    """
    values, _degraded = _load_secret_scrub(hass)
    return values


# --- Entity join + scoring ---------------------------------------------------
def _search_entities(
    hass: HomeAssistant,
    view: _RegistryView,
    query_lower: str,
    *,
    match_all: bool,
    exact: bool,
    include_hidden: bool,
    domain_filter: str | None,
    area_filter: str | None,
    state_filter: str | None,
) -> list[dict[str, Any]]:
    """Score every state against the query over the joined registry view."""
    results: list[dict[str, Any]] = []
    area_filter_lower = area_filter.lower() if area_filter else None
    # Lower the state filter once; the entity state is lowered per record so the
    # compare is case-insensitive (e.g. an input_select holding "Vacation"
    # matches state_filter="vacation").
    state_filter_lower = state_filter.lower() if state_filter is not None else None
    for state in _iter_states(hass):
        rec = _entity_record(state, view)
        if domain_filter and rec["domain"] != domain_filter:
            continue
        if rec["_hidden"] and not include_hidden:
            continue
        if (
            state_filter_lower is not None
            and (rec["state"] or "").lower() != state_filter_lower
        ):
            continue
        if area_filter_lower is not None and not _entity_matches_area(
            rec, area_filter_lower
        ):
            continue

        if match_all:
            score: int | None = _apply_hidden_penalty(100, rec["_hidden"])
            match_type = "match_all"
        else:
            tier = _text_tier(query_lower, rec["_match_texts"], fuzzy=not exact)
            if tier is None:
                continue
            score = _apply_hidden_penalty(tier, rec["_hidden"])
            match_type = _entity_match_type(
                query_lower,
                rec["entity_id"],
                rec["friendly_name"],
                rec["domain"],
                rec["aliases"],
                exact=exact,
            )
        rec["score"] = score
        rec["match_type"] = match_type
        results.append(rec)
    return results


def _entity_match_type(
    query_lower: str,
    entity_id: str,
    friendly: str,
    domain: str,
    aliases: list[str],
    *,
    exact: bool,
) -> str:
    """Classify an entity hit into the server's match_type taxonomy.

    The server labels matches two ways and the component must be
    indistinguishable from it:

    - **exact mode** — the server's ``_match_exact_search_entity`` stamps a flat
      ``"exact_match"`` on every hit, so mirror that constant.
    - **fuzzy mode** — the server's ``FuzzySearchEngine`` emits a richer set that
      agents key on. ``"alias_match"`` wins when the hit is driven by an alias
      token the id/name don't already carry (the engine's ``alias_hit`` tracking
      — closes #1166); otherwise the ``_get_match_type`` tiers: ``exact_id`` /
      ``exact_name`` / ``exact_domain`` / ``partial_id`` / ``partial_name``,
      falling to ``fuzzy_match``.
    """
    if exact:
        return "exact_match"
    if _is_alias_driven(query_lower, entity_id, friendly, aliases):
        return "alias_match"
    return _get_match_type_tier(query_lower, entity_id, friendly, domain)


def _is_alias_driven(
    query_lower: str, entity_id: str, friendly: str, aliases: list[str]
) -> bool:
    """Whether a query token lands only on an alias, mirroring the engine's alias_hit.

    Collects the alias tokens (and each alias's separator-stripped concat form)
    that are NOT already present in the id/name token set; a query token in that
    set means the friendly_name / id alone would not have surfaced this entity.
    """
    id_tail = entity_id.split(".", 1)[1] if "." in entity_id else entity_id
    id_name_tokens = set(_tokenize(entity_id)) | set(_tokenize(str(friendly)))
    id_name_tokens.add(_SPLIT_RE.sub("", id_tail.lower()))
    id_name_tokens.add(_SPLIT_RE.sub("", str(friendly).lower()))
    alias_only: set[str] = set()
    for alias in aliases:
        a_lower = str(alias).lower()
        for tok in _tokenize(a_lower):
            if tok not in id_name_tokens:
                alias_only.add(tok)
        a_concat = _SPLIT_RE.sub("", a_lower)
        if a_concat and a_concat not in id_name_tokens:
            alias_only.add(a_concat)
    return bool(set(_tokenize(query_lower)) & alias_only)


def _get_match_type_tier(
    query_lower: str, entity_id: str, friendly: str, domain: str
) -> str:
    """The server's ``_get_match_type`` id/name/domain tiers (non-alias hits)."""
    eid = entity_id.lower()
    fname = str(friendly).lower()
    if query_lower == eid:
        return "exact_id"
    if query_lower == fname:
        return "exact_name"
    if query_lower == domain.lower():
        return "exact_domain"
    if query_lower in eid:
        return "partial_id"
    if query_lower in fname:
        return "partial_name"
    return "fuzzy_match"


def _registry_enrichment(view: _RegistryView, entity_id: str) -> dict[str, Any]:
    """Join one entity_id with the entity/device/area/floor/label registries.

    The shared registry read behind BOTH the search record (:func:`_entity_record`)
    and the ``entity_enrich`` / ``exposure`` commands, so the area/floor/label-name
    resolution lives in exactly one place. Resolves the entity's aliases plus its
    area / floor / label NAMES (device-inherited when the entity itself carries
    none), keyed off the entity registry — no ``State`` object required, so a
    registry-only (stateless) entity is enriched too. Returns the public
    enrichment fields (``area`` / ``floor`` / ``labels`` / ``aliases``) alongside
    the internal ``_area_id`` / ``_hidden`` / ``_dev_texts`` the scorer consumes.
    """
    reg = _reg_entity(view, entity_id)
    # String entries only: HA core's aliases can carry the COMPUTED_NAME
    # sentinel (entity_registry.ComputedNameType._singleton, "the computed
    # entity name is an alias"). Blind str() published it as a literal
    # "ComputedNameType._singleton" alias on every carrying entity — fake data
    # in results AND a scored match_text. The name it stands for is already
    # matched via ``friendly``, so dropping the sentinel loses nothing.
    aliases = (
        sorted(a for a in (getattr(reg, "aliases", None) or []) if isinstance(a, str))
        if reg
        else []
    )
    area_id = getattr(reg, "area_id", None) if reg else None
    device_id = getattr(reg, "device_id", None) if reg else None
    labels = set(getattr(reg, "labels", None) or []) if reg else set()
    hidden = bool(getattr(reg, "hidden_by", None)) if reg else False

    dev = _device(view, device_id) if device_id else None
    dev_texts: list[str] = []
    if dev is not None:
        if area_id is None:
            area_id = getattr(dev, "area_id", None)
        labels |= set(getattr(dev, "labels", None) or [])
        for attr in ("name_by_user", "name", "manufacturer", "model"):
            val = getattr(dev, attr, None)
            if val:
                dev_texts.append(str(val))

    return {
        "area": _area_name(view, area_id),
        "floor": _floor_name_for_area(view, area_id),
        "labels": _label_names(view, labels),
        "aliases": aliases,
        "_area_id": area_id,
        "_hidden": hidden,
        "_dev_texts": dev_texts,
    }


def _entity_record(state: Any, view: _RegistryView) -> dict[str, Any]:
    """Join a state with the entity/device/area/floor/label registries."""
    entity_id = getattr(state, "entity_id", "") or ""
    domain = entity_id.split(".")[0] if "." in entity_id else ""
    attrs = getattr(state, "attributes", None) or {}
    friendly = attrs.get("friendly_name", entity_id)

    join = _registry_enrichment(view, entity_id)
    area_name = join["area"]
    floor_name = join["floor"]
    label_names = join["labels"]
    aliases = join["aliases"]

    # Scored texts extend the server's id + friendly-name pair with the specific
    # joined identifiers (alias / area / floor / label / device). The bare domain
    # is deliberately excluded: matching it would score every entity of a domain
    # at the exact tier (a "light" query flooding all lights), which the server
    # does not do — domain is a filter dimension, not a scored text.
    match_texts = [entity_id, friendly, *aliases, *label_names, *join["_dev_texts"]]
    if area_name:
        match_texts.append(area_name)
    if floor_name:
        match_texts.append(floor_name)

    return {
        "entity_id": entity_id,
        "friendly_name": friendly,
        "domain": domain,
        "state": getattr(state, "state", "unknown"),
        "area": area_name,
        "floor": floor_name,
        "labels": label_names,
        "aliases": aliases,
        "_hidden": join["_hidden"],
        "_area_id": join["_area_id"],
        "_match_texts": match_texts,
    }


def _entity_matches_area(rec: dict[str, Any], area_filter_lower: str) -> bool:
    area_id = rec.get("_area_id")
    if area_id and str(area_id).lower() == area_filter_lower:
        return True
    area_name = rec.get("area")
    return bool(area_name and str(area_name).lower() == area_filter_lower)


def _project_entity(rec: dict[str, Any]) -> dict[str, Any]:
    """Strip internal ``_``-prefixed keys for the wire response."""
    return {
        "entity_id": rec["entity_id"],
        "friendly_name": rec["friendly_name"],
        "domain": rec["domain"],
        "state": rec["state"],
        "area": rec["area"],
        "floor": rec["floor"],
        "labels": rec["labels"],
        "aliases": rec["aliases"],
        "score": rec["score"],
        "match_type": rec["match_type"],
    }


# --- Config surfaces (automation/script/scene) -------------------------------
def _search_config_surface(
    hass: HomeAssistant,
    view: _RegistryView,
    domain: str,
    query_lower: str,
    *,
    match_all: bool,
    exact: bool,
    include_config: bool,
    partial_reasons: list[str],
    diagnostics: dict[str, int],
    secret_values: frozenset[str] = frozenset(),
) -> list[dict[str, Any]]:
    """Score one config domain's loaded entities (raw_config indexed, not emitted for YAML)."""
    component = hass.data.get(domain) if getattr(hass, "data", None) else None
    entities = getattr(component, "entities", None)
    if entities is None:
        diagnostics["config_components_inaccessible"] = (
            diagnostics.get("config_components_inaccessible", 0) + 1
        )
        return []

    results: list[dict[str, Any]] = []
    for entity in entities:
        entity_id = getattr(entity, "entity_id", None)
        if not entity_id:
            continue
        name, item_id, config_dict = _extract_config(domain, entity)
        source = _classify_source(item_id)

        if match_all:
            score: int | None = 100
            match_in_name = False
            match_in_config = False
        else:
            scored = _config_score(
                query_lower,
                entity_id,
                name,
                config_dict,
                exact=exact,
                secret_values=secret_values,
            )
            if scored is None:
                continue
            score, match_in_name, match_in_config = scored

        # Scenes never emit a component-served body: a HomeAssistantScene holds no
        # raw storage dict (its states are runtime State objects), so config stays
        # None and config_dict is used only as the faithful match corpus.
        config_out: dict[str, Any] | None = None
        if (
            domain != SEARCH_TYPE_SCENE
            and include_config
            and source == "storage"
            and config_dict is not None
        ):
            if _too_large(config_dict):
                partial_reasons.append(f"{domain} {entity_id} body omitted (too large)")
            else:
                config_out = config_dict

        rec: dict[str, Any] = {
            "id": item_id,
            "entity_id": entity_id,
            "source": source,
            "score": score,
            "match_in_name": match_in_name,
            "match_in_config": match_in_config,
            "config": config_out,
        }
        # Scenes carry a "name"; automations/scripts carry an "alias".
        if domain == SEARCH_TYPE_SCENE:
            rec["name"] = name
        else:
            rec["alias"] = name
        results.append(rec)
    return results


def _extract_config(
    domain: str, entity: Any
) -> tuple[str, str | None, dict[str, Any] | None]:
    """Return (display_name, item_id, config_dict) for a config entity.

    Uses defensive getattr because the exact accessor can drift across core
    versions: automation/script expose ``raw_config``; scenes expose
    ``scene_config`` (name/icon/id/states) rather than ``raw_config``. For a
    scene the returned ``config_dict`` is the faithful MATCH corpus only (see
    :func:`_scene_match_corpus`), never an emittable body.
    """
    entity_id = getattr(entity, "entity_id", "") or ""
    name = getattr(entity, "name", None) or entity_id
    unique_id = getattr(entity, "unique_id", None)

    if domain == SEARCH_TYPE_SCENE:
        scene_config = getattr(entity, "scene_config", None)
        config_dict = _scene_match_corpus(scene_config)
        item_id = unique_id
        if item_id is None and config_dict is not None:
            item_id = config_dict.get("id")
        if config_dict is not None:
            cfg_name = config_dict.get("name")
            if cfg_name:
                name = str(cfg_name)
        return str(name), (str(item_id) if item_id is not None else None), config_dict

    raw = getattr(entity, "raw_config", None)
    config_dict = dict(raw) if isinstance(raw, dict) else None
    item_id = unique_id
    if item_id is None and config_dict is not None:
        item_id = config_dict.get("id")
    return str(name), (str(item_id) if item_id is not None else None), config_dict


def _scene_match_corpus(scene_config: Any) -> dict[str, Any] | None:
    """Faithful, minimal MATCH corpus for a scene — never an emittable body.

    A ``HomeAssistantScene`` holds no raw storage dict: ``scene_config.states`` is
    a ``{entity_id: State}`` map of RUNTIME ``State`` objects. Scoring/emitting
    those (each stringifying to ``<state light.x=on; ...>``) was garbage and
    diverged the component's scoring from any real body. Index only the faithful,
    non-runtime facts instead: ``id`` / ``name`` / ``icon`` plus the entity-id
    KEYS of ``states`` (so "which scenes touch ``light.x``" still matches) — no
    State values, no timestamps or contexts. Used for MATCHING only; the scene
    record never emits a ``config`` body (see :func:`_search_config_surface`).
    """
    if scene_config is None:
        return None
    if isinstance(scene_config, Mapping):
        src: Mapping[str, Any] = scene_config
    else:
        collected: dict[str, Any] = {}
        for attr in ("id", "name", "icon", "states", "entities"):
            val = getattr(scene_config, attr, None)
            if val is not None:
                collected[attr] = val
        src = collected
    out: dict[str, Any] = {}
    for key in ("id", "name", "icon"):
        val = src.get(key)
        if val is not None:
            out[key] = str(val)
    entity_ids: set[str] = set()
    for key in ("states", "entities"):
        mapping = src.get(key)
        if isinstance(mapping, Mapping):
            entity_ids.update(str(k) for k in mapping)
    if entity_ids:
        out["entities"] = sorted(entity_ids)
    return out or None


def _plainify(value: Any) -> Any:
    """Best-effort conversion of registry/state objects to plain JSON-able data.

    Recurses on any ``Mapping`` (not just ``dict``): a nested ``MappingProxyType``
    (common in ``ConfigEntry.options``) must be walked into a plain dict, NOT
    stringified — otherwise a secret buried inside it survives embedded in the repr
    string, past the equality scrub that runs on the result.
    """
    if isinstance(value, Mapping):
        return {str(k): _plainify(v) for k, v in value.items()}
    if isinstance(value, (list, tuple, set)):
        return [_plainify(v) for v in value]
    if isinstance(value, (str, int, float, bool)) or value is None:
        return value
    return str(value)


def _classify_source(item_id: str | None) -> str:
    """Classify an automation/script/scene as storage- or YAML-backed.

    HA addresses editor-managed items by their ``id`` (the entity's
    ``unique_id``); the config editor's ``/config/<domain>/config/<id>`` REST
    path — and its edit link — key off exactly that id, and 404 for items with
    no id. So an id-bearing item is treated as ``storage`` (body emittable under
    ``include_config``); an id-less item is ``yaml`` and its body is never
    emitted from search (its ``raw_config`` may carry resolved ``!secret``
    plaintext). This is the conservative rule: the safe error is toward
    withholding a body, not leaking one.
    """
    return "storage" if item_id else "yaml"


def _too_large(config_dict: dict[str, Any]) -> bool:
    """Rough guard so a huge body never balloons a single WS frame."""
    try:
        return len(repr(config_dict)) > MAX_BODY_BYTES
    except Exception:  # pragma: no cover - defensive
        return False


# --- Helpers surface ---------------------------------------------------------
def _collection_storage_index(
    hass: HomeAssistant, domains: frozenset[str]
) -> dict[str, tuple[dict[str, Any], str | None]]:
    """Map collection-helper ``entity_id`` -> ``(storage body, storage id)``.

    Collection helpers keep their full storage config on the ``CollectionEntity``
    as ``_config`` — a schedule's weekday blocks, an input_datetime's
    ``has_date``/``has_time``, an input_boolean's ``initial`` — fields the live
    state attributes do NOT carry. The ``StorageCollection`` that loaded them is a
    setup-local (``helpers/collection.py`` writes nothing to ``hass.data``), so
    the reachable in-process source is the domain's ``EntityComponent``
    (``hass.data['entity_components'][domain]``, or ``hass.data[domain]`` for the
    automation/script/scene pattern) and each entity's ``_config``.

    Domains that decompose config into ``_attr_*`` instead of keeping ``_config``
    (input_number / input_text / input_select) have no entry here; the caller
    falls back to the state-attributes body for them (and for any YAML-defined
    helper whose entity is absent). All access is getattr-guarded against drift.
    """
    index: dict[str, tuple[dict[str, Any], str | None]] = {}
    data = getattr(hass, "data", None)
    if not isinstance(data, Mapping):
        return index
    instances = data.get(ENTITY_COMPONENTS_KEY)
    for domain in domains:
        component = (
            instances.get(domain) if isinstance(instances, Mapping) else None
        ) or data.get(domain)
        entities = getattr(component, "entities", None)
        if entities is None:
            continue
        try:
            entity_list = list(entities)
        except Exception:  # pragma: no cover - defensive
            continue
        for entity in entity_list:
            entity_id = getattr(entity, "entity_id", None)
            if not entity_id:
                continue
            raw = getattr(entity, "_config", None)
            if not isinstance(raw, dict):
                continue
            storage_id = getattr(entity, "unique_id", None) or raw.get("id")
            index[entity_id] = (
                dict(raw),
                str(storage_id) if storage_id is not None else None,
            )
    return index


def _search_helpers(
    hass: HomeAssistant,
    query_lower: str,
    *,
    match_all: bool,
    exact: bool,
    include_config: bool,
    secret_values: frozenset[str] = frozenset(),
) -> list[dict[str, Any]]:
    """Index collection helpers (states) + flow helpers (config-entry options)."""
    results: list[dict[str, Any]] = []

    # Collection helpers: entities in the state machine, matched on entity_id /
    # friendly_name AND the searchable config body. That body is the entity's real
    # storage ``_config`` (a schedule's weekday blocks, an input_select's
    # ``options`` + ``initial``, …) when reachable, falling back to the live state
    # attributes otherwise (see _collection_storage_index). The name still comes
    # from the CURRENT friendly_name, not the creation-time storage name.
    storage = _collection_storage_index(hass, COLLECTION_HELPER_DOMAINS)
    for state in _iter_states(hass):
        entity_id = getattr(state, "entity_id", "") or ""
        domain = entity_id.split(".")[0] if "." in entity_id else ""
        if domain not in COLLECTION_HELPER_DOMAINS:
            continue
        attrs = getattr(state, "attributes", None) or {}
        name = attrs.get("friendly_name", entity_id)
        object_id = entity_id.split(".", 1)[1] if "." in entity_id else entity_id
        stored = storage.get(entity_id)
        if stored is not None:
            body = _plainify(stored[0])
        else:
            body = dict(attrs) if isinstance(attrs, Mapping) else {}
        if match_all:
            score: int | None = 100
            match_in_name = False
            match_in_config = False
        else:
            scored = _config_score(
                query_lower,
                entity_id,
                name,
                body,
                exact=exact,
                secret_values=secret_values,
            )
            if scored is None:
                continue
            score, match_in_name, match_in_config = scored
        results.append(
            {
                "entity_id": entity_id,
                "helper_type": domain,
                "object_id": object_id,
                "name": name,
                "kind": "collection",
                "score": score,
                "match_in_name": match_in_name,
                "match_in_config": match_in_config,
                "config": body if include_config else None,
            }
        )

    # Flow helpers: config entries — options + title ONLY, never data.
    for entry in _iter_config_entries(hass):
        domain = getattr(entry, "domain", None)
        if domain not in FLOW_HELPER_DOMAINS:
            continue
        title = getattr(entry, "title", None) or ""
        # ``ConfigEntry.options`` is a ``MappingProxyType`` in live HA, not a
        # ``dict``; the old ``isinstance(..., dict)`` guard silently dropped it to
        # ``{}``, so a flow helper's body (a template's ``state``, a group's
        # members, …) was never indexed and ``match_in_config`` could never fire.
        # Accept any ``Mapping`` so the persisted options are searchable and
        # emittable under ``include_config``.
        raw_options = getattr(entry, "options", None)
        options = dict(raw_options) if isinstance(raw_options, Mapping) else {}
        entry_id = getattr(entry, "entry_id", None)
        if match_all:
            score = 100
            match_in_name = False
            match_in_config = False
        else:
            scored = _config_score(
                query_lower,
                title,
                title,
                options,
                exact=exact,
                secret_values=secret_values,
            )
            if scored is None:
                continue
            score, match_in_name, match_in_config = scored
        results.append(
            {
                "entity_id": None,
                "helper_type": domain,
                "entry_id": entry_id,
                "name": title,
                "kind": "flow",
                "score": score,
                "match_in_name": match_in_name,
                "match_in_config": match_in_config,
                # Data minimization: options only, never entry.data.
                "options": options if include_config else None,
            }
        )
    return results


# =============================================================================
# Scoring — mirrors the server's tiers (guarded by the golden parity test)
# =============================================================================
def _apply_hidden_penalty(score: int, hidden: bool) -> int:
    """Reduce ``score`` by :data:`HIDDEN_SCORE_PENALTY` for hidden entities.

    Mirrors ``utils.fuzzy_search.apply_hidden_penalty`` so the two rankings
    stay consistent.
    """
    s = int(score)
    return max(0, s - HIDDEN_SCORE_PENALTY) if hidden else s


def _calc_ratio(a: str, b: str) -> int:
    """SequenceMatcher ratio (0-100). Mirrors ``fuzzy_search.calculate_ratio``."""
    return int(SequenceMatcher(None, a, b, autojunk=False).ratio() * 100)


def _tokenize(text: str) -> list[str]:
    """Split on ``.``/``_``/``-``/whitespace, lowercase, drop empties.

    Mirrors ``utils.fuzzy_search.tokenize``.
    """
    return [t for t in _SPLIT_RE.split(text.lower()) if t]


def _sep_normalized(text: str) -> str:
    """Collapse ``.``/``_``/``-``/whitespace runs to single spaces.

    The server's fuzzy engine (BM25) tokenizes query and documents with the
    same splitter, making ``input_boolean`` and ``input boolean`` equivalent
    queries (pinned by the e2e underscore/space-equivalence test). Comparing
    separator-normalized strings replicates that equivalence for the
    component's tier scorer.
    """
    return " ".join(_tokenize(text))


def _text_tier(query_lower: str, texts: Any, *, fuzzy: bool) -> int | None:
    """Entity tier: 100 (exact), 80 (substring), fuzzy ratio (>=threshold), or None.

    Mirrors the server's ``_match_exact_search_entity`` (100/80) over the entity
    id + friendly name, extended to the joined alias/area/floor/label/domain/
    device texts. In fuzzy mode comparisons run on BOTH the raw strings and
    their separator-normalized forms (unified tokenization — ``_``/space
    equivalence), with a whole-string ``calculate_ratio`` fallback surfacing
    typos above :data:`FUZZY_THRESHOLD`. Exact mode stays raw-only for
    byte-parity with the server's exact path.
    """
    query_norm = _sep_normalized(query_lower) if fuzzy else ""
    best_substring: int | None = None
    best_ratio = 0
    for text in texts:
        if not text:
            continue
        tier, ratio = _tier_one_text(query_lower, query_norm, str(text).lower(), fuzzy)
        if tier == 100:
            return 100
        if tier == 80:
            best_substring = 80
        elif ratio > best_ratio:
            best_ratio = ratio
    if best_substring is not None:
        return best_substring
    if fuzzy and best_ratio >= FUZZY_THRESHOLD:
        return best_ratio
    return None


def _tier_one_text(
    query_lower: str, query_norm: str, text_lower: str, fuzzy: bool
) -> tuple[int | None, int]:
    """Score one candidate text: ``(tier, ratio)``.

    Tier 100 = exact (raw, or separator-normalized in fuzzy mode); tier 80 =
    substring (same two forms); otherwise ``ratio`` carries the fuzzy
    whole-string fallback (0 when not in fuzzy mode).
    """
    if query_lower == text_lower:
        return 100, 0
    text_norm = _sep_normalized(text_lower) if fuzzy and query_norm else ""
    if text_norm and query_norm == text_norm:
        return 100, 0
    if query_lower in text_lower:
        return 80, 0
    if text_norm and query_norm in text_norm:
        return 80, 0
    if fuzzy:
        return None, _calc_ratio(query_lower, text_lower)
    return None, 0


def _name_tier(query_lower: str, texts: Any, *, exact: bool) -> int | None:
    """Config-name tier: substring => 100 (not 80), else fuzzy ratio or None.

    Config name matches are binary 100/0 in the server's exact path
    (``_score_deep_match``: ``name_exact = 100 if query in id/name else 0``),
    unlike entity matches which have the 80 substring tier.
    """
    query_norm = "" if exact else _sep_normalized(query_lower)
    best_ratio = 0
    for text in texts:
        if not text:
            continue
        text_lower = str(text).lower()
        if query_lower in text_lower:
            return 100
        if not exact:
            if query_norm and query_norm in _sep_normalized(text_lower):
                return 100
            ratio = _calc_ratio(query_lower, text_lower)
            best_ratio = max(best_ratio, ratio)
    if not exact and best_ratio >= FUZZY_THRESHOLD:
        return best_ratio
    return None


def _config_score(
    query_lower: str,
    entity_id: str,
    name: str,
    config_dict: dict[str, Any] | None,
    *,
    exact: bool,
    secret_values: frozenset[str] = frozenset(),
) -> tuple[int, bool, bool] | None:
    """Score a config surface: (total, match_in_name, match_in_config) or None.

    Exact mode is binary 100/0 with a threshold of 100 (server parity); fuzzy
    mode floors at :data:`FUZZY_THRESHOLD`. ``secret_values`` scrubs the body
    match corpus (see :func:`_search_in_dict_exact`).
    """
    name_score = _name_tier(query_lower, [entity_id, name], exact=exact) or 0
    config_score = _config_body_score(
        query_lower, config_dict, exact=exact, secret_values=secret_values
    )
    threshold = 100 if exact else FUZZY_THRESHOLD
    total = max(name_score, config_score)
    if total < threshold:
        return None
    return total, name_score >= threshold, config_score >= threshold


def _config_body_score(
    query_lower: str,
    config_dict: dict[str, Any] | None,
    *,
    exact: bool,
    secret_values: frozenset[str] = frozenset(),
) -> int:
    """Match the query against a config body's keys/values.

    Exact => 100/0 substring (``_search_in_dict_exact`` parity). Fuzzy adds a
    token-vs-token ``calculate_ratio`` fallback (the server's tier-3 path).
    ``secret_values`` scrubs resolved-``!secret`` leaves from the corpus.
    """
    if config_dict is None:
        return 0
    if _search_in_dict_exact(config_dict, query_lower, secret_values) >= 100:
        return 100
    if exact:
        return 0
    leaves: list[str] = []
    _collect_string_leaves(config_dict, leaves, secret_values)
    query_tokens = _tokenize(query_lower)
    if not query_tokens:
        return 0
    doc_tokens = {tok for leaf in leaves for tok in _tokenize(leaf)}
    best = 0
    for qt in query_tokens:
        for dt in doc_tokens:
            best = max(best, _calc_ratio(qt, dt))
    return best if best >= FUZZY_THRESHOLD else 0


def _search_in_dict_exact(
    data: Any, query_lower: str, secret_values: frozenset[str] = frozenset()
) -> int:
    """Exact substring search in nested structures (100 or 0).

    Mirrors ``smart_search._scoring.ScoringMixin._search_in_dict_exact``, plus a
    secret scrub: a string leaf that exactly equals a known secret value never
    contributes a match (see :func:`_load_secret_values`), so a query equal to a
    resolved ``!secret`` cannot be confirmed via ``match_in_config``. Keys and
    non-string scalars are never secrets, so they are matched as before.
    """
    if isinstance(data, dict):
        for key, value in data.items():
            if query_lower in str(key).lower():
                return 100
            if _search_in_dict_exact(value, query_lower, secret_values) >= 100:
                return 100
        return 0
    if isinstance(data, (list, tuple)):
        for item in data:
            if _search_in_dict_exact(item, query_lower, secret_values) >= 100:
                return 100
        return 0
    return _leaf_exact_score(data, query_lower, secret_values)


def _leaf_exact_score(
    data: Any, query_lower: str, secret_values: frozenset[str]
) -> int:
    """Exact substring score for a scalar leaf (100 or 0).

    A string leaf that exactly equals a known secret value scores 0 — the scrub
    that keeps a resolved ``!secret`` out of the match corpus.
    """
    if isinstance(data, str):
        if data in secret_values:
            return 0
        return 100 if query_lower in data.lower() else 0
    if data is not None:
        return 100 if query_lower in str(data).lower() else 0
    return 0


def _collect_string_leaves(
    data: Any, out: list[str], secret_values: frozenset[str] = frozenset()
) -> None:
    """Recursively collect string representations. Mirrors the server helper.

    A string leaf that exactly equals a known secret value is dropped so it
    never reaches the fuzzy token corpus (the scrub in :func:`_search_in_dict_exact`
    covers the exact path).
    """
    if isinstance(data, dict):
        for key, value in data.items():
            out.append(str(key))
            _collect_string_leaves(value, out, secret_values)
    elif isinstance(data, (list, tuple)):
        for item in data:
            _collect_string_leaves(item, out, secret_values)
    elif isinstance(data, str):
        if data not in secret_values:
            out.append(data)
    elif data is not None:
        out.append(str(data))


# =============================================================================
# Registry accessors (all getattr-guarded against core drift)
# =============================================================================
def _iter_states(hass: HomeAssistant) -> list[Any]:
    states = getattr(hass, "states", None)
    getter = getattr(states, "async_all", None) if states is not None else None
    if getter is None:
        return []
    try:
        return list(getter())
    except Exception:  # pragma: no cover - defensive
        return []


def _iter_config_entries(hass: HomeAssistant) -> list[Any]:
    config_entries = getattr(hass, "config_entries", None)
    getter = (
        getattr(config_entries, "async_entries", None)
        if config_entries is not None
        else None
    )
    if getter is None:
        return []
    try:
        return list(getter())
    except Exception:  # pragma: no cover - defensive
        return []


def _reg_entity(view: _RegistryView, entity_id: str) -> Any:
    return _call_lookup(view.entity, "async_get", entity_id)


def _device(view: _RegistryView, device_id: str | None) -> Any:
    if not device_id:
        return None
    return _call_lookup(view.device, "async_get", device_id)


def _area_name(view: _RegistryView, area_id: str | None) -> str | None:
    if not area_id:
        return None
    area = _call_lookup(view.area, "async_get_area", area_id)
    name = getattr(area, "name", None) if area is not None else None
    return str(name) if name else None


def _floor_name_for_area(view: _RegistryView, area_id: str | None) -> str | None:
    if not area_id:
        return None
    area = _call_lookup(view.area, "async_get_area", area_id)
    floor_id = getattr(area, "floor_id", None) if area is not None else None
    if not floor_id:
        return None
    floor = _call_lookup(view.floor, "async_get_floor", floor_id)
    name = getattr(floor, "name", None) if floor is not None else None
    return str(name) if name else None


def _label_names(view: _RegistryView, label_ids: Any) -> list[str]:
    names: list[str] = []
    for label_id in sorted(label_ids or []):
        label = _call_lookup(view.label, "async_get_label", label_id)
        name = getattr(label, "name", None) if label is not None else None
        names.append(str(name) if name else str(label_id))
    return names


def _call_lookup(registry: Any, method: str, key: str) -> Any:
    if registry is None:
        return None
    getter = getattr(registry, method, None)
    if getter is None:
        return None
    try:
        return getter(key)
    except Exception:  # pragma: no cover - defensive
        return None


def _call_no_arg(obj: Any, method: str) -> Any:
    """Call a no-argument accessor (e.g. ``async_services``), guarded."""
    if obj is None:
        return None
    fn = getattr(obj, method, None)
    if not callable(fn):
        return None
    try:
        return fn()
    except Exception:  # pragma: no cover - defensive
        return None


def _iso(value: Any) -> Any:
    """Serialize a datetime-ish value to an ISO string; pass through otherwise.

    HA registry/state timestamps are ``datetime`` objects. The WS layer can
    encode them, but the REST shapes the overview consumer mirrors carry ISO
    strings, so normalize here for a stable wire contract.
    """
    if value is None:
        return None
    iso = getattr(value, "isoformat", None)
    if callable(iso):
        try:
            return iso()
        except Exception:  # pragma: no cover - defensive
            return None
    return value if isinstance(value, (str, int, float, bool)) else str(value)


def _enum_value(value: Any) -> Any:
    """Unwrap a StrEnum-ish registry field (``entity_category``/``hidden_by``/…).

    HA stores these as enums whose ``.value`` is the wire string; a plain string
    (or None) passes through unchanged. Also unwraps ``ConfigEntryState`` (a plain
    ``Enum`` whose ``.value`` is the wire string, e.g. ``"loaded"``) — core's
    ``config_entries/get`` serializes it as ``entry.state.value``.
    """
    if value is None or isinstance(value, str):
        return value
    return getattr(value, "value", str(value))


def _timestamp(value: Any) -> float | None:
    """Serialize a datetime-ish registry timestamp as a float, like core.

    core's registry WS list responses emit ``created_at`` / ``modified_at`` via
    ``entry.created_at.timestamp()`` (a float, seconds since epoch), so mirror
    that. A value that is already numeric passes through; anything else (or a
    ``.timestamp()`` that raises) degrades to ``None``.
    """
    if value is None:
        return None
    ts = getattr(value, "timestamp", None)
    if callable(ts):
        try:
            return float(ts())
        except Exception:  # pragma: no cover - defensive
            return None
    return float(value) if isinstance(value, (int, float)) else None


def _reg_name(reg: Any) -> str | None:
    """Current display name from a registry entry: user override, else original."""
    if reg is None:
        return None
    name = getattr(reg, "name", None) or getattr(reg, "original_name", None)
    return str(name) if name else None


def _current_friendly_name(
    hass: HomeAssistant, entity_id: str | None, fallback: str | None
) -> str | None:
    """Current friendly_name from the state machine, falling back to the config name."""
    if entity_id:
        for state in _iter_states(hass):
            if getattr(state, "entity_id", None) != entity_id:
                continue
            attrs = getattr(state, "attributes", None) or {}
            friendly = (
                attrs.get("friendly_name") if isinstance(attrs, Mapping) else None
            )
            if friendly:
                return str(friendly)
            break
    if fallback:
        return str(fallback)
    return entity_id


# =============================================================================
# ha_mcp_tools/helpers_list
# =============================================================================
def _do_helpers_list(
    hass: HomeAssistant,
    params: dict[str, Any],
    *,
    secret_values: frozenset[str] = frozenset(),
    secret_scrub_degraded: bool = False,
) -> dict[str, Any]:
    """List collection helpers (live state bodies) + flow helpers (config-entry options).

    Flow-helper ``options`` come straight from ``ConfigEntry.options`` — no
    OptionsFlow start/abort dance, and NEVER ``entry.data`` (integration
    credentials). Every record carries the CURRENT entity_id + display name from
    the entity registry so a renamed helper shows current values (issue #1794),
    not the stale storage-collection name.

    Flow-helper ``options`` share the credential-bearing exposure class of
    ``config_entries``'s ``options`` (a flow helper IS a config entry), so they pass
    through the SAME best-effort resolved-``!secret`` scrub for uniformity — a
    present-but-unreadable ``secrets.yaml`` degrades the scrub to a no-op and sets
    ``secret_scrub_degraded: true`` (present ONLY when degraded). Collection-helper
    bodies (storage collection / live state attributes) are not scrubbed — they carry
    no YAML-resolved secret.

    ``covered_types`` names exactly the helper_type values this command can
    enumerate (the state-machine collection domains + the flow domains, minus the
    flow set when ``include_flow_helpers`` is false). It is the anti-silent-wrong
    signal: for a requested helper_type NOT in ``covered_types`` (e.g. ``tag``,
    which has no state entity), an empty ``helpers`` list means "cannot
    enumerate", NOT "none exist" — the server must fall back to its legacy
    ``<type>/list`` path rather than trust the emptiness.
    """
    requested = params.get("helper_types")
    type_filter = frozenset(requested) if requested else None
    include_flow = params.get("include_flow_helpers", True)

    view = _resolve_registries(hass)
    helpers = _collection_helpers_list(hass, view, type_filter)
    covered = set(HELPERS_LIST_COLLECTION_DOMAINS)
    if include_flow:
        helpers.extend(_flow_helpers_list(hass, view, type_filter, secret_values))
        covered |= FLOW_HELPER_DOMAINS
    result: dict[str, Any] = {
        "helpers": helpers,
        "count": len(helpers),
        "covered_types": sorted(covered),
    }
    if include_flow and secret_scrub_degraded:
        result["secret_scrub_degraded"] = True
    return result


async def _helpers_list_prep(
    hass: HomeAssistant, msg: dict[str, Any]
) -> dict[str, Any]:
    """Async pre-step for ``helpers_list``: load the secret-scrub set off the loop.

    Only the flow-helper ``options`` are scrubbed, so the blocking ``secrets.yaml``
    read is skipped entirely when ``include_flow_helpers`` is false (perf gate,
    mirroring ``search``'s entity-only skip). The read runs in the executor via
    :meth:`hass.async_add_executor_job`, keeping :func:`_do_helpers_list` a pure
    in-memory read. See :func:`_load_secret_scrub`.
    """
    if not msg.get("include_flow_helpers", True):
        return {"secret_values": frozenset(), "secret_scrub_degraded": False}
    values, degraded = await hass.async_add_executor_job(_load_secret_scrub, hass)
    return {"secret_values": values, "secret_scrub_degraded": degraded}


def _collection_helpers_list(
    hass: HomeAssistant, view: _RegistryView, type_filter: frozenset[str] | None
) -> list[dict[str, Any]]:
    """Collection helpers from the state machine (input_*, counter, timer, zone, …).

    The record's ``config`` is the entity's real storage ``_config`` body when
    reachable — so a schedule surfaces its weekday blocks, which the live state
    attributes omit — falling back to the state attributes otherwise (see
    :func:`_collection_storage_index`). ``name`` stays the CURRENT display name
    (a rename updates the registry, not the storage body — issue #1794).
    """
    out: list[dict[str, Any]] = []
    storage = _collection_storage_index(hass, HELPERS_LIST_COLLECTION_DOMAINS)
    for state in _iter_states(hass):
        entity_id = getattr(state, "entity_id", "") or ""
        domain = entity_id.split(".")[0] if "." in entity_id else ""
        if domain not in HELPERS_LIST_COLLECTION_DOMAINS:
            continue
        if type_filter is not None and domain not in type_filter:
            continue
        attrs = getattr(state, "attributes", None) or {}
        object_id = entity_id.split(".", 1)[1] if "." in entity_id else entity_id
        reg = _reg_entity(view, entity_id)
        # Current display name: state friendly_name reflects a registry rename;
        # fall back to the registry name, then the object_id.
        current = attrs.get("friendly_name") if isinstance(attrs, Mapping) else None
        name = current or _reg_name(reg) or object_id
        # Prefer the real storage body + id; the state attributes omit fields like
        # a schedule's weekday blocks.
        stored = storage.get(entity_id)
        if stored is not None:
            body, storage_id = stored
        else:
            body = dict(attrs) if isinstance(attrs, Mapping) else {}
            storage_id = getattr(reg, "unique_id", None) or object_id
        out.append(
            {
                "helper_type": domain,
                "kind": "collection",
                "entity_id": entity_id,
                "object_id": object_id,
                "name": str(name),
                "storage_id": storage_id,
                "config": _plainify(body),
            }
        )
    return out


def _flow_helpers_list(
    hass: HomeAssistant,
    view: _RegistryView,
    type_filter: frozenset[str] | None,
    secret_values: frozenset[str] = frozenset(),
) -> list[dict[str, Any]]:
    """Flow (config-entry-backed) helpers — options + title + entry_id, never data.

    ``options`` is passed through the same resolved-``!secret`` scrub
    ``config_entries`` applies (a flow helper is a config entry, so its ``options``
    share the same exposure class); an empty ``secret_values`` is a no-op.
    """
    out: list[dict[str, Any]] = []
    entity_by_entry = _entities_by_config_entry(view)
    for entry in _iter_config_entries(hass):
        domain = getattr(entry, "domain", None)
        if domain not in FLOW_HELPER_DOMAINS:
            continue
        if type_filter is not None and domain not in type_filter:
            continue
        entry_id = getattr(entry, "entry_id", None)
        title = getattr(entry, "title", None) or ""
        raw_options = getattr(entry, "options", None)
        options = (
            _scrub_secret_values(_plainify(dict(raw_options)), secret_values)
            if isinstance(raw_options, Mapping)
            else {}
        )
        reg = entity_by_entry.get(entry_id)
        entity_id = getattr(reg, "entity_id", None) if reg is not None else None
        name = _reg_name(reg) or _current_friendly_name(hass, entity_id, title)
        out.append(
            {
                "helper_type": domain,
                "kind": "flow",
                "entry_id": entry_id,
                "entity_id": entity_id,
                "name": str(name) if name else title,
                "storage_id": entry_id,
                # Data minimization: options only, never entry.data.
                "options": options,
            }
        )
    return out


def _entities_by_config_entry(view: _RegistryView) -> dict[Any, Any]:
    """Index the first registry entity bound to each config entry (flow helpers)."""
    index: dict[Any, Any] = {}
    for entry in _all_entity_entries(view):
        config_entry_id = getattr(entry, "config_entry_id", None)
        if config_entry_id and config_entry_id not in index:
            index[config_entry_id] = entry
    return index


def _all_entity_entries(view: _RegistryView) -> list[Any]:
    """All entity-registry entries (``registry.entities`` is a mapping in HA)."""
    reg = view.entity
    entities = getattr(reg, "entities", None) if reg is not None else None
    if entities is None:
        return []
    try:
        return list(entities.values())
    except Exception:  # pragma: no cover - defensive
        return []


# =============================================================================
# ha_mcp_tools/overview
# =============================================================================
def _do_overview(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
    """Return the raw in-process reads the server's overview path consumes.

    NOT the assembled overview envelope — the RAW slices the server's
    ``get_system_overview`` + ``ha_get_overview`` wrapper fetch today (states,
    services, entity/device/area registries, ``hass.config``, persistent
    notifications, repairs issues). The server runs its existing overview logic
    over these, so detail_level / domains / pagination stay server-side and no
    logic is duplicated (or drifts) in the component. Registries are BARE lists
    (not the ``{success, result}`` WS wrapper); the server adapts. Collapses the
    ~8 round-trips to one in-process call.

    ``slice_errors`` names any slice whose accessor RAISED (empty list when
    clean). A missing/None registry degrades to an empty slice WITHOUT an entry —
    that is "nothing here", not "failed". A genuine raise is caught per slice,
    logged, and named here so the server can tell "empty" from "failed" and fall
    back to its legacy REST read for just that slice instead of trusting the
    empty value.
    """
    include_notifications = params.get("include_notifications", True)
    include_repairs = params.get("include_repairs", True)

    view = _resolve_registries(hass)
    slice_errors: list[str] = []

    def _slice(name: str, fn: Any, default: Any) -> Any:
        try:
            return fn()
        except Exception:
            _LOGGER.warning("overview slice %r degraded", name, exc_info=True)
            slice_errors.append(name)
            return default

    result: dict[str, Any] = {
        "states": _slice("states", lambda: _overview_states(hass), []),
        "services": _slice("services", lambda: _overview_services(hass), []),
        "entity_registry": _slice(
            "entity_registry", lambda: _overview_entity_registry(view), []
        ),
        "device_registry": _slice(
            "device_registry", lambda: _overview_device_registry(view), []
        ),
        "area_registry": _slice(
            "area_registry", lambda: _overview_area_registry(view), []
        ),
        "config": _slice("config", lambda: _overview_config(hass), {}),
        "notifications": _slice(
            "notifications", lambda: _overview_notifications(hass), []
        )
        if include_notifications
        else [],
        "repairs": _slice("repairs", lambda: _overview_repairs(hass), [])
        if include_repairs
        else [],
    }
    result["slice_errors"] = slice_errors
    return result


def _overview_states(hass: HomeAssistant) -> list[dict[str, Any]]:
    """States in the ``client.get_states()`` shape the overview consumer reads."""
    out: list[dict[str, Any]] = []
    for state in _iter_states(hass):
        entity_id = getattr(state, "entity_id", None)
        if not entity_id:
            continue
        attrs = getattr(state, "attributes", None) or {}
        out.append(
            {
                "entity_id": entity_id,
                "state": getattr(state, "state", "unknown"),
                "attributes": _plainify(dict(attrs))
                if isinstance(attrs, Mapping)
                else {},
                "last_changed": _iso(getattr(state, "last_changed", None)),
                "last_updated": _iso(getattr(state, "last_updated", None)),
            }
        )
    return out


def _overview_services(hass: HomeAssistant) -> list[dict[str, Any]]:
    """Service catalog in the ``client.get_services()`` list shape.

    The consumer's ``_build_service_stats`` reads only the per-domain service
    *names*, so each service maps to an empty dict — keeps the frame small while
    preserving the ``{domain, services: {name: {...}}}`` structure.
    """
    services = _call_no_arg(getattr(hass, "services", None), "async_services")
    if not isinstance(services, Mapping):
        return []
    out: list[dict[str, Any]] = []
    for domain, svcs in services.items():
        names = list(svcs.keys()) if isinstance(svcs, Mapping) else []
        out.append({"domain": domain, "services": {name: {} for name in names}})
    return out


def _overview_entity_registry(view: _RegistryView) -> list[dict[str, Any]]:
    """Entity registry as a bare list, with the fields the overview + visibility
    consumers read (area/device/labels/entity_category/hidden_by/options/…)."""
    out: list[dict[str, Any]] = []
    for entry in _all_entity_entries(view):
        entity_id = getattr(entry, "entity_id", None)
        if not entity_id:
            continue
        out.append(
            {
                "entity_id": entity_id,
                "area_id": getattr(entry, "area_id", None),
                "device_id": getattr(entry, "device_id", None),
                "labels": sorted(
                    str(x) for x in (getattr(entry, "labels", None) or [])
                ),
                "entity_category": _enum_value(getattr(entry, "entity_category", None)),
                "hidden_by": _enum_value(getattr(entry, "hidden_by", None)),
                "categories": _plainify(getattr(entry, "categories", None) or {}),
                "options": _plainify(getattr(entry, "options", None) or {}),
                "name": getattr(entry, "name", None),
                "original_name": getattr(entry, "original_name", None),
                "platform": getattr(entry, "platform", None),
                "unique_id": getattr(entry, "unique_id", None),
                "disabled_by": _enum_value(getattr(entry, "disabled_by", None)),
            }
        )
    return out


def _overview_device_registry(view: _RegistryView) -> list[dict[str, Any]]:
    """Device registry as a bare list (id + area + labels + name/manufacturer/model)."""
    out: list[dict[str, Any]] = []
    reg = view.device
    devices = getattr(reg, "devices", None) if reg is not None else None
    values = _mapping_values(devices)
    for dev in values:
        dev_id = getattr(dev, "id", None)
        if not dev_id:
            continue
        out.append(
            {
                "id": dev_id,
                "area_id": getattr(dev, "area_id", None),
                "labels": sorted(str(x) for x in (getattr(dev, "labels", None) or [])),
                "name": getattr(dev, "name", None),
                "name_by_user": getattr(dev, "name_by_user", None),
                "manufacturer": getattr(dev, "manufacturer", None),
                "model": getattr(dev, "model", None),
            }
        )
    return out


def _overview_area_registry(view: _RegistryView) -> list[dict[str, Any]]:
    """Area registry as a bare list (area_id + name + floor_id)."""
    out: list[dict[str, Any]] = []
    for area in _all_area_entries(view):
        area_id = getattr(area, "id", None) or getattr(area, "area_id", None)
        if not area_id:
            continue
        out.append(
            {
                "area_id": area_id,
                "name": getattr(area, "name", None),
                "floor_id": getattr(area, "floor_id", None),
            }
        )
    return out


def _all_area_entries(view: _RegistryView) -> list[Any]:
    """All area-registry entries via ``async_list_areas()`` or the ``areas`` mapping."""
    reg = view.area
    if reg is None:
        return []
    listed = _call_no_arg(reg, "async_list_areas")
    if listed is not None:
        try:
            return list(listed)
        except Exception:  # pragma: no cover - defensive
            return []
    return _mapping_values(getattr(reg, "areas", None))


def _mapping_values(mapping: Any) -> list[Any]:
    """``list(mapping.values())`` guarded against a non-mapping / drift."""
    if mapping is None:
        return []
    try:
        return list(mapping.values())
    except Exception:  # pragma: no cover - defensive
        return []


def _overview_config(hass: HomeAssistant) -> dict[str, Any]:
    """The ``hass.config`` fields the wrapper's ``_fetch_system_info`` reads.

    ``base_url`` is intentionally omitted — the server supplies it from its own
    client; only HA-core config values are the component's to provide.
    """
    config = getattr(hass, "config", None)
    raw = _call_no_arg(config, "as_dict")
    if not isinstance(raw, Mapping):
        return {}
    keys = (
        "version",
        "location_name",
        "time_zone",
        "language",
        "state",
        "country",
        "currency",
        "unit_system",
        "latitude",
        "longitude",
        "elevation",
        "components",
        "safe_mode",
        "internal_url",
        "external_url",
        "allowlist_external_dirs",
    )
    return {k: _plainify(raw[k]) for k in keys if k in raw}


def _overview_notifications(hass: HomeAssistant) -> list[dict[str, Any]]:
    """Active persistent notifications (``persistent_notification/get`` shape)."""
    store = getattr(hass, "data", None)
    data = store.get("persistent_notification") if isinstance(store, Mapping) else None
    return [
        {
            "notification_id": _field(note, "notification_id"),
            "title": _field(note, "title"),
            "message": _field(note, "message"),
            "created_at": _iso(_field(note, "created_at")),
        }
        for note in _notification_values(data)
    ]


def _field(obj: Any, key: str) -> Any:
    """Read ``key`` from a mapping (``.get``) or an object (``getattr``)."""
    if isinstance(obj, Mapping):
        return obj.get(key)
    return getattr(obj, key, None)


def _notification_values(data: Any) -> list[Any]:
    """Notification records: ``{id: note}`` mapping values, or a bare list."""
    if isinstance(data, Mapping):
        return list(data.values())
    if isinstance(data, list):
        return list(data)
    return []


def _overview_repairs(hass: HomeAssistant) -> list[dict[str, Any]]:
    """Raw issue-registry entries (the server filters/projects them itself).

    ``ignored`` is derived from ``dismissed_version`` so the server's
    ``filter_active_repairs`` (which keys off ``ignored``) works unchanged.

    Inactive entries are skipped to match HA core's ``ws_list_issues``: on
    restart the registry reloads each stored non-persistent issue as an
    inactive stub (``active=False``), and it stays inactive until the
    integration re-raises or deletes it.
    """
    registry = _safe(ir.async_get, hass)
    issues = getattr(registry, "issues", None) if registry is not None else None
    out: list[dict[str, Any]] = []
    for issue in _mapping_values(issues):
        if getattr(issue, "active", True) is False:
            continue
        dismissed = getattr(issue, "dismissed_version", None)
        out.append(
            {
                "issue_id": getattr(issue, "issue_id", None),
                "domain": getattr(issue, "domain", None),
                "severity": _enum_value(getattr(issue, "severity", None)),
                "translation_key": getattr(issue, "translation_key", None),
                "translation_placeholders": _plainify(
                    getattr(issue, "translation_placeholders", None) or {}
                ),
                "ignored": dismissed is not None,
                "dismissed_version": dismissed,
                "is_fixable": getattr(issue, "is_fixable", None),
                "breaks_in_ha_version": getattr(issue, "breaks_in_ha_version", None),
                "created": _iso(getattr(issue, "created", None)),
                "issue_domain": getattr(issue, "issue_domain", None),
                "learn_more_url": getattr(issue, "learn_more_url", None),
                "active": getattr(issue, "active", None),
            }
        )
    return out


# =============================================================================
# ha_mcp_tools/states
# =============================================================================
def _do_states(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
    """Return ``State.as_dict()`` for each requested entity_id + a ``missing`` list.

    ``hass.states.get(id)`` is a pure O(1) in-memory dict read, and core's
    ``State.as_dict()`` is exactly the serialization the REST ``/api/states/<id>``
    endpoint emits — so a component-served record is byte-identical to the legacy
    per-id REST fetch by construction (the WS transport JSON-encodes the same
    datetimes to the same ISO strings the REST layer does). The body is returned
    UNMODIFIED — never ``_plainify``'d — precisely so that byte-parity holds:
    ``_plainify``'s ``str()`` would render a datetime with a space separator where
    both REST and WS use ``isoformat``'s ``T``. No freshness or secrets concern:
    state bodies are always live and carry no ``!secret`` plaintext. The server
    enforces its own ``MAX_ENTITIES`` cap before calling, so no per-frame guard is
    needed here (100 full states is well within one frame — ``overview`` already
    returns every state in one call).
    """
    entity_ids = params.get("entity_ids") or []
    states: dict[str, Any] = {}
    missing: list[str] = []
    for entity_id in entity_ids:
        state = _state_get(hass, entity_id)
        if state is None:
            missing.append(entity_id)
            continue
        as_dict = _state_as_dict(state)
        if as_dict is None:
            # A live state that could not be serialized (core drift) goes to
            # ``missing`` rather than emitting a null state indistinguishable from
            # a real value — the server maps ``missing`` onto its per-id contract.
            missing.append(entity_id)
            continue
        states[entity_id] = as_dict
    return {"states": states, "missing": missing}


def _state_get(hass: HomeAssistant, entity_id: str) -> Any:
    """``hass.states.get(entity_id)`` guarded against core drift (``None`` if absent)."""
    states = getattr(hass, "states", None)
    getter = getattr(states, "get", None) if states is not None else None
    if getter is None:
        return None
    try:
        return getter(entity_id)
    except Exception:  # pragma: no cover - defensive
        return None


def _state_as_dict(state: Any) -> Any:
    """core ``State.as_dict()`` verbatim — the REST ``/api/states/<id>`` shape.

    Returned unmodified so the WS transport encodes its datetimes with the same
    ``isoformat`` the REST layer uses (byte-parity — see :func:`_do_states`).
    """
    as_dict = getattr(state, "as_dict", None)
    if callable(as_dict):
        try:
            return as_dict()
        except Exception:  # pragma: no cover - defensive
            return None
    return None


# =============================================================================
# ha_mcp_tools/blueprint_get
# =============================================================================
def _do_blueprint_get(
    hass: HomeAssistant,
    params: dict[str, Any],
    *,
    body: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Return one installed blueprint's full body as ``{metadata, config}``.

    core's ``blueprint/list`` returns only ``{metadata}`` (no triggers /
    conditions / actions / sequence), so the server can otherwise serve metadata
    only. This reads the on-disk blueprint file and returns the parsed body:
    ``config`` is the full file (the server merges it additively over the
    ``blueprint/list`` metadata) and ``metadata`` is its ``blueprint:`` section.
    When the file is missing, unparseable, or the requested path escapes the jail,
    both come back ``None`` (the server keeps metadata-only) — see
    :func:`_read_blueprint_file`.

    Pure: the blocking jail-resolve + file read + YAML parse run in the executor
    via :func:`_blueprint_get_prep`, which passes the parsed ``body`` in.
    """
    if not isinstance(body, dict):
        return {"metadata": None, "config": None}
    metadata = body.get("blueprint")
    return {
        "metadata": _plainify(metadata) if isinstance(metadata, dict) else None,
        "config": _plainify(body),
    }


async def _blueprint_get_prep(
    hass: HomeAssistant, msg: dict[str, Any]
) -> dict[str, Any]:
    """Async pre-step for ``blueprint_get``: jail + read + parse off the loop.

    The path jail (symlink-safe ``Path.resolve`` containment), the ``open()`` and
    the YAML parse are all blocking filesystem work, so they run in the executor
    via :meth:`hass.async_add_executor_job` — keeping :func:`_do_blueprint_get` a
    pure assembler over the parsed ``body`` this returns (``None`` on any failure).
    """
    domain = msg["domain"]
    path = msg["path"]
    body = await hass.async_add_executor_job(_read_blueprint_file, hass, domain, path)
    return {"body": body}


def _read_blueprint_file(
    hass: HomeAssistant, domain: str, path: str
) -> dict[str, Any] | None:
    """Resolve + jail + read + parse one blueprint YAML file. ``None`` on failure.

    Blueprint files live under ``<config>/blueprints/<domain>/``. The requested
    ``path`` is joined under that root and resolved symlink-safe (mirrors the
    file-tool jail's ``_resolves_within`` — resolve the RAW input, following
    symlinks, THEN check containment, so ``<root>/<symlink>/..`` cannot escape). A
    path escaping the root — via ``..``, an absolute path, or a symlink — yields
    ``None`` (rejected, never opened). A missing file, a non-file target, a read
    error, or a YAML parse error also yields ``None``. Only a valid, contained,
    parseable blueprint returns its full parsed body.

    Parsed with :class:`_BlueprintLoader`: ``!input`` markers are preserved and
    every other custom tag (``!secret`` / ``!include`` / …) is neutralized to
    ``None``, so no resolved secret plaintext can ever enter the returned body
    (defense in depth — blueprints use ``!input``, not ``!secret``).
    """
    config = getattr(hass, "config", None)
    path_fn = getattr(config, "path", None)
    if not callable(path_fn):
        return None
    try:
        base = Path(path_fn("blueprints", domain))
        candidate = Path(path) if path.startswith("/") else base / path
        real = candidate.resolve()
        base_real = base.resolve()
    except (OSError, ValueError):
        return None
    if not (real == base_real or real.is_relative_to(base_real)):
        return None
    try:
        with open(real, encoding="utf-8") as handle:
            # Instance form (not yaml.load) mirrors the component's existing
            # _PackagesDirLoader usage; _BlueprintLoader is a SafeLoader subclass,
            # so no !!python/object can construct arbitrary types.
            loader = _BlueprintLoader(handle)
            try:
                parsed = loader.get_single_data()
            finally:
                loader.dispose()
    except (OSError, ValueError, yaml.YAMLError):
        return None
    return parsed if isinstance(parsed, dict) else None


def _construct_blueprint_input(loader: Any, node: Any) -> dict[str, str]:
    """Represent ``!input <name>`` as ``{"__input__": <name>}``.

    A JSON-safe, unambiguous marker of a blueprint input substitution point (the
    body is a display artifact, not a runnable config), so a consumer can see
    which fields an input fills without the tag crashing a plain safe-load.
    """
    return {"__input__": str(getattr(node, "value", ""))}


def _drop_blueprint_tag(loader: Any, tag_suffix: Any, node: Any) -> None:
    """Neutralize every non-``!input`` custom tag to ``None`` (never resolve it).

    ``!secret`` must never resolve to plaintext; ``!include`` / ``!env_var`` /
    unknown tags are irrelevant to a read-only body view. Mirrors the component's
    ``_ignore_unknown_tag`` pattern in ``__init__.py``.
    """
    return None


class _BlueprintLoader(yaml.SafeLoader):
    """SafeLoader for blueprint files: keep ``!input``, neutralize all other tags."""


_BlueprintLoader.add_constructor("!input", _construct_blueprint_input)
_BlueprintLoader.add_multi_constructor("!", _drop_blueprint_tag)


# =============================================================================
# ha_mcp_tools/device_get + ha_mcp_tools/device_list
# =============================================================================
def _do_device_get(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
    """Return one device registry entry by id, optionally with its entities.

    ``{device: <DeviceEntry.dict_repr> | None}`` — ``registry.async_get(device_id)``
    is a pure O(1) in-memory dict read, and the emitted body is core's
    ``DeviceEntry.dict_repr`` returned UNMODIFIED — exactly the shape
    ``config/device_registry/list`` serializes (it sends
    ``json_bytes(entry.dict_repr)``), so a component-served record is byte-identical
    to one legacy list element by construction (the WS transport JSON-encodes the
    same dict with the same encoder). The body is never ``_plainify``'d: that would
    ``str()`` the ``disabled_by`` / ``entry_type`` enums to their repr instead of the
    wire value core's encoder emits, breaking parity. ``device`` is ``None`` when no
    such device exists — the server maps that onto its own not-found contract.

    When ``include_entities`` is set, a SIBLING ``entities`` key carries the device's
    entity-registry rows (``[<RegistryEntry.as_partial_dict>, ...]`` — the same shape
    and serialization ``config/entity_registry/list`` emits), so a single-device
    lookup no longer pulls the WHOLE entity registry to list one device's entities.
    ``er.async_entries_for_device`` is called with ``include_disabled_entities=True``
    to match what ``config/entity_registry/list`` returns (it lists disabled entities
    too). The DeviceEntry dict itself stays exactly the raw shape — the join is a
    sibling, so consumers keep their own transforms. The ``entities`` key is present
    only when requested.
    """
    device_id = params.get("device_id")
    include_entities = params.get("include_entities", False)
    view = _resolve_registries(hass)
    entry = _device(view, device_id) if device_id else None
    result: dict[str, Any] = {
        "device": _device_dict_repr(entry) if entry is not None else None
    }
    if include_entities:
        result["entities"] = _device_entities(view, device_id) if device_id else []
    return result


def _do_device_list(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
    """Return every device registry entry as ``{devices: [dict_repr, ...]}``.

    The in-process equivalent of ``config/device_registry/list``: each element is
    core's ``DeviceEntry.dict_repr`` returned VERBATIM (same byte-parity rationale
    as :func:`_do_device_get`), so the server's existing device transforms consume
    it unchanged. An entry whose ``dict_repr`` is unavailable is skipped rather
    than emitted as a partial record.
    """
    view = _resolve_registries(hass)
    reg = view.device
    devices = getattr(reg, "devices", None) if reg is not None else None
    out: list[dict[str, Any]] = []
    for dev in _mapping_values(devices):
        repr_dict = _device_dict_repr(dev)
        if repr_dict is not None:
            out.append(repr_dict)
        else:
            _LOGGER.warning(
                "device_list: skipping device %r with unavailable dict_repr",
                getattr(dev, "id", None),
            )
    return {"devices": out}


def _device_dict_repr(entry: Any) -> dict[str, Any] | None:
    """core ``DeviceEntry.dict_repr`` verbatim — the ``config/device_registry/list`` shape.

    Returned UNMODIFIED so the WS transport encodes it with the same JSON
    serializer ``config/device_registry/list`` uses (byte-parity — see
    :func:`_do_device_get`). Guarded against core drift: a missing/raising
    ``dict_repr`` yields ``None`` rather than propagating.
    """
    try:
        repr_dict = entry.dict_repr
    except Exception:  # pragma: no cover - defensive; core drift
        return None
    return repr_dict if isinstance(repr_dict, dict) else None


def _device_entities(view: _RegistryView, device_id: str) -> list[dict[str, Any]]:
    """The device's entity-registry rows as ``config/entity_registry/list`` elements.

    Each row is core's ``RegistryEntry.as_partial_dict`` returned VERBATIM (the same
    shape + serialization ``config/entity_registry/list`` emits — it sends
    ``json_bytes(entry.partial_json_repr)`` over ``as_partial_dict``), so the
    server's device<->entity map builds identically off the join or the legacy list.
    A row whose ``as_partial_dict`` is unavailable is skipped.
    """
    out: list[dict[str, Any]] = []
    for entry in _entries_for_device(view, device_id):
        partial = _entity_partial_dict(entry)
        if partial is not None:
            out.append(partial)
    return out


def _entries_for_device(view: _RegistryView, device_id: str) -> list[Any]:
    """Entity-registry entries bound to ``device_id``, disabled ones INCLUDED.

    Delegates to core's ``er.async_entries_for_device`` (its device_id index) with
    ``include_disabled_entities=True`` so the result matches what
    ``config/entity_registry/list`` returns — that command lists disabled entities
    too, and dropping them would diverge the join from the legacy shape. Guarded
    against a missing registry / core drift (returns ``[]``).
    """
    reg = view.entity
    if reg is None or not device_id:
        return []
    try:
        entries = er.async_entries_for_device(
            reg, device_id, include_disabled_entities=True
        )
    except Exception:  # pragma: no cover - defensive; core drift
        return []
    return list(entries)


def _entity_partial_dict(entry: Any) -> dict[str, Any] | None:
    """core ``RegistryEntry.as_partial_dict`` verbatim — the ``config/entity_registry/list`` shape.

    Returned UNMODIFIED so the WS transport encodes it with the same serializer
    ``config/entity_registry/list`` uses (byte-parity, mirroring
    :func:`_device_dict_repr`). Guarded against core drift.
    """
    try:
        partial = entry.as_partial_dict
    except Exception:  # pragma: no cover - defensive; core drift
        return None
    return partial if isinstance(partial, dict) else None


# =============================================================================
# ha_mcp_tools/entity_enrich
# =============================================================================
def _do_entity_enrich(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
    """Return the area/floor/labels/aliases join for each requested entity_id.

    ``{entities: {id: {area, floor, labels, aliases}}}`` — each id runs through the
    SAME :func:`_registry_enrichment` join the search path uses (device-inherited
    area/labels, resolved NAMES), so ``ha_get_entity`` adds the resolved-name
    fields the raw registry entry lacks without the caller fanning out its own
    area/floor/label registry reads. Pure O(id) in-memory registry lookups; a
    registry-only (stateless) entity is enriched too (the join keys off the
    registry, not the state machine). An id with no registry entry yields empty /
    ``None`` fields rather than being dropped, so the caller can pair the result
    back to its request by key.
    """
    entity_ids = params.get("entity_ids") or []
    view = _resolve_registries(hass)
    entities: dict[str, Any] = {}
    for entity_id in entity_ids:
        join = _registry_enrichment(view, entity_id)
        entities[entity_id] = {
            "area": join["area"],
            "floor": join["floor"],
            "labels": join["labels"],
            "aliases": join["aliases"],
        }
    return {"entities": entities}


# =============================================================================
# ha_mcp_tools/exposure
# =============================================================================
def _do_exposure(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
    """Return voice-assistant exposure with names/areas attached.

    ``{exposed_entities: {id: {assistant: True}}, entity_info: {id: {...}}}``.
    ``exposed_entities`` is byte-identical to core's ``ws_list_exposed_entities``
    result (``homeassistant/expose_entity/list``): only ``should_expose``-true
    assistants appear, and an entity with none is omitted from the map — so the
    server's existing exposure shaping consumes it unchanged. ``entity_info`` is
    the additive half: each relevant id enriched through :func:`_registry_enrichment`
    (friendly_name/domain/area/floor/labels), closing the "one call gives a bare
    ``{id: {assistant: bool}}`` map with no names/areas" gap.

    Modes:

    * single-entity (``entity_id`` set) — reads core's module-level
      ``async_get_entity_settings`` for that id and enriches it (whether exposed or
      not — the caller asked about that specific entity).
    * list (``entity_id`` omitted) — mirrors ``ws_list_exposed_entities``: walks
      the exposed-entities store ids + the entity registry, keeps the exposed ones,
      and enriches each.

    Parity guardrails (mirroring the legacy shape, pinned in tests):

    1. only ``should_expose``-true assistants are reported (the raw helper returns
       every assistant that has *any* stored option, not just exposed ones);
    2. core's ``HomeAssistantError("Unknown entity")`` on a junk id is caught and
       degrades to the not-exposed default (the legacy ``expose_entity/list`` never
       raises on a junk id);
    3. a missing ``hass.states.get(id)`` omits the live-state fields
       (friendly_name / state) from ``entity_info`` rather than crashing.
    """
    entity_id = params.get("entity_id")
    view = _resolve_registries(hass)

    if entity_id:
        exposed_to = _entity_exposed_to(hass, entity_id)
        return {
            "exposed_entities": {entity_id: exposed_to} if exposed_to else {},
            "entity_info": {entity_id: _exposure_enrichment(hass, view, entity_id)},
        }

    exposed_entities: dict[str, Any] = {}
    entity_info: dict[str, Any] = {}
    for eid in _all_exposable_entity_ids(hass, view):
        exposed_to = _entity_exposed_to(hass, eid)
        if not exposed_to:
            continue
        exposed_entities[eid] = exposed_to
        entity_info[eid] = _exposure_enrichment(hass, view, eid)
    return {"exposed_entities": exposed_entities, "entity_info": entity_info}


def _entity_exposed_to(hass: HomeAssistant, entity_id: str) -> dict[str, bool]:
    """``{assistant: True}`` for the entity's ``should_expose``-true assistants.

    Reads core's ``async_get_entity_settings`` (via the local
    :func:`_async_get_entity_settings` test-seam wrapper) and keeps only assistants
    whose settings carry a truthy ``should_expose`` (guardrail 1 — the raw helper is
    not pre-filtered like ``ws_list_exposed_entities``). A junk id whose helper raises
    ``HomeAssistantError("Unknown entity")`` degrades to ``{}`` (guardrail 2), the
    same not-exposed default the legacy path returns for an id it never listed.
    """
    try:
        settings = _async_get_entity_settings(hass, entity_id)
    except Exception as exc:
        if _is_unknown_entity_error(exc):
            return {}
        raise
    out: dict[str, bool] = {}
    if isinstance(settings, Mapping):
        for assistant, opts in settings.items():
            if isinstance(opts, Mapping) and opts.get("should_expose"):
                out[str(assistant)] = True
    return out


def _exposure_enrichment(
    hass: HomeAssistant, view: _RegistryView, entity_id: str
) -> dict[str, Any]:
    """area/floor/labels + domain for an id, plus live-state fields when present.

    Runs the id through :func:`_registry_enrichment` for area/floor/labels
    (device-inherited names). ``domain`` comes from the id itself (no state
    needed). ``friendly_name`` and ``state`` are LIVE-STATE fields: included only
    when ``hass.states.get(id)`` exists, omitted otherwise (guardrail 3 — a
    disabled / legacy-only entity has no state, so those keys are simply absent
    rather than crashing the join).
    """
    join = _registry_enrichment(view, entity_id)
    domain = entity_id.split(".", maxsplit=1)[0] if "." in entity_id else ""
    info: dict[str, Any] = {
        "domain": domain,
        "area": join["area"],
        "floor": join["floor"],
        "labels": join["labels"],
    }
    state = _state_get(hass, entity_id)
    if state is not None:
        attrs = getattr(state, "attributes", None) or {}
        friendly = (
            attrs.get("friendly_name", entity_id)
            if isinstance(attrs, Mapping)
            else entity_id
        )
        info["friendly_name"] = str(friendly)
        info["state"] = getattr(state, "state", "unknown")
    return info


def _all_exposable_entity_ids(hass: HomeAssistant, view: _RegistryView) -> list[str]:
    """Every id ``ws_list_exposed_entities`` walks: store ids plus registry ids.

    Core iterates ``chain(exposed_entities.entities, entity_registry.entities)`` —
    the legacy store (entities WITHOUT a unique_id, exposed manually) plus every
    registry entity. This reproduces that union, de-duplicated with store-first
    order, so an exposed YAML entity that lives only in the store is not missed.
    """
    ordered: list[str] = []
    seen: set[str] = set()
    for eid in _legacy_exposed_entity_ids(hass):
        if eid and eid not in seen:
            seen.add(eid)
            ordered.append(eid)
    for entry in _all_entity_entries(view):
        entry_eid = getattr(entry, "entity_id", None)
        if entry_eid and entry_eid not in seen:
            seen.add(entry_eid)
            ordered.append(entry_eid)
    return ordered


def _async_get_entity_settings(hass: HomeAssistant, entity_id: str) -> Any:
    """core's ``async_get_entity_settings(hass, entity_id)``; test seam.

    Imported lazily so the fake-hass unit suite (which MagicMock-stubs
    ``homeassistant.*`` at import time) can monkeypatch this whole function rather
    than the deep core module. Returns ``{assistant: settings_mapping}`` and raises
    ``HomeAssistantError("Unknown entity")`` for an id in neither the registry nor
    the exposed-entities store — caught by :func:`_entity_exposed_to`.
    """
    from homeassistant.components.homeassistant.exposed_entities import (
        async_get_entity_settings,
    )

    return async_get_entity_settings(hass, entity_id)


def _legacy_exposed_entity_ids(hass: HomeAssistant) -> list[str]:
    """Entity ids in the exposed-entities store (entities without a unique_id).

    The ``exposed_entities.entities`` half of core's ``ws_list_exposed_entities``
    iteration. Imported lazily (test seam); a missing store / core drift yields
    ``[]`` so list mode still enumerates the registry half.
    """
    try:
        from homeassistant.components.homeassistant.const import (
            DATA_EXPOSED_ENTITIES,
        )

        data = getattr(hass, "data", None)
        store = data.get(DATA_EXPOSED_ENTITIES) if isinstance(data, Mapping) else None
    except Exception:  # pragma: no cover - defensive; core drift
        return []
    entities = getattr(store, "entities", None)
    if isinstance(entities, Mapping):
        return [str(eid) for eid in entities]
    return []


def _is_unknown_entity_error(exc: Exception) -> bool:
    """True for core's ``HomeAssistantError('Unknown entity')`` from the settings helper.

    Keyed off the exception type NAME (not an ``isinstance`` against the imported
    class) so the fake-hass suite — which stubs ``homeassistant.exceptions`` — can
    raise a stand-in ``HomeAssistantError`` without importing the real class. The
    type name alone is too wide: core raises a plain ``HomeAssistantError`` for
    other faults too, so the message is also required to carry ``unknown entity``
    (case-insensitive). A store-read failure that raises a bare
    ``HomeAssistantError`` therefore propagates instead of being silently reported
    as not-exposed; the audit guardrail (junk id → not-exposed default) still
    matches because that raise carries the ``Unknown entity`` message.
    """
    return (
        type(exc).__name__ == "HomeAssistantError"
        and "unknown entity" in str(exc).lower()
    )


# =============================================================================
# ha_mcp_tools/config_entries
# =============================================================================
def _do_config_entries(
    hass: HomeAssistant,
    params: dict[str, Any],
    *,
    secret_values: frozenset[str] = frozenset(),
    secret_scrub_degraded: bool = False,
) -> dict[str, Any]:
    """Return config entries in the ``config_entries/get`` WS shape.

    ``{entries: [{created_at, modified_at, entry_id, domain, title, state, source,
    supports_options, supports_remove_device, supports_unload, supports_reconfigure,
    supported_subentry_types, pref_disable_new_entities, pref_disable_polling,
    disabled_by, reason, error_reason_translation_key,
    error_reason_translation_placeholders, num_subentries, options, subentries}]}``.
    The FULL ``as_json_fragment`` field set (``created_at`` / ``modified_at`` as
    ``.timestamp()`` floats, ``supported_subentry_types`` as core emits it), so the
    component row carries the same fields the legacy REST row does — no field is
    dropped on the component path. Filtered by ``domain`` when
    given, or the single entry by ``entry_id``
    (``hass.config_entries.async_get_entry`` — an id that matches nothing,
    including an empty string, yields an empty list). Only a WHOLLY ABSENT
    ``entry_id`` key (``None``) selects list mode; an empty-string ``entry_id`` is
    a single-entry lookup for a nonexistent id, so it returns ``entries: []``
    (mirroring ``async_get_entry("")``) rather than falling through to list mode
    and returning the first entry. ``state`` is serialized as
    ``ConfigEntryState.value`` (mirroring
    how core's ``config_entries/get`` emits it via ``as_json_fragment``, whose
    ``json_repr`` in ``homeassistant/config_entries.py`` is this row's source
    of truth for every field above except ``options``/``subentries``, which
    this integration re-derives since ``as_json_fragment`` never carries
    credential data).

    Data minimization: ``entry.data`` (integration credentials) is NEVER read.
    ``options`` is the only credential-bearing surface emitted, so it is passed
    through a BEST-EFFORT resolved-``!secret`` scrub — an options leaf (dict value,
    list item, or scalar whose ``str()`` form) that exactly equals a ``secrets.yaml``
    value becomes ``"**redacted**"`` (``secret_values`` loaded off the loop by
    :func:`_config_entries_prep`). ``subentries`` carries identity fields only
    (``subentry_id`` / ``subentry_type`` / ``title`` / ``unique_id``) — never a
    subentry's ``data``; a core version without subentries degrades to ``[]``.

    The scrub is BEST-EFFORT: a present-but-unreadable ``secrets.yaml`` degrades it
    to a no-op (options emitted unredacted). That degradation is signalled to the
    caller as ``secret_scrub_degraded: true`` (present ONLY when degraded), so an
    agent echoing ``options`` onward can tell an unscrubbed response from a clean
    one rather than trusting redaction that did not run.

    Pure over ``hass``: the blocking ``secrets.yaml`` read is offloaded by the
    async prep, so this stays a synchronous in-memory read.
    """
    entry_id = params.get("entry_id")
    domain = params.get("domain")
    if entry_id is not None:
        # Single-entry mode. An empty string is a valid (nonexistent) id — it
        # must NOT fall through to list mode, where a truthiness check would
        # return the first entry for a bogus id.
        entry = _config_entry_by_id(hass, entry_id)
        entries: list[Any] = [entry] if entry is not None else []
    else:
        entries = _iter_config_entries(hass)
        if domain:
            entries = [e for e in entries if getattr(e, "domain", None) == domain]
    result: dict[str, Any] = {
        "entries": [_config_entry_row(e, secret_values) for e in entries]
    }
    if secret_scrub_degraded:
        result["secret_scrub_degraded"] = True
    return result


async def _config_entries_prep(
    hass: HomeAssistant, msg: dict[str, Any]
) -> dict[str, Any]:
    """Async pre-step for ``config_entries``: load the secret-scrub set off the loop.

    Unlike ``search`` (which skips the read for an entity-only query),
    ``config_entries`` ALWAYS emits ``options``, so the ``secrets.yaml`` read is
    unconditional. The blocking ``open()`` + ``yaml.safe_load`` runs in the
    executor via :meth:`hass.async_add_executor_job`, keeping
    :func:`_do_config_entries` a pure in-memory read. The ``degraded`` flag (a
    present-but-unreadable ``secrets.yaml``) rides through so the response can signal
    that ``options`` may be unredacted. See :func:`_load_secret_scrub`.
    """
    values, degraded = await hass.async_add_executor_job(_load_secret_scrub, hass)
    return {"secret_values": values, "secret_scrub_degraded": degraded}


def _config_entry_by_id(hass: HomeAssistant, entry_id: str) -> Any:
    """``hass.config_entries.async_get_entry(entry_id)`` guarded (``None`` if absent)."""
    config_entries = getattr(hass, "config_entries", None)
    getter = (
        getattr(config_entries, "async_get_entry", None)
        if config_entries is not None
        else None
    )
    if getter is None:
        return None
    try:
        return getter(entry_id)
    except Exception:  # pragma: no cover - defensive
        return None


def _config_entry_row(entry: Any, secret_values: frozenset[str]) -> dict[str, Any]:
    """One config entry as the ``config_entries/get`` row (options scrubbed)."""
    raw_options = getattr(entry, "options", None)
    options = _plainify(dict(raw_options)) if isinstance(raw_options, Mapping) else {}
    options = _scrub_secret_values(options, secret_values)
    return {
        # Timestamps as floats via ``.timestamp()``, mirroring core's
        # as_json_fragment (``self.created_at.timestamp()``). Absent on a core old
        # enough to predate them -> None (this row is read-only, never restored, so a
        # None key is harmless here — unlike the area-registry rows).
        "created_at": _timestamp(getattr(entry, "created_at", None)),
        "modified_at": _timestamp(getattr(entry, "modified_at", None)),
        "entry_id": getattr(entry, "entry_id", None),
        "domain": getattr(entry, "domain", None),
        "title": getattr(entry, "title", None),
        "state": _enum_value(getattr(entry, "state", None)),
        "source": getattr(entry, "source", None),
        # supports_* are computed properties (they touch the flow handler) — read
        # through _safe_prop so a domain whose handler is unavailable degrades
        # instead of raising. ``or False`` mirrors core's as_json_fragment, which
        # coerces the optional bools to False.
        "supports_options": bool(_safe_prop(entry, "supports_options")),
        "supports_remove_device": _safe_prop(entry, "supports_remove_device") or False,
        "supports_unload": _safe_prop(entry, "supports_unload") or False,
        "supports_reconfigure": bool(_safe_prop(entry, "supports_reconfigure")),
        # supported_subentry_types is a computed property (it touches the flow
        # handler, same hazard class as supports_*) — _safe_prop-guarded, defaulting
        # to {} like core's ``self._supported_subentry_types or {}``.
        "supported_subentry_types": _safe_prop(entry, "supported_subentry_types", {})
        or {},
        "pref_disable_new_entities": bool(
            getattr(entry, "pref_disable_new_entities", False)
        ),
        "pref_disable_polling": bool(getattr(entry, "pref_disable_polling", False)),
        "disabled_by": _enum_value(getattr(entry, "disabled_by", None)),
        "reason": getattr(entry, "reason", None),
        "error_reason_translation_key": getattr(
            entry, "error_reason_translation_key", None
        ),
        "error_reason_translation_placeholders": getattr(
            entry, "error_reason_translation_placeholders", None
        ),
        "num_subentries": len(_mapping_values(getattr(entry, "subentries", None))),
        "options": options,
        "subentries": _config_subentries(entry),
    }


def _config_subentries(entry: Any) -> list[dict[str, Any]]:
    """Identity fields of each config subentry — NEVER the subentry ``data``.

    ``entry.subentries`` is a ``MappingProxyType`` keyed by subentry_id in modern
    core; a version without it (``getattr`` -> ``None``) degrades to ``[]``.
    """
    return [
        {
            "subentry_id": getattr(sub, "subentry_id", None),
            "subentry_type": getattr(sub, "subentry_type", None),
            "title": getattr(sub, "title", None),
            "unique_id": getattr(sub, "unique_id", None),
        }
        for sub in _mapping_values(getattr(entry, "subentries", None))
    ]


def _scrub_secret_values(value: Any, secret_values: frozenset[str]) -> Any:
    """Recursively replace any leaf equal to a known secret with ``"**redacted**"``.

    Walks ``Mapping`` values, list/tuple items, and scalar leaves of the (already
    ``_plainify``'d) options structure. A scalar leaf whose plaintext form
    (``str(leaf)``) exactly equals a ``secrets.yaml`` value is redacted, so a
    resolved ``!secret`` never leaves the component whether an integration persists
    it as a string OR as the original scalar (an int ``alarm_code`` leaves as an int
    leaf). ``bool`` leaves are never secrets and pass through unchanged (their
    ``"True"``/``"False"`` form would over-redact). An empty ``secret_values`` is a
    no-op (fast path). Handles ``Mapping``/``tuple`` directly so it is correct even
    if applied to a structure that skipped :func:`_plainify`.
    """
    if not secret_values:
        return value
    if isinstance(value, Mapping):
        return {k: _scrub_secret_values(v, secret_values) for k, v in value.items()}
    if isinstance(value, (list, tuple)):
        return [_scrub_secret_values(v, secret_values) for v in value]
    if isinstance(value, bool):
        return value
    if isinstance(value, (str, int, float)) and str(value) in secret_values:
        return "**redacted**"
    return value


def _safe_prop(obj: Any, name: str, default: Any = None) -> Any:
    """Read a possibly-computed property, degrading to ``default`` if it raises.

    ``getattr`` alone only defaults on ``AttributeError``; a ConfigEntry's
    ``supports_options`` / ``supports_reconfigure`` are computed off the flow
    handler and can raise other errors when it is unavailable, so catch broadly.
    """
    try:
        return getattr(obj, name, default)
    except Exception:  # pragma: no cover - defensive; core drift
        return default


# =============================================================================
# ha_mcp_tools/registry_lookup
# =============================================================================
def _do_registry_lookup(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
    """Return entity-registry rows for a set of ids or one config entry.

    Rows are core's ``RegistryEntry.as_partial_dict`` VERBATIM (the
    ``config/entity_registry/list`` shape, disabled entities included), so the
    consumers that parse that exact shape today read the component-served rows
    unchanged.

    * ``config_entry_id`` — scans the whole entity registry and returns EVERY
      entity bound to that entry (``{entities: [...]}``). It deliberately does
      NOT reuse :func:`_entities_by_config_entry` (single-valued — first entity
      only), so a multi-entity flow helper (a utility_meter and its tariff
      sub-entities) does not silently lose members.
    * ``entity_ids`` — looks each up (``{entities: [...], missing: [...]}``); an
      id with no registry entry lands in ``missing`` rather than being dropped.

    Pure O(n)/O(id) in-memory registry reads. Exactly one of the two params is
    meaningful — the schema rejects both being present; neither present (or only an
    empty-string ``config_entry_id`` / empty ``entity_ids`` list — no usable target)
    raises ``HomeAssistantError`` (see :func:`_registry_lookup_missing_target`)
    rather than silently returning an empty result the caller could mistake for "no
    matches".
    """
    config_entry_id = params.get("config_entry_id")
    entity_ids = params.get("entity_ids") or []
    if not config_entry_id and not entity_ids:
        raise _registry_lookup_missing_target()

    view = _resolve_registries(hass)
    if config_entry_id:
        rows = [
            _entity_partial_dict(entry)
            for entry in _all_entity_entries(view)
            if getattr(entry, "config_entry_id", None) == config_entry_id
        ]
        return {"entities": [row for row in rows if row is not None]}

    found: list[dict[str, Any]] = []
    missing: list[str] = []
    for entity_id in entity_ids:
        entry = _reg_entity(view, entity_id)
        row = _entity_partial_dict(entry) if entry is not None else None
        if row is None:
            missing.append(entity_id)
        else:
            found.append(row)
    return {"entities": found, "missing": missing}


def _registry_lookup_missing_target() -> Exception:
    """Build a ``HomeAssistantError`` for a target-less ``registry_lookup`` request.

    Mirrors :func:`_backup_unavailable`: imported function-locally (test-stubbable)
    so a request with no usable target — neither ``entity_ids`` nor
    ``config_entry_id``, OR only an empty-string / empty-list one — raises instead of
    returning ``{entities: [], missing: []}``, a shape indistinguishable from a
    genuine "nothing matched" result, letting the server's command-error fallback
    fire for the degenerate case. (This is a deliberate divergence from
    ``config_entries``, whose empty-string ``entry_id`` is an authoritative empty
    result mirroring ``async_get_entry("")`` — a different, intentional semantic.)
    """
    from homeassistant.exceptions import HomeAssistantError

    err: Exception = HomeAssistantError(
        "ha_mcp_tools/registry_lookup requires a non-empty entity_ids or "
        "config_entry_id"
    )
    return err


# =============================================================================
# ha_mcp_tools/system_snapshot
# =============================================================================
def _do_system_snapshot(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
    """One consistent synchronous pass over the live objects the health path reads.

    ``{config_entries: [...], issues: [...], entities: [...], states: [...]}`` —
    every section read from the live in-memory objects in a SINGLE synchronous
    handler call, which is the point: it collapses the server's former 3x
    ``config_entries/get`` fetches (a TOCTOU where entries changed mid-read) into
    one coherent snapshot. ``include_*`` flags gate each section; a disabled
    section is an empty list (present, so the consumer need not special-case a
    missing key).

    * ``config_entries`` — identity fields only (``entry_id`` / ``domain`` /
      ``title`` / ``state`` / ``source`` / ``disabled_by``); the health view does
      not need ``options`` / ``subentries`` (and skipping them avoids the secret
      scrub here).
    * ``issues`` — the ``_overview_repairs`` slice, reused verbatim.
    * ``entities`` — the ``registry_lookup`` row shape (``as_partial_dict``).
    * ``states`` — ``State.as_dict()`` per state (REST-parity bodies).
    """
    view = _resolve_registries(hass)
    result: dict[str, Any] = {}
    result["config_entries"] = (
        [_config_entry_identity_row(e) for e in _iter_config_entries(hass)]
        if params.get("include_config_entries", True)
        else []
    )
    result["issues"] = (
        _overview_repairs(hass) if params.get("include_issues", True) else []
    )
    result["entities"] = (
        [
            row
            for row in (_entity_partial_dict(e) for e in _all_entity_entries(view))
            if row is not None
        ]
        if params.get("include_entities", True)
        else []
    )
    result["states"] = (
        _snapshot_states(hass) if params.get("include_states", True) else []
    )
    return result


def _config_entry_identity_row(entry: Any) -> dict[str, Any]:
    """Identity-only config-entry row for the health snapshot (no options/subentries)."""
    return {
        "entry_id": getattr(entry, "entry_id", None),
        "domain": getattr(entry, "domain", None),
        "title": getattr(entry, "title", None),
        "state": _enum_value(getattr(entry, "state", None)),
        "source": getattr(entry, "source", None),
        "disabled_by": _enum_value(getattr(entry, "disabled_by", None)),
    }


def _snapshot_states(hass: HomeAssistant) -> list[dict[str, Any]]:
    """Every live state as ``State.as_dict()`` (REST-parity body, unmodified)."""
    out: list[dict[str, Any]] = []
    for state in _iter_states(hass):
        if not getattr(state, "entity_id", None):
            continue
        as_dict = _state_as_dict(state)
        if as_dict is not None:
            out.append(as_dict)
    return out


# =============================================================================
# ha_mcp_tools/entity_lookup
# =============================================================================
def _do_entity_lookup(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
    """Return registry entries whose ``unique_id`` matches (domain/platform narrow).

    ``{matches: [{entity_id, unique_id, platform, domain, config_entry_id,
    categories, disabled_by, hidden_by}]}``. Scans the entity registry for every
    entry whose ``unique_id`` equals the requested one, optionally narrowed by
    ``domain`` (the entity's own domain, from its entity_id) and ``platform``
    (the owning integration). Multiple matches across platforms are all returned
    — the server picks. ``categories`` mirrors ``as_partial_dict``'s
    ``dict(entry.categories)``; ``disabled_by`` / ``hidden_by`` are unwrapped to
    their wire strings. In-process, so the read is authoritative immediately (no
    registry-write settle retry).

    A drifted entity registry (``er.async_get`` raised / renamed → ``None``) RAISES
    ``HomeAssistantError`` (→ server command-error fallback to the legacy scan)
    rather than returning ``{matches: []}`` — a well-formed empty the server can't
    tell from a genuine "no entry with that unique_id". A present-but-empty registry
    still returns ``{matches: []}`` (correct: no match).
    """
    unique_id = params.get("unique_id")
    domain = params.get("domain")
    platform = params.get("platform")
    view = _resolve_registries(hass)
    if view.entity is None:
        raise _substrate_unavailable("entity registry")
    matches: list[dict[str, Any]] = []
    for entry in _all_entity_entries(view):
        if getattr(entry, "unique_id", None) != unique_id:
            continue
        entity_id = getattr(entry, "entity_id", "") or ""
        ent_domain = entity_id.split(".")[0] if "." in entity_id else ""
        if domain and ent_domain != domain:
            continue
        if platform and getattr(entry, "platform", None) != platform:
            continue
        matches.append(
            {
                "entity_id": entity_id,
                "unique_id": getattr(entry, "unique_id", None),
                "platform": getattr(entry, "platform", None),
                "domain": ent_domain,
                "config_entry_id": getattr(entry, "config_entry_id", None),
                "categories": _plainify(getattr(entry, "categories", None) or {}),
                "disabled_by": _enum_value(getattr(entry, "disabled_by", None)),
                "hidden_by": _enum_value(getattr(entry, "hidden_by", None)),
            }
        )
    return {"matches": matches}


# =============================================================================
# ha_mcp_tools/backup_prep
# =============================================================================
def _do_backup_prep(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
    """Return the backup identity the server needs before a create.

    ``{agent_ids: [...], local_agent_id: <str|None>, default_password:
    <str|None>}`` read from the backup integration's in-process manager
    (``hass.data[DATA_MANAGER]``). ``local_agent_id`` uses the SAME preference the
    server's ``_get_local_backup_agent_id`` does — an agent whose ``name`` is
    ``"local"``, preferring ``hassio.local`` (Supervised) over ``backup.local``
    (Core). ``default_password`` is getattr-chained off
    ``manager.config.data.create_backup.password``.

    A missing backup integration (``ImportError``) or an uninitialized manager
    raises ``HomeAssistantError`` so the server's command-error path falls back to
    its legacy WS reads — NOT a silent empty result the server could mistake for
    "no agents". The password is sensitive, but the legacy ``backup/config/info``
    already serves it to the same admin connection (parity, not new exposure).

    STRUCTURAL core drift raises for the same reason, rather than degrading to a
    well-formed authoritative negative: a non-Mapping ``manager.backup_agents``
    (below) or a broken config→data→create_backup chain
    (:func:`_backup_default_password`) would otherwise produce a "no agents" / "no
    password" answer the server trusts and hard-fails on (or, for the password,
    silently drops the restore safety backup) with no fallback. Value-level reads
    (an id string, a genuinely-``None`` password on an intact chain) stay
    getattr-guarded and pass through.
    """
    try:
        from homeassistant.components.backup import DATA_MANAGER
    except ImportError as exc:
        raise _backup_unavailable("backup integration is not available") from exc
    manager = _hass_data_get(hass, DATA_MANAGER)
    if manager is None:
        raise _backup_unavailable("backup manager is not initialized")
    agents = getattr(manager, "backup_agents", None)
    if not isinstance(agents, Mapping):
        raise _backup_unavailable("backup manager exposes no agent mapping")
    return {
        "agent_ids": [str(a) for a in agents],
        "local_agent_id": _preferred_local_agent_id(agents),
        "default_password": _backup_default_password(manager),
    }


def _backup_unavailable(message: str) -> Exception:
    """Build a ``HomeAssistantError`` (imported function-locally — test-stubbable)."""
    from homeassistant.exceptions import HomeAssistantError

    err: Exception = HomeAssistantError(message)
    return err


def _hass_data_get(hass: HomeAssistant, key: Any) -> Any:
    """``hass.data.get(key)`` guarded against a non-mapping / drift."""
    data = getattr(hass, "data", None)
    if not isinstance(data, Mapping):
        return None
    try:
        return data.get(key)
    except Exception:  # pragma: no cover - defensive
        return None


def _preferred_local_agent_id(agents: Any) -> str | None:
    """The local backup agent id, mirroring the server's hassio-over-core preference.

    Collects agent ids whose agent ``name`` is exactly ``"local"``
    (``hassio.local`` on Supervised, ``backup.local`` on Core both use that
    name), prefers ``hassio.local`` then ``backup.local``, else the first local
    agent, else ``None``.
    """
    if not isinstance(agents, Mapping):
        return None
    local_ids: list[str] = [
        str(agent_id)
        for agent_id, agent in agents.items()
        if getattr(agent, "name", None) == "local"
    ]
    for preferred in ("hassio.local", "backup.local"):
        if preferred in local_ids:
            return preferred
    return local_ids[0] if local_ids else None


def _backup_default_password(manager: Any) -> str | None:
    """``manager.config.data.create_backup.password`` (getattr-chained; ``str``/None).

    A STRUCTURALLY broken chain (a missing ``config`` / ``data`` / ``create_backup``
    link — core drift) RAISES ``HomeAssistantError`` so the server falls back to its
    legacy ``backup/config/info`` read, rather than returning ``None`` the server
    reads as "no default password configured" — which on restore silently drops the
    safety backup while telling the user the password is unset. Only a genuine
    ``None`` ``password`` on an INTACT chain is the authoritative "not configured".
    """
    config = getattr(manager, "config", None)
    data = getattr(config, "data", None)
    create_backup = getattr(data, "create_backup", None)
    if config is None or data is None or create_backup is None:
        raise _backup_unavailable("backup manager config chain is unavailable")
    password = getattr(create_backup, "password", None)
    return password if isinstance(password, str) else None


# =============================================================================
# ha_mcp_tools/registries
# =============================================================================
def _do_registries(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
    """Return the requested registries as the FULL-FIELD ``config/<x>_registry/list`` shapes.

    ``{areas: [...], floors: [...], labels: [...], categories: {scope: [...]}}`` —
    only the requested ``registries`` keys are present. Each row is byte-compatible
    with the legacy WS list response the consumers parse (verified against core's
    registry serializers): area = aliases / area_id / floor_id / icon / labels /
    name / picture / created_at / modified_at, plus humidity_entity_id /
    temperature_entity_id ONLY on core >= 2024.12 (emitted conditionally so an older
    core's restore does not get None-valued keys it rejects — see
    :func:`_area_row`); floor = aliases / created_at / floor_id / icon /
    level / name / modified_at (floor rows carry NO ``labels`` — core omits it);
    label = color / created_at / description / icon / label_id / name /
    modified_at; category = category_id / created_at / icon / modified_at / name.
    Timestamps are floats (``.timestamp()``), matching core.

    ``category`` is scoped: ``category_scopes`` names which scopes to list (a
    ``{scope: [rows]}`` map) and is REQUIRED when ``category`` is requested — a
    scope-less category request raises ``HomeAssistantError`` (see
    :func:`_registries_missing_category_scopes`) rather than silently serving
    ``{}``, a shape indistinguishable from "every requested scope is empty". The
    category registry is imported function-locally (not needed at module top).
    Pure in-memory reads over the resolved registries.
    """
    requested = params.get("registries") or []
    category_scopes = params.get("category_scopes") or []
    if "category" in requested and not category_scopes:
        raise _registries_missing_category_scopes()

    view = _resolve_registries(hass)
    result: dict[str, Any] = {}
    # A requested registry whose accessor drifted (raised / renamed → ``None`` via
    # ``_safe``) RAISES → server command-error fallback to the legacy WS list,
    # rather than serving a well-formed empty list the capture pipeline would read
    # as "this entity does not exist" and silently skip. A present-but-empty
    # registry serves its empty list (correct).
    if "area" in requested:
        if view.area is None:
            raise _substrate_unavailable("area registry")
        result["areas"] = [_area_row(a) for a in _all_area_entries(view)]
    if "floor" in requested:
        if view.floor is None:
            raise _substrate_unavailable("floor registry")
        result["floors"] = [_floor_row(f) for f in _all_floor_entries(view)]
    if "label" in requested:
        if view.label is None:
            raise _substrate_unavailable("label registry")
        result["labels"] = [_label_row(x) for x in _all_label_entries(view)]
    if "category" in requested:
        result["categories"] = _category_rows(hass, category_scopes)
    return result


def _registries_missing_category_scopes() -> Exception:
    """Build a ``HomeAssistantError`` for a scope-less ``category`` request.

    Mirrors :func:`_backup_unavailable`: imported function-locally (test-stubbable)
    so a ``registries`` request naming ``category`` without a non-empty
    ``category_scopes`` raises instead of silently returning ``{}`` (a caller
    could otherwise mistake that for "no categories in any scope").
    """
    from homeassistant.exceptions import HomeAssistantError

    err: Exception = HomeAssistantError(
        "ha_mcp_tools/registries: category_scopes is required when "
        "'category' is requested"
    )
    return err


def _area_row(area: Any) -> dict[str, Any]:
    """One area as core's ``AreaEntry.json_fragment`` shape (id renamed to area_id)."""
    row: dict[str, Any] = {
        "aliases": sorted(str(a) for a in (getattr(area, "aliases", None) or [])),
        "area_id": getattr(area, "id", None) or getattr(area, "area_id", None),
        "floor_id": getattr(area, "floor_id", None),
        "icon": getattr(area, "icon", None),
        "labels": sorted(str(x) for x in (getattr(area, "labels", None) or [])),
        "name": getattr(area, "name", None),
        "picture": getattr(area, "picture", None),
        "created_at": _timestamp(getattr(area, "created_at", None)),
        "modified_at": _timestamp(getattr(area, "modified_at", None)),
    }
    # humidity_entity_id / temperature_entity_id were added to AreaEntry in core
    # 2024.12. Emit each ONLY when the running core's AreaEntry actually has the
    # attribute — a pre-2024.12 core would otherwise get a None-valued key injected
    # here that the restore path's config/area_registry/update schema rejects
    # ("extra keys not allowed"). ``hasattr`` (not ``getattr(..., None)``)
    # distinguishes "core has the field, value is None" from "core has no field".
    for attr in ("humidity_entity_id", "temperature_entity_id"):
        if hasattr(area, attr):
            row[attr] = getattr(area, attr, None)
    return row


def _floor_row(floor: Any) -> dict[str, Any]:
    """One floor as core's ``config/floor_registry/list`` shape (NO ``labels`` field)."""
    return {
        "aliases": sorted(str(a) for a in (getattr(floor, "aliases", None) or [])),
        "created_at": _timestamp(getattr(floor, "created_at", None)),
        "floor_id": getattr(floor, "floor_id", None),
        "icon": getattr(floor, "icon", None),
        "level": getattr(floor, "level", None),
        "name": getattr(floor, "name", None),
        "modified_at": _timestamp(getattr(floor, "modified_at", None)),
    }


def _label_row(label: Any) -> dict[str, Any]:
    """One label as core's ``config/label_registry/list`` shape."""
    return {
        "color": getattr(label, "color", None),
        "created_at": _timestamp(getattr(label, "created_at", None)),
        "description": getattr(label, "description", None),
        "icon": getattr(label, "icon", None),
        "label_id": getattr(label, "label_id", None),
        "name": getattr(label, "name", None),
        "modified_at": _timestamp(getattr(label, "modified_at", None)),
    }


def _category_rows(hass: HomeAssistant, scopes: list[str]) -> dict[str, Any]:
    """``{scope: [category rows]}`` for each requested scope (categories are scoped).

    A drifted / absent category registry (``None`` — ``cr.async_get`` raised /
    renamed, or the module is missing on an old core) RAISES rather than serving
    ``{scope: []}`` for every scope, so the server falls back to the legacy
    ``config/category_registry/list`` instead of trusting an empty map.
    """
    registry = _category_registry(hass)
    if registry is None:
        raise _substrate_unavailable("category registry")
    return {
        scope: [_category_row(c) for c in _list_categories(registry, scope)]
        for scope in scopes
    }


def _category_row(category: Any) -> dict[str, Any]:
    """One category as core's ``config/category_registry/list`` shape."""
    return {
        "category_id": getattr(category, "category_id", None),
        "created_at": _timestamp(getattr(category, "created_at", None)),
        "icon": getattr(category, "icon", None),
        "modified_at": _timestamp(getattr(category, "modified_at", None)),
        "name": getattr(category, "name", None),
    }


def _category_registry(hass: HomeAssistant) -> Any:
    """The category registry (imported function-locally). Test seam. ``None`` on drift."""
    try:
        from homeassistant.helpers import category_registry as cr
    except ImportError:  # pragma: no cover - defensive; core drift
        return None
    return _safe(cr.async_get, hass)


def _list_categories(registry: Any, scope: str) -> list[Any]:
    """``registry.async_list_categories(scope=...)`` guarded (scope is keyword-only)."""
    if registry is None:
        return []
    lister = getattr(registry, "async_list_categories", None)
    if not callable(lister):
        return []
    try:
        return list(lister(scope=scope))
    except Exception:  # pragma: no cover - defensive
        return []


def _all_floor_entries(view: _RegistryView) -> list[Any]:
    """All floor-registry entries via ``async_list_floors()`` or the ``floors`` mapping."""
    reg = view.floor
    if reg is None:
        return []
    listed = _call_no_arg(reg, "async_list_floors")
    if listed is not None:
        try:
            return list(listed)
        except Exception:  # pragma: no cover - defensive
            return []
    return _mapping_values(getattr(reg, "floors", None))


def _all_label_entries(view: _RegistryView) -> list[Any]:
    """All label-registry entries via ``async_list_labels()`` or the ``labels`` mapping."""
    reg = view.label
    if reg is None:
        return []
    listed = _call_no_arg(reg, "async_list_labels")
    if listed is not None:
        try:
            return list(listed)
        except Exception:  # pragma: no cover - defensive
            return []
    return _mapping_values(getattr(reg, "labels", None))


# =============================================================================
# ha_mcp_tools/dashboards
# =============================================================================
# LovelaceConfig.mode returns these wire strings (MODE_STORAGE / MODE_YAML in
# core's lovelace const). Compared as strings so a MagicMock-stubbed core in the
# unit suite (no real constants) still exercises the branches.
_LOVELACE_MODE_STORAGE = "storage"
_LOVELACE_MODE_YAML = "yaml"

# Keys of a stored dashboard collection item (core's STORAGE_DASHBOARD_*_FIELDS),
# echoed by ``lovelace/dashboards/list`` — the row shape ``list`` mode mirrors.
_DASHBOARD_ROW_KEYS = (
    "id",
    "url_path",
    "title",
    "icon",
    "show_in_sidebar",
    "require_admin",
)

# Cap on ``search``-mode matches per call so one WS frame stays bounded.
_DASHBOARD_MATCH_CAP = 200

# Structural keys walked as containers (not scored as leaf strings) in a card.
_DASHBOARD_STRUCTURAL_KEYS = frozenset({"cards", "sections"})


def _do_dashboards(
    hass: HomeAssistant,
    params: dict[str, Any],
    *,
    prepped: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Return Lovelace dashboards read in-process (``list`` / ``get`` / ``search``).

    Pure assembler over the plain dicts :func:`_dashboards_prep` loads off the
    event loop — every Store load (``async_load``) happens in the prep, so this
    function only shapes / walks already-materialized config. ``available`` is
    ``False`` when the lovelace integration is not set up (no ``LOVELACE_DATA``);
    the server falls back to its legacy ``lovelace/*`` path in that case.

    YAML-mode dashboard bodies are NEVER emitted (``get`` returns a ``yaml_excluded``
    status; ``search`` skips them) — their config may carry resolved ``!secret``
    plaintext, so body emission for YAML belongs to a future file-based tool.
    """
    mode = params.get("mode", "list")
    prepped = prepped or {}
    if not prepped.get("available"):
        result: dict[str, Any] = {"mode": mode, "available": False}
        if mode == "list":
            result["dashboards"] = []
        elif mode == "search":
            result["matches"] = []
            result["truncated"] = False
        return result

    if mode == "get":
        return {
            "mode": "get",
            "available": True,
            "status": prepped.get("status"),
            "url_path": prepped.get("url_path"),
            "config": prepped.get("config"),
        }
    if mode == "search":
        query_lower = (params.get("query") or "").strip().lower()
        matches, truncated = _search_dashboard_docs(
            prepped.get("docs") or [], query_lower
        )
        return {
            "mode": "search",
            "available": True,
            "matches": matches,
            "truncated": truncated,
            # ``dashboards_doc_search`` additions (issue #2008): the whole-
            # document per-dashboard verdicts + honesty counters the server's
            # ha_search dashboard bucket needs. Additive — a pre-#2008 server
            # ignores them.
            "document_matches": _dashboard_document_matches(
                prepped.get("docs") or [], query_lower
            ),
            "yaml_skipped": prepped.get("yaml_skipped", 0),
            "load_failed": prepped.get("load_failed", 0),
        }
    return {"mode": "list", "available": True, "dashboards": prepped.get("rows") or []}


async def _dashboards_prep(hass: HomeAssistant, msg: dict[str, Any]) -> dict[str, Any]:
    """Async pre-step for ``dashboards``: do ALL the Store loading off the loop.

    Reaching ``hass.data[LOVELACE_DATA].dashboards`` is a pure in-memory read, but
    loading a dashboard's config (``LovelaceConfig.async_load``) awaits a Store
    read, so every mode's loading lives here and :func:`_do_dashboards` gets plain
    dicts. Returns ``{"prepped": {...}}`` with ``available=False`` when lovelace is
    not set up (the server falls back to legacy).
    """
    mode = msg.get("mode", "list")
    dashboards_map = _lovelace_dashboards_map(hass)
    if dashboards_map is None:
        return {"prepped": {"available": False}}

    prepped: dict[str, Any] = {"available": True}
    if mode == "get":
        prepped.update(await _dashboard_get_config(dashboards_map, msg.get("url_path")))
    elif mode == "search":
        (
            prepped["docs"],
            prepped["yaml_skipped"],
            prepped["load_failed"],
        ) = await _dashboard_search_docs(dashboards_map)
    else:
        prepped["rows"] = _dashboard_list_rows(dashboards_map)
    return {"prepped": prepped}


def _lovelace_dashboards_map(hass: HomeAssistant) -> Mapping[Any, Any] | None:
    """The ``{url_path|None: LovelaceConfig}`` map, or ``None`` if lovelace is absent.

    Reads ``hass.data[LOVELACE_DATA].dashboards`` via a function-local import of
    core's key (older cores keyed ``hass.data["lovelace"]``, so both are tried),
    guarded so a missing key / core drift degrades to ``None`` (the server keeps
    its legacy path) rather than raising.
    """
    try:
        from homeassistant.components.lovelace import LOVELACE_DATA

        key: Any = LOVELACE_DATA
    except Exception:  # pragma: no cover - defensive; core drift / older core
        key = "lovelace"
    data = getattr(hass, "data", None)
    if not isinstance(data, Mapping):
        return None
    container = data.get(key)
    if container is None and key != "lovelace":
        container = data.get("lovelace")
    if container is None:
        return None
    dashboards = getattr(container, "dashboards", None)
    if dashboards is None and isinstance(container, Mapping):
        dashboards = container.get("dashboards")
    return dashboards if isinstance(dashboards, Mapping) else None


def _dashboard_list_rows(dashboards_map: Mapping[Any, Any]) -> list[dict[str, Any]]:
    """One metadata row per non-default dashboard, tagged with ``mode``.

    Mirrors the ``lovelace/dashboards/list`` row shape (the stored collection item
    for storage dashboards, read from ``LovelaceConfig.config``) plus an additive
    ``mode`` so the server can exclude YAML dashboards. The default dashboard
    (``url_path`` key ``None``) is omitted — the legacy list omits it too and the
    server special-cases the built-in dashboard as always-existing; ``get`` mode
    resolves ``None`` to that default instead.
    """
    rows: list[dict[str, Any]] = []
    for url_path, dash in dashboards_map.items():
        if url_path is None:
            continue
        config = getattr(dash, "config", None)
        meta = config if isinstance(config, Mapping) else {}
        row: dict[str, Any] = {key: meta.get(key) for key in _DASHBOARD_ROW_KEYS}
        # The dict key is the authoritative url_path (the metadata may lack it).
        row["url_path"] = url_path
        row["mode"] = _dashboard_mode(dash)
        rows.append(row)
    return rows


async def _dashboard_get_config(
    dashboards_map: Mapping[Any, Any], url_path: Any
) -> dict[str, Any]:
    """Load one dashboard's config body; ``status`` names the outcome.

    ``url_path`` ``None``/absent resolves to the default dashboard. A YAML-mode
    dashboard returns ``status="yaml_excluded"`` with no body (its config may
    carry resolved ``!secret`` plaintext — storage-only emission). A missing
    dashboard or a load error returns ``status="not_found"``. Storage freshness is
    safe: ``LovelaceStorage.async_save`` mutates the in-memory object
    synchronously, so this read never lags a save (audit-verified).
    """
    dash = dashboards_map.get(url_path)
    resolved = getattr(dash, "url_path", None) if dash is not None else url_path
    if dash is None:
        return {"status": "not_found", "url_path": url_path, "config": None}
    if _dashboard_mode(dash) == _LOVELACE_MODE_YAML:
        return {"status": "yaml_excluded", "url_path": resolved, "config": None}
    loader = getattr(dash, "async_load", None)
    if not callable(loader):
        return {"status": "not_found", "url_path": resolved, "config": None}
    try:
        config = await loader(False)
    except Exception:  # any load failure degrades to not_found (fail-soft)
        return {"status": "not_found", "url_path": resolved, "config": None}
    if not isinstance(config, dict):
        return {"status": "not_found", "url_path": resolved, "config": None}
    return {"status": "ok", "url_path": resolved, "config": _plainify(config)}


async def _dashboard_search_docs(
    dashboards_map: Mapping[Any, Any],
) -> tuple[list[dict[str, Any]], int, int]:
    """Load every STORAGE dashboard's config for the ``search`` walk.

    Only storage dashboards are loaded — YAML bodies are never searched/emitted.
    Returns ``(docs, yaml_skipped, load_failed)``: ``docs`` are
    ``[{url_path, title, registry_title, config}, ...]`` plain dicts —
    ``title`` stays the config body's (the card-scoped ``matches`` records pin
    byte parity with the server's legacy MODE 4 walk on it) while the additive
    ``registry_title`` carries the list-row metadata title that
    ``document_matches`` emits (what the legacy ha_search bucket records
    carry); ``yaml_skipped`` counts
    the YAML-mode entries this walk never reads, INCLUDING a default dashboard
    forced to YAML (``lovelace: mode: yaml``), which has no ``list`` row for
    the server to count — the server treats a non-zero count as its
    fall-back-to-legacy signal, since the legacy walk DOES read YAML bodies
    and coverage must not depend on which path served (issue #2008 review);
    ``load_failed`` counts storage
    dashboards whose config load raised or returned a non-dict — real gaps the
    caller must surface as partial rather than fail-soft into a clean-looking
    result. A ``ConfigNotFound`` load is a clean skip, not a failure: an
    auto-generated (never taken control of) dashboard has no stored config to
    scan. If core drift breaks the guarded ``ConfigNotFound`` import, those
    loads degrade to ``load_failed`` — over-reported as partial, never silent.
    """
    try:
        from homeassistant.components.lovelace.const import ConfigNotFound
    except Exception:  # pragma: no cover - defensive; core drift
        ConfigNotFound = None

    docs: list[dict[str, Any]] = []
    yaml_skipped = 0
    load_failed = 0
    for url_path, dash in dashboards_map.items():
        if _dashboard_mode(dash) == _LOVELACE_MODE_YAML:
            yaml_skipped += 1
            continue
        if _dashboard_mode(dash) != _LOVELACE_MODE_STORAGE:
            continue
        loader = getattr(dash, "async_load", None)
        if not callable(loader):
            continue
        try:
            config = await loader(False)
        except Exception as err:
            if ConfigNotFound is not None and isinstance(err, ConfigNotFound):
                # Auto-generated dashboard: nothing stored, nothing to scan.
                continue
            load_failed += 1
            continue
        if not isinstance(config, dict):
            load_failed += 1
            continue
        meta = getattr(dash, "config", None)
        registry_title = meta.get("title") if isinstance(meta, Mapping) else None
        title = config.get("title")
        docs.append(
            {
                "url_path": url_path,
                "title": str(title) if title is not None else None,
                "registry_title": (
                    str(registry_title) if registry_title is not None else None
                ),
                "config": config,
            }
        )
    return docs, yaml_skipped, load_failed


def _dashboard_mode(dash: Any) -> str | None:
    """A dashboard's ``mode`` (``storage``/``yaml``), guarded against core drift."""
    mode = getattr(dash, "mode", None)
    return str(mode) if isinstance(mode, str) else None


def _dashboard_document_matches(
    docs: list[dict[str, Any]], query_lower: str
) -> list[dict[str, Any]]:
    """Per-dashboard whole-document verdicts: ``[{url_path, title}, ...]``.

    One entry per doc whose ENTIRE config contains ``query_lower`` — the
    coverage the server's legacy ``_search_in_dict`` walk provides (view
    titles, dashboard-level keys, every leaf), which the card-scoped
    ``matches`` walk deliberately narrows to. ``title`` is the registry
    metadata's (falling back to the body's) — what the legacy ha_search
    bucket records carry. An empty query matches nothing. Bounded by the
    dashboard count, so no cap/truncation applies.
    """
    if not query_lower:
        return []
    return [
        {
            "url_path": doc.get("url_path"),
            "title": doc.get("registry_title") or doc.get("title"),
        }
        for doc in docs
        if _doc_contains(doc.get("config"), query_lower)
    ]


def _doc_contains(data: Any, query_lower: str) -> bool:
    """Case-insensitive substring test over keys and every leaf of a config.

    Exact port of the server's ``_search_in_dict_exact`` (keys + string
    leaves + ``str()`` of non-None scalars) so the component-served verdict
    matches the legacy walk's, leaf for leaf.
    """
    if isinstance(data, dict):
        return any(
            query_lower in str(key).lower() or _doc_contains(value, query_lower)
            for key, value in data.items()
        )
    if isinstance(data, list):
        return any(_doc_contains(item, query_lower) for item in data)
    if isinstance(data, str):
        return query_lower in data.lower()
    if data is not None:
        return query_lower in str(data).lower()
    return False


def _search_dashboard_docs(
    docs: list[dict[str, Any]], query_lower: str
) -> tuple[list[dict[str, Any]], bool]:
    """Walk each dashboard config for ``query_lower``; return ``(matches, truncated)``.

    An empty query matches nothing (a bare substring would match every string).
    Matches are capped at :data:`_DASHBOARD_MATCH_CAP` with a ``truncated`` flag.
    """
    if not query_lower:
        return [], False
    matches: list[dict[str, Any]] = []
    for doc in docs:
        _collect_dashboard_matches(doc, query_lower, matches)
    truncated = len(matches) > _DASHBOARD_MATCH_CAP
    return matches[:_DASHBOARD_MATCH_CAP], truncated


def _collect_dashboard_matches(
    doc: dict[str, Any], query_lower: str, matches: list[dict[str, Any]]
) -> None:
    """Append every ``query_lower`` hit in one dashboard config to ``matches``.

    Walks each view's card containers (``cards`` + sections-view ``sections.cards``,
    nested cards recursed), plus the two view-level containers the card walk never
    visits: ``badges`` and a sections-view ``header.card``. This matches what the
    single-dashboard (MODE 2) search covers, so a query answered "no match" here is
    a real absence, not a blind spot for entities referenced only as a badge or in a
    header card.
    """
    config = doc.get("config")
    if not isinstance(config, dict):
        return
    views = config.get("views")
    if not isinstance(views, list):
        return
    url_path = doc.get("url_path")
    dash_title = doc.get("title")
    for view_index, view in enumerate(views):
        if not isinstance(view, dict):
            continue
        view_title = view.get("title")
        for cards, base_path in _view_card_containers(view, view_index):
            _collect_card_matches(
                cards,
                base_path,
                url_path,
                dash_title,
                view_index,
                view_title,
                query_lower,
                matches,
            )
        _collect_badge_matches(
            view, view_index, url_path, dash_title, view_title, query_lower, matches
        )
        _collect_header_card_matches(
            view, view_index, url_path, dash_title, view_title, query_lower, matches
        )


def _view_card_containers(
    view: dict[str, Any], view_index: int
) -> list[tuple[Any, str]]:
    """The card lists in a view: top-level ``cards`` plus each section's ``cards``."""
    containers: list[tuple[Any, str]] = []
    if isinstance(view.get("cards"), list):
        containers.append((view["cards"], f"views[{view_index}].cards"))
    sections = view.get("sections")
    if isinstance(sections, list):
        for si, section in enumerate(sections):
            if isinstance(section, dict) and isinstance(section.get("cards"), list):
                containers.append(
                    (section["cards"], f"views[{view_index}].sections[{si}].cards")
                )
    return containers


def _dashboard_match(
    url_path: Any,
    dash_title: Any,
    view_index: int,
    view_title: Any,
    card_path: str,
    card_type: Any,
    matched_field: str,
    matched_value: str,
) -> dict[str, Any]:
    """One MODE 4 cross-dashboard search match record (shared, fixed shape).

    Every match site — cards, badges, header cards — builds its record here so the
    wire shape stays identical (the server-side legacy walk mirrors it for parity).
    """
    return {
        "url_path": url_path,
        "title": dash_title,
        "view_index": view_index,
        "view_title": view_title,
        "card_path": card_path,
        "card_type": card_type,
        "matched_field": matched_field,
        "matched_value": matched_value,
    }


def _collect_card_matches(
    cards: Any,
    base_path: str,
    url_path: Any,
    dash_title: Any,
    view_index: int,
    view_title: Any,
    query_lower: str,
    matches: list[dict[str, Any]],
) -> None:
    """Recurse a card list, recording one match per string leaf containing the query.

    ``matched_field`` is the leaf's immediate key (``entity`` / ``entities`` /
    ``camera_image`` / any plain-string field); nested ``cards`` are walked as
    their own cards (their strings are attributed to the nested card, not the
    parent), so ``card_path`` / ``card_type`` always name the card the string
    actually lives on.
    """
    if not isinstance(cards, list):
        return
    for card_index, card in enumerate(cards):
        if not isinstance(card, dict):
            continue
        _collect_one_card_matches(
            card,
            f"{base_path}[{card_index}]",
            url_path,
            dash_title,
            view_index,
            view_title,
            query_lower,
            matches,
        )


def _collect_one_card_matches(
    card: dict[str, Any],
    card_path: str,
    url_path: Any,
    dash_title: Any,
    view_index: int,
    view_title: Any,
    query_lower: str,
    matches: list[dict[str, Any]],
) -> None:
    """Record matches for a SINGLE card at ``card_path`` and recurse its nested cards.

    Shared by :func:`_collect_card_matches` (list-indexed cards) and
    :func:`_collect_header_card_matches` (a header card is a single card, not
    list-indexed).
    """
    card_type = card.get("type")
    for field, value in _card_string_leaves(card):
        if query_lower in value.lower():
            matches.append(
                _dashboard_match(
                    url_path,
                    dash_title,
                    view_index,
                    view_title,
                    card_path,
                    card_type,
                    field,
                    value,
                )
            )
    nested = card.get("cards")
    if isinstance(nested, list):
        _collect_card_matches(
            nested,
            f"{card_path}.cards",
            url_path,
            dash_title,
            view_index,
            view_title,
            query_lower,
            matches,
        )


def _collect_badge_matches(
    view: dict[str, Any],
    view_index: int,
    url_path: Any,
    dash_title: Any,
    view_title: Any,
    query_lower: str,
    matches: list[dict[str, Any]],
) -> None:
    """Record query hits in a view's ``badges`` — entity refs the card walk misses.

    View-level badges are entity references by construction: a bare string
    (``sensor.x``) or a dict (``{type: entity, entity: sensor.x}``). A bare-string
    badge is recorded as a ``badges`` leaf; a dict badge's string leaves are walked
    like a card's. Mirrors the single-dashboard (MODE 2) badge coverage.
    """
    badges = view.get("badges")
    if not isinstance(badges, list):
        return
    for badge_index, badge in enumerate(badges):
        badge_path = f"views[{view_index}].badges[{badge_index}]"
        if isinstance(badge, str):
            if badge and query_lower in badge.lower():
                matches.append(
                    _dashboard_match(
                        url_path,
                        dash_title,
                        view_index,
                        view_title,
                        badge_path,
                        "badge",
                        "badges",
                        badge,
                    )
                )
        elif isinstance(badge, dict):
            badge_type = badge.get("type") or "badge"
            for field, value in _card_string_leaves(badge):
                if query_lower in value.lower():
                    matches.append(
                        _dashboard_match(
                            url_path,
                            dash_title,
                            view_index,
                            view_title,
                            badge_path,
                            badge_type,
                            field,
                            value,
                        )
                    )


def _collect_header_card_matches(
    view: dict[str, Any],
    view_index: int,
    url_path: Any,
    dash_title: Any,
    view_title: Any,
    query_lower: str,
    matches: list[dict[str, Any]],
) -> None:
    """Record query hits in a sections-view header card (``views[n].header.card``).

    The header accepts a card (typically Markdown) that can carry entity refs; the
    card walk never visits it. Mirrors the single-dashboard (MODE 2) header-card
    coverage.
    """
    header = view.get("header")
    if not isinstance(header, dict):
        return
    header_card = header.get("card")
    if not isinstance(header_card, dict):
        return
    _collect_one_card_matches(
        header_card,
        f"views[{view_index}].header.card",
        url_path,
        dash_title,
        view_index,
        view_title,
        query_lower,
        matches,
    )


def _card_string_leaves(card: dict[str, Any]) -> list[tuple[str, str]]:
    """``(immediate_key, string)`` for every string leaf of a card.

    Descends into nested dicts/lists but NOT the structural ``cards``/``sections``
    keys (those are walked as their own cards). The key attributed to a leaf is
    the nearest dict key, so ``entities: [{entity: light.a}]`` yields
    ``("entity", "light.a")`` and ``entities: [light.a]`` yields
    ``("entities", "light.a")`` — matching the brief's field taxonomy.
    """
    out: list[tuple[str, str]] = []
    _walk_card_leaves(card, "", out)
    return out


def _walk_card_leaves(value: Any, key: str, out: list[tuple[str, str]]) -> None:
    """Recursive worker for :func:`_card_string_leaves` (module-level for clarity).

    Descends dicts/lists collecting ``(nearest_key, string)`` leaves, skipping the
    structural ``cards``/``sections`` keys (walked as their own cards). A top-level
    card dict enters the ``dict`` branch, so its own keys attribute their leaves.
    """
    if isinstance(value, str):
        if value:
            out.append((key, value))
    elif isinstance(value, dict):
        for k, v in value.items():
            if k not in _DASHBOARD_STRUCTURAL_KEYS:
                _walk_card_leaves(v, str(k), out)
    elif isinstance(value, (list, tuple)):
        for item in value:
            _walk_card_leaves(item, key, out)


# =============================================================================
# ha_mcp_tools/services_list
# =============================================================================
def _do_services_list(
    hass: HomeAssistant,
    params: dict[str, Any],
    *,
    descriptions: Mapping[str, Any] | None = None,
    translations: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
    """Reshape the service catalog to the REST ``/api/services`` list, ``domain``-filtered.

    ``descriptions`` (``async_get_all_descriptions`` — ``{domain: {service:
    desc}}``) and ``translations`` (``async_get_translations`` for the ``services``
    category) are loaded off the loop by :func:`_services_list_prep`.

    ``domain`` is the only filter — an exact match, same semantics both paths.
    There is deliberately NO ``query`` coarse-filter: the server's exact filter
    matches against the CONCATENATION ``f"{domain}.{service} {name} {description}"``
    (with a title-cased ``service.replace("_"," ").title()`` name fallback), and a
    cheap per-field component pass is NOT a superset of that — it misses queries
    spanning the ``domain.service`` / name / description boundaries and the
    title-cased fallback, so forwarding ``query`` would silently drop matching
    services. No consumer forwards ``query`` (the server re-runs its exact filter +
    pagination over the full payload), so the component simply doesn't accept it.
    Translations are scoped to the kept domains' key prefixes.
    """
    descriptions = descriptions or {}
    translations = translations or {}
    domain_filter = params.get("domain")

    services: list[dict[str, Any]] = []
    kept_domains: set[str] = set()
    for domain, domain_services in descriptions.items():
        if domain_filter and domain != domain_filter:
            continue
        services_map = (
            dict(domain_services) if isinstance(domain_services, Mapping) else {}
        )
        services.append({"domain": domain, "services": services_map})
        kept_domains.add(str(domain))

    return {
        "services": services,
        "translations": _filter_service_translations(translations, kept_domains),
    }


async def _services_list_prep(
    hass: HomeAssistant, msg: dict[str, Any]
) -> dict[str, Any]:
    """Async pre-step for ``services_list``: load descriptions + translations off-loop.

    Both ``async_get_all_descriptions`` and ``async_get_translations`` await
    (translation loads touch the filesystem / integration setup), so they run in
    the prep via the seam wrappers below and :func:`_do_services_list` stays a pure
    reshape/filter.
    """
    language = msg.get("language") or "en"
    descriptions = await _fetch_service_descriptions(hass)
    translations = await _fetch_service_translations(hass, language)
    return {"descriptions": descriptions, "translations": translations}


async def _fetch_service_descriptions(hass: HomeAssistant) -> Mapping[str, Any]:
    """core ``async_get_all_descriptions(hass)``; function-local import test seam.

    A non-``Mapping`` return is core drift, NOT an empty catalog: RAISE
    ``HomeAssistantError`` (→ server command-error fallback to the legacy REST
    ``/api/services`` read) rather than serving a well-formed empty catalog the
    server would trust as authoritative. A raising ``async_get_all_descriptions``
    already propagates the same way.
    """
    from homeassistant.helpers.service import async_get_all_descriptions

    result = await async_get_all_descriptions(hass)
    if not isinstance(result, Mapping):
        raise _substrate_unavailable("service descriptions")
    return result


async def _fetch_service_translations(
    hass: HomeAssistant, language: str
) -> Mapping[str, Any]:
    """core ``async_get_translations(hass, language, "services")``; test seam.

    Fails soft (empty map) so a translation-load failure degrades to the untranslated
    service list rather than failing the whole command.
    """
    from homeassistant.helpers.translation import async_get_translations

    try:
        result = await async_get_translations(hass, language, "services")
    except Exception:  # translations are additive; degrade to none on any error
        _LOGGER.warning(
            "services_list: could not load service translations; "
            "continuing without them",
            exc_info=True,
        )
        return {}
    if isinstance(result, Mapping):
        return result
    # Same visibility as the failure above: discarding the catalog leaves the
    # service list untranslated, and the type is the only useful clue. Kept in
    # step with the mirrored seam in config_flow.
    _LOGGER.warning(
        "services_list: ignoring the %s service translations: expected a "
        "Mapping, got %s",
        language,
        type(result).__name__,
    )
    return {}


def _filter_service_translations(
    translations: Mapping[str, Any], kept_domains: set[str]
) -> dict[str, Any]:
    """Keep only translation keys whose domain segment is in ``kept_domains``.

    Backend ``services``-category keys are ``component.<domain>.services.<service>.…``
    so the domain is the second dotted segment.
    """
    return {
        key: value
        for key, value in translations.items()
        if _translation_key_domain(key) in kept_domains
    }


def _translation_key_domain(key: Any) -> str | None:
    """The ``<domain>`` segment of a ``component.<domain>.services.…`` translation key."""
    if not isinstance(key, str):
        return None
    parts = key.split(".")
    return parts[1] if len(parts) > 1 else None


# =============================================================================
# ha_mcp_tools/reference_data
# =============================================================================
def _do_reference_data(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
    """Return the service index + entity-id universe the reference validator reads.

    ``services`` is the REST ``/api/services`` list shape ``build_service_index``
    consumes — reusing :func:`_overview_services`, whose per-service bodies are
    EMPTY dicts (the index only reads service-name keys). ``entity_ids`` is every
    ``hass.states.async_all()`` id (the ``build_entity_set`` universe). Pure,
    synchronous, no prep — both are in-memory reads.

    A drifted service registry (``hass.services.async_services()`` raised / renamed
    → non-``Mapping``) or state machine (``hass.states.async_all`` absent / renamed)
    RAISES ``HomeAssistantError`` (→ server command-error fallback to the legacy
    REST ``get_services()`` / ``get_states()`` pair) rather than returning empty
    catalogs — which would make EVERY reference emit a false "not found" warning,
    where the legacy failure mode is skip-validation. A genuinely-empty (but
    present) substrate still returns its empty result.
    """
    include_states = params.get("include_states", True)
    if not isinstance(
        _call_no_arg(getattr(hass, "services", None), "async_services"), Mapping
    ):
        raise _substrate_unavailable("service registry")
    entity_ids: list[str] = []
    if include_states:
        states_obj = getattr(hass, "states", None)
        if not callable(getattr(states_obj, "async_all", None)):
            raise _substrate_unavailable("state machine")
        for state in _iter_states(hass):
            entity_id = getattr(state, "entity_id", None)
            if entity_id:
                entity_ids.append(entity_id)
    return {"services": _overview_services(hass), "entity_ids": entity_ids}


# =============================================================================
# ha_mcp_tools/search — search_visibility (opt-in hidden-set exclusion)
# =============================================================================
# HA's EntityCategory enum values (homeassistant.const). Mirrors the server
# resolver's KNOWN_ENTITY_CATEGORIES so an unknown exclude_category hides nothing.
_KNOWN_ENTITY_CATEGORIES = frozenset({"config", "diagnostic"})

# Degradation warnings surfaced when a visibility dimension fails open (mirrors the
# server resolver so the two paths emit byte-identical text — pinned by the
# cross-seam warnings contract test). The component cannot import the server
# package (it ships over HACS independently), so these strings are duplicated here
# and the contract test asserts they stay equal to ``visibility.resolver``'s.
_ASSIST_UNAVAILABLE_WARNING = (
    "Entity visibility filter is enabled with respect_assist_exposure but the "
    "Assist exposure data was unavailable; that dimension is skipped for this "
    "request (other dimensions still apply)."
)
_ALLOWLIST_REGISTRY_EMPTY_WARNING = (
    "Entity visibility filter is enabled with an area/label allowlist but the "
    "entity registry returned no entries; those allow dimensions are skipped for "
    "this request (an allow_entity_ids list, if set, still applies) so the filter "
    "does not blank every entity."
)


def _unknown_categories_warning(unknown_categories: set[str]) -> str:
    """The resolver's unknown-``exclude_categories`` warning text (byte-identical)."""
    return (
        "Entity visibility: ignoring unknown exclude_categories "
        f"{sorted(unknown_categories)} (valid: config, diagnostic)."
    )


def _visibility_hidden_set(
    view: _RegistryView,
    states: Any,
    visibility: Mapping[str, Any],
    should_expose_fn: Any,
    *,
    assist_available: bool = True,
) -> set[str]:
    """Compute the opt-in hidden entity_id set, mirroring the server's resolver.

    A pure replication of ``visibility.resolver.hidden_entity_ids`` over the live
    ``_RegistryView`` + ``states`` (rather than the WS ``{success, result}``
    payloads the server passes): a conjunction of independent hide dimensions —
    the deny list, the category / hidden / area / label excludes (area/labels are
    device-inherited), the allow-list restrict mode, and the Assist dimension. The
    Assist dimension delegates to the injectable ``should_expose_fn(entity_id) ->
    bool`` (:func:`_assist_should_expose` in production — a READ-ONLY reconstruction
    of core's ``async_should_expose`` from the explicit exposure map + expose_new +
    domain defaults, matching the resolver; a fake in tests), the injection point the
    cross-seam contract test aligns with the server's own Assist result. The
    production seam is read-only by design: core's own ``async_should_expose`` writes
    computed defaults back, which this fast path must not do (see
    :func:`_assist_should_expose`).

    ``should_expose_fn`` is consulted only when ``respect_assist_exposure`` is set
    AND ``assist_available`` is True. When the config requests the Assist dimension
    but the exposure machinery is unavailable (``assist_available=False``), the
    dimension is SKIPPED — hiding nothing by Assist — mirroring the resolver's
    "skip the dimension when its data is unavailable" fail-open (the paired
    degradation warning is surfaced by :func:`_visibility_warnings`). Kept a
    standalone pure function so it is unit-testable without the full search
    pipeline.
    """
    exclude_categories = set(visibility.get("exclude_categories") or [])
    categories = exclude_categories & _KNOWN_ENTITY_CATEGORIES
    exclude_hidden = bool(visibility.get("exclude_hidden"))
    denied = set(visibility.get("deny_entity_ids") or [])
    exclude_areas = set(visibility.get("exclude_areas") or [])
    exclude_labels = set(visibility.get("exclude_labels") or [])
    allow_entity_ids = set(visibility.get("allow_entity_ids") or [])
    allow_areas = set(visibility.get("allow_areas") or [])
    allow_labels = set(visibility.get("allow_labels") or [])
    respect_assist = bool(visibility.get("respect_assist_exposure"))
    # ``assist_active`` gates the per-entity Assist should_expose sub-check (skipped
    # when the config asks for Assist but its data is unavailable). The allow/assist
    # loop below is guarded by ``allow_active or respect_assist`` — a structural
    # mirror of the resolver's guard, not a functional requirement: when Assist is
    # requested-but-unavailable and no allowlist is set, the loop runs but hides
    # nothing (both sub-checks are inert). Kept identical so the two paths match.
    assist_active = respect_assist and assist_available
    allow_active = bool(allow_areas or allow_labels or allow_entity_ids)

    registry_by_id = _registry_index_by_id(view)
    # states-only entity universe (YAML/template entities absent from the registry
    # that the allow / Assist dimensions must still be able to hide).
    state_ids = {
        eid for eid in (getattr(s, "entity_id", None) for s in states or []) if eid
    }

    # Fail-open guard (mirrors the resolver): an area/label allowlist needs registry
    # data to match; if the registry is empty but there are states-only candidates,
    # restrict mode would blank everything, so drop those allow dimensions.
    if (allow_areas or allow_labels) and not registry_by_id and state_ids:
        allow_areas = set()
        allow_labels = set()
        allow_active = bool(allow_entity_ids)

    hidden: set[str] = set(denied)
    _apply_visibility_excludes(
        view,
        registry_by_id,
        denied,
        categories,
        exclude_hidden,
        exclude_areas,
        exclude_labels,
        hidden,
    )
    if allow_active or respect_assist:
        _apply_visibility_allow_assist(
            view,
            registry_by_id,
            state_ids,
            allow_active,
            allow_entity_ids,
            allow_areas,
            allow_labels,
            assist_active,
            should_expose_fn,
            hidden,
        )
    return hidden


def _visibility_warnings(
    view: _RegistryView,
    states: Any,
    visibility: Mapping[str, Any],
    *,
    assist_available: bool = True,
) -> list[str]:
    """Degradation warnings for a visibility computation, mirroring the resolver.

    Companion to :func:`_visibility_hidden_set`: the hidden-set function silently
    fails open on a degraded dimension (an unknown ``exclude_category``, an
    area/label allowlist against an empty registry, or a requested-but-unavailable
    Assist dimension), so this returns the operator-facing warnings the server's
    ``load_hidden_set`` would emit for the same config. The ha_search consumer
    merges them into the response so the component fast path is no longer silent
    about incomplete filtering. Byte-identical to ``visibility.resolver``'s warning
    text (pinned by the cross-seam contract test). Kept a standalone pure function
    so each degraded dimension is unit-testable.

    The resolver's registry-unavailable warning has no analog here: the component
    reads HA's live in-process registry, which is never the failed-WS payload the
    server can receive.
    """
    warnings: list[str] = []

    exclude_categories = set(visibility.get("exclude_categories") or [])
    unknown = exclude_categories - _KNOWN_ENTITY_CATEGORIES
    if unknown:
        warnings.append(_unknown_categories_warning(unknown))

    allow_areas = set(visibility.get("allow_areas") or [])
    allow_labels = set(visibility.get("allow_labels") or [])
    registry_by_id = _registry_index_by_id(view)
    state_ids = {
        eid for eid in (getattr(s, "entity_id", None) for s in states or []) if eid
    }
    # Empty-registry allowlist fail-open: same guard as _visibility_hidden_set —
    # only fires when there are states-only candidates the restrict mode would
    # otherwise blank.
    if (allow_areas or allow_labels) and not registry_by_id and state_ids:
        warnings.append(_ALLOWLIST_REGISTRY_EMPTY_WARNING)

    if bool(visibility.get("respect_assist_exposure")) and not assist_available:
        warnings.append(_ASSIST_UNAVAILABLE_WARNING)

    return warnings


def _registry_index_by_id(view: _RegistryView) -> dict[str, Any]:
    """Index the entity-registry entries by entity_id."""
    index: dict[str, Any] = {}
    for entry in _all_entity_entries(view):
        eid = getattr(entry, "entity_id", None)
        if eid:
            index[eid] = entry
    return index


def _apply_visibility_excludes(
    view: _RegistryView,
    registry_by_id: dict[str, Any],
    denied: set[str],
    categories: set[str],
    exclude_hidden: bool,
    exclude_areas: set[str],
    exclude_labels: set[str],
    hidden: set[str],
) -> None:
    """Add the exclude-dimension hits to ``hidden`` (registry-derived, deny-first).

    Mirrors the resolver's exclude loop. The empty-set dimensions are inert (``x in
    set()`` / ``set() & x`` are falsy), so an inactive dimension hides nothing
    without a guard; only ``exclude_hidden`` is a bool flag and keeps its guard.
    """
    for eid, entry in registry_by_id.items():
        if eid in denied:
            continue
        if _enum_value(getattr(entry, "entity_category", None)) in categories:
            hidden.add(eid)
            continue
        if exclude_hidden and getattr(entry, "hidden_by", None) is not None:
            hidden.add(eid)
            continue
        if _effective_area_for_entry(view, entry) in exclude_areas:
            hidden.add(eid)
            continue
        if exclude_labels & _effective_labels_for_entry(view, entry):
            hidden.add(eid)


def _apply_visibility_allow_assist(
    view: _RegistryView,
    registry_by_id: dict[str, Any],
    state_ids: set[str],
    allow_active: bool,
    allow_entity_ids: set[str],
    allow_areas: set[str],
    allow_labels: set[str],
    assist_active: bool,
    should_expose_fn: Any,
    hidden: set[str],
) -> None:
    """Add allow-restrict + Assist hits to ``hidden`` over registry + states.

    These conjunctive filters must reach states-only entities, so they iterate the
    full candidate universe. Mirrors the resolver's allow/assist loop. ``should_expose_fn``
    is consulted only when ``assist_active`` (Assist requested AND its data
    available); an unavailable-Assist request degrades to no Assist hiding here,
    with the warning surfaced separately.
    """
    for eid in registry_by_id.keys() | state_ids:
        if eid in hidden:
            continue
        entry = registry_by_id.get(eid)
        if allow_active and not _entity_allowed(
            view, eid, entry, allow_entity_ids, allow_areas, allow_labels
        ):
            hidden.add(eid)
            continue
        if assist_active and not should_expose_fn(eid):
            hidden.add(eid)


def _entity_allowed(
    view: _RegistryView,
    eid: str,
    entry: Any,
    allow_entity_ids: set[str],
    allow_areas: set[str],
    allow_labels: set[str],
) -> bool:
    """Whether an entity satisfies the allowlist (restrict mode) — matched, so kept."""
    if eid in allow_entity_ids:
        return True
    if entry is None:
        return False
    if _effective_area_for_entry(view, entry) in allow_areas:
        return True
    return bool(allow_labels & _effective_labels_for_entry(view, entry))


def _effective_area_for_entry(view: _RegistryView, entry: Any) -> str | None:
    """An entity's ``area_id`` falling back to its device's (HA area inheritance)."""
    area_id = getattr(entry, "area_id", None)
    if isinstance(area_id, str) and area_id:
        return area_id
    device_id = getattr(entry, "device_id", None)
    if isinstance(device_id, str) and device_id:
        dev = _device(view, device_id)
        dev_area = getattr(dev, "area_id", None) if dev is not None else None
        return dev_area if isinstance(dev_area, str) and dev_area else None
    return None


def _effective_labels_for_entry(view: _RegistryView, entry: Any) -> set[str]:
    """An entity's labels plus its device's labels (device labels apply to entities)."""
    labels = set(getattr(entry, "labels", None) or [])
    device_id = getattr(entry, "device_id", None)
    if isinstance(device_id, str) and device_id:
        dev = _device(view, device_id)
        if dev is not None:
            labels |= set(getattr(dev, "labels", None) or [])
    return labels


def _assist_should_expose(hass: HomeAssistant, entity_id: str) -> bool:
    """Whether ``entity_id`` is exposed to the ``conversation`` assistant — READ-ONLY.

    A read-only replication of core's ``async_should_expose`` for the conversation
    assistant. core's real function is NOT called because it has a WRITE side effect:
    for an entity with no explicit stored exposure it computes the default and
    persists it back (``entity_registry.async_update_entity_options`` for a registry
    entity, or the exposed-entities store for a legacy one — see core
    ``exposed_entities.py``). Consulting it once per candidate entity from a
    ``readOnlyHint`` search would stamp exposure onto the whole entity universe and
    pin those defaults, which violates this module's pure-read contract. So this
    reconstructs the SAME precedence (matching the server resolver's
    ``_is_assist_exposed``) with no write: an explicit per-entity ``should_expose``
    wins; otherwise the "expose new entities" flag gates the default-exposure check.

    Composed from three read-only, individually monkeypatchable seams. Fails OPEN
    (returns ``True`` — do not hide) on any error, matching the resolver's "skip the
    Assist dimension when its data is unavailable" behaviour.
    """
    try:
        explicit = _explicit_assist_exposure(hass, entity_id)
        if explicit is not None:
            return explicit
        if not _assist_expose_new_entities(hass):
            return False
        return _assist_default_exposed(hass, entity_id)
    except Exception:  # fail open (do not hide) on any error, mirroring the resolver
        return True


def _explicit_assist_exposure(hass: HomeAssistant, entity_id: str) -> bool | None:
    """The entity's explicit ``conversation`` ``should_expose`` (True/False), else None.

    Reads the SAME read-only surface :func:`_do_exposure`'s list mode mirrors — core's
    ``async_get_entity_settings`` (registry ``options`` for a registry entity, else
    the legacy exposed-entities store), which never writes. Returns ``None`` when there
    is no explicit override (an id in neither the registry nor the store, or no
    ``conversation.should_expose`` key), so the caller falls through to the
    expose-new default. A non-``Unknown entity`` raise propagates (fails open upstream).
    """
    try:
        settings = _async_get_entity_settings(hass, entity_id)
    except Exception as exc:
        if _is_unknown_entity_error(exc):
            return None
        raise
    conv = settings.get("conversation") if isinstance(settings, Mapping) else None
    if isinstance(conv, Mapping) and "should_expose" in conv:
        return bool(conv["should_expose"])
    return None


def _assist_expose_new_entities(hass: HomeAssistant) -> bool:
    """core's ``ExposedEntities.async_get_expose_new_entities("conversation")`` — read-only.

    Function-local import + a standalone seam so the fake-hass suite can monkeypatch
    it. A ``@callback`` that only reads the assistant preferences (no store write).
    """
    from homeassistant.components.homeassistant.exposed_entities import (
        DATA_EXPOSED_ENTITIES,
    )

    exposed = hass.data[DATA_EXPOSED_ENTITIES]
    return bool(exposed.async_get_expose_new_entities("conversation"))


def _assist_default_exposed(hass: HomeAssistant, entity_id: str) -> bool:
    """Read-only mirror of ``ExposedEntities._is_default_exposed`` for ``conversation``.

    core's ``async_should_expose`` calls the private ``_is_default_exposed`` and then
    WRITES the result back; this recomputes it WITHOUT the write. Imports core's own
    default-exposure constants (no drift — the running core defines them) and uses
    core's ``get_device_class``, so the domain / device-class verdict matches core
    exactly. entity_category / hidden_by entities are never a default exposure. A
    standalone seam so the fake-hass suite can monkeypatch it.
    """
    from homeassistant.components.homeassistant.exposed_entities import (
        DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES,
        DEFAULT_EXPOSED_DOMAINS,
        DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES,
    )
    from homeassistant.helpers import entity_registry as er

    entry = er.async_get(hass).async_get(entity_id)
    if entry is not None and (
        getattr(entry, "entity_category", None) is not None
        or getattr(entry, "hidden_by", None) is not None
    ):
        return False
    domain = entity_id.split(".", maxsplit=1)[0] if "." in entity_id else entity_id
    if domain in DEFAULT_EXPOSED_DOMAINS:
        return True
    from homeassistant.exceptions import HomeAssistantError
    from homeassistant.helpers.entity import get_device_class

    try:
        device_class = get_device_class(hass, entity_id)
    except HomeAssistantError:  # the entity no longer exists — matches core
        return False
    if domain == "binary_sensor":
        return device_class in DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES
    if domain == "sensor":
        return device_class in DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES
    return False


def _assist_exposure_available(hass: HomeAssistant) -> bool:
    """Whether core's Assist exposure machinery can be consulted for this request.

    The resolver emits ``_ASSIST_UNAVAILABLE_WARNING`` when its expose-list fetch
    fails wholesale; the component's analog is core's ``async_should_expose`` being
    unavailable — it reads ``hass.data[DATA_EXPOSED_ENTITIES]`` and raises when the
    exposed_entities store isn't set up (``_assist_should_expose`` then fails open
    per entity, hiding nothing but warning about nothing either). Probing the store
    once lets the caller skip the Assist dimension AND surface the resolver-parity
    degradation warning instead of degrading silently. Imported lazily and a test
    seam (monkeypatched alongside ``_assist_should_expose``).
    """
    try:
        from homeassistant.components.homeassistant.exposed_entities import (
            DATA_EXPOSED_ENTITIES,
        )
    except Exception:
        return False
    data = getattr(hass, "data", None)
    return isinstance(data, Mapping) and DATA_EXPOSED_ENTITIES in data


# =============================================================================
# ha_mcp_tools/server_entry
# =============================================================================
def _find_server_config_entry(hass: HomeAssistant) -> Any | None:
    """Return the component's OWN server config entry, or ``None`` if absent.

    Picks the single ``DOMAIN`` entry stamped with
    ``entry.data[CONF_ENTRY_TYPE] == ENTRY_TYPE_SERVER`` (the one ``entry.data``
    key the component reads — the documented data-minimization exception). Shared
    by the ``server_entry`` READ cap (:func:`_do_server_entry`) and the
    ``server_entry_update`` WRITE cap (:func:`_server_entry_update_prep`), so both
    discriminate the entry identically.
    """
    for entry in _iter_config_entries(hass):
        if getattr(entry, "domain", None) != DOMAIN:
            continue
        if _entry_marker_type(entry) != ENTRY_TYPE_SERVER:
            continue
        return entry
    return None


def _do_server_entry(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]:
    """Locate the component's OWN server config entry and return its identity.

    Discriminates the server-type entry via :func:`_find_server_config_entry`
    (the ``entry.data[CONF_ENTRY_TYPE] == ENTRY_TYPE_SERVER`` marker). ``channel`` /
    ``pip_spec`` come from ``entry.options`` (``None`` when absent); ``entry_id`` is
    ``None`` when no server entry exists.
    """
    entry = _find_server_config_entry(hass)
    if entry is None:
        return {"entry_id": None, "channel": None, "pip_spec": None}
    options = getattr(entry, "options", None)
    opts = options if isinstance(options, Mapping) else {}
    return {
        "entry_id": getattr(entry, "entry_id", None),
        "channel": opts.get(OPT_CHANNEL),
        "pip_spec": opts.get(OPT_PIP_SPEC),
    }


def _entry_marker_type(entry: Any) -> Any:
    """Read ONLY ``entry.data[CONF_ENTRY_TYPE]`` — the entry-type marker key."""
    data = getattr(entry, "data", None)
    if isinstance(data, Mapping):
        return data.get(CONF_ENTRY_TYPE)
    return None


# =============================================================================
# ha_mcp_tools/server_entry_update  (the server_entry WRITE counterpart — Phase 3)
# =============================================================================
def _do_server_entry_update(
    hass: HomeAssistant, params: dict[str, Any], *, result: dict[str, Any]
) -> dict[str, Any]:
    """Pure sync formatter for ``server_entry_update``.

    ALL of the work — locating the entry, merging the delta against the LIVE
    options, and (unless it is a no-op) scheduling the deferred
    ``async_update_entry`` — happens in the async :func:`_server_entry_update_prep`,
    which hands the finished envelope in as ``result``. This only returns it (the WS
    wrapper's ``send_result`` adds the outer success frame), so no scheduling /
    awaiting work ever runs in a ``_do_*`` step.
    """
    return result


async def _server_entry_update_prep(
    hass: HomeAssistant, msg: dict[str, Any]
) -> dict[str, Any]:
    """Apply a ``channel`` / ``pip_spec`` delta to the server entry, DEFERRED.

    Returns ``{"result": <envelope>}``. The order is load-bearing:

    1. Locate the server entry (:func:`_find_server_config_entry`); a missing entry
       raises ``HomeAssistantError`` so the server's command-error path falls back to
       its legacy options-flow submit.
    2. Require at least one of ``channel`` / ``pip_spec`` (the server always sends
       one — this is defence-in-depth).
    3. Snapshot the ``delta`` (the provided fields, keyed by the OPT_* option keys)
       and the CURRENT ``entry.options`` — used ONLY for the no-op check and the
       ``previous``/``applying`` response envelope. Every UNtouched key is preserved
       because the actual write MERGES the delta against the LIVE ``entry.options``
       at APPLY time (step 5), NOT against this snapshot — so a concurrent change to
       another key during the flush window is not clobbered, and none of the
       URL/secret overrides get blanked (which is why the server drops its
       preserved-key resend on this path).
    4. No-op short-circuit: if the delta applied to the snapshot equals the current
       options, return ``{scheduled: False, unchanged: True, ...}`` WITHOUT
       scheduling. (The update listener's own ``DATA_LAST_OPTIONS`` guard would also
       make such an update not reload, but skipping the schedule keeps it explicit.)
    5. Otherwise schedule the deferred ``_apply`` — which re-reads the LIVE
       ``entry.options`` and merges the delta against THAT before calling
       ``async_update_entry`` — on a HASS-owned background task after
       :data:`SERVER_ENTRY_UPDATE_FLUSH_DELAY_S`, and return
       ``{scheduled: True, ...}`` immediately.

    **The deferred-reload crux.** ``async_update_entry`` fires the server entry's
    update listener, which reloads the entry — tearing down the very in-process
    server thread answering THIS frame. Calling it inline would kill that thread
    before the WS ``{scheduled: True}`` response flushed, so the caller would never
    get its confirmation. It is therefore scheduled after a flush delay. The task is
    created with :func:`hass.async_create_background_task` (hass-owned), NOT
    ``entry.async_create_background_task``: an entry-owned task is cancelled by the
    unload the reload performs, so it could cancel itself before firing — the exact
    trap ``embedded_entry._on_version_update`` documents. Being hass-owned, the task
    survives to invoke ``async_update_entry`` (a synchronous ``@callback`` that
    returns as soon as it schedules the listener), then completes; the reload it
    triggers runs as its own hass task after the response has flushed.
    """
    import asyncio

    from homeassistant.exceptions import HomeAssistantError

    entry = _find_server_config_entry(hass)
    if entry is None:
        raise HomeAssistantError(
            "no ha_mcp_tools in-process server config entry to update"
        )

    has_channel = "channel" in msg
    has_pip_spec = "pip_spec" in msg
    if not has_channel and not has_pip_spec:
        raise HomeAssistantError(
            "server_entry_update needs at least one of channel / pip_spec"
        )

    options = getattr(entry, "options", None)
    current = dict(options) if isinstance(options, Mapping) else {}
    # ``delta`` is the applied write (merged against the LIVE options at APPLY time);
    # ``applying`` is its response-envelope view. The prep-time ``new_options`` is a
    # snapshot used ONLY for the no-op check below, never for the write.
    delta: dict[str, Any] = {}
    applying: dict[str, Any] = {}
    if has_channel:
        delta[OPT_CHANNEL] = msg["channel"]
        applying["channel"] = msg["channel"]
    if has_pip_spec:
        # Normalize like the options flow's ``_normalize`` (config_flow.py): a
        # whitespace-only value OR the default unpinned dist (``DEFAULT_PIP_SPEC``)
        # means "no override" — collapse it to "" so the channel keeps
        # auto-updating. Persisting it verbatim would read as an intentional
        # override and disable auto-updates. This keeps the no-op check honest: a
        # frame that normalizes to the stored value is unchanged, not a schedule.
        # 'clear' (case-insensitive) is the empty string's mangling-proof
        # alias (see ha_dev_manage_server): recognize it here too so raw WS
        # callers and older servers cannot persist the literal word as a pip
        # requirement that fails at install time.
        pip_spec = msg["pip_spec"]
        if str(pip_spec).strip() in ("", DEFAULT_PIP_SPEC) or (
            str(pip_spec).strip().lower() == "clear"
        ):
            pip_spec = ""
        delta[OPT_PIP_SPEC] = pip_spec
        applying["pip_spec"] = pip_spec
    new_options = {**current, **delta}

    entry_id = getattr(entry, "entry_id", None)
    previous = {
        "channel": current.get(OPT_CHANNEL),
        "pip_spec": current.get(OPT_PIP_SPEC),
    }

    if new_options == current:
        return {
            "result": {
                "scheduled": False,
                "unchanged": True,
                "entry_id": entry_id,
                "applying": applying,
                "previous": previous,
            }
        }

    async def _apply() -> None:
        # Deferred so the WS response flushes before the reload this triggers tears
        # down the serving thread. See the docstring for why it is hass-owned. The
        # delta is merged against the LIVE ``entry.options`` HERE (not at prep) so a
        # concurrent change to another key during the flush window is preserved.
        await asyncio.sleep(SERVER_ENTRY_UPDATE_FLUSH_DELAY_S)
        try:
            live = getattr(entry, "options", None)
            merged = {**(dict(live) if isinstance(live, Mapping) else {}), **delta}
            applied = hass.config_entries.async_update_entry(entry, options=merged)
            if applied is False:
                # ``async_update_entry`` returns False when the merged options already
                # match (e.g. a concurrent write applied the same delta first). No
                # caller is left to answer, so surface the no-apply in the log.
                _LOGGER.warning(
                    "server_entry_update: no change applied to entry %s (options "
                    "already current)",
                    entry_id,
                )
            else:
                _LOGGER.info(
                    "server_entry_update applied %s to entry %s", applying, entry_id
                )
        except Exception:  # pragma: no cover - defensive; no caller left to raise to
            _LOGGER.exception("server_entry_update deferred apply failed")

    hass.async_create_background_task(
        _apply(), name=f"{WS_API_PREFIX} server_entry_update"
    )
    return {
        "result": {
            "scheduled": True,
            "entry_id": entry_id,
            "applying": applying,
            "previous": previous,
        }
    }


# =============================================================================
# ha_mcp_tools/call_service  (the first WRITE capability — Phase 3, issue #1813)
# =============================================================================
def _do_call_service(
    hass: HomeAssistant, params: dict[str, Any], *, result: dict[str, Any]
) -> dict[str, Any]:
    """Pure sync formatter for ``call_service``.

    ALL of the work — the authoritative domain block, the ``ServiceNotFound``
    check, the pre-state capture, the expected-aware register-before-fire listener,
    the single ``async_call`` dispatch, the immediate-match, and the bounded
    confirmation wait — happens in the async :func:`_call_service_prep`, which hands
    the finished result dict in as ``result``. This function only returns that
    envelope (mirroring every other ``_do_*``: the WS wrapper's ``send_result`` adds
    the outer success frame), so no awaiting / blocking work ever runs in a ``_do_*``
    step (D2).
    """
    return result


async def _call_service_prep(
    hass: HomeAssistant, msg: dict[str, Any]
) -> dict[str, Any]:
    """Do all of ``call_service``'s async work; return ``{"result": <envelope>}``.

    The order is load-bearing:

    1. **D1 — authoritative domain block (security-critical).** Refuse
       ``domain == "ha_mcp_tools"`` (case/whitespace-normalized) BEFORE any
       ``has_service`` / dispatch. This is enforced HERE, in the component that
       fires the call, independent of (and in addition to) the server-side guard:
       a component ``call_service`` that skipped it would let a caller invoke the
       admin-gated ``ha_mcp_tools.get_caller_token`` in-process (the server IS
       admin) and then every file/YAML service. The block keys off the RESOLVED
       domain, so it holds no matter which path reaches this function.
    2. **ServiceNotFound** before dispatch, so an unknown service is a clean
       ``SERVICE_NOT_FOUND`` and never a landed-but-unreported write.
    3. Pre-state capture for each ``entity_id`` (a synchronous in-memory read).
    4. Register the expected-aware ``EVENT_STATE_CHANGED`` waiter BEFORE the dispatch
       (D5) so a fast entity's event can't arrive before the listener exists. The
       waiter confirms only on reaching the server's ``expected_state`` hint (skipping
       intermediate/noise events); a ``None`` hint keeps any-first-event confirmation.
    5. Fire exactly ONE ``async_call`` (``blocking=True``); flip ``dispatched``
       immediately after so a post-dispatch problem is never retried as a failed
       call (D3/D9).
    6. Immediate-match (:func:`_match_immediate`) for an idempotent no-op — a target
       whose CURRENT state already equals its hint confirms with NO wait — then a
       bounded wait for whatever is still unconfirmed (D4); expiry is ``partial``.
    7. Diff pre→post into the real transition(s) — the REAL observed transition, never
       the hint value.

    Raised exceptions PROPAGATE — the WS handler turns them into a command error the
    server maps (D7). Two distinct classes propagate: the D1 domain block and
    ``ServiceNotFound`` are PRE-dispatch (they raise before ``async_call``, so nothing
    landed); an ``async_call`` / ``return_response`` validation error is MID-dispatch
    (the handler may mutate state and THEN raise — the documented D9 at-most-once
    residual, NOT pre-dispatch). A confirmation timeout is caught and reported as
    ``partial`` (never re-raised): the call already landed.

    The whole POST-dispatch section — the immediate-match re-read, the wait, and the
    pre→post diff — runs inside ONE ``try`` that, once ``dispatched`` is True, never
    lets a raise escape (I1): a raise mapped to a command error would re-POST an
    already-landed write. A pre-confirmation ``async_call`` failure (``dispatched``
    still False) re-raises so the server can map it; any post-dispatch failure degrades
    to a minimal dispatched-but-unconfirmed envelope. The immediate-match re-read is
    itself raise-proof (:func:`_match_immediate`), the attribute diff is raise-proofed
    (:func:`_values_differ`), and ``unsub`` always runs in the ``finally``.
    Serialization residual (bounded, no sanitize pass): the transition embeds each
    state's ``as_dict()`` and the WS transport re-encodes it; HA core enforces
    JSON-serializable state attributes for its own REST/WS/recorder APIs, so a state
    that reached the component already serializes — re-encoding it here is safe.
    """
    domain = msg["domain"]
    service = msg["service"]

    # 1./2. Pre-dispatch guards (D1 domain block + ServiceNotFound) — both raise
    # BEFORE any listener registration or dispatch, so a refused call is never a
    # landed-but-unreported write and ``async_call`` is provably never reached.
    _guard_call_service_target(hass, domain, service)

    service_data = msg.get("service_data") or {}
    entity_ids = list(msg.get("entity_ids") or [])
    wait = msg.get("wait", True)
    timeout = msg.get("timeout", CALL_SERVICE_DEFAULT_TIMEOUT)
    return_response = msg.get("return_response", False)
    should_confirm = bool(wait and entity_ids)
    # The server's confirmation HINT (``_SERVICE_TO_STATE.get(service)``), applied to
    # every confirmation target. Absent / None keeps any-first-event confirmation.
    expected_state = msg.get("expected_state")
    expected_by_entity = dict.fromkeys(entity_ids, expected_state)

    # 3. Pre-state capture (synchronous in-memory reads, guarded against drift).
    pre = {eid: _state_as_dict(_state_get(hass, eid)) for eid in entity_ids}

    # 4. Register-before-fire (D5): only when there is something to confirm.
    evt: Any = None
    captured: dict[str, Any] = {}
    unsub: Any = None
    if should_confirm:
        evt, captured, unsub = _register_transition_waiter(
            hass, set(entity_ids), expected_by_entity
        )

    # 5. Dispatch exactly once. 6. Immediate-match + bounded wait. 7. Build the diff.
    # Everything after ``dispatched = True`` is inside this ONE try so no post-dispatch
    # raise (a drifted re-read, an exotic-attribute diff) escapes (I1) — that would be
    # mapped to legacy and re-POST an already-landed write. ``unsub`` always runs.
    response: Any = None
    dispatched = False
    result: dict[str, Any]
    try:
        response = await hass.services.async_call(
            domain,
            service,
            dict(service_data),
            blocking=True,
            return_response=return_response,
        )
        dispatched = True
        if should_confirm:
            await _await_confirmation(
                hass, entity_ids, expected_by_entity, captured, evt, timeout
            )
        result = _build_call_service_result(
            hass,
            domain,
            service,
            entity_ids,
            pre,
            captured,
            should_confirm=should_confirm,
            dispatched=dispatched,
            return_response=return_response,
            response=response,
        )
    except Exception:
        # PRE-confirmation ``async_call`` failure (never dispatched) → re-raise so the
        # server maps it (D7/D9 MID-dispatch residual). Any POST-dispatch failure →
        # degrade to a minimal dispatched-but-unconfirmed envelope (never re-POSTed).
        if not dispatched:
            raise
        _LOGGER.exception(
            "call_service post-dispatch step failed after dispatch; returning "
            "dispatched-but-unconfirmed envelope (%s.%s)",
            domain,
            service,
        )
        result = _dispatched_unconfirmed_result(domain, service)
    finally:
        if unsub is not None:
            unsub()
    return {"result": result}


def _guard_call_service_target(hass: HomeAssistant, domain: str, service: str) -> None:
    """Pre-dispatch refusals for ``call_service`` — raise BEFORE any dispatch.

    * **D1 (security-critical)** — refuse ``domain == "ha_mcp_tools"``
      (case/whitespace-normalized). Enforced HERE, in the component that fires the
      call, independent of (and in addition to) the server-side guard: a component
      ``call_service`` that skipped it would let a caller invoke the admin-gated
      ``ha_mcp_tools.get_caller_token`` in-process (the server IS admin) and then
      every file/YAML service. The block keys off the RESOLVED domain, so it holds
      no matter which path reaches this function.
    * **ServiceNotFound** — an unknown service is a clean ``SERVICE_NOT_FOUND``, not
      a phantom write. Both raises propagate to the WS handler (D7).
    """
    if str(domain).strip().lower() == DOMAIN:
        from homeassistant.exceptions import HomeAssistantError

        raise HomeAssistantError(
            "the ha_mcp_tools domain is not callable through call_service; "
            "use the dedicated ha_* tools"
        )
    if not hass.services.has_service(domain, service):
        from homeassistant.exceptions import ServiceNotFound

        raise ServiceNotFound(domain, service)


def _event_state_value(state: Any) -> Any:
    """The primary state string from a ``State`` object OR an ``as_dict()`` mapping.

    Raise-proof: an exotic/stub shape (or a ``.state`` accessor that raises) degrades
    to ``None`` so the expected-aware waiter and the post-dispatch immediate-match can
    never propagate past the dispatch (I1). ``None`` never equals a (str) expected
    hint, so an unreadable state simply does not confirm — it keeps waiting.
    """
    try:
        if isinstance(state, Mapping):
            return state.get("state")
        return getattr(state, "state", None)
    except Exception:  # pragma: no cover - defensive; exotic/stub shapes
        return None


def _register_transition_waiter(
    hass: HomeAssistant, target_set: set[str], expected_by_entity: Mapping[str, Any]
) -> tuple[Any, dict[str, Any], Any]:
    """Register the ``EVENT_STATE_CHANGED`` listener BEFORE the dispatch (D5).

    Returns ``(evt, captured, unsub)``: ``evt`` is set once every id in
    ``target_set`` has reported a CONFIRMING ``new_state``; ``captured`` maps each id
    to its raw new_state; ``unsub`` tears the listener down. Registering before the
    dispatch closes the race where a fast entity's event arrives before the
    listener exists.

    ``expected_by_entity`` supplies each target's server-computed expected-state HINT
    (``_SERVICE_TO_STATE``). With a hint, ONLY the event that reaches that state
    confirms — a multi-phase service's intermediate states (``lock``:
    unlocked→locking→locked) and attribute-only noise (a ``media_player`` position
    tick while ``state`` stays "playing") are skipped, NOT captured. With no hint
    (``None``, e.g. ``set_temperature``) any first ``new_state`` confirms — today's
    unchanged behavior.
    """
    import asyncio

    from homeassistant.const import EVENT_STATE_CHANGED

    evt = asyncio.Event()
    captured: dict[str, Any] = {}

    def _on_change(event: Any) -> None:
        data = getattr(event, "data", None) or {}
        eid = data.get("entity_id")
        new = data.get("new_state")
        # M-newstate-none: a state_changed with new_state=None means the entity was
        # REMOVED mid-wait — that is not a confirmed transition, so do NOT capture it
        # (leaving the target uncaptured keeps the op ``partial``, not falsely
        # confirmed). ``_post_state`` still re-reads a best-available current state.
        if eid in target_set and new is not None:
            exp = expected_by_entity.get(eid)
            # Hint present → confirm ONLY on reaching the expected state (skip
            # intermediate/noise events). Hint None → any first event confirms.
            if exp is None or _event_state_value(new) == exp:
                captured[eid] = new
                if target_set <= set(captured):
                    evt.set()

    # Mark the listener a HA callback so ``EventBus.async_listen`` classifies its
    # ``HassJob`` as ``HassJobType.Callback`` and runs it INLINE on the event loop
    # (``is_callback`` reads exactly ``getattr(func, "_hass_callback", False)``).
    # Without this a plain function is ``HassJobType.Executor``: every instance-wide
    # ``state_changed`` gets thrown at the thread pool, and ``evt.set()`` then runs
    # cross-thread on a non-thread-safe ``asyncio.Event`` → delayed/spurious
    # ``partial`` confirmations and an ``InvalidStateError`` race with the
    # timeout-cancel. We set the attribute directly rather than using ``@callback``:
    # the unit-test harness MagicMock-stubs ``homeassistant.core``, so the decorator
    # would be a MagicMock and break the listener, whereas the plain attribute set is
    # exactly what ``callback`` does (``func.__dict__["_hass_callback"] = True``) and
    # is inert under the stub.
    _on_change._hass_callback = True  # type: ignore[attr-defined]

    unsub = hass.bus.async_listen(EVENT_STATE_CHANGED, _on_change)
    return evt, captured, unsub


def _match_immediate(
    hass: HomeAssistant,
    entity_ids: list[str],
    expected_by_entity: Mapping[str, Any],
    captured: dict[str, Any],
) -> None:
    """Capture a target whose CURRENT state already equals its expected hint.

    Mirrors legacy's "sample current state first": for each not-yet-captured target
    with a known expected state, re-read the live state right after ``async_call``
    returns and, if it already equals the expected value, capture it as confirmation
    so NO wait is needed — a ``turn_on`` on an already-on light confirms instantly
    (pre == expected == "on") instead of timing out to a false ``partial``. No-hint
    targets (``exp is None``) are left for the any-first-event waiter — unchanged.

    Called AFTER the dispatch, so it MUST be raise-proof (I1): a re-read that raised
    and reached the WS handler would be mapped to legacy and re-POST an already-landed
    write. ``_state_get`` is guarded (``None`` on drift) and ``_event_state_value`` is
    raise-proof, so this never propagates past dispatch. ``captured`` is mutated in
    place with the raw ``State`` (``_post_state``/``_state_as_dict`` normalize it, the
    same shape the waiter captures).
    """
    for eid in entity_ids:
        exp = expected_by_entity.get(eid)
        if exp is None or eid in captured:
            continue
        cur = _state_get(hass, eid)
        if cur is not None and _event_state_value(cur) == exp:
            captured[eid] = cur


async def _await_confirmation(
    hass: HomeAssistant,
    entity_ids: list[str],
    expected_by_entity: Mapping[str, Any],
    captured: dict[str, Any],
    evt: Any,
    timeout: float,
) -> None:
    """Immediate-match the idempotent no-ops, then bounded-wait whatever remains (D4).

    Runs AFTER the single ``async_call``: :func:`_match_immediate` captures a target
    whose current state already equals its hint (a ``turn_on`` on an already-on light)
    with NO wait; the bounded wait runs only if some target is still unconfirmed and
    its expiry is swallowed (``partial``, never a failure). Kept as its own helper so
    :func:`_call_service_prep` stays under the complexity gate; raise-proof re-read
    (``_match_immediate``), so nothing here escapes past the dispatch (I1).
    """
    import asyncio

    _match_immediate(hass, entity_ids, expected_by_entity, captured)
    if set(entity_ids) <= set(captured):
        return  # every target confirmed by the immediate-match — no wait needed
    try:
        await asyncio.wait_for(evt.wait(), timeout)
    except TimeoutError:
        pass  # partial confirmation, not a failure (D4)


def _build_call_service_result(
    hass: HomeAssistant,
    domain: str,
    service: str,
    entity_ids: list[str],
    pre: Mapping[str, Any],
    captured: Mapping[str, Any],
    *,
    should_confirm: bool,
    dispatched: bool,
    return_response: bool,
    response: Any,
) -> dict[str, Any]:
    """Assemble the ``call_service`` response envelope from the captured transition.

    ``confirmed`` is True only when every target reported within the wait;
    ``partial`` is a confirmation that lapsed (never a failure). ``dispatched`` is
    only ever ``True`` here (a pre-dispatch problem raised out of the prep);
    reporting the flag rather than a literal keeps the D9 at-most-once boundary —
    "reached this shape ⇒ the single async_call fired" — explicit for the server,
    which never retries a dispatched write. ``service_response`` is present only
    when it was both requested AND non-``None``.
    """
    transitions = [
        _call_service_transition(eid, pre.get(eid), _post_state(hass, eid, captured))
        for eid in entity_ids
    ]
    confirmed = bool(should_confirm and set(entity_ids) <= set(captured))
    result: dict[str, Any] = {
        "domain": domain,
        "service": service,
        "dispatched": dispatched,
        "confirmed": confirmed,
        "partial": bool(should_confirm and not confirmed),
        "transitions": transitions,
    }
    if return_response and response is not None:
        result["service_response"] = response
    return result


def _dispatched_unconfirmed_result(domain: str, service: str) -> dict[str, Any]:
    """Minimal ``call_service`` envelope when post-dispatch formatting raised (I1).

    The single ``async_call`` already fired; building the rich transition raised (an
    exotic captured state / serialization edge). Report dispatched-but-unconfirmed with
    no transitions rather than propagating — a propagated raise would become a command
    error the server maps to legacy and re-POST an already-landed write (double-apply).
    Reads only the plain domain/service strings, so it cannot itself raise.
    """
    return {
        "domain": domain,
        "service": service,
        "dispatched": True,
        "confirmed": False,
        "partial": True,
        "transitions": [],
    }


def _post_state(
    hass: HomeAssistant, entity_id: str, captured: Mapping[str, Any]
) -> Any:
    """The post-dispatch state for ``entity_id`` as a plain dict (or ``None``).

    Prefers the listener-captured ``new_state`` (the event that confirmed the
    transition, normalized through the shared :func:`_state_as_dict`); falls back
    to a fresh guarded ``hass.states`` re-read when the target did not report within
    the wait (the ``partial`` case), so a transition row is still populated with the
    best-available current state. Both ``None`` (a vanished/stateless entity) and
    core drift degrade to ``None`` rather than raising.
    """
    if entity_id in captured:
        as_dict = _state_as_dict(captured[entity_id])
        if as_dict is not None:
            return as_dict
    return _state_as_dict(_state_get(hass, entity_id))


def _values_differ(a: Any, b: Any) -> bool:
    """Whether two attribute values differ, raise-proof for array-like values.

    A plain ``a != b`` raises "truth value ... is ambiguous" for numpy arrays and
    other exotic ``__ne__`` results that aren't a bool. Post-dispatch formatting MUST
    NOT raise (a raise here would surface as a command error the server maps to legacy
    → a re-POST of an already-landed write), so fall back to a ``repr`` compare when
    the direct compare's truthiness is not a plain bool.
    """
    try:
        return bool(a != b)
    except Exception:  # array-like / exotic __ne__ whose result isn't a plain bool
        return repr(a) != repr(b)


def _call_service_transition(
    entity_id: str,
    old_state: dict[str, Any] | None,
    new_state: dict[str, Any] | None,
) -> dict[str, Any]:
    """The real pre→post transition for one target entity.

    ``changed`` compares the top-level ``state`` (always a plain string — safe);
    ``attributes_changed`` lists the attribute keys whose values differ (added/removed
    keys included), compared through :func:`_values_differ` so an array-like attribute
    value cannot raise. Both sides may be ``None`` (a stateless or vanished entity),
    which the ``or {}`` guards fold into an all-``None`` comparison rather than raising.
    """
    old = old_state or {}
    new = new_state or {}
    old_attrs = old.get("attributes") or {}
    new_attrs = new.get("attributes") or {}
    attributes_changed = sorted(
        key
        for key in set(old_attrs) | set(new_attrs)
        if _values_differ(old_attrs.get(key), new_attrs.get(key))
    )
    return {
        "entity_id": entity_id,
        "old_state": old_state,
        "new_state": new_state,
        "changed": old.get("state") != new.get("state"),
        "attributes_changed": attributes_changed,
    }


# =============================================================================
# ha_mcp_tools/bulk_call_service  (the BATCH write capability — Phase 3, D5a)
# =============================================================================
def _do_bulk_call_service(
    hass: HomeAssistant, params: dict[str, Any], *, result: dict[str, Any]
) -> dict[str, Any]:
    """Pure sync formatter for ``bulk_call_service``.

    Like :func:`_do_call_service`, ALL of the work — the per-op D1 domain block,
    the ``ServiceNotFound`` checks, the expected-aware register-before-fire pass, the
    dispatches, the per-op immediate-match, and the one bounded batch wait — happens
    in the async :func:`_bulk_call_service_prep`, which hands the finished envelope in
    as ``result``. This function only returns it, so no awaiting / blocking work runs
    in a ``_do_*`` step (D2).
    """
    return result


async def _bulk_call_service_prep(
    hass: HomeAssistant, msg: dict[str, Any]
) -> dict[str, Any]:
    """Do all of ``bulk_call_service``'s async work; return ``{"result": ...}``.

    Register-before-fire is trivially correct for the batch: every listener is
    registered in one synchronous pass BEFORE any ``async_call`` is issued, so no
    op's confirming event can arrive before its listener exists. The order is
    load-bearing:

    1. **D1 batch fail-closed (security-critical).** Run
       :func:`_guard_call_service_target` for EVERY operation FIRST — before any
       pre-state read, listener, or dispatch. A single op targeting the
       ``ha_mcp_tools`` domain (or an unknown service) makes the WHOLE frame raise:
       no partial batch is dispatched, so no ``ha_mcp_tools.*`` op can ever slip
       through in a batch (register-before-fire + all-guards-first means a refused
       op aborts before any real write lands).
    2. Pre-state capture for every op's ``entity_ids`` (synchronous in-memory).
    3. Register ALL expected-aware confirmation listeners in one pass BEFORE any
       dispatch (each op's ``expected_state`` hint governs its waiter); every
       ``unsub`` is torn down in the ``finally``.
    4. Dispatch: ``parallel`` fans the ``async_call``s out through
       :func:`asyncio.gather` with ``return_exceptions=True`` so one op's failure
       does not abort the others (its exception is recorded on that op, NOT raised —
       UNLIKE the step-1 guards, which DO raise the whole frame pre-dispatch);
       ``parallel=False`` awaits them in order. Each op flips its own ``dispatched``
       flag the moment its ``async_call`` returns.
    5. Immediate-match per dispatched op (:func:`_bulk_match_immediate`) — an
       idempotent no-op whose current state already equals its hint confirms with no
       wait — then ONE shared bounded deadline (D4, not per-op serial timeouts) for
       every op still unconfirmed.
    6. Per-op pre→post diff, reusing the single-call transition/build helpers.
    7. Return every op's result plus batch counts.
    """
    operations = list(msg.get("operations") or [])
    parallel = bool(msg.get("parallel", True))
    wait = bool(msg.get("wait", True))
    timeout = msg.get("timeout", CALL_SERVICE_DEFAULT_TIMEOUT)

    # 1. D1 batch fail-closed: guard EVERY op before ANY pre-state / listener /
    #    dispatch. A refused op (``ha_mcp_tools`` domain or unknown service) raises
    #    the whole frame here, so nothing in the batch is ever dispatched partially.
    for op in operations:
        _guard_call_service_target(hass, op["domain"], op["service"])

    # 2. Normalize + pre-state capture (synchronous in-memory reads) per op.
    ops = [_bulk_op_record(hass, op, wait=wait) for op in operations]

    # 3. Register-before-fire (D5): every confirmable op's listener is registered in
    #    one synchronous pass BEFORE any dispatch; ALL unsubs torn down in finally.
    unsubs = _bulk_register_all(hass, ops)
    try:
        await _bulk_dispatch_all(hass, ops, parallel=parallel)  # 4
        _bulk_match_immediate(hass, ops)  # 5a immediate-match idempotent no-ops
        await _bulk_wait_all(ops, timeout)  # 5b (one shared deadline; expiry=partial)
    finally:
        for unsub in unsubs:
            unsub()

    # 6./7. Per-op pre→post diff + batch counts. Post-dispatch assembly MUST be total
    # (I1): the ops already fired, so a raise here would be mapped to legacy and
    # re-dispatch every landed op (double-fire). On any failure return a minimal
    # envelope reporting each op dispatched-but-unconfirmed (its real dispatched/error
    # preserved) instead of propagating.
    try:
        result = _build_bulk_result(hass, ops)
    except Exception:
        _LOGGER.exception(
            "bulk_call_service post-dispatch assembly failed after dispatch; "
            "returning dispatched-but-unconfirmed batch envelope"
        )
        result = _dispatched_unconfirmed_bulk_result(ops)
    return {"result": result}


def _bulk_op_record(
    hass: HomeAssistant, op: Mapping[str, Any], *, wait: bool
) -> dict[str, Any]:
    """A mutable working record for one batch operation (incl. its pre-state).

    Reads the resolved ``{domain, service, service_data?, entity_ids?,
    expected_state?}`` row defensively (the direct-prep tests pass raw dicts that
    never went through the schema, so the mutable defaults are re-applied here).
    ``pre`` is the synchronous in-memory pre-state per target; ``expected_by_entity``
    maps every target to this op's confirmation hint (``_SERVICE_TO_STATE``) so the
    waiter + immediate-match key off it; ``dispatched`` / ``error`` / ``response``
    start empty and are filled during dispatch; ``should_confirm`` is true only when
    the batch is waiting AND this op names targets to confirm.
    """
    entity_ids = list(op.get("entity_ids") or [])
    expected_state = op.get("expected_state")
    return {
        "domain": op["domain"],
        "service": op["service"],
        "service_data": op.get("service_data") or {},
        "entity_ids": entity_ids,
        "expected_by_entity": dict.fromkeys(entity_ids, expected_state),
        "should_confirm": bool(wait and entity_ids),
        "pre": {eid: _state_as_dict(_state_get(hass, eid)) for eid in entity_ids},
        "evt": None,
        "captured": {},
        "dispatched": False,
        "response": None,
        "error": None,
    }


async def _bulk_dispatch_one(hass: HomeAssistant, op: dict[str, Any]) -> None:
    """Fire exactly one op's ``async_call`` and flip its ``dispatched`` flag.

    Mirrors the single-call dispatch (``blocking=True``), but bulk never requests a
    per-op ``return_response`` (D5a keeps the batch simple — the single
    ``call_service`` covers response-returning calls). ``dispatched`` is set only
    AFTER ``async_call`` returns, so a raise (captured per-op by the caller) leaves
    it ``False`` and the op is never counted as a landed write.
    """
    op["response"] = await hass.services.async_call(
        op["domain"],
        op["service"],
        dict(op["service_data"]),
        blocking=True,
        return_response=False,
    )
    op["dispatched"] = True


def _bulk_register_all(hass: HomeAssistant, ops: list[dict[str, Any]]) -> list[Any]:
    """Register every confirmable op's transition listener in one pass (D5).

    One synchronous sweep BEFORE any dispatch, so no op's confirming event can
    arrive before its listener exists. Each confirmable op is handed its own ``evt``
    / ``captured`` (mutating the op record); the returned ``unsub`` list is torn down
    in the prep's ``finally``. Non-confirmable ops register nothing.

    If ``async_listen`` raises mid-sweep (near-impossible in practice), every listener
    already registered in this pass is unsubbed before re-raising — the prep's
    ``try/finally`` has not been entered yet, so those would otherwise leak on the bus.
    """
    unsubs: list[Any] = []
    try:
        for op in ops:
            if op["should_confirm"]:
                evt, captured, unsub = _register_transition_waiter(
                    hass, set(op["entity_ids"]), op["expected_by_entity"]
                )
                op["evt"] = evt
                op["captured"] = captured
                unsubs.append(unsub)
    except Exception:
        for unsub in unsubs:
            unsub()
        raise
    return unsubs


async def _bulk_dispatch_all(
    hass: HomeAssistant, ops: list[dict[str, Any]], *, parallel: bool
) -> None:
    """Fire every op's dispatch, recording a per-op failure without aborting the batch.

    ``parallel`` fans the dispatches out through :func:`asyncio.gather` with
    ``return_exceptions=True`` so one op's ``async_call`` raising is captured on THAT
    op (``error`` set, ``dispatched`` left ``False``) and the others still run;
    ``parallel=False`` awaits them in order, catching each op's failure the same way.
    Neither mode propagates a per-op dispatch error — that is the whole point of the
    batch (UNLIKE the pre-dispatch D1/ServiceNotFound guards, which DO raise).
    """
    import asyncio

    if parallel:
        outcomes = await asyncio.gather(
            *(_bulk_dispatch_one(hass, op) for op in ops),
            return_exceptions=True,
        )
        for op, outcome in zip(ops, outcomes, strict=True):
            if isinstance(outcome, BaseException):
                op["error"] = _bulk_op_error(outcome)
    else:
        for op in ops:
            try:
                await _bulk_dispatch_one(hass, op)
            except Exception as err:
                op["error"] = _bulk_op_error(err)


def _bulk_match_immediate(hass: HomeAssistant, ops: list[dict[str, Any]]) -> None:
    """Immediate-match every dispatched, confirmable op (idempotent no-ops).

    Runs the SAME raise-proof :func:`_match_immediate` per op AFTER the batch
    dispatch and BEFORE the shared wait: an op whose target already sits at its
    expected hint (a ``turn_on`` on an already-on light) is captured here so
    :func:`_bulk_wait_all` skips it — no full-timeout stall for a batch of no-ops.
    Raise-proof (``_match_immediate`` guards each re-read), so it cannot propagate
    past the batch dispatch (I1).
    """
    for op in ops:
        if op["should_confirm"] and op["dispatched"]:
            _match_immediate(
                hass, op["entity_ids"], op["expected_by_entity"], op["captured"]
            )


async def _bulk_wait_all(ops: list[dict[str, Any]], timeout: float) -> None:
    """Bounded confirmation wait for the batch: ONE shared deadline (D4).

    Waits up to ``timeout`` for every dispatched, confirmable op's transition on a
    single shared deadline (not per-op serial timeouts). An op already FULLY captured
    by the immediate-match (:func:`_bulk_match_immediate`) is skipped — its ``evt`` was
    never ``set`` (the match populates ``captured`` directly), so waiting on it would
    stall the whole batch to the timeout. Expiry is swallowed — whichever ops did not
    report are ``partial`` (never a failure); the ops that did report stay confirmed.
    A batch with nothing left to confirm returns immediately.
    """
    import asyncio

    waiters = [
        op["evt"].wait()
        for op in ops
        if op["should_confirm"]
        and op["dispatched"]
        and not (set(op["entity_ids"]) <= set(op["captured"]))
    ]
    if not waiters:
        return
    try:
        await asyncio.wait_for(asyncio.gather(*waiters), timeout)
    except TimeoutError:
        pass  # partial confirmation for whichever ops did not report (D4)


def _bulk_op_error(exc: BaseException) -> str:
    """A short, stable error string for a per-op dispatch failure.

    A per-op ``async_call`` exception under the batch is recorded here (never
    propagated past the frame — the other ops still return their results), so the
    server can surface which op failed and why without the whole batch aborting.
    """
    text = str(exc).strip()
    return f"{type(exc).__name__}: {text}" if text else type(exc).__name__


def _build_bulk_op_result(hass: HomeAssistant, op: Mapping[str, Any]) -> dict[str, Any]:
    """Assemble one op's result envelope from its captured transition.

    Reuses the single-call :func:`_call_service_transition` / :func:`_post_state`
    diff helpers so the per-op transition shape is byte-identical to
    ``call_service``. ``confirmed`` requires the op to have DISPATCHED and every
    target to have reported within the shared wait; ``partial`` is a dispatched-but
    -unconfirmed op (never a failure); an op whose ``async_call`` raised carries
    ``error`` with ``dispatched: false`` and is neither confirmed nor partial.
    """
    entity_ids = list(op["entity_ids"])
    captured = op["captured"]
    should_confirm = op["should_confirm"]
    dispatched = op["dispatched"]
    transitions = [
        _call_service_transition(
            eid, op["pre"].get(eid), _post_state(hass, eid, captured)
        )
        for eid in entity_ids
    ]
    confirmed = bool(should_confirm and dispatched and set(entity_ids) <= set(captured))
    result: dict[str, Any] = {
        "domain": op["domain"],
        "service": op["service"],
        "entity_ids": entity_ids,
        "dispatched": dispatched,
        "confirmed": confirmed,
        "partial": bool(should_confirm and dispatched and not confirmed),
        "transitions": transitions,
    }
    if op["error"] is not None:
        result["error"] = op["error"]
    return result


def _build_bulk_result(
    hass: HomeAssistant, ops: list[dict[str, Any]]
) -> dict[str, Any]:
    """The batch envelope: every op's result plus the batch counts.

    ``dispatched`` counts ops whose single ``async_call`` fired; ``failed`` counts
    ops that recorded a per-op ``error`` (dispatch raised). ``total`` is the batch
    size, so ``total - dispatched`` is the refused/failed-before-landing count.
    """
    op_results = [_build_bulk_op_result(hass, op) for op in ops]
    return {
        "operations": op_results,
        "total": len(op_results),
        "dispatched": sum(1 for r in op_results if r["dispatched"]),
        "failed": sum(1 for r in op_results if r.get("error") is not None),
    }


def _dispatched_unconfirmed_bulk_result(
    ops: list[dict[str, Any]],
) -> dict[str, Any]:
    """Minimal batch envelope when post-dispatch assembly raised (I1 total-formatting).

    Every op already fired (or recorded a pre-landing ``error``); building the rich
    transition rows raised (an exotic attribute value / serialization edge). Report
    each op with empty transitions rather than propagating — a propagated raise would
    become a command error the server maps to legacy and re-dispatch every landed op
    (double-fire). Reads only each record's plain ``domain``/``service``/``entity_ids``
    /``dispatched``/``should_confirm``/``error`` fields (never the rich captured
    state), so it cannot itself raise; the real per-op ``dispatched``/``error`` are
    preserved so a genuinely-failed op is not misreported as landed.
    """
    op_results = [
        {
            "domain": op["domain"],
            "service": op["service"],
            "entity_ids": list(op["entity_ids"]),
            "dispatched": op["dispatched"],
            "confirmed": False,
            "partial": bool(op["should_confirm"] and op["dispatched"]),
            "transitions": [],
            **({"error": op["error"]} if op["error"] is not None else {}),
        }
        for op in ops
    ]
    return {
        "operations": op_results,
        "total": len(op_results),
        "dispatched": sum(1 for r in op_results if r["dispatched"]),
        "failed": sum(1 for r in op_results if r.get("error") is not None),
    }
