"""Unit tests for esphome.__main__ module."""

from __future__ import annotations

from collections.abc import Generator
from dataclasses import dataclass
import logging
from pathlib import Path
import re
from typing import Any
from unittest.mock import MagicMock, Mock, patch

import pytest
from pytest import CaptureFixture

from esphome import platformio_api
from esphome.__main__ import (
    Purpose,
    choose_upload_log_host,
    command_analyze_memory,
    command_clean_all,
    command_rename,
    command_update_all,
    command_wizard,
    detect_external_components,
    get_port_type,
    has_ip_address,
    has_mqtt,
    has_mqtt_ip_lookup,
    has_mqtt_logging,
    has_non_ip_address,
    has_resolvable_address,
    mqtt_get_ip,
    show_logs,
    upload_program,
    upload_using_esptool,
)
from esphome.components.esp32.const import KEY_ESP32, KEY_VARIANT, VARIANT_ESP32
from esphome.const import (
    CONF_API,
    CONF_BROKER,
    CONF_DISABLED,
    CONF_ESPHOME,
    CONF_LEVEL,
    CONF_LOG_TOPIC,
    CONF_MDNS,
    CONF_MQTT,
    CONF_NAME,
    CONF_OTA,
    CONF_PASSWORD,
    CONF_PLATFORM,
    CONF_PORT,
    CONF_SUBSTITUTIONS,
    CONF_TOPIC,
    CONF_USE_ADDRESS,
    CONF_WIFI,
    KEY_CORE,
    KEY_TARGET_PLATFORM,
    PLATFORM_BK72XX,
    PLATFORM_ESP32,
    PLATFORM_ESP8266,
    PLATFORM_RP2040,
)
from esphome.core import CORE, EsphomeError


def strip_ansi_codes(text: str) -> str:
    """Remove ANSI escape codes from text.

    This helps make test assertions cleaner by removing color codes and other
    terminal formatting that can make tests brittle.
    """
    # Pattern to match ANSI escape sequences
    ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
    return ansi_escape.sub("", text)


@dataclass
class MockSerialPort:
    """Mock serial port for testing.

    Attributes:
        path (str): The device path of the mock serial port (e.g., '/dev/ttyUSB0').
        description (str): A human-readable description of the mock serial port.
    """

    path: str
    description: str


def setup_core(
    config: dict[str, Any] | None = None,
    address: str | None = None,
    platform: str | None = None,
    tmp_path: Path | None = None,
    name: str = "test",
) -> None:
    """
    Helper to set up CORE configuration with optional address.

    Args:
        config (dict[str, Any] | None): The configuration dictionary to set for CORE. If None, an empty dict is used.
        address (str | None): Optional network address to set in the configuration. If provided, it is set under the wifi config.
        platform (str | None): Optional target platform to set in CORE.data.
        tmp_path (Path | None): Optional temp path for setting up build paths.
        name (str): The name of the device (defaults to "test").
    """
    if config is None:
        config = {}

    if address is not None:
        # Set address via wifi config (could also use ethernet)
        config[CONF_WIFI] = {CONF_USE_ADDRESS: address}

    CORE.config = config

    if platform is not None:
        CORE.data[KEY_CORE] = {}
        CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform

    if tmp_path is not None:
        CORE.config_path = str(tmp_path / f"{name}.yaml")
        CORE.name = name
        CORE.build_path = str(tmp_path / ".esphome" / "build" / name)


@pytest.fixture
def mock_no_serial_ports() -> Generator[Mock]:
    """Mock get_serial_ports to return no ports."""
    with patch("esphome.__main__.get_serial_ports", return_value=[]) as mock:
        yield mock


@pytest.fixture
def mock_get_port_type() -> Generator[Mock]:
    """Mock get_port_type for testing."""
    with patch("esphome.__main__.get_port_type") as mock:
        yield mock


@pytest.fixture
def mock_check_permissions() -> Generator[Mock]:
    """Mock check_permissions for testing."""
    with patch("esphome.__main__.check_permissions") as mock:
        yield mock


@pytest.fixture
def mock_run_miniterm() -> Generator[Mock]:
    """Mock run_miniterm for testing."""
    with patch("esphome.__main__.run_miniterm") as mock:
        yield mock


@pytest.fixture
def mock_upload_using_esptool() -> Generator[Mock]:
    """Mock upload_using_esptool for testing."""
    with patch("esphome.__main__.upload_using_esptool") as mock:
        yield mock


@pytest.fixture
def mock_upload_using_platformio() -> Generator[Mock]:
    """Mock upload_using_platformio for testing."""
    with patch("esphome.__main__.upload_using_platformio") as mock:
        yield mock


@pytest.fixture
def mock_run_ota() -> Generator[Mock]:
    """Mock espota2.run_ota for testing."""
    with patch("esphome.espota2.run_ota") as mock:
        yield mock


@pytest.fixture
def mock_is_ip_address() -> Generator[Mock]:
    """Mock is_ip_address for testing."""
    with patch("esphome.__main__.is_ip_address") as mock:
        yield mock


@pytest.fixture
def mock_mqtt_get_ip() -> Generator[Mock]:
    """Mock mqtt_get_ip for testing."""
    with patch("esphome.__main__.mqtt_get_ip") as mock:
        yield mock


@pytest.fixture
def mock_serial_ports() -> Generator[Mock]:
    """Mock get_serial_ports to return test ports."""
    mock_ports = [
        MockSerialPort("/dev/ttyUSB0", "USB Serial"),
        MockSerialPort("/dev/ttyUSB1", "Another USB Serial"),
    ]
    with patch("esphome.__main__.get_serial_ports", return_value=mock_ports) as mock:
        yield mock


@pytest.fixture
def mock_choose_prompt() -> Generator[Mock]:
    """Mock choose_prompt to return default selection."""
    with patch("esphome.__main__.choose_prompt", return_value="/dev/ttyUSB0") as mock:
        yield mock


@pytest.fixture
def mock_no_mqtt_logging() -> Generator[Mock]:
    """Mock has_mqtt_logging to return False."""
    with patch("esphome.__main__.has_mqtt_logging", return_value=False) as mock:
        yield mock


@pytest.fixture
def mock_has_mqtt_logging() -> Generator[Mock]:
    """Mock has_mqtt_logging to return True."""
    with patch("esphome.__main__.has_mqtt_logging", return_value=True) as mock:
        yield mock


@pytest.fixture
def mock_run_external_process() -> Generator[Mock]:
    """Mock run_external_process for testing."""
    with patch("esphome.__main__.run_external_process") as mock:
        mock.return_value = 0  # Default to success
        yield mock


@pytest.fixture
def mock_run_external_command_main() -> Generator[Mock]:
    """Mock run_external_command in __main__ module (different from platformio_api)."""
    with patch("esphome.__main__.run_external_command") as mock:
        mock.return_value = 0  # Default to success
        yield mock


@pytest.fixture
def mock_write_cpp() -> Generator[Mock]:
    """Mock write_cpp for testing."""
    with patch("esphome.__main__.write_cpp") as mock:
        mock.return_value = 0  # Default to success
        yield mock


@pytest.fixture
def mock_compile_program() -> Generator[Mock]:
    """Mock compile_program for testing."""
    with patch("esphome.__main__.compile_program") as mock:
        mock.return_value = 0  # Default to success
        yield mock


@pytest.fixture
def mock_get_esphome_components() -> Generator[Mock]:
    """Mock get_esphome_components for testing."""
    with patch("esphome.analyze_memory.helpers.get_esphome_components") as mock:
        mock.return_value = {"logger", "api", "ota"}
        yield mock


@pytest.fixture
def mock_memory_analyzer_cli() -> Generator[Mock]:
    """Mock MemoryAnalyzerCLI for testing."""
    with patch("esphome.analyze_memory.cli.MemoryAnalyzerCLI") as mock_class:
        mock_analyzer = MagicMock()
        mock_analyzer.generate_report.return_value = "Mock Memory Report"
        mock_class.return_value = mock_analyzer
        yield mock_class


def test_choose_upload_log_host_with_string_default() -> None:
    """Test with a single string default device."""
    setup_core()
    result = choose_upload_log_host(
        default="192.168.1.100",
        check_default=None,
        purpose=Purpose.UPLOADING,
    )
    assert result == ["192.168.1.100"]


def test_choose_upload_log_host_with_list_default() -> None:
    """Test with a list of default devices."""
    setup_core()
    result = choose_upload_log_host(
        default=["192.168.1.100", "192.168.1.101"],
        check_default=None,
        purpose=Purpose.UPLOADING,
    )
    assert result == ["192.168.1.100", "192.168.1.101"]


def test_choose_upload_log_host_with_multiple_ip_addresses() -> None:
    """Test with multiple IP addresses as defaults."""
    setup_core()
    result = choose_upload_log_host(
        default=["1.2.3.4", "4.5.5.6"],
        check_default=None,
        purpose=Purpose.LOGGING,
    )
    assert result == ["1.2.3.4", "4.5.5.6"]


def test_choose_upload_log_host_with_mixed_hostnames_and_ips() -> None:
    """Test with a mix of hostnames and IP addresses."""
    setup_core()
    result = choose_upload_log_host(
        default=["host.one", "host.one.local", "1.2.3.4"],
        check_default=None,
        purpose=Purpose.UPLOADING,
    )
    assert result == ["host.one", "host.one.local", "1.2.3.4"]


def test_choose_upload_log_host_with_ota_list() -> None:
    """Test with OTA as the only item in the list."""
    setup_core(config={CONF_OTA: {}}, address="192.168.1.100")

    result = choose_upload_log_host(
        default=["OTA"],
        check_default=None,
        purpose=Purpose.UPLOADING,
    )
    assert result == ["192.168.1.100"]


@pytest.mark.usefixtures("mock_has_mqtt_logging")
def test_choose_upload_log_host_with_ota_list_mqtt_fallback() -> None:
    """Test with OTA list falling back to MQTT when no address."""
    setup_core(config={CONF_OTA: {}, "mqtt": {}})

    result = choose_upload_log_host(
        default=["OTA"],
        check_default=None,
        purpose=Purpose.UPLOADING,
    )
    assert result == ["MQTTIP"]


@pytest.mark.usefixtures("mock_has_mqtt_logging")
def test_choose_upload_log_host_with_ota_list_mqtt_fallback_logging() -> None:
    """Test with OTA list with API and MQTT when no address."""
    setup_core(config={CONF_API: {}, "mqtt": {}})

    result = choose_upload_log_host(
        default=["OTA"],
        check_default=None,
        purpose=Purpose.LOGGING,
    )
    assert result == ["MQTTIP", "MQTT"]


@pytest.mark.usefixtures("mock_no_serial_ports")
def test_choose_upload_log_host_with_serial_device_no_ports(
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Test SERIAL device when no serial ports are found."""
    setup_core()
    with pytest.raises(
        EsphomeError, match="All specified devices .* could not be resolved"
    ):
        choose_upload_log_host(
            default="SERIAL",
            check_default=None,
            purpose=Purpose.UPLOADING,
        )
    assert "No serial ports found, skipping SERIAL device" in caplog.text


@pytest.mark.usefixtures("mock_serial_ports")
def test_choose_upload_log_host_with_serial_device_with_ports(
    mock_choose_prompt: Mock,
) -> None:
    """Test SERIAL device when serial ports are available."""
    setup_core()
    result = choose_upload_log_host(
        default="SERIAL",
        check_default=None,
        purpose=Purpose.UPLOADING,
    )
    assert result == ["/dev/ttyUSB0"]
    mock_choose_prompt.assert_called_once_with(
        [
            ("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0"),
            ("/dev/ttyUSB1 (Another USB Serial)", "/dev/ttyUSB1"),
        ],
        purpose=Purpose.UPLOADING,
    )


def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None:
    """Test OTA device when OTA is configured."""
    setup_core(config={CONF_OTA: {}}, address="192.168.1.100")

    result = choose_upload_log_host(
        default="OTA",
        check_default=None,
        purpose=Purpose.UPLOADING,
    )
    assert result == ["192.168.1.100"]


def test_choose_upload_log_host_with_ota_device_with_api_config() -> None:
    """Test OTA device when API is configured (no upload without OTA in config)."""
    setup_core(config={CONF_API: {}}, address="192.168.1.100")

    with pytest.raises(
        EsphomeError, match="All specified devices .* could not be resolved"
    ):
        choose_upload_log_host(
            default="OTA",
            check_default=None,
            purpose=Purpose.UPLOADING,
        )


def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> None:
    """Test OTA device when API is configured."""
    setup_core(config={CONF_API: {}}, address="192.168.1.100")

    result = choose_upload_log_host(
        default="OTA",
        check_default=None,
        purpose=Purpose.LOGGING,
    )
    assert result == ["192.168.1.100"]


@pytest.mark.usefixtures("mock_has_mqtt_logging")
def test_choose_upload_log_host_with_ota_device_fallback_to_mqtt() -> None:
    """Test OTA device fallback to MQTT when no OTA/API config."""
    setup_core(config={"mqtt": {}})

    result = choose_upload_log_host(
        default="OTA",
        check_default=None,
        purpose=Purpose.LOGGING,
    )
    assert result == ["MQTT"]


@pytest.mark.usefixtures("mock_no_mqtt_logging")
def test_choose_upload_log_host_with_ota_device_no_fallback() -> None:
    """Test OTA device with no valid fallback options."""
    setup_core()

    with pytest.raises(
        EsphomeError, match="All specified devices .* could not be resolved"
    ):
        choose_upload_log_host(
            default="OTA",
            check_default=None,
            purpose=Purpose.UPLOADING,
        )


@pytest.mark.usefixtures("mock_choose_prompt")
def test_choose_upload_log_host_multiple_devices() -> None:
    """Test with multiple devices including special identifiers."""
    setup_core(config={CONF_OTA: {}}, address="192.168.1.100")

    mock_ports = [MockSerialPort("/dev/ttyUSB0", "USB Serial")]

    with patch("esphome.__main__.get_serial_ports", return_value=mock_ports):
        result = choose_upload_log_host(
            default=["192.168.1.50", "OTA", "SERIAL"],
            check_default=None,
            purpose=Purpose.UPLOADING,
        )
        assert result == ["192.168.1.50", "192.168.1.100", "/dev/ttyUSB0"]


def test_choose_upload_log_host_no_defaults_with_serial_ports(
    mock_choose_prompt: Mock,
) -> None:
    """Test interactive mode with serial ports available."""
    mock_ports = [
        MockSerialPort("/dev/ttyUSB0", "USB Serial"),
    ]

    setup_core()

    with patch("esphome.__main__.get_serial_ports", return_value=mock_ports):
        result = choose_upload_log_host(
            default=None,
            check_default=None,
            purpose=Purpose.UPLOADING,
        )
        assert result == ["/dev/ttyUSB0"]
        mock_choose_prompt.assert_called_once_with(
            [("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0")],
            purpose=Purpose.UPLOADING,
        )


@pytest.mark.usefixtures("mock_no_serial_ports")
def test_choose_upload_log_host_no_defaults_with_ota() -> None:
    """Test interactive mode with OTA option."""
    setup_core(config={CONF_OTA: {}}, address="192.168.1.100")

    with patch(
        "esphome.__main__.choose_prompt", return_value="192.168.1.100"
    ) as mock_prompt:
        result = choose_upload_log_host(
            default=None,
            check_default=None,
            purpose=Purpose.UPLOADING,
        )
        assert result == ["192.168.1.100"]
        mock_prompt.assert_called_once_with(
            [("Over The Air (192.168.1.100)", "192.168.1.100")],
            purpose=Purpose.UPLOADING,
        )


@pytest.mark.usefixtures("mock_no_serial_ports")
def test_choose_upload_log_host_no_defaults_with_api() -> None:
    """Test interactive mode with API option."""
    setup_core(config={CONF_API: {}}, address="192.168.1.100")

    with patch(
        "esphome.__main__.choose_prompt", return_value="192.168.1.100"
    ) as mock_prompt:
        result = choose_upload_log_host(
            default=None,
            check_default=None,
            purpose=Purpose.LOGGING,
        )
        assert result == ["192.168.1.100"]
        mock_prompt.assert_called_once_with(
            [("Over The Air (192.168.1.100)", "192.168.1.100")],
            purpose=Purpose.LOGGING,
        )


@pytest.mark.usefixtures("mock_no_serial_ports", "mock_has_mqtt_logging")
def test_choose_upload_log_host_no_defaults_with_mqtt() -> None:
    """Test interactive mode with MQTT option."""
    setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local"}})

    with patch("esphome.__main__.choose_prompt", return_value="MQTT") as mock_prompt:
        result = choose_upload_log_host(
            default=None,
            check_default=None,
            purpose=Purpose.LOGGING,
        )
        assert result == ["MQTT"]
        mock_prompt.assert_called_once_with(
            [("MQTT (mqtt.local)", "MQTT")],
            purpose=Purpose.LOGGING,
        )


@pytest.mark.usefixtures("mock_has_mqtt_logging")
def test_choose_upload_log_host_no_defaults_with_all_options(
    mock_choose_prompt: Mock,
) -> None:
    """Test interactive mode with all options available."""
    setup_core(
        config={CONF_OTA: {}, CONF_API: {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}},
        address="192.168.1.100",
    )

    mock_ports = [MockSerialPort("/dev/ttyUSB0", "USB Serial")]

    with patch("esphome.__main__.get_serial_ports", return_value=mock_ports):
        result = choose_upload_log_host(
            default=None,
            check_default=None,
            purpose=Purpose.UPLOADING,
        )
        assert result == ["/dev/ttyUSB0"]

        expected_options = [
            ("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0"),
            ("Over The Air (192.168.1.100)", "192.168.1.100"),
            ("Over The Air (MQTT IP lookup)", "MQTTIP"),
        ]
        mock_choose_prompt.assert_called_once_with(
            expected_options, purpose=Purpose.UPLOADING
        )


def test_choose_upload_log_host_no_defaults_with_all_options_logging(
    mock_choose_prompt: Mock,
) -> None:
    """Test interactive mode with all options available."""
    setup_core(
        config={CONF_OTA: {}, CONF_API: {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}},
        address="192.168.1.100",
    )

    mock_ports = [MockSerialPort("/dev/ttyUSB0", "USB Serial")]

    with patch("esphome.__main__.get_serial_ports", return_value=mock_ports):
        result = choose_upload_log_host(
            default=None,
            check_default=None,
            purpose=Purpose.LOGGING,
        )
        assert result == ["/dev/ttyUSB0"]

        expected_options = [
            ("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0"),
            ("MQTT (mqtt.local)", "MQTT"),
            ("Over The Air (192.168.1.100)", "192.168.1.100"),
            ("Over The Air (MQTT IP lookup)", "MQTTIP"),
        ]
        mock_choose_prompt.assert_called_once_with(
            expected_options, purpose=Purpose.LOGGING
        )


@pytest.mark.usefixtures("mock_no_serial_ports")
def test_choose_upload_log_host_check_default_matches() -> None:
    """Test when check_default matches an available option."""
    setup_core(config={CONF_OTA: {}}, address="192.168.1.100")

    result = choose_upload_log_host(
        default=None,
        check_default="192.168.1.100",
        purpose=Purpose.UPLOADING,
    )
    assert result == ["192.168.1.100"]


@pytest.mark.usefixtures("mock_no_serial_ports")
def test_choose_upload_log_host_check_default_no_match() -> None:
    """Test when check_default doesn't match any available option."""
    setup_core()

    with patch(
        "esphome.__main__.choose_prompt", return_value="fallback"
    ) as mock_prompt:
        result = choose_upload_log_host(
            default=None,
            check_default="192.168.1.100",
            purpose=Purpose.UPLOADING,
        )
        assert result == ["fallback"]
        mock_prompt.assert_called_once()


@pytest.mark.usefixtures("mock_no_serial_ports")
def test_choose_upload_log_host_empty_defaults_list() -> None:
    """Test with an empty list as default."""
    setup_core()
    with patch("esphome.__main__.choose_prompt", return_value="chosen") as mock_prompt:
        result = choose_upload_log_host(
            default=[],
            check_default=None,
            purpose=Purpose.UPLOADING,
        )
        assert result == ["chosen"]
        mock_prompt.assert_called_once()


@pytest.mark.usefixtures("mock_no_serial_ports", "mock_no_mqtt_logging")
def test_choose_upload_log_host_all_devices_unresolved() -> None:
    """Test when all specified devices cannot be resolved."""
    setup_core()

    with pytest.raises(
        EsphomeError,
        match=r"All specified devices \['SERIAL', 'OTA'\] could not be resolved",
    ):
        choose_upload_log_host(
            default=["SERIAL", "OTA"],
            check_default=None,
            purpose=Purpose.UPLOADING,
        )


@pytest.mark.usefixtures("mock_no_serial_ports", "mock_no_mqtt_logging")
def test_choose_upload_log_host_mixed_resolved_unresolved() -> None:
    """Test with a mix of resolved and unresolved devices."""
    setup_core()

    result = choose_upload_log_host(
        default=["192.168.1.50", "SERIAL", "OTA"],
        check_default=None,
        purpose=Purpose.UPLOADING,
    )
    assert result == ["192.168.1.50"]


def test_choose_upload_log_host_ota_both_conditions() -> None:
    """Test OTA device when both OTA and API are configured and enabled."""
    setup_core(config={CONF_OTA: {}, CONF_API: {}}, address="192.168.1.100")

    result = choose_upload_log_host(
        default="OTA",
        check_default=None,
        purpose=Purpose.UPLOADING,
    )
    assert result == ["192.168.1.100"]


@pytest.mark.usefixtures("mock_serial_ports")
def test_choose_upload_log_host_ota_ip_all_options() -> None:
    """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not."""
    setup_core(
        config={
            CONF_OTA: {},
            CONF_API: {},
            CONF_MQTT: {
                CONF_BROKER: "mqtt.local",
            },
            CONF_MDNS: {
                CONF_DISABLED: True,
            },
        },
        address="192.168.1.100",
    )

    result = choose_upload_log_host(
        default="OTA",
        check_default=None,
        purpose=Purpose.UPLOADING,
    )
    assert result == ["192.168.1.100", "MQTTIP"]


@pytest.mark.usefixtures("mock_serial_ports")
def test_choose_upload_log_host_ota_local_all_options() -> None:
    """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not."""
    setup_core(
        config={
            CONF_OTA: {},
            CONF_API: {},
            CONF_MQTT: {
                CONF_BROKER: "mqtt.local",
            },
            CONF_MDNS: {
                CONF_DISABLED: True,
            },
        },
        address="test.local",
    )

    result = choose_upload_log_host(
        default="OTA",
        check_default=None,
        purpose=Purpose.UPLOADING,
    )
    assert result == ["MQTTIP", "test.local"]


@pytest.mark.usefixtures("mock_serial_ports")
def test_choose_upload_log_host_ota_ip_all_options_logging() -> None:
    """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not."""
    setup_core(
        config={
            CONF_OTA: {},
            CONF_API: {},
            CONF_MQTT: {
                CONF_BROKER: "mqtt.local",
            },
            CONF_MDNS: {
                CONF_DISABLED: True,
            },
        },
        address="192.168.1.100",
    )

    result = choose_upload_log_host(
        default="OTA",
        check_default=None,
        purpose=Purpose.LOGGING,
    )
    assert result == ["192.168.1.100", "MQTTIP", "MQTT"]


@pytest.mark.usefixtures("mock_serial_ports")
def test_choose_upload_log_host_ota_local_all_options_logging() -> None:
    """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not."""
    setup_core(
        config={
            CONF_OTA: {},
            CONF_API: {},
            CONF_MQTT: {
                CONF_BROKER: "mqtt.local",
            },
            CONF_MDNS: {
                CONF_DISABLED: True,
            },
        },
        address="test.local",
    )

    result = choose_upload_log_host(
        default="OTA",
        check_default=None,
        purpose=Purpose.LOGGING,
    )
    assert result == ["MQTTIP", "MQTT", "test.local"]


@pytest.mark.usefixtures("mock_no_mqtt_logging")
def test_choose_upload_log_host_no_address_with_ota_config() -> None:
    """Test OTA device when OTA is configured but no address is set."""
    setup_core(config={CONF_OTA: {}})

    with pytest.raises(
        EsphomeError, match="All specified devices .* could not be resolved"
    ):
        choose_upload_log_host(
            default="OTA",
            check_default=None,
            purpose=Purpose.UPLOADING,
        )


@dataclass
class MockArgs:
    """Mock args for testing."""

    file: str | None = None
    upload_speed: int = 460800
    username: str | None = None
    password: str | None = None
    client_id: str | None = None
    topic: str | None = None
    configuration: str | None = None
    name: str | None = None
    dashboard: bool = False


def test_upload_program_serial_esp32(
    mock_upload_using_esptool: Mock,
    mock_get_port_type: Mock,
    mock_check_permissions: Mock,
) -> None:
    """Test upload_program with serial port for ESP32."""
    setup_core(platform=PLATFORM_ESP32)
    mock_get_port_type.return_value = "SERIAL"
    mock_upload_using_esptool.return_value = 0

    config = {}
    args = MockArgs()
    devices = ["/dev/ttyUSB0"]

    exit_code, host = upload_program(config, args, devices)

    assert exit_code == 0
    assert host == "/dev/ttyUSB0"
    mock_check_permissions.assert_called_once_with("/dev/ttyUSB0")
    mock_upload_using_esptool.assert_called_once()


def test_upload_program_serial_esp8266_with_file(
    mock_upload_using_esptool: Mock,
    mock_get_port_type: Mock,
    mock_check_permissions: Mock,
) -> None:
    """Test upload_program with serial port for ESP8266 with custom file."""
    setup_core(platform=PLATFORM_ESP8266)
    mock_get_port_type.return_value = "SERIAL"
    mock_upload_using_esptool.return_value = 0

    config = {}
    args = MockArgs(file="firmware.bin")
    devices = ["/dev/ttyUSB0"]

    exit_code, host = upload_program(config, args, devices)

    assert exit_code == 0
    assert host == "/dev/ttyUSB0"
    mock_check_permissions.assert_called_once_with("/dev/ttyUSB0")
    mock_upload_using_esptool.assert_called_once_with(
        config, "/dev/ttyUSB0", "firmware.bin", 460800
    )


def test_upload_using_esptool_path_conversion(
    tmp_path: Path,
    mock_run_external_command_main: Mock,
    mock_get_idedata: Mock,
) -> None:
    """Test upload_using_esptool properly converts Path objects to strings for esptool.

    This test ensures that img.path (Path object) is converted to string before
    passing to esptool, preventing AttributeError.
    """
    setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test")

    # Set up ESP32-specific data required by get_esp32_variant()
    CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32}

    # Create mock IDEData with Path objects
    mock_idedata = MagicMock(spec=platformio_api.IDEData)
    mock_idedata.firmware_bin_path = tmp_path / "firmware.bin"
    mock_idedata.extra_flash_images = [
        platformio_api.FlashImage(path=tmp_path / "bootloader.bin", offset="0x1000"),
        platformio_api.FlashImage(path=tmp_path / "partitions.bin", offset="0x8000"),
    ]

    mock_get_idedata.return_value = mock_idedata

    # Create the actual firmware files so they exist
    (tmp_path / "firmware.bin").touch()
    (tmp_path / "bootloader.bin").touch()
    (tmp_path / "partitions.bin").touch()

    config = {CONF_ESPHOME: {"platformio_options": {}}}

    # Call upload_using_esptool without custom file argument
    result = upload_using_esptool(config, "/dev/ttyUSB0", None, None)

    assert result == 0

    # Verify that run_external_command was called
    assert mock_run_external_command_main.call_count == 1

    # Get the actual call arguments
    call_args = mock_run_external_command_main.call_args[0]

    # The first argument should be esptool.main function,
    # followed by the command arguments
    assert len(call_args) > 1

    # Find the indices of the flash image arguments
    # They should come after "write-flash" and "-z"
    cmd_list = list(call_args[1:])  # Skip the esptool.main function

    # Verify all paths are strings, not Path objects
    # The firmware and flash images should be at specific positions
    write_flash_idx = cmd_list.index("write-flash")

    # After write-flash we have: -z, --flash-size, detect, then offset/path pairs
    # Check firmware at offset 0x10000 (ESP32)
    firmware_offset_idx = write_flash_idx + 4
    assert cmd_list[firmware_offset_idx] == "0x10000"
    firmware_path = cmd_list[firmware_offset_idx + 1]
    assert isinstance(firmware_path, str)
    assert firmware_path.endswith("firmware.bin")

    # Check bootloader
    bootloader_offset_idx = firmware_offset_idx + 2
    assert cmd_list[bootloader_offset_idx] == "0x1000"
    bootloader_path = cmd_list[bootloader_offset_idx + 1]
    assert isinstance(bootloader_path, str)
    assert bootloader_path.endswith("bootloader.bin")

    # Check partitions
    partitions_offset_idx = bootloader_offset_idx + 2
    assert cmd_list[partitions_offset_idx] == "0x8000"
    partitions_path = cmd_list[partitions_offset_idx + 1]
    assert isinstance(partitions_path, str)
    assert partitions_path.endswith("partitions.bin")


def test_upload_using_esptool_with_file_path(
    tmp_path: Path,
    mock_run_external_command_main: Mock,
) -> None:
    """Test upload_using_esptool with a custom file that's a Path object."""
    setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test")

    # Create a test firmware file
    firmware_file = tmp_path / "custom_firmware.bin"
    firmware_file.touch()

    config = {CONF_ESPHOME: {"platformio_options": {}}}

    # Call with a Path object as the file argument (though usually it's a string)
    result = upload_using_esptool(config, "/dev/ttyUSB0", str(firmware_file), None)

    assert result == 0

    # Verify that run_external_command was called
    mock_run_external_command_main.assert_called_once()

    # Get the actual call arguments
    call_args = mock_run_external_command_main.call_args[0]
    cmd_list = list(call_args[1:])  # Skip the esptool.main function

    # Find the firmware path in the command
    write_flash_idx = cmd_list.index("write-flash")

    # For custom file, it should be at offset 0x0
    firmware_offset_idx = write_flash_idx + 4
    assert cmd_list[firmware_offset_idx] == "0x0"
    firmware_path = cmd_list[firmware_offset_idx + 1]

    # Verify it's a string, not a Path object
    assert isinstance(firmware_path, str)
    assert firmware_path.endswith("custom_firmware.bin")


@pytest.mark.parametrize(
    "platform,device",
    [
        (PLATFORM_RP2040, "/dev/ttyACM0"),
        (PLATFORM_BK72XX, "/dev/ttyUSB0"),  # LibreTiny platform
    ],
)
def test_upload_program_serial_platformio_platforms(
    mock_upload_using_platformio: Mock,
    mock_get_port_type: Mock,
    mock_check_permissions: Mock,
    platform: str,
    device: str,
) -> None:
    """Test upload_program with serial port for platformio platforms (RP2040/LibreTiny)."""
    setup_core(platform=platform)
    mock_get_port_type.return_value = "SERIAL"
    mock_upload_using_platformio.return_value = 0

    config = {}
    args = MockArgs()
    devices = [device]

    exit_code, host = upload_program(config, args, devices)

    assert exit_code == 0
    assert host == device
    mock_check_permissions.assert_called_once_with(device)
    mock_upload_using_platformio.assert_called_once_with(config, device)


def test_upload_program_serial_upload_failed(
    mock_upload_using_esptool: Mock,
    mock_get_port_type: Mock,
    mock_check_permissions: Mock,
) -> None:
    """Test upload_program when serial upload fails."""
    setup_core(platform=PLATFORM_ESP32)
    mock_get_port_type.return_value = "SERIAL"
    mock_upload_using_esptool.return_value = 1  # Failed

    config = {}
    args = MockArgs()
    devices = ["/dev/ttyUSB0"]

    exit_code, host = upload_program(config, args, devices)

    assert exit_code == 1
    assert host is None
    mock_check_permissions.assert_called_once_with("/dev/ttyUSB0")
    mock_upload_using_esptool.assert_called_once()


def test_upload_program_ota_success(
    mock_run_ota: Mock,
    mock_get_port_type: Mock,
    tmp_path: Path,
) -> None:
    """Test upload_program with OTA."""
    setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)

    mock_get_port_type.return_value = "NETWORK"
    mock_run_ota.return_value = (0, "192.168.1.100")

    config = {
        CONF_OTA: [
            {
                CONF_PLATFORM: CONF_ESPHOME,
                CONF_PORT: 3232,
                CONF_PASSWORD: "secret",
            }
        ]
    }
    args = MockArgs()
    devices = ["192.168.1.100"]

    exit_code, host = upload_program(config, args, devices)

    assert exit_code == 0
    assert host == "192.168.1.100"
    expected_firmware = (
        tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
    )
    mock_run_ota.assert_called_once_with(
        ["192.168.1.100"], 3232, "secret", expected_firmware
    )


def test_upload_program_ota_with_file_arg(
    mock_run_ota: Mock,
    mock_get_port_type: Mock,
    tmp_path: Path,
) -> None:
    """Test upload_program with OTA and custom file."""
    setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)

    mock_get_port_type.return_value = "NETWORK"
    mock_run_ota.return_value = (0, "192.168.1.100")

    config = {
        CONF_OTA: [
            {
                CONF_PLATFORM: CONF_ESPHOME,
                CONF_PORT: 3232,
            }
        ]
    }
    args = MockArgs(file="custom.bin")
    devices = ["192.168.1.100"]

    exit_code, host = upload_program(config, args, devices)

    assert exit_code == 0
    assert host == "192.168.1.100"
    mock_run_ota.assert_called_once_with(
        ["192.168.1.100"], 3232, None, Path("custom.bin")
    )


def test_upload_program_ota_no_config(
    mock_get_port_type: Mock,
) -> None:
    """Test upload_program with OTA but no OTA config."""
    setup_core(platform=PLATFORM_ESP32)
    mock_get_port_type.return_value = "NETWORK"

    config = {}  # No OTA config
    args = MockArgs()
    devices = ["192.168.1.100"]

    with pytest.raises(EsphomeError, match="Cannot upload Over the Air"):
        upload_program(config, args, devices)


def test_upload_program_ota_with_mqtt_resolution(
    mock_mqtt_get_ip: Mock,
    mock_is_ip_address: Mock,
    mock_run_ota: Mock,
    tmp_path: Path,
) -> None:
    """Test upload_program with OTA using MQTT for address resolution."""
    setup_core(address="device.local", platform=PLATFORM_ESP32, tmp_path=tmp_path)

    mock_is_ip_address.return_value = False
    mock_mqtt_get_ip.return_value = ["192.168.1.100"]
    mock_run_ota.return_value = (0, "192.168.1.100")

    config = {
        CONF_OTA: [
            {
                CONF_PLATFORM: CONF_ESPHOME,
                CONF_PORT: 3232,
            }
        ],
        CONF_MQTT: {
            CONF_BROKER: "mqtt.local",
        },
        CONF_MDNS: {
            CONF_DISABLED: True,
        },
    }
    args = MockArgs(username="user", password="pass", client_id="client")
    devices = ["MQTT"]

    exit_code, host = upload_program(config, args, devices)

    assert exit_code == 0
    assert host == "192.168.1.100"
    mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client")
    expected_firmware = (
        tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
    )
    mock_run_ota.assert_called_once_with(
        ["192.168.1.100"], 3232, None, expected_firmware
    )


@patch("esphome.__main__.importlib.import_module")
def test_upload_program_platform_specific_handler(
    mock_import: Mock,
    mock_get_port_type: Mock,
) -> None:
    """Test upload_program with platform-specific upload handler."""
    setup_core(platform="custom_platform")
    mock_get_port_type.return_value = "CUSTOM"

    mock_module = MagicMock()
    mock_module.upload_program.return_value = True
    mock_import.return_value = mock_module

    config = {}
    args = MockArgs()
    devices = ["custom_device"]

    exit_code, host = upload_program(config, args, devices)

    assert exit_code == 0
    assert host == "custom_device"
    mock_import.assert_called_once_with("esphome.components.custom_platform")
    mock_module.upload_program.assert_called_once_with(config, args, "custom_device")


def test_show_logs_serial(
    mock_get_port_type: Mock,
    mock_check_permissions: Mock,
    mock_run_miniterm: Mock,
) -> None:
    """Test show_logs with serial port."""
    setup_core(config={"logger": {}}, platform=PLATFORM_ESP32)
    mock_get_port_type.return_value = "SERIAL"
    mock_run_miniterm.return_value = 0

    args = MockArgs()
    devices = ["/dev/ttyUSB0"]

    result = show_logs(CORE.config, args, devices)

    assert result == 0
    mock_check_permissions.assert_called_once_with("/dev/ttyUSB0")
    mock_run_miniterm.assert_called_once_with(CORE.config, "/dev/ttyUSB0", args)


def test_show_logs_no_logger() -> None:
    """Test show_logs when logger is not configured."""
    setup_core(config={}, platform=PLATFORM_ESP32)  # No logger config
    args = MockArgs()
    devices = ["/dev/ttyUSB0"]

    with pytest.raises(EsphomeError, match="Logger is not configured"):
        show_logs(CORE.config, args, devices)


@patch("esphome.components.api.client.run_logs")
def test_show_logs_api(
    mock_run_logs: Mock,
) -> None:
    """Test show_logs with API."""
    setup_core(
        config={
            "logger": {},
            CONF_API: {},
            CONF_MDNS: {CONF_DISABLED: False},
        },
        platform=PLATFORM_ESP32,
    )
    mock_run_logs.return_value = 0

    args = MockArgs()
    devices = ["192.168.1.100", "192.168.1.101"]

    result = show_logs(CORE.config, args, devices)

    assert result == 0
    mock_run_logs.assert_called_once_with(
        CORE.config, ["192.168.1.100", "192.168.1.101"]
    )


@patch("esphome.components.api.client.run_logs")
def test_show_logs_api_with_fqdn_mdns_disabled(
    mock_run_logs: Mock,
) -> None:
    """Test show_logs with API using FQDN when mDNS is disabled."""
    setup_core(
        config={
            "logger": {},
            CONF_API: {},
            CONF_MDNS: {CONF_DISABLED: True},
        },
        platform=PLATFORM_ESP32,
    )
    mock_run_logs.return_value = 0

    args = MockArgs()
    devices = ["device.example.com"]

    result = show_logs(CORE.config, args, devices)

    assert result == 0
    # Should use the FQDN directly, not try MQTT lookup
    mock_run_logs.assert_called_once_with(CORE.config, ["device.example.com"])


@patch("esphome.components.api.client.run_logs")
def test_show_logs_api_with_mqtt_fallback(
    mock_run_logs: Mock,
    mock_mqtt_get_ip: Mock,
) -> None:
    """Test show_logs with API using MQTT for address resolution."""
    setup_core(
        config={
            "logger": {},
            CONF_API: {},
            CONF_MDNS: {CONF_DISABLED: True},
            CONF_MQTT: {CONF_BROKER: "mqtt.local"},
        },
        platform=PLATFORM_ESP32,
    )
    mock_run_logs.return_value = 0
    mock_mqtt_get_ip.return_value = ["192.168.1.200"]

    args = MockArgs(username="user", password="pass", client_id="client")
    devices = ["MQTTIP"]

    result = show_logs(CORE.config, args, devices)

    assert result == 0
    mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client")
    mock_run_logs.assert_called_once_with(CORE.config, ["192.168.1.200"])


@patch("esphome.mqtt.show_logs")
def test_show_logs_mqtt(
    mock_mqtt_show_logs: Mock,
) -> None:
    """Test show_logs with MQTT."""
    setup_core(
        config={
            "logger": {},
            "mqtt": {CONF_BROKER: "mqtt.local"},
        },
        platform=PLATFORM_ESP32,
    )
    mock_mqtt_show_logs.return_value = 0

    args = MockArgs(
        topic="esphome/logs",
        username="user",
        password="pass",
        client_id="client",
    )
    devices = ["MQTT"]

    result = show_logs(CORE.config, args, devices)

    assert result == 0
    mock_mqtt_show_logs.assert_called_once_with(
        CORE.config, "esphome/logs", "user", "pass", "client"
    )


@patch("esphome.mqtt.show_logs")
def test_show_logs_network_with_mqtt_only(
    mock_mqtt_show_logs: Mock,
) -> None:
    """Test show_logs with network port but only MQTT configured."""
    setup_core(
        config={
            "logger": {},
            "mqtt": {CONF_BROKER: "mqtt.local"},
            # No API configured
        },
        platform=PLATFORM_ESP32,
    )
    mock_mqtt_show_logs.return_value = 0

    args = MockArgs(
        topic="esphome/logs",
        username="user",
        password="pass",
        client_id="client",
    )
    devices = ["192.168.1.100"]

    result = show_logs(CORE.config, args, devices)

    assert result == 0
    mock_mqtt_show_logs.assert_called_once_with(
        CORE.config, "esphome/logs", "user", "pass", "client"
    )


def test_show_logs_no_method_configured() -> None:
    """Test show_logs when no remote logging method is configured."""
    setup_core(
        config={
            "logger": {},
            # No API or MQTT configured
        },
        platform=PLATFORM_ESP32,
    )

    args = MockArgs()
    devices = ["192.168.1.100"]

    with pytest.raises(
        EsphomeError, match="No remote or local logging method configured"
    ):
        show_logs(CORE.config, args, devices)


@patch("esphome.__main__.importlib.import_module")
def test_show_logs_platform_specific_handler(
    mock_import: Mock,
) -> None:
    """Test show_logs with platform-specific logs handler."""
    setup_core(platform="custom_platform", config={"logger": {}})

    mock_module = MagicMock()
    mock_module.show_logs.return_value = True
    mock_import.return_value = mock_module

    config = {"logger": {}}
    args = MockArgs()
    devices = ["custom_device"]

    result = show_logs(config, args, devices)

    assert result == 0
    mock_import.assert_called_once_with("esphome.components.custom_platform")
    mock_module.show_logs.assert_called_once_with(config, args, devices)


def test_has_mqtt_logging_no_log_topic() -> None:
    """Test has_mqtt_logging returns True when CONF_LOG_TOPIC is not in mqtt_config."""

    # Setup MQTT config without CONF_LOG_TOPIC (defaults to enabled - this is the missing test case)
    setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local"}})
    assert has_mqtt_logging() is True

    # Setup MQTT config with CONF_LOG_TOPIC set to None (explicitly disabled)
    setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local", CONF_LOG_TOPIC: None}})
    assert has_mqtt_logging() is False

    # Setup MQTT config with CONF_LOG_TOPIC set with topic and level (explicitly enabled)
    setup_core(
        config={
            CONF_MQTT: {
                CONF_BROKER: "mqtt.local",
                CONF_LOG_TOPIC: {CONF_TOPIC: "esphome/logs", CONF_LEVEL: "DEBUG"},
            }
        }
    )
    assert has_mqtt_logging() is True

    # Setup MQTT config with CONF_LOG_TOPIC set but level is NONE (disabled)
    setup_core(
        config={
            CONF_MQTT: {
                CONF_BROKER: "mqtt.local",
                CONF_LOG_TOPIC: {CONF_TOPIC: "esphome/logs", CONF_LEVEL: "NONE"},
            }
        }
    )
    assert has_mqtt_logging() is False

    # Setup without MQTT config at all
    setup_core(config={})
    assert has_mqtt_logging() is False

    # Setup MQTT config with CONF_LOG_TOPIC but no CONF_LEVEL (regression test for #10771)
    # This simulates the default configuration created by validate_config in the MQTT component
    setup_core(
        config={
            CONF_MQTT: {
                CONF_BROKER: "mqtt.local",
                CONF_LOG_TOPIC: {CONF_TOPIC: "esphome/debug"},
            }
        }
    )
    assert has_mqtt_logging() is True


def test_has_mqtt() -> None:
    """Test has_mqtt function."""

    # Test with MQTT configured
    setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local"}})
    assert has_mqtt() is True

    # Test without MQTT configured
    setup_core(config={})
    assert has_mqtt() is False

    # Test with other components but no MQTT
    setup_core(config={CONF_API: {}, CONF_OTA: {}})
    assert has_mqtt() is False


def test_get_port_type() -> None:
    """Test get_port_type function."""

    assert get_port_type("/dev/ttyUSB0") == "SERIAL"
    assert get_port_type("/dev/ttyACM0") == "SERIAL"
    assert get_port_type("COM1") == "SERIAL"
    assert get_port_type("COM10") == "SERIAL"

    assert get_port_type("MQTT") == "MQTT"
    assert get_port_type("MQTTIP") == "MQTTIP"

    assert get_port_type("192.168.1.100") == "NETWORK"
    assert get_port_type("esphome-device.local") == "NETWORK"
    assert get_port_type("10.0.0.1") == "NETWORK"


def test_has_mqtt_ip_lookup() -> None:
    """Test has_mqtt_ip_lookup function."""

    CONF_DISCOVER_IP = "discover_ip"

    setup_core(config={})
    assert has_mqtt_ip_lookup() is False

    setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local"}})
    assert has_mqtt_ip_lookup() is True

    setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local", CONF_DISCOVER_IP: True}})
    assert has_mqtt_ip_lookup() is True

    setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local", CONF_DISCOVER_IP: False}})
    assert has_mqtt_ip_lookup() is False


def test_has_non_ip_address() -> None:
    """Test has_non_ip_address function."""

    setup_core(address=None)
    assert has_non_ip_address() is False

    setup_core(address="192.168.1.100")
    assert has_non_ip_address() is False

    setup_core(address="10.0.0.1")
    assert has_non_ip_address() is False

    setup_core(address="esphome-device.local")
    assert has_non_ip_address() is True

    setup_core(address="my-device")
    assert has_non_ip_address() is True


def test_has_ip_address() -> None:
    """Test has_ip_address function."""

    setup_core(address=None)
    assert has_ip_address() is False

    setup_core(address="192.168.1.100")
    assert has_ip_address() is True

    setup_core(address="10.0.0.1")
    assert has_ip_address() is True

    setup_core(address="esphome-device.local")
    assert has_ip_address() is False

    setup_core(address="my-device")
    assert has_ip_address() is False


def test_mqtt_get_ip() -> None:
    """Test mqtt_get_ip function."""
    config = {CONF_MQTT: {CONF_BROKER: "mqtt.local"}}

    with patch("esphome.mqtt.get_esphome_device_ip") as mock_get_ip:
        mock_get_ip.return_value = ["192.168.1.100", "192.168.1.101"]

        result = mqtt_get_ip(config, "user", "pass", "client-id")

        assert result == ["192.168.1.100", "192.168.1.101"]
        mock_get_ip.assert_called_once_with(config, "user", "pass", "client-id")


def test_has_resolvable_address() -> None:
    """Test has_resolvable_address function."""

    # Test with mDNS enabled and .local hostname address
    setup_core(config={}, address="esphome-device.local")
    assert has_resolvable_address() is True

    # Test with mDNS disabled and .local hostname address (still resolvable via DNS)
    setup_core(
        config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local"
    )
    assert has_resolvable_address() is True

    # Test with mDNS disabled and regular DNS hostname (resolvable)
    setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address="device.example.com")
    assert has_resolvable_address() is True

    # Test with IP address (always resolvable, mDNS doesn't matter)
    setup_core(config={}, address="192.168.1.100")
    assert has_resolvable_address() is True

    # Test with IP address and mDNS disabled (still resolvable)
    setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address="192.168.1.100")
    assert has_resolvable_address() is True

    # Test with no address
    setup_core(config={}, address=None)
    assert has_resolvable_address() is False

    # Test with no address and mDNS disabled
    setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address=None)
    assert has_resolvable_address() is False


def test_command_wizard(tmp_path: Path) -> None:
    """Test command_wizard function."""
    config_file = tmp_path / "test.yaml"

    # Mock wizard.wizard to avoid interactive prompts
    with patch("esphome.wizard.wizard") as mock_wizard:
        mock_wizard.return_value = 0

        args = MockArgs(configuration=str(config_file))
        result = command_wizard(args)

        assert result == 0
        mock_wizard.assert_called_once_with(config_file)


def test_command_rename_invalid_characters(
    tmp_path: Path, capfd: CaptureFixture[str]
) -> None:
    """Test command_rename with invalid characters in name."""
    setup_core(tmp_path=tmp_path)

    # Test with invalid character (space)
    args = MockArgs(name="invalid name")
    result = command_rename(args, {})

    assert result == 1
    captured = capfd.readouterr()
    assert "invalid character" in captured.out.lower()


def test_command_rename_complex_yaml(
    tmp_path: Path, capfd: CaptureFixture[str]
) -> None:
    """Test command_rename with complex YAML that cannot be renamed."""
    config_file = tmp_path / "test.yaml"
    config_file.write_text("# Complex YAML without esphome section\nsome_key: value\n")
    setup_core(tmp_path=tmp_path)
    CORE.config_path = config_file

    args = MockArgs(name="newname")
    result = command_rename(args, {})

    assert result == 1
    captured = capfd.readouterr()
    assert "complex yaml" in captured.out.lower()


def test_command_rename_success(
    tmp_path: Path,
    capfd: CaptureFixture[str],
    mock_run_external_process: Mock,
) -> None:
    """Test successful rename of a simple configuration."""
    config_file = tmp_path / "oldname.yaml"
    config_file.write_text("""
esphome:
  name: oldname

esp32:
  board: nodemcu-32s

wifi:
  ssid: "test"
  password: "test1234"
""")
    setup_core(tmp_path=tmp_path)
    CORE.config_path = config_file

    # Set up CORE.config to avoid ValueError when accessing CORE.address
    CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}

    args = MockArgs(name="newname", dashboard=False)

    # Simulate successful validation and upload
    mock_run_external_process.return_value = 0

    result = command_rename(args, {})

    assert result == 0

    # Verify new file was created
    new_file = tmp_path / "newname.yaml"
    assert new_file.exists()

    # Verify old file was removed
    assert not config_file.exists()

    # Verify content was updated
    content = new_file.read_text()
    assert (
        'name: "newname"' in content
        or "name: 'newname'" in content
        or "name: newname" in content
    )

    captured = capfd.readouterr()
    assert "SUCCESS" in captured.out


def test_command_rename_with_substitutions(
    tmp_path: Path,
    mock_run_external_process: Mock,
) -> None:
    """Test rename with substitutions in YAML."""
    config_file = tmp_path / "oldname.yaml"
    config_file.write_text("""
substitutions:
  device_name: oldname

esphome:
  name: ${device_name}

esp32:
  board: nodemcu-32s
""")
    setup_core(tmp_path=tmp_path)
    CORE.config_path = config_file

    # Set up CORE.config to avoid ValueError when accessing CORE.address
    CORE.config = {
        CONF_ESPHOME: {CONF_NAME: "oldname"},
        CONF_SUBSTITUTIONS: {"device_name": "oldname"},
    }

    args = MockArgs(name="newname", dashboard=False)

    mock_run_external_process.return_value = 0

    result = command_rename(args, {})

    assert result == 0

    # Verify substitution was updated
    new_file = tmp_path / "newname.yaml"
    content = new_file.read_text()
    assert 'device_name: "newname"' in content


def test_command_rename_validation_failure(
    tmp_path: Path,
    capfd: CaptureFixture[str],
    mock_run_external_process: Mock,
) -> None:
    """Test rename when validation fails."""
    config_file = tmp_path / "oldname.yaml"
    config_file.write_text("""
esphome:
  name: oldname

esp32:
  board: nodemcu-32s
""")
    setup_core(tmp_path=tmp_path)
    CORE.config_path = config_file

    args = MockArgs(name="newname", dashboard=False)

    # First call for validation fails
    mock_run_external_process.return_value = 1

    result = command_rename(args, {})

    assert result == 1

    # Verify new file was created but then removed due to failure
    new_file = tmp_path / "newname.yaml"
    assert not new_file.exists()

    # Verify old file still exists (not removed on failure)
    assert config_file.exists()

    captured = capfd.readouterr()
    assert "Rename failed" in captured.out


def test_command_update_all_path_string_conversion(
    tmp_path: Path,
    mock_run_external_process: Mock,
    capfd: CaptureFixture[str],
) -> None:
    """Test that command_update_all properly converts Path objects to strings in output."""
    yaml1 = tmp_path / "device1.yaml"
    yaml1.write_text("""
esphome:
  name: device1

esp32:
  board: nodemcu-32s
""")

    yaml2 = tmp_path / "device2.yaml"
    yaml2.write_text("""
esphome:
  name: device2

esp8266:
  board: nodemcuv2
""")

    setup_core(tmp_path=tmp_path)
    mock_run_external_process.return_value = 0

    assert command_update_all(MockArgs(configuration=[str(tmp_path)])) == 0

    captured = capfd.readouterr()
    clean_output = strip_ansi_codes(captured.out)

    # Check that Path objects were properly converted to strings
    # The output should contain file paths without causing TypeError
    assert "device1.yaml" in clean_output
    assert "device2.yaml" in clean_output
    assert "SUCCESS" in clean_output
    assert "SUMMARY" in clean_output

    # Verify run_external_process was called for each file
    assert mock_run_external_process.call_count == 2


def test_command_update_all_with_failures(
    tmp_path: Path,
    mock_run_external_process: Mock,
    capfd: CaptureFixture[str],
) -> None:
    """Test command_update_all handles mixed success/failure cases properly."""
    yaml1 = tmp_path / "success_device.yaml"
    yaml1.write_text("""
esphome:
  name: success_device

esp32:
  board: nodemcu-32s
""")

    yaml2 = tmp_path / "failed_device.yaml"
    yaml2.write_text("""
esphome:
  name: failed_device

esp8266:
  board: nodemcuv2
""")

    setup_core(tmp_path=tmp_path)

    # Mock mixed results - first succeeds, second fails
    mock_run_external_process.side_effect = [0, 1]

    # Should return 1 (failure) since one device failed
    assert command_update_all(MockArgs(configuration=[str(tmp_path)])) == 1

    captured = capfd.readouterr()
    clean_output = strip_ansi_codes(captured.out)

    # Check that both success and failure are properly displayed
    assert "SUCCESS" in clean_output
    assert "ERROR" in clean_output or "FAILED" in clean_output
    assert "SUMMARY" in clean_output

    # Files are processed in alphabetical order, so we need to check which one succeeded/failed
    # The mock_run_external_process.side_effect = [0, 1] applies to files in alphabetical order
    # So "failed_device.yaml" gets 0 (success) and "success_device.yaml" gets 1 (failure)
    assert "failed_device.yaml: SUCCESS" in clean_output
    assert "success_device.yaml: FAILED" in clean_output


def test_command_update_all_empty_directory(
    tmp_path: Path,
    mock_run_external_process: Mock,
    capfd: CaptureFixture[str],
) -> None:
    """Test command_update_all with an empty directory (no YAML files)."""
    setup_core(tmp_path=tmp_path)

    assert command_update_all(MockArgs(configuration=[str(tmp_path)])) == 0
    mock_run_external_process.assert_not_called()

    captured = capfd.readouterr()
    clean_output = strip_ansi_codes(captured.out)

    assert "SUMMARY" in clean_output


def test_command_update_all_single_file(
    tmp_path: Path,
    mock_run_external_process: Mock,
    capfd: CaptureFixture[str],
) -> None:
    """Test command_update_all with a single YAML file specified."""
    yaml_file = tmp_path / "single_device.yaml"
    yaml_file.write_text("""
esphome:
  name: single_device

esp32:
  board: nodemcu-32s
""")

    setup_core(tmp_path=tmp_path)
    mock_run_external_process.return_value = 0

    assert command_update_all(MockArgs(configuration=[str(yaml_file)])) == 0

    captured = capfd.readouterr()
    clean_output = strip_ansi_codes(captured.out)

    assert "single_device.yaml" in clean_output
    assert "SUCCESS" in clean_output
    mock_run_external_process.assert_called_once()


def test_command_update_all_path_formatting_in_color_calls(
    tmp_path: Path,
    mock_run_external_process: Mock,
    capfd: CaptureFixture[str],
) -> None:
    """Test that Path objects are properly converted when passed to color() function."""
    yaml_file = tmp_path / "test-device_123.yaml"
    yaml_file.write_text("""
esphome:
  name: test-device_123

esp32:
  board: nodemcu-32s
""")

    setup_core(tmp_path=tmp_path)
    mock_run_external_process.return_value = 0

    assert command_update_all(MockArgs(configuration=[str(tmp_path)])) == 0

    captured = capfd.readouterr()
    clean_output = strip_ansi_codes(captured.out)

    assert "test-device_123.yaml" in clean_output
    assert "Updating" in clean_output
    assert "SUCCESS" in clean_output
    assert "SUMMARY" in clean_output

    # Should not have any Python error messages
    assert "TypeError" not in clean_output
    assert "can only concatenate str" not in clean_output


def test_command_clean_all_success(
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Test command_clean_all when writer.clean_all() succeeds."""
    args = MockArgs(configuration=["/path/to/config1", "/path/to/config2"])

    # Set logger level to capture INFO messages
    with (
        caplog.at_level(logging.INFO),
        patch("esphome.writer.clean_all") as mock_clean_all,
    ):
        result = command_clean_all(args)

        assert result == 0
        mock_clean_all.assert_called_once_with(["/path/to/config1", "/path/to/config2"])

        # Check that success message was logged
        assert "Done!" in caplog.text


def test_command_clean_all_oserror(
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Test command_clean_all when writer.clean_all() raises OSError."""
    args = MockArgs(configuration=["/path/to/config1"])

    # Create a mock OSError with a specific message
    mock_error = OSError("Permission denied: cannot delete directory")

    # Set logger level to capture ERROR and INFO messages
    with (
        caplog.at_level(logging.INFO),
        patch("esphome.writer.clean_all", side_effect=mock_error) as mock_clean_all,
    ):
        result = command_clean_all(args)

        assert result == 1
        mock_clean_all.assert_called_once_with(["/path/to/config1"])

        # Check that error message was logged
        assert (
            "Error cleaning all files: Permission denied: cannot delete directory"
            in caplog.text
        )
        # Should not have success message
        assert "Done!" not in caplog.text


def test_command_clean_all_oserror_no_message(
    caplog: pytest.LogCaptureFixture,
) -> None:
    """Test command_clean_all when writer.clean_all() raises OSError without message."""
    args = MockArgs(configuration=["/path/to/config1"])

    # Create a mock OSError without a message
    mock_error = OSError()

    # Set logger level to capture ERROR and INFO messages
    with (
        caplog.at_level(logging.INFO),
        patch("esphome.writer.clean_all", side_effect=mock_error) as mock_clean_all,
    ):
        result = command_clean_all(args)

        assert result == 1
        mock_clean_all.assert_called_once_with(["/path/to/config1"])

        # Check that error message was logged (should show empty string for OSError without message)
        assert "Error cleaning all files:" in caplog.text
        # Should not have success message
        assert "Done!" not in caplog.text


def test_command_clean_all_args_used() -> None:
    """Test that command_clean_all uses args.configuration parameter."""
    # Test with different configuration paths
    args1 = MockArgs(configuration=["/path/to/config1"])
    args2 = MockArgs(configuration=["/path/to/config2", "/path/to/config3"])

    with patch("esphome.writer.clean_all") as mock_clean_all:
        result1 = command_clean_all(args1)
        result2 = command_clean_all(args2)

        assert result1 == 0
        assert result2 == 0
        assert mock_clean_all.call_count == 2

        # Verify the correct configuration paths were passed
        mock_clean_all.assert_any_call(["/path/to/config1"])
        mock_clean_all.assert_any_call(["/path/to/config2", "/path/to/config3"])


def test_upload_program_ota_static_ip_with_mqttip(
    mock_mqtt_get_ip: Mock,
    mock_run_ota: Mock,
    tmp_path: Path,
) -> None:
    """Test upload_program with static IP and MQTTIP (issue #11260).

    This tests the scenario where a device has manual_ip (static IP) configured
    and MQTT is also configured. The devices list contains both the static IP
    and "MQTTIP" magic string. This previously failed because only the first
    device was checked for MQTT resolution.
    """
    setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)

    mock_mqtt_get_ip.return_value = ["192.168.2.50"]  # Different subnet
    mock_run_ota.return_value = (0, "192.168.1.100")

    config = {
        CONF_OTA: [
            {
                CONF_PLATFORM: CONF_ESPHOME,
                CONF_PORT: 3232,
            }
        ],
        CONF_MQTT: {
            CONF_BROKER: "mqtt.local",
        },
    }
    args = MockArgs(username="user", password="pass", client_id="client")
    # Simulates choose_upload_log_host returning static IP + MQTTIP
    devices = ["192.168.1.100", "MQTTIP"]

    exit_code, host = upload_program(config, args, devices)

    assert exit_code == 0
    assert host == "192.168.1.100"

    # Verify MQTT was resolved
    mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client")

    # Verify espota2.run_ota was called with both IPs
    expected_firmware = (
        tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
    )
    mock_run_ota.assert_called_once_with(
        ["192.168.1.100", "192.168.2.50"], 3232, None, expected_firmware
    )


def test_upload_program_ota_multiple_mqttip_resolves_once(
    mock_mqtt_get_ip: Mock,
    mock_run_ota: Mock,
    tmp_path: Path,
) -> None:
    """Test that MQTT resolution only happens once even with multiple MQTT magic strings."""
    setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)

    mock_mqtt_get_ip.return_value = ["192.168.2.50", "192.168.2.51"]
    mock_run_ota.return_value = (0, "192.168.2.50")

    config = {
        CONF_OTA: [
            {
                CONF_PLATFORM: CONF_ESPHOME,
                CONF_PORT: 3232,
            }
        ],
        CONF_MQTT: {
            CONF_BROKER: "mqtt.local",
        },
    }
    args = MockArgs(username="user", password="pass", client_id="client")
    # Multiple MQTT magic strings in the list
    devices = ["MQTTIP", "MQTT", "192.168.1.100"]

    exit_code, host = upload_program(config, args, devices)

    assert exit_code == 0
    assert host == "192.168.2.50"

    # Verify MQTT was only resolved once despite multiple MQTT magic strings
    mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client")

    # Verify espota2.run_ota was called with all unique IPs
    expected_firmware = (
        tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
    )
    mock_run_ota.assert_called_once_with(
        ["192.168.2.50", "192.168.2.51", "192.168.1.100"], 3232, None, expected_firmware
    )


def test_upload_program_ota_mqttip_deduplication(
    mock_mqtt_get_ip: Mock,
    mock_run_ota: Mock,
    tmp_path: Path,
) -> None:
    """Test that duplicate IPs are filtered when MQTT returns same IP as static IP."""
    setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)

    # MQTT returns the same IP as the static IP
    mock_mqtt_get_ip.return_value = ["192.168.1.100"]
    mock_run_ota.return_value = (0, "192.168.1.100")

    config = {
        CONF_OTA: [
            {
                CONF_PLATFORM: CONF_ESPHOME,
                CONF_PORT: 3232,
            }
        ],
        CONF_MQTT: {
            CONF_BROKER: "mqtt.local",
        },
    }
    args = MockArgs(username="user", password="pass", client_id="client")
    devices = ["192.168.1.100", "MQTTIP"]

    exit_code, host = upload_program(config, args, devices)

    assert exit_code == 0
    assert host == "192.168.1.100"

    # Verify MQTT was resolved
    mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client")

    # Verify espota2.run_ota was called with deduplicated IPs (only one instance of 192.168.1.100)
    # Note: Current implementation doesn't dedupe, so we'll get the IP twice
    # This test documents current behavior - deduplication could be future enhancement
    mock_run_ota.assert_called_once()
    call_args = mock_run_ota.call_args[0]
    # Should contain both the original IP and MQTT-resolved IP (even if duplicate)
    assert "192.168.1.100" in call_args[0]


@patch("esphome.components.api.client.run_logs")
def test_show_logs_api_static_ip_with_mqttip(
    mock_run_logs: Mock,
    mock_mqtt_get_ip: Mock,
) -> None:
    """Test show_logs with static IP and MQTTIP (issue #11260).

    This tests the scenario where a device has manual_ip (static IP) configured
    and MQTT is also configured. The devices list contains both the static IP
    and "MQTTIP" magic string.
    """
    setup_core(
        config={
            "logger": {},
            CONF_API: {},
            CONF_MQTT: {CONF_BROKER: "mqtt.local"},
        },
        platform=PLATFORM_ESP32,
    )
    mock_run_logs.return_value = 0
    mock_mqtt_get_ip.return_value = ["192.168.2.50"]

    args = MockArgs(username="user", password="pass", client_id="client")
    # Simulates choose_upload_log_host returning static IP + MQTTIP
    devices = ["192.168.1.100", "MQTTIP"]

    result = show_logs(CORE.config, args, devices)

    assert result == 0

    # Verify MQTT was resolved
    mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client")

    # Verify run_logs was called with both IPs
    mock_run_logs.assert_called_once_with(
        CORE.config, ["192.168.1.100", "192.168.2.50"]
    )


@patch("esphome.components.api.client.run_logs")
def test_show_logs_api_multiple_mqttip_resolves_once(
    mock_run_logs: Mock,
    mock_mqtt_get_ip: Mock,
) -> None:
    """Test that MQTT resolution only happens once for show_logs with multiple MQTT magic strings."""
    setup_core(
        config={
            "logger": {},
            CONF_API: {},
            CONF_MQTT: {CONF_BROKER: "mqtt.local"},
        },
        platform=PLATFORM_ESP32,
    )
    mock_run_logs.return_value = 0
    mock_mqtt_get_ip.return_value = ["192.168.2.50", "192.168.2.51"]

    args = MockArgs(username="user", password="pass", client_id="client")
    # Multiple MQTT magic strings in the list
    devices = ["MQTTIP", "192.168.1.100", "MQTT"]

    result = show_logs(CORE.config, args, devices)

    assert result == 0

    # Verify MQTT was only resolved once despite multiple MQTT magic strings
    mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client")

    # Verify run_logs was called with all unique IPs (MQTT strings replaced with IPs)
    # Note: "MQTT" is a different magic string from "MQTTIP", but both trigger MQTT resolution
    # The _resolve_network_devices helper filters out both after first resolution
    mock_run_logs.assert_called_once_with(
        CORE.config, ["192.168.2.50", "192.168.2.51", "192.168.1.100"]
    )


def test_upload_program_ota_mqtt_timeout_fallback(
    mock_mqtt_get_ip: Mock,
    mock_run_ota: Mock,
    tmp_path: Path,
) -> None:
    """Test upload_program falls back to other devices when MQTT times out."""
    setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)

    # MQTT times out
    mock_mqtt_get_ip.side_effect = EsphomeError("Failed to find IP via MQTT")
    mock_run_ota.return_value = (0, "192.168.1.100")

    config = {
        CONF_OTA: [
            {
                CONF_PLATFORM: CONF_ESPHOME,
                CONF_PORT: 3232,
            }
        ],
        CONF_MQTT: {
            CONF_BROKER: "mqtt.local",
        },
    }
    args = MockArgs(username="user", password="pass", client_id="client")
    # Static IP first, MQTTIP second
    devices = ["192.168.1.100", "MQTTIP"]

    exit_code, host = upload_program(config, args, devices)

    # Should succeed using the static IP even though MQTT failed
    assert exit_code == 0
    assert host == "192.168.1.100"

    # Verify MQTT was attempted
    mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client")

    # Verify espota2.run_ota was called with only the static IP (MQTT failed)
    expected_firmware = (
        tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
    )
    mock_run_ota.assert_called_once_with(
        ["192.168.1.100"], 3232, None, expected_firmware
    )


@patch("esphome.components.api.client.run_logs")
def test_show_logs_api_mqtt_timeout_fallback(
    mock_run_logs: Mock,
    mock_mqtt_get_ip: Mock,
) -> None:
    """Test show_logs falls back to other devices when MQTT times out."""
    setup_core(
        config={
            "logger": {},
            CONF_API: {},
            CONF_MQTT: {CONF_BROKER: "mqtt.local"},
        },
        platform=PLATFORM_ESP32,
    )
    mock_run_logs.return_value = 0
    # MQTT times out
    mock_mqtt_get_ip.side_effect = EsphomeError("Failed to find IP via MQTT")

    args = MockArgs(username="user", password="pass", client_id="client")
    # Static IP first, MQTTIP second
    devices = ["192.168.1.100", "MQTTIP"]

    result = show_logs(CORE.config, args, devices)

    # Should succeed using the static IP even though MQTT failed
    assert result == 0

    # Verify MQTT was attempted
    mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client")

    # Verify run_logs was called with only the static IP (MQTT failed)
    mock_run_logs.assert_called_once_with(CORE.config, ["192.168.1.100"])


def test_detect_external_components_no_external(
    mock_get_esphome_components: Mock,
) -> None:
    """Test detect_external_components with no external components."""
    config = {
        CONF_ESPHOME: {CONF_NAME: "test_device"},
        "logger": {},
        "api": {},
    }

    result = detect_external_components(config)

    assert result == set()
    mock_get_esphome_components.assert_called_once()


def test_detect_external_components_with_external(
    mock_get_esphome_components: Mock,
) -> None:
    """Test detect_external_components detects external components."""
    config = {
        CONF_ESPHOME: {CONF_NAME: "test_device"},
        "logger": {},  # Built-in
        "api": {},  # Built-in
        "my_custom_sensor": {},  # External
        "another_custom": {},  # External
        "external_components": [],  # Special key, not a component
        "substitutions": {},  # Special key, not a component
    }

    result = detect_external_components(config)

    assert result == {"my_custom_sensor", "another_custom"}
    mock_get_esphome_components.assert_called_once()


def test_detect_external_components_filters_special_keys(
    mock_get_esphome_components: Mock,
) -> None:
    """Test detect_external_components filters out special config keys."""
    config = {
        CONF_ESPHOME: {CONF_NAME: "test_device"},
        "substitutions": {"key": "value"},
        "packages": {},
        "globals": [],
        "external_components": [],
        "<<": {},  # YAML merge key
    }

    result = detect_external_components(config)

    assert result == set()
    mock_get_esphome_components.assert_called_once()


def test_command_analyze_memory_success(
    tmp_path: Path,
    capfd: CaptureFixture[str],
    mock_write_cpp: Mock,
    mock_compile_program: Mock,
    mock_get_idedata: Mock,
    mock_get_esphome_components: Mock,
    mock_memory_analyzer_cli: Mock,
) -> None:
    """Test command_analyze_memory with successful compilation and analysis."""
    setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")

    # Create firmware.elf file
    firmware_path = (
        tmp_path / ".esphome" / "build" / "test_device" / ".pioenvs" / "test_device"
    )
    firmware_path.mkdir(parents=True, exist_ok=True)
    firmware_elf = firmware_path / "firmware.elf"
    firmware_elf.write_text("mock elf file")

    # Mock idedata
    mock_idedata_obj = MagicMock(spec=platformio_api.IDEData)
    mock_idedata_obj.firmware_elf_path = str(firmware_elf)
    mock_idedata_obj.objdump_path = "/path/to/objdump"
    mock_idedata_obj.readelf_path = "/path/to/readelf"
    mock_get_idedata.return_value = mock_idedata_obj

    config = {
        CONF_ESPHOME: {CONF_NAME: "test_device"},
        "logger": {},
    }

    args = MockArgs()

    result = command_analyze_memory(args, config)

    assert result == 0

    # Verify compilation was done
    mock_write_cpp.assert_called_once_with(config)
    mock_compile_program.assert_called_once_with(args, config)

    # Verify analyzer was created with correct parameters
    mock_memory_analyzer_cli.assert_called_once_with(
        str(firmware_elf),
        "/path/to/objdump",
        "/path/to/readelf",
        set(),  # No external components
    )

    # Verify analysis was run
    mock_analyzer = mock_memory_analyzer_cli.return_value
    mock_analyzer.analyze.assert_called_once()
    mock_analyzer.generate_report.assert_called_once()

    # Verify report was printed
    captured = capfd.readouterr()
    assert "Mock Memory Report" in captured.out


def test_command_analyze_memory_with_external_components(
    tmp_path: Path,
    mock_write_cpp: Mock,
    mock_compile_program: Mock,
    mock_get_idedata: Mock,
    mock_get_esphome_components: Mock,
    mock_memory_analyzer_cli: Mock,
) -> None:
    """Test command_analyze_memory detects external components."""
    setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")

    # Create firmware.elf file
    firmware_path = (
        tmp_path / ".esphome" / "build" / "test_device" / ".pioenvs" / "test_device"
    )
    firmware_path.mkdir(parents=True, exist_ok=True)
    firmware_elf = firmware_path / "firmware.elf"
    firmware_elf.write_text("mock elf file")

    # Mock idedata
    mock_idedata_obj = MagicMock(spec=platformio_api.IDEData)
    mock_idedata_obj.firmware_elf_path = str(firmware_elf)
    mock_idedata_obj.objdump_path = "/path/to/objdump"
    mock_idedata_obj.readelf_path = "/path/to/readelf"
    mock_get_idedata.return_value = mock_idedata_obj

    config = {
        CONF_ESPHOME: {CONF_NAME: "test_device"},
        "logger": {},
        "my_custom_component": {"param": "value"},  # External component
        "external_components": [{"source": "github://user/repo"}],  # Not a component
    }

    args = MockArgs()

    result = command_analyze_memory(args, config)

    assert result == 0

    # Verify analyzer was created with external components detected
    mock_memory_analyzer_cli.assert_called_once_with(
        str(firmware_elf),
        "/path/to/objdump",
        "/path/to/readelf",
        {"my_custom_component"},  # External component detected
    )


def test_command_analyze_memory_write_cpp_fails(
    tmp_path: Path,
    mock_write_cpp: Mock,
) -> None:
    """Test command_analyze_memory when write_cpp fails."""
    setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")

    config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
    args = MockArgs()

    mock_write_cpp.return_value = 1  # Failure

    result = command_analyze_memory(args, config)

    assert result == 1
    mock_write_cpp.assert_called_once_with(config)


def test_command_analyze_memory_compile_fails(
    tmp_path: Path,
    mock_write_cpp: Mock,
    mock_compile_program: Mock,
) -> None:
    """Test command_analyze_memory when compilation fails."""
    setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")

    config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
    args = MockArgs()

    mock_compile_program.return_value = 1  # Compilation failed

    result = command_analyze_memory(args, config)

    assert result == 1
    mock_write_cpp.assert_called_once_with(config)
    mock_compile_program.assert_called_once_with(args, config)


def test_command_analyze_memory_no_idedata(
    tmp_path: Path,
    caplog: pytest.LogCaptureFixture,
    mock_write_cpp: Mock,
    mock_compile_program: Mock,
    mock_get_idedata: Mock,
) -> None:
    """Test command_analyze_memory when idedata cannot be retrieved."""
    setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")

    config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
    args = MockArgs()

    mock_get_idedata.return_value = None  # Failed to get idedata

    with caplog.at_level(logging.ERROR):
        result = command_analyze_memory(args, config)

    assert result == 1
    assert "Failed to get IDE data for memory analysis" in caplog.text
