"""WebSocket API for the TaskMate admin panel.

The panel speaks to the integration via these commands rather than via HA
services — services are intended for automation/templating consumers and
would clutter the service registry with two dozen panel-only entries.

Read:
    taskmate/get_state          — full snapshot

Children:
    taskmate/add_child          — name, avatar?, availability_entity?
    taskmate/update_child       — child_id + any of the above
    taskmate/remove_child       — child_id

Chores:
    taskmate/add_chore          — name + many optional fields
    taskmate/update_chore       — chore_id + any of the above
    taskmate/remove_chore       — chore_id

Rewards:
    taskmate/add_reward         — name + cost + many optional fields
    taskmate/update_reward      — reward_id + any of the above
    taskmate/remove_reward      — reward_id

Penalties / Bonuses (same shape):
    taskmate/add_penalty        — name + points + optional
    taskmate/update_penalty     — penalty_id + any of the above
    taskmate/remove_penalty     — penalty_id
    taskmate/apply_penalty      — penalty_id + child_id (operational, exposed for convenience)
    (and the bonus equivalents)

Task groups:
    taskmate/add_task_group     — name, policy, chore_ids?
    taskmate/update_task_group  — group_id + any of the above
    taskmate/remove_task_group  — group_id

Ordering:
    taskmate/set_chore_order        — child_id + chore_order (per-child)
    taskmate/set_global_chore_order — chore_order (admin panel display order)

Settings:
    taskmate/update_settings    — partial dict of {points_name, points_icon, history_days, streak_reset_mode, ...}

All commands require admin. Mutations write through coordinator methods so
TaskMate's existing business logic (refunds, cleanup, recompute) runs.
"""

from __future__ import annotations

import logging
import re
from datetime import date
from functools import wraps
from typing import Any, Final

import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant

from . import images, photos
from .const import (
    ASSIGNMENT_MODES,
    DEFAULT_NOTIFICATION_GROUP,
    DEFAULT_NOTIFICATION_NAV_URL,
    DEFAULT_TIME_PERIODS,
    DIFFICULTY_TIERS,
    DOMAIN,
    MAX_TIME_PERIODS,
    SCHEDULE_MODES,
    TIME_CATEGORY_ICONS,
)
from .coordinator import TaskMateCoordinator
from .models import BonusSubTask, Reward

_LOGGER = logging.getLogger(__name__)

WS_REGISTERED: Final = "ws_registered"

# --- command names ---------------------------------------------------------
WS_GET_STATE: Final = "taskmate/get_state"

WS_ADD_CHILD: Final = "taskmate/add_child"
WS_UPDATE_CHILD: Final = "taskmate/update_child"
WS_REMOVE_CHILD: Final = "taskmate/remove_child"
WS_LIST_HA_USERS: Final = "taskmate/list_ha_users"

WS_ADD_CHORE: Final = "taskmate/add_chore"
WS_UPDATE_CHORE: Final = "taskmate/update_chore"
WS_REMOVE_CHORE: Final = "taskmate/remove_chore"

WS_REPORT_FAIRNESS: Final = "taskmate/reports/fairness"
WS_REPORT_FRICTION: Final = "taskmate/reports/friction"
WS_REPORT_PROJECTION: Final = "taskmate/reports/projection"
WS_REPORT_HEALTH: Final = "taskmate/reports/health"
WS_SCHEDULED_LIST: Final = "taskmate/scheduled/list"
WS_SCHEDULED_ADD: Final = "taskmate/scheduled/add"
WS_SCHEDULED_REMOVE: Final = "taskmate/scheduled/remove"

WS_ADD_REWARD: Final = "taskmate/add_reward"
WS_UPDATE_REWARD: Final = "taskmate/update_reward"
WS_REMOVE_REWARD: Final = "taskmate/remove_reward"

WS_ADD_PENALTY: Final = "taskmate/add_penalty"
WS_UPDATE_PENALTY: Final = "taskmate/update_penalty"
WS_REMOVE_PENALTY: Final = "taskmate/remove_penalty"
WS_APPLY_PENALTY: Final = "taskmate/apply_penalty"

WS_ADD_BONUS: Final = "taskmate/add_bonus"
WS_UPDATE_BONUS: Final = "taskmate/update_bonus"
WS_REMOVE_BONUS: Final = "taskmate/remove_bonus"
WS_APPLY_BONUS: Final = "taskmate/apply_bonus"

WS_CREATE_QUEST: Final = "taskmate/create_quest"
WS_UPDATE_QUEST: Final = "taskmate/update_quest"
WS_DELETE_QUEST: Final = "taskmate/delete_quest"

WS_UPDATE_AVATAR_CATALOG: Final = "taskmate/update_avatar_catalog"
WS_SET_CHILD_AVATAR: Final = "taskmate/set_child_avatar"

WS_CREATE_CHALLENGE: Final = "taskmate/create_challenge"
WS_UPDATE_CHALLENGE: Final = "taskmate/update_challenge"
WS_DELETE_CHALLENGE: Final = "taskmate/delete_challenge"

WS_ADD_TASK_GROUP: Final = "taskmate/add_task_group"
WS_UPDATE_TASK_GROUP: Final = "taskmate/update_task_group"
WS_REMOVE_TASK_GROUP: Final = "taskmate/remove_task_group"

WS_UPDATE_SETTINGS: Final = "taskmate/update_settings"

# Operational
WS_COMPLETE_BONUS_SUBTASK: Final = "taskmate/complete_bonus_subtask"
WS_APPROVE_CHORE: Final = "taskmate/approve_chore"
WS_APPROVE_ALL_CHORES: Final = "taskmate/approve_all_chores"
WS_REJECT_CHORE: Final = "taskmate/reject_chore"
WS_APPROVE_REWARD: Final = "taskmate/approve_reward"
WS_REJECT_REWARD: Final = "taskmate/reject_reward"
WS_SET_CHORE_ORDER: Final = "taskmate/set_chore_order"
WS_SET_GLOBAL_CHORE_ORDER: Final = "taskmate/set_global_chore_order"
WS_ADD_CHORES_BULK: Final = "taskmate/add_chores_bulk"
WS_PARENT_COMPLETE_CHORE: Final = "taskmate/parent_complete_chore"

# Templates
WS_TEMPLATES_LIST: Final = "taskmate/templates/list"
WS_TEMPLATES_GET: Final = "taskmate/templates/get"
WS_TEMPLATES_APPLY: Final = "taskmate/templates/apply"
WS_TEMPLATES_SAVE_FROM: Final = "taskmate/templates/save_from_chores"
WS_TEMPLATES_CREATE: Final = "taskmate/templates/create"
WS_TEMPLATES_UPDATE: Final = "taskmate/templates/update"
WS_TEMPLATES_EXPORT: Final = "taskmate/templates/export"
WS_TEMPLATES_IMPORT: Final = "taskmate/templates/import"
WS_PRINT_CHART: Final = "taskmate/print/weekly_chart"
WS_TEMPLATES_DELETE: Final = "taskmate/templates/delete"

# Notifications
WS_NOTIF_GET_STATE: Final = "taskmate/notifications/get_state"
WS_NOTIF_SET_MASTER: Final = "taskmate/notifications/set_master_enabled"
WS_NOTIF_SET_ROUTE: Final = "taskmate/notifications/set_route"
WS_NOTIF_SET_CHILD_NOTIFY: Final = "taskmate/notifications/set_child_notify"
WS_NOTIF_SET_CHILD_QUIET: Final = "taskmate/notifications/set_child_quiet"
WS_NOTIF_UPSERT_PARENT: Final = "taskmate/notifications/upsert_parent"
WS_NOTIF_DELETE_PARENT: Final = "taskmate/notifications/delete_parent"
WS_NOTIF_UPSERT_CUSTOM: Final = "taskmate/notifications/upsert_custom"
WS_NOTIF_DELETE_CUSTOM: Final = "taskmate/notifications/delete_custom"
WS_NOTIF_LIST_NOTIFY: Final = "taskmate/notifications/list_notify_services"
WS_NOTIF_SET_STREAK_CUTOFF: Final = "taskmate/notifications/set_streak_cutoff"
WS_NOTIF_SET_ESCALATION: Final = "taskmate/notifications/set_escalation"
WS_NOTIF_SEND_TEST: Final = "taskmate/notifications/send_test"
WS_NOTIF_SET_NAV_URL: Final = "taskmate/notifications/set_nav_url"
WS_NOTIF_SET_GROUP: Final = "taskmate/notifications/set_group"

# Calendar ICS feed (FEAT-10)
WS_CAL_GET_URL: Final = "taskmate/calendar/get_ics_url"
WS_CAL_REGEN_TOKEN: Final = "taskmate/calendar/regenerate_ics_token"

# Admin audit log
WS_AUDIT_LIST: Final = "taskmate/audit/list"
WS_AUDIT_CLEAR: Final = "taskmate/audit/clear"

# Undo / retract
WS_UNDO_TRANSACTION: Final = "taskmate/undo_transaction"

# Clone / duplicate
WS_CLONE_CHORE: Final = "taskmate/clone_chore"

# Bulk chore operations
WS_BULK_CHORE_ACTION: Final = "taskmate/bulk_chore_action"

# Inter-child gifting
WS_GIFT_POINTS: Final = "taskmate/gift_points"

# Sibling chore swaps
WS_REQUEST_SWAP: Final = "taskmate/request_swap"
WS_APPROVE_SWAP: Final = "taskmate/approve_swap"
WS_REJECT_SWAP: Final = "taskmate/reject_swap"

# Backup / restore
WS_CONFIG_EXPORT: Final = "taskmate/config/export"
WS_CONFIG_IMPORT: Final = "taskmate/config/import"

# Read-only / audit-management commands that should NOT themselves be audited.
# Everything else routed through @_admin_only mutates state and is logged.
_AUDIT_EXCLUDE: Final = {
    WS_GET_STATE,
    WS_NOTIF_GET_STATE,
    WS_NOTIF_LIST_NOTIFY,
    WS_TEMPLATES_LIST,
    WS_TEMPLATES_GET,
    WS_TEMPLATES_EXPORT,
    WS_PRINT_CHART,
    WS_AUDIT_LIST,
    WS_AUDIT_CLEAR,
    WS_CONFIG_EXPORT,
    WS_SCHEDULED_LIST,
    WS_REPORT_FAIRNESS,
    WS_REPORT_FRICTION,
    WS_REPORT_PROJECTION,
    WS_REPORT_HEALTH,
}


def _audit_target(coordinator, msg: dict) -> str:
    """Best-effort human-readable target for an admin action from its payload."""
    name = msg.get("name")
    if isinstance(name, str) and name.strip():
        return name.strip()
    for key, getter in (
        ("chore_id", coordinator.get_chore),
        ("child_id", coordinator.get_child),
        ("reward_id", coordinator.get_reward),
    ):
        val = msg.get(key)
        if val:
            obj = getter(val)
            return getattr(obj, "name", None) or str(val)
    for key in (
        "penalty_id",
        "bonus_id",
        "badge_id",
        "group_id",
        "template_id",
        "completion_id",
        "claim_id",
        "parent_id",
        "custom_id",
        "awarded_badge_id",
        "type_id",
        "transaction_id",
    ):
        if msg.get(key):
            return str(msg[key])
    return ""


def _get_coordinator(hass: HomeAssistant) -> TaskMateCoordinator | None:
    for value in hass.data.get(DOMAIN, {}).values():
        if isinstance(value, TaskMateCoordinator):
            return value
    return None


def _admin_only(handler):
    """Enforce admin + coordinator availability + uniform error reporting."""

    @wraps(handler)
    async def wrapper(hass, connection, msg):
        if not connection.user.is_admin:
            connection.send_error(msg["id"], websocket_api.const.ERR_UNAUTHORIZED, "Admin only")
            return
        coordinator = _get_coordinator(hass)
        if not coordinator:
            connection.send_error(msg["id"], "no_coordinator", "TaskMate not initialised")
            return
        try:
            await handler(hass, connection, msg, coordinator)
        except vol.Invalid as err:
            connection.send_error(msg["id"], "invalid_args", str(err))
        except ValueError as err:
            connection.send_error(msg["id"], "invalid", str(err))
        except Exception as err:  # noqa: BLE001
            _LOGGER.exception("WS handler %s failed", msg.get("type"))
            connection.send_error(msg["id"], "handler_failed", str(err))
        else:
            # Record successful mutating commands in the admin audit log.
            mtype = msg.get("type", "")
            if mtype not in _AUDIT_EXCLUDE:
                try:
                    user = connection.user
                    await coordinator.async_record_audit(
                        getattr(user, "id", ""),
                        getattr(user, "name", "") or "",
                        mtype.split("taskmate/", 1)[-1],
                        _audit_target(coordinator, msg),
                    )
                except Exception:  # noqa: BLE001
                    _LOGGER.debug("audit record failed for %s", msg.get("type"), exc_info=True)

    return wrapper


# ---------------------------------------------------------------------------
# Validators / coercers
# ---------------------------------------------------------------------------


def _opt_str(v: Any) -> str:
    """Coerce optional string field to stripped str (or empty)."""
    if v is None:
        return ""
    return str(v).strip()


# ---------------------------------------------------------------------------
# State snapshot
# ---------------------------------------------------------------------------


def _build_state_snapshot(coordinator: TaskMateCoordinator) -> dict[str, Any]:
    data = coordinator.storage.data
    completions = list(data.get("completions", []))
    # Sign evidence-photo URLs so the admin panel can render thumbnails and
    # open/save the full image — a plain <img>/navigation request carries no
    # bearer token, so the auth-gated serve view would 401. Copy each dict so
    # the expiring signed URL is never written back into storage.
    completions = [
        {**c, "photo_url": photos.sign_photo_url(coordinator.hass, c["photo_url"])} if c.get("photo_url") else c
        for c in completions
    ]
    reward_claims = list(data.get("reward_claims", []))
    transactions = list(data.get("points_transactions", []))

    parent_completable = {}
    for chore_dict in data.get("chores", []):
        chore_id = chore_dict.get("id")
        if not chore_id or not chore_dict.get("enabled", True):
            continue
        if chore_dict.get("schedule_mode", "specific_days") == "one_shot":
            continue
        parent_completable[chore_id] = True

    return {
        "version": "2",
        "children": list(data.get("children", [])),
        # Sign picture URLs so the panel's <img> loads — browsers do not
        # send the bearer token on image requests, so a bare URL 401s.
        "chores": [
            {**c, "image_url": images.sign_image_url(coordinator.hass, c["image_url"])} if c.get("image_url") else c
            for c in data.get("chores", [])
        ],
        "chore_display_order": list(data.get("chore_display_order", [])),
        "scheduled_changes": list(data.get("scheduled_changes", [])),
        "rewards": list(data.get("rewards", [])),
        "penalties": list(data.get("penalties", [])),
        "bonuses": list(data.get("bonuses", [])),
        "task_groups": list(data.get("task_groups", [])),
        "quests": list(data.get("quests", [])),
        "quest_progress": dict(data.get("quest_progress", {}) or {}),
        "avatar_catalog": coordinator.avatar_catalog(),
        "challenges": list(data.get("challenges", [])),
        "pool_allocations": list(data.get("pool_allocations", [])),
        "timed_sessions": list(data.get("timed_sessions", [])),
        "templates": coordinator.get_all_templates(),
        # Operational state — used by the panel's Activity tab + approval banner
        "completions": completions,  # all (panel slices for display)
        "pending_completions": [c for c in completions if not c.get("approved")],
        "reward_claims": reward_claims,
        "pending_reward_claims": [c for c in reward_claims if not c.get("approved")],
        "mandatory_misses": coordinator.mandatory_misses_state(),  # missed mandatory chores awaiting review (#532)
        "points_transactions": transactions[-100:],  # most recent 100 for audit log
        "badges": list(data.get("badges", [])),
        "awarded_badges": list(data.get("awarded_badges", [])),
        "audit_log": coordinator.storage.get_audit_log()[:100],  # newest 100 for the panel
        "swap_requests": [r for r in coordinator.storage.get_swap_requests() if r.get("status") == "pending"],
        "allowance_payouts": list(reversed(coordinator.storage.get_allowance_payouts()))[:50],  # newest first (FEAT-3)
        "settings": {
            "points_name": data.get("points_name", "Stars"),
            "points_icon": data.get("points_icon", "mdi:star"),
            "card_design": "classic",
            # Difficulty multiplier defaults; overridden by stored values below.
            "difficulty_multiplier_easy": 0.5,
            "difficulty_multiplier_medium": 1.0,
            "difficulty_multiplier_hard": 2.0,
            **(data.get("settings", {}) or {}),
        },
        "parent_completable": parent_completable,
    }


@websocket_api.websocket_command({vol.Required("type"): WS_GET_STATE})
@websocket_api.async_response
@_admin_only
async def _ws_get_state(hass, connection, msg, coordinator):
    connection.send_result(msg["id"], _build_state_snapshot(coordinator))


# ---------------------------------------------------------------------------
# Children
# ---------------------------------------------------------------------------


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_ADD_CHILD,
        vol.Required("name"): vol.All(str, vol.Length(min=1, max=120)),
        vol.Optional("avatar", default="mdi:account-circle"): str,
        vol.Optional("availability_entity", default=""): str,
        vol.Optional("availability_inverted", default=False): bool,
        vol.Optional("unavailability_entity", default=""): str,
        vol.Optional("pause_streak_when_unavailable", default=False): bool,
        vol.Optional("linked_user_id", default=""): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_add_child(hass, connection, msg, coordinator):
    child = await coordinator.async_add_child(
        name=msg["name"].strip(),
        avatar=msg.get("avatar") or "mdi:account-circle",
        availability_entity=_opt_str(msg.get("availability_entity")),
        availability_inverted=bool(msg.get("availability_inverted", False)),
        unavailability_entity=_opt_str(msg.get("unavailability_entity")),
        pause_streak_when_unavailable=bool(msg.get("pause_streak_when_unavailable", False)),
        linked_user_id=_opt_str(msg.get("linked_user_id")),
    )
    connection.send_result(msg["id"], {"id": child.id})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_UPDATE_CHILD,
        vol.Required("child_id"): str,
        vol.Optional("name"): vol.All(str, vol.Length(min=1, max=120)),
        vol.Optional("avatar"): str,
        vol.Optional("availability_entity"): str,
        vol.Optional("availability_inverted"): bool,
        vol.Optional("unavailability_entity"): str,
        vol.Optional("pause_streak_when_unavailable"): bool,
        vol.Optional("linked_user_id"): str,
        vol.Optional("is_guest"): bool,
        vol.Optional("guest_expires_on"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_update_child(hass, connection, msg, coordinator):
    existing = coordinator.storage.get_child(msg["child_id"])
    if not existing:
        connection.send_error(msg["id"], "not_found", f"Child {msg['child_id']} not found")
        return
    if "name" in msg:
        existing.name = msg["name"].strip()
    if "avatar" in msg:
        existing.avatar = msg["avatar"] or "mdi:account-circle"
    if "availability_entity" in msg:
        existing.availability_entity = _opt_str(msg["availability_entity"])
    if "availability_inverted" in msg:
        existing.availability_inverted = bool(msg["availability_inverted"])
    if "unavailability_entity" in msg:
        existing.unavailability_entity = _opt_str(msg["unavailability_entity"])
    if "pause_streak_when_unavailable" in msg:
        existing.pause_streak_when_unavailable = bool(msg["pause_streak_when_unavailable"])
    if "linked_user_id" in msg:
        existing.linked_user_id = _opt_str(msg["linked_user_id"])
    if "is_guest" in msg or "guest_expires_on" in msg:
        # Routed through the coordinator so the expiry is validated and an
        # archived guest is un-archived when promoted to a family member.
        try:
            await coordinator.async_set_guest(
                existing.id,
                bool(msg.get("is_guest", existing.is_guest)),
                _opt_str(msg.get("guest_expires_on", existing.guest_expires_on)),
            )
        except ValueError as err:
            connection.send_error(msg["id"], "invalid_format", str(err))
            return
        existing = coordinator.storage.get_child(msg["child_id"])
    await coordinator.async_update_child(existing)
    connection.send_result(msg["id"], {"id": existing.id})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_REMOVE_CHILD,
        vol.Required("child_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_remove_child(hass, connection, msg, coordinator):
    if not coordinator.storage.get_child(msg["child_id"]):
        connection.send_error(msg["id"], "not_found", f"Child {msg['child_id']} not found")
        return
    await coordinator.async_remove_child(msg["child_id"])
    connection.send_result(msg["id"], {"id": msg["child_id"]})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_LIST_HA_USERS,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_list_ha_users(hass, connection, msg, coordinator):
    """Return selectable HA users for linking a child to an account.

    Admin-only — it exposes account names. Excludes system-generated accounts
    (e.g. Supervisor, Home Assistant Content) and inactive users.
    """
    users = await hass.auth.async_get_users()
    result = [
        {"id": u.id, "name": u.name or "(unnamed user)", "is_admin": bool(u.is_admin)}
        for u in users
        if u.is_active and not u.system_generated
    ]
    result.sort(key=lambda x: x["name"].lower())
    connection.send_result(msg["id"], {"users": result})


# ---------------------------------------------------------------------------
# Chores
# ---------------------------------------------------------------------------


def _image_url_or_blank(value):
    """Accept a blank string (clears the picture) or one of our image URLs.

    A bare predicate can't be used as a voluptuous validator: voluptuous treats
    a callable as a coercer, so returning False would be a *value*, not a
    rejection. This raises instead.
    """
    text = str(value or "")
    if not text or images.is_taskmate_image_url(text):
        return text
    raise vol.Invalid("image_url must be a TaskMate image URL")


# Fields the panel is allowed to set directly. Anything else (skip_date,
# assignment_current_child_id, publish_calendar_published_dates, etc.) is
# coordinator-managed runtime state and intentionally not exposed.
_CHORE_EDITABLE_FIELDS = {
    "name",
    "description",
    "points",
    "assigned_to",
    "depends_on",
    "requires_approval",
    "time_category",
    "claim_allowance_minutes",
    "daily_limit",
    "completion_sound",
    "icon",
    "image_url",
    "difficulty",
    "schedule_mode",
    "due_days",
    "recurrence",
    "recurrence_day",
    "recurrence_start",
    "first_occurrence_mode",
    "visibility_entity",
    "visibility_state",
    "visibility_operator",
    "weather_entity",
    "weather_block_conditions",
    "weather_temp_min",
    "weather_temp_max",
    "weather_wind_max",
    "deadline_at",
    "speed_bonus_points",
    "enabled",
    "expires_on",
    "due_time",
    "early_bonus",
    "late_penalty",
    "require_photo",
    "mandatory",
    "mandatory_penalty_points",
    "assignment_mode",
    "assignment_rotation_anchor",
    "require_availability",
    "publish_calendar_entities",
    "bonus_subtasks",
    "task_type",
    "timed_rate_points",
    "timed_rate_minutes",
    "timed_max_daily_minutes",
}


def _chore_payload_schema(*, require_name: bool):
    """Build a vol.Schema for add/update chore. require_name=True for add."""
    name_field = vol.Required("name") if require_name else vol.Optional("name")
    return {
        name_field: vol.All(str, vol.Length(min=1, max=200)),
        vol.Optional("description"): str,
        vol.Optional("points"): vol.All(int, vol.Range(min=0)),
        vol.Optional("assigned_to"): [str],
        vol.Optional("depends_on"): [str],
        vol.Optional("requires_approval"): bool,
        vol.Optional("time_category"): str,
        vol.Optional("claim_allowance_minutes"): vol.All(int, vol.Range(min=0)),
        vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)),
        vol.Optional("completion_sound"): str,
        vol.Optional("icon"): str,
        vol.Optional("image_url"): _image_url_or_blank,
        vol.Optional("difficulty"): vol.In(DIFFICULTY_TIERS),
        vol.Optional("schedule_mode"): vol.In(SCHEDULE_MODES),
        vol.Optional("due_days"): [str],
        vol.Optional("recurrence"): str,
        vol.Optional("recurrence_day"): str,
        vol.Optional("recurrence_start"): str,
        vol.Optional("first_occurrence_mode"): str,
        vol.Optional("visibility_entity"): str,
        vol.Optional("visibility_state"): str,
        vol.Optional("visibility_operator"): str,
        vol.Optional("weather_entity"): str,
        vol.Optional("weather_block_conditions"): [str],
        # None clears the limit — 0 is a real threshold, so it can't double as "off".
        vol.Optional("weather_temp_min"): vol.Any(None, vol.Coerce(float)),
        vol.Optional("weather_temp_max"): vol.Any(None, vol.Coerce(float)),
        vol.Optional("weather_wind_max"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0))),
        vol.Optional("deadline_at"): str,
        vol.Optional("speed_bonus_points"): vol.All(int, vol.Range(min=0)),
        vol.Optional("enabled"): bool,
        vol.Optional("expires_on"): str,
        vol.Optional("due_time"): str,
        vol.Optional("early_bonus"): vol.All(int, vol.Range(min=0)),
        vol.Optional("late_penalty"): vol.All(int, vol.Range(min=0)),
        vol.Optional("mandatory"): bool,
        vol.Optional("mandatory_penalty_points"): vol.All(int, vol.Range(min=0)),
        vol.Optional("require_photo"): bool,
        vol.Optional("assignment_mode"): vol.In(ASSIGNMENT_MODES),
        vol.Optional("assignment_rotation_anchor"): str,
        vol.Optional("require_availability"): bool,
        vol.Optional("publish_calendar_entities"): [str],
        vol.Optional("bonus_subtasks"): [
            {
                vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)),
                vol.Optional("points"): vol.All(int, vol.Range(min=0)),
                vol.Optional("description"): str,
                vol.Optional("id"): str,
            }
        ],
        vol.Optional("task_type"): vol.In(["standard", "timed"]),
        vol.Optional("timed_rate_points"): vol.All(int, vol.Range(min=1)),
        vol.Optional("timed_rate_minutes"): vol.All(int, vol.Range(min=1)),
        vol.Optional("timed_max_daily_minutes"): vol.All(int, vol.Range(min=0)),
    }


async def _maybe_apply_manual_start(coordinator, chore_id: str, child_id: str | None) -> None:
    """If a manual rotation start was supplied, apply it via the coordinator.

    The coordinator handles the "alternating reorders the pool / random+balanced
    is ephemeral" semantics; we just pass through. Silently ignored for
    'everyone' mode chores (the coordinator raises ValueError).
    """
    if not child_id:
        return
    try:
        await coordinator.async_set_chore_manual_start(chore_id, child_id)
    except ValueError as err:
        _LOGGER.debug("manual start ignored for %s: %s", chore_id, err)


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_ADD_CHORE,
        vol.Optional("manual_start_child_id"): vol.Any(str, None),
        **_chore_payload_schema(require_name=True),
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_add_chore(hass, connection, msg, coordinator):
    chore = await coordinator.async_add_chore(
        name=msg["name"].strip(),
        points=msg.get("points", 10),
        description=msg.get("description", ""),
        assigned_to=list(msg.get("assigned_to", []) or []),
        requires_approval=msg.get("requires_approval", True),
        time_category=msg.get("time_category", "anytime"),
        claim_allowance_minutes=msg.get("claim_allowance_minutes", 0),
        daily_limit=msg.get("daily_limit", 1),
        completion_sound=msg.get("completion_sound", "coin"),
        schedule_mode=msg.get("schedule_mode", "specific_days"),
    )
    extra_fields = (set(msg.keys()) & _CHORE_EDITABLE_FIELDS) - {
        "name",
        "points",
        "description",
        "assigned_to",
        "requires_approval",
        "time_category",
        "claim_allowance_minutes",
        "daily_limit",
        "completion_sound",
        "schedule_mode",
    }
    if extra_fields:
        for f in extra_fields:
            if f == "bonus_subtasks":
                setattr(chore, f, [BonusSubTask.from_dict(b) for b in (msg[f] or [])])
            else:
                setattr(chore, f, msg[f] if not isinstance(msg[f], list) else list(msg[f]))
        await coordinator.async_update_chore(chore)
    await _maybe_apply_manual_start(coordinator, chore.id, msg.get("manual_start_child_id"))
    connection.send_result(msg["id"], {"id": chore.id})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_UPDATE_CHORE,
        vol.Required("chore_id"): str,
        vol.Optional("manual_start_child_id"): vol.Any(str, None),
        **_chore_payload_schema(require_name=False),
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_update_chore(hass, connection, msg, coordinator):
    existing = coordinator.storage.get_chore(msg["chore_id"])
    if not existing:
        connection.send_error(msg["id"], "not_found", f"Chore {msg['chore_id']} not found")
        return
    for field in _CHORE_EDITABLE_FIELDS:
        if field in msg:
            value = msg[field]
            if field == "bonus_subtasks":
                value = [BonusSubTask.from_dict(b) for b in (value or [])]
            elif isinstance(value, list):
                value = list(value)
            elif field == "name":
                value = value.strip()
            setattr(existing, field, value)
    await coordinator.async_update_chore(existing)
    await _maybe_apply_manual_start(coordinator, existing.id, msg.get("manual_start_child_id"))
    connection.send_result(msg["id"], {"id": existing.id})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_REMOVE_CHORE,
        vol.Required("chore_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_remove_chore(hass, connection, msg, coordinator):
    if not coordinator.storage.get_chore(msg["chore_id"]):
        connection.send_error(msg["id"], "not_found", f"Chore {msg['chore_id']} not found")
        return
    await coordinator.async_remove_chore(msg["chore_id"])
    connection.send_result(msg["id"], {"id": msg["chore_id"]})


# ---------------------------------------------------------------------------
# Insight reports (#679)
# ---------------------------------------------------------------------------


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_REPORT_FAIRNESS,
        vol.Optional("days"): vol.All(int, vol.Range(min=1, max=90)),
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_report_fairness(hass, connection, msg, coordinator):
    connection.send_result(msg["id"], coordinator.fairness_report(msg.get("days")))


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_REPORT_FRICTION,
        vol.Optional("days"): vol.All(int, vol.Range(min=1, max=90)),
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_report_friction(hass, connection, msg, coordinator):
    connection.send_result(msg["id"], coordinator.friction_report(msg.get("days")))


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_REPORT_PROJECTION,
        vol.Optional("days"): vol.All(int, vol.Range(min=1, max=28)),
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_report_projection(hass, connection, msg, coordinator):
    connection.send_result(msg["id"], coordinator.projection_report(msg.get("days")))


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_TEMPLATES_EXPORT,
        vol.Optional("template_ids"): [str],
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_templates_export(hass, connection, msg, coordinator):
    connection.send_result(msg["id"], coordinator.export_templates(msg.get("template_ids")))


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_TEMPLATES_IMPORT,
        vol.Required("pack"): dict,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_templates_import(hass, connection, msg, coordinator):
    try:
        result = await coordinator.async_import_pack(msg["pack"])
    except ValueError as err:
        connection.send_error(msg["id"], "invalid_format", str(err))
        return
    connection.send_result(msg["id"], result)


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_PRINT_CHART,
        vol.Optional("orientation", default="portrait"): vol.In(["portrait", "landscape"]),
        vol.Optional("week_start"): str,
        vol.Optional("title"): vol.All(str, vol.Length(max=80)),
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_print_chart(hass, connection, msg, coordinator):
    from datetime import date as _date

    from homeassistant.util import dt as dt_util

    from . import printable

    raw = msg.get("week_start", "")
    try:
        anchor = _date.fromisoformat(raw) if raw else dt_util.as_local(dt_util.now()).date()
    except (TypeError, ValueError):
        connection.send_error(msg["id"], "invalid_format", "week_start must be an ISO date")
        return

    data = coordinator.storage.data
    html = printable.build_chart(
        children=list(data.get("children", [])),
        chores=list(data.get("chores", [])),
        start=printable.week_start(anchor),
        orientation=msg.get("orientation", "portrait"),
        title=msg.get("title") or "This week",
        points_name=coordinator.storage.get_points_name(),
    )
    connection.send_result(msg["id"], {"html": html})


@websocket_api.websocket_command({vol.Required("type"): WS_REPORT_HEALTH})
@websocket_api.async_response
@_admin_only
async def _ws_report_health(hass, connection, msg, coordinator):
    connection.send_result(msg["id"], coordinator.health_report())


# ---------------------------------------------------------------------------
# Scheduled config changes (#675)
# ---------------------------------------------------------------------------


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_SCHEDULED_LIST,
        vol.Optional("chore_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_scheduled_list(hass, connection, msg, coordinator):
    changes = coordinator.get_scheduled_changes(msg.get("chore_id", ""))
    connection.send_result(msg["id"], {"changes": [c.to_dict() for c in changes]})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_SCHEDULED_ADD,
        vol.Required("chore_id"): str,
        vol.Required("apply_on"): str,
        vol.Required("changes"): dict,
        vol.Optional("note", default=""): vol.All(str, vol.Length(max=200)),
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_scheduled_add(hass, connection, msg, coordinator):
    try:
        change = await coordinator.async_add_scheduled_change(
            chore_id=msg["chore_id"],
            apply_on=msg["apply_on"],
            changes=msg["changes"],
            note=msg.get("note", ""),
        )
    except ValueError as err:
        connection.send_error(msg["id"], "invalid_format", str(err))
        return
    connection.send_result(msg["id"], {"id": change.id})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_SCHEDULED_REMOVE,
        vol.Required("change_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_scheduled_remove(hass, connection, msg, coordinator):
    try:
        await coordinator.async_remove_scheduled_change(msg["change_id"])
    except ValueError as err:
        connection.send_error(msg["id"], "not_found", str(err))
        return
    connection.send_result(msg["id"], {"success": True})


# ---------------------------------------------------------------------------
# Rewards
# ---------------------------------------------------------------------------

_REWARD_FIELDS = {
    "name",
    "cost",
    "description",
    "icon",
    "assigned_to",
    "is_jackpot",
    "pool_enabled",
    "quantity",
    "expires_at",
    "restock_enabled",
    "restock_amount",
    "restock_period",
    "unlock_entity",
    "unlock_minutes",
}


def _reward_payload_schema(*, require_name: bool):
    name_field = vol.Required("name") if require_name else vol.Optional("name")
    cost_field = vol.Required("cost") if require_name else vol.Optional("cost")
    return {
        name_field: vol.All(str, vol.Length(min=1, max=200)),
        cost_field: vol.All(int, vol.Range(min=0)),
        vol.Optional("description"): str,
        vol.Optional("icon"): str,
        vol.Optional("assigned_to"): [str],
        vol.Optional("is_jackpot"): bool,
        vol.Optional("pool_enabled"): bool,
        vol.Optional("quantity"): vol.Any(None, vol.All(int, vol.Range(min=0))),
        vol.Optional("expires_at"): vol.Any(None, str),
        vol.Optional("restock_enabled"): bool,
        vol.Optional("restock_amount"): vol.All(int, vol.Range(min=0, max=10000)),
        vol.Optional("restock_period"): vol.In(["daily", "weekly", "monthly"]),
        vol.Optional("unlock_entity"): str,
        vol.Optional("unlock_minutes"): vol.All(int, vol.Range(min=0, max=1440)),
    }


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_ADD_REWARD,
        **_reward_payload_schema(require_name=True),
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_add_reward(hass, connection, msg, coordinator):
    # Timed unlock (#678): refuse an entity that isn't on the parent's
    # allowlist, at save time, with a message the panel can show.
    try:
        unlock_entity, unlock_minutes = coordinator.validate_unlock(
            msg.get("unlock_entity", ""),
            msg.get("unlock_minutes", 0),
        )
    except ValueError as err:
        connection.send_error(msg["id"], "invalid_format", str(err))
        return

    # coordinator.async_add_reward accepts a subset; build a Reward dataclass
    # to populate every editable field uniformly.
    reward = Reward(
        name=msg["name"].strip(),
        cost=msg["cost"],
        description=msg.get("description", ""),
        icon=msg.get("icon", "mdi:gift"),
        assigned_to=list(msg.get("assigned_to", []) or []),
        is_jackpot=msg.get("is_jackpot", False),
        pool_enabled=msg.get("pool_enabled", False),
        quantity=msg.get("quantity"),
        expires_at=msg.get("expires_at") or None,
        restock_enabled=msg.get("restock_enabled", False),
        restock_amount=msg.get("restock_amount", 0),
        restock_period=msg.get("restock_period", "weekly"),
        unlock_entity=unlock_entity,
        unlock_minutes=unlock_minutes,
    )
    coordinator.storage.add_reward(reward)
    await coordinator.storage.async_save()
    await coordinator.async_refresh()
    connection.send_result(msg["id"], {"id": reward.id})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_UPDATE_REWARD,
        vol.Required("reward_id"): str,
        **_reward_payload_schema(require_name=False),
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_update_reward(hass, connection, msg, coordinator):
    existing = coordinator.storage.get_reward(msg["reward_id"])
    if not existing:
        connection.send_error(msg["id"], "not_found", f"Reward {msg['reward_id']} not found")
        return
    if "unlock_entity" in msg or "unlock_minutes" in msg:
        try:
            coordinator.validate_unlock(
                msg.get("unlock_entity", existing.unlock_entity),
                msg.get("unlock_minutes", existing.unlock_minutes),
            )
        except ValueError as err:
            connection.send_error(msg["id"], "invalid_format", str(err))
            return
    for field in _REWARD_FIELDS:
        if field in msg:
            value = msg[field]
            if field == "name" and value:
                value = value.strip()
            if field == "expires_at":
                value = value or None
            if isinstance(value, list):
                value = list(value)
            setattr(existing, field, value)
    await coordinator.async_update_reward(existing)
    connection.send_result(msg["id"], {"id": existing.id})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_REMOVE_REWARD,
        vol.Required("reward_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_remove_reward(hass, connection, msg, coordinator):
    if not coordinator.storage.get_reward(msg["reward_id"]):
        connection.send_error(msg["id"], "not_found", f"Reward {msg['reward_id']} not found")
        return
    await coordinator.async_remove_reward(msg["reward_id"])
    connection.send_result(msg["id"], {"id": msg["reward_id"]})


# ---------------------------------------------------------------------------
# Quests (chore chains)
# ---------------------------------------------------------------------------


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_CREATE_QUEST,
        vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)),
        vol.Optional("description", default=""): str,
        vol.Optional("icon", default="mdi:map-marker-path"): str,
        vol.Required("steps"): vol.All([str], vol.Length(min=1)),
        vol.Optional("bonus_points", default=25): vol.All(int, vol.Range(min=0, max=1000000)),
        vol.Optional("assigned_to", default=[]): [str],
        vol.Optional("repeatable", default=False): bool,
        vol.Optional("active", default=True): bool,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_create_quest(hass, connection, msg, coordinator):
    quest_id = await coordinator.async_create_quest(
        name=msg["name"].strip(),
        description=msg.get("description", ""),
        icon=msg.get("icon", "mdi:map-marker-path"),
        steps=list(msg["steps"]),
        bonus_points=msg.get("bonus_points", 25),
        assigned_to=list(msg.get("assigned_to", []) or []),
        repeatable=msg.get("repeatable", False),
        active=msg.get("active", True),
    )
    connection.send_result(msg["id"], {"id": quest_id})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_UPDATE_QUEST,
        vol.Required("quest_id"): str,
        vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)),
        vol.Optional("description"): str,
        vol.Optional("icon"): str,
        vol.Optional("steps"): vol.All([str], vol.Length(min=1)),
        vol.Optional("bonus_points"): vol.All(int, vol.Range(min=0, max=1000000)),
        vol.Optional("assigned_to"): [str],
        vol.Optional("repeatable"): bool,
        vol.Optional("active"): bool,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_update_quest(hass, connection, msg, coordinator):
    fields = {k: v for k, v in msg.items() if k not in ("id", "type", "quest_id")}
    if "name" in fields and fields["name"]:
        fields["name"] = fields["name"].strip()
    try:
        await coordinator.async_update_quest(msg["quest_id"], **fields)
    except ValueError as err:
        connection.send_error(msg["id"], "not_found", str(err))
        return
    connection.send_result(msg["id"], {"id": msg["quest_id"]})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_DELETE_QUEST,
        vol.Required("quest_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_delete_quest(hass, connection, msg, coordinator):
    try:
        await coordinator.async_delete_quest(msg["quest_id"])
    except ValueError as err:
        connection.send_error(msg["id"], "not_found", str(err))
        return
    connection.send_result(msg["id"], {"id": msg["quest_id"]})


# ---------------------------------------------------------------------------
# Avatar unlockables
# ---------------------------------------------------------------------------


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_UPDATE_AVATAR_CATALOG,
        vol.Required("catalog"): [
            {
                vol.Optional("id"): str,
                vol.Optional("label"): str,
                vol.Required("icon"): str,
                vol.Optional("unlock_type"): vol.In(["free", "level", "points", "streak"]),
                vol.Optional("unlock_value"): vol.All(vol.Coerce(int), vol.Range(min=0, max=1000000)),
            }
        ],
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_update_avatar_catalog(hass, connection, msg, coordinator):
    await coordinator.async_update_avatar_catalog(list(msg["catalog"]))
    connection.send_result(msg["id"], {"count": len(msg["catalog"])})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_SET_CHILD_AVATAR,
        vol.Required("child_id"): str,
        vol.Required("icon"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_set_child_avatar(hass, connection, msg, coordinator):
    # Admin (panel) may set any catalogue avatar; unlock gate is for children.
    try:
        await coordinator.async_set_avatar(msg["child_id"], msg["icon"], enforce_unlock=False)
    except ValueError as err:
        connection.send_error(msg["id"], "invalid", str(err))
        return
    connection.send_result(msg["id"], {"id": msg["child_id"]})


# ---------------------------------------------------------------------------
# Challenges (daily / weekly)
# ---------------------------------------------------------------------------


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_CREATE_CHALLENGE,
        vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)),
        vol.Optional("description", default=""): str,
        vol.Optional("icon", default="mdi:trophy-outline"): str,
        vol.Optional("scope", default="daily"): vol.In(["daily", "weekly"]),
        vol.Optional("metric", default="chores"): vol.In(["chores", "points"]),
        vol.Required("target"): vol.All(int, vol.Range(min=1, max=1000000)),
        vol.Optional("bonus_points", default=15): vol.All(int, vol.Range(min=0, max=1000000)),
        vol.Optional("assigned_to", default=[]): [str],
        vol.Optional("active", default=True): bool,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_create_challenge(hass, connection, msg, coordinator):
    challenge_id = await coordinator.async_create_challenge(
        name=msg["name"].strip(),
        description=msg.get("description", ""),
        icon=msg.get("icon", "mdi:trophy-outline"),
        scope=msg.get("scope", "daily"),
        metric=msg.get("metric", "chores"),
        target=msg["target"],
        bonus_points=msg.get("bonus_points", 15),
        assigned_to=list(msg.get("assigned_to", []) or []),
        active=msg.get("active", True),
    )
    connection.send_result(msg["id"], {"id": challenge_id})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_UPDATE_CHALLENGE,
        vol.Required("challenge_id"): str,
        vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)),
        vol.Optional("description"): str,
        vol.Optional("icon"): str,
        vol.Optional("scope"): vol.In(["daily", "weekly"]),
        vol.Optional("metric"): vol.In(["chores", "points"]),
        vol.Optional("target"): vol.All(int, vol.Range(min=1, max=1000000)),
        vol.Optional("bonus_points"): vol.All(int, vol.Range(min=0, max=1000000)),
        vol.Optional("assigned_to"): [str],
        vol.Optional("active"): bool,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_update_challenge(hass, connection, msg, coordinator):
    fields = {k: v for k, v in msg.items() if k not in ("id", "type", "challenge_id")}
    if "name" in fields and fields["name"]:
        fields["name"] = fields["name"].strip()
    try:
        await coordinator.async_update_challenge(msg["challenge_id"], **fields)
    except ValueError as err:
        connection.send_error(msg["id"], "not_found", str(err))
        return
    connection.send_result(msg["id"], {"id": msg["challenge_id"]})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_DELETE_CHALLENGE,
        vol.Required("challenge_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_delete_challenge(hass, connection, msg, coordinator):
    try:
        await coordinator.async_delete_challenge(msg["challenge_id"])
    except ValueError as err:
        connection.send_error(msg["id"], "not_found", str(err))
        return
    connection.send_result(msg["id"], {"id": msg["challenge_id"]})


# ---------------------------------------------------------------------------
# Penalties
# ---------------------------------------------------------------------------


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_ADD_PENALTY,
        vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)),
        vol.Required("points"): vol.All(int, vol.Range(min=1)),
        vol.Optional("description", default=""): str,
        vol.Optional("icon", default="mdi:alert-circle-outline"): str,
        vol.Optional("assigned_to", default=[]): [str],
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_add_penalty(hass, connection, msg, coordinator):
    pen = await coordinator.async_add_penalty(
        name=msg["name"].strip(),
        points=msg["points"],
        description=msg.get("description", ""),
        icon=msg.get("icon", "mdi:alert-circle-outline"),
        assigned_to=list(msg.get("assigned_to", []) or []),
    )
    connection.send_result(msg["id"], {"id": pen.id})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_UPDATE_PENALTY,
        vol.Required("penalty_id"): str,
        vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)),
        vol.Optional("points"): vol.All(int, vol.Range(min=1)),
        vol.Optional("description"): str,
        vol.Optional("icon"): str,
        vol.Optional("assigned_to"): [str],
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_update_penalty(hass, connection, msg, coordinator):
    existing = coordinator.storage.get_penalty(msg["penalty_id"])
    if not existing:
        connection.send_error(msg["id"], "not_found", f"Penalty {msg['penalty_id']} not found")
        return
    for k in ("name", "points", "description", "icon", "assigned_to"):
        if k in msg:
            val = msg[k]
            if k == "name" and val:
                val = val.strip()
            if k == "assigned_to":
                val = list(val)
            setattr(existing, k, val)
    await coordinator.async_update_penalty(existing)
    connection.send_result(msg["id"], {"id": existing.id})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_REMOVE_PENALTY,
        vol.Required("penalty_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_remove_penalty(hass, connection, msg, coordinator):
    if not coordinator.storage.get_penalty(msg["penalty_id"]):
        connection.send_error(msg["id"], "not_found", f"Penalty {msg['penalty_id']} not found")
        return
    await coordinator.async_remove_penalty(msg["penalty_id"])
    connection.send_result(msg["id"], {"id": msg["penalty_id"]})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_APPLY_PENALTY,
        vol.Required("penalty_id"): str,
        vol.Required("child_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_apply_penalty(hass, connection, msg, coordinator):
    await coordinator.async_apply_penalty(penalty_id=msg["penalty_id"], child_id=msg["child_id"])
    connection.send_result(msg["id"], {"penalty_id": msg["penalty_id"], "child_id": msg["child_id"]})


# ---------------------------------------------------------------------------
# Bonuses (mirror of penalties)
# ---------------------------------------------------------------------------


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_ADD_BONUS,
        vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)),
        vol.Required("points"): vol.All(int, vol.Range(min=1)),
        vol.Optional("description", default=""): str,
        vol.Optional("icon", default="mdi:star-circle-outline"): str,
        vol.Optional("assigned_to", default=[]): [str],
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_add_bonus(hass, connection, msg, coordinator):
    b = await coordinator.async_add_bonus(
        name=msg["name"].strip(),
        points=msg["points"],
        description=msg.get("description", ""),
        icon=msg.get("icon", "mdi:star-circle-outline"),
        assigned_to=list(msg.get("assigned_to", []) or []),
    )
    connection.send_result(msg["id"], {"id": b.id})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_UPDATE_BONUS,
        vol.Required("bonus_id"): str,
        vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)),
        vol.Optional("points"): vol.All(int, vol.Range(min=1)),
        vol.Optional("description"): str,
        vol.Optional("icon"): str,
        vol.Optional("assigned_to"): [str],
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_update_bonus(hass, connection, msg, coordinator):
    existing = coordinator.storage.get_bonus(msg["bonus_id"])
    if not existing:
        connection.send_error(msg["id"], "not_found", f"Bonus {msg['bonus_id']} not found")
        return
    for k in ("name", "points", "description", "icon", "assigned_to"):
        if k in msg:
            val = msg[k]
            if k == "name" and val:
                val = val.strip()
            if k == "assigned_to":
                val = list(val)
            setattr(existing, k, val)
    await coordinator.async_update_bonus(existing)
    connection.send_result(msg["id"], {"id": existing.id})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_REMOVE_BONUS,
        vol.Required("bonus_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_remove_bonus(hass, connection, msg, coordinator):
    if not coordinator.storage.get_bonus(msg["bonus_id"]):
        connection.send_error(msg["id"], "not_found", f"Bonus {msg['bonus_id']} not found")
        return
    await coordinator.async_remove_bonus(msg["bonus_id"])
    connection.send_result(msg["id"], {"id": msg["bonus_id"]})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_APPLY_BONUS,
        vol.Required("bonus_id"): str,
        vol.Required("child_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_apply_bonus(hass, connection, msg, coordinator):
    await coordinator.async_apply_bonus(bonus_id=msg["bonus_id"], child_id=msg["child_id"])
    connection.send_result(msg["id"], {"bonus_id": msg["bonus_id"], "child_id": msg["child_id"]})


# ---------------------------------------------------------------------------
# Task groups
# ---------------------------------------------------------------------------


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_ADD_TASK_GROUP,
        vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)),
        vol.Required("policy"): vol.In(["sticky", "spread"]),
        vol.Optional("chore_ids", default=[]): [str],
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_add_task_group(hass, connection, msg, coordinator):
    g = await coordinator.async_add_task_group(
        name=msg["name"].strip(),
        policy=msg["policy"],
        chore_ids=list(msg.get("chore_ids", []) or []),
    )
    connection.send_result(msg["id"], {"id": g.id})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_UPDATE_TASK_GROUP,
        vol.Required("group_id"): str,
        vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)),
        vol.Optional("policy"): vol.In(["sticky", "spread"]),
        vol.Optional("chore_ids"): [str],
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_update_task_group(hass, connection, msg, coordinator):
    g = await coordinator.async_update_task_group(
        group_id=msg["group_id"],
        name=msg["name"].strip() if "name" in msg else None,
        policy=msg.get("policy"),
        chore_ids=list(msg["chore_ids"]) if "chore_ids" in msg else None,
    )
    connection.send_result(msg["id"], {"id": g.id if g else msg["group_id"]})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_REMOVE_TASK_GROUP,
        vol.Required("group_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_remove_task_group(hass, connection, msg, coordinator):
    if not coordinator.storage.get_task_group(msg["group_id"]):
        connection.send_error(msg["id"], "not_found", f"Group {msg['group_id']} not found")
        return
    await coordinator.async_remove_task_group(msg["group_id"])
    connection.send_result(msg["id"], {"id": msg["group_id"]})


# ---------------------------------------------------------------------------
# Settings — partial update of currency + the "settings" subkey
# ---------------------------------------------------------------------------

# Top-level fields stored at storage._data root
_TOP_LEVEL_SETTINGS = {"points_name", "points_icon"}
# Allowed values for the global default card-design style (per-card design styles).
_ALLOWED_CARD_DESIGNS = {"classic", "playroom", "console", "cleanpro", "accessible"}
# Settings stored under storage._data["settings"][key]
_SUBKEY_SETTINGS = {
    "history_days",
    "streak_reset_mode",
    "card_design",
    "weekend_multiplier",
    "streak_milestones_enabled",
    "perfect_week_enabled",
    "perfect_week_bonus",
    "streak_milestones",
    "quick_point_amounts",
    "streak_requires_all_chores",
    "perfect_week_requires_all_chores",
    "difficulty_multiplier_easy",
    "difficulty_multiplier_medium",
    "difficulty_multiplier_hard",
    "unlock_allowlist",
    "parent_routing",
    "read_aloud_media_player",
    "read_aloud_tts_entity",
    "read_aloud_template",
    "read_aloud_one_template",
    "read_aloud_done_template",
    "read_aloud_joiner",
    "roulette_enabled",
    "roulette_multiplier",
    "roulette_daily_spins",
    "surprise_bonus_enabled",
    "surprise_bonus_chance",
    "surprise_bonus_min",
    "surprise_bonus_max",
    "points_decay_enabled",
    "points_decay_period",
    "points_decay_percent",
    "level_xp_step",
    "spend_cap_enabled",
    "spend_cap_period",
    "spend_cap_amount",
    "interest_enabled",
    "interest_period",
    "interest_percent",
    "celebration_notify",
    "celebration_notify_min_tier",
    "allow_negative_balance",
    "allowance_enabled",
    "allowance_rate",
    "allowance_currency",
    "family_goal_enabled",
    "family_goal_name",
    "family_goal_target",
    "family_goal_reward",
    "notify_service",
    "calendar_projection_days",
    "skip_confirmation_enabled",
    "vacation_calendar",
    "time_morning_start",
    "time_morning_end",
    "time_afternoon_start",
    "time_afternoon_end",
    "time_evening_start",
    "time_evening_end",
    "time_night_start",
    "time_night_end",
}


def _slugify_period_id(label: str, taken: set[str]) -> str:
    """Derive a stable, unique slug id from a period label."""
    base = re.sub(r"[^a-z0-9]+", "_", label.lower()).strip("_") or "period"
    if base == "anytime":
        base = "period"
    slug = base
    n = 2
    while slug in taken:
        slug = f"{base}_{n}"
        n += 1
    return slug


def _validate_time_periods(raw: list, coordinator) -> tuple[list[dict] | None, str | None]:
    """Normalize and validate a time_periods payload.

    Returns (periods, None) on success or (None, error_message) on failure.
    Enforces: HH:MM times, start < end, non-empty labels for custom periods,
    unique ids, non-overlapping when sorted by start, and block-on-delete for
    periods still referenced by chores.
    """
    if not isinstance(raw, list) or not raw:
        return None, "time_periods must be a non-empty list"
    if len(raw) > MAX_TIME_PERIODS:
        return None, f"too many periods (max {MAX_TIME_PERIODS})"

    builtin_ids = {p["id"] for p in DEFAULT_TIME_PERIODS}
    time_re = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$")
    periods: list[dict] = []
    ids: set[str] = {"anytime"}

    for entry in raw:
        if not isinstance(entry, dict):
            return None, "each period must be an object"
        label = str(entry.get("label") or "").strip()[:60]
        pid = str(entry.get("id") or "").strip()
        if pid and pid != "anytime" and pid in ids:
            return None, f"duplicate period id: {pid}"
        if not pid or pid == "anytime":
            pid = _slugify_period_id(label, ids)
        if not label and pid not in builtin_ids:
            return None, "every custom period needs a name"
        start = str(entry.get("start") or "")
        end = str(entry.get("end") or "")
        if not time_re.match(start) or not time_re.match(end):
            return None, f"invalid time for period '{label or pid}' (use HH:MM)"
        if start >= end:
            return None, f"period '{label or pid}' must start before it ends"
        ids.add(pid)
        periods.append(
            {
                "id": pid,
                "label": label,
                "start": start,
                "end": end,
                "icon": str(entry.get("icon") or "").strip()[:120] or TIME_CATEGORY_ICONS.get(pid, "mdi:clock-outline"),
            }
        )

    periods.sort(key=lambda p: p["start"])
    for prev, cur in zip(periods, periods[1:], strict=False):
        if cur["start"] < prev["end"]:
            return None, (
                f"'{cur['label'] or cur['id']}' overlaps '{prev['label'] or prev['id']}' — periods cannot overlap"
            )

    removed = {p["id"] for p in coordinator.get_time_periods()} - {p["id"] for p in periods}
    if removed:
        in_use = sorted(
            {chore.name or chore.id for chore in coordinator.storage.get_chores() if chore.time_category in removed}
        )
        if in_use:
            return None, (
                "cannot delete a period still used by chores: "
                + ", ".join(in_use[:10])
                + ("…" if len(in_use) > 10 else "")
            )

    return periods, None


MAX_VACATION_PERIODS: Final = 50


def _validate_vacation_periods(raw: list) -> tuple[list[dict] | None, str | None]:
    """Normalise and validate a vacation_periods payload.

    Returns (periods, None) on success or (None, error_message) on failure.
    An empty list is valid (clears all vacations). Each entry needs valid ISO
    start/end dates; start/end are swapped if reversed; ids are generated when
    missing. Returns periods sorted by start.
    """
    if not isinstance(raw, list):
        return None, "vacation_periods must be a list"
    if len(raw) > MAX_VACATION_PERIODS:
        return None, f"too many vacation periods (max {MAX_VACATION_PERIODS})"
    periods: list[dict] = []
    taken: set[str] = set()
    for entry in raw:
        if not isinstance(entry, dict):
            return None, "each vacation period must be an object"
        try:
            start = date.fromisoformat(str(entry.get("start")))
            end = date.fromisoformat(str(entry.get("end")))
        except (TypeError, ValueError):
            return None, "each vacation period needs valid start and end dates (YYYY-MM-DD)"
        if end < start:
            start, end = end, start
        pid = str(entry.get("id") or "").strip()
        if not pid or pid in taken:
            pid = f"{start.isoformat()}_{len(periods)}"
        taken.add(pid)
        periods.append(
            {
                "id": pid,
                "name": str(entry.get("name") or "").strip(),
                "start": start.isoformat(),
                "end": end.isoformat(),
            }
        )
    return sorted(periods, key=lambda p: p["start"]), None


# Extracted to a module constant so the accepted settings keys can be unit-tested
# (the websocket_command decorator does not expose the compiled schema). Every key
# accepted here must also be routed in _ws_update_settings below.
_UPDATE_SETTINGS_SCHEMA = {
    vol.Required("type"): WS_UPDATE_SETTINGS,
    vol.Optional("points_name"): vol.All(str, vol.Length(min=1, max=120)),
    vol.Optional("points_icon"): str,
    vol.Optional("card_design"): vol.In(_ALLOWED_CARD_DESIGNS),
    vol.Optional("history_days"): vol.All(int, vol.Range(min=30, max=365)),
    vol.Optional("streak_reset_mode"): vol.In(["reset", "pause"]),
    vol.Optional("weekend_multiplier"): vol.All(vol.Coerce(float), vol.Range(min=1.0, max=5.0)),
    vol.Optional("streak_milestones_enabled"): bool,
    vol.Optional("perfect_week_enabled"): bool,
    vol.Optional("perfect_week_bonus"): vol.All(int, vol.Range(min=0)),
    vol.Optional("streak_requires_all_chores"): bool,
    vol.Optional("perfect_week_requires_all_chores"): bool,
    vol.Optional("difficulty_multiplier_easy"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=10.0)),
    vol.Optional("difficulty_multiplier_medium"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=10.0)),
    vol.Optional("difficulty_multiplier_hard"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=10.0)),
    vol.Optional("unlock_allowlist"): [str],
    vol.Optional("parent_routing"): vol.In(["all", "home", "round_robin"]),
    vol.Optional("read_aloud_media_player"): str,
    vol.Optional("read_aloud_tts_entity"): str,
    vol.Optional("read_aloud_template"): vol.All(str, vol.Length(max=300)),
    vol.Optional("read_aloud_one_template"): vol.All(str, vol.Length(max=300)),
    vol.Optional("read_aloud_done_template"): vol.All(str, vol.Length(max=300)),
    vol.Optional("read_aloud_joiner"): vol.All(str, vol.Length(max=20)),
    vol.Optional("roulette_enabled"): bool,
    vol.Optional("roulette_multiplier"): vol.All(vol.Coerce(float), vol.Range(min=1.0, max=5.0)),
    vol.Optional("roulette_daily_spins"): vol.All(int, vol.Range(min=1, max=10)),
    vol.Optional("surprise_bonus_enabled"): bool,
    vol.Optional("surprise_bonus_chance"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=100.0)),
    vol.Optional("surprise_bonus_min"): vol.All(int, vol.Range(min=0, max=10000)),
    vol.Optional("surprise_bonus_max"): vol.All(int, vol.Range(min=0, max=10000)),
    vol.Optional("points_decay_enabled"): bool,
    vol.Optional("points_decay_period"): vol.In(["weekly", "monthly"]),
    vol.Optional("points_decay_percent"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=100.0)),
    vol.Optional("level_xp_step"): vol.All(int, vol.Range(min=1, max=100000)),
    vol.Optional("spend_cap_enabled"): bool,
    vol.Optional("spend_cap_period"): vol.In(["weekly", "monthly"]),
    vol.Optional("spend_cap_amount"): vol.All(int, vol.Range(min=0, max=1000000)),
    vol.Optional("interest_enabled"): bool,
    vol.Optional("interest_period"): vol.In(["weekly", "monthly"]),
    vol.Optional("interest_percent"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=100.0)),
    vol.Optional("celebration_notify"): bool,
    vol.Optional("celebration_notify_min_tier"): vol.All(vol.Coerce(int), vol.Range(min=1, max=3)),
    vol.Optional("skip_confirmation_enabled"): bool,
    vol.Optional("allow_negative_balance"): bool,
    vol.Optional("allowance_enabled"): bool,
    vol.Optional("allowance_rate"): vol.All(vol.Coerce(int), vol.Range(min=1, max=100000)),
    vol.Optional("allowance_currency"): vol.All(str, vol.Length(max=8)),
    vol.Optional("family_goal_enabled"): bool,
    vol.Optional("family_goal_name"): vol.All(str, vol.Length(max=120)),
    vol.Optional("family_goal_target"): vol.All(vol.Coerce(int), vol.Range(min=1, max=10000000)),
    vol.Optional("family_goal_reward"): vol.All(str, vol.Length(max=200)),
    vol.Optional("streak_milestones"): str,
    # Comma-separated quick point-adjust amounts, e.g. "5, 10, 20" (#746). Kept a
    # string like streak_milestones so the panel's generic settings collector can
    # round-trip it as a plain text input; the panel parses and bounds it.
    vol.Optional("quick_point_amounts"): vol.All(str, vol.Length(max=60)),
    vol.Optional("notify_service"): str,
    vol.Optional("calendar_projection_days"): vol.All(int, vol.Range(min=1, max=90)),
    vol.Optional("vacation_calendar"): str,
    vol.Optional("time_morning_start"): vol.Match(r"^\d{2}:\d{2}$"),
    vol.Optional("time_morning_end"): vol.Match(r"^\d{2}:\d{2}$"),
    vol.Optional("time_afternoon_start"): vol.Match(r"^\d{2}:\d{2}$"),
    vol.Optional("time_afternoon_end"): vol.Match(r"^\d{2}:\d{2}$"),
    vol.Optional("time_evening_start"): vol.Match(r"^\d{2}:\d{2}$"),
    vol.Optional("time_evening_end"): vol.Match(r"^\d{2}:\d{2}$"),
    vol.Optional("time_night_start"): vol.Match(r"^\d{2}:\d{2}$"),
    vol.Optional("time_night_end"): vol.Match(r"^\d{2}:\d{2}$"),
    vol.Optional("time_periods"): list,
    vol.Optional("vacation_periods"): list,
    vol.Optional("parent_user_ids"): [str],
}


@websocket_api.websocket_command(_UPDATE_SETTINGS_SCHEMA)
@websocket_api.async_response
@_admin_only
async def _ws_update_settings(hass, connection, msg, coordinator):
    storage = coordinator.storage
    changed = []
    if "time_periods" in msg:
        periods, err = _validate_time_periods(msg["time_periods"], coordinator)
        if err:
            connection.send_error(msg["id"], "invalid_time_periods", err)
            return
        storage.set_setting("time_periods", periods)
        changed.append("time_periods")
    if "vacation_periods" in msg:
        vacations, verr = _validate_vacation_periods(msg["vacation_periods"])
        if verr:
            connection.send_error(msg["id"], "invalid_vacation_periods", verr)
            return
        storage.set_setting("vacation_periods", vacations)
        changed.append("vacation_periods")
    if "parent_user_ids" in msg:
        # Non-admin parent role (#661). Admin-gated write (this whole handler is
        # @_admin_only) so a parent can never grant/escalate the role.
        storage.set_parent_user_ids(list(msg["parent_user_ids"]))
        changed.append("parent_user_ids")
    for k, v in msg.items():
        if k in {"id", "type", "time_periods", "vacation_periods", "parent_user_ids"}:
            continue
        if k == "points_name":
            storage.set_points_name(v.strip())
            changed.append(k)
        elif k == "points_icon":
            storage.set_points_icon(v or "mdi:star")
            changed.append(k)
        elif k in _SUBKEY_SETTINGS:
            if k == "card_design" and v not in _ALLOWED_CARD_DESIGNS:
                continue
            storage.set_setting(k, v)
            changed.append(k)
    # Re-arm a family goal when its target/enabled changes (FEAT-4).
    if "family_goal_target" in changed or "family_goal_enabled" in changed:
        storage.set_setting("family_goal_achieved", False)
    if changed:
        await storage.async_save()
        # Period boundaries moved → re-arm the mandatory-chore end-of-period checks (#532)
        if "time_periods" in changed:
            await coordinator.async_rearm_mandatory_schedules()
        await coordinator.async_refresh()
    connection.send_result(msg["id"], {"updated": changed})


# ---------------------------------------------------------------------------
# Operational — approval / rejection / reorder / bulk add
# ---------------------------------------------------------------------------


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_COMPLETE_BONUS_SUBTASK,
        vol.Required("chore_id"): str,
        vol.Required("bonus_subtask_id"): str,
        vol.Required("child_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_complete_bonus_subtask(hass, connection, msg, coordinator):
    completion = await coordinator.async_complete_bonus_subtask(
        msg["chore_id"], msg["bonus_subtask_id"], msg["child_id"]
    )
    connection.send_result(msg["id"], {"id": completion.id})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_APPROVE_CHORE,
        vol.Required("completion_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_approve_chore(hass, connection, msg, coordinator):
    await coordinator.async_approve_chore(msg["completion_id"])
    connection.send_result(msg["id"], {"completion_id": msg["completion_id"]})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_APPROVE_ALL_CHORES,
        vol.Optional("completion_ids"): [str],
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_approve_all_chores(hass, connection, msg, coordinator):
    count = await coordinator.async_approve_chores_bulk(msg.get("completion_ids"))
    connection.send_result(msg["id"], {"count": count})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_REJECT_CHORE,
        vol.Required("completion_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_reject_chore(hass, connection, msg, coordinator):
    await coordinator.async_reject_chore(msg["completion_id"])
    connection.send_result(msg["id"], {"completion_id": msg["completion_id"]})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_APPROVE_REWARD,
        vol.Required("claim_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_approve_reward(hass, connection, msg, coordinator):
    await coordinator.async_approve_reward(msg["claim_id"])
    connection.send_result(msg["id"], {"claim_id": msg["claim_id"]})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_REJECT_REWARD,
        vol.Required("claim_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_reject_reward(hass, connection, msg, coordinator):
    await coordinator.async_reject_reward(msg["claim_id"])
    connection.send_result(msg["id"], {"claim_id": msg["claim_id"]})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_PARENT_COMPLETE_CHORE,
        vol.Required("chore_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_parent_complete_chore(hass, connection, msg, coordinator):
    await coordinator.async_parent_complete_chore(msg["chore_id"])
    connection.send_result(msg["id"], {"ok": True})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_SET_CHORE_ORDER,
        vol.Required("child_id"): str,
        vol.Required("chore_order"): [str],
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_set_chore_order(hass, connection, msg, coordinator):
    await coordinator.async_set_chore_order(msg["child_id"], list(msg["chore_order"]))
    connection.send_result(msg["id"], {"child_id": msg["child_id"]})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_SET_GLOBAL_CHORE_ORDER,
        vol.Required("chore_order"): [str],
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_set_global_chore_order(hass, connection, msg, coordinator):
    await coordinator.async_set_global_chore_order(list(msg["chore_order"]))
    connection.send_result(msg["id"], {"ok": True})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_ADD_CHORES_BULK,
        vol.Required("chore_names"): [str],
        vol.Optional("points"): vol.All(int, vol.Range(min=0)),
        vol.Optional("assigned_to"): [str],
        vol.Optional("requires_approval"): bool,
        vol.Optional("time_category"): str,
        vol.Optional("schedule_mode"): vol.In(SCHEDULE_MODES),
        vol.Optional("due_days"): [str],
        vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)),
        vol.Optional("completion_sound"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_add_chores_bulk(hass, connection, msg, coordinator):
    chore_names = [n.strip() for n in msg.get("chore_names", []) if n and n.strip()]
    if not chore_names:
        connection.send_error(msg["id"], "no_names", "At least one chore name is required")
        return
    chores = await coordinator.async_add_chores_bulk(
        chore_names=chore_names,
        points=msg.get("points", 10),
        due_days=list(msg.get("due_days", []) or []),
        assigned_to=list(msg.get("assigned_to", []) or []),
        requires_approval=msg.get("requires_approval", True),
        time_category=msg.get("time_category", "anytime"),
        daily_limit=msg.get("daily_limit", 1),
        schedule_mode=msg.get("schedule_mode", "specific_days"),
        completion_sound=msg.get("completion_sound", "coin"),
    )
    connection.send_result(msg["id"], {"created": [c.id for c in chores], "count": len(chores)})


# ---------------------------------------------------------------------------
# Templates
# ---------------------------------------------------------------------------


@websocket_api.websocket_command({vol.Required("type"): WS_TEMPLATES_LIST})
@websocket_api.async_response
@_admin_only
async def _ws_templates_list(hass, connection, msg, coordinator):
    connection.send_result(msg["id"], {"templates": coordinator.get_all_templates()})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_TEMPLATES_GET,
        vol.Required("template_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_templates_get(hass, connection, msg, coordinator):
    tpl = coordinator.get_template(msg["template_id"])
    if tpl is None:
        connection.send_error(msg["id"], "not_found", f"Template {msg['template_id']} not found")
        return
    connection.send_result(msg["id"], tpl)


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_TEMPLATES_APPLY,
        vol.Required("chores"): [
            {
                vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)),
                vol.Optional("points"): vol.All(int, vol.Range(min=0)),
                vol.Optional("description"): str,
                vol.Optional("assigned_to"): [str],
                vol.Optional("requires_approval"): bool,
                vol.Optional("time_category"): str,
                vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)),
                vol.Optional("completion_sound"): str,
                vol.Optional("schedule_mode"): vol.In(SCHEDULE_MODES),
                vol.Optional("due_days"): [str],
                vol.Optional("recurrence"): str,
                vol.Optional("recurrence_day"): str,
                vol.Optional("recurrence_start"): str,
                vol.Optional("first_occurrence_mode"): str,
                vol.Optional("assignment_mode"): vol.In(ASSIGNMENT_MODES),
                vol.Optional("require_availability"): bool,
                vol.Optional("visibility_entity"): str,
                vol.Optional("visibility_state"): str,
                vol.Optional("visibility_operator"): str,
                vol.Optional("task_type"): vol.In(["standard", "timed"]),
                vol.Optional("timed_rate_points"): vol.All(int, vol.Range(min=1)),
                vol.Optional("timed_rate_minutes"): vol.All(int, vol.Range(min=1)),
                vol.Optional("timed_max_daily_minutes"): vol.All(int, vol.Range(min=0)),
            }
        ],
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_templates_apply(hass, connection, msg, coordinator):
    created_ids = await coordinator.async_apply_template(list(msg["chores"]))
    connection.send_result(msg["id"], {"created_ids": created_ids})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_TEMPLATES_SAVE_FROM,
        vol.Required("chore_ids"): vol.All([str], vol.Length(min=1)),
        vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)),
        vol.Optional("icon", default="mdi:clipboard-list"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_templates_save_from(hass, connection, msg, coordinator):
    tpl_id = await coordinator.async_save_template_from_chores(
        chore_ids=list(msg["chore_ids"]),
        name=msg["name"],
        icon=msg.get("icon", "mdi:clipboard-list"),
    )
    connection.send_result(msg["id"], {"template_id": tpl_id})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_TEMPLATES_CREATE,
        vol.Required("name"): vol.All(str, vol.Length(min=1, max=200)),
        vol.Optional("icon", default="mdi:clipboard-list"): str,
        vol.Required("chores"): vol.All(list, vol.Length(min=1)),
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_templates_create(hass, connection, msg, coordinator):
    tpl_id = await coordinator.async_create_template(
        name=msg["name"],
        icon=msg.get("icon", "mdi:clipboard-list"),
        chores=list(msg["chores"]),
    )
    connection.send_result(msg["id"], {"template_id": tpl_id})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_TEMPLATES_UPDATE,
        vol.Required("template_id"): str,
        vol.Optional("name"): vol.All(str, vol.Length(min=1, max=200)),
        vol.Optional("icon"): str,
        vol.Optional("chores"): vol.All(list, vol.Length(min=1)),
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_templates_update(hass, connection, msg, coordinator):
    await coordinator.async_update_template(
        msg["template_id"],
        name=msg.get("name"),
        icon=msg.get("icon"),
        chores=msg.get("chores"),
    )
    connection.send_result(msg["id"], {"success": True})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_TEMPLATES_DELETE,
        vol.Required("template_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_templates_delete(hass, connection, msg, coordinator):
    await coordinator.async_delete_template(msg["template_id"])
    connection.send_result(msg["id"], {"success": True})


# ---------------------------------------------------------------------------
# Notifications
# ---------------------------------------------------------------------------


@websocket_api.websocket_command({vol.Required("type"): WS_NOTIF_GET_STATE})
@websocket_api.async_response
@_admin_only
async def ws_notif_get_state(hass, connection, msg, coordinator):
    from .coord_notifications import NOTIFICATION_TYPES

    c = coordinator
    state = {
        "recipients": {
            "children": [
                {
                    "id": f"child:{ch.id}",
                    "name": ch.name,
                    "notify_service": ch.notify_service,
                    "quiet_hours_start": ch.quiet_hours_start,
                    "quiet_hours_end": ch.quiet_hours_end,
                }
                for ch in c.storage.get_children()
            ],
            "parents": [p.to_dict() for p in c.storage.get_parent_recipients()],
        },
        "types": [
            {
                "id": t.id,
                "audience": t.audience,
                "time_gated": t.time_gated,
                "per_recipient_time": t.per_recipient_time,
                "actionable": t.actionable,
                "default_enabled": t.default_enabled,
            }
            for t in NOTIFICATION_TYPES
        ],
        "config": {tid: cfg.to_dict() for tid, cfg in c.storage.get_all_notification_configs().items()},
        "custom": [n.to_dict() for n in c.storage.get_custom_notifications()],
        "settings": {
            "streak_at_risk_cutoff_time": c.storage.get_streak_at_risk_cutoff(),
            "mandatory_escalation_reminder_minutes": c.storage.get_escalation_reminder_minutes(),
            "mandatory_escalation_parent_minutes": c.storage.get_escalation_parent_minutes(),
            "notification_nav_url": c.storage.get_setting("notification_nav_url", DEFAULT_NOTIFICATION_NAV_URL),
            "notification_group": c.storage.get_setting("notification_group", DEFAULT_NOTIFICATION_GROUP),
        },
    }
    connection.send_result(msg["id"], state)


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_NOTIF_SET_MASTER,
        vol.Required("type_id"): str,
        vol.Required("enabled"): bool,
    }
)
@websocket_api.async_response
@_admin_only
async def ws_notif_set_master(hass, connection, msg, coordinator):
    await coordinator.notifications.set_master_enabled(msg["type_id"], msg["enabled"])
    connection.send_result(msg["id"], {"ok": True})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_NOTIF_SET_ROUTE,
        vol.Required("type_id"): str,
        vol.Required("recipient_id"): str,
        vol.Required("enabled"): bool,
        vol.Optional("time"): vol.Any(str, None),
    }
)
@websocket_api.async_response
@_admin_only
async def ws_notif_set_route(hass, connection, msg, coordinator):
    from .models import NotificationRoute

    route = NotificationRoute(enabled=msg["enabled"], time=msg.get("time"))
    await coordinator.notifications.set_route(msg["type_id"], msg["recipient_id"], route)
    connection.send_result(msg["id"], {"ok": True})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_NOTIF_SET_CHILD_NOTIFY,
        vol.Required("child_id"): str,
        vol.Required("notify_service"): vol.Any(str, None),
    }
)
@websocket_api.async_response
@_admin_only
async def ws_notif_set_child_notify(hass, connection, msg, coordinator):
    c = coordinator
    child = c.storage.get_child(msg["child_id"])
    if child is None:
        connection.send_error(msg["id"], "not_found", "Child not found")
        return
    child.notify_service = msg["notify_service"] or None
    c.storage.update_child(child)
    await c.storage.async_save()
    await c.notifications.async_setup_schedules()
    connection.send_result(msg["id"], {"ok": True})


def _validate_hhmm_or_empty(value):
    """voluptuous validator: '' (disabled) or a valid 'HH:MM' string."""
    if value in (None, ""):
        return ""
    if not isinstance(value, str):
        raise vol.Invalid("must be a string")
    parts = value.split(":")
    if len(parts) != 2 or not (parts[0].isdigit() and parts[1].isdigit()):
        raise vol.Invalid("must be HH:MM")
    hour, minute = int(parts[0]), int(parts[1])
    if not (0 <= hour <= 23 and 0 <= minute <= 59):
        raise vol.Invalid("HH:MM out of range")
    return f"{hour:02d}:{minute:02d}"


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_NOTIF_SET_CHILD_QUIET,
        vol.Required("child_id"): str,
        vol.Required("quiet_hours_start"): _validate_hhmm_or_empty,
        vol.Required("quiet_hours_end"): _validate_hhmm_or_empty,
    }
)
@websocket_api.async_response
@_admin_only
async def ws_notif_set_child_quiet(hass, connection, msg, coordinator):
    c = coordinator
    child = c.storage.get_child(msg["child_id"])
    if child is None:
        connection.send_error(msg["id"], "not_found", "Child not found")
        return
    child.quiet_hours_start = msg["quiet_hours_start"]
    child.quiet_hours_end = msg["quiet_hours_end"]
    c.storage.update_child(child)
    await c.storage.async_save()
    connection.send_result(msg["id"], {"ok": True})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_NOTIF_UPSERT_PARENT,
        vol.Optional("parent_id"): str,
        vol.Required("name"): str,
        vol.Required("notify_service"): str,
        vol.Optional("enabled", default=True): bool,
        vol.Optional("presence_entity", default=""): str,
    }
)
@websocket_api.async_response
@_admin_only
async def ws_notif_upsert_parent(hass, connection, msg, coordinator):
    from .models import ParentRecipient

    c = coordinator
    p_id = msg.get("parent_id")
    if p_id:
        existing = next((p for p in c.storage.get_parent_recipients() if p.id == p_id), None)
        if existing is None:
            connection.send_error(msg["id"], "not_found", "Parent not found")
            return
        existing.name = msg["name"]
        existing.notify_service = msg["notify_service"]
        existing.enabled = msg["enabled"]
        existing.presence_entity = msg.get("presence_entity", "")
        await c.notifications.upsert_parent(existing)
        connection.send_result(msg["id"], existing.to_dict())
    else:
        p = ParentRecipient(
            name=msg["name"],
            notify_service=msg["notify_service"],
            enabled=msg["enabled"],
            presence_entity=msg.get("presence_entity", ""),
        )
        await c.notifications.upsert_parent(p)
        connection.send_result(msg["id"], p.to_dict())


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_NOTIF_DELETE_PARENT,
        vol.Required("parent_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def ws_notif_delete_parent(hass, connection, msg, coordinator):
    await coordinator.notifications.delete_parent(msg["parent_id"])
    connection.send_result(msg["id"], {"ok": True})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_NOTIF_UPSERT_CUSTOM,
        vol.Optional("custom_id"): str,
        vol.Required("name"): str,
        vol.Required("message_template"): str,
        vol.Required("time"): str,
        vol.Optional("day_mask", default=0b1111111): int,
        vol.Optional("recipient_ids", default=list): list,
        vol.Optional("enabled", default=True): bool,
    }
)
@websocket_api.async_response
@_admin_only
async def ws_notif_upsert_custom(hass, connection, msg, coordinator):
    from .models import CustomNotification

    c = coordinator
    n = CustomNotification.from_dict(
        {
            "id": msg.get("custom_id"),
            "name": msg["name"],
            "message_template": msg["message_template"],
            "time": msg["time"],
            "day_mask": int(msg["day_mask"]),
            "recipient_ids": list(msg["recipient_ids"]),
            "enabled": bool(msg["enabled"]),
        }
    )
    await c.notifications.upsert_custom(n)
    connection.send_result(msg["id"], n.to_dict())


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_NOTIF_DELETE_CUSTOM,
        vol.Required("custom_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def ws_notif_delete_custom(hass, connection, msg, coordinator):
    await coordinator.notifications.delete_custom(msg["custom_id"])
    connection.send_result(msg["id"], {"ok": True})


@websocket_api.websocket_command({vol.Required("type"): WS_NOTIF_LIST_NOTIFY})
@websocket_api.async_response
@_admin_only
async def ws_notif_list_notify(hass, connection, msg, coordinator):
    services = [f"notify.{name}" for name in hass.services.async_services().get("notify", {})]
    services.sort()
    connection.send_result(msg["id"], services)


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_NOTIF_SET_STREAK_CUTOFF,
        vol.Required("time"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def ws_notif_set_streak_cutoff(hass, connection, msg, coordinator):
    await coordinator.notifications.set_streak_cutoff(msg["time"])
    connection.send_result(msg["id"], {"ok": True})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_NOTIF_SET_ESCALATION,
        vol.Required("reminder_minutes"): vol.All(vol.Coerce(int), vol.Range(min=1, max=1440)),
        vol.Required("parent_minutes"): vol.All(vol.Coerce(int), vol.Range(min=1, max=1440)),
    }
)
@websocket_api.async_response
@_admin_only
async def ws_notif_set_escalation(hass, connection, msg, coordinator):
    coordinator.storage.set_escalation_minutes(msg["reminder_minutes"], msg["parent_minutes"])
    await coordinator.storage.async_save()
    connection.send_result(msg["id"], {"ok": True})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_NOTIF_SEND_TEST,
        vol.Required("type_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def ws_notif_send_test(hass, connection, msg, coordinator):
    sent = await coordinator.notifications.send_test(msg["type_id"])
    connection.send_result(msg["id"], {"sent": sent})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_NOTIF_SET_NAV_URL,
        vol.Optional("type_id"): vol.Any(str, None),
        vol.Required("nav_url"): vol.All(str, vol.Length(max=200)),
    }
)
@websocket_api.async_response
@_admin_only
async def ws_notif_set_nav_url(hass, connection, msg, coordinator):
    await coordinator.notifications.set_nav_url(msg.get("type_id"), msg["nav_url"])
    connection.send_result(msg["id"], {"ok": True})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_NOTIF_SET_GROUP,
        vol.Optional("type_id"): vol.Any(str, None),
        vol.Required("group"): vol.All(str, vol.Length(max=64)),
    }
)
@websocket_api.async_response
@_admin_only
async def ws_notif_set_group(hass, connection, msg, coordinator):
    await coordinator.notifications.set_group(msg.get("type_id"), msg["group"])
    connection.send_result(msg["id"], {"ok": True})


# ---------------------------------------------------------------------------
# Calendar ICS feed (FEAT-10)
# ---------------------------------------------------------------------------


def _build_ics_url(hass, token: str) -> str:
    from .http_calendar import CALENDAR_URL

    base = ""
    try:
        from homeassistant.helpers.network import get_url

        base = get_url(hass, prefer_external=True)
    except Exception:  # noqa: BLE001 - no configured URL yet
        base = ""
    return f"{base}{CALENDAR_URL}?token={token}"


@websocket_api.websocket_command({vol.Required("type"): WS_CAL_GET_URL})
@websocket_api.async_response
@_admin_only
async def ws_cal_get_url(hass, connection, msg, coordinator):
    token = await coordinator.async_get_or_create_ics_token()
    connection.send_result(msg["id"], {"token": token, "url": _build_ics_url(hass, token)})


@websocket_api.websocket_command({vol.Required("type"): WS_CAL_REGEN_TOKEN})
@websocket_api.async_response
@_admin_only
async def ws_cal_regen_token(hass, connection, msg, coordinator):
    token = await coordinator.async_regenerate_ics_token()
    connection.send_result(msg["id"], {"token": token, "url": _build_ics_url(hass, token)})


# ---------------------------------------------------------------------------
# Admin audit log
# ---------------------------------------------------------------------------


@websocket_api.websocket_command({vol.Required("type"): WS_AUDIT_LIST})
@websocket_api.async_response
@_admin_only
async def _ws_audit_list(hass, connection, msg, coordinator):
    connection.send_result(msg["id"], {"entries": coordinator.storage.get_audit_log()})


@websocket_api.websocket_command({vol.Required("type"): WS_AUDIT_CLEAR})
@websocket_api.async_response
@_admin_only
async def _ws_audit_clear(hass, connection, msg, coordinator):
    coordinator.storage.clear_audit_log()
    await coordinator.storage.async_save()
    connection.send_result(msg["id"], {"cleared": True})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_UNDO_TRANSACTION,
        vol.Required("transaction_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_undo_transaction(hass, connection, msg, coordinator):
    await coordinator.async_undo_transaction(msg["transaction_id"])
    connection.send_result(msg["id"], {"undone": msg["transaction_id"]})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_CLONE_CHORE,
        vol.Required("chore_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_clone_chore(hass, connection, msg, coordinator):
    clone = await coordinator.async_clone_chore(msg["chore_id"])
    connection.send_result(msg["id"], {"id": clone.id})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_BULK_CHORE_ACTION,
        vol.Required("action"): vol.In(["delete", "enable", "disable", "reassign"]),
        vol.Required("chore_ids"): [str],
        vol.Optional("assigned_to"): [str],
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_bulk_chore_action(hass, connection, msg, coordinator):
    count = await coordinator.async_bulk_chore_action(
        msg["action"],
        msg["chore_ids"],
        msg.get("assigned_to"),
    )
    connection.send_result(msg["id"], {"count": count})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_GIFT_POINTS,
        vol.Required("from_child_id"): str,
        vol.Required("to_child_id"): str,
        vol.Required("points"): vol.All(int, vol.Range(min=1)),
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_gift_points(hass, connection, msg, coordinator):
    await coordinator.async_gift_points(msg["from_child_id"], msg["to_child_id"], msg["points"])
    connection.send_result(msg["id"], {"ok": True})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_REQUEST_SWAP,
        vol.Required("chore_id"): str,
        vol.Required("requester_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_request_swap(hass, connection, msg, coordinator):
    rid = await coordinator.async_request_swap(msg["chore_id"], msg["requester_id"])
    connection.send_result(msg["id"], {"id": rid})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_APPROVE_SWAP,
        vol.Required("request_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_approve_swap(hass, connection, msg, coordinator):
    await coordinator.async_approve_swap(msg["request_id"])
    connection.send_result(msg["id"], {"ok": True})


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_REJECT_SWAP,
        vol.Required("request_id"): str,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_reject_swap(hass, connection, msg, coordinator):
    await coordinator.async_reject_swap(msg["request_id"])
    connection.send_result(msg["id"], {"ok": True})


@websocket_api.websocket_command({vol.Required("type"): WS_CONFIG_EXPORT})
@websocket_api.async_response
@_admin_only
async def _ws_config_export(hass, connection, msg, coordinator):
    connection.send_result(msg["id"], coordinator.export_config())


@websocket_api.websocket_command(
    {
        vol.Required("type"): WS_CONFIG_IMPORT,
        vol.Required("payload"): dict,
    }
)
@websocket_api.async_response
@_admin_only
async def _ws_config_import(hass, connection, msg, coordinator):
    await coordinator.async_import_config(msg["payload"])
    connection.send_result(msg["id"], {"imported": True})


# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------

_COMMANDS = (
    _ws_get_state,
    _ws_audit_list,
    _ws_audit_clear,
    _ws_undo_transaction,
    _ws_add_child,
    _ws_update_child,
    _ws_remove_child,
    _ws_list_ha_users,
    _ws_add_chore,
    _ws_update_chore,
    _ws_remove_chore,
    _ws_clone_chore,
    _ws_scheduled_list,
    _ws_scheduled_add,
    _ws_scheduled_remove,
    _ws_report_fairness,
    _ws_report_friction,
    _ws_report_projection,
    _ws_report_health,
    _ws_templates_export,
    _ws_templates_import,
    _ws_print_chart,
    _ws_bulk_chore_action,
    _ws_gift_points,
    _ws_request_swap,
    _ws_approve_swap,
    _ws_reject_swap,
    _ws_config_export,
    _ws_config_import,
    _ws_add_reward,
    _ws_update_reward,
    _ws_remove_reward,
    _ws_create_quest,
    _ws_update_quest,
    _ws_delete_quest,
    _ws_update_avatar_catalog,
    _ws_set_child_avatar,
    _ws_create_challenge,
    _ws_update_challenge,
    _ws_delete_challenge,
    _ws_add_penalty,
    _ws_update_penalty,
    _ws_remove_penalty,
    _ws_apply_penalty,
    _ws_add_bonus,
    _ws_update_bonus,
    _ws_remove_bonus,
    _ws_apply_bonus,
    _ws_add_task_group,
    _ws_update_task_group,
    _ws_remove_task_group,
    _ws_update_settings,
    _ws_complete_bonus_subtask,
    _ws_approve_chore,
    _ws_approve_all_chores,
    _ws_reject_chore,
    _ws_approve_reward,
    _ws_reject_reward,
    _ws_parent_complete_chore,
    _ws_set_chore_order,
    _ws_set_global_chore_order,
    _ws_add_chores_bulk,
    _ws_templates_list,
    _ws_templates_get,
    _ws_templates_apply,
    _ws_templates_save_from,
    _ws_templates_create,
    _ws_templates_update,
    _ws_templates_delete,
    ws_notif_get_state,
    ws_notif_set_master,
    ws_notif_set_route,
    ws_notif_set_child_notify,
    ws_notif_set_child_quiet,
    ws_notif_upsert_parent,
    ws_notif_delete_parent,
    ws_notif_upsert_custom,
    ws_notif_delete_custom,
    ws_notif_list_notify,
    ws_notif_set_streak_cutoff,
    ws_notif_send_test,
    ws_notif_set_escalation,
    ws_notif_set_nav_url,
    ws_notif_set_group,
    ws_cal_get_url,
    ws_cal_regen_token,
)


def async_register_websocket_commands(hass: HomeAssistant) -> None:
    """Register all TaskMate WebSocket commands. Idempotent."""
    if hass.data.get(DOMAIN, {}).get(WS_REGISTERED):
        _LOGGER.debug("TaskMate WS commands already registered, skipping")
        return
    for cmd in _COMMANDS:
        websocket_api.async_register_command(hass, cmd)
    hass.data.setdefault(DOMAIN, {})[WS_REGISTERED] = True
    _LOGGER.info("Registered %d TaskMate WebSocket commands", len(_COMMANDS))
