#!/usr/bin/env python3
"""
tests_py.py — Unit tests for the streaming-chain E2E Python framework.

Run directly (``python3 -m unittest tests_py``) or, for coverage, via collect.py
which wraps it in ``coverage run``. These exercise the pure logic of the
generators, the waveform oracle, the config emitter, the RCV1 reader and the
/proc perf sampler — i.e. everything the orchestrator depends on, without needing
a live MARTe/StreamHub stack.
"""
import os
import struct
import sys
import tempfile
import unittest

import numpy as np

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import scenarios as S
import gen_data as G
import gen_cfg as C
import validate_waveform as V
import proc_perf as P


class TestScenarios(unittest.TestCase):
    def test_all_starter_scenarios_valid(self):
        for s in S.SCENARIOS:
            self.assertEqual(S.validate_scenario(s), [], f"{s['id']} invalid")

    def test_seamless_loop_constraint(self):
        s = {
            "id": "x", "network": "unicast", "publishing": "Strict",
            "ratio": None, "min_refresh_hz": None, "max_payload": 1400,
            "ws_port": 9000,
            "sources": [{"id": "s", "udp_port": 1, "data_port": None,
                         "multicast_group": None,
                         "signals": [S._sig("Bad", "float32", freq=3.0)]}],
            "oracle": "analytic", "client_checks": ["live"], "trig_signal": None,
        }
        errs = S.validate_scenario(s)
        self.assertTrue(any("multiple of LOOP_HZ" in e for e in errs), errs)
        s["sources"][0]["signals"][0]["freq"] = S.LOOP_HZ * 2
        self.assertEqual(S.validate_scenario(s), [])

    def test_bad_quant_rejected(self):
        s = {
            "id": "x", "network": "unicast", "publishing": "Strict",
            "ratio": None, "min_refresh_hz": None, "max_payload": 1400,
            "ws_port": 9000,
            "sources": [{"id": "s", "udp_port": 1, "data_port": None,
                         "multicast_group": None,
                         "signals": [S._sig("Q", "int32", quant="uint8",
                                            range_min=0, range_max=1, formula="ramp")]}],
            "oracle": "analytic", "client_checks": ["live"], "trig_signal": None,
        }
        errs = S.validate_scenario(s)
        self.assertTrue(any("quant only on float" in e for e in errs), errs)

    def test_all_scenarios_have_kind(self):
        for s in S.SCENARIOS:
            self.assertIn("kind", s, f"{s['id']} missing kind")
            self.assertIn(s["kind"], ("chain", "direct", "recorder", "debug", "tcplogger"))

    def test_direct_and_recorder_scenarios_present(self):
        kinds = {s["kind"] for s in S.SCENARIOS}
        self.assertIn("direct", kinds)
        self.assertIn("recorder", kinds)
        direct_ids = {s["id"] for s in S.SCENARIOS if s["kind"] == "direct"}
        self.assertEqual(direct_ids, {"s52_direct_unicast", "s53_direct_multicast"})
        recorder_ids = {s["id"] for s in S.SCENARIOS if s["kind"] == "recorder"}
        self.assertEqual(recorder_ids, {"s54_recorder"})

    def test_validate_scenario_accepts_all_kinds(self):
        for s in S.SCENARIOS:
            S.validate_scenario(s)  # must not raise


class TestGenData(unittest.TestCase):
    def test_ground_truth_shapes(self):
        gt = G.build_ground_truth(S.SCENARIOS[1])  # s02: TimeArr+Wave, 100 elem
        wave = gt["src:Wave"]
        self.assertEqual(wave["elements"], 100)
        self.assertEqual(wave["v"].size, S.NUM_ROWS * 100)
        self.assertEqual(wave["t"].size, S.NUM_ROWS * 100)

    def test_binary_roundtrip(self):
        sc = S.SCENARIOS[0]  # s01: Counter(uint32)+Sine(float32)
        with tempfile.TemporaryDirectory() as d:
            p = os.path.join(d, "in.bin")
            gt = G.write_input(sc, p)
            cols = V.read_marte_binary(p)
            self.assertIn("Counter", cols)
            self.assertIn("Sine", cols)
            # counter is row index → first/last match ground truth
            self.assertEqual(cols["Counter"].reshape(-1)[0], gt["src:Counter"]["v"][0])
            self.assertEqual(int(cols["Counter"].reshape(-1)[5]), 5)


class TestOracle(unittest.TestCase):
    def _gt(self, **kw):
        t = np.linspace(0, 1, 200)
        base = {"v": np.sin(2 * np.pi * 5 * t), "type": "float32", "quant": "none",
                "formula": "sine", "freq": 5.0, "range_min": -1.0, "range_max": 1.0,
                "elements": 1, "rows": 200, "is_time": False, "t": t,
                "dt": t[1] - t[0]}
        base.update(kw)
        return base, t

    def test_quant_tol_is_one_level(self):
        gt, _ = self._gt(quant="uint16", range_min=-5.0, range_max=5.0)
        tol, step = V._tol(gt)
        self.assertAlmostEqual(step, 10.0 / 65535, places=9)
        self.assertGreaterEqual(tol, step)  # one full level, not half

    def test_good_passes_bad_fails(self):
        gt, t = self._gt(quant="uint16")
        _, step = V._tol(gt)
        good = gt["v"] + (np.random.rand(200) - 0.5) * step
        bad = gt["v"] + (np.random.rand(200) - 0.5) * step * 50
        self.assertTrue(V.compare_signal(gt, t, good)["pass"])
        self.assertFalse(V.compare_signal(gt, t, bad)["pass"])

    def test_wrong_frequency_fails_shape(self):
        gt, t = self._gt()
        wrong = np.sin(2 * np.pi * 50 * t)  # 10x frequency
        m = V.compare_signal(gt, t, wrong)
        self.assertFalse(m["shape_ok"], m)

    def test_integer_offgrid_fails(self):
        gt, t = self._gt(type="uint32", quant="none",
                         v=np.arange(200, dtype=np.float64), formula="counter")
        self.assertTrue(V.compare_signal(gt, t, np.arange(50, 150, dtype=float))["pass"])
        self.assertFalse(V.compare_signal(gt, t, np.array([1.5, 999.0]))["pass"])


class TestRCV1(unittest.TestCase):
    def test_read_received(self):
        with tempfile.TemporaryDirectory() as d:
            p = os.path.join(d, "r.bin")
            t = [0.0, 0.1, 0.2]
            v = [1.0, 2.0, 3.0]
            buf = bytearray(b"RCV1")
            buf += struct.pack("<I", 1)
            key = b"src:Sig"
            buf += struct.pack("<H", len(key)) + key + struct.pack("<I", len(t))
            for x in t:
                buf += struct.pack("<d", x)
            for x in v:
                buf += struct.pack("<d", x)
            open(p, "wb").write(buf)
            r = V.read_received(p)
            self.assertIn("src:Sig", r)
            tt, vv = r["src:Sig"]
            self.assertEqual(list(vv), v)


class TestGenCfg(unittest.TestCase):
    def test_tap_routes_through_ddb(self):
        sc = S.SCENARIOS[1]  # s02, oracle=both
        with tempfile.TemporaryDirectory() as d:
            mc = os.path.join(d, "m.cfg")
            C.write_marte_cfg(sc, mc, os.path.join(d, "in.bin"),
                              tap_bin=os.path.join(d, "tap.bin"))
            cfg = open(mc).read()
            self.assertIn("StreamGAM_src", cfg)
            self.assertIn("TapGAM", cfg)
            # TapGAM must read from the DDB (prefixed name), not the FileReader
            self.assertIn("src_Wave", cfg)
            tap_block = cfg[cfg.index("+TapGAM"):]
            tap_in = tap_block[tap_block.index("InputSignals"):tap_block.index("OutputSignals")]
            self.assertNotIn("FileReaderDS", tap_in)

    def test_no_tap_direct(self):
        sc = S.SCENARIOS[0]  # s01, oracle=analytic
        with tempfile.TemporaryDirectory() as d:
            mc = os.path.join(d, "m.cfg")
            C.write_marte_cfg(sc, mc, os.path.join(d, "in.bin"))
            cfg = open(mc).read()
            self.assertNotIn("TapGAM", cfg)
            self.assertIn("ReaderGAM_src", cfg)

    def test_hub_cfg_has_ports(self):
        sc = S.SCENARIOS[0]
        with tempfile.TemporaryDirectory() as d:
            hc = os.path.join(d, "h.cfg")
            C.write_hub_cfg(sc, hc)
            cfg = open(hc).read()
            self.assertIn(f"WSPort        = {sc['ws_port']}", cfg)
            self.assertIn(f"Port = {sc['sources'][0]['udp_port']}", cfg)


class TestProcPerf(unittest.TestCase):
    def test_snapshot_self(self):
        rec = P.snapshot(os.getpid())
        self.assertTrue(rec["avail"])
        self.assertIn("cpu_s", rec)
        self.assertGreaterEqual(rec["cpu_s"], 0.0)

    def test_snapshot_missing(self):
        self.assertFalse(P.snapshot(2 ** 30).get("avail", False))


if __name__ == "__main__":
    unittest.main(verbosity=2)
