"""Lifecycle manager for learned heating slope (LHS) caching and refresh."""

from __future__ import annotations

import logging
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Callable

from ..domain.value_objects.heating import HeatingCycle

if TYPE_CHECKING:
    from ..domain.interfaces import ILhsStorage, ITimerScheduler
    from ..domain.services.contextual_lhs_calculator_service import ContextualLHSCalculatorService
    from ..domain.services.global_lhs_calculator_service import GlobalLHSCalculatorService

_LOGGER = logging.getLogger(__name__)

try:
    from homeassistant.util import dt as dt_util
except ImportError:
    dt_util = None  # For testing without HA


class LhsLifecycleManager:
    """Manage global and contextual LHS lifecycle, caching, and refresh.

    This manager is a singleton per IHP device (identified by device_id).
    It orchestrates:
    1. In-memory cache management (_cached_global_lhs, _cached_contextual_lhs)
    2. Persistent storage via ILhsStorage
    3. Periodic refresh scheduling (24h timer)
    4. Cascade updates triggered by HeatingCycleLifecycleManager

    Architecture:
    - **In-memory cache**: Fast lookups for global and contextual LHS values
    - **Model storage (ILhsStorage)**: Persistent storage for LHS values with timestamps
    - **Triggered by**: HeatingCycleLifecycleManager when cycles change

    Lazy Loading Strategy:
    ========================
    This class implements LAZY LOADING for contextual LHS to optimize startup performance:

    **Startup Phase:**
    - Global LHS: Loaded eagerly (single read from storage)
    - Contextual LHS: Loaded ONLY for current hour (datetime.now().hour)
    - Other 23 hours: NOT loaded at startup (deferred to on-demand)

    **On-Demand Loading:**
    - get_contextual_lhs(): Loads requested hour from storage (if not in memory)
    - ensure_contextual_lhs_populated(): Same lazy-load behavior with optional force_recalculate
    - Per-hour memory caching: Each hour cached independently after first load

    **Bulk Update Methods (intentionally load all 24 hours):**
    - on_retention_change(): Recalculates and caches all 24 hours when retention changes
    - on_24h_timer(): Recalculates and caches all 24 hours on periodic refresh
    - update_contextual_lhs_from_cycles(): Persists all 24 hours when called

    Cache Hierarchy (fastest to slowest):
    1. Memory cache (_cached_contextual_lhs[hour]): O(1) lookup
    2. Storage cache (ILhsStorage.get_cached_contextual_lhs): Single disk read
    3. Computation cache (calculate_contextual_lhs_for_hour): On-demand calculation

    Lazy Loading Strategy:
    ========================
    This class implements LAZY LOADING for contextual LHS to optimize startup performance:

    **Startup Phase:**
    - Global LHS: Loaded eagerly (single read from storage)
    - Contextual LHS: Loaded ONLY for current hour (datetime.now().hour)
    - Other 23 hours: NOT loaded at startup (deferred to on-demand)

    **On-Demand Loading:**
    - get_contextual_lhs(): Loads requested hour from storage (if not in memory)
    - ensure_contextual_lhs_populated(): Same lazy-load behavior with optional force_recalculate
    - Per-hour memory caching: Each hour cached independently after first load

    **Bulk Update Methods (intentionally load all 24 hours):**
    - on_retention_change(): Recalculates and caches all 24 hours when retention changes
    - on_24h_timer(): Recalculates and caches all 24 hours on periodic refresh
    - update_contextual_lhs_from_cycles(): Persists all 24 hours when called

    Cache Hierarchy (fastest to slowest):
    1. Memory cache (_cached_contextual_lhs[hour]): O(1) lookup
    2. Storage cache (ILhsStorage.get_cached_contextual_lhs): Single disk read
    3. Computation cache (calculate_contextual_lhs_for_hour): On-demand calculation

    Lifecycle Events:
    - startup(): Load global LHS + current hour contextual (lazy loading)
    - on_retention_change(cycles): Recalculate ALL 24 hours with new retention window
    - on_24h_timer(cycles): Recalculate ALL 24 hours on timer event
    - update_global_lhs_from_cycles(cycles): Persist global LHS from cycles
    - update_contextual_lhs_from_cycles(cycles): Persist ALL 24 contextual hours from cycles
    - cancel(): Cleanup timers and release resources (keep cached data)
    - startup(): Load global LHS + current hour contextual (lazy loading)
    - on_retention_change(cycles): Recalculate ALL 24 hours with new retention window
    - on_24h_timer(cycles): Recalculate ALL 24 hours on timer event
    - update_global_lhs_from_cycles(cycles): Persist global LHS from cycles
    - update_contextual_lhs_from_cycles(cycles): Persist ALL 24 contextual hours from cycles
    - cancel(): Cleanup timers and release resources (keep cached data)

    Cascade Pattern:
    HeatingCycleLifecycleManager calls update_*_lhs_from_cycles() when:
    - startup(): Initial cycles extracted
    - on_retention_change(): Cycles re-extracted with new retention
    - on_24h_timer(): Cycles refreshed with latest data
    """

    def __init__(
        self,
        model_storage: ILhsStorage,
        global_lhs_calculator: GlobalLHSCalculatorService,
        contextual_lhs_calculator: ContextualLHSCalculatorService,
        timer_scheduler: ITimerScheduler | None = None,
    ) -> None:
        """Initialize the lifecycle manager.

        Note: This should be instantiated via LhsLifecycleManagerFactory
        to ensure singleton behavior per device_id.

        Args:
            model_storage: Persistent storage adapter for cached LHS values.
            global_lhs_calculator: Domain service for computing global LHS from cycles.
            contextual_lhs_calculator: Domain service for computing contextual LHS by hour from cycles.
            timer_scheduler: Optional scheduler for periodic 24h refresh tasks.
        """
        self._model_storage = model_storage
        self._global_lhs_calculator = global_lhs_calculator
        self._contextual_lhs_calculator = contextual_lhs_calculator
        self._timer_scheduler = timer_scheduler

        # In-memory caches for fast repeated lookups (avoids disk I/O)
        # These are loaded from storage on startup and invalidated on updates
        self._cached_global_lhs: float | None = None  # Global LHS value
        self._cached_contextual_lhs: dict[int, float] = {}  # hour (0-23) -> LHS value
        self._timer_cancel_func: Callable[[], None] | None = None

    async def startup(self) -> None:
        """Initialize LHS caches and schedule periodic refresh.

        Lifecycle Event Flow:
        1. Load cached global LHS from storage → memory cache
        2. Load cached contextual LHS for CURRENT HOUR ONLY (lazy loading)
        3. Schedule 24h timer for automatic refresh (if scheduler provided)

        Lazy Loading Strategy:
        This method implements lazy loading to optimize startup performance:
        - **Current hour**: Loaded eagerly during startup (most frequently used)
        - **Other 23 hours**: Loaded on-demand via get_contextual_lhs() or ensure_contextual_lhs_populated()
        - **Memory cache**: Per-hour caching in _cached_contextual_lhs (fast lookups)
        - **Storage cache**: Persistent on-disk cache (fallback if not in memory)
        - **Computation cache**: Computed on-demand when no cached value exists

        Cache Strategy:
        - **Reads from storage**: model_storage.get_cached_global_lhs()
        - **Reads from storage**: model_storage.get_cached_contextual_lhs() for CURRENT HOUR ONLY
        - **Writes to memory**: Populates _cached_global_lhs and _cached_contextual_lhs[current_hour]
        - **Does NOT write to storage**: Only loads existing cached values
        - **Does NOT compute**: Uses cached values or returns defaults via get_* methods

        Note:
        - Initial LHS values are computed and stored by HeatingCycleLifecycleManager during its startup
        - Bulk updates (on_retention_change(), on_24h_timer()) intentionally load all 24 hours
        - On-demand loads (get_contextual_lhs(), ensure_contextual_lhs_populated()) load per-hour

        Returns:
            None.
        """
        _LOGGER.debug("Entering LhsLifecycleManager.startup")

        try:
            # Load cached global LHS
            cached_global_lhs = await self._model_storage.get_cached_global_lhs()
            if cached_global_lhs is not None:
                # Extract value from LHSCacheEntry
                lhs_value = cached_global_lhs.value
                self._cached_global_lhs = lhs_value
                _LOGGER.debug("Loaded cached global LHS: %.2f °C/h", lhs_value)

            # Lazy Loading: Load contextual LHS for current hour ONLY
            # Other hours will be loaded on-demand via get_contextual_lhs() or ensure_contextual_lhs_populated()
            current_hour = datetime.now().hour if dt_util is None else dt_util.now().hour
            _LOGGER.debug(
                "Loading contextual LHS for current hour: %d (lazy loading enabled)", current_hour
            )

            cached_contextual = await self._model_storage.get_cached_contextual_lhs(current_hour)
            if cached_contextual is not None:
                # Extract value from LHSCacheEntry
                lhs_value = cached_contextual.value
                self._cached_contextual_lhs[current_hour] = lhs_value
                _LOGGER.debug(
                    "Loaded cached contextual LHS for current hour %d: %.2f °C/h",
                    current_hour,
                    lhs_value,
                )
            else:
                _LOGGER.debug(
                    "No cached contextual LHS for current hour %d; will load on-demand",
                    current_hour,
                )

            _LOGGER.info(
                "LHS startup complete: loaded global LHS and current hour contextual LHS (lazy loading enabled)"
            )
        except Exception as exc:
            _LOGGER.error("Error during startup: %s", exc)
            # Don't re-raise - continue with defaults

        # Schedule 24h timer for automatic refresh
        if self._timer_scheduler is not None:
            if dt_util is not None:
                next_refresh = dt_util.now() + timedelta(hours=24)
            else:
                next_refresh = datetime.now() + timedelta(hours=24)

            # Timer callback: no-op placeholder (cycles provided by HeatingCycleLifecycleManager)
            async def noop_callback() -> None:
                """Placeholder timer callback - actual update comes from HCLM."""
                pass

            self._timer_cancel_func = self._timer_scheduler.schedule_timer(
                next_refresh, noop_callback
            )
            _LOGGER.debug("Scheduled 24h LHS refresh timer for %s", next_refresh.isoformat())

        _LOGGER.debug("Exiting LhsLifecycleManager.startup")

    async def on_retention_change(self, cycles: list[HeatingCycle]) -> None:
        """Handle retention configuration changes.

        Lifecycle Event Flow (triggered by HeatingCycleLifecycleManager):
        1. HeatingCycleLifecycleManager re-extracts cycles for new retention window
        2. HeatingCycleLifecycleManager calls this method with the new cycles
        3. Invalidate in-memory caches FIRST (prevents use of stale data)
        4. Recalculate global LHS from provided cycles
        5. Recalculate contextual LHS (by hour) from provided cycles
        6. Persist new LHS values to storage

        Cache Strategy:
        - **Invalidates memory FIRST**: Clears _cached_global_lhs and _cached_contextual_lhs
        - **Receives cycles from**: HeatingCycleLifecycleManager.on_retention_change()
        - **Computes**: global_lhs_calculator.calculate_global_lhs(cycles)
        - **Computes**: contextual_lhs_calculator.calculate_contextual_lhs(cycles)
        - **Writes to storage**: model_storage.set_cached_global_lhs()
        - **Writes to storage**: model_storage.set_cached_contextual_lhs(hour, value)

        Args:
            cycles: Heating cycles extracted for the new retention window.

        Returns:
            None.
        """
        from ..domain.constants import DEFAULT_LEARNED_SLOPE, MINIMUM_REALISTIC_LHS

        _LOGGER.debug("Entering LhsLifecycleManager.on_retention_change")
        _LOGGER.debug("Recalculating LHS from %d cycles after retention change", len(cycles))

        # Step 1: Invalidate in-memory caches FIRST (before any computation)
        self._cached_global_lhs = None
        self._cached_contextual_lhs = {}
        _LOGGER.debug("Invalidated in-memory caches before recalculation")

        # Step 2: Recalculate global LHS from provided cycles
        global_lhs = self._global_lhs_calculator.calculate_global_lhs(cycles)

        # Validate: LHS must be realistically positive (>= 0.5°C/h)
        if global_lhs < MINIMUM_REALISTIC_LHS:
            _LOGGER.warning(
                "Calculated global LHS is invalid (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)",
                global_lhs,
                MINIMUM_REALISTIC_LHS,
                DEFAULT_LEARNED_SLOPE,
            )
            global_lhs = DEFAULT_LEARNED_SLOPE

        updated_at = dt_util.now() if dt_util is not None else datetime.now()
        await self._model_storage.set_cached_global_lhs(global_lhs, updated_at)
        _LOGGER.info("Recalculated global LHS: %.2f °C/h", global_lhs)

        # Step 3: Recalculate contextual LHS from provided cycles
        contextual_lhs_by_hour = self._contextual_lhs_calculator.calculate_all_contextual_lhs(
            cycles
        )
        for hour, lhs_value in contextual_lhs_by_hour.items():
            if lhs_value is not None and lhs_value > 0:
                await self._model_storage.set_cached_contextual_lhs(hour, lhs_value, updated_at)
        _LOGGER.debug("Recalculated contextual LHS for %d hours", len(contextual_lhs_by_hour))

        _LOGGER.debug("Exiting LhsLifecycleManager.on_retention_change")

    async def on_24h_timer(self, cycles: list[HeatingCycle]) -> None:
        """Handle periodic 24h refresh execution.

        Lifecycle Event Flow (triggered by HeatingCycleLifecycleManager):
        1. HeatingCycleLifecycleManager extracts fresh cycles for retention window
        2. HeatingCycleLifecycleManager calls this method with the fresh cycles
        3. Recalculate global LHS from provided cycles
        4. Recalculate contextual LHS (by hour) from provided cycles
        5. Persist new LHS values to storage
        6. Invalidate in-memory caches (will reload from storage on next access)

        Cache Strategy:
        - **Receives cycles from**: HeatingCycleLifecycleManager.on_24h_timer()
        - **Computes**: global_lhs_calculator.calculate_global_lhs(cycles)
        - **Computes**: contextual_lhs_calculator.calculate_contextual_lhs(cycles)
        - **Writes to storage**: model_storage.set_cached_global_lhs()
        - **Writes to storage**: model_storage.set_cached_contextual_lhs(hour, value)
        - **Invalidates memory**: Sets _cached_global_lhs = None, _cached_contextual_lhs = {}

        Args:
            cycles: Heating cycles extracted for the current retention window.

        Returns:
            None.
        """
        from ..domain.constants import DEFAULT_LEARNED_SLOPE, MINIMUM_REALISTIC_LHS

        _LOGGER.debug("Entering LhsLifecycleManager.on_24h_timer")
        _LOGGER.info("24h LHS refresh timer triggered")

        # Recalculate global LHS from provided cycles
        global_lhs = self._global_lhs_calculator.calculate_global_lhs(cycles)

        # Validate: LHS must be realistically positive (>= 0.5°C/h)
        if global_lhs < MINIMUM_REALISTIC_LHS:
            _LOGGER.warning(
                "Calculated global LHS is invalid (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)",
                global_lhs,
                MINIMUM_REALISTIC_LHS,
                DEFAULT_LEARNED_SLOPE,
            )
            global_lhs = DEFAULT_LEARNED_SLOPE

        updated_at = dt_util.now() if dt_util is not None else datetime.now()
        await self._model_storage.set_cached_global_lhs(global_lhs, updated_at)
        # Invalidate cache to ensure fresh load
        self._cached_global_lhs = None
        _LOGGER.info("Refreshed global LHS: %.2f °C/h", global_lhs)

        # Recalculate contextual LHS from provided cycles
        contextual_lhs_by_hour = self._contextual_lhs_calculator.calculate_all_contextual_lhs(
            cycles
        )
        for hour, lhs_value in contextual_lhs_by_hour.items():
            if lhs_value is not None and lhs_value > 0:
                await self._model_storage.set_cached_contextual_lhs(hour, lhs_value, updated_at)
        # Invalidate cache to ensure fresh load
        self._cached_contextual_lhs = {}

        _LOGGER.debug("Exiting LhsLifecycleManager.on_24h_timer")

    async def get_global_lhs(self) -> float:
        """Return the global learned heating slope (LHS).

        Cache Strategy (read-only, optimized with memory cache):
        - **Reads from memory first**: Checks _cached_global_lhs
        - **On memory cache hit**: Returns immediately (fast path)
        - **On memory cache miss**: Loads from model_storage.get_cached_global_lhs()
        - **Writes to memory**: Caches loaded value in _cached_global_lhs
        - **Validation**: Returns default if cached value is invalid (<=0 or None)
        - **Does NOT write to storage**: Read-only operation

        Use Case:
        Called frequently during anticipation calculations to get the baseline heating rate.
        Memory cache ensures fast lookups without repeated disk I/O.

        Returns:
            The cached or default global LHS in C/hour.
        """
        from ..domain.constants import DEFAULT_LEARNED_SLOPE

        _LOGGER.debug("Entering LhsLifecycleManager.get_global_lhs")

        # Check in-memory cache first (fast path)
        if self._cached_global_lhs is not None:
            _LOGGER.debug(
                "Returning in-memory cached global LHS: %.2f °C/h", self._cached_global_lhs
            )
            _LOGGER.debug("Exiting LhsLifecycleManager.get_global_lhs")
            return self._cached_global_lhs

        # Not in memory cache, load from storage
        cached_entry = await self._model_storage.get_cached_global_lhs()

        # Validate cached value is positive
        if cached_entry is not None:
            cached_lhs = cached_entry.value
            if cached_lhs > 0:
                # Cache in memory for subsequent calls
                self._cached_global_lhs = cached_lhs
                _LOGGER.debug("Loaded from storage and cached global LHS: %.2f °C/h", cached_lhs)
                _LOGGER.debug("Exiting LhsLifecycleManager.get_global_lhs")
                return cached_lhs

        # Return default if cache invalid or missing
        _LOGGER.debug(
            "No valid cached global LHS, returning default: %.2f °C/h", DEFAULT_LEARNED_SLOPE
        )
        _LOGGER.debug("Exiting LhsLifecycleManager.get_global_lhs")
        return DEFAULT_LEARNED_SLOPE

    async def cancel(self) -> None:
        """Cancel any scheduled refresh work and release resources.

        Cache Strategy:
        - **Memory cache**: NOT cleared (remains valid until next update)
        - **Storage cache**: NOT cleared (persistent data remains)
        - **Timers**: Cancelled to stop periodic refresh

        Use Case:
        Called when the IHP device is being shut down or removed.
        Does NOT clear learned data, only stops active timers.

        Returns:
            None.
        """
        _LOGGER.debug("Entering LhsLifecycleManager.cancel")

        # Cancel timer if scheduled
        if self._timer_cancel_func is not None:
            try:
                self._timer_cancel_func()
                _LOGGER.debug("Cancelled scheduled timer")
            except Exception as exc:
                _LOGGER.error("Error cancelling timer: %s", exc)
            finally:
                self._timer_cancel_func = None

        _LOGGER.debug("Exiting LhsLifecycleManager.cancel")

    async def get_contextual_lhs(
        self,
        target_time: datetime,
        cycles: list[HeatingCycle],
    ) -> float:
        """Return contextual LHS for a target time.

        Cache Strategy (read-only, optimized with memory cache per hour):
        - **Reads from memory first**: Checks _cached_contextual_lhs[target_hour]
        - **On memory cache hit**: Returns immediately (fast path)
        - **On memory cache miss**: Loads from model_storage.get_cached_contextual_lhs(hour)
        - **If no storage cache**: Computes from provided cycles
        - **Writes to memory**: Caches loaded/computed value in _cached_contextual_lhs[hour]
        - **Falls back to global LHS**: If no contextual data exists for the hour
        - **Does NOT write to storage**: Read-only operation (use update_contextual_lhs_from_cycles)

        Use Case:
        Called during anticipation calculations to get hour-specific heating rates.
        Memory cache per hour ensures fast lookups without repeated disk I/O.

        Args:
            target_time: Target datetime used to select the contextual hour (0-23).
            cycles: Heating cycles used to compute contextual LHS when not cached.

        Returns:
            Contextual LHS for the target hour in C/hour, or global LHS fallback.
        """
        _LOGGER.debug("Entering LhsLifecycleManager.get_contextual_lhs")

        target_hour = target_time.hour
        _LOGGER.debug("Getting contextual LHS for hour %d", target_hour)

        # Check in-memory cache first (fast path)
        if target_hour in self._cached_contextual_lhs:
            cached_value = self._cached_contextual_lhs[target_hour]
            _LOGGER.debug(
                "Returning in-memory cached contextual LHS for hour %d: %.2f °C/h",
                target_hour,
                cached_value,
            )
            _LOGGER.debug("Exiting LhsLifecycleManager.get_contextual_lhs")
            return cached_value

        # Not in memory cache, try to get from storage
        cached_entry = await self._model_storage.get_cached_contextual_lhs(target_hour)

        if cached_entry is not None:
            cached_contextual = cached_entry.value
            # Cache in memory for subsequent calls
            self._cached_contextual_lhs[target_hour] = cached_contextual
            _LOGGER.debug(
                "Loaded from storage and cached contextual LHS for hour %d: %.2f °C/h",
                target_hour,
                cached_contextual,
            )
            _LOGGER.debug("Exiting LhsLifecycleManager.get_contextual_lhs")
            return cached_contextual

        # No cache, compute contextual LHS for THIS HOUR ONLY (not all 24)
        _LOGGER.debug("No cached contextual LHS for hour %d, computing from cycles", target_hour)
        computed_lhs = self._contextual_lhs_calculator.calculate_contextual_lhs_for_hour(
            cycles, target_hour
        )

        # If contextual LHS is None or invalid (< 0.5°C/h), fallback to global LHS
        from ..domain.constants import MINIMUM_REALISTIC_LHS

        if computed_lhs is None or computed_lhs < MINIMUM_REALISTIC_LHS:
            _LOGGER.debug(
                "Contextual LHS for hour %d is invalid (%.2f°C/h < %.2f°C/h), falling back to global LHS",
                target_hour,
                computed_lhs or 0,
                MINIMUM_REALISTIC_LHS,
            )
            return await self.get_global_lhs()

        # Cache computed value in memory
        self._cached_contextual_lhs[target_hour] = computed_lhs
        _LOGGER.debug(
            "Computed and cached contextual LHS for hour %d: %.2f °C/h",
            target_hour,
            computed_lhs,
        )
        _LOGGER.debug("Exiting LhsLifecycleManager.get_contextual_lhs")
        return computed_lhs

    async def update_global_lhs_from_cycles(self, cycles: list[HeatingCycle]) -> float:
        """Recalculate and persist global LHS from cycles.

        Lifecycle Event (triggered by HeatingCycleLifecycleManager):
        This is called when:
        - HeatingCycleLifecycleManager.startup(): Initial cycles extracted
        - HeatingCycleLifecycleManager.on_retention_change(): Cycles re-extracted
        - HeatingCycleLifecycleManager.on_24h_timer(): Cycles refreshed

        Cache Strategy (write operation):
        - **Receives cycles from**: HeatingCycleLifecycleManager
        - **Computes**: global_lhs_calculator.calculate_global_lhs(cycles)
        - **Writes to storage**: model_storage.set_cached_global_lhs(lhs, timestamp)
        - **Invalidates memory**: Sets _cached_global_lhs = None
        - **Next read**: get_global_lhs() will reload from storage into memory

        Args:
            cycles: Heating cycles used to compute the global LHS.

        Returns:
            The computed and persisted global LHS in C/hour.
        """
        from ..domain.constants import DEFAULT_LEARNED_SLOPE, MINIMUM_REALISTIC_LHS

        _LOGGER.debug("Entering LhsLifecycleManager.update_global_lhs_from_cycles")
        _LOGGER.debug("Updating global LHS from %d cycles", len(cycles))

        try:
            global_lhs = self._global_lhs_calculator.calculate_global_lhs(cycles)

            # Validate: LHS must be realistically positive (>= 0.5°C/h)
            if global_lhs < MINIMUM_REALISTIC_LHS:
                _LOGGER.warning(
                    "Calculated global LHS is invalid (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)",
                    global_lhs,
                    MINIMUM_REALISTIC_LHS,
                    DEFAULT_LEARNED_SLOPE,
                )
                global_lhs = DEFAULT_LEARNED_SLOPE

            updated_at = dt_util.now() if dt_util is not None else datetime.now()
            await self._model_storage.set_cached_global_lhs(global_lhs, updated_at)

            # Update in-memory cache with new value for fast subsequent access
            self._cached_global_lhs = global_lhs
            _LOGGER.debug("Updated global LHS in-memory cache: %.2f °C/h", global_lhs)

            _LOGGER.info("Updated global LHS: %.2f °C/h", global_lhs)
            _LOGGER.debug("Exiting LhsLifecycleManager.update_global_lhs_from_cycles")
            return global_lhs
        except Exception as exc:
            _LOGGER.error("Error calculating global LHS: %s", exc)
            _LOGGER.debug("Exiting LhsLifecycleManager.update_global_lhs_from_cycles")
            return DEFAULT_LEARNED_SLOPE

    async def update_contextual_lhs_from_cycles(
        self,
        cycles: list[HeatingCycle],
    ) -> dict[int, float | None]:
        """Recalculate and persist contextual LHS for all hours.

        Lifecycle Event (triggered by HeatingCycleLifecycleManager):
        This is called when:
        - HeatingCycleLifecycleManager.startup(): Initial cycles extracted
        - HeatingCycleLifecycleManager.on_retention_change(): Cycles re-extracted
        - HeatingCycleLifecycleManager.on_24h_timer(): Cycles refreshed

        Cache Strategy (write operation):
        - **Receives cycles from**: HeatingCycleLifecycleManager
        - **Computes**: contextual_lhs_calculator.calculate_contextual_lhs(cycles)
        - **Writes to storage**: model_storage.set_cached_contextual_lhs(hour, lhs, timestamp) for each hour
        - **Invalidates memory**: Sets _cached_contextual_lhs = {}
        - **Next read**: get_contextual_lhs() will reload from storage into memory per hour

        Args:
            cycles: Heating cycles used to compute contextual LHS by hour.

        Returns:
            Mapping of hour (0-23) to LHS in C/hour, or None when no data exists.
        """
        _LOGGER.debug("Entering LhsLifecycleManager.update_contextual_lhs_from_cycles")
        _LOGGER.debug("Updating contextual LHS from %d cycles", len(cycles))

        contextual_lhs_by_hour = self._contextual_lhs_calculator.calculate_all_contextual_lhs(
            cycles
        )

        # Persist non-None values with timestamp
        updated_at = dt_util.now() if dt_util is not None else datetime.now()

        persisted_count = 0
        for hour, lhs_value in contextual_lhs_by_hour.items():
            if lhs_value is not None:
                await self._model_storage.set_cached_contextual_lhs(hour, lhs_value, updated_at)
                persisted_count += 1

        # Update in-memory cache with new values for fast subsequent access
        # Only cache non-None values
        self._cached_contextual_lhs = {
            hour: lhs for hour, lhs in contextual_lhs_by_hour.items() if lhs is not None
        }
        _LOGGER.debug(
            "Updated contextual LHS in-memory cache for %d hours", len(self._cached_contextual_lhs)
        )

        _LOGGER.info("Updated contextual LHS for %d hours", persisted_count)
        _LOGGER.debug("Exiting LhsLifecycleManager.update_contextual_lhs_from_cycles")

        return contextual_lhs_by_hour

    async def ensure_contextual_lhs_populated(
        self,
        target_hour: int,
        cycles: list[HeatingCycle],
        force_recalculate: bool = False,
    ) -> float:
        """Ensure contextual LHS is populated for a specific hour.

        Lazy Population Strategy:
        This method is called when anticipation calculation needs LHS for a specific hour
        but it's not in the cache (memory or storage).

        Cache Strategy (write operation with lazy loading):
        - **If force_recalculate=True**: Skip cache checks, recompute immediately
        - **Reads from memory first**: Checks _cached_contextual_lhs[target_hour]
        - **On memory cache hit**: Returns immediately (fast path)
        - **Reads from storage**: Tries model_storage.get_cached_contextual_lhs(hour)
        - **On storage cache hit**: Loads into memory, returns
        - **On cache miss**: Computes from provided cycles
        - **Writes to storage**: model_storage.set_cached_contextual_lhs(hour, value, timestamp)
        - **Writes to memory**: Updates _cached_contextual_lhs[hour]
        - **Fallback**: Returns global LHS if no contextual data exists

        Use Case:
        Called during anticipation calculations when:
        - Cache is cold (first run after startup)
        - Hour-specific LHS was never calculated
        - Explicit refresh requested (force_recalculate=True)

        Args:
            target_hour: Hour to ensure LHS is populated for (0-23).
            cycles: Heating cycles to use if computation is needed.
            force_recalculate: If True, bypass cache and recompute from cycles.

        Returns:
            Contextual LHS for the target hour in C/hour, or global LHS fallback.
        """
        _LOGGER.debug("Entering LhsLifecycleManager.ensure_contextual_lhs_populated")
        _LOGGER.debug(
            "Ensuring contextual LHS populated for hour %d (force_recalculate=%s)",
            target_hour,
            force_recalculate,
        )

        # If force_recalculate, skip cache checks and recompute THIS HOUR ONLY
        if force_recalculate:
            _LOGGER.debug("Force recalculate enabled, bypassing cache for hour %d", target_hour)
            computed_lhs = self._contextual_lhs_calculator.calculate_contextual_lhs_for_hour(
                cycles, target_hour
            )

            # Persist if value exists
            if computed_lhs is not None:
                updated_at = dt_util.now() if dt_util is not None else datetime.now()
                await self._model_storage.set_cached_contextual_lhs(
                    target_hour, computed_lhs, updated_at
                )
                # Update memory cache
                self._cached_contextual_lhs[target_hour] = computed_lhs
                _LOGGER.debug(
                    "Forced recalculation: contextual LHS for hour %d: %.2f °C/h",
                    target_hour,
                    computed_lhs,
                )
                _LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
                return computed_lhs

            # No contextual data, fallback to global LHS
            _LOGGER.debug("No contextual LHS for hour %d, falling back to global", target_hour)
            global_lhs = await self.get_global_lhs()
            _LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
            return global_lhs

        # Check memory cache first (fast path)
        if target_hour in self._cached_contextual_lhs:
            cached_value = self._cached_contextual_lhs[target_hour]
            _LOGGER.debug(
                "Contextual LHS already in memory cache for hour %d: %.2f °C/h",
                target_hour,
                cached_value,
            )
            _LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
            return cached_value

        # Check storage cache
        cached_entry = await self._model_storage.get_cached_contextual_lhs(target_hour)

        if cached_entry is not None:
            cached_contextual = cached_entry.value
            # Load into memory cache for subsequent calls
            self._cached_contextual_lhs[target_hour] = cached_contextual
            _LOGGER.debug(
                "Loaded contextual LHS from storage for hour %d: %.2f °C/h",
                target_hour,
                cached_contextual,
            )
            _LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
            return cached_contextual

        # No cache, compute contextual LHS for THIS HOUR ONLY (not all 24)
        _LOGGER.debug("No cached contextual LHS for hour %d, computing from cycles", target_hour)
        computed_lhs = self._contextual_lhs_calculator.calculate_contextual_lhs_for_hour(
            cycles, target_hour
        )

        # If contextual LHS exists, persist and cache
        if computed_lhs is not None:
            updated_at = dt_util.now() if dt_util is not None else datetime.now()
            await self._model_storage.set_cached_contextual_lhs(
                target_hour, computed_lhs, updated_at
            )
            # Cache in memory for subsequent calls
            self._cached_contextual_lhs[target_hour] = computed_lhs
            _LOGGER.debug(
                "Computed and cached contextual LHS for hour %d: %.2f °C/h",
                target_hour,
                computed_lhs,
            )
            _LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
            return computed_lhs

        # No contextual data, fallback to global LHS
        _LOGGER.debug("No contextual LHS for hour %d, falling back to global LHS", target_hour)
        global_lhs = await self.get_global_lhs()
        _LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
        return global_lhs
