#!/usr/bin/env python3
"""Subsea status indicator state machine (+ illumination ring control).

Priority (highest first):
  1. A mission-critical service reports an error (or has gone stale) ->
     blink BLUE at a low duty cycle (default 0.5s on / 5s off). This
     overrides everything else.
  2. Recording not confirmed (camera + sensors both genuinely running,
     per their own state files) -> blink RED (default 0.5s on / 2.5s off
     -- blinking rather than solid to save power).
  3. Recording confirmed, within `led.green_timeout_minutes` of THIS
     recording session's confirmation -> solid GREEN. The window restarts
     with each recording session: confirmed_at resets whenever recording
     stops, so every switch-on gets its own 10 minutes of green.
  4. Recording confirmed, past the green window -> brief GREEN heartbeat
     (default 0.2s every 10s): still alive and recording, at minimal
     power, instead of going fully dark.

The illumination ring (all non-indicator pixels) is independent of the
status pattern: white while recording is confirmed, dark otherwise.

"Mission-critical" here deliberately means camera + sensors + the watchdog
itself -- NOT the e-paper or Lumen services. A diver checking the LED cares
whether recording is actually happening; a transient e-paper SPI glitch or
a Lumen PWM hiccup isn't mission failure and shouldn't burn battery
blinking a false alarm. Non-critical service faults are still recorded in
their own /run/bubblecam/*.json state files for post-mission diagnosis --
they just don't drive this LED.

The green-confirmation window's start time is recovered from this script's
own last-written state on startup, so a led_status.service restart (not a
full Pi reboot) doesn't spuriously reset the diver's visual confirmation
window. A real reboot correctly does reset it, since state lives on tmpfs.
"""

from __future__ import annotations

import sys
import time
from pathlib import Path
from typing import List, Optional

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from bubblecam import config, events, state_lib  # noqa: E402
from bubblecam.hardware.ws2812b import StatusLED  # noqa: E402

RED = (255, 0, 0)
GREEN = (0, 255, 0)
BLUE = (0, 0, 255)
OFF = (0, 0, 0)

MISSION_CRITICAL_SERVICES = ["sensors", "watchdog"]

# The lumen service refreshes its state every ~60s (its dusk/dawn poll),
# so its `dark` flag needs a staleness window comfortably above that --
# the default 30s would judge a perfectly healthy lumen service stale.
LUMEN_DARK_STALE_SECONDS = 180


def lumen_says_dark(stale_after: float = LUMEN_DARK_STALE_SECONDS) -> bool:
    """The dusk/dawn `dark` flag published by lumen_control (the single
    source of astronomical truth -- no duplicate astral math here).

    Missing or stale lumen data defaults to True: for a camera mission,
    failing toward "lights on" costs battery, failing toward "lights off"
    costs the footage. systemd restarts a crashed lumen service, so this
    default only rules during brief gaps."""
    state = state_lib.read_state("lumen")
    if not state_lib.is_fresh(state, stale_after):
        return True
    return bool(state.get("dark", True))


def ring_should_light(mode: str, confirmed: bool) -> bool:
    """Illumination ring policy, selected by [led].ring_mode:
      "night"                on between dusk and dawn (Milos schedule)
      "recording"            on whenever recording is confirmed
      "night_and_recording"  on only when both
      "always"               on whenever the service runs
      "off"                  never (ring disabled)
    """
    if mode == "off":
        return False
    if mode == "always":
        return True
    if mode == "recording":
        return confirmed
    if mode == "night_and_recording":
        return confirmed and lumen_says_dark()
    # "night" (default)
    return lumen_says_dark()


def camera_service_names(cfg: dict) -> List[str]:
    mode = cfg["camera"]["mode"]
    if mode.startswith("dual_node"):
        return ["camera_left", "camera_right"]
    return ["camera_primary"]


def recording_confirmed(cfg: dict) -> bool:
    services = camera_service_names(cfg) + ["sensors"]
    return all(state_lib.is_running(name) for name in services)


def any_mission_critical_error(cfg: dict, stale_after: float) -> bool:
    # A camera deliberately stopped by the physical record switch is not a
    # fault -- don't blink the diver-facing error signal for it. The LED
    # still shows solid RED via recording_confirmed() being False, which is
    # the accurate "not recording" indication for a switched-off camera.
    services = list(MISSION_CRITICAL_SERVICES)
    if state_lib.recording_requested(stale_after):
        services = camera_service_names(cfg) + services
    return any(state_lib.has_error(name, stale_after) for name in services)


def recover_confirmed_at() -> Optional[float]:
    previous = state_lib.read_state("led")
    if previous:
        return previous.get("confirmed_at")
    return None


def next_confirmed_at(confirmed: bool, confirmed_at: Optional[float], now: float) -> Optional[float]:
    """Track the start of the CURRENT recording session's green window.

    Resets to None whenever recording is not confirmed -- without that, a
    second recording session in the same boot inherits the first session's
    (long-expired) window and skips green entirely, which reads to a diver
    as "recording never confirmed"."""
    if not confirmed:
        return None
    return confirmed_at if confirmed_at is not None else now


def ramp_toward(current: float, target: float, rate_per_second: float, dt: float) -> float:
    """One soft-start/soft-stop step of the ring brightness ramp.

    The 40-LED ring at full white is amps of load; snapping it on or off
    in a single 1.25ms strip update puts a step transient on the same 5V
    rail feeding the Pi, camera, and SD card. Ramping over a couple of
    seconds turns that step into a gentle slope."""
    step = rate_per_second * dt
    if current < target:
        return min(target, current + step)
    return max(target, current - step)


def pattern_for(mode: str, led_cfg: dict):
    """(color, on_seconds, off_seconds) for a mode; off_seconds 0 = solid."""
    if mode == "error":
        return BLUE, led_cfg["error_blink_on_ms"] / 1000, led_cfg["error_blink_off_ms"] / 1000
    if mode == "red":
        return RED, led_cfg["idle_blink_on_ms"] / 1000, led_cfg["idle_blink_off_ms"] / 1000
    if mode == "green":
        return GREEN, 0.0, 0.0
    # "heartbeat": recording continues past the green window.
    return GREEN, led_cfg["heartbeat_blink_on_ms"] / 1000, led_cfg["heartbeat_blink_off_ms"] / 1000


def main() -> int:
    cfg = config.load()
    led_cfg = cfg["led"]
    watchdog_cfg = cfg["watchdog"]

    led = StatusLED(
        gpio_pin=led_cfg["gpio_pin"],
        led_count=led_cfg["led_count"],
        indicator_position=led_cfg["indicator_position"],
        indicator_order=led_cfg["indicator_order"],
    )
    ring_target_level = float(led_cfg["ring_white_brightness"])
    ramp_rate = 255.0 / max(0.1, led_cfg["ring_ramp_seconds"])  # brightness units per second
    ring_level = 0.0

    confirmed_at = recover_confirmed_at()
    poll_seconds = led_cfg["poll_seconds"]
    green_timeout_seconds = led_cfg["green_timeout_minutes"] * 60
    stale_after = watchdog_cfg["stale_after_seconds"]

    # Render tick: fine enough to hit a 0.2s heartbeat flash reliably.
    # State files are only re-read every poll_seconds; ticks in between
    # just advance the blink phase (and re-push the strip, which recovers
    # any pixel showing stale latched data within a tick or two).
    tick_seconds = 0.1

    mode: Optional[str] = None
    confirmed = False
    ring_on = False
    ring_mode = led_cfg["ring_mode"]
    last_check: Optional[float] = None

    try:
        while True:
            now = time.monotonic()

            if last_check is None or now - last_check >= poll_seconds:
                last_check = now
                error = any_mission_critical_error(cfg, stale_after)
                confirmed = recording_confirmed(cfg)
                confirmed_at = next_confirmed_at(confirmed, confirmed_at, now)
                previous_ring_on = ring_on
                ring_on = ring_should_light(ring_mode, confirmed)
                if ring_on != previous_ring_on:
                    events.log("ring", "ring light ON (ramping up to "
                                       f"{int(ring_target_level)}/255)" if ring_on
                                       else "ring light OFF (ramping down)")

                previous_mode = mode
                if error:
                    mode = "error"
                elif not confirmed:
                    mode = "red"
                elif now - confirmed_at < green_timeout_seconds:
                    mode = "green"
                else:
                    mode = "heartbeat"
                if mode != previous_mode:
                    events.log("status-led", f"status -> {mode}")

                state_lib.write_state("led", mode, confirmed_at=confirmed_at, ring_on=ring_on)

            # Phase-based pattern render: reacts to state changes within a
            # poll even mid-blink (no long sleeps holding the loop hostage).
            color, on_s, off_s = pattern_for(mode, led_cfg)
            if off_s <= 0:
                led.set_status_color(color)
            else:
                phase = now % (on_s + off_s)
                led.set_status_color(color if phase < on_s else OFF)

            # Illumination ring, per [led].ring_mode policy, soft-ramped
            # (see ramp_toward). Independent of the status pattern.
            ring_level = ramp_toward(
                ring_level, ring_target_level if ring_on else 0.0, ramp_rate, tick_seconds
            )
            level = int(round(ring_level))
            led.set_ring_color((level, level, level))
            led.refresh()

            time.sleep(tick_seconds)
    finally:
        led.set_ring_color(OFF)
        led.set_status_color(OFF)


if __name__ == "__main__":
    sys.exit(main())
