"""Hardware-free tests for the obdcore acquisition engine.

Drives the scheduler deterministically with a fake clock + MockLink, so the
prioritized polling, derived channels, dead-PID parking, store min/max, and
record/replay are all validated without a truck or serial port.

Run:  python -m pytest tests/ -q   (or)   python tests/test_obdcore.py
"""
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from obdcore import (PidRegistry, TimeSeriesStore, PollScheduler, CsvRecorder,
                     replay_csv, load_default, load_profile, default_profile_path,
                     list_profiles, compile_formula, FormulaError)
from obdcore.mock import MockLink


class FakeClock:
    def __init__(self):
        self.t = 0.0

    def __call__(self):
        return self.t

    def advance(self, dt):
        self.t += dt


def _setup(specs):
    clk = FakeClock()
    reg = PidRegistry(load_default())
    store = TimeSeriesStore()
    link = MockLink(clock=clk)
    sch = PollScheduler(link, reg, store, clock=clk)
    sch.set_subscriptions(specs)
    return clk, reg, store, sch


def test_profiles_load_and_validate():
    profs = list_profiles()
    assert any("ford-6.0" in p for p, _ in profs), "ford profile should be listed"
    for path, meta in profs:
        prof = load_profile(path)               # compiles every formula -> raises if bad
        assert prof.meta.get("name")
        assert all(p.decode or p.mode == "atrv" for p in prof.pids)
    print(f"  {len(profs)} profiles load + compile clean: OK")


def test_formula_is_sandboxed():
    # legit
    fn = compile_formula("(A*256+B)*0.57", "ABCDEFGH")
    assert abs(fn({"A": 0, "B": 22}) - 12.54) < 0.01
    # hostile / disallowed -> rejected at compile
    for bad in ("__import__('os').system('x')", "open('/etc/passwd')",
                "A.__class__", "Z+1", "A if B else C"):
        try:
            compile_formula(bad, "ABC")
            raise AssertionError(f"should have rejected: {bad}")
        except FormulaError:
            pass
    print("  formula evaluator rejects code/unknowns: OK")


def test_formula_dos_bounded():
    import time
    # giant-integer / resource-exhaustion payloads must be blocked fast, not
    # allowed to freeze the polling thread
    for bad in ("9**9**9", "1<<10**9", "2**5000", "10**9**9", "A<<100000000",
                "A+" * 300 + "A"):
        t = time.time()
        try:
            fn = compile_formula(bad, "AB")
            fn({"A": 250, "B": 250})
            raise AssertionError(f"formula not bounded: {bad}")
        except FormulaError:
            assert time.time() - t < 0.5, f"{bad} was slow to reject"
    # legit bit-field / scaling formulas still work
    assert compile_formula("(A<<8)|B", "AB")({"A": 1, "B": 2}) == 258
    print("  formula DoS payloads bounded (<0.5s), legit bit ops intact: OK")


def test_registry_decoders_match_truck_bytes():
    reg = PidRegistry(load_default())
    cases = {
        "ICP": ([0x00, 0x16], 12.5), "EBP": ([0x01, 0x8F], 14.46),
        "MAP": ([0x01, 0x89], 14.25), "BARO": ([0x01, 0x88], 14.21),
        "EOT": ([0x1C, 0x92], 33.1), "GEAR": ([0x02], 1),
        "FICM_M": ([0x30, 0x00], 48.0),
    }
    for key, (raw, expect) in cases.items():
        got = reg.get(key).decode(raw)
        assert abs(got - expect) < 0.05, f"{key}: {got} != {expect}"
    print("  decoders match truck bytes: OK")


def test_crank_ramp_and_peak():
    clk, reg, store, sch = _setup([("ICP", 5), ("FICM_M", 2), ("BATT", 2), ("RPM", 2)])
    for _ in range(60):                     # ~3s at 50ms steps
        sch.tick()
        clk.advance(0.05)
    icp = store.channel("ICP")
    lo, hi = store.minmax("ICP")
    assert hi >= 500, f"ICP should ramp past 500, got peak {hi}"
    assert lo is not None and lo < 50, "ICP should start low"
    assert store.latest("FICM_M") == 48.0
    bat_lo, _ = store.minmax("BATT")
    assert bat_lo <= 10.7, f"battery sag should be captured, got {bat_lo}"
    print(f"  crank ramp: ICP {lo} -> peak {hi}, FICM {store.latest('FICM_M')}V, "
          f"batt min {bat_lo}V: OK")


def test_derived_boost_channel():
    clk, reg, store, sch = _setup([("MAP", 5), ("BARO", 5), ("BOOST", 5)])
    for _ in range(10):
        sch.tick()
        clk.advance(0.05)
    boost = store.latest("BOOST")
    assert boost is not None and abs(boost) < 0.5, f"atm boost ~0, got {boost}"
    print(f"  derived BOOST = MAP-BARO = {boost} psi: OK")


def test_dead_pid_parks_and_revives():
    clk, reg, store, sch = _setup([("ICP", 5), ("IPR", 5)])   # IPR not in MockLink -> dead
    for _ in range(20):
        sch.tick()
        clk.advance(0.05)
    # IPR should have parked (4 fails) but scheduler still runs ICP
    assert store.latest("ICP") is not None
    assert store.latest("IPR") is None
    # advance past revive window -> IPR re-attempted (still None, but tried)
    clk.advance(6.0)
    sch.tick()
    print("  dead-PID parking + revive: OK")


def test_record_replay_roundtrip(tmp_path=None):
    import tempfile
    path = os.path.join(tempfile.gettempdir(), "obdcore_replay_test.csv")
    clk, reg, store, sch = _setup([("ICP", 5), ("FICM_M", 5)])
    store.recorder = CsvRecorder(path)
    for _ in range(20):
        sch.tick()
        clk.advance(0.05)
    store.recorder.close()
    peak_orig = store.minmax("ICP")[1]

    store2 = TimeSeriesStore()
    replay_csv(path, store2)
    peak_replay = store2.minmax("ICP")[1]
    assert abs(peak_orig - peak_replay) < 0.01, "replay should reproduce peak ICP"
    print(f"  record/replay: peak ICP {peak_orig} == replay {peak_replay}: OK")
    os.remove(path)


if __name__ == "__main__":
    for fn in [test_profiles_load_and_validate, test_formula_is_sandboxed,
               test_formula_dos_bounded,
               test_registry_decoders_match_truck_bytes, test_crank_ramp_and_peak,
               test_derived_boost_channel, test_dead_pid_parks_and_revives,
               test_record_replay_roundtrip]:
        fn()
    print("\nALL obdcore TESTS PASS")
