"""Repairs support for the Maintenance Supporter integration.

Provides a multi-step repair flow for missing trigger entities with
three options:
1. Replace — select a new entity to use as trigger
2. Remove  — remove the trigger entirely (convert to time_based/manual)
3. Dismiss — close the issue (will reappear on next check)
"""

from __future__ import annotations

import logging
from typing import Any

import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.core import HomeAssistant
from homeassistant.helpers import selector

from .config_flow_trigger import TRIGGER_ENTITY_DOMAINS
from .const import (
    CONF_ADMIN_PANEL_USER_IDS,
    CONF_TASKS,
    DOMAIN,
    HistoryEntryType,
    ScheduleType,
)
from .helpers.aggregate import object_name
from .helpers.schedule import (
    FLAT_RECURRENCE_KEYS,
    normalize_task_storage,
    read_legacy_fields,
)
from .models.maintenance_task import MaintenanceTask

_LOGGER = logging.getLogger(__name__)


# ─── Repair-flow guard helpers ──────────────────────────────────────────
# Used by all three RepairsFlow subclasses below to centralise the
# "issue references an entry/task that may have been deleted since the
# issue was created" guard pattern. See StaleActionEntityRepairFlow,
# MissingTriggerEntityRepairFlow, OrphanAdminPanelUserRepairFlow.


def _entry_for_issue(hass: HomeAssistant, issue_data: dict[str, Any] | None) -> Any:
    """Resolve the per-object ConfigEntry referenced by ``issue_data['entry_id']``.

    Returns the entry, or ``None`` if the id is missing or the entry has
    been deleted since the issue was created. Repair-flow steps should
    abort with ``reason="entry_gone"`` on ``None``.
    """
    eid = str((issue_data or {}).get("entry_id", ""))
    if not eid:
        return None
    return hass.config_entries.async_get_entry(eid)


def _entry_has_task(entry: Any, task_id: str | None) -> bool:
    """True iff ``entry`` exists and ``entry.data[CONF_TASKS]`` contains ``task_id``.

    Stricter variant of :func:`_entry_for_issue` for repair flows whose
    target is a specific task within an entry — used by
    :class:`MissingTriggerEntityRepairFlow` to abort with
    ``reason="task_deleted"`` when the task has been removed.
    """
    if entry is None or not task_id:
        return False
    return task_id in entry.data.get(CONF_TASKS, {})


def _replace_entity_in_dict(cfg: dict[str, Any], old: str, new: str) -> dict[str, Any]:
    """Return a copy of ``cfg`` with entity_id/entity_ids replaced if matching."""
    cfg = dict(cfg)
    eids = cfg.get("entity_ids")
    if isinstance(eids, list) and old in eids:
        cfg["entity_ids"] = [new if e == old else e for e in eids]
    if cfg.get("entity_id") == old:
        cfg["entity_id"] = new
    return cfg


def _replace_entity_in_condition(cond: dict[str, Any], old: str, new: str) -> dict[str, Any]:
    """Replace ``old`` entity with ``new`` in a compound trigger condition.

    Conditions may have entity references at top level OR in a nested
    ``trigger_config`` sub-dict.
    """
    cond = _replace_entity_in_dict(cond, old, new)
    nested = cond.get("trigger_config")
    if isinstance(nested, dict):
        cond["trigger_config"] = _replace_entity_in_dict(nested, old, new)
    return cond


def _strip_entity_from_dict(cfg: dict[str, Any], target: str) -> tuple[dict[str, Any], bool]:
    """Strip ``target`` from a flat trigger config dict.

    Returns (modified copy, has_remaining_entity).
    """
    cfg = dict(cfg)
    eids = cfg.get("entity_ids")
    if isinstance(eids, list):
        eids = [e for e in eids if e != target]
        if eids:
            cfg["entity_ids"] = eids
            if cfg.get("entity_id") == target:
                cfg["entity_id"] = eids[0]
            return cfg, True
        cfg.pop("entity_ids", None)
    if cfg.get("entity_id") == target:
        cfg.pop("entity_id", None)
    has_remaining = bool(cfg.get("entity_id") or cfg.get("entity_ids"))
    return cfg, has_remaining


def _strip_entity_from_condition(cond: dict[str, Any], target: str) -> tuple[dict[str, Any], bool]:
    """Strip ``target`` from a compound condition.

    Returns (modified copy, keep_condition). The condition is kept if it
    still references at least one entity (top-level or nested).
    """
    cond, top_has = _strip_entity_from_dict(cond, target)
    nested = cond.get("trigger_config")
    nested_has = False
    if isinstance(nested, dict):
        nested_stripped, nested_has = _strip_entity_from_dict(nested, target)
        cond["trigger_config"] = nested_stripped
    return cond, top_has or nested_has


class MissingTriggerEntityRepairFlow(RepairsFlow):
    """Handle repair for a missing trigger entity.

    ``self.data`` is populated by ``async_create_issue(data=...)`` and
    contains::

        {
            "entry_id": str,
            "task_id": str,
            "task_name": str,
            "object_name": str,
            "entity_id": str,          # the missing entity
        }
    """

    async def async_step_init(self, user_input: dict[str, str] | None = None) -> data_entry_flow.FlowResult:
        """Show a menu with repair options."""
        issue_data = self.data or {}
        return self.async_show_menu(
            step_id="init",
            menu_options=["replace_entity", "remove_trigger", "dismiss"],
            description_placeholders={
                "entity_id": str(issue_data.get("entity_id", "unknown")),
                "task_name": str(issue_data.get("task_name", "unknown")),
                "object_name": str(issue_data.get("object_name", "unknown")),
            },
        )

    async def async_step_replace_entity(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult:
        """Let the user pick a replacement entity."""
        issue_data = self.data or {}

        if user_input is not None:
            new_entity_id = user_input["new_entity_id"]
            issue_data = self.data or {}
            entry = _entry_for_issue(self.hass, issue_data)
            if not _entry_has_task(entry, str(issue_data.get("task_id") or "")):
                return self.async_abort(reason="task_deleted")
            await self._replace_trigger_entity(new_entity_id)
            return self.async_create_entry(data={})

        return self.async_show_form(
            step_id="replace_entity",
            data_schema=vol.Schema(
                {
                    vol.Required("new_entity_id"): selector.EntitySelector(
                        selector.EntitySelectorConfig(
                            domain=TRIGGER_ENTITY_DOMAINS,
                            multiple=False,
                        )
                    ),
                }
            ),
            description_placeholders={
                "entity_id": str(issue_data.get("entity_id", "unknown")),
                "task_name": str(issue_data.get("task_name", "unknown")),
                "object_name": str(issue_data.get("object_name", "unknown")),
            },
        )

    async def async_step_remove_trigger(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult:
        """Confirm removal of the trigger (convert to time_based or manual)."""
        issue_data = self.data or {}

        if user_input is not None:
            issue_data = self.data or {}
            entry = _entry_for_issue(self.hass, issue_data)
            if not _entry_has_task(entry, str(issue_data.get("task_id") or "")):
                return self.async_abort(reason="task_deleted")
            await self._remove_trigger()
            return self.async_create_entry(data={})

        return self.async_show_form(
            step_id="remove_trigger",
            data_schema=vol.Schema({}),
            description_placeholders={
                "entity_id": str(issue_data.get("entity_id", "unknown")),
                "task_name": str(issue_data.get("task_name", "unknown")),
                "object_name": str(issue_data.get("object_name", "unknown")),
            },
        )

    async def async_step_dismiss(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult:
        """Dismiss the issue (it will reappear if entity is still missing)."""
        return self.async_create_entry(data={})

    # --- Helpers ---

    async def _replace_trigger_entity(self, new_entity_id: str) -> None:
        """Replace the trigger entity in config entry data and reload.

        For multi-entity triggers, replaces the specific missing entity
        within the entity_ids list.
        """
        issue_data = self.data or {}
        entry_id = str(issue_data.get("entry_id", ""))
        task_id = issue_data.get("task_id")
        old_entity_id = str(issue_data.get("entity_id", ""))

        if not entry_id or not task_id:
            _LOGGER.error("Repair flow missing entry_id or task_id in issue data")
            return

        entry = self.hass.config_entries.async_get_entry(entry_id)
        if entry is None:
            _LOGGER.error("Config entry %s not found", entry_id)
            return

        # Read static task data from ConfigEntry
        tasks_data = dict(entry.data.get(CONF_TASKS, {}))
        task_dict = dict(tasks_data[task_id])
        trigger_config = dict(task_dict.get("trigger_config", {}))

        if trigger_config.get("type") == "compound":
            # Recurse into conditions
            new_conditions = [
                _replace_entity_in_condition(cond, old_entity_id, new_entity_id) for cond in trigger_config.get("conditions", [])
            ]
            trigger_config["conditions"] = new_conditions
        else:
            # Flat trigger: update entity_ids list if present
            entity_ids = trigger_config.get("entity_ids", [])
            if entity_ids and old_entity_id in entity_ids:
                entity_ids = [new_entity_id if eid == old_entity_id else eid for eid in entity_ids]
                trigger_config["entity_ids"] = entity_ids

            # Update entity_id (for backwards compat / single-entity)
            if trigger_config.get("entity_id") == old_entity_id:
                trigger_config["entity_id"] = new_entity_id
            if entity_ids:
                trigger_config["entity_id"] = entity_ids[0]

        # Reset runtime values
        trigger_config.pop("trigger_baseline_value", None)
        trigger_config.pop("trigger_change_count", None)
        task_dict["trigger_config"] = trigger_config
        tasks_data[task_id] = task_dict

        # Write static changes to ConfigEntry
        new_data = dict(entry.data)
        new_data[CONF_TASKS] = tasks_data
        self.hass.config_entries.async_update_entry(entry, data=new_data)

        # Add history entry via Store (dynamic state)
        rd = getattr(entry, "runtime_data", None)
        store = getattr(rd, "store", None) if rd else None
        if store is not None:
            merged = store.merge_task_data(task_id, task_dict)
            task = MaintenanceTask.from_dict(merged)
            task.add_history_entry(
                entry_type=HistoryEntryType.TRIGGER_REPLACED,
                notes=f"Trigger entity replaced: {old_entity_id} → {new_entity_id}",
            )
            td = task.to_dict()
            store.set_history(task_id, td.get("history", []))
            store.clear_trigger_runtime(task_id)
            store.async_delay_save()
        else:
            # Legacy: full task roundtrip via ConfigEntry
            task = MaintenanceTask.from_dict(task_dict)
            task.add_history_entry(
                entry_type=HistoryEntryType.TRIGGER_REPLACED,
                notes=f"Trigger entity replaced: {old_entity_id} → {new_entity_id}",
            )
            tasks_data[task_id] = task.to_dict()
            new_data[CONF_TASKS] = tasks_data
            self.hass.config_entries.async_update_entry(entry, data=new_data)

        # Reload entry so the trigger re-initialises with the new entity
        await self.hass.config_entries.async_reload(entry_id)

        _LOGGER.info(
            "Trigger entity for task '%s' replaced: %s → %s",
            issue_data.get("task_name"),
            old_entity_id,
            new_entity_id,
        )

    async def _remove_trigger(self) -> None:
        """Remove the missing entity from the trigger.

        For multi-entity triggers, removes only the specific entity from the
        entity_ids list.  If only one entity remains (or was the only one),
        removes the entire trigger and converts to time_based or manual.
        """
        issue_data = self.data or {}
        entry_id = str(issue_data.get("entry_id", ""))
        task_id = issue_data.get("task_id")
        missing_entity_id = str(issue_data.get("entity_id", ""))

        if not entry_id or not task_id:
            _LOGGER.error("Repair flow missing entry_id or task_id in issue data")
            return

        entry = self.hass.config_entries.async_get_entry(entry_id)
        if entry is None:
            _LOGGER.error("Config entry %s not found", entry_id)
            return

        tasks_data = dict(entry.data.get(CONF_TASKS, {}))
        task_dict = dict(tasks_data[task_id])
        trigger_config = dict(task_dict.get("trigger_config", {}))

        history_notes: str

        if trigger_config.get("type") == "compound":
            history_notes = self._remove_from_compound(task_dict, trigger_config, missing_entity_id)
        else:
            history_notes = self._remove_from_flat(task_dict, trigger_config, missing_entity_id)

        # Write static changes to ConfigEntry (recurrence normalized to nested)
        tasks_data[task_id] = normalize_task_storage(task_dict)
        new_data = dict(entry.data)
        new_data[CONF_TASKS] = tasks_data
        self.hass.config_entries.async_update_entry(entry, data=new_data)

        # Add history entry via Store (dynamic state)
        rd = getattr(entry, "runtime_data", None)
        store = getattr(rd, "store", None) if rd else None
        if store is not None:
            merged = store.merge_task_data(task_id, task_dict)
            task = MaintenanceTask.from_dict(merged)
            task.add_history_entry(
                entry_type=HistoryEntryType.TRIGGER_REMOVED,
                notes=history_notes,
            )
            td = task.to_dict()
            store.set_history(task_id, td.get("history", []))
            store.clear_trigger_runtime(task_id)
            store.async_delay_save()
        else:
            # Legacy: history via full task roundtrip in ConfigEntry
            task = MaintenanceTask.from_dict(task_dict)
            task.add_history_entry(
                entry_type=HistoryEntryType.TRIGGER_REMOVED,
                notes=history_notes,
            )
            tasks_data[task_id] = task.to_dict()
            new_data[CONF_TASKS] = tasks_data
            self.hass.config_entries.async_update_entry(entry, data=new_data)

        await self.hass.config_entries.async_reload(entry_id)

        _LOGGER.info(
            "Trigger entity %s removed for task '%s'",
            missing_entity_id,
            issue_data.get("task_name"),
        )

    def _remove_from_flat(
        self,
        task_dict: dict[str, Any],
        trigger_config: dict[str, Any],
        missing_entity_id: str,
    ) -> str:
        """Remove ``missing_entity_id`` from a flat trigger config.

        Mutates ``task_dict`` in place. Returns a history notes string.
        """
        entity_ids = trigger_config.get("entity_ids", [])
        remaining = [eid for eid in entity_ids if eid != missing_entity_id]

        if remaining:
            trigger_config["entity_ids"] = remaining
            trigger_config["entity_id"] = remaining[0]
            task_dict["trigger_config"] = trigger_config
            return f"Entity {missing_entity_id} removed from multi-entity trigger. Remaining: {', '.join(remaining)}"

        old_entity_id = trigger_config.get("entity_id", missing_entity_id)
        safety_interval = trigger_config.get("interval_days")
        fields = read_legacy_fields(task_dict)
        task_dict.pop("trigger_config", None)

        # Rebuild the recurrence from a flat spec (drop the nested schedule so
        # the change takes effect; the caller normalizes back to nested).
        task_dict.pop("schedule", None)
        for key in FLAT_RECURRENCE_KEYS:
            task_dict.pop(key, None)
        if safety_interval or fields["interval_days"]:
            new_type = ScheduleType.TIME_BASED
            task_dict["schedule_type"] = new_type
            task_dict["interval_days"] = fields["interval_days"] or safety_interval
            task_dict["interval_unit"] = fields["interval_unit"]
            task_dict["interval_anchor"] = fields["interval_anchor"]
        else:
            new_type = ScheduleType.MANUAL
            task_dict["schedule_type"] = new_type

        return f"Sensor trigger removed (entity was: {old_entity_id}). Schedule converted to {new_type}."

    def _remove_from_compound(
        self,
        task_dict: dict[str, Any],
        trigger_config: dict[str, Any],
        missing_entity_id: str,
    ) -> str:
        """Remove ``missing_entity_id`` from a compound trigger config.

        Walks each condition; if a condition still references at least one
        entity it is kept (with the missing entity stripped). Otherwise the
        condition is dropped.

        Resulting condition count handling:
          - ``>= 2``: stays compound
          - ``== 1``: demoted to a flat trigger using the remaining condition
          - ``== 0``: trigger removed entirely (delegate to flat fallback)

        Mutates ``task_dict`` in place. Returns a history notes string.
        """
        new_conditions = []
        for cond in trigger_config.get("conditions", []):
            stripped, keep = _strip_entity_from_condition(cond, missing_entity_id)
            if keep:
                new_conditions.append(stripped)

        if len(new_conditions) >= 2:
            trigger_config["conditions"] = new_conditions
            task_dict["trigger_config"] = trigger_config
            return f"Entity {missing_entity_id} removed from compound trigger; {len(new_conditions)} conditions remain."

        if len(new_conditions) == 1:
            sole = new_conditions[0]
            # Conditions may carry their config nested under "trigger_config"
            nested = sole.get("trigger_config")
            if isinstance(nested, dict):
                new_tc = dict(nested)
                if "type" not in new_tc and sole.get("type"):
                    new_tc["type"] = sole["type"]
            else:
                new_tc = dict(sole)
            task_dict["trigger_config"] = new_tc
            return (
                f"Entity {missing_entity_id} removed; compound trigger "
                f"demoted to single trigger ({new_tc.get('type', 'unknown')})."
            )

        # 0 conditions remain — fall back to the flat removal path
        # (uses the original trigger_config to determine schedule_type)
        return self._remove_from_flat(task_dict, trigger_config, missing_entity_id)


class OrphanAdminPanelUserRepairFlow(RepairsFlow):
    """Repair flow for an admin_panel_user_ids entry pointing at a deleted HA user.

    Single action: remove the orphaned id from the panel-access list. There's
    no scenario where keeping an invalid user_id makes sense — if an admin
    really wants to silence the issue without fixing it, they can use HA's
    built-in "Ignore issue" UI on the Repairs page.

    `self.data` is populated by ``async_create_issue(data=...)`` and contains::

        {
            "user_id": str,    # the orphaned HA user UUID
            "entry_id": str,   # the global config entry ID
        }
    """

    async def async_step_init(self, user_input: dict[str, str] | None = None) -> data_entry_flow.FlowResult:
        """Show a confirmation form, then remove on submit."""
        issue_data = self.data or {}
        if user_input is not None:
            return await self.async_step_remove_user_id()
        return self.async_show_form(
            step_id="init",
            data_schema=vol.Schema({}),
            description_placeholders={
                "user_id": str(issue_data.get("user_id", "?"))[:8],
            },
        )

    async def async_step_remove_user_id(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult:
        """Remove the orphaned id from admin_panel_user_ids and persist."""
        issue_data = self.data or {}
        entry = _entry_for_issue(self.hass, issue_data)
        if entry is None:
            return self.async_abort(reason="entry_gone")
        target_uid = str(issue_data.get("user_id", ""))
        ids = list(entry.options.get(CONF_ADMIN_PANEL_USER_IDS, []) or [])
        if target_uid in ids:
            ids.remove(target_uid)
            self.hass.config_entries.async_update_entry(
                entry,
                options={**entry.options, CONF_ADMIN_PANEL_USER_IDS: ids},
            )
        return self.async_create_entry(data={})


class StaleActionEntityRepairFlow(RepairsFlow):
    """Repair flow for an `on_complete_action.target.entity_id` that no longer
    resolves to an entity in HA. Three options:

    1. Replace — pick a new entity to point the action at
    2. Remove  — clear `on_complete_action` from the task entirely
    3. Ignore — fall through (user uses HA's Ignore-issue button)

    `self.data` carries::
        {
            "entry_id": str,    # the per-object config entry holding the task
            "task_id": str,     # the task whose action references the dead entity
            "task_name": str,   # for the description placeholder
            "stale_entity": str # the now-missing entity_id
        }
    """

    async def async_step_init(self, user_input: dict[str, str] | None = None) -> data_entry_flow.FlowResult:
        return self.async_show_menu(
            step_id="init",
            menu_options=["replace_entity", "remove_action"],
            description_placeholders={
                "task_name": str((self.data or {}).get("task_name", "?")),
                "stale_entity": str((self.data or {}).get("stale_entity", "?")),
            },
        )

    async def async_step_replace_entity(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult:
        if user_input is not None:
            entry = self._entry()
            if entry is None:
                return self.async_abort(reason="entry_gone")
            new_eid = str(user_input.get("new_entity", "")).strip()
            if not new_eid:
                return self.async_abort(reason="no_entity")
            self._patch_action_entity(entry, new_eid)
            return self.async_create_entry(data={})
        return self.async_show_form(
            step_id="replace_entity",
            data_schema=vol.Schema(
                {
                    vol.Required("new_entity"): selector.EntitySelector(),
                }
            ),
            description_placeholders={
                "task_name": str((self.data or {}).get("task_name", "?")),
                "stale_entity": str((self.data or {}).get("stale_entity", "?")),
            },
        )

    async def async_step_remove_action(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult:
        if user_input is not None:
            entry = self._entry()
            if entry is None:
                return self.async_abort(reason="entry_gone")
            self._clear_action(entry)
            return self.async_create_entry(data={})
        return self.async_show_form(
            step_id="remove_action",
            data_schema=vol.Schema({}),
            description_placeholders={
                "task_name": str((self.data or {}).get("task_name", "?")),
            },
        )

    def _entry(self) -> Any:
        # Thin wrapper kept for readability of callers in this class —
        # delegates to the shared helper so all flows use the same lookup.
        return _entry_for_issue(self.hass, self.data)

    def _patch_action_entity(self, entry: Any, new_entity_id: str) -> None:
        from .helpers.entry_tasks import write_task

        task_id = str((self.data or {}).get("task_id", ""))
        task = dict(entry.data.get(CONF_TASKS, {}).get(task_id) or {})
        action = dict(task.get("on_complete_action") or {})
        target = dict(action.get("target") or {})
        target["entity_id"] = new_entity_id
        action["target"] = target
        task["on_complete_action"] = action
        write_task(self.hass, entry, task_id, task)

    def _clear_action(self, entry: Any) -> None:
        from .helpers.entry_tasks import write_task

        task_id = str((self.data or {}).get("task_id", ""))
        task = dict(entry.data.get(CONF_TASKS, {}).get(task_id) or {})
        task.pop("on_complete_action", None)
        write_task(self.hass, entry, task_id, task)


class DocumentStorageRepairFlow(RepairsFlow):
    """Confirm + reclaim orphaned / dangling document storage.

    Single action: run :meth:`DocumentStore.async_cleanup_issues`, which deletes
    unreferenced blob files and prunes dead document records. Completing the flow
    resolves the issue; if a later boot-time scan still finds anomalies it simply
    reappears. To leave things alone, use HA's built-in **Ignore** button.
    """

    async def async_step_init(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult:
        """Confirm, then reclaim orphaned/dangling storage on submit."""
        if user_input is not None:
            from . import DOCUMENT_STORE_KEY

            store = self.hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
            if store is not None:
                await store.async_cleanup_issues()
            return self.async_create_entry(data={})

        return self.async_show_form(step_id="init", data_schema=vol.Schema({}))


class MissingGlobalEntryRepairFlow(RepairsFlow):
    """Recreate the global "Maintenance Supporter" entry after it was deleted.

    When the global entry is removed but object entries remain, the summary
    sensors, sidebar panel and digests all disappear and the dashboard KPI chips
    read "unknown" (#86). Submitting this flow kicks off the config flow's import
    step, which recreates the global entry with default settings (it aborts
    harmlessly if one already exists). To leave things as they are, use HA's
    built-in **Ignore** button.
    """

    async def async_step_init(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult:
        """Confirm, then recreate the global entry on submit."""
        if user_input is not None:
            from homeassistant.config_entries import SOURCE_IMPORT

            await self.hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_IMPORT})
            return self.async_create_entry(data={})

        return self.async_show_form(step_id="init", data_schema=vol.Schema({}))


class DeviceLinkRepairFlow(RepairsFlow):
    """Relink or unlink an object whose stored device link is unusable.

    One flow serves both translation keys behind the ``device_link_lost_…``
    issue id: the linked device VANISHED (``device_link_lost``), and the link
    points at the object's own doppelgänger device (``device_link_self`` —
    the old picker offered it under the appliance's exact name).

    Two menu options:

    1. Relink — a device picker, pre-filled with the best name/manufacturer
       match among devices that are NOT ours; Maintenance Supporter devices
       are rejected on submit (the same guard the WS write path applies).
    2. Unlink — clear the stored link; the object keeps a device of its own.

    ``self.data`` carries ``{"entry_id": str}``.
    """

    _match_cached = False
    _match: Any = None

    async def async_step_init(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult:
        entry = _entry_for_issue(self.hass, self.data)
        if entry is None:
            return self.async_abort(reason="entry_gone")
        return self.async_show_menu(
            step_id="init",
            menu_options=["relink", "unlink"],
            description_placeholders=self._placeholders(entry),
        )

    async def async_step_relink(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult:
        from homeassistant.helpers import device_registry as dr

        from .helpers.device_link import is_maintenance_device

        entry = _entry_for_issue(self.hass, self.data)
        if entry is None:
            return self.async_abort(reason="entry_gone")

        errors: dict[str, str] = {}
        if user_input is not None:
            device_id = str(user_input.get("device", "")).strip()
            device = dr.async_get(self.hass).async_get(device_id) if device_id else None
            if device is None:
                errors["device"] = "device_gone"
            elif is_maintenance_device(self.hass, device):
                errors["device"] = "self_link"
            else:
                self._set_link(entry, device_id)
                return self.async_create_entry(data={})

        suggestion = self._best_match(entry)
        device_key = vol.Required("device", default=suggestion.id) if suggestion else vol.Required("device")
        return self.async_show_form(
            step_id="relink",
            data_schema=vol.Schema({device_key: selector.DeviceSelector()}),
            errors=errors,
            description_placeholders=self._placeholders(entry),
        )

    async def async_step_unlink(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult:
        entry = _entry_for_issue(self.hass, self.data)
        if entry is None:
            return self.async_abort(reason="entry_gone")
        if user_input is not None:
            self._set_link(entry, None)
            return self.async_create_entry(data={})
        return self.async_show_form(
            step_id="unlink",
            data_schema=vol.Schema({}),
            description_placeholders=self._placeholders(entry),
        )

    # ── helpers ──────────────────────────────────────────────────────────

    def _placeholders(self, entry: Any) -> dict[str, str]:
        best = self._best_match(entry)
        return {
            "object": object_name(entry),
            "suggestion": str((best.name_by_user or best.name) if best else "—"),
        }

    def _best_match(self, entry: Any) -> Any:
        """The non-maintenance device whose identity best matches the object.

        Same scoring the prod diagnosis used: count the object's name /
        manufacturer / model words appearing in the device's name-plus-model
        string. A device RE-CREATED under a new id (the usual reason a link
        dies) keeps its name, so it surfaces as the natural suggestion.
        """
        if self._match_cached:
            return self._match
        self._match_cached = True

        from homeassistant.helpers import device_registry as dr

        from .const import CONF_OBJECT
        from .helpers.device_link import is_maintenance_device

        def norm(value: Any) -> str:
            import re

            return re.sub(r"[^a-z0-9]+", " ", str(value or "").lower()).strip()

        obj = entry.data.get(CONF_OBJECT, {}) or {}
        words = {w for w in norm(obj.get("name")).split() if len(w) > 2}
        words |= {w for w in (norm(obj.get("manufacturer")), norm(obj.get("model"))) if w}

        best, best_score = None, 0
        for device in dr.async_get(self.hass).devices.values():
            if is_maintenance_device(self.hass, device):
                continue
            hay = norm(f"{device.name_by_user or ''} {device.name or ''} {device.manufacturer or ''} {device.model or ''}")
            score = sum(1 for w in words if w and w in hay)
            if score > best_score:
                best, best_score = device, score
        self._match = best
        return best

    def _set_link(self, entry: Any, device_id: str | None) -> None:
        """Write the new link and reload — the entity→device attachment only
        changes on entity re-add (the same reason the WS update path reloads)."""
        from .const import CONF_OBJECT

        obj = dict(entry.data.get(CONF_OBJECT, {}) or {})
        obj["ha_device_id"] = device_id
        self.hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_OBJECT: obj})
        self.hass.config_entries.async_schedule_reload(entry.entry_id)


async def async_create_fix_flow(
    hass: HomeAssistant,
    issue_id: str,
    data: dict[str, Any] | None,
) -> RepairsFlow:
    """Create a repair flow for the given issue."""
    if issue_id.startswith("orphan_admin_panel_user_"):
        return OrphanAdminPanelUserRepairFlow()
    if issue_id.startswith("stale_action_entity_"):
        return StaleActionEntityRepairFlow()
    if issue_id.startswith("device_link_lost_"):
        return DeviceLinkRepairFlow()
    if issue_id == "document_storage_issues":
        return DocumentStorageRepairFlow()
    if issue_id == "missing_global_entry":
        return MissingGlobalEntryRepairFlow()
    return MissingTriggerEntityRepairFlow()
