"""Auto TPI Manager implementing TPI algorithm."""

import logging
from vtherm_api.log_collector import get_vtherm_logger
import json
import os
import math
import statistics
from datetime import datetime, timedelta
from typing import Optional
from homeassistant.util.unit_conversion import TemperatureConverter
from homeassistant.const import UnitOfTemperature
from dataclasses import dataclass, asdict, field

import asyncio
from typing import Callable

from homeassistant.core import HomeAssistant, callback
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers import entity_platform, service, translation
from homeassistant.helpers.storage import Store
from homeassistant.components.recorder import history, get_instance
from homeassistant.util import dt as dt_util
from functools import partial

from .const import (
    DOMAIN,
    CONF_TPI_COEF_INT,
    CONF_TPI_COEF_EXT,
    CONF_AUTO_TPI_HEATING_POWER,
    CONF_AUTO_TPI_COOLING_POWER,
)
from .vtherm_central_api import VersatileThermostatAPI

_LOGGER = get_vtherm_logger(__name__)

STORAGE_VERSION = 8
STORAGE_KEY_PREFIX = "versatile_thermostat.auto_tpi"

# Configurable constants for learning algorithm behavior
MIN_KINT = 0.01  # Minimum Kint threshold to maintain temperature responsiveness
OVERSHOOT_THRESHOLD = 0.2  # Temperature overshoot threshold (°C) to trigger aggressive Kext correction
OVERSHOOT_POWER_THRESHOLD = 0.05  # Minimum power (5%) to consider overshoot as Kext error
OVERSHOOT_CORRECTION_BOOST = 2.0  # Multiplier for alpha during overshoot correction
NATURAL_RECOVERY_POWER_THRESHOLD = 0.10  # Max power (10%) to consider temperature change as natural recovery
KEXT_LEARNING_MAX_GAP = 1.0  # Max gap (°C) to allow Kext learning (Near-Field vs Far-Field)
INSUFFICIENT_RISE_GAP_THRESHOLD = KEXT_LEARNING_MAX_GAP  # Min gap (°C) to trigger Kint correction when temp stagnates
INSUFFICIENT_RISE_BOOST_FACTOR = 1.08  # Kint increase factor (8%) per stagnating cycle
MAX_CONSECUTIVE_KINT_BOOSTS = 5  # Max consecutive Kint boosts before warning (undersized heating)
MIN_PRE_BOOTSTRAP_CALIBRATION_RELIABILITY = 20.0  # Min reliability (%) to use calibration instead of bootstrap
MIN_EFFICIENCY_FOR_CAPACITY = 0.60  # Min efficiency (60%) to learn capacity - prevents outliers from external factors


@dataclass
class AutoTpiState:
    """Persistent state for Auto TPI algorithm."""

    # Learning coefficients (heat)
    coeff_indoor_heat: float = 0.1
    coeff_outdoor_heat: float = 0.01
    coeff_indoor_autolearn: int = 1  # Counter
    coeff_outdoor_autolearn: int = 0

    # Learning coefficients for Cool
    coeff_indoor_cool: float = 0.1
    coeff_outdoor_cool: float = 0.01
    coeff_indoor_cool_autolearn: int = 1
    coeff_outdoor_cool_autolearn: int = 0

    # Max Capacity (physical power of the system)
    max_capacity_heat: float = 0.0
    max_capacity_cool: float = 0.0

    # Offsets.
    offset: float = 0.0

    # Previous cycle state (Snapshot for learning)
    last_power: float = 0.0
    last_order: float = 0.0
    last_temp_in: float = 0.0
    last_temp_out: float = 0.0
    last_state: str = "stop"  # 'heat', 'cool', 'stop'
    previous_state: str = "stop"  # State of the previous cycle
    last_on_temp_in: float = 0.0  # Temp at the end of ON time
    last_update_date: Optional[datetime] = None
    last_heater_stop_time: Optional[datetime] = None  # When heater stopped

    # Cycle management
    cycle_start_date: Optional[datetime] = None  # Start of current cycle
    cycle_active: bool = False
    current_cycle_cold_factor: float = 0.0  # 1.0 = cold, 0.0 = hot
    current_cycle_params: dict = None  # Parameters of the current/last cycle

    # Management
    consecutive_failures: int = 0
    autolearn_enabled: bool = False
    last_learning_status: str = "startup"
    total_cycles: int = 0  # Total number of TPI cycles
    consecutive_boosts: int = 0  # Track consecutive boost attempts
    recent_errors: list = field(default_factory=list)  # Store last N errors for regime change detection
    regime_change_detected: bool = False  # Flag for temporary alpha boost
    learning_start_date: Optional[datetime] = None  # Date when learning started

    # Capacity learning (Heat only)
    capacity_heat_learn_count: int = 0
    bootstrap_failure_count: int = 0 # Number of consecutive failures to learn capacity during bootstrap
    # Bootstrap is implied when capacity_heat_learn_count < 3

    # Optional features configuration
    allow_kint_boost: bool = False
    allow_kext_overshoot: bool = False

    def to_dict(self):
        """Convert to a JSON-safe dict for HA state attributes and storage."""
        def make_json_safe(value):
            """Convert non-JSON-serializable types to JSON-safe equivalents."""
            if value is None:
                return None
            if isinstance(value, datetime):
                return value.isoformat()
            if isinstance(value, (list, tuple)):
                return [make_json_safe(v) for v in value]
            if isinstance(value, dict):
                return {k: make_json_safe(v) for k, v in value.items()}
            return value

        result = asdict(self)
        return {k: make_json_safe(v) for k, v in result.items()}

    @classmethod
    def from_dict(cls, data):
        d = data.copy()
        # Date conversion from ISO format
        for date_field in ["last_update_date", "cycle_start_date", "last_heater_stop_time", "learning_start_date"]:
            if d.get(date_field):
                try:
                    # Use dt_util.parse_datetime to preserve timezone information
                    parsed = dt_util.parse_datetime(d[date_field])
                    d[date_field] = parsed if parsed else datetime.fromisoformat(d[date_field])
                except (ValueError, TypeError):
                    d[date_field] = None

        # Create instance with defaults first
        instance = cls()

        # Filter unknown fields and update only valid ones
        valid_fields = {k for k in cls.__annotations__}
        for key, value in d.items():
            if key in valid_fields:
                setattr(instance, key, value)

        return instance


class AutoTpiManager:
    """Auto TPI Manager implementing TPI algorithm."""

    def __init__(
        self,
        hass: HomeAssistant,
        config_entry: ConfigEntry,
        unique_id: str,
        name: str,
        cycle_min: int,
        tpi_threshold_low: float = 0.0,
        tpi_threshold_high: float = 0.0,
        minimal_deactivation_delay: int = 0,
        coef_int: float = 0.6,
        coef_ext: float = 0.04,
        heater_heating_time: int = 5,
        heater_cooling_time: int = 5,
        calculation_method: str = "ema",
        heating_rate: float = 1.0,
        cooling_rate: float = 1.0,
        avg_initial_weight: int = 1,
        ema_alpha: float = 0.15,
        ema_decay_rate: float = 0.08,
        aggressiveness: float = 0.9,
        continuous_kext: bool = False,
        continuous_kext_alpha: float = 0.04,
    ):
        self._hass = hass
        self._name = name
        self._cycle_min = cycle_min
        self._config_entry = config_entry
        self._enable_update_config = True
        self._enable_notification = True
        self._unique_id = unique_id
        self._entity_id: str | None = None  # Set by thermostat after entity registration
        self._tpi_threshold_low = tpi_threshold_low
        self._tpi_threshold_high = tpi_threshold_high
        self._minimal_deactivation_delay_sec = minimal_deactivation_delay
        self._heater_heating_time = heater_heating_time
        self._heater_cooling_time = heater_cooling_time

        self._continuous_kext = continuous_kext
        self._continuous_kext_alpha = continuous_kext_alpha

        self._temp_unit = self._hass.config.units.temperature_unit
        self._unit_factor = 1.8 if self._temp_unit == UnitOfTemperature.FAHRENHEIT else 1.0

        self._calculation_method = calculation_method
        self._ema_alpha = ema_alpha
        self._avg_initial_weight = avg_initial_weight
        self._max_coef_int = 1.0
        # Convert rates to Celsius/h if needed
        self._heating_rate = heating_rate / self._unit_factor
        self._cooling_rate = cooling_rate / self._unit_factor
        self._ema_decay_rate = ema_decay_rate
        self._continuous_learning = False
        self._keep_ext_learning = True
        self._aggressiveness = aggressiveness

        # Notification management
        self._last_notified_coef_int: Optional[float] = None
        self._last_notified_coef_ext: Optional[float] = None

        storage_key = f"{STORAGE_KEY_PREFIX}.{unique_id.replace('.', '_')}"
        self._store = Store(hass, STORAGE_VERSION, storage_key)
        # Convert config coefficients (User Unit) to Internal (Celsius)
        # K_C = K_F * 1.8
        self._default_coef_int = (coef_int if coef_int is not None else 0.6) * self._unit_factor
        self._default_coef_ext = (coef_ext if coef_ext is not None else 0.04) * self._unit_factor

        self.state = AutoTpiState(
            coeff_indoor_heat=self._default_coef_int, coeff_outdoor_heat=self._default_coef_ext, coeff_indoor_cool=self._default_coef_int, coeff_outdoor_cool=self._default_coef_ext
        )
        self._calculated_params = {}

        # Transient state
        self._current_temp_in: float = 0.0
        self._current_temp_out: float = 0.0
        self._current_target_temp: float = 0.0
        self._current_hvac_mode: str = "heat"  # 'heat' or 'cool' (or 'off' etc)
        self._last_cycle_power_efficiency: float = 1.0
        self._save_lock = asyncio.Lock()

        self._timer_capture_remove_callback: Callable[[], None] | None = None
        self._learning_just_completed: bool = False  # Transient flag to suppress 'cycle interrupted' log after learning

        # Interruption management
        self._current_cycle_interrupted: bool = False
        self._central_boiler_off: bool = False
        self._current_is_heating_failure: bool = False

        # Shutdown safety check
        self._is_vtherm_stopping_callback: Callable[[], bool] | None = None

    def get_filtered_state(self) -> dict:
        """Get the AutoTpiState as a dict, but filtered for public exposure."""
        data = self.state.to_dict()

        # 1. Remove internal debugging attributes
        if "recent_errors" in data:
            del data["recent_errors"]

        # 2. Filter counters if learning session is NOT active (Continuous Kext only)
        if not self.state.autolearn_enabled:
            # If main learning is off, hide indoor counters as they are not updated/relevant in continuous mode
            # (Continuous mode only updates Kext/Outdoor)
            keys_to_hide = [
                "coeff_indoor_autolearn",
                "coeff_indoor_cool_autolearn",
                "learning_start_date",
                "last_learning_status"
            ]
            for k in keys_to_hide:
                if k in data:
                    del data[k]

        # 2. Filter based on Mode
        is_cool_mode = self._current_hvac_mode == "cool"

        keys_to_remove = []
        for key in data.keys():
            if is_cool_mode:
                # In Cool mode, remove heat-related keys
                if "_heat" in key:
                    keys_to_remove.append(key)
            else:
                # In Heat mode, remove cool-related keys
                if "_cool" in key:
                    keys_to_remove.append(key)

        for key in keys_to_remove:
            del data[key]

        return data

    def set_is_vtherm_stopping_callback(self, callback: Callable[[], bool]):
        """Set a callback to check if the VTherm is stopping."""
        self._is_vtherm_stopping_callback = callback


    def _to_celsius(self, temp: float) -> float:
        """Convert temperature to Celsius if needed."""
        if temp is None:
            return 0.0
        if self._temp_unit == UnitOfTemperature.FAHRENHEIT:
            return TemperatureConverter.convert(temp, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS)
        return temp

    async def async_update_learning_data(self, coef_int: float = None, coef_ext: float = None, capacity: float = None, is_heat_mode: bool = True):
        """Update coefficients and/or capacity in one go to avoid double reload."""

        updates = {}

        # 1. Update coefficients if provided
        if coef_int is not None:
            updates[CONF_TPI_COEF_INT] = round(coef_int / self._unit_factor, 3)

        if coef_ext is not None:
            updates[CONF_TPI_COEF_EXT] = round(coef_ext / self._unit_factor, 3)

        # 2. Update capacity if provided and valid
        if capacity is not None and capacity > 0.0:
            rate_key = CONF_AUTO_TPI_HEATING_POWER if is_heat_mode else CONF_AUTO_TPI_COOLING_POWER
            updates[rate_key] = round(capacity * self._unit_factor, 3)

            # Also update local state (Memory)
            if is_heat_mode:
                self.state.max_capacity_heat = capacity
                self._heating_rate = capacity
            else:
                self.state.max_capacity_cool = capacity
                self._cooling_rate = capacity

        # 3. Always save to local storage (Persistence)
        await self.async_save_data()

        # 4. Check if we should skip config update (Restart/Shutdown safety)
        if not self._enable_update_config:
             _LOGGER.debug("%s - Auto TPI: update_learning_data - enable_update_config is False", self._name)
             return

        if self._is_vtherm_stopping_callback and self._is_vtherm_stopping_callback():
             _LOGGER.debug("%s - Auto TPI: update_learning_data - VTherm is stopping, skipping config update", self._name)
             return

        if not updates:
            _LOGGER.debug("%s - Auto TPI: update_learning_data - no updates to apply", self._name)
            return

        # 5. Apply atomic config update (no reload: flag prevents update_listener from reloading)
        api = VersatileThermostatAPI.get_vtherm_api(self._hass)
        if api:
            api.skip_reload_on_config_update = True
        try:
            new_data = {**self._config_entry.data, **updates}
            self._hass.config_entries.async_update_entry(self._config_entry, data=new_data)
        finally:
            if api:
                api.skip_reload_on_config_update = False

        _LOGGER.info(
            "%s - Auto TPI: ATOMIC UPDATE: Kint=%s, Kext=%s, Capacity=%s",
            self._name,
            f"{coef_int:.3f}" if coef_int is not None else "N/A",
            f"{coef_ext:.3f}" if coef_ext is not None else "N/A",
            f"{capacity:.3f}" if capacity is not None else "N/A"
        )







    async def process_learning_completion(self) -> Optional[dict]:
        """
        Processes the learned coefficients after a cycle to:
        1. Check if learning is finished/stabilized.
        2. Apply continuous learning if enabled.
        3. Persist coefficients to HA config if enabled.
        4. Send persistent notifications if enabled.

        Returns a dict of finalized coefficients if persisted, or None.
        """

        is_cool_mode = self._current_hvac_mode == "cool"

        if is_cool_mode:
            k_int = self.state.coeff_indoor_cool
            k_ext = self.state.coeff_outdoor_cool
            int_cycles_count = self.state.coeff_indoor_cool_autolearn
            ext_cycles_count = self.state.coeff_outdoor_cool_autolearn
        else:
            k_int = self.state.coeff_indoor_heat
            k_ext = self.state.coeff_outdoor_heat
            int_cycles_count = self.state.coeff_indoor_autolearn
            ext_cycles_count = self.state.coeff_outdoor_autolearn

        # 1. Check if learning is finished/stabilized (for non-continuous learning)
        # We check if the *raw* counter has reached the threshold, which accounts for _avg_initial_weight.
        INT_CYCLES_THRESHOLD = 50 + self._avg_initial_weight
        EXT_CYCLES_THRESHOLD = 50

        is_int_finished = int_cycles_count >= INT_CYCLES_THRESHOLD
        is_kext_standard_finished = ext_cycles_count >= EXT_CYCLES_THRESHOLD

        is_ext_finished = is_kext_standard_finished and (not self._keep_ext_learning or is_int_finished)

        if self._continuous_learning:
            # For continuous learning, we persist if the base threshold is met (stabilized).
            if is_int_finished:
                _LOGGER.debug("%s - Auto TPI: Continuous learning stabilized (Kint > %d cycles). Persisting values.", self._name, INT_CYCLES_THRESHOLD - self._avg_initial_weight)
            else:
                _LOGGER.debug("%s - Auto TPI: Continuous learning in progress (Kint cycles: %d). Skipping persistence.", self._name, int_cycles_count - self._avg_initial_weight)
                return None
        else:
            # Standard learning: stop if both finished.
            if not (is_int_finished and is_ext_finished):
                _LOGGER.debug(
                    "%s - Auto TPI: Learning in progress (Kint cycles: %d/%d, Kext cycles: %d/%d). Skipping persistence.",
                    self._name,
                    int_cycles_count - self._avg_initial_weight,
                    INT_CYCLES_THRESHOLD - self._avg_initial_weight,
                    ext_cycles_count,
                    EXT_CYCLES_THRESHOLD,
                )
                return None
            else:
                _LOGGER.info("%s - Auto TPI: Learning completed. Persisting final coefficients and stopping learning.", self._name)
                # Calling stop_learning will NOT save capacity immediately (we do it in atomic update below)
                await self.stop_learning(save_capacity=False)

                # Check if we also need to update capacity (if it was learned)
                # This combines both updates into ONE config entry update
                capacity_to_save = None
                if self.state.capacity_heat_learn_count >= 3:
                     # Check if value has changed
                     current_conv_capacity = self._config_entry.data.get(CONF_AUTO_TPI_HEATING_POWER)
                     current_capacity = current_conv_capacity / self._unit_factor if current_conv_capacity else 0.0

                     if abs(current_capacity - self.state.max_capacity_heat) > 0.01:
                         capacity_to_save = self.state.max_capacity_heat

                await self.async_update_learning_data(coef_int=k_int, coef_ext=k_ext, capacity=capacity_to_save, is_heat_mode=True)



        # 4. Send persistent notifications if enabled
        if self._enable_notification:
            # Implement "notify once" logic
            # Check for a significant change (> 0.005) or if it's the first time
            if (self._last_notified_coef_int is None or abs(self._last_notified_coef_int - k_int) > 0.005) or (
                self._last_notified_coef_ext is None or abs(self._last_notified_coef_ext - k_ext) > 0.005
            ):

                # Get translated message. Since I cannot read translations, I will use a simple English message.
                title = f"Versatile Thermostat: Auto TPI Learned Coefficients for {self._name}"
                message = f"Auto TPI has learned new coefficients: Indoor={round(k_int, 3)}, Outdoor={round(k_ext, 3)} (Cycles: Int={int_cycles_count - self._avg_initial_weight}, Ext={ext_cycles_count}). These values have been saved to the configuration."

                try:
                    await self._hass.services.async_call(
                        "persistent_notification",
                        "create",
                        {
                            "title": title,
                            "message": message,
                            "notification_id": f"autotpi_learning_completed_{self._unique_id}",
                        },
                        blocking=False,
                    )
                    self._last_notified_coef_int = k_int
                    self._last_notified_coef_ext = k_ext
                    _LOGGER.info("%s - Auto TPI: Persistent notification sent for final coefficients.", self._name)
                except Exception as e:
                    _LOGGER.error("%s - Auto TPI: Error sending persistent notification: %s", self._name, e)

        # Return learned coefficients converted back to User Unit
        # Internal: C. Output: F/C. If F, output = internal / 1.8 (internal * 0.55 ?)
        # K_F = K_C / 1.8.
        return {
            CONF_TPI_COEF_INT: k_int / self._unit_factor,
            CONF_TPI_COEF_EXT: k_ext / self._unit_factor
        }

    async def async_save_data(self):
        """Save data."""
        await self._store.async_save(self.state.to_dict())

    async def async_load_data(self):
        """Load data."""
        data = await self._store.async_load()

        if not data:
            # Try to migrate from old JSON file
            old_storage_key = f"versatile_thermostat_{self._unique_id}_auto_tpi_v2.json"
            old_path = self._hass.config.path(f".storage/{old_storage_key}")
            if os.path.exists(old_path):
                _LOGGER.debug("%s - Auto TPI: Migrating from old storage %s", self._name, old_path)
                try:
                    with open(old_path, "r") as f:
                        old_json = json.load(f)
                    # Extract state from old format
                    data = old_json.get("state", old_json)
                    await self._store.async_save(data)  # Save to new format
                    os.remove(old_path)  # Clean up old file
                except Exception as e:
                    _LOGGER.error("%s - Auto TPI: Migration error: %s", self._name, e)

        if data:
            self.state = AutoTpiState.from_dict(data)

            # Clamping: Apply new max_coef_int to loaded coefficients immediately,
            # in case the user lowered the limit via config flow.
            self.state.coeff_indoor_heat = min(self.state.coeff_indoor_heat, self._max_coef_int)
            self.state.coeff_indoor_cool = min(self.state.coeff_indoor_cool, self._max_coef_int)

            # Capacity update logic on load (to fix startup issue after config change)
            # We prioritize the capacity from the latest config flow over the persisted state
            # if they are different and the config value is valid. This handles both an initial
            # state with 0 capacity and a configuration change on restart.

            is_capacity_heat_outdated = self.state.max_capacity_heat != self._heating_rate
            is_capacity_cool_outdated = self.state.max_capacity_cool != self._cooling_rate

            # Handle capacity reset: if configured heat_rate is 0, reset capacity to trigger bootstrap
            if self._heating_rate == 0.0 and self.state.max_capacity_heat > 0.0:
                _LOGGER.info(
                    "%s - Auto TPI: Configured heat_rate is 0, resetting capacity (was %.3f) to trigger bootstrap.",
                    self._name,
                    self.state.max_capacity_heat,
                )
                self.state.max_capacity_heat = 0.0
                self.state.capacity_heat_learn_count = 0
            elif is_capacity_heat_outdated and self._heating_rate > 0.0:
                if self.state.max_capacity_heat == 0.0:
                    _LOGGER.info(
                        "%s - Auto TPI: Overwriting persisted max_capacity_heat (0.000) with new configured value (%.3f) on load.",
                        self._name,
                        self._heating_rate,
                    )
                    self.state.max_capacity_heat = self._heating_rate
                    self.state.capacity_heat_learn_count = 3  # Assume learned if we take config value
                else:
                    _LOGGER.info(
                        "%s - Auto TPI: Persisted max_capacity_heat (%.3f) differs from config (%.3f). Keeping persisted value.",
                        self._name,
                        self.state.max_capacity_heat,
                        self._heating_rate,
                    )
                    # Sync the effective rate to the persisted one
                    self._heating_rate = self.state.max_capacity_heat

            # Handle capacity reset for cooling mode
            if self._cooling_rate == 0.0 and self.state.max_capacity_cool > 0.0:
                _LOGGER.info(
                    "%s - Auto TPI: Configured cooling_rate is 0, resetting capacity (was %.3f) to trigger bootstrap.",
                    self._name,
                    self.state.max_capacity_cool,
                )
                self.state.max_capacity_cool = 0.0
                # Note: capacity_cool_learn_count does not exist, cooling bootstrap uses different logic
            elif is_capacity_cool_outdated and self._cooling_rate > 0.0:
                # Same logic for cooling capacity
                if self.state.max_capacity_cool == 0.0:
                    _LOGGER.info(
                        "%s - Auto TPI: Overwriting persisted max_capacity_cool (0.000) with new configured value (%.3f) on load.",
                        self._name,
                        self._cooling_rate,
                    )
                    self.state.max_capacity_cool = self._cooling_rate
                else:
                    _LOGGER.info(
                        "%s - Auto TPI: Persisted max_capacity_cool (%.3f) differs from config (%.3f). Keeping persisted value.",
                        self._name,
                        self.state.max_capacity_cool,
                        self._cooling_rate,
                    )
                    # Sync the effective rate to the persisted one
                    self._cooling_rate = self.state.max_capacity_cool

            if is_capacity_heat_outdated or is_capacity_cool_outdated:
                await self.async_save_data()  # Save the new correct config value

            # If no learning has been done yet, force the configured defaults
            if self.state.total_cycles == 0:
                _LOGGER.info("%s - Auto TPI: No learning cycles yet. Enforcing configured coefficients.", self._name)
                self.state.coeff_indoor_heat = self._default_coef_int
                self.state.coeff_outdoor_heat = self._default_coef_ext
                self.state.coeff_indoor_cool = self._default_coef_int
                self.state.coeff_outdoor_cool = self._default_coef_ext
                # Initialize counters with the configured weight
                self.state.coeff_indoor_autolearn = self._avg_initial_weight
                self.state.coeff_indoor_cool_autolearn = self._avg_initial_weight
                self.state.coeff_outdoor_autolearn = 0
                self.state.coeff_outdoor_cool_autolearn = 0

            _LOGGER.info("%s - Auto TPI: State loaded. Cycles: %d, Indoor learn count: %d", self._name, self.state.total_cycles, self.state.coeff_indoor_autolearn)
        else:
            self.state = AutoTpiState(
                coeff_indoor_heat=self._default_coef_int,
                coeff_outdoor_heat=self._default_coef_ext,
                coeff_indoor_cool=self._default_coef_int,
                coeff_outdoor_cool=self._default_coef_ext,
            )

        # Reset last_learning_status on load to avoid stale messages from previous sessions
        self.state.last_learning_status = "learning_started"

        # Reset cycle state to discard any cycle interrupted by the reboot
        # This prevents "cycle_gap_detected" or validation failures on the first cycle after restart
        self.state.cycle_active = False
        self.state.cycle_start_date = None

        # MIGRATION FIX: If capacity is already known (legacy or manual), mark as learned
        # This prevents re-triggering bootstrap (count=0) for existing users
        if self.state.max_capacity_heat > 0 and self.state.capacity_heat_learn_count == 0:
            _LOGGER.info("%s - Auto TPI: Existing capacity found (%.3f), marking as learned (count=3)", self._name, self.state.max_capacity_heat)
            self.state.capacity_heat_learn_count = 3

        await self.calculate()

    async def update(self, room_temp: float, ext_temp: float, hvac_mode: str, target_temp: float, is_overpowering_detected: bool = False, is_central_boiler_off: bool = False, is_heating_failure: bool = False) -> float:
        """Update state with new data.

        This method is called at each control_heating cycle.
        It updates the transient state used for power calculation and future learning.

        Returns the calculated power for validation/indication.
        """

        # Check for Overpowering Interruption
        # If overpowering is detected, the heating/cooling is artificially stopped/limited.
        # We must mark this cycle as interrupted so we don't learn from it (false data).
        if is_overpowering_detected:
            if not self._current_cycle_interrupted:
                _LOGGER.info("%s - Auto TPI: Cycle interrupted by Overpowering/Power Shedding. Learning will be skipped for this cycle.", self._name)
                self._current_cycle_interrupted = True

        # Store current values for later use in cycle callbacks
        # Convert inputs to Celsius for internal logic
        self._current_temp_in = self._to_celsius(room_temp) if room_temp is not None else 0.0
        self._current_temp_out = self._to_celsius(ext_temp) if ext_temp is not None else 0.0
        self._current_target_temp = self._to_celsius(target_temp) if target_temp is not None else 0.0
        self._current_hvac_mode = hvac_mode
        self._central_boiler_off = is_central_boiler_off
        self._current_is_heating_failure = is_heating_failure

        # Calculate and return power
        # Use hvac_mode to force direction
        calc_state_str = "stop"
        if hvac_mode == "cool":
            calc_state_str = "cool"
        elif hvac_mode == "heat":
            calc_state_str = "heat"

        return self.calculate_power(self._current_target_temp, self._current_temp_in, self._current_temp_out, calc_state_str)

    async def calculate(self) -> Optional[dict]:
        """Calculate TPI parameters, using aggressive coefficients during bootstrap."""

        # Determine if in bootstrap (capacity not yet learned)
        in_bootstrap = (
            self.state.max_capacity_heat == 0 or
            self.state.capacity_heat_learn_count < 3
        )

        # Temporarily override learned coefficients if in bootstrap
        saved_kint = self.state.coeff_indoor_heat
        saved_kext = self.state.coeff_outdoor_heat

        if in_bootstrap:
            # Use aggressive coefficients for bootstrap
            # User requested 1.0 / 0.1 as "normal" values (sufficiently aggressive vs 0.6 default)
            KINT_BOOTSTRAP = 1.0
            KEXT_BOOTSTRAP = 0.1

            self.state.coeff_indoor_heat = KINT_BOOTSTRAP
            self.state.coeff_outdoor_heat = KEXT_BOOTSTRAP

        try:
            # Return current coefficients for the thermostat to use
            params = {}

            # Use hvac_mode to determine which coefficients to return
            # This prevents flapping when switching between heating/cooling actions while in the same mode (e.g. idle)
            # Note: hvac_mode usually comes from VThermHvacMode (heat, cool, off, auto...)

            is_cool_mode = self._current_hvac_mode == "cool"

            if is_cool_mode:
                params[CONF_TPI_COEF_INT] = self.state.coeff_indoor_cool / self._unit_factor
                params[CONF_TPI_COEF_EXT] = self.state.coeff_outdoor_cool / self._unit_factor
            else:
                params[CONF_TPI_COEF_INT] = self.state.coeff_indoor_heat / self._unit_factor
                params[CONF_TPI_COEF_EXT] = self.state.coeff_outdoor_heat / self._unit_factor

            self._calculated_params = params
            return params
        finally:
            # Restore original values
            if in_bootstrap:
                self.state.coeff_indoor_heat = saved_kint
                self.state.coeff_outdoor_heat = saved_kext

    def _get_adaptive_alpha(self, cycle_count: int) -> float:
        """Calculate adaptive alpha for EMA, with temporary boost on regime change."""

        # Standard calculation
        base_alpha = self._ema_alpha / (1 + self._ema_decay_rate * cycle_count)

        # If continuous learning is enabled and regime change detected, temporary boost
        if self._continuous_learning and self.state.regime_change_detected:
            # Max boost alpha is min(base_alpha * 3.0, 0.15)
            # We want to ensure the base alpha is not too small before boosting.
            # If base_alpha is very small (after many cycles), boost will still be limited.
            boost_alpha = min(base_alpha * 3.0, 0.15)

            _LOGGER.info(f"%s - Auto TPI: Regime change detected, boosting alpha: {base_alpha:.3f} -> {boost_alpha:.3f}", self._name)

            # The flag will be reset in _learn_indoor after consumption
            return boost_alpha

        return base_alpha

    def _detect_regime_change(self, recent_errors: list) -> bool:
        """
        Detects a thermal regime change (systematic bias).
        If detected, we can temporarily increase alpha for faster adaptation.
        """
        N = 10
        if not self._continuous_learning or len(recent_errors) < N:
            return False

        # We only look at the last N errors
        errors_to_check = recent_errors[-N:]

        # Simple statistical test:
        # Do the last N errors have a systematic bias?
        # mean_error is the average 'correction needed' in °C
        mean_error = sum(errors_to_check) / N

        # Calculate standard deviation
        # Avoid zero division
        std_error = (sum((e - mean_error) ** 2 for e in errors_to_check) / N) ** 0.5
        if std_error == 0:
            return False

        # Student's t-test: significant systematic error?
        t_stat = abs(mean_error) / (std_error / math.sqrt(N))

        # 95% confidence threshold (t > 2.0 for n=10)
        return t_stat > 2.0

    def _should_learn(self) -> bool:
        """Check if learning should be performed."""
        # We learn if:
        # 1. Main learning session is active (autolearn_enabled)
        # 2. OR Continuous Kext is enabled (we will filter Kint vs Kext inside _perform_learning)
        if not self.state.autolearn_enabled and not self._continuous_kext:
            return False

        # Power conditions: 0 < last_power < saturation_threshold
        # If power is >= saturation_threshold, the cycle is saturated and we skip learning.
        saturation_threshold = self.saturation_threshold
        if not (0 < self.state.last_power < saturation_threshold):
            _LOGGER.debug("%s - Auto TPI: Not learning - Power out of range (%.3f not in 0 < power < %.3f)", self._name, self.state.last_power, saturation_threshold)
            return False

        if self._current_cycle_interrupted:
            _LOGGER.debug("%s - Auto TPI: Not learning - Cycle was interrupted (e.g. Power Shedding)", self._name)
            return False

        if self._central_boiler_off:
            _LOGGER.debug("%s - Auto TPI: Not learning - Central boiler is OFF although VTherm is active (boiler below activation threshold)", self._name)
            return False

        if self._current_is_heating_failure:
            _LOGGER.debug("%s - Auto TPI: Not learning - Heating/Cooling failure detected", self._name)
            return False

        # Failures check
        if self.state.consecutive_failures >= 3:
            return False

        # 1. First Cycle Exclusion
        if self.state.previous_state == "stop":
            _LOGGER.debug("%s - Auto TPI: Not learning - First cycle (previous state was stop)", self._name)
            return False
        if self.state.last_order == 0:
            _LOGGER.debug("%s - Auto TPI: Not learning - Last order is 0", self._name)
            return False

        # 2. Mild Weather Exclusion (Safe Ratio)
        # Avoid division by small numbers or learning when delta is too small to be significant
        delta_out = self.state.last_order - self._current_temp_out
        delta_out_threshold = 1.0  # Celsius
        if abs(delta_out) < delta_out_threshold:
            _LOGGER.debug("%s - Auto TPI: Not learning - Delta out too small (< %.1f)", self._name, delta_out_threshold)
            return False

        # Natural drift exclusion - check temperature at CYCLE START
        # If temp was already past setpoint at cycle START, this is passive drift, not active regulation
        # is_heat = self.state.last_state == 'heat'
        # is_cool = self.state.last_state == 'cool'

        # if is_heat and self.state.last_temp_in > self.state.last_order + 0.05:
        #     _LOGGER.debug("%s - Auto TPI: Not learning - Passive cooling at cycle start (T_in %.2f > Target %.2f + 0.05)",
        #                 self._name, self.state.last_temp_in, self.state.last_order)
        #     return False

        # if is_cool and self.state.last_temp_in < self.state.last_order - 0.05:
        #     _LOGGER.debug("%s - Auto TPI: Not learning - Passive heating at cycle start (T_in %.2f < Target %.2f - 0.05)",
        #                 self._name, self.state.last_temp_in, self.state.last_order)
        #     return False

        return True

    def _get_no_learn_reason(self) -> str:
        """Get reason why learning is not happening."""
        if not self.state.autolearn_enabled and not self._continuous_kext:
            return "learning_disabled"

        saturation_threshold = self.saturation_threshold  # pylint: disable=no-member
        if not (0 < self.state.last_power < saturation_threshold):
            return f"power_out_of_range({self.state.last_power * 100:.1f}% vs Saturation {saturation_threshold * 100:.1f}%)"

        if self._current_cycle_interrupted:
            return "cycle_interrupted_by_overpowering"

        if self._central_boiler_off:
            return "central_boiler_off"

        if self._current_is_heating_failure:
            return "heating_failure_detected"

        if self.state.consecutive_failures >= 3:
            return f"too_many_failures({self.state.consecutive_failures})"

        if self.state.previous_state == "stop":
            return "startup_cycle"

        if self.state.last_order == 0:
            return "target_temp_is_zero"

        delta_out = self.state.last_order - self._current_temp_out
        if abs(delta_out) < 1.0:
            return f"outdoor_delta_too_small({delta_out:.1f})"

        return "unknown"

    async def _perform_learning(self, current_temp_in: float, current_temp_out: float):
        """Execute the learning logic based on previous state and current observations."""

        is_heat = self.state.last_state == "heat"
        is_cool = self.state.last_state == "cool"

        if not (is_heat or is_cool):
            self.state.last_learning_status = "not_heating_or_cooling"
            _LOGGER.debug("%s - Auto TPI: Not learning - system was in %s mode", self._name, self.state.last_state)
            return

        # Check if setpoint changed during the cycle - if so, skip ALL learning
        # This prevents incorrect coefficient updates when user adjusts temperature mid-cycle
        setpoint_changed = abs(self._current_target_temp - self.state.last_order) > 0.1
        if setpoint_changed:
            self.state.last_learning_status = "setpoint_changed_during_cycle"
            _LOGGER.debug(
                "%s - Auto TPI: Skipping learning - setpoint changed during cycle (%.1f → %.1f)",
                self._name, self.state.last_order, self._current_target_temp
            )
            return

        target_temp = self.state.last_order

        # Calculate deltas based on direction
        if is_heat:
            temp_progress = current_temp_in - self.state.last_temp_in
            target_diff = self.state.last_order - self.state.last_temp_in
            outdoor_condition = current_temp_out < self.state.last_order
        else:  # Cool
            temp_progress = self.state.last_temp_in - current_temp_in
            target_diff = self.state.last_temp_in - self.state.last_order
            outdoor_condition = current_temp_out > self.state.last_order

        # CASE 0: Overshoot Correction (BEFORE standard learning)
        # ----------------------------------------------------------
        # When room is overheating despite heat still being applied,
        # Kext is clearly too high. Correct it aggressively before
        # attempting normal learning.
        #
        # IMPORTANT: Only correct if temperature is NOT FALLING despite overshoot.
        # If temp is falling naturally (e.g., after setpoint was lowered), the
        # system is working correctly - no need to reduce Kext.
        # If temp stagnates or rises, Kext is too high (preventing natural cooling).
        # A small threshold (0.02°C) filters out sensor noise.
        temp_not_falling = current_temp_in >= self.state.last_temp_in - 0.02

        if is_heat:
            overshoot = current_temp_in - target_temp
            if overshoot > OVERSHOOT_THRESHOLD and self.state.last_power > OVERSHOOT_POWER_THRESHOLD and temp_not_falling:
                _LOGGER.info(
                    "%s - Auto TPI: Overshoot detected (%.2f°C > %.2f°C threshold, power=%.1f%%, temp not falling)",
                    self._name, overshoot, OVERSHOOT_THRESHOLD, self.state.last_power * 100
                )
                if self._correct_kext_overshoot(overshoot, is_cool=False):
                    return  # Skip other learning for this cycle
        elif is_cool:
            temp_not_rising = current_temp_in <= self.state.last_temp_in + 0.02
            overshoot = target_temp - current_temp_in
            if overshoot > OVERSHOOT_THRESHOLD and self.state.last_power > OVERSHOOT_POWER_THRESHOLD and temp_not_rising:
                _LOGGER.info(
                    "%s - Auto TPI: Overcooling detected (%.2f°C > %.2f°C threshold, power=%.1f%%, temp not rising)",
                    self._name, overshoot, OVERSHOOT_THRESHOLD, self.state.last_power * 100
                )
                if self._correct_kext_overshoot(overshoot, is_cool=True):
                    return  # Skip other learning for this cycle

        # CASE 0.5: Insufficient Rise Correction
        # ----------------------------------------
        # When temperature stagnates despite a significant gap (> 0.3°C)
        # and power is not saturated, Kint is likely too low.
        # Instead of incorrectly adjusting Kext, we boost Kint.
        #
        # This handles the scenario where:
        # - target_diff > 0.3°C (significant gap to setpoint)
        # - temp_progress < 0.02 (temperature is stagnating or dropping)
        # - power < 0.99 (not saturated, so we CAN increase power)
        #
        # In this case, standard indoor learning fails (requires temp_progress > 0.05)
        # and the system incorrectly falls through to outdoor learning, increasing Kext.

        temp_stagnating = temp_progress < 0.02

        if target_diff > INSUFFICIENT_RISE_GAP_THRESHOLD and temp_stagnating and self.state.last_power < 0.99:
            if self._correct_kint_insufficient_rise(target_diff, temp_progress, is_cool):
                return  # Kint corrected, skip other learning for this cycle

        # CASE 1: Indoor Learning
        # ---------------------------
        # Strict conditions to avoid false positives:
        # - Significant temperature progress (> 0.05°C)
        # - Significant gap to cover (> 0.1°C)
        # - Power not saturated (0 < power < 0.99)
        # - Main Learning Session MUST be active (we don't learn Kint in continuous mode)

        if self.state.autolearn_enabled and 0 < self.state.last_power < 0.99:
            temp_progress_threshold = 0.05
            target_diff_threshold = 0.01
            if temp_progress > temp_progress_threshold and target_diff > target_diff_threshold:
                # Indoor learning attempt
                error = self._learn_indoor(target_diff, temp_progress, self._last_cycle_power_efficiency, is_cool)
                if error is not None:
                    # Learning was successful - temperature is rising
                    self.state.last_learning_status = f"learned_indoor_{'cool' if is_cool else 'heat'}"
                    _LOGGER.info("%s - Auto TPI: Indoor coefficient learned successfully (Error: %.3f)", self._name, error)
                    self._learning_just_completed = True

                    # Reset consecutive Kint boosts counter since temperature is now rising
                    if self.state.consecutive_boosts > 0:
                        _LOGGER.debug("%s - Auto TPI: Resetting consecutive_boosts counter (was %d)", self._name, self.state.consecutive_boosts)
                        self.state.consecutive_boosts = 0

                    # Continuous Learning: Track error and detect regime change
                    if self._continuous_learning:
                        self.state.recent_errors.append(error)
                        # Keep only the last 20 errors (N=10 for detection + buffer)
                        if len(self.state.recent_errors) > 20:
                            self.state.recent_errors = self.state.recent_errors[-20:]

                        is_regime_change = self._detect_regime_change(self.state.recent_errors)
                        if is_regime_change and not self.state.regime_change_detected:
                            self.state.regime_change_detected = True
                            _LOGGER.warning("%s - Auto TPI: SYSTEMIC REGIME CHANGE DETECTED. Alpha boost activated.", self._name)

                    return  # Indoor success, we exit
                else:
                    # Indoor failed, reason already logged in _learn_indoor
                    _LOGGER.debug("%s - Auto TPI: Indoor learning failed, will try outdoor", self._name)
            else:
                _LOGGER.debug("%s - Auto TPI: Indoor conditions not met (progress=%.3f, target_diff=%.3f)", self._name, temp_progress, target_diff)
        else:
            _LOGGER.debug("%s - Auto TPI: Skipping indoor coeff learning because power is saturated (%.1f%%)", self._name, self.state.last_power * 100)

        # CASE 2: Outdoor Learning
        # ----------------------------
        # Executed when:
        # - Indoor was not applicable (conditions not met)
        # - OR indoor failed (_learn_indoor returned False)
        # Conditions:
        # - Relevant outdoor temperature (outdoor_condition)
        # - Significant remaining gap

        gap_in = target_temp - current_temp_in
        gap_threshold = 0.05

        if outdoor_condition and abs(gap_in) > gap_threshold:
            # Domain Separation: Far-Field vs Near-Field
            # If the gap is large (> KEXT_LEARNING_MAX_GAP), it's a transient state (Kint domain).
            # Kext (Steady State) should only be learned in Near-Field.
            #
            # EXCEPTION: During overshoot with significant power, we MUST learn Kext regardless
            # of gap size. Overshoot means temp > target (heat) or temp < target (cool),
            # which is gap_in < 0 (heat) or gap_in > 0 (cool) - the opposite of normal Far-Field.
            is_overshoot_heat = is_heat and gap_in < 0
            is_overshoot_cool = is_cool and gap_in > 0
            has_significant_power = self.state.last_power >= NATURAL_RECOVERY_POWER_THRESHOLD
            is_active_overshoot = (is_overshoot_heat or is_overshoot_cool) and has_significant_power

            if abs(gap_in) > KEXT_LEARNING_MAX_GAP and not is_active_overshoot:
                self.state.last_learning_status = f"gap_too_large_for_outdoor(gap={gap_in:.2f} > {KEXT_LEARNING_MAX_GAP})"
                _LOGGER.debug(
                    "%s - Auto TPI: Skipping outdoor learning: Gap %.2f > %.2f - Far field stagnation is a Kint/Capacity issue, not Kext.",
                    self._name, abs(gap_in), KEXT_LEARNING_MAX_GAP
                )
                return

            if self._learn_outdoor(current_temp_in, current_temp_out, is_cool):
                if "naturally" not in self.state.last_learning_status:
                    self.state.last_learning_status = f"learned_outdoor_{'cool' if is_cool else 'heat'}"
                    _LOGGER.info("%s - Auto TPI: Outdoor coefficient learned successfully", self._name)
                    self._learning_just_completed = True
                return  # Outdoor success
            else:
                _LOGGER.debug("%s - Auto TPI: Outdoor learning failed", self._name)
        else:
            _LOGGER.debug("%s - Auto TPI: Outdoor conditions not met (outdoor_condition=%s, gap_in=%.3f)", self._name, outdoor_condition, gap_in)

        # No learning was possible
        self.state.last_learning_status = f"no_learning_possible(progress={temp_progress:.2f},target_diff={target_diff:.2f},gap_in={gap_in:.2f})"
        _LOGGER.debug("%s - Auto TPI: No learning possible - %s", self._name, self.state.last_learning_status)

    def _learn_indoor(self, delta_theoretical: float, delta_real: float, efficiency: float = 1.0, is_cool: bool = False) -> Optional[float]:
        """Learn indoor coefficient and optionally capacity."""

        real_rise = delta_real
        rise_threshold = 0.01

        if real_rise <= rise_threshold:
            _LOGGER.debug("%s - Auto TPI: Cannot learn indoor - real_rise %.3f <= %.3f. Will try outdoor learning.", self._name, real_rise, rise_threshold)
            self.state.last_learning_status = "real_rise_too_small"
            return None


        # === KINT LEARNING ===
        # 1. Get adiabatic capacity
        ref_capacity_h = self.state.max_capacity_heat if not is_cool else self.state.max_capacity_cool

        # Fallback if not learned yet
        if ref_capacity_h <= 0:
            count = self.state.capacity_heat_learn_count

            if count == 0:
                ref_capacity_h = 0.5  # Very conservative for first cycle
                _LOGGER.warning(
                    "%s - First cycle: using very conservative capacity 0.5°C/h",
                    self._name
                )
            else:
                ref_capacity_h = 1.0  # Standard fallback
                _LOGGER.debug(
                    "%s - Capacity not yet converged (count=%d), using fallback 1.0°C/h",
                    self._name, count
                )

        # If no capacity defined, skip learning for this cycle
        if ref_capacity_h <= 0:
            _LOGGER.debug("%s - Auto TPI: Cannot learn indoor - no capacity defined (ref_capacity_h=%.2f)", self._name, ref_capacity_h)
            self.state.last_learning_status = "no_capacity_defined"
            return False

        # 2. Calculate Effective Capacity with thermal losses
        if is_cool:
            k_ext = self.state.coeff_outdoor_cool
            delta_t = self._current_temp_out - self._current_temp_in
        else:
            k_ext = self.state.coeff_outdoor_heat
            delta_t = self._current_temp_in - self._current_temp_out

        loss_factor = k_ext * max(0.0, delta_t)
        loss_factor = min(loss_factor, 0.95)  # Prevent going negative

        effective_capacity_h = ref_capacity_h * (1.0 - loss_factor)

        # 3. Calculate Max Achievable Rise in this cycle (°C)
        cycle_duration_h = self._cycle_min / 60.0
        max_achievable_rise = effective_capacity_h * cycle_duration_h * efficiency

        _LOGGER.debug(
            "%s - Auto TPI: Capacity calc: ref=%.3f °C/h, loss=%.2f, eff=%.3f °C/h, max_rise=%.3f °C (cycle=%.1f min, eff=%.2f)",
            self._name,
            ref_capacity_h,
            loss_factor,
            effective_capacity_h,
            max_achievable_rise,
            self._cycle_min,
            efficiency,
        )

        # 4. Calculate adjusted_theoretical: aim for full gap, capped by capacity
        adjusted_theoretical = min(delta_theoretical, max_achievable_rise)

        if max_achievable_rise < delta_theoretical:
            mode_str = "cooling" if is_cool else "heating"
            _LOGGER.debug("%s - Auto TPI: Target rise clamped from %.3f to %.3f (Max %s Capacity)", self._name, delta_theoretical, max_achievable_rise, mode_str)

        if adjusted_theoretical <= 0:
            _LOGGER.warning("%s - Auto TPI: Cannot learn indoor - adjusted_theoretical <= 0 (max_rise=%.3f, target_diff=%.3f)", self._name, max_achievable_rise, delta_theoretical)
            self.state.last_learning_status = "adjusted_theoretical_lte_0"
            return False

        ratio = adjusted_theoretical / real_rise

        # Apply aggressiveness to the ratio to get more conservative Kint
        # This ensures aggressiveness always has an effect, regardless of capacity saturation
        ratio = ratio * self._aggressiveness

        current_coeff = self.state.coeff_indoor_cool if is_cool else self.state.coeff_indoor_heat
        coeff_new = current_coeff * ratio

        # Validate coefficient - reject only truly invalid values (non-finite or <= 0)
        if not math.isfinite(coeff_new) or coeff_new <= 0:
            _LOGGER.warning("%s - Auto TPI: Invalid new indoor coeff: %.3f (non-finite or <= 0), skipping", self._name, coeff_new)
            self.state.last_learning_status = "invalid_indoor_coeff"
            return False

        # 4. Cap Coefficient
        MAX_COEFF = self._max_coef_int
        if coeff_new > MAX_COEFF:
            _LOGGER.info("%s - Auto TPI: Calculated indoor coeff %.3f > %.1f, capping to %.1f before averaging", self._name, coeff_new, MAX_COEFF, MAX_COEFF)
            coeff_new = MAX_COEFF

        old_coeff = self.state.coeff_indoor_cool if is_cool else self.state.coeff_indoor_heat
        count = self.state.coeff_indoor_cool_autolearn if is_cool else self.state.coeff_indoor_autolearn

        # 5. Calculation Method
        # 5. Calculation Method
        # Cap the effective count to keep the system responsive
        # Even if we have 1000 cycles history, we weigh the new sample as if we had at most 50 cycles.
        effective_count = min(count, 50)

        if self._calculation_method == "average":
            # Weighted average
            # avg_coeff = ((old_coeff * count + coeff_new) / (count + 1))
            # We must use the current count (not incremented) as weight for old_coeff

            # If count is 0 (should not happen for valid state), treat as 1
            weight_old = max(effective_count, 1)

            avg_coeff = ((old_coeff * weight_old) + coeff_new) / (weight_old + 1)
            _LOGGER.debug("%s - Auto TPI: Weighted Average: old=%.3f (weight=%d, real_count=%d), new=%.3f, result=%.3f", self._name, old_coeff, weight_old, count, coeff_new, avg_coeff)

        else:  # EMA
            # EMA Smoothing (20% weight by default)
            # new_avg = (old_avg * (1 - alpha)) + (new_sample * alpha)
            alpha = self._get_adaptive_alpha(effective_count)
            avg_coeff = (old_coeff * (1.0 - alpha)) + (coeff_new * alpha)
            _LOGGER.debug("%s - Auto TPI: EMA: old=%.3f, new=%.3f, alpha=%.3f (eff_count=%d, real_count=%d), result=%.3f", self._name, old_coeff, coeff_new, alpha, effective_count, count, avg_coeff)

        # Apply minimum Kint threshold to maintain temperature responsiveness
        if avg_coeff < MIN_KINT:
            _LOGGER.warning(
                "%s - Auto TPI: Calculated Kint %.4f is below minimum %.4f, capping to minimum",
                self._name, avg_coeff, MIN_KINT
            )
            avg_coeff = MIN_KINT

        # Update counters
        new_count = count + 1

        if is_cool:
            self.state.coeff_indoor_cool = avg_coeff
            self.state.coeff_indoor_cool_autolearn = new_count
        else:
            self.state.coeff_indoor_heat = avg_coeff
            self.state.coeff_indoor_autolearn = new_count

        _LOGGER.info(
            "%s - Auto TPI: Learn indoor (%s). Old: %.3f, New calculated: %.3f (rise=%.3f), Averaged: %.3f (count: %d)",
            self._name,
            "cool" if is_cool else "heat",
            old_coeff,
            coeff_new,
            real_rise,
            avg_coeff,
            new_count,
        )

        # Reset boost counter after successful learning
        if hasattr(self.state, "consecutive_boosts"):
            self.state.consecutive_boosts = 0

        # Reset regime change flag after consuming the boost
        if self._continuous_learning and self.state.regime_change_detected:
            _LOGGER.debug("%s - Auto TPI: Regime change alpha consumed, resetting flag", self._name)
            self.state.regime_change_detected = False

        return adjusted_theoretical - real_rise  # Return the error: Expected Rise - Actual Rise

    def _learn_outdoor(self, current_temp_in: float, current_temp_out: float, is_cool: bool = False) -> bool:
        """Learn outdoor coefficient."""
        gap_in = self.state.last_order - current_temp_in
        gap_out = self.state.last_order - current_temp_out

        # Validation delta_out (moved here)
        if abs(gap_out) < 0.05:
            _LOGGER.debug("%s - Auto TPI: Cannot learn outdoor - gap_out too small (%.3f)", self._name, abs(gap_out))
            self.state.last_learning_status = "gap_out_too_small"
            return False

        if gap_out == 0:
            _LOGGER.debug("%s - Auto TPI: Cannot learn outdoor - gap_out is 0", self._name)
            self.state.last_learning_status = "gap_out_is_zero"
            return False

        # =============================================================================
        # INTELLIGENT VALIDATION : Overshoot
        # =============================================================================
        # An overshoot indicates that the model OVERESTIMATED the necessary power.
        # This is VALUABLE information to correct Kext.
        #
        # BUT: We must filter external anomalies (open door, sun, etc.)
        # → Overshoot without significant power = anomaly, not model error

        # CASE 1: Check that the setpoint did not change during the cycle
        consigne_changed = abs(self._current_target_temp - self.state.last_order) > 0.1

        if consigne_changed:
            _LOGGER.debug("%s - Auto TPI: Cannot learn outdoor - consigne changed during cycle (%.1f → %.1f)", self._name, self.state.last_order, self._current_target_temp)
            self.state.last_learning_status = "consigne_changed"
            return False

        # CASE 2: Overshoot without significant power = external anomaly
        # If we have an overshoot but we barely heated/cooled (power < 20%),
        # it is an external anomaly (open door, sun), not a model error
        if is_cool:
            # In cool mode: overcooled if gap_in > 0 (temp < target)
            # Acceptable only if we really cooled (power > 1% instead of 20%)
            # If power is > 1% and we are overcooling, it means Kext is too high and should be reduced.
            if gap_in > 0 and self.state.last_power < 0.01:
                _LOGGER.debug("%s - Auto TPI: Cannot learn outdoor - Anomalous overcooling (gap_in=%.2f, power=%.1f%%)", self._name, gap_in, self.state.last_power * 100)
                self.state.last_learning_status = "anomalous_overcooling"
                return False

            # Directional Protection
            # If we are overcooling (below target) BUT the temperature is Rising (going back to target)
            # AND power is low, then natural recovery is happening. Do not lower Kext.
            # However, if power is still significant, the system is actively cooling and Kext might be too high.
            if gap_in > 0 and current_temp_in > self.state.last_temp_in and self.state.last_power < NATURAL_RECOVERY_POWER_THRESHOLD:
                _LOGGER.debug(
                    "%s - Auto TPI: Skipping outdoor learning during natural undershoot recovery (Temp rising %.2f -> %.2f, power=%.1f%%)",
                    self._name, self.state.last_temp_in, current_temp_in, self.state.last_power * 100
                )
                self.state.last_learning_status = "warming_up_naturally"
                return True  # Considered handled (skipped)

        else:
            # In heat mode: overheated if gap_in < 0 (temp > target)
            # Acceptable only if we really heated (power > 1% instead of 20%)
            # If power is > 1% and we are overheating, it means Kext is too high and should be reduced.
            if gap_in < 0 and self.state.last_power < 0.01:
                _LOGGER.debug("%s - Auto TPI: Cannot learn outdoor - Anomalous overheating (gap_in=%.2f, power=%.1f%%)", self._name, gap_in, self.state.last_power * 100)
                self.state.last_learning_status = "anomalous_overheating"
                return False

            # Directional Protection
            # If we are overheating (above target) BUT the temperature is Falling (going back to target)
            # AND power is low, then natural recovery is happening. Do not lower Kext.
            # However, if power is still significant, the system is actively heating and Kext is likely too high.
            if gap_in < 0 and current_temp_in < self.state.last_temp_in and self.state.last_power < NATURAL_RECOVERY_POWER_THRESHOLD:
                _LOGGER.debug(
                    "%s - Auto TPI: Skipping outdoor learning during natural overshoot recovery (Temp falling %.2f -> %.2f, power=%.1f%%)",
                    self._name, self.state.last_temp_in, current_temp_in, self.state.last_power * 100
                )
                self.state.last_learning_status = "cooling_down_naturally"
                return True  # Considered handled (skipped)

        # If we get here with an overshoot AND significant power:
        # → It is a real model error, we MUST learn from it
        # → The Kext correction will help correct the underestimated external influence
        _LOGGER.debug("%s - Auto TPI: Overshoot validation passed (gap_in=%.2f, power=%.1f%%) - proceeding with learning", self._name, gap_in, self.state.last_power * 100)

        # ratio_influence = gap_in / gap_out
        current_indoor = self.state.coeff_indoor_cool if is_cool else self.state.coeff_indoor_heat
        current_outdoor = self.state.coeff_outdoor_cool if is_cool else self.state.coeff_outdoor_heat

        # Calculate corrective term based on indoor error (Missing power = Gap_In * Kint)
        # Shift this missing power to Outdoor term (Equivalent Kext = Missing Power / Gap_Out)
        # correction = (Gap_In / Gap_Out) * Kint
        # Target = Current_Kext + Correction

        correction = current_indoor * (gap_in / gap_out)
        target_outdoor = current_outdoor + correction

        # Use target_outdoor as the new sample
        coeff_new = target_outdoor

        # Validate coefficient
        if not math.isfinite(coeff_new) or coeff_new <= 0:
            _LOGGER.warning("%s - Auto TPI: Invalid new outdoor coeff: %.3f (non-finite or <= 0), skipping", self._name, coeff_new)
            self.state.last_learning_status = "invalid_outdoor_coeff"
            return False

        # Cap at 1.2 (Slightly relaxed to allow logic to work in extreme cases, but bounded)
        MAX_KEXT = 1.2
        if coeff_new > MAX_KEXT:
            _LOGGER.info("%s - Auto TPI: Calculated outdoor coeff %.3f > %.1f, capping to %.1f before averaging", self._name, coeff_new, MAX_KEXT, MAX_KEXT)
            coeff_new = MAX_KEXT

        count = self.state.coeff_outdoor_cool_autolearn if is_cool else self.state.coeff_outdoor_autolearn
        old_coeff = current_outdoor

        # Apply EMA or average
        effective_count = min(count, 50)

        if self._calculation_method == "average":
            # Kext counter starts at 0, so first cycle should have weight 0
            weight_old = effective_count
            avg_coeff = ((old_coeff * weight_old) + coeff_new) / (weight_old + 1)
            _LOGGER.debug("%s - Auto TPI: Outdoor Weighted Average: old=%.3f (weight=%d, real_count=%d), new=%.3f, result=%.3f", self._name, old_coeff, weight_old, count, coeff_new, avg_coeff)
        else:  # EMA
            alpha = self._get_adaptive_alpha(effective_count)
            avg_coeff = (old_coeff * (1.0 - alpha)) + (coeff_new * alpha)
            _LOGGER.debug("%s - Auto TPI: Outdoor EMA: old=%.3f, new=%.3f, alpha=%.3f (eff_count=%d, real_count=%d), result=%.3f", self._name, old_coeff, coeff_new, alpha, effective_count, count, avg_coeff)

        new_count = count + 1

        # We only cap if continuous learning is OFF, and we want to stop learning
        if not self._continuous_learning:
            # The standard threshold is 50 + initial weight
            INT_CYCLES_THRESHOLD = 50 + self._avg_initial_weight

            indoor_autolearn_count = self.state.coeff_indoor_cool_autolearn if is_cool else self.state.coeff_indoor_autolearn
            is_indoor_finished = indoor_autolearn_count >= INT_CYCLES_THRESHOLD

            #  Kext learning stops (capped at 50) ONLY if:
            # (kext_cycles >= 50) AND (not keep_ext_learning OR kint_cycles >= 50 + initial_weight).
            # This ensures Kext always learns a minimum of 50 cycles (Standard minimum).

            EXT_CYCLES_THRESHOLD = 50
            is_kext_standard_finished = count >= EXT_CYCLES_THRESHOLD

            # stop_learning_now is not used here, only for final persistence check

            # new_count is NOT capped anymore to reflect the real number of cycles
            pass  # No cap

        self._calculate_retroactive_capacity(avg_coeff, old_coeff, is_cool)

        if is_cool:
            self.state.coeff_outdoor_cool = avg_coeff
            self.state.coeff_outdoor_cool_autolearn = new_count
        else:
            self.state.coeff_outdoor_heat = avg_coeff
            self.state.coeff_outdoor_autolearn = new_count

        _LOGGER.info(
            "%s - Auto TPI: Learn outdoor (%s). Old: %.3f, Correction: %.3f, Target: %.3f, Averaged: %.3f (count: %d)",
            self._name,
            "cool" if is_cool else "heat",
            old_coeff,
            correction,
            coeff_new,
            avg_coeff,
            new_count,
        )
        return True

    def _calculate_retroactive_capacity(self, avg_coeff: float, old_coeff: float, is_cool: bool) -> None:
        """Calculate and apply retroactive capacity adjustment based on Kext change."""
        # RETRO-ACTIVE CAPACITY ADJUSTMENT
        kext_diff = avg_coeff - old_coeff
        delta_t_losses = 0.0
        max_capacity_attr = ""

        if is_cool:
            delta_t_losses = self._current_temp_out - self._current_temp_in
            max_capacity_attr = "max_capacity_cool"
        else:
            delta_t_losses = self._current_temp_in - self._current_temp_out
            max_capacity_attr = "max_capacity_heat"

        delta_t_losses = max(0.0, delta_t_losses)

        if abs(kext_diff) > 0.0001 and delta_t_losses > 0.0:
            current_capacity = getattr(self.state, max_capacity_attr)
            if current_capacity > 0:
                # We need to reverse the adiabatic calculation to find the implicit current "rise_rate"
                # Old_Capacity = Rise_Rate + (Old_Kext * dT)
                # Rise_Rate = Old_Capacity - (Old_Kext * dT)
                #
                # New_Capacity = Old_Capacity + kext_diff * dT

                # Using the shared formula:
                # 1. Reverse to get invariant rise_rate
                implicit_rise_rate = current_capacity - (old_coeff * delta_t_losses)

                # 2. Recalculate with new coefficient
                new_capacity = self._calculate_adiabatic_capacity(implicit_rise_rate, avg_coeff, delta_t_losses)

                new_capacity = max(0.01, new_capacity)

                setattr(self.state, max_capacity_attr, new_capacity)

                _LOGGER.info(
                    "%s - Auto TPI: Adjusted %s: %.3f -> %.3f due to Kext change (diff: %.4f, dT: %.1f)",
                    self._name, max_capacity_attr, current_capacity, new_capacity, kext_diff, delta_t_losses
                )

    def _calculate_adiabatic_capacity(self, observed_rise_rate: float, k_ext: float, delta_t: float) -> float:
        """Calculate adiabatic capacity (decoupled from losses).

        Formula: Capacity_adiabatic = Rise_Rate + (Kext * DeltaT)
        """
        return observed_rise_rate + (k_ext * delta_t)

    def _should_learn_capacity(self) -> bool:
        """Check if capacity learning should occur this cycle."""
        if not self.learning_active and not self._continuous_kext:
             _LOGGER.debug("%s - Not learning capacity: learning and continuous kext are disabled", self._name)
             return False

        # Determine if we are in bootstrap
        in_bootstrap = (
            self.state.max_capacity_heat == 0 or
            self.state.capacity_heat_learn_count < 3
        )

        # Baseline thresholds
        power_threshold = 0.80
        # Dynamic rise threshold:
        # normally 0.05°C to avoid noise.
        # BUT if power is near saturation (>95%), we might be limited by capacity, so we accept almost any rise (0.01°C).
        # This allows max_capacity to decrease if the system struggles to heat (high power, low rise).
        rise_threshold = 0.01 if self.state.last_power > 0.95 else 0.05

        min_gap = 1.0 if self.state.capacity_heat_learn_count < 3 else 0.3

        # Timeout Strategy: Force default capacity if bootstrap fails too many times
        if in_bootstrap:
            failures = self.state.bootstrap_failure_count

            if failures > 5:
                # Force exit bootstrap with conservative capacity
                _LOGGER.warning(
                    "%s - Bootstrap timeout after %d failures. Forcing default capacity 0.3°C/h and exiting bootstrap.",
                    self._name, failures
                )
                self.state.max_capacity_heat = 0.3
                # We interpret this forced exit as having "learned" enough to stabilize
                # Setting count to 3 ensures we use alpha=0.15 (stabilized) for future updates
                self.state.capacity_heat_learn_count = 3
                self.state.bootstrap_failure_count = 0 # Reset counter

                # Persist default capacity to config
                if self._hass and self._hass.loop and not self._hass.loop.is_closed():
                    self._hass.async_create_task(
                        self.async_update_learning_data(capacity=0.3, is_heat_mode=True)
                    )

                return False # Cycle handled (we set default), skip calculation logic for this cycle

        # Check Condition 1: Power
        if self.state.last_power < power_threshold:
            _LOGGER.debug(
                "%s - Not learning capacity: power too low (%.1f%% < %.0f%%)",
                self._name, self.state.last_power * 100, power_threshold * 100
            )
            if in_bootstrap:
                self.state.bootstrap_failure_count += 1
            return False

        # Condition 1b: Minimum efficiency (heater on-time ratio)
        # When efficiency is low, temperature rise from external factors (sun, window close)
        # gets amplified in capacity calculation, causing outlier spikes.
        if self._last_cycle_power_efficiency < MIN_EFFICIENCY_FOR_CAPACITY:
            _LOGGER.debug(
                "%s - Not learning capacity: efficiency too low (%.1f%% < %.0f%%) - external factors may dominate",
                self._name, self._last_cycle_power_efficiency * 100, MIN_EFFICIENCY_FOR_CAPACITY * 100
            )
            if in_bootstrap:
                self.state.bootstrap_failure_count += 1
            return False

        # Condition 2: Significant rise
        real_rise = self._current_temp_in - self.state.last_temp_in
        if real_rise < rise_threshold:
            _LOGGER.debug(
                "%s - Not learning capacity: rise too small (%.3f < %.2f°C)",
                self._name, real_rise, rise_threshold
            )
            if in_bootstrap:
                self.state.bootstrap_failure_count += 1
            return False

        # Condition 3: Adequate gap (stricter during bootstrap)
        target_diff = self._current_target_temp - self.state.last_temp_in
        if target_diff < min_gap:
            _LOGGER.debug(
                "%s - Not learning capacity: gap too small (%.2f < %.1f°C)",
                self._name, target_diff, min_gap
            )
            # Note: We don't necessarily increment failure count for "small gap"
            # as this is not a "failed attempt" to heat, but rather "no need to heat much".
            # But if we are in bootstrap, we WANT larger gaps.
            # Let's be conservative and NOT increment here to avoid relaxing just because setpoint is close.
            return False

        return True

    async def _learn_capacity(self, power: float, delta_t: float, rise: float,
                      efficiency: float, k_ext: float) -> bool:
        """Learn heating capacity using simple EWMA with adiabatic correction.

        Inspired by regul2.py parameter estimation approach.

        Args:
            power: Heating power ratio (0-1)
            delta_t: Temperature gap (Tin - Tout) in °C
            rise: Observed temperature rise in °C
            efficiency: Cycle efficiency (0-1)
            k_ext: Current external coefficient

        Returns:
            True if capacity was updated and RELOAD triggered, False otherwise (or no reload needed)
        """
        # Calculate observed capacity (with thermal losses included)
        cycle_duration_h = self._cycle_min / 60.0
        # Check for division by zero
        if cycle_duration_h * efficiency <= 0:
            return False

        observed_rise_rate = rise / (cycle_duration_h * efficiency)

        # Adiabatic correction: add back the estimated losses
        # This decouples heating capacity from thermal losses
        adiabatic_capacity = self._calculate_adiabatic_capacity(observed_rise_rate, k_ext, delta_t)

        # Basic validation (physical bounds)
        if adiabatic_capacity <= 0 or adiabatic_capacity > 20.0:
            _LOGGER.debug(
                "%s - Capacity measurement out of bounds: %.2f°C/h, skipping",
                self._name, adiabatic_capacity
            )
            return False

        # Capacity learning with adaptive weighting:
        # - Bootstrap (<3 cycles): EMA with alpha=0.4 for fast convergence
        # - Transition (3-MAX_WEIGHT cycles):EWMA alpha decreases as 1/(count+1)
        # - Stable (>MAX_WEIGHT cycles):  EMA with alpha=0.05 for outlier resistance
        count = self.state.capacity_heat_learn_count
        MAX_CAPACITY_WEIGHT = 20  # After 20 cycles, switch to pure EMA
        STABLE_ALPHA = 0.05  # Fixed alpha for mature model

        old_capacity = self.state.max_capacity_heat

        if old_capacity == 0:
            # First measurement: take directly
            self.state.max_capacity_heat = adiabatic_capacity
            effective_alpha = 1.0
        elif count < 3:
            # Bootstrap: EWMA with high alpha for fast convergence
            alpha = 0.4
            self.state.max_capacity_heat = (1 - alpha) * old_capacity + alpha * adiabatic_capacity
            effective_alpha = alpha
        elif count < MAX_CAPACITY_WEIGHT:
            # Transition: weighted average (alpha equivalent = 1/(count+1))
            # New value gets weight=1, old value gets weight=count
            self.state.max_capacity_heat = (old_capacity * count + adiabatic_capacity) / (count + 1)
            effective_alpha = 1.0 / (count + 1)
        else:
            # Stable: pure EMA with fixed low alpha
            self.state.max_capacity_heat = (1 - STABLE_ALPHA) * old_capacity + STABLE_ALPHA * adiabatic_capacity
            effective_alpha = STABLE_ALPHA

        # Clamp protection: limit capacity change to ±50% per cycle (except during bootstrap)
        MAX_CHANGE_RATIO = 1.5
        if old_capacity > 0 and count >= 3:
            min_allowed = old_capacity / MAX_CHANGE_RATIO
            max_allowed = old_capacity * MAX_CHANGE_RATIO
            if self.state.max_capacity_heat < min_allowed or self.state.max_capacity_heat > max_allowed:
                clamped_value = max(min_allowed, min(max_allowed, self.state.max_capacity_heat))
                _LOGGER.warning(
                    "%s - Capacity clamped: %.2f -> %.2f (allowed: %.2f - %.2f)",
                    self._name, self.state.max_capacity_heat, clamped_value, min_allowed, max_allowed
                )
                self.state.max_capacity_heat = clamped_value

        self.state.capacity_heat_learn_count += 1

        # Store in history for confidence calculation
        if not hasattr(self, '_capacity_history'):
            self._capacity_history = []
        self._capacity_history.append(self.state.max_capacity_heat)
        if len(self._capacity_history) > 10:
            self._capacity_history.pop(0)

        _LOGGER.info(
            "%s - Capacity learned: %.2f°C/h (count: %d, alpha: %.3f)",
            self._name, self.state.max_capacity_heat,
            self.state.capacity_heat_learn_count, effective_alpha
        )

        # Reset failure count on success
        self.state.bootstrap_failure_count = 0

        return False

    def _get_capacity_confidence(self) -> float:
        """Calculate capacity learning confidence based on CV (coefficient of variation).

        Similar to tau_reliability() in regul2.py.

        Returns:
            Confidence score from 0.0 (no confidence) to 1.0 (high confidence)
        """
        # Need minimum samples
        if self.state.capacity_heat_learn_count < 3:
            return 0.3

        # Need history
        if not hasattr(self, '_capacity_history'):
            self._capacity_history = []

        if len(self._capacity_history) < 3:
            return 0.5

        # Calculate coefficient of variation (CV)
        mean_cap = statistics.mean(self._capacity_history)
        if mean_cap <= 0:
            return 0.0

        std_cap = statistics.pstdev(self._capacity_history)
        cv = std_cap / mean_cap

        # Confidence decreases with CV
        # CV = 0.1 → confidence = 0.90
        # CV = 0.3 → confidence = 0.70
        # CV = 0.5 → confidence = 0.50
        # CV > 1.0 → confidence = 0.0
        confidence = max(0.0, min(1.0, 1.0 - cv))

        return confidence

    def _check_deboost(self, is_heat: bool, real_rise: float, adjusted_theoretical: float) -> bool:
        """Check if we should reduce indoor coefficient after excessive performance.

        Only activates after MIN_DEBOOST_CYCLES to let normal learning stabilize first.
        Returns True if deboost was applied.
        """
        MIN_DEBOOST_CYCLES = 20

        if is_heat:
            count = self.state.coeff_indoor_autolearn
            current_kint = self.state.coeff_indoor_heat
        else:
            count = self.state.coeff_indoor_cool_autolearn
            current_kint = self.state.coeff_indoor_cool

        # Wait for learning to stabilize before applying deboost
        if count < MIN_DEBOOST_CYCLES:
            return False

        # If we achieved more than expected, consider reducing coefficient
        if real_rise <= adjusted_theoretical * 1.2:  # Need 20% overshoot to trigger
            return False

        DEBOOST_FACTOR = 0.95

        # Calculate target Kint
        target_kint = current_kint * DEBOOST_FACTOR
        effective_count = min(count, 50)
        old = current_kint

        # Apply same weighting logic as Kext overshoot correction
        if self._calculation_method == "average":
            boosted_weight = max(1, int(effective_count / OVERSHOOT_CORRECTION_BOOST))
            new_kint = ((old * boosted_weight) + target_kint) / (boosted_weight + 1)
            _LOGGER.debug(
                "%s - Deboost Kint (Average): old=%.4f, target=%.4f, weight=%d (boosted from %d), result=%.4f",
                self._name, old, target_kint, boosted_weight, effective_count, new_kint
            )
        else:  # EMA
            base_alpha = self._get_adaptive_alpha(effective_count)
            boosted_alpha = min(base_alpha * OVERSHOOT_CORRECTION_BOOST, 0.3)
            new_kint = (old * (1.0 - boosted_alpha)) + (target_kint * boosted_alpha)
            _LOGGER.debug(
                "%s - Deboost Kint (EMA): old=%.4f, target=%.4f, alpha=%.3f (boosted from %.3f), result=%.4f",
                self._name, old, target_kint, boosted_alpha, base_alpha, new_kint
            )

        if is_heat:
            if old > self._default_coef_int:
                self.state.coeff_indoor_heat = max(new_kint, self._default_coef_int)
                _LOGGER.info("%s - Deboosting Kint heat: %.3f → %.3f (weighted)", self._name, old, self.state.coeff_indoor_heat)
        else:
            if old > self._default_coef_int:
                self.state.coeff_indoor_cool = max(new_kint, self._default_coef_int)
                _LOGGER.info("%s - Deboosting Kint cool: %.3f → %.3f (weighted)", self._name, old, self.state.coeff_indoor_cool)

        # Reset boost counter
        if hasattr(self.state, "consecutive_boosts"):
            self.state.consecutive_boosts = 0

        return True

    def _correct_kext_overshoot(self, overshoot: float, is_cool: bool) -> bool:
        """Aggressively reduce Kext when room is overshooting with significant power.

        This method is called when the room temperature exceeds the setpoint
        while heat is still being applied. This indicates Kext is too high.

        Args:
            overshoot: Temperature above setpoint (positive value) in °C
            is_cool: True if in cooling mode

        Returns:
            True if correction was applied, False otherwise
        """
        # Feature flag check
        if not self.state.allow_kext_overshoot:
            return False

        current_kext = self.state.coeff_outdoor_cool if is_cool else self.state.coeff_outdoor_heat
        current_kint = self.state.coeff_indoor_cool if is_cool else self.state.coeff_indoor_heat

        # Calculate delta_ext for the correction
        delta_ext = self.state.last_order - self._current_temp_out
        if abs(delta_ext) < 0.1:
            _LOGGER.debug("%s - Auto TPI: Cannot correct Kext overshoot - delta_ext too small (%.2f)", self._name, delta_ext)
            return False

        # Calculate how much Kext should be reduced
        # At setpoint, Power = Kext * delta_ext
        # To allow temperature to fall, we need to reduce power by at least: overshoot * Kint
        # So: needed_power_reduction = overshoot * Kint
        # And: needed_kext_reduction = needed_power_reduction / delta_ext
        needed_reduction = (overshoot * current_kint) / delta_ext

        # Target Kext that would produce correct power at setpoint
        target_kext = max(0.001, current_kext - needed_reduction)

        # Get base alpha for calculation
        count = self.state.coeff_outdoor_cool_autolearn if is_cool else self.state.coeff_outdoor_autolearn
        effective_count = min(count, 50)

        old_kext = current_kext

        if self._calculation_method == "average":
            # For average mode: reduce effective weight to give more influence to correction
            # Instead of weight = effective_count, use weight / OVERSHOOT_CORRECTION_BOOST
            boosted_weight = max(1, int(effective_count / OVERSHOOT_CORRECTION_BOOST))
            new_kext = ((old_kext * boosted_weight) + target_kext) / (boosted_weight + 1)
            _LOGGER.debug(
                "%s - Auto TPI: Overshoot correction (Average): old=%.4f, target=%.4f, weight=%d (boosted from %d), result=%.4f",
                self._name, old_kext, target_kext, boosted_weight, effective_count, new_kext
            )
        else:  # EMA
            # Use boosted alpha for faster correction
            base_alpha = self._get_adaptive_alpha(effective_count)
            boosted_alpha = min(base_alpha * OVERSHOOT_CORRECTION_BOOST, 0.3)
            new_kext = (old_kext * (1.0 - boosted_alpha)) + (target_kext * boosted_alpha)
            _LOGGER.debug(
                "%s - Auto TPI: Overshoot correction (EMA): old=%.4f, target=%.4f, alpha=%.3f (boosted from %.3f), result=%.4f",
                self._name, old_kext, target_kext, boosted_alpha, base_alpha, new_kext
            )

        # Ensure Kext doesn't go below minimum
        new_kext = max(0.001, new_kext)

        if is_cool:
            self.state.coeff_outdoor_cool = new_kext
        else:
            self.state.coeff_outdoor_heat = new_kext

        self.state.last_learning_status = "corrected_kext_overshoot"
        self._learning_just_completed = True

        _LOGGER.info(
            "%s - Auto TPI: Overshoot correction applied! Kext: %.4f -> %.4f (overshoot=%.2f°C, power=%.1f%%)",
            self._name, old_kext, new_kext, overshoot, self.state.last_power * 100
        )

        return True

    def _correct_kint_insufficient_rise(self, target_diff: float, temp_progress: float, is_cool: bool) -> bool:
        """Boost Kint when temperature stagnates despite significant gap to setpoint.

        This method is called when:
        - target_diff > INSUFFICIENT_RISE_GAP_THRESHOLD (0.3°C)
        - temp_progress < 0.02 (temperature stagnating)
        - power < 0.99 (not saturated)

        Instead of incorrectly adjusting Kext (which would happen in outdoor learning),
        we boost Kint to increase power output.

        Args:
            target_diff: The gap between setpoint and room temperature (positive value)
            temp_progress: Temperature change during the cycle (can be negative)
            is_cool: True if in cooling mode

        Returns:
            True if correction was applied, False otherwise
        """
        # Feature flag check
        if not self.state.allow_kint_boost:
            return False

        # Check if we've hit the max consecutive boosts limit
        if self.state.consecutive_boosts >= MAX_CONSECUTIVE_KINT_BOOSTS:
            _LOGGER.warning(
                "%s - Auto TPI: Kint boost skipped - max consecutive boosts (%d) reached. Possible undersized heating.",
                self._name, MAX_CONSECUTIVE_KINT_BOOSTS
            )
            self.state.last_learning_status = "max_kint_boosts_reached"
            # Send notification if enabled (only once per limit hit)
            if self._enable_notification and self.state.consecutive_boosts == MAX_CONSECUTIVE_KINT_BOOSTS:
                self._hass.async_create_task(self._notify_undersized_heating())
            return False

        current_kint = self.state.coeff_indoor_cool if is_cool else self.state.coeff_indoor_heat

        # Calculate proportional boost based on gap size
        # Base boost is 8%, but increases slightly with larger gaps
        # For gap = 0.3°C: boost = 8%, for gap = 0.6°C: boost ≈ 10%
        gap_factor = min(target_diff / INSUFFICIENT_RISE_GAP_THRESHOLD, 2.0)  # Cap at 2x
        base_boost_percent = (INSUFFICIENT_RISE_BOOST_FACTOR - 1.0) * gap_factor

        target_kint = current_kint * (1.0 + base_boost_percent)

        count = self.state.coeff_indoor_cool_autolearn if is_cool else self.state.coeff_indoor_autolearn
        effective_count = min(count, 50)
        old_kint = current_kint

        # Apply same weighting logic as Kext overshoot correction
        if self._calculation_method == "average":
            boosted_weight = max(1, int(effective_count / OVERSHOOT_CORRECTION_BOOST))
            new_kint = ((old_kint * boosted_weight) + target_kint) / (boosted_weight + 1)
            _LOGGER.debug(
                "%s - Boost Kint (Average): old=%.4f, target=%.4f, weight=%d (boosted from %d), result=%.4f",
                self._name, old_kint, target_kint, boosted_weight, effective_count, new_kint
            )
        else:  # EMA
            base_alpha = self._get_adaptive_alpha(effective_count)
            boosted_alpha = min(base_alpha * OVERSHOOT_CORRECTION_BOOST, 0.3)
            new_kint = (old_kint * (1.0 - boosted_alpha)) + (target_kint * boosted_alpha)
            _LOGGER.debug(
                "%s - Boost Kint (EMA): old=%.4f, target=%.4f, alpha=%.3f (boosted from %.3f), result=%.4f",
                self._name, old_kint, target_kint, boosted_alpha, base_alpha, new_kint
            )

        # Cap to max coefficient
        new_kint = min(new_kint, self._max_coef_int)

        # Ensure minimum Kint
        new_kint = max(new_kint, MIN_KINT)

        # Check if we actually changed anything (might hit cap)
        if abs(new_kint - current_kint) < 0.001:
            _LOGGER.debug(
                "%s - Auto TPI: Kint correction skipped - already at limit (current=%.3f, max=%.3f)",
                self._name, current_kint, self._max_coef_int
            )
            return False

        old_kint = current_kint

        if is_cool:
            self.state.coeff_indoor_cool = new_kint
        else:
            self.state.coeff_indoor_heat = new_kint

        self.state.last_learning_status = "corrected_kint_insufficient_rise"
        self._learning_just_completed = True

        _LOGGER.info(
            "%s - Auto TPI: Kint correction applied! Kint: %.4f -> %.4f (gap=%.2f°C, progress=%.2f°C, power=%.1f%%, boost #%d)",
            self._name, old_kint, new_kint, target_diff, temp_progress, self.state.last_power * 100, self.state.consecutive_boosts + 1
        )

        # Increment consecutive boosts counter
        self.state.consecutive_boosts += 1

        return True

    async def _notify_undersized_heating(self):
        """Send notification when max consecutive Kint boosts is reached."""
        title = f"Versatile Thermostat: Auto TPI Warning for {self._name}"
        message = (
            f"Auto TPI has reached the maximum consecutive Kint boost limit ({MAX_CONSECUTIVE_KINT_BOOSTS}). "
            f"The temperature is not rising despite increased power demand. "
            f"This may indicate undersized heating or abnormal heat loss. "
            f"Learning will continue normally, but Kint boosting is paused until external temperature rises."
        )

        try:
            await self._hass.services.async_call(
                "persistent_notification",
                "create",
                {
                    "title": title,
                    "message": message,
                    "notification_id": f"autotpi_undersized_heating_{self._unique_id}",
                },
                blocking=False,
            )
            _LOGGER.warning("%s - Auto TPI: Undersized heating notification sent.", self._name)
        except Exception as e:
            _LOGGER.error("%s - Auto TPI: Error sending undersized heating notification: %s", self._name, e)

    async def _detect_failures(self, current_temp_in: float):
        """Detect system failures."""
        OFFSET_FAILURE = 1.0
        MIN_LEARN_FOR_DETECTION = 25

        failure_detected = False
        reason = "unknown"

        if (
            self.state.last_state == "heat"
            and self.state.last_power >= self.saturation_threshold
            and current_temp_in < self.state.last_order - OFFSET_FAILURE
            and current_temp_in < self.state.last_temp_in
            and self.state.coeff_indoor_autolearn > MIN_LEARN_FOR_DETECTION
        ):
            failure_detected = True
            reason = "Temperature dropped while heating at full power"
            _LOGGER.warning("%s - Auto TPI: Failure detected in HEAT mode at saturation (power=%.1f%%)", self._name, self.state.last_power * 100)

        elif (
            self.state.last_state == "cool"
            and self.state.last_power >= self.saturation_threshold
            and current_temp_in > self.state.last_order + OFFSET_FAILURE
            and current_temp_in > self.state.last_temp_in
            and self.state.coeff_indoor_autolearn > MIN_LEARN_FOR_DETECTION
        ):
            failure_detected = True
            reason = "Temperature rose while cooling at full power"
            _LOGGER.warning("%s - Auto TPI: Failure detected in COOL mode at saturation (power=%.1f%%)", self._name, self.state.last_power * 100)

        if failure_detected:
            self.state.consecutive_failures += 1
            if self.state.consecutive_failures >= 3:
                if self._continuous_learning:
                    # In continuous learning mode, don't stop learning - just skip faulty cycles
                    _LOGGER.warning(
                        "%s - Auto TPI: %d consecutive failures detected in continuous mode. "
                        "Skipping faulty cycles and continuing learning. Reason: %s",
                        self._name,
                        self.state.consecutive_failures,
                        reason,
                    )
                    # Reset the counter to allow future failure detection
                    self.state.consecutive_failures = 0
                else:
                    # Standard mode: disable learning after 3 consecutive failures
                    self.state.autolearn_enabled = False
                    _LOGGER.error(
                        "%s - Auto TPI: Learning disabled due to %d consecutive failures.",
                        self._name,
                        self.state.consecutive_failures,
                    )

                    # Send persistent notification
                    # Retrieve the message from translations
                    # We use the "exceptions" category in strings.json
                    # The key is "component.versatile_thermostat.exceptions.auto_tpi_learning_stopped.message"
                    title = "Versatile Thermostat: Auto TPI Learning Stopped"
                    try:
                        translations = await translation.async_get_translations(
                            self._hass,
                            self._hass.config.language,
                            "exceptions",
                            {DOMAIN}
                        )

                        # Key format for exceptions: component.{domain}.exceptions.{key}.message
                        key = f"component.{DOMAIN}.exceptions.auto_tpi_learning_stopped.message"
                        message_template = translations.get(key)

                        if message_template:
                            message = message_template.format(name=self._name, reason=reason)
                        else:
                            # Fallback if translation not found
                            message = f"Auto TPI learning for {self._name} has been stopped due to 3 consecutive failures. Reason: {reason}. Please check your configuration."

                        await self._hass.services.async_call(
                            "persistent_notification",
                            "create",
                            {
                                "title": title,
                                "message": message,
                                "notification_id": f"autotpi_learning_stopped_{self._unique_id}",
                            },
                            blocking=False,
                        )
                    except Exception as e:
                        _LOGGER.error("%s - Auto TPI: Error sending persistent notification: %s", self._name, e)

        else:
            self.state.consecutive_failures = 0

    @property
    def saturation_threshold(self) -> float:
        """The saturation power threshold (default 1.0, 100%)."""
        # This property is expected to be overridden by the mixing/main component.
        # Defaulting to 1.0 for self-contained use if not overridden.
        return 1.0

    def calculate_power(self, setpoint: float, temp_in: float, temp_out: float, state_str: str) -> float:
        """Calculate TPI power, using aggressive coefficients during bootstrap."""

        # Bootstrap logic: aggressive coefficients
        in_bootstrap = (
            state_str == "heat" and
            (self.state.max_capacity_heat == 0 or self.state.capacity_heat_learn_count < 3)
        )

        saved_kint = self.state.coeff_indoor_heat
        saved_kext = self.state.coeff_outdoor_heat

        if in_bootstrap:
            self.state.coeff_indoor_heat = 1.0
            self.state.coeff_outdoor_heat = 0.1

        try:
            return self._calculate_power_tpi(setpoint, temp_in, temp_out, state_str)
        finally:
            if in_bootstrap:
                 self.state.coeff_indoor_heat = saved_kint
                 self.state.coeff_outdoor_heat = saved_kext

    def _calculate_power_tpi(self, setpoint: float, temp_in: float, temp_out: float, state_str: str) -> float:
        """Normal TPI proportional control."""
        if temp_out is None:
            return 0.0

        direction = 1 if state_str == "heat" else -1
        delta_in = setpoint - temp_in
        delta_out = setpoint - temp_out

        if state_str == "cool":
            coeff_int = self.state.coeff_indoor_cool
            coeff_ext = self.state.coeff_outdoor_cool
        else:
            coeff_int = self.state.coeff_indoor_heat
            coeff_ext = self.state.coeff_outdoor_heat

        offset = self.state.offset
        power = (direction * delta_in * coeff_int) + (direction * delta_out * coeff_ext) + offset
        return max(0.0, min(1.0, power))

    @staticmethod
    def _remove_outliers_iqr(values: list[float]) -> list[float]:
        """
        Remove outliers using Interquartile Range (IQR) method.
        Keeps values within [Q1 - 1.5*IQR, Q3 + 1.5*IQR].
        """
        if len(values) < 4:
            return values

        sorted_values = sorted(values)
        n = len(sorted_values)

        q1_idx = n // 4
        q3_idx = (3 * n) // 4

        q1 = sorted_values[q1_idx]
        q3 = sorted_values[q3_idx]
        iqr = q3 - q1

        lower_bound = q1 - 1.5 * iqr
        upper_bound = q3 + 1.5 * iqr

        return [v for v in values if lower_bound <= v <= upper_bound]

    def _get_power_at_time_sample_hold(self, target_dt: datetime, sorted_power: list, start_idx: int = 0) -> tuple[Optional[float], int]:
        """
        Get the power value at a specific time using sample-and-hold logic.

        For event-driven sensors, returns the last known power value before or at target_dt.
        This is more appropriate for capacity calibration where power may stay stable
        for long periods without new history entries.

        Args:
            target_dt: The datetime to find power for
            sorted_power: Power history sorted by time (ascending)
            start_idx: Index to start searching from (optimization)

        Returns:
            Tuple of (power value in percent, next index to continue from)
        """
        if not sorted_power:
            return None, 0

        last_valid_power = None
        last_valid_idx = start_idx

        # Find the last power entry that is <= target_dt
        for i in range(start_idx, len(sorted_power)):
            state = sorted_power[i]
            try:
                state_dt = state.last_changed

                if state_dt > target_dt:
                    # We've passed the target time, use the last valid power
                    break

                # This entry is at or before target_dt
                state_value = getattr(state, "state", None)
                if state_value not in ["unknown", "unavailable", None]:
                    try:
                        last_valid_power = float(state_value)
                        last_valid_idx = i
                    except (ValueError, TypeError):
                        pass

            except (AttributeError, TypeError):
                continue

        return last_valid_power, last_valid_idx

    async def calculate_capacity_from_slope_sensor(
        self,
        slope_history: list,
        power_history: list,
        min_power_threshold: float = 0.95,
        kext_coeff: float = 0.0,
        current_indoor_temp: Optional[float] = None,
        current_outdoor_temp: Optional[float] = None,
    ) -> dict:
        """
        Calculate ADIABATIC capacity using temperature_slope and power_percent sensor histories.

        ALGORITHM:
        1. Match slope points with power values at the same time
        2. Keep points where power >= threshold AND slope direction is correct
        3. Remove outliers using IQR method
        4. Use 75th percentile (biases toward higher/adiabatic values)
        5. Add Kext compensation: capacity = percentile_75 + Kext × avg_delta_T

        Args:
            slope_history: History of temperature_slope sensor
            power_history: History of power_percent sensor
            min_power_threshold: Minimum power (0.0-1.0) to consider. Default 0.95 (95%)
            kext_coeff: Current Kext coefficient for adiabatic correction
            current_indoor_temp: Current indoor temperature for delta_T estimation
            current_outdoor_temp: Current outdoor temperature for delta_T estimation

        Returns:
            Dictionary with adiabatic capacity result and metrics
        """
        # Always True now
        is_heat_mode = True
        power_threshold_percent = min_power_threshold * 100.0

        _LOGGER.debug(
            "%s - Capacity Calibration: Analyzing %d slope points and %d power points (threshold=%.0f%%)",
            self._name,
            len(slope_history) if slope_history else 0,
            len(power_history) if power_history else 0,
            power_threshold_percent,
        )

        if not slope_history:
            return {"success": False, "error": "No temperature slope history found", "samples_used": 0}

        if not power_history:
            return {"success": False, "error": "No power percent history found", "samples_used": 0}

        # Collect valid slope values
        raw_slopes = []
        rejected_low_power = 0
        rejected_wrong_direction = 0
        rejected_invalid = 0

        # Sort histories by time to enable O(N+M) matching
        sorted_slopes = sorted(slope_history, key=lambda s: s.last_changed)
        sorted_power = sorted(power_history, key=lambda s: s.last_changed)

        power_idx = 0
        for slope_state in sorted_slopes:
            try:
                slope_dt = slope_state.last_changed
                slope_str = getattr(slope_state, "state", None)

                if slope_str in ["unknown", "unavailable", None]:
                    rejected_invalid += 1
                    continue

                slope_value = float(slope_str)

                # Find power value using sample-and-hold logic (handles event-driven sensors)
                power, power_idx = self._get_power_at_time_sample_hold(slope_dt, sorted_power, start_idx=power_idx)

                if power is None:
                    rejected_invalid += 1
                    continue

                # Check power threshold
                if power < power_threshold_percent:
                    rejected_low_power += 1
                    continue

                # Check slope direction (always heating check now)
                if slope_value <= 0:
                    rejected_wrong_direction += 1
                    continue

                raw_slopes.append(slope_value)

            except (ValueError, TypeError, AttributeError) as e:
                _LOGGER.debug("%s - Capacity Calibration: Invalid slope state: %s", self._name, e)
                rejected_invalid += 1
                continue

        _LOGGER.info(
            "%s - Capacity Calibration: Found %d valid samples (rejected: %d low-power, %d wrong-direction, %d invalid)",
            self._name,
            len(raw_slopes),
            rejected_low_power,
            rejected_wrong_direction,
            rejected_invalid,
        )

        if len(raw_slopes) < 2:
            return {
                "success": False,
                "error": f"Not enough valid samples ({len(raw_slopes)} found, minimum 2 required)",
                "samples_used": len(raw_slopes),
                "rejection_stats": {"low_power": rejected_low_power, "wrong_direction": rejected_wrong_direction, "invalid": rejected_invalid},
            }

        # Remove outliers
        filtered_slopes = self._remove_outliers_iqr(raw_slopes)
        outliers_removed = len(raw_slopes) - len(filtered_slopes)

        _LOGGER.debug("%s - Capacity Calibration: Removed %d outliers, %d samples remaining", self._name, outliers_removed, len(filtered_slopes))

        if len(filtered_slopes) < 2:
            return {
                "success": False,
                "error": f"Not enough samples after outlier removal ({len(filtered_slopes)} remaining)",
                "samples_used": len(filtered_slopes),
                "samples_before_filter": len(raw_slopes),
            }

        # Calculate 75th percentile (biases toward adiabatic - higher values)
        # Higher slopes = less heat loss = closer to adiabatic
        sorted_slopes = sorted(filtered_slopes)
        n = len(sorted_slopes)

        # 75th percentile index
        p75_idx = int(0.75 * (n - 1))
        observed_capacity = sorted_slopes[p75_idx]

        # Estimate average delta_T for Kext compensation
        # When power is at 100%, we typically have a significant delta_T
        # Use current temperatures if available, otherwise use typical value
        if current_indoor_temp is not None and current_outdoor_temp is not None:
            avg_delta_t = abs(current_indoor_temp - current_outdoor_temp)
        else:
            # Typical delta_T when heating at high power (rough estimate: 10-15°C)
            avg_delta_t = 12.0  # Conservative estimate

        # Apply Kext compensation for adiabatic capacity
        # Capacity_adiabatic = Slope_observed + Kext × delta_T
        kext_compensation = kext_coeff * avg_delta_t
        capacity = observed_capacity + kext_compensation

        _LOGGER.debug(
            "%s - Capacity Calibration: 75th percentile=%.3f, Kext=%.4f, delta_T=%.1f, compensation=%.3f, final=%.3f",
            self._name,
            observed_capacity,
            kext_coeff,
            avg_delta_t,
            kext_compensation,
            capacity,
        )

        # Ensure capacity is positive
        if capacity <= 0.0:
            _LOGGER.warning("%s - Capacity Calibration: Calculated capacity (%.3f) is not positive. Setting to 0.01.", self._name, capacity)
            capacity = 0.01

        # Calculate reliability based on sample count and variance
        mean_slope = sum(filtered_slopes) / len(filtered_slopes)
        variance = sum((s - mean_slope) ** 2 for s in filtered_slopes) / len(filtered_slopes)
        std_dev = math.sqrt(variance) if variance > 0 else 0.0
        cv = std_dev / mean_slope if mean_slope > 0 else 0.0  # Coefficient of variation

        # Reliability: higher with more samples and lower variance
        sample_factor = min(1.0, len(filtered_slopes) / 20.0)  # Max at 20 samples
        variance_factor = max(0.0, 1.0 - (cv / 2.0))  # Lower if high variance
        reliability = 100.0 * sample_factor * variance_factor

        # Period calculation (in days)
        period_days = 0.0
        if slope_history:
            try:
                timestamps = [s.last_changed for s in slope_history]
                if timestamps:
                    start_date = min(timestamps)
                    end_date = max(timestamps)
                    period_days = (end_date - start_date).total_seconds() / 86400.0
            except (AttributeError, TypeError):
                pass

        _LOGGER.info(
            "%s - Capacity Calibration: Adiabatic Capacity=%.3f °C/h (observed=%.3f + Kext×ΔT=%.3f), Reliability=%.1f%%, Samples=%d",
            self._name,
            capacity,
            observed_capacity,
            kext_compensation,
            reliability,
            len(filtered_slopes),
        )

        return {
            "success": True,
            "capacity": round(capacity, 3),
            "observed_capacity": round(observed_capacity, 3),
            "kext_compensation": round(kext_compensation, 3),
            "avg_delta_t": round(avg_delta_t, 1),
            "samples_used": len(filtered_slopes),
            "samples_before_filter": len(raw_slopes),
            "outliers_removed": outliers_removed,
            "reliability": round(reliability, 1),
            "min_power_threshold": min_power_threshold,
            "period": round(period_days, 1),
        }

    async def service_calibrate_capacity(
        self,
        thermostat_entity_id: str,
        ext_temp_entity_id: str,
        save_to_config: bool,
        min_power_threshold: float,
        start_date: datetime | str | None = None,
        end_date: datetime | str | None = None,
    ) -> dict:
        """
        Orchestrates the capacity calibration service using temperature_slope
        and power_percent sensor histories.

        NEW ALGORITHM:
        1. Derives slope and power sensor entity IDs from thermostat entity ID
        2. Fetches history for both sensors
        3. Matches points where power >= threshold and slope direction is correct
        4. Removes outliers and calculates median as Capacity

        Args:
            thermostat_entity_id: The climate entity ID (e.g., "climate.thermostat_salon")
            ext_temp_entity_id: External temperature sensor (unused in new algorithm but kept for API compatibility)
            save_to_config: Whether to save the result to config
            start_date: Start of history period (default: 30 days ago)
            end_date: End of history period (default: now)
            min_power_threshold: Minimum power percentage (0.0-1.0) to consider a sample.
                                 Default is 1.0 (100%). Lower values (e.g., 0.90) include more samples.
        """
        # 1. Derive sensor entity IDs from thermostat entity ID
        # climate.thermostat_salon -> sensor.thermostat_salon_temperature_slope
        # climate.thermostat_salon -> sensor.thermostat_salon_power_percent
        if thermostat_entity_id.startswith("climate."):
            base_name = thermostat_entity_id.replace("climate.", "")
        else:
            base_name = thermostat_entity_id.split(".")[-1]

        slope_sensor_id = f"sensor.{base_name}_temperature_slope"
        power_sensor_id = f"sensor.{base_name}_power_percent"

        _LOGGER.info("%s - Capacity calibration: Using slope sensor '%s' and power sensor '%s'", self._name, slope_sensor_id, power_sensor_id)

        # 2. Convert start_date and end_date to datetime objects
        if isinstance(start_date, str):
            _date = dt_util.parse_date(start_date)
            start_date = dt_util.start_of_local_day(_date) if _date else None

        if isinstance(end_date, str):
            _date = dt_util.parse_date(end_date)
            _end_day_start = dt_util.start_of_local_day(_date) if _date else None
            end_date = _end_day_start + timedelta(days=1) if _end_day_start else None

        # 3. Determine History Time Range
        now = dt_util.now()
        start_time = dt_util.as_utc(start_date) if start_date is not None else now - timedelta(days=30)
        end_time = dt_util.as_utc(end_date) if end_date is not None else now

        _LOGGER.info("%s - Calibrating capacity using history from %s to %s", self._name, start_time, end_time)

        # Handle percentage value for min_power_threshold (e.g. 95 -> 0.95)
        if min_power_threshold > 1.0:
            _LOGGER.debug("%s - Converting min_power_threshold from %.1f to %.2f", self._name, min_power_threshold, min_power_threshold / 100.0)
            min_power_threshold = min_power_threshold / 100.0

        # 4. Fetch sensor histories in chunks to avoid timeouts and cope with gaps
        entity_ids = [slope_sensor_id, power_sensor_id]
        slope_history = []
        power_history = []

        # We use 2-day chunks for robustness
        chunk_delta = timedelta(days=2)
        current_start = start_time

        while current_start < end_time:
            current_end = min(current_start + chunk_delta, end_time)
            _LOGGER.debug("%s - Fetching history chunk from %s to %s", self._name, current_start, current_end)

            try:
                chunk_states = await get_instance(self._hass).async_add_executor_job(
                    partial(
                        history.get_significant_states,
                        self._hass,
                        current_start,
                        end_time=current_end,
                        entity_ids=entity_ids,
                        significant_changes_only=False,
                    )
                )

                if chunk_states:
                    slope_history.extend(chunk_states.get(slope_sensor_id, []))
                    power_history.extend(chunk_states.get(power_sensor_id, []))

            except Exception as e:
                _LOGGER.warning("%s - Error fetching history chunk %s to %s: %s", self._name, current_start, current_end, e)

            current_start = current_end

        _LOGGER.debug("%s - Fetched %d slope sensor states and %d power sensor states for capacity calibration.", self._name, len(slope_history), len(power_history))

        # Check if sensors exist
        if not slope_history:
            _LOGGER.warning("%s - No history found for slope sensor '%s'. " "Make sure the sensor exists and has history enabled in recorder.", self._name, slope_sensor_id)
        if not power_history:
            _LOGGER.warning("%s - No history found for power sensor '%s'. " "Make sure the sensor exists and has history enabled in recorder.", self._name, power_sensor_id)

        # 5. Get Kext from HA config (not learned value) for adiabatic correction
        kext_coeff = self._default_coef_ext

        # Get current temperatures from thermostat for delta_T estimation
        current_indoor_temp = None
        current_outdoor_temp = None

        # Try to get current thermostat state for indoor temp
        thermostat_state = self._hass.states.get(thermostat_entity_id)
        if thermostat_state:
            try:
                current_indoor_temp = float(thermostat_state.attributes.get("current_temperature", 0))
            except (ValueError, TypeError):
                pass

        # Try to get outdoor temp from the external sensor
        if ext_temp_entity_id:
            outdoor_state = self._hass.states.get(ext_temp_entity_id)
            if outdoor_state and outdoor_state.state not in ["unknown", "unavailable"]:
                try:
                    current_outdoor_temp = float(outdoor_state.state)
                except (ValueError, TypeError):
                    pass

        _LOGGER.debug(
            "%s - Adiabatic correction params: Kext_config=%.4f, T_indoor=%.1f, T_outdoor=%.1f",
            self._name,
            kext_coeff,
            current_indoor_temp if current_indoor_temp else 0,
            current_outdoor_temp if current_outdoor_temp else 0,
        )

        # 6. Call calculation method with adiabatic correction
        result = await self.calculate_capacity_from_slope_sensor(
            slope_history,
            power_history,
            min_power_threshold=min_power_threshold,
            kext_coeff=kext_coeff,
            current_indoor_temp=current_indoor_temp,
            current_outdoor_temp=current_outdoor_temp,
        )

        _LOGGER.info("%s - Capacity calibration result: %s", self._name, result)

        # 6. Save to config if requested
        if result and isinstance(result, dict) and result.get("success"):

            max_capacity = result.get("capacity")
            if max_capacity is not None:
                # Rename capacity to max_capacity in result
                if "capacity" in result:
                    del result["capacity"]

                result["max_capacity"] = max_capacity

                if save_to_config:
                    # Always heat mode
                    is_heat_mode = True

                    await self.async_update_learning_data(capacity=max_capacity, is_heat_mode=is_heat_mode)

                    _LOGGER.info(
                        "%s - Heating capacity calibrated and saved: %.3f °C/h",
                        self._name, max_capacity
                    )

        return result

    async def _try_pre_bootstrap_calibration(self) -> float | None:
        """
        Try to calibrate capacity from historical data before starting bootstrap.

        Calls the calibration service internally with min_power_threshold=80%.
        If reliability >= MIN_PRE_BOOTSTRAP_CALIBRATION_RELIABILITY, returns max_capacity.
        Otherwise, returns None to trigger bootstrap.
        """
        try:
            # Use the stored entity_id if available, otherwise fall back to unique_id
            if self._entity_id:
                thermostat_entity_id = self._entity_id
            else:
                thermostat_entity_id = f"climate.{self._unique_id}"
                _LOGGER.warning(
                    "%s - Auto TPI: entity_id not set, falling back to unique_id-based entity_id: %s",
                    self._name, thermostat_entity_id
                )

            # Get external temperature entity from thermostat state if available
            ext_temp_entity_id = ""
            thermostat_state = self._hass.states.get(thermostat_entity_id)
            if thermostat_state:
                ext_temp_entity_id = thermostat_state.attributes.get("ext_current_temperature_entity_id", "")

            _LOGGER.debug(
                "%s - Auto TPI: Attempting pre-bootstrap calibration with min_power_threshold=80%%",
                self._name
            )

            result = await self.service_calibrate_capacity(
                thermostat_entity_id=thermostat_entity_id,
                ext_temp_entity_id=ext_temp_entity_id,
                save_to_config=False,  # Do not save yet, just check
                min_power_threshold=0.80,  # 80% power threshold for more samples
            )

            if not result or not result.get("success"):
                error = result.get("error", "unknown error") if result else "no result"
                _LOGGER.debug(
                    "%s - Auto TPI: Pre-bootstrap calibration failed: %s",
                    self._name, error
                )
                return None

            reliability = result.get("reliability", 0.0)
            max_capacity = result.get("max_capacity", 0.0)

            if reliability >= MIN_PRE_BOOTSTRAP_CALIBRATION_RELIABILITY and max_capacity > 0:
                _LOGGER.info(
                    "%s - Auto TPI: Pre-bootstrap calibration returned reliability=%.1f%% (>= %.1f%%), capacity=%.2f °C/h",
                    self._name, reliability, MIN_PRE_BOOTSTRAP_CALIBRATION_RELIABILITY, max_capacity
                )
                return max_capacity
            else:
                _LOGGER.debug(
                    "%s - Auto TPI: Pre-bootstrap calibration reliability too low (%.1f%% < %.1f%%) or capacity invalid (%.2f)",
                    self._name, reliability, MIN_PRE_BOOTSTRAP_CALIBRATION_RELIABILITY, max_capacity
                )
                return None

        except Exception as e:
            _LOGGER.warning(
                "%s - Auto TPI: Pre-bootstrap calibration error: %s",
                self._name, e
            )
            return None

    @callback
    def _capture_end_of_on_temp(self, _):
        """Capture the temperature at the end of the ON pulse."""
        self.state.last_on_temp_in = self._current_temp_in
        _LOGGER.debug("%s - Auto TPI: Captured end of ON temp: %.1f", self._name, self.state.last_on_temp_in)
        self._timer_capture_remove_callback = None

    def update_realized_power(self, realized_percent: float):
        """Update the power actually applied to the underlyings.

        Called by the handler when the realized power differs from the
        requested on_percent. Sources of difference:
        - timing constraints (min_activation_delay, min_deactivation_delay)
        - max_on_percent clamping
        - safety mode override

        This ensures learning uses the actual applied power, not the requested one.
        """
        if self.state.cycle_active:
            old = self.state.last_power
            self.state.last_power = realized_percent
            if abs(old - realized_percent) > 0.001:
                _LOGGER.debug(
                    "%s - Auto TPI: Realized power updated: %.1f%% -> %.1f%%",
                    self._name, old * 100, realized_percent * 100
                )

    async def on_cycle_started(self, on_time_sec: float, off_time_sec: float, on_percent: float, hvac_mode: str):
        """Called when a TPI cycle starts."""
        # Detect if previous cycle was interrupted
        is_expected_interruption = self._learning_just_completed
        self._learning_just_completed = False  # Reset the flag after check

        if self.state.cycle_active and not is_expected_interruption:
            _LOGGER.info("%s - Auto TPI: Previous cycle was interrupted (not completed). Discarding it.", self._name)
            # You could add specific logic here if needed (stats, etc)

        # Cancel any pending capture timer
        if self._timer_capture_remove_callback:
            self._timer_capture_remove_callback()
            self._timer_capture_remove_callback = None

        self.state.cycle_active = True

        _LOGGER.debug("%s - Auto TPI: Cycle started. On: %.0fs, Off: %.0fs (%.1f%%), Mode: %s", self._name, on_time_sec, off_time_sec, on_percent * 100, hvac_mode)

        now = dt_util.now()

        # Snapshot current state for learning at the end of the cycle
        self.state.last_temp_in = self._current_temp_in
        self.state.last_temp_out = self._current_temp_out
        self.state.last_order = self._current_target_temp
        self.state.last_power = on_percent if on_percent is not None else 0.0
        self.state.last_on_temp_in = 0.0  # Reset

        # Save previous state before updating last_state (for first cycle detection)
        self.state.previous_state = self.state.last_state

        # Map VThermHvacMode/HVACMode to internal state string
        # hvac_mode is expected to be VThermHvacMode or string representation
        mode_str = str(hvac_mode)
        if mode_str == "heat" or mode_str == "heating":
            self.state.last_state = "heat"
        elif mode_str == "cool" or mode_str == "cooling":
            self.state.last_state = "cool"
        else:
            self.state.last_state = "stop"

        self.state.cycle_start_date = now
        self.state.last_update_date = now

        # Store current cycle params so on_cycle_completed() can read them without params
        self.state.current_cycle_params = {
            "on_time_sec": on_time_sec,
            "off_time_sec": off_time_sec,
            "on_percent": on_percent,
            "hvac_mode": str(hvac_mode),
        }

        # Schedule capture of temperature at the end of the ON pulse
        if on_time_sec > 0:
            self._timer_capture_remove_callback = async_call_later(self._hass, on_time_sec, self._capture_end_of_on_temp)

        # Calculate cold factor for this cycle
        self.state.current_cycle_cold_factor = 0.0
        if self._heater_cooling_time > 0 and self.state.last_heater_stop_time:
            # Ensure both datetimes are timezone-aware (handles legacy data)
            last_stop = self.state.last_heater_stop_time
            if last_stop.tzinfo is None:
                last_stop = dt_util.as_local(last_stop)
            elapsed_off = (now - last_stop).total_seconds() / 60.0
            if elapsed_off >= 0:
                self.state.current_cycle_cold_factor = min(1.0, max(0.0, elapsed_off / self._heater_cooling_time))
                _LOGGER.debug(
                    "%s - Auto TPI: Cold factor calc: elapsed_off=%.1f min, cooling_time=%.1f min, factor=%.2f",
                    self._name,
                    elapsed_off,
                    self._heater_cooling_time,
                    self.state.current_cycle_cold_factor,
                )

        await self.async_save_data()

    def _should_learn_continuous_kext(self) -> bool:
        """Check if we should proceed with continuous Kext learning."""
        if not self._continuous_kext:
            return False

        # Must be bootstrapped (at least 1 outdoor sample)
        # We check both modes as we don't know the future mode yet,
        # but technically we should check the count for the CURRENT mode in _learn_kext_continuous.
        # Here we just check if it's generally possible (any learning done).
        # However, to be strict, we can defer the check to _learn_kext_continuous.
        if self.state.coeff_outdoor_autolearn == 0 and self.state.coeff_outdoor_cool_autolearn == 0:
            return False

        # Standard exclusions adapted from _should_learn
        saturation_threshold = self.saturation_threshold
        if not (0 < self.state.last_power < saturation_threshold):
            return False

        if self._current_cycle_interrupted:
            return False

        if self._central_boiler_off:
            return False

        if self._current_is_heating_failure:
            return False

        if self.state.consecutive_failures >= 3:
            return False

        if self.state.previous_state == "stop":
            return False

        if self.state.last_order == 0:
            return False

        # Significant outdoor delta (> 1.0)
        delta_out = self.state.last_order - self._current_temp_out
        if abs(delta_out) < 1.0:
            return False

        return True

    async def _learn_kext_continuous(self, current_temp_in: float, current_temp_out: float):
        """Perform continuous Kext learning."""
        if not self._should_learn_continuous_kext():
            return

        is_heat = self.state.last_state == "heat"
        is_cool = self.state.last_state == "cool"

        if not (is_heat or is_cool):
            return

        # Check bootstrap for specific mode
        count = self.state.coeff_outdoor_autolearn if is_heat else self.state.coeff_outdoor_cool_autolearn
        if count == 0:
            _LOGGER.debug("%s - Continuous Kext: Not bootstrapped for %s mode", self._name, "heat" if is_heat else "cool")
            self.state.last_learning_status = "continuous_kext_not_bootstrapped"
            return

        # Check setpoint change
        if abs(self._current_target_temp - self.state.last_order) > 0.1:
            _LOGGER.debug("%s - Continuous Kext: Setpoint changed", self._name)
            self.state.last_learning_status = "continuous_kext_setpoint_changed"
            return

        target_temp = self.state.last_order
        gap_in = target_temp - current_temp_in
        gap_out = target_temp - current_temp_out

        # Avoid division by zero or small deltas
        if abs(gap_out) < 1.0:
             # Already checked in _should_learn but good to be safe
             return

        current_indoor = self.state.coeff_indoor_heat if is_heat else self.state.coeff_indoor_cool
        current_outdoor = self.state.coeff_outdoor_heat if is_heat else self.state.coeff_outdoor_cool

        # Formula: correction = Kint * (Gap_In / Gap_Out)
        correction = current_indoor * (gap_in / gap_out)
        target_outdoor = current_outdoor + correction

        # Validations
        if not math.isfinite(target_outdoor) or target_outdoor <= 0:
             _LOGGER.warning("%s - Continuous Kext: Invalid target Kext %.4f", self._name, target_outdoor)
             self.state.last_learning_status = "continuous_kext_invalid"
             return

        MAX_KEXT = 1.2
        if target_outdoor > MAX_KEXT:
             target_outdoor = MAX_KEXT

        # EMA
        alpha = self._continuous_kext_alpha
        new_kext = (current_outdoor * (1.0 - alpha)) + (target_outdoor * alpha)

        # Update state in memory
        if is_heat:
             self.state.coeff_outdoor_heat = new_kext
        else:
             self.state.coeff_outdoor_cool = new_kext

        self.state.last_learning_status = f"continuous_kext_learned_{'cool' if is_cool else 'heat'}"
        self._learning_just_completed = True

        _LOGGER.info(
            "%s - Continuous Kext Learning (%s): Old=%.4f, Target=%.4f, New=%.4f (Alpha=%.3f, GapIn=%.2f, GapOut=%.2f)",
            self._name, "heat" if is_heat else "cool", current_outdoor, target_outdoor, new_kext, alpha, gap_in, gap_out
        )

        # Persist immediately to config entry if Kext changed significantly (threshold: 0.001).
        # This avoids having to rely on a startup sync, and keeps the config entry up to date.
        if abs(new_kext - current_outdoor) > 0.001:
            await self.async_update_learning_data(
                coef_ext=new_kext,
                is_heat_mode=is_heat
            )

    async def on_cycle_completed(self, e_eff: float = None, **_kw) -> None:
        """Called when a TPI cycle completes."""
        # Validation logic (moved from old _tick)
        now = dt_util.now()

        prev_params = self.state.current_cycle_params or {}
        if self.state.cycle_start_date is not None and self.state.current_cycle_params is not None:
             # Ensure cycle_start_date is timezone-aware
             cycle_start = self.state.cycle_start_date
             if cycle_start.tzinfo is None:
                 cycle_start = dt_util.as_local(cycle_start)

             elapsed_minutes = (now - cycle_start).total_seconds() / 60
             expected_duration = self._cycle_min
             tolerance = max(expected_duration * 0.1, 1.0)

             duration_diff = elapsed_minutes - expected_duration

             # Case 1: Cycle too short (likely forced restart due to preset/temp change or restart)
             if duration_diff < -tolerance:
                _LOGGER.debug(
                    "%s - Cycle too short: duration=%.1fmin (expected=%.1fmin). Likely forced restart. Skipping learning.",
                    self._name,
                    elapsed_minutes,
                    expected_duration,
                )
                self.state.last_learning_status = "cycle_too_short"
                # We return here because a short cycle shouldn't count towards total_cycles or update stop time
                # (it was interrupted actively)
                self.state.cycle_active = False
                return

             # Case 2: Cycle too long (Gap/Silence detected)
             if duration_diff > tolerance:
                _LOGGER.debug(
                    "%s - Cycle gap detected: duration=%.1fmin (expected=%.1fmin, tolerance=%.1fmin). Resetting cycle but skipping learning.",
                    self._name,
                    elapsed_minutes,
                    expected_duration,
                    tolerance,
                )
                # We do NOT return here. We allow update of total_cycles and last_heater_stop_time
                self.state.last_learning_status = "cycle_gap_detected"
        else:
            # No start date or params, nothing to do
            return

        on_time_sec = prev_params.get("on_time_sec", 0)
        off_time_sec = prev_params.get("off_time_sec", 0)

        if not self.state.cycle_active:
            _LOGGER.debug("%s - Auto TPI: Cycle completed but no cycle active. Ignoring.", self._name)
            return

        self.state.cycle_active = False

        if e_eff is not None:
            self.state.last_power = e_eff

        elapsed_minutes = (on_time_sec + off_time_sec) / 60
        on_time_minutes = on_time_sec / 60.0
        self.state.total_cycles += 1

        # Update last_heater_stop_time if we were heating
        if self.state.last_state == "heat":
            self.state.last_heater_stop_time = dt_util.now()

        # Calculate Power Efficiency based on Heater Warm-up Time and Cold Factor
        # heater_heating_time is the time for the heater to warm up when fully cold.
        # effective_warm_up_time is the actual warm-up time in this cycle, adjusted by the cold_factor.
        # This part of the ON time is considered 'ineffective' for room temperature rise.
        self._last_cycle_power_efficiency = 1.0
        # effective_warm_up_time is the portion of the ON time used to heat up the radiator itself
        effective_warm_up_time = self._heater_heating_time * self.state.current_cycle_cold_factor

        if effective_warm_up_time > 0 and on_time_minutes > 0:
            # effective_time is the time remaining after the radiator is warmed up
            effective_time = max(0.0, on_time_minutes - effective_warm_up_time)
            self._last_cycle_power_efficiency = effective_time / on_time_minutes

            _LOGGER.debug(
                "%s - Auto TPI: Power Efficiency calc: on_time=%.1f min, warm_up_time=%.1f, cold_factor=%.2f, eff_warm_up_time=%.1f, eff=%.2f",
                self._name,
                on_time_minutes,
                self._heater_heating_time,
                self.state.current_cycle_cold_factor,
                effective_warm_up_time,
                self._last_cycle_power_efficiency,
            )

        if self.learning_active:
            _LOGGER.info(
                "%s - Auto TPI: Cycle #%d completed after %.1f minutes (efficiency: %.2f)", self._name, self.state.total_cycles, elapsed_minutes, self._last_cycle_power_efficiency
            )
        else:
            _LOGGER.debug(
                "%s - Auto TPI: Cycle #%d completed after %.1f minutes (efficiency: %.2f)", self._name, self.state.total_cycles, elapsed_minutes, self._last_cycle_power_efficiency
            )

        # Attempt learning
        # Determine if in bootstrap
        in_bootstrap = (
            self._current_hvac_mode == "heat" and
            (self.state.max_capacity_heat == 0 or self.state.capacity_heat_learn_count < 3)
        )
        # Check if cycle is significant enough for learning
        # Significant if we had some effective heating time (efficiency > 0)
        is_significant_cycle = self._last_cycle_power_efficiency > 0.0

        # PHASE 1: Capacity Learning (independent of saturation check)
        # Capacity learning needs high power cycles (>=80%), which may be saturated (100%)
        # This must run independently of _should_learn() which rejects saturated power
        real_rise = self._current_temp_in - self.state.last_temp_in
        efficiency = self._last_cycle_power_efficiency

        # Check if cycle was flagged as invalid (e.g. gap detected)
        if self.state.last_learning_status == "cycle_gap_detected":
            _LOGGER.debug("%s - Auto TPI: Skipping capacity learning due to invalid cycle (gap detected)", self._name)
        elif self._should_learn_capacity():
            await self._learn_capacity(
                power=self.state.last_power,
                delta_t=self._current_temp_in - self._current_temp_out,
                rise=real_rise,
                efficiency=efficiency,
                k_ext=self.state.coeff_outdoor_heat
            )
            if in_bootstrap:
                _LOGGER.info(
                    "%s - Bootstrap cycle %d/%d completed, capacity: %.2f°C/h",
                    self._name,
                    self.state.capacity_heat_learn_count,
                    3,  # Total bootstrap cycles
                    self.state.max_capacity_heat
                )

        # PHASE 2: Kint/Kext Learning (requires non-saturated power)
        # Skip during bootstrap (learn only capacity first)
        if in_bootstrap:
            _LOGGER.debug("%s - Auto TPI: In bootstrap mode, skipping Kint/Kext learning", self._name)
        elif self._should_learn() and is_significant_cycle:
            _LOGGER.info("%s - Auto TPI: Attempting to learn Kint/Kext from cycle data", self._name)
            await self._perform_learning(self._current_temp_in, self._current_temp_out)
        elif self._continuous_kext and is_significant_cycle and self._should_learn_continuous_kext():
            _LOGGER.info("%s - Continuous Kext: Learning active...", self._name)
            await self._learn_kext_continuous(self._current_temp_in, self._current_temp_out)
        else:
            reason = self._get_no_learn_reason()
            if not is_significant_cycle and reason == "unknown":
                reason = "on_time_too_short_vs_heating_time"

            _LOGGER.debug("%s - Auto TPI: Not learning Kint/Kext this cycle: %s", self._name, reason)
            # Only update status if it wasn't already set to "cycle_gap_detected" or other critical error
            if self.state.last_learning_status != "cycle_gap_detected":
                self.state.last_learning_status = reason

        # Check for failures
        await self._detect_failures(self._current_temp_in)

        # Centralized persistence: Check if learning is finished and persist if needed
        if self.learning_active:
            await self.process_learning_completion()

        await self.async_save_data()



    def get_calculated_params(self) -> dict:
        return self._calculated_params

    @property
    def is_in_bootstrap(self) -> bool:
        """Return True if the algorithm is in bootstrap mode (learning capacity)."""
        return (
            self.state.max_capacity_heat == 0 or
            self.state.capacity_heat_learn_count < 3
        )

    @property
    def learning_active(self) -> bool:
        return self.state.autolearn_enabled

    @property
    def int_cycles(self) -> int:
        """Number of ACTUAL learning cycles completed for internal coefficient"""
        is_cool_mode = self._current_hvac_mode == "cool"
        if is_cool_mode:
            return max(0, self.state.coeff_indoor_cool_autolearn - self._avg_initial_weight)
        return max(0, self.state.coeff_indoor_autolearn - self._avg_initial_weight)

    @property
    def ext_cycles(self) -> int:
        """Number of learning cycles completed for external coefficient"""
        is_cool_mode = self._current_hvac_mode == "cool"
        if is_cool_mode:
            return self.state.coeff_outdoor_cool_autolearn
        return self.state.coeff_outdoor_autolearn

    @property
    def heating_cycles_count(self) -> int:
        """Number of total TPI cycles"""
        return self.state.total_cycles

    @property
    def time_constant(self) -> float:
        """Thermal time constant in hours"""
        if self.state.coeff_indoor_heat > 0:
            return round(1.0 / self.state.coeff_indoor_heat, 2)
        return 0.0

    @property
    def confidence(self) -> float:
        """Confidence level in the learned model (0.0 to 1.0)"""
        # We consider stability reached when both coefficients have 50 cycles
        int_cycles = self.int_cycles
        ext_cycles = self.ext_cycles

        if int_cycles == 0 and ext_cycles == 0:
            return 0.0

        # Average of progress for both
        confidence_int = min(int_cycles / 50.0, 1.0)
        confidence_ext = min(ext_cycles, 50) / 50.0

        cycle_confidence = (confidence_int + confidence_ext) / 2.0

        if self.state.consecutive_failures > 0:
            failure_penalty = min(self.state.consecutive_failures * 0.15, 0.6)
            cycle_confidence = max(0.2, cycle_confidence - failure_penalty)

        return round(cycle_confidence, 2)

    async def start_learning(
        self,
        coef_int: float = None,
        coef_ext: float = None,
        reset_data: bool = True,
        allow_kint_boost: bool = True,
        allow_kext_overshoot: bool = False,
    ):
        """Start learning, optionally resetting coefficients and learning data.

        Args:
            coef_int: Target internal coefficient (defaults to configured value)
            coef_ext: Target external coefficient (defaults to configured value)
            reset_data: If True, reset all learning data; if False, resume with existing data
            allow_kint_boost: Enable Kint boost on stagnation
            allow_kext_overshoot: Enable Kext compensation on overshoot
        """
        # Update optional flags immediately (even if not resetting data)
        self.state.allow_kint_boost = allow_kint_boost
        self.state.allow_kext_overshoot = allow_kext_overshoot
        _LOGGER.info(
            "%s - Auto TPI: Optional parameters set: allow_kint_boost=%s, allow_kext_overshoot=%s",
            self._name, allow_kint_boost, allow_kext_overshoot
        )

        # Use provided values, or fallback to default (configured) values
        target_int = coef_int if coef_int is not None else self._default_coef_int
        target_ext = coef_ext if coef_ext is not None else self._default_coef_ext

        if reset_data:
            _LOGGER.info("%s - Auto TPI: Starting learning with coef_int=%.3f, coef_ext=%.3f (resetting all data)", self._name, target_int, target_ext)

            # Reset coefficients to target values
            self.state.coeff_indoor_heat = target_int
            self.state.coeff_indoor_cool = target_int
            self.state.coeff_outdoor_heat = target_ext
            self.state.coeff_outdoor_cool = target_ext

            # Reset all counters
            self.state.coeff_indoor_autolearn = self._avg_initial_weight
            self.state.coeff_outdoor_autolearn = 0
            self.state.coeff_indoor_cool_autolearn = self._avg_initial_weight
            self.state.coeff_outdoor_cool_autolearn = 0

            # Reset all learning data for fresh start
            self.state.last_power = 0.0
            self.state.last_order = 0.0
            self.state.last_temp_in = 0.0
            self.state.last_temp_out = 0.0
            self.state.last_state = "stop"
            self.state.last_update_date = None
            self.state.last_heater_stop_time = None
            self.state.total_cycles = 0
            self.state.consecutive_failures = 0
            self.state.last_learning_status = "learning_started"
            self.state.cycle_start_date = dt_util.now()
            self.state.cycle_active = False
            self.state.current_cycle_params = None  # Ensure first tick starts fresh

            # Reset capacity if configured heat_rate is 0 (user wants to re-learn capacity)
            if self._heating_rate == 0.0:
                _LOGGER.info(
                    "%s - Auto TPI: Configured heat_rate is 0, resetting capacity for bootstrap",
                    self._name
                )
                self.state.max_capacity_heat = 0.0
                self.state.capacity_heat_learn_count = 0
                self.state.bootstrap_failure_count = 0
        else:
            # If start_learning is called with explicit target values that differ from defaults,
            # apply them as an update to the current state, even without a full reset.
            if coef_int is not None and abs(coef_int - self._default_coef_int) > 0.001:
                _LOGGER.info("%s - Auto TPI: Updating Kint to %.3f (Manual override in resume)", self._name, target_int)
                self.state.coeff_indoor_heat = target_int
                self.state.coeff_indoor_cool = target_int

            if coef_ext is not None and abs(coef_ext - self._default_coef_ext) > 0.001:
                _LOGGER.info("%s - Auto TPI: Updating Kext to %.3f (Manual override in resume)", self._name, target_ext)
                self.state.coeff_outdoor_heat = target_ext
                self.state.coeff_outdoor_cool = target_ext

            _LOGGER.info(
                "%s - Auto TPI: Resuming learning with existing data (coef_int=%.3f, coef_ext=%.3f, cycles=%d)",
                self._name,
                self.state.coeff_indoor_heat,
                self.state.coeff_outdoor_heat,
                self.state.total_cycles,
            )
            # Update status to indicate learning has resumed
            self.state.last_learning_status = "learning_resumed"

        # Always enable learning when activating
        self.state.autolearn_enabled = True

        # Set start date only if it's a new session (reset) or if it wasn't set (first start)
        if reset_data or self.state.learning_start_date is None:
            self.state.learning_start_date = dt_util.now()

        # ===== BOOTSTRAP PHASE LOGIC =====
        # Determine bootstrap strategy (3 modes)
        manual_capacity = self._heating_rate  # From config (CONF_AUTO_TPI_HEATING_POWER)

        if manual_capacity > 0:
            # Mode 1: Manual capacity provided - skip bootstrap
            self.state.max_capacity_heat = manual_capacity
            self.state.capacity_heat_learn_count = 3  # Mark as learned

            _LOGGER.info(
                "%s - Auto TPI: Using manual capacity %.2f °C/h, skipping bootstrap",
                self._name, manual_capacity
            )

        elif self.state.max_capacity_heat > 0 and not reset_data:
            # Capacity already learned from previous session - skip bootstrap

            _LOGGER.info(
                "%s - Auto TPI: Capacity already known (%.2f °C/h), resuming in TPI mode",
                self._name, self.state.max_capacity_heat
            )

        else:
            # Mode 2: No manual capacity - Try pre-bootstrap calibration first
            calibration_result = await self._try_pre_bootstrap_calibration()

            if calibration_result:
                # Pre-calibration succeeded, skip bootstrap
                self.state.max_capacity_heat = calibration_result
                self.state.capacity_heat_learn_count = 3  # Mark as learned
                _LOGGER.info(
                    "%s - Auto TPI: Pre-bootstrap calibration succeeded (capacity=%.2f °C/h), skipping bootstrap",
                    self._name, calibration_result
                )
            else:
                # Pre-calibration failed or insufficient reliability, proceed with bootstrap
                # Bootstrap will automatically activate (capacity_heat_learn_count < 3)
                # High coefficients will be used during first 3 cycles
                _LOGGER.info(
                    "%s - Auto TPI: Pre-bootstrap calibration failed or insufficient reliability, starting capacity bootstrap (TPI aggressive mode)",
                    self._name
                )

        # Ensure max_capacity fallback for TPI mode (unchanged)
        if self.state.max_capacity_heat == 0.0:
            self.state.max_capacity_heat = 1.0
        if self.state.max_capacity_cool == 0.0:
            self.state.max_capacity_cool = 1.0

        await self.async_save_data()

    async def stop_learning(self, save_capacity: bool = True):
        _LOGGER.info("%s - Auto TPI: Stopping learning", self._name)
        self.state.autolearn_enabled = False
        # Do not clear learning_start_date to allow resuming or display in history
        # self.state.learning_start_date = None
        self.state.last_learning_status = "learning_stopped"
        await self.async_save_data()

        # If we have learned enough, save capacity to config
        # ONLY if save_capacity is True (avoid double reload if caller handles it)
        if save_capacity and self.state.capacity_heat_learn_count >= 3:
             # Check if value has changed before saving to avoid useless reload
             current_capacity = self._config_entry.data.get(CONF_AUTO_TPI_HEATING_POWER)
             if current_capacity is None or abs(current_capacity - self.state.max_capacity_heat) > 0.01:
                 if self._hass and self._hass.loop and not self._hass.loop.is_closed():
                    await self.async_update_learning_data(capacity=self.state.max_capacity_heat, is_heat_mode=True)


    async def reset_learning_data(self):
        _LOGGER.info("%s - Auto TPI: Resetting all learning data", self._name)
        self.state = AutoTpiState()
        self.state.cycle_active = False
        await self.async_save_data()

    async def reset_capacities(self):
        """Reset max heat/cool capacities to default (1.0)."""
        _LOGGER.info("%s - Auto TPI: Resetting max heat/cool capacities to default (1.0)", self._name)
        self.state.max_capacity_heat = 1.0
        self.state.max_capacity_cool = 1.0
        await self.async_save_data()
