#!/usr/bin/env python3
"""Blue Robotics Lumen dusk/dawn brightness control.

Fully offline: computes each night's dusk/dawn times from a fixed lat/long
(bubblecam.toml) using the `astral` library -- no internet dependency,
since there won't be any in the field. Every ~60s, compares current
RTC-synced UTC time against a freshly recomputed window and sets PWM duty
cycle idempotently. Polling (rather than a single long sleep-until-next-
event) is self-healing: a service restart just recomputes correctly on the
very next tick, no re-arming logic needed.

Lights are turned OFF at dawn as well as ON at dusk -- there's no value to
underwater lighting during daylight at 25m depth, and it's a real,
avoidable power saving across the mission's two covered nights.

Dusk/dawn definition (civil/nautical/actual-sunset) is configurable via
[lumen].dusk_definition -- default civil twilight (sun 6 deg below
horizon), which given ~20-30m of light attenuation at depth is arguably
more correct for underwater conditions than surface actual-sunset anyway.
"""

from __future__ import annotations

import signal
import sys
import time
from datetime import date, datetime, timedelta, timezone
from pathlib import Path

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

POLL_SECONDS = 60

_shutdown_requested = False


def _handle_sigterm(signum, frame):
    global _shutdown_requested
    _shutdown_requested = True


def _depression_for(dusk_definition: str):
    from astral import Depression

    return {
        "civil": Depression.CIVIL,
        "nautical": Depression.NAUTICAL,
        "actual": Depression.CIVIL,  # unused for "actual" -- sunset/sunrise keys are used directly instead
    }.get(dusk_definition, Depression.CIVIL)


def _on_off_keys(dusk_definition: str):
    if dusk_definition == "actual":
        return "sunset", "sunrise"
    return "dusk", "dawn"


def is_dark(now: datetime, latitude: float, longitude: float, dusk_definition: str) -> bool:
    """True if `now` (UTC) falls within a dusk-to-dawn window, checking both
    the window that may have started yesterday evening (if now is in the
    small-hours/pre-dawn portion of it) and the one starting this evening."""
    from astral import Observer
    from astral.sun import sun

    observer = Observer(latitude=latitude, longitude=longitude)
    depression = _depression_for(dusk_definition)
    on_key, off_key = _on_off_keys(dusk_definition)

    today = now.date()
    for offset in (-1, 0):
        day = today + timedelta(days=offset)
        dusk_events = sun(observer, date=day, dawn_dusk_depression=depression, tzinfo=timezone.utc)
        dawn_events = sun(
            observer, date=day + timedelta(days=1), dawn_dusk_depression=depression, tzinfo=timezone.utc
        )
        dusk_time = dusk_events[on_key]
        dawn_time = dawn_events[off_key]
        if dusk_time <= now < dawn_time:
            return True
    return False


def main() -> int:
    signal.signal(signal.SIGTERM, _handle_sigterm)

    cfg = config.load()
    lumen_cfg = cfg["lumen"]

    # enabled=false means no Lumen hardware is attached: never touch the
    # PWM at all -- not even to initialize it. The kernel's sysfs PWM
    # driver and rpi_ws281x share the PWM peripheral, and a single sysfs
    # write while WS2812Bs run on a PWM pin (GPIO13) corrupts the block
    # and freezes the LED service solid (observed). The service still runs
    # to compute and publish the dusk/dawn `dark` flag that the LED ring's
    # night mode reads.
    pwm = LumenPWM(chip=lumen_cfg["pwm_chip"], channel=lumen_cfg["pwm_channel"]) if lumen_cfg["enabled"] else None
    state_lib.write_state("lumen", "starting")
    previous_dark: bool | None = None

    try:
        while not _shutdown_requested:
            now = datetime.now(timezone.utc)
            try:
                dark = is_dark(
                    now, lumen_cfg["latitude"], lumen_cfg["longitude"], lumen_cfg["dusk_definition"]
                )
            except Exception as exc:  # astral/config errors -- don't crash the light schedule silently
                state_lib.write_state("lumen", "error", detail=f"dusk/dawn calc failed: {exc}")
                time.sleep(POLL_SECONDS)
                continue

            # `enabled` gates only the physical PWM output -- dark is still
            # computed and published every poll, because the LED ring's
            # night mode reads this service's `dark` flag as the single
            # source of dusk/dawn truth (see led_status.ring_should_light).
            brightness_pct = lumen_cfg["brightness_pct"] if (dark and lumen_cfg["enabled"]) else 0.0
            if pwm is not None:
                pwm.set_brightness_pct(brightness_pct)
            if dark != previous_dark:
                events.log("lumen", f"{'dusk -- dark' if dark else 'dawn -- daylight'}"
                                    f" (lumen at {brightness_pct:.0f}%)")
                previous_dark = dark
            state_lib.write_state(
                "lumen", "running",
                dark=dark, brightness_pct=brightness_pct, enabled=lumen_cfg["enabled"],
            )

            time.sleep(POLL_SECONDS)
    finally:
        if pwm is not None:
            pwm.off()

    return 0


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