#!/usr/bin/env python3
"""
scenarios.py — Declarative scenario matrix for the streaming-chain E2E suite.

Each scenario describes one full-chain run:
    MARTe2 App (UDPStreamer) -> StreamHub -> mock client

The matrix is a *curated covering set*: every configurable option value of the
UDPStreamer appears in at least one scenario, plus deliberately chosen
high-risk interactions. gen_data.py / gen_cfg.py / client / validate_waveform.py
all consume the scenario dict schema documented below.

Scenario dict schema
--------------------
{
  "id":              str   # unique slug, also the artifact basename
  "desc":            str   # human-readable one-liner for the report
  "network":         "unicast" | "multicast"
  "publishing":      "Strict" | "Accumulate" | "Decimate"
  "ratio":           int | None        # Decimate only (>=1)
  "min_refresh_hz":  float | None       # Accumulate only (>0)
  "max_payload":     int                # MaxPayloadSize bytes
  "ws_port":         int                # StreamHub WSPort (unique per scenario)
  "sources": [                          # one or more UDPStreamer blocks
    {
      "id":            str              # source/session id (hub Source key)
      "udp_port":      int              # UDPStreamer Port (unique per scenario)
      "data_port":     int | None       # multicast DATA port
      "multicast_group": str | None     # e.g. "239.0.0.1"
      "signals": [
        {
          "name":          str
          "type":          one of TYPE_CODES keys
          "elements":      int          # NumberOfElements (1 = scalar)
          "time_mode":     "PacketTime"|"FullArray"|"FirstSample"|"LastSample"
          "time_signal":   str | None   # name of the TimeSignal (when needed)
          "sampling_rate": float | None # Hz, for First/LastSample
          "quant":         "none"|"uint8"|"int8"|"uint16"|"int16"
          "range_min":     float | None # quant only
          "range_max":     float | None # quant only
          "unit":          str | None   # e.g. "V", "us", "ns"
          "formula":       "sine"|"ramp"|"counter"|"time_ns"|"time_us"
          "freq":          float | None # sine frequency (Hz over row index)
          "is_time":       bool         # True if this signal is a TimeSignal
        }, ...
      ]
    }, ...
  ],
  "oracle":          "analytic" | "fed" | "both"
  "client_checks":   subset of ["live","zoom","window","trigger"]
  "trig_signal":     "src:sig" | None   # which signal to trigger on
  "known_issue":     str | None         # if set, the scenario exercises a
                                        # documented, not-yet-fixed chain gap:
                                        # a FAIL is reclassified XFAIL (does not
                                        # break the baseline) and a PASS becomes
                                        # XPASS (the bug is unexpectedly fixed —
                                        # time to drop the marker). The string is
                                        # the human-readable reason.
}
"""

# MARTe2 TypeDescriptor.all codes: all = (type<<2) | (bits<<6),
# BasicType SignedInteger=0, UnsignedInteger=1, Float=2 (verified against
# MARTe2 L0Types/BasicType.h and L1Portability/TypeDescriptor.h).
TYPE_CODES = {
    "int8":    0x0200,
    "uint8":   0x0204,
    "int16":   0x0400,
    "uint16":  0x0404,
    "int32":   0x0800,
    "uint32":  0x0804,
    "int64":   0x1000,
    "uint64":  0x1004,
    "float32": 0x0808,
    "float64": 0x1008,
}

# numpy dtype per MARTe type (little-endian).
NP_DTYPE = {
    "int8": "<i1", "uint8": "<u1", "int16": "<i2", "uint16": "<u2",
    "int32": "<i4", "uint32": "<u4", "int64": "<i8", "uint64": "<u8",
    "float32": "<f4", "float64": "<f8",
}

# Wire constraints shared with the C++ UDPS layer. The UDPStreamer fragments the
# serialised packet into chunks of at most MaxPayloadSize *payload* bytes and
# prepends a UDPS_HEADER_SIZE-byte header, so the datagram it hands to sendto() is
# MaxPayloadSize+UDPS_HEADER_SIZE bytes. That must not exceed the maximum UDP
# payload or sendto() fails with EMSGSIZE.
UDPS_HEADER_SIZE = 17       # bytes; mirrors Common/UDP/UDPSProtocol.h
MAX_UDP_PAYLOAD = 65507     # 65535 - 20 (IP) - 8 (UDP)

FLOAT_TYPES = {"float32", "float64"}
QUANT_TYPES = {"none", "uint8", "int8", "uint16", "int16"}
QUANT_LEVELS = {"uint8": 255, "int8": 254, "uint16": 65535, "int16": 65534}
TIME_MODES = {"PacketTime", "FullArray", "FirstSample", "LastSample"}

# Producer buffer geometry — the single source of truth shared with gen_data.py.
# The MARTe FileReader loops this finite buffer (EOF=Rewind), so the streamed
# signal is only a continuous waveform if the buffer holds an integer number of
# periods. The buffer fundamental LOOP_HZ = 1/(NUM_ROWS*ROW_DT) is therefore the
# smallest sine frequency that loops seamlessly; every sine freq must be a
# positive integer multiple of it or the analytic shape oracle is invalid.
NUM_ROWS = 200          # producer cycles written to the FileReader input
ROW_DT = 1.0e-3         # seconds per producer cycle (row); 1 kHz producer
LOOP_HZ = 1.0 / (NUM_ROWS * ROW_DT)   # 5.0 Hz buffer fundamental


def geometry(s):
    """Resolve a scenario's producer geometry (per-scenario override or global).

    Returns (row_dt, num_rows, producer_hz, loop_hz). Every data/config/validator
    consumer reads geometry through here so the override cannot drift apart from
    the seamless-loop fundamental used by the sine constraint.
    """
    row_dt = s.get("row_dt") or ROW_DT
    num_rows = s.get("num_rows") or NUM_ROWS
    producer_hz = s.get("producer_hz") or int(round(1.0 / row_dt))
    loop_hz = 1.0 / (num_rows * row_dt)
    return row_dt, num_rows, producer_hz, loop_hz


def _sig(name, type, elements=1, time_mode="PacketTime", time_signal=None,
         sampling_rate=None, quant="none", range_min=None, range_max=None,
         unit=None, formula="sine", freq=1.0, is_time=False):
    return {
        "name": name, "type": type, "elements": elements,
        "time_mode": time_mode, "time_signal": time_signal,
        "sampling_rate": sampling_rate, "quant": quant,
        "range_min": range_min, "range_max": range_max, "unit": unit,
        "formula": formula, "freq": freq, "is_time": is_time,
    }


def validate_scenario(s):
    """Return a list of validity error strings (empty == valid)."""
    errs = []
    kind = s.get("kind", "chain")
    if kind == "direct":
        if "cfg" not in s:
            errs.append(f"direct scenario {s.get('id')} missing cfg")
        return errs
    if kind == "recorder":
        if "hub_cfg" not in s or "marte_cfg" not in s:
            errs.append(f"recorder scenario {s.get('id')} missing hub_cfg/marte_cfg")
        return errs
    if kind != "chain":
        errs.append(f"unknown scenario kind: {kind}")
        return errs
    row_dt, num_rows, _producer_hz, loop_hz = geometry(s)
    if s.get("network") not in ("unicast", "multicast"):
        errs.append("network must be unicast|multicast")
    if s.get("publishing") not in ("Strict", "Accumulate", "Decimate"):
        errs.append("publishing invalid")
    if s["publishing"] == "Decimate" and not (s.get("ratio") and s["ratio"] >= 1):
        errs.append("Decimate needs ratio>=1")
    if s["publishing"] == "Accumulate" and not (s.get("min_refresh_hz") and
                                                 s["min_refresh_hz"] > 0):
        errs.append("Accumulate needs min_refresh_hz>0")
    if not s.get("sources"):
        errs.append("no sources")
    max_payload = s.get("max_payload")
    if max_payload is not None and max_payload + UDPS_HEADER_SIZE > MAX_UDP_PAYLOAD:
        errs.append(
            f"max_payload {max_payload} + {UDPS_HEADER_SIZE}B header exceeds the "
            f"{MAX_UDP_PAYLOAD}B UDP datagram limit (sendto EMSGSIZE); "
            f"cap at {MAX_UDP_PAYLOAD - UDPS_HEADER_SIZE}")
    for src in s.get("sources", []):
        if s["network"] == "multicast":
            if not src.get("multicast_group") or not src.get("data_port"):
                errs.append(f"src {src['id']}: multicast needs group+data_port")
        names = {sig["name"] for sig in src["signals"]}
        for sig in src["signals"]:
            if sig["type"] not in TYPE_CODES:
                errs.append(f"{sig['name']}: bad type {sig['type']}")
            if sig["quant"] not in QUANT_TYPES:
                errs.append(f"{sig['name']}: bad quant {sig['quant']}")
            if sig["quant"] != "none":
                if sig["type"] not in FLOAT_TYPES:
                    errs.append(f"{sig['name']}: quant only on float types")
                if sig["range_min"] is None or sig["range_max"] is None:
                    errs.append(f"{sig['name']}: quant needs range_min/max")
            if sig["time_mode"] not in TIME_MODES:
                errs.append(f"{sig['name']}: bad time_mode")
            if sig["time_mode"] != "PacketTime":
                if not sig["time_signal"] or sig["time_signal"] not in names:
                    errs.append(f"{sig['name']}: time_mode needs valid time_signal")
            if sig["time_mode"] == "FullArray":
                ts = next((x for x in src["signals"]
                           if x["name"] == sig["time_signal"]), None)
                if ts and ts["elements"] != sig["elements"]:
                    errs.append(f"{sig['name']}: FullArray time_signal length mismatch")
            if sig["time_mode"] in ("FirstSample", "LastSample"):
                if not sig["sampling_rate"] or sig["sampling_rate"] <= 0:
                    errs.append(f"{sig['name']}: First/LastSample needs sampling_rate>0")
                else:
                    # The per-packet sample window must fit inside one producer
                    # cycle, or successive packets' expanded timestamps overlap
                    # and the hub's binary-search ring (which assumes a sorted
                    # time axis) is corrupted — the s17/s18 failure class.
                    window = (sig["elements"] - 1) / sig["sampling_rate"]
                    if window > row_dt + 1e-12:
                        errs.append(
                            f"{sig['name']}: {sig['time_mode']} window "
                            f"{window * 1e3:.4f} ms > row period {row_dt * 1e3:.4f} ms "
                            f"(non-monotonic ring); raise producer rate or "
                            f"sampling_rate, or lower elements")
            if sig["formula"] == "sine" and sig.get("freq"):
                ratio = sig["freq"] / loop_hz
                if abs(ratio - round(ratio)) > 1e-9 or round(ratio) < 1:
                    errs.append(f"{sig['name']}: sine freq {sig['freq']} must be a "
                                f"positive multiple of LOOP_HZ={loop_hz} (seamless loop)")
    return errs


# ── Curated covering matrix ───────────────────────────────────────────────────
# The first three scenarios are referenced positionally by tests_py.py
# (SCENARIOS[0..2]) and are kept verbatim. The remainder is built with the helpers
# below, which auto-allocate unique WS/UDP/DATA ports so the matrix can grow
# without manual bookkeeping. Coverage goal: every UDPStreamer option *value*
# (all 10 types, scalar+array shapes, all four TimeModes, all five quant kinds,
# the three publishing modes, unicast+multicast, fragmentation via small payload,
# multi-source) appears at least once, plus deliberately chosen high-risk
# interactions (decimate+quant+array, accumulate+fullarray, multicast+decimate,
# fragmentation+decimate). Non-sine formulas (counter/ramp) are preferred for
# pure type/shape coverage because only the fidelity oracle gates them; sine is
# used where the shape metric should be tracked. MCAST_GROUP must have a route on
# the test host or those scenarios report SKIP (the orchestrator probes it).
import itertools

MCAST_GROUP = "239.0.7.7"
_ws = itertools.count(8104)
_udp = itertools.count(44616, 2)
_data = itertools.count(45616, 2)


def _src(sid, signals, multicast=False):
    return {
        "id": sid, "udp_port": next(_udp),
        "data_port": next(_data) if multicast else None,
        "multicast_group": MCAST_GROUP if multicast else None,
        "signals": signals,
    }


def mk(sid, desc, sources, network="unicast", publishing="Strict",
       ratio=None, min_refresh_hz=None, max_payload=1400,
       oracle="analytic", checks=("live", "zoom"), trig=None,
       known_issue=None, row_dt=None, num_rows=None, producer_hz=None):
    # row_dt / num_rows / producer_hz override the global producer geometry for
    # this scenario only (default None == use the suite-wide NUM_ROWS / ROW_DT /
    # 1 kHz). They are kept together so the per-cycle wall gap (1/producer_hz),
    # the encoded time-step (row_dt) and the seamless-loop fundamental
    # (1/(num_rows*row_dt)) stay mutually consistent.
    return {
        "id": sid, "desc": desc, "network": network, "publishing": publishing,
        "ratio": ratio, "min_refresh_hz": min_refresh_hz,
        "max_payload": max_payload, "ws_port": next(_ws),
        "sources": sources, "oracle": oracle,
        "client_checks": list(checks), "trig_signal": trig,
        "known_issue": known_issue,
        "row_dt": row_dt, "num_rows": num_rows, "producer_hz": producer_hz,
    }


_STARTERS = [
    {
        "id": "s01_scalar_uint32",
        "desc": "Single uint32 scalar counter, Strict unicast (type fidelity)",
        "network": "unicast", "publishing": "Strict",
        "ratio": None, "min_refresh_hz": None, "max_payload": 1400,
        "ws_port": 8101,
        "sources": [{
            "id": "src", "udp_port": 44610, "data_port": None,
            "multicast_group": None,
            "signals": [
                _sig("Counter", "uint32", 1, formula="counter"),
                _sig("Sine", "float32", 1, formula="sine", freq=5.0, unit="V"),
            ],
        }],
        "oracle": "analytic",
        "client_checks": ["live", "zoom", "window", "trigger"],
        "trig_signal": "src:Sine",
    },
    {
        "id": "s02_array_float32_fullarray",
        "desc": "100-elem float32 array, FullArray time mode, uint64 ns time array",
        "network": "unicast", "publishing": "Strict",
        "ratio": None, "min_refresh_hz": None,
        "max_payload": MAX_UDP_PAYLOAD - UDPS_HEADER_SIZE,
        "ws_port": 8102,
        "sources": [{
            "id": "src", "udp_port": 44612, "data_port": None,
            "multicast_group": None,
            "signals": [
                _sig("TimeArr", "uint64", 100, unit="ns",
                     formula="time_ns", is_time=True),
                _sig("Wave", "float32", 100, time_mode="FullArray",
                     time_signal="TimeArr", formula="sine", freq=5.0, unit="V"),
            ],
        }],
        "oracle": "both",
        "client_checks": ["live", "zoom"],
        "trig_signal": None,
    },
    {
        "id": "s03_quant_uint16",
        "desc": "float32 scalar quantised to uint16 over [-5,5], Strict unicast",
        "network": "unicast", "publishing": "Strict",
        "ratio": None, "min_refresh_hz": None, "max_payload": 1400,
        "ws_port": 8103,
        "sources": [{
            "id": "src", "udp_port": 44614, "data_port": None,
            "multicast_group": None,
            "signals": [
                _sig("Sine", "float32", 1, quant="uint16",
                     range_min=-5.0, range_max=5.0, formula="sine",
                     freq=10.0, unit="V"),
            ],
        }],
        "oracle": "analytic",
        "client_checks": ["live", "zoom"],
        "trig_signal": "src:Sine",
    },
]

# ── Type fidelity: one scalar per remaining MARTe type (fidelity-only) ────────
_TYPES = [
    mk("s04_int8_scalar", "int8 scalar counter, type fidelity",
       [_src("src", [_sig("Cnt", "int8", 1, formula="counter")])]),
    mk("s05_uint8_scalar", "uint8 scalar counter, type fidelity",
       [_src("src", [_sig("Cnt", "uint8", 1, formula="counter")])]),
    mk("s06_int16_scalar", "int16 scalar ramp, type fidelity",
       [_src("src", [_sig("Ramp", "int16", 1, formula="ramp")])]),
    mk("s07_uint16_scalar", "uint16 scalar ramp, type fidelity",
       [_src("src", [_sig("Ramp", "uint16", 1, formula="ramp")])]),
    mk("s08_int32_scalar", "int32 scalar counter, type fidelity",
       [_src("src", [_sig("Cnt", "int32", 1, formula="counter")])]),
    mk("s09_int64_scalar", "int64 scalar counter, type fidelity",
       [_src("src", [_sig("Cnt", "int64", 1, formula="counter")])]),
    mk("s10_uint64_scalar", "uint64 scalar counter, type fidelity",
       [_src("src", [_sig("Cnt", "uint64", 1, formula="counter")])]),
    mk("s11_float64_scalar", "float64 scalar sine 5 Hz (double-precision path)",
       [_src("src", [_sig("Sine", "float64", 1, formula="sine", freq=5.0,
                          unit="V")])],
       checks=("live", "zoom", "trigger"), trig="src:Sine"),
]

# ── Array shapes (NumberOfElements) ──────────────────────────────────────────
_ARRAYS = [
    mk("s12_f32_arr8", "float32 8-elem array sine 5 Hz",
       [_src("src", [_sig("Wave", "float32", 8, formula="sine", freq=5.0)])]),
    mk("s13_f32_arr32", "float32 32-elem array sine 10 Hz",
       [_src("src", [_sig("Wave", "float32", 32, formula="sine", freq=10.0)])]),
    mk("s14_f64_arr64", "float64 64-elem array ramp",
       [_src("src", [_sig("Ramp", "float64", 64, formula="ramp")])]),
    mk("s15_i16_arr16", "int16 16-elem array counter",
       [_src("src", [_sig("Cnt", "int16", 16, formula="counter")])]),
    mk("s16_f32_arr256", "float32 256-elem array sine 5 Hz (large frame)",
       [_src("src", [_sig("Wave", "float32", 256, formula="sine", freq=5.0)])],
       max_payload=MAX_UDP_PAYLOAD - UDPS_HEADER_SIZE),
    mk("s39_uint8_arr32", "uint8 32-elem array counter (wrap fidelity)",
       [_src("src", [_sig("Cnt", "uint8", 32, formula="counter")])]),
    mk("s40_int8_arr16", "int8 16-elem array counter (wrap fidelity)",
       [_src("src", [_sig("Cnt", "int8", 16, formula="counter")])]),
]

# ── Time modes (each non-PacketTime mode needs a TimeSignal in the source) ────
_TIMEMODES = [
    # sampling_rate = elements/ROW_DT (8/0.001 = 8000) so the per-cycle window
    # of 8 samples fills exactly one 1 kHz producer cycle.  A smaller rate makes
    # each cycle's window wider than the inter-cycle gap, so successive windows
    # overlap and the published timestamps stop being monotonic — which corrupts
    # the hub's binary-search range/zoom queries (they assume a sorted ring).
    mk("s17_lastsample", "float32 8-elem LastSample, uint64 ns scalar anchor",
       [_src("src", [
           _sig("Tns", "uint64", 1, unit="ns", formula="time_ns", is_time=True),
           _sig("Data", "float32", 8, time_mode="LastSample",
                time_signal="Tns", sampling_rate=8000.0, formula="counter"),
       ])]),
    mk("s18_firstsample", "float32 8-elem FirstSample, uint32 us scalar anchor",
       [_src("src", [
           _sig("Tus", "uint32", 1, unit="us", formula="time_us", is_time=True),
           _sig("Data", "float32", 8, time_mode="FirstSample",
                time_signal="Tus", sampling_rate=8000.0, formula="counter"),
       ])]),
    mk("s19_fullarray_f64", "float64 50-elem FullArray sine 5 Hz, uint64 ns time",
       [_src("src", [
           _sig("TimeArr", "uint64", 50, unit="ns", formula="time_ns",
                is_time=True),
           _sig("Wave", "float64", 50, time_mode="FullArray",
                time_signal="TimeArr", formula="sine", freq=5.0, unit="V"),
       ])],
       oracle="both"),
    mk("s43_fullarray_quant", "float32 16-elem FullArray quant uint16 sine 5 Hz",
       [_src("src", [
           _sig("TimeArr", "uint64", 16, unit="ns", formula="time_ns",
                is_time=True),
           _sig("Wave", "float32", 16, time_mode="FullArray",
                time_signal="TimeArr", quant="uint16", range_min=-1.0,
                range_max=1.0, formula="sine", freq=5.0, unit="V"),
       ])]),
]

# ── Quantization (each QuantizedType, on float signals) ──────────────────────
_QUANT = [
    mk("s20_quant_uint8", "float32 scalar quant uint8 [-1,1] sine 5 Hz",
       [_src("src", [_sig("Sine", "float32", 1, quant="uint8", range_min=-1.0,
                          range_max=1.0, formula="sine", freq=5.0)])]),
    mk("s21_quant_int8", "float32 scalar quant int8 [-10,10] sine 5 Hz",
       [_src("src", [_sig("Sine", "float32", 1, quant="int8", range_min=-10.0,
                          range_max=10.0, formula="sine", freq=5.0)])]),
    mk("s22_quant_int16", "float32 scalar quant int16 [-100,100] ramp",
       [_src("src", [_sig("Ramp", "float32", 1, quant="int16", range_min=-100.0,
                          range_max=100.0, formula="ramp")])]),
    mk("s23_quant_f64_arr", "float64 16-elem quant uint16 [-2,2] sine 5 Hz",
       [_src("src", [_sig("Wave", "float64", 16, quant="uint16", range_min=-2.0,
                          range_max=2.0, formula="sine", freq=5.0)])]),
]

# ── Publishing modes (Strict covered; Accumulate + Decimate here) ────────────
_PUBLISH = [
    mk("s24_accumulate", "float32 scalar sine 5 Hz, Accumulate @50 Hz refresh",
       [_src("src", [_sig("Sine", "float32", 1, formula="sine", freq=5.0)])],
       publishing="Accumulate", min_refresh_hz=50.0),
    mk("s25_decimate4", "float32 scalar sine 5 Hz, Decimate ratio 4",
       [_src("src", [_sig("Sine", "float32", 1, formula="sine", freq=5.0)])],
       publishing="Decimate", ratio=4),
    mk("s26_decimate10_arr", "float32 8-elem counter, Decimate ratio 10",
       [_src("src", [_sig("Cnt", "float32", 8, formula="counter")])],
       publishing="Decimate", ratio=10),
    mk("s46_accumulate_arr",
       "Accumulate @200 Hz: accumulated scalar sine + 16-elem array passenger",
       [_src("src", [_sig("Sine", "float32", 1, formula="sine", freq=5.0),
                     _sig("Wave", "float32", 16, formula="sine", freq=5.0)])],
       publishing="Accumulate", min_refresh_hz=200.0),
    mk("s45_decimate_multisig", "Decimate ratio 2 over a 2-signal source",
       [_src("src", [_sig("Sine", "float32", 1, formula="sine", freq=5.0),
                     _sig("Cnt", "uint32", 1, formula="counter")])],
       publishing="Decimate", ratio=2),
]

# ── Payload size / fragmentation (small MaxPayloadSize vs large array) ───────
_PAYLOAD = [
    mk("s27_frag_f64_128", "float64 128-elem ramp, MaxPayload 512 (fragmented)",
       [_src("src", [_sig("Ramp", "float64", 128, formula="ramp")])],
       max_payload=512),
    mk("s28_frag_f32_100", "float32 100-elem sine 5 Hz, MaxPayload 256 (fragmented)",
       [_src("src", [_sig("Wave", "float32", 100, formula="sine", freq=5.0)])],
       max_payload=256),
    mk("s48_f64_arr_big_payload", "float64 100-elem ramp, MaxPayload 65490 (single frame)",
       [_src("src", [_sig("Ramp", "float64", 100, formula="ramp")])],
       max_payload=MAX_UDP_PAYLOAD - UDPS_HEADER_SIZE),
]

# ── Multicast ────────────────────────────────────────────────────────────────
_MCAST = [
    mk("s29_mcast_scalar", "multicast float32 scalar sine 5 Hz",
       [_src("src", [_sig("Sine", "float32", 1, formula="sine", freq=5.0)],
             multicast=True)],
       network="multicast"),
    mk("s30_mcast_arr_fullarray", "multicast float32 32-elem FullArray sine 5 Hz",
       [_src("src", [
           _sig("TimeArr", "uint64", 32, unit="ns", formula="time_ns",
                is_time=True),
           _sig("Wave", "float32", 32, time_mode="FullArray",
                time_signal="TimeArr", formula="sine", freq=5.0),
       ], multicast=True)],
       network="multicast"),
    mk("s47_mcast_multisrc", "multicast, two sources (scalar each)",
       [_src("a", [_sig("Sine", "float32", 1, formula="sine", freq=5.0)],
             multicast=True),
        _src("b", [_sig("Cnt", "uint32", 1, formula="counter")],
             multicast=True)],
       network="multicast"),
]

# ── Multiple sources (independent UDPStreamer blocks, one hub) ───────────────
_MULTISRC = [
    mk("s31_two_src", "two unicast sources: float32 sine + uint32 counter",
       [_src("a", [_sig("Sine", "float32", 1, formula="sine", freq=5.0)]),
        _src("b", [_sig("Cnt", "uint32", 1, formula="counter")])]),
    mk("s32_three_src", "three unicast sources: int16 ramp / float64 sine / uint8 counter",
       [_src("a", [_sig("Ramp", "int16", 1, formula="ramp")]),
        _src("b", [_sig("Sine", "float64", 1, formula="sine", freq=5.0)]),
        _src("c", [_sig("Cnt", "uint8", 1, formula="counter")])]),
]

# ── High-risk interactions ───────────────────────────────────────────────────
_INTERACT = [
    mk("s33_dec_arr_quant", "Decimate 2 + 16-elem quant uint16 sine 5 Hz",
       [_src("src", [_sig("Wave", "float32", 16, quant="uint16", range_min=-1.0,
                          range_max=1.0, formula="sine", freq=5.0)])],
       publishing="Decimate", ratio=2),
    mk("s34_acc_fullarray",
       "Accumulate @100 Hz: accumulated scalar + 32-elem FullArray sine passenger",
       [_src("src", [
           _sig("Sine", "float32", 1, formula="sine", freq=5.0),
           _sig("TimeArr", "uint64", 32, unit="ns", formula="time_ns",
                is_time=True),
           _sig("Wave", "float32", 32, time_mode="FullArray",
                time_signal="TimeArr", formula="sine", freq=5.0),
       ])],
       publishing="Accumulate", min_refresh_hz=100.0),
    mk("s35_mcast_decimate", "multicast + Decimate ratio 5, float32 scalar sine 5 Hz",
       [_src("src", [_sig("Sine", "float32", 1, formula="sine", freq=5.0)],
             multicast=True)],
       network="multicast", publishing="Decimate", ratio=5),
    mk("s36_big_frag_dec", "float64 64-elem ramp, MaxPayload 256 + Decimate 4",
       [_src("src", [_sig("Ramp", "float64", 64, formula="ramp")])],
       max_payload=256, publishing="Decimate", ratio=4),
    mk("s49_mixed_quant_raw", "one source: quant uint8 sine + raw float32 sine",
       [_src("src", [
           _sig("Q", "float32", 1, quant="uint8", range_min=-1.0, range_max=1.0,
                formula="sine", freq=5.0),
           _sig("Raw", "float32", 1, formula="sine", freq=5.0),
       ])]),
]

# ── Trigger + client-check coverage (rising/falling/both swept by the client) ─
_TRIGGER = [
    mk("s37_trig_ramp_i32", "trigger on int32 ramp scalar",
       [_src("src", [_sig("Ramp", "int32", 1, formula="ramp")])],
       checks=("live", "trigger"), trig="src:Ramp"),
    mk("s38_trig_f64_sine", "trigger on float64 sine 5 Hz scalar",
       [_src("src", [_sig("Sine", "float64", 1, formula="sine", freq=5.0)])],
       checks=("live", "zoom", "trigger"), trig="src:Sine"),
    mk("s44_window_check", "float32 sine 5 Hz scalar, window time-range check",
       [_src("src", [_sig("Sine", "float32", 1, formula="sine", freq=5.0)])],
       checks=("live", "zoom", "window")),
    mk("s50_trig_quant", "trigger on quantised uint16 sine 10 Hz",
       [_src("src", [_sig("Sine", "float32", 1, quant="uint16", range_min=-5.0,
                          range_max=5.0, formula="sine", freq=10.0)])],
       checks=("live", "trigger"), trig="src:Sine"),
]

# ── Misc scalar coverage (unit annotation, counter on float) ─────────────────
_MISC = [
    mk("s41_f32_unit", "float32 scalar ramp with Unit=V",
       [_src("src", [_sig("Ramp", "float32", 1, formula="ramp", unit="V")])]),
    mk("s42_f64_counter", "float64 scalar counter (large integer values)",
       [_src("src", [_sig("Cnt", "float64", 1, formula="counter")])]),
]

# ── High sample-rate / high-throughput stress ────────────────────────────────
# Eight signals, each a 1000-element float32 array carrying 1 ms worth of 1 MSps
# data, published once per 1 kHz producer cycle. Aggregate wire rate is
# 8 x 1 MSps x 4 B = 32 MB/s. A single scalar uint64 ns anchor (FirstSample) is
# shared by all eight data signals; the per-packet sample window
# (1000 / 1 MHz = 1 ms) equals the 1 kHz inter-packet gap so the expanded
# per-sample timestamps stay monotonic for the hub's binary-search ring. Ramp
# formula keeps the gate fidelity-only (robust to the UDP loss expected at rate).
#
# Packet sizing — DATA payload is 8 (ns anchor) + 8 x 1000 x 4 = 32008 B, which
# is deliberately kept under the 64 KB single-datagram ceiling. That ceiling is
# the UDPSClient reassembly buffer (UDPSReassemblySlot::payload[65535]) plus its
# offset overflow guard: a packet whose payload exceeds ~64 KB can never be
# reassembled (fragment 1 lands past the buffer and is dropped), so high-rate
# streaming must use single-datagram packets. max_payload is set to the maximum
# valid value (UDP limit - 17 B header) so the 32 KB packet is sent unfragmented.
_HIGHRATE = [
    mk("s51_8x1msps_100hz",
       "8x float32 10k-elem arrays @1 MSps, FirstSample, 100 Hz packets (~32 MB/s, "
       "320 KB/cycle fragmented)",
       [_src("src", [
           _sig("Tns", "uint64", 1, unit="ns", formula="time_ns", is_time=True),
       ] + [
           _sig(f"S{i}", "float32", 10000, time_mode="FirstSample",
                time_signal="Tns", sampling_rate=1.0e6, formula="ramp")
           for i in range(8)
       ])],
       max_payload=40000, row_dt=0.01, num_rows=50, producer_hz=100,
       checks=("live", "zoom")),
]

SCENARIOS = (_STARTERS + _TYPES + _ARRAYS + _TIMEMODES + _QUANT + _PUBLISH +
             _PAYLOAD + _MCAST + _MULTISRC + _INTERACT + _TRIGGER + _MISC +
             _HIGHRATE)

for _s in SCENARIOS:
    _s.setdefault("kind", "chain")

# ── Non-chain scenario kinds ──────────────────────────────────────────────────
# "direct" and "recorder" scenarios exercise the other two data paths described
# in ARCHITECTURE.md (direct UDPStreamer<->UDPStreamerClient, and StreamHub's
# BinaryRecorder disk sink) and intentionally do not carry the chain-only keys
# (sources/network/oracle/...): they have their own orchestration in run_e2e.sh.
_DIRECT = [
    {
        "id": "s52_direct_unicast",
        "desc": "Direct UDPStreamer->UDPStreamerClient round-trip, unicast",
        "kind": "direct",
        "cfg": "Test/E2E/datasources/E2ETest.cfg",
        "client_checks": [],
        "known_issue": None,
    },
    {
        "id": "s53_direct_multicast",
        "desc": "Direct UDPStreamer->UDPStreamerClient round-trip, multicast",
        "kind": "direct",
        "cfg": "Test/E2E/datasources/E2EMulticastTest.cfg",
        "client_checks": [],
        "known_issue": None,
    },
]

_RECORDER = [
    {
        "id": "s54_recorder",
        "desc": "StreamHub BinaryRecorder disk-output round-trip",
        "kind": "recorder",
        "hub_cfg": "Test/E2E/recorder/StreamHubRec.cfg",
        "marte_cfg": "Test/E2E/recorder/RecorderStreamer.cfg",
        "client_checks": [],
        "known_issue": None,
    },
]

SCENARIOS = SCENARIOS + _DIRECT + _RECORDER


if __name__ == "__main__":
    import sys
    ok = True
    seen_ids, seen_ws, seen_udp = set(), set(), set()
    for s in SCENARIOS:
        errs = validate_scenario(s)
        # uniqueness invariants the orchestrator relies on
        if s["id"] in seen_ids:
            errs.append("duplicate id")
        seen_ids.add(s["id"])
        if s["kind"] == "chain":
            if s["ws_port"] in seen_ws:
                errs.append(f"duplicate ws_port {s['ws_port']}")
            seen_ws.add(s["ws_port"])
            for src in s["sources"]:
                if src["udp_port"] in seen_udp:
                    errs.append(f"duplicate udp_port {src['udp_port']}")
                seen_udp.add(src["udp_port"])
        print(f"{s['id']:32s} {'OK' if not errs else errs}")
        ok = ok and not errs
    print(f"\n{len(SCENARIOS)} scenarios, {'ALL VALID' if ok else 'INVALID PRESENT'}")
    sys.exit(0 if ok else 1)
