from collections import defaultdict
from contextlib import contextmanager
import logging
import math
import os
from pathlib import Path
import re
from typing import TYPE_CHECKING

from esphome.const import (
    CONF_COMMENT,
    CONF_ESPHOME,
    CONF_ETHERNET,
    CONF_OPENTHREAD,
    CONF_PORT,
    CONF_USE_ADDRESS,
    CONF_WEB_SERVER,
    CONF_WIFI,
    KEY_CORE,
    KEY_NATIVE_IDF,
    KEY_TARGET_FRAMEWORK,
    KEY_TARGET_PLATFORM,
    PLATFORM_BK72XX,
    PLATFORM_ESP32,
    PLATFORM_ESP8266,
    PLATFORM_HOST,
    PLATFORM_LN882X,
    PLATFORM_NRF52,
    PLATFORM_RP2040,
    PLATFORM_RTL87XX,
)

# pylint: disable=unused-import
from esphome.coroutine import (  # noqa: F401
    CoroPriority,
    FakeAwaitable as _FakeAwaitable,
    FakeEventLoop as _FakeEventLoop,
    coroutine,
    coroutine_with_priority,
)
from esphome.helpers import ensure_unique_string, get_str_env, is_ha_addon
from esphome.util import OrderedDict

if TYPE_CHECKING:
    from esphome.address_cache import AddressCache

    from ..cpp_generator import MockObj, MockObjClass, Statement
    from ..types import ConfigType, EntityMetadata

_LOGGER = logging.getLogger(__name__)

# Key for tracking controller count in CORE.data for ControllerRegistry StaticVector sizing
KEY_CONTROLLER_REGISTRY_COUNT = "controller_registry_count"


class EsphomeError(Exception):
    """General ESPHome exception occurred."""


class HexInt(int):
    def __str__(self):
        value = self
        sign = "-" if value < 0 else ""
        value = abs(value)
        if 0 <= value <= 255:
            return f"{sign}0x{value:02X}"
        return f"{sign}0x{value:X}"


class MACAddress:
    def __init__(self, *parts):
        if len(parts) != 6:
            raise ValueError("MAC Address must consist of 6 items")
        self.parts = parts

    def __str__(self):
        return ":".join(f"{part:02X}" for part in self.parts)

    @property
    def as_hex(self):
        from esphome.cpp_generator import RawExpression

        num = "".join(f"{part:02X}" for part in self.parts)
        return RawExpression(f"0x{num}ULL")


def is_approximately_integer(value):
    if isinstance(value, int):
        return True
    return abs(value - round(value)) < 0.001


class TimePeriod:
    def __init__(
        self,
        nanoseconds=None,
        microseconds=None,
        milliseconds=None,
        seconds=None,
        minutes=None,
        hours=None,
        days=None,
    ):
        if days is not None:
            if not is_approximately_integer(days):
                frac_days, days = math.modf(days)
                hours = (hours or 0) + frac_days * 24
            self.days = int(round(days))
        else:
            self.days = None

        if hours is not None:
            if not is_approximately_integer(hours):
                frac_hours, hours = math.modf(hours)
                minutes = (minutes or 0) + frac_hours * 60
            self.hours = int(round(hours))
        else:
            self.hours = None

        if minutes is not None:
            if not is_approximately_integer(minutes):
                frac_minutes, minutes = math.modf(minutes)
                seconds = (seconds or 0) + frac_minutes * 60
            self.minutes = int(round(minutes))
        else:
            self.minutes = None

        if seconds is not None:
            if not is_approximately_integer(seconds):
                frac_seconds, seconds = math.modf(seconds)
                milliseconds = (milliseconds or 0) + frac_seconds * 1000
            self.seconds = int(round(seconds))
        else:
            self.seconds = None

        if milliseconds is not None:
            if not is_approximately_integer(milliseconds):
                frac_milliseconds, milliseconds = math.modf(milliseconds)
                microseconds = (microseconds or 0) + frac_milliseconds * 1000
            self.milliseconds = int(round(milliseconds))
        else:
            self.milliseconds = None

        if microseconds is not None:
            if not is_approximately_integer(microseconds):
                frac_microseconds, microseconds = math.modf(microseconds)
                nanoseconds = (nanoseconds or 0) + frac_microseconds * 1000
            self.microseconds = int(round(microseconds))
        else:
            self.microseconds = None

        if nanoseconds is not None:
            if not is_approximately_integer(nanoseconds):
                raise ValueError("Maximum precision is nanoseconds")
            self.nanoseconds = int(round(nanoseconds))
        else:
            self.nanoseconds = None

    def as_dict(self):
        out = OrderedDict()
        if self.nanoseconds is not None:
            out["nanoseconds"] = self.nanoseconds
        if self.microseconds is not None:
            out["microseconds"] = self.microseconds
        if self.milliseconds is not None:
            out["milliseconds"] = self.milliseconds
        if self.seconds is not None:
            out["seconds"] = self.seconds
        if self.minutes is not None:
            out["minutes"] = self.minutes
        if self.hours is not None:
            out["hours"] = self.hours
        if self.days is not None:
            out["days"] = self.days
        return out

    def __str__(self):
        if self.nanoseconds is not None:
            return f"{self.total_nanoseconds}ns"
        if self.microseconds is not None:
            return f"{self.total_microseconds}us"
        if self.milliseconds is not None:
            return f"{self.total_milliseconds}ms"
        if self.seconds is not None:
            return f"{self.total_seconds}s"
        if self.minutes is not None:
            return f"{self.total_minutes}min"
        if self.hours is not None:
            return f"{self.total_hours}h"
        if self.days is not None:
            return f"{self.total_days}d"
        return "0s"

    def __repr__(self):
        return f"TimePeriod<{self.total_nanoseconds}ns>"

    @property
    def total_nanoseconds(self):
        return self.total_microseconds * 1000 + (self.nanoseconds or 0)

    @property
    def total_microseconds(self):
        return self.total_milliseconds * 1000 + (self.microseconds or 0)

    @property
    def total_milliseconds(self):
        return self.total_seconds * 1000 + (self.milliseconds or 0)

    @property
    def total_seconds(self):
        return self.total_minutes * 60 + (self.seconds or 0)

    @property
    def total_minutes(self):
        return self.total_hours * 60 + (self.minutes or 0)

    @property
    def total_hours(self):
        return self.total_days * 24 + (self.hours or 0)

    @property
    def total_days(self):
        return self.days or 0

    def __eq__(self, other):
        if isinstance(other, TimePeriod):
            return self.total_nanoseconds == other.total_nanoseconds
        return NotImplemented

    def __ne__(self, other):
        if isinstance(other, TimePeriod):
            return self.total_nanoseconds != other.total_nanoseconds
        return NotImplemented

    def __lt__(self, other):
        if isinstance(other, TimePeriod):
            return self.total_nanoseconds < other.total_nanoseconds
        return NotImplemented

    def __gt__(self, other):
        if isinstance(other, TimePeriod):
            return self.total_nanoseconds > other.total_nanoseconds
        return NotImplemented

    def __le__(self, other):
        if isinstance(other, TimePeriod):
            return self.total_nanoseconds <= other.total_nanoseconds
        return NotImplemented

    def __ge__(self, other):
        if isinstance(other, TimePeriod):
            return self.total_nanoseconds >= other.total_nanoseconds
        return NotImplemented


class TimePeriodNanoseconds(TimePeriod):
    pass


class TimePeriodMicroseconds(TimePeriod):
    pass


class TimePeriodMilliseconds(TimePeriod):
    pass


class TimePeriodSeconds(TimePeriod):
    pass


class TimePeriodMinutes(TimePeriod):
    pass


LAMBDA_PROG = re.compile(r"\bid\(\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\)(\.?)")


class Lambda:
    def __init__(self, value):
        from esphome.cpp_generator import Expression, statement

        # pylint: disable=protected-access
        if isinstance(value, Lambda):
            self._value = value._value
        elif isinstance(value, Expression):
            self._value = str(statement(value))
        else:
            self._value = value
        self._parts = None
        self._requires_ids = None

    # https://stackoverflow.com/a/241506/229052
    def comment_remover(self, text):
        def replacer(match):
            s = match.group(0)
            if s.startswith("/"):
                return " "  # note: a space and not an empty string
            return s

        pattern = re.compile(
            r'//.*?$|/\*.*?\*/|\'(?:\\.|[^\\\'])*\'|"(?:\\.|[^\\"])*"',
            re.DOTALL | re.MULTILINE,
        )
        return re.sub(pattern, replacer, text)

    @property
    def parts(self):
        if self._parts is None:
            self._parts = re.split(LAMBDA_PROG, self.comment_remover(self._value))
        return self._parts

    @property
    def requires_ids(self):
        if self._requires_ids is None:
            self._requires_ids = [
                ID(self.parts[i]) for i in range(1, len(self.parts), 3)
            ]
        return self._requires_ids

    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, value):
        self._value = value
        self._parts = None
        self._requires_ids = None

    def __str__(self):
        return self.value

    def __repr__(self):
        return f"Lambda<{self.value}>"


class ID:
    def __init__(self, id, is_declaration=False, type=None, is_manual=None):
        self.id = id
        if is_manual is None:
            self.is_manual = id is not None
        else:
            self.is_manual = is_manual
        self.is_declaration = is_declaration
        self.type: MockObjClass | None = type

    def resolve(self, registered_ids):
        from esphome.config_validation import RESERVED_IDS

        if self.id is None:
            base = str(self.type).replace("::", "_").lower()
            if base == self.type:
                base = base + "_id"
            name = "".join(c for c in base if c.isalnum() or c == "_")
            used = set(registered_ids) | set(RESERVED_IDS) | CORE.loaded_integrations
            self.id = ensure_unique_string(name, used)
        return self.id

    def __str__(self):
        if self.id is None:
            return ""
        return self.id

    def __repr__(self):
        return (
            f"ID<{self.id} declaration={self.is_declaration}, "
            f"type={self.type}, manual={self.is_manual}>"
        )

    def __eq__(self, other):
        if isinstance(other, ID):
            return self.id == other.id
        return NotImplemented

    def __hash__(self):
        return hash(self.id)

    def copy(self):
        return ID(
            self.id,
            is_declaration=self.is_declaration,
            type=self.type,
            is_manual=self.is_manual,
        )


class DocumentLocation:
    def __init__(self, document: str, line: int, column: int):
        self.document: str = document
        self.line: int = line
        self.column: int = column

    @classmethod
    def from_mark(cls, mark):
        return cls(str(mark.name), mark.line, mark.column)

    def __str__(self):
        return f"{self.document} {self.line}:{self.column}"

    @property
    def as_line_directive(self):
        document_path = str(self.document).replace("\\", "\\\\")
        return f'#line {self.line + 1} "{document_path}"'


class DocumentRange:
    def __init__(self, start_mark: DocumentLocation, end_mark: DocumentLocation):
        self.start_mark: DocumentLocation = start_mark
        self.end_mark: DocumentLocation = end_mark

    @classmethod
    def from_marks(cls, start_mark, end_mark):
        return cls(
            DocumentLocation.from_mark(start_mark), DocumentLocation.from_mark(end_mark)
        )

    def __str__(self):
        return f"[{self.start_mark} - {self.end_mark}]"


class Define:
    def __init__(self, name, value=None):
        self.name = name
        self.value = value

    @property
    def as_build_flag(self):
        if self.value is None:
            return f"-D{self.name}"
        return f"-D{self.name}={self.value}"

    @property
    def as_macro(self):
        if self.value is None:
            return f"#define {self.name}"
        return f"#define {self.name} {self.value}"

    @property
    def as_tuple(self):
        return self.name, self.value

    def __hash__(self):
        return hash(self.as_tuple)

    def __eq__(self, other):
        if isinstance(other, Define):
            return self.as_tuple == other.as_tuple
        return NotImplemented

    def __str__(self):
        return f"{self.name}={self.value}"


class Library:
    def __init__(self, name, version, repository=None):
        self.name = name
        self.version = version
        self.repository = repository

    def __str__(self):
        return self.as_lib_dep

    @property
    def as_lib_dep(self):
        if self.repository is not None:
            if self.name is not None:
                return f"{self.name}={self.repository}"
            return self.repository

        if self.version is None:
            return self.name
        return f"{self.name}@{self.version}"

    @property
    def as_tuple(self):
        return self.name, self.version, self.repository

    def __hash__(self):
        return hash(self.as_tuple)

    def __eq__(self, other):
        if isinstance(other, Library):
            return self.as_tuple == other.as_tuple
        return NotImplemented

    def reconcile_with(self, other):
        """Merge two libraries, reconciling any conflicts."""

        if self.name != other.name:
            # Different libraries, no reconciliation possible
            raise ValueError(
                f"Cannot reconcile libraries with different names: {self.name} and {other.name}"
            )

        # repository specificity takes precedence over version specificity
        if self.repository is None and other.repository is None:
            pass  # No repositories, no conflict, continue on

        elif self.repository is None:
            # incoming library has a repository, use it
            self.repository = other.repository
            self.version = other.version
            return self

        elif other.repository is None:
            return self  # use the repository/version already present

        elif self.repository != other.repository:
            raise ValueError(
                f"Reconciliation failed! Libraries {self} and {other} requested with conflicting repositories!"
            )

        if self.version is None and other.version is None:
            return self  # Arduino library reconciled against another Arduino library, current is acceptable

        if self.version is None:
            # incoming library has a version, use it
            self.version = other.version
            return self

        if other.version is None:
            return self  # incoming library has no version, current is acceptable

            # Same versions, current library is acceptable
        if self.version != other.version:
            raise ValueError(
                f"Version pinning failed! Libraries {other} and {self} "
                "requested with conflicting versions!"
            )
        return self


# pylint: disable=too-many-public-methods
class EsphomeCore:
    def __init__(self):
        # True if command is run from dashboard
        self.dashboard = False
        # True if command is run from vscode api
        self.vscode = False
        # True if running in testing mode (disables validation checks for grouped testing)
        self.testing_mode = False
        # The name of the node
        self.name: str | None = None
        # The friendly name of the node
        self.friendly_name: str | None = None
        # The area / zone of the node
        self.area: str | None = None
        # Additional data components can store temporary data in.
        # This dict is cleared between compilation runs.
        #
        # Usage pattern (use @dataclass for type safety):
        #   DOMAIN = "my_component"
        #
        #   @dataclass
        #   class MyComponentData:
        #       feature_enabled: bool = False
        #
        #   def _get_data() -> MyComponentData:
        #       if DOMAIN not in CORE.data:
        #           CORE.data[DOMAIN] = MyComponentData()
        #       return CORE.data[DOMAIN]
        #
        # The first key should always be the component domain name (DOMAIN constant).
        self.data = {}
        # The relative path to the configuration YAML
        self.config_path: Path | None = None
        # The relative path to where all build files are stored
        self.build_path: Path | None = None
        # The validated configuration, this is None until the config has been validated
        self.config: ConfigType | None = None
        # The pending tasks in the task queue (mostly for C++ generation)
        # This is a priority queue (with heapq)
        # Each item is a tuple of form: (-priority, unique number, task)
        self.event_loop = _FakeEventLoop()
        # Task counter for pending tasks
        self.task_counter = 0
        # The variable cache, for each ID this holds a MockObj of the variable obj
        self.variables: dict[str, MockObj] = {}
        # A list of statements that go in the main setup() block
        self.main_statements: list[Statement] = []
        # A list of statements to insert in the global block (includes and global variables)
        self.global_statements: list[Statement] = []
        # A map of platformio libraries to add to the project (shortname: (name, version, repository))
        self.platformio_libraries: dict[str, Library] = {}
        # A set of build flags to set in the platformio project
        self.build_flags: set[str] = set()
        # A set of build unflags to set in the platformio project
        self.build_unflags: set[str] = set()
        # A set of defines to set for the compile process in esphome/core/defines.h
        self.defines: set[Define] = set()
        # A map of all platformio options to apply
        self.platformio_options: dict[str, str | list[str]] = {}
        # A set of strings of names of loaded integrations, used to find namespace ID conflicts
        self.loaded_integrations = set()
        # A set of strings for platform/integration combos
        self.loaded_platforms: set[str] = set()
        # A set of component IDs to track what Component subclasses are declared
        self.component_ids = set()
        # Dict to track platform entity counts for pre-allocation
        # Key: platform name (e.g. "sensor", "binary_sensor"), Value: count
        self.platform_counts: defaultdict[str, int] = defaultdict(int)
        # Track entity unique IDs to handle duplicates
        # Dict mapping (device_id, platform, sanitized_name) -> entity metadata
        self.unique_ids: dict[tuple[str, str, str], EntityMetadata] = {}
        # Whether ESPHome was started in verbose mode
        self.verbose = False
        # Whether ESPHome was started in quiet mode
        self.quiet = False
        # A list of all known ID classes
        self.id_classes = {}
        # The current component being processed during validation
        self.current_component: str | None = None
        # Address cache for DNS and mDNS lookups from command line arguments
        self.address_cache: AddressCache | None = None
        # Cached config hash (computed lazily)
        self._config_hash: int | None = None
        # When True, skip network freshness checks for cached external files
        # (e.g. for `esphome logs`, where remote downloads aren't needed)
        self.skip_external_update: bool = False

    def reset(self):
        from esphome.pins import PIN_SCHEMA_REGISTRY

        self.dashboard = False
        self.name = None
        self.friendly_name = None
        self.area = None
        self.data = {}
        self.config_path = None
        self.build_path = None
        self.config = None
        self.event_loop = _FakeEventLoop()
        self.task_counter = 0
        self.variables = {}
        self.main_statements = []
        self.global_statements = []
        self.platformio_libraries = {}
        self.build_flags = set()
        self.build_unflags = set()
        self.defines = set()
        self.platformio_options = {}
        self.loaded_integrations = set()
        self.component_ids = set()
        self.platform_counts = defaultdict(int)
        self.unique_ids = {}
        self.current_component = None
        self.address_cache = None
        self._config_hash = None
        self.skip_external_update = False
        PIN_SCHEMA_REGISTRY.reset()

    @contextmanager
    def component_context(self, component: str):
        """Context manager to set the current component being processed."""
        old_component = self.current_component
        self.current_component = component
        try:
            yield
        finally:
            self.current_component = old_component

    @property
    def address(self) -> str | None:
        if self.config is None:
            raise ValueError("Config has not been loaded yet")

        for network_type in (CONF_WIFI, CONF_ETHERNET, CONF_OPENTHREAD):
            if network_type in self.config:
                return self.config[network_type][CONF_USE_ADDRESS]

        if CONF_OPENTHREAD in self.config:
            return f"{self.name}.local"

        return None

    @property
    def web_port(self) -> int | None:
        if self.config is None:
            raise ValueError("Config has not been loaded yet")

        if CONF_WEB_SERVER in self.config:
            try:
                return self.config[CONF_WEB_SERVER][CONF_PORT]
            except KeyError:
                return 80

        return None

    @property
    def comment(self) -> str | None:
        if self.config is None:
            raise ValueError("Config has not been loaded yet")

        if CONF_COMMENT in self.config[CONF_ESPHOME]:
            return self.config[CONF_ESPHOME][CONF_COMMENT]

        return None

    @property
    def config_hash(self) -> int:
        """Get the FNV-1a 32-bit hash of the config.

        The hash is computed lazily and cached for performance.
        Uses sort_keys=True to ensure deterministic ordering.
        """
        if self._config_hash is None:
            from esphome import yaml_util
            from esphome.helpers import fnv1a_32bit_hash

            config_str = yaml_util.dump(self.config, show_secrets=True, sort_keys=True)
            self._config_hash = fnv1a_32bit_hash(config_str)
        return self._config_hash

    @property
    def config_dir(self) -> Path:
        if self.config_path.is_dir():
            return self.config_path.absolute()
        return self.config_path.absolute().parent

    @property
    def data_dir(self) -> Path:
        if is_ha_addon():
            return Path("/data")
        if "ESPHOME_DATA_DIR" in os.environ:
            return Path(get_str_env("ESPHOME_DATA_DIR", None))
        return self.relative_config_path(".esphome")

    @property
    def config_filename(self) -> str:
        return self.config_path.name

    def has_at_least_one_component(self, *components: str) -> bool:
        """
        Are any of the given components configured?
        :param components: component names
        :return: true if so
        """
        if self.config is None:
            raise ValueError("Config has not been loaded yet")

        return any(component in self.config for component in components)

    @property
    def has_networking(self) -> bool:
        """
        Is a network component configured?
        :return: true if so
        """
        return self.has_at_least_one_component("wifi", "ethernet", "openthread")

    def relative_config_path(self, *path: str | Path) -> Path:
        path_ = Path(*path).expanduser()
        return self.config_dir / path_

    def relative_internal_path(self, *path: str | Path) -> Path:
        path_ = Path(*path).expanduser()
        return self.data_dir / path_

    def relative_build_path(self, *path: str | Path) -> Path:
        path_ = Path(*path).expanduser()
        return self.build_path / path_

    def relative_src_path(self, *path: str | Path) -> Path:
        return self.relative_build_path("src", *path)

    def relative_pioenvs_path(self, *path: str | Path) -> Path:
        return self.relative_build_path(".pioenvs", *path)

    def relative_piolibdeps_path(self, *path: str | Path) -> Path:
        return self.relative_build_path(".piolibdeps", *path)

    @property
    def firmware_bin(self) -> Path:
        # Check if using native ESP-IDF build (--native-idf)
        if self.data.get(KEY_NATIVE_IDF, False):
            return self.relative_build_path("build", f"{self.name}.bin")
        if self.is_libretiny:
            return self.relative_pioenvs_path(self.name, "firmware.uf2")
        return self.relative_pioenvs_path(self.name, "firmware.bin")

    @property
    def partition_table_bin(self) -> Path:
        # Native ESP-IDF (--native-idf): the partition table image is emitted under
        # build/partition_table/partition-table.bin alongside firmware.bin. PlatformIO writes the
        # equivalent file as partitions.bin in the env-specific .pioenvs directory.
        if self.data.get(KEY_NATIVE_IDF):
            return self.relative_build_path(
                "build", "partition_table", "partition-table.bin"
            )
        return self.relative_pioenvs_path(self.name, "partitions.bin")

    @property
    def target_platform(self):
        return self.data[KEY_CORE][KEY_TARGET_PLATFORM]

    @property
    def is_esp8266(self):
        return self.target_platform == PLATFORM_ESP8266

    @property
    def is_esp32(self):
        return self.target_platform == PLATFORM_ESP32

    @property
    def is_rp2040(self):
        return self.target_platform == PLATFORM_RP2040

    @property
    def is_bk72xx(self):
        return self.target_platform == PLATFORM_BK72XX

    @property
    def is_rtl87xx(self):
        return self.target_platform == PLATFORM_RTL87XX

    @property
    def is_ln882x(self):
        return self.target_platform == PLATFORM_LN882X

    @property
    def is_libretiny(self):
        return self.is_bk72xx or self.is_rtl87xx or self.is_ln882x

    @property
    def is_nrf52(self):
        return self.target_platform == PLATFORM_NRF52

    @property
    def is_host(self):
        return self.target_platform == PLATFORM_HOST

    @property
    def target_framework(self):
        return self.data[KEY_CORE][KEY_TARGET_FRAMEWORK]

    @property
    def using_arduino(self):
        return self.target_framework == "arduino"

    @property
    def using_esp_idf(self):
        _LOGGER.warning(
            "CORE.using_esp_idf was deprecated in 2026.1, will change behavior in 2026.6. "
            "ESP32 Arduino builds on top of ESP-IDF, so ESP-IDF features are available in both frameworks. "
            "Use CORE.is_esp32 and/or CORE.using_arduino instead."
        )
        return self.target_framework == "esp-idf"

    @property
    def using_zephyr(self):
        return self.target_framework == "zephyr"

    def add_job(self, func, *args, **kwargs) -> None:
        self.event_loop.add_job(func, *args, **kwargs)

    def flush_tasks(self) -> None:
        try:
            self.event_loop.flush_tasks()
        except RuntimeError as e:
            raise EsphomeError(str(e)) from e

    def add(self, expression, prepend=False) -> "Statement":
        """Add an expression or statement to the main setup() block."""
        from esphome.cpp_generator import Expression, Statement, statement

        if isinstance(expression, Expression):
            expression = statement(expression)
        if not isinstance(expression, Statement):
            raise ValueError(
                f"Add '{expression}' must be expression or statement, not {type(expression)}"
            )

        if prepend:
            self.main_statements.insert(0, expression)
        else:
            self.main_statements.append(expression)
        _LOGGER.debug("Adding: %s", expression)
        return expression

    def add_global(self, expression, prepend=False) -> "Statement":
        from esphome.cpp_generator import Expression, Statement, statement

        if isinstance(expression, Expression):
            expression = statement(expression)
        if not isinstance(expression, Statement):
            raise ValueError(
                f"Add '{expression}' must be expression or statement, not {type(expression)}"
            )
        if prepend:
            self.global_statements.insert(0, expression)
        else:
            self.global_statements.append(expression)
        _LOGGER.debug("Adding global: %s", expression)
        return expression

    def add_library(self, library: Library):
        if not isinstance(library, Library):
            raise TypeError(
                f"Library {library} must be instance of Library, not {type(library)}"
            )

        if not library.name:
            raise ValueError(f"The library for {library.repository} must have a name")

        short_name = (
            library.name if "/" not in library.name else library.name.split("/")[-1]
        )

        # Auto-enable Arduino libraries on ESP32 Arduino builds
        if self.is_esp32 and self.using_arduino:
            from esphome.components.esp32 import (
                ARDUINO_DISABLED_LIBRARIES,
                _enable_arduino_library,
            )

            if short_name in ARDUINO_DISABLED_LIBRARIES:
                _enable_arduino_library(short_name)

        if short_name not in self.platformio_libraries:
            _LOGGER.debug("Adding library: %s", library)
            self.platformio_libraries[short_name] = library
            return library

        self.platformio_libraries[short_name].reconcile_with(library)
        return self.platformio_libraries[short_name]

    def add_build_flag(self, build_flag: str) -> str:
        self.build_flags.add(build_flag)
        _LOGGER.debug("Adding build flag: %s", build_flag)
        return build_flag

    def add_build_unflag(self, build_unflag: str) -> None:
        self.build_unflags.add(build_unflag)
        _LOGGER.debug("Adding build unflag: %s", build_unflag)

    def add_define(self, define):
        if isinstance(define, str):
            define = Define(define)
        elif isinstance(define, Define):
            pass
        else:
            raise ValueError(
                f"Define {define} must be string or Define, not {type(define)}"
            )
        self.defines.add(define)
        _LOGGER.debug("Adding define: %s", define)
        return define

    def add_platformio_option(self, key: str, value: str | list[str]) -> None:
        new_val = value
        old_val = self.platformio_options.get(key)
        if isinstance(old_val, list):
            assert isinstance(value, list)
            new_val = old_val + value
        self.platformio_options[key] = new_val

    def _get_variable_generator(self, id):
        while True:
            try:
                return self.variables[id]
            except KeyError:
                _LOGGER.debug("Waiting for variable %s (%r)", id, id)
                yield

    async def get_variable(self, id) -> "MockObj":
        if not isinstance(id, ID):
            raise ValueError(f"ID {id!r} must be of type ID!")
        # Fast path, check if already registered without awaiting
        if id in self.variables:
            return self.variables[id]
        return await _FakeAwaitable(self._get_variable_generator(id))

    def _get_variable_with_full_id_generator(self, id):
        while True:
            if id in self.variables:
                for k, v in self.variables.items():
                    if k == id:
                        return (k, v)
            _LOGGER.debug("Waiting for variable %s", id)
            yield

    async def get_variable_with_full_id(self, id: ID) -> tuple[ID, "MockObj"]:
        if not isinstance(id, ID):
            raise ValueError(f"ID {id!r} must be of type ID!")
        return await _FakeAwaitable(self._get_variable_with_full_id_generator(id))

    def register_variable(self, id, obj):
        if id in self.variables:
            raise EsphomeError(f"ID {id} is already registered")
        _LOGGER.debug("Registered variable %s of type %s", id.id, id.type)
        self.variables[id] = obj

    def has_id(self, id):
        return id in self.variables

    def register_platform_component(self, platform_name: str, var) -> None:
        """Register a component for a platform and track its count.

        :param platform_name: The name of the platform (e.g., 'sensor', 'binary_sensor')
        :param var: The variable (component) being registered (currently unused but kept for future use)
        """
        self.platform_counts[platform_name] += 1

    def testing_ensure_platform_registered(self, platform_name: str) -> None:
        """Ensure a platform has at least one entity registered for testing.

        Used during C++ test builds to guarantee USE_* defines are emitted
        without needing a real component variable.
        """
        if not self.platform_counts[platform_name]:
            self.platform_counts[platform_name] = 1

    def register_controller(self) -> None:
        """Track registration of a Controller for ControllerRegistry StaticVector sizing."""
        controller_count = self.data.setdefault(KEY_CONTROLLER_REGISTRY_COUNT, 0)
        self.data[KEY_CONTROLLER_REGISTRY_COUNT] = controller_count + 1

    @property
    def cpp_main_section(self):
        from esphome.cpp_generator import statement

        main_code = []
        for exp in self.main_statements:
            text = str(statement(exp))
            text = text.rstrip()
            main_code.append(text)
        return "\n".join(main_code) + "\n\n"

    @property
    def cpp_global_section(self):
        from esphome.cpp_generator import statement

        global_code = []
        for exp in self.global_statements:
            text = str(statement(exp))
            text = text.rstrip()
            global_code.append(text)
        return "\n".join(global_code) + "\n"


class AutoLoad(OrderedDict):
    pass


class EnumValue:
    """Special type used by ESPHome to mark enum values for cv.enum."""

    @property
    def enum_value(self):
        return getattr(self, "_enum_value", None)

    @enum_value.setter
    def enum_value(self, value):
        setattr(self, "_enum_value", value)


CORE = EsphomeCore()
