from collections.abc import Callable
from dataclasses import dataclass, field
import functools
import logging

import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
    CONF_DEVICE_CLASS,
    CONF_DEVICE_ID,
    CONF_DISABLED_BY_DEFAULT,
    CONF_ENTITY_CATEGORY,
    CONF_ICON,
    CONF_ID,
    CONF_INTERNAL,
    CONF_NAME,
    CONF_UNIT_OF_MEASUREMENT,
)
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core.config import (
    DEVICE_CLASS_MAX_LENGTH,
    ICON_MAX_LENGTH,
    UNIT_OF_MEASUREMENT_MAX_LENGTH,
)
from esphome.cpp_generator import MockObj, RawStatement, add, get_variable
import esphome.final_validate as fv
from esphome.helpers import cpp_string_escape, fnv1_hash_object_id, sanitize, snake_case
from esphome.types import ConfigType, EntityMetadata

_LOGGER = logging.getLogger(__name__)

DOMAIN = "entity_string_pool"

# Private config keys for storing registered string indices
_KEY_DC_IDX = "_entity_dc_idx"
_KEY_UOM_IDX = "_entity_uom_idx"
_KEY_ICON_IDX = "_entity_icon_idx"
_KEY_ENTITY_NAME = "_entity_name"
_KEY_OBJECT_ID_HASH = "_entity_object_id_hash"

# Bit layout for entity_fields in configure_entity_().
# Keep in sync with ENTITY_FIELD_*_SHIFT constants in esphome/core/entity_base.h
_DC_SHIFT = 0
_UOM_SHIFT = 8
_ICON_SHIFT = 16
_INTERNAL_SHIFT = 24
_DISABLED_BY_DEFAULT_SHIFT = 25
_ENTITY_CATEGORY_SHIFT = 26

# Private config keys for storing flags
_KEY_INTERNAL = "_entity_internal"
_KEY_DISABLED_BY_DEFAULT = "_entity_disabled_by_default"
_KEY_ENTITY_CATEGORY = "_entity_category"

# Maximum unique strings per category (8-bit index, 0 = not set)
_MAX_DEVICE_CLASSES = 0xFF  # 255
_MAX_UNITS = 0xFF  # 255
_MAX_ICONS = 0xFF  # 255


@dataclass
class EntityStringPool:
    """Pool of entity string properties for PROGMEM pointer tables.

    Strings are registered during to_code() and assigned 1-based indices.
    Index 0 means "not set" (empty string). At render time, the pool
    generates C++ PROGMEM pointer table + lookup function per category.
    """

    device_classes: dict[str, int] = field(default_factory=dict)
    units: dict[str, int] = field(default_factory=dict)
    icons: dict[str, int] = field(default_factory=dict)
    tables_registered: bool = False


def _get_pool() -> EntityStringPool:
    """Get or create the entity string pool from CORE.data."""
    if DOMAIN not in CORE.data:
        CORE.data[DOMAIN] = EntityStringPool()
    return CORE.data[DOMAIN]


def _ensure_tables_registered() -> None:
    """Schedule the table generation job (once)."""
    pool = _get_pool()
    if pool.tables_registered:
        return
    pool.tables_registered = True
    CORE.add_job(_generate_tables_job)


def _generate_category_code(
    table_var: str,
    lookup_fn: str,
    strings: dict[str, int],
    *,
    progmem_strings: bool = False,
) -> str:
    """Generate C++ code for one string category (PROGMEM pointer table + lookup).

    Uses a PROGMEM array of string pointers. On ESP8266, pointers are stored
    in flash (via PROGMEM) and read with progmem_read_ptr(). String literals
    themselves remain in RAM but benefit from linker string deduplication.
    Index 0 means "not set" and returns empty string.

    When progmem_strings=True, each string is declared as a separate PROGMEM
    char array. This ensures the string data itself is in flash on ESP8266
    (where .rodata is RAM). On other platforms PROGMEM is a no-op.
    """
    if not strings:
        return ""

    sorted_strings = sorted(strings.items(), key=lambda x: x[1])
    count = len(sorted_strings)

    if progmem_strings:
        # Emit individual PROGMEM char arrays so string data lives in flash
        lines: list[str] = []
        var_names: list[str] = []
        for i, (s, _) in enumerate(sorted_strings):
            var_name = f"{table_var}_STR_{i}"
            var_names.append(var_name)
            lines.append(
                f"static const char {var_name}[] PROGMEM = {cpp_string_escape(s)};"
            )
        entries = ", ".join(var_names)
        # Empty string must also be PROGMEM — on ESP8266, callers use strncpy_P
        empty_var = f"{table_var}_EMPTY"
        lines.append(f'static const char {empty_var}[] PROGMEM = "";')
        lines.append(f"static const char *const {table_var}[] PROGMEM = {{{entries}}};")
        lines.append(f"const char *{lookup_fn}(uint8_t index) {{")
        lines.append(f"  if (index == 0 || index > {count}) return {empty_var};")
        lines.append(f"  return progmem_read_ptr(&{table_var}[index - 1]);")
        lines.append("}")
        return "\n".join(lines) + "\n"

    entries = ", ".join(cpp_string_escape(s) for s, _ in sorted_strings)

    return (
        f"static const char *const {table_var}[] PROGMEM = {{{entries}}};\n"
        f"const char *{lookup_fn}(uint8_t index) {{\n"
        f'  if (index == 0 || index > {count}) return "";\n'
        f"  return progmem_read_ptr(&{table_var}[index - 1]);\n"
        f"}}\n"
    )


_CATEGORY_CONFIGS = (
    ("ENTITY_DC_TABLE", "entity_device_class_lookup", "device_classes", True),
    ("ENTITY_UOM_TABLE", "entity_uom_lookup", "units", False),
    ("ENTITY_ICON_TABLE", "entity_icon_lookup", "icons", True),
)


@coroutine_with_priority(CoroPriority.FINAL)
async def _generate_tables_job() -> None:
    """Generate all entity string table C++ code as a FINAL-priority job.

    Runs after all component to_code() calls have registered their strings.
    """
    pool = _get_pool()
    parts = ["namespace esphome {"]
    for table_var, lookup_fn, attr, progmem_strs in _CATEGORY_CONFIGS:
        code = _generate_category_code(
            table_var, lookup_fn, getattr(pool, attr), progmem_strings=progmem_strs
        )
        if code:
            parts.append(code)
    parts.append("}  // namespace esphome")
    cg.add_global(RawStatement("\n".join(parts)))


def _register_string(
    value: str, category: dict[str, int], max_count: int, category_name: str
) -> int:
    """Register a string in a category dict and return its 1-based index.

    Returns 0 if value is empty/None (meaning "not set").
    """
    if not value:
        return 0
    if value in category:
        return category[value]
    idx = len(category) + 1
    if idx > max_count:
        raise ValueError(
            f"Too many unique {category_name} values (max {max_count}), got {idx}: '{value}'"
        )
    category[value] = idx
    _ensure_tables_registered()
    return idx


def register_device_class(value: str) -> int:
    """Register a device_class string and return its 1-based index."""
    byte_len = len(value.encode("utf-8")) if value else 0
    if byte_len > DEVICE_CLASS_MAX_LENGTH:
        raise ValueError(
            f"Device class string too long ({byte_len} bytes, max {DEVICE_CLASS_MAX_LENGTH}): '{value}'"
        )
    return _register_string(
        value, _get_pool().device_classes, _MAX_DEVICE_CLASSES, "device_class"
    )


def register_unit_of_measurement(value: str) -> int:
    """Register a unit_of_measurement string and return its 1-based index."""
    byte_len = len(value.encode("utf-8")) if value else 0
    if byte_len > UNIT_OF_MEASUREMENT_MAX_LENGTH:
        raise ValueError(
            f"Unit of measurement string too long ({byte_len} bytes, "
            f"max {UNIT_OF_MEASUREMENT_MAX_LENGTH}): '{value}'"
        )
    return _register_string(value, _get_pool().units, _MAX_UNITS, "unit_of_measurement")


def register_icon(value: str) -> int:
    """Register an icon string and return its 1-based index."""
    byte_len = len(value.encode("utf-8")) if value else 0
    if byte_len > ICON_MAX_LENGTH:
        raise ValueError(
            f"Icon string too long ({byte_len} bytes, max {ICON_MAX_LENGTH}): '{value}'"
        )
    return _register_string(value, _get_pool().icons, _MAX_ICONS, "icon")


def setup_device_class(config: ConfigType) -> None:
    """Register config's device_class and store its index for finalize_entity_strings."""
    idx = register_device_class(config.get(CONF_DEVICE_CLASS, ""))
    if idx:
        cg.add_define("USE_ENTITY_DEVICE_CLASS")
    config[_KEY_DC_IDX] = idx


def setup_unit_of_measurement(config: ConfigType) -> None:
    """Register config's unit_of_measurement and store its index for finalize_entity_strings."""
    idx = register_unit_of_measurement(config.get(CONF_UNIT_OF_MEASUREMENT, ""))
    if idx:
        cg.add_define("USE_ENTITY_UNIT_OF_MEASUREMENT")
    config[_KEY_UOM_IDX] = idx


def _sanitize_comment(text: str) -> str:
    r"""Sanitize a string for safe inclusion in a C++ // line comment.

    Dangerous characters:
    - \n, \r: break out of line comment, next line becomes code
    - \: at end of line, splices next line into comment (eats real code)
    """
    return text.replace("\\", "/").replace("\n", " ").replace("\r", "")


def _describe_packed_flags(config: ConfigType, entity_category: int) -> str:
    """Build a human-readable description of packed entity flags for C++ comments."""
    parts: list[str] = []
    if config.get(_KEY_INTERNAL):
        parts.append("internal")
    if config.get(_KEY_DISABLED_BY_DEFAULT):
        parts.append("disabled_by_default")
    entity_cat_keys = list(cv.ENTITY_CATEGORIES)
    if entity_category < len(entity_cat_keys) and (
        cat_name := entity_cat_keys[entity_category]
    ):
        parts.append(f"category:{cat_name}")
    if config.get(_KEY_DC_IDX) and (dc := config.get(CONF_DEVICE_CLASS)):
        parts.append(f"dc:{_sanitize_comment(dc)}")
    if config.get(_KEY_UOM_IDX) and (uom := config.get(CONF_UNIT_OF_MEASUREMENT)):
        parts.append(f"uom:{_sanitize_comment(uom)}")
    if config.get(_KEY_ICON_IDX) and (icon := config.get(CONF_ICON)):
        parts.append(f"icon:{_sanitize_comment(icon)}")
    return ", ".join(parts)


def finalize_entity_strings(var: MockObj, config: ConfigType) -> None:
    """Emit a single configure_entity_() call with name, hash, packed string indices, and flags.

    Call this at the end of each component's setup function, after
    setup_entity() and any register_device_class/register_unit_of_measurement calls.
    """
    entity_name = config[_KEY_ENTITY_NAME]
    object_id_hash = config[_KEY_OBJECT_ID_HASH]
    dc_idx = config.get(_KEY_DC_IDX, 0)
    uom_idx = config.get(_KEY_UOM_IDX, 0)
    icon_idx = config.get(_KEY_ICON_IDX, 0)
    internal = config.get(_KEY_INTERNAL, 0)
    disabled_by_default = config.get(_KEY_DISABLED_BY_DEFAULT, 0)
    entity_category = config.get(_KEY_ENTITY_CATEGORY, 0)
    packed = (
        (dc_idx << _DC_SHIFT)
        | (uom_idx << _UOM_SHIFT)
        | (icon_idx << _ICON_SHIFT)
        | (internal << _INTERNAL_SHIFT)
        | (disabled_by_default << _DISABLED_BY_DEFAULT_SHIFT)
        | (entity_category << _ENTITY_CATEGORY_SHIFT)
    )
    # Build inline comment describing the packed flags for readability
    comment = _describe_packed_flags(config, entity_category)
    expr = var.configure_entity_(entity_name, object_id_hash, packed)
    if comment:
        add(RawStatement(f"{expr};  // {comment}"))
    else:
        add(expr)


def get_base_entity_object_id(
    name: str, friendly_name: str | None, device_name: str | None = None
) -> str:
    """Calculate the base object ID for an entity that will be set via set_object_id().

    This function calculates what object_id_c_str_ should be set to in C++.

    The C++ EntityBase::get_object_id() (entity_base.cpp lines 38-49) works as:
    - If !has_own_name && is_name_add_mac_suffix_enabled():
        return str_sanitize(str_snake_case(App.get_friendly_name()))  // Dynamic
    - Else:
        return object_id_c_str_ ?? ""  // What we set via set_object_id()

    Since we're calculating what to pass to set_object_id(), we always need to
    generate the object_id the same way, regardless of name_add_mac_suffix setting.

    Args:
        name: The entity name (empty string if no name)
        friendly_name: The friendly name from CORE.friendly_name
        device_name: The device name if entity is on a sub-device

    Returns:
        The base object ID to use for duplicate checking and to pass to set_object_id()
    """

    if name:
        # Entity has its own name (has_own_name will be true)
        base_str = name
    elif device_name:
        # Entity has empty name and is on a sub-device
        # C++ EntityBase::set_name() uses device->get_name() when device is set
        base_str = device_name
    elif friendly_name:
        # Entity has empty name (has_own_name will be false)
        # C++ uses App.get_friendly_name() which returns friendly_name or device name
        base_str = friendly_name
    else:
        # Fallback to device name
        base_str = CORE.name

    return sanitize(snake_case(base_str))


def setup_entity(var_or_platform, config=None, platform=None):
    """Set up entity properties — works as both decorator and direct call.

    Decorator mode::

        @setup_entity("sensor")
        async def setup_sensor_core_(var, config):
            setup_device_class(config)
            setup_unit_of_measurement(config)
            ...

    Direct call mode (for entities with no extra string properties)::

        await setup_entity(var, config, "camera")
    """
    if isinstance(var_or_platform, str) and config is None:
        # Decorator mode: @setup_entity("sensor")
        platform = var_or_platform

        def decorator(func: Callable) -> Callable:
            @functools.wraps(func)
            async def wrapper(
                var: MockObj, config: ConfigType, *args, **kwargs
            ) -> None:
                await _setup_entity_impl(var, config, platform)
                await func(var, config, *args, **kwargs)
                finalize_entity_strings(var, config)

            return wrapper

        return decorator

    # Direct call mode: await setup_entity(var, config, "camera")
    async def _do() -> None:
        await _setup_entity_impl(var_or_platform, config, platform)
        finalize_entity_strings(var_or_platform, config)

    return _do()


async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> None:
    """Set up generic properties of an Entity (internal implementation).

    This function sets up the common entity properties like name, icon,
    entity category, etc.

    Args:
        var: The entity variable to set up
        config: Configuration dictionary containing entity settings
        platform: The platform name (e.g., "sensor", "binary_sensor")
    """
    # Get device info if configured
    if device_id_obj := config.get(CONF_DEVICE_ID):
        device: MockObj = await get_variable(device_id_obj)
        add(var.set_device_(device))

    # Pre-compute entity name and object_id hash for configure_entity_()
    # which is emitted later by finalize_entity_strings().
    # For named entities: pre-compute hash from entity name
    # For empty-name entities: pass 0, C++ calculates hash at runtime from
    # device name, friendly_name, or app name (bug-for-bug compatibility)
    entity_name = config[CONF_NAME]
    object_id_hash = fnv1_hash_object_id(entity_name) if entity_name else 0
    config[_KEY_ENTITY_NAME] = entity_name
    config[_KEY_OBJECT_ID_HASH] = object_id_hash
    # Store flags for packing into configure_entity_()
    config[_KEY_DISABLED_BY_DEFAULT] = int(config[CONF_DISABLED_BY_DEFAULT])
    if CONF_INTERNAL in config:
        config[_KEY_INTERNAL] = int(config[CONF_INTERNAL])
    icon_idx = 0
    if CONF_ICON in config:
        # Add USE_ENTITY_ICON define when icons are used
        cg.add_define("USE_ENTITY_ICON")
        icon_idx = register_icon(config[CONF_ICON])
    if CONF_ENTITY_CATEGORY in config:
        # Derive integer value from key position in cv.ENTITY_CATEGORIES
        # (must match C++ EntityCategory enum in entity_base.h)
        entity_cat_str = str(config[CONF_ENTITY_CATEGORY])
        entity_cat_keys = list(cv.ENTITY_CATEGORIES)
        config[_KEY_ENTITY_CATEGORY] = (
            entity_cat_keys.index(entity_cat_str)
            if entity_cat_str in entity_cat_keys
            else 0
        )
    # Store icon index for finalize_entity_strings
    config[_KEY_ICON_IDX] = icon_idx


def inherit_property_from(property_to_inherit, parent_id_property, transform=None):
    """Validator that inherits a configuration property from another entity, for use with FINAL_VALIDATE_SCHEMA.
    If a property is already set, it will not be inherited.
    Keyword arguments:
    property_to_inherit -- the name or path of the property to inherit, e.g. CONF_ICON or [CONF_SENSOR, 0, CONF_ICON]
                           (the parent must exist, otherwise nothing is done).
    parent_id_property -- the name or path of the property that holds the ID of the parent, e.g. CONF_POWER_ID or
                          [CONF_SENSOR, 1, CONF_POWER_ID].
    """

    def _walk_config(config, path):
        walk = [path] if not isinstance(path, list) else path
        for item_or_index in walk:
            config = config[item_or_index]
        return config

    def inherit_property(config):
        # Split the property into its path and name
        if not isinstance(property_to_inherit, list):
            property_path, property = [], property_to_inherit
        else:
            property_path, property = property_to_inherit[:-1], property_to_inherit[-1]

        # Check if the property to inherit is accessible
        try:
            config_part = _walk_config(config, property_path)
        except KeyError:
            return config

        # Only inherit the property if it does not exist yet
        if property not in config_part:
            fconf = fv.full_config.get()

            # Get config for the parent entity
            parent_id = _walk_config(config, parent_id_property)
            parent_path = fconf.get_path_for_id(parent_id)[:-1]
            parent_config = fconf.get_config_for_path(parent_path)

            # If parent sensor has the property set, inherit it
            if property in parent_config:
                path = fconf.get_path_for_id(config[CONF_ID])[:-1]
                this_config = _walk_config(
                    fconf.get_config_for_path(path), property_path
                )
                value = parent_config[property]
                if transform:
                    value = transform(value, config)
                this_config[property] = value

        return config

    return inherit_property


def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigType]:
    """Create a validator function to check for duplicate entity names.

    This validator is meant to be used with schema.add_extra() for entity base schemas.

    Args:
        platform: The platform name (e.g., "sensor", "binary_sensor")

    Returns:
        A validator function that checks for duplicate names
    """

    def validator(config: ConfigType) -> ConfigType:
        if CONF_NAME not in config:
            # No name to validate
            return config

        # Skip validation for internal entities
        # Internal entities are not exposed to Home Assistant and don't use the hash-based
        # entity state tracking system, so name collisions don't matter for them
        if config.get(CONF_INTERNAL, False):
            return config

        # Get the entity name
        entity_name = config[CONF_NAME]

        # Get device name if entity is on a sub-device
        device_name = None
        device_id = ""  # Empty string for main device
        device_id_obj: ID | None
        if device_id_obj := config.get(CONF_DEVICE_ID):
            device_name = device_id_obj.id
            # Use the device ID string directly for uniqueness
            device_id = device_id_obj.id

        # Calculate what object_id will actually be used
        # This handles empty names correctly by using device/friendly names
        name_key = get_base_entity_object_id(
            entity_name, CORE.friendly_name, device_name
        )

        # Check for duplicates
        unique_key = (device_id, platform, name_key)
        if unique_key in CORE.unique_ids:
            # Get the existing entity metadata
            existing = CORE.unique_ids[unique_key]
            existing_name = existing.get("name", entity_name)
            existing_device = existing.get("device_id", "")
            existing_id = existing.get("entity_id", "unknown")

            # Build detailed error message
            device_prefix = f" on device '{device_id}'" if device_id else ""
            existing_device_prefix = (
                f" on device '{existing_device}'" if existing_device else ""
            )
            existing_component = existing.get("component", "unknown")

            # Provide more context about where the duplicate was found
            conflict_msg = (
                f"Conflicts with entity '{existing_name}'{existing_device_prefix}"
            )
            if existing_id != "unknown":
                conflict_msg += f" (id: {existing_id})"
            if existing_component != "unknown":
                conflict_msg += f" from component '{existing_component}'"

            # Show both original names and their ASCII-only versions if they differ
            sanitized_msg = ""
            if entity_name != existing_name:
                sanitized_msg = (
                    f"\n  Original names: '{entity_name}' and '{existing_name}'"
                    f"\n  Both convert to ASCII ID: '{name_key}'"
                    "\n  To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B')"
                    "\n          to distinguish them"
                )

            # Skip duplicate entity name validation when testing_mode is enabled
            # This flag is used for grouped component testing
            if not CORE.testing_mode:
                raise cv.Invalid(
                    f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. "
                    f"{conflict_msg}. "
                    "Each entity on a device must have a unique name within its platform."
                    f"{sanitized_msg}"
                )

        # Store metadata about this entity
        entity_metadata: EntityMetadata = {
            "name": entity_name,
            "device_id": device_id,
            "platform": platform,
            "entity_id": str(config.get(CONF_ID, "unknown")),
            "component": CORE.current_component or "unknown",
        }

        # Add to tracking dict
        CORE.unique_ids[unique_key] = entity_metadata
        return config

    return validator
