"""test_bag_policy.py — Unit tests for bag recording policy (Issue #615).

ROS2-free.  Run with:
    python3 -m pytest \
        jetson/ros2_ws/src/saltybot_bag_recorder/test/test_bag_policy.py -v
"""

from __future__ import annotations

import math
import os
import sys
from unittest.mock import MagicMock, patch

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

import pytest
from bag_policy import BagPolicy, DiskInfo, MotionState, StorageSelector


# ─── MotionState ──────────────────────────────────────────────────────────────

class TestMotionStateInit:
    def test_default_idle_timeout(self):
        assert MotionState().idle_timeout_s == 30.0

    def test_custom_idle_timeout(self):
        assert MotionState(idle_timeout_s=60.0).idle_timeout_s == 60.0

    def test_zero_timeout_raises(self):
        with pytest.raises(ValueError, match="idle_timeout_s"):
            MotionState(idle_timeout_s=0.0)

    def test_negative_timeout_raises(self):
        with pytest.raises(ValueError):
            MotionState(idle_timeout_s=-5.0)

    def test_initial_not_ever_moved(self):
        assert not MotionState().ever_moved

    def test_initial_idle_seconds_inf(self):
        assert MotionState().idle_seconds(1000.0) == math.inf

    def test_initial_is_idle(self):
        assert MotionState().is_idle(1000.0)

    def test_initial_not_moving(self):
        assert not MotionState().is_moving(1000.0)


class TestMotionStateUpdate:
    def setup_method(self):
        self.ms = MotionState(idle_timeout_s=30.0)

    def test_nonzero_linear_sets_moved(self):
        self.ms.update(0.5, 0.0, 100.0)
        assert self.ms.ever_moved

    def test_nonzero_angular_sets_moved(self):
        self.ms.update(0.0, 0.3, 100.0)
        assert self.ms.ever_moved

    def test_zero_cmd_vel_does_not_set_moved(self):
        self.ms.update(0.0, 0.0, 100.0)
        assert not self.ms.ever_moved

    def test_noise_below_threshold_ignored(self):
        self.ms.update(0.0005, 0.0005, 100.0)
        assert not self.ms.ever_moved

    def test_idle_seconds_after_motion(self):
        self.ms.update(0.5, 0.0, 100.0)
        assert abs(self.ms.idle_seconds(105.0) - 5.0) < 1e-9

    def test_not_idle_immediately(self):
        self.ms.update(0.5, 0.0, 100.0)
        assert not self.ms.is_idle(100.0)

    def test_not_idle_within_timeout(self):
        self.ms.update(0.5, 0.0, 100.0)
        assert not self.ms.is_idle(129.9)

    def test_idle_at_boundary(self):
        self.ms.update(0.5, 0.0, 100.0)
        assert self.ms.is_idle(130.0)

    def test_idle_after_timeout(self):
        self.ms.update(0.5, 0.0, 100.0)
        assert self.ms.is_idle(200.0)

    def test_moving_within_window(self):
        self.ms.update(0.5, 0.0, 100.0)
        assert self.ms.is_moving(115.0)

    def test_not_moving_after_timeout(self):
        self.ms.update(0.5, 0.0, 100.0)
        assert not self.ms.is_moving(200.0)

    def test_second_motion_resets_timer(self):
        self.ms.update(0.5, 0.0, 100.0)
        self.ms.update(0.5, 0.0, 125.0)
        assert not self.ms.is_idle(150.0)   # 25s since last motion
        assert self.ms.is_idle(160.0)       # 35s since last motion


class TestMotionStateAutoDecisions:
    def setup_method(self):
        self.ms = MotionState(idle_timeout_s=30.0)

    def test_no_start_before_motion(self):
        assert not self.ms.should_start_recording(1000.0)

    def test_start_after_motion(self):
        self.ms.update(0.5, 0.0, 1000.0)
        assert self.ms.should_start_recording(1001.0)

    def test_no_start_after_idle(self):
        self.ms.update(0.5, 0.0, 1000.0)
        assert not self.ms.should_start_recording(1040.0)

    def test_no_stop_before_motion(self):
        assert not self.ms.should_stop_recording(1000.0)

    def test_no_stop_while_active(self):
        self.ms.update(0.5, 0.0, 1000.0)
        assert not self.ms.should_stop_recording(1010.0)

    def test_stop_after_idle(self):
        self.ms.update(0.5, 0.0, 1000.0)
        assert self.ms.should_stop_recording(1035.0)


class TestMotionStateReset:
    def test_reset_clears_motion(self):
        ms = MotionState()
        ms.update(1.0, 0.0, 100.0)
        ms.reset()
        assert not ms.ever_moved
        assert ms.idle_seconds(200.0) == math.inf


# ─── DiskInfo ─────────────────────────────────────────────────────────────────

def _disk(total_gb=100.0, free_gb=50.0, path="/fake"):
    total = int(total_gb * 1024**3)
    free  = int(free_gb  * 1024**3)
    used  = total - free
    return DiskInfo(path=path, total_bytes=total, used_bytes=used, free_bytes=free)


class TestDiskInfo:
    def test_used_pct_50(self):
        assert abs(_disk(100, 50).used_pct - 50.0) < 0.01

    def test_used_pct_100(self):
        assert _disk(100, 0).used_pct == 100.0

    def test_used_pct_0(self):
        assert abs(_disk(100, 100).used_pct) < 0.01

    def test_free_pct(self):
        assert abs(_disk(100, 30).free_pct - 30.0) < 0.01

    def test_free_gb(self):
        assert abs(_disk(100, 20).free_gb - 20.0) < 0.01

    def test_total_gb(self):
        assert abs(_disk(250, 100).total_gb - 250.0) < 0.01

    def test_used_gb(self):
        assert abs(_disk(100, 30).used_gb - 70.0) < 0.01

    def test_exceeds_pct_true(self):
        assert _disk(100, 10).exceeds_pct(80.0)   # 90% used

    def test_exceeds_pct_false(self):
        assert not _disk(100, 50).exceeds_pct(80.0)  # 50% used

    def test_exceeds_pct_boundary(self):
        assert _disk(100, 20).exceeds_pct(80.0)   # exactly 80%

    def test_has_min_free_true(self):
        assert _disk(100, 10).has_min_free(2.0)

    def test_has_min_free_false(self):
        assert not _disk(100, 1).has_min_free(2.0)

    def test_zero_total_is_100pct(self):
        di = DiskInfo(path="/", total_bytes=0, used_bytes=0, free_bytes=0)
        assert di.used_pct == 100.0

    def test_summary_has_path(self):
        di = _disk(path="/media/usb0")
        assert "/media/usb0" in di.summary()


# ─── StorageSelector ─────────────────────────────────────────────────────────

class TestStorageSelector:
    def test_empty_paths_raises(self):
        with pytest.raises(ValueError, match="paths"):
            StorageSelector([])

    def test_select_none_when_no_paths_exist(self):
        sel = StorageSelector(["/nonexistent/a", "/nonexistent/b"])
        assert sel.select() is None

    def test_select_first_accessible(self, tmp_path):
        p1 = tmp_path / "p1"; p1.mkdir()
        p2 = tmp_path / "p2"; p2.mkdir()
        sel = StorageSelector([str(p1), str(p2)], min_free_gb=0.0)
        assert sel.select() == str(p1)

    def test_select_skips_insufficient_free(self, tmp_path):
        small = tmp_path / "small"; small.mkdir()
        large = tmp_path / "large"; large.mkdir()

        def fake_from(path):
            if "small" in path:
                return DiskInfo(path=path, total_bytes=10*1024**3,
                                used_bytes=9*1024**3, free_bytes=1*1024**3)
            return DiskInfo(path=path, total_bytes=100*1024**3,
                            used_bytes=10*1024**3, free_bytes=90*1024**3)

        with patch("bag_policy.DiskInfo.from_path", side_effect=fake_from):
            sel = StorageSelector([str(small), str(large)], min_free_gb=2.0)
            assert sel.select() == str(large)

    def test_select_or_default_fallback(self):
        sel = StorageSelector(["/nonexistent"], min_free_gb=1.0)
        assert sel.select_or_default("/fb") == "/fb"

    def test_disk_infos_empty_nonexistent(self):
        sel = StorageSelector(["/nonexistent"])
        assert sel.disk_infos() == []


# ─── BagPolicy constructor ────────────────────────────────────────────────────

class TestBagPolicyConstructor:
    def test_defaults(self):
        p = BagPolicy()
        assert p.split_size_bytes == 1 * 1024**3
        assert p.split_duration_s == 600.0
        assert p.cleanup_age_days == 7
        assert p.cleanup_disk_pct == 80.0
        assert p.warn_disk_pct    == 70.0

    def test_size_stored_as_bytes(self):
        assert BagPolicy(split_size_gb=2.0).split_size_bytes == 2 * 1024**3

    def test_duration_stored_as_seconds(self):
        assert BagPolicy(split_duration_min=5.0).split_duration_s == 300.0

    def test_zero_size_raises(self):
        with pytest.raises(ValueError, match="split_size_gb"):
            BagPolicy(split_size_gb=0.0)

    def test_negative_size_raises(self):
        with pytest.raises(ValueError):
            BagPolicy(split_size_gb=-1.0)

    def test_zero_duration_raises(self):
        with pytest.raises(ValueError, match="split_duration_min"):
            BagPolicy(split_duration_min=0.0)

    def test_cleanup_age_zero_raises(self):
        with pytest.raises(ValueError, match="cleanup_age_days"):
            BagPolicy(cleanup_age_days=0)

    def test_warn_above_cleanup_raises(self):
        with pytest.raises(ValueError):
            BagPolicy(warn_disk_pct=85.0, cleanup_disk_pct=80.0)

    def test_equal_warn_cleanup_raises(self):
        with pytest.raises(ValueError):
            BagPolicy(warn_disk_pct=80.0, cleanup_disk_pct=80.0)


# ─── BagPolicy split ─────────────────────────────────────────────────────────

class TestBagPolicySplit:
    def setup_method(self):
        self.p = BagPolicy(split_size_gb=1.0, split_duration_min=10.0)

    def test_no_split_small_short(self):
        split, reason = self.p.needs_split(100*1024**2, 60)
        assert not split and reason == ""

    def test_split_at_size_boundary(self):
        split, reason = self.p.needs_split(1024**3, 60)
        assert split
        assert "size" in reason.lower() or "gb" in reason.lower()

    def test_split_over_size(self):
        split, _ = self.p.needs_split(int(1.5*1024**3), 60)
        assert split

    def test_split_at_duration_boundary(self):
        split, reason = self.p.needs_split(100*1024**2, 600)
        assert split
        assert "min" in reason.lower() or "duration" in reason.lower()

    def test_split_over_duration(self):
        split, _ = self.p.needs_split(100*1024**2, 700)
        assert split

    def test_just_below_both(self):
        split, _ = self.p.needs_split(1024**3 - 1, 599)
        assert not split

    def test_both_exceeded_still_splits(self):
        split, _ = self.p.needs_split(2*1024**3, 700)
        assert split


# ─── BagPolicy disk thresholds ────────────────────────────────────────────────

class TestBagPolicyDisk:
    def setup_method(self):
        self.p = BagPolicy(warn_disk_pct=70.0, cleanup_disk_pct=80.0,
                           min_free_gb=2.0)

    def test_no_warn_below(self):
        assert not self.p.should_warn_disk(_disk(100, 40))  # 60% used

    def test_warn_at_boundary(self):
        assert self.p.should_warn_disk(_disk(100, 30))   # 70% used

    def test_warn_above(self):
        assert self.p.should_warn_disk(_disk(100, 25))   # 75% used

    def test_no_cleanup_at_warn(self):
        assert not self.p.needs_cleanup(_disk(100, 30))  # 70% used

    def test_cleanup_at_boundary(self):
        assert self.p.needs_cleanup(_disk(100, 20))      # 80% used

    def test_cleanup_above(self):
        assert self.p.needs_cleanup(_disk(100, 5))       # 95% used

    def test_can_record_exact_min_free(self):
        total = 100 * 1024**3
        free  = int(2.0 * 1024**3)
        di = DiskInfo(path="/", total_bytes=total,
                      used_bytes=total-free, free_bytes=free)
        assert self.p.can_record(di)

    def test_cannot_record_below_min_free(self):
        total = 100 * 1024**3
        free  = int(1.0 * 1024**3)
        di = DiskInfo(path="/", total_bytes=total,
                      used_bytes=total-free, free_bytes=free)
        assert not self.p.can_record(di)


# ─── BagPolicy age ────────────────────────────────────────────────────────────

class TestBagPolicyAge:
    def setup_method(self):
        self.p = BagPolicy(cleanup_age_days=7)

    def test_fresh_not_expired(self):
        assert not self.p.bag_is_expired(1.0)

    def test_at_boundary_expired(self):
        assert self.p.bag_is_expired(7.0)

    def test_old_expired(self):
        assert self.p.bag_is_expired(30.0)

    def test_age_computation(self):
        mtime = 1000.0
        age = self.p.bag_age_days(mtime, mtime + 8 * 86400)
        assert abs(age - 8.0) < 1e-9

    def test_age_zero(self):
        assert self.p.bag_age_days(5000.0, 5000.0) == 0.0
