from __future__ import annotations

import asyncio
import logging
from pathlib import Path
import re
import shutil
import subprocess

from esphome import pins
import esphome.codegen as cg
from esphome.components.zephyr import (
    add_extra_script,
    copy_files as zephyr_copy_files,
    zephyr_add_overlay,
    zephyr_add_pm_static,
    zephyr_add_prj_conf,
    zephyr_data,
    zephyr_set_core_data,
    zephyr_setup_preferences,
    zephyr_to_code,
)
from esphome.components.zephyr.const import (
    BOOTLOADER_MCUBOOT,
    CONF_CDC_ACM,
    KEY_BOARD,
    KEY_BOOTLOADER,
    KEY_ZEPHYR,
    CdcAcm,
)
import esphome.config_validation as cv
from esphome.const import (
    CONF_ADVANCED,
    CONF_BOARD,
    CONF_DISABLED,
    CONF_ENABLE_OTA_ROLLBACK,
    CONF_FRAMEWORK,
    CONF_ID,
    CONF_OTA,
    CONF_RESET_PIN,
    CONF_SAFE_MODE,
    CONF_TOOLCHAIN,
    CONF_VERSION,
    CONF_VOLTAGE,
    KEY_CORE,
    KEY_FRAMEWORK_VERSION,
    KEY_TARGET_FRAMEWORK,
    KEY_TARGET_PLATFORM,
    PLATFORM_NRF52,
    ThreadModel,
    Toolchain,
)
from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority
from esphome.core.config import BOARD_MAX_LENGTH
import esphome.final_validate as fv
from esphome.framework_helpers import (
    get_project_compile_flags,
    get_project_link_flags,
    run_command_ok,
)
from esphome.helpers import rmtree, write_file_if_changed
from esphome.storage_json import StorageJSON
from esphome.types import ConfigType

from .boards import BOARDS_ZEPHYR, BOOTLOADER_CONFIG
from .const import (
    BOOTLOADER_ADAFRUIT,
    BOOTLOADER_ADAFRUIT_NRF52_SD132,
    BOOTLOADER_ADAFRUIT_NRF52_SD140_V6,
    BOOTLOADER_ADAFRUIT_NRF52_SD140_V7,
)
from .framework import (
    check_and_install,
    get_build_env,
    get_build_paths,
    setup_platformio_python_env,
)

# force import gpio to register pin schema
from .gpio import nrf52_pin_to_code  # noqa: F401

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

# Default framework versions per toolchain. The sdk-nrf one also keys the CI
# sdk-nrf install cache and pins the clang-tidy project's SDK.
RECOMMENDED_PLATFORMIO_VERSION = "2.6.1-b"
RECOMMENDED_SDK_NRF_VERSION = "2.9.2"

FAKE_BOARD_MANIFEST = """
{
    "frameworks": [
        "zephyr"
    ],
    "name": "esphome nrf52",
    "upload": {
        "maximum_ram_size": 248832,
        "maximum_size": 815104,
        "speed": 115200
    },
    "url": "https://esphome.io/",
    "vendor": "esphome",
    "build": {
        "bsp": {
            "name": "adafruit"
        },
        "softdevice": {
            "sd_fwid": "0x00B6"
        }
    }
}
"""


def set_core_data(config: ConfigType) -> ConfigType:
    zephyr_set_core_data(config)
    CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_NRF52
    CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR

    if config[KEY_BOOTLOADER] in BOOTLOADER_CONFIG:
        zephyr_add_pm_static(BOOTLOADER_CONFIG[config[KEY_BOOTLOADER]])

    return config


def _resolve_toolchain(config: ConfigType) -> ConfigType:
    if CORE.toolchain is None:
        CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.SDK_NRF)
    return config


def set_framework(config: ConfigType) -> ConfigType:
    if CONF_VERSION not in config[CONF_FRAMEWORK]:
        default_version = (
            RECOMMENDED_PLATFORMIO_VERSION
            if CORE.using_toolchain_platformio
            else RECOMMENDED_SDK_NRF_VERSION
        )
        config = {
            **config,
            CONF_FRAMEWORK: {**config[CONF_FRAMEWORK], CONF_VERSION: default_version},
        }
    framework_ver = cv.Version.parse(
        cv.version_number(config[CONF_FRAMEWORK][CONF_VERSION])
    )
    CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = framework_ver
    if not CORE.using_toolchain_platformio:
        return config
    if framework_ver < cv.Version(2, 9, 2):
        return cv.require_framework_version(
            nrf52_zephyr=cv.Version(2, 6, 1, "a"),
        )(config)
    if framework_ver < cv.Version(3, 2, 0):
        return cv.require_framework_version(
            nrf52_zephyr=cv.Version(2, 9, 2, "2"),
        )(config)
    return cv.require_framework_version(
        nrf52_zephyr=cv.Version(3, 2, 0, "1"),
    )(config)


BOOTLOADERS = [
    BOOTLOADER_ADAFRUIT,
    BOOTLOADER_ADAFRUIT_NRF52_SD132,
    BOOTLOADER_ADAFRUIT_NRF52_SD140_V6,
    BOOTLOADER_ADAFRUIT_NRF52_SD140_V7,
    BOOTLOADER_MCUBOOT,
]


def _validate_toolchain(value) -> Toolchain:
    return Toolchain(
        cv.one_of(Toolchain.PLATFORMIO, Toolchain.SDK_NRF, lower=True)(value)
    )


def _detect_bootloader(config: ConfigType) -> ConfigType:
    """Detect the bootloader for the given board."""
    config = config.copy()
    bootloaders: list[str] = []
    board = config[CONF_BOARD]

    if board in BOARDS_ZEPHYR and KEY_BOOTLOADER in BOARDS_ZEPHYR[board]:
        # this board have bootloaders config available
        bootloaders = BOARDS_ZEPHYR[board][KEY_BOOTLOADER]

    if KEY_BOOTLOADER not in config:
        if bootloaders:
            # there is no bootloader in config -> take first one
            config[KEY_BOOTLOADER] = bootloaders[0]
        else:
            # make mcuboot as default if there is no configuration for that board
            config[KEY_BOOTLOADER] = BOOTLOADER_MCUBOOT
    elif bootloaders and config[KEY_BOOTLOADER] not in bootloaders:
        raise cv.Invalid(
            f"{board} does not support {config[KEY_BOOTLOADER]}, select one of: {', '.join(bootloaders)}"
        )
    return config


nrf52_ns = cg.esphome_ns.namespace("nrf52")
DeviceFirmwareUpdate = nrf52_ns.class_("DeviceFirmwareUpdate", cg.Component)

CONF_DFU = "dfu"
CONF_DCDC = "dcdc"
CONF_LIBC_NANO = "libc_nano"
CONF_REG0 = "reg0"
CONF_UICR_ERASE = "uicr_erase"

VOLTAGE_LEVELS = [1.8, 2.1, 2.4, 2.7, 3.0, 3.3]


_DFU_SCHEMA = cv.Schema(
    {
        cv.GenerateID(): cv.declare_id(DeviceFirmwareUpdate),
        cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema,
    }
)


def _dfu_schema(value: bool | ConfigType) -> ConfigType:
    if isinstance(value, bool):
        if not value:
            raise cv.Invalid("Use 'dfu: true' or specify a configuration dict")
        return _DFU_SCHEMA({})
    return _DFU_SCHEMA(value)


CONFIG_SCHEMA = cv.All(
    _detect_bootloader,
    set_core_data,
    cv.Schema(
        {
            cv.Required(CONF_BOARD): cv.All(
                cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH)
            ),
            cv.Optional(KEY_BOOTLOADER): cv.one_of(*BOOTLOADERS, lower=True),
            cv.Optional(CONF_DFU): _dfu_schema,
            cv.Optional(CONF_DCDC): cv.boolean,
            cv.Optional(CONF_REG0): cv.Schema(
                {
                    cv.Required(CONF_VOLTAGE): cv.All(
                        cv.voltage,
                        cv.one_of(*VOLTAGE_LEVELS, float=True),
                    ),
                    cv.Optional(CONF_UICR_ERASE, default=False): cv.boolean,
                }
            ),
            cv.Optional(
                CONF_FRAMEWORK,
                default={},
            ): cv.Schema(
                {
                    cv.Optional(CONF_VERSION): cv.string_strict,
                    cv.Optional(CONF_LIBC_NANO, default=True): cv.boolean,
                    cv.Optional(
                        CONF_ADVANCED, default={}, visibility=cv.Visibility.YAML_ONLY
                    ): cv.Schema(
                        {
                            cv.Optional(
                                CONF_ENABLE_OTA_ROLLBACK, default=True
                            ): cv.boolean,
                        }
                    ),
                }
            ),
            cv.Optional(CONF_TOOLCHAIN): _validate_toolchain,
            cv.GenerateID(CONF_CDC_ACM): cv.declare_id(CdcAcm),
        }
    ),
    _resolve_toolchain,
    set_framework,
)


def _validate_mcumgr(config):
    bootloader = zephyr_data()[KEY_BOOTLOADER]
    if bootloader == BOOTLOADER_MCUBOOT:
        raise cv.Invalid(f"'{bootloader}' bootloader does not support DFU")


def _final_validate(config):

    if CONF_DFU in config:
        _validate_mcumgr(config)
    if config[KEY_BOOTLOADER] == BOOTLOADER_ADAFRUIT:
        _LOGGER.warning(
            "Selected generic Adafruit bootloader. The board might crash. Consider settings `bootloader:`"
        )
    full_config = fv.full_config.get()
    conf = config[CONF_FRAMEWORK]
    advanced = conf[CONF_ADVANCED]

    if conf[CONF_LIBC_NANO] and "logger" in CORE.loaded_integrations:
        _LOGGER.warning(
            "Logger is enabled with newlib-nano (libc_nano: true). Some format specifiers "
            "such as %%zu are not supported and will print incorrectly. "
            "Set 'libc_nano: false' under 'framework:' to use the full newlib."
        )

    if advanced[CONF_ENABLE_OTA_ROLLBACK]:
        # "disabled: false" means safe mode *is* enabled.
        safe_mode_config = full_config.get(CONF_SAFE_MODE, {CONF_DISABLED: True})
        safe_mode_enabled = not safe_mode_config[CONF_DISABLED]
        ota_enabled = CONF_OTA in full_config
        # Both need to be enabled for rollback to work
        if not (ota_enabled and safe_mode_enabled):
            # But only warn if ota is even possible
            if ota_enabled:
                _LOGGER.warning(
                    "OTA rollback requires safe_mode, disabling rollback support"
                )
            # disable the rollback feature anyway since it can't be used.
            advanced[CONF_ENABLE_OTA_ROLLBACK] = False


FINAL_VALIDATE_SCHEMA = _final_validate


@coroutine_with_priority(CoroPriority.PLATFORM)
async def to_code(config: ConfigType) -> None:
    """Convert the configuration to code."""
    cg.add_build_flag("-DUSE_NRF52")
    cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
    cg.add_define("ESPHOME_VARIANT", "NRF52")
    # nRF52 processors are single-core
    cg.add_define(ThreadModel.SINGLE)
    if CORE.using_toolchain_platformio:
        cg.add_platformio_option("board", config[CONF_BOARD])
        cg.add_platformio_option(
            CONF_FRAMEWORK, CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK]
        )
        cg.add_platformio_option(
            "platform",
            "https://github.com/tomaszduda23/platform-nordicnrf52/archive/refs/tags/v10.3.0-5.zip",
        )
        cg.add_platformio_option(
            "platform_packages",
            [
                f"platformio/framework-zephyr@https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v{CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]}.zip",
            ],
        )
        if config[KEY_BOOTLOADER] != BOOTLOADER_MCUBOOT:
            # make sure that firmware.zip is created
            # for Adafruit_nRF52_Bootloader
            cg.add_platformio_option("board_upload.protocol", "nrfutil")
            cg.add_platformio_option("board_upload.use_1200bps_touch", "true")
            cg.add_platformio_option("board_upload.require_upload_port", "true")
            cg.add_platformio_option("board_upload.wait_for_upload_port", "true")

        add_extra_script(
            "pre",
            "pre_build.py",
            Path(__file__).parent / "pre_build.py.script",
        )
        # build is done by west so bypass board checking in platformio
        cg.add_platformio_option("boards_dir", CORE.relative_build_path("boards"))

    if config[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT:
        cg.add_define("USE_BOOTLOADER_MCUBOOT")
    elif "_sd" in config[KEY_BOOTLOADER]:
        bootloader = config[KEY_BOOTLOADER].split("_")
        sd_id = bootloader[2][2:]
        cg.add_define("USE_SOFTDEVICE_ID", int(sd_id))
        if (len(bootloader)) > 3:
            sd_version = bootloader[3][1:]
            cg.add_define("USE_SOFTDEVICE_VERSION", int(sd_version))

    zephyr_setup_preferences()
    zephyr_to_code(config)

    if dfu_config := config.get(CONF_DFU):
        CORE.add_job(_dfu_to_code, dfu_config)
    framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
    if CONF_DCDC in config:
        if framework_ver < cv.Version(2, 9, 2):
            zephyr_add_prj_conf("BOARD_ENABLE_DCDC", config[CONF_DCDC])
        else:
            zephyr_add_overlay(
                f"""
                    &reg1 {{
                        regulator-initial-mode = <{"NRF5X_REG_MODE_DCDC" if config[CONF_DCDC] else "NRF5X_REG_MODE_LDO"}>;
                    }};
                """
            )

    if reg0_config := config.get(CONF_REG0):
        value = VOLTAGE_LEVELS.index(reg0_config[CONF_VOLTAGE])
        cg.add_define("USE_NRF52_REG0_VOUT", value)
        if reg0_config[CONF_UICR_ERASE]:
            cg.add_define("USE_NRF52_UICR_ERASE")

    conf = config[CONF_FRAMEWORK]
    advanced = conf[CONF_ADVANCED]
    # Enable OTA rollback support
    if advanced[CONF_ENABLE_OTA_ROLLBACK]:
        cg.add_define("USE_OTA_ROLLBACK")
    zephyr_add_prj_conf("NEWLIB_LIBC", True)
    zephyr_add_prj_conf("NEWLIB_LIBC_FLOAT_PRINTF", True)
    zephyr_add_prj_conf("NEWLIB_LIBC_NANO", conf[CONF_LIBC_NANO])
    # c++ support
    if framework_ver < cv.Version(2, 9, 2):
        zephyr_add_prj_conf("CPLUSPLUS", True)
        zephyr_add_prj_conf("LIB_CPLUSPLUS", True)
    else:
        zephyr_add_prj_conf("CPP", True)
        zephyr_add_prj_conf("REQUIRES_FULL_LIBCPP", True)
    # watchdog
    zephyr_add_prj_conf("WATCHDOG", True)
    zephyr_add_prj_conf("WDT_DISABLE_AT_BOOT", False)
    # disable console
    zephyr_add_prj_conf("UART_CONSOLE", False)
    zephyr_add_prj_conf("CONSOLE", False, False)
    # use NFC pins as GPIO
    if framework_ver < cv.Version(2, 9, 2):
        zephyr_add_prj_conf("NFCT_PINS_AS_GPIOS", True)
    else:
        zephyr_add_overlay(
            """
                &uicr {
                    nfct-pins-as-gpios;
                };
            """
        )
    zephyr_add_prj_conf("REBOOT", True)


@coroutine_with_priority(CoroPriority.DIAGNOSTICS)
async def _dfu_to_code(dfu_config):
    cg.add_define("USE_NRF52_DFU")
    var = cg.new_Pvariable(dfu_config[CONF_ID])
    if CONF_RESET_PIN in dfu_config:
        pin = await cg.gpio_pin_expression(dfu_config[CONF_RESET_PIN])
        cg.add(var.set_reset_pin(pin))
    zephyr_add_prj_conf("CDC_ACM_DTE_RATE_CALLBACK_SUPPORT", True)
    await cg.register_component(var, dfu_config)


def copy_files() -> None:
    """Copy files to the build directory."""

    # Library conversion to Zephyr modules is wired into the sdk-nrf
    # CMakeLists only; the PlatformIO toolchain's forked platform package
    # cannot compile external libraries at all, so the build would fail at
    # link time anyway. Fail fast with a clear message instead.
    if CORE.using_toolchain_platformio and CORE.platformio_libraries:
        raise EsphomeError(
            f"Libraries ({', '.join(sorted(CORE.platformio_libraries))}) are "
            "not supported on the nRF52 'platformio' toolchain; use toolchain "
            "'sdk-nrf' to build them as Zephyr modules."
        )

    if CORE.using_toolchain_platformio and (
        zephyr_data()[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT
        or zephyr_data()[KEY_BOARD] == "xiao_ble"
    ):
        write_file_if_changed(
            CORE.relative_build_path(f"boards/{zephyr_data()[KEY_BOARD]}.json"),
            FAKE_BOARD_MANIFEST,
        )

    zephyr_copy_files()


def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]:
    """Get the download types for the firmware."""
    types = []
    UF2_PATH = "zephyr/zephyr.uf2"
    DFU_PATH = "firmware.zip"
    HEX_PATH = "zephyr/zephyr.hex"  # SDK 2.6.1, only generated when OTA is disabled
    HEX_MERGED_PATH = "zephyr/merged.hex"  # SDK 2.9.2, always generated
    APP_IMAGE_PATH = "zephyr/app_update.bin"
    build_dir = Path(storage_json.firmware_bin_path).parent
    if (build_dir / UF2_PATH).is_file():
        types = [
            {
                "title": "UF2 package (recommended)",
                "description": "For flashing via Adafruit nRF52 Bootloader as a flash drive.",
                "file": UF2_PATH,
                "download": f"{storage_json.name}.uf2",
            },
            {
                "title": "DFU package",
                "description": "For flashing via adafruit-nrfutil using USB CDC.",
                "file": DFU_PATH,
                "download": f"dfu-{storage_json.name}.zip",
            },
        ]
    else:
        types = [
            {
                "title": "HEX package",
                "description": "For flashing via pyocd using SWD.",
                "file": (
                    HEX_MERGED_PATH
                    if (build_dir / HEX_MERGED_PATH).is_file()
                    else HEX_PATH
                ),
                "download": f"{storage_json.name}.hex",
            },
        ]
        if (build_dir / APP_IMAGE_PATH).is_file():
            types += [
                {
                    "title": "App update package",
                    "description": "For flashing via mcumgr-web using BLE or smpclient using USB CDC.",
                    "file": APP_IMAGE_PATH,
                    "download": f"app-{storage_json.name}.img",
                },
            ]

    return types


def _upload_using_platformio(
    config: ConfigType, port: str, upload_args: list[str]
) -> int | str:
    from esphome.platformio import toolchain

    setup_platformio_python_env()
    if port is not None:
        upload_args += ["--upload-port", port]
    return toolchain.run_platformio_cli_run(config, CORE.verbose, *upload_args)


def upload_program(config: ConfigType, args, host: str) -> bool:
    from esphome.__main__ import check_permissions
    from esphome.upload_targets import PortType, get_port_type

    if KEY_ZEPHYR not in CORE.data:
        platform_config = config.get(CORE.target_platform)
        if not platform_config:
            raise EsphomeError(
                "nRF52 platform configuration is missing; "
                "please re-validate and recompile."
            )
        set_core_data(platform_config)
        set_framework(platform_config)

    mcumgr_device: str | None = None

    if get_port_type(host) == PortType.SERIAL:
        check_permissions(host)
        if zephyr_data()[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT:
            mcumgr_device = host
        else:
            if not CORE.using_toolchain_platformio:
                bootloader = zephyr_data()[KEY_BOOTLOADER]
                if bootloader not in (
                    BOOTLOADER_ADAFRUIT,
                    BOOTLOADER_ADAFRUIT_NRF52_SD132,
                    BOOTLOADER_ADAFRUIT_NRF52_SD140_V6,
                    BOOTLOADER_ADAFRUIT_NRF52_SD140_V7,
                ):
                    raise EsphomeError("Not implemented yet")
                check_and_install()
                paths = get_build_paths()
                env = get_build_env()
                build_dir = CORE.relative_pioenvs_path(CORE.name)
                dfu_package = build_dir / "firmware.zip"
                if not dfu_package.is_file():
                    raise EsphomeError("Firmware not found. Please compile first.")
                import time as _time

                import serial as _serial
                import serial.tools.list_ports as _list_ports

                try:
                    ser = _serial.Serial(host, baudrate=1200, timeout=1)
                    ser.close()
                except _serial.SerialException as err:
                    raise EsphomeError(f"Failed to open {host}: {err}") from err

                # Wait for device to reset (port disappears)
                deadline = _time.monotonic() + 5
                while _time.monotonic() < deadline:
                    _time.sleep(0.1)
                    if host not in {p.device for p in _list_ports.comports()}:
                        break
                else:
                    _LOGGER.warning(
                        "Device did not leave %s within 5 s; "
                        "it may not have entered bootloader mode",
                        host,
                    )

                # Wait for DFU port to reappear
                deadline = _time.monotonic() + 10
                while _time.monotonic() < deadline:
                    _time.sleep(0.1)
                    if host in {p.device for p in _list_ports.comports()}:
                        break
                else:
                    raise EsphomeError(
                        f"DFU port {host!r} did not reappear within 10 s. "
                        "Check that the device entered DFU mode."
                    )

                # Wait for udev to finish setting up device permissions
                deadline = _time.monotonic() + 5
                while _time.monotonic() < deadline:
                    try:
                        check_permissions(host)
                        break
                    except EsphomeError:
                        _time.sleep(0.05)
                else:
                    check_permissions(host)  # raises with helpful message

                python = str(paths["python_executable"])
                if not run_command_ok(
                    [
                        python,
                        "-m",
                        "nordicsemi.__main__",
                        "dfu",
                        "serial",
                        "-pkg",
                        str(dfu_package),
                        "-p",
                        host,
                        "-b",
                        "115200",
                        "--singlebank",
                    ],
                    env=env,
                    stream_output=True,
                ):
                    raise EsphomeError("nRF52 serial DFU upload failed")
            else:
                result = _upload_using_platformio(config, host, ["-t", "upload"])
                if result != 0:
                    raise EsphomeError(f"Upload failed with result: {result}")
            return True  # Handled: serial upload

    if host == "PYOCD":
        if not CORE.using_toolchain_platformio:
            check_and_install()
            paths = get_build_paths()
            env = get_build_env()
            build_dir = CORE.relative_pioenvs_path(CORE.name)
            west_cmd = [
                str(paths["python_executable"]),
                "-m",
                "west",
                "flash",
                "--runner",
                "pyocd",
                "-d",
                str(build_dir),
            ]
            if not run_command_ok(
                west_cmd,
                env=env,
                stream_output=True,
                cwd=str(paths["framework_path"]),
            ):
                raise EsphomeError("nRF52 pyocd flash failed")
        else:
            result = _upload_using_platformio(config, host, ["-t", "flash_pyocd"])
            if result != 0:
                raise EsphomeError(f"Upload failed with result: {result}")
        return True  # Handled: PYOCD upload

    # Deferred imports: bleak/smpclient are heavy, only load for BLE/mcumgr paths
    from .ble_logger import is_mac_address
    from .ota import smpmgr_scan, smpmgr_upload

    if host == "BLE":
        mcumgr_device = asyncio.run(smpmgr_scan(CORE.name))

    if is_mac_address(host):
        mcumgr_device = host

    if mcumgr_device:
        firmware = Path(
            CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin")
        ).resolve()
        asyncio.run(smpmgr_upload(mcumgr_device, firmware))
        return True  # Handled: mcumgr OTA upload

    return False  # Not handled: let caller try default upload methods


def show_logs(config: ConfigType, args, devices: list[str]) -> bool:
    address = devices[0]
    from .ble_logger import is_mac_address, logger_connect, logger_scan

    if devices[0] == "BLE":
        ble_device = asyncio.run(logger_scan(CORE.name))
        if ble_device:
            address = ble_device.address
        else:
            return True

    if is_mac_address(address):
        asyncio.run(logger_connect(address))
        return True
    return False


def _addr2line(addr2line: str, elf: Path, addr: str) -> str:
    try:
        result = subprocess.run(
            [addr2line, "-e", elf, addr],
            capture_output=True,
            text=True,
            check=True,
        )
        return result.stdout.strip().splitlines()[0]
    except Exception as err:  # noqa: BLE001  # pylint: disable=broad-except
        _LOGGER.error("Running command failed: %s", err)
    return ""


def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> bool:
    if "Last crash:" in line:
        return True
    if backtrace_state:
        match = re.search(r"PC=(0x[0-9a-fA-F]+)\s+LR=(0x[0-9a-fA-F]+)", line)
        if match:
            pc = match.group(1)
            lr = match.group(2)
            from esphome.analyze_memory.toolchain import find_tool

            addr2line = find_tool("addr2line")
            if addr2line is None:
                return False

            candidates = [
                CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr", "zephyr.elf"),
                CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr.elf"),
                CORE.relative_pioenvs_path(CORE.name, "firmware.elf"),
            ]

            elf = next((path for path in candidates if path.exists()), None)

            if elf is None:
                _LOGGER.warning(
                    "None of the expected ELF files exist:\n%s",
                    "\n".join(str(p) for p in candidates),
                )
                return False

            _LOGGER.error("=== CRASH ===")
            _LOGGER.error("PC: %s", _addr2line(addr2line, elf, pc))
            _LOGGER.error("LR: %s", _addr2line(addr2line, elf, lr))

    return False


def _generate_cmake_lists() -> bool:
    """Write the project CMakeLists.txt, returning True if it changed."""
    compile_flags = get_project_compile_flags()
    link_flags = get_project_link_flags()

    # Convert any PlatformIO libraries added via cg.add_library() into Zephyr
    # modules and discover them through EXTRA_ZEPHYR_MODULES (a CMake list, set
    # before find_package(Zephyr) so the modules are picked up). Only
    # framework-agnostic libraries actually compile under Zephyr.
    from esphome.components.zephyr.library import generate_zephyr_modules

    module_dirs = generate_zephyr_modules(list(CORE.platformio_libraries.values()))

    lines = [
        "cmake_minimum_required(VERSION 3.20.0)",
        "",
        'set(Zephyr_DIR "$ENV{ZEPHYR_BASE}/share/zephyr-package/cmake/")',
        "",
    ]

    if module_dirs:
        modules = ";".join(str(d).replace("\\", "/") for d in module_dirs)
        lines += [f'set(EXTRA_ZEPHYR_MODULES "{modules}")', ""]

    lines += [
        "find_package(Zephyr REQUIRED)",
        "",
        f"project({CORE.name})",
        "",
        'file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_LIST_DIR}/../src/*.cpp" "${CMAKE_CURRENT_LIST_DIR}/../src/*.c")',
        "",
        "target_sources(app PRIVATE ${APP_SOURCES})",
        'target_include_directories(app PRIVATE "${CMAKE_CURRENT_LIST_DIR}/../src")',
    ]

    if compile_flags:
        lines += [
            "",
            "target_compile_options(app PRIVATE",
            *[f'  "{flag}"' for flag in compile_flags],
            ")",
        ]

    if link_flags:
        lines += [
            "",
            "zephyr_ld_options(",
            *[f'  "{flag}"' for flag in link_flags],
            ")",
        ]

    return write_file_if_changed(
        CORE.relative_build_path("zephyr", "CMakeLists.txt"),
        "\n".join(lines) + "\n",
    )


def _copy_if_exists(src: Path, dst: Path) -> None:
    if src.is_file():
        shutil.copy2(src, dst)


def run_compile(args, config: ConfigType) -> bool:
    if CORE.using_toolchain_platformio:
        # The actual build is done by PlatformIO (the caller falls through to
        # it when this returns False); prepare the Python environment its
        # Zephyr build script expects first.
        setup_platformio_python_env()
        return False
    if not CORE.using_toolchain_sdk_nrf:
        raise EsphomeError(
            "Unsupported toolchain for nRF52. "
            "Supported toolchains are 'platformio' and 'sdk-nrf'."
        )
    check_and_install()

    paths = get_build_paths()
    env = get_build_env()

    cmake_lists_changed = _generate_cmake_lists()

    board = zephyr_data()[KEY_BOARD]
    build_dir = CORE.relative_pioenvs_path(CORE.name)
    source_dir = CORE.relative_build_path("zephyr")

    # A missing CMake cache (dropped by zephyr's copy_files() on config
    # change) or a changed CMakeLists.txt requires a pristine build: Zephyr
    # caches Kconfig/devicetree state that survives a plain cmake re-run.
    # West can't do the wipe — its pristine modes only recognize a build dir
    # by reading ZEPHYR_BASE from the very cache that was dropped.
    if (
        cmake_lists_changed or not (build_dir / "CMakeCache.txt").is_file()
    ) and build_dir.is_dir():
        _LOGGER.info("Build inputs changed, cleaning %s", build_dir)
        rmtree(build_dir)

    west_cmd = [
        str(paths["python_executable"]),
        "-m",
        "west",
        "build",
        "--pristine=auto",
        "-b",
        board,
        "-d",
        str(build_dir),
        str(source_dir),
    ]

    if not run_command_ok(
        west_cmd,
        env=env,
        stream_output=True,
        cwd=str(paths["framework_path"]),
    ):
        raise EsphomeError("nRF52 native build failed")

    zephyr_dir = build_dir / "zephyr"
    framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
    # SDK < 2.9.2 places artifacts directly in build_dir/zephyr/.
    # SDK >= 2.9.2 nests them one level deeper (build_dir/zephyr/zephyr/);
    # copy files to match get_download_types layout.
    if framework_ver < cv.Version(2, 9, 2):
        west_out = zephyr_dir
    else:
        west_out = zephyr_dir / "zephyr"
        _copy_if_exists(west_out / "zephyr.uf2", zephyr_dir / "zephyr.uf2")
        _copy_if_exists(west_out / "zephyr.signed.bin", zephyr_dir / "app_update.bin")
        _copy_if_exists(build_dir / "merged.hex", zephyr_dir / "merged.hex")

    # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes
    _GENPKG_PARAMS = {
        BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"),
        BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"),
        BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"),
    }
    bootloader = zephyr_data()[KEY_BOOTLOADER]
    if bootloader in (
        BOOTLOADER_ADAFRUIT,
        BOOTLOADER_ADAFRUIT_NRF52_SD132,
        BOOTLOADER_ADAFRUIT_NRF52_SD140_V6,
        BOOTLOADER_ADAFRUIT_NRF52_SD140_V7,
    ):
        hex_file = west_out / "zephyr.hex"
        dfu_package = build_dir / "firmware.zip"
        genpkg_cmd = [
            str(paths["python_executable"]),
            "-m",
            "nordicsemi.__main__",
            "dfu",
            "genpkg",
        ]
        if bootloader in _GENPKG_PARAMS:
            dev_type, sd_req = _GENPKG_PARAMS[bootloader]
            genpkg_cmd += ["--dev-type", dev_type, "--sd-req", sd_req]
        genpkg_cmd += ["--application", str(hex_file), str(dfu_package)]
        if not run_command_ok(genpkg_cmd, env=env, stream_output=True):
            raise EsphomeError("Failed to create adafruit DFU package")

    return True
