import struct

from meshy.models import Contact, ContactType, battery_percent_from_mv
from meshy.protocol import (
    CMD_SEND_BINARY_REQ,
    CMD_SEND_LOGIN,
    LPP_ACCELEROMETER,
    LPP_COLOUR,
    LPP_CONCENTRATION,
    LPP_CURRENT,
    LPP_DIGITAL_INPUT,
    LPP_DISTANCE,
    LPP_FREQUENCY,
    LPP_GPS,
    LPP_HUMIDITY,
    LPP_LUMINOSITY,
    LPP_PERCENTAGE,
    LPP_PRESSURE,
    LPP_SWITCH,
    LPP_TEMPERATURE,
    LPP_UNIX_TIME,
    LPP_VOLTAGE,
    REQ_TYPE_TELEMETRY,
    build_get_telemetry,
    build_send_login,
    parse_cayenne_lpp,
)

# ─── Contact model: sensor type ─────────────────────────────


class TestSensorContact:
    def _make(self, **kwargs):
        defaults = dict(public_key=bytes(32), name="Weather-1", type=ContactType.SENSOR)
        defaults.update(kwargs)
        return Contact(**defaults)

    def test_type_label(self):
        c = self._make()
        assert c.type_label == "Sensor"

    def test_type_value(self):
        assert ContactType.SENSOR == 4

    def test_public_key_hex(self):
        key = bytes(range(32))
        c = self._make(public_key=key)
        assert c.public_key_hex == key.hex()

    def test_path_label_flood(self):
        c = self._make(path_length=-1)
        assert c.path_label == "Flood"

    def test_path_label_direct(self):
        c = self._make(path_length=0)
        assert c.path_label == "Direct"

    def test_has_location_valid(self):
        c = self._make(latitude=50.0, longitude=14.0)
        assert c.has_location is True

    def test_has_location_none(self):
        c = self._make()
        assert c.has_location is False


# ─── Protocol: build_get_telemetry ───────────────────────────


class TestBuildGetTelemetry:
    def test_frame_layout(self):
        pub_key = bytes(range(32))
        frame = build_get_telemetry(pub_key)
        assert frame[0] == CMD_SEND_BINARY_REQ
        assert frame[1:33] == pub_key
        assert frame[33] == REQ_TYPE_TELEMETRY
        assert len(frame) == 34

    def test_different_keys(self):
        k1 = bytes([0xAA] * 32)
        k2 = bytes([0xBB] * 32)
        f1 = build_get_telemetry(k1)
        f2 = build_get_telemetry(k2)
        assert f1[1:33] != f2[1:33]
        assert f1[0] == f2[0] == CMD_SEND_BINARY_REQ
        assert f1[33] == f2[33] == REQ_TYPE_TELEMETRY


# ─── Protocol: build_send_login (sensor admin) ───────────────


class TestSensorLogin:
    def test_frame_layout(self):
        pub_key = bytes(range(32))
        frame = build_send_login(pub_key, "secret")
        assert frame[0] == CMD_SEND_LOGIN
        assert frame[1:33] == pub_key
        assert frame[33:-1] == b"secret"
        assert frame[-1] == 0  # null terminator

    def test_empty_password(self):
        frame = build_send_login(bytes(32), "")
        assert frame[-1] == 0
        assert len(frame) == 34  # cmd + 32 key + null


# ─── Cayenne LPP: sensor-typical payloads ────────────────────


class TestCayenneLPPSensorPayloads:
    def test_voltage_battery(self):
        data = bytes([1, LPP_VOLTAGE]) + struct.pack(">h", 380)
        result = parse_cayenne_lpp(data)
        assert len(result) == 1
        assert result[0]["channel"] == 1
        assert result[0]["name"] == "Voltage"
        assert abs(result[0]["value"] - 3.80) < 0.01
        assert result[0]["unit"] == "V"

    def test_battery_percent_from_voltage(self):
        pct = battery_percent_from_mv(3800)
        assert pct == 50

    def test_multi_channel_payload(self):
        data = b""
        data += bytes([1, LPP_VOLTAGE]) + struct.pack(">h", 420)
        data += bytes([2, LPP_TEMPERATURE]) + struct.pack(">h", 235)
        data += bytes([3, LPP_HUMIDITY, 120])
        result = parse_cayenne_lpp(data)
        assert len(result) == 3
        assert result[0]["channel"] == 1
        assert result[0]["type"] == LPP_VOLTAGE
        assert result[1]["channel"] == 2
        assert abs(result[1]["value"] - 23.5) < 0.01
        assert result[2]["channel"] == 3
        assert result[2]["value"] == 60.0

    def test_pressure(self):
        data = bytes([1, LPP_PRESSURE]) + struct.pack(">H", 10132)
        result = parse_cayenne_lpp(data)
        assert abs(result[0]["value"] - 1013.2) < 0.1
        assert result[0]["unit"] == "hPa"

    def test_luminosity(self):
        data = bytes([1, LPP_LUMINOSITY]) + struct.pack(">H", 450)
        result = parse_cayenne_lpp(data)
        assert result[0]["value"] == 450
        assert result[0]["unit"] == "lux"

    def test_digital_input(self):
        data = bytes([1, LPP_DIGITAL_INPUT, 1])
        result = parse_cayenne_lpp(data)
        assert result[0]["value"] == 1

    def test_switch(self):
        data = bytes([5, LPP_SWITCH, 0])
        result = parse_cayenne_lpp(data)
        assert result[0]["value"] == 0
        assert result[0]["name"] == "Switch"

    def test_percentage(self):
        data = bytes([1, LPP_PERCENTAGE, 75])
        result = parse_cayenne_lpp(data)
        assert result[0]["value"] == 75
        assert result[0]["unit"] == "%"

    def test_current(self):
        data = bytes([1, LPP_CURRENT]) + struct.pack(">h", 1500)
        result = parse_cayenne_lpp(data)
        assert abs(result[0]["value"] - 1.5) < 0.001
        assert result[0]["unit"] == "A"

    def test_frequency(self):
        data = bytes([1, LPP_FREQUENCY]) + struct.pack(">I", 868000000)
        result = parse_cayenne_lpp(data)
        assert result[0]["value"] == 868000000
        assert result[0]["unit"] == "Hz"

    def test_concentration_ppm(self):
        data = bytes([1, LPP_CONCENTRATION]) + struct.pack(">H", 400)
        result = parse_cayenne_lpp(data)
        assert result[0]["value"] == 400
        assert result[0]["unit"] == "ppm"

    def test_distance(self):
        data = bytes([1, LPP_DISTANCE]) + struct.pack(">I", 12500)
        result = parse_cayenne_lpp(data)
        assert abs(result[0]["value"] - 12.5) < 0.01
        assert result[0]["unit"] == "m"

    def test_unix_time(self):
        ts = 1719000000
        data = bytes([1, LPP_UNIX_TIME]) + struct.pack(">I", ts)
        result = parse_cayenne_lpp(data)
        assert result[0]["value"] == ts

    def test_accelerometer(self):
        data = bytes([1, LPP_ACCELEROMETER])
        data += struct.pack(">h", 100)  # x = 0.100G
        data += struct.pack(">h", -50)  # y = -0.050G
        data += struct.pack(">h", 9810)  # z = 9.810G
        result = parse_cayenne_lpp(data)
        val = result[0]["value"]
        assert abs(val["x"] - 0.100) < 0.001
        assert abs(val["y"] + 0.050) < 0.001
        assert abs(val["z"] - 9.810) < 0.001

    def test_colour_rgb(self):
        data = bytes([1, LPP_COLOUR, 255, 128, 0])
        result = parse_cayenne_lpp(data)
        val = result[0]["value"]
        assert val == {"r": 255, "g": 128, "b": 0}

    def test_gps_with_altitude(self):
        lat_raw = int(49.1953 * 10000).to_bytes(3, "big", signed=True)
        lon_raw = int(16.6068 * 10000).to_bytes(3, "big", signed=True)
        alt_raw = int(237.0 * 100).to_bytes(3, "big", signed=True)
        data = bytes([1, LPP_GPS]) + lat_raw + lon_raw + alt_raw
        result = parse_cayenne_lpp(data)
        val = result[0]["value"]
        assert abs(val["lat"] - 49.1953) < 0.001
        assert abs(val["lon"] - 16.6068) < 0.001
        assert abs(val["alt"] - 237.0) < 0.1

    def test_negative_temperature(self):
        data = bytes([1, LPP_TEMPERATURE]) + struct.pack(">h", -50)
        result = parse_cayenne_lpp(data)
        assert abs(result[0]["value"] - (-5.0)) < 0.01

    def test_full_sensor_payload(self):
        """Realistic sensor payload: battery + temp + humidity + pressure + GPS."""
        data = b""
        data += bytes([1, LPP_VOLTAGE]) + struct.pack(">h", 395)
        data += bytes([2, LPP_TEMPERATURE]) + struct.pack(">h", 218)
        data += bytes([3, LPP_HUMIDITY, 130])
        data += bytes([4, LPP_PRESSURE]) + struct.pack(">H", 10153)
        lat = int(50.0875 * 10000).to_bytes(3, "big", signed=True)
        lon = int(14.4213 * 10000).to_bytes(3, "big", signed=True)
        alt = int(195.0 * 100).to_bytes(3, "big", signed=True)
        data += bytes([5, LPP_GPS]) + lat + lon + alt
        result = parse_cayenne_lpp(data)
        assert len(result) == 5
        assert result[0]["type"] == LPP_VOLTAGE
        assert abs(result[0]["value"] - 3.95) < 0.01
        assert result[1]["type"] == LPP_TEMPERATURE
        assert abs(result[1]["value"] - 21.8) < 0.1
        assert result[2]["type"] == LPP_HUMIDITY
        assert result[2]["value"] == 65.0
        assert result[3]["type"] == LPP_PRESSURE
        assert abs(result[3]["value"] - 1015.3) < 0.1
        gps = result[4]["value"]
        assert abs(gps["lat"] - 50.0875) < 0.001
        assert abs(gps["lon"] - 14.4213) < 0.001

    def test_truncated_payload(self):
        data = bytes([1, LPP_TEMPERATURE, 0x01])  # missing second byte
        result = parse_cayenne_lpp(data)
        assert result == []

    def test_unknown_type_stops_parsing(self):
        data = bytes([1, 0xFE, 0x00, 0x00])  # unknown type
        result = parse_cayenne_lpp(data)
        assert result == []

    def test_same_channel_multiple_readings(self):
        data = b""
        data += bytes([1, LPP_TEMPERATURE]) + struct.pack(">h", 200)
        data += bytes([1, LPP_HUMIDITY, 100])
        result = parse_cayenne_lpp(data)
        assert len(result) == 2
        assert result[0]["channel"] == result[1]["channel"] == 1


# ─── Storage: sensor contacts ────────────────────────────────


class TestStorageSensorContacts:
    def test_save_and_load_sensor(self, storage):
        c = Contact(
            public_key=bytes(range(32)),
            name="Outdoor Sensor",
            type=ContactType.SENSOR,
        )
        storage.save_contact(c)
        loaded = storage.get_contacts()
        assert len(loaded) == 1
        assert loaded[0].name == "Outdoor Sensor"
        assert loaded[0].type == ContactType.SENSOR

    def test_sensor_type_persisted(self, storage):
        c = Contact(
            public_key=bytes([0xAA] * 32),
            name="Indoor",
            type=ContactType.SENSOR,
        )
        storage.save_contact(c)
        loaded = storage.get_contacts()
        assert loaded[0].type == 4
        assert loaded[0].type == ContactType.SENSOR

    def test_sensor_alongside_other_types(self, storage):
        chat = Contact(public_key=bytes([1] * 32), name="Alice", type=ContactType.CHAT)
        repeater = Contact(public_key=bytes([2] * 32), name="Relay-1", type=ContactType.REPEATER)
        sensor = Contact(public_key=bytes([3] * 32), name="Weather", type=ContactType.SENSOR)
        for c in (chat, repeater, sensor):
            storage.save_contact(c)
        loaded = storage.get_contacts()
        types = {c.name: c.type for c in loaded}
        assert types["Alice"] == ContactType.CHAT
        assert types["Relay-1"] == ContactType.REPEATER
        assert types["Weather"] == ContactType.SENSOR

    def test_sensor_update_preserves_type(self, storage):
        key = bytes([0xCC] * 32)
        c = Contact(public_key=key, name="Sensor-Old", type=ContactType.SENSOR)
        storage.save_contact(c)
        c2 = Contact(public_key=key, name="Sensor-New", type=ContactType.SENSOR)
        storage.save_contact(c2)
        loaded = storage.get_contacts()
        assert len(loaded) == 1
        assert loaded[0].name == "Sensor-New"
        assert loaded[0].type == ContactType.SENSOR

    def test_sensor_with_location(self, storage):
        c = Contact(
            public_key=bytes([0xDD] * 32),
            name="GPS Sensor",
            type=ContactType.SENSOR,
            latitude=50.0875,
            longitude=14.4213,
        )
        storage.save_contact(c)
        loaded = storage.get_contacts()
        assert loaded[0].has_location is True
        assert abs(loaded[0].latitude - 50.0875) < 0.001
