"""Tests for the validated-config cache used by upload/logs."""

from __future__ import annotations

from contextlib import contextmanager
from ipaddress import IPv4Address, IPv4Network
import json
import os
from pathlib import Path
from typing import Any
from unittest.mock import patch
from uuid import UUID

import pytest

from esphome import const, yaml_util
from esphome.__main__ import run_esphome
from esphome.compiled_config import (
    _LAMBDA_KEY,
    compiled_config_path,
    load_compiled_config,
    save_compiled_config,
    save_compiled_config_and_sidecar,
)
from esphome.const import (
    CONF_API,
    CONF_ESPHOME,
    CONF_NAME,
    KEY_CORE,
    KEY_ESP32,
    KEY_TARGET_FRAMEWORK,
    KEY_TARGET_PLATFORM,
    KEY_VARIANT,
    Toolchain,
)
from esphome.core import (
    CORE,
    ID,
    EsphomeError,
    HexInt,
    Lambda,
    MACAddress,
    TimePeriodMilliseconds,
)
from esphome.storage_json import StorageJSON
from esphome.util import OrderedDict

_VALIDATED_CONFIG = {
    "esphome": {"name": "lite_test", "friendly_name": "Lite Test Device"},
    "esp32": {"board": "nodemcu-32s"},
    "logger": {"baud_rate": 115200},
    "api": {"port": 6053, "encryption": {"key": "6dGhpcyBpcyBhIHRlc3Q="}},
    "ota": [{"platform": "esphome", "port": 3232, "password": "secret"}],
    "wifi": {"ssid": "ssid", "use_address": "192.168.1.42"},
}


def _cache_body(config: dict | None = None) -> str:
    """Render the JSON envelope the production save writes."""
    return json.dumps(
        {"v": 1, "esphome": const.__version__, "config": config or _VALIDATED_CONFIG}
    )


def _write_storage(
    storage_path: Path,
    *,
    esp_platform: str | None = "ESP32",
    core_platform: str | None = "esp32",
    build_path: str | None = "/build/lite_test",
) -> None:
    """Write a vanilla StorageJSON sidecar for the cache tests."""
    storage_path.parent.mkdir(parents=True, exist_ok=True)
    data = {
        "storage_version": 1,
        "name": "lite_test",
        "friendly_name": "Lite Test Device",
        "comment": None,
        "esphome_version": "2026.1.0",
        "src_version": 1,
        "address": "192.168.1.42",
        "web_port": None,
        "esp_platform": esp_platform,
        "build_path": build_path,
        "firmware_bin_path": "/build/lite_test/firmware.bin",
        "loaded_integrations": ["api", "logger", "ota", "wifi"],
        "loaded_platforms": [],
        "no_mdns": False,
        "framework": "arduino",
        "core_platform": core_platform,
    }
    storage_path.write_text(json.dumps(data), encoding="utf-8")


def _write_cache(cache_path: Path, body: str | None = None) -> Path:
    """Write the cache file and return it."""
    cache_path.parent.mkdir(parents=True, exist_ok=True)
    cache_path.write_text(body if body is not None else _cache_body(), encoding="utf-8")
    return cache_path


def _set_cache_mtime(cache_path: Path, yaml_path: Path, *, offset: int) -> None:
    """Force the cache file's mtime relative to the source YAML.

    Positive offset → cache is fresh. Negative → cache is stale.
    """
    yaml_stat = yaml_path.stat()
    os.utime(cache_path, (yaml_stat.st_atime, yaml_stat.st_mtime + offset))


@pytest.fixture
def primed_storage(tmp_path: Path) -> Path:
    """YAML + StorageJSON sidecar, no cache yet."""
    yaml_path = tmp_path / "lite_test.yaml"
    yaml_path.write_text("esphome:\n  name: lite_test\n")
    CORE.config_path = yaml_path
    _write_storage(tmp_path / ".esphome" / "storage" / "lite_test.yaml.json")
    return yaml_path


@pytest.fixture
def fresh_cache_files(primed_storage: Path) -> Path:
    """YAML + StorageJSON + cache, all consistent and fresh."""
    storage_dir = primed_storage.parent / ".esphome" / "storage"
    cache = _write_cache(storage_dir / "lite_test.yaml.validated.json")
    _set_cache_mtime(cache, primed_storage, offset=5)
    return primed_storage


def test_compiled_config_path_lives_alongside_sidecar(setup_core: Path) -> None:
    """The cache file shape is predictable from the YAML filename."""
    path = compiled_config_path("device.yaml")
    assert path.name == "device.yaml.validated.json"
    assert path.parent.name == "storage"


def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None:
    """Fresh cache + sidecar → returns config and populates CORE."""
    config = load_compiled_config(fresh_cache_files)

    assert config is not None
    assert config[CONF_ESPHOME][CONF_NAME] == "lite_test"
    assert config[CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q="
    assert config["ota"][0]["password"] == "secret"

    # The fast path loads plain scalars; no per-node source ranges exist.
    assert type(config[CONF_ESPHOME][CONF_NAME]) is str

    # apply_to_core populated exactly what upload/logs read off CORE.
    assert CORE.name == "lite_test"
    assert CORE.build_path == Path("/build/lite_test")
    assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32"
    assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino"
    # upload_using_esptool reads get_esp32_variant() off CORE.data[KEY_ESP32].
    assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32"


def test_load_compiled_config_populates_esp32_variant(tmp_path: Path) -> None:
    """ESP32 variants survive the cache fast path so esptool gets the right --chip."""
    yaml_path = tmp_path / "lite_test.yaml"
    yaml_path.write_text("esphome:\n  name: lite_test\n")
    CORE.config_path = yaml_path

    storage_dir = tmp_path / ".esphome" / "storage"
    _write_storage(storage_dir / "lite_test.yaml.json", esp_platform="ESP32S3")
    cache = _write_cache(storage_dir / "lite_test.yaml.validated.json")
    _set_cache_mtime(cache, yaml_path, offset=5)

    assert load_compiled_config(yaml_path) is not None
    assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32S3"


def test_load_compiled_config_skips_esp32_block_for_other_platforms(
    tmp_path: Path,
) -> None:
    """Non-esp32 targets shouldn't fabricate an esp32 data block."""
    yaml_path = tmp_path / "lite_test.yaml"
    yaml_path.write_text("esphome:\n  name: lite_test\n")
    CORE.config_path = yaml_path

    storage_dir = tmp_path / ".esphome" / "storage"
    _write_storage(
        storage_dir / "lite_test.yaml.json",
        esp_platform="ESP8266",
        core_platform="esp8266",
    )
    cache = _write_cache(storage_dir / "lite_test.yaml.validated.json")
    _set_cache_mtime(cache, yaml_path, offset=5)

    assert load_compiled_config(yaml_path) is not None
    assert KEY_ESP32 not in CORE.data


@pytest.mark.parametrize(
    "scenario",
    ["missing_cache", "stale_cache", "corrupt_cache", "missing_sidecar"],
)
def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None:
    """All non-happy cases return None so the caller falls back."""
    yaml_path = tmp_path / "lite_test.yaml"
    yaml_path.write_text("esphome:\n  name: lite_test\n")
    CORE.config_path = yaml_path
    storage_dir = tmp_path / ".esphome" / "storage"
    cache_path = storage_dir / "lite_test.yaml.validated.json"
    sidecar_path = storage_dir / "lite_test.yaml.json"

    if scenario == "missing_cache":
        pass  # no cache, no sidecar
    elif scenario == "stale_cache":
        _write_storage(sidecar_path)
        _set_cache_mtime(_write_cache(cache_path), yaml_path, offset=-60)
    elif scenario == "corrupt_cache":
        _write_storage(sidecar_path)
        _set_cache_mtime(
            _write_cache(cache_path, '{"v": 1, "config": {'), yaml_path, offset=5
        )
    elif scenario == "missing_sidecar":
        # Cache fresh + parseable, but no StorageJSON → can't populate CORE.
        _set_cache_mtime(_write_cache(cache_path), yaml_path, offset=5)

    assert load_compiled_config(yaml_path) is None


@pytest.mark.parametrize(
    "body",
    [
        pytest.param(
            json.dumps(
                {"v": 999, "esphome": const.__version__, "config": {"esphome": {}}}
            ),
            id="wrong_version",
        ),
        pytest.param(
            json.dumps({"esphome": const.__version__, "config": {"esphome": {}}}),
            id="missing_version",
        ),
        pytest.param(
            json.dumps({"v": 1, "esphome": "2020.1.0", "config": {"esphome": {}}}),
            id="other_esphome_version",
        ),
        pytest.param(
            json.dumps({"v": 1, "config": {"esphome": {}}}),
            id="missing_esphome_version",
        ),
        pytest.param(
            json.dumps(
                {
                    "v": 1,
                    "esphome": const.__version__,
                    "config": ["not", "a", "dict"],
                }
            ),
            id="non_dict_config",
        ),
        pytest.param(
            json.dumps({"v": 1, "esphome": const.__version__}), id="missing_config"
        ),
        pytest.param(json.dumps(["not", "an", "envelope"]), id="non_dict_envelope"),
    ],
)
def test_load_compiled_config_rejects_bad_envelope(
    primed_storage: Path, body: str
) -> None:
    """A foreign or future cache shape falls back instead of half-loading."""
    storage_dir = primed_storage.parent / ".esphome" / "storage"
    cache = _write_cache(storage_dir / "lite_test.yaml.validated.json", body)
    _set_cache_mtime(cache, primed_storage, offset=5)

    assert load_compiled_config(primed_storage) is None


def test_load_ignores_legacy_yaml_cache(primed_storage: Path) -> None:
    """A fresh pre-JSON ``.validated.yaml`` alone can't drive the fast path."""
    storage_dir = primed_storage.parent / ".esphome" / "storage"
    legacy = _write_cache(
        storage_dir / "lite_test.yaml.validated.yaml", "esphome:\n  name: lite_test\n"
    )
    _set_cache_mtime(legacy, primed_storage, offset=5)

    assert load_compiled_config(primed_storage) is None


def test_save_removes_stale_legacy_yaml_cache(tmp_path: Path) -> None:
    """A successful save leaves only the JSON cache behind."""
    CORE.config_path = tmp_path / "lite_test.yaml"
    legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml"
    legacy.parent.mkdir(parents=True, exist_ok=True)
    legacy.write_text("esphome:\n  name: lite_test\n")

    save_compiled_config({"esphome": {"name": "lite_test"}})

    assert compiled_config_path("lite_test.yaml").is_file()
    assert not legacy.exists()


def test_save_removes_legacy_yaml_even_when_write_fails(tmp_path: Path) -> None:
    """The secret-bearing legacy cache goes away regardless of write outcome."""
    CORE.config_path = tmp_path / "lite_test.yaml"
    legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml"
    legacy.parent.mkdir(parents=True, exist_ok=True)
    legacy.write_text("esphome:\n  name: lite_test\n")

    with patch("esphome.compiled_config.write_file", side_effect=RuntimeError("boom")):
        save_compiled_config({"esphome": {"name": "lite_test"}})

    assert not legacy.exists()
    assert not compiled_config_path("lite_test.yaml").exists()


def test_save_warns_when_legacy_cache_unremovable(
    tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
    """A secret-bearing legacy file that won't unlink warns; the write proceeds."""
    CORE.config_path = tmp_path / "lite_test.yaml"
    legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml"
    legacy.parent.mkdir(parents=True, exist_ok=True)
    legacy.mkdir()  # unlink() on a directory raises OSError

    with caplog.at_level("WARNING", logger="esphome.compiled_config"):
        save_compiled_config({"esphome": {"name": "lite_test"}})

    assert "legacy validated-config cache" in caplog.text
    assert compiled_config_path("lite_test.yaml").is_file()


@pytest.mark.parametrize("command", ["upload", "logs"])
def test_run_esphome_upload_and_logs_use_cache_when_fresh(
    command: str,
    fresh_cache_files: Path,
    caplog: pytest.LogCaptureFixture,
) -> None:
    """upload/logs skip read_config() when the cache is fresh."""
    captured: dict = {}

    def _stub(_args, config):
        captured["config"] = config
        return 0

    with (
        caplog.at_level("INFO", logger="esphome.__main__"),
        patch("esphome.config.read_config") as mock_read,
        patch.dict("esphome.__main__.POST_CONFIG_ACTIONS", {command: _stub}),
    ):
        assert run_esphome(["esphome", command, str(fresh_cache_files)]) == 0

    mock_read.assert_not_called()
    assert captured["config"][CONF_ESPHOME][CONF_NAME] == "lite_test"
    assert captured["config"][CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q="
    # The success-branch log line is part of the patch; assert on it so
    # branch coverage stays unambiguous in CI.
    assert "Loaded validated config cache" in caplog.text


@pytest.mark.parametrize("command", ["upload", "logs"])
def test_run_esphome_upload_and_logs_fall_back_when_no_cache(
    tmp_path: Path, command: str
) -> None:
    """Without a cache, the dispatcher falls back to read_config()."""
    yaml_path = tmp_path / "lite_test.yaml"
    yaml_path.write_text("esphome:\n  name: lite_test\n")

    with (
        patch("esphome.config.read_config", return_value=None) as mock_read,
        patch.dict(
            "esphome.__main__.POST_CONFIG_ACTIONS",
            {command: lambda args, config: 0},
        ),
    ):
        assert run_esphome(["esphome", command, str(yaml_path)]) == 2

    mock_read.assert_called_once()


def _storage_fixture(tmp_path: Path) -> StorageJSON:
    """A loaded StorageJSON instance matching _write_storage's contents."""
    fixture = tmp_path / "fixture_storage.json"
    _write_storage(fixture)
    return StorageJSON.load(fixture)


def _bare_yaml(tmp_path: Path) -> Path:
    """A minimal YAML with CORE.config_path pointed at it."""
    yaml_path = tmp_path / "lite_test.yaml"
    yaml_path.write_text("esphome:\n  name: lite_test\n")
    CORE.config_path = yaml_path
    return yaml_path


@contextmanager
def _fallback_run(command: str = "upload", **from_core_kwargs) -> Any:
    """Patch the fallback path's collaborators for a run_esphome call.

    Without kwargs, from_esphome_core stays real (yielded mock is None).
    """
    with (
        patch(
            "esphome.config.read_config",
            return_value={"esphome": {"name": "lite_test"}},
        ) as mock_read,
        patch.dict(
            "esphome.__main__.POST_CONFIG_ACTIONS",
            {command: lambda args, config: 0},
        ),
    ):
        if not from_core_kwargs:
            yield mock_read, None
            return
        with patch.object(
            StorageJSON, "from_esphome_core", **from_core_kwargs
        ) as mock_from_core:
            yield mock_read, mock_from_core


@pytest.mark.parametrize("command", ["upload", "logs"])
def test_run_esphome_fallback_writes_sidecar_and_cache_without_sidecar(
    tmp_path: Path, command: str
) -> None:
    """A never-compiled config caches on its first upload/logs run: the
    fallback writes the StorageJSON sidecar itself (load_compiled_config
    needs it), so the second run hits the fast path."""
    yaml_path = _bare_yaml(tmp_path)
    storage_dir = tmp_path / ".esphome" / "storage"

    with _fallback_run(command, return_value=_storage_fixture(tmp_path)) as (
        mock_read,
        mock_from_core,
    ):
        assert run_esphome(["esphome", command, str(yaml_path)]) == 0
        mock_from_core.assert_called_once()
        assert (storage_dir / "lite_test.yaml.validated.json").exists()
        storage = StorageJSON.load(storage_dir / "lite_test.yaml.json")
        assert storage is not None
        # No compile happened, so the sidecar must not claim one.
        assert mock_from_core.call_args.kwargs == {"claim_build": False}

        # The second run loads the cache instead of re-validating.
        assert run_esphome(["esphome", command, str(yaml_path)]) == 0
        mock_read.assert_called_once()


# as_dict serialized unset paths as str(None) until 2026.9; files
# written by those wizards are still on disk.
_WIZARD_SIDECAR_CASES = pytest.mark.parametrize(
    "wizard_kwargs",
    [
        {"esp_platform": None, "core_platform": None, "build_path": None},
        {"build_path": None},
        {"build_path": "None"},
    ],
    ids=["legacy_wizard", "modern_wizard", "none_string_wizard"],
)


def _prime_core(tmp_path: Path) -> None:
    """Set the post-validation CORE state from_esphome_core reads."""
    CORE.name = "lite_test"
    CORE.build_path = tmp_path / "build" / "lite_test"
    CORE.data[KEY_CORE] = {
        KEY_TARGET_PLATFORM: "esp8266",
        KEY_TARGET_FRAMEWORK: "arduino",
    }


@_WIZARD_SIDECAR_CASES
def test_run_esphome_fallback_completes_wizard_sidecar(
    tmp_path: Path, wizard_kwargs: dict[str, Any]
) -> None:
    """A wizard-written sidecar can't drive the fast path (no build_path;
    older wizards also no platform fields); the fallback rewrites it from
    CORE so the cache loads on the next run."""
    yaml_path = _bare_yaml(tmp_path)
    storage_dir = tmp_path / ".esphome" / "storage"
    _write_storage(storage_dir / "lite_test.yaml.json", **wizard_kwargs)

    with _fallback_run(return_value=_storage_fixture(tmp_path)) as (_, mock_from_core):
        assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0

    mock_from_core.assert_called_once()
    storage = StorageJSON.load(storage_dir / "lite_test.yaml.json")
    assert storage is not None and storage.core_platform == "esp32"
    # What the wizard recorded about a build (nothing, or a real one)
    # carries through instead of being stamped with this run's values.
    assert storage.esphome_version == "2026.1.0"
    assert load_compiled_config(yaml_path) is not None


def test_run_esphome_fallback_skips_cache_when_sidecar_write_fails(
    tmp_path: Path,
) -> None:
    """A failed sidecar write is non-fatal and skips the cache save too:
    without the sidecar the cache could never be loaded back, so writing
    it would only leave resolved secrets on disk."""
    yaml_path = _bare_yaml(tmp_path)

    with (
        _fallback_run(side_effect=RuntimeError("boom")),
        patch("esphome.compiled_config.save_compiled_config") as mock_save,
    ):
        assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0

    mock_save.assert_not_called()
    assert not (tmp_path / ".esphome" / "storage" / "lite_test.yaml.json").exists()


def test_run_esphome_fallback_write_failure_takes_io_branch(
    tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
    """StorageJSON.save raises EsphomeError (write_file wraps OSError into
    it), which must land in the plain I/O warning, not the traceback
    branch for structural bugs."""
    yaml_path = _bare_yaml(tmp_path)

    with (
        _fallback_run(return_value=_storage_fixture(tmp_path)),
        patch.object(StorageJSON, "save", side_effect=EsphomeError("boom")),
        patch("esphome.compiled_config.save_compiled_config") as mock_save,
        caplog.at_level("WARNING", logger="esphome.compiled_config"),
    ):
        assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0

    mock_save.assert_not_called()
    assert "Could not refresh the storage sidecar" in caplog.text
    assert "Unexpected error" not in caplog.text


def test_run_esphome_fallback_leaves_unreadable_sidecar_alone(tmp_path: Path) -> None:
    """A present-but-corrupt sidecar is not overwritten: it may hold a real
    build's metadata, and replacing it would suppress the next compile's
    clean of a possibly incoherent build tree. The cache save is skipped."""
    yaml_path = _bare_yaml(tmp_path)
    storage_dir = tmp_path / ".esphome" / "storage"
    sidecar = storage_dir / "lite_test.yaml.json"
    sidecar.parent.mkdir(parents=True, exist_ok=True)
    sidecar.write_text("{truncated", encoding="utf-8")

    with _fallback_run(return_value=None) as (_, mock_from_core):
        assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0

    mock_from_core.assert_not_called()
    assert sidecar.read_text(encoding="utf-8") == "{truncated"
    assert not (storage_dir / "lite_test.yaml.validated.json").exists()


def test_run_esphome_fallback_skips_cache_when_rebuilt_sidecar_incomplete(
    tmp_path: Path,
) -> None:
    """If the rebuilt sidecar would still be incomplete, nothing is written:
    the cache could never be loaded back, so saving it would only rewrite
    resolved secrets on every run."""
    yaml_path = _bare_yaml(tmp_path)
    storage_dir = tmp_path / ".esphome" / "storage"

    incomplete = tmp_path / "incomplete_storage.json"
    _write_storage(incomplete, build_path=None)

    with _fallback_run(return_value=StorageJSON.load(incomplete)):
        assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0

    assert not (storage_dir / "lite_test.yaml.json").exists()
    assert not (storage_dir / "lite_test.yaml.validated.json").exists()


def test_run_esphome_fallback_sidecar_records_platformio_toolchain(
    tmp_path: Path,
) -> None:
    """The toolchain fallback runs before the sidecar write, so platforms
    whose validators leave CORE.toolchain unset record the same
    "platformio" a compile writes, not null."""
    yaml_path = _bare_yaml(tmp_path)
    _prime_core(tmp_path)
    assert CORE.toolchain is None

    with _fallback_run():
        assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0

    storage = StorageJSON.load(
        tmp_path / ".esphome" / "storage" / "lite_test.yaml.json"
    )
    assert storage is not None
    assert storage.toolchain == "platformio"


@pytest.mark.parametrize("existing_sidecar", [None, "wizard"])
def test_run_esphome_fallback_skips_sidecar_when_build_tree_exists(
    tmp_path: Path, existing_sidecar: str | None
) -> None:
    """An existing build tree with a missing or wizard-only sidecar keeps
    it that way: the mismatch is what makes the next compile wipe the
    unknown tree, so the fallback writes nothing and skips the cache."""
    yaml_path = _bare_yaml(tmp_path)
    _prime_core(tmp_path)
    CORE.build_path.mkdir(parents=True)
    storage_dir = tmp_path / ".esphome" / "storage"
    if existing_sidecar == "wizard":
        _write_storage(storage_dir / "lite_test.yaml.json", build_path=None)
        wizard_body = (storage_dir / "lite_test.yaml.json").read_text(encoding="utf-8")

    with _fallback_run(return_value=_storage_fixture(tmp_path)) as (_, mock_from_core):
        assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0

    mock_from_core.assert_not_called()
    assert not (storage_dir / "lite_test.yaml.validated.json").exists()
    if existing_sidecar == "wizard":
        sidecar_body = (storage_dir / "lite_test.yaml.json").read_text(encoding="utf-8")
        assert sidecar_body == wizard_body
    else:
        assert not (storage_dir / "lite_test.yaml.json").exists()


def test_save_compiled_config_and_sidecar_builds_real_sidecar(tmp_path: Path) -> None:
    """Drive the real from_esphome_core on the fallback path: the
    post-validation CORE state yields a complete, loadable sidecar."""
    yaml_path = _bare_yaml(tmp_path)
    _prime_core(tmp_path)
    CORE.config = {CONF_ESPHOME: {CONF_NAME: "lite_test"}}
    CORE.toolchain = Toolchain.PLATFORMIO

    save_compiled_config_and_sidecar(CORE.config)

    storage = StorageJSON.load(
        tmp_path / ".esphome" / "storage" / "lite_test.yaml.json"
    )
    assert storage is not None
    assert storage.core_platform == "esp8266"
    assert storage.build_path is not None
    # No compile happened, so the sidecar must not claim one.
    assert storage.esphome_version is None
    assert storage.firmware_bin_path is None
    assert load_compiled_config(yaml_path) is not None


@pytest.mark.parametrize("command", ["upload", "logs"])
def test_run_esphome_upload_and_logs_refresh_cache_on_fallback(
    tmp_path: Path, command: str
) -> None:
    """A stale-cache fallback rewrites the cache so the next call hits
    the fast path. Without this, every upload/logs after a YAML edit
    pays for read_config() until the next compile rewrites the cache."""
    yaml_path = tmp_path / "lite_test.yaml"
    yaml_path.write_text("esphome:\n  name: lite_test\n")
    CORE.config_path = yaml_path

    storage_dir = tmp_path / ".esphome" / "storage"
    _write_storage(storage_dir / "lite_test.yaml.json")
    cache = _write_cache(storage_dir / "lite_test.yaml.validated.json")
    _set_cache_mtime(cache, yaml_path, offset=-60)  # stale

    fresh_config = {"esphome": {"name": "lite_test"}, "logger": {}}

    with (
        patch("esphome.config.read_config", return_value=fresh_config),
        patch(
            "esphome.compiled_config.save_compiled_config", wraps=save_compiled_config
        ) as mock_save,
        patch.object(StorageJSON, "from_esphome_core") as mock_from_core,
        patch.dict(
            "esphome.__main__.POST_CONFIG_ACTIONS",
            {command: lambda args, config: 0},
        ),
    ):
        assert run_esphome(["esphome", command, str(yaml_path)]) == 0

    mock_save.assert_called_once_with(fresh_config)
    # The compile-written sidecar is complete; the fallback leaves it alone.
    mock_from_core.assert_not_called()
    # mtime is now newer than the source YAML, so a follow-up call hits
    # the fast path instead of repeating read_config.
    assert cache.stat().st_mtime >= yaml_path.stat().st_mtime


def test_run_esphome_upload_with_substitution_does_not_refresh_cache(
    fresh_cache_files: Path,
) -> None:
    """`-s` substitutions skip the cache on both read and write -- saving
    here would clobber the cache with a substitution-specific config."""
    with (
        patch("esphome.config.read_config", return_value={"esphome": {}}),
        patch("esphome.compiled_config.save_compiled_config") as mock_save,
        patch.dict(
            "esphome.__main__.POST_CONFIG_ACTIONS",
            {"upload": lambda args, config: 0},
        ),
    ):
        run_esphome(["esphome", "-s", "var", "val", "upload", str(fresh_cache_files)])

    mock_save.assert_not_called()


def test_run_esphome_compile_does_not_refresh_cache_via_fallback(
    fresh_cache_files: Path,
) -> None:
    """Compile writes the cache through update_storage_json, not via the
    upload/logs fallback path -- the fallback save would skip the
    storage_should_clean check."""
    with (
        patch("esphome.config.read_config", return_value={"esphome": {}}),
        patch("esphome.compiled_config.save_compiled_config") as mock_save,
        patch.dict(
            "esphome.__main__.POST_CONFIG_ACTIONS",
            {"compile": lambda args, config: 0},
        ),
    ):
        run_esphome(["esphome", "compile", str(fresh_cache_files)])

    mock_save.assert_not_called()


def test_run_esphome_upload_with_substitution_skips_cache(
    fresh_cache_files: Path,
) -> None:
    """`-s key value` forces a fresh validation -- the cache was written
    against the prior substitution set, so reusing it would silently
    ignore the override."""
    with (
        patch("esphome.config.read_config", return_value=None) as mock_read,
        patch.dict(
            "esphome.__main__.POST_CONFIG_ACTIONS",
            {"upload": lambda args, config: 0},
        ),
    ):
        run_esphome(["esphome", "-s", "var", "val", "upload", str(fresh_cache_files)])

    mock_read.assert_called_once()


def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None:
    """The compile subcommand always re-validates -- it's what writes the cache."""
    with (
        patch("esphome.config.read_config", return_value=None) as mock_read,
        patch.dict(
            "esphome.__main__.POST_CONFIG_ACTIONS",
            {"compile": lambda args, config: 0},
        ),
    ):
        run_esphome(["esphome", "compile", str(fresh_cache_files)])

    mock_read.assert_called_once()


def test_save_compiled_config_writes_cache(tmp_path: Path) -> None:
    """`save_compiled_config` writes the JSON envelope next to the sidecar."""
    CORE.config_path = tmp_path / "lite_test.yaml"
    save_compiled_config({"esphome": {"name": "lite_test"}, "logger": {}})

    cache_path = compiled_config_path("lite_test.yaml")
    assert cache_path.is_file()
    envelope = json.loads(cache_path.read_text())
    assert envelope["v"] == 1
    assert envelope["esphome"] == const.__version__
    assert envelope["config"] == {"esphome": {"name": "lite_test"}, "logger": {}}


def test_save_compiled_config_swallows_write_errors(
    tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
    """Failures during the write are non-fatal -- a bad cache just means
    the next fast path falls back to read_config()."""
    CORE.config_path = tmp_path / "lite_test.yaml"
    with patch("esphome.compiled_config.write_file", side_effect=RuntimeError("boom")):
        save_compiled_config({"esphome": {"name": "lite_test"}})
    assert not compiled_config_path("lite_test.yaml").exists()


def test_save_stringifies_unknown_values(tmp_path: Path) -> None:
    """A type with no dedicated encoding stores its string form."""

    class Weird:
        def __str__(self) -> str:
            return "weird-str"

    CORE.config_path = tmp_path / "lite_test.yaml"
    save_compiled_config({"esphome": {"name": "lite_test", "weird": Weird()}})
    envelope = json.loads(compiled_config_path("lite_test.yaml").read_text())
    assert envelope["config"]["esphome"]["weird"] == "weird-str"


def test_save_skips_cache_on_unserializable_key(tmp_path: Path) -> None:
    """A non-basic dict key aborts the write; the fast path falls back."""
    CORE.config_path = tmp_path / "lite_test.yaml"
    save_compiled_config({"esphome": {("a", "b"): "lite_test"}})
    assert not compiled_config_path("lite_test.yaml").exists()


def _normalize(value: Any) -> Any:
    """Make Lambda comparable; everything else compares by value already."""
    if isinstance(value, Lambda):
        return ("__lambda__", value.value)
    if isinstance(value, dict):
        return {k: _normalize(v) for k, v in value.items()}
    if isinstance(value, (list, tuple)):
        return [_normalize(v) for v in value]
    return value


def _round_trip_config() -> OrderedDict:
    """A post-validation shaped config exercising every representer type."""
    return OrderedDict(
        {
            "esphome": OrderedDict(
                {
                    "name": "lite_test",
                    "build_path": Path("/build/lite_test"),
                    "on_boot": [
                        OrderedDict(
                            {
                                "trigger_id": ID("trigger_1", type="Trigger"),
                                "then": [{"lambda": Lambda('ESP_LOGD("t", "x");')}],
                            }
                        )
                    ],
                }
            ),
            "wifi": OrderedDict(
                {
                    "id": ID("wifi_id", type="WiFiComponent"),
                    "reboot_timeout": TimePeriodMilliseconds(milliseconds=900000),
                    "use_address": IPv4Address("192.168.1.42"),
                    "subnet": IPv4Network("192.168.1.0/24"),
                    "mac": MACAddress(0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01),
                }
            ),
            "misc": OrderedDict(
                {
                    "uuid": UUID("12345678-1234-5678-1234-567812345678"),
                    "toolchain": Toolchain.PLATFORMIO,
                    "hex": HexInt(0x1234),
                    "levels": (1, 2.5, True, None),
                    "empty": {},
                }
            ),
        }
    )


def test_cache_round_trip_matches_yaml_cache(primed_storage: Path) -> None:
    """The JSON cache loads the same tree the YAML cache used to."""
    config = _round_trip_config()
    save_compiled_config(config)
    from_json = load_compiled_config(primed_storage)
    assert from_json is not None

    yaml_cache = primed_storage.parent / "dumped.yaml"
    yaml_cache.write_text(yaml_util.dump(config, show_secrets=True))
    from_yaml = yaml_util.load_yaml(
        yaml_cache, clear_secrets=False, track_document_range=False
    )

    assert _normalize(from_json) == _normalize(from_yaml)


def test_lambda_sentinel_round_trips(primed_storage: Path) -> None:
    """A !lambda body comes back as a Lambda with the same source."""
    body = 'id(sensor_1).publish_state(42);\nreturn "multi\\nline";'
    save_compiled_config(
        {
            "esphome": {"name": "lite_test"},
            "script": [{"then": [{"lambda": Lambda(body)}]}],
        }
    )

    config = load_compiled_config(primed_storage)
    assert config is not None
    revived = config["script"][0]["then"][0]["lambda"]
    assert isinstance(revived, Lambda)
    assert revived.value == body


def test_object_hook_requires_exact_shape(primed_storage: Path) -> None:
    """Only the exact one-key string-valued sentinel revives a Lambda."""
    storage_dir = primed_storage.parent / ".esphome" / "storage"
    config = {
        "esphome": {"name": "lite_test"},
        "extra_key": {_LAMBDA_KEY: "x", "y": 1},
        "non_str": {_LAMBDA_KEY: 5},
    }
    cache = _write_cache(
        storage_dir / "lite_test.yaml.validated.json", _cache_body(config)
    )
    _set_cache_mtime(cache, primed_storage, offset=5)

    loaded = load_compiled_config(primed_storage)
    assert loaded is not None
    assert loaded["extra_key"] == {_LAMBDA_KEY: "x", "y": 1}
    assert loaded["non_str"] == {_LAMBDA_KEY: 5}


def test_int_keys_coerce_to_strings(primed_storage: Path) -> None:
    """Non-str basic keys stringify; validated configs only use string keys."""
    save_compiled_config({"esphome": {"name": "lite_test"}, "table": {1: "a", 2: "b"}})

    config = load_compiled_config(primed_storage)
    assert config is not None
    assert config["table"] == {"1": "a", "2": "b"}


@_WIZARD_SIDECAR_CASES
def test_load_compiled_config_rejects_wizard_only_sidecar(
    tmp_path: Path, wizard_kwargs: dict[str, Any]
) -> None:
    """A wizard-written sidecar (no build_path; older wizards also no
    platform fields) can't drive upload/logs, so the fast path falls back."""
    yaml_path = _bare_yaml(tmp_path)
    storage_dir = tmp_path / ".esphome" / "storage"
    _write_storage(storage_dir / "lite_test.yaml.json", **wizard_kwargs)
    cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.json")
    _set_cache_mtime(cache_path, yaml_path, offset=5)

    assert load_compiled_config(yaml_path) is None
