"""Self-tests: run with  python3 -m unittest discover -s tests  (from the pi folder).

The key test fakes a ride with a known engine horsepower (same physics as the
analysis, plus GPS-like noise) and checks the analysis finds that horsepower again.
"""
import json
import os
import random
import struct
import sys
import tempfile
import unittest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ["DYNO_DATA"] = tempfile.mkdtemp(prefix="dyno-test-")

from dyno import analysis, config, recorder, server, ubx  # noqa: E402

MPH = analysis.MPS_TO_MPH


def fake_run(engine_hp, noise=0.05, rate_hz=10, settings=None, seed=1):
    random.seed(seed)
    sim = analysis.simulate_run(engine_hp=engine_hp, seconds=9.0, settings=settings,
                                dt_out=1.0 / rate_hz)
    speeds = [max(0.0, v + random.gauss(0, noise)) for v in sim["speed_mps"]]
    pre = [0.0] * (2 * rate_hz)  # standing still before the launch
    n = len(pre) + len(speeds)
    t = [round(i / rate_hz, 3) for i in range(n)]
    return {"channels": {"gps": {"t": t, "speed_mps": pre + speeds, "ok": [True] * n,
                                 "sats": [15] * n}}}, sim


class TestHorsepower(unittest.TestCase):
    def settings(self, **changes):
        s = dict(config.DEFAULT_SETTINGS)
        s["weights_set"] = True
        s.update(changes)
        return s

    def test_finds_the_horsepower_it_was_given(self):
        for hp in (3.0, 6.5, 10.0):
            run, sim = fake_run(hp)
            result = analysis.analyze(run, self.settings())
            self.assertTrue(result["ok"], result)
            found = result["summary"]["peak_engine_hp"]
            print("  pretend %.1f hp engine -> measured %.2f hp (wheel %.2f), 0-20 mph %s s, top %s mph"
                  % (hp, found, result["summary"]["peak_wheel_hp"],
                     result["summary"]["zero_to_20_s"], result["summary"]["top_mph"]))
            self.assertAlmostEqual(found, hp, delta=hp * 0.07)

    def test_torque_converter_setting(self):
        s = self.settings(drivetrain="torque_converter")
        run, _ = fake_run(6.5, settings=s)
        found = analysis.analyze(run, s)["summary"]["peak_engine_hp"]
        self.assertAlmostEqual(found, 6.5, delta=0.5)

    def test_heavier_rider_means_more_power_for_the_same_speeds(self):
        run, _ = fake_run(6.5)
        light = analysis.analyze(run, self.settings(rider_weight_lb=150))["summary"]["peak_engine_hp"]
        heavy = analysis.analyze(run, self.settings(rider_weight_lb=200))["summary"]["peak_engine_hp"]
        self.assertGreater(heavy, light * 1.1)

    def test_slow_gps_still_works(self):
        run, _ = fake_run(6.5, rate_hz=5)
        result = analysis.analyze(run, self.settings())
        self.assertAlmostEqual(result["summary"]["peak_engine_hp"], 6.5, delta=0.6)
        self.assertTrue(any("updates a second" in w for w in result["warnings"]))

    def test_wheel_sensor_is_used_when_present(self):
        run, sim = fake_run(6.5)
        s = self.settings()
        circ = 3.14159265 * s["tire_diameter_in"] * 0.0254
        gps = run["channels"]["gps"]
        t20 = [i / 20 for i in range(len(gps["t"]) * 2)]
        rpm = [max(0.0, analysis.resample(gps["t"], gps["speed_mps"], [tt])[0]) * 60 / circ for tt in t20]
        run["channels"]["wheel_rpm"] = {"t": t20, "v": rpm}
        result = analysis.analyze(run, s)
        self.assertEqual(result["summary"]["speed_source"], "wheel sensor")
        self.assertAlmostEqual(result["summary"]["peak_engine_hp"], 6.5, delta=0.6)

    def test_no_movement(self):
        n = 50
        run = {"channels": {"gps": {"t": [i / 10 for i in range(n)], "speed_mps": [0.0] * n,
                                    "ok": [True] * n}}}
        self.assertFalse(analysis.analyze(run, self.settings())["ok"])


class TestUbx(unittest.TestCase):
    def test_frame_checksum_and_reader(self):
        payload = bytes(92)
        msg = ubx.frame(0x01, 0x07, payload)
        reader = ubx.Reader()
        got = reader.feed(b"junk$GPGGA,\r\n" + msg[:10]) + reader.feed(msg[10:] + b"\xb5")
        self.assertEqual(got, [(0x01, 0x07, payload)])

    def test_bad_checksum_is_skipped(self):
        msg = bytearray(ubx.frame(0x01, 0x07, bytes(92)))
        msg[-1] ^= 0xFF
        self.assertEqual(ubx.Reader().feed(bytes(msg)), [])

    def test_nav_pvt_decoding(self):
        p = bytearray(92)
        struct.pack_into("<I", p, 0, 123456)
        struct.pack_into("<HBBBBB", p, 4, 2026, 10, 18, 20, 30, 15)
        p[20], p[21], p[23] = 3, 0x01, 14            # 3D fix, fix OK, 14 satellites
        struct.pack_into("<ii", p, 24, -841234567, 401234567)  # lon, lat
        struct.pack_into("<i", p, 60, 12345)         # 12.345 m/s
        struct.pack_into("<I", p, 68, 150)           # speed accuracy 0.15 m/s
        pvt = ubx.parse_nav_pvt(bytes(p))
        self.assertTrue(pvt["fix_ok"])
        self.assertEqual(pvt["sats"], 14)
        self.assertAlmostEqual(pvt["speed_mps"], 12.345)
        self.assertAlmostEqual(pvt["lat"], 40.1234567)
        self.assertAlmostEqual(pvt["speed_acc_mps"], 0.15)
        self.assertEqual(pvt["utc"], "2026-10-18T20:30:15Z")

    def test_valset_layout(self):
        msg = ubx.valset({"CFG-RATE-MEAS": 100})
        self.assertEqual(msg[2:4], b"\x06\x8a")
        self.assertEqual(struct.unpack_from("<H", msg, 4)[0], 4 + 4 + 2)
        self.assertEqual(struct.unpack_from("<IH", msg, 10), (0x30210001, 100))


class TestRecorder(unittest.TestCase):
    def test_records_a_run_by_itself_and_ignores_rolling_around(self):
        rec = recorder.Recorder()
        rec.settings["weights_set"] = True
        before = set(os.listdir(config.RUNS_DIR)) if os.path.isdir(config.RUNS_DIR) else set()

        def gps(t, v):
            rec.emit("gps", t, {"speed_mps": v, "fix_ok": True, "fix": 3, "sats": 15,
                                "speed_acc_mps": 0.1})

        t = 1000.0
        for v in [0.0] * 30 + [1.5] * 20 + [0.0] * 30:  # walking pace: not a run
            gps(t, v)
            t += 0.1
        sim = analysis.simulate_run(engine_hp=6.5)
        for v in [0.0] * 30 + sim["speed_mps"] + [0.0] * 30:
            gps(t, v)
            t += 0.1
        after = set(os.listdir(config.RUNS_DIR)) - before
        self.assertEqual(len(after), 1, after)
        with open(os.path.join(config.RUNS_DIR, after.pop())) as f:
            run = json.load(f)
        self.assertGreater(run["top_mph_live"], 25)
        self.assertGreaterEqual(run["channels"]["gps"]["t"][0], 0)
        result = analysis.analyze(run, rec.settings)
        self.assertTrue(result["ok"])
        self.assertAlmostEqual(result["summary"]["peak_engine_hp"], 6.5, delta=0.6)


    def test_unplugged_wheel_sensor_does_not_block_gps_runs(self):
        rec = recorder.Recorder()
        before = set(os.listdir(config.RUNS_DIR)) if os.path.isdir(config.RUNS_DIR) else set()
        t = 5000.0
        sim = analysis.simulate_run(engine_hp=6.5)
        for v in [0.0] * 30 + sim["speed_mps"] + [0.0] * 30:
            rec.emit("wheel_rpm", t, 0.0)  # sensor fitted but not seeing the magnet
            rec.emit("gps", t, {"speed_mps": v, "fix_ok": True, "fix": 3, "sats": 15})
            t += 0.1
        self.assertEqual(len(set(os.listdir(config.RUNS_DIR)) - before), 1)


class TestSensorList(unittest.TestCase):
    def test_cleaning(self):
        cleaned = server.clean_sensor_list([
            {"type": "pulse_rpm", "name": "Rear wheel", "gpio": "27", "pulses_per_rev": 2,
             "role": "wheel", "edge": "rising", "evil": "x"},
            {"type": "not_a_sensor"},
            {"type": "mpu6050", "address": "0x69", "forward": "sideways"},
        ])
        self.assertEqual(len(cleaned), 2)
        self.assertEqual(cleaned[0]["gpio"], 27)
        self.assertNotIn("evil", cleaned[0])
        self.assertEqual(cleaned[1]["forward"], "x")  # unknown choice -> default
        self.assertEqual(cleaned[1]["address"], "0x69")


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