#!/usr/bin/env python3
"""
reTerminal DM — Bridge Screen Brightness Daemon
================================================
Regulatory alignment
  IMO MSC.191(79)  §8.1  Display must be dimmable but NEVER extinguished
                   §8.1.2 Navigator must be able to reset to a preset level
  IMO MSC.302(87)        Alarm visuals = high luminance; night mode must not
                         degrade crew night vision
  IHO S-52 / IEC 62288  Three operating modes: NIGHT · DUSK · DAY

Operating states
  NIGHT     Very low lux  → level 1 (minimum — "not to extinction")
  DUSK      Low lux       → level 2 (dim)
  DAY-*     Daylight      → levels 3-5 graduated by lux sub-range
  ALERT     Buzzer ON     → level 5 (max) immediately — no smooth stepping
  COOLDOWN  Buzzer just   → hold level 5 for BUZZER_COOLDOWN_SEC seconds,
            stopped          then fade back one step per poll
  OVERRIDE  Manual file   → fixed level set by navigator (per MSC.191(79))

Sysfs paths (reTerminal DM)
  Backlight : /sys/class/backlight/lcd_backlight/brightness  (0-5)
  Buzzer    : /sys/class/leds/usr-buzzer/brightness          (0..max, non-zero = ON; often 255)
  Light IIO : /sys/bus/iio/devices/iio:device0/in_illuminance_input  (lux)

Navigator override (satisfies MSC.191(79) §8.1.2 preset requirement)
  echo 3    > /run/screen-brightness/override   # force fixed level 3
  echo auto > /run/screen-brightness/override   # return to ambient control

Read current state (useful for dashboard integration)
  cat /run/screen-brightness/state

Config file  : /etc/screen-brightness.conf  (INI, optional)
Reload config: kill -HUP <pid>              (or: systemctl reload screen-brightness)
"""

import configparser
import logging
import logging.handlers
import os
import signal
import sys
import time
from pathlib import Path
from typing import Optional, Tuple

# ── Default configuration ────────────────────────────────────────────────────
DEFAULTS: dict = {
    # sysfs paths
    "backlight_path": "/sys/class/backlight/lcd_backlight/brightness",
    "buzzer_path":    "/sys/class/leds/usr-buzzer/brightness",
    "lux_path":       "/sys/bus/iio/devices/iio:device0/in_illuminance_input",

    # timing
    "poll_interval":   "2",    # seconds between lux/ambient reads
    "buzzer_poll":     "0.5",  # seconds between buzzer checks (faster so we catch alarms)
    "buzzer_cooldown": "10",   # seconds to hold max brightness after buzzer stops

    # lux mode boundaries (IHO S-52 three-mode framework)
    "lux_night_max":      "20",    # 0–20   lux  → NIGHT mode
    "lux_dusk_max":       "200",   # 21–200 lux  → DUSK  mode
    "lux_day_dim_max":    "500",   # 201–500 lux → DAY-DIM
    "lux_day_normal_max": "1000",  # 501–1000 lux→ DAY-NORMAL
                                   # >1000   lux → DAY-BRIGHT

    # backlight levels per mode (1 = minimum, 5 = maximum, 0 is NEVER used)
    "level_night":      "1",
    "level_dusk":       "2",
    "level_day_dim":    "3",
    "level_day_normal": "4",
    "level_day_bright": "5",
    "level_alert":      "5",   # during ALERT and COOLDOWN states

    # hysteresis: require this many consecutive polls agreeing on a new ambient
    # target before committing the change (prevents flickering near thresholds)
    "hysteresis_polls": "3",
}

CONFIG_FILE   = Path("/etc/screen-brightness.conf")
OVERRIDE_FILE = Path("/run/screen-brightness/override")
STATE_FILE    = Path("/run/screen-brightness/state")
RUN_DIR       = Path("/run/screen-brightness")

# ── Logging — stdout (captured by journald) + syslog ────────────────────────
log = logging.getLogger("screen-brightness")
log.setLevel(logging.INFO)

_fmt_detail = logging.Formatter("%(asctime)s [screen-brightness] %(levelname)s %(message)s")
_fmt_syslog = logging.Formatter("screen-brightness[%(process)d]: %(levelname)s %(message)s")

_stdout_h = logging.StreamHandler(sys.stdout)
_stdout_h.setFormatter(_fmt_detail)
log.addHandler(_stdout_h)

try:
    _syslog_h = logging.handlers.SysLogHandler(address="/dev/log")
    _syslog_h.setFormatter(_fmt_syslog)
    log.addHandler(_syslog_h)
except OSError:
    pass  # /dev/log not available; journald captures stdout


# ── Config helpers ───────────────────────────────────────────────────────────
def load_config() -> dict:
    cfg = dict(DEFAULTS)
    if CONFIG_FILE.exists():
        parser = configparser.ConfigParser()
        parser.read(CONFIG_FILE)
        section = "screen-brightness"
        if parser.has_section(section):
            cfg.update(parser[section])
        log.info("Config loaded from %s", CONFIG_FILE)
    return cfg


# ── Sysfs helpers ────────────────────────────────────────────────────────────
def _read_int(path: Path, default: Optional[int] = None) -> Optional[int]:
    try:
        return int(path.read_text().strip())
    except Exception as exc:
        log.warning("Cannot read %s: %s", path, exc)
        return default


def _write_int(path: Path, value: int) -> bool:
    try:
        path.write_text(f"{value}\n")
        return True
    except Exception as exc:
        log.error("Cannot write %s = %s: %s", path, value, exc)
        return False


# ── Override file ────────────────────────────────────────────────────────────
def read_override() -> Optional[int]:
    """Return int level (1-5) if a manual override is active, else None."""
    try:
        if not OVERRIDE_FILE.exists():
            return None
        raw = OVERRIDE_FILE.read_text().strip().lower()
        if raw in ("auto", ""):
            return None
        val = int(raw)
        if 1 <= val <= 5:
            return val
    except Exception:
        pass
    return None


# ── State file (read by dashboard / other services) ──────────────────────────
def write_state(mode: str, level: int, lux: Optional[int],
                buzzer: bool, override: bool) -> None:
    try:
        RUN_DIR.mkdir(parents=True, exist_ok=True)
        STATE_FILE.write_text(
            f"mode={mode}\n"
            f"level={level}\n"
            f"lux={lux if lux is not None else 'N/A'}\n"
            f"buzzer={'1' if buzzer else '0'}\n"
            f"override={'1' if override else '0'}\n"
            f"updated={int(time.time())}\n"
        )
    except Exception as exc:
        log.debug("Cannot write state file: %s", exc)


# ── Lux → ambient level ───────────────────────────────────────────────────────
def lux_to_level(lux: int, cfg: dict) -> Tuple[int, str]:
    """Return (backlight_level, mode_name) for the given lux reading."""
    if lux <= int(cfg["lux_night_max"]):
        return int(cfg["level_night"]), "NIGHT"
    if lux <= int(cfg["lux_dusk_max"]):
        return int(cfg["level_dusk"]), "DUSK"
    if lux <= int(cfg["lux_day_dim_max"]):
        return int(cfg["level_day_dim"]), "DAY-DIM"
    if lux <= int(cfg["lux_day_normal_max"]):
        return int(cfg["level_day_normal"]), "DAY-NORMAL"
    return int(cfg["level_day_bright"]), "DAY-BRIGHT"


# ── Main loop ────────────────────────────────────────────────────────────────
def main() -> None:
    cfg = load_config()

    BACKLIGHT = Path(cfg["backlight_path"])
    BUZZER    = Path(cfg["buzzer_path"])
    LUX       = Path(cfg["lux_path"])

    for p in (BACKLIGHT, BUZZER, LUX):
        if not p.exists():
            log.warning("sysfs path not found at startup (will retry): %s", p)

    RUN_DIR.mkdir(parents=True, exist_ok=True)

    # SIGHUP → reload config without restart (e.g. systemctl reload)
    _reload_flag = {"pending": False}

    def _on_sighup(_sig: int, _frame: object) -> None:
        _reload_flag["pending"] = True

    signal.signal(signal.SIGHUP, _on_sighup)

    buzz_interval = float(cfg.get("buzzer_poll", "0.5"))
    poll_interval = float(cfg["poll_interval"])

    log.info(
        "Bridge brightness daemon started | "
        "buzzer_poll=%ss lux_poll=%ss cooldown=%ss night≤%slux dusk≤%slux",
        buzz_interval, poll_interval, cfg["buzzer_cooldown"],
        cfg["lux_night_max"], cfg["lux_dusk_max"],
    )

    current_level: int = -1          # last level written to sysfs
    cooldown_until: float = 0.0      # monotonic time when ALERT cooldown ends
    buzzer_was_on: bool = False

    # Hysteresis state — track how many consecutive polls agree on a new ambient target
    pending_ambient: int = -1
    pending_count: int = 0

    # Buzzer is checked every buzzer_poll (0.5s); lux/ambient only every poll_interval (2s)
    last_lux_poll: float = -999.0  # force first lux read
    lux: Optional[int] = None  # last successful lux read (reused between lux polls)
    target, mode = 1, "NIGHT"   # fallback for step calc before first ambient target

    while True:
        # ── Config reload on SIGHUP ──────────────────────────────────────────
        if _reload_flag["pending"]:
            cfg = load_config()
            BACKLIGHT = Path(cfg["backlight_path"])
            BUZZER    = Path(cfg["buzzer_path"])
            LUX       = Path(cfg["lux_path"])
            _reload_flag["pending"] = False
            pending_ambient = -1
            pending_count = 0
            buzz_interval = float(cfg.get("buzzer_poll", "0.5"))
            poll_interval = float(cfg["poll_interval"])
            log.info("Config reloaded (SIGHUP)")

        now = time.monotonic()
        buzzer_raw = _read_int(BUZZER, default=0) or 0
        buzzer = buzzer_raw > 0
        override = read_override()

        # Read lux only every poll_interval seconds
        if now - last_lux_poll >= poll_interval:
            last_lux_poll = now
            lux = _read_int(LUX, default=None)

        # ── Determine target level and mode name ─────────────────────────────
        immediate = False  # skip smooth stepping when True

        if override is not None:
            target, mode = override, "OVERRIDE"
            immediate = True

        elif buzzer:
            target, mode = int(cfg["level_alert"]), "ALERT"
            cooldown_until = now + int(cfg["buzzer_cooldown"])
            immediate = True  # alarm: go to max brightness instantly
            if not buzzer_was_on:
                log.info("ALERT: buzzer ON → brightness %d (immediate)", target)

        elif now < cooldown_until:
            target, mode = int(cfg["level_alert"]), "COOLDOWN"
            immediate = True  # hold alert level until cooldown expires
            if buzzer_was_on:
                remaining = cooldown_until - now
                log.info("COOLDOWN: buzzer OFF → hold brightness %d for %.0fs",
                         target, remaining)

        else:
            # Ambient mode — lux controls level (use last lux read)
            if lux is None:
                # Sensor not read yet or unreadable — keep current level
                buzzer_was_on = buzzer
                time.sleep(buzz_interval)
                continue

            ambient_target, mode = lux_to_level(lux, cfg)

            # Hysteresis: commit new ambient level only after N consecutive
            # polls agree (avoids flickering near a lux threshold)
            if ambient_target == pending_ambient:
                pending_count += 1
            else:
                pending_ambient = ambient_target
                pending_count = 1

            if pending_count >= int(cfg["hysteresis_polls"]):
                target = ambient_target
            else:
                # Not stable yet — keep current level if we have one
                target = current_level if current_level > 0 else ambient_target

            if buzzer_was_on and not buzzer and now >= cooldown_until:
                log.info("COOLDOWN done → %s | lux=%d | brightness=%d", mode, lux, target)

        # ── Smooth stepping vs immediate ─────────────────────────────────────
        # Ambient changes step one level at a time to avoid harsh
        # brightness jumps that could disrupt night-adapted vision.
        # Alarm and override changes are immediate (safety requirement).
        if immediate or current_level < 0:
            step = target
        elif target > current_level:
            step = current_level + 1
        elif target < current_level:
            step = current_level - 1
        else:
            step = current_level  # no change needed

        # ── Write only when level actually changes ───────────────────────────
        if step != current_level:
            if _write_int(BACKLIGHT, step):
                log.info(
                    "Brightness %s→%d | mode=%-10s | lux=%s",
                    current_level if current_level >= 0 else "—",
                    step, mode,
                    lux if lux is not None else "N/A",
                )
                current_level = step

        write_state(mode, current_level, lux, buzzer, override is not None)
        buzzer_was_on = buzzer
        time.sleep(buzz_interval)


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        log.info("Stopped by keyboard interrupt.")
        sys.exit(0)
