"""CycleScheduler: orchestrates cycles for multiple underlyings within a master cycle.

For switches: manages staggered ON/OFF PWM timing to minimize overlap
and smooth electrical load.
For valves: passthrough mode that calls set_valve_open_percent() directly
without temporal scheduling.
"""

import logging
import time
from vtherm_api.log_collector import get_vtherm_logger
from typing import Any, Callable

from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers.event import async_call_later


from .vtherm_hvac_mode import VThermHvacMode, VThermHvacMode_OFF

from .cycle_tick_logic import (
    UnderlyingCycleState,
    compute_circular_offsets,
    compute_target_state,
    evaluate_need_on,
    evaluate_need_off,
    compute_e_eff,
)
_LOGGER = get_vtherm_logger(__name__)


def calculate_cycle_times(
    on_percent: float,
    cycle_min: int,
    minimal_activation_delay: int | None = 0,
    minimal_deactivation_delay: int | None = 0,
) -> tuple[int, int, bool]:
    """Convert on_percent to on_time_sec and off_time_sec.

    Applies minimal activation and deactivation delays to avoid
    very short on/off periods that may damage equipment or be ineffective.

    Args:
        on_percent: The calculated heating percentage (0.0 to 1.0)
        cycle_min: The cycle duration in minutes
        minimal_activation_delay: Minimum on time in seconds (below this, turn off)
        minimal_deactivation_delay: Minimum off time in seconds (below this, stay on)

    Returns:
        Tuple of (on_time_sec, off_time_sec, forced_by_timing)
        - forced_by_timing: True if min_on or min_off delays modified the percentage significantly.
    """
    min_on = minimal_activation_delay if minimal_activation_delay is not None else 0
    min_off = minimal_deactivation_delay if minimal_deactivation_delay is not None else 0

    on_percent = max(0.0, min(1.0, on_percent))

    cycle_sec = cycle_min * 60
    on_time_sec = on_percent * cycle_sec
    forced_by_timing = False

    if on_time_sec > 0 and on_time_sec < min_on:
        on_time_sec = 0
        forced_by_timing = True

    off_time_sec = cycle_sec - on_time_sec

    if on_time_sec < cycle_sec and off_time_sec < min_off:
        on_time_sec = cycle_sec
        off_time_sec = 0
        forced_by_timing = True

    return int(on_time_sec), int(off_time_sec), forced_by_timing


class CycleScheduler:
    """Orchestrates cycles for multiple underlyings within a master cycle.

    For switches: all underlyings operate within the same time window.
    ON periods are staggered using computed offsets to minimize overlap.
    For valves: passthrough mode — calls set_valve_open_percent() directly.
    """

    def __init__(
        self,
        hass: HomeAssistant,
        thermostat: Any,
        underlyings: list,
        cycle_duration_sec: float,
        min_activation_delay: int = 0,
        min_deactivation_delay: int = 0,
    ):
        self._hass = hass
        self._thermostat = thermostat
        self._underlyings = underlyings
        self._cycle_duration_sec = cycle_duration_sec
        self.min_activation_delay: int = min_activation_delay
        self.min_deactivation_delay: int = min_deactivation_delay
        self._tick_unsub: CALLBACK_TYPE | None = None
        self._cycle_end_unsub: CALLBACK_TYPE | None = None
        self._on_cycle_start_callbacks: list[Callable] = []
        self._on_cycle_end_callbacks: list[Callable] = []
        # Current cycle parameters (for repeat at cycle end)
        self._current_hvac_mode: VThermHvacMode | None = None
        self._current_on_time_sec: float = 0
        self._current_off_time_sec: float = 0
        self._current_on_percent: float = 0
        # Active cycle parameters describe what is physically being executed
        # right now. They intentionally differ from _current_* when a running
        # cycle receives non-forced updates that should only apply to the next
        # repeat at cycle end.
        self._active_hvac_mode: VThermHvacMode | None = None
        self._active_on_time_sec: float = 0
        self._active_off_time_sec: float = 0
        self._active_on_percent: float = 0
        self._states: list[UnderlyingCycleState] = []
        self._penalty: float = 0.0
        self._cycle_start_time: float = 0.0
        # For valves, keep the applied-power segments within the current
        # master cycle so realized e_eff reflects real mid-cycle updates.
        self._valve_cycle_trace: list[tuple[float, float]] = []
        # Guard flags to keep is_cycle_running=True during async yield points
        # where timers are not yet (re-)installed:
        # _is_cancelling: True while cancel_cycle() awaits _fire_cycle_end_callbacks()
        # _is_starting:   True while start_cycle() awaits callbacks or device-control
        #                 operations before the new timers are set
        self._is_cancelling: bool = False
        self._is_starting: bool = False
        # Detect valve mode from underlying types
        self._is_valve_mode: bool = self._detect_valve_mode()

    @property
    def is_cycle_running(self) -> bool:
        """Return True if a cycle is currently scheduled or in a lifecycle transition.

        _is_cancelling stays True while cancel_cycle() awaits _fire_cycle_end_callbacks(),
        preventing concurrent start_cycle(force=False) from seeing is_cycle_running=False
        and starting a duplicate cycle during that window (Race 1).

        _is_starting stays True while start_cycle() has finished cancellation but has not
        yet finished device-control setup (including _fire_cycle_start_callbacks and
        _start_cycle_switch/_start_cycle_valve), preventing concurrent start_cycle(force=False)
        from starting a duplicate cycle during that window (Race 2).
        """
        return self._tick_unsub is not None or self._cycle_end_unsub is not None or self._is_cancelling or self._is_starting

    @property
    def is_valve_mode(self) -> bool:
        """Return True if managing valve underlyings (passthrough mode)."""
        return self._is_valve_mode

    def _detect_valve_mode(self) -> bool:
        """Detect if underlyings are valves by checking entity_type."""
        from .underlyings import UnderlyingEntityType  # pylint: disable=import-outside-toplevel
        if not self._underlyings:
            return False
        return self._underlyings[0].entity_type in (
            UnderlyingEntityType.VALVE,
            UnderlyingEntityType.VALVE_REGULATION,
        )

    def register_cycle_start_callback(self, callback: Callable):
        """Register a callback to be called at the start of each master cycle.

        Callback signature: async def callback(on_time_sec, off_time_sec, on_percent, hvac_mode)
        """
        self._on_cycle_start_callbacks.append(callback)

    def register_cycle_end_callback(self, callback: Callable[[float], Any]):
        """Register a callback to be called at the end of each master cycle."""
        self._on_cycle_end_callbacks.append(callback)

    def _set_pending_cycle(
        self,
        hvac_mode: VThermHvacMode | None,
        on_time_sec: float,
        off_time_sec: float,
        on_percent: float,
    ) -> None:
        """Store parameters that the next cycle repeat must use."""
        self._current_hvac_mode = hvac_mode
        self._current_on_time_sec = on_time_sec
        self._current_off_time_sec = off_time_sec
        self._current_on_percent = on_percent

    def _set_active_cycle(
        self,
        hvac_mode: VThermHvacMode | None,
        on_time_sec: float,
        off_time_sec: float,
        on_percent: float,
    ) -> None:
        """Store parameters of the master cycle currently being executed."""
        self._active_hvac_mode = hvac_mode
        self._active_on_time_sec = on_time_sec
        self._active_off_time_sec = off_time_sec
        self._active_on_percent = on_percent

    @staticmethod
    def _same_cycle_request(
        hvac_mode_a: VThermHvacMode | None,
        on_time_sec_a: float,
        off_time_sec_a: float,
        on_percent_a: float,
        hvac_mode_b: VThermHvacMode | None,
        on_time_sec_b: float,
        off_time_sec_b: float,
        on_percent_b: float,
    ) -> bool:
        """Return True when two cycle requests are effectively identical."""
        return (
            hvac_mode_a == hvac_mode_b
            and on_time_sec_a == on_time_sec_b
            and off_time_sec_a == off_time_sec_b
            and abs(on_percent_a - on_percent_b) <= 1e-9
        )

    async def start_cycle(
        self,
        hvac_mode: VThermHvacMode,
        on_percent: float,
        force: bool = False,
        _from_cycle_end: bool = False,
    ):
        """Start a new master cycle for all underlyings.

        Computes on_time_sec and off_time_sec from on_percent, applying
        min_activation_delay and min_deactivation_delay constraints.

        Args:
            hvac_mode: Current HVAC mode.
            on_percent: Power percentage as a fraction (0.0 to 1.0).
            force: If True, cancel any running cycle and restart immediately.
            _from_cycle_end: Internal flag — True when called from _on_master_cycle_end.
        """
        cycle_min = self._cycle_duration_sec / 60
        on_time_sec, off_time_sec, _ = calculate_cycle_times(
            on_percent,
            cycle_min,
            self.min_activation_delay,
            self.min_deactivation_delay,
        )
        realized_on_percent = on_time_sec / self._cycle_duration_sec if self._cycle_duration_sec > 0 else 0.0

        # Always update thermostat timing attributes immediately so sensors
        # reflect the latest computed value, even when the cycle returns early.
        self._thermostat._on_time_sec = on_time_sec
        self._thermostat._off_time_sec = off_time_sec

        if self.is_cycle_running and not force:
            if self._is_valve_mode:
                # Valve mode must keep the current master-cycle window for
                # learning callbacks, but the physical valve command still has
                # to follow each new regulation result immediately.
                _LOGGER.debug(
                    "%s - Valve cycle already running, applying immediate update: "
                    "on_time=%.0f, off_time=%.0f, on_percent=%.2f",
                    self._thermostat,
                    on_time_sec,
                    off_time_sec,
                    realized_on_percent,
                )
                await self._update_running_valve_cycle(
                    hvac_mode,
                    on_time_sec,
                    off_time_sec,
                    realized_on_percent,
                )
                return
            if self._active_on_time_sec > 0:
                # A real cycle is actively running — don't interrupt it.
                # Just update stored params so the next auto-repeat uses them.
                _LOGGER.debug(
                    "%s - Cycle already running (on_time=%.0fs), skipping (force=%s). "
                    "Updating params for next repeat: on_time=%.0f, off_time=%.0f, on_percent=%.2f",
                    self._thermostat,
                    self._active_on_time_sec,
                    force,
                    on_time_sec,
                    off_time_sec,
                    realized_on_percent,
                )
                self._set_pending_cycle(hvac_mode, on_time_sec, off_time_sec, realized_on_percent)
                return
            # Current cycle is idle (on_time=0, device off).
            # Keep it running if the requested idle cycle is unchanged; otherwise
            # cancel it and allow the new cycle to start immediately.
            if self._same_cycle_request(
                self._active_hvac_mode,
                self._active_on_time_sec,
                self._active_off_time_sec,
                self._active_on_percent,
                hvac_mode,
                on_time_sec,
                off_time_sec,
                realized_on_percent,
            ):
                _LOGGER.debug(
                    "%s - Current cycle is idle and unchanged, keeping existing cycle",
                    self._thermostat,
                )
                self._set_pending_cycle(hvac_mode, on_time_sec, off_time_sec, realized_on_percent)
                return
            _LOGGER.debug(
                "%s - Current cycle is idle (on_time=0), replacing with changed cycle",
                self._thermostat,
            )

        await self._cancel_cycle_impl()

        # Store current cycle parameters for repeat
        self._set_pending_cycle(hvac_mode, on_time_sec, off_time_sec, realized_on_percent)
        self._set_active_cycle(hvac_mode, on_time_sec, off_time_sec, realized_on_percent)

        # _is_starting guards the Race window: after _cancel_cycle_impl() cleared all
        # timers and flags, but before the new timers are set by _start_cycle_switch() or
        # _start_cycle_valve(). During this window, is_cycle_running must return True to
        # prevent concurrent start_cycle(force=False) from starting a duplicate full cycle.
        self._is_starting = True
        try:
            # Fire cycle start callbacks with realized percent so learners see actual applied power
            await self._fire_cycle_start_callbacks(on_time_sec, off_time_sec, realized_on_percent, hvac_mode)

            if self._is_valve_mode:
                await self._start_cycle_valve(hvac_mode)
            else:
                await self._start_cycle_switch(hvac_mode, on_time_sec, off_time_sec, realized_on_percent)
        finally:
            self._is_starting = False

    async def apply_valve_update(
        self,
        hvac_mode: VThermHvacMode,
        on_percent: float,
    ) -> None:
        """Apply a deferred valve recompute without running a full control cycle."""
        if not self._is_valve_mode:
            return

        cycle_min = self._cycle_duration_sec / 60
        on_time_sec, off_time_sec, _ = calculate_cycle_times(
            on_percent,
            cycle_min,
            self.min_activation_delay,
            self.min_deactivation_delay,
        )
        realized_on_percent = on_time_sec / self._cycle_duration_sec if self._cycle_duration_sec > 0 else 0.0

        self._thermostat._on_time_sec = on_time_sec
        self._thermostat._off_time_sec = off_time_sec
        self._set_pending_cycle(hvac_mode, on_time_sec, off_time_sec, realized_on_percent)

        if self.is_cycle_running:
            await self._update_running_valve_cycle(
                hvac_mode,
                on_time_sec,
                off_time_sec,
                realized_on_percent,
            )
            return

        self._set_active_cycle(hvac_mode, on_time_sec, off_time_sec, realized_on_percent)
        await self._apply_valve_command(hvac_mode, on_time_sec, off_time_sec)

    async def _update_running_valve_cycle(
        self,
        hvac_mode: VThermHvacMode,
        on_time_sec: float,
        off_time_sec: float,
        on_percent: float,
    ) -> None:
        """Apply a valve update while preserving the current master-cycle window."""
        self._set_pending_cycle(hvac_mode, on_time_sec, off_time_sec, on_percent)
        self._set_active_cycle(hvac_mode, on_time_sec, off_time_sec, on_percent)
        await self._apply_valve_command(hvac_mode, on_time_sec, off_time_sec)
        self._append_valve_cycle_trace(on_percent)

    async def _apply_valve_command(
        self,
        hvac_mode: VThermHvacMode,
        on_time_sec: float,
        off_time_sec: float,
    ) -> None:
        """Apply the latest valve command to all underlyings immediately."""
        for under in self._underlyings:
            under._on_time_sec = on_time_sec
            under._off_time_sec = off_time_sec
            under._hvac_mode = hvac_mode
            await under.set_valve_open_percent()

    def _reset_valve_cycle_trace(self, on_percent: float) -> None:
        """Start a new valve trace for the current master cycle."""
        self._valve_cycle_trace = [(0.0, max(0.0, min(1.0, on_percent)))]

    def _append_valve_cycle_trace(self, on_percent: float) -> None:
        """Append a new applied-power segment for a running valve cycle."""
        if self._cycle_start_time <= 0:
            self._reset_valve_cycle_trace(on_percent)
            return

        applied_on_percent = max(0.0, min(1.0, on_percent))
        if self._valve_cycle_trace:
            last_offset, last_on_percent = self._valve_cycle_trace[-1]
            if abs(last_on_percent - applied_on_percent) <= 1e-9:
                return
        else:
            last_offset = 0.0

        offset = min(
            max(0.0, time.time() - self._cycle_start_time),
            self._cycle_duration_sec,
        )
        offset = max(offset, last_offset)
        self._valve_cycle_trace.append((offset, applied_on_percent))

    async def _start_cycle_valve(self, hvac_mode: VThermHvacMode):
        """Valve passthrough: call set_valve_open_percent() on each underlying.

        Valves don't need temporal ON/OFF scheduling. They just need
        their open percentage updated. A master cycle window is still kept so
        cycle callbacks remain available to SmartPI in valve-based setups.
        """
        self._cycle_start_time = time.time()
        await self._apply_valve_command(
            hvac_mode,
            self._current_on_time_sec,
            self._current_off_time_sec,
        )
        self._reset_valve_cycle_trace(self._current_on_percent)
        self._cycle_end_unsub = async_call_later(
            self._hass,
            self._cycle_duration_sec,
            self._on_master_cycle_end,
        )

    async def _start_cycle_switch(
        self,
        hvac_mode: VThermHvacMode,
        on_time_sec: float,
        off_time_sec: float,
        on_percent: float,
    ):
        """Switch True Tick scheduling: initialize cycle and start ticking."""
        # Update on_time/off_time on each underlying for keep-alive and monitoring
        for under in self._underlyings:
            under._on_time_sec = on_time_sec
            under._off_time_sec = off_time_sec
            under._hvac_mode = hvac_mode

        if hvac_mode == VThermHvacMode_OFF or on_time_sec <= 0:
            # Turn off all underlyings
            for under in self._underlyings:
                if under.is_device_active:
                    await under.turn_off()
                under._should_be_on = False
            # Keep a real master-cycle start time so the next automatic restart
            # reports a full elapsed_ratio instead of looking interrupted with 0 s elapsed.
            self._cycle_start_time = time.time()
            # Schedule next cycle evaluation
            self._cycle_end_unsub = async_call_later(self._hass, self._cycle_duration_sec, self._on_master_cycle_end)
            return

        if on_time_sec >= self._cycle_duration_sec:
            # 100% power: Turn on all underlyings unconditionally to enforce state
            for under in self._underlyings:
                await under.turn_on()
                under._should_be_on = True
            # Keep a real master-cycle start time for the same reason as 0% cycles.
            self._cycle_start_time = time.time()
            # Schedule next cycle evaluation
            self._cycle_end_unsub = async_call_later(self._hass, self._cycle_duration_sec, self._on_master_cycle_end)
            return

        self._init_cycle(on_percent)

        # Start ticking immediately with is_initial=True to enforce state
        await self._tick(_is_initial=True)

        # Also ensure master cycle end is scheduled independently to wrap up the cycle
        self._cycle_end_unsub = async_call_later(self._hass, self._cycle_duration_sec, self._on_master_cycle_end)

    def _init_cycle(self, on_percent: float):
        """Initialize states and penalty for the new cycle.

        Uses circular offsets for evenly distributed power across underlyings,
        with natural wrap-around for smooth load distribution.
        """
        self._penalty = 0.0
        self._cycle_start_time = time.time()

        n = len(self._underlyings)
        on_time = self._cycle_duration_sec * on_percent
        offsets = compute_circular_offsets(self._cycle_duration_sec, n)

        self._states = []
        for i, under in enumerate(self._underlyings):
            state = UnderlyingCycleState(under, offsets[i])
            state.on_t = offsets[i]
            state.on_time = on_time
            state.off_t = (state.on_t + state.on_time) % self._cycle_duration_sec
            self._states.append(state)

        _LOGGER.debug(
            "%s - Initialized true tick cycle: on_percent=%.2f, offsets=%s",
            self._thermostat, on_percent, offsets
        )

    async def _tick(self, _now=None, _is_initial: bool = False):
        """Evaluate all underlyings and schedule the next tick.

        When _is_initial=True (called at cycle start), current_t is forced to 0.0
        to avoid floating-point drift, and the is_device_active check is skipped so
        the desired state is always enforced unconditionally on the first tick.
        """
        from homeassistant.util import dt as dt_util
        now = time.time()
        current_t = 0.0 if _is_initial else (now - self._cycle_start_time)

        if not _is_initial and current_t >= self._cycle_duration_sec:
            # We reached the end of the cycle; let the master cycle end handle it.
            return

        next_global_tick = self._cycle_duration_sec - current_t

        for state in self._states:
            under = state.underlying

            target_is_on, next_tick, state_duration = compute_target_state(
                state.on_t, state.off_t, current_t, self._cycle_duration_sec
            )

            # Update global next tick to the earliest upcoming event
            time_to_next = next_tick - current_t
            if time_to_next > 0 and time_to_next < next_global_tick:
                next_global_tick = time_to_next

            under_dt = 0.0
            if under.last_change:
                under_dt = (dt_util.utcnow() - under.last_change).total_seconds()
            else:
                under_dt = 999999.0  # Safe large value if no history

            if target_is_on:
                if not under.is_device_active:
                    action, new_on_t, pen_delta = evaluate_need_on(
                        under_dt, state_duration,
                        self.min_deactivation_delay, self.min_activation_delay,
                        state.on_t, current_t
                    )
                    if action == 'turn_on':
                        _LOGGER.info(
                            "%s - tick turn_on (state_duration=%.1fs, initial=%s)",
                            under, state_duration, _is_initial
                        )
                        try:
                            await under.turn_on()
                            under._should_be_on = True
                        except Exception as err:
                            _LOGGER.error("%s - tick turn_on failed: %s", under, err)
                    elif action == 'skip' and new_on_t is not None:
                        _LOGGER.debug(
                            "%s - tick skip turn_on (racollage), on_t shifted %.1f -> %.1f, penalty=%.1f",
                            under, state.on_t, new_on_t, pen_delta
                        )
                        state.on_t = new_on_t
                        self._penalty += pen_delta
                        resched_time = new_on_t - current_t
                        if 0 < resched_time < next_global_tick:
                            next_global_tick = resched_time
                elif _is_initial:
                    # Enforce state unconditionally on the first tick
                    if not under.is_device_active:
                        await under.turn_on()
                    under._should_be_on = True

            elif not target_is_on:
                if under.is_device_active:
                    action, new_off_t, pen_delta = evaluate_need_off(
                        under_dt, state_duration,
                        self.min_activation_delay, self.min_deactivation_delay,
                        state.off_t, current_t
                    )
                    if action == 'turn_off':
                        _LOGGER.info(
                            "%s - tick turn_off (state_duration=%.1fs, initial=%s)",
                            under, state_duration, _is_initial
                        )
                        try:
                            await under.turn_off()
                            under._should_be_on = False
                        except Exception as err:
                            _LOGGER.error("%s - tick turn_off failed: %s", under, err)
                    elif action == 'skip' and new_off_t is not None:
                        _LOGGER.debug(
                            "%s - tick skip turn_off (racollage), off_t shifted %.1f -> %.1f, penalty=%.1f",
                            under, state.off_t, new_off_t, pen_delta
                        )
                        state.off_t = new_off_t
                        self._penalty += pen_delta
                        resched_time = new_off_t - current_t
                        if 0 < resched_time < next_global_tick:
                            next_global_tick = resched_time
                elif _is_initial:
                    # Enforce state unconditionally on the first tick
                    if under.is_device_active:
                        try:
                            await under.turn_off()
                            under._should_be_on = False
                        except Exception as err:
                            _LOGGER.error("%s - initial turn_off failed: %s", under, err)

        # Ensure we do not schedule too fast (< 0.1s)
        next_global_tick = max(0.1, next_global_tick)

        # Schedule next tick
        self._tick_unsub = async_call_later(self._hass, next_global_tick, self._tick)

    async def _on_master_cycle_end(self, _now):
        """Called at the end of the master cycle. Restart with the same parameters.

        The cycle end callback (e_eff) is fired by cancel_cycle(), which is called
        inside start_cycle(force=True). This ensures exactly one callback per cycle
        end regardless of how the cycle terminates.
        """
        if not self.is_cycle_running:
            return

        # Increment energy counter
        self._thermostat.incremente_energy()

        # Restart cycle — cancel_cycle() inside start_cycle will fire the end callback.
        await self.start_cycle(
            self._current_hvac_mode,
            self._current_on_percent,
            force=True,
            _from_cycle_end=True,
        )

    def shutdown(self):
        """Cancel pending timers immediately without firing end-of-cycle callbacks.

        Must be called synchronously when the entity is being removed from HA so
        that leftover async_call_later handles cannot fire after the new entity
        (potentially with a different cycle duration) has already started.
        """
        if self._tick_unsub:
            self._tick_unsub()
            self._tick_unsub = None
        if self._cycle_end_unsub:
            self._cycle_end_unsub()
            self._cycle_end_unsub = None
        self._valve_cycle_trace = []
        self._set_pending_cycle(None, 0, 0, 0.0)
        self._set_active_cycle(None, 0, 0, 0.0)
        self._is_cancelling = False
        self._is_starting = False

    async def cancel_cycle(self):
        """Cancel the current cycle if one is running."""
        await self._cancel_cycle_impl()

    async def _cancel_cycle_impl(self):
        """Internal cancel logic, shared by cancel_cycle() and start_cycle()."""
        was_running = self.is_cycle_running

        # Set before unsubscribing so that is_cycle_running stays True
        # throughout this coroutine, even during the async yield below (Race 1 guard).
        self._is_cancelling = True

        if self._tick_unsub:
            self._tick_unsub()
            self._tick_unsub = None
        if self._cycle_end_unsub:
            self._cycle_end_unsub()
            self._cycle_end_unsub = None

        elapsed_sec = time.time() - self._cycle_start_time if self._cycle_start_time > 0 else 0

        # Fire end-of-cycle callback for cycles that ran long enough.
        # This includes normal ends (via _on_master_cycle_end -> start_cycle(force=True))
        # and mid-cycle interruptions (force restart on setpoint change, etc.).
        # During this await, _is_cancelling=True keeps is_cycle_running=True so any
        # concurrent start_cycle(force=False) call takes the update path, not full start.
        if was_running and elapsed_sec > 1.0:
            realized_e_eff = self._calculate_realized_e_eff(elapsed_sec)
            elapsed_ratio = min(1.0, elapsed_sec / self._cycle_duration_sec) if self._cycle_duration_sec > 0 else 1.0

            _LOGGER.debug("%s - cycle end: elapsed_sec=%.1f, realized_e_eff=%.3f, elapsed_ratio=%.2f", self._thermostat, elapsed_sec, realized_e_eff, elapsed_ratio)
            await self._fire_cycle_end_callbacks(realized_e_eff, elapsed_ratio)

        self._states = []
        self._valve_cycle_trace = []
        self._set_pending_cycle(None, 0, 0, 0.0)
        self._set_active_cycle(None, 0, 0, 0.0)
        self._cycle_start_time = 0.0
        # Reset only after all state is cleared so the guard stays active until fully done.
        self._is_cancelling = False
        _LOGGER.debug("%s - Cycle cancelled", self._thermostat)

    def _calculate_realized_e_eff(self, elapsed_sec: float) -> float:
        """Calculate the actual effective power applied over the given elapsed time."""
        if not self._underlyings or elapsed_sec <= 0:
            return 0.0

        if self._is_valve_mode:
            if not self._valve_cycle_trace:
                return max(0.0, min(1.0, self._active_on_percent))

            weighted_power = 0.0
            for idx, (start_offset, on_percent) in enumerate(self._valve_cycle_trace):
                if start_offset >= elapsed_sec:
                    break
                end_offset = elapsed_sec
                if idx + 1 < len(self._valve_cycle_trace):
                    end_offset = min(self._valve_cycle_trace[idx + 1][0], elapsed_sec)
                if end_offset > start_offset:
                    weighted_power += (end_offset - start_offset) * on_percent

            return max(0.0, min(1.0, weighted_power / elapsed_sec))

        # When _states is empty the cycle ran at either 0% or 100% (no tick scheduling).
        # Infer from _active_on_time_sec: if it covers the full duration, e_eff = 1.0.
        if not self._states:
            if self._active_on_time_sec >= self._cycle_duration_sec:
                return 1.0
            return 0.0

        t_on_actual = 0.0
        for state in self._states:
            if state.off_t >= state.on_t:
                start_on = min(state.on_t, elapsed_sec)
                end_on = min(state.off_t, elapsed_sec)
                if end_on > start_on:
                    t_on_actual += (end_on - start_on)
            else:
                end_on_1 = min(state.off_t, elapsed_sec)
                t_on_actual += end_on_1

                start_on_2 = min(state.on_t, elapsed_sec)
                end_on_2 = elapsed_sec
                if end_on_2 > start_on_2:
                    t_on_actual += (end_on_2 - start_on_2)

        # e_eff is the true instantaneous duty cycle over the elapsed window.
        e_eff = max(0.0, t_on_actual - self._penalty) / (elapsed_sec * len(self._underlyings))
        return max(0.0, min(1.0, e_eff))

    async def _fire_cycle_start_callbacks(
        self, on_time_sec, off_time_sec, on_percent, hvac_mode
    ):
        """Fire all registered cycle start callbacks."""
        for callback in self._on_cycle_start_callbacks:
            try:
                await callback(
                    on_time_sec=on_time_sec,
                    off_time_sec=off_time_sec,
                    on_percent=on_percent,
                    hvac_mode=hvac_mode,
                )
            except Exception as ex:
                _LOGGER.warning(
                    "%s - Error calling cycle start callback %s: %s",
                    self._thermostat,
                    callback,
                    ex,
                )

    async def _fire_cycle_end_callbacks(self, e_eff: float, elapsed_ratio: float = 1.0):
        """Fire all registered cycle end callbacks with e_eff and elapsed_ratio."""
        cycle_duration_min = self._cycle_duration_sec / 60.0
        for callback in self._on_cycle_end_callbacks:
            try:
                await callback(
                    e_eff=e_eff,
                    elapsed_ratio=elapsed_ratio,
                    cycle_duration_min=cycle_duration_min,
                )
            except Exception as ex:
                _LOGGER.warning(
                    "%s - Error calling cycle end callback %s: %s",
                    self._thermostat,
                    callback,
                    ex,
                )
