"""Copyable template for tests that drive the PSU and observe the LIN bus.

WHEN TO USE THIS TEMPLATE
-------------------------
Voltage-tolerance, brown-out, over-voltage, and "supply transient"
tests can't be done from either side alone — you need to *perturb*
the bench supply (Owon PSU) and *observe* the ECU's reaction on the
LIN bus. This template wires both ends together with the
SETUP / PROCEDURE / ASSERT / TEARDOWN pattern so the test stays
independent of the others even when it raises mid-flight.

THE CANONICAL PATTERN — settle then validate
--------------------------------------------
The Owon PSU does NOT slew instantaneously, and the slew time is
**bench-dependent** (PSU model, load, cable drop). Don't sleep a
fixed amount and assume the rail is there — *measure*. Every voltage
change in this template goes through
:func:`apply_voltage_and_settle` from ``psu_helpers``, which:

  1. Issues the setpoint.
  2. **Polls** ``measure_voltage_v()`` until the rail is actually at
     the target (within ``DEFAULT_VOLTAGE_TOL_V``, or raises on
     timeout).
  3. Holds for ``ECU_VALIDATION_TIME_S`` so the firmware-side voltage
     monitor can detect, debounce, and republish status.

After that, a **single read** of ``ALM_Status.ALMVoltageStatus``
gives an unambiguous answer — no polling-on-the-bus race.

THREE FLAVORS PROVIDED
----------------------
  A) ``test_template_overvoltage_status``  — overvoltage detection.
  B) ``test_template_undervoltage_status`` — undervoltage detection.
  C) ``test_template_voltage_status_parametrized`` — sweep.

WHY THE NAME STARTS WITH AN UNDERSCORE
--------------------------------------
pytest only collects ``test_*.py``; this file's leading underscore
keeps the example bodies out of the suite. Copy to
``test_<feature>.py`` and edit.

SAFETY — three layers keep the bench safe
-----------------------------------------
  1. The session-scoped ``psu`` fixture (in
     ``tests/hardware/conftest.py``) parks the supply at nominal
     voltage with output ON at session start, and closes with
     ``output 0`` at session end (``safe_off_on_close=True``).
  2. The autouse ``_park_at_nominal`` fixture in this file restores
     nominal voltage before AND after every test in this module —
     also via ``apply_voltage_and_settle`` so the rail is *measurably*
     back at nominal before the next test runs.
  3. Every test wraps its voltage change in ``try``/``finally`` that
     restores nominal so an assertion failure cannot leave the bench
     at an over/undervoltage rail.

WHY ``set_output`` IS NEVER CALLED HERE
---------------------------------------
On this bench the Owon PSU **powers the ECU**. Calling
``psu.set_output(False)`` mid-session would brown out the ECU and
break every test that runs afterwards. The session fixture enables
the output once at session start; tests perturb voltage but never
toggle the output state.
"""
from __future__ import annotations

import pytest

from ecu_framework.config import EcuTestConfig
from ecu_framework.lin.base import LinInterface
from ecu_framework.power import OwonPSU

from frame_io import FrameIO
from alm_helpers import AlmTester
from psu_helpers import apply_voltage_and_settle, downsample_trace


# ╔══════════════════════════════════════════════════════════════════════╗
# ║ MODULE MARKERS                                                       ║
# ╚══════════════════════════════════════════════════════════════════════╝
# ``hardware`` excludes from default mock-only runs; ``mum`` selects the
# Melexis Universal Master adapter for the LIN side.
pytestmark = [pytest.mark.hardware, pytest.mark.mum]


# ╔══════════════════════════════════════════════════════════════════════╗
# ║ CONSTANTS                                                            ║
# ╚══════════════════════════════════════════════════════════════════════╝
#
# ALM_Status.ALMVoltageStatus values, taken verbatim from the LDF's
# Signal_encoding_types: VoltageStatus block. Named constants make the
# assertions self-explanatory and give readers something to grep for.
VOLTAGE_STATUS_NORMAL = 0x00      # 'Normal Voltage'
VOLTAGE_STATUS_UNDER  = 0x01      # 'Power UnderVoltage'
VOLTAGE_STATUS_OVER   = 0x02      # 'Power OverVoltage'

# Bench voltage profile. **TUNE THESE TO YOUR ECU'S DATASHEET** before
# running on real hardware. Values shown are conservative automotive
# ranges; many ECUs trip earlier.
NOMINAL_VOLTAGE  = 13.0           # V — typical 12 V automotive nominal
OVERVOLTAGE_V    = 18.0           # V — comfortably above the OV threshold
UNDERVOLTAGE_V   = 7.0            # V — below most brown-out points

# Time to hold the rail steady AFTER the PSU has reached the target,
# before reading ``ALMVoltageStatus``. This is the **firmware-dependent**
# budget — the ECU's voltage monitor needs to sample, debounce, and
# republish on its 10 ms LIN cycle. **Tune to your firmware spec.**
# 1.0 s is a conservative starting point.
ECU_VALIDATION_TIME_S = 1.0


# ╔══════════════════════════════════════════════════════════════════════╗
# ║ FIXTURES                                                             ║
# ╚══════════════════════════════════════════════════════════════════════╝
#
# ``psu`` is provided by ``tests/hardware/conftest.py`` at SESSION
# scope (autouse) — the bench is powered up once at session start and
# stays on. Tests in this file just READ the psu fixture and perturb
# voltage; they MUST NOT close it or toggle output.
#
# ``fio`` and ``alm`` are module-scoped here. As soon as a third test
# file needs them, move both to ``tests/hardware/conftest.py``.


@pytest.fixture(scope="module")
def fio(config: EcuTestConfig, lin: LinInterface, ldf) -> FrameIO:
    """Generic LDF-driven LIN I/O for any frame in the project's LDF."""
    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."""
    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 _park_at_nominal(psu: OwonPSU, alm: AlmTester):
    """Per-test baseline: PSU voltage at NOMINAL_VOLTAGE + LED off.

    Uses :func:`apply_voltage_and_settle` so the rail is *measurably*
    at nominal before the test body runs — and afterwards, even on
    assertion failure. Validation time is short here: we just need
    the rail steady, not the ECU to react to it (the test body does
    its own settle+validation in PROCEDURE).
    """
    # SETUP — nominal voltage (measured), LED off
    apply_voltage_and_settle(psu, NOMINAL_VOLTAGE, validation_time=0.2)
    alm.force_off()
    yield
    # TEARDOWN — back to nominal even on test failure
    apply_voltage_and_settle(psu, NOMINAL_VOLTAGE, validation_time=0.2)
    alm.force_off()


# ╔══════════════════════════════════════════════════════════════════════╗
# ║ TEST FLAVOR A — overvoltage detection                                ║
# ╚══════════════════════════════════════════════════════════════════════╝


def test_template_overvoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester, rp):
    """
    Title: ECU reports OverVoltage when supply exceeds the threshold

    Description:
        Apply OVERVOLTAGE_V via :func:`apply_voltage_and_settle`, hold
        for ECU_VALIDATION_TIME_S, then read ALM_Status.ALMVoltageStatus
        once and assert it equals VOLTAGE_STATUS_OVER (0x02). Restore
        nominal supply on the way out.

    Requirements: REQ-OVP-001

    Test Steps:
        1. SETUP:    confirm baseline ALMVoltageStatus == Normal
        2. PROCEDURE: apply OVERVOLTAGE_V, wait for the rail to be
                     there, hold ECU_VALIDATION_TIME_S
        3. ASSERT:   single read of ALMVoltageStatus == OverVoltage
        4. TEARDOWN: restore NOMINAL_VOLTAGE via the same helper
                     and verify recovery to Normal

    Expected Result:
        - Baseline status is Normal
        - After settle + validation hold at OVERVOLTAGE_V,
          ALMVoltageStatus reads OverVoltage
        - After restoring nominal, ALMVoltageStatus returns to Normal
    """
    # ── SETUP ─────────────────────────────────────────────────────────
    baseline = fio.read_signal("ALM_Status", "ALMVoltageStatus", default=-1)
    rp("baseline_voltage_status", int(baseline))
    assert int(baseline) == VOLTAGE_STATUS_NORMAL, (
        f"Expected Normal at nominal supply but got {baseline!r}; "
        f"check PSU output and ECU power rail before continuing."
    )

    try:
        # ── PROCEDURE ─────────────────────────────────────────────────
        result = apply_voltage_and_settle(
            psu, OVERVOLTAGE_V,
            validation_time=ECU_VALIDATION_TIME_S,
        )
        # Single read after the rail is steady AND the ECU has had its
        # validation budget. No polling, no race.
        status = fio.read_signal(
            "ALM_Status", "ALMVoltageStatus", default=-1,
        )

        # ── ASSERT ────────────────────────────────────────────────────
        rp("psu_setpoint_v",       OVERVOLTAGE_V)
        rp("psu_settled_s",        round(result["settled_s"], 4))
        rp("psu_final_v",          result["final_v"])
        rp("validation_time_s",    result["validation_s"])
        rp("voltage_status_after", int(status))
        rp("voltage_trace",        downsample_trace(result["trace"]))
        assert int(status) == VOLTAGE_STATUS_OVER, (
            f"ALMVoltageStatus = 0x{int(status):02X} after applying "
            f"{OVERVOLTAGE_V} V (settled in {result['settled_s']:.3f} s, "
            f"held {result['validation_s']} s). Expected "
            f"0x{VOLTAGE_STATUS_OVER:02X} (OverVoltage)."
        )

    finally:
        # ── TEARDOWN ──────────────────────────────────────────────────
        # ALWAYS runs, even on assertion failure.
        apply_voltage_and_settle(
            psu, NOMINAL_VOLTAGE,
            validation_time=ECU_VALIDATION_TIME_S,
        )

    # Regression check after the try/finally: status returned to Normal.
    recovery_status = fio.read_signal("ALM_Status", "ALMVoltageStatus", default=-1)
    rp("voltage_status_recovery", int(recovery_status))
    assert int(recovery_status) == VOLTAGE_STATUS_NORMAL, (
        f"ECU did not return to Normal after restoring nominal supply. "
        f"Got 0x{int(recovery_status):02X}."
    )


# ╔══════════════════════════════════════════════════════════════════════╗
# ║ TEST FLAVOR B — undervoltage detection                               ║
# ╚══════════════════════════════════════════════════════════════════════╝


def test_template_undervoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester, rp):
    """
    Title: ECU reports UnderVoltage when supply drops below the threshold

    Description:
        Symmetric counterpart to flavor A — apply UNDERVOLTAGE_V via
        :func:`apply_voltage_and_settle`, hold for the validation
        window, then assert ALMVoltageStatus = 0x01.

        Note that at very low voltages the ECU may stop publishing
        ALM_Status entirely (full brown-out). Pick UNDERVOLTAGE_V high
        enough to keep the LIN node alive but low enough to trip the
        UV flag — your firmware spec defines the right value.

    Test Steps:
        1. SETUP:    confirm baseline ALMVoltageStatus == Normal
        2. PROCEDURE: apply UNDERVOLTAGE_V via apply_voltage_and_settle
        3. ASSERT:   single read of ALMVoltageStatus == UnderVoltage
        4. TEARDOWN: restore NOMINAL_VOLTAGE and verify recovery
    """
    # ── SETUP ─────────────────────────────────────────────────────────
    baseline = fio.read_signal("ALM_Status", "ALMVoltageStatus", default=-1)
    rp("baseline_voltage_status", int(baseline))
    assert int(baseline) == VOLTAGE_STATUS_NORMAL, (
        f"Expected Normal at nominal supply but got {baseline!r}"
    )

    try:
        # ── PROCEDURE ─────────────────────────────────────────────────
        result = apply_voltage_and_settle(
            psu, UNDERVOLTAGE_V,
            validation_time=ECU_VALIDATION_TIME_S,
        )
        status = fio.read_signal(
            "ALM_Status", "ALMVoltageStatus", default=-1,
        )

        # ── ASSERT ────────────────────────────────────────────────────
        rp("psu_setpoint_v",       UNDERVOLTAGE_V)
        rp("psu_settled_s",        round(result["settled_s"], 4))
        rp("psu_final_v",          result["final_v"])
        rp("validation_time_s",    result["validation_s"])
        rp("voltage_status_after", int(status))
        rp("voltage_trace",        downsample_trace(result["trace"]))
        assert int(status) == VOLTAGE_STATUS_UNDER, (
            f"ALMVoltageStatus = 0x{int(status):02X} after applying "
            f"{UNDERVOLTAGE_V} V (settled in {result['settled_s']:.3f} s, "
            f"held {result['validation_s']} s). Expected "
            f"0x{VOLTAGE_STATUS_UNDER:02X} (UnderVoltage). "
            f"If status == -1 the slave likely browned out — raise "
            f"UNDERVOLTAGE_V toward the trip point so the node stays alive."
        )

    finally:
        # ── TEARDOWN ──────────────────────────────────────────────────
        apply_voltage_and_settle(
            psu, NOMINAL_VOLTAGE,
            validation_time=ECU_VALIDATION_TIME_S,
        )

    recovery_status = fio.read_signal("ALM_Status", "ALMVoltageStatus", default=-1)
    rp("voltage_status_recovery", int(recovery_status))
    assert int(recovery_status) == VOLTAGE_STATUS_NORMAL, (
        f"ECU did not return to Normal after restoring nominal supply. "
        f"Got 0x{int(recovery_status):02X}."
    )


# ╔══════════════════════════════════════════════════════════════════════╗
# ║ TEST FLAVOR C — parametrized voltage sweep                           ║
# ╚══════════════════════════════════════════════════════════════════════╝
#
# A single function that walks several (voltage, expected_status)
# pairs. ``@pytest.mark.parametrize`` repeats the body once per tuple,
# generating one independent test per row in the report. Each
# invocation goes through the autouse fixture again, so they remain
# isolated from each other.

_VOLTAGE_SCENARIOS = [
    # (psu_voltage, expected_alm_status, label)
    (NOMINAL_VOLTAGE, VOLTAGE_STATUS_NORMAL, "nominal"),
    (OVERVOLTAGE_V,   VOLTAGE_STATUS_OVER,   "overvoltage"),
    (UNDERVOLTAGE_V,  VOLTAGE_STATUS_UNDER,  "undervoltage"),
]


@pytest.mark.parametrize(
    "voltage,expected,label",
    _VOLTAGE_SCENARIOS,
    ids=[s[2] for s in _VOLTAGE_SCENARIOS],   # nice IDs in the report
)
def test_template_voltage_status_parametrized(
    psu: OwonPSU,
    fio: FrameIO,
    rp,
    voltage: float,
    expected: int,
    label: str,
):
    """
    Title: ECU voltage status tracks the supply (sweep)

    Description:
        Walks a small matrix of supply levels and asserts the ECU
        reports the corresponding ``ALMVoltageStatus``. Each row uses
        :func:`apply_voltage_and_settle` so the supply is *measurably*
        at the target before the validation hold and the status read.

    Expected Result:
        For each (voltage, expected) tuple: a single ALMVoltageStatus
        read after settle + validation equals ``expected``.
    """
    try:
        # ── PROCEDURE ─────────────────────────────────────────────────
        result = apply_voltage_and_settle(
            psu, voltage,
            validation_time=ECU_VALIDATION_TIME_S,
        )
        status = fio.read_signal(
            "ALM_Status", "ALMVoltageStatus", default=-1,
        )

        # ── ASSERT ────────────────────────────────────────────────────
        rp("scenario",             label)
        rp("psu_setpoint_v",       voltage)
        rp("expected_status",      expected)
        rp("psu_settled_s",        round(result["settled_s"], 4))
        rp("psu_final_v",          result["final_v"])
        rp("validation_time_s",    result["validation_s"])
        rp("voltage_status_after", int(status))
        assert int(status) == expected, (
            f"[{label}] ALMVoltageStatus = 0x{int(status):02X} after "
            f"applying {voltage} V (settled in {result['settled_s']:.3f} s, "
            f"held {result['validation_s']} s). Expected 0x{expected:02X}."
        )

    finally:
        # ── TEARDOWN ──────────────────────────────────────────────────
        apply_voltage_and_settle(
            psu, NOMINAL_VOLTAGE,
            validation_time=ECU_VALIDATION_TIME_S,
        )


# ╔══════════════════════════════════════════════════════════════════════╗
# ║ APPENDIX — patterns you'll reach for                                 ║
# ╚══════════════════════════════════════════════════════════════════════╝
#
# Read the parsed measured voltage / current at any time:
#     v = psu.measure_voltage_v()       # float | None
#     i = psu.measure_current_a()       # float | None
#     rp("psu_measured_v", v)
#
# Apply a setpoint and just settle (no firmware-side wait):
#     from psu_helpers import apply_voltage_and_settle
#     apply_voltage_and_settle(psu, 13.0, validation_time=0.2)
#
# Decode the entire ALM_Status frame (all signals at once):
#     decoded = fio.receive("ALM_Status")
#     # decoded → {'ALMNadNo': 1, 'ALMVoltageStatus': 0,
#     #            'ALMThermalStatus': 0, 'ALMNVMStatus': 0,
#     #            'ALMLEDState': 0, 'SigCommErr': 0}
#
# Verify the LED also turns OFF in undervoltage (some firmwares do):
#     reached, _, hist = alm.wait_for_state(LED_STATE_OFF, timeout=2.0)
#     assert reached, hist
#
# Add a per-test marker for the requirements matrix:
#     @pytest.mark.req_007
#     def test_xxx(...): ...
