from __future__ import annotations

import asyncio
import logging
import os
import traceback

from homeassistant.core import HomeAssistant, ServiceCall, callback  # type: ignore[reportMissingImports]
from homeassistant.config_entries import ConfigEntry  # type: ignore[reportMissingImports]
from homeassistant.helpers import config_validation as cv  # type: ignore[reportMissingImports]
from homeassistant.helpers.storage import Store  # type: ignore[reportMissingImports]
from homeassistant.helpers.dispatcher import async_dispatcher_send, async_dispatcher_connect  # type: ignore[reportMissingImports]
from homeassistant.helpers.event import async_track_point_in_utc_time, async_track_state_change_event, async_track_time_interval  # type: ignore[reportMissingImports]
from homeassistant.util import dt as dt_util  # type: ignore[reportMissingImports]
from datetime import timedelta
from homeassistant.components.http import HomeAssistantView  # type: ignore[reportMissingImports]
from homeassistant.const import UnitOfTemperature  # type: ignore[reportMissingImports]
from typing import Any, Optional

from .const import DOMAIN, STORAGE_KEY, STORAGE_VERSION, SIGNAL_UPDATED, BACKUP_STORAGE_KEY

CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)


_LOGGER = logging.getLogger(__name__)


def _norm_instance_id(raw: Any) -> str:
    """Normalize an instance id used to namespace schedules/settings.

    Must be stable, JSON-safe and reasonably human editable.
    """
    try:
        s = str(raw or "").strip()
    except Exception:
        s = ""
    if not s:
        return "default"
    # Keep a conservative charset to avoid surprises in storage keys and URLs.
    out = []
    for ch in s:
        if ch.isalnum() or ch in ("_", "-", "."):
            out.append(ch)
        else:
            out.append("_")
    s2 = "".join(out)
    s2 = s2[:64]
    return s2 or "default"


def _ensure_instances_loaded(hass: HomeAssistant) -> None:
    """Ensure hass.data[DOMAIN] has instances + active_instance_id.

    This is defensive in case older installs call services before async_setup_entry finished.
    """
    try:
        data = hass.data.setdefault(DOMAIN, {})
        if not isinstance(data.get("instances"), dict):
            data["instances"] = {"default": {"schedules": {}, "weekdays": {}, "profiles": {}, "settings": {}, "colors": {}}}
        if not isinstance(data.get("active_instance_id"), str):
            data["active_instance_id"] = "default"
    except Exception:
        pass


def _c_to_f(c: float) -> float:
    return (float(c) * 9.0 / 5.0) + 32.0


def _f_to_c(f: float) -> float:
    return (float(f) - 32.0) * 5.0 / 9.0


def _dbg_write_sync(path: str, msg: str) -> None:
    with open(path, "a", encoding="utf-8") as fh:
        fh.write(msg + "\n")


def _dbg(msg: str) -> None:
    """Write lightweight debug info to local debug.log without blocking the event loop."""
    try:
        base = os.path.dirname(__file__)
        path = os.path.join(base, "debug.log")

        try:
            loop = asyncio.get_running_loop()
        except RuntimeError:
            _dbg_write_sync(path, msg)
            return

        # Fire-and-forget file write in a worker thread.
        loop.create_task(asyncio.to_thread(_dbg_write_sync, path, msg))
    except Exception:
        # Debug logging must never interfere with the integration.
        pass


def build_state_payload(hass: HomeAssistant, versions_only: bool = False, instance_id: str | None = None) -> dict[str, Any]:
    """Build a JSON-serializable snapshot of the current timeline state."""
    if not hass or DOMAIN not in hass.data:
        # Return safe defaults if integration not yet loaded
        _dbg("build_state_payload: hass or DOMAIN missing, returning defaults")
        return {
            "version": 1,
            "settings_version": 1,
            "colors_version": 1,
            "weekday_version": 1,
            "profile_version": 1,
            "backup_version": 1,
        } if versions_only else {
            "version": 1,
            "settings_version": 1,
            "colors_version": 1,
            "weekday_version": 1,
            "profile_version": 1,
            "backup_version": 1,
            "schedules": {},
            "weekdays": {},
            "profiles": {},
            "settings": {},
            "colors": {},
            "backup": {"slots": [], "slot_index": 0, "schedules": {}, "settings": {}, "weekdays": {}, "profiles": {}, "version": 1, "last_backup_ts": None, "partial_flags": None},
        }
    data = hass.data[DOMAIN]
    _ensure_instances_loaded(hass)
    instances = data.get("instances") if isinstance(data.get("instances"), dict) else {}
    active_iid = _norm_instance_id(data.get("active_instance_id") or "default")
    if active_iid not in instances and instances:
        # Pick first available instance if active id is missing.
        try:
            active_iid = sorted(instances.keys())[0]
        except Exception:
            active_iid = "default"
    req_iid = _norm_instance_id(instance_id) if instance_id else active_iid
    if req_iid not in instances and instances:
        req_iid = active_iid
    inst = instances.get(req_iid, {}) if isinstance(instances.get(req_iid), dict) else {}
    payload: dict[str, Any] = {
        "version": int(data.get("version", 1)),
        "settings_version": int(data.get("settings_version", data.get("version", 1))),
        "colors_version": int(data.get("colors_version", data.get("version", 1))),
        "weekday_version": int(data.get("weekday_version", data.get("version", 1))),
        "profile_version": int(data.get("profile_version", data.get("version", 1))),
        "backup_version": int(data.get("backup_version", 1)),
        "active_instance_id": active_iid,
        "instance_id": req_iid,
        "instances": sorted(list(instances.keys())) if isinstance(instances, dict) else ["default"],
    }
    if versions_only:
        return payload
    payload.update({
        # Return data for the requested instance (or the active instance when not specified).
        "schedules": inst.get("schedules", {}) if isinstance(inst, dict) else data.get("schedules", {}),
        "weekdays": inst.get("weekdays", {}) if isinstance(inst, dict) else data.get("weekday_schedules", {}),
        "profiles": inst.get("profiles", {}) if isinstance(inst, dict) else data.get("profile_schedules", {}),
        "settings": inst.get("settings", {}) if isinstance(inst, dict) else data.get("settings", {}),
        "colors": inst.get("colors", {}) if isinstance(inst, dict) else data.get("colors", {}),
        "backup": {
            "slots": data.get("backup_slots", []),
            "slot_index": data.get("backup_slot_index", 0),
            "schedules": data.get("backup_schedules", {}),
            "settings": data.get("backup_settings", {}),
            "weekdays": data.get("backup_weekdays", {}),
            "profiles": data.get("backup_profiles", {}),
            "version": data.get("backup_version", 1),
            "last_backup_ts": data.get("backup_last_ts"),
            "partial_flags": data.get("backup_partial_flags"),
        },
    })
    return payload


class ThermostatTimelineStateView(HomeAssistantView):
    url = "/api/thermostat_timeline/state"
    name = "api:thermostat_timeline_state"
    requires_auth = True

    def __init__(self, hass: HomeAssistant):
        self.hass = hass

    async def get(self, request):
        try:
            _dbg("API /state requested")
            iid = None
            try:
                iid = request.query.get("instance_id")
            except Exception:
                iid = None
            payload = build_state_payload(self.hass, versions_only=False, instance_id=iid)
            _LOGGER.debug("API /state requested, version=%s", payload.get("version"))
            _dbg(f"API /state returning version={payload.get('version')}")
            return self.json(payload)
        except Exception as e:
            _LOGGER.error("API /state failed: %s", e, exc_info=True)
            _dbg("API /state failed: " + str(e) + "\n" + traceback.format_exc())
            return self.json({"error": str(e)}, status_code=500)


class ThermostatTimelineVersionView(HomeAssistantView):
    url = "/api/thermostat_timeline/version"
    name = "api:thermostat_timeline_version"
    requires_auth = True

    def __init__(self, hass: HomeAssistant):
        self.hass = hass

    async def get(self, request):
        try:
            _dbg("API /version requested")
            payload = build_state_payload(self.hass, versions_only=True)
            _LOGGER.debug("API /version requested, returning: %s", payload)
            _dbg(f"API /version returning: {payload}")
            return self.json(payload)
        except Exception as e:
            _LOGGER.error("API /version failed: %s", e, exc_info=True)
            _dbg("API /version failed: " + str(e) + "\n" + traceback.format_exc())
            return self.json({"error": str(e)}, status_code=500)

async def async_setup(hass: HomeAssistant, config: dict) -> bool:
    # YAML fallback: hvis brugeren har 'thermostat_timeline:' i configuration.yaml
    if DOMAIN in config:
        # Opret/indlæs config entry via IMPORT
        hass.async_create_task(
            hass.config_entries.flow.async_init(
                DOMAIN, context={"source": "import"}, data={}
            )
        )
    return True

async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
    store = Store(hass, STORAGE_VERSION, f"{STORAGE_KEY}.json")
    data = await store.async_load() or {}
    backup_store = Store(hass, STORAGE_VERSION, f"{BACKUP_STORAGE_KEY}.json")
    backup_data = await backup_store.async_load() or {}

    _dbg("async_setup_entry: loaded data version=" + str(data.get("version")))

    hass.data.setdefault(DOMAIN, {})
    hass.data[DOMAIN]["store"] = store

    # Ensure Lovelace card JS is available and resource is registered (best-effort)
    try:
        from .frontend import ensure_frontend
        await ensure_frontend(hass)
    except Exception:
        _LOGGER.debug("%s: ensure_frontend failed", DOMAIN, exc_info=True)

    # --- Multi-instance store support (backwards compatible) ---
    instances: dict[str, Any] = {}
    active_iid = _norm_instance_id(data.get("active_instance_id") or data.get("active_instance") or "default")
    raw_instances = data.get("instances")
    if isinstance(raw_instances, dict):
        for k, v in raw_instances.items():
            if not isinstance(v, dict):
                continue
            iid = _norm_instance_id(k)
            instances[iid] = {
                "schedules": v.get("schedules", {}) if isinstance(v.get("schedules"), dict) else {},
                "weekdays": v.get("weekdays", {}) if isinstance(v.get("weekdays"), dict) else {},
                "profiles": v.get("profiles", {}) if isinstance(v.get("profiles"), dict) else {},
                "settings": v.get("settings", {}) if isinstance(v.get("settings"), dict) else {},
                "colors": v.get("colors", {}) if isinstance(v.get("colors"), dict) else {},
            }
    if not instances:
        # Legacy flat format: migrate into default instance.
        instances = {
            "default": {
                "schedules": data.get("schedules", {}) if isinstance(data.get("schedules"), dict) else {},
                "weekdays": data.get("weekdays", {}) if isinstance(data.get("weekdays"), dict) else {},
                "profiles": data.get("profiles", {}) if isinstance(data.get("profiles"), dict) else {},
                "settings": data.get("settings", {}) if isinstance(data.get("settings"), dict) else {},
                "colors": data.get("colors", {}) if isinstance(data.get("colors"), dict) else {},
            }
        }
        active_iid = "default"
    if active_iid not in instances:
        try:
            active_iid = sorted(instances.keys())[0]
        except Exception:
            active_iid = "default"

    hass.data[DOMAIN]["instances"] = instances
    hass.data[DOMAIN]["active_instance_id"] = active_iid

    # Keep legacy top-level keys pointing at the active instance for minimal change elsewhere.
    active_store = instances.get(active_iid, {}) if isinstance(instances.get(active_iid), dict) else {}
    hass.data[DOMAIN]["schedules"] = active_store.get("schedules", {})
    hass.data[DOMAIN]["weekday_schedules"] = active_store.get("weekdays", {}) or {}
    hass.data[DOMAIN]["profile_schedules"] = active_store.get("profiles", {}) or {}
    hass.data[DOMAIN]["settings"] = active_store.get("settings", {})
    hass.data[DOMAIN]["colors"] = active_store.get("colors", {}) or {}
    hass.data[DOMAIN]["version"] = int(data.get("version", 1))
    # Track a separate version for settings-only changes (for the new settings sensor)
    hass.data[DOMAIN]["settings_version"] = int(data.get("settings_version", data.get("version", 1)))
    # Track a separate version for colors-only changes
    hass.data[DOMAIN]["colors_version"] = int(data.get("colors_version", data.get("version", 1)))
    # Track a separate version for weekday-only changes
    hass.data[DOMAIN]["weekday_version"] = int(data.get("weekday_version", data.get("version", 1)))
    # Track a separate version for profiles-only changes
    hass.data[DOMAIN]["profile_version"] = int(data.get("profile_version", data.get("version", 1)))
    # Backup payloads
    hass.data[DOMAIN]["backup_schedules"] = backup_data.get("schedules", {})
    hass.data[DOMAIN]["backup_settings"] = backup_data.get("settings", {})
    hass.data[DOMAIN]["backup_weekdays"] = backup_data.get("weekdays", {}) or {}
    hass.data[DOMAIN]["backup_profiles"] = backup_data.get("profiles", {}) or {}
    hass.data[DOMAIN]["backup_version"] = int(backup_data.get("version", 1))
    hass.data[DOMAIN]["backup_last_ts"] = backup_data.get("last_backup_ts")
    # Track whether the backup is partial and which sections it includes
    hass.data[DOMAIN]["backup_partial_flags"] = backup_data.get("partial_flags")
    slots = backup_data.get("slots", [])
    if not isinstance(slots, list):
        slots = []
    # Normalize: only dict entries or None
    norm_slots = []
    for entry in slots:
        if isinstance(entry, dict):
            norm_slots.append(entry)
        else:
            norm_slots.append(None)
    hass.data[DOMAIN]["backup_slots"] = norm_slots
    hass.data[DOMAIN]["backup_slot_index"] = len([s for s in norm_slots if s])

    _dbg("async_setup_entry: setup complete, version=" + str(hass.data[DOMAIN]["version"]))

    async def _save_and_broadcast():
        # Broadcast first so UI updates immediately
        async_dispatcher_send(hass, SIGNAL_UPDATED)
        try:
            _ensure_instances_loaded(hass)
            instances = hass.data[DOMAIN].get("instances", {})
            active_iid = _norm_instance_id(hass.data[DOMAIN].get("active_instance_id") or "default")
            active_store = instances.get(active_iid, {}) if isinstance(instances, dict) else {}
            await store.async_save({
                # New multi-instance store
                "instances": instances,
                "active_instance_id": active_iid,
                # Legacy top-level mirror (active instance)
                "schedules": active_store.get("schedules", hass.data[DOMAIN].get("schedules", {})),
                "weekdays": active_store.get("weekdays", hass.data[DOMAIN].get("weekday_schedules", {})),
                "profiles": active_store.get("profiles", hass.data[DOMAIN].get("profile_schedules", {})),
                "settings": active_store.get("settings", hass.data[DOMAIN].get("settings", {})),
                "colors": active_store.get("colors", hass.data[DOMAIN].get("colors", {})),
                "version": hass.data[DOMAIN]["version"],
                "settings_version": hass.data[DOMAIN].get("settings_version", hass.data[DOMAIN]["version"]),
                "colors_version": hass.data[DOMAIN].get("colors_version", hass.data[DOMAIN]["version"]),
                "weekday_version": hass.data[DOMAIN].get("weekday_version", hass.data[DOMAIN]["version"]),
                "profile_version": hass.data[DOMAIN].get("profile_version", hass.data[DOMAIN]["version"]),
            })
        except Exception:
            # Storage failure shouldn't block UI update
            _LOGGER.warning("%s: failed to save store after broadcast", DOMAIN)

    async def _save_backup():
        # Broadcast so backup sensor updates
        async_dispatcher_send(hass, SIGNAL_UPDATED)
        try:
            await backup_store.async_save({
                "schedules": hass.data[DOMAIN].get("backup_schedules", {}),
                "settings": hass.data[DOMAIN].get("backup_settings", {}),
                "weekdays": hass.data[DOMAIN].get("backup_weekdays", {}),
                "profiles": hass.data[DOMAIN].get("backup_profiles", {}),
                "version": hass.data[DOMAIN].get("backup_version", 1),
                "last_backup_ts": hass.data[DOMAIN].get("backup_last_ts"),
                "partial_flags": hass.data[DOMAIN].get("backup_partial_flags"),
                "slots": hass.data[DOMAIN].get("backup_slots", []),
                "slot_index": hass.data[DOMAIN].get("backup_slot_index", 0),
            })
        except Exception:
            _LOGGER.warning("%s: failed to save backup store", DOMAIN)

    def _latest_backup_ts(slots: list) -> str | None:
        """Find newest backup timestamp across slots (iso string)."""
        newest = None
        try:
            for slot in (slots or []):
                if not isinstance(slot, dict):
                    continue
                ts = slot.get("ts") or slot.get("created_at") or slot.get("created")
                if not ts:
                    continue
                try:
                    dt = dt_util.parse_datetime(ts)
                except Exception:
                    dt = None
                if dt:
                    if not newest or dt > newest[0]:
                        newest = (dt, ts)
                else:
                    # Fallback: keep first seen string when parse fails
                    if not newest:
                        newest = (None, ts)
        except Exception:
            return None
        return newest[1] if newest else None

    async def set_store(call: ServiceCall):
        force = bool(call.data.get("force"))
        _ensure_instances_loaded(hass)
        instances = hass.data[DOMAIN].setdefault("instances", {})
        iid_in_call = "instance_id" in call.data
        iid = _norm_instance_id(call.data.get("instance_id")) if iid_in_call else _norm_instance_id(hass.data[DOMAIN].get("active_instance_id") or "default")

        inst = instances.get(iid)
        if not isinstance(inst, dict):
            inst = {"schedules": {}, "weekdays": {}, "profiles": {}, "settings": {}, "colors": {}}
            instances[iid] = inst

        cur_sched = inst.get("schedules", {}) if isinstance(inst.get("schedules"), dict) else {}
        cur_set = inst.get("settings", {}) if isinstance(inst.get("settings"), dict) else {}
        cur_colors = inst.get("colors", {}) if isinstance(inst.get("colors"), dict) else {}
        cur_week = inst.get("weekdays", {}) if isinstance(inst.get("weekdays"), dict) else {}
        cur_prof = inst.get("profiles", {}) if isinstance(inst.get("profiles"), dict) else {}
        changed = False
        sched_changed = False
        settings_changed = False
        colors_changed = False
        weekday_changed = False
        profile_changed = False

        prev_active = _norm_instance_id(hass.data[DOMAIN].get("active_instance_id") or "default")
        activate = False
        if "activate" in call.data:
            activate = bool(call.data.get("activate"))
        elif iid_in_call:
            # New clients that pass instance_id will default to activating that instance.
            activate = True

        if "schedules" in call.data:
            schedules = call.data.get("schedules")
            if not isinstance(schedules, dict):
                _LOGGER.warning("%s.set_store: schedules must be an object when provided", DOMAIN)
                return
            if force or schedules != cur_sched:
                inst["schedules"] = schedules
                sched_changed = True
                changed = True
            if "weekdays" not in call.data:
                try:
                    new_week = {}
                    for eid, row in (schedules.items() if isinstance(schedules, dict) else []):
                        if not isinstance(row, dict):
                            continue
                        wk = {}
                        if "weekly" in row:
                            wk["weekly"] = row["weekly"]
                        if "weekly_modes" in row:
                            wk["weekly_modes"] = row["weekly_modes"]
                        if wk:
                            new_week[eid] = wk
                    if force or new_week != cur_week:
                        inst["weekdays"] = new_week
                        weekday_changed = True
                        changed = True
                except Exception:
                    pass
            if "profiles" not in call.data:
                try:
                    new_prof = {}
                    for eid, row in (schedules.items() if isinstance(schedules, dict) else []):
                        if not isinstance(row, dict):
                            continue
                        pr = {}
                        if "profiles" in row:
                            pr["profiles"] = row["profiles"]
                        if "activeProfile" in row:
                            pr["activeProfile"] = row["activeProfile"]
                        if pr:
                            new_prof[eid] = pr
                    if force or new_prof != cur_prof:
                        inst["profiles"] = new_prof
                        profile_changed = True
                        changed = True
                except Exception:
                    pass

        if "settings" in call.data:
            settings = call.data.get("settings")
            if isinstance(settings, dict):
                merged_settings = dict(cur_set)
                merged_settings.update(settings)
                try:
                    sw = merged_settings.get("boiler_switch")
                    if isinstance(sw, str) and sw:
                        dom = sw.split(".")[0]
                        if dom in ("switch", "input_boolean"):
                            merged_settings["boiler_switch_domain"] = dom
                except Exception:
                    pass
                if force or merged_settings != cur_set:
                    inst["settings"] = merged_settings
                    settings_changed = True
                    changed = True
                c_ranges = merged_settings.get("color_ranges")
                c_global = merged_settings.get("color_global")
                if c_ranges is not None or c_global is not None:
                    inst["colors"] = {
                        "color_ranges": c_ranges if c_ranges is not None else cur_colors.get("color_ranges", {}),
                        "color_global": c_global if c_global is not None else cur_colors.get("color_global", False),
                    }
                    changed = True
                    settings_changed = True
                    colors_changed = True

        if "colors" in call.data:
            colors = call.data.get("colors")
            if isinstance(colors, dict):
                if force or colors != cur_colors:
                    inst["colors"] = colors
                    colors_changed = True
                    changed = True

        if "weekdays" in call.data:
            weekdays = call.data.get("weekdays")
            if isinstance(weekdays, dict):
                if force or weekdays != cur_week:
                    inst["weekdays"] = weekdays
                    weekday_changed = True
                    changed = True
                try:
                    for eid, wk in (weekdays.items() if isinstance(weekdays, dict) else []):
                        if not isinstance(wk, dict):
                            continue
                        row = dict(inst.get("schedules", {}).get(eid, {}))
                        if "weekly" in wk:
                            row["weekly"] = wk["weekly"]
                        if "weekly_modes" in wk:
                            row["weekly_modes"] = wk["weekly_modes"]
                        inst.setdefault("schedules", {})[eid] = row
                        sched_changed = True
                except Exception:
                    pass

        if "profiles" in call.data:
            profiles = call.data.get("profiles")
            if isinstance(profiles, dict):
                if force or profiles != cur_prof:
                    inst["profiles"] = profiles
                    profile_changed = True
                    changed = True
                try:
                    for eid, pdata in (profiles.items() if isinstance(profiles, dict) else []):
                        if not isinstance(pdata, dict):
                            continue
                        row = dict(inst.get("schedules", {}).get(eid, {}))
                        if "profiles" in pdata:
                            row["profiles"] = pdata["profiles"]
                        if "activeProfile" in pdata:
                            row["activeProfile"] = pdata["activeProfile"]
                        inst.setdefault("schedules", {})[eid] = row
                        sched_changed = True
                except Exception:
                    pass

        # Allow activation-only switches (no payload changes).
        if not changed and not force and not (activate and prev_active != iid):
            return

        # Handle activation and keep legacy pointers synced.
        if activate:
            hass.data[DOMAIN]["active_instance_id"] = iid

        if activate or _norm_instance_id(hass.data[DOMAIN].get("active_instance_id") or "default") == iid:
            hass.data[DOMAIN]["schedules"] = inst.get("schedules", {})
            hass.data[DOMAIN]["weekday_schedules"] = inst.get("weekdays", {})
            hass.data[DOMAIN]["profile_schedules"] = inst.get("profiles", {})
            hass.data[DOMAIN]["settings"] = inst.get("settings", {})
            hass.data[DOMAIN]["colors"] = inst.get("colors", {})

        if sched_changed:
            hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN]["version"]) + 1
        if settings_changed:
            hass.data[DOMAIN]["settings_version"] = int(hass.data[DOMAIN].get("settings_version", hass.data[DOMAIN].get("version", 1))) + 1
            if not sched_changed:
                hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN]["version"]) + 1
        if colors_changed:
            hass.data[DOMAIN]["colors_version"] = int(hass.data[DOMAIN].get("colors_version", hass.data[DOMAIN].get("version", 1))) + 1
            if not sched_changed:
                hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN]["version"]) + 1
        if weekday_changed:
            hass.data[DOMAIN]["weekday_version"] = int(hass.data[DOMAIN].get("weekday_version", hass.data[DOMAIN].get("version", 1))) + 1
            if not sched_changed:
                hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN]["version"]) + 1
        if profile_changed:
            hass.data[DOMAIN]["profile_version"] = int(hass.data[DOMAIN].get("profile_version", hass.data[DOMAIN].get("version", 1))) + 1
            if not sched_changed:
                hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN]["version"]) + 1
        if force and not sched_changed and not settings_changed:
            hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN]["version"]) + 1
            hass.data[DOMAIN]["settings_version"] = int(hass.data[DOMAIN].get("settings_version", hass.data[DOMAIN]["version"])) + 1
            hass.data[DOMAIN]["colors_version"] = int(hass.data[DOMAIN].get("colors_version", hass.data[DOMAIN]["version"])) + 1
            hass.data[DOMAIN]["weekday_version"] = int(hass.data[DOMAIN].get("weekday_version", hass.data[DOMAIN]["version"])) + 1
            hass.data[DOMAIN]["profile_version"] = int(hass.data[DOMAIN].get("profile_version", hass.data[DOMAIN]["version"])) + 1

        # Switching active instance should also trigger a refresh.
        if activate and prev_active != iid:
            hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN]["version"]) + 1
        _LOGGER.info("set_store: version=%s, sched_changed=%s, settings_changed=%s", 
                     hass.data[DOMAIN]["version"], sched_changed, settings_changed)
        await _save_and_broadcast()

    async def select_instance(call: ServiceCall):
        """Select the active instance id.

        Optional: create the instance if missing, and optionally copy from current active.
        """
        _ensure_instances_loaded(hass)
        instances = hass.data[DOMAIN].setdefault("instances", {})
        target = _norm_instance_id(call.data.get("instance_id"))
        create_if_missing = bool(call.data.get("create_if_missing"))
        copy_from_active = bool(call.data.get("copy_from_active"))
        prev_active = _norm_instance_id(hass.data[DOMAIN].get("active_instance_id") or "default")

        if target not in instances:
            if not create_if_missing:
                _LOGGER.warning("%s.select_instance: instance_id '%s' does not exist", DOMAIN, target)
                return
            base = {}
            if copy_from_active and prev_active in instances and isinstance(instances.get(prev_active), dict):
                base = instances.get(prev_active) or {}
            instances[target] = {
                "schedules": (base.get("schedules") or {}) if isinstance(base, dict) else {},
                "weekdays": (base.get("weekdays") or {}) if isinstance(base, dict) else {},
                "profiles": (base.get("profiles") or {}) if isinstance(base, dict) else {},
                "settings": (base.get("settings") or {}) if isinstance(base, dict) else {},
                "colors": (base.get("colors") or {}) if isinstance(base, dict) else {},
            }

        hass.data[DOMAIN]["active_instance_id"] = target
        inst = instances.get(target, {}) if isinstance(instances.get(target), dict) else {}
        hass.data[DOMAIN]["schedules"] = inst.get("schedules", {})
        hass.data[DOMAIN]["weekday_schedules"] = inst.get("weekdays", {})
        hass.data[DOMAIN]["profile_schedules"] = inst.get("profiles", {})
        hass.data[DOMAIN]["settings"] = inst.get("settings", {})
        hass.data[DOMAIN]["colors"] = inst.get("colors", {})

        if prev_active != target:
            hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN].get("version", 0)) + 1
        await _save_and_broadcast()

    async def rename_instance(call: ServiceCall):
        """Rename an instance id (move data)."""
        _ensure_instances_loaded(hass)
        instances = hass.data[DOMAIN].setdefault("instances", {})
        old_id = _norm_instance_id(call.data.get("old_instance_id") or call.data.get("old_id"))
        new_id = _norm_instance_id(call.data.get("new_instance_id") or call.data.get("new_id"))
        if old_id == new_id:
            return
        if old_id not in instances:
            _LOGGER.warning("%s.rename_instance: old instance '%s' not found", DOMAIN, old_id)
            return
        if new_id in instances:
            _LOGGER.warning("%s.rename_instance: new instance '%s' already exists", DOMAIN, new_id)
            return
        instances[new_id] = instances.pop(old_id)
        if _norm_instance_id(hass.data[DOMAIN].get("active_instance_id") or "default") == old_id:
            hass.data[DOMAIN]["active_instance_id"] = new_id
            inst = instances.get(new_id, {}) if isinstance(instances.get(new_id), dict) else {}
            hass.data[DOMAIN]["schedules"] = inst.get("schedules", {})
            hass.data[DOMAIN]["weekday_schedules"] = inst.get("weekdays", {})
            hass.data[DOMAIN]["profile_schedules"] = inst.get("profiles", {})
            hass.data[DOMAIN]["settings"] = inst.get("settings", {})
            hass.data[DOMAIN]["colors"] = inst.get("colors", {})
        hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN].get("version", 0)) + 1
        await _save_and_broadcast()

    async def clear_store(call: ServiceCall):
        # Explicit clear: wipe ALL instances + active and bump versions.
        hass.data[DOMAIN]["instances"] = {"default": {"schedules": {}, "weekdays": {}, "profiles": {}, "settings": {}, "colors": {}}}
        hass.data[DOMAIN]["active_instance_id"] = "default"
        hass.data[DOMAIN]["schedules"] = {}
        hass.data[DOMAIN]["weekday_schedules"] = {}
        hass.data[DOMAIN]["profile_schedules"] = {}
        hass.data[DOMAIN]["settings"] = {}
        hass.data[DOMAIN]["colors"] = {}
        hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN].get("version", 0)) + 1
        hass.data[DOMAIN]["settings_version"] = int(hass.data[DOMAIN].get("settings_version", hass.data[DOMAIN]["version"])) + 1
        hass.data[DOMAIN]["colors_version"] = int(hass.data[DOMAIN].get("colors_version", hass.data[DOMAIN]["version"])) + 1
        hass.data[DOMAIN]["weekday_version"] = int(hass.data[DOMAIN].get("weekday_version", hass.data[DOMAIN]["version"])) + 1
        hass.data[DOMAIN]["profile_version"] = int(hass.data[DOMAIN].get("profile_version", hass.data[DOMAIN]["version"])) + 1
        await _save_and_broadcast()

    async def factory_reset(call: ServiceCall):
        """Factory reset thermostat_timeline.

        Deletes the underlying .storage JSON files and recreates them empty.
        This is useful when the store has become corrupted or needs a hard reset.
        """
        # 1) Remove persisted files (best-effort)
        try:
            await store.async_remove()
        except Exception:
            pass
        try:
            await backup_store.async_remove()
        except Exception:
            pass

        # 2) Reset runtime state (equivalent to clear_store + clearing backups)
        hass.data[DOMAIN]["instances"] = {"default": {"schedules": {}, "weekdays": {}, "profiles": {}, "settings": {}, "colors": {}}}
        hass.data[DOMAIN]["active_instance_id"] = "default"
        hass.data[DOMAIN]["schedules"] = {}
        hass.data[DOMAIN]["weekday_schedules"] = {}
        hass.data[DOMAIN]["profile_schedules"] = {}
        hass.data[DOMAIN]["settings"] = {}
        hass.data[DOMAIN]["colors"] = {}
        hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN].get("version", 0)) + 1
        hass.data[DOMAIN]["settings_version"] = int(hass.data[DOMAIN].get("settings_version", hass.data[DOMAIN]["version"])) + 1
        hass.data[DOMAIN]["colors_version"] = int(hass.data[DOMAIN].get("colors_version", hass.data[DOMAIN]["version"])) + 1
        hass.data[DOMAIN]["weekday_version"] = int(hass.data[DOMAIN].get("weekday_version", hass.data[DOMAIN]["version"])) + 1
        hass.data[DOMAIN]["profile_version"] = int(hass.data[DOMAIN].get("profile_version", hass.data[DOMAIN]["version"])) + 1

        # Clear backups too
        hass.data[DOMAIN]["backup_schedules"] = {}
        hass.data[DOMAIN]["backup_settings"] = {}
        hass.data[DOMAIN]["backup_weekdays"] = {}
        hass.data[DOMAIN]["backup_profiles"] = {}
        hass.data[DOMAIN]["backup_partial_flags"] = None
        hass.data[DOMAIN]["backup_slot_index"] = 0
        hass.data[DOMAIN]["backup_last_ts"] = None
        hass.data[DOMAIN]["backup_slots"] = []
        hass.data[DOMAIN]["backup_version"] = int(hass.data[DOMAIN].get("backup_version", 1)) + 1

        # 3) Recreate empty files and broadcast
        await _save_and_broadcast()
        await _save_backup()

    async def backup_now(call: ServiceCall):
        """Create a backup of the current store.

        Supports selective sections via optional booleans:
          - main: base daily schedules (defaultTemp, blocks)
          - weekday: weekday schedules (weekly, weekly_modes)
          - presence: presence schedules (advanced away combos per room)
          - settings: editor/global settings (except color ranges)
          - holiday: holiday schedules per room
          - colors: color ranges and color mode
          - profiles: profile schedules (profiles, activeProfile)
        If no flags are provided, a full backup is made (backwards compatible).
        """
        want_main = bool(call.data.get("main", True))
        want_week = bool(call.data.get("weekday", True))
        want_presence = bool(call.data.get("presence", True))
        want_settings = bool(call.data.get("settings", True))
        want_holiday = bool(call.data.get("holiday", True))
        want_colors = bool(call.data.get("colors", True))
        want_profiles = bool(call.data.get("profiles", True))

        src_sched = hass.data[DOMAIN].get("schedules", {}) or {}
        src_week = hass.data[DOMAIN].get("weekday_schedules", {}) or {}
        src_prof = hass.data[DOMAIN].get("profile_schedules", {}) or {}
        src_set = hass.data[DOMAIN].get("settings", {}) or {}

        out_sched: dict = {}
        if any([want_main, want_week, want_presence, want_holiday, want_profiles]):
            for eid, row in (src_sched.items() if isinstance(src_sched, dict) else []):
                if not isinstance(row, dict):
                    continue
                new_row = {}
                if want_main:
                    if "defaultTemp" in row:
                        new_row["defaultTemp"] = row["defaultTemp"]
                    if "blocks" in row:
                        new_row["blocks"] = row["blocks"]
                if want_profiles:
                    prof_src = src_prof.get(eid, {}) if isinstance(src_prof, dict) else {}
                    if "profiles" in prof_src:
                        new_row["profiles"] = prof_src["profiles"]
                    elif "profiles" in row:
                        new_row["profiles"] = row["profiles"]
                    if "activeProfile" in prof_src:
                        new_row["activeProfile"] = prof_src["activeProfile"]
                    elif "activeProfile" in row:
                        new_row["activeProfile"] = row["activeProfile"]
                if want_week:
                    try:
                        wk_src = src_week.get(eid, {}) if isinstance(src_week, dict) else {}
                    except Exception:
                        wk_src = {}
                    if "weekly" in wk_src:
                        new_row["weekly"] = wk_src["weekly"]
                    elif "weekly" in row:
                        new_row["weekly"] = row["weekly"]
                    if "weekly_modes" in wk_src:
                        new_row["weekly_modes"] = wk_src["weekly_modes"]
                    elif "weekly_modes" in row:
                        new_row["weekly_modes"] = row["weekly_modes"]
                if want_presence and "presence" in row:
                    new_row["presence"] = row["presence"]
                if want_holiday and "holiday" in row:
                    new_row["holiday"] = row["holiday"]
                if new_row:
                    out_sched[eid] = new_row

        out_set: dict = {}
        if want_settings:
            for k, v in (src_set.items() if isinstance(src_set, dict) else []):
                if k in ("color_ranges", "color_global"):
                    continue
                out_set[k] = v
        if want_colors:
            if "color_ranges" in src_set:
                out_set["color_ranges"] = src_set["color_ranges"]
            if "color_global" in src_set:
                out_set["color_global"] = src_set["color_global"]

        legacy_full = (
            "main" not in call.data
            and "weekday" not in call.data
            and "presence" not in call.data
            and "settings" not in call.data
            and "holiday" not in call.data
            and "colors" not in call.data
            and "profiles" not in call.data
        )

        if legacy_full:
            hass.data[DOMAIN]["backup_schedules"] = dict(src_sched)
            hass.data[DOMAIN]["backup_settings"] = dict(src_set)
            hass.data[DOMAIN]["backup_profiles"] = dict(src_prof)
            hass.data[DOMAIN]["backup_weekdays"] = dict(src_week)
            hass.data[DOMAIN]["backup_partial_flags"] = None
        else:
            hass.data[DOMAIN]["backup_schedules"] = out_sched
            hass.data[DOMAIN]["backup_settings"] = out_set
            hass.data[DOMAIN]["backup_profiles"] = {k: v for k, v in (src_prof.items() if isinstance(src_prof, dict) else [])} if want_profiles else {}
            hass.data[DOMAIN]["backup_weekdays"] = {k: v for k, v in (src_week.items() if isinstance(src_week, dict) else [])}
            hass.data[DOMAIN]["backup_partial_flags"] = {
                "main": want_main,
                "weekday": want_week,
                "presence": want_presence,
                "settings": want_settings,
                "holiday": want_holiday,
                "colors": want_colors,
                "profiles": want_profiles,
            }

        # Maintain append-only backup list (unbounded)
        try:
            slots = hass.data[DOMAIN].get("backup_slots", [])
            if not isinstance(slots, list):
                slots = []
            # Colors come from live colors store (matches colors sensor)
            backup_colors = hass.data[DOMAIN].get("colors", {}) or {}
            entry = {
                "ts": dt_util.utcnow().isoformat(),
                "version": hass.data[DOMAIN].get("version", 1),
                "settings_version": hass.data[DOMAIN].get("settings_version", hass.data[DOMAIN].get("version", 1)),
                "weekday_version": hass.data[DOMAIN].get("weekday_version", hass.data[DOMAIN].get("version", 1)),
                "profile_version": hass.data[DOMAIN].get("profile_version", hass.data[DOMAIN].get("version", 1)),
                "colors_version": hass.data[DOMAIN].get("colors_version", hass.data[DOMAIN].get("version", 1)),
                "partial_flags": hass.data[DOMAIN].get("backup_partial_flags"),
                "sections": {
                    "schedules": hass.data[DOMAIN].get("backup_schedules", {}),
                    "settings": hass.data[DOMAIN].get("backup_settings", {}),
                    "weekdays": hass.data[DOMAIN].get("backup_weekdays", {}),
                    "profiles": hass.data[DOMAIN].get("backup_profiles", {}),
                    "colors": backup_colors,
                },
            }
            # Append new entry (skip None placeholders)
            slots = [s for s in slots if s is None or isinstance(s, dict)]
            slots.append(entry)
            hass.data[DOMAIN]["backup_slot_index"] = len(slots)
            hass.data[DOMAIN]["backup_slots"] = slots
        except Exception:
            pass

        hass.data[DOMAIN]["backup_version"] = int(hass.data[DOMAIN].get("backup_version", 1)) + 1
        try:
            hass.data[DOMAIN]["backup_last_ts"] = dt_util.utcnow().isoformat()
        except Exception:
            pass
        await _save_backup()

    async def delete_backup(call: ServiceCall):
        """Delete a specific backup slot or clear all backups when no slot is provided."""
        slots = hass.data[DOMAIN].get("backup_slots", [])
        if not isinstance(slots, list):
            slots = []

        slot_num = call.data.get("slot")
        changed = False

        if slot_num is not None:
            try:
                idx = int(slot_num) - 1
            except Exception:
                idx = -1
            if 0 <= idx < len(slots):
                try:
                    slots.pop(idx)
                except Exception:
                    pass
                else:
                    changed = True
        else:
            # Clear all backup payloads and slots
            hass.data[DOMAIN]["backup_schedules"] = {}
            hass.data[DOMAIN]["backup_settings"] = {}
            hass.data[DOMAIN]["backup_weekdays"] = {}
            hass.data[DOMAIN]["backup_profiles"] = {}
            hass.data[DOMAIN]["backup_partial_flags"] = None
            hass.data[DOMAIN]["backup_slot_index"] = 0
            hass.data[DOMAIN]["backup_last_ts"] = None
            slots = []
            changed = True

        if not changed:
            return

        hass.data[DOMAIN]["backup_slots"] = slots
        hass.data[DOMAIN]["backup_version"] = int(hass.data[DOMAIN].get("backup_version", 1)) + 1
        hass.data[DOMAIN]["backup_slot_index"] = len(slots)
        hass.data[DOMAIN]["backup_last_ts"] = _latest_backup_ts(slots)
        await _save_backup()

    async def import_backup(call: ServiceCall):
        """Import backup slots from a JSON payload and append as new slots.

        Accepted inputs:
        - slots: [ {slot objects} ]
        - data: { schedules, settings, weekdays, profiles, slots?, partial_flags? }
          If 'slots' is provided inside data, those are used. Otherwise a single
          slot is synthesized from schedules/settings/weekdays/profiles.
        """
        try:
            payload = call.data or {}
            slots_in = []
            if isinstance(payload.get("slots"), list):
                slots_in = payload.get("slots")
            elif isinstance(payload.get("data"), dict):
                d = payload.get("data")
                if isinstance(d.get("slots"), list):
                    slots_in = d.get("slots")
                else:
                    # Build a single slot from provided sections
                    sections = {
                        "schedules": d.get("schedules", {}) or {},
                        "settings": d.get("settings", {}) or {},
                        "weekdays": d.get("weekdays", {}) or {},
                        "profiles": d.get("profiles", {}) or {},
                        "colors": (d.get("colors") or {}),
                    }
                    slot = {
                        "ts": dt_util.utcnow().isoformat(),
                        "version": hass.data[DOMAIN].get("version", 1),
                        "settings_version": hass.data[DOMAIN].get("settings_version", 1),
                        "weekday_version": hass.data[DOMAIN].get("weekday_version", 1),
                        "profile_version": hass.data[DOMAIN].get("profile_version", 1),
                        "colors_version": hass.data[DOMAIN].get("colors_version", 1),
                        "partial_flags": d.get("partial_flags") or None,
                        "sections": sections,
                        "imported": True,
                    }
                    slots_in = [slot]
            else:
                # Try synthesizing from top-level sections (backward compat)
                sections = {
                    "schedules": payload.get("schedules", {}) or {},
                    "settings": payload.get("settings", {}) or {},
                    "weekdays": payload.get("weekdays", {}) or {},
                    "profiles": payload.get("profiles", {}) or {},
                    "colors": (payload.get("colors") or {}),
                }
                if any(len(sections.get(k, {})) for k in ("schedules","settings","weekdays","profiles")):
                    slots_in = [{
                        "ts": dt_util.utcnow().isoformat(),
                        "version": hass.data[DOMAIN].get("version", 1),
                        "settings_version": hass.data[DOMAIN].get("settings_version", 1),
                        "weekday_version": hass.data[DOMAIN].get("weekday_version", 1),
                        "profile_version": hass.data[DOMAIN].get("profile_version", 1),
                        "colors_version": hass.data[DOMAIN].get("colors_version", 1),
                        "partial_flags": payload.get("partial_flags") or None,
                        "sections": sections,
                    }]

            if not isinstance(slots_in, list) or not slots_in:
                return

            # Normalize and append
            slots = hass.data[DOMAIN].get("backup_slots", [])
            if not isinstance(slots, list):
                slots = []
            for s in slots_in:
                if not isinstance(s, dict):
                    continue
                sec = s.get("sections") if isinstance(s.get("sections"), dict) else None
                if sec is None:
                    # Skip invalid slot
                    continue
                # Ensure timestamp strings
                if not isinstance(s.get("ts"), str) or not s.get("ts"):
                    s["ts"] = dt_util.utcnow().isoformat()
                # Mark as imported (for UI labeling)
                try:
                    if "imported" not in s:
                        s["imported"] = True
                except Exception:
                    pass
                slots.append(s)

            hass.data[DOMAIN]["backup_slots"] = [x for x in slots if (x is None or isinstance(x, dict))]
            hass.data[DOMAIN]["backup_slot_index"] = len(hass.data[DOMAIN]["backup_slots"])
            hass.data[DOMAIN]["backup_version"] = int(hass.data[DOMAIN].get("backup_version", 1)) + 1
            hass.data[DOMAIN]["backup_last_ts"] = _latest_backup_ts(hass.data[DOMAIN]["backup_slots"]) or dt_util.utcnow().isoformat()
            await _save_backup()
            # Also broadcast so UI refreshes
            async_dispatcher_send(hass, SIGNAL_UPDATED)
        except Exception:
            _LOGGER.warning("%s: import_backup failed", DOMAIN, exc_info=True)

    async def restore_now(call: ServiceCall):
        """Restore from backup.

        Optional mode: 'replace' (default) or 'merge'.
        Optional section flags like backup_now: main, weekday, presence, settings, holiday, colors, profiles.
        Optional slot (1-based) to restore from rolling backup slots.
        If backup contains partial_flags and no mode is provided, merge is used by default.
        """
        # Always operate on the active instance as well as the legacy pointers.
        _ensure_instances_loaded(hass)
        instances = hass.data[DOMAIN].setdefault("instances", {})
        active_iid = _norm_instance_id(hass.data[DOMAIN].get("active_instance_id") or "default")
        inst = instances.get(active_iid)
        if not isinstance(inst, dict):
            inst = {"schedules": {}, "weekdays": {}, "profiles": {}, "settings": {}, "colors": {}}
            instances[active_iid] = inst
        slot_num = call.data.get("slot")
        slot_entry = None
        if slot_num is not None:
            try:
                idx = int(slot_num) - 1
                slots = hass.data[DOMAIN].get("backup_slots", []) if isinstance(hass.data[DOMAIN].get("backup_slots", []), list) else []
                if 0 <= idx < len(slots):
                    slot_entry = slots[idx]
            except Exception:
                slot_entry = None

        mode = str(call.data.get("mode", "")).lower().strip()
        flags = hass.data[DOMAIN].get("backup_partial_flags") or {}
        # Allow caller to override flags
        for key in ("main","weekday","presence","settings","holiday","colors","profiles"):
            if key in call.data:
                flags[key] = bool(call.data.get(key))

        sections = None
        if slot_entry and isinstance(slot_entry, dict):
            sections = slot_entry.get("sections", {}) if isinstance(slot_entry.get("sections"), dict) else {}
            flags = slot_entry.get("partial_flags") or flags

        backup_sched = (sections.get("schedules") if sections else None) or hass.data[DOMAIN].get("backup_schedules", {}) or {}
        backup_set = (sections.get("settings") if sections else None) or hass.data[DOMAIN].get("backup_settings", {}) or {}
        backup_week = (sections.get("weekdays") if sections else None) or hass.data[DOMAIN].get("backup_weekdays", {}) or {}
        backup_prof = (sections.get("profiles") if sections else None) or hass.data[DOMAIN].get("backup_profiles", {}) or {}

        # If no flags and mode empty, keep legacy behavior (replace)
        if not flags and mode not in ("merge", "replace"):
            # Legacy: full replace
            hass.data[DOMAIN]["schedules"] = dict(backup_sched)
            hass.data[DOMAIN]["settings"] = dict(backup_set)
            hass.data[DOMAIN]["weekday_schedules"] = dict(backup_week)
            hass.data[DOMAIN]["profile_schedules"] = dict(backup_prof)
            # Mirror into active instance so persistence uses the restored data
            inst["schedules"] = dict(backup_sched)
            inst["weekdays"] = dict(backup_week)
            inst["profiles"] = dict(backup_prof)
            inst["settings"] = dict(backup_set)
            # Keep colors in sync with settings if present
            try:
                inst["colors"] = {
                    "color_ranges": (backup_set.get("color_ranges") if isinstance(backup_set, dict) else {}) or {},
                    "color_global": bool((backup_set.get("color_global") if isinstance(backup_set, dict) else False)),
                }
            except Exception:
                pass
            hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN].get("version", 1)) + 1
            hass.data[DOMAIN]["settings_version"] = int(hass.data[DOMAIN].get("settings_version", 1)) + 1
            hass.data[DOMAIN]["weekday_version"] = int(hass.data[DOMAIN].get("weekday_version", 1)) + 1
            hass.data[DOMAIN]["profile_version"] = int(hass.data[DOMAIN].get("profile_version", 1)) + 1
            await _save_and_broadcast()
            return

        do_merge = (mode == "merge") or (not mode and bool(flags))
        if not do_merge:
            # Explicit replace
            hass.data[DOMAIN]["schedules"] = dict(backup_sched)
            hass.data[DOMAIN]["settings"] = dict(backup_set)
            hass.data[DOMAIN]["weekday_schedules"] = dict(backup_week)
            hass.data[DOMAIN]["profile_schedules"] = dict(backup_prof)
            # Mirror into active instance so persistence uses the restored data
            inst["schedules"] = dict(backup_sched)
            inst["weekdays"] = dict(backup_week)
            inst["profiles"] = dict(backup_prof)
            inst["settings"] = dict(backup_set)
            # Sync colors from settings if present
            try:
                inst["colors"] = {
                    "color_ranges": (backup_set.get("color_ranges") if isinstance(backup_set, dict) else {}) or {},
                    "color_global": bool((backup_set.get("color_global") if isinstance(backup_set, dict) else False)),
                }
            except Exception:
                pass
            hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN].get("version", 1)) + 1
            hass.data[DOMAIN]["settings_version"] = int(hass.data[DOMAIN].get("settings_version", 1)) + 1
            hass.data[DOMAIN]["weekday_version"] = int(hass.data[DOMAIN].get("weekday_version", 1)) + 1
            hass.data[DOMAIN]["profile_version"] = int(hass.data[DOMAIN].get("profile_version", 1)) + 1
            await _save_and_broadcast()
            return

        # Merge: only update selected sections/keys
        cur_sched = hass.data[DOMAIN].get("schedules", {}) or {}
        cur_set = hass.data[DOMAIN].get("settings", {}) or {}
        cur_week = hass.data[DOMAIN].get("weekday_schedules", {}) or {}
        cur_prof = hass.data[DOMAIN].get("profile_schedules", {}) or {}

        want_main = bool(flags.get("main", False))
        want_week = bool(flags.get("weekday", False))
        want_presence = bool(flags.get("presence", False))
        want_settings = bool(flags.get("settings", False))
        want_holiday = bool(flags.get("holiday", False))
        want_colors = bool(flags.get("colors", False))
        want_profiles = bool(flags.get("profiles", False))

        if any([want_main, want_week, want_presence, want_holiday, want_profiles]):
            for eid, brow in (backup_sched.items() if isinstance(backup_sched, dict) else []):
                if not isinstance(brow, dict):
                    continue
                row = dict(cur_sched.get(eid, {}))
                if want_main:
                    if "defaultTemp" in brow:
                        row["defaultTemp"] = brow["defaultTemp"]
                    if "blocks" in brow:
                        row["blocks"] = brow["blocks"]
                if want_profiles:
                    prof_src = backup_prof.get(eid, {}) if isinstance(backup_prof, dict) else {}
                    if "profiles" in prof_src:
                        row["profiles"] = prof_src["profiles"]
                    elif "profiles" in brow:
                        row["profiles"] = brow["profiles"]
                    if "activeProfile" in prof_src:
                        row["activeProfile"] = prof_src["activeProfile"]
                    elif "activeProfile" in brow:
                        row["activeProfile"] = brow["activeProfile"]
                if want_week:
                    wk_src = backup_week.get(eid, {}) if isinstance(backup_week, dict) else {}
                    if "weekly" in wk_src:
                        row["weekly"] = wk_src["weekly"]
                    elif "weekly" in brow:
                        row["weekly"] = brow["weekly"]
                    if "weekly_modes" in wk_src:
                        row["weekly_modes"] = wk_src["weekly_modes"]
                    elif "weekly_modes" in brow:
                        row["weekly_modes"] = brow["weekly_modes"]
                if want_presence and "presence" in brow:
                    row["presence"] = brow["presence"]
                if want_holiday and "holiday" in brow:
                    row["holiday"] = brow["holiday"]
                cur_sched[eid] = row

        if want_settings or want_colors:
            # Merge settings keys
            for k, v in (backup_set.items() if isinstance(backup_set, dict) else []):
                if (k in ("color_ranges", "color_global")) and not want_colors:
                    continue
                if (k not in ("color_ranges", "color_global")) and not want_settings:
                    continue
                cur_set[k] = v

        if want_week:
            hass.data[DOMAIN]["weekday_schedules"] = {k: v for k, v in (backup_week.items() if isinstance(backup_week, dict) else [])}
        else:
            hass.data[DOMAIN]["weekday_schedules"] = cur_week
        if want_profiles:
            hass.data[DOMAIN]["profile_schedules"] = {k: v for k, v in (backup_prof.items() if isinstance(backup_prof, dict) else [])}
        else:
            hass.data[DOMAIN]["profile_schedules"] = cur_prof

        hass.data[DOMAIN]["schedules"] = cur_sched
        hass.data[DOMAIN]["settings"] = cur_set
        # Mirror merge results into active instance for persistence
        inst["schedules"] = dict(cur_sched)
        inst["settings"] = dict(cur_set)
        inst["weekdays"] = dict(hass.data[DOMAIN]["weekday_schedules"])
        inst["profiles"] = dict(hass.data[DOMAIN]["profile_schedules"])
        try:
            if want_colors:
                inst["colors"] = {
                    "color_ranges": (backup_set.get("color_ranges") if isinstance(backup_set, dict) else {}) or inst.get("colors", {}).get("color_ranges", {}),
                    "color_global": bool((backup_set.get("color_global") if isinstance(backup_set, dict) else inst.get("colors", {}).get("color_global", False))),
                }
        except Exception:
            pass
        hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN].get("version", 1)) + 1
        hass.data[DOMAIN]["settings_version"] = int(hass.data[DOMAIN].get("settings_version", 1)) + 1
        if want_week:
            hass.data[DOMAIN]["weekday_version"] = int(hass.data[DOMAIN].get("weekday_version", 1)) + 1
        if want_profiles:
            hass.data[DOMAIN]["profile_version"] = int(hass.data[DOMAIN].get("profile_version", 1)) + 1
        await _save_and_broadcast()

    async def patch_entity(call: ServiceCall):
        eid = str(call.data.get("entity_id","")).strip()
        d = call.data.get("data", {})
        if not eid or not isinstance(d, dict):
            _LOGGER.warning("%s.patch_entity: invalid args", DOMAIN)
            return
        cur = dict(hass.data[DOMAIN]["schedules"].get(eid, {}))
        if "defaultTemp" in d: cur["defaultTemp"] = d["defaultTemp"]
        if "blocks" in d and isinstance(d["blocks"], list): cur["blocks"] = d["blocks"]
        hass.data[DOMAIN]["schedules"][eid] = cur
        hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN]["version"]) + 1
        await _save_and_broadcast()

    async def clear(call: ServiceCall):
        hass.data[DOMAIN]["schedules"] = {}
        hass.data[DOMAIN]["version"] = int(hass.data[DOMAIN]["version"]) + 1
        await _save_and_broadcast()

    hass.services.async_register(DOMAIN, "set_store", set_store)
    hass.services.async_register(DOMAIN, "clear_store", clear_store)
    hass.services.async_register(DOMAIN, "factory_reset", factory_reset)
    hass.services.async_register(DOMAIN, "select_instance", select_instance)
    hass.services.async_register(DOMAIN, "rename_instance", rename_instance)
    hass.services.async_register(DOMAIN, "backup_now", backup_now)
    hass.services.async_register(DOMAIN, "delete_backup", delete_backup)
    hass.services.async_register(DOMAIN, "import_backup", import_backup)
    hass.services.async_register(DOMAIN, "restore_now", restore_now)
    hass.services.async_register(DOMAIN, "patch_entity", patch_entity)
    hass.services.async_register(DOMAIN, "clear", clear)
    # no apply_now service (removed)

    # Expose lightweight HTTP views for clients (works over HA Cloud)
    try:
        hass.http.register_view(ThermostatTimelineStateView(hass))
        hass.http.register_view(ThermostatTimelineVersionView(hass))
    except Exception:
        _LOGGER.warning("%s: failed to register HTTP views", DOMAIN)

    # ---- Background Auto-Apply Manager (multi-instance) ----
    mgr = MultiInstanceAutoApplyManager(hass)
    hass.data[DOMAIN]["manager"] = mgr
    await mgr.async_start()
    # ---- Open Window Detection (background, multi-instance) ----
    owd = MultiInstanceOpenWindowManager(hass, apply_multi=mgr)
    # Keep legacy key for compatibility (points to the multi wrapper).
    hass.data[DOMAIN]["open_window_manager"] = owd
    await owd.async_start()
    # ---- Presence Sensor Manager (background, multi-instance) ----
    psm = MultiInstancePresenceSensorManager(hass, apply_multi=mgr)
    hass.data[DOMAIN]["presence_sensor_manager"] = psm
    await psm.async_start()
    # ---- Backup Manager (auto backup) ----
    bkm = BackupManager(hass, backup_cb=backup_now)
    hass.data[DOMAIN]["backup_manager"] = bkm
    await bkm.async_start()
    return True

async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
    return True


class AutoApplyManager:
    def __init__(self, hass: HomeAssistant, instance_id: str = "default"):
        self.hass = hass
        self._instance_id = _norm_instance_id(instance_id)
        self._unsub_timer = None
        self._unsub_persons = None
        self._unsub_boiler = None
        self._unsub_boiler_interval = None
        self._unsub_pause_sensor = None
        self._last_pause_sensor_on: bool | None = None
        self._last_applied = {}  # eid -> {"min": int, "temp": float}
        self._next_is_resume = False

    async def async_start(self):
        # Apply once on startup and schedule next if enabled
        await self._maybe_apply_now(force=True)
        await self._maybe_control_boiler(force=True)
        await self._schedule_next()
        # Re-apply on store updates
        @callback
        def _on_store_update():
            self.hass.async_create_task(self._on_store_changed())
        async_dispatcher_connect(self.hass, SIGNAL_UPDATED, _on_store_update)
        # Watch person.* states if away mode is used
        self._reset_person_watch()
        self._reset_boiler_watch()
        self._reset_boiler_interval()
        self._reset_pause_sensor_watch()

    async def _on_store_changed(self):
        # Respect apply_on_edit toggle: only apply immediately on store changes when enabled
        _s, settings = self._get_data()
        if bool(settings.get("apply_on_edit", True)):
            await self._maybe_apply_now(force=True)
        await self._maybe_control_boiler(force=True)
        await self._schedule_next()
        self._reset_person_watch()
        self._reset_boiler_watch()
        self._reset_boiler_interval()
        self._reset_pause_sensor_watch()

    def _pause_sensor_on(self, settings: dict) -> bool:
        """Return True when pause is requested via binary sensor."""
        try:
            if not bool(settings.get("pause_sensor_enabled")):
                return False
            eid = str(settings.get("pause_sensor_entity") or "").strip()
            if not eid or not eid.startswith("binary_sensor."):
                return False
            st = self.hass.states.get(eid)
            s = str(st.state if st else "").lower().strip()
            return s == "on"
        except Exception:
            return False

    def _get_data(self):
        d = self.hass.data.get(DOMAIN, {})
        try:
            instances = d.get("instances") if isinstance(d.get("instances"), dict) else None
            if isinstance(instances, dict):
                inst = instances.get(self._instance_id)
                if isinstance(inst, dict):
                    schedules = inst.get("schedules", {}) if isinstance(inst.get("schedules"), dict) else {}
                    settings = inst.get("settings", {}) if isinstance(inst.get("settings"), dict) else {}
                    return schedules, settings
        except Exception:
            pass
        # Fallback to legacy active-instance pointers
        schedules = d.get("schedules", {})
        settings = d.get("settings", {})
        return schedules, settings

    def _owd_mgr(self):
        """Return the Open Window manager for this instance (if any)."""
        try:
            d = self.hass.data.get(DOMAIN, {})
            m = d.get("open_window_managers")
            if isinstance(m, dict):
                owd = m.get(self._instance_id)
                if owd is not None:
                    return owd
            return d.get("open_window_manager")
        except Exception:
            return None

    def _auto_apply_enabled(self) -> bool:
        _s, settings = self._get_data()
        if not bool(settings.get("auto_apply_enabled")):
            return False
        # Pause via binary sensor (indefinite while sensor is on)
        try:
            if self._pause_sensor_on(settings):
                return False
        except Exception:
            pass
        # Pause gates
        try:
            if bool(settings.get("pause_indef")):
                return False
            pu = settings.get("pause_until_ms")
            if isinstance(pu, (int, float)):
                # compare in ms
                now_ms = dt_util.utcnow().timestamp() * 1000.0
                if float(pu) > now_ms:
                    return False
        except Exception:
            pass
        return True

    def _paused_until_dt(self):
        _s, settings = self._get_data()
        try:
            if bool(settings.get("pause_indef")):
                return None
            pu = settings.get("pause_until_ms")
            if isinstance(pu, (int, float)) and float(pu) > 0:
                # Ignore expired timestamps to avoid scheduling timers in the past,
                # which can create an immediate-fire feedback loop.
                now_ms = dt_util.utcnow().timestamp() * 1000.0
                if float(pu) <= (now_ms + 250.0):
                    return None
                return dt_util.utc_from_timestamp(float(pu) / 1000.0)
        except Exception:
            pass
        return None

    def _now_min(self) -> int:
        now = dt_util.now()
        return now.hour * 60 + now.minute

    def _today_key(self) -> str:
        # Monday=0 ... Sunday=6 -> map to keys
        idx = (dt_util.now().weekday())  # 0..6
        return ["mon","tue","wed","thu","fri","sat","sun"][idx]

    def _today_iso(self) -> str:
        """Return today's date in HA local timezone as YYYY-MM-DD."""
        try:
            return dt_util.now().date().isoformat()
        except Exception:
            return ""

    def _season_key(self, settings: dict) -> str:
        try:
            return "summer" if str(settings.get("seasonal_mode") or "winter").lower().strip() == "summer" else "winter"
        except Exception:
            return "winter"

    def _seasonal_row(self, row: dict, settings: dict) -> dict:
        """Return row view using seasonal data when enabled."""
        try:
            if not bool(settings.get("seasonal_enabled")):
                return row
            seasons = row.get("seasons") if isinstance(row, dict) else None
            if not isinstance(seasons, dict):
                return row
            key = self._season_key(settings)
            srow = seasons.get(key)
            if not isinstance(srow, dict):
                return row
            out = dict(row)
            # Blocks: if missing in season, treat as empty (do not inherit).
            if isinstance(srow.get("blocks"), list):
                out["blocks"] = srow.get("blocks")
            else:
                out["blocks"] = []
            # Weekly/weekly_modes/presence: if missing, remove to avoid inheriting base data.
            if "weekly" in srow:
                out["weekly"] = srow.get("weekly")
            else:
                out.pop("weekly", None)
            if "weekly_modes" in srow:
                out["weekly_modes"] = srow.get("weekly_modes")
            else:
                out.pop("weekly_modes", None)
            if "presence" in srow:
                out["presence"] = srow.get("presence")
            else:
                out.pop("presence", None)
            return out
        except Exception:
            return row

    def _is_holiday_active(self, settings: dict) -> bool:
        """Determine whether Holiday mode is active.

        Uses settings written by the frontend card:
          - holidays_enabled: bool
          - holidays_source: 'calendar' | 'manual'
          - holidays_entity: entity id (calendar.* or binary_sensor.*)
          - holidays_dates: ['YYYY-MM-DD', ...]
        """
        try:
            if not bool(settings.get("holidays_enabled")):
                return False
            src = str(settings.get("holidays_source") or "calendar").lower().strip()
            if src == "manual":
                dates = settings.get("holidays_dates")
                if not isinstance(dates, list) or not dates:
                    return False
                today = self._today_iso()
                return any(str(d) == today for d in dates)

            # calendar/binary_sensor entity
            eid = str(settings.get("holidays_entity") or "").strip()
            if not eid:
                return False
            st = self.hass.states.get(eid)
            s = str(st.state if st else "").lower()
            return s == "on"
        except Exception:
            return False

    def _effective_blocks_today(self, row: dict, settings: dict):
        # Apply seasonal selection first (affects blocks/weekly/presence buckets).
        try:
            row = self._seasonal_row(row, settings)
        except Exception:
            pass
        # Holidays override have highest precedence when enabled and active.
        # Note: only apply holiday override when this room actually has holiday blocks.
        try:
            if self._is_holiday_active(settings):
                hol = row.get("holiday") or {}
                blk = hol.get("blocks") if isinstance(hol, dict) else None
                if isinstance(blk, list) and len(blk) > 0:
                    return blk
        except Exception:
            pass
        # Presence (advanced away) overrides if enabled and an active combo exists
        try:
            away = settings.get("away") or {}
            if bool(away.get("advanced_enabled")):
                key = self._active_presence_combo_key(settings)
                if key:
                    pres = row.get("presence") or {}
                    blk = (pres.get(key) or {}).get("blocks")
                    if isinstance(blk, list) and len(blk) > 0:
                        return blk
                    # Presence combo is active but this room has no presence schedule:
                    # return a virtual all‑day block at Away temperature
                    try:
                        val = (away.get("target_c") if isinstance(away, dict) else None)
                        target = float(val) if isinstance(val, (int, float, str)) else None
                        if target is not None:
                            return [{"id": "__presence_away__", "startMin": 0, "endMin": 1440, "temp": target}]
                    except Exception:
                        pass
        except Exception:
            pass
        # Profiles override take precedence when enabled (per-room activeProfile)
        try:
            if settings.get("profiles_enabled"):
                ap = row.get("activeProfile")
                if ap:
                    profs = row.get("profiles") or {}
                    blk = (profs.get(ap) or {}).get("blocks")
                    # Treat empty profile blocks as "no override".
                    # If a manual profile is active globally but a room has no
                    # blocks defined for that profile, the room must follow its
                    # normal schedule instead of falling back to defaultTemp.
                    if isinstance(blk, list) and len(blk) > 0:
                        return blk
        except Exception:
            pass
        # If row has weekly structure, use today's day list, otherwise default blocks
        try:
            wk = row.get("weekly")
            if wk and isinstance(wk, dict):
                days = wk.get("days") or {}
                arr = days.get(self._today_key())
                if isinstance(arr, list):
                    return arr
        except Exception:
            pass
        return row.get("blocks") or []

    def _presence_persons(self, settings: dict) -> list[str]:
        try:
            away = settings.get("away") or {}
            persons = away.get("persons")
            return list(persons) if isinstance(persons, list) else []
        except Exception:
            return []

    def _presence_combo_key(self, home: list[str], away: list[str]) -> str:
        try:
            h = sorted([str(x) for x in (home or [])])
            a = sorted([str(x) for x in (away or [])])
            return f"H:{','.join(h)}|A:{','.join(a)}"
        except Exception:
            return "H:|A:"

    def _active_presence_combo_key(self, settings: dict) -> str | None:
        try:
            away = settings.get("away") or {}
            if not bool(away.get("advanced_enabled")):
                return None
            persons = self._presence_persons(settings)
            if not persons:
                return None
            home, not_home = [], []
            for p in persons:
                st = self.hass.states.get(p)
                if st and str(st.state).lower() == "home":
                    home.append(p)
                else:
                    not_home.append(p)
            key = self._presence_combo_key(home, not_home)
            combos = (away.get("combos") or {}) if isinstance(away, dict) else {}
            meta = combos.get(key)
            if meta and bool(meta.get("enabled")):
                return key
            return None
        except Exception:
            return None

    def _all_targets(self, schedules: dict, merges: dict) -> set[str]:
        out = set()
        for primary in schedules.keys():
            out.add(primary)
            linked = merges.get(primary) or []
            for e in linked:
                out.add(e)
        return out

    def _desired_for(self, eid: str, schedules: dict, settings: dict, now_min: int) -> Optional[float]:
        # Resolve primary (merged)
        merges = settings.get("merges") or {}
        primary = None
        if eid in schedules:
            primary = eid
        else:
            # find primary that lists eid
            for p, lst in (merges.items() if isinstance(merges, dict) else []):
                try:
                    if eid in (lst or []):
                        primary = p
                        break
                except Exception:
                    continue
        if primary is None:
            primary = eid
        row = schedules.get(primary)
        if not isinstance(row, dict):
            return None
        # Determine which blocks are effective for today
        blocks = self._effective_blocks_today(row, settings)
        # Track if advanced presence is active globally and whether this room has a presence schedule for the active combo
        presence_key_active = self._active_presence_combo_key(settings)
        room_has_presence_blocks = False
        try:
            if presence_key_active:
                pres = row.get("presence") or {}
                bl = (pres.get(presence_key_active) or {}).get("blocks")
                room_has_presence_blocks = isinstance(bl, list) and len(bl) > 0
        except Exception:
            room_has_presence_blocks = False
        hit = None
        for b in blocks:
            try:
                if now_min >= int(b.get("startMin", -1)) and now_min < int(b.get("endMin", -1)):
                    hit = b
                    break
            except Exception:
                continue
        # Base desired setpoint
        if hit is not None:
            v = hit.get("temp") if isinstance(hit, dict) else None
            if isinstance(v, (int, float, str)):
                want = float(v)
            else:
                want = float(row.get("defaultTemp", 20))
        else:
            # When advanced presence is active but this room has no presence schedule,
            # use Away temperature instead of the room default.
            if presence_key_active and not room_has_presence_blocks:
                try:
                    away = settings.get("away") or {}
                    val = away.get("target_c")
                    if isinstance(val, (int, float, str)):
                        want = float(val)
                    else:
                        want = float(row.get("defaultTemp", 20))
                except Exception:
                    want = float(row.get("defaultTemp", 20))
            else:
                want = float(row.get("defaultTemp", 20))
        # Away override (disabled when advanced presence is enabled)
        try:
            away = settings.get("away") or {}
            if bool(away.get("advanced_enabled")):
                pass  # advanced presence replaces simple away clamp entirely
            elif away.get("enabled") and isinstance(away.get("persons"), list):
                anyone_home = False
                for p in away["persons"]:
                    st = self.hass.states.get(p)
                    if st and str(st.state).lower() == "home":
                        anyone_home = True
                        break
                if not anyone_home and ("target_c" in away):
                    target_c = float(away.get("target_c", want))
                    want = min(want, target_c)
        except Exception:
            pass
        # Per-room presence sensor override (simple presence entity)
        try:
            # Only when feature is enabled in settings
            if bool(settings.get("presence_sensor_enabled")):
                # Map of primary climate/input_number to binary_sensor entity id
                pmap = settings.get("presence_sensors") or {}
                if isinstance(pmap, dict):
                    s_eid = str(pmap.get(primary) or "").strip()
                    if s_eid and s_eid.startswith("binary_sensor."):
                        st = self.hass.states.get(s_eid)
                        s = str(st.state if st else "").lower().strip()
                        if s == "on":
                            # Temperature override when presence is ON
                            tmap = settings.get("presence_sensor_temps") or {}
                            try:
                                val = tmap.get(primary)
                                if isinstance(val, (int, float, str)):
                                    want = float(val)
                            except Exception:
                                pass
        except Exception:
            pass
        # Clamp
        try:
            mn = settings.get("min_temp")
            mx = settings.get("max_temp")
            if mn is not None:
                want = max(want, float(mn))
            if mx is not None:
                want = min(want, float(mx))
        except Exception:
            pass
        return want

    async def _maybe_apply_now(self, force: bool = False, boundary_only: bool = False, reconcile: bool = False):
        if not self._auto_apply_enabled():
            return
        schedules, settings = self._get_data()
        merges = settings.get("merges") or {}
        now_min = self._now_min()
        targets = self._all_targets(schedules, merges)
        for eid in targets:
            # Supported targets: climate.* and input_number.*
            if not isinstance(eid, str) or not (eid.startswith("climate.") or eid.startswith("input_number.")):
                continue
            if not self.hass.states.get(eid):
                continue
            # Open Window Detection: skip applying schedules while a room is suspended
            try:
                owd = self._owd_mgr()
                if owd and hasattr(owd, "is_entity_suspended") and owd.is_entity_suspended(eid):
                    continue
            except Exception:
                pass
            # If we are at a boundary tick (timer), only apply for entities that have a boundary now
            if boundary_only:
                try:
                    primary = None
                    if eid in schedules:
                        primary = eid
                    else:
                        for p, lst in (merges.items() if isinstance(merges, dict) else []):
                            try:
                                if eid in (lst or []):
                                    primary = p
                                    break
                            except Exception:
                                continue
                    if primary is None:
                        primary = eid
                    row = schedules.get(primary) or {}
                    if not self._entity_has_boundary_now(row, now_min):
                        continue
                except Exception:
                    # if in doubt, skip to avoid overriding manual changes unnecessarily
                    continue
            desired = self._desired_for(eid, schedules, settings, now_min)
            if desired is None:
                continue
            # Manual override guard: if the thermostat target differs from the
            # scheduled value, do not force it back until the next block boundary.
            # Guard is only active from 5 min after block start until 5 min
            # before block end, to keep a buffer around boundaries.
            # (Boundary ticks and resume reconciles are allowed.)
            if not boundary_only and not reconcile:
                try:
                    # Check if guard window is active for this entity right now
                    guard_active = False
                    try:
                        primary = None
                        if eid in schedules:
                            primary = eid
                        else:
                            for p, lst in (merges.items() if isinstance(merges, dict) else []):
                                try:
                                    if eid in (lst or []):
                                        primary = p
                                        break
                                except Exception:
                                    continue
                        if primary is None:
                            primary = eid
                        row = schedules.get(primary) or {}
                        blocks = self._effective_blocks_today(row, settings)
                        for b in blocks:
                            try:
                                s = int(b.get("startMin", -1))
                                e = int(b.get("endMin", -1))
                                if s < 0 or e < 0:
                                    continue
                                if s <= now_min < e:
                                    if (e - s) >= 10:
                                        guard_active = (now_min >= (s + 5)) and (now_min <= (e - 5))
                                    else:
                                        guard_active = False
                                    break
                            except Exception:
                                continue
                    except Exception:
                        guard_active = False

                    if not guard_active:
                        pass
                    st = self.hass.states.get(eid)
                    cur = None
                    if st is not None:
                        if eid.startswith("input_number."):
                            try:
                                cur = float(st.state)
                            except Exception:
                                cur = None
                        else:
                            for attr in ("temperature", "target_temperature", "target_temp"):
                                v = st.attributes.get(attr)
                                if isinstance(v, (int, float)):
                                    cur = float(v)
                                    break

                    if cur is not None:
                        try:
                            u = self.hass.config.units.temperature_unit
                            ha_is_f = u == UnitOfTemperature.FAHRENHEIT or "F" in str(u).upper()
                        except Exception:
                            ha_is_f = False
                        cur_c = _f_to_c(cur) if ha_is_f else float(cur)

                        # Convert desired to °C (storage is canonical °C, but keep legacy fallback)
                        desired_c = float(desired)
                        su = str((settings or {}).get("storage_temp_unit") or "").upper().strip()
                        if su == "F":
                            desired_c = _f_to_c(desired_c)
                        elif su != "C":
                            legacy_u = str((settings or {}).get("temp_unit") or "").upper().strip()
                            if legacy_u == "F" and desired_c > 45.0:
                                desired_c = _f_to_c(desired_c)
                        if guard_active and abs(cur_c - desired_c) > 0.05:
                            continue
                except Exception:
                    pass
            # Only apply when desired setpoint changes for this entity.
            # This prevents overriding manual thermostat changes just because
            # another room hits a new block (or any global refresh runs).
            #
            # Exceptions:
            # - On resume from pause we reconcile to the desired setpoint even
            #   if it hasn't changed (reconcile=True).
            last = self._last_applied.get(eid) or {}
            try:
                lt = last.get("temp")
                last_temp = float(lt) if isinstance(lt, (int, float, str)) else None
            except Exception:
                last_temp = None
            if (not reconcile) and (last_temp is not None) and abs(last_temp - float(desired)) < 0.05:
                continue
            # If current equals desired, just update cache
            st = self.hass.states.get(eid)
            cur = None
            if eid.startswith("input_number."):
                try:
                    cur = float(st.state)
                except Exception:
                    cur = None
            else:
                for attr in ("temperature","target_temperature","target_temp"):
                    v = st.attributes.get(attr)
                    if isinstance(v, (int, float)):
                        cur = float(v)
                        break
            if cur is not None and abs(cur - float(desired)) < 0.05:
                self._last_applied[eid] = {"min": now_min, "temp": float(desired)}
                continue
            ok = await self._apply_setpoint(eid, float(desired))
            if ok:
                self._last_applied[eid] = {"min": now_min, "temp": float(desired)}
            else:
                _LOGGER.warning("Auto-apply failed for %s", eid)

    async def async_apply_entity_now(self, eid: str, reconcile: bool = False) -> None:
        """Apply the current desired setpoint for a single entity (best-effort).

        Used by other managers (e.g. Open Window Detection) to reconcile a room
        back to schedule without forcing a global re-apply.
        """
        try:
            if not isinstance(eid, str):
                return
            if not (eid.startswith("climate.") or eid.startswith("input_number.")):
                return
            if not self.hass.states.get(eid):
                return
            if not self._auto_apply_enabled():
                return
            # Respect Open Window Detection suspension
            try:
                owd = self._owd_mgr()
                if owd and hasattr(owd, "is_entity_suspended") and owd.is_entity_suspended(eid):
                    return
            except Exception:
                pass

            schedules, settings = self._get_data()
            now_min = self._now_min()
            desired = self._desired_for(eid, schedules, settings, now_min)
            if desired is None:
                return

            if not reconcile:
                last = self._last_applied.get(eid) or {}
                try:
                    lt = last.get("temp")
                    last_temp = float(lt) if isinstance(lt, (int, float, str)) else None
                except Exception:
                    last_temp = None
                if (last_temp is not None) and abs(last_temp - float(desired)) < 0.05:
                    return

            ok = await self._apply_setpoint(eid, float(desired))
            if ok:
                self._last_applied[eid] = {"min": now_min, "temp": float(desired)}
        except Exception:
            return

    def _next_boundary_dt(self):
        schedules, settings = self._get_data()
        now = dt_util.now()
        now_min = self._now_min()
        best_delta = None
        # consider all block boundaries today
        for eid, row in schedules.items():
            try:
                blocks = self._effective_blocks_today(row, settings)
                for b in blocks:
                    for t in (int(b.get("startMin", -1)), int(b.get("endMin", -1))):
                        if t < 0:
                            continue
                        delta = (t - now_min) % 1440
                        if delta == 0:
                            delta = 1
                        if best_delta is None or delta < best_delta:
                            best_delta = delta
            except Exception:
                continue
        # always include midnight rollover
        delta_mid = (1440 - now_min) % 1440
        if delta_mid == 0:
            delta_mid = 1
        if best_delta is None or delta_mid < best_delta:
            best_delta = delta_mid
        # build dt in UTC
        return dt_util.as_utc(now + timedelta(minutes=best_delta or 1))

    async def _schedule_next(self):
        # cancel previous
        if self._unsub_timer:
            self._unsub_timer()
            self._unsub_timer = None
        if not self._auto_apply_enabled():
            # If paused indefinitely, do not schedule anything
            _s, settings = self._get_data()
            if bool(settings.get("pause_indef")):
                return
            # If paused until a time, schedule wake-up then
            wake = self._paused_until_dt()
            if not wake:
                return
            boundary = None
        else:
            wake = self._paused_until_dt()
            boundary = self._next_boundary_dt()
        # choose earliest available
        if wake and boundary:
            self._next_is_resume = wake <= boundary
            when = wake if self._next_is_resume else boundary
        elif wake:
            self._next_is_resume = True
            when = wake
        else:
            self._next_is_resume = False
            when = boundary

        if not when:
            return

        # Never schedule in the past (HA will execute immediately), which can
        # otherwise turn stale timestamps into a hot loop. Timers are minute-
        # granularity here, so clamp to at least +1 minute.
        try:
            when = dt_util.as_utc(when)
        except Exception:
            pass
        try:
            now_utc = dt_util.utcnow()
            if when <= now_utc:
                when = now_utc + timedelta(minutes=1)
        except Exception:
            pass
        @callback
        def _cb(_now):
            self.hass.async_create_task(self._timer_fire())
        self._unsub_timer = async_track_point_in_utc_time(self.hass, _cb, when)

    async def _timer_fire(self):
        # If this wake was caused by pause expiry, reconcile immediately across all entities.
        resume = bool(self._next_is_resume)
        await self._maybe_apply_now(force=True, boundary_only=(not resume), reconcile=resume)
        await self._maybe_control_boiler(force=True)
        self._next_is_resume = False
        await self._schedule_next()

    def _reset_boiler_watch(self):
        if self._unsub_boiler:
            self._unsub_boiler()
            self._unsub_boiler = None
        _schedules, settings = self._get_data()
        try:
            if not bool(settings.get("boiler_enabled")):
                return
            # Backwards compat: if the new schedule-driven keys are absent,
            # keep the legacy sensor-based behavior.
            use_schedule_mode = (
                ("boiler_rooms" in settings)
                or ("boiler_on_offset" in settings)
                or ("boiler_off_offset" in settings)
                or ("boiler_multi_enabled" in settings)
                or ("boiler_room_settings" in settings)
            )

            if use_schedule_mode:
                schedules, _settings = self._get_data()
                merges = (settings.get("merges") or {}) if isinstance(settings, dict) else {}

                rooms = None
                if bool(settings.get("boiler_multi_enabled")):
                    try:
                        room_cfgs = settings.get("boiler_room_settings")
                        if isinstance(room_cfgs, dict):
                            rooms = []
                            for eid, rc in room_cfgs.items():
                                if not (isinstance(eid, str) and eid.startswith("climate.")):
                                    continue
                                if not (isinstance(rc, dict) and bool(rc.get("enabled"))):
                                    continue
                                sw = str(rc.get("switch") or "").strip()
                                if not sw:
                                    continue
                                if "." not in sw:
                                    dom = str(rc.get("switch_domain") or "").strip().lower()
                                    if dom in ("switch", "input_boolean"):
                                        sw = f"{dom}.{sw}"
                                if "." not in sw:
                                    continue
                                rooms.append(eid)
                            if not rooms:
                                return
                    except Exception:
                        rooms = None

                if rooms is None:
                    br = settings.get("boiler_rooms")
                    if isinstance(br, list):
                        rooms = [str(x).strip() for x in br if str(x).strip()]
                        if len(rooms) == 0:
                            return
                if rooms is None:
                    rooms = sorted(list(self._all_targets(schedules if isinstance(schedules, dict) else {}, merges)))

                watch = []
                for eid in rooms:
                    if isinstance(eid, str) and eid.startswith("climate."):
                        watch.append(eid)
                if not watch:
                    return

                @callback
                def _ch(_event):
                    self.hass.async_create_task(self._maybe_control_boiler(force=True))

                self._unsub_boiler = async_track_state_change_event(self.hass, watch, _ch)
                return

            # Legacy mode: react when boiler sensor updates
            sens = settings.get("boiler_temp_sensor")
            if not isinstance(sens, str) or not sens:
                return

            @callback
            def _ch(_event):
                # React quickly when boiler sensor updates
                self.hass.async_create_task(self._maybe_control_boiler(force=True))

            self._unsub_boiler = async_track_state_change_event(self.hass, [sens], _ch)
        except Exception:
            self._unsub_boiler = None

    def _reset_boiler_interval(self) -> None:
        if self._unsub_boiler_interval:
            try:
                self._unsub_boiler_interval()
            except Exception:
                pass
            self._unsub_boiler_interval = None

        _schedules, settings = self._get_data()
        try:
            if not bool(settings.get("boiler_enabled")):
                return

            @callback
            def _tick(_now):
                # Periodic sanity check (requested): independent of UI being open.
                self.hass.async_create_task(self._maybe_control_boiler(force=True))

            self._unsub_boiler_interval = async_track_time_interval(self.hass, _tick, timedelta(minutes=10))
        except Exception:
            self._unsub_boiler_interval = None

    def _reset_pause_sensor_watch(self) -> None:
        if self._unsub_pause_sensor:
            try:
                self._unsub_pause_sensor()
            except Exception:
                pass
            self._unsub_pause_sensor = None
        _schedules, settings = self._get_data()
        try:
            if not bool(settings.get("pause_sensor_enabled")):
                self._last_pause_sensor_on = None
                return
            eid = str(settings.get("pause_sensor_entity") or "").strip()
            if not eid or not eid.startswith("binary_sensor."):
                self._last_pause_sensor_on = None
                return

            # Prime last state
            self._last_pause_sensor_on = self._pause_sensor_on(settings)

            @callback
            def _ch(event):
                try:
                    ns = event.data.get("new_state")
                    s = str(ns.state if ns else "").lower().strip()
                    now_on = s == "on"
                except Exception:
                    return
                if self._last_pause_sensor_on is not None and now_on == self._last_pause_sensor_on:
                    return
                self._last_pause_sensor_on = now_on

                async def _handle() -> None:
                    # When pausing: just reschedule (cancels timers).
                    # When resuming: reconcile once and schedule.
                    if now_on:
                        await self._schedule_next()
                        return
                    await self._maybe_apply_now(force=True, reconcile=True)
                    await self._maybe_control_boiler(force=True)
                    await self._schedule_next()

                self.hass.async_create_task(_handle())

            self._unsub_pause_sensor = async_track_state_change_event(self.hass, [eid], _ch)
        except Exception:
            self._unsub_pause_sensor = None
            self._last_pause_sensor_on = None

    @staticmethod
    def _f_to_c(v: float) -> float:
        return (float(v) - 32.0) * 5.0 / 9.0

    async def _maybe_control_boiler(self, force: bool = False) -> None:
        """Control boiler switch (switch.* or input_boolean.*).

        New (schedule-driven) mode:
        - ON if any included room current temp is below its desired schedule temp minus boiler_on_offset.
        - OFF when all included rooms are at/above desired schedule temp plus boiler_off_offset.

        Offsets are stored as °C deltas (independent of HA display unit).

        Backwards compat:
        - If schedule-driven keys are not present, fall back to legacy boiler sensor min/max logic.
        """
        try:
            if not self._auto_apply_enabled():
                return
            _schedules, settings = self._get_data()
            if not bool(settings.get("boiler_enabled")):
                return

            use_schedule_mode = (
                ("boiler_rooms" in settings)
                or ("boiler_on_offset" in settings)
                or ("boiler_off_offset" in settings)
                or ("boiler_multi_enabled" in settings)
                or ("boiler_room_settings" in settings)
            )

            if use_schedule_mode:
                schedules, settings = self._get_data()
                if not isinstance(schedules, dict):
                    return

                # HA display unit for climate attributes
                def _ha_is_f() -> bool:
                    try:
                        u = self.hass.config.units.temperature_unit
                        return u == UnitOfTemperature.FAHRENHEIT or "F" in str(u).upper()
                    except Exception:
                        return False

                ha_is_f = _ha_is_f()

                def _to_c_from_ha(val_ha: float) -> float:
                    return _f_to_c(val_ha) if ha_is_f else float(val_ha)

                try:
                    on_offset_c = float(settings.get("boiler_on_offset", 0.0) or 0.0)
                except Exception:
                    on_offset_c = 0.0
                try:
                    off_offset_c = float(settings.get("boiler_off_offset", 0.0) or 0.0)
                except Exception:
                    off_offset_c = 0.0

                # Multi-boiler mode
                if bool(settings.get("boiler_multi_enabled")):
                    room_cfgs = settings.get("boiler_room_settings")
                    if not isinstance(room_cfgs, dict):
                        return

                    def _clamp_offset(v):
                        try:
                            n = float(v)
                            return max(-10.0, min(10.0, n))
                        except Exception:
                            return None

                    groups = {}
                    for eid, rc in room_cfgs.items():
                        try:
                            if not (isinstance(eid, str) and eid.startswith("climate.")):
                                continue
                            if not (isinstance(rc, dict) and bool(rc.get("enabled"))):
                                continue
                            sw = str(rc.get("switch") or "").strip()
                            if not sw:
                                continue
                            if "." not in sw:
                                dom = str(rc.get("switch_domain") or "").strip().lower()
                                if dom in ("switch", "input_boolean"):
                                    sw = f"{dom}.{sw}"
                            if "." not in sw:
                                continue
                            sw_domain = sw.split(".", 1)[0]
                            if sw_domain not in ("switch", "input_boolean"):
                                continue
                            on_off = _clamp_offset(rc.get("on_offset"))
                            off_off = _clamp_offset(rc.get("off_offset"))
                            entry = {
                                "eid": eid,
                                "on_off": on_off if on_off is not None else on_offset_c,
                                "off_off": off_off if off_off is not None else off_offset_c,
                            }
                            groups.setdefault(sw, []).append(entry)
                        except Exception:
                            continue

                    if not groups:
                        return

                    now_min = self._now_min()
                    for sw, rooms in groups.items():
                        try:
                            if not rooms:
                                continue
                            st_sw = self.hass.states.get(sw)
                            cur = str(st_sw.state).lower().strip() if st_sw else ""

                            any_below = False
                            all_above = True
                            any_seen = False
                            any_unknown = False

                            for r in rooms:
                                try:
                                    desired_c = self._desired_for(r["eid"], schedules, settings, now_min)
                                    if desired_c is None:
                                        any_unknown = True
                                        continue
                                    st = self.hass.states.get(r["eid"])
                                    if not st:
                                        any_unknown = True
                                        continue
                                    attrs = st.attributes or {}
                                    cur_ha = attrs.get("current_temperature")
                                    if cur_ha is None:
                                        cur_ha = attrs.get("temperature")
                                    if cur_ha is None:
                                        any_unknown = True
                                        continue
                                    cur_c = _to_c_from_ha(float(cur_ha))
                                    any_seen = True
                                    if cur_c < (float(desired_c) - float(r.get("on_off", on_offset_c))):
                                        any_below = True
                                        all_above = False
                                        break
                                    if cur_c < (float(desired_c) + float(r.get("off_off", off_offset_c))):
                                        all_above = False
                                except Exception:
                                    any_unknown = True
                                    continue

                            if not any_seen:
                                continue

                            want = None
                            if any_below:
                                want = "on"
                            else:
                                if any_unknown:
                                    continue
                                if all_above:
                                    want = "off"
                                else:
                                    continue

                            if want == "on" and cur == "on":
                                continue
                            if want == "off" and cur == "off":
                                continue

                            sw_domain = sw.split(".", 1)[0]
                            if sw_domain not in ("switch", "input_boolean"):
                                continue
                            await self.hass.services.async_call(
                                sw_domain,
                                "turn_on" if want == "on" else "turn_off",
                                {"entity_id": sw},
                                blocking=False,
                            )
                        except Exception:
                            continue
                    return

                # Single-boiler mode
                sw = settings.get("boiler_switch")
                if not isinstance(sw, str) or "." not in sw:
                    return
                sw_domain = sw.split(".", 1)[0]
                if sw_domain not in ("switch", "input_boolean"):
                    return

                # Determine current boiler state up-front (used by single mode)
                st_sw = self.hass.states.get(sw)
                cur = str(st_sw.state).lower().strip() if st_sw else ""

                merges = settings.get("merges") or {}
                rooms = None
                br = settings.get("boiler_rooms")
                if isinstance(br, list):
                    rooms = [str(x).strip() for x in br if str(x).strip()]
                    if len(rooms) == 0:
                        return
                if rooms is None:
                    rooms = sorted(list(self._all_targets(schedules, merges if isinstance(merges, dict) else {})))

                now_min = self._now_min()
                any_below = False
                all_above = True
                compared = 0
                missing = 0

                for eid in rooms:
                    try:
                        if not (isinstance(eid, str) and eid.startswith("climate.")):
                            missing += 1
                            continue

                        desired_c = self._desired_for(eid, schedules, settings, now_min)
                        if desired_c is None:
                            missing += 1
                            continue

                        st = self.hass.states.get(eid)
                        if not st:
                            missing += 1
                            continue

                        attrs = st.attributes or {}
                        cur_ha = attrs.get("current_temperature")
                        if cur_ha is None:
                            # some devices only expose "temperature"
                            cur_ha = attrs.get("temperature")
                        if cur_ha is None:
                            missing += 1
                            continue
                        cur_c = _to_c_from_ha(float(cur_ha))

                        compared += 1
                        if cur_c < (float(desired_c) - on_offset_c):
                            any_below = True
                            all_above = False
                        elif cur_c < (float(desired_c) + off_offset_c):
                            all_above = False
                    except Exception:
                        missing += 1
                        continue

                if compared <= 0:
                    return

                want = None
                if any_below:
                    want = "on"
                else:
                    # Conservative OFF: require complete data for all included rooms.
                    if missing > 0:
                        return
                    if all_above:
                        want = "off"
                    else:
                        return

                if want == "on" and cur == "on":
                    return
                if want == "off" and cur == "off":
                    return

                await self.hass.services.async_call(
                    sw_domain,
                    "turn_on" if want == "on" else "turn_off",
                    {"entity_id": sw},
                    blocking=False,
                )
                return

            # ---- Legacy mode (sensor min/max hysteresis) ----
            sens = settings.get("boiler_temp_sensor")
            if not isinstance(sens, str) or "." not in sens:
                return

            st_sens = self.hass.states.get(sens)
            if not st_sens:
                return
            try:
                raw = float(st_sens.state)
            except Exception:
                return

            unit = str(settings.get("temp_unit") or "C").upper()
            bmin_raw = settings.get("boiler_min_temp")
            bmax_raw = settings.get("boiler_max_temp")

            if bmin_raw is None:
                min_c = 20.0
            else:
                min_v = float(bmin_raw)
                min_c = self._f_to_c(min_v) if unit == "F" else min_v

            if bmax_raw is None:
                max_c = 25.0
            else:
                max_v = float(bmax_raw)
                max_c = self._f_to_c(max_v) if unit == "F" else max_v

            lo = min(min_c, max_c)
            hi = max(min_c, max_c)

            val_c = raw
            try:
                u = st_sens.attributes.get("unit_of_measurement")
                if isinstance(u, str) and ("°F" in u or u.strip().upper() == "F"):
                    val_c = self._f_to_c(raw)
            except Exception:
                pass

            want = None
            if val_c < lo:
                want = "on"
            elif val_c > hi:
                want = "off"
            else:
                return

            if want == "on" and cur == "on":
                return
            if want == "off" and cur == "off":
                return

            await self.hass.services.async_call(
                sw_domain,
                "turn_on" if want == "on" else "turn_off",
                {"entity_id": sw},
                blocking=False,
            )
        except Exception:
            # Boiler control must never interfere with normal schedule apply
            return

    def _reset_person_watch(self):
        # Recreate person watcher based on settings.away.persons (both simple away and advanced presence)
        if self._unsub_persons:
            self._unsub_persons()
            self._unsub_persons = None
        _schedules, settings = self._get_data()
        away = settings.get("away") or {}
        persons = away.get("persons") if isinstance(away, dict) else None
        if (away.get("enabled") or away.get("advanced_enabled")) and isinstance(persons, list) and persons:
            @callback
            def _ch(event):
                # Apply immediately when presence changes.
                # Use reconcile to bypass manual-override guard so away/presence
                # transitions take effect even mid-block.
                self.hass.async_create_task(self._maybe_apply_now(force=True, reconcile=True))
                self.hass.async_create_task(self._schedule_next())
            self._unsub_persons = async_track_state_change_event(self.hass, persons, _ch)

    def _entity_has_boundary_now(self, row: dict, now_min: int) -> bool:
        """Return True if the entity has a block boundary at (or just before) now.

        The timer intentionally schedules a few seconds/minutes past the exact
        boundary to avoid immediate execution collisions. That means the check
        here must tolerate a small offset, otherwise we miss the boundary and
        never apply. We therefore consider the current minute and the previous
        minute as being "at the boundary".
        """
        try:
            _s, settings = self._get_data()
            blocks = self._effective_blocks_today(row, settings)
            # allow a 1‑minute grace window (handle wraparound at midnight)
            prev_min = (now_min - 1) % 1440
            for b in blocks:
                try:
                    s = int(b.get("startMin", -1))
                    e = int(b.get("endMin", -1))
                    if s in (now_min, prev_min) or e in (now_min, prev_min):
                        return True
                except Exception:
                    continue
        except Exception:
            pass
        return False

    async def _apply_setpoint(self, eid: str, desired: float) -> bool:
        """Apply desired setpoint robustly across HVAC modes.

        Strategy:
        - If device exposes range (target_temp_low/high) and mode is heat_cool/auto, set a band around desired.
        - If preset supports a manual/hold, switch to it first.
        - If current mode is off/dry/fan_only, try switching to heat, else cool.
        - Fallback to single temperature set.
        """
        try:
            # Store contract: temperatures are canonical °C.
            # Backwards compat: older clients may have stored °F when temp_unit == 'F'.
            try:
                schedules, settings = self._get_data()
            except Exception:
                settings = {}
                schedules = {}

            # Optional: for some thermostats we need to send a climate.turn_on command as well.
            # This is configured per primary room entity (merged entities inherit the primary room setting).
            do_turn_on = False
            turn_on_order = "before"  # 'before' | 'after'
            turn_on_delay_s = 1.0
            if isinstance(eid, str) and eid.startswith("climate."):
                try:
                    merges = settings.get("merges") or {}
                    primary = None
                    if isinstance(schedules, dict) and eid in schedules:
                        primary = eid
                    else:
                        for p, lst in (merges.items() if isinstance(merges, dict) else []):
                            try:
                                if eid in (lst or []):
                                    primary = p
                                    break
                            except Exception:
                                continue
                    if primary is None:
                        primary = eid

                    cfg_map = settings.get("turn_on") or {}
                    cfg = cfg_map.get(primary) if isinstance(cfg_map, dict) else None
                    if isinstance(cfg, dict):
                        do_turn_on = bool(cfg.get("enabled"))
                        o = str(cfg.get("order") or "before").lower().strip()
                        turn_on_order = "after" if o == "after" else "before"
                    # Allow optional global delay override (seconds)
                    try:
                        v = float(settings.get("turn_on_delay_s", turn_on_delay_s))
                        # Keep it short and safe
                        turn_on_delay_s = max(0.0, min(5.0, v))
                    except Exception:
                        turn_on_delay_s = 1.0
                except Exception:
                    do_turn_on = False

            async def _turn_on_and_delay() -> None:
                if not (do_turn_on and isinstance(eid, str) and eid.startswith("climate.")):
                    return
                try:
                    await self.hass.services.async_call("climate", "turn_on", {"entity_id": eid}, blocking=False)
                except Exception:
                    return
                try:
                    if turn_on_delay_s:
                        await asyncio.sleep(turn_on_delay_s)
                except Exception:
                    return

            def _ha_is_f() -> bool:
                try:
                    u = self.hass.config.units.temperature_unit
                    return u == UnitOfTemperature.FAHRENHEIT or "F" in str(u).upper()
                except Exception:
                    return False

            ha_is_f = _ha_is_f()

            def _to_ha_from_c(val_c: float) -> float:
                return _c_to_f(val_c) if ha_is_f else float(val_c)

            def _to_c_from_ha(val_ha: float) -> float:
                return _f_to_c(val_ha) if ha_is_f else float(val_ha)

            def _store_to_c(val: float) -> float:
                v = float(val)
                su = str((settings or {}).get("storage_temp_unit") or "").upper().strip()
                if su == "F":
                    return _f_to_c(v)
                if su == "C":
                    return v
                # Legacy heuristic: only treat as °F when display unit is F *and* values look like °F.
                legacy_u = str((settings or {}).get("temp_unit") or "").upper().strip()
                if legacy_u == "F" and v > 45.0:
                    return _f_to_c(v)
                return v

            desired_c = _store_to_c(desired)

            st = self.hass.states.get(eid)
            if not st:
                return False
            # input_number support: set value directly (no HVAC modes/presets)
            if isinstance(eid, str) and eid.startswith("input_number."):
                attrs = st.attributes or {}
                min_v = attrs.get("min")
                max_v = attrs.get("max")

                # input_number min/max are in HA's unit system
                val = float(_to_ha_from_c(desired_c))
                try:
                    if isinstance(min_v, (int, float)):
                        val = max(val, float(min_v))
                    if isinstance(max_v, (int, float)):
                        val = min(val, float(max_v))
                except Exception:
                    pass
                await self.hass.services.async_call(
                    "input_number", "set_value", {"entity_id": eid, "value": float(val)}, blocking=False
                )
                return True
            attrs = st.attributes or {}
            hvac_mode = str(attrs.get("hvac_mode", st.state)).lower()
            hvac_modes = [str(x).lower() for x in (attrs.get("hvac_modes") or [])]
            preset_mode = str(attrs.get("preset_mode", "") or "").lower()
            preset_modes = [str(x).lower() for x in (attrs.get("preset_modes") or [])]
            min_t = attrs.get("min_temp")
            max_t = attrs.get("max_temp")

            min_c = None
            max_c = None
            try:
                if isinstance(min_t, (int, float)):
                    min_c = _to_c_from_ha(float(min_t))
                if isinstance(max_t, (int, float)):
                    max_c = _to_c_from_ha(float(max_t))
            except Exception:
                min_c = None
                max_c = None

            def _clamp_c(val_c: float) -> float:
                try:
                    if isinstance(min_c, (int, float)):
                        val_c = max(val_c, float(min_c))
                    if isinstance(max_c, (int, float)):
                        val_c = min(val_c, float(max_c))
                except Exception:
                    pass
                return val_c

            # If preset has a manual/hold and not active, switch to it
            try:
                want_preset = None
                for cand in ("manual", "hold"):
                    if cand in preset_modes:
                        want_preset = cand
                        break
                if want_preset and preset_mode != want_preset:
                    await self.hass.services.async_call(
                        "climate", "set_preset_mode", {"entity_id": eid, "preset_mode": want_preset}, blocking=False
                    )
            except Exception:
                pass

            # If in a mode that ignores temperature, try switching to a workable one
            try:
                if hvac_mode in ("off", "dry", "fan_only"):
                    new_mode = None
                    if "heat" in hvac_modes:
                        new_mode = "heat"
                    elif "cool" in hvac_modes:
                        new_mode = "cool"
                    if new_mode:
                        await self.hass.services.async_call(
                            "climate", "set_hvac_mode", {"entity_id": eid, "hvac_mode": new_mode}, blocking=False
                        )
                        hvac_mode = new_mode
            except Exception:
                pass

            # Decide whether to use range or single setpoint
            has_low = isinstance(attrs.get("target_temp_low"), (int, float)) or ("target_temp_low" in attrs)
            has_high = isinstance(attrs.get("target_temp_high"), (int, float)) or ("target_temp_high" in attrs)
            use_range = (hvac_mode in ("heat_cool", "auto")) and has_low and has_high

            if use_range:
                # Build a narrow band around desired
                band = 1.0
                try:
                    # Allow global override via settings.range_band_c
                    _s, settings = self._get_data()
                    band = float(settings.get("range_band_c", 1.0))
                except Exception:
                    pass
                half = max(0.2, band / 2.0)
                low_c = _clamp_c(desired_c - half)
                high_c = _clamp_c(desired_c + half)
                if high_c <= low_c:
                    # Ensure valid ordering
                    high_c = min(_clamp_c(low_c + 0.5), _clamp_c(desired_c + 0.5))
                data = {
                    "entity_id": eid,
                    "target_temp_low": float(_to_ha_from_c(low_c)),
                    "target_temp_high": float(_to_ha_from_c(high_c)),
                }
                if turn_on_order == "before":
                    await _turn_on_and_delay()
                await self.hass.services.async_call("climate", "set_temperature", data, blocking=False)
                if turn_on_order == "after":
                    # Delay should be between set_temp and turn_on
                    try:
                        if turn_on_delay_s:
                            await asyncio.sleep(turn_on_delay_s)
                    except Exception:
                        pass
                    try:
                        if do_turn_on:
                            await self.hass.services.async_call("climate", "turn_on", {"entity_id": eid}, blocking=False)
                    except Exception:
                        pass
                return True

            # Fallback: single temperature
            data = {"entity_id": eid, "temperature": float(_to_ha_from_c(_clamp_c(desired_c)))}
            if turn_on_order == "before":
                await _turn_on_and_delay()
            await self.hass.services.async_call("climate", "set_temperature", data, blocking=False)
            if turn_on_order == "after":
                try:
                    if turn_on_delay_s:
                        await asyncio.sleep(turn_on_delay_s)
                except Exception:
                    pass
                try:
                    if do_turn_on:
                        await self.hass.services.async_call("climate", "turn_on", {"entity_id": eid}, blocking=False)
                except Exception:
                    pass
            return True
        except Exception:
            return False


class MultiInstanceAutoApplyManager:
    """Run auto-apply/boiler control for all known instance_id namespaces.

    This makes the card's "Configuration ID" feature work fully in the background:
    each instance gets its own timers/watchers and runs independently.
    """

    def __init__(self, hass: HomeAssistant):
        self.hass = hass
        self._managers: dict[str, AutoApplyManager] = {}
        self._unsub = None

    def get(self, instance_id: str) -> AutoApplyManager | None:
        try:
            return self._managers.get(_norm_instance_id(instance_id))
        except Exception:
            return None

    async def async_start(self) -> None:
        await self._sync()

        @callback
        def _on_update():
            # New instances can appear via set_store; ensure they get background managers.
            self.hass.async_create_task(self._sync())

        async_dispatcher_connect(self.hass, SIGNAL_UPDATED, _on_update)

    async def _sync(self) -> None:
        try:
            _ensure_instances_loaded(self.hass)
            d = self.hass.data.get(DOMAIN, {})
            instances = d.get("instances") if isinstance(d.get("instances"), dict) else {}
            for iid in (instances.keys() if isinstance(instances, dict) else []):
                try:
                    iidn = _norm_instance_id(iid)
                    if iidn in self._managers:
                        continue
                    mgr = AutoApplyManager(self.hass, instance_id=iidn)
                    self._managers[iidn] = mgr
                    await mgr.async_start()
                except Exception:
                    continue
            # Expose for other components
            d["instance_managers"] = self._managers
        except Exception:
            return


class MultiInstanceOpenWindowManager:
    """Run Open Window Detection independently per instance."""

    def __init__(self, hass: HomeAssistant, apply_multi: MultiInstanceAutoApplyManager):
        self.hass = hass
        self._apply_multi = apply_multi
        self._managers: dict[str, OpenWindowManager] = {}
        self._unsub = None

    def get(self, instance_id: str) -> 'OpenWindowManager' | None:
        try:
            return self._managers.get(_norm_instance_id(instance_id))
        except Exception:
            return None

    async def async_start(self) -> None:
        await self._sync()

        @callback
        def _on_update():
            self.hass.async_create_task(self._sync())

        async_dispatcher_connect(self.hass, SIGNAL_UPDATED, _on_update)

    async def _sync(self) -> None:
        try:
            _ensure_instances_loaded(self.hass)
            d = self.hass.data.get(DOMAIN, {})
            instances = d.get("instances") if isinstance(d.get("instances"), dict) else {}
            for iid in (instances.keys() if isinstance(instances, dict) else []):
                try:
                    iidn = _norm_instance_id(iid)
                    if iidn in self._managers:
                        continue
                    mgr = self._apply_multi.get(iidn)
                    if not mgr:
                        continue
                    owd = OpenWindowManager(self.hass, apply_mgr=mgr, instance_id=iidn)
                    self._managers[iidn] = owd
                    await owd.async_start()
                except Exception:
                    continue
            d["open_window_managers"] = self._managers
        except Exception:
            return


class OpenWindowManager:
    """Background Open Window Detection.

    Uses settings.open_window from the shared store.
    """

    def __init__(self, hass: HomeAssistant, apply_mgr: AutoApplyManager, instance_id: str = "default"):
        self.hass = hass
        self._mgr = apply_mgr
        self._instance_id = _norm_instance_id(instance_id)
        self._unsub = None
        self._room_sensors: dict[str, list[str]] = {}
        self._open_delay_s: int = 0
        self._close_delay_s: int = 0
        self._room_tasks: dict[tuple[str, str], asyncio.Task] = {}  # (room, kind)
        self._suspended_entities: set[str] = set()

    def is_entity_suspended(self, eid: str) -> bool:
        try:
            return isinstance(eid, str) and eid in self._suspended_entities
        except Exception:
            return False

    def _settings(self) -> dict:
        try:
            d = self.hass.data.get(DOMAIN, {}) or {}
            insts = d.get("instances") if isinstance(d.get("instances"), dict) else None
            if isinstance(insts, dict):
                inst = insts.get(self._instance_id)
                if isinstance(inst, dict):
                    s = inst.get("settings")
                    if isinstance(s, dict):
                        return s
        except Exception:
            pass
        return (self.hass.data.get(DOMAIN, {}) or {}).get("settings", {}) or {}

    def _enabled(self, settings: dict | None = None) -> bool:
        try:
            settings = settings if isinstance(settings, dict) else self._settings()
            ow = settings.get("open_window") or {}
            if not (isinstance(ow, dict) and bool(ow.get("enabled"))):
                return False
            # Only run when background control is active
            if not bool(settings.get("auto_apply_enabled")):
                return False
            # Respect pause via binary sensor
            try:
                if bool(settings.get("pause_sensor_enabled")):
                    eid = str(settings.get("pause_sensor_entity") or "").strip()
                    if eid and eid.startswith("binary_sensor."):
                        st = self.hass.states.get(eid)
                        if str(st.state if st else "").lower().strip() == "on":
                            return False
            except Exception:
                pass
            # Respect pause gates
            if bool(settings.get("pause_indef")):
                return False
            pu = settings.get("pause_until_ms")
            if isinstance(pu, (int, float)):
                now_ms = dt_util.utcnow().timestamp() * 1000.0
                if float(pu) > now_ms:
                    return False
            return True
        except Exception:
            return False

    @staticmethod
    def _clamp_int(v, default: int, lo: int, hi: int) -> int:
        try:
            n = int(float(v))
        except Exception:
            n = int(default)
        return max(lo, min(hi, n))

    @staticmethod
    def _is_open_state(state: str) -> bool:
        s = str(state or "").strip().lower()
        return s in ("on", "open", "true", "1")

    def _sensor_open(self, eid: str) -> bool:
        try:
            st = self.hass.states.get(eid)
            return self._is_open_state(st.state if st else "")
        except Exception:
            return False

    def _entities_for_room(self, room_eid: str, settings: dict) -> list[str]:
        out = [room_eid]
        try:
            merges = settings.get("merges") or {}
            linked = merges.get(room_eid) or []
            if isinstance(linked, list):
                out.extend([x for x in linked if isinstance(x, str) and x])
        except Exception:
            pass
        seen = set()
        uniq = []
        for e in out:
            if e in seen:
                continue
            seen.add(e)
            uniq.append(e)
        return uniq

    def _cancel_task(self, room: str, kind: str) -> None:
        t = self._room_tasks.pop((room, kind), None)
        if t:
            try:
                t.cancel()
            except Exception:
                pass

    async def async_start(self) -> None:
        @callback
        def _on_update():
            self.hass.async_create_task(self._rebuild())
        async_dispatcher_connect(self.hass, SIGNAL_UPDATED, _on_update)
        await self._rebuild()

    async def _rebuild(self) -> None:
        # Unsubscribe old watcher
        try:
            if self._unsub:
                self._unsub()
                self._unsub = None
        except Exception:
            self._unsub = None

        # Cancel pending tasks
        for k, t in list(self._room_tasks.items()):
            try:
                t.cancel()
            except Exception:
                pass
        self._room_tasks.clear()

        settings = self._settings()
        ow = settings.get("open_window") or {}
        self._open_delay_s = self._clamp_int(ow.get("open_delay_min"), 2, 0, 1440) * 60
        self._close_delay_s = self._clamp_int(ow.get("close_delay_min"), 5, 0, 1440) * 60

        sensors_map = ow.get("sensors") if isinstance(ow, dict) else None
        room_sensors: dict[str, list[str]] = {}
        flat: list[str] = []
        if isinstance(sensors_map, dict):
            for room, arr in sensors_map.items():
                if not isinstance(room, str) or not room:
                    continue
                if not isinstance(arr, list) or not arr:
                    continue
                clean = [x for x in arr if isinstance(x, str) and x.startswith("binary_sensor.")]
                if not clean:
                    continue
                room_sensors[room] = clean
                flat.extend(clean)

        self._room_sensors = room_sensors

        # If disabled, resume anything we previously turned off
        if not self._enabled(settings):
            await self._resume_all(settings)
            return

        flat = sorted(set(flat))
        if flat:
            @callback
            def _ch(_event):
                self.hass.async_create_task(self._evaluate_all())
            self._unsub = async_track_state_change_event(self.hass, flat, _ch)

        await self._evaluate_all()

    async def _evaluate_all(self) -> None:
        try:
            settings = self._settings()
            if not self._enabled(settings):
                await self._resume_all(settings)
                return
            for room in list(self._room_sensors.keys()):
                await self._evaluate_room(room, settings)
        except Exception:
            return

    async def _evaluate_room(self, room: str, settings: dict) -> None:
        sensors = self._room_sensors.get(room) or []
        if not sensors:
            await self._resume_room(room, settings)
            return

        open_now = any(self._sensor_open(s) for s in sensors)
        is_susp = any(e in self._suspended_entities for e in self._entities_for_room(room, settings))

        if open_now:
            self._cancel_task(room, "resume")
            if not is_susp and (room, "suspend") not in self._room_tasks:
                self._room_tasks[(room, "suspend")] = self.hass.async_create_task(self._delayed_suspend(room))
        else:
            self._cancel_task(room, "suspend")
            if is_susp and (room, "resume") not in self._room_tasks:
                self._room_tasks[(room, "resume")] = self.hass.async_create_task(self._delayed_resume(room))

    async def _delayed_suspend(self, room: str) -> None:
        try:
            if self._open_delay_s:
                await asyncio.sleep(self._open_delay_s)
            settings = self._settings()
            if not self._enabled(settings):
                return
            sensors = self._room_sensors.get(room) or []
            if sensors and any(self._sensor_open(s) for s in sensors):
                await self._suspend_room(room, settings)
        except asyncio.CancelledError:
            return
        except Exception:
            return

    async def _delayed_resume(self, room: str) -> None:
        try:
            if self._close_delay_s:
                await asyncio.sleep(self._close_delay_s)
            settings = self._settings()
            if not self._enabled(settings):
                return
            sensors = self._room_sensors.get(room) or []
            if sensors and any(self._sensor_open(s) for s in sensors):
                return
            await self._resume_room(room, settings)
        except asyncio.CancelledError:
            return
        except Exception:
            return

    async def _suspend_room(self, room: str, settings: dict) -> None:
        try:
            entities = self._entities_for_room(room, settings)
            for eid in entities:
                self._suspended_entities.add(eid)
            for eid in entities:
                if isinstance(eid, str) and eid.startswith("climate."):
                    await self.hass.services.async_call("climate", "turn_off", {"entity_id": eid}, blocking=False)
        except Exception:
            return

    async def _resume_room(self, room: str, settings: dict) -> None:
        try:
            entities = self._entities_for_room(room, settings)
            for eid in entities:
                try:
                    self._suspended_entities.discard(eid)
                except Exception:
                    pass
            for eid in entities:
                if isinstance(eid, str) and eid.startswith("climate."):
                    await self.hass.services.async_call("climate", "turn_on", {"entity_id": eid}, blocking=False)
                try:
                    await self._mgr.async_apply_entity_now(eid, reconcile=True)
                except Exception:
                    pass
        except Exception:
            return

    async def _resume_all(self, settings: dict) -> None:
        try:
            if not self._suspended_entities:
                return
            to_resume = list(self._suspended_entities)
            self._suspended_entities.clear()
            for eid in to_resume:
                if isinstance(eid, str) and eid.startswith("climate."):
                    await self.hass.services.async_call("climate", "turn_on", {"entity_id": eid}, blocking=False)
                try:
                    await self._mgr.async_apply_entity_now(eid, reconcile=True)
                except Exception:
                    pass
        except Exception:
            return


class MultiInstancePresenceSensorManager:
    """Runs PresenceSensorManager per instance for per-room presence entity delays.

    This mirrors the card's frontend presence feature in the background so
    overrides apply even when the card is closed.
    """

    def __init__(self, hass: HomeAssistant, apply_multi: MultiInstanceAutoApplyManager):
        self.hass = hass
        self._apply_multi = apply_multi
        self._managers: dict[str, PresenceSensorManager] = {}

    def get(self, instance_id: str) -> 'PresenceSensorManager' | None:
        try:
            return self._managers.get(_norm_instance_id(instance_id))
        except Exception:
            return None

    async def async_start(self) -> None:
        await self._sync()

        @callback
        def _on_update():
            self.hass.async_create_task(self._sync())
        async_dispatcher_connect(self.hass, SIGNAL_UPDATED, _on_update)

    async def _sync(self) -> None:
        try:
            _ensure_instances_loaded(self.hass)
            d = self.hass.data.get(DOMAIN, {})
            instances = d.get("instances") if isinstance(d.get("instances"), dict) else {}
            for iid in (instances.keys() if isinstance(instances, dict) else []):
                try:
                    iidn = _norm_instance_id(iid)
                    if iidn in self._managers:
                        continue
                    mgr = self._apply_multi.get(iidn)
                    if not mgr:
                        continue
                    psm = PresenceSensorManager(self.hass, apply_mgr=mgr, instance_id=iidn)
                    self._managers[iidn] = psm
                    await psm.async_start()
                except Exception:
                    continue
            d["presence_sensor_managers"] = self._managers
        except Exception:
            return


class PresenceSensorManager:
    """Background handler for simple per-room presence sensors with ON/OFF delays."""

    def __init__(self, hass: HomeAssistant, apply_mgr: AutoApplyManager, instance_id: str = "default"):
        self.hass = hass
        self._mgr = apply_mgr
        self._instance_id = _norm_instance_id(instance_id)
        self._unsub = None
        self._room_sensor: dict[str, str] = {}
        self._delays_s: dict[str, tuple[int, int]] = {}  # room -> (on_s, off_s)
        self._tasks: dict[tuple[str, str], asyncio.Task] = {}  # (room, kind)

    def _settings(self) -> dict:
        try:
            d = self.hass.data.get(DOMAIN, {}) or {}
            insts = d.get("instances") if isinstance(d.get("instances"), dict) else None
            if isinstance(insts, dict):
                inst = insts.get(self._instance_id)
                if isinstance(inst, dict):
                    s = inst.get("settings")
                    if isinstance(s, dict):
                        return s
        except Exception:
            pass
        return (self.hass.data.get(DOMAIN, {}) or {}).get("settings", {}) or {}

    def _enabled(self, settings: dict | None = None) -> bool:
        try:
            settings = settings if isinstance(settings, dict) else self._settings()
            if not bool(settings.get("presence_sensor_enabled")):
                return False
            if not bool(settings.get("auto_apply_enabled")):
                return False
            # Respect pause via sensor and pause gates
            try:
                if bool(settings.get("pause_sensor_enabled")):
                    eid = str(settings.get("pause_sensor_entity") or "").strip()
                    if eid and eid.startswith("binary_sensor."):
                        st = self.hass.states.get(eid)
                        if str(st.state if st else "").lower().strip() == "on":
                            return False
            except Exception:
                pass
            if bool(settings.get("pause_indef")):
                return False
            pu = settings.get("pause_until_ms")
            if isinstance(pu, (int, float)):
                now_ms = dt_util.utcnow().timestamp() * 1000.0
                if float(pu) > now_ms:
                    return False
            return True
        except Exception:
            return False

    async def async_start(self) -> None:
        @callback
        def _on_update():
            self.hass.async_create_task(self._rebuild())
        async_dispatcher_connect(self.hass, SIGNAL_UPDATED, _on_update)
        await self._rebuild()

    def _cancel_task(self, room: str, kind: str) -> None:
        t = self._tasks.pop((room, kind), None)
        if t:
            try:
                t.cancel()
            except Exception:
                pass

    async def _rebuild(self) -> None:
        # Unsubscribe previous watchers and cancel tasks
        try:
            if self._unsub:
                self._unsub()
                self._unsub = None
        except Exception:
            self._unsub = None
        for k, t in list(self._tasks.items()):
            try:
                t.cancel()
            except Exception:
                pass
        self._tasks.clear()

        settings = self._settings()
        if not self._enabled(settings):
            return

        # Build maps
        rooms_list = settings.get("entities") or []
        pmap = settings.get("presence_sensors") or {}
        dmap = settings.get("presence_sensor_delays") or {}
        room_sensor: dict[str, str] = {}
        delays_s: dict[str, tuple[int, int]] = {}
        flat: list[str] = []
        if isinstance(pmap, dict):
            for room in (rooms_list if isinstance(rooms_list, list) else []):
                try:
                    if not isinstance(room, str) or not room:
                        continue
                    s_eid = str(pmap.get(room) or "").strip()
                    if not s_eid or not s_eid.startswith("binary_sensor."):
                        continue
                    room_sensor[room] = s_eid
                    flat.append(s_eid)
                    # delays stored in seconds by the editor
                    cfg = dmap.get(room) if isinstance(dmap, dict) else None
                    on_s = int(float(cfg.get("on_s", 180))) if isinstance(cfg, dict) else 180
                    off_s = int(float(cfg.get("off_s", 180))) if isinstance(cfg, dict) else 180
                    on_s = max(0, on_s)
                    off_s = max(0, off_s)
                    delays_s[room] = (on_s, off_s)
                except Exception:
                    continue
        self._room_sensor = room_sensor
        self._delays_s = delays_s

        if not flat:
            return

        @callback
        def _ch(event):
            try:
                eid = str(event.data.get("entity_id") or "")
                # Find the room for this sensor
                for room, se in list(self._room_sensor.items()):
                    if se == eid:
                        ns = event.data.get("new_state")
                        s = str(ns.state if ns else "").lower().strip()
                        self.hass.async_create_task(self._schedule_room(room, s))
                        break
            except Exception:
                return

        self._unsub = async_track_state_change_event(self.hass, sorted(set(flat)), _ch)

        # Initial evaluation: if sensors already stable past delay, apply now
        try:
            for room, se in self._room_sensor.items():
                st = self.hass.states.get(se)
                if not st:
                    continue
                s = str(st.state or "").lower().strip()
                try:
                    changed = st.last_changed
                    if hasattr(changed, "timestamp"):
                        age_s = max(0.0, (dt_util.utcnow() - changed).total_seconds())
                    else:
                        age_s = 0.0
                except Exception:
                    age_s = 0.0
                on_s, off_s = self._delays_s.get(room, (180, 180))
                need = on_s if s == "on" else off_s
                if age_s >= need:
                    # Apply immediately based on current state
                    self.hass.async_create_task(self._mgr._maybe_apply_now(force=True))
        except Exception:
            pass

    async def _schedule_room(self, room: str, state: str) -> None:
        try:
            on_s, off_s = self._delays_s.get(room, (180, 180))
            if state == "on":
                self._cancel_task(room, "off")
                if (room, "on") in self._tasks:
                    return
                async def _fire_on():
                    try:
                        if on_s:
                            await asyncio.sleep(on_s)
                        # still on?
                        se = self._room_sensor.get(room)
                        st = self.hass.states.get(se) if se else None
                        if str(st.state if st else "").lower().strip() != "on":
                            return
                        await self._mgr._maybe_apply_now(force=True)
                        await self._mgr._schedule_next()
                    except asyncio.CancelledError:
                        return
                    except Exception:
                        return
                    finally:
                        self._tasks.pop((room, "on"), None)
                self._tasks[(room, "on")] = self.hass.async_create_task(_fire_on())
            elif state == "off":
                self._cancel_task(room, "on")
                if (room, "off") in self._tasks:
                    return
                async def _fire_off():
                    try:
                        if off_s:
                            await asyncio.sleep(off_s)
                        se = self._room_sensor.get(room)
                        st = self.hass.states.get(se) if se else None
                        if str(st.state if st else "").lower().strip() != "off":
                            return
                        await self._mgr._maybe_apply_now(force=True)
                        await self._mgr._schedule_next()
                    except asyncio.CancelledError:
                        return
                    except Exception:
                        return
                    finally:
                        self._tasks.pop((room, "off"), None)
                self._tasks[(room, "off")] = self.hass.async_create_task(_fire_off())
        except Exception:
            return


class BackupManager:
    def __init__(self, hass: HomeAssistant, backup_cb):
        self.hass = hass
        self._unsub_timer = None
        self._backup_cb = backup_cb

    def _settings(self):
        d = self.hass.data.get(DOMAIN, {})
        return d.get("settings", {})

    def _enabled(self) -> bool:
        s = self._settings()
        return bool(s.get("backup_auto_enabled", False))

    def _interval_min(self) -> int:
        s = self._settings()
        # Prefer days; fallback to minutes for backward compat
        try:
            if "backup_interval_days" in s:
                d = max(1, min(365, int(s.get("backup_interval_days", 1))))
                return d * 1440
        except Exception:
            pass
        try:
            v = int(s.get("backup_interval_min", 1440))
            return max(1440, min(365*1440, v))
        except Exception:
            return 1440

    async def async_start(self):
        await self._schedule_next()
        # Re-schedule on any store update
        @callback
        def _on_update():
            self.hass.async_create_task(self._schedule_next())
        async_dispatcher_connect(self.hass, SIGNAL_UPDATED, _on_update)

    async def _schedule_next(self):
        if self._unsub_timer:
            self._unsub_timer()
            self._unsub_timer = None
        if not self._enabled():
            return
        mins = self._interval_min()
        when = dt_util.utcnow() + timedelta(minutes=mins)
        @callback
        def _cb(_now):
            self.hass.async_create_task(self._do_backup())
        self._unsub_timer = async_track_point_in_utc_time(self.hass, _cb, when)

    async def _do_backup(self):
        try:
            await self._backup_cb(ServiceCall(DOMAIN, "backup_now", {}))
        except Exception:
            _LOGGER.warning("%s: auto backup failed", DOMAIN)
        await self._schedule_next()
