"""test_battery.py — Unit tests for battery_node SoC, alerts, and SQLite logging.

Tests:
  - SoC lookup table: known voltages map to expected percentages
  - SoC interpolation: midpoint between curve entries
  - SoC clamping: below min → 0%, above max → 100%
  - Alert level computation: correct level for various SoC values
  - Alert threshold transitions: NONE→WARN→CRITICAL→EMERGENCY
  - Speed factor assignments: correct factor per alert level
  - Charging detection: current_ma < threshold → charging
  - sensor_msgs/BatteryState field population
  - SQLite logging: records inserted with correct fields
  - SQLite pruning: old records removed
  - Voltage-based SoC fallback when firmware SoC == 0
  - Multi-cell voltage scaling (3S, 4S)

Run with: pytest test/test_battery.py -v
No ROS2 runtime required.
"""

from __future__ import annotations

import json
import os
import sqlite3
import sys
import tempfile
import time

import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

from saltybot_bridge.battery_node import (
    _soc_from_voltage,
    _ALERT_NONE, _ALERT_WARN, _ALERT_CRITICAL, _ALERT_EMERGENCY,
    _SOC_CURVE_PER_CELL,
)


# ═══════════════════════════════════════════════════════════════════
# SoC voltage lookup table
# ═══════════════════════════════════════════════════════════════════

class TestSocLookup:

    def test_full_voltage_returns_100(self):
        """4.20 V/cell (3-cell = 12.60 V) → 100%."""
        assert _soc_from_voltage(12.60, cell_count=3) == 100

    def test_below_min_returns_0(self):
        """Below 3.0 V/cell → 0%."""
        assert _soc_from_voltage(8.0, cell_count=3) == 0

    def test_nominal_voltage_returns_50ish(self):
        """3S at ~11.1 V (3.7 V/cell) should be near 50%."""
        soc = _soc_from_voltage(11.10, cell_count=3)
        assert 45 <= soc <= 55

    def test_warn_threshold_voltage_single_cell(self):
        """Single-cell 3.53 V is on the 20% curve point."""
        assert _soc_from_voltage(3.53, cell_count=1) == 20

    def test_critical_threshold_voltage(self):
        """3.37 V/cell → 10%."""
        assert _soc_from_voltage(3.37, cell_count=1) == 10

    def test_emergency_threshold_voltage(self):
        """3.27 V/cell → 5%."""
        assert _soc_from_voltage(3.27, cell_count=1) == 5

    def test_4s_pack_full(self):
        """4S fully charged: 4 × 4.20 = 16.80 V → 100%."""
        assert _soc_from_voltage(16.80, cell_count=4) == 100

    def test_4s_pack_nominal(self):
        """4S nominal: 4 × 3.7 = 14.8 V → ~50%."""
        soc = _soc_from_voltage(14.80, cell_count=4)
        assert 45 <= soc <= 55

    def test_interpolation_midpoint(self):
        """Midpoint between two curve entries should yield midpoint SoC."""
        # Between (3.27, 5) and (3.37, 10): midpoint ≈ 3.32 V → ~7.5%
        mid_v = (3.27 + 3.37) / 2 * 3  # 3-cell pack voltage
        soc = _soc_from_voltage(mid_v, cell_count=3)
        assert 5 < soc < 10

    def test_exactly_at_curve_point(self):
        """Values exactly on a curve point must return the tabulated SoC."""
        for cell_v, expected_soc in _SOC_CURVE_PER_CELL:
            pack_v = cell_v * 3   # 3-cell
            result = _soc_from_voltage(pack_v, cell_count=3)
            # Allow ±1 for floating-point interpolation edge
            assert abs(result - expected_soc) <= 1, \
                f"At {cell_v}V/cell ({pack_v}V 3S): expected {expected_soc}%, got {result}%"

    def test_zero_cell_count_returns_0(self):
        assert _soc_from_voltage(12.0, cell_count=0) == 0

    def test_above_max_clamped_to_100(self):
        """Overcharged (e.g., 4.5 V/cell) → clamped to 100%."""
        assert _soc_from_voltage(15.0, cell_count=3) == 100

    def test_zero_voltage_returns_0(self):
        assert _soc_from_voltage(0.0, cell_count=3) == 0

    def test_returns_integer(self):
        result = _soc_from_voltage(11.1, cell_count=3)
        assert isinstance(result, int)


# ═══════════════════════════════════════════════════════════════════
# Alert level computation
# ═══════════════════════════════════════════════════════════════════

class TestAlertComputation:
    """Test _compute_alert() logic (instantiated via a minimal stub)."""

    # Mirror of BatteryNode._compute_alert() for unit testing
    @staticmethod
    def _compute_alert(soc_pct, warn=20.0, crit=10.0, emergency=5.0):
        if soc_pct <= emergency:
            return _ALERT_EMERGENCY
        if soc_pct <= crit:
            return _ALERT_CRITICAL
        if soc_pct <= warn:
            return _ALERT_WARN
        return _ALERT_NONE

    def test_full_is_ok(self):
        assert self._compute_alert(100) == _ALERT_NONE

    def test_above_warn_is_ok(self):
        assert self._compute_alert(21) == _ALERT_NONE

    def test_at_warn_threshold(self):
        assert self._compute_alert(20) == _ALERT_WARN

    def test_between_warn_and_critical(self):
        assert self._compute_alert(15) == _ALERT_WARN

    def test_at_critical_threshold(self):
        assert self._compute_alert(10) == _ALERT_CRITICAL

    def test_between_critical_and_emergency(self):
        assert self._compute_alert(7) == _ALERT_CRITICAL

    def test_at_emergency_threshold(self):
        assert self._compute_alert(5) == _ALERT_EMERGENCY

    def test_below_emergency(self):
        assert self._compute_alert(1) == _ALERT_EMERGENCY

    def test_zero_is_emergency(self):
        assert self._compute_alert(0) == _ALERT_EMERGENCY

    def test_custom_thresholds(self):
        assert self._compute_alert(25, warn=30, crit=15, emergency=8) == _ALERT_WARN
        assert self._compute_alert(13, warn=30, crit=15, emergency=8) == _ALERT_WARN
        assert self._compute_alert(12, warn=30, crit=15, emergency=8) == _ALERT_CRITICAL

    def test_speed_factor_at_warn(self):
        """Speed factor at WARN must be 0 < factor < 1.0."""
        factor = 0.6    # warn_speed_factor default
        assert 0.0 < factor < 1.0

    def test_speed_factor_at_critical(self):
        factor = 0.3    # critical_speed_factor default
        assert 0.0 < factor < 1.0

    def test_speed_factor_at_emergency(self):
        factor = 0.0    # full stop
        assert factor == 0.0


# ═══════════════════════════════════════════════════════════════════
# Charging detection
# ═══════════════════════════════════════════════════════════════════

class TestChargingDetection:

    THRESHOLD_MA = -100

    def _is_charging(self, current_ma):
        return current_ma < self.THRESHOLD_MA

    def test_discharging_positive(self):
        assert not self._is_charging(500)

    def test_discharging_zero(self):
        assert not self._is_charging(0)

    def test_just_below_threshold_is_charging(self):
        assert self._is_charging(-101)

    def test_at_threshold_not_charging(self):
        assert not self._is_charging(-100)

    def test_deeply_negative_is_charging(self):
        assert self._is_charging(-2000)    # fast charge

    def test_regen_braking_not_charging(self):
        # Small negative current during regen isn't real charging
        assert not self._is_charging(-50)


# ═══════════════════════════════════════════════════════════════════
# SQLite history logging
# ═══════════════════════════════════════════════════════════════════

class TestSqliteLogging:
    """Test the SQLite DB operations directly (no ROS2)."""

    def _make_db(self):
        """Return an in-memory SQLite connection with the battery_history table."""
        db = sqlite3.connect(":memory:")
        db.execute("""
            CREATE TABLE battery_history (
                id          INTEGER PRIMARY KEY AUTOINCREMENT,
                ts          REAL    NOT NULL,
                voltage_v   REAL    NOT NULL,
                current_ma  INTEGER NOT NULL,
                soc_pct     INTEGER NOT NULL,
                charging    INTEGER NOT NULL,
                alert_level TEXT    NOT NULL
            )
        """)
        db.execute("CREATE INDEX idx_ts ON battery_history(ts)")
        db.commit()
        return db

    def _insert(self, db, ts, voltage_v, current_ma, soc_pct, charging, alert):
        db.execute(
            "INSERT INTO battery_history(ts,voltage_v,current_ma,soc_pct,charging,alert_level) "
            "VALUES (?,?,?,?,?,?)",
            (ts, voltage_v, current_ma, soc_pct, int(charging), alert)
        )
        db.commit()

    def test_insert_and_retrieve(self):
        db = self._make_db()
        self._insert(db, time.time(), 11.1, 500, 50, False, _ALERT_NONE)
        row = db.execute("SELECT * FROM battery_history").fetchone()
        assert row is not None
        assert row[2] == pytest.approx(11.1)
        assert row[3] == 500
        assert row[4] == 50
        assert row[5] == 0     # not charging
        assert row[6] == _ALERT_NONE

    def test_insert_charging_event(self):
        db = self._make_db()
        self._insert(db, time.time(), 12.0, -1500, 85, True, _ALERT_NONE)
        row = db.execute("SELECT charging FROM battery_history").fetchone()
        assert row[0] == 1    # charging = True

    def test_insert_alert_event(self):
        db = self._make_db()
        self._insert(db, time.time(), 10.5, 300, 18, False, _ALERT_WARN)
        row = db.execute("SELECT alert_level FROM battery_history").fetchone()
        assert row[0] == _ALERT_WARN

    def test_prune_old_records(self):
        db = self._make_db()
        old_ts = time.time() - 8 * 86400   # 8 days ago (>7 day retention)
        new_ts = time.time()
        self._insert(db, old_ts, 11.0, 100, 60, False, _ALERT_NONE)
        self._insert(db, new_ts, 11.5, 200, 70, False, _ALERT_NONE)

        retention_days = 7
        cutoff = time.time() - retention_days * 86400
        db.execute("DELETE FROM battery_history WHERE ts < ?", (cutoff,))
        db.commit()

        rows = db.execute("SELECT COUNT(*) FROM battery_history").fetchone()
        assert rows[0] == 1    # only new record remains

    def test_multiple_inserts_sequential_ids(self):
        db = self._make_db()
        for i in range(5):
            self._insert(db, time.time() + i, 11.0 + i * 0.1, 100, 50 + i, False, _ALERT_NONE)
        count = db.execute("SELECT COUNT(*) FROM battery_history").fetchone()[0]
        assert count == 5

    def test_db_on_disk(self):
        """Write and read a real on-disk SQLite DB."""
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
            path = f.name
        try:
            db = sqlite3.connect(path)
            db.execute("""CREATE TABLE battery_history (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                ts REAL, voltage_v REAL, current_ma INTEGER,
                soc_pct INTEGER, charging INTEGER, alert_level TEXT
            )""")
            db.execute(
                "INSERT INTO battery_history VALUES (NULL,?,?,?,?,?,?)",
                (time.time(), 11.5, 500, 70, 0, _ALERT_NONE)
            )
            db.commit()
            db.close()

            # Reopen and verify
            db2 = sqlite3.connect(path)
            row = db2.execute("SELECT soc_pct FROM battery_history").fetchone()
            assert row[0] == 70
            db2.close()
        finally:
            os.unlink(path)


# ═══════════════════════════════════════════════════════════════════
# BatteryState field population
# ═══════════════════════════════════════════════════════════════════

class TestBatteryStateFields:
    """Verify sensor_msgs/BatteryState field logic without a live node."""

    def _make_state(self, voltage_v, current_ma, soc_pct, charging,
                    cell_count=3, alert=_ALERT_NONE):
        """Minimal BatteryState-like dict mirrors _publish_cb logic."""
        return {
            "voltage":          voltage_v,
            "current":          current_ma / 1000.0,
            "percentage":       soc_pct / 100.0,
            "power_supply_status": "CHARGING" if charging else (
                "FULL" if soc_pct >= 99 else "DISCHARGING"
            ),
            "location":         alert,
            "cell_voltage":     [voltage_v / cell_count] * cell_count,
        }

    def test_voltage_field(self):
        state = self._make_state(11.1, 500, 50, False)
        assert state["voltage"] == pytest.approx(11.1)

    def test_current_converted_to_amps(self):
        state = self._make_state(11.1, 1500, 50, False)
        assert state["current"] == pytest.approx(1.5)

    def test_percentage_normalised(self):
        state = self._make_state(11.1, 500, 75, False)
        assert state["percentage"] == pytest.approx(0.75)

    def test_charging_status(self):
        state = self._make_state(12.0, -1500, 85, True)
        assert state["power_supply_status"] == "CHARGING"

    def test_discharging_status(self):
        state = self._make_state(11.1, 500, 50, False)
        assert state["power_supply_status"] == "DISCHARGING"

    def test_full_status_at_99_pct(self):
        state = self._make_state(12.5, 0, 99, False)
        assert state["power_supply_status"] == "FULL"

    def test_cell_voltages_equal_distribution(self):
        state = self._make_state(12.0, 0, 80, False, cell_count=3)
        assert len(state["cell_voltage"]) == 3
        for cv in state["cell_voltage"]:
            assert cv == pytest.approx(4.0)

    def test_alert_in_location_field(self):
        state = self._make_state(10.5, 300, 18, False, alert=_ALERT_WARN)
        assert state["location"] == _ALERT_WARN

    def test_zero_percentage_at_empty(self):
        state = self._make_state(9.6, 50, 0, False)
        assert state["percentage"] == pytest.approx(0.0)

    def test_full_percentage(self):
        state = self._make_state(12.6, 0, 100, False)
        assert state["percentage"] == pytest.approx(1.0)


# ═══════════════════════════════════════════════════════════════════
# Telemetry JSON parsing
# ═══════════════════════════════════════════════════════════════════

class TestTelemetryParsing:
    """Test the JSON parsing logic that the battery_node uses."""

    @staticmethod
    def _parse(json_str: str, charge_threshold: int = -100):
        data = json.loads(json_str)
        voltage_mv  = int(data["voltage_mv"])
        current_ma  = int(data["current_ma"])
        soc_from_fw = int(data.get("soc_pct", 0))
        voltage_v   = voltage_mv / 1000.0
        charging    = current_ma < charge_threshold
        soc_pct     = soc_from_fw if soc_from_fw > 0 else \
                      _soc_from_voltage(voltage_v, cell_count=3)
        return voltage_v, current_ma, soc_pct, charging

    def test_normal_discharge_telemetry(self):
        v, i, soc, chg = self._parse(
            '{"voltage_mv":11100,"current_ma":1000,"soc_pct":55}'
        )
        assert v   == pytest.approx(11.1)
        assert i   == 1000
        assert soc == 55      # from firmware
        assert not chg

    def test_charging_telemetry(self):
        v, i, soc, chg = self._parse(
            '{"voltage_mv":12000,"current_ma":-1500,"soc_pct":80}'
        )
        assert v   == pytest.approx(12.0)
        assert i   == -1500
        assert chg

    def test_firmware_soc_zero_uses_voltage_curve(self):
        """If soc_pct == 0, fall back to voltage curve."""
        v, i, soc, chg = self._parse(
            '{"voltage_mv":11100,"current_ma":500,"soc_pct":0}'
        )
        # soc_pct=0 from firmware → use voltage 11.1V / 3 cells → ~50%
        assert 45 <= soc <= 55

    def test_missing_soc_falls_back_to_voltage(self):
        """soc_pct field absent → use voltage lookup."""
        v, i, soc, chg = self._parse(
            '{"voltage_mv":10500,"current_ma":300}'
        )
        assert soc < 25    # 10.5V (3.5V/cell) → ~20%

    def test_parse_error_on_missing_fields(self):
        with pytest.raises(KeyError):
            self._parse('{"current_ma":500}')    # missing voltage_mv
