import json
import logging

import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
    CONF_BOARD,
    CONF_COMPONENT_ID,
    CONF_DEBUG,
    CONF_FAMILY,
    CONF_FRAMEWORK,
    CONF_ID,
    CONF_NAME,
    CONF_OPTIONS,
    CONF_PROJECT,
    CONF_SOURCE,
    CONF_VERSION,
    KEY_CORE,
    KEY_FRAMEWORK_VERSION,
    KEY_TARGET_FRAMEWORK,
    KEY_TARGET_PLATFORM,
    ThreadModel,
    __version__,
)
from esphome.core import CORE
from esphome.storage_json import StorageJSON

from . import gpio  # noqa
from .const import (
    COMPONENT_BK72XX,
    CONF_GPIO_RECOVER,
    CONF_LOGLEVEL,
    CONF_SDK_SILENT,
    CONF_UART_PORT,
    FAMILIES,
    FAMILY_BK7231N,
    FAMILY_COMPONENT,
    FAMILY_FRIENDLY,
    FAMILY_RTL8710B,
    KEY_BOARD,
    KEY_COMPONENT,
    KEY_COMPONENT_DATA,
    KEY_FAMILY,
    KEY_LIBRETINY,
    LT_DEBUG_MODULES,
    LT_LOGLEVELS,
    LibreTinyComponent,
    LTComponent,
)

_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@kuba2k2"]
AUTO_LOAD = ["preferences"]
IS_TARGET_PLATFORM = True

# BK7231N SDK options to disable unused features.
# Disabling BLE saves ~21KB RAM and ~200KB Flash because BLE init code is
# called unconditionally by the SDK. ESPHome doesn't use BLE on LibreTiny.
#
# This only works on BK7231N (BLE 5.x). Other BK72XX chips using BLE 4.2
# (BK7231T, BK7231Q, BK7251; BK7252 boards use the BK7251 family) have a bug
# where the BLE library still links and references undefined symbols when
# CFG_SUPPORT_BLE=0.
#
# Other options like CFG_TX_EVM_TEST, CFG_RX_SENSITIVITY_TEST, CFG_SUPPORT_BKREG,
# CFG_SUPPORT_OTA_HTTP, and CFG_USE_SPI_SLAVE were evaluated but provide no  # NOLINT
# measurable benefit - the linker already strips unreferenced code via -gc-sections.
_BK7231N_SYS_CONFIG_OPTIONS = [
    "CFG_SUPPORT_BLE=0",
]


def _detect_variant(value):
    if KEY_LIBRETINY not in CORE.data:
        raise cv.Invalid("Family component didn't populate core data properly!")
    component: LibreTinyComponent = CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA]
    board = value[CONF_BOARD]
    # read board-default family if not specified
    if board not in component.boards:
        if CONF_FAMILY not in value:
            raise cv.Invalid(
                "This board is unknown, if you are sure you want to compile with this board selection, "
                f"override with option '{CONF_FAMILY}'",
                path=[CONF_BOARD],
            )
        _LOGGER.warning(
            "This board is unknown. Make sure the chosen chip component is correct.",
        )
    else:
        family = component.boards[board][KEY_FAMILY]
        if CONF_FAMILY in value and family != value[CONF_FAMILY]:
            raise cv.Invalid(
                f"Option '{CONF_FAMILY}' does not match selected board.",
                path=[CONF_FAMILY],
            )
        value = value.copy()
        value[CONF_FAMILY] = family
    # read component name matching this family
    value[CONF_COMPONENT_ID] = FAMILY_COMPONENT[value[CONF_FAMILY]]
    # make sure the chosen component matches the family
    if value[CONF_COMPONENT_ID] != component.name:
        raise cv.Invalid(
            f"The chosen family doesn't belong to '{component.name}' component. The correct component is '{value[CONF_COMPONENT_ID]}'",
            path=[CONF_FAMILY],
        )
    return value


def _update_core_data(config):
    CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = config[CONF_COMPONENT_ID]
    CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "arduino"
    CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse(
        config[CONF_FRAMEWORK][CONF_VERSION]
    )
    CORE.data[KEY_LIBRETINY][KEY_BOARD] = config[CONF_BOARD]
    CORE.data[KEY_LIBRETINY][KEY_COMPONENT] = config[CONF_COMPONENT_ID]
    CORE.data[KEY_LIBRETINY][KEY_FAMILY] = config[CONF_FAMILY]
    return config


def get_libretiny_component(core_obj=None):
    return (core_obj or CORE).data[KEY_LIBRETINY][KEY_COMPONENT]


def get_libretiny_family(core_obj=None):
    return (core_obj or CORE).data[KEY_LIBRETINY][KEY_FAMILY]


def only_on_family(*, supported=None, unsupported=None):
    """Config validator for features only available on some LibreTiny families."""
    if supported is not None and not isinstance(supported, list):
        supported = [supported]
    if unsupported is not None and not isinstance(unsupported, list):
        unsupported = [unsupported]

    def validator_(obj):
        family = get_libretiny_family()
        if supported is not None and family not in supported:
            raise cv.Invalid(
                f"This feature is only available on {', '.join(supported)}"
            )
        if unsupported is not None and family in unsupported:
            raise cv.Invalid(
                f"This feature is not available on {', '.join(unsupported)}"
            )
        return obj

    return validator_


def get_download_types(storage_json: StorageJSON = None):
    types = [
        {
            "title": "UF2 package (recommended)",
            "description": "For flashing via web_server OTA or with ltchiptool (UART)",
            "file": "firmware.uf2",
            "download": f"{storage_json.name}.uf2",
        },
    ]

    build_dir = storage_json.firmware_bin_path.parent
    outputs = build_dir / "firmware.json"
    if not outputs.is_file():
        return types
    with outputs.open(encoding="utf-8") as f:
        outputs = json.load(f)
    for output in outputs:
        if not output["public"]:
            continue
        suffix = output["filename"].partition(".")[2]
        suffix = f"-{suffix}" if "." in suffix else f".{suffix}"
        types.append(
            {
                "title": output["title"],
                "description": output["description"],
                "file": output["filename"],
                "download": storage_json.name + suffix,
            }
        )
    return types


def _notify_old_style(config):
    if config:
        raise cv.Invalid(
            "The LibreTiny component is now split between supported chip families.\n"
            "Migrate your config file to include a chip-based configuration, "
            "instead of the 'libretiny:' block.\n"
            "For example 'bk72xx:' or 'rtl87xx:'."
        )
    return config


# The dev and latest branches will be at *least* this version, which is what matters.
# Use GitHub releases directly to avoid PlatformIO moderation delays.
ARDUINO_VERSIONS = {
    "dev": (cv.Version(1, 12, 1), "https://github.com/libretiny-eu/libretiny.git"),
    "latest": (
        cv.Version(1, 12, 1),
        "https://github.com/libretiny-eu/libretiny.git#v1.12.1",
    ),
    "recommended": (
        cv.Version(1, 12, 1),
        "https://github.com/libretiny-eu/libretiny.git#v1.12.1",
    ),
}


def _check_framework_version(value):
    value = value.copy()

    if value[CONF_VERSION] in ARDUINO_VERSIONS:
        if CONF_SOURCE in value:
            raise cv.Invalid(
                "Framework version needs to be explicitly specified when custom source is used."
            )

        version, source = ARDUINO_VERSIONS[value[CONF_VERSION]]
    else:
        version = cv.Version.parse(cv.version_number(value[CONF_VERSION]))
        source = value.get(CONF_SOURCE, None)

    value[CONF_VERSION] = str(version)
    value[CONF_SOURCE] = source

    return value


def _check_debug_order(value):
    debug = value[CONF_DEBUG]
    if "NONE" in debug and "NONE" in debug[1:]:
        raise cv.Invalid(
            "'none' has to be specified before other modules, and only once",
            path=[CONF_DEBUG],
        )
    return value


FRAMEWORK_SCHEMA = cv.All(
    cv.Schema(
        {
            cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict,
            cv.Optional(CONF_SOURCE): cv.string_strict,
            cv.Optional(CONF_LOGLEVEL, default="warn"): (
                cv.one_of(*LT_LOGLEVELS, upper=True)
            ),
            cv.Optional(CONF_DEBUG, default=[]): cv.ensure_list(
                cv.one_of("NONE", *LT_DEBUG_MODULES, upper=True)
            ),
            cv.Optional(CONF_SDK_SILENT, default="all"): (
                cv.one_of("all", "auto", "none", lower=True)
            ),
            cv.Optional(CONF_UART_PORT): cv.one_of(0, 1, 2, int=True),
            cv.Optional(CONF_GPIO_RECOVER, default=True): cv.boolean,
            cv.Optional(CONF_OPTIONS, default={}): {
                cv.string_strict: cv.string,
            },
        }
    ),
    _check_framework_version,
    _check_debug_order,
)

CONFIG_SCHEMA = cv.All(_notify_old_style)

BASE_SCHEMA = cv.Schema(
    {
        cv.GenerateID(): cv.declare_id(LTComponent),
        cv.Required(CONF_BOARD): cv.string_strict,
        cv.Optional(CONF_FAMILY): cv.one_of(*FAMILIES, upper=True),
        cv.Optional(CONF_FRAMEWORK, default={}): FRAMEWORK_SCHEMA,
    },
)

BASE_SCHEMA.add_extra(_detect_variant)
BASE_SCHEMA.add_extra(_update_core_data)


def _configure_lwip(config: dict) -> None:
    """Configure lwIP options for LibreTiny platforms.

    The BK/RTL/LN SDKs each ship different lwIP defaults. BK72XX defaults are
    wildly oversized for ESPHome's IoT use case, causing OOM on BK7231N.
    RTL87XX and LN882H have more conservative defaults but still need tuning
    for ESPHome's socket usage patterns.

    See https://github.com/esphome/esphome/issues/14095

    Comparison of SDK defaults vs ESPHome targets (TCP_MSS=1460 on all LT):

    Setting                   ESP8266  ESP32  BK SDK   RTL SDK  LN SDK   New
    ────────────────────────────────────────────────────────────────────────────
    TCP_SND_BUF               2×MSS   4×MSS  10×MSS   5×MSS    7×MSS    4×MSS
    TCP_WND                   4×MSS   4×MSS  3/10×MSS 2×MSS    3×MSS    4×MSS
    MEM_LIBC_MALLOC           1       1      0        0        1        1
    MEMP_MEM_MALLOC           1       1      0        0        0        1
    MEM_SIZE                  N/A*    N/A*   16/32KB  5KB      N/A*     N/A* BK
    PBUF_POOL_SIZE            10      16     3/10     20       20       10 BK
    MAX_SOCKETS_TCP           5       16     12       —**      —**      dynamic
    MAX_SOCKETS_UDP           4       16     22       —**      —**      dynamic
    TCP_SND_QUEUELEN          ~8      17     20       20       35       17
    MEMP_NUM_TCP_SEG          10      16     40       20       =qlen    17
    MEMP_NUM_TCP_PCB          5       16     12       10       8        =TCP
    MEMP_NUM_TCP_PCB_LISTEN   4       16     4        5        3        dynamic
    MEMP_NUM_UDP_PCB          4       16     25***    7****    7****    =UDP
    MEMP_NUM_NETCONN          0       10     38       4*****   =sum     =sum
    MEMP_NUM_NETBUF           0       2      16       2*****   8        4
    MEMP_NUM_TCPIP_MSG_INPKT  4       8      16       8*****   12       8

    * ESP8266/ESP32/LN882H use MEM_LIBC_MALLOC=1 (system heap, no dedicated pool).
      ESP8266/ESP32 also use MEMP_MEM_MALLOC=1 (MEMP pools from heap, not static).
    ** RTL/LN SDKs don't define MAX_SOCKETS_TCP/UDP (LibreTiny-specific).
    *** BK LT overlay: MAX_SOCKETS_UDP+2+1 = 25.
    **** RTL/LN LT overlay overrides to flat 7.
    ***** Not defined in RTL SDK — lwIP opt.h defaults shown.
    "dynamic" = auto-calculated from component socket registrations via
    socket.get_socket_counts() with minimums of 8 TCP / 6 UDP.
    """
    from esphome.components.socket import (
        MIN_TCP_LISTEN_SOCKETS,
        MIN_TCP_SOCKETS,
        MIN_UDP_SOCKETS,
        get_socket_counts,
    )

    sc = get_socket_counts()
    # Apply platform minimums — ensure headroom for ESPHome's needs
    tcp_sockets = max(MIN_TCP_SOCKETS, sc.tcp)
    udp_sockets = max(MIN_UDP_SOCKETS, sc.udp)
    # Listening sockets — registered by components (api, ota, web_server_base, etc.)
    # Not all components register yet, so ensure a minimum for baseline operation.
    listening_tcp = max(MIN_TCP_LISTEN_SOCKETS, sc.tcp_listen)

    # TCP_SND_BUF: ESPAsyncWebServer allocates malloc(tcp_sndbuf()) per
    # response chunk. At 10×MSS=14.6KB (BK default) this causes OOM (#14095).
    # 4×MSS=5,840 matches ESP32. RTL(5×) and LN(7×) are close already.
    tcp_snd_buf = "(4*TCP_MSS)"  # BK: 10×MSS, RTL: 5×MSS, LN: 7×MSS

    # TCP_WND: receive window. 4×MSS matches ESP32.
    # RTL SDK uses only 2×MSS; increasing to 4× is safe and improves throughput.
    tcp_wnd = "(4*TCP_MSS)"  # BK: 10×MSS, RTL: 2×MSS, LN: 3×MSS

    # TCP_SND_QUEUELEN: max pbufs queued for send buffer
    # ESP-IDF formula: (4 * TCP_SND_BUF + (TCP_MSS - 1)) / TCP_MSS
    # With 4×MSS: (4*5840 + 1459) / 1460 = 17 — match ESP32
    tcp_snd_queuelen = 17  # BK: 20, RTL: 20, LN: 35
    # MEMP_NUM_TCP_SEG: segment pool, must be >= TCP_SND_QUEUELEN (lwIP sanity check)
    memp_num_tcp_seg = tcp_snd_queuelen  # BK: 40, RTL: 20, LN: =qlen

    lwip_opts: list[str] = [
        # Disable statistics — not needed for production, saves RAM
        "LWIP_STATS=0",  # BK: 1, RTL: 0 already, LN: 0 already
        "MEM_STATS=0",
        "MEMP_STATS=0",
        # TCP send buffer — 4×MSS matches ESP32
        f"TCP_SND_BUF={tcp_snd_buf}",
        # TCP receive window — 4×MSS matches ESP32
        f"TCP_WND={tcp_wnd}",
        # Socket counts — auto-calculated from component registrations
        f"MAX_SOCKETS_TCP={tcp_sockets}",
        f"MAX_SOCKETS_UDP={udp_sockets}",
        # Listening sockets — BK SDK uses this to derive MEMP_NUM_TCP_PCB_LISTEN;
        # RTL/LN don't use it, but we set MEMP_NUM_TCP_PCB_LISTEN explicitly below.
        f"MAX_LISTENING_SOCKETS_TCP={listening_tcp}",
        # Queued segment limits — derived from 4×MSS buffer size
        f"TCP_SND_QUEUELEN={tcp_snd_queuelen}",
        f"MEMP_NUM_TCP_SEG={memp_num_tcp_seg}",  # must be >= queuelen
        # PCB pools — active connections + listening sockets
        f"MEMP_NUM_TCP_PCB={tcp_sockets}",  # BK: 12, RTL: 10, LN: 8
        f"MEMP_NUM_TCP_PCB_LISTEN={listening_tcp}",  # BK: =MAX_LISTENING, RTL: 5, LN: 3
        # UDP PCB pool — includes wifi.lwip_internal (DHCP + DNS)
        f"MEMP_NUM_UDP_PCB={udp_sockets}",  # BK: 25, RTL/LN: 7 via LT
        # Netconn pool — each socket (active + listening) needs a netconn
        f"MEMP_NUM_NETCONN={tcp_sockets + udp_sockets + listening_tcp}",
        # Netbuf pool
        "MEMP_NUM_NETBUF=4",  # BK: 16, RTL: 2 (opt.h), LN: 8
        # Inbound message pool
        "MEMP_NUM_TCPIP_MSG_INPKT=8",  # BK: 16, RTL: 8 (opt.h), LN: 12
    ]

    # Use system heap for all lwIP allocations on all LibreTiny platforms.
    # - MEM_LIBC_MALLOC=1: Use system heap instead of dedicated lwIP heap.
    #   LN882H already ships with this. BK SDK defaults to a 16/32KB dedicated
    #   pool that fragments during OTA. RTL SDK defaults to a 5KB pool.
    #   All three SDKs wire malloc → pvPortMalloc (FreeRTOS thread-safe heap).
    # - MEMP_MEM_MALLOC=1: Allocate MEMP pools from heap on demand instead
    #   of static arrays. Saves ~20KB RAM on BK72XX. Safe because WiFi
    #   receive paths run in task context, not ISR context. ESP32 and ESP8266
    #   both ship with MEMP_MEM_MALLOC=1.
    lwip_opts.append("MEM_LIBC_MALLOC=1")
    lwip_opts.append("MEMP_MEM_MALLOC=1")

    # BK72XX-specific: PBUF_POOL_SIZE override
    # BK SDK "reduced plan" sets this to only 3 — too few for multiple
    # concurrent connections (API + web_server + OTA). BK default plan
    # uses 10; match that. RTL(20) and LN(20) need no override.
    # With MEMP_MEM_MALLOC=1, this is a max count (allocated on demand).
    if CORE.is_bk72xx:
        lwip_opts.append("PBUF_POOL_SIZE=10")

    tcp_min = " (min)" if tcp_sockets > sc.tcp else ""
    udp_min = " (min)" if udp_sockets > sc.udp else ""
    listen_min = " (min)" if listening_tcp > sc.tcp_listen else ""
    _LOGGER.info(
        "Configuring lwIP: TCP=%d%s [%s], UDP=%d%s [%s], TCP_LISTEN=%d%s [%s]",
        tcp_sockets,
        tcp_min,
        sc.tcp_details,
        udp_sockets,
        udp_min,
        sc.udp_details,
        listening_tcp,
        listen_min,
        sc.tcp_listen_details,
    )
    cg.add_platformio_option("custom_options.lwip", lwip_opts)


# pylint: disable=use-dict-literal
async def component_to_code(config):
    var = cg.new_Pvariable(config[CONF_ID])

    # setup board config
    cg.add_platformio_option("board", config[CONF_BOARD])
    cg.add_build_flag("-DUSE_LIBRETINY")
    cg.add_build_flag(f"-DUSE_{config[CONF_COMPONENT_ID].upper()}")
    cg.add_build_flag(f"-DUSE_LIBRETINY_VARIANT_{config[CONF_FAMILY]}")
    cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
    cg.add_define("ESPHOME_VARIANT", FAMILY_FRIENDLY[config[CONF_FAMILY]])
    # Set threading model based on chip architecture
    component: LibreTinyComponent = CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA]
    if component.supports_atomics:
        # RTL87xx (Cortex-M4) and LN882x (Cortex-M4F) have LDREX/STREX
        cg.add_define(ThreadModel.MULTI_ATOMICS)
    else:
        # BK72xx uses ARM968E-S (ARMv5TE) which lacks LDREX/STREX.
        # std::atomic RMW operations would require libatomic (not linked to save
        # 4-8KB flash). Even if linked, it would use locks, so explicit FreeRTOS
        # mutexes are simpler and equivalent.
        cg.add_define(ThreadModel.MULTI_NO_ATOMICS)

    # RTL8710B needs FreeRTOS 8.2.3+ for xTaskNotifyGive/ulTaskNotifyTake
    # required by AsyncTCP 3.4.3+ (https://github.com/esphome/esphome/issues/10220)
    # RTL8720C (ambz2) requires FreeRTOS 10.x so this only applies to RTL8710B
    if config[CONF_FAMILY] == FAMILY_RTL8710B:
        cg.add_platformio_option("custom_versions.freertos", "8.2.3")

    # force using arduino framework
    cg.add_platformio_option("framework", "arduino")
    cg.add_build_flag("-DUSE_ARDUINO")
    cg.set_cpp_standard("gnu++20")

    # disable library compatibility checks
    cg.add_platformio_option("lib_ldf_mode", "off")
    cg.add_platformio_option("lib_compat_mode", "soft")
    # include <Arduino.h> in every file
    build_src_flags = "-include Arduino.h"
    if FAMILY_COMPONENT[config[CONF_FAMILY]] == COMPONENT_BK72XX:
        # LibreTiny forces -O1 globally for BK72xx because the Beken SDK
        # has issues with higher optimization levels. However, ESPHome code
        # works fine with -Os (used on every other platform), so override
        # it for project source files only. GCC uses the last -O flag.
        build_src_flags += " -Os"
    cg.add_platformio_option("build_src_flags", build_src_flags)
    # dummy version code
    cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)"))
    # decrease web server stack size (16k words -> 4k words)
    cg.add_build_flag("-DCONFIG_ASYNC_TCP_STACK_SIZE=4096")

    # build framework version
    # if platform version is a valid version constraint, prefix the default package
    framework = config[CONF_FRAMEWORK]
    cv.platformio_version_constraint(framework[CONF_VERSION])
    if framework[CONF_SOURCE]:
        cg.add_platformio_option("platform", framework[CONF_SOURCE])
    elif str(framework[CONF_VERSION]) != "0.0.0":
        cg.add_platformio_option("platform", f"libretiny @ {framework[CONF_VERSION]}")
    else:
        cg.add_platformio_option("platform", "libretiny")

    # apply LibreTiny options from framework: block
    # setup LT logger to work nicely with ESPHome logger
    lt_options = dict(
        LT_LOGLEVEL="LT_LEVEL_" + framework[CONF_LOGLEVEL],
        LT_LOGGER_CALLER=0,
        LT_LOGGER_TASK=0,
        LT_LOGGER_COLOR=1,
        LT_USE_TIME=1,
    )
    # enable/disable per-module debugging
    for module in framework[CONF_DEBUG]:
        if module == "NONE":
            # disable all modules
            for module in LT_DEBUG_MODULES:
                lt_options[f"LT_DEBUG_{module}"] = 0
        else:
            # enable one module
            lt_options[f"LT_DEBUG_{module}"] = 1
    # set SDK silencing mode
    if framework[CONF_SDK_SILENT] == "all":
        lt_options["LT_UART_SILENT_ENABLED"] = 1
        lt_options["LT_UART_SILENT_ALL"] = 1
    elif framework[CONF_SDK_SILENT] == "auto":
        lt_options["LT_UART_SILENT_ENABLED"] = 1
        lt_options["LT_UART_SILENT_ALL"] = 0
    else:
        lt_options["LT_UART_SILENT_ENABLED"] = 0
        lt_options["LT_UART_SILENT_ALL"] = 0
    # set default UART port
    if (uart_port := framework.get(CONF_UART_PORT, None)) is not None:
        lt_options["LT_UART_DEFAULT_PORT"] = uart_port
    # add custom options
    lt_options.update(framework[CONF_OPTIONS])

    # apply ESPHome options from framework: block
    cg.add_define("LT_GPIO_RECOVER", int(framework[CONF_GPIO_RECOVER]))

    # build PlatformIO compiler flags
    for name, value in sorted(lt_options.items()):
        cg.add_build_flag(f"-D{name}={value}")

    # custom output firmware name and version
    if CONF_PROJECT in config:
        cg.add_platformio_option(
            "custom_fw_name", "esphome." + config[CONF_PROJECT][CONF_NAME]
        )
        cg.add_platformio_option(
            "custom_fw_version", config[CONF_PROJECT][CONF_VERSION]
        )
    else:
        cg.add_platformio_option("custom_fw_name", "esphome")
        cg.add_platformio_option("custom_fw_version", __version__)

    # Apply chip-specific SDK options to save RAM/Flash
    if config[CONF_FAMILY] == FAMILY_BK7231N:
        cg.add_platformio_option(
            "custom_options.sys_config#h", _BK7231N_SYS_CONFIG_OPTIONS
        )

    # Tune lwIP for ESPHome's actual needs.
    # The SDK defaults (TCP_SND_BUF=10*MSS, MAX_SOCKETS_TCP=12, MEM_SIZE=32KB)
    # are wildly oversized for an IoT device. ESPAsyncWebServer allocates
    # malloc(tcp_sndbuf()) per response chunk — at 14.6KB this causes silent
    # OOM on memory-constrained chips like BK7231N.
    # See https://github.com/esphome/esphome/issues/14095
    _configure_lwip(config)

    await cg.register_component(var, config)
