"""Tests for part_dict / obj_dict / part_schema / obj_schema mapping contracts.

These guard the dict-merge refactor: the dict helpers must keep returning the
same logical mapping as the chained-extend version produced, and the
corresponding Schema(...) wrappers must accept and reject the same configs.
"""

from __future__ import annotations

from collections.abc import Generator

import pytest
import voluptuous as vol

from esphome import config_validation as cv
from esphome.components.lvgl import defines as df, schemas as lvgl_schemas
from esphome.components.lvgl.schemas import (
    ALIGN_TO_SCHEMA,
    FLAG_SCHEMA,
    FULL_STYLE_SCHEMA,
    STATE_SCHEMA,
    STYLE_SCHEMA,
    WIDGET_TYPES,
    automation_schema,
    obj_dict,
    obj_schema,
    part_dict,
    part_schema,
    theme_schema,
    theme_update_schema,
)
from esphome.components.lvgl.types import LvType
from esphome.components.lvgl.widgets import WidgetType


@pytest.fixture(autouse=True)
def _clear_obj_dict_cache() -> Generator[None]:
    cache = getattr(lvgl_schemas, "_OBJ_DICT_CACHE", None)
    if cache is not None:
        cache.clear()
    # The lazily-built theme schema is cached on _build_theme_schema; clear it
    # too so each test starts from a clean slate.
    build_theme = getattr(lvgl_schemas, "_build_theme_schema", None)
    if build_theme is not None and hasattr(build_theme, "cache_clear"):
        build_theme.cache_clear()
    yield
    if cache is not None:
        cache.clear()
    if build_theme is not None and hasattr(build_theme, "cache_clear"):
        build_theme.cache_clear()


def _marker_names(mapping) -> set[str]:
    """Return the underlying string names of every voluptuous Marker key."""
    names: set[str] = set()
    for key in mapping:
        if isinstance(key, vol.Marker):
            schema = key.schema
            if isinstance(schema, str):
                names.add(schema)
    return names


def _widget_type(name: str = "obj"):
    wt = WIDGET_TYPES.get(name)
    assert wt is not None, f"widget type {name!r} not registered"
    return wt


def test_part_dict_includes_state_flag_and_part_keys() -> None:
    parts = ("indicator", "knob")
    keys = _marker_names(part_dict(parts))

    assert {"indicator", "knob"} <= keys
    assert _marker_names(STATE_SCHEMA.schema) <= keys
    assert _marker_names(FLAG_SCHEMA.schema) <= keys


def test_obj_dict_extends_part_dict_with_align_automation_state_group() -> None:
    wt = _widget_type("obj")
    part_keys = _marker_names(part_dict(wt.parts))
    obj_keys = _marker_names(obj_dict(wt))

    assert part_keys <= obj_keys
    assert _marker_names(ALIGN_TO_SCHEMA) <= obj_keys
    assert _marker_names(automation_schema(wt.w_type)) <= obj_keys
    assert {"state", "group"} <= obj_keys


def test_obj_dict_is_memoized_by_widget_type() -> None:
    wt = _widget_type("obj")
    first = obj_dict(wt)
    second = obj_dict(wt)
    assert first is second
    # Different widget type, different dict.
    assert obj_dict(_widget_type("label")) is not first


def test_part_schema_round_trips_known_state_and_part_settings() -> None:
    schema = part_schema(("indicator",))
    out = schema(
        {
            "bg_color": 0x112233,
            "checked": {"bg_color": 0x445566},
            "indicator": {"bg_color": 0x778899},
        }
    )
    assert out["bg_color"] == 0x112233
    assert out["checked"]["bg_color"] == 0x445566
    assert out["indicator"]["bg_color"] == 0x778899


def test_part_schema_rejects_unknown_part() -> None:
    schema = part_schema(("indicator",))
    with pytest.raises(vol.Invalid):
        schema({"definitely_not_a_part": {}})


@pytest.mark.parametrize("name", sorted(WIDGET_TYPES))
def test_obj_schema_accepts_empty_config_for_every_widget_type(name: str) -> None:
    obj_schema(_widget_type(name))({})


def test_obj_schema_accepts_align_to_and_state_group() -> None:
    schema = obj_schema(_widget_type("obj"))
    out = schema(
        {
            df.CONF_ALIGN_TO: {
                "id": "some_other_widget",
                df.CONF_ALIGN: "TOP_LEFT",
            },
            "state": {"checked": True},
        }
    )
    assert out[df.CONF_ALIGN_TO][df.CONF_ALIGN] == "LV_ALIGN_TOP_LEFT"
    assert out["state"]["checked"] is True


def test_obj_schema_rejects_unknown_top_level_key() -> None:
    with pytest.raises(vol.Invalid):
        obj_schema(_widget_type("obj"))({"definitely_not_a_real_key": 1})


def test_part_schema_returns_cv_schema_for_extend_callers() -> None:
    schema = part_schema(("indicator",))
    extended = schema.extend({cv.Optional("extra_key"): cv.string})
    out = extended({"extra_key": "value", "bg_color": 0xAABBCC})
    assert out["extra_key"] == "value"
    assert out["bg_color"] == 0xAABBCC


def test_obj_schema_returns_cv_schema_for_extend_callers() -> None:
    schema = obj_schema(_widget_type("obj"))
    extended = schema.extend({cv.Optional("extra_key"): cv.string})
    extended({"extra_key": "value"})


@pytest.mark.parametrize(
    "schema",
    [STATE_SCHEMA, FLAG_SCHEMA, STYLE_SCHEMA, FULL_STYLE_SCHEMA],
)
def test_spread_sources_carry_no_extra_schemas(schema: cv.Schema) -> None:
    # part_dict / obj_dict reach into .schema and rebuild via cv.Schema(...),
    # which silently drops _extra_schemas and any non-default extra/required.
    # Lock the invariant so a future add_extra() on these sources fails CI
    # instead of quietly removing validation from part/obj/theme schemas.
    assert not schema._extra_schemas
    assert schema.extra is vol.PREVENT_EXTRA
    assert schema.required is False


def test_theme_schema_merges_obj_dict_and_full_style_props() -> None:
    # theme_schema is the riskiest merge: obj_dict(w) and FULL_STYLE_SCHEMA.schema
    # share many STYLE_SCHEMA marker instances. Exercise the merged schema
    # end-to-end with one key from each side (a STATE_SCHEMA part from obj_dict
    # and a FULL_STYLE-only property) to lock the behaviour against future
    # regressions in either source.
    out = theme_schema(
        {
            df.CONF_DARK_MODE: True,
            "obj": {
                "bg_color": 0x112233,
                "checked": {"bg_color": 0x445566},
                df.CONF_PAD_ROW: 4,
                df.CONF_GRID_CELL_X_ALIGN: "CENTER",
            },
        }
    )
    assert out[df.CONF_DARK_MODE] is True
    obj_out = out["obj"]
    assert obj_out["bg_color"] == 0x112233
    assert obj_out["checked"]["bg_color"] == 0x445566
    assert obj_out[df.CONF_PAD_ROW] == 4
    assert obj_out[df.CONF_GRID_CELL_X_ALIGN] == "LV_GRID_ALIGN_CENTER"


def test_theme_schema_self_heals_when_a_widget_type_is_registered_later() -> None:
    # _build_theme_schema is functools.cached on a snapshot of WIDGET_TYPES.
    # any_widget_schema explicitly supports external components registering
    # widgets lazily, and the device builder revalidates in-process, so a
    # widget registered after first use must invalidate the cached snapshot.
    theme_schema({df.CONF_DARK_MODE: True})  # populate the cache

    name = "test_self_heal_widget"
    assert name not in WIDGET_TYPES
    # is_mock=True skips registration side-effects; insert into WIDGET_TYPES
    # manually so the next theme call sees the new entry.
    WIDGET_TYPES[name] = WidgetType(name, LvType("test_fake_t"), (), is_mock=True)
    try:
        out = theme_schema({df.CONF_DARK_MODE: False, name: {"bg_color": 0x010203}})
        assert out[name]["bg_color"] == 0x010203
    finally:
        WIDGET_TYPES.pop(name, None)


@pytest.mark.parametrize(
    ("config", "expected_path"),
    [
        ({"button": {"styles": ["foo"]}}, ["button", "styles"]),
        (
            {"button": {"pressed": {"styles": ["foo"]}}},
            ["button", "pressed", "styles"],
        ),
        (
            {"arc": {"indicator": {"styles": ["foo"]}}},
            ["arc", "indicator", "styles"],
        ),
        (
            {"arc": {"indicator": {"pressed": {"styles": ["foo"]}}}},
            ["arc", "indicator", "pressed", "styles"],
        ),
    ],
)
def test_theme_schema_rejects_styles_key(
    config: dict, expected_path: list[str]
) -> None:
    # `styles:` (references to named styles) is accepted by FULL_STYLE_SCHEMA
    # but silently dropped by style_set when building a theme's hidden style
    # -- it only walks ALL_STYLES. Reject it instead of quietly doing nothing,
    # at the top level and when nested under a part and/or state.
    with pytest.raises(vol.Invalid, match="'styles:' is not allowed") as exc_info:
        theme_schema(config)
    assert exc_info.value.path == expected_path


def test_theme_update_schema_rejects_styles_key() -> None:
    with pytest.raises(vol.Invalid, match="'styles:' is not allowed") as exc_info:
        theme_update_schema({"label": {"styles": ["foo"]}})
    assert exc_info.value.path == ["label", "styles"]


def test_theme_update_schema_does_not_request_untargeted_main_default() -> None:
    # collect_parts() unconditionally seeds a main/default entry even when
    # only a specific state (here "pressed") was targeted -- registering a
    # request for that spurious entry would make theme_to_code create an
    # unused, empty style and attach it to every widget of this type.
    theme_update_schema({"label": {"pressed": {"text_color": 0x010203}}})
    assert df.get_theme_update_requests()["label"] == {("main", "pressed"): None}


def test_theme_update_schema_requests_explicit_main_default() -> None:
    theme_update_schema({"label": {"text_color": 0x010203}})
    assert df.get_theme_update_requests()["label"] == {("main", "default"): None}


@pytest.mark.parametrize(
    "schema",
    [STATE_SCHEMA, FLAG_SCHEMA, STYLE_SCHEMA, FULL_STYLE_SCHEMA],
)
def test_spread_sources_have_no_top_level_marker_defaults(schema: cv.Schema) -> None:
    # theme_schema merges obj_dict(w) with FULL_STYLE_SCHEMA.schema; on a key
    # collision, dict-spread keeps the first source's marker (and its default)
    # but the last source's value, whereas .extend() would take both from the
    # later source. The two are equivalent today because the overlapping
    # markers are the same instances (both derive from STYLE_SCHEMA) and none
    # carry a top-level default. Lock that so a future divergent default would
    # fail CI rather than silently drift the merged validation.
    offenders = [
        marker.schema
        for marker in schema.schema
        if isinstance(marker, vol.Optional) and marker.default is not vol.UNDEFINED
    ]
    assert not offenders, f"top-level Optional with default: {offenders}"
