"""Animation tests using only the generated LIN API + FrameIO.

Parallels :mod:`test_mum_alm_animation` but imports **nothing** from
``alm_helpers`` — frame and signal names, state values, encoding-type
constants, and tolerances all come from the generated ``_generated.lin_api``
module (or are declared locally in this file).

Why this file exists:

- It's a worked example of what tests look like when they go straight
  through the generated layer.
- It makes the trade-off concrete. The patterns ``AlmTester`` provides
  (``force_off``, ``wait_for_state``, ``measure_animating_window``,
  ``assert_pwm_matches_rgb``) reappear in this file as module-level
  helpers because they can't be derived from the LDF — they're test
  intent, not schema.
- It serves as a reference for "what does the generated layer give you
  on its own" before deciding whether a future ECU needs its own
  ``<ecu>_helpers.py``.

If you're writing a *new* ALM test that needs these patterns, prefer the
``alm_helpers.AlmTester`` path — the patterns are reused across the suite
and belong in one place. This file deliberately duplicates them to
demonstrate the seam.
"""
from __future__ import annotations

import time
from typing import Optional

import pytest


from frame_io import FrameIO
from vendor.rgb_to_pwm import compute_pwm

from _generated.lin_api import (
    AlmReqA,
    AlmStatus,
    ConfigFrame,
    PwmFrame,
    PwmWoComp,
    TjFrame,
    LedState,
    Mode,
    Update,
)


pytestmark = [pytest.mark.ANM]


# --- cadences / tolerances (not in the LDF) --------------------------------
# These are test-bench choices, not schema. They mirror the values in
# alm_helpers.py:40-53 and exist here only because this file is a worked
# example of avoiding the alm_helpers import.
STATE_POLL_INTERVAL = 0.05      # 50 ms (5 LIN periods)
STATE_TIMEOUT_DEFAULT = 1.0
PWM_SETTLE_SECONDS = 0.1        # 100 ms — TX-buffer refresh
FORCE_OFF_SETTLE_SECONDS = 0.4
KELVIN_TO_CELSIUS_OFFSET = 273.15
PWM_ABS_TOL = 3277              # ±5% of 16-bit full scale
PWM_REL_TOL = 0.05


# --- module-local semantic helpers -----------------------------------------
# These mirror AlmTester's methods. They live here only because this file
# is the "no alm_helpers" reference. New code should use AlmTester instead.


def _force_off(fio: FrameIO, nad: int) -> None:
    AlmReqA.send(
        fio,
        AmbLightColourRed=0, AmbLightColourGreen=0, AmbLightColourBlue=0,
        AmbLightIntensity=0,
        AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE,
        AmbLightMode=Mode.IMMEDIATE_SETPOINT,
        AmbLightDuration=0,
        AmbLightLIDFrom=nad, AmbLightLIDTo=nad,
    )
    time.sleep(FORCE_OFF_SETTLE_SECONDS)


def _read_led_state(fio: FrameIO, timeout: float = 0.2) -> int:
    """Read ALM_Status.ALMLEDState; -1 on timeout."""
    decoded = AlmStatus.receive(fio, timeout=timeout)
    if decoded is None:
        return -1
    return int(decoded.get("ALMLEDState", -1))


def _wait_for_state(
    fio: FrameIO, target: int, timeout: float
) -> tuple[bool, float, list[int]]:
    seen: list[int] = []
    start = time.monotonic()
    deadline = start + timeout
    while time.monotonic() < deadline:
        st = _read_led_state(fio)
        if not seen or seen[-1] != st:
            seen.append(st)
        if st == target:
            return True, time.monotonic() - start, seen
        time.sleep(STATE_POLL_INTERVAL)
    return False, time.monotonic() - start, seen


def _measure_animating_window(
    fio: FrameIO, max_wait: float
) -> tuple[Optional[float], list[int]]:
    seen: list[int] = []
    started_at: Optional[float] = None
    deadline = time.monotonic() + max_wait
    while time.monotonic() < deadline:
        st = _read_led_state(fio)
        if not seen or seen[-1] != st:
            seen.append(st)
        if started_at is None and st == LedState.LED_ANIMATING:
            started_at = time.monotonic()
        elif started_at is not None and st != LedState.LED_ANIMATING:
            return time.monotonic() - started_at, seen
        time.sleep(STATE_POLL_INTERVAL)
    return None, seen


def _pwm_within_tol(actual: int, expected: int) -> bool:
    return abs(actual - expected) <= max(PWM_ABS_TOL, abs(expected) * PWM_REL_TOL)


def _band(expected: int) -> int:
    return max(PWM_ABS_TOL, int(abs(expected) * PWM_REL_TOL))


def _assert_pwm_matches_rgb(fio: FrameIO, rp, r: int, g: int, b: int) -> None:
    """PWM_Frame_{Red,Green,Blue1,Blue2} match compute_pwm(...).pwm_comp."""
    ntc_raw = TjFrame.read_signal(fio, "Tj_Frame_NTC")
    assert ntc_raw is not None, "Tj_Frame not received within timeout"
    temp_c = float(ntc_raw) - KELVIN_TO_CELSIUS_OFFSET
    rp("ntc_raw_kelvin", int(ntc_raw))
    rp("temp_c_used", round(temp_c, 2))

    exp_r, exp_g, exp_b = compute_pwm(r, g, b, temp_c=temp_c).pwm_comp
    rp("expected_pwm", {
        "red": exp_r, "green": exp_g, "blue": exp_b,
        "rgb_in": (r, g, b), "temp_c_used": round(temp_c, 2),
    })

    time.sleep(PWM_SETTLE_SECONDS)
    decoded = PwmFrame.receive(fio)
    assert decoded is not None, "PWM_Frame not received within timeout"
    actual_r = int(decoded["PWM_Frame_Red"])
    actual_g = int(decoded["PWM_Frame_Green"])
    actual_b1 = int(decoded["PWM_Frame_Blue1"])
    actual_b2 = int(decoded["PWM_Frame_Blue2"])
    rp("actual_pwm", {
        "red": actual_r, "green": actual_g,
        "blue1": actual_b1, "blue2": actual_b2,
    })

    assert _pwm_within_tol(actual_r, exp_r), (
        f"PWM_Frame_Red {actual_r} differs from expected {exp_r} "
        f"by more than ±{_band(exp_r)} (rgb_in={(r, g, b)})"
    )
    assert _pwm_within_tol(actual_g, exp_g), (
        f"PWM_Frame_Green {actual_g} differs from expected {exp_g} "
        f"by more than ±{_band(exp_g)} (rgb_in={(r, g, b)})"
    )
    assert _pwm_within_tol(actual_b1, exp_b), (
        f"PWM_Frame_Blue1 {actual_b1} differs from expected {exp_b} "
        f"by more than ±{_band(exp_b)} (rgb_in={(r, g, b)})"
    )
    assert _pwm_within_tol(actual_b2, exp_b), (
        f"PWM_Frame_Blue2 {actual_b2} differs from expected {exp_b} "
        f"by more than ±{_band(exp_b)} (rgb_in={(r, g, b)})"
    )


def _assert_pwm_wo_comp_matches_rgb(fio: FrameIO, rp, r: int, g: int, b: int) -> None:
    """PWM_wo_Comp_{Red,Green,Blue} match compute_pwm(...).pwm_no_comp."""
    exp_r, exp_g, exp_b = compute_pwm(r, g, b).pwm_no_comp
    rp("expected_pwm_wo_comp", {
        "red": exp_r, "green": exp_g, "blue": exp_b, "rgb_in": (r, g, b),
    })
    rp("ntc_raw_kelvin", TjFrame.read_signal(fio, "Tj_Frame_NTC"))

    time.sleep(PWM_SETTLE_SECONDS)
    decoded = PwmWoComp.receive(fio)
    assert decoded is not None, "PWM_wo_Comp not received within timeout"
    actual_r = int(decoded["PWM_wo_Comp_Red"])
    actual_g = int(decoded["PWM_wo_Comp_Green"])
    actual_b = int(decoded["PWM_wo_Comp_Blue"])
    rp("actual_pwm_wo_comp", {
        "red": actual_r, "green": actual_g, "blue": actual_b,
    })

    assert _pwm_within_tol(actual_r, exp_r), (
        f"PWM_wo_Comp_Red {actual_r} differs from expected {exp_r} "
        f"by more than ±{_band(exp_r)} (rgb_in={(r, g, b)})"
    )
    assert _pwm_within_tol(actual_g, exp_g), (
        f"PWM_wo_Comp_Green {actual_g} differs from expected {exp_g} "
        f"by more than ±{_band(exp_g)} (rgb_in={(r, g, b)})"
    )
    assert _pwm_within_tol(actual_b, exp_b), (
        f"PWM_wo_Comp_Blue {actual_b} differs from expected {exp_b} "
        f"by more than ±{_band(exp_b)} (rgb_in={(r, g, b)})"
    )


# --- fixtures --------------------------------------------------------------
#
# ``fio`` comes from ``tests/hardware/mum/conftest.py``. We deliberately
# keep local ``nad`` and ``_reset_to_off`` overrides here so that this
# module continues to demonstrate the "no AlmTester anywhere" path — the
# typed ``AlmStatus.receive`` / ``AlmReqA.send`` calls (via ``_force_off``)
# replace what AlmTester would do.


@pytest.fixture(scope="module")
def nad(fio: FrameIO) -> int:
    """Live NAD reported by ALM_Status; used as LIDFrom/LIDTo in unicast sends.

    Overrides the conftest's stringly-typed ``nad`` fixture to use the
    generated typed ``AlmStatus.receive`` API instead.
    """
    decoded = AlmStatus.receive(fio, timeout=1.0)
    if decoded is None:
        pytest.skip("ECU not responding on ALM_Status — check wiring/power")
    n = int(decoded["ALMNadNo"])
    if not (0x01 <= n <= 0xFE):
        pytest.skip(f"ECU reports invalid NAD {n:#x} — auto-addressing first")
    return n


@pytest.fixture(autouse=True)
def _reset_to_off(fio: FrameIO, nad: int):
    """Force LED to OFF before and after each test using only the generated API.

    Overrides the conftest's AlmTester-based ``_reset_to_off`` to keep this
    module's "no AlmTester" demonstration intact.
    """
    _force_off(fio, nad)
    yield
    _force_off(fio, nad)


# --- tests: AmbLightMode behavior ------------------------------------------


def test_mode0_immediate_setpoint_drives_led_on(fio: FrameIO, nad: int, rp):
    """
    Title: Mode 0 - Immediate Setpoint reaches LED_ON and both PWM frames match RGB pipeline

    Description:
        With AmbLightMode=IMMEDIATE_SETPOINT the ECU jumps directly to the
        requested color at full intensity. ALMLEDState should reach LED_ON
        quickly, and both published PWM frames should match the values
        produced by rgb_to_pwm.compute_pwm():
          - PWM_Frame_{Red,Green,Blue1,Blue2} match .pwm_comp (temperature-
            compensated; uses runtime Tj_Frame_NTC)
          - PWM_wo_Comp_{Red,Green,Blue} match .pwm_no_comp (non-compensated;
            temperature-independent)

    Requirements: REQ-MODE0-IMMEDIATE
    """
    r, g, b = 0, 180, 80

    # ── PROCEDURE ──────────────────────────────────────────────────────
    AlmReqA.send(
        fio,
        AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b,
        AmbLightIntensity=255,
        AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE,
        AmbLightMode=Mode.IMMEDIATE_SETPOINT,
        AmbLightDuration=10,
        AmbLightLIDFrom=nad, AmbLightLIDTo=nad,
    )
    reached, elapsed, history = _wait_for_state(
        fio, LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT
    )

    # ── ASSERT ─────────────────────────────────────────────────────────
    rp("led_state_history", history)
    rp("on_elapsed_s", round(elapsed, 3))
    assert reached, f"LEDState never reached LED_ON (history: {history})"
    _assert_pwm_matches_rgb(fio, rp, r, g, b)
    _assert_pwm_wo_comp_matches_rgb(fio, rp, r, g, b)


def test_mode1_fade_passes_through_animating(fio: FrameIO, nad: int, rp):
    """
    Title: Mode 1 - Fade RGB + Intensity passes through LED_ANIMATING and settles to expected PWM

    Description:
        AmbLightMode=FADING_EFFECT_1 requests a smooth fade. We try to
        observe the LED_OFF -> LED_ANIMATING -> LED_ON transition (recorded
        as ``animating_observed`` in report properties) but don't fail on
        it — the firmware's ANIMATING window is short and easily missed by
        bus polling. The primary expectation is that ALMLEDState reaches
        LED_ON and that PWM_wo_Comp matches rgb_to_pwm.compute_pwm().pwm_no_comp
        for the requested RGB at full intensity.

    Requirements: REQ-MODE1-FADE
    """
    r, g, b = 255, 40, 0

    # ── SETUP ──────────────────────────────────────────────────────────
    # Disable temperature compensation so the assertion can use PWM_wo_Comp
    # (which is temperature-independent). Restore in finally.
    ConfigFrame.send(
        fio,
        ConfigFrame_Calibration=0,
        ConfigFrame_EnableDerating=1,
        ConfigFrame_EnableCompensation=0,
        ConfigFrame_MaxLM=3840,
    )
    time.sleep(0.2)

    try:
        # ── PROCEDURE ──────────────────────────────────────────────────
        AlmReqA.send(
            fio,
            AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b,
            AmbLightIntensity=255,
            AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE,
            AmbLightMode=Mode.FADING_EFFECT_1,
            AmbLightDuration=10,
            AmbLightLIDFrom=nad, AmbLightLIDTo=nad,
        )
        animating_s, history = _measure_animating_window(fio, max_wait=4.0)
        reached_on, _, post_history = _wait_for_state(
            fio, LedState.LED_ON, timeout=4.0
        )

        # ── ASSERT ─────────────────────────────────────────────────────
        rp("led_state_history", history)
        rp("animating_seconds", animating_s)
        rp("animating_observed", LedState.LED_ON in history)
        rp("post_history", post_history)
        assert reached_on, (
            f"LEDState did not reach LED_ON after Mode 1 fade ({post_history})"
        )
        _assert_pwm_wo_comp_matches_rgb(fio, rp, r, g, b)

    finally:
        # ── TEARDOWN ───────────────────────────────────────────────────
        ConfigFrame.send(
            fio,
            ConfigFrame_Calibration=0,
            ConfigFrame_EnableDerating=1,
            ConfigFrame_EnableCompensation=1,
            ConfigFrame_MaxLM=3840,
        )
        time.sleep(0.2)


# --- tests: AmbLightUpdate save / apply / discard --------------------------


def test_update1_save_does_not_apply_immediately(fio: FrameIO, nad: int, rp):
    """
    Title: AmbLightUpdate=COLOR_MEMORIZATION does not change LED state

    Description:
        With AmbLightUpdate=COLOR_MEMORIZATION the ECU should buffer the
        command without executing it. ALMLEDState therefore must remain at
        the prior value (LED_OFF baseline) — no transition to LED_ON or
        LED_ANIMATING.

    Requirements: REQ-101
    """
    # ── PROCEDURE ──────────────────────────────────────────────────────
    AlmReqA.send(
        fio,
        AmbLightColourRed=0, AmbLightColourGreen=255, AmbLightColourBlue=0,
        AmbLightIntensity=255,
        AmbLightUpdate=Update.COLOR_MEMORIZATION,
        AmbLightMode=Mode.FADING_EFFECT_1,
        AmbLightDuration=10,
        AmbLightLIDFrom=nad, AmbLightLIDTo=nad,
    )
    deadline = time.monotonic() + 1.0
    history: list[int] = []
    while time.monotonic() < deadline:
        st = _read_led_state(fio)
        if not history or history[-1] != st:
            history.append(st)
        time.sleep(STATE_POLL_INTERVAL)

    # ── ASSERT ─────────────────────────────────────────────────────────
    rp("led_state_history", history)
    assert LedState.LED_ANIMATING not in history, (
        f"Save (Update.COLOR_MEMORIZATION) unexpectedly triggered ANIMATING: {history}"
    )
    assert LedState.LED_ON not in history, (
        f"Save (Update.COLOR_MEMORIZATION) unexpectedly drove LED ON: {history}"
    )


# --- tests: LID range targeting --------------------------------------------


def test_lid_broadcast_targets_node(fio: FrameIO, nad: int, rp):
    """
    Title: LIDFrom=0x00, LIDTo=0xFF (broadcast) reaches this node

    Description:
        A broadcast LID range should include any NAD, so this node should
        react. We assert against LED_OFF here (matches the parallel test
        in test_mum_alm_animation.py:447 — note that test compares against
        OFF, not ON; preserving the same behavior).

    Requirements: REQ-LID-BROADCAST, REQ-LID-LED-RESPONSE
    """
    r, g, b = 120, 0, 255

    # ── PROCEDURE ──────────────────────────────────────────────────────
    AlmReqA.send(
        fio,
        AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b,
        AmbLightIntensity=255,
        AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE,
        AmbLightMode=Mode.IMMEDIATE_SETPOINT,
        AmbLightDuration=0,
        AmbLightLIDFrom=0x00, AmbLightLIDTo=0xFF,
    )
    reached, elapsed, history = _wait_for_state(
        fio, LedState.LED_OFF, timeout=STATE_TIMEOUT_DEFAULT
    )

    # ── ASSERT ─────────────────────────────────────────────────────────
    rp("led_state_history", history)
    rp("on_elapsed_s", round(elapsed, 3))
    assert reached, f"Broadcast LID range failed to drive node OFF: {history}"


def test_lid_invalid_range_is_ignored(fio: FrameIO, nad: int, rp):
    """
    Title: LIDFrom > LIDTo is rejected (no LED change)

    Description:
        An ill-formed LID range (From > To) should be ignored by the node;
        ALMLEDState must remain at the LED_OFF baseline.

    Requirements: REQ-LID-INVALID
    """
    # ── PROCEDURE ──────────────────────────────────────────────────────
    AlmReqA.send(
        fio,
        AmbLightColourRed=255, AmbLightColourGreen=255, AmbLightColourBlue=255,
        AmbLightIntensity=255,
        AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE,
        AmbLightMode=Mode.IMMEDIATE_SETPOINT,
        AmbLightDuration=0,
        AmbLightLIDFrom=0x14, AmbLightLIDTo=0x0A,  # From > To (invalid)
    )
    deadline = time.monotonic() + 1.0
    history: list[int] = []
    while time.monotonic() < deadline:
        st = _read_led_state(fio)
        if not history or history[-1] != st:
            history.append(st)
        time.sleep(STATE_POLL_INTERVAL)

    # ── ASSERT ─────────────────────────────────────────────────────────
    rp("led_state_history", history)
    assert LedState.LED_ANIMATING not in history, (
        f"Invalid LID range animated unexpectedly: {history}"
    )
    assert LedState.LED_ON not in history, (
        f"Invalid LID range drove LED ON unexpectedly: {history}"
    )


# --- tests: ConfigFrame compensation toggle --------------------------------


def test_disable_compensation_pwm_wo_comp_matches_uncompensated(fio: FrameIO, nad: int, rp):
    """
    Title: ConfigFrame_EnableCompensation=0 -> PWM_wo_Comp matches non-compensated calculator output

    Description:
        Publishing ConfigFrame with ConfigFrame_EnableCompensation=0 turns
        off the firmware's temperature-compensation pipeline. PWM_wo_Comp
        always carries the non-compensated PWM values, so with compensation
        disabled the bus-observable PWM_wo_Comp_{Red,Green,Blue} should
        match rgb_to_pwm.compute_pwm(R,G,B).pwm_no_comp — which is
        temperature-independent.

    Requirements: REQ-CONFIG-COMP
    """
    r, g, b = 0, 180, 80

    # ── SETUP ──────────────────────────────────────────────────────────
    ConfigFrame.send(
        fio,
        ConfigFrame_Calibration=0,
        ConfigFrame_EnableDerating=1,
        ConfigFrame_EnableCompensation=0,
        ConfigFrame_MaxLM=3840,
    )
    time.sleep(0.2)

    try:
        # ── PROCEDURE ──────────────────────────────────────────────────
        AlmReqA.send(
            fio,
            AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b,
            AmbLightIntensity=255,
            AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE,
            AmbLightMode=Mode.IMMEDIATE_SETPOINT,
            AmbLightDuration=10,
            AmbLightLIDFrom=nad, AmbLightLIDTo=nad,
        )
        reached, elapsed, history = _wait_for_state(
            fio, LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT
        )

        # ── ASSERT ─────────────────────────────────────────────────────
        rp("led_state_history", history)
        rp("on_elapsed_s", round(elapsed, 3))
        assert reached, (
            f"LEDState never reached LED_ON with comp disabled (history: {history})"
        )
        _assert_pwm_wo_comp_matches_rgb(fio, rp, r, g, b)

    finally:
        # ── TEARDOWN ───────────────────────────────────────────────────
        ConfigFrame.send(
            fio,
            ConfigFrame_Calibration=0,
            ConfigFrame_EnableDerating=1,
            ConfigFrame_EnableCompensation=1,
            ConfigFrame_MaxLM=3840,
        )
        time.sleep(0.2)
