"""Copyable starting point for new MUM hardware tests.

WHY THE NAME STARTS WITH AN UNDERSCORE
--------------------------------------
pytest only collects files whose name matches ``test_*.py`` (configured
in ``pytest.ini``). Because this file is named ``_test_case_template.py``
(leading underscore), pytest skips it — so the example bodies below
won't accidentally run on your bench.

HOW TO USE THIS FILE
--------------------
1. Copy this file to ``tests/hardware/test_<feature>.py``.
2. Rename ``test_template_*`` functions to describe what they verify
   (e.g. ``test_blue_at_full_intensity_drives_pwm``).
3. Fill in each docstring's ``Title / Description / Test Steps /
   Expected Result`` block — the conftest plugin parses those into the
   HTML report's metadata columns.
4. Decide which template body matches your test (see TEST FLAVORS below)
   and delete the others.
5. Use ``fio`` for generic LDF-driven I/O; use ``alm`` for ALM_Node
   patterns. Full reference: ``docs/19_frame_io_and_alm_helpers.md``.

TEST FLAVORS PROVIDED BELOW
---------------------------
Three example bodies cover the most common shapes:

  A) ``test_template_minimal`` — relies on the autouse reset; no
     per-test setup or teardown. Use this when the test only sends
     a frame, observes a state change, and asserts on PWM.

  B) ``test_template_with_isolation`` — uses the explicit four-phase
     SETUP / PROCEDURE / ASSERT / TEARDOWN pattern with try/finally so
     the test stays independent of the others even if it mutates
     persistent ECU state (e.g. ConfigFrame). **Use this for any test
     that changes a value the autouse reset doesn't restore.**

  C) ``test_template_signal_probe`` — short pattern for "read one
     signal, assert something about it" cases.

THE FOUR-PHASE PATTERN (read this once, the comments below assume it)
---------------------------------------------------------------------
Each test body in flavor B is split into four labelled sections:

  • SETUP      — bring the ECU to the *exact* state this test needs
                  beyond the common baseline already provided by the
                  autouse fixture. Anything you change here MUST be
                  undone in TEARDOWN.

  • PROCEDURE  — the actions under test (sending a frame, waiting for
                  a state, etc.). Should be readable top-to-bottom as
                  the steps of the requirement you are verifying.

  • ASSERT     — bus-observable expectations. Use ``rp("key", value)``
                  to attach data to the report, then ``assert ...`` for
                  the actual check.

  • TEARDOWN   — runs in a ``finally`` so it executes even when an
                  assertion fails. Restores any state that SETUP
                  perturbed. This is what guarantees test independence.

Tests in flavor A skip SETUP/TEARDOWN because the autouse
``_reset_to_off`` fixture is enough — the LED is forced OFF before and
after every test.
"""
from __future__ import annotations

# ╔══════════════════════════════════════════════════════════════════════╗
# ║ IMPORTS                                                              ║
# ╚══════════════════════════════════════════════════════════════════════╝
# Standard library: ``time`` is used for short delays where we wait for
# the ECU to apply a new ConfigFrame or for the slave to refresh its TX
# buffer. Test code generally prefers ``alm.wait_for_state(...)`` over
# raw sleeps, but a short ``time.sleep(...)`` is fine for "let the ECU
# latch this command" pauses.
import time

# pytest itself: ``@pytest.fixture``, ``@pytest.mark.*``, ``pytest.skip``.
import pytest

# Project framework: ``EcuTestConfig`` holds the merged YAML config (so we
# can guard against running this suite on a non-MUM bench), and
# ``LinInterface`` is the abstract LIN adapter the ``lin`` session
# fixture provides.
from ecu_framework.config import EcuTestConfig
from ecu_framework.lin.base import LinInterface

# The two test-helper modules. Sibling imports work because pytest's
# default rootdir mode puts the test file's directory on ``sys.path``.
#   • ``frame_io.FrameIO``  — generic, LDF-driven send/receive/pack/unpack
#   • ``alm_helpers``       — ALM_Node domain helpers + constants
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,
    PWM_SETTLE_SECONDS, DURATION_LSB_SECONDS,
)


# ╔══════════════════════════════════════════════════════════════════════╗
# ║ MODULE MARKERS                                                       ║
# ╚══════════════════════════════════════════════════════════════════════╝
# ``pytestmark`` applies the listed markers to every test in this file.
#
#   • ``pytest.mark.hardware`` — needs a real LIN master + ECU.
#     Excluded from default mock-only runs (``pytest -m "not hardware"``).
#   • ``pytest.mark.mum``      — uses the Melexis Universal Master
#     adapter. Pair with ``hardware`` when running:
#         pytest -m "hardware and mum"
#
# Add per-test markers (e.g. ``@pytest.mark.smoke`` or ``@pytest.mark.req_001``)
# directly above individual test functions.
pytestmark = [pytest.mark.hardware, pytest.mark.mum]


# ╔══════════════════════════════════════════════════════════════════════╗
# ║ FIXTURES — the wiring that gives every test its tools                ║
# ╚══════════════════════════════════════════════════════════════════════╝
#
# A "fixture" in pytest is a function decorated with ``@pytest.fixture``
# that prepares (and optionally cleans up) something tests need. A test
# requests a fixture by listing its name as a parameter — pytest matches
# the parameter name to the fixture name and injects the return value.
#
# WHAT EACH SCOPE MEANS
#   scope="function" (default) → fixture re-runs for every test
#   scope="module"             → runs once per file; same value reused
#                                 across all tests in this file
#   scope="session"            → runs once per pytest invocation
#
# ``module`` scope is the right default for ``fio`` and ``alm`` because
# building a FrameIO and resolving the NAD only need to happen once
# per file — they don't change between tests.
#
# ``autouse=True`` on a fixture means tests don't have to request it by
# name; pytest applies it to every test in scope automatically. We use
# this for ``_reset_to_off`` so the LED reset is mechanical, not
# something each test author has to remember.
#
# ``yield`` inside a fixture splits it into setup (before yield) and
# teardown (after yield). The teardown runs even when the test fails.


@pytest.fixture(scope="module")
def fio(config: EcuTestConfig, lin: LinInterface, ldf) -> FrameIO:
    """Generic LDF-driven I/O for any frame in the project's LDF.

    Built once per file. The test asks pytest for ``fio`` by name and
    receives this single ``FrameIO`` instance, with three layers of
    access available:
      • ``fio.send("FrameName", **signals)``        — high level, by name
      • ``fio.pack(...)`` / ``fio.unpack(...)``     — bytes ↔ signals, no I/O
      • ``fio.send_raw(id, data)``                  — bypass the LDF entirely

    SKIP IF NOT ON MUM: this whole suite is meaningless on a mock or
    deprecated-BabyLIN bench, so we skip cleanly rather than letting
    later asserts fail in confusing ways.
    """
    if config.interface.type != "mum":
        pytest.skip("interface.type must be 'mum' for this suite")
    return FrameIO(lin, ldf)


@pytest.fixture(scope="module")
def alm(fio: FrameIO) -> AlmTester:
    """ALM_Node domain helper bound to the live NAD reported by ALM_Status.

    Reads ALM_Status once, picks the NAD out, and constructs an
    ``AlmTester`` carrying ``(fio, nad)``. From there every test can
    do ``alm.force_off()``, ``alm.wait_for_state(...)``, etc., without
    re-deriving the NAD or re-discovering frames.

    SKIPS we want to be loud about:
      • The slave didn't respond at all       → wiring/power issue
      • The reported NAD is outside 0x01..0xFE → auto-addressing issue
    These belong as skips (not failures) because they indicate the bench
    isn't ready, which is independent of any logic this file checks.
    """
    decoded = fio.receive("ALM_Status", timeout=1.0)
    if decoded is None:
        pytest.skip("ECU not responding on ALM_Status — check wiring/power")
    nad = int(decoded["ALMNadNo"])
    if not (0x01 <= nad <= 0xFE):
        pytest.skip(f"ECU reports invalid NAD {nad:#x} — auto-addressing first")
    return AlmTester(fio, nad)


@pytest.fixture(autouse=True)
def _reset_to_off(alm: AlmTester):
    """The COMMON baseline: LED is OFF before AND after every test.

    Why this matters:
        Without it, a test that left the LED in some state (mid-fade,
        ON, etc.) would bleed into the next test, and a failure could
        cascade across the whole file. Forcing OFF before and after
        guarantees that whatever happens inside a test, the next test
        starts from the same place — that is *test independence*.

    The leading underscore is just a hint that this fixture isn't
    meant to be requested directly by a test; ``autouse=True`` already
    pulls it in automatically.

    Note this only handles the LED state. If your test also writes
    something the reset doesn't undo (e.g. ConfigFrame), you must
    restore it yourself in the test's TEARDOWN block — see flavor B.
    """
    alm.force_off()    # SETUP   (runs before the test body)
    yield              # ←── the test runs here
    alm.force_off()    # TEARDOWN (runs after, even if the test failed)


# ╔══════════════════════════════════════════════════════════════════════╗
# ║ TEST FLAVOR A — minimal, no per-test setup/teardown                  ║
# ╚══════════════════════════════════════════════════════════════════════╝
# Use this shape when the autouse ``_reset_to_off`` is enough — i.e. the
# only mutable state the test touches is the LED itself.

def test_template_minimal(fio: FrameIO, alm: AlmTester, rp):
    """
    Title: <one-line summary; appears as a column in the HTML report>

    Description:
        <2–3 sentences explaining what this test validates and why it
        matters. Avoid mentioning implementation details that change
        often — focus on the requirement.>

    Requirements: REQ-XXX

    Test Steps:
        1. <step>
        2. <step>
        3. <step>

    Expected Result:
        - <bus-observable expectation>
        - <bus-observable expectation>
    """
    # The colour we want to drive the LED to. Using locals (r, g, b)
    # makes the assertion below read naturally.
    r, g, b = 0, 180, 80

    # ── PROCEDURE ──────────────────────────────────────────────────────
    # ``fio.send`` packs the frame against the LDF and pushes it on the
    # bus. Every signal the LDF defines for the frame must be supplied;
    # ldfparser raises if you forget one.
    fio.send(
        "ALM_Req_A",
        AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b,
        AmbLightIntensity=255,         # full brightness
        AmbLightUpdate=0,              # 0 = immediate (no save buffer)
        AmbLightMode=0,                # 0 = immediate setpoint, no fade
        AmbLightDuration=10,           # ignored for mode=0; harmless
        AmbLightLIDFrom=alm.nad,       # target THIS node
        AmbLightLIDTo=alm.nad,
    )

    # Poll ALM_Status until ALMLEDState reports ON (or timeout).
    # ``wait_for_state`` returns three things:
    #   reached  — True if we saw the target state in time
    #   elapsed  — seconds it took (for diagnostics)
    #   history  — distinct LED states observed during the wait
    reached, elapsed, history = alm.wait_for_state(
        LED_STATE_ON, timeout=STATE_TIMEOUT_DEFAULT
    )

    # ── ASSERT ─────────────────────────────────────────────────────────
    # ``rp("key", value)`` attaches a property to the JUnit XML and HTML
    # report. The conftest plugin renders these in the report row, so
    # we get useful per-test diagnostics even without re-running.
    rp("led_state_history", history)
    rp("on_elapsed_s", round(elapsed, 3))
    assert reached, f"LEDState never reached ON (history: {history})"

    # Assert the published PWM matches what rgb_to_pwm.compute_pwm()
    # predicts for these RGB inputs — at the live ECU temperature.
    # ``alm.assert_pwm_matches_rgb`` reads Tj_Frame_NTC, converts it
    # to °C, and feeds it into the calculator before comparing.
    alm.assert_pwm_matches_rgb(rp, r, g, b)
    alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b)


# ╔══════════════════════════════════════════════════════════════════════╗
# ║ TEST FLAVOR B — explicit SETUP / PROCEDURE / ASSERT / TEARDOWN       ║
# ╚══════════════════════════════════════════════════════════════════════╝
# Use this shape any time the test mutates state the autouse reset
# doesn't put back. The four sections are clearly labelled and the
# try/finally guarantees TEARDOWN runs even on assertion failure —
# which is what keeps the suite independent across runs.

def test_template_with_isolation(fio: FrameIO, alm: AlmTester, rp):
    """
    Title: <verifies a behaviour that requires touching ConfigFrame>

    Description:
        <Same docstring shape as flavor A. The plugin reads it.>

    Requirements: REQ-XXX

    Test Steps:
        1. SETUP:    disable temperature compensation
        2. PROCEDURE: drive LED, wait for ON
        3. ASSERT:   PWM_wo_Comp matches the non-compensated calculator
        4. TEARDOWN: re-enable compensation so other tests see defaults

    Expected Result:
        - LED reaches ON
        - PWM_wo_Comp_{Red,Green,Blue} match compute_pwm(R,G,B).pwm_no_comp
    """
    r, g, b = 0, 180, 80

    # ── SETUP ──────────────────────────────────────────────────────────
    # The autouse fixture has already forced the LED OFF for us. Here
    # we make any *additional* changes this test specifically needs.
    # Anything we change here gets undone in TEARDOWN below.
    fio.send(
        "ConfigFrame",
        ConfigFrame_Calibration=0,
        ConfigFrame_EnableDerating=1,
        ConfigFrame_EnableCompensation=0,   # ← the change under test
        ConfigFrame_MaxLM=3840,
    )
    # Brief pause so the ECU latches the new config before the next
    # frame. 200 ms is comfortable on a 10 ms LIN bus.
    time.sleep(0.2)

    try:
        # ── PROCEDURE ─────────────────────────────────────────────────
        # The actions whose effects we are validating.
        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 "
            f"(history: {history})"
        )
        # PWM_wo_Comp is temperature-independent, so we only check it
        # here (the comp PWM would still be temperature-corrected).
        alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b)

    finally:
        # ── TEARDOWN ──────────────────────────────────────────────────
        # ALWAYS runs, even if an assertion above failed. This is what
        # keeps the suite independent: by the time the next test starts,
        # ConfigFrame is back at its default and ``_reset_to_off`` has
        # taken the LED OFF.
        fio.send(
            "ConfigFrame",
            ConfigFrame_Calibration=0,
            ConfigFrame_EnableDerating=1,
            ConfigFrame_EnableCompensation=1,   # ← restore default
            ConfigFrame_MaxLM=3840,
        )
        time.sleep(0.2)


# ╔══════════════════════════════════════════════════════════════════════╗
# ║ TEST FLAVOR C — single-signal probe                                  ║
# ╚══════════════════════════════════════════════════════════════════════╝
# Quick shape for "ask the ECU one thing and check the answer".
# ``fio.read_signal`` is the convenience reader: it receives a frame
# and pulls one signal out, returning ``default`` on timeout.

def test_template_signal_probe(fio: FrameIO, alm: AlmTester, rp):
    """
    Title: Tj_Frame_NTC reports a sensible junction temperature

    Description:
        Probes a single signal on a slave-published frame. Fast and
        useful for sanity-checking that a sensor is alive without
        decoding the rest of the frame.

    Expected Result:
        Tj_Frame_NTC is received and falls within a plausible range
        (200..400 K covers anything from a cold lab to a hot bench).
    """
    # No SETUP needed: the autouse reset already gave us OFF baseline,
    # and this test doesn't perturb anything.

    # ── PROCEDURE ──────────────────────────────────────────────────────
    ntc_kelvin = fio.read_signal(
        "Tj_Frame", "Tj_Frame_NTC",
        timeout=0.5,        # fail fast if the slave is silent
        default=None,       # what to return on timeout (so we can branch)
    )

    # ── ASSERT ─────────────────────────────────────────────────────────
    rp("ntc_raw_kelvin", ntc_kelvin)
    assert ntc_kelvin is not None, "Tj_Frame did not respond"
    assert 200 <= ntc_kelvin <= 400, (
        f"NTC reading {ntc_kelvin}K outside plausible range; "
        f"check the firmware's encoding"
    )

    # No TEARDOWN needed: nothing was perturbed.


# ╔══════════════════════════════════════════════════════════════════════╗
# ║ APPENDIX — handy patterns you'll reach for                           ║
# ╚══════════════════════════════════════════════════════════════════════╝
#
# Send raw bytes (bypass the LDF):
#     fio.send_raw(0x12, bytes([0x00] * 8))
#     rx = fio.receive_raw(0x11, timeout=0.5)
#
# Pack with the LDF, hand-edit, then send raw:
#     data = bytearray(fio.pack("ALM_Req_A", AmbLightColourRed=255, ...))
#     data[7] |= 0x80                            # twiddle a bit
#     fio.send_raw(fio.frame_id("ALM_Req_A"), bytes(data))
#
# Decode bytes you already captured:
#     decoded = fio.unpack("PWM_Frame", b"\x12\x34\x56\x78\x9A\xBC\xDE\xF0")
#
# Inspect a frame's metadata:
#     fio.frame_id("PWM_Frame")        # 0x12
#     fio.frame_length("PWM_Frame")    # 8
#
# Wait for an arbitrary state with custom timeout:
#     reached, elapsed, hist = alm.wait_for_state(LED_STATE_ANIMATING, timeout=2.0)
#
# Per-test marker for the requirements matrix:
#     @pytest.mark.req_005
#     def test_something(...): ...
