"""bag_policy.py — Pure-Python recording policy logic (Issue #615).

No ROS2 dependencies — importable and fully unit-testable without a live
ROS2 install.

Classes
───────
    MotionState     — tracks /cmd_vel activity and idle timeout
    DiskInfo        — disk-usage snapshot with threshold helpers
    StorageSelector — picks the best storage path from a priority list
    BagPolicy       — decides when to start/stop/split/clean up
"""

from __future__ import annotations

import math
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional, Tuple


# ── Motion tracking ───────────────────────────────────────────────────────────

class MotionState:
    """Tracks /cmd_vel activity and determines idle timeout.

    Usage::

        ms = MotionState(idle_timeout_s=30.0)
        ms.update(linear_x=0.5, angular_z=0.0, now=time.monotonic())
        if ms.should_start_recording(now):
            ...
        if ms.should_stop_recording(now):
            ...
    """

    def __init__(self, idle_timeout_s: float = 30.0) -> None:
        if idle_timeout_s <= 0:
            raise ValueError(f"idle_timeout_s must be > 0, got {idle_timeout_s!r}")
        self._idle_timeout    = idle_timeout_s
        self._last_motion_t:  Optional[float] = None
        self._ever_moved:     bool = False

    # ── Mutators ───────────────────────────────────────────────────────────

    def update(self, linear_x: float, angular_z: float, now: float) -> None:
        """Feed the latest cmd_vel values.  Non-zero → reset idle timer."""
        if abs(linear_x) > 1e-3 or abs(angular_z) > 1e-3:
            self._last_motion_t = now
            self._ever_moved    = True

    def reset(self) -> None:
        """Forget all motion history (e.g. after a recording session ends)."""
        self._last_motion_t = None
        self._ever_moved    = False

    # ── Queries ────────────────────────────────────────────────────────────

    def idle_seconds(self, now: float) -> float:
        """Seconds since last non-zero cmd_vel (inf if never moved)."""
        if self._last_motion_t is None:
            return math.inf
        return now - self._last_motion_t

    def is_idle(self, now: float) -> bool:
        """True if idle_timeout has elapsed since last non-zero cmd_vel."""
        return self.idle_seconds(now) >= self._idle_timeout

    def is_moving(self, now: float) -> bool:
        """True if non-zero cmd_vel was received within the idle window."""
        return self._ever_moved and not self.is_idle(now)

    def should_start_recording(self, now: float) -> bool:
        """True when motion is detected (used to trigger auto-record start)."""
        return self._ever_moved and not self.is_idle(now)

    def should_stop_recording(self, now: float) -> bool:
        """True when idle timeout has elapsed after motion (trigger auto-stop)."""
        return self._ever_moved and self.is_idle(now)

    @property
    def ever_moved(self) -> bool:
        return self._ever_moved

    @property
    def idle_timeout_s(self) -> float:
        return self._idle_timeout


# ── Disk information ──────────────────────────────────────────────────────────

@dataclass
class DiskInfo:
    """Snapshot of disk usage for a single mount point.

    Construct with :meth:`DiskInfo.from_path` for live data or build
    directly from known values for unit tests.
    """

    path:        str
    total_bytes: int
    used_bytes:  int
    free_bytes:  int

    @classmethod
    def from_path(cls, path: str) -> "DiskInfo":
        """Read live disk usage from the OS."""
        u = shutil.disk_usage(path)
        return cls(path=path, total_bytes=u.total,
                   used_bytes=u.used, free_bytes=u.free)

    # ── Derived properties ─────────────────────────────────────────────────

    @property
    def used_pct(self) -> float:
        """Percentage of disk space used (0–100)."""
        if self.total_bytes == 0:
            return 100.0
        return self.used_bytes / self.total_bytes * 100.0

    @property
    def free_pct(self) -> float:
        return 100.0 - self.used_pct

    @property
    def free_gb(self) -> float:
        return self.free_bytes / (1024 ** 3)

    @property
    def used_gb(self) -> float:
        return self.used_bytes / (1024 ** 3)

    @property
    def total_gb(self) -> float:
        return self.total_bytes / (1024 ** 3)

    # ── Threshold helpers ──────────────────────────────────────────────────

    def exceeds_pct(self, threshold_pct: float) -> bool:
        return self.used_pct >= threshold_pct

    def has_min_free(self, min_free_gb: float) -> bool:
        return self.free_gb >= min_free_gb

    def summary(self) -> str:
        return (f"{self.path}: {self.used_gb:.1f}/{self.total_gb:.1f} GB "
                f"({self.used_pct:.1f}% used, {self.free_gb:.1f} GB free)")


# ── Storage path selection ────────────────────────────────────────────────────

class StorageSelector:
    """Selects the best storage path from a priority-ordered list.

    Paths are tried in order; the first accessible path with at least
    ``min_free_gb`` of free space is returned.  Typical priority order:
    USB → NVMe → internal home directory.

    Example::

        sel = StorageSelector(['/media/usb0', '/mnt/nvme', '~/bags'])
        path = sel.select()   # e.g. '/media/usb0'
    """

    DEFAULT_MIN_FREE_GB = 2.0

    def __init__(self,
                 paths: List[str],
                 min_free_gb: float = DEFAULT_MIN_FREE_GB) -> None:
        if not paths:
            raise ValueError("paths list must not be empty")
        self._paths       = [str(Path(p).expanduser()) for p in paths]
        self._min_free_gb = min_free_gb

    def select(self) -> Optional[str]:
        """Return the best available storage path, or None if none qualify."""
        for path in self._paths:
            p = Path(path)
            if not p.exists():
                continue
            try:
                info = DiskInfo.from_path(path)
                if info.has_min_free(self._min_free_gb):
                    return path
            except OSError:
                continue
        return None

    def select_or_default(self, default: str) -> str:
        """Return the best path, falling back to ``default`` if none qualify."""
        return self.select() or default

    def disk_infos(self) -> List[DiskInfo]:
        """Return DiskInfo for all accessible paths (for status publishing)."""
        result = []
        for path in self._paths:
            if Path(path).exists():
                try:
                    result.append(DiskInfo.from_path(path))
                except OSError:
                    pass
        return result


# ── Recording policy ──────────────────────────────────────────────────────────

class BagPolicy:
    """Encapsulates all recording policy thresholds (Issue #615).

    Separates business logic from ROS2 so it can be unit-tested without a
    live ROS2 install.

    Parameters
    ──────────
    split_size_gb      float   Split bag when it reaches this size.   Default 1.0
    split_duration_min float   Split bag after this many minutes.     Default 10.0
    cleanup_age_days   int     Bags older than this are cleanup candidates. Default 7
    cleanup_disk_pct   float   Trigger cleanup when disk exceeds this %. Default 80.0
    warn_disk_pct      float   Log a warning when disk exceeds this %.  Default 70.0
    min_free_gb        float   Minimum free space to start recording.   Default 2.0
    """

    def __init__(
        self,
        split_size_gb:      float = 1.0,
        split_duration_min: float = 10.0,
        cleanup_age_days:   int   = 7,
        cleanup_disk_pct:   float = 80.0,
        warn_disk_pct:      float = 70.0,
        min_free_gb:        float = 2.0,
    ) -> None:
        if split_size_gb <= 0:
            raise ValueError(f"split_size_gb must be > 0, got {split_size_gb!r}")
        if split_duration_min <= 0:
            raise ValueError(f"split_duration_min must be > 0, got {split_duration_min!r}")
        if cleanup_age_days < 1:
            raise ValueError(f"cleanup_age_days must be >= 1, got {cleanup_age_days!r}")
        if not (0 < warn_disk_pct < cleanup_disk_pct <= 100):
            raise ValueError(
                f"Must have 0 < warn_disk_pct({warn_disk_pct}) "
                f"< cleanup_disk_pct({cleanup_disk_pct}) <= 100"
            )

        self.split_size_bytes  = int(split_size_gb * 1024 ** 3)
        self.split_duration_s  = split_duration_min * 60.0
        self.cleanup_age_days  = cleanup_age_days
        self.cleanup_disk_pct  = cleanup_disk_pct
        self.warn_disk_pct     = warn_disk_pct
        self.min_free_gb       = min_free_gb

    # ── Split decision ─────────────────────────────────────────────────────

    def needs_split(
        self,
        bag_size_bytes: int,
        bag_duration_s: float,
    ) -> Tuple[bool, str]:
        """Return (True, reason) if the active bag segment should be split."""
        if bag_size_bytes >= self.split_size_bytes:
            size_gb = bag_size_bytes / 1024 ** 3
            lim_gb  = self.split_size_bytes / 1024 ** 3
            return True, f"size {size_gb:.2f} GB ≥ {lim_gb:.1f} GB"
        if bag_duration_s >= self.split_duration_s:
            return True, (
                f"duration {bag_duration_s / 60:.1f} min "
                f"≥ {self.split_duration_s / 60:.0f} min"
            )
        return False, ""

    # ── Disk thresholds ────────────────────────────────────────────────────

    def needs_cleanup(self, disk: DiskInfo) -> bool:
        """True if disk is full enough to warrant deleting old bags."""
        return disk.exceeds_pct(self.cleanup_disk_pct)

    def should_warn_disk(self, disk: DiskInfo) -> bool:
        """True if disk usage deserves a warning log."""
        return disk.exceeds_pct(self.warn_disk_pct)

    def can_record(self, disk: DiskInfo) -> bool:
        """True if there is enough free space to start/continue recording."""
        return disk.has_min_free(self.min_free_gb)

    # ── Age-based cleanup ──────────────────────────────────────────────────

    def bag_is_expired(self, bag_age_days: float) -> bool:
        """True if a bag is old enough to be a cleanup candidate."""
        return bag_age_days >= self.cleanup_age_days

    def bag_age_days(self, bag_mtime: float, now: float) -> float:
        """Compute bag age in fractional days from mtime and current time."""
        return (now - bag_mtime) / 86400.0
