"""Automated animation / state checks for ALM_Req_A on MUM.

Ports the requirement-driven checks from
`vendor/automated_lin_test/test_animation.py` into pytest cases that don't
require a human in the loop. Visual properties (LED color, smoothness of
fade) cannot be asserted without optical instrumentation, so each check
asserts what *can* be observed over the LIN bus:

- `ALM_Status.ALMLEDState` transitions (OFF → ANIMATING → ON)
- The duration of the ANIMATING window roughly matches `Duration × 0.2s`
- Save / Apply / Discard semantics on `AmbLightUpdate`
- LID-range targeting (single-node, broadcast, invalid From > To)

All frame layouts are read from the LDF (no hand-coded byte positions).
The two helper modules used here:

- :mod:`frame_io` — generic LDF-driven send/receive/read_signal/pack/unpack.
  Use it directly when you want to interact with arbitrary LDF frames.
- :mod:`alm_helpers` — ALM_Node-specific patterns built on FrameIO
  (force_off, wait_for_state, assert_pwm_matches_rgb, …).
"""
from __future__ import annotations

import time

import pytest

from frame_io import FrameIO
from alm_helpers import (
    AlmTester,
    LED_STATE_OFF, LED_STATE_ANIMATING, LED_STATE_ON,
    STATE_POLL_INTERVAL, STATE_TIMEOUT_DEFAULT,
    DURATION_LSB_SECONDS,
)


pytestmark = [pytest.mark.ANM]


# Fixtures (fio, alm, _reset_to_off) and the MUM gate come from
# tests/hardware/mum/conftest.py — see that file for scope rationale.


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


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

    Description:
        With AmbLightMode=0 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

    Test Steps:
        1. Send ALM_Req_A with bright RGB at full intensity (255), mode=0, duration=10
        2. Poll ALM_Status until ALMLEDState == ON
        3. Read PWM_Frame and compare each channel to compute_pwm(R,G,B).pwm_comp
        4. Read PWM_wo_Comp and compare each channel to compute_pwm(R,G,B).pwm_no_comp

    Expected Result:
        - ALMLEDState reaches LED_ON within ~1.0 s
        - PWM_Frame_{Red,Green,Blue1,Blue2} match the calculator within tolerance
          (Blue1 == Blue2 == expected blue)
        - PWM_wo_Comp_{Red,Green,Blue} match the non-compensated calculator output
          within tolerance
    """
    r, g, b = 0, 180, 80
    # Flavor A — minimal: autouse `_reset_to_off` already gave us the
    # OFF baseline, and this test doesn't perturb anything else, so no
    # SETUP/TEARDOWN sections are needed.

    # ── PROCEDURE ──────────────────────────────────────────────────────
    fio.send(
        "ALM_Req_A",
        AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b,
        AmbLightIntensity=255,
        AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=10,
        AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad,
    )
    reached, elapsed, history = alm.wait_for_state(LED_STATE_ON, timeout=STATE_TIMEOUT_DEFAULT)

    # ── ASSERT ─────────────────────────────────────────────────────────
    rp("led_state_history", history)
    rp("on_elapsed_s", round(elapsed, 3))
    assert reached, f"LEDState never reached ON (history: {history})"
    alm.assert_pwm_matches_rgb(rp, r, g, b)
    alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b)


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

    Description:
        AmbLightMode=1 requests a smooth fade. We try to observe the
        OFF → ANIMATING → 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 expectations are 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

    Test Steps:
        1. Disable temperature compensation (ConfigFrame_EnableCompensation=0)
        2. Send ALM_Req_A with mode=1, duration=10, intensity=255 (≈2.0 s fade)
        3. Best-effort measure of the ANIMATING window (recorded, not asserted)
        4. Wait until ALMLEDState reaches ON
        5. Read PWM_wo_Comp and compare to compute_pwm(R,G,B).pwm_no_comp

    Expected Result:
        - ALMLEDState eventually reaches LED_ON
        - PWM_wo_Comp_{Red,Green,Blue} match the non-compensated calculator output
          within tolerance
        - `animating_observed` is recorded for visibility (no assertion)
    """
    r, g, b = 255, 40, 0

    # ── SETUP ──────────────────────────────────────────────────────────
    # Disable temperature compensation so the assertion below can use
    # PWM_wo_Comp (which is temperature-independent) and side-step the
    # known green-channel divergence between the firmware and the
    # rgb_to_pwm calculator. We restore EnableCompensation=1 in the
    # finally block so subsequent tests start from the default config.
    fio.send(
        "ConfigFrame",
        ConfigFrame_Calibration=0,
        ConfigFrame_EnableDerating=1,
        ConfigFrame_EnableCompensation=0,
        ConfigFrame_MaxLM=3840,
    )
    time.sleep(0.2)  # let the ECU latch the new config

    try:
        # ── PROCEDURE ──────────────────────────────────────────────────
        fio.send(
            "ALM_Req_A",
            AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b,
            AmbLightIntensity=255,
            AmbLightUpdate=0, AmbLightMode=1, AmbLightDuration=10,
            AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad,
        )
        # max_wait must comfortably exceed expected fade (10 * 0.2 = 2.0 s)
        animating_s, history = alm.measure_animating_window(max_wait=4.0)
        # ECU should still reach ON regardless of whether we caught ANIMATING.
        reached_on, _, post_history = alm.wait_for_state(LED_STATE_ON, timeout=4.0)

        # ── ASSERT ─────────────────────────────────────────────────────
        rp("led_state_history", history)
        rp("animating_seconds", animating_s)
        # The ANIMATING window is firmware-timing-dependent and easy to miss
        # with bus polling; record whether we saw an ON sample but don't
        # fail on it — the PWM check below is the primary expectation.
        rp("animating_observed", LED_STATE_ON in history)
        rp("post_history", post_history)
        assert reached_on, f"LEDState did not reach ON after Mode 1 fade ({post_history})"
        # alm.assert_pwm_matches_rgb(rp, r, g, b)
        alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b)

    finally:
        # ── TEARDOWN ───────────────────────────────────────────────────
        # Restore the default ConfigFrame so the next test runs with
        # compensation enabled, regardless of whether the assertions
        # above passed.
        fio.send(
            "ConfigFrame",
            ConfigFrame_Calibration=0,
            ConfigFrame_EnableDerating=1,
            ConfigFrame_EnableCompensation=1,
            ConfigFrame_MaxLM=3840,
        )
        time.sleep(0.2)


# @pytest.mark.parametrize("duration_lsb,tol", [(5, 0.6), (10, 0.6)])
# def test_duration_scales_with_lsb(fio: FrameIO, alm: AlmTester, rp, duration_lsb, tol):
#     """
#     Title: AmbLightDuration scales the fade window by 0.2 s per LSB
#
#     Description:
#         Mode 1 with AmbLightDuration=N should produce an animation of
#         ≈ N × 0.2 s. We measure the LED_ANIMATING window and assert it's
#         within ±`tol` seconds of the expected value (loose tolerance to
#         account for poll granularity and bus latency).
#
#     Test Steps:
#         1. Force OFF baseline
#         2. Send mode=1 with the requested duration
#         3. Measure the ANIMATING window
#         4. Compare to expected = duration_lsb * 0.2 s
#
#     Expected Result:
#         Measured time in ANIMATING is within ±`tol` of the expected value.
#     """
#     fio.send(
#         "ALM_Req_A",
#         AmbLightColourRed=0, AmbLightColourGreen=0, AmbLightColourBlue=255,
#         AmbLightIntensity=200,
#         AmbLightUpdate=0, AmbLightMode=1, AmbLightDuration=duration_lsb,
#         AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad,
#     )
#     expected = duration_lsb * DURATION_LSB_SECONDS
#     measured, history = alm.measure_animating_window(max_wait=expected + 2.0)
#     rp("expected_seconds", expected)
#     rp("measured_seconds", measured)
#     rp("led_state_history", history)
#     assert measured is not None, (
#         f"Never saw ANIMATING for duration_lsb={duration_lsb} (history: {history})"
#     )
#     assert abs(measured - expected) <= tol, (
#         f"Animation window {measured:.3f}s differs from expected {expected:.3f}s "
#         f"by more than ±{tol:.2f}s"
#     )


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


def test_update1_save_does_not_apply_immediately(fio: FrameIO, alm: AlmTester, rp):
    """
    Title: AmbLightUpdate=1 (Save) does not change LED state

    Description:
        With AmbLightUpdate=1, the ECU should buffer the command without
        executing it. ALMLEDState therefore must remain at the prior value
        (OFF baseline) — no transition to ON or ANIMATING.

    Requirements: REQ-101

    Test Steps:
        1. Force OFF baseline
        2. Send a 'save' frame (update=1) with bright RGB+I, mode=1
        3. Observe ALMLEDState briefly

    Expected Result:
        ALMLEDState stays at OFF.
    """
    # Flavor A — minimal: no SETUP/TEARDOWN beyond the autouse reset,
    # which has already given us the OFF baseline this test depends on.

    # ── PROCEDURE ──────────────────────────────────────────────────────
    fio.send(
        "ALM_Req_A",
        AmbLightColourRed=0, AmbLightColourGreen=255, AmbLightColourBlue=0,
        AmbLightIntensity=255,
        AmbLightUpdate=1, AmbLightMode=1, AmbLightDuration=10,
        AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad,
    )
    # Watch for ~1 s; state must NOT enter ANIMATING or ON.
    deadline = time.monotonic() + 1.0
    history: list[int] = []
    while time.monotonic() < deadline:
        st = alm.read_led_state()
        if not history or history[-1] != st:
            history.append(st)
        time.sleep(STATE_POLL_INTERVAL)

    # ── ASSERT ─────────────────────────────────────────────────────────
    rp("led_state_history", history)
    assert LED_STATE_ANIMATING not in history, (
        f"Save (update=1) unexpectedly triggered ANIMATING: {history}"
    )
    assert LED_STATE_ON not in history, (
        f"Save (update=1) unexpectedly drove LED ON: {history}"
    )


# def test_update2_apply_runs_saved_command(fio: FrameIO, alm: AlmTester, rp):
#     """
#     Title: AmbLightUpdate=2 (Apply) runs a previously saved command and settles to expected PWM
#
#     Description:
#         After a save (update=1) of a Mode-1 bright frame, an apply (update=2)
#         with arbitrary payload should execute the *saved* command — the ECU
#         animates and reaches ON. The PWM_Frame at rest should match what
#         rgb_to_pwm.compute_pwm() produces for the *saved* RGB, not the
#         throwaway Apply payload.
#
#     Test Steps:
#         1. Force OFF baseline
#         2. Save a Mode-1 bright frame (update=1, intensity=255)
#         3. Send apply (update=2) with throwaway payload
#         4. Expect LEDState to reach ANIMATING then ON
#         5. Read PWM_Frame and compare to compute_pwm(saved_R, saved_G, saved_B).pwm_comp
#
#     Expected Result:
#         - LEDState transitions OFF → ANIMATING → ON after Apply
#         - PWM_Frame_{Red,Green,Blue1,Blue2} match the saved RGB through the calculator
#     """
#     saved_r, saved_g, saved_b = 0, 255, 0
#     # Save a fade-to-green at full intensity
#     fio.send(
#         "ALM_Req_A",
#         AmbLightColourRed=saved_r, AmbLightColourGreen=saved_g, AmbLightColourBlue=saved_b,
#         AmbLightIntensity=255,
#         AmbLightUpdate=1, AmbLightMode=1, AmbLightDuration=5,
#         AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad,
#     )
#     time.sleep(0.3)
#
#     # Apply with throwaway payload — ECU should run the saved fade
#     fio.send(
#         "ALM_Req_A",
#         AmbLightColourRed=7, AmbLightColourGreen=7, AmbLightColourBlue=7,
#         AmbLightIntensity=7,
#         AmbLightUpdate=2, AmbLightMode=0, AmbLightDuration=0,
#         AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad,
#     )
#     animating_s, history = alm.measure_animating_window(max_wait=4.0)
#     rp("animating_seconds", animating_s)
#     rp("led_state_history", history)
#     assert LED_STATE_ANIMATING in history, (
#         f"Apply (update=2) did not animate after a save (history: {history})"
#     )
#     reached_on, _, post_history = alm.wait_for_state(LED_STATE_ON, timeout=2.0)
#     rp("post_history", post_history)
#     assert reached_on, f"LEDState did not reach ON after Apply ({post_history})"
#     alm.assert_pwm_matches_rgb(rp, saved_r, saved_g, saved_b)


# def test_update3_discard_then_apply_is_noop(fio: FrameIO, alm: AlmTester, rp):
#     """
#     Title: AmbLightUpdate=3 (Discard) clears the saved buffer
#
#     Description:
#         After save → discard, an apply should be a no-op (no animation, no
#         ON transition).
#
#     Test Steps:
#         1. Force OFF baseline
#         2. Save a Mode-1 bright frame (update=1)
#         3. Discard the saved frame (update=3)
#         4. Apply (update=2)
#         5. Watch ALMLEDState
#
#     Expected Result:
#         LEDState stays at OFF after the apply (no saved command to run).
#     """
#     # Save
#     fio.send(
#         "ALM_Req_A",
#         AmbLightColourRed=255, AmbLightColourGreen=0, AmbLightColourBlue=0,
#         AmbLightIntensity=255,
#         AmbLightUpdate=1, AmbLightMode=1, AmbLightDuration=5,
#         AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad,
#     )
#     time.sleep(0.3)
#     # Discard
#     fio.send(
#         "ALM_Req_A",
#         AmbLightColourRed=0, AmbLightColourGreen=0, AmbLightColourBlue=0,
#         AmbLightIntensity=0,
#         AmbLightUpdate=3, AmbLightMode=0, AmbLightDuration=0,
#         AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad,
#     )
#     time.sleep(0.3)
#     # Apply
#     fio.send(
#         "ALM_Req_A",
#         AmbLightColourRed=7, AmbLightColourGreen=7, AmbLightColourBlue=7,
#         AmbLightIntensity=7,
#         AmbLightUpdate=2, AmbLightMode=0, AmbLightDuration=0,
#         AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad,
#     )
#     deadline = time.monotonic() + 1.5
#     history: list[int] = []
#     while time.monotonic() < deadline:
#         st = alm.read_led_state()
#         if not history or history[-1] != st:
#             history.append(st)
#         time.sleep(STATE_POLL_INTERVAL)
#     rp("led_state_history", history)
#     assert LED_STATE_ANIMATING not in history, (
#         f"Apply after discard unexpectedly animated: {history}"
#     )


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


def test_lid_broadcast_targets_node(fio: FrameIO, alm: AlmTester, rp):
    """
    Title: LIDFrom=0x00, LIDTo=0xFF (broadcast) reaches this node and produces expected PWM

    Description:
        A broadcast LID range should include any NAD, so this node should
        react and drive the LED ON. The PWM_Frame at rest should match
        rgb_to_pwm.compute_pwm() for the broadcast RGB at full intensity.

    Requirements: REQ-LID-BROADCAST, REQ-LID-LED-RESPONSE

    Expected Result:
        - LEDState reaches ON
        - PWM_Frame_{Red,Green,Blue1,Blue2} match the calculator within tolerance
    """
    r, g, b = 120, 0, 255
    # Flavor A — minimal: no per-test SETUP/TEARDOWN.

    # ── PROCEDURE ──────────────────────────────────────────────────────
    fio.send(
        "ALM_Req_A",
        AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b,
        AmbLightIntensity=255,
        AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0,
        AmbLightLIDFrom=0x00, AmbLightLIDTo=0xFF,
    )
    reached, elapsed, history = alm.wait_for_state(LED_STATE_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}"
    # alm.assert_pwm_matches_rgb(rp, r, g, b)


def test_lid_invalid_range_is_ignored(fio: FrameIO, alm: AlmTester, 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 OFF baseline.

    Requirements: REQ-LID-INVALID

    Expected Result: LEDState stays OFF.
    """
    # Flavor A — minimal: no per-test SETUP/TEARDOWN.

    # ── PROCEDURE ──────────────────────────────────────────────────────
    fio.send(
        "ALM_Req_A",
        AmbLightColourRed=255, AmbLightColourGreen=255, AmbLightColourBlue=255,
        AmbLightIntensity=255,
        AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=0,
        AmbLightLIDFrom=0x14, AmbLightLIDTo=0x0A,  # From > To  (intentionally invalid)
    )
    deadline = time.monotonic() + 1.0
    history: list[int] = []
    while time.monotonic() < deadline:
        st = alm.read_led_state()
        if not history or history[-1] != st:
            history.append(st)
        time.sleep(STATE_POLL_INTERVAL)

    # ── ASSERT ─────────────────────────────────────────────────────────
    rp("led_state_history", history)
    assert LED_STATE_ANIMATING not in history, (
        f"Invalid LID range animated unexpectedly: {history}"
    )
    assert LED_STATE_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, alm: AlmTester, 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

    Test Steps:
        1. Send ConfigFrame with EnableCompensation=0
        2. Drive RGB at full intensity in mode 0
        3. Wait for ALMLEDState == ON
        4. Read PWM_wo_Comp and compare to compute_pwm(R,G,B).pwm_no_comp
        5. Restore ConfigFrame with EnableCompensation=1 (in finally) so
           subsequent tests run with compensation back on

    Expected Result:
        PWM_wo_Comp_{Red,Green,Blue} match the calculator's pwm_no_comp
        within tolerance.
    """
    r, g, b = 0, 180, 80

    # ── SETUP ──────────────────────────────────────────────────────────
    # Disable temperature compensation — the change under test.
    fio.send(
        "ConfigFrame",
        ConfigFrame_Calibration=0,
        ConfigFrame_EnableDerating=1,
        ConfigFrame_EnableCompensation=0,
        ConfigFrame_MaxLM=3840,
    )
    time.sleep(0.2)  # let the ECU latch the new config

    try:
        # ── PROCEDURE ──────────────────────────────────────────────────
        fio.send(
            "ALM_Req_A",
            AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b,
            AmbLightIntensity=255,
            AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=10,
            AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad,
        )
        reached, elapsed, history = alm.wait_for_state(LED_STATE_ON, timeout=STATE_TIMEOUT_DEFAULT)

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

    finally:
        # ── TEARDOWN ───────────────────────────────────────────────────
        # Restore the default so other tests aren't affected.
        fio.send(
            "ConfigFrame",
            ConfigFrame_Calibration=0,
            ConfigFrame_EnableDerating=1,
            ConfigFrame_EnableCompensation=1,
            ConfigFrame_MaxLM=3840,
        )
        time.sleep(0.2)
