"""
Configuration management tools for Home Assistant Lovelace dashboards.

This module provides tools for managing dashboard metadata and content.
"""

import json
import logging
import re
from typing import Annotated, Any, cast, overload

from fastmcp.exceptions import ToolError
from fastmcp.tools.tool import ToolResult
from pydantic import Field

from ..dashboard_screenshot.capture import FULL_PAGE_PARAM_DESC
from ..errors import ErrorCode, create_error_response
from ..utils.config_hash import compute_config_hash
from ..utils.python_sandbox import (
    PythonSandboxError,
    PythonSandboxExecutionError,
    format_sandbox_error,
    get_security_documentation,
    safe_execute,
)
from .auto_backup import with_auto_backup
from .helpers import (
    exception_to_structured_error,
    extract_tool_error_message,
    log_tool_usage,
    raise_tool_error,
    validate_identifier_not_empty,
)
from .util_helpers import (
    JSON_STRING_COERCION,
    attach_skill_content,
    augment_error_dict_with_skill_content,
    augment_tool_error_with_skill_content,
    parse_json_param,
)

logger = logging.getLogger(__name__)


# dashboard-guide.md + dashboard-cards.md cover layout patterns and the
# card-type taxonomy — both relevant on every dashboard write.
_DASHBOARD_SKILL_FILES: tuple[str, ...] = (
    "references/dashboard-guide.md",
    "references/dashboard-cards.md",
)


def _attach_dashboard_skill(response: dict[str, Any], MandatoryBPS: bool) -> None:
    """In-place attach skill_content to a dashboard response when applicable.

    Delegates to the shared :func:`attach_skill_content` so the
    missing-vendor-warning path is consistent across every write tool.
    """
    attach_skill_content(
        response,
        MandatoryBPS=MandatoryBPS,
        canonical_files=_DASHBOARD_SKILL_FILES,
        referenced_files=None,
    )


async def _get_dashboard_config_internal(
    client: Any, url_path: str | None
) -> tuple[dict[str, Any], str]:
    """Fetch dashboard config from HA and compute its hash.

    Returns ``(config, config_hash)`` tuple where ``config`` is the
    authoritative Lovelace config dict returned by HA's ``lovelace/config``
    WebSocket call (with ``force=True`` to bypass any cache) and
    ``config_hash`` is computed from that config via ``compute_config_hash``.

    Used internally to obtain the authoritative post-save hash and as the
    fetch+hash building block for the optimistic-locking pre-read paths.
    Mirrors the ``_get_<entity>_config_internal`` helpers in the sibling
    files (``tools_config_scripts.py``, ``tools_config_automations.py``,
    ``tools_config_scenes.py``).

    Raises ``ToolError`` with ``ErrorCode.SERVICE_CALL_FAILED`` if the
    WebSocket call reports failure or the response is not a dict; callers
    can rely on the returned tuple being populated.
    """
    get_data: dict[str, Any] = {"type": "lovelace/config", "force": True}
    if url_path:
        get_data["url_path"] = url_path

    response = await client.send_websocket_message(get_data)

    if isinstance(response, dict) and not response.get("success", True):
        error_msg = response.get("error", {})
        if isinstance(error_msg, dict):
            error_msg = error_msg.get("message", str(error_msg))
        raise_tool_error(
            create_error_response(
                ErrorCode.SERVICE_CALL_FAILED,
                f"Dashboard fetch failed: {error_msg}",
                context={"url_path": url_path},
            )
        )

    config = response.get("result") if isinstance(response, dict) else response
    if not isinstance(config, dict):
        raise_tool_error(
            create_error_response(
                ErrorCode.SERVICE_CALL_FAILED,
                "Dashboard config response was not a dict",
                context={"url_path": url_path},
            )
        )

    return cast(dict[str, Any], config), compute_config_hash(config)


async def _verify_config_unchanged(
    client: Any,
    url_path: str,
    original_hash: str,
) -> dict[str, Any]:
    """
    Verify dashboard config hasn't changed since original read.

    Returns dict with:
    - success: bool (True if config unchanged)
    - error: str (if config changed)
    - suggestions: list[str] (if config changed)
    """
    # Re-fetch current config
    get_data: dict[str, Any] = {"type": "lovelace/config"}
    if url_path:
        get_data["url_path"] = url_path

    result = await client.send_websocket_message(get_data)
    current_config = (
        result.get("result", result) if isinstance(result, dict) else result
    )

    if not isinstance(current_config, dict):
        return {"success": True}  # Can't verify, proceed anyway

    current_hash = compute_config_hash(current_config)

    if current_hash != original_hash:
        raise_tool_error(
            create_error_response(
                ErrorCode.SERVICE_CALL_FAILED,
                "Dashboard modified since last read (conflict)",
                suggestions=[
                    "Re-read dashboard with ha_config_get_dashboard",
                    "Then retry the operation with fresh data",
                ],
            )
        )

    return {"success": True}


def _badge_matches(badge: Any, entity_id: str) -> bool:
    """Check if a badge matches the entity_id search criteria.

    Badges can be simple strings (entity IDs) or dicts with an 'entity' field.
    Supports wildcard matching with *.
    """
    # Extract entity from badge
    if isinstance(badge, str):
        badge_entity = badge
    elif isinstance(badge, dict):
        badge_entity = badge.get("entity", "")
    else:
        return False

    if not badge_entity:
        return False

    # Support wildcard matching (same logic as _card_matches)
    if "*" in entity_id:
        pattern = entity_id.replace(".", r"\.").replace("*", ".*")
        return bool(re.match(pattern, badge_entity))

    return entity_id == badge_entity


# Keys under which a card nests other cards, by descent rule (issue #1599):
#   - ``cards`` (list): vertical/horizontal-stack, grid, and any custom wrapper
#     following the stack convention.
#   - ``card`` (dict): conditional and wrapper cards such as
#     ``custom:auto-entities``.
#   - ``custom_fields`` (dict of field-configs): ``custom:button-card`` embeds
#     sub-cards under ``custom_fields.<name>.card`` (a very common pattern that
#     wraps an entire view in one button-card). Each field-config is descended
#     as a node, so its own ``card`` / ``cards`` are picked up by the recursion.
#   - ``states`` (name->card map): ``custom:state-switch`` swaps a whole card per
#     source state. Each value is itself a card, descended directly as a node.
# Picture-elements ``elements`` is deliberately NOT traversed: it is not one of
# the descent keys above, so a node carrying it is disclosed at the response
# boundary instead of being walked (see ``_UNTRAVERSED_NESTED_KEYS`` and the
# find-card warnings). A blanket "descend every dict with a ``type``" walk is
# intentionally avoided: tile ``features`` and view ``conditions`` also carry
# ``type`` and would false-match as cards.
_NESTED_CARDS_KEY = "cards"
_NESTED_CARD_KEY = "card"
_NESTED_CUSTOM_FIELDS_KEY = "custom_fields"
_NESTED_STATES_KEY = "states"
# Child-bearing keys recognised but deliberately NOT traversed. A walked card
# carrying one of these (with a truthy value) cannot be fully covered, so it is
# its *presence* — not the absence of matches — that the response discloses
# (issue #1599: disclose by presence, not by absence-inference). picture-elements
# ``elements`` is the canonical case.
_UNTRAVERSED_NESTED_KEYS = ("elements",)
# Defensive bound against pathological/malformed configs. Real dashboards nest
# only a handful of levels; this guards recursion depth far above any real use.
_MAX_CARD_DEPTH = 50


def _py_key(name: str) -> str:
    """Render a mapping key as a Python subscript segment (``['name']``).

    ``repr`` quotes and escapes the key, so a name containing a quote (e.g.
    ``o'brien``) yields a valid literal; a raw ``['{name}']`` interpolation would
    splice an unterminated string into ``python_transform``.
    """
    return f"[{name!r}]"


def _jq_key(name: str) -> str:
    """Render a mapping key as a jq path segment.

    Identifier-safe keys use dot notation (``.name``); any other key (a dot, a
    space, a quote) is emitted as a bracketed JSON string (``["weird.key"]``) so
    jq does not read an embedded dot as further nesting.
    """
    if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
        return f".{name}"
    return f"[{json.dumps(name)}]"


def _log_non_str_key(container_key: str, name: object, jq_prefix: str) -> None:
    """Breadcrumb a non-string mapping key under a card-bearing container.

    Dashboard config arrives as JSON, so keys are normally strings; a non-string
    key (from a corrupted or hand-edited config) cannot form a valid path, so the
    entry is skipped rather than crashing the walk via ``_jq_key`` / ``_py_key``.
    """
    logger.debug(
        "Card-search skipping non-string %s key at %s (%r, %s)",
        container_key,
        jq_prefix,
        name,
        type(name).__name__,
    )


def _walk_card(
    card: Any,
    entity_id: str | None,
    card_type: str | None,
    heading: str | None,
    *,
    jq_prefix: str,
    python_prefix: str,
    view_index: int,
    section_index: int | None,
    card_index: int | None,
    depth: int = 0,
    truncation: list[str] | None = None,
    uncovered: list[str] | None = None,
) -> list[dict[str, Any]]:
    """Return matches for ``card`` and every card nested beneath it.

    Descends ``cards`` (list), ``card`` (dict), each ``custom_fields`` value, and
    each ``states`` value (custom:state-switch), for nested as well as top-level
    cards, up to ``_MAX_CARD_DEPTH``.

    ``jq_prefix`` / ``python_prefix`` locate ``card`` itself — the former in jq
    dot-notation, the latter as a Python subscript chain usable (appended after
    ``config``) directly in ``ha_config_set_dashboard(python_transform=...)``.
    Nested descendants extend both prefixes per level, so the path strings are
    the authoritative locator for nested cards (the flat ``view_index`` /
    ``section_index`` / ``card_index`` identify the top-level container only and
    are carried unchanged into nested matches for back-compat).

    Only a dict carrying a ``type`` key is treated as a card; this keeps non-card
    dicts reached under these keys (action targets, style blocks, entity rows)
    from matching. If ``truncation`` is provided, the prefix of any subtree
    skipped at the depth bound is appended to it. If ``uncovered`` is provided,
    the path of any walked card carrying a non-traversed child-bearing key (see
    ``_UNTRAVERSED_NESTED_KEYS``) is appended to it, so the caller can disclose
    the incompleteness regardless of whether the search matched anything.
    """
    matches: list[dict[str, Any]] = []
    if not isinstance(card, dict):
        # Structurally-present but malformed slot (e.g. a string where a card
        # dict is expected): skip, but breadcrumb so it is not a silent drop.
        if card is not None:
            logger.debug(
                "Card-search skipping non-dict node at %s (%s)",
                jq_prefix,
                type(card).__name__,
            )
        return matches
    if depth > _MAX_CARD_DEPTH:
        # Stop, but make the truncation visible rather than silently dropping
        # any cards nested below this point. Only reachable on pathological or
        # malformed configs (real dashboards nest a handful of levels).
        logger.warning(
            "Card-search depth bound (%d) exceeded at %s; not descending further",
            _MAX_CARD_DEPTH,
            jq_prefix,
        )
        if truncation is not None:
            truncation.append(jq_prefix)
        return matches

    if "type" in card:
        if _card_matches(card, entity_id, card_type, heading):
            matches.append(
                {
                    "view_index": view_index,
                    "section_index": section_index,
                    "card_index": card_index,
                    "jq_path": jq_prefix,
                    "python_path": python_prefix,
                    "card_type": card.get("type"),
                    "card_config": card,
                }
            )
        # Disclose un-coverable nesting by presence during the walk, not by the
        # absence of matches: a card that carries e.g. picture-elements
        # ``elements`` hides content this search cannot reach whether or not it
        # (or anything else) matched.
        if uncovered is not None:
            for key in _UNTRAVERSED_NESTED_KEYS:
                if card.get(key):
                    uncovered.append(f"{jq_prefix}.{key}")
                    break

    nested_list = card.get(_NESTED_CARDS_KEY)
    if isinstance(nested_list, list):
        for i, child in enumerate(nested_list):
            matches.extend(
                _walk_card(
                    child,
                    entity_id,
                    card_type,
                    heading,
                    jq_prefix=f"{jq_prefix}.{_NESTED_CARDS_KEY}[{i}]",
                    python_prefix=f"{python_prefix}['{_NESTED_CARDS_KEY}'][{i}]",
                    view_index=view_index,
                    section_index=section_index,
                    card_index=card_index,
                    depth=depth + 1,
                    truncation=truncation,
                    uncovered=uncovered,
                )
            )
    elif nested_list is not None:
        # ``cards`` key present but not a list — structurally malformed slot.
        logger.debug(
            "Card-search skipping non-list '%s' under %s (%s)",
            _NESTED_CARDS_KEY,
            jq_prefix,
            type(nested_list).__name__,
        )

    nested_card = card.get(_NESTED_CARD_KEY)
    if isinstance(nested_card, dict):
        matches.extend(
            _walk_card(
                nested_card,
                entity_id,
                card_type,
                heading,
                jq_prefix=f"{jq_prefix}.{_NESTED_CARD_KEY}",
                python_prefix=f"{python_prefix}['{_NESTED_CARD_KEY}']",
                view_index=view_index,
                section_index=section_index,
                card_index=card_index,
                depth=depth + 1,
                truncation=truncation,
                uncovered=uncovered,
            )
        )
    elif nested_card is not None:
        # ``card`` key present but not a dict — structurally malformed slot.
        logger.debug(
            "Card-search skipping non-dict '%s' under %s (%s)",
            _NESTED_CARD_KEY,
            jq_prefix,
            type(nested_card).__name__,
        )

    # custom:button-card and similar embed sub-cards under custom_fields.<name>.
    # Descend each field-config as a node; its own card/cards (and the type gate)
    # are handled by the recursion, so a field that is not itself a card
    # contributes nothing but is still traversed for nested cards. Keys are
    # rendered quote/dot-safe so a field name like ``o'brien`` yields a usable
    # python_path/jq_path (issue #1599: handle a quote/dot in the field name).
    custom_fields = card.get(_NESTED_CUSTOM_FIELDS_KEY)
    if isinstance(custom_fields, dict):
        for name, field in custom_fields.items():
            if not isinstance(name, str):
                _log_non_str_key(_NESTED_CUSTOM_FIELDS_KEY, name, jq_prefix)
                continue
            matches.extend(
                _walk_card(
                    field,
                    entity_id,
                    card_type,
                    heading,
                    jq_prefix=f"{jq_prefix}.{_NESTED_CUSTOM_FIELDS_KEY}{_jq_key(name)}",
                    python_prefix=(
                        f"{python_prefix}['{_NESTED_CUSTOM_FIELDS_KEY}']{_py_key(name)}"
                    ),
                    view_index=view_index,
                    section_index=section_index,
                    card_index=card_index,
                    depth=depth + 1,
                    truncation=truncation,
                    uncovered=uncovered,
                )
            )
    elif custom_fields is not None:
        logger.debug(
            "Card-search skipping non-dict '%s' under %s (%s)",
            _NESTED_CUSTOM_FIELDS_KEY,
            jq_prefix,
            type(custom_fields).__name__,
        )

    # custom:state-switch swaps a whole card per source state under states.<name>.
    # Each value is itself a card (not a field-config wrapper), descended
    # directly — the same quote/dot-safe key rendering applies for state names
    # like ``on'hold`` (issue #1599: state-switch nests a card per state).
    states = card.get(_NESTED_STATES_KEY)
    if isinstance(states, dict):
        for name, child in states.items():
            if not isinstance(name, str):
                _log_non_str_key(_NESTED_STATES_KEY, name, jq_prefix)
                continue
            matches.extend(
                _walk_card(
                    child,
                    entity_id,
                    card_type,
                    heading,
                    jq_prefix=f"{jq_prefix}.{_NESTED_STATES_KEY}{_jq_key(name)}",
                    python_prefix=(
                        f"{python_prefix}['{_NESTED_STATES_KEY}']{_py_key(name)}"
                    ),
                    view_index=view_index,
                    section_index=section_index,
                    card_index=card_index,
                    depth=depth + 1,
                    truncation=truncation,
                    uncovered=uncovered,
                )
            )
    elif states is not None:
        logger.debug(
            "Card-search skipping non-dict '%s' under %s (%s)",
            _NESTED_STATES_KEY,
            jq_prefix,
            type(states).__name__,
        )

    return matches


def _find_cards_in_config(
    config: dict[str, Any],
    entity_id: str | None = None,
    card_type: str | None = None,
    heading: str | None = None,
    truncation: list[str] | None = None,
    uncovered: list[str] | None = None,
) -> list[dict[str, Any]]:
    """
    Find cards, badges, and header cards in a dashboard config matching the search criteria.

    Returns a list of matches with location info and card/badge/header config.
    Searches cards (in sections and flat views), view-level badges, and
    sections-view header cards (views[n].header.card). Card search recurses into
    nested containers (``cards`` lists in stacks/grids, ``card`` dicts in
    conditional/wrapper cards, ``custom_fields`` sub-cards in button-card, and
    ``states`` sub-cards in custom:state-switch), so a nested card is found like
    a top-level one (issue #1599) — up to a depth bound.

    Each match carries both ``jq_path`` (jq dot-notation) and ``python_path``
    (a Python subscript chain appended after ``config`` for
    ``ha_config_set_dashboard(python_transform)``); these locate nested as well
    as top-level cards. The flat ``*_index`` fields identify the top-level
    container only. If ``truncation`` is provided, the prefixes of any subtrees
    skipped at the depth bound are appended to it. If ``uncovered`` is provided,
    the paths of any walked cards carrying a non-traversed child-bearing key
    (e.g. picture-elements ``elements``) are appended to it.
    """
    matches: list[dict[str, Any]] = []

    if "strategy" in config:
        return []  # Strategy dashboards don't have explicit cards

    views = config.get("views", [])
    for view_idx, view in enumerate(views):
        if not isinstance(view, dict):
            continue

        # Search view-level badges when filtering by entity_id or card_type="badge"
        if (
            entity_id is not None
            and heading is None
            and (card_type is None or card_type == "badge")
        ):
            badges = view.get("badges", [])
            for badge_idx, badge in enumerate(badges):
                if _badge_matches(badge, entity_id):
                    is_dict_badge = isinstance(badge, dict)
                    badge_config = badge if is_dict_badge else {"entity": badge}
                    badge_match: dict[str, Any] = {
                        "view_index": view_idx,
                        "section_index": None,
                        "card_index": None,
                        "badge_index": badge_idx,
                        "jq_path": f".views[{view_idx}].badges[{badge_idx}]",
                        "card_type": "badge",
                        "card_config": badge_config,
                    }
                    # A bare-string badge (the common form) is not subscript-
                    # assignable, so a python_path spliced into python_transform
                    # would raise TypeError. Only advertise python_path for dict
                    # badges; string badges must be converted to dict form first.
                    if is_dict_badge:
                        badge_match["python_path"] = (
                            f"['views'][{view_idx}]['badges'][{badge_idx}]"
                        )
                    matches.append(badge_match)

        # Search sections-view header card (views[n].header.card)
        # The header accepts a card (typically Markdown) that can contain entity refs
        header = view.get("header", {})
        if isinstance(header, dict):
            header_card = header.get("card")
            if isinstance(header_card, dict):
                matches.extend(
                    _walk_card(
                        header_card,
                        entity_id,
                        card_type,
                        heading,
                        jq_prefix=f".views[{view_idx}].header.card",
                        python_prefix=f"['views'][{view_idx}]['header']['card']",
                        view_index=view_idx,
                        section_index=None,
                        card_index=None,
                        truncation=truncation,
                        uncovered=uncovered,
                    )
                )

        view_type = view.get("type", "masonry")

        if view_type == "sections":
            # Sections-based view
            sections = view.get("sections", [])
            for section_idx, section in enumerate(sections):
                if not isinstance(section, dict):
                    continue
                cards = section.get("cards", [])
                for card_idx, card in enumerate(cards):
                    matches.extend(
                        _walk_card(
                            card,
                            entity_id,
                            card_type,
                            heading,
                            jq_prefix=f".views[{view_idx}].sections[{section_idx}].cards[{card_idx}]",
                            python_prefix=f"['views'][{view_idx}]['sections'][{section_idx}]['cards'][{card_idx}]",
                            view_index=view_idx,
                            section_index=section_idx,
                            card_index=card_idx,
                            truncation=truncation,
                            uncovered=uncovered,
                        )
                    )
        else:
            # Flat view (masonry, panel, sidebar)
            cards = view.get("cards", [])
            for card_idx, card in enumerate(cards):
                matches.extend(
                    _walk_card(
                        card,
                        entity_id,
                        card_type,
                        heading,
                        jq_prefix=f".views[{view_idx}].cards[{card_idx}]",
                        python_prefix=f"['views'][{view_idx}]['cards'][{card_idx}]",
                        view_index=view_idx,
                        section_index=None,
                        card_index=card_idx,
                        truncation=truncation,
                        uncovered=uncovered,
                    )
                )

    return matches


def _card_matches(
    card: dict[str, Any],
    entity_id: str | None,
    card_type: str | None,
    heading: str | None,
) -> bool:
    """Check if a card matches the search criteria."""
    # Type filter
    if card_type is not None:
        if card.get("type") != card_type:
            return False

    # Entity filter (supports partial matching with *)
    if entity_id is not None:
        card_entity = card.get("entity", "")
        # Also check entities list for cards that have multiple entities
        card_entities = card.get("entities", [])
        if isinstance(card_entities, list):
            all_entities = [card_entity] + [
                e.get("entity", e) if isinstance(e, dict) else e for e in card_entities
            ]
        else:
            all_entities = [card_entity]

        # Support wildcard matching
        if "*" in entity_id:
            pattern = entity_id.replace(".", r"\.").replace("*", ".*")
            if not any(re.match(pattern, e) for e in all_entities if e):
                return False
        else:
            if entity_id not in all_entities:
                return False

    # Heading filter (for heading cards or section titles)
    if heading is not None:
        card_heading = card.get("heading", card.get("title", ""))
        # Case-insensitive partial match
        if heading.lower() not in card_heading.lower():
            return False

    return True


# Substring in WS error message that signals the dashboard identifier was not
# accepted by lovelace/config (e.g., caller passed an internal id where url_path
# is expected). Used to gate the lazy resolver fallback in get/set tools.
#
# Source: homeassistant/components/lovelace/websocket.py, _handle_errors —
# emits f"Unknown config specified: {url_path}" paired with structured
# error.code "config_not_found". The websocket client currently surfaces only
# the message string, so substring matching is the only signal available at
# the tool layer. If HA reformats this string, the lazy fallback regresses
# silently to never firing — re-verify with major HA upgrades.
_LAZY_RESOLVE_TRIGGER = "Unknown config specified"


def _should_lazy_resolve(error_msg: str) -> bool:
    """Return True if a WS error message indicates the identifier needs resolving."""
    return _LAZY_RESOLVE_TRIGGER in error_msg


async def fetch_dashboards_list(
    client: Any,
) -> list[dict[str, Any]] | None:
    """Fetch and normalise the lovelace/dashboards/list WebSocket response.

    Returns the list of dashboard registry entries on success, or ``None``
    when the response shape is unrecognised.  A warning is logged on
    unexpected shapes so that future HA response-format changes surface at
    every fetch site rather than silently degrading.

    Callers decide how to handle ``None`` (e.g. fall through to ``[]`` or
    propagate the failure).
    """
    result = await client.send_websocket_message({"type": "lovelace/dashboards/list"})
    if isinstance(result, dict) and isinstance(result.get("result"), list):
        return cast(list[dict[str, Any]], result["result"])
    if isinstance(result, list):
        return cast(list[dict[str, Any]], result)
    logger.warning(
        "lovelace/dashboards/list returned an unexpected shape (type=%s); "
        "treating as no-match",
        type(result).__name__,
    )
    return None


async def _resolve_dashboard(
    client: Any, identifier: str
) -> tuple[dict[str, str] | None, list[dict[str, Any]] | None]:
    """Resolve a dashboard identifier (url_path or internal id) to both forms.

    Calls ``lovelace/dashboards/list`` and returns a 2-tuple
    ``(match, dashboards)``:

    - ``match`` is ``{"url_path": ..., "id": ...}`` when the identifier
      matches either field on a registry entry that has both fields
      populated; otherwise ``None``.
    - ``dashboards`` is the raw list as returned by HA when the
      response shape is recognised (dict-with-``result`` or bare list);
      ``None`` when the shape was unexpected and a warning was logged.

    Returning ``dashboards`` alongside ``match`` lets callers reuse the
    list for follow-on checks (existence, id lookup) instead of paying
    a second ``lovelace/dashboards/list`` round-trip.

    Three call sites:
    - **Lazy fallback** (``_lazy_resolve_and_retry``): only invoked after
      ``lovelace/config`` rejected the identifier with
      ``_LAZY_RESOLVE_TRIGGER`` — the round-trip is gated by the caller.
      Discards ``dashboards``.
    - **Eager pre-resolve** (``ha_config_set_dashboard``): invoked before
      hyphen validation so callers may pass either form; gated on a
      cheap heuristic ("no hyphen, not 'lovelace'") rather than an error
      from HA. Reuses ``dashboards`` for the existence-check below.
    - **Delete** (``ha_config_delete_dashboard``): resolves either form
      to the registry id before issuing the delete. Discards
      ``dashboards``.
    """
    dashboards = await fetch_dashboards_list(client)
    if dashboards is None:
        return None, None

    for d in dashboards:
        if d.get("id") == identifier or d.get("url_path") == identifier:
            url_path = d.get("url_path") or ""
            entry_id = d.get("id") or ""
            if not url_path or not entry_id:
                # Malformed registry entry — neither form is safe to
                # forward. Skip rather than return empty strings that
                # would be silently used by callers (e.g.
                # ``delete_dashboard`` would forward ``resolved_id=""``).
                continue
            return {"url_path": url_path, "id": entry_id}, dashboards
    return None, dashboards


@overload
async def _lazy_resolve_and_retry(
    client: Any,
    url_path: str,
    ws_data: dict[str, Any],
    response: Any,
) -> tuple[str, Any]:
    pass


@overload
async def _lazy_resolve_and_retry(
    client: Any,
    url_path: None,
    ws_data: dict[str, Any],
    response: Any,
) -> tuple[None, Any]:
    pass


async def _lazy_resolve_and_retry(
    client: Any,
    url_path: str | None,
    ws_data: dict[str, Any],
    response: Any,
) -> tuple[str | None, Any]:
    """Trigger-gated lazy resolve + single retry of a lovelace/config call.

    If `response` indicates HA rejected the identifier with the
    _LAZY_RESOLVE_TRIGGER substring, resolves `url_path` via
    lovelace/dashboards/list and retries the WS call with the canonical
    url_path. Returns the (possibly updated) url_path and the
    (possibly retried) response so the caller can chain naturally:

        url_path, response = await _lazy_resolve_and_retry(
            client, url_path, ws_data, response
        )

    No-op when:
    - the response is not a failure (success=True or non-dict),
    - ``url_path`` is empty,
    - the error message does not contain ``_LAZY_RESOLVE_TRIGGER``
      (the substring miss),
    - the resolver finds no match,
    - or the resolver itself raises (logged at WARNING).

    In every no-op case the original ``response`` is returned unchanged
    so the caller's existing error-handling path runs against the real
    HA error rather than a synthetic "resolver failed" one.

    The caller's `ws_data` dict is never mutated: when a retry is needed,
    a shallow copy is made and the canonical `url_path` written into the
    copy before the retry call.
    """
    if not (isinstance(response, dict) and not response.get("success", True)):
        return url_path, response
    if not url_path:
        return url_path, response

    err = response.get("error", {})
    err_msg = err.get("message", str(err)) if isinstance(err, dict) else str(err)
    if not _should_lazy_resolve(err_msg):
        return url_path, response

    try:
        resolved, _ = await _resolve_dashboard(client, url_path)
    except Exception as resolver_exc:
        # Resolver itself raised (timeout, network blip, etc.). Don't let
        # this exception escape and replace the original HA error with
        # one about the resolver — fall through with the original
        # response so the caller surfaces the actual "Unknown config
        # specified" error.
        logger.warning(
            "Lazy resolver failed for url_path=%r: %s; "
            "falling through to original error",
            url_path,
            resolver_exc,
        )
        return url_path, response

    if resolved is None or not resolved["url_path"]:
        return url_path, response

    url_path = resolved["url_path"]
    retry_data = dict(ws_data)
    retry_data["url_path"] = url_path
    response = await client.send_websocket_message(retry_data)
    return url_path, response


def _dashboard_frontend_path(url_path: str | None) -> str:
    """Map a dashboard url_path to its Lovelace frontend path for screenshots."""
    if not url_path or url_path == "default":
        return "lovelace"
    return url_path


def _note_screenshot_ignored(
    result: dict[str, Any],
    *,
    include_screenshot: bool,
    full_page: bool,
    mode: str,
) -> None:
    """Warn when a screenshot was requested in a mode that can't render one.

    ``include_screenshot`` / ``full_page`` are only honoured in get mode. In
    list and search mode they are accepted but inapplicable, so surface a
    ``warnings`` entry rather than dropping the request as a silent no-op
    (matches the warn-don't-fail contract the params document)."""
    if include_screenshot or full_page:
        result.setdefault("warnings", []).append(
            f"include_screenshot/full_page is ignored in {mode} mode; call "
            "ha_config_get_dashboard with a url_path (and no search criteria) "
            "to get a screenshot."
        )


async def _maybe_attach_screenshot(
    result: dict[str, Any],
    url_path: str | None,
    requested: bool,
    *,
    full_page: bool = False,
    raise_on_failure: bool = False,
) -> "dict[str, Any] | ToolResult":
    """Optionally render the dashboard and attach it as an image content block.

    Shared by ``ha_config_get_dashboard`` (include_screenshot) and
    ``ha_config_set_dashboard`` (return_screenshot). On success returns a
    FastMCP ``ToolResult`` carrying ``result`` as structured_content plus the
    PNG as an image content block — so structured_content is present on both
    the screenshot and no-screenshot paths (a bare ``[dict, Image]`` list
    would drop structured_content because the Image isn't JSON-serializable).
    ``full_page`` captures the whole scrollable dashboard rather than the
    viewport.

    ``raise_on_failure`` governs what a capture failure does. The set path
    (``return_screenshot``) leaves it False: a screenshot failure must never
    break a write that already committed, so it degrades to a ``warnings``
    entry. The get path (``include_screenshot``) passes True: it is a pure
    read with nothing to protect, and the screenshot is the requested payload,
    so an engine failure propagates as a ToolError (matching the dedicated
    ``ha_get_dashboard_screenshot`` tool) instead of being demoted to a
    warning the caller may never inspect. A disabled feature flag is always a
    warning either way — it is an expected configuration state, not a failure.
    """
    if not requested:
        if full_page:
            result.setdefault("warnings", []).append(
                "full_page is ignored because no screenshot was requested "
                "(set include_screenshot / return_screenshot to use it)."
            )
        return result

    from ..config import get_global_settings

    if not get_global_settings().enable_dashboard_screenshot:
        result.setdefault("warnings", []).append(
            "Screenshot requested but dashboard screenshot mode is disabled. "
            "Enable the 'dashboard screenshot' beta feature to use it."
        )
        return result

    try:
        from fastmcp.utilities.types import Image

        from ..dashboard_screenshot.capture import capture_dashboard_png

        png = await capture_dashboard_png(
            _dashboard_frontend_path(url_path), full_page=full_page
        )
        return ToolResult(
            content=[Image(data=png, format="png").to_image_content()],
            structured_content=result,
        )
    except ToolError as e:
        if raise_on_failure:
            raise
        result.setdefault("warnings", []).append(
            f"Screenshot unavailable: {extract_tool_error_message(e)}"
        )
        return result
    except Exception as e:
        # On the set path a screenshot failure must never break a write that
        # already committed, so catch everything non-ToolError (lazy import
        # errors, Image construction, timeouts, transport) and degrade to a
        # warning. On the get path (raise_on_failure) there is nothing to
        # protect, so let it surface.
        if raise_on_failure:
            raise
        logger.warning("Dashboard screenshot capture failed: %s", e, exc_info=True)
        result.setdefault("warnings", []).append(f"Screenshot unavailable: {e}")
        return result


def register_config_dashboard_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
    """Register Home Assistant dashboard configuration tools."""

    @mcp.tool(
        tags={"Dashboards"},
        annotations={
            "idempotentHint": True,
            "readOnlyHint": True,
            "title": "Get Dashboard",
        },
    )
    @log_tool_usage
    async def ha_config_get_dashboard(
        url_path: Annotated[
            str | None,
            Field(
                description="Dashboard URL path (e.g., 'lovelace-home'). "
                "Use 'default' for default dashboard. "
                "If omitted with list_only=True, lists all dashboards."
            ),
        ] = None,
        list_only: Annotated[
            bool,
            Field(
                description="If True, list all dashboards instead of getting config. "
                "When True, url_path is ignored.",
            ),
        ] = False,
        force_reload: Annotated[
            bool,
            Field(
                description="Force reload from storage (bypass cache). Not applicable in search mode (search always uses force=True for fresh results)."
            ),
        ] = False,
        entity_id: Annotated[
            str | None,
            Field(
                description="Find cards by entity ID. Supports wildcards, e.g. "
                "'sensor.temperature_*'. Matches cards with this entity in "
                "'entity' or 'entities' field, view-level badges, and header cards. "
                "When provided, activates search mode (returns matches, not full config)."
            ),
        ] = None,
        card_type: Annotated[
            str | None,
            Field(
                description="Find cards by type, e.g. 'tile', 'button', 'heading'. "
                "When provided, activates search mode."
            ),
        ] = None,
        heading: Annotated[
            str | None,
            Field(
                description="Find cards by heading/title text (case-insensitive partial match). "
                "When provided, activates search mode."
            ),
        ] = None,
        include_config: Annotated[
            bool,
            Field(
                description="In search mode: include each matched card's own configuration "
                "object in results (increases output size). Note that a matched container "
                "card's config contains its descendants, which are themselves separate "
                "matches with their own config, so deeply-nested stacks multiply the "
                "payload — keep the default (False) unless you need the bodies. Does not "
                "affect whether the full dashboard config is returned — search mode always "
                "returns matches only, not the full dashboard. Ignored outside search mode."
            ),
        ] = False,
        include_screenshot: Annotated[
            bool,
            Field(
                description="Get mode only: also return a rendered PNG of the "
                "dashboard for visual verification. Requires the 'dashboard "
                "screenshot' beta feature + engine add-on/sidecar. If the "
                "feature is disabled the config is returned with a warning; if "
                "the engine is configured but the render fails, the call errors "
                "(the screenshot is the requested payload). Ignored in "
                "list/search mode."
            ),
        ] = False,
        full_page: Annotated[
            bool,
            Field(description=f"With include_screenshot: {FULL_PAGE_PARAM_DESC}."),
        ] = False,
    ) -> "dict[str, Any] | ToolResult":
        """
        Get dashboard info - list all dashboards, get config, or search for cards.

        MODE 1 — List: list_only=True
          Lists all storage-mode dashboards with metadata (url_path, title, icon).

        MODE 2 — Search: any of entity_id / card_type / heading provided
          Finds cards, badges, and header cards matching the criteria, including
          cards nested inside stacks, grids, conditional cards, button-card
          custom_fields, and state-switch states. Each match carries a
          python_path and a jq_path that locate the card for nested as well as
          top-level cards. The python_path is a Python subscript chain to be
          appended after `config` — e.g.
          python_transform=f'config{m["python_path"]}["icon"] = "mdi:x"' (it is
          NOT valid on its own without the `config` prefix). jq_path is the same
          location in jq dot-notation.
          Multiple criteria are AND-ed. Always fetches fresh config (force=True).
          Search covers cards/card/custom_fields/states containers up to a depth
          bound; if the dashboard carries a non-traversed child-bearing shape
          (e.g. picture-elements `elements`), the result carries a `warnings`
          entry naming where, so its hidden content is not mistaken for absent.
          Strategy dashboards are not searchable (no explicit cards).

        MODE 3 — Get: Active when list_only=False and no search parameters are provided.
          Returns the full Lovelace dashboard config, defaulting to the
          main dashboard if url_path is omitted.

        Return a stable `config_hash` (Get and Search modes only; not present in list_only mode) across consecutive reads of an unchanged config — `compute_config_hash` documents the underlying contract.

        EXAMPLES:
        - List all dashboards: ha_config_get_dashboard(list_only=True)
        - Get default dashboard: ha_config_get_dashboard(url_path="default")
        - Get custom dashboard: ha_config_get_dashboard(url_path="lovelace-mobile")
        - Force reload: ha_config_get_dashboard(url_path="lovelace-home", force_reload=True)
        - Find cards by entity: ha_config_get_dashboard(url_path="my-dash", entity_id="light.living_room")
        - Find by wildcard: ha_config_get_dashboard(url_path="my-dash", entity_id="sensor.temperature_*")
        - Find by type: ha_config_get_dashboard(url_path="my-dash", card_type="tile")
        - Find heading: ha_config_get_dashboard(url_path="my-dash", heading="Climate", card_type="heading")

        SEARCH WORKFLOW EXAMPLE:
        1. find = ha_config_get_dashboard(url_path="my-dash", entity_id="light.bedroom")
        2. ha_config_set_dashboard(
               url_path="my-dash",
               config_hash=find["config_hash"],
               python_transform=f'config{find["matches"][0]["python_path"]}["icon"] = "mdi:lamp"'
           )

        Note: YAML-mode dashboards (defined in configuration.yaml) are not included in list.
        """
        search_mode = (
            entity_id is not None or card_type is not None or heading is not None
        )
        try:
            # List mode
            if list_only:
                dashboards = await fetch_dashboards_list(client) or []
                list_result: dict[str, Any] = {
                    "success": True,
                    "action": "list",
                    "dashboards": dashboards,
                    "count": len(dashboards),
                }
                _note_screenshot_ignored(
                    list_result,
                    include_screenshot=include_screenshot,
                    full_page=full_page,
                    mode="list",
                )
                return list_result

            # ``url_path`` is optional in this tool (omitted with
            # ``list_only=True`` lists all dashboards — handled above; omitted
            # without ``list_only`` falls back to the default dashboard via
            # the resolver below). When provided, reject empty/whitespace
            # up-front so the caller gets a structured parameter error
            # instead of a misleading ``RESOURCE_NOT_FOUND``. Extension of
            # the #1312 validate_identifier_not_empty pattern to the
            # dashboards family per #1313.
            if url_path is not None:
                validate_identifier_not_empty(
                    url_path,
                    "url_path",
                    suggestions=[
                        "Pass a dashboard URL path (e.g. 'lovelace-home')",
                        "Omit url_path and pass list_only=True to list dashboards",
                        "Use 'default' to target the default dashboard",
                    ],
                )

            # Search mode — find cards, badges, or header cards
            if search_mode:
                get_data: dict[str, Any] = {"type": "lovelace/config", "force": True}
                effective_url_path: str | None = (
                    url_path if url_path and url_path != "default" else None
                )
                if effective_url_path is not None:
                    get_data["url_path"] = effective_url_path

                response = await client.send_websocket_message(get_data)

                # Lazy resolver fallback: same gate as get-mode. If the
                # caller passed an internal id where url_path is expected,
                # HA rejects with the trigger substring; resolve and retry
                # once. (set_dashboard handles this via an eager pre-resolver
                # before the hyphen check, so it has no equivalent fallback
                # here.)
                search_resolved_from: str | None = None
                if effective_url_path is not None:
                    new_url_path, response = await _lazy_resolve_and_retry(
                        client, effective_url_path, get_data, response
                    )
                    if new_url_path != effective_url_path:
                        # Surface the original caller-passed identifier so
                        # the caller can see their input was canonicalized.
                        search_resolved_from = url_path
                        url_path = new_url_path

                if isinstance(response, dict) and not response.get("success", True):
                    error_msg = response.get("error", {})
                    if isinstance(error_msg, dict):
                        error_msg = error_msg.get("message", str(error_msg))
                    raise_tool_error(
                        create_error_response(
                            ErrorCode.SERVICE_CALL_FAILED,
                            f"Failed to get dashboard: {error_msg}",
                            suggestions=[
                                "Verify dashboard exists with ha_config_get_dashboard(list_only=True)",
                                "Check HA connection",
                            ],
                            context={"action": "find_card", "url_path": url_path},
                        )
                    )

                config = (
                    response.get("result") if isinstance(response, dict) else response
                )
                if not isinstance(config, dict):
                    raise_tool_error(
                        create_error_response(
                            ErrorCode.SERVICE_CALL_FAILED,
                            "Dashboard config is empty or invalid",
                            suggestions=[
                                "Initialize dashboard with ha_config_set_dashboard"
                            ],
                            context={"action": "find_card", "url_path": url_path},
                        )
                    )

                if "strategy" in config:
                    raise_tool_error(
                        create_error_response(
                            ErrorCode.VALIDATION_FAILED,
                            "Strategy dashboards have no explicit cards to search",
                            suggestions=[
                                "Use 'Take Control' in HA UI to convert to editable",
                                "Or create a non-strategy dashboard",
                            ],
                            context={"action": "find_card", "url_path": url_path},
                        )
                    )

                truncation: list[str] = []
                uncovered: list[str] = []
                matches = _find_cards_in_config(
                    config,
                    entity_id,
                    card_type,
                    heading,
                    truncation=truncation,
                    uncovered=uncovered,
                )

                if not include_config:
                    for match in matches:
                        del match["card_config"]

                config_hash: str | None = compute_config_hash(config)

                # Warn-don't-truncate (AGENTS.md Return Values): the walker covers
                # cards / card / custom_fields / states containers and stops at
                # the depth bound, so neither a depth-truncated search nor a
                # search over a dashboard carrying a non-traversed child-bearing
                # shape may read as an authoritative complete result. Disclosure
                # keys off the *presence* of such a shape (collected during the
                # walk), not off a 0-match — a matching un-walkable container no
                # longer suppresses the warning, and a true negative over a
                # fully-coverable dashboard no longer cries wolf.
                warnings: list[str] = []
                if truncation:
                    warnings.append(
                        f"Search stopped at the nesting depth bound "
                        f"(_MAX_CARD_DEPTH={_MAX_CARD_DEPTH}) in "
                        f"{len(truncation)} place(s); cards nested deeper were not "
                        "searched, so results may be incomplete."
                    )
                if uncovered:
                    locations = ", ".join(sorted(set(uncovered)))
                    warnings.append(
                        "Cards nesting content under keys this search does not "
                        "traverse (e.g. picture-elements 'elements') are present at: "
                        f"{locations}. That nested content is not searched; fetch the "
                        "full config (ha_config_get_dashboard without search params) "
                        "to inspect those."
                    )

                if matches:
                    hint = (
                        "Use python_path with "
                        "ha_config_set_dashboard(python_transform=...) for targeted "
                        "updates"
                    )
                else:
                    hint = (
                        "No matches in searched containers. Try other criteria, or "
                        "fetch the full config (no search params) to inspect nesting "
                        "shapes this search does not cover."
                    )

                search_result: dict[str, Any] = {
                    "success": True,
                    "action": "find_card",
                    "url_path": url_path,
                    "config_hash": config_hash,
                    "search_criteria": {
                        "entity_id": entity_id,
                        "card_type": card_type,
                        "heading": heading,
                    },
                    "matches": matches,
                    "match_count": len(matches),
                    "hint": hint,
                }
                if warnings:
                    search_result["warnings"] = warnings
                if search_resolved_from is not None:
                    search_result["resolved_from"] = search_resolved_from
                _note_screenshot_ignored(
                    search_result,
                    include_screenshot=include_screenshot,
                    full_page=full_page,
                    mode="search",
                )
                return search_result

            # Get mode - build WebSocket message
            data: dict[str, Any] = {"type": "lovelace/config", "force": force_reload}
            # Handle "default" as special value for default dashboard
            if url_path and url_path != "default":
                data["url_path"] = url_path

            response = await client.send_websocket_message(data)

            # Lazy resolver fallback: if HA rejects the identifier as unknown,
            # resolve it via lovelace/dashboards/list and retry once. The
            # round-trip is only paid when the caller passed an internal
            # dashboard id (or another non-url_path form) HA does not accept.
            original_url_path = url_path
            url_path, response = await _lazy_resolve_and_retry(
                client, url_path, data, response
            )

            # Check if request failed (after potential retry)
            if isinstance(response, dict) and not response.get("success", True):
                error_msg = response.get("error", {})
                if isinstance(error_msg, dict):
                    error_msg = error_msg.get("message", str(error_msg))
                raise_tool_error(
                    create_error_response(
                        ErrorCode.SERVICE_CALL_FAILED,
                        str(error_msg),
                        suggestions=[
                            "Use ha_config_get_dashboard(list_only=True) to see available dashboards",
                            "Check if you have permission to access this dashboard",
                            "Use url_path='default' for default dashboard",
                        ],
                        context={"action": "get", "url_path": url_path},
                    )
                )

            # Extract config from WebSocket response
            config = response.get("result") if isinstance(response, dict) else response

            # Compute hash for optimistic locking in subsequent operations
            config_hash = (
                compute_config_hash(config) if isinstance(config, dict) else None
            )

            # Calculate config size for progressive disclosure hint
            config_size = len(json.dumps(config)) if isinstance(config, dict) else 0

            get_result: dict[str, Any] = {
                "success": True,
                "action": "get",
                "url_path": url_path,
                "config": config,
                "config_hash": config_hash,
                "config_size_bytes": config_size,
            }
            # Surface the original caller-passed identifier when the lazy
            # resolver canonicalised it (parity with delete_dashboard's
            # resolved_id field). Caller can use this to detect that their
            # input was an internal id rather than a url_path.
            if original_url_path is not None and original_url_path != url_path:
                get_result["resolved_from"] = original_url_path

            # Add hint for large configs (progressive disclosure) - 10KB ≈ 2-3k tokens
            if config_size >= 10000:
                get_result["hint"] = (
                    f"Large config ({config_size:,} bytes). For edits, use "
                    "ha_config_get_dashboard(entity_id=...) to find card positions, "
                    "then ha_config_set_dashboard(python_transform=...) "
                    "instead of full config replacement."
                )

            return await _maybe_attach_screenshot(
                get_result,
                url_path,
                include_screenshot,
                full_page=full_page,
                raise_on_failure=True,
            )
        except ToolError:
            raise
        except Exception as e:
            if search_mode:
                suggestions = [
                    "Check HA connection",
                    "Verify dashboard with ha_config_get_dashboard(list_only=True)",
                ]
                context: dict[str, Any] = {
                    "action": "find_card",
                    "url_path": url_path,
                    "entity_id": entity_id,
                    "card_type": card_type,
                    "heading": heading,
                }
            else:
                suggestions = [
                    "Use ha_config_get_dashboard(list_only=True) to see available dashboards",
                    "Check if you have permission to access this dashboard",
                    "Use url_path='default' for default dashboard",
                ]
                context = {
                    "action": "get" if not list_only else "list",
                    "url_path": url_path,
                }
            exception_to_structured_error(
                e,
                context=context,
                suggestions=suggestions,
            )
            return None

    @mcp.tool(
        tags={"Dashboards"},
        annotations={"destructiveHint": True, "title": "Create or Update Dashboard"},
    )
    @with_auto_backup(domain="dashboard", id_param="url_path", client=client)
    @log_tool_usage
    async def ha_config_set_dashboard(
        url_path: Annotated[
            str,
            Field(
                description="Dashboard URL path (e.g., 'my-dashboard'). "
                "Use 'default' or 'lovelace' for the default dashboard. "
                "New dashboards must use a hyphenated path."
            ),
        ],
        config: Annotated[
            dict[str, Any] | None,
            JSON_STRING_COERCION,
            Field(
                description="Dashboard configuration with views and cards. "
                "Omit or set to None to create dashboard without initial config. "
                "Mutually exclusive with python_transform."
            ),
        ] = None,
        python_transform: Annotated[
            str | None,
            Field(
                description="Python expression to transform existing dashboard config. "
                "Mutually exclusive with config. "
                "Requires config_hash for validation. "
                "See PYTHON TRANSFORM SECURITY below for allowed operations. "
                "Examples: "
                "Simple: python_transform=\"config['views'][0]['cards'][0]['icon'] = 'mdi:lamp'\" "
                "Pattern: python_transform=\"for card in config['views'][0]['cards']: if 'light' in card.get('entity', ''): card['icon'] = 'mdi:lightbulb'\" "
                "Multi-op: python_transform=\"config['views'][0]['cards'][0]['icon'] = 'mdi:lamp'; del config['views'][0]['cards'][2]\" "
                "\n\n" + get_security_documentation(),
            ),
        ] = None,
        config_hash: Annotated[
            str | None,
            Field(
                description="Config hash from ha_config_get_dashboard for optimistic locking. "
                "REQUIRED for python_transform (validates dashboard unchanged). "
                "Optional for config (validates before full replacement if provided)."
            ),
        ] = None,
        title: Annotated[
            str | None,
            Field(description="Dashboard display name shown in sidebar"),
        ] = None,
        icon: Annotated[
            str | None,
            Field(
                description="MDI icon name (e.g., 'mdi:home', 'mdi:cellphone'). "
                "Defaults to 'mdi:view-dashboard'"
            ),
        ] = None,
        require_admin: Annotated[
            bool | None,
            Field(
                description="Restrict dashboard to admin users only. "
                "For existing dashboards, only updated when explicitly provided."
            ),
        ] = None,
        show_in_sidebar: Annotated[
            bool | None,
            Field(
                description="Show dashboard in sidebar navigation. "
                "For existing dashboards, only updated when explicitly provided."
            ),
        ] = None,
        MandatoryBPS: Annotated[
            bool,
            Field(default=True),
        ] = True,
        return_screenshot: Annotated[
            bool,
            Field(
                description="After writing, also return a rendered PNG of the "
                "dashboard so you can see what it looks like in a single call "
                "(the dashboard creation/iteration loop). Requires the "
                "'dashboard screenshot' beta feature + engine add-on/sidecar; "
                "if unavailable, the write result is returned with a warning."
            ),
        ] = False,
        full_page: Annotated[
            bool,
            Field(description=f"With return_screenshot: {FULL_PAGE_PARAM_DESC}."),
        ] = False,
    ) -> "dict[str, Any] | ToolResult":
        """
        Create or update a Home Assistant dashboard. MUST call ha_get_skill_guide first.

        Creates a new dashboard or updates an existing one with the provided configuration.
        Supports two modes: full config replacement OR Python transformation.

        Use 'default' or 'lovelace' to target the built-in default dashboard.
        New dashboards require a hyphenated url_path (e.g., 'my-dashboard').

        WHEN TO USE WHICH MODE:
        - python_transform: RECOMMENDED for edits. Surgical/pattern-based updates, works on all platforms.
        - config: New dashboards only, or full restructure. Replaces everything.

        IMPORTANT: After delete/add operations, indices shift! Subsequent python_transform calls
        must use fresh config_hash from ha_config_get_dashboard()
        to get updated structure. Chain multiple ops in ONE expression when possible.

        TIP: Use ha_config_get_dashboard(entity_id=...) to get the path for any card.

        PYTHON TRANSFORM EXAMPLES (RECOMMENDED):
        - Update card icon: 'config["views"][0]["cards"][0]["icon"] = "mdi:thermometer"'
        - Add card: 'config["views"][0]["cards"].append({"type": "button", "entity": "light.bedroom"})'
        - Delete card: 'del config["views"][0]["cards"][2]'
        - Pattern-based update: 'for card in config["views"][0]["cards"]: if "light" in card.get("entity", ""): card["icon"] = "mdi:lightbulb"'
        - Multi-operation: 'config["views"][0]["cards"][0]["icon"] = "mdi:a"; config["views"][0]["cards"][1]["icon"] = "mdi:b"'

        MODERN DASHBOARD BEST PRACTICES:
        - Use "sections" view type (default) with grid-based layouts
        - Use "tile" cards as primary card type (replaces legacy entity/light/climate cards)
        - Use "grid" cards for multi-column layouts within sections
        - Create multiple views with navigation paths (avoid single-view endless scrolling)
        - Use "area" cards with navigation for hierarchical organization

        DISCOVERING ENTITY IDs FOR DASHBOARDS:
        Do NOT guess entity IDs - use these tools to find exact entity IDs:
        1. ha_get_overview(include_entity_id=True) - Get all entities organized by domain/area
        2. ha_search(query, domain_filter, area_filter, search_types) - Find entities and config-body references in one call

        If unsure about entity IDs, ALWAYS use one of these tools first.

        DASHBOARD DOCUMENTATION:
        - dashboard-guide.md and dashboard-cards.md ship in this response
          under ``skill_content`` by default — layout patterns,
          card-type taxonomy, and worked examples.
        - ha_get_skill_guide — deeper card-type and configuration guidance.

        EXAMPLES:

        Create empty dashboard:
        ha_config_set_dashboard(
            url_path="mobile-dashboard",
            title="Mobile View",
            icon="mdi:cellphone"
        )

        Create dashboard with modern sections view:
        ha_config_set_dashboard(
            url_path="home-dashboard",
            title="Home Overview",
            config={
                "views": [{
                    "title": "Home",
                    "type": "sections",
                    "sections": [{
                        "title": "Climate",
                        "cards": [{
                            "type": "tile",
                            "entity": "climate.living_room",
                            "features": [{"type": "target-temperature"}]
                        }]
                    }]
                }]
            }
        )

        Create strategy-based dashboard (auto-generated):
        ha_config_set_dashboard(
            url_path="my-home",
            title="My Home",
            config={
                "strategy": {
                    "type": "home",
                    "favorite_entities": ["light.bedroom"]
                }
            }
        )

        Note: Strategy dashboards cannot be converted to custom dashboards via this tool.
        Use the "Take Control" feature in the Home Assistant interface to convert them.

        Update existing dashboard config:
        ha_config_set_dashboard(
            url_path="existing-dashboard",
            config={
                "views": [{
                    "title": "Updated View",
                    "type": "sections",
                    "sections": [{
                        "cards": [{"type": "markdown", "content": "Updated!"}]
                    }]
                }]
            }
        )

        Note: When updating an existing dashboard, title/icon/require_admin/show_in_sidebar
        are also updated if explicitly provided alongside (or instead of) a config change.

        STORAGE-MODE vs YAML-MODE DASHBOARDS:
        This tool only manages storage-mode dashboards (created via UI/API and stored in
        Home Assistant's storage backend). It does NOT touch YAML-defined dashboards.
        Two distinct YAML cases exist and this tool covers neither:
        - "YAML-mode" dashboards: written in their own .yaml file referenced from
          configuration.yaml under ``lovelace: dashboards:``. The dashboard itself lives
          in a separate YAML file but its registration is in configuration.yaml.
        - Dashboards inlined directly in ``configuration.yaml`` under the ``lovelace:``
          key (legacy single-dashboard mode).
        For either YAML case, edit the dashboard's .yaml file directly.
        ``ha_config_set_yaml`` can update the ``lovelace:`` registration
        entry in configuration.yaml but does NOT touch the dashboard
        body in the referenced .yaml file.
        """
        try:
            # ``url_path`` is required (always non-None). Reject empty/
            # whitespace up-front so the caller gets a structured parameter
            # error instead of a misleading downstream failure (the
            # subsequent "default" alias, pre-resolver, and hyphen check
            # all assume a usable string). Extension of the #1312
            # validate_identifier_not_empty pattern to the dashboards
            # family per #1313.
            validate_identifier_not_empty(
                url_path,
                "url_path",
                suggestions=[
                    "Pass a dashboard URL path (e.g. 'my-dashboard')",
                    "Use 'default' or 'lovelace' for the default dashboard",
                ],
                context={"action": "set"},
            )
            # Handle "default" as alias for the default dashboard
            # (matches ha_config_get_dashboard behavior)
            if url_path == "default":
                url_path = "lovelace"

            # Pre-resolve internal dashboard ID to url_path form before the
            # hyphen check below, so callers may pass either form. Only fires
            # when the identifier looks like an internal id (no hyphen, not
            # the built-in "lovelace") and matches a known dashboard.
            #
            # Caveat: if a caller passes a hyphenless identifier intending
            # to *create* a new dashboard, but it happens to match an
            # existing dashboard's id, the rewrite silently re-targets the
            # operation onto that existing dashboard. Pre-PR they'd have
            # hit the hyphen-validation error and known their input was
            # invalid; now the create-vs-update distinction depends on
            # whether the registry happens to contain a matching id.
            # We log the rewrite and surface the original identifier as
            # ``resolved_from`` on the success response so callers can
            # detect this redirect.
            pre_resolved_from: str | None = None
            # When the pre-resolver fires and finds a match, ``_resolve_dashboard``
            # has already fetched ``lovelace/dashboards/list``. Capture that list
            # so the existence-check site below can reuse it instead of paying
            # a second round-trip.
            pre_fetched_dashboards: list[dict[str, Any]] | None = None
            if "-" not in url_path and url_path != "lovelace":
                resolved, dashboards = await _resolve_dashboard(client, url_path)
                if resolved is not None and resolved["url_path"]:
                    original_url_path = url_path
                    url_path = resolved["url_path"]
                    pre_resolved_from = original_url_path
                    pre_fetched_dashboards = dashboards
                    logger.info(
                        "ha_config_set_dashboard pre-resolver mapped %r -> %r",
                        original_url_path,
                        url_path,
                    )

            # Validate url_path contains hyphen for new dashboards
            # The built-in "lovelace" dashboard is exempt since it already exists
            if "-" not in url_path and url_path != "lovelace":
                raise_tool_error(
                    create_error_response(
                        ErrorCode.VALIDATION_INVALID_PARAMETER,
                        "url_path must contain a hyphen (-)",
                        suggestions=[
                            f"Try '{url_path.replace('_', '-')}' instead",
                            "Use format like 'my-dashboard' or 'mobile-view'",
                            "Use 'lovelace' or 'default' to edit the default dashboard",
                        ],
                        context={"action": "set", "url_path": url_path},
                    )
                )

            # Validate mutual exclusivity of config and python_transform
            if config is not None and python_transform is not None:
                raise_tool_error(
                    create_error_response(
                        ErrorCode.VALIDATION_INVALID_PARAMETER,
                        "Cannot use both config and python_transform simultaneously",
                        suggestions=[
                            "Use only ONE of: config or python_transform",
                            "config: Full replacement",
                            "python_transform: Python-based edits (recommended)",
                        ],
                        context={"action": "set", "url_path": url_path},
                    )
                )

            # Handle python_transform mode
            if python_transform is not None:
                # config_hash is REQUIRED
                if config_hash is None:
                    raise_tool_error(
                        create_error_response(
                            ErrorCode.VALIDATION_INVALID_PARAMETER,
                            "config_hash is required for python_transform",
                            suggestions=[
                                "Call ha_config_get_dashboard() first",
                                "Use the config_hash from that response",
                            ],
                            context={
                                "action": "python_transform",
                                "url_path": url_path,
                            },
                        )
                    )

                # Fetch current dashboard config + hash via the shared helper.
                # Re-wrap helper's generic fetch error with python_transform-
                # specific UX suggestions so the caller learns this branch
                # requires an existing dashboard.
                try:
                    current_config, current_hash = await _get_dashboard_config_internal(
                        client, url_path
                    )
                except ToolError as e:
                    raise_tool_error(
                        create_error_response(
                            ErrorCode.SERVICE_CALL_FAILED,
                            f"Dashboard not found or inaccessible: {extract_tool_error_message(e)}",
                            suggestions=[
                                "python_transform requires an existing dashboard",
                                "Use 'config' parameter to create a new dashboard",
                                "Verify dashboard exists with ha_config_get_dashboard(list_only=True)",
                            ],
                            context={
                                "action": "python_transform",
                                "url_path": url_path,
                            },
                        )
                    )

                # Validate config_hash for optimistic locking
                if current_hash != config_hash:
                    raise_tool_error(
                        create_error_response(
                            ErrorCode.SERVICE_CALL_FAILED,
                            "Dashboard modified since last read (conflict)",
                            suggestions=[
                                "Call ha_config_get_dashboard() again",
                                "Use the fresh config_hash from that response",
                            ],
                            context={
                                "action": "python_transform",
                                "url_path": url_path,
                            },
                        )
                    )

                # Apply Python transformation with validation
                try:
                    transformed_config = safe_execute(python_transform, current_config)
                except PythonSandboxError as e:
                    message, suggestions = format_sandbox_error(e, python_transform)
                    # A path-shape mismatch (IndexError/KeyError) is almost always
                    # a hallucinated path; steer the retry toward search mode so
                    # the next transform is built from a verified python_path.
                    if isinstance(e, PythonSandboxExecutionError) and isinstance(
                        e.__cause__, (IndexError, KeyError)
                    ):
                        suggestions = [
                            "Call ha_config_get_dashboard with card_type=..., "
                            "entity_id=..., or heading=... to get the verified "
                            "python_path for the target card, then build "
                            "python_transform from that path",
                            *suggestions,
                        ]
                    raise_tool_error(
                        create_error_response(
                            ErrorCode.VALIDATION_FAILED,
                            message,
                            suggestions=suggestions,
                            context={
                                "action": "python_transform",
                                "url_path": url_path,
                            },
                        )
                    )

                # Save transformed config
                save_data: dict[str, Any] = {
                    "type": "lovelace/config/save",
                    "config": transformed_config,
                }
                if url_path:
                    save_data["url_path"] = url_path

                save_result = await client.send_websocket_message(save_data)

                if isinstance(save_result, dict) and not save_result.get(
                    "success", True
                ):
                    error_msg = save_result.get("error", {})
                    if isinstance(error_msg, dict):
                        error_msg = error_msg.get("message", str(error_msg))
                    raise_tool_error(
                        create_error_response(
                            ErrorCode.SERVICE_CALL_FAILED,
                            f"Failed to save transformed config: {error_msg}",
                            suggestions=[
                                "Expression may have produced invalid dashboard structure",
                                "Verify config format is valid Lovelace JSON",
                            ],
                            context={
                                "action": "python_transform",
                                "url_path": url_path,
                            },
                        )
                    )

                # Re-fetch to get authoritative hash (HA may normalize after save)
                _, new_config_hash = await _get_dashboard_config_internal(
                    client, url_path
                )

                transform_result: dict[str, Any] = {
                    "success": True,
                    "action": "python_transform",
                    "url_path": url_path,
                    "config_hash": new_config_hash,
                    "python_expression": python_transform,
                    "message": f"Dashboard {url_path} updated via Python transform",
                }
                if pre_resolved_from is not None:
                    transform_result["resolved_from"] = pre_resolved_from
                _attach_dashboard_skill(transform_result, MandatoryBPS)
                return transform_result

            # Check if dashboard exists. When the pre-resolver fired
            # and matched (internal-id branch), reuse its already-fetched
            # ``lovelace/dashboards/list`` response to skip a redundant
            # round-trip — the matched dashboard is guaranteed present in
            # that list.
            if pre_fetched_dashboards is not None:
                existing_dashboards = pre_fetched_dashboards
            else:
                existing_dashboards = await fetch_dashboards_list(client) or []
            dashboard_exists = any(
                d.get("url_path") == url_path for d in existing_dashboards
            )

            # The built-in default dashboard ("lovelace") is always present
            # but isn't listed by lovelace/dashboards/list on fresh installs
            if url_path == "lovelace":
                dashboard_exists = True

            # If dashboard doesn't exist, create it
            dashboard_id = None
            metadata_updated = False
            hint = None
            if not dashboard_exists:
                # Use provided title or generate from url_path
                dashboard_title = title or url_path.replace("-", " ").title()

                # Build create message
                create_data: dict[str, Any] = {
                    "type": "lovelace/dashboards/create",
                    "url_path": url_path,
                    "title": dashboard_title,
                    "require_admin": require_admin
                    if require_admin is not None
                    else False,
                    "show_in_sidebar": show_in_sidebar
                    if show_in_sidebar is not None
                    else True,
                }
                if icon:
                    create_data["icon"] = icon
                create_result = await client.send_websocket_message(create_data)

                # Check if dashboard creation was successful
                if isinstance(create_result, dict) and not create_result.get(
                    "success", True
                ):
                    error_msg = create_result.get("error", {})
                    if isinstance(error_msg, dict):
                        error_msg = error_msg.get("message", str(error_msg))
                    raise_tool_error(
                        create_error_response(
                            ErrorCode.SERVICE_CALL_FAILED,
                            str(error_msg),
                            context={"action": "create", "url_path": url_path},
                        )
                    )

                # Extract dashboard ID from create response
                if isinstance(create_result, dict) and "result" in create_result:
                    dashboard_info = create_result["result"]
                    dashboard_id = dashboard_info.get("id")
                elif isinstance(create_result, dict):
                    dashboard_id = create_result.get("id")
            else:
                # If dashboard already exists, get its ID from the list
                for dashboard in existing_dashboards:
                    if dashboard.get("url_path") == url_path:
                        dashboard_id = dashboard.get("id")
                        break

                # Update metadata for existing dashboard if any metadata params provided
                metadata_update_fields: dict[str, Any] = {
                    k: v
                    for k, v in {
                        "title": title,
                        "icon": icon,
                        "require_admin": require_admin,
                        "show_in_sidebar": show_in_sidebar,
                    }.items()
                    if v is not None
                }
                if metadata_update_fields and dashboard_id is not None:
                    meta_update: dict[str, Any] = {
                        "type": "lovelace/dashboards/update",
                        "dashboard_id": dashboard_id,
                        **metadata_update_fields,
                    }
                    meta_result = await client.send_websocket_message(meta_update)
                    if isinstance(meta_result, dict) and not meta_result.get(
                        "success", True
                    ):
                        error_msg = meta_result.get("error", {})
                        if isinstance(error_msg, dict):
                            error_msg = error_msg.get("message", str(error_msg))
                        raise_tool_error(
                            create_error_response(
                                code=ErrorCode.SERVICE_CALL_FAILED,
                                message=f"Failed to update dashboard metadata: {error_msg}",
                                suggestions=[
                                    "Check that you have admin permissions",
                                    "Verify dashboard is in storage mode (not YAML mode)",
                                ],
                                context={"action": "update", "url_path": url_path},
                            )
                        )
                    metadata_updated = True
                elif metadata_update_fields and dashboard_id is None:
                    # Dashboard ID not found in storage list (e.g. default lovelace on
                    # fresh installs). Metadata update via lovelace/dashboards/update
                    # is not possible without a storage ID — config update still proceeds.
                    metadata_updated = False
                    hint = (
                        "Metadata fields were provided but could not be applied: "
                        "dashboard has no storage ID (likely the built-in default dashboard). "
                        "Config changes were still saved."
                    )

            # Set config if provided
            config_updated = False
            existing_config_size = 0

            if config is not None:
                parsed_config = parse_json_param(config, "config")
                if parsed_config is None or not isinstance(parsed_config, dict):
                    raise_tool_error(
                        create_error_response(
                            ErrorCode.VALIDATION_INVALID_PARAMETER,
                            "Config parameter must be a dict/object",
                            context={
                                "action": "set",
                                "provided_type": type(parsed_config).__name__,
                            },
                        )
                    )

                config_dict = cast(dict[str, Any], parsed_config)

                # For existing dashboards, optionally validate config_hash and warn on large replacement
                if dashboard_exists:
                    # Fetch current config + hash via the shared helper.
                    # Tolerate fetch failures here — full-config replacement
                    # should still proceed even if the pre-read can't load
                    # the current state (force-replace path). The strict
                    # ``ToolError`` raised by the helper is downgraded to a
                    # skip of both the optimistic-locking check and the
                    # large-config soft warning, matching the prior
                    # silently-fall-through behaviour.
                    # Distinct names from the python_transform branch's
                    # ``current_config``/``current_hash`` so the optional
                    # type here doesn't redefine the non-optional binding
                    # mypy infers there.
                    existing_config: dict[str, Any] | None = None
                    existing_hash: str | None = None
                    try:
                        (
                            existing_config,
                            existing_hash,
                        ) = await _get_dashboard_config_internal(client, url_path)
                    except ToolError:
                        # Pre-read failure is non-fatal on the force-replace
                        # path: skip the optimistic-lock check and large-config
                        # warning and proceed with the replacement (see the
                        # rationale above the try).
                        pass

                    if isinstance(existing_config, dict):
                        existing_config_size = len(json.dumps(existing_config))

                        # Optional config_hash validation for full replacement
                        if config_hash is not None and existing_hash != config_hash:
                            raise_tool_error(
                                create_error_response(
                                    ErrorCode.SERVICE_CALL_FAILED,
                                    "Dashboard modified since last read (conflict)",
                                    suggestions=[
                                        "Call ha_config_get_dashboard() again",
                                        "Use the fresh config_hash, or omit config_hash to force replace",
                                    ],
                                    context={"action": "set", "url_path": url_path},
                                )
                            )

                        # Soft warning for large config full replacement (10KB ≈ 2-3k tokens)
                        if existing_config_size >= 10000:
                            hint = (
                                f"Replaced large config ({existing_config_size:,} bytes). "
                                "Consider python_transform for targeted edits."
                            )

                # Build save config message
                config_save_data: dict[str, Any] = {
                    "type": "lovelace/config/save",
                    "config": config_dict,
                }
                if url_path:
                    config_save_data["url_path"] = url_path
                save_result = await client.send_websocket_message(config_save_data)

                # Check if save failed
                if isinstance(save_result, dict) and not save_result.get(
                    "success", True
                ):
                    error_msg = save_result.get("error", {})
                    if isinstance(error_msg, dict):
                        error_msg = error_msg.get("message", str(error_msg))
                    raise_tool_error(
                        create_error_response(
                            ErrorCode.SERVICE_CALL_FAILED,
                            f"Failed to save dashboard config: {error_msg}",
                            suggestions=[
                                "Verify config format is valid Lovelace JSON",
                                "Check that you have admin permissions",
                                "Ensure all entity IDs in config exist",
                            ],
                            context={"action": "set", "url_path": url_path},
                        )
                    )

                config_updated = True

            result_dict: dict[str, Any] = {
                "success": True,
                "action": "create" if not dashboard_exists else "update",
                "url_path": url_path,
                "dashboard_id": dashboard_id,
                "dashboard_created": not dashboard_exists,
                "config_updated": config_updated,
                "metadata_updated": metadata_updated,
                "message": f"Dashboard {url_path} {'created' if not dashboard_exists else 'updated'} successfully",
            }

            if hint:
                result_dict["hint"] = hint
            if pre_resolved_from is not None:
                # Caller passed an internal id; pre-resolver mapped it to
                # the canonical url_path. Surface the original so a caller
                # who *intended* to create a new dashboard can detect that
                # an existing dashboard was updated instead.
                result_dict["resolved_from"] = pre_resolved_from

            _attach_dashboard_skill(result_dict, MandatoryBPS)
            return await _maybe_attach_screenshot(
                result_dict, url_path, return_screenshot, full_page=full_page
            )

        except ToolError as te:
            raise augment_tool_error_with_skill_content(te, bp_warnings=None) from None
        except Exception as e:
            error = exception_to_structured_error(
                e,
                context={"action": "set", "url_path": url_path},
                suggestions=[
                    "Ensure url_path is unique (not already in use for different dashboard type)",
                    "New dashboards require a hyphenated url_path",
                    "Check that you have admin permissions",
                    "Verify config format is valid Lovelace JSON",
                ],
                raise_error=False,
            )
            augment_error_dict_with_skill_content(error, bp_warnings=None)
            raise_tool_error(error)
            return None

    @mcp.tool(
        tags={"Dashboards"},
        annotations={"destructiveHint": True, "title": "Delete Dashboard"},
    )
    @with_auto_backup(domain="dashboard", id_param="url_path", client=client)
    @log_tool_usage
    async def ha_config_delete_dashboard(
        url_path: Annotated[
            str,
            Field(
                description="Dashboard URL path or internal ID to delete "
                "(e.g., 'my-dashboard' or 'my_dashboard'). Both forms are accepted."
            ),
        ],
    ) -> dict[str, Any]:
        """
        Delete a storage-mode dashboard completely.

        WARNING: This permanently deletes the dashboard and all its configuration.
        Cannot be undone. Does not work on YAML-mode dashboards.

        Accepts either the URL path or the internal dashboard ID. HA internal IDs
        may differ from url_path (e.g. hyphens → underscores); the tool resolves
        either form to the actual registry ID before deletion.

        EXAMPLES:
        - Delete dashboard: ha_config_delete_dashboard("mobile-dashboard")

        Note: The default dashboard cannot be deleted via this method.
        """
        try:
            # ``url_path`` is required. Reject empty/whitespace up-front so
            # the caller gets a structured parameter error instead of a
            # misleading "no dashboard found" from the resolver below.
            # Extension of the #1312 validate_identifier_not_empty pattern
            # to the dashboards family per #1313.
            validate_identifier_not_empty(
                url_path,
                "url_path",
                suggestions=[
                    "Pass a dashboard URL path or internal ID (e.g. 'my-dashboard')",
                    "Use ha_config_get_dashboard(list_only=True) to list dashboards",
                ],
                context={"action": "delete"},
            )
            resolved, dashboards = await _resolve_dashboard(client, url_path)
            if resolved is None:
                available_ids = [
                    d.get("url_path")
                    for d in (dashboards or [])[:10]
                    if d.get("url_path")
                ]
                raise_tool_error(
                    create_error_response(
                        ErrorCode.RESOURCE_NOT_FOUND,
                        f"Dashboard '{url_path}' not found",
                        details=f"No dashboard found with URL path or internal ID '{url_path}'.",
                        suggestions=[
                            "Use ha_config_get_dashboard(list_only=True) to see available dashboards",
                            "YAML-mode and default dashboards are not deletable via this tool",
                        ],
                        context={
                            "action": "delete",
                            "url_path": url_path,
                            "available_dashboard_ids": available_ids,
                        },
                    )
                )
            resolved_id = resolved["id"]

            response = await client.send_websocket_message(
                {"type": "lovelace/dashboards/delete", "dashboard_id": resolved_id}
            )

            # Check response for error indication
            if isinstance(response, dict) and not response.get("success", True):
                error_msg = response.get("error", {})
                if isinstance(error_msg, dict):
                    error_str = error_msg.get("message", str(error_msg))
                else:
                    error_str = str(error_msg)

                # If the error is "not found" / "doesn't exist", treat as success (idempotent)
                if (
                    "unable to find" in error_str.lower()
                    or "not found" in error_str.lower()
                ):
                    return {
                        "success": True,
                        "action": "delete",
                        "url_path": url_path,
                        "message": "Dashboard already deleted or does not exist",
                    }

                # For other errors, raise
                raise_tool_error(
                    create_error_response(
                        ErrorCode.SERVICE_CALL_FAILED,
                        f"Failed to delete dashboard: {error_str}",
                        suggestions=[
                            "Verify dashboard exists and is storage-mode",
                            "Check that you have admin permissions",
                            "Use ha_config_get_dashboard(list_only=True) to see available dashboards",
                            "Cannot delete YAML-mode or default dashboard",
                        ],
                        context={"action": "delete", "url_path": url_path},
                    )
                )

            # Delete successful
            result: dict[str, Any] = {
                "success": True,
                "action": "delete",
                "url_path": url_path,
                "message": "Dashboard deleted successfully",
            }
            if resolved_id != url_path:
                result["resolved_id"] = resolved_id
            return result
        except ToolError:
            raise
        except Exception as e:
            exception_to_structured_error(
                e,
                context={"action": "delete", "url_path": url_path},
                suggestions=[
                    "Verify dashboard exists and is storage-mode",
                    "Check that you have admin permissions",
                    "Use ha_config_get_dashboard(list_only=True) to see available dashboards",
                    "Cannot delete YAML-mode or default dashboard",
                ],
            )
        return None  # py/mixed-returns: explicit terminal; error handlers above always raise (NoReturn), unreachable

    # =========================================================================
    # Dashboard Resource Management Tools
    # =========================================================================
    # Resource tools have been moved to tools_resources.py for better organization.
    # Available tools:
    # - ha_config_list_dashboard_resources: List all resources
    # - ha_config_set_dashboard_resource: Create/update resources (inline code or URL)
    # - ha_config_delete_dashboard_resource: Delete resources
    # =========================================================================
