"""Voltage-tolerance tests: drive the PSU and observe the LIN bus.

WHAT THIS FILE COVERS
---------------------
Voltage-tolerance, brown-out, over-voltage, and "supply transient"
behaviour. Tests perturb the bench supply (Owon PSU) and observe the
ECU's reaction on the LIN bus, using the
SETUP / PROCEDURE / ASSERT / TEARDOWN pattern so each case stays
independent of the others even when it raises mid-flight.

PATTERN — settle-then-validate
------------------------------
The Owon PSU does NOT slew instantaneously, and the slew time depends
on the bench (PSU model, load, cable drop). The test_psu_voltage_settling
characterization showed e.g. up-step ≠ down-step time. Instead of
guessing a fixed sleep, every voltage change in this file 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 tolerance, or raises on timeout).
  3. Holds for ``ECU_VALIDATION_TIME_S`` so the firmware-side voltage
     monitor can detect and republish status.

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

THREE FLAVORS
-------------
  A) ``test_template_overvoltage_status``  — over-voltage detection.
  B) ``test_template_undervoltage_status`` — under-voltage detection.
  C) ``test_template_voltage_status_parametrized`` — sweep.

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
     (using the same settle helper, so the next test starts steady).
  3. Every test wraps its voltage change in ``try``/``finally`` so an
     assertion failure cannot leave the bench at an over/undervoltage
     rail.

NEVER call ``psu.set_output(False)`` or ``psu.close()`` from a test —
the Owon PSU powers the ECU on this bench, so toggling output kills
LIN communication for every test that follows in the same session.
The session fixture owns the PSU lifecycle.
"""
from __future__ import annotations

import pytest

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. Hard-coding them as named
# constants makes the assertions self-explanatory and gives 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 the test 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    = 19.0           # V — comfortably above the OV threshold
UNDERVOLTAGE_V   = 7.0            # V — below most brown-out points

# Time we 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`` come from ``tests/hardware/mum/conftest.py``.
# This module overrides ``_reset_to_off`` because parking the PSU at the
# nominal voltage is part of every test's baseline here, not just the
# LED state — see the docstring below.


@pytest.fixture(autouse=True)
def _reset_to_off(psu: OwonPSU, alm: AlmTester):
    """Per-test baseline: PSU voltage at NOMINAL_VOLTAGE + LED off.

    Overrides the conftest's LED-only ``_reset_to_off`` because over/under-
    voltage tests need both the rail and the LED restored. 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 will do its own
    settle+validation in the PROCEDURE).
    """
    # SETUP — nominal voltage, then 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:
        Drive the PSU above the firmware's overvoltage threshold,
        wait for the rail to actually be there, give the ECU
        ``ECU_VALIDATION_TIME_S`` to detect and republish, then read
        ``ALM_Status.ALMVoltageStatus`` once and assert it equals
        ``VOLTAGE_STATUS_OVER`` (0x02).

    Requirements: REQ-OVP-001

    Test Steps:
        1. SETUP:    confirm baseline ALMVoltageStatus == Normal
                     (the autouse fixture parked us at nominal and
                     waited for the rail to settle)
        2. PROCEDURE: apply OVERVOLTAGE_V, wait until measured rail
                     reaches it, hold ECU_VALIDATION_TIME_S
        3. ASSERT:   single read of ALMVoltageStatus == OverVoltage
        4. TEARDOWN: restore NOMINAL_VOLTAGE via the same helper,
                     then verify the ECU returns to Normal

    Expected Result:
        - Baseline status is Normal
        - After settle + validation hold at OVERVOLTAGE_V,
          ALMVoltageStatus reads OverVoltage
        - After settle + validation hold at NOMINAL_VOLTAGE again,
          ALMVoltageStatus reads Normal
    """
    # ── SETUP ─────────────────────────────────────────────────────────
    # Sanity-check the baseline. If the ECU isn't reporting Normal at
    # nominal supply, our test premise is broken — fail fast rather
    # than hunt the wrong issue later.
    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 ─────────────────────────────────────────────────
        # Apply the OV setpoint and wait for the rail to actually be
        # there, then hold for ECU_VALIDATION_TIME_S so the firmware
        # can sample, debounce, and republish ALM_Status.
        result = apply_voltage_and_settle(
            psu, OVERVOLTAGE_V,
            validation_time=ECU_VALIDATION_TIME_S,
        )
        # Single, deterministic read after the rail is steady AND the
        # ECU has had its validation budget.
        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. Belt-and-suspenders:
        # the autouse fixture also restores nominal on the way out.
        apply_voltage_and_settle(
            psu, NOMINAL_VOLTAGE,
            validation_time=ECU_VALIDATION_TIME_S,
        )

    # Regression check: after restoring nominal supply and validation
    # hold, status returns to Normal. Outside the try/finally so a
    # failure here doesn't mask the primary OV assertion.
    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 — drop the supply below the
        firmware's brown-out threshold, wait for the rail to be there,
        hold for the ECU validation window, then assert
        ``ALMVoltageStatus = 0x01`` (Power UnderVoltage).

        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

    Expected Result:
        - Baseline status is Normal
        - After settle + validation hold at UNDERVOLTAGE_V,
          ALMVoltageStatus reads UnderVoltage
        - 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}"
    )

    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],
)
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,
        )
