"""Coordinator for Climate Scheduler."""
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional

from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.const import ATTR_TEMPERATURE
from homeassistant.util import dt as dt_util

from .const import DOMAIN, MIN_TEMP, MAX_TEMP, NO_CHANGE_TEMP, SETTING_USE_WORKDAY, SETTING_WORKDAYS, DEFAULT_WORKDAYS
from .storage import ScheduleStorage

_LOGGER = logging.getLogger(__name__)


class HeatingSchedulerCoordinator(DataUpdateCoordinator):
    """Coordinator to manage heating schedule updates."""

    # Backwards-compatible service API wrappers
    # Services call these names (async_advance_schedule/async_advance_group/async_cancel_advance)
    # but the coordinator implementation uses advance_to_next_node/advance_group_to_next_node/cancel_advance.
    async def async_advance_schedule(self, entity_id: str) -> Dict[str, Any]:
        """Service wrapper: advance a single climate entity to its next node."""
        return await self.advance_to_next_node(entity_id)

    async def async_advance_group(self, group_name: str) -> Dict[str, Any]:
        """Service wrapper: advance all entities in a group to their next node."""
        return await self.advance_group_to_next_node(group_name)

    async def async_cancel_advance(self, entity_id: str) -> Dict[str, Any]:
        """Service wrapper: cancel an active advance override for an entity."""
        return await self.cancel_advance(entity_id)

    async def async_get_advance_status(self, entity_id: str) -> dict:
        """Return advance override status for a climate entity or a group schedule_id."""
        now = dt_util.now()

        # If this is a group schedule_id, aggregate across member entities.
        group = await self.storage.async_get_group(entity_id)
        if group is not None:
            member_ids = group.get("entities", [])
            latest_until: Optional[datetime] = None

            for member_id in member_ids:
                until = self.override_until.get(member_id)
                if until and until > now:
                    if latest_until is None or until > latest_until:
                        latest_until = until

            return {
                "entity_id": entity_id,
                "is_advanced": latest_until is not None,
                "advance_time": latest_until.isoformat() if latest_until else None,
                "original_node": None,
                "advanced_node": None,
            }

        # Non-group: check if the entity has an active override (advance)
        is_advanced = False
        advance_time = None
        original_node = None
        advanced_node = None

        if entity_id in self.override_until:
            until = self.override_until[entity_id]
            if until > now:
                is_advanced = True
                advance_time = until.isoformat()

        history = self.advance_history.get(entity_id, [])
        if history:
            last = history[-1]
            original_node = last.get("original_node")
            advanced_node = last.get("advanced_node")

        return {
            "entity_id": entity_id,
            "is_advanced": is_advanced,
            "advance_time": advance_time,
            "original_node": original_node,
            "advanced_node": advanced_node,
        }

    def __init__(
        self,
        hass: HomeAssistant,
        storage: ScheduleStorage,
        update_interval: timedelta,
    ) -> None:
        """Initialize the coordinator."""
        super().__init__(
            hass,
            _LOGGER,
            name=DOMAIN,
            update_interval=update_interval,
        )
        self.storage = storage
        self.last_node_states = {}  # Track last node state (temp + modes) for each entity
        self.last_node_times = {}  # Track last node time for each entity to detect time transitions
        self.override_until = {}  # Track entities with advance override (entity_id -> time)
        self.advance_history = {}  # Track advance events (entity_id -> list of {activated_at, target_time, cancelled_at})
        self._workday_available = None  # Cache for workday integration availability

    async def async_config_entry_first_refresh(self) -> None:
        """Handle the first refresh."""
        # Load advance history from storage
        self.advance_history = await self.storage.async_get_advance_history()
        _LOGGER.debug(f"Loaded advance history from storage: {self.advance_history}")
        # Check for workday integration
        self._check_workday_integration()
        # Call parent's first refresh
        await super().async_config_entry_first_refresh()

    def _check_workday_integration(self) -> bool:
        """Check if the Workday integration is available."""
        if self._workday_available is not None:
            return self._workday_available
        
        # Check if binary_sensor.workday_sensor exists
        workday_state = self.hass.states.get("binary_sensor.workday_sensor")
        self._workday_available = workday_state is not None
        
        if self._workday_available:
            _LOGGER.info("Workday integration detected (binary_sensor.workday_sensor found) - available for 5/2 mode scheduling")
        else:
            _LOGGER.debug("Workday integration not found (binary_sensor.workday_sensor not present) - 5/2 mode will use basic day matching")
        
        return self._workday_available

    async def is_workday_enabled(self) -> bool:
        """Return whether Workday integration should be used for 5/2 scheduling."""
        # First check if integration is available
        if not self.is_workday_available():
            return False
        
        # Then check if user has enabled it in settings
        try:
            settings = await self.storage.async_get_settings()
            use_workday = settings.get(SETTING_USE_WORKDAY, False)
            return use_workday
        except Exception as e:
            _LOGGER.debug(f"Failed to check workday setting: {e}")
            return False

    async def get_workdays(self) -> List[str]:
        """Return list of days considered workdays from settings."""
        try:
            settings = await self.storage.async_get_settings()
            workdays = settings.get(SETTING_WORKDAYS, DEFAULT_WORKDAYS)
            # Validate workdays list
            if not isinstance(workdays, list) or not workdays:
                _LOGGER.warning(f"Invalid workdays setting: {workdays}, using defaults")
                return DEFAULT_WORKDAYS.copy()
            return workdays
        except Exception as e:
            _LOGGER.debug(f"Failed to get workdays setting: {e}")
            return DEFAULT_WORKDAYS.copy()

    async def is_workday(self, day: str) -> bool:
        """Check if a given day (e.g., 'mon', 'tue') is a workday.
        
        Args:
            day: Three-letter day abbreviation (mon, tue, wed, thu, fri, sat, sun)
        
        Returns:
            True if the day is configured as a workday, False otherwise
        """
        workdays = await self.get_workdays()
        return day.lower() in [d.lower() for d in workdays]

    def is_workday_available(self) -> bool:
        """Return whether the Workday integration is available."""
        if self._workday_available is None:
            self._check_workday_integration()
        return self._workday_available or False

    async def force_update_all(self) -> None:
        """Force update all thermostats to their scheduled temperatures."""
        _LOGGER.info("Force updating all thermostats to scheduled temperatures")
        # Clear last node states and times to force updates
        self.last_node_states.clear()
        self.last_node_times.clear()
        # Trigger immediate refresh
        await self.async_request_refresh()
    
    async def advance_to_next_node(self, entity_id: str) -> Dict[str, Any]:
        """Manually advance a specific entity to its next scheduled node."""
        _LOGGER.info(f"Advancing {entity_id} to next scheduled node")
        
        now = dt_util.now()
        current_time = now.time()
        current_day = now.strftime('%a').lower()
        
        # Load global settings (min/max temps)
        try:
            settings = await self.storage.async_get_settings()
        except Exception:
            settings = {}
        min_temp = settings.get("min_temp", MIN_TEMP)
        max_temp = settings.get("max_temp", MAX_TEMP)
        
        # Find the entity's group (all entities are in groups now)
        groups = await self.storage.async_get_groups()
        schedule_data = None
        group_name = None
        
        for g_name, group_data in groups.items():
            if not group_data.get("enabled", True):
                continue
            # Skip ignored groups
            if group_data.get("ignored", False):
                continue
            if entity_id in group_data.get("entities", []):
                schedule_data = await self.storage.async_get_group_schedule(g_name, current_day)
                group_name = g_name
                is_single = group_data.get("_is_single_entity_group", False)
                group_type = "single-entity" if is_single else "multi-entity"
                _LOGGER.info(f"{entity_id} is in enabled {group_type} group '{g_name}'")
                break
        
        # If not in any group, entity schedule is effectively disabled or ignored
        if not schedule_data:
            return {
                "success": False,
                "error": "Entity schedule is disabled, ignored, or not found"
            }
        
        if not schedule_data or "nodes" not in schedule_data:
            return {
                "success": False,
                "error": "No schedule found for entity"
            }
        
        nodes = schedule_data["nodes"]
        if not nodes:
            return {
                "success": False,
                "error": "Schedule has no nodes"
            }

        # Get the next node with day-aware wrap-around.
        schedule_mode = schedule_data.get("schedule_mode", "all_days")
        current_minutes = current_time.hour * 60 + current_time.minute
        def _time_to_minutes(time_str: str) -> int:
            hours, minutes = map(int, time_str.split(":"))
            return hours * 60 + minutes

        sorted_nodes = sorted(nodes, key=lambda n: _time_to_minutes(n["time"]))

        next_node = None
        next_node_day = current_day

        # Try to find the next node later today first.
        for node in sorted_nodes:
            node_minutes = _time_to_minutes(node["time"])
            if node_minutes > current_minutes:
                next_node = node
                break

        # If none later today, wrap to tomorrow's first node where relevant.
        if next_node is None:
            days_of_week = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
            current_day_index = days_of_week.index(current_day)
            next_day = days_of_week[(current_day_index + 1) % 7]

            # In schedule modes with day-specific schedules, prefer tomorrow's first node.
            if schedule_mode in ["individual", "5/2"]:
                next_day_schedule = await self.storage.async_get_group_schedule(group_name, next_day)
                next_day_nodes = next_day_schedule.get("nodes", []) if next_day_schedule else []
                if next_day_nodes:
                    next_node = sorted(next_day_nodes, key=lambda n: _time_to_minutes(n["time"]))[0]
                    next_node_day = next_day

            # Fallback: wrap to today's first node if tomorrow schedule is empty/missing.
            if next_node is None:
                next_node = sorted_nodes[0]
                next_node_day = next_day

        if not next_node:
            return {
                "success": False,
                "error": "Could not determine next node"
            }
        
        # Set override to prevent auto-revert until next node's scheduled time
        next_node_time_str = next_node["time"]
        next_node_hours, next_node_minutes = map(int, next_node_time_str.split(":"))
        override_until = dt_util.now().replace(hour=next_node_hours, minute=next_node_minutes, second=0, microsecond=0)
        # If next node time is earlier in the day than current time, it's tomorrow
        if override_until <= dt_util.now():
            override_until += timedelta(days=1)
        self.override_until[entity_id] = override_until
        
        # Record advance activation in history
        if entity_id not in self.advance_history:
            self.advance_history[entity_id] = []
        self.advance_history[entity_id].append({
            "activated_at": dt_util.now().isoformat(),
            "target_time": next_node_time_str,
            "target_node": next_node,
            "cancelled_at": None
        })
        
        # Save history to storage
        await self.storage.async_save_advance_history(self.advance_history)
        
        _LOGGER.info(f"Set override for {entity_id} until {override_until}")
        
        _LOGGER.info(f"{entity_id} next node: {next_node}")
        
        # Clamp target temp (or use None for no change)
        target_temp = next_node.get("temp")
        is_no_change = next_node.get("noChange", False)
        if is_no_change:
            clamped_temp = None
            _LOGGER.info(f"{entity_id} temp set to NO_CHANGE - will not modify temperature")
        else:
            clamped_temp = max(min_temp, min(max_temp, target_temp)) if target_temp is not None else None
        
        # Create node signature
        node_signature = {
            "temp": clamped_temp,
            "hvac_mode": next_node.get("hvac_mode"),
            "fan_mode": next_node.get("fan_mode"),
            "swing_mode": next_node.get("swing_mode"),
            "preset_mode": next_node.get("preset_mode"),
        }
        
        # Update last node state to mark this as applied
        self.last_node_states[entity_id] = node_signature
        
        # Get entity state
        state = self.hass.states.get(entity_id)
        if state is None:
            return {
                "success": False,
                "error": "Entity not found"
            }
        
        # Get entity capabilities
        hvac_modes = state.attributes.get("hvac_modes", [])
        fan_modes = state.attributes.get("fan_modes", [])
        swing_modes = state.attributes.get("swing_modes", [])
        preset_modes = state.attributes.get("preset_modes", [])

        # Check if this is a preset-only entity
        current_temperature = state.attributes.get("current_temperature")
        is_preset_only = current_temperature is None
        
        # Apply the next node settings
        target_hvac_mode = next_node.get("hvac_mode")
        
        if target_hvac_mode == "off":
            _LOGGER.info(f"Advancing {entity_id} - turning off")
            try:
                await self.hass.services.async_call(
                    "climate",
                    "turn_off",
                    {"entity_id": entity_id},
                    blocking=True,
                )
            except Exception as e:
                _LOGGER.debug(f"turn_off failed for {entity_id}, trying set_hvac_mode: {e}")
                if "off" in hvac_modes:
                    await self.hass.services.async_call(
                        "climate",
                        "set_hvac_mode",
                        {"entity_id": entity_id, "hvac_mode": "off"},
                        blocking=True,
                    )
        else:
            # Apply HVAC mode
            if "hvac_mode" in next_node and next_node["hvac_mode"] != "off" and next_node["hvac_mode"] in hvac_modes:
                await self.hass.services.async_call(
                    "climate",
                    "set_hvac_mode",
                    {"entity_id": entity_id, "hvac_mode": next_node["hvac_mode"]},
                    blocking=True,
                )

        # Set temperature after applying HVAC/off mode
        if clamped_temp is not None and not is_preset_only:
            _LOGGER.info(f"Advancing {entity_id} to temp={clamped_temp}°C")
            try:
                await self.hass.services.async_call(
                    "climate",
                    "set_temperature",
                    {
                        "entity_id": entity_id,
                        ATTR_TEMPERATURE: clamped_temp,
                    },
                    blocking=True,
                )
            except Exception as exc:
                if target_hvac_mode == "off":
                    _LOGGER.warning(f"Failed to set temperature after turning off {entity_id}: {exc}")
                else:
                    return {
                        "success": False,
                        "error": f"Failed to set temperature: {str(exc)}"
                    }
        elif clamped_temp is not None and is_preset_only:
            _LOGGER.info(f"Skipping temperature change for {entity_id} (preset-only entity)")
        else:
            _LOGGER.info(f"Skipping temperature change for {entity_id} (NO_CHANGE set)")
            
            # Apply fan mode
            if "fan_mode" in next_node and fan_modes and next_node["fan_mode"] in fan_modes:
                await self.hass.services.async_call(
                    "climate",
                    "set_fan_mode",
                    {"entity_id": entity_id, "fan_mode": next_node["fan_mode"]},
                    blocking=True,
                )
            
            # Apply swing mode
            if "swing_mode" in next_node and swing_modes and next_node["swing_mode"] in swing_modes:
                await self.hass.services.async_call(
                    "climate",
                    "set_swing_mode",
                    {"entity_id": entity_id, "swing_mode": next_node["swing_mode"]},
                    blocking=True,
                )
            
            # Apply preset mode
            if "preset_mode" in next_node and preset_modes and next_node["preset_mode"] in preset_modes:
                await self.hass.services.async_call(
                    "climate",
                    "set_preset_mode",
                    {"entity_id": entity_id, "preset_mode": next_node["preset_mode"]},
                    blocking=True,
                )
        
        # Fire event for manual advance
        # Get all entities in the group for event data
        group_entities = groups[group_name].get("entities", [])
        
        self.hass.bus.async_fire(
            f"{DOMAIN}_node_activated",
            {
                # TODO: Remove entity_id in future version - deprecated in favor of entities list
                "entity_id": entity_id,  # Specific entity that was advanced
                "entities": group_entities,  # All entities in the group
                "group_name": group_name,
                "node": {
                    "time": next_node.get("time"),
                    "temp": clamped_temp,
                    "hvac_mode": next_node.get("hvac_mode"),
                    "fan_mode": next_node.get("fan_mode"),
                    "swing_mode": next_node.get("swing_mode"),
                    "preset_mode": next_node.get("preset_mode"),
                    "A": next_node.get("A"),
                    "B": next_node.get("B"),
                    "C": next_node.get("C"),
                },
                "day": next_node_day,
                "trigger_type": "manual_advance",
            }
        )
        _LOGGER.info(f"Fired node_activated event for {entity_id} (manual_advance)")
        
        return {
            "success": True,
            "next_node": next_node,
            "next_node_day": next_node_day,
            "applied_temp": clamped_temp
        }
    
    async def cancel_advance(self, entity_id: str) -> Dict[str, Any]:
        """Cancel an active advance override for an entity or a group schedule_id."""

        async def _cancel_single(target_entity_id: str) -> None:
            # Mark the most recent advance as cancelled in history (even if override expired)
            if target_entity_id in self.advance_history and self.advance_history[target_entity_id]:
                latest = self.advance_history[target_entity_id][-1]
                if latest.get("cancelled_at") is None:
                    latest["cancelled_at"] = dt_util.now().isoformat()

            # Remove override if it exists
            if target_entity_id in self.override_until:
                del self.override_until[target_entity_id]

            # Clear last node state to force immediate update to current schedule
            if target_entity_id in self.last_node_states:
                del self.last_node_states[target_entity_id]

        group = await self.storage.async_get_group(entity_id)
        if group is not None:
            member_ids = group.get("entities", [])
            for member_id in member_ids:
                await _cancel_single(member_id)

            # Cancel group-level history/override if present.
            await _cancel_single(entity_id)

            await self.storage.async_save_advance_history(self.advance_history)
            _LOGGER.info(f"Cancelled advance for group '{entity_id}' ({len(member_ids)} members)")
            await self.async_request_refresh()
            return {"success": True}

        _LOGGER.info(f"cancel_advance called for {entity_id}")

        if entity_id not in self.advance_history or not self.advance_history[entity_id]:
            _LOGGER.warning(f"No advance history found for {entity_id} to cancel")

        await _cancel_single(entity_id)
        await self.storage.async_save_advance_history(self.advance_history)
        _LOGGER.info(f"Cancelled advance for {entity_id}")
        await self.async_request_refresh()
        return {"success": True}
    
    async def clear_advance_history(self, entity_id: str) -> Dict[str, Any]:
        """Clear advance history for an entity."""
        if entity_id in self.advance_history:
            del self.advance_history[entity_id]
            # Save updated history to storage
            await self.storage.async_save_advance_history(self.advance_history)
            _LOGGER.info(f"Cleared advance history for {entity_id}")
        
        return {
            "success": True
        }
    
    def get_advance_history(self, entity_id: str, hours: int = 24) -> List[Dict[str, Any]]:
        """Get advance history for an entity within the last N hours."""
        if entity_id not in self.advance_history:
            return []
        
        cutoff = dt_util.now() - timedelta(hours=hours)
        history = []
        
        for event in self.advance_history[entity_id]:
            activated = datetime.fromisoformat(event["activated_at"])
            if activated >= cutoff:
                history.append(event)
        
        return history
    
    async def advance_group_to_next_node(self, group_name: str) -> Dict[str, Any]:
        """Advance all entities in a group to their next scheduled node."""
        _LOGGER.info(f"Advancing group '{group_name}' to next scheduled node")
        
        # Get the group
        groups = await self.storage.async_get_groups()
        if group_name not in groups:
            return {
                "success": False,
                "error": f"Group '{group_name}' not found"
            }
        
        group_data = groups[group_name]
        if not group_data.get("enabled", True):
            return {
                "success": False,
                "error": "Group is disabled"
            }
        
        entity_ids = group_data.get("entities", [])
        if not entity_ids:
            return {
                "success": False,
                "error": "Group has no entities"
            }
        
        # Advance each entity in the group
        results = {}
        success_count = 0
        error_count = 0
        first_success_entity_id: Optional[str] = None
        first_success_next_node: Optional[Dict[str, Any]] = None
        
        for entity_id in entity_ids:
            try:
                result = await self.advance_to_next_node(entity_id)
                results[entity_id] = result
                if result.get("success"):
                    success_count += 1
                    if first_success_entity_id is None:
                        first_success_entity_id = entity_id
                        first_success_next_node = result.get("next_node")
                else:
                    error_count += 1
            except Exception as e:
                results[entity_id] = {
                    "success": False,
                    "error": str(e)
                }
                error_count += 1

        # Record a group-level advance entry so schedule_id==group_name behaves consistently.
        if success_count > 0 and first_success_entity_id and first_success_next_node:
            if group_name not in self.advance_history:
                self.advance_history[group_name] = []
            self.advance_history[group_name].append(
                {
                    "activated_at": dt_util.now().isoformat(),
                    "target_time": first_success_next_node.get("time"),
                    "target_node": first_success_next_node,
                    "cancelled_at": None,
                }
            )

            # Mirror an override timestamp for the group id (used by status checks).
            member_until = self.override_until.get(first_success_entity_id)
            if member_until is not None:
                self.override_until[group_name] = member_until

            await self.storage.async_save_advance_history(self.advance_history)
        
        return {
            "success": success_count > 0,
            "total_entities": len(entity_ids),
            "success_count": success_count,
            "error_count": error_count,
            "results": results
        }
    
    def get_override_status(self, entity_id: str) -> Dict[str, Any]:
        """Get override status for an entity."""
        if entity_id in self.override_until:
            override_time = self.override_until[entity_id]
            if dt_util.now() < override_time:
                return {
                    "has_override": True,
                    "override_until": override_time.isoformat()
                }
        return {"has_override": False}

    async def _async_update_data(self) -> Dict[str, Any]:
        """Update heating schedules."""
        _LOGGER.info("=== COORDINATOR UPDATE CYCLE START ===")
        try:
            now = dt_util.now()
            current_time = now.time()
            current_day = now.strftime('%a').lower()  # Get day: mon, tue, wed, etc.
            _LOGGER.info(f"Current time: {current_time}, day: {current_day}")
            # Load global settings (min/max temps)
            try:
                settings = await self.storage.async_get_settings()
            except Exception:
                settings = {}
            min_temp = settings.get("min_temp", MIN_TEMP)
            max_temp = settings.get("max_temp", MAX_TEMP)

            # Get all groups and build a map of entities to their group schedules
            # Now ALL entities are in groups (either multi-entity or single-entity groups)
            groups = await self.storage.async_get_groups()
            entity_group_schedules = {}  # Maps entity_id -> (group_name, schedule_data)
            groups_migrated = False
            
            for group_name, group_data in groups.items():
                # Migrate existing groups - add enabled=True if missing
                if "enabled" not in group_data:
                    group_data["enabled"] = True
                    groups_migrated = True
                    _LOGGER.info(f"Migrated group '{group_name}' - added enabled=True")
                
                if not group_data.get("enabled", True):
                    _LOGGER.debug(f"Skipping disabled group '{group_name}'")
                    continue
                
                # Skip ignored groups (single-entity groups marked as ignored)
                if group_data.get("ignored", False):
                    _LOGGER.debug(f"Skipping ignored group '{group_name}'")
                    continue
                
                # Get group schedule for current day
                group_schedule = await self.storage.async_get_group_schedule(group_name, current_day)
                if group_schedule and "nodes" in group_schedule:
                    # In individual or 5/2 mode, if current time is before all nodes today,
                    # we need to check previous day's schedule for the active node
                    schedule_mode = group_schedule.get("schedule_mode", "all_days")
                    nodes = group_schedule["nodes"]
                    
                    if schedule_mode in ["individual", "5/2"] and nodes:
                        # Check if current time is before all nodes today
                        sorted_nodes = sorted(nodes, key=lambda n: self.storage._time_to_minutes(n["time"]))
                        current_minutes = current_time.hour * 60 + current_time.minute
                        first_node_minutes = self.storage._time_to_minutes(sorted_nodes[0]["time"])
                        
                        if current_minutes < first_node_minutes:
                            # We're before the first node of today, need previous day/period's last node
                            _LOGGER.info(f"Group '{group_name}': Current time {current_time} is before first node today, checking previous period")
                            
                            # Calculate previous day
                            days_of_week = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
                            current_day_index = days_of_week.index(current_day)
                            prev_day = days_of_week[(current_day_index - 1) % 7]
                            
                            # Get previous day's schedule
                            prev_day_schedule = await self.storage.async_get_group_schedule(group_name, prev_day)
                            if prev_day_schedule and prev_day_schedule.get("nodes"):
                                # Use previous day's last node as the active node until first node today
                                prev_nodes = prev_day_schedule["nodes"]
                                sorted_prev_nodes = sorted(prev_nodes, key=lambda n: self.storage._time_to_minutes(n["time"]))
                                last_prev_node = sorted_prev_nodes[-1]
                                
                                # Prepend the previous day's last node to today's schedule with time "00:00"
                                # This way get_active_node will correctly use it as the active node until first node today
                                carryover_node = {**last_prev_node, "time": "00:00", "_from_previous_day": True}
                                group_schedule["nodes"] = [carryover_node] + nodes
                                _LOGGER.info(f"Group '{group_name}': Carrying over previous period's node (temp={last_prev_node.get('temp')}) to bridge to first node at {sorted_nodes[0]['time']}")
                    
                    entities_list = group_data.get("entities", [])
                    
                    if len(entities_list) == 0:
                        # Virtual group with no entities - track separately for event-only processing
                        entity_group_schedules[f"_virtual_{group_name}"] = (group_name, group_schedule, True)
                        _LOGGER.info(f"Virtual group '{group_name}' will fire events only (no entities)")
                    else:
                        # Map all entities in this group to this schedule
                        for entity_id in entities_list:
                            entity_group_schedules[entity_id] = (group_name, group_schedule, False)
                            is_single = group_data.get("_is_single_entity_group", False)
                            group_type = "single-entity" if is_single else "multi-entity"
                            _LOGGER.info(f"{entity_id} will use enabled {group_type} group '{group_name}' schedule")
            
            # Save storage if any groups were migrated
            if groups_migrated:
                await self.storage.async_save()
                _LOGGER.info("Saved migrated group data")
            
            _LOGGER.info(f"Found {len(entity_group_schedules)} entities with enabled group schedules")
            
            results = {}
            
            # Process all entities that have group schedules (both single and multi-entity groups)
            for entity_id, (group_name, schedule_data, is_virtual) in entity_group_schedules.items():
                # Handle virtual groups (no entities, events only)
                if is_virtual:
                    _LOGGER.info(f"Processing virtual group: '{group_name}'")
                    
                    nodes = schedule_data["nodes"]
                    active_node = self.storage.get_active_node(nodes, current_time)
                    if not active_node:
                        _LOGGER.debug(f"No active node for virtual group '{group_name}'")
                        continue
                    
                    # Create signature for virtual group tracking (includes time for comparison)
                    virtual_key = f"_virtual_{group_name}"
                    node_time = active_node.get("time")
                    node_signature = {
                        "time": node_time,
                        "temp": active_node.get("temp"),
                        "hvac_mode": active_node.get("hvac_mode"),
                        "fan_mode": active_node.get("fan_mode"),
                        "swing_mode": active_node.get("swing_mode"),
                        "preset_mode": active_node.get("preset_mode"),
                        "A": active_node.get("A"),
                        "B": active_node.get("B"),
                        "C": active_node.get("C"),
                    }
                    
                    # Check if we've transitioned to a new node (time or state change)
                    last_node = self.last_node_states.get(virtual_key)
                    last_node_time = self.last_node_times.get(virtual_key)
                    
                    # For virtual groups, only fire event on transitions (no settings to apply)
                    if last_node == node_signature:
                        _LOGGER.debug(f"Virtual group '{group_name}' still on same node (time: {node_time}), skipping")
                        results[virtual_key] = {
                            "updated": False,
                            "reason": "same_node"
                        }
                        continue
                    
                    # Node has changed, fire event
                    self.last_node_states[virtual_key] = node_signature
                    self.last_node_times[virtual_key] = node_time
                    
                    self.hass.bus.async_fire(
                        f"{DOMAIN}_node_activated",
                        {
                            # TODO: Remove entity_id in future version - deprecated in favor of entities list
                            "entity_id": None,  # No entity for virtual groups
                            "entities": [],  # Empty list for virtual groups
                            "group_name": group_name,
                            "node": {
                                "time": active_node.get("time"),
                                "temp": active_node.get("temp"),
                                "hvac_mode": active_node.get("hvac_mode"),
                                "fan_mode": active_node.get("fan_mode"),
                                "swing_mode": active_node.get("swing_mode"),
                                "preset_mode": active_node.get("preset_mode"),
                                "A": active_node.get("A"),
                                "B": active_node.get("B"),
                                "C": active_node.get("C"),
                            },
                            "previous_node": last_node,
                            "day": current_day,
                            "trigger_type": "scheduled",
                        }
                    )
                    _LOGGER.info(f"Fired node_activated event for virtual group '{group_name}' (scheduled)")
                    
                    results[virtual_key] = {
                        "updated": True,
                        "virtual": True
                    }
                    continue
                
                # Check if entity exists in Home Assistant first
                state = self.hass.states.get(entity_id)
                if state is None:
                    _LOGGER.debug(f"Entity {entity_id} not found in Home Assistant, skipping (may have been removed or renamed)")
                    continue
                
                _LOGGER.info(f"Processing entity: {entity_id} from group '{group_name}'")
                
                # Check if entity has an active advance override
                if entity_id in self.override_until:
                    override_time = self.override_until[entity_id]
                    if dt_util.now() < override_time:
                        _LOGGER.debug(f"Skipping {entity_id} - advance override active until {override_time}")
                        results[entity_id] = {
                            "updated": False,
                            "reason": "advance_override_active"
                        }
                        continue
                    else:
                        # Override expired, mark as completed in history
                        _LOGGER.info(f"Override expired for {entity_id}, resuming normal scheduling")
                        history_updated = False
                        if entity_id in self.advance_history and self.advance_history[entity_id]:
                            # Find the most recent uncompleted advance
                            for event in reversed(self.advance_history[entity_id]):
                                if event["cancelled_at"] is None:
                                    event["cancelled_at"] = dt_util.now().isoformat()
                                    _LOGGER.info(f"Marked advance as completed for {entity_id}")
                                    history_updated = True
                                    break
                        if history_updated:
                            # Save updated history to storage
                            await self.storage.async_save_advance_history(self.advance_history)
                        del self.override_until[entity_id]
                
                # Entity is in a group (either multi-entity or single-entity group)
                _LOGGER.info(f"{entity_id} using group '{group_name}' schedule")
                
                _LOGGER.info(f"{entity_id} schedule data for {current_day}: {schedule_data}")
                if not schedule_data or "nodes" not in schedule_data:
                    _LOGGER.debug(f"No schedule nodes for {entity_id}")
                    continue
                
                nodes = schedule_data["nodes"]
                _LOGGER.info(f"{entity_id} has {len(nodes)} nodes for {current_day}")
                
                # Get active node (includes temp and other settings)
                active_node = self.storage.get_active_node(nodes, current_time)
                if not active_node:
                    _LOGGER.debug(f"No active node for {entity_id}")
                    continue
                    
                target_temp = active_node.get("temp")
                _LOGGER.info(f"{entity_id} active node: {active_node}")
                
                # Clamp target temp to global min/max BEFORE creating signature
                # This prevents infinite update loops where unclamped signature differs from clamped output
                # Handle NO_CHANGE temperature (noChange flag)
                is_no_change = active_node.get("noChange", False)
                if is_no_change:
                    clamped_temp = None
                    _LOGGER.info(f"{entity_id} temp set to NO_CHANGE - will not modify temperature")
                else:
                    clamped_temp = target_temp
                    if target_temp is not None:
                        if target_temp < min_temp:
                            clamped_temp = min_temp
                            _LOGGER.debug(f"Clamping {entity_id} target {target_temp} -> {clamped_temp}")
                        elif target_temp > max_temp:
                            clamped_temp = max_temp
                            _LOGGER.debug(f"Clamping {entity_id} target {target_temp} -> {clamped_temp}")
                
                # Create a state signature for the node using CLAMPED temp + modes
                node_signature = {
                    "temp": clamped_temp,
                    "hvac_mode": active_node.get("hvac_mode"),
                    "fan_mode": active_node.get("fan_mode"),
                    "swing_mode": active_node.get("swing_mode"),
                    "preset_mode": active_node.get("preset_mode"),
                }
                
                # Get the node time for tracking
                node_time = active_node.get("time")
                last_node_time = self.last_node_times.get(entity_id)
                
                # Check if we've transitioned to a new node or if this is first run
                last_node = self.last_node_states.get(entity_id)
                node_time_changed = last_node_time != node_time
                node_state_changed = last_node != node_signature
                is_first_run = last_node is None
                
                # Only apply settings on: time transitions, state changes (user edits), or first run (initialization)
                if not is_first_run and not node_time_changed and not node_state_changed:
                    # Still on same node with same settings, don't override manual changes
                    _LOGGER.debug(f"{entity_id} still on same node (time: {node_time}), skipping")
                    results[entity_id] = {
                        "updated": False,
                        "target_temp": target_temp,
                        "reason": "same_node"
                    }
                    continue
                
                # Log the reason for applying settings
                if is_first_run:
                    _LOGGER.info(f"{entity_id} first run - applying initial settings")
                elif node_time_changed and not node_state_changed:
                    _LOGGER.info(f"{entity_id} node time changed ({last_node_time} -> {node_time}), applying settings")
                elif node_state_changed and not node_time_changed:
                    _LOGGER.info(f"{entity_id} node state changed: {last_node} -> {node_signature}")
                else:
                    _LOGGER.info(f"{entity_id} node changed (time and state)")
                    
                self.last_node_states[entity_id] = node_signature
                self.last_node_times[entity_id] = node_time
                
                # SPECIAL CASE: If temperature is NO_CHANGE, use the current target temperature from the climate entity.
                temp_to_apply = clamped_temp
                if is_no_change:
                    current_target_for_reapply = state.attributes.get("temperature")
                    # For entities using min/max range (heat_cool/auto mode), check target_temp_high/low
                    target_temp_high = state.attributes.get("target_temp_high")
                    target_temp_low = state.attributes.get("target_temp_low")
                    
                    if current_target_for_reapply is not None:
                        temp_to_apply = current_target_for_reapply
                        _LOGGER.info(f"{entity_id} NO_CHANGE - will use current temperature {temp_to_apply}°C")
                    elif target_temp_high is not None or target_temp_low is not None:
                        # Entity uses min/max range - NO_CHANGE doesn't apply to range entities
                        # Still set temp_to_apply to None so we skip temperature but apply modes
                        temp_to_apply = None
                        _LOGGER.info(f"{entity_id} NO_CHANGE - entity uses temp range (high={target_temp_high}, low={target_temp_low}), will apply modes only")
                    else:
                        temp_to_apply = None
                        _LOGGER.info(f"{entity_id} NO_CHANGE but no current temperature available")
                
                # Re-get current state (we checked it exists earlier)
                _LOGGER.info(f"{entity_id} state found: {state.state}")
                # Get current target temperature
                current_target = state.attributes.get("temperature")
                _LOGGER.info(f"{entity_id} current target: {current_target}°C")
                
                # Check if this is a preset-only entity (no current_temperature sensor)
                current_temperature = state.attributes.get("current_temperature")
                is_preset_only = current_temperature is None
                if is_preset_only:
                    _LOGGER.info(f"{entity_id} is preset-only (no current_temperature), will skip temperature changes")
                
                # Get entity capabilities
                supported_features = state.attributes.get("supported_features", 0)
                hvac_modes = state.attributes.get("hvac_modes", [])
                fan_modes = state.attributes.get("fan_modes", [])
                swing_modes = state.attributes.get("swing_modes", [])
                preset_modes = state.attributes.get("preset_modes", [])
                
                # Check if we're turning off - apply mode first
                target_hvac_mode = active_node.get("hvac_mode")
                _LOGGER.info(f"{entity_id} target_hvac_mode: {target_hvac_mode}, supported modes: {hvac_modes}")
                if target_hvac_mode == "off":
                    _LOGGER.info(f"Turning off {entity_id}")

                    # Try using turn_off service first (more reliable for some integrations)
                    try:
                        await self.hass.services.async_call(
                            "climate",
                            "turn_off",
                            {
                                "entity_id": entity_id,
                            },
                            blocking=True,
                        )
                    except Exception as e:
                        # Fallback to set_hvac_mode if turn_off not supported
                        _LOGGER.debug(f"turn_off failed for {entity_id}, trying set_hvac_mode: {e}")
                        if "off" in hvac_modes:
                            await self.hass.services.async_call(
                                "climate",
                                "set_hvac_mode",
                                {
                                    "entity_id": entity_id,
                                    "hvac_mode": "off",
                                },
                                blocking=True,
                            )
                else:
                    # Apply HVAC mode if specified in node and supported by entity (except off, handled above)
                    if "hvac_mode" in active_node and active_node["hvac_mode"] != "off" and active_node["hvac_mode"] in hvac_modes:
                        _LOGGER.info(f"Setting HVAC mode to {active_node['hvac_mode']}")
                        await self.hass.services.async_call(
                            "climate",
                            "set_hvac_mode",
                            {
                                "entity_id": entity_id,
                                "hvac_mode": active_node["hvac_mode"],
                            },
                            blocking=True,
                        )
                    elif "hvac_mode" in active_node and active_node["hvac_mode"] != "off":
                        _LOGGER.debug(f"HVAC mode {active_node['hvac_mode']} not supported by {entity_id}")

                # Apply temperature after HVAC/off mode
                if temp_to_apply is not None and not is_preset_only:
                    _LOGGER.info(
                        f"Updating {entity_id} to new node: temp={temp_to_apply}°C"
                    )

                    service_data = {
                        "entity_id": entity_id,
                        ATTR_TEMPERATURE: temp_to_apply,
                    }

                    try:
                        await self.hass.services.async_call(
                            "climate",
                            "set_temperature",
                            service_data,
                            blocking=True,
                        )
                    except Exception as exc:
                        if target_hvac_mode == "off":
                            _LOGGER.warning(f"Failed to set temperature after turning off {entity_id}: {exc}")
                        else:
                            _LOGGER.error(f"Failed to set_temperature for {entity_id}: {exc}")
                            results[entity_id] = {
                                "updated": False,
                                "target_temp": target_temp,
                                "applied_temp": None,
                                "error": str(exc),
                            }
                            # Skip further actions for this entity
                            continue
                elif temp_to_apply is not None and is_preset_only:
                    _LOGGER.info(f"Skipping temperature change for {entity_id} (preset-only entity)")
                else:
                    _LOGGER.info(f"Skipping temperature change for {entity_id} (NO_CHANGE with no current temperature)")
                
                # Apply fan mode if specified in node and supported by entity
                if "fan_mode" in active_node and fan_modes and active_node["fan_mode"] in fan_modes:
                    _LOGGER.info(f"Setting fan mode to {active_node['fan_mode']}")
                    await self.hass.services.async_call(
                        "climate",
                        "set_fan_mode",
                        {
                            "entity_id": entity_id,
                            "fan_mode": active_node["fan_mode"],
                        },
                        blocking=True,
                    )
                elif "fan_mode" in active_node and fan_modes:
                    _LOGGER.debug(f"Fan mode {active_node['fan_mode']} not supported by {entity_id}")
                
                # Apply swing mode if specified in node and supported by entity
                if "swing_mode" in active_node and swing_modes and active_node["swing_mode"] in swing_modes:
                    _LOGGER.info(f"Setting swing mode to {active_node['swing_mode']}")
                    await self.hass.services.async_call(
                        "climate",
                        "set_swing_mode",
                        {
                            "entity_id": entity_id,
                            "swing_mode": active_node["swing_mode"],
                        },
                        blocking=True,
                    )
                elif "swing_mode" in active_node and swing_modes:
                    _LOGGER.debug(f"Swing mode {active_node['swing_mode']} not supported by {entity_id}")
                
                # Apply preset mode if specified in node and supported by entity
                if "preset_mode" in active_node and preset_modes and active_node["preset_mode"] in preset_modes:
                    _LOGGER.info(f"Setting preset mode to {active_node['preset_mode']}")
                    await self.hass.services.async_call(
                        "climate",
                        "set_preset_mode",
                        {
                            "entity_id": entity_id,
                            "preset_mode": active_node["preset_mode"],
                        },
                        blocking=True,
                    )
                elif "preset_mode" in active_node and preset_modes:
                    _LOGGER.debug(f"Preset mode {active_node['preset_mode']} not supported by {entity_id}")
                
                # Fire event for scheduled node activation ONLY if node time changed (scheduled transition)
                # Do NOT fire events when only state changed (user editing current node)
                if node_time_changed:
                    # Get all entities in the group for event data
                    all_groups = await self.storage.async_get_groups()
                    group_entities = all_groups.get(group_name, {}).get("entities", [])
                    
                    self.hass.bus.async_fire(
                        f"{DOMAIN}_node_activated",
                        {
                            # TODO: Remove entity_id in future version - deprecated in favor of entities list
                            "entity_id": entity_id,
                            "entities": group_entities,  # All entities in the group
                            "group_name": group_name,
                            "node": {
                                "time": active_node.get("time"),
                                "temp": clamped_temp,
                                "hvac_mode": active_node.get("hvac_mode"),
                                "fan_mode": active_node.get("fan_mode"),
                                "swing_mode": active_node.get("swing_mode"),
                                "preset_mode": active_node.get("preset_mode"),
                                "A": active_node.get("A"),
                                "B": active_node.get("B"),
                                "C": active_node.get("C"),
                            },
                            "previous_node": last_node,
                            "day": current_day,
                            "trigger_type": "scheduled",
                        }
                    )
                    _LOGGER.info(f"Fired node_activated event for {entity_id} (scheduled transition)")
                else:
                    _LOGGER.debug(f"Skipping event for {entity_id} - settings applied but not a time transition (user edit or first run)")
                
                results[entity_id] = {
                    "updated": True,
                    "target_temp": target_temp,
                    "previous_temp": current_target,
                }
            
            return results
            
        except Exception as err:
            _LOGGER.error(f"Error updating heating schedules: {err}")
            raise UpdateFailed(f"Error updating heating schedules: {err}")
