"""The Maintenance Supporter integration."""

from __future__ import annotations

import logging
from dataclasses import dataclass
from datetime import timedelta
from typing import Any

import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import (
    Event,
    HomeAssistant,
    ServiceCall,
    SupportsResponse,
    callback,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import (
    config_validation as cv,
)
from homeassistant.helpers import (
    device_registry as dr,
)
from homeassistant.helpers import (
    entity_registry as er,
)
from homeassistant.helpers.event import (
    async_track_time_change,
    async_track_time_interval,
)
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.typing import ConfigType

from .const import (
    BATTERY_FLEET_OBJECT_FLAG,
    CONF_ADMIN_PANEL_USER_IDS,
    CONF_ADVANCED_ADAPTIVE,
    CONF_ADVANCED_BUDGET,
    CONF_ADVANCED_CHECKLISTS,
    CONF_ADVANCED_ENVIRONMENTAL,
    CONF_ADVANCED_GROUPS,
    CONF_ADVANCED_PREDICTIONS,
    CONF_ADVANCED_SCHEDULE_TIME,
    CONF_ADVANCED_SEASONAL,
    CONF_BUDGET_MONTHLY,
    CONF_BUDGET_YEARLY,
    CONF_GROUPS,
    CONF_INSTALL_ASSIST_SENTENCES,
    CONF_OBJECT,
    CONF_PANEL_ENABLED,
    CONF_TASKS,
    CONF_WEEKLY_DIGEST_ENABLED,
    DEFAULT_PANEL_ENABLED,
    DEFAULT_WARNING_DAYS,
    DOMAIN,
    EVENT_UNSUBS_KEY,
    GLOBAL_UNIQUE_ID,
    MAX_COST,
    MAX_DURATION_MINUTES,
    MAX_LABEL_LENGTH,
    MAX_LABELS,
    MAX_NAME_LENGTH,
    MAX_TEXT_LENGTH,
    MAX_TYPE_LENGTH,
    PLATFORMS,
    SERVICE_ADD_OBJECT,
    SERVICE_ADD_TASK,
    SERVICE_COMPLETE,
    SERVICE_DELETE_TASK,
    SERVICE_EXPORT,
    SERVICE_LIST_TASKS,
    SERVICE_RESET,
    SERVICE_SKIP,
    SERVICE_UPDATE_TASK,
    SIGNAL_NEW_OBJECT_ENTRY,
    SIGNAL_OBJECT_ENTRY_REMOVED,
    STORES_CACHE_KEY,
)
from .const import (
    DOCUMENT_STORE_KEY as _DS_KEY,
)
from .const import (
    NOTIFICATION_MANAGER_KEY as _NM_KEY,
)
from .coordinator import MaintenanceCoordinator
from .entity.summary_coordinator import MaintenanceSummaryCoordinator
from .frontend import async_register_card
from .helpers.aggregate import object_name as aggregate_object_name
from .helpers.assist_sentences import async_sync as async_sync_assist_sentences
from .helpers.dates import INTERVAL_UNITS
from .helpers.documents import DocumentStore
from .helpers.global_options import get_global_entry
from .helpers.notification_manager import NotificationManager
from .helpers.schedule import normalize_task_storage
from .helpers.task_fields import (
    INTERVAL_DAYS_RANGE,
    TASK_PRIORITIES,
    WARNING_DAYS_RANGE,
)
from .panel import async_register_panel, async_unregister_panel
from .storage import MaintenanceStore, async_migrate_to_store
from .websocket import async_register_commands

_LOGGER = logging.getLogger(__name__)

CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
# Re-exported from const for the existing deferred `from . import ...` users.
NOTIFICATION_MANAGER_KEY = _NM_KEY
DOCUMENT_STORE_KEY = _DS_KEY


@dataclass
class MaintenanceSupporterData:
    """Runtime data for a Maintenance Supporter config entry."""

    coordinator: MaintenanceCoordinator | None = None
    store: MaintenanceStore | None = None
    # Only set on the global entry: aggregates status counts for the summary
    # sensors. None on per-object entries.
    summary_coordinator: MaintenanceSummaryCoordinator | None = None


type MaintenanceSupporterConfigEntry = ConfigEntry[MaintenanceSupporterData]


# --- Service Schemas ---
SERVICE_COMPLETE_SCHEMA = vol.Schema(
    {
        vol.Required(ATTR_ENTITY_ID): cv.entity_id,
        vol.Optional("notes"): vol.All(cv.string, vol.Length(max=2000)),
        vol.Optional("cost"): vol.All(vol.Coerce(float), vol.Range(min=0, max=MAX_COST)),
        vol.Optional("duration"): vol.All(vol.Coerce(int), vol.Range(min=0, max=MAX_DURATION_MINUTES)),
        # Meter readings (v2.20, #83): recorded value for `reading` tasks.
        vol.Optional("reading_value"): vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)),
        # #128: who did it — a person ENTITY (validated picker, no free text);
        # resolved to the linked HA user id. Omitted -> the calling user.
        vol.Optional("completed_by"): cv.entity_id,
        # #133: when the maintenance was actually performed (backfill a past
        # completion). Future values are rejected by the coordinator.
        vol.Optional("completed_at"): cv.datetime,
    }
)

SERVICE_RESET_SCHEMA = vol.Schema(
    {
        vol.Required(ATTR_ENTITY_ID): cv.entity_id,
        vol.Optional("date"): cv.date,
    }
)

SERVICE_SKIP_SCHEMA = vol.Schema(
    {
        vol.Required(ATTR_ENTITY_ID): cv.entity_id,
        vol.Optional("reason"): vol.All(cv.string, vol.Length(max=2000)),
    }
)

SERVICE_EXPORT_SCHEMA = vol.Schema(
    {
        vol.Optional("format", default="json"): vol.In(["json", "yaml"]),
        vol.Optional("include_history", default=True): cv.boolean,
    }
)

SERVICE_ADD_OBJECT_SCHEMA = vol.Schema(
    {
        vol.Required("name"): vol.All(cv.string, vol.Length(min=1, max=255)),
        vol.Optional("area_id"): cv.string,
        vol.Optional("manufacturer"): cv.string,
        vol.Optional("model"): cv.string,
        vol.Optional("serial_number"): cv.string,
        vol.Optional("installation_date"): cv.string,
        vol.Optional("warranty_expiry"): cv.string,
        vol.Optional("documentation_url"): cv.string,
        vol.Optional("notes"): cv.string,
    }
)

# The service task schemas mirror the WS `task/create` / `task/update` schemas
# field-for-field, consuming the SAME constants + ranges (const.py /
# helpers.task_fields) rather than restating literals — the service path is
# just a third UI onto the same storage, so a value the WS API rejects must not
# be reachable from an automation. `vol.Coerce` stays (unlike the WS schemas)
# because YAML/templated service data arrives as strings.
SERVICE_ADD_TASK_SCHEMA = vol.Schema(
    {
        vol.Required("entry_id"): cv.string,
        vol.Required("name"): vol.All(cv.string, vol.Length(min=1, max=MAX_NAME_LENGTH)),
        vol.Optional("task_type"): vol.All(cv.string, vol.Length(max=MAX_TYPE_LENGTH)),
        vol.Optional("schedule_type"): vol.All(cv.string, vol.Length(max=MAX_TYPE_LENGTH)),
        vol.Optional("interval_days"): vol.All(
            vol.Coerce(int), vol.Range(min=INTERVAL_DAYS_RANGE[0], max=INTERVAL_DAYS_RANGE[1])
        ),
        vol.Optional("interval_unit"): vol.In(INTERVAL_UNITS),
        vol.Optional("due_date"): cv.string,
        # Nested recurrence for the calendar kinds (weekdays / nth_weekday /
        # day_of_month); takes precedence over the flat fields above.
        vol.Optional("schedule"): dict,
        vol.Optional("warning_days"): vol.All(vol.Coerce(int), vol.Range(min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1])),
        vol.Optional("enabled"): cv.boolean,
        vol.Optional("notes"): vol.All(cv.string, vol.Length(max=MAX_TEXT_LENGTH)),
    }
)

SERVICE_UPDATE_TASK_SCHEMA = vol.Schema(
    {
        vol.Required("entry_id"): cv.string,
        vol.Required("task_id"): cv.string,
        vol.Optional("name"): vol.All(cv.string, vol.Length(min=1, max=MAX_NAME_LENGTH)),
        vol.Optional("task_type"): vol.All(cv.string, vol.Length(max=MAX_TYPE_LENGTH)),
        vol.Optional("schedule_type"): vol.All(cv.string, vol.Length(max=MAX_TYPE_LENGTH)),
        vol.Optional("interval_days"): vol.All(
            vol.Coerce(int), vol.Range(min=INTERVAL_DAYS_RANGE[0], max=INTERVAL_DAYS_RANGE[1])
        ),
        vol.Optional("interval_unit"): vol.In(INTERVAL_UNITS),
        vol.Optional("due_date"): cv.string,
        vol.Optional("schedule"): dict,
        vol.Optional("warning_days"): vol.All(vol.Coerce(int), vol.Range(min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1])),
        vol.Optional("enabled"): cv.boolean,
        vol.Optional("notes"): vol.All(cv.string, vol.Length(max=MAX_TEXT_LENGTH)),
        vol.Optional("priority"): vol.In(TASK_PRIORITIES),
        vol.Optional("labels"): vol.All([vol.All(cv.string, vol.Length(max=MAX_LABEL_LENGTH))], vol.Length(max=MAX_LABELS)),
        # #128: (re)assign via automations — a person ENTITY resolved to the
        # linked HA user; the boolean clears the assignment instead.
        vol.Optional("responsible_user"): cv.entity_id,
        vol.Optional("clear_responsible_user"): cv.boolean,
    }
)

SERVICE_DELETE_TASK_SCHEMA = vol.Schema(
    {
        vol.Required("entry_id"): cv.string,
        vol.Required("task_id"): cv.string,
    }
)

SERVICE_LIST_TASKS_SCHEMA = vol.Schema(
    {
        vol.Optional("entry_id"): cv.string,
        vol.Optional("status"): vol.In(["ok", "due_soon", "overdue", "triggered"]),
    }
)


async def async_maybe_send_weekly_digest(hass: HomeAssistant, *, force: bool = False) -> None:
    """Send the opt-in weekly digest when it's due.

    Gated on: it being Monday (skipped when ``force``), the global
    ``weekly_digest_enabled`` setting, and there being something to report.
    Extracted from the daily 08:00 time-change tick so it's unit-testable and
    can be triggered directly.
    """
    from homeassistant.util import dt as dt_util

    if not force and dt_util.now().weekday() != 0:  # Monday only
        return
    global_entry = get_global_entry(hass)
    if global_entry is None:
        return
    options = global_entry.options or global_entry.data
    if not options.get(CONF_WEEKLY_DIGEST_ENABLED, False):
        return
    from .helpers.aggregate import compute_status_counts

    counts = compute_status_counts(hass)
    overdue = int(counts.get("overdue", 0))
    due_soon = int(counts.get("due_soon", 0)) + int(counts.get("triggered", 0))
    if overdue == 0 and due_soon == 0:
        return  # nothing to report — stay silent
    nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
    if nm is not None:
        await nm.async_send_weekly_digest(overdue, due_soon)


async def async_maybe_send_warranty_reminders(hass: HomeAssistant, *, force: bool = False) -> None:
    """Remind about maintenance-object warranties nearing expiry.

    Opt-in (``warranty_reminder_enabled``). Fires the day an object's warranty is
    exactly ``warranty_reminder_days`` days out — once, no per-object persistence
    needed. ``force`` widens it to the whole 0..N window (for the test hook).
    """
    from datetime import date

    from homeassistant.util import dt as dt_util

    from .const import (
        CONF_WARRANTY_REMINDER_DAYS,
        CONF_WARRANTY_REMINDER_ENABLED,
        DEFAULT_WARRANTY_REMINDER_DAYS,
    )

    global_entry = get_global_entry(hass)
    if global_entry is None:
        return
    options = global_entry.options or global_entry.data
    if not options.get(CONF_WARRANTY_REMINDER_ENABLED, False):
        return
    days = int(options.get(CONF_WARRANTY_REMINDER_DAYS, DEFAULT_WARRANTY_REMINDER_DAYS))
    today = dt_util.now().date()
    names: list[str] = []
    for entry in hass.config_entries.async_entries(DOMAIN):
        if entry.unique_id == GLOBAL_UNIQUE_ID:
            continue
        obj = entry.data.get(CONF_OBJECT) or {}
        expiry = obj.get("warranty_expiry")
        if not expiry:
            continue
        try:
            delta = (date.fromisoformat(expiry) - today).days
        except (ValueError, TypeError):
            continue
        if delta == days or (force and 0 <= delta <= days):
            names.append(aggregate_object_name(entry))
    if not names:
        return
    nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
    if nm is not None:
        await nm.async_send_warranty_reminder(names, days)


async def async_maybe_send_lead_reminders(hass: HomeAssistant) -> None:
    """Send extra lead-time reminders for tasks due in exactly N days.

    Opt-in via the ``reminder_lead_days`` list (e.g. [14, 3, 0]) — for every
    active task whose days-until-due matches one of the configured leads, one
    reminder fires through the notification manager (per-user routing, quiet
    hours, vacation, snooze, daily limit all apply). Runs from the daily
    08:00 tick, so each lead fires at most once per day per task, and once
    per cycle overall (the day-match only occurs on one day per lead).
    Complements — does not replace — the warning_days status-change path.
    """
    from .const import CONF_REMINDER_LEAD_DAYS
    from .models.maintenance_task import MaintenanceTask

    global_entry = get_global_entry(hass)
    if global_entry is None:
        return
    options = global_entry.options or global_entry.data
    leads_raw = options.get(CONF_REMINDER_LEAD_DAYS) or []
    leads = {v for v in leads_raw if isinstance(v, int) and not isinstance(v, bool)}
    if not leads:
        return

    nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
    if nm is None:
        return

    for entry in hass.config_entries.async_entries(DOMAIN):
        if entry.unique_id == GLOBAL_UNIQUE_ID:
            continue
        obj = entry.data.get(CONF_OBJECT) or {}
        if obj.get("archived_at") is not None:
            continue
        # v2.20 (N3): a seasonally paused object's schedules are frozen — no
        # lead reminders until it resumes.
        if obj.get("paused_at") is not None:
            continue
        rd = getattr(entry, "runtime_data", None)
        coordinator = getattr(rd, "coordinator", None)
        if coordinator is None:
            continue
        # Merged data so last_performed reflects the Store, not stale entry data.
        merged = coordinator._get_merged_tasks_data()
        obj_name = aggregate_object_name(entry)
        for task_id, task_data in merged.items():
            if not task_data.get("enabled", True):
                continue
            if task_data.get("archived_at") is not None:
                continue
            task = MaintenanceTask.from_dict(task_data)
            days = task.days_until_due
            # days == 0 is a valid lead ("on the due date"); negatives are the
            # overdue path's job (notify_overdue_interval_hours).
            if days is None or days < 0 or days not in leads:
                continue
            await nm.async_send_lead_reminder(
                entry_id=entry.entry_id,
                task_id=task_id,
                task_name=task.name,
                object_name=obj_name,
                days=days,
                next_due=task.next_due.isoformat() if task.next_due else None,
                responsible_user_id=task.responsible_user_id,
            )


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
    """Set up the Maintenance Supporter integration."""
    return await _async_setup_shared(hass)


async def _async_setup_shared(hass: HomeAssistant) -> bool:
    """Build the entry-independent runtime (services, WS API, listeners,
    notification manager, document store).

    HA calls ``async_setup`` exactly once per boot, but unloading the LAST
    entry pops ``hass.data[DOMAIN]`` — so an uninstall → reinstall within the
    same HA run must rebuild all of this (journey O1). ``async_setup_entry``
    re-invokes this when it finds the shared runtime missing. Re-running is
    safe: service/WS registrations overwrite in place, and the HTTP-level
    registrations (card static paths, document views) carry their own guards
    outside the popped dict.
    """
    hass.data.setdefault(DOMAIN, {})

    # "Business day" scheduling (the day-of-month `business` flag) follows the
    # user's Workday integration when one is configured — public holidays and
    # custom working weekdays instead of the plain Mon-Fri rule (#83 follow-up).
    from .helpers.workday import async_setup_business_days

    await async_setup_business_days(hass)

    # Create the notification manager (shared across all entries)
    hass.data[DOMAIN][NOTIFICATION_MANAGER_KEY] = NotificationManager(hass)

    # Create + load the global document store (per-object doc metadata + the
    # content-addressed blob registry; binaries live on disk under /config).
    doc_store = DocumentStore(hass)
    await doc_store.async_load()
    hass.data[DOMAIN][DOCUMENT_STORE_KEY] = doc_store

    # Authenticated upload + serve endpoints for document blobs (the blobs live
    # under /config, so they must never be exposed via an unauthenticated path).
    from .views import async_register_document_views

    async_register_document_views(hass)

    def _resolve_person_user_id(person_entity_id: str) -> str:
        """Resolve a person ENTITY to its linked HA user id (#128).

        Person entities are the validated way to reference a user in a
        service form (HA has no user selector) — the picker rules out typos,
        and the entity's ``user_id`` attribute carries the account link.
        """
        state = hass.states.get(person_entity_id)
        if state is None or not person_entity_id.startswith("person."):
            raise ServiceValidationError(
                f"{person_entity_id!r} is not a known person entity"
            )
        user_id = state.attributes.get("user_id")
        if not user_id:
            raise ServiceValidationError(
                f"Person {state.name!r} is not linked to a Home Assistant user account"
            )
        return str(user_id)

    async def _handle_complete(call: ServiceCall) -> None:
        """Handle the complete service call."""
        entity_id = call.data[ATTR_ENTITY_ID]
        coordinator = _get_coordinator_for_entity(hass, entity_id)
        if coordinator is None:
            raise ServiceValidationError(
                translation_domain=DOMAIN,
                translation_key="no_coordinator_for_entity",
                translation_placeholders={"entity_id": entity_id},
            )
        task_id = _get_task_id_for_entity(hass, entity_id)
        if task_id is None:
            raise ServiceValidationError(
                translation_domain=DOMAIN,
                translation_key="no_task_for_entity",
                translation_placeholders={"entity_id": entity_id},
            )
        # #128: explicit person beats the call context; the context covers the
        # common case for free (a dashboard tap propagates the tapping user).
        if call.data.get("completed_by"):
            completed_by: str | None = _resolve_person_user_id(call.data["completed_by"])
        else:
            completed_by = call.context.user_id if call.context else None
        await coordinator.complete_maintenance(
            task_id=task_id,
            notes=call.data.get("notes"),
            cost=call.data.get("cost"),
            duration=call.data.get("duration"),
            reading_value=call.data.get("reading_value"),
            completed_by=completed_by,
            completed_at=call.data.get("completed_at"),
        )

    async def _handle_reset(call: ServiceCall) -> None:
        """Handle the reset service call."""
        entity_id = call.data[ATTR_ENTITY_ID]
        coordinator = _get_coordinator_for_entity(hass, entity_id)
        if coordinator is None:
            raise ServiceValidationError(
                translation_domain=DOMAIN,
                translation_key="no_coordinator_for_entity",
                translation_placeholders={"entity_id": entity_id},
            )
        task_id = _get_task_id_for_entity(hass, entity_id)
        if task_id is None:
            raise ServiceValidationError(
                translation_domain=DOMAIN,
                translation_key="no_task_for_entity",
                translation_placeholders={"entity_id": entity_id},
            )
        await coordinator.reset_maintenance(
            task_id=task_id,
            date=call.data.get("date"),
        )

    async def _handle_skip(call: ServiceCall) -> None:
        """Handle the skip service call."""
        entity_id = call.data[ATTR_ENTITY_ID]
        coordinator = _get_coordinator_for_entity(hass, entity_id)
        if coordinator is None:
            raise ServiceValidationError(
                translation_domain=DOMAIN,
                translation_key="no_coordinator_for_entity",
                translation_placeholders={"entity_id": entity_id},
            )
        task_id = _get_task_id_for_entity(hass, entity_id)
        if task_id is None:
            raise ServiceValidationError(
                translation_domain=DOMAIN,
                translation_key="no_task_for_entity",
                translation_placeholders={"entity_id": entity_id},
            )
        await coordinator.skip_maintenance(
            task_id=task_id,
            reason=call.data.get("reason"),
        )

    async def _handle_export(call: ServiceCall) -> None:
        """Handle the export_data service call."""
        from .export import build_export_data, serialize_export_to_file

        fmt = call.data.get("format", "json")
        include_history = call.data.get("include_history", True)

        # Phase 1: gather data on the event loop (accesses HA APIs)
        data = build_export_data(hass, include_history=include_history)

        # Phase 2: serialize in executor (CPU-bound, no HA API calls)
        file_path = hass.config.path(f"maintenance_export.{fmt}")
        await hass.async_add_executor_job(serialize_export_to_file, data, fmt, file_path)

        # Fire lightweight event (file path only, not the full payload)
        hass.bus.async_fire(
            f"{DOMAIN}_export_completed",
            {"format": fmt, "file_path": file_path},
        )

    async def _handle_add_object(call: ServiceCall) -> dict[str, Any] | None:
        """Handle the add_object service — create a maintenance object."""
        from .websocket.objects import async_create_object
        from .websocket.tasks import _is_safe_url

        url = call.data.get("documentation_url")
        if url and not _is_safe_url(url):
            raise ServiceValidationError("Only http/https URLs are allowed")
        try:
            entry_id = await async_create_object(
                hass,
                name=call.data["name"],
                area_id=call.data.get("area_id"),
                manufacturer=call.data.get("manufacturer"),
                model=call.data.get("model"),
                serial_number=call.data.get("serial_number"),
                installation_date=call.data.get("installation_date"),
                warranty_expiry=call.data.get("warranty_expiry"),
                documentation_url=url,
                notes=call.data.get("notes"),
            )
        except ValueError as err:
            raise ServiceValidationError(str(err)) from err
        return {"entry_id": entry_id} if call.return_response else None

    async def _handle_add_task(call: ServiceCall) -> dict[str, Any] | None:
        """Handle the add_task service — add a task to an existing object."""
        from .websocket.tasks import async_create_task_simple

        try:
            task_id = await async_create_task_simple(
                hass,
                entry_id=call.data["entry_id"],
                name=call.data["name"],
                task_type=call.data.get("task_type", "custom"),
                schedule_type=call.data.get("schedule_type", "time_based"),
                interval_days=call.data.get("interval_days"),
                interval_unit=call.data.get("interval_unit", "days"),
                due_date=call.data.get("due_date"),
                warning_days=call.data.get("warning_days", DEFAULT_WARNING_DAYS),
                enabled=call.data.get("enabled", True),
                notes=call.data.get("notes"),
                schedule=call.data.get("schedule"),
            )
        except ValueError as err:
            raise ServiceValidationError(str(err)) from err
        return {"task_id": task_id} if call.return_response else None

    hass.services.async_register(DOMAIN, SERVICE_COMPLETE, _handle_complete, schema=SERVICE_COMPLETE_SCHEMA)
    hass.services.async_register(DOMAIN, SERVICE_RESET, _handle_reset, schema=SERVICE_RESET_SCHEMA)
    hass.services.async_register(DOMAIN, SERVICE_SKIP, _handle_skip, schema=SERVICE_SKIP_SCHEMA)
    hass.services.async_register(DOMAIN, SERVICE_EXPORT, _handle_export, schema=SERVICE_EXPORT_SCHEMA)
    hass.services.async_register(
        DOMAIN,
        SERVICE_ADD_OBJECT,
        _handle_add_object,
        schema=SERVICE_ADD_OBJECT_SCHEMA,
        supports_response=SupportsResponse.OPTIONAL,
    )
    hass.services.async_register(
        DOMAIN,
        SERVICE_ADD_TASK,
        _handle_add_task,
        schema=SERVICE_ADD_TASK_SCHEMA,
        supports_response=SupportsResponse.OPTIONAL,
    )

    async def _handle_update_task(call: ServiceCall) -> None:
        """Handle update_task — patch the common fields of an existing task."""
        from .websocket.tasks_persist import async_update_task_simple

        updates = {
            "name": call.data.get("name"),
            "type": call.data.get("task_type"),
            "schedule_type": call.data.get("schedule_type"),
            "interval_days": call.data.get("interval_days"),
            "interval_unit": call.data.get("interval_unit"),
            "due_date": call.data.get("due_date"),
            "schedule": call.data.get("schedule"),
            "warning_days": call.data.get("warning_days"),
            "enabled": call.data.get("enabled"),
            "notes": call.data.get("notes"),
            "priority": call.data.get("priority"),
            "labels": call.data.get("labels"),
        }
        # #128: assignment via automations. Person entity -> HA user id; the
        # clear flag maps to "" (async_update_task_simple pops the key).
        if call.data.get("responsible_user") and call.data.get("clear_responsible_user"):
            raise ServiceValidationError(
                "Provide either responsible_user or clear_responsible_user, not both"
            )
        if call.data.get("responsible_user"):
            updates["responsible_user_id"] = _resolve_person_user_id(call.data["responsible_user"])
        elif call.data.get("clear_responsible_user"):
            updates["responsible_user_id"] = ""
        try:
            await async_update_task_simple(
                hass,
                entry_id=call.data["entry_id"],
                task_id=call.data["task_id"],
                updates=updates,
            )
        except ValueError as err:
            raise ServiceValidationError(str(err)) from err

    async def _handle_delete_task(call: ServiceCall) -> None:
        """Handle delete_task — remove a task and its side-state."""
        from .websocket.tasks_crud import async_delete_task

        entry_id = call.data["entry_id"]
        entry = hass.config_entries.async_get_entry(entry_id)
        if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID:
            raise ServiceValidationError(f"No maintenance object found for entry_id {entry_id!r}")
        if not await async_delete_task(hass, entry, call.data["task_id"]):
            raise ServiceValidationError(f"No task {call.data['task_id']!r} in {entry.title!r}")
        await hass.config_entries.async_reload(entry_id)

    async def _handle_list_tasks(call: ServiceCall) -> dict[str, Any]:
        """Handle list_tasks — a queryable snapshot for automations/voice."""
        wanted_entry = call.data.get("entry_id")
        wanted_status = call.data.get("status")
        tasks: list[dict[str, Any]] = []
        for ce in hass.config_entries.async_entries(DOMAIN):
            if ce.unique_id == GLOBAL_UNIQUE_ID:
                continue
            if wanted_entry and ce.entry_id != wanted_entry:
                continue
            rd = getattr(ce, "runtime_data", None)
            coordinator = getattr(rd, "coordinator", None) if rd else None
            if coordinator is None or not coordinator.data:
                continue
            object_name = aggregate_object_name(ce)
            for task_id, task in coordinator.data.get(CONF_TASKS, {}).items():
                status = str(task.get("_status", ""))
                if status == "archived":
                    continue
                if wanted_status and status != wanted_status:
                    continue
                tasks.append(
                    {
                        "entry_id": ce.entry_id,
                        "task_id": task_id,
                        "object_name": object_name,
                        "name": task.get("name"),
                        "status": status,
                        "next_due": task.get("_next_due"),
                        "days_until_due": task.get("_days_until_due"),
                        "type": task.get("type"),
                        "priority": task.get("priority", "normal"),
                    }
                )
        return {"tasks": tasks, "count": len(tasks)}

    hass.services.async_register(
        DOMAIN,
        SERVICE_UPDATE_TASK,
        _handle_update_task,
        schema=SERVICE_UPDATE_TASK_SCHEMA,
    )
    hass.services.async_register(
        DOMAIN,
        SERVICE_DELETE_TASK,
        _handle_delete_task,
        schema=SERVICE_DELETE_TASK_SCHEMA,
    )
    hass.services.async_register(
        DOMAIN,
        SERVICE_LIST_TASKS,
        _handle_list_tasks,
        schema=SERVICE_LIST_TASKS_SCHEMA,
        supports_response=SupportsResponse.ONLY,
    )

    # Register WebSocket commands
    async_register_commands(hass)

    # Register Lovelace card (always available)
    await async_register_card(hass)

    # Register listener for mobile app notification action buttons
    async def _handle_notification_action(event: Event) -> None:
        """Handle mobile_app_notification_action events from Companion App."""
        action = event.data.get("action", "")
        if not action.startswith("MS_"):
            return

        # Parse action: MS_COMPLETE_{entry_id}_{task_id}
        #                MS_SKIP_{entry_id}_{task_id}
        #                MS_SNOOZE_{entry_id}_{task_id}
        # entry_id is a 32-char hex UUID, task_id is a 32-char hex UUID
        if action.startswith("MS_COMPLETE_"):
            action_type = "complete"
            remainder = action[len("MS_COMPLETE_") :]
        elif action.startswith("MS_SKIP_"):
            action_type = "skip"
            remainder = action[len("MS_SKIP_") :]
        elif action.startswith("MS_SNOOZE_"):
            action_type = "snooze"
            remainder = action[len("MS_SNOOZE_") :]
        else:
            return

        # Split on underscore — entry_id and task_id contain no underscores
        parts = remainder.split("_", 1)
        if len(parts) != 2 or not parts[0] or not parts[1]:
            _LOGGER.warning("Invalid notification action format: %s", action)
            return

        entry_id, task_id = parts

        config_entry = hass.config_entries.async_get_entry(entry_id)
        runtime_data = getattr(config_entry, "runtime_data", None) if config_entry else None
        if not runtime_data or not hasattr(runtime_data, "coordinator") or not runtime_data.coordinator:
            _LOGGER.warning("No coordinator found for notification action (entry_id=%s)", entry_id)
            return

        nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)

        try:
            if action_type == "complete":
                _LOGGER.info("Completing task %s via notification action", task_id)
                await runtime_data.coordinator.complete_maintenance(task_id=task_id, unattended=True)
            elif action_type == "skip":
                _LOGGER.info("Skipping task %s via notification action", task_id)
                await runtime_data.coordinator.skip_maintenance(task_id=task_id, reason="Skipped from notification")
            elif action_type == "snooze":
                _LOGGER.info("Snoozing task %s via notification action", task_id)
                if nm is not None:
                    nm.snooze_task(entry_id, task_id)
                return  # Snooze: keep notification visible for later reminder
        except ServiceValidationError as err:
            # The task demands details a notification button cannot capture.
            # Not an error in our code — log it plainly and deliberately do
            # NOT dismiss the notification, so the reminder survives until the
            # user completes the task properly in the panel.
            _LOGGER.warning(
                "Notification completion of task %s rejected: %s",
                task_id,
                err,
            )
            return
        except Exception:
            _LOGGER.exception(
                "Failed to handle notification action %s for task %s",
                action_type,
                task_id,
            )
            return

        # Action succeeded — dismiss notification and clear NM rate-limit state
        if nm is not None:
            await nm.async_dismiss_task_notification(task_id)
            nm.clear_task_state(entry_id, task_id)

    unsub_notification = hass.bus.async_listen("mobile_app_notification_action", _handle_notification_action)

    # Register listener for NFC tag scans → complete linked tasks
    async def _handle_tag_scanned(event: Event) -> None:
        """Handle tag_scanned events — complete task linked to NFC tag."""
        tag_id = event.data.get("tag_id", "")
        if not tag_id:
            return

        # Search all entries for a task with matching nfc_tag_id
        for ce in hass.config_entries.async_entries(DOMAIN):
            if ce.unique_id == GLOBAL_UNIQUE_ID:
                continue
            tasks = ce.data.get(CONF_TASKS, {})
            for task_id, task_data in tasks.items():
                if task_data.get("nfc_tag_id") != tag_id:
                    continue
                # Found matching task
                runtime_data = getattr(ce, "runtime_data", None)
                if not runtime_data or not getattr(runtime_data, "coordinator", None):
                    _LOGGER.warning(
                        "No coordinator for NFC tag match (entry=%s)",
                        ce.entry_id,
                    )
                    continue
                _LOGGER.debug(
                    "Completing task %s via NFC tag scan (%s)",
                    task_id,
                    tag_id,
                )
                user_id = event.context.user_id if event.context else None
                try:
                    await runtime_data.coordinator.complete_maintenance(
                        task_id=task_id,
                        completed_by=user_id,
                        notes="Completed via NFC tag",
                        unattended=True,
                    )
                except ServiceValidationError as err:
                    # A tag tap cannot capture a cost or a photo. Log it
                    # plainly and leave the task open so the user finishes it
                    # in the panel — the honest outcome, not a silent no-op.
                    _LOGGER.warning("NFC completion of task %s rejected: %s", task_id, err)
                return

        # No match found — not our tag, ignore silently

    unsub_tag = hass.bus.async_listen("tag_scanned", _handle_tag_scanned)

    # v1.3.0: per-task on_complete_action listener (Layer B). Subscribes to
    # EVENT_TASK_COMPLETED — same event power users can listen for in their
    # own automations. Single listener handles all entries' tasks.
    from .helpers.action_listener import register_action_listener

    unsub_action = register_action_listener(hass)

    # v1.5.3 (#48): reverse-sync HA device.area_id → obj.area_id when the user
    # changes the area in the HA UI / device settings. The forward sync
    # (obj.area_id → device.area_id) happens via the per-entry update listener
    # registered in async_setup_entry. Together these two listeners keep the
    # dashboard area and the HA device area in sync after the initial creation
    # (DeviceInfo.suggested_area only fires once, on first device creation).
    @callback
    def _object_entries_for_device(
        hass: HomeAssistant, device: dr.DeviceEntry
    ) -> list[ConfigEntry]:
        """Our object entries that live on this device.

        Two ways an object reaches a device page, and they need different
        lookups:

        * its OWN device — our config entry is listed on it;
        * a LINKED device owned by another integration — we deliberately do
          NOT add our config entry there any more (HA 2026.8 forbids it), so
          the only record of the link is ``obj["ha_device_id"]``. Matching on
          ``device.config_entries`` alone would silently stop syncing exactly
          the objects the user attached by hand.

        Several objects may point at the same device; all of them follow it.
        """
        found: list[ConfigEntry] = []
        seen: set[str] = set()
        # 2026.8 gives a device exactly one config entry (`config_entry_id`);
        # the plural `config_entries` is a silently-deprecated compat shim
        # there — no report_usage, so the deprecation gate cannot see it — and
        # the only spelling 2026.7 has. Prefer the singular where it exists.
        single = getattr(device, "config_entry_id", None)
        owner_ids = (single,) if single is not None else tuple(getattr(device, "config_entries", ()) or ())
        for ce_id in owner_ids:
            ce = hass.config_entries.async_get_entry(ce_id)
            if ce is not None and ce.domain == DOMAIN and ce.unique_id != GLOBAL_UNIQUE_ID:
                found.append(ce)
                seen.add(ce.entry_id)
        for ce in hass.config_entries.async_entries(DOMAIN):
            if ce.entry_id in seen or ce.unique_id == GLOBAL_UNIQUE_ID:
                continue
            if (ce.data.get(CONF_OBJECT) or {}).get("ha_device_id") == device.id:
                found.append(ce)
        return found

    @callback
    def _on_device_registry_update(
        event: Event[dr.EventDeviceRegistryUpdatedData],
    ) -> None:
        if event.data["action"] != "update":
            return
        changes = event.data.get("changes") or {}
        if "area_id" not in changes:
            return
        device_id = event.data["device_id"]
        device = dr.async_get(hass).async_get(device_id)
        if device is None:
            return
        for ce in _object_entries_for_device(hass, device):
            obj = dict(ce.data.get(CONF_OBJECT, {}))
            if obj.get("area_id") == device.area_id:
                continue  # already in sync — breaks the loop with the forward listener
            obj["area_id"] = device.area_id
            hass.config_entries.async_update_entry(ce, data={**ce.data, CONF_OBJECT: obj})

    unsub_device = hass.bus.async_listen(dr.EVENT_DEVICE_REGISTRY_UPDATED, _on_device_registry_update)

    # v1.5.4: rewrite stored entity_id references when HA renames an entity.
    # Without this, ``trigger_config["entity_id"]`` /
    # ``adaptive_config["environmental_entity"]`` go stale and the underlying
    # ``async_track_state_change_event`` listeners silently miss events on the
    # new id. Same dual-storage bug class as #48 — different field, same fix.
    async def _on_entity_registry_update(
        event: Event[er.EventEntityRegistryUpdatedData],
    ) -> None:
        if event.data["action"] != "update":
            return
        changes = event.data.get("changes") or {}
        if "entity_id" not in changes:
            return  # not a rename
        new_eid = event.data["entity_id"]
        old_eid = changes["entity_id"]
        if not isinstance(old_eid, str) or old_eid == new_eid:
            return

        from .helpers.entity_rename import rewrite_store, rewrite_tasks

        for ce in hass.config_entries.async_entries(DOMAIN):
            if ce.unique_id == GLOBAL_UNIQUE_ID:
                continue

            # 1. trigger_config refs live in entry.data
            entry_changed = False
            tasks = ce.data.get(CONF_TASKS, {})
            if tasks:
                new_tasks, entry_changed = rewrite_tasks(tasks, old_eid, new_eid)
                if entry_changed:
                    new_data = {**ce.data, CONF_TASKS: new_tasks}
                    hass.config_entries.async_update_entry(ce, data=new_data)

            # 2. adaptive_config.environmental_entity + trigger_runtime keys
            #    live in Store (post-migration; see _DYNAMIC_TASK_FIELDS).
            rd = getattr(ce, "runtime_data", None)
            store = getattr(rd, "store", None) if rd else None
            store_changed = False
            if store is not None:
                store_changed = rewrite_store(store, old_eid, new_eid)
                if store_changed:
                    # Force-save before the reload — the reload re-instantiates
                    # the store from disk, so a debounced save would race the
                    # reload and lose the rewrite.
                    await store.async_save()

            if not (entry_changed or store_changed):
                continue

            # The trigger listeners on `BaseTrigger` subscribed to the OLD
            # entity_id and won't follow the rename — schedule a reload so
            # they re-instantiate against the new id.
            hass.config_entries.async_schedule_reload(ce.entry_id)
            _LOGGER.info(
                "Rewrote entity references %s → %s for entry %s (entry_data=%s, store=%s) and scheduled reload",
                old_eid,
                new_eid,
                ce.entry_id,
                entry_changed,
                store_changed,
            )

    unsub_entity = hass.bus.async_listen(er.EVENT_ENTITY_REGISTRY_UPDATED, _on_entity_registry_update)

    # v2.10.0: daily archive / auto-delete retention sweep (completed one-offs).
    # The pure decision logic + the sweep live in helpers/retention; this just
    # paces it once a day. First fire is +24h, which is plenty for a day-granular
    # policy (a freshly due archive simply waits for the next tick).
    from .helpers.retention import async_run_retention_sweep

    async def _retention_tick(_now: Any) -> None:
        await async_run_retention_sweep(hass)

    unsub_retention = async_track_time_interval(hass, _retention_tick, timedelta(hours=24), cancel_on_shutdown=True)

    # v2.15.0: opt-in weekly digest — one summary notification on Monday morning.
    # Fires daily at 08:00 local; the gating lives in async_maybe_send_weekly_digest.
    async def _weekly_digest_tick(now: Any) -> None:
        await async_maybe_send_weekly_digest(hass)
        await async_maybe_send_warranty_reminders(hass)
        await async_maybe_send_lead_reminders(hass)

    unsub_digest = async_track_time_change(hass, _weekly_digest_tick, hour=8, minute=0, second=0)
    # async_track_time_change has no cancel_on_shutdown, so cancel it explicitly
    # on HA stop too — otherwise the daily timer lingers past shutdown when the
    # entries aren't formally unloaded (e.g. test teardown).
    hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, lambda _e: unsub_digest())

    # Store unsub callbacks so they can be cleaned up when domain is unloaded
    hass.data[DOMAIN][EVENT_UNSUBS_KEY] = [
        unsub_notification,
        unsub_tag,
        unsub_action,
        unsub_device,
        unsub_entity,
        unsub_retention,
        unsub_digest,
    ]

    # Once HA has started (all entries loaded), flag object entries that were
    # orphaned by a deleted global entry as a fixable repair issue (#86).
    async_at_started(hass, _sync_missing_global_entry_issue)

    return True


def _detect_advanced_feature_usage(hass: HomeAssistant, global_options: dict[str, Any]) -> dict[str, bool]:
    """Scan existing entries to detect which advanced features are in use."""
    adaptive = False
    predictions = False
    seasonal = False
    environmental = False
    checklists = False
    schedule_time = False

    for entry in hass.config_entries.async_entries(DOMAIN):
        if entry.unique_id == GLOBAL_UNIQUE_ID:
            continue
        tasks = entry.data.get(CONF_TASKS, {})
        for task_data in tasks.values():
            ac = task_data.get("adaptive_config") or {}
            if ac.get("enabled"):
                adaptive = True
            if ac.get("sensor_prediction_enabled"):
                predictions = True
            if ac.get("seasonal_enabled"):
                seasonal = True
            if ac.get("environmental_entity"):
                environmental = True
            if task_data.get("checklist"):
                checklists = True
            if task_data.get("schedule_time"):
                schedule_time = True

    budget = global_options.get(CONF_BUDGET_MONTHLY, 0) > 0 or global_options.get(CONF_BUDGET_YEARLY, 0) > 0
    groups = bool(global_options.get(CONF_GROUPS, {}))

    return {
        CONF_ADVANCED_ADAPTIVE: adaptive,
        CONF_ADVANCED_PREDICTIONS: predictions,
        CONF_ADVANCED_SEASONAL: seasonal,
        CONF_ADVANCED_ENVIRONMENTAL: environmental,
        CONF_ADVANCED_BUDGET: budget,
        CONF_ADVANCED_GROUPS: groups,
        CONF_ADVANCED_CHECKLISTS: checklists,
        CONF_ADVANCED_SCHEDULE_TIME: schedule_time,
    }


async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
    """Migrate a config entry forward in version (HA pattern).

    minor_version 1 → 2 (issue #30): backfill ``created_at`` for tasks that
    were created before the field existed. Uses the earliest history entry
    timestamp when available, otherwise today. This locks the next_due
    fallback anchor so the schedule advances normally instead of always
    pointing at "today".

    minor_version 2 → 3 (schedule-model v2): move the flat recurrence fields
    (interval_days / interval_unit / interval_anchor / schedule_type / due_date)
    into a single nested ``schedule`` object. Behaviour is identical — readers
    accept both shapes — this just makes storage canonical and unblocks the
    calendar recurrence kinds (nth-weekday etc.).

    minor_version 3 → 4 (discussion #49): seed ``responsible_user_id`` on
    rotation tasks that were configured without an initial assignee — those
    were invisible to every user filter until their first completion.
    """
    if entry.version > 1 or entry.minor_version >= 5:
        return True

    is_object_entry = entry.unique_id != GLOBAL_UNIQUE_ID
    data = dict(entry.data)
    minor = entry.minor_version

    # 1 → 2: backfill created_at (issue #30)
    if minor < 2:
        if is_object_entry:
            from homeassistant.util import dt as dt_util

            tasks_data = data.get(CONF_TASKS, {})
            if tasks_data:
                today_iso = dt_util.now().date().isoformat()
                new_tasks: dict[str, dict[str, Any]] = {}
                for task_id, td in tasks_data.items():
                    if "created_at" in td or td.get("last_performed"):
                        new_tasks[task_id] = td
                        continue
                    # Try to recover creation date from earliest history entry
                    anchor: str = today_iso
                    history = td.get("history") or []
                    if isinstance(history, list) and history:
                        timestamps: list[str] = [
                            ts for h in history if isinstance(h, dict) and isinstance((ts := h.get("timestamp")), str) and ts
                        ]
                        if timestamps:
                            # ISO timestamps sort lexicographically; take YYYY-MM-DD
                            anchor = min(timestamps)[:10]
                    new_td = dict(td)
                    new_td["created_at"] = anchor
                    new_tasks[task_id] = new_td
                data[CONF_TASKS] = new_tasks
        minor = 2

    # 2 → 3: flat recurrence fields → nested `schedule` (schedule-model v2)
    if minor < 3:
        if is_object_entry:
            tasks_data = data.get(CONF_TASKS, {})
            if tasks_data:
                data[CONF_TASKS] = {task_id: normalize_task_storage(td) for task_id, td in tasks_data.items()}
        minor = 3

    # 3 → 4: rotation tasks without an initial assignee get one (discussion #49)
    if minor < 4:
        if is_object_entry:
            from .helpers.sanitize import seed_rotation_assignee

            tasks_data = data.get(CONF_TASKS, {})
            if tasks_data:
                new_tasks = {}
                for task_id, td in tasks_data.items():
                    new_td = dict(td)
                    seed_rotation_assignee(new_td)
                    new_tasks[task_id] = new_td
                data[CONF_TASKS] = new_tasks
        minor = 4

    # 4 → 5: stop co-owning a linked device. Until now, attaching an object to
    # an existing device also put OUR config entry on that device. Home
    # Assistant forbids that from 2026.8 and splits such a device into one per
    # config entry on upgrade — leaving a nameless extra device holding our
    # entities while the appliance's page shows none. Removing the association
    # here means the split never has anything to split.
    if minor < 5:
        if is_object_entry and (device_id := (data.get(CONF_OBJECT) or {}).get("ha_device_id")):
            from .helpers.device_link import is_self_link, shed_owned_devices

            # A SELF-link (the stored id names our own device — the old picker
            # offered the object's doppelgänger) must not be shed: the "source"
            # is the very device the entities live on, and shedding it deletes
            # and restores it in one boot (prod 2026-08-01, three Roborocks).
            # Setup raises the device_link_self notice for these instead.
            if not is_self_link(hass, device_id, own_entry_id=entry.entry_id):
                shed_owned_devices(hass, own_entry_id=entry.entry_id, source_device_id=device_id)
        minor = 5

    hass.config_entries.async_update_entry(entry, data=data, minor_version=minor)
    _LOGGER.info("Migrated entry %s to minor_version %s", entry.entry_id, minor)
    return True


async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConfigEntry) -> bool:
    """Set up Maintenance Supporter from a config entry."""
    # Unloading the LAST entry pops hass.data[DOMAIN] (notification manager,
    # document store, event listeners) and HA runs async_setup only once per
    # boot — a reinstall in the same run must rebuild the shared runtime
    # (journey O1: sensor setup crashed on the missing document store).
    if NOTIFICATION_MANAGER_KEY not in hass.data.setdefault(DOMAIN, {}):
        await _async_setup_shared(hass)

    is_global = entry.unique_id == GLOBAL_UNIQUE_ID
    # True once the object's stored device link resolved to a live device this
    # setup — drives the post-platform cleanup of empty leftover own devices.
    linked_device_live = False

    if is_global:
        # Global entry: no per-object coordinator, but a summary coordinator
        # aggregates task status counts across all objects to back the global
        # summary sensors (sensor.maintenance_supporter_overdue, etc.).
        summary_coordinator = MaintenanceSummaryCoordinator(hass)
        entry.runtime_data = MaintenanceSupporterData(summary_coordinator=summary_coordinator)
        summary_coordinator.async_setup_listeners()
        entry.async_on_unload(summary_coordinator.async_teardown_listeners)
        await summary_coordinator.async_refresh()

        # One-time migration: auto-enable advanced feature flags for existing users
        options = dict(entry.options or entry.data)
        if CONF_ADVANCED_ADAPTIVE not in options:
            flags = _detect_advanced_feature_usage(hass, options)
            options.update(flags)
            hass.config_entries.async_update_entry(entry, options=options)
            _LOGGER.info("Migrated advanced feature flags: %s", flags)

        # Register panel if enabled in options (on by default — see const).
        if entry.options.get(CONF_PANEL_ENABLED, DEFAULT_PANEL_ENABLED):
            await async_register_panel(hass)

        # Listen for options changes (panel toggle)
        entry.async_on_unload(entry.add_update_listener(_async_global_options_updated))

        # Keep <config>/custom_sentences/ in step with the opt-in setting. Runs
        # on every setup, not just on change, so an upgrade that ships new
        # sentences reaches an install that already opted in.
        await async_sync_assist_sentences(
            hass, entry.options.get(CONF_INSTALL_ASSIST_SENTENCES, False)
        )

        # Initial orphan check for admin_panel_user_ids (HA users deleted
        # while the integration was offline land here as repair issues).
        await _check_admin_panel_user_orphans(hass, entry)

        # v1.5.4: also clear stale ``task.responsible_user_id`` so the
        # dashboard stops showing the ghost of deleted users.
        await _check_task_responsible_user_orphans(hass)

        # Surface a configured-but-missing notify service as a repair issue.
        # Deferred to HA-started so lazily-registered notify services (mobile_app,
        # notify groups) are present before we judge the service missing.
        entry.async_on_unload(async_at_started(hass, _verify_notify_service))

        # Surface document-storage anomalies (orphan/dangling blobs after a
        # crash or partial restore) as a fixable repair issue. Deferred to
        # HA-started so the store has finished loading.
        entry.async_on_unload(async_at_started(hass, _check_document_storage_issues))

        # A global entry now exists — clear the orphan repair issue immediately
        # (e.g. right after the repair flow recreated it, when HA is already
        # started and the async_at_started check above won't fire again).
        _sync_missing_global_entry_issue(hass)

        _LOGGER.debug("Global config entry set up: %s", entry.entry_id)
    else:
        # Maintenance object entry: fetch-or-create the Store. Reused across
        # entry RELOADS (cache in a top-level hass.data key): a second Store
        # instance for the same file would race the first one's pending
        # debounced save — see STORES_CACHE_KEY in const.py.
        stores: dict[str, MaintenanceStore] = hass.data.setdefault(STORES_CACHE_KEY, {})
        cached_store = stores.get(entry.entry_id)
        store = cached_store if cached_store is not None else MaintenanceStore(hass, entry.entry_id)
        stores[entry.entry_id] = store

        if cached_store is None:
            # First setup this run: load from disk + one-time migration.
            # Compare against the CAPTURED snapshot, not the live entry.data:
            # the migration awaits store I/O, and a concurrent WS write during
            # that window replaces entry.data — an identity check against the
            # live attribute then fails spuriously and this write clobbers the
            # concurrent update with the pre-await snapshot (lost update, seen
            # live when rapid part creates raced a reload's setup).
            data_before_migration = entry.data
            cleaned_data = await async_migrate_to_store(hass, entry.entry_id, data_before_migration, store)
            if cleaned_data is not data_before_migration:
                hass.config_entries.async_update_entry(entry, data=dict(cleaned_data))
        # A cached store's memory is authoritative — re-loading from disk here
        # would drop any change still sitting in its debounce window.

        # Reconcile the entry.data <-> Store split (journey I1): drop store
        # state orphaned by a crash between the two writes of a deletion.
        # Same reconciliation for spare-part stock state (journey S6): a crash
        # between the ConfigEntry write and the Store save on a part deletion
        # leaves its stock orphaned forever otherwise.
        pruned_parts = store.prune_part_orphans(set(entry.data.get("parts") or {}))
        if pruned_parts:
            _LOGGER.info("Pruned %d orphaned part stock state(s) for %s", pruned_parts, entry.title)
            await store.async_save()

        pruned = store.prune_orphans(set(entry.data.get(CONF_TASKS, {})))
        if pruned:
            _LOGGER.info(
                "Pruned %d orphaned task state(s) from store for %s",
                pruned,
                entry.title,
            )
            await store.async_save()

        # A device link stored before HA 2026.8 may now point at a
        # pre-migration composite id, which names no registered device — HA
        # refuses to attach entities to it and tells the user to file a bug
        # against us. Repoint it at the surviving device and write that back,
        # so the repair happens once rather than on every start.
        obj_data = entry.data.get(CONF_OBJECT) or {}
        if stored_device_id := obj_data.get("ha_device_id"):
            from .helpers.device_link import (
                has_own_devices,
                resolve_linked_device_id,
                shed_owned_devices,
            )

            resolved = resolve_linked_device_id(hass, stored_device_id, own_entry_id=entry.entry_id)
            if resolved and resolved != stored_device_id:
                _LOGGER.info(
                    "Object %s was linked to device %s, which the 2026.8 split replaced; repointed to %s",
                    entry.title,
                    stored_device_id,
                    resolved,
                )
                hass.config_entries.async_update_entry(
                    entry,
                    data={**entry.data, CONF_OBJECT: {**obj_data, "ha_device_id": resolved}},
                )
            # An id we cannot resolve is deliberately LEFT ALONE. Clearing it
            # would gain nothing — a missing device already falls back to an own
            # device at render time — and would destroy the user's choice for
            # good. The id may well be meaningful again later: a JSON export
            # carries it to a foreign instance where it dangles until the backup
            # comes home, and a device can return when its integration is
            # re-added or re-enabled.
            #
            # Falling back is quiet, though, and a link the user set on purpose
            # is worth telling them about rather than letting the object drift
            # onto a device of its own unannounced. The issue clears itself the
            # moment the link resolves again.
            from homeassistant.helpers import issue_registry as ir

            from .helpers.device_link import is_self_link

            issue_id = f"device_link_lost_{entry.entry_id}"
            if resolved is None:
                # Two distinct stories share one issue id (so resolve/delete
                # stays single-path): the linked device is GONE, or the link
                # points at the object's own doppelgänger device — the old
                # picker offered it under the appliance's exact name. The
                # advice differs, so the translation key does too. Both are
                # fixable: the flow relinks to the device the user picks.
                self_link = is_self_link(hass, stored_device_id, own_entry_id=entry.entry_id)
                ir.async_create_issue(
                    hass,
                    DOMAIN,
                    issue_id,
                    is_fixable=True,
                    severity=ir.IssueSeverity.WARNING,
                    translation_key="device_link_self" if self_link else "device_link_lost",
                    translation_placeholders={"object": aggregate_object_name(entry)},
                    data={"entry_id": entry.entry_id},
                )
            else:
                ir.async_delete_issue(hass, DOMAIN, issue_id)

            # A linked object owns no device. If it does, this install predates
            # the rework — either it co-owned the appliance's device (split on
            # upgrade) or it forked one carrying the appliance's identity. Hand
            # that to Home Assistant's own repair, which also relinks the
            # entities, rather than deleting devices by hand.
            if has_own_devices(hass, entry.entry_id):
                _LOGGER.info("Shedding device(s) %s should not own after linking", entry.title)
                shed_owned_devices(hass, own_entry_id=entry.entry_id, source_device_id=resolved)
            linked_device_live = resolved is not None

        # #115: the battery fleet's seeded name/notes/part texts are stored
        # snapshots — a fleet set up before localized seeding existed (or under
        # a different UI language) keeps English notes forever while every
        # runtime string around them is translated. Rewrite the ones the user
        # never touched into the instance's language; edited texts stay.
        if obj_data.get(BATTERY_FLEET_OBJECT_FLAG):
            from .helpers.battery_fleet_setup import retranslate_seeded_texts
            from .helpers.i18n import normalize_language

            if retranslate_seeded_texts(hass, entry, normalize_language(hass)):
                _LOGGER.info("Retranslated the battery fleet's seeded texts for %s", entry.title)

        coordinator = MaintenanceCoordinator(hass, entry, store)
        entry.runtime_data = MaintenanceSupporterData(coordinator=coordinator, store=store)
        await coordinator.async_config_entry_first_refresh()

        # v1.5.3 (#48): forward-sync obj fields → device_registry whenever the
        # entry data changes (WS update OR config-flow re-edit). DeviceInfo
        # only seeds these on first device creation; after that, dashboard
        # edits never reach the device unless we push them. Combined with the
        # global EVENT_DEVICE_REGISTRY_UPDATED listener registered in
        # async_setup, this gives the area a true bidirectional sync.
        entry.async_on_unload(entry.add_update_listener(_async_sync_obj_to_device))

        # Notify WS subscribers that a new object entry is available
        from homeassistant.helpers.dispatcher import async_dispatcher_send

        async_dispatcher_send(hass, SIGNAL_NEW_OBJECT_ENTRY, entry.entry_id)

        # Buy-task catch-up (spare parts): converge the shopping reminders with
        # the current stock/pause/archive state — covers import/restore, a
        # resume/unarchive, and any change made while this entry was unloaded.
        # Declarative + idempotent, reloads only when something changed.
        if entry.data.get("parts"):
            from .parts_runtime import schedule_buy_task_reconcile

            schedule_buy_task_reconcile(hass, entry)

        _LOGGER.debug(
            "Maintenance object entry set up: %s (%s)",
            entry.title,
            entry.entry_id,
        )

    await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

    if not is_global:
        # Parent nesting is written as an explicit registry `via_device_id`
        # AFTER the platforms created the devices — DeviceInfo's `via_device`
        # identifier tuple is deprecated (removal HA 2027.8) and its
        # cross-entry identifier lookup is what 2026.8's scoping ends. Runs
        # both directions, so a child that boots before its parent still nests.
        from .helpers.device_link import sync_via_device_links

        sync_via_device_links(hass, entry)

    if linked_device_live:
        # The entities of a linked object live on the appliance'''s device, so a
        # device of our own with nothing on it is a leftover — of the object'''s
        # earlier unlinked life, or of the 2026.8 split — and shows up as an
        # empty duplicate named after the object. The targeted shed above only
        # removes duplicates of the SOURCE device, so an old own device (our
        # identifiers, not a fork) survives it on both HA versions. Decidable
        # only now: the platform setup above has re-attached the entities.
        dev_reg = dr.async_get(hass)
        ent_reg = er.async_get(hass)
        for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id):
            if not er.async_entries_for_device(ent_reg, device.id, include_disabled_entities=True):
                _LOGGER.info("Removing empty leftover device %s of linked object %s", device.id, entry.title)
                dev_reg.async_remove_device(device.id)

    return True


async def _async_sync_obj_to_device(hass: HomeAssistant, entry: ConfigEntry) -> None:
    """Push obj.* (area_id, name, manufacturer, model, serial_number) → device.

    Fires whenever an object entry's data is updated. Without this, the
    dashboard's WS update of obj.area_id never reaches HA's device_registry,
    so the device stays stuck on whatever DeviceInfo.suggested_area set at
    first creation (issue #48).
    """
    obj = entry.data.get(CONF_OBJECT, {}) or {}
    if obj.get("ha_device_id"):
        # Linked to an EXISTING device owned by another integration (2.19) —
        # pushing our object metadata would overwrite that device's
        # name/manufacturer/area. The owning integration stays authoritative.
        return
    dev_reg = dr.async_get(hass)
    devices = dr.async_entries_for_config_entry(dev_reg, entry.entry_id)
    obj_name = obj.get("name")
    obj_area = obj.get("area_id")
    obj_manu = obj.get("manufacturer")
    obj_model = obj.get("model")
    obj_serial = obj.get("serial_number")
    for device in devices:
        kwargs: dict[str, Any] = {}
        if device.area_id != obj_area:
            kwargs["area_id"] = obj_area
        # Only push name when obj has one (HA requires non-empty device.name).
        if obj_name and device.name != obj_name:
            kwargs["name"] = obj_name
        if device.manufacturer != obj_manu:
            kwargs["manufacturer"] = obj_manu
        if device.model != obj_model:
            kwargs["model"] = obj_model
        if device.serial_number != obj_serial:
            kwargs["serial_number"] = obj_serial
        if kwargs:
            dev_reg.async_update_device(device.id, **kwargs)


@callback
def _verify_notify_service(hass: HomeAssistant) -> None:
    """Sync the "configured notify service missing" repair issue with reality.

    Runs once HA has started (lazily-registered services such as mobile_app are
    available by then, avoiding a false alarm during boot) and again whenever the
    global options change (the service may have just been (re)configured).
    """
    nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
    if isinstance(nm, NotificationManager):
        nm.async_verify_configured_service()


_DOC_STORAGE_ISSUE_ID = "document_storage_issues"


async def _check_document_storage_issues(hass: HomeAssistant) -> None:
    """Sync the document-storage repair issue with the current on-disk reality.

    The store is consistent under normal operation; anomalies (orphaned blobs,
    dangling records, stale registry entries) only arise from a crash, an
    external file delete, or a partial restore — so a boot-time scan is the
    right trigger. The fixable issue's flow reclaims the space; once a later
    scan comes back clean the issue is removed.
    """
    from homeassistant.helpers import issue_registry as ir

    store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
    if not isinstance(store, DocumentStore):
        return
    issues = await store.async_find_issues()
    if any(issues.values()):
        ir.async_create_issue(
            hass,
            DOMAIN,
            _DOC_STORAGE_ISSUE_ID,
            is_fixable=True,
            severity=ir.IssueSeverity.WARNING,
            translation_key=_DOC_STORAGE_ISSUE_ID,
        )
    else:
        ir.async_delete_issue(hass, DOMAIN, _DOC_STORAGE_ISSUE_ID)


_MISSING_GLOBAL_ISSUE_ID = "missing_global_entry"


@callback
def _sync_missing_global_entry_issue(hass: HomeAssistant) -> None:
    """Surface "object entries exist but the global entry is gone" as a repair.

    The config flow always creates the global "Maintenance Supporter" entry
    before the first object, but a user can later delete just that entry from
    Settings → Devices & Services, leaving orphan object entries behind. That
    strips the summary sensors, the sidebar panel and the digests — and the
    dashboard KPI chips read "unknown" (#86, 2nd report). The fixable issue's
    flow recreates the global entry with default settings; it clears itself once
    a global entry is present again.
    """
    from homeassistant.helpers import issue_registry as ir

    entries = hass.config_entries.async_entries(DOMAIN)
    has_global = any(e.unique_id == GLOBAL_UNIQUE_ID for e in entries)
    has_objects = any(e.unique_id != GLOBAL_UNIQUE_ID for e in entries)
    if has_objects and not has_global:
        ir.async_create_issue(
            hass,
            DOMAIN,
            _MISSING_GLOBAL_ISSUE_ID,
            is_fixable=True,
            severity=ir.IssueSeverity.ERROR,
            translation_key=_MISSING_GLOBAL_ISSUE_ID,
        )
    else:
        ir.async_delete_issue(hass, DOMAIN, _MISSING_GLOBAL_ISSUE_ID)


async def _async_global_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
    """React to global options changes (panel toggle / sidebar title)."""
    panel_enabled = entry.options.get(CONF_PANEL_ENABLED, DEFAULT_PANEL_ENABLED)
    if panel_enabled:
        # force=True so a changed sidebar title (CONF_PANEL_TITLE) re-registers
        # the panel; it's a no-op refresh when the title is unchanged.
        await async_register_panel(hass, force=True)
    else:
        await async_unregister_panel(hass)
    await _check_admin_panel_user_orphans(hass, entry)
    # The notify service may have just been (re)configured — re-check it.
    _verify_notify_service(hass)
    # Install or remove the Assist sentence files to match the setting.
    await async_sync_assist_sentences(
        hass, entry.options.get(CONF_INSTALL_ASSIST_SENTENCES, False)
    )


_ORPHAN_ISSUE_PREFIX = "orphan_admin_panel_user_"


async def _check_task_responsible_user_orphans(
    hass: HomeAssistant,
) -> None:
    """Clear task assignments pointing at deleted HA users.

    Same class of bug as ``admin_panel_user_ids`` orphans (#48 audit) but for
    tasks: a user can be deleted in HA while we hold their UUID in entry data.
    Two references need pruning:

    * ``responsible_user_id`` — dashboards would render "Unknown user"
      forever; notifications already fall back to the global service.
    * ``assignee_pool`` (2.17 rotation) — a ghost member is WORSE than a
      ghost pointer: ``advance_rotation`` keeps assigning the deleted user
      (``least_completed`` picks them forever at 0 completions), so pruning
      only the pointer would resurrect it on the next completion.

    When the pool shrinks below 2 members the rotation is dissolved
    (mirroring the task-dialog save rule); when the orphaned pointer leaves a
    healthy pool behind, responsibility moves to the first remaining member
    so the shared task never goes unassigned. Silent removal with an info
    log — no repair issue, no user prompt.
    """
    valid_ids = {u.id for u in await hass.auth.async_get_users()}
    if not valid_ids:
        # Defensive: production HA always has at least the owner user. An
        # empty list means we're in a test harness without an auth fixture
        # or auth is mid-load — either way, don't prune references we
        # can't actually verify as orphaned.
        return
    for entry in hass.config_entries.async_entries(DOMAIN):
        if entry.unique_id == GLOBAL_UNIQUE_ID:
            continue
        tasks = entry.data.get(CONF_TASKS, {})
        new_tasks: dict[str, dict[str, Any]] = {}
        changed = False
        for tid, td in tasks.items():
            ruid = td.get("responsible_user_id")
            pool = [u for u in (td.get("assignee_pool") or []) if isinstance(u, str)]
            pruned_pool = [u for u in pool if u in valid_ids]
            resp_orphaned = bool(ruid) and ruid not in valid_ids
            if not resp_orphaned and pruned_pool == pool:
                new_tasks[tid] = td
                continue

            new_td = dict(td)
            changed = True
            if pruned_pool != pool:
                _LOGGER.info(
                    "Removing %d deleted user(s) from assignee_pool on task %s (%s)",
                    len(pool) - len(pruned_pool),
                    tid,
                    entry.title,
                )
                if len(pruned_pool) >= 2:
                    new_td["assignee_pool"] = pruned_pool
                else:
                    # A rotation needs at least two members.
                    new_td.pop("assignee_pool", None)
                    new_td.pop("rotation_strategy", None)
            if resp_orphaned:
                _LOGGER.info(
                    "Clearing orphaned responsible_user_id %s on task %s (%s)",
                    ruid,
                    tid,
                    entry.title,
                )
                if len(pruned_pool) >= 2:
                    # Keep the shared task assigned: hand over to the pool.
                    new_td["responsible_user_id"] = pruned_pool[0]
                else:
                    new_td.pop("responsible_user_id", None)
            new_tasks[tid] = new_td
        if changed:
            new_data = {**entry.data, CONF_TASKS: new_tasks}
            hass.config_entries.async_update_entry(entry, data=new_data)


async def _check_admin_panel_user_orphans(hass: HomeAssistant, entry: ConfigEntry) -> None:
    """Surface admin_panel_user_ids entries pointing at deleted HA users.

    Each orphaned id becomes a fixable repair issue. The repair flow lets
    the admin remove the entry from the list with one click. Issues for
    ids that have been validated, removed from the list, or restored are
    cleared automatically.
    """
    from homeassistant.helpers import issue_registry as ir

    user_ids_raw = entry.options.get(CONF_ADMIN_PANEL_USER_IDS, []) or []
    if not isinstance(user_ids_raw, list):
        user_ids_raw = []
    user_ids: set[str] = {u for u in user_ids_raw if isinstance(u, str)}
    valid_ids = {u.id for u in await hass.auth.async_get_users()}

    # 1) Drop any pre-existing orphan issue whose target id is no longer
    #    in the list (admin removed it) OR is now valid (user re-created).
    #    Scope the iteration to OUR domain via the (domain, issue_id) tuple
    #    keys instead of scanning every integration's issues.
    issue_reg = ir.async_get(hass)
    stale_issue_ids = [iid for (dom, iid) in issue_reg.issues if dom == DOMAIN and iid.startswith(_ORPHAN_ISSUE_PREFIX)]
    for issue_id in stale_issue_ids:
        target_uid = issue_id[len(_ORPHAN_ISSUE_PREFIX) :]
        if target_uid not in user_ids or target_uid in valid_ids:
            ir.async_delete_issue(hass, DOMAIN, issue_id)

    # 2) Create issues for ids in the list that no longer match a HA user.
    for uid in user_ids:
        if uid in valid_ids:
            continue
        ir.async_create_issue(
            hass,
            DOMAIN,
            f"{_ORPHAN_ISSUE_PREFIX}{uid}",
            is_fixable=True,
            severity=ir.IssueSeverity.WARNING,
            translation_key="orphan_admin_panel_user",
            translation_placeholders={"user_id": uid[:8]},
            data={"user_id": uid, "entry_id": entry.entry_id},
        )


async def async_unload_entry(hass: HomeAssistant, entry: MaintenanceSupporterConfigEntry) -> bool:
    """Unload a config entry."""
    if entry.unique_id == GLOBAL_UNIQUE_ID:
        # Unregister panel when global entry is unloaded
        await async_unregister_panel(hass)

    # Flush a pending debounced store save BEFORE tearing down — belt and
    # suspenders next to the store cache: disk is current the moment the entry
    # goes away, whether or not it ever comes back this run.
    store = hass.data.get(STORES_CACHE_KEY, {}).get(entry.entry_id)
    if store is not None:
        await store.async_save()

    unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

    # Clean up domain data if no entries left
    remaining = [e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id != entry.entry_id]
    if not remaining:
        nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
        if nm is not None:
            await nm.async_unload()
        for unsub in hass.data.get(DOMAIN, {}).get(EVENT_UNSUBS_KEY, []):
            unsub()
        hass.data.pop(DOMAIN, None)

    return unload_ok


async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
    """Clean up store file when a config entry is permanently deleted."""
    if entry.unique_id == GLOBAL_UNIQUE_ID:
        # Deleting the global entry while object entries remain is a broken
        # state (no summary sensors / panel / digests) — surface it right away
        # as a fixable repair issue so the user can restore it (#86).
        _sync_missing_global_entry_issue(hass)
        return

    # A "lost its device link" notice belongs to the object; deleting the object
    # answers it, so it must not outlive it as an orphan in the Repairs list.
    from homeassistant.helpers import issue_registry as ir

    ir.async_delete_issue(hass, DOMAIN, f"device_link_lost_{entry.entry_id}")

    # #111: hand any shared spare-part pool to a borrower BEFORE the Store goes
    # — the stock numbers exist nowhere else. Must happen here rather than in
    # the panel's delete: HA's own Configure-UI removal never reaches that path.
    from .helpers.shared_parts import async_transfer_pools_on_removal

    try:
        await async_transfer_pools_on_removal(hass, entry)
    except Exception:
        _LOGGER.exception("Could not transfer shared spare parts from %s", entry.title)

    store = hass.data.get(STORES_CACHE_KEY, {}).pop(entry.entry_id, None)
    if store is None:
        store = MaintenanceStore(hass, entry.entry_id)
    await store.async_remove()
    # Drop the per-entry reconcile lock — the module dict would otherwise grow
    # by one lock per object ever created in this HA run.
    from .parts_runtime import discard_reconcile_lock

    discard_reconcile_lock(entry.entry_id)
    # v1.5.4: also called from ws_delete_object — but if the user removes the
    # config entry from HA's "Configure" UI, that path doesn't run, leaving
    # phantom task_refs in groups. Belt-and-suspenders.
    from .websocket import cleanup_group_refs, object_id_for_entry

    cleanup_group_refs(hass, entry_id=entry.entry_id)

    # Remove this object's documents (refcount-aware — a blob shared with another
    # object survives; only bytes freed for good are reclaimed). Archiving keeps
    # docs; permanent deletion is the trigger, and this hook fires for the WS,
    # the Configure-UI, and the service delete paths alike — the single place it
    # belongs.
    doc_store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
    if isinstance(doc_store, DocumentStore):
        await doc_store.async_remove_object(object_id_for_entry(entry))

    _LOGGER.debug("Removed store for entry %s", entry.entry_id)
    # The global summary coordinator has no listener for entry removal, so tell
    # it to drop this object from its counts immediately (otherwise the summary
    # sensors stay stale until the next event or a restart).
    from homeassistant.helpers.dispatcher import async_dispatcher_send

    async_dispatcher_send(hass, SIGNAL_OBJECT_ENTRY_REMOVED, entry.entry_id)


async def async_remove_config_entry_device(
    hass: HomeAssistant,
    config_entry: ConfigEntry,
    device_entry: dr.DeviceEntry,
) -> bool:
    """Allow device removal when it has no entities."""
    entity_registry = er.async_get(hass)
    return not er.async_entries_for_device(entity_registry, device_entry.id)


def _get_coordinator_for_entity(hass: HomeAssistant, entity_id: str) -> MaintenanceCoordinator | None:
    """Find the coordinator that manages the given entity."""
    entity_registry = er.async_get(hass)
    entry = entity_registry.async_get(entity_id)
    if entry is None:
        return None

    config_entry_id = entry.config_entry_id
    if config_entry_id is None:
        return None

    config_entry = hass.config_entries.async_get_entry(config_entry_id)
    if config_entry is None:
        return None

    runtime_data: MaintenanceSupporterData | None = getattr(config_entry, "runtime_data", None)
    if runtime_data is None:
        return None

    return runtime_data.coordinator


def _get_task_id_for_entity(hass: HomeAssistant, entity_id: str) -> str | None:
    """Extract the task ID from an entity's unique_id."""
    entity_registry = er.async_get(hass)
    entry = entity_registry.async_get(entity_id)
    if entry is None or entry.unique_id is None:
        return None

    # unique_id format: maintenance_supporter_{object_slug}_{task_id}
    # task_id is a 32-char hex UUID without dashes at the end
    unique_id = entry.unique_id
    prefix = "maintenance_supporter_"
    if not unique_id.startswith(prefix):
        return None

    # Find the task_id: last part after the object slug
    # Object slug could have underscores, so we find the task_id
    # by looking at what config entry this entity belongs to
    config_entry_id = entry.config_entry_id
    if config_entry_id is None:
        return None

    config_entry = hass.config_entries.async_get_entry(config_entry_id)
    if config_entry is None:
        return None

    # Look up which task matches this unique_id
    # Sensor unique_id: maintenance_supporter_{slug}_{task_id}
    # Binary sensor: maintenance_supporter_{slug}_{task_id}_overdue
    from .const import CONF_TASKS

    tasks: dict[str, Any] = config_entry.data.get(CONF_TASKS, {})
    clean_id = unique_id.removesuffix("_overdue")
    for task_id in tasks:
        if clean_id.endswith(f"_{task_id}"):
            return str(task_id)

    return None
