#!/usr/bin/env python3
"""POG-COM-PI-HAT status LED2 (DyzineFrame) — BCM3 active-low sink.

Same patterns as EtherGold LED1 (independent, unsynced).
Env: DF_STATUS_LED_GPIO=3  HAT_STATUS_LED=0 to disable.
No-op on Windows / when GPIO unavailable.
"""

from __future__ import annotations

import argparse
import errno
import os
import struct
import sys
import threading
import time
from typing import Iterable, Optional, Set

DEFAULT_BCM = 3
POST_PULSE_MS = 100
POST_GAP_MS = 500
SOFT_CODE_GAP_MS = 1000
HEALTHY_MS = 250
DEGRADED_MS = 150
DEGRADED_HB_S = 2.0

# Minimal gpiochip v2 output (active driven by HatStatusLed).
GPIO_MAX_NAME_SIZE = 32
GPIO_V2_LINES_MAX = 64
SIZE_LINE_CONFIG_ATTRIBUTE = 24
SIZE_LINE_CONFIG = 8 + 4 + 4 + (SIZE_LINE_CONFIG_ATTRIBUTE * 10) + 4
SIZE_LINE_REQUEST = (
    GPIO_V2_LINES_MAX * 4 + GPIO_MAX_NAME_SIZE + SIZE_LINE_CONFIG + 4 + 4 + 20 + 4
)
SIZE_LINE_VALUES = 16
FLAG_OUTPUT = 1 << 1
ATTR_ID_OUTPUT_VALUES = 3


def _ioc(dir_, typ, nr, size):
    return (dir_ << 30) | (size << 16) | (typ << 8) | nr


_IOC_READ = 2
_IOC_WRITE = 1
GPIO_V2_GET_LINE_IOCTL = _ioc(_IOC_READ | _IOC_WRITE, 0xB4, 0x07, SIZE_LINE_REQUEST)
GPIO_V2_LINE_SET_VALUES_IOCTL = _ioc(_IOC_READ | _IOC_WRITE, 0xB4, 0x0F, SIZE_LINE_VALUES)


def led_enabled() -> bool:
    v = (os.environ.get("HAT_STATUS_LED") or "1").strip().lower()
    return v not in ("0", "false", "no", "off")


def resolve_bcm(default: int = DEFAULT_BCM) -> int:
    raw = (os.environ.get("DF_STATUS_LED_GPIO") or str(default)).strip()
    try:
        return int(raw)
    except ValueError:
        return default


def _find_header_chip(chip: Optional[str] = None):
    import fcntl  # noqa: F401 — Linux only

    if chip:
        path = chip if chip.startswith("/dev/") else f"/dev/{chip}"
        return path, 64
    # Prefer pinctrl-compatible chip; fall back to gpiochip0.
    for name in ("gpiochip0", "gpiochip1", "gpiochip2"):
        path = f"/dev/{name}"
        if os.path.exists(path):
            return path, 64
    raise OSError("no /dev/gpiochip*")


def _open_output(bcm: int, consumer: str):
    import fcntl

    chip_path, _chip_lines = _find_header_chip()
    fd = os.open(chip_path, os.O_RDONLY | os.O_CLOEXEC)
    try:
        buf = bytearray(SIZE_LINE_REQUEST)
        struct.pack_into("<I", buf, 0, int(bcm))
        name = consumer.encode("ascii", "replace")[: GPIO_MAX_NAME_SIZE - 1]
        off = GPIO_V2_LINES_MAX * 4
        buf[off : off + len(name)] = name
        cfg = off + GPIO_MAX_NAME_SIZE
        struct.pack_into("<Q", buf, cfg, FLAG_OUTPUT)
        struct.pack_into("<I", buf, cfg + 8, 1)
        attr = cfg + 32
        struct.pack_into("<I", buf, attr, ATTR_ID_OUTPUT_VALUES)
        struct.pack_into("<Q", buf, attr + 8, 0)
        struct.pack_into("<Q", buf, attr + 16, 1)
        tail = cfg + SIZE_LINE_CONFIG
        struct.pack_into("<I", buf, tail, 1)
        struct.pack_into("<I", buf, tail + 4, 1)
        fcntl.ioctl(fd, GPIO_V2_GET_LINE_IOCTL, buf, True)
        (req_fd,) = struct.unpack_from("<i", buf, SIZE_LINE_REQUEST - 4)
        if req_fd < 0:
            raise OSError("invalid GPIO request fd")
        return req_fd
    except OSError as exc:
        if exc.errno == errno.EBUSY:
            raise OSError(f"GPIO{bcm} already claimed") from exc
        raise
    finally:
        os.close(fd)


def _set_hi(req_fd: int, high: bool) -> None:
    import fcntl

    buf = bytearray(SIZE_LINE_VALUES)
    struct.pack_into("<QQ", buf, 0, 1 if high else 0, 1)
    fcntl.ioctl(req_fd, GPIO_V2_LINE_SET_VALUES_IOCTL, buf, True)


class HatStatusLed:
    def __init__(self, bcm: int, *, consumer: str = "df-status-led") -> None:
        self.bcm = int(bcm)
        self._req_fd = -1
        self._lock = threading.Lock()
        self._stop = threading.Event()
        self._thread: Optional[threading.Thread] = None
        self._mode = "off"
        self._post_n = 1
        self._soft_fails: Set[int] = set()
        self._disabled = not led_enabled() or sys.platform != "linux"
        if self._disabled:
            return
        try:
            self._req_fd = _open_output(self.bcm, consumer)
            _set_hi(self._req_fd, True)  # LED off
        except OSError as exc:
            print(f"hat_status_led: GPIO{self.bcm} unavailable ({exc}) — LED disabled", flush=True)
            self._disabled = True
            self._req_fd = -1

    def _set_lit(self, on: bool) -> None:
        if self._req_fd < 0:
            return
        try:
            _set_hi(self._req_fd, not bool(on))
        except OSError:
            pass

    def close(self) -> None:
        self._stop.set()
        t = self._thread
        if t is not None and t.is_alive():
            t.join(timeout=1.5)
        self._thread = None
        if self._req_fd >= 0:
            try:
                _set_hi(self._req_fd, True)
            except OSError:
                pass
            try:
                os.close(self._req_fd)
            except OSError:
                pass
            self._req_fd = -1

    def _ensure_thread(self) -> None:
        if self._disabled:
            return
        if self._thread is not None and self._thread.is_alive():
            return
        self._stop.clear()
        self._thread = threading.Thread(target=self._run, name="df-hat-status-led", daemon=True)
        self._thread.start()

    def set_phase(self, n: int) -> None:
        with self._lock:
            self._mode = "post"
            self._post_n = max(1, int(n))
        self._ensure_thread()

    def mark_soft_fail(self, n: int) -> None:
        with self._lock:
            self._soft_fails.add(max(1, int(n)))

    def clear_soft_fail(self, n: int) -> None:
        with self._lock:
            self._soft_fails.discard(int(n))

    def enter_healthy(self) -> None:
        with self._lock:
            self._mode = "degraded" if self._soft_fails else "healthy"
        self._ensure_thread()

    def enter_degraded(self, soft_fails: Optional[Iterable[int]] = None) -> None:
        with self._lock:
            if soft_fails is not None:
                self._soft_fails = {max(1, int(x)) for x in soft_fails}
            self._mode = "degraded"
        self._ensure_thread()

    def _sleep_ms(self, ms: float) -> bool:
        return not self._stop.wait(ms / 1000.0)

    def _pulse_count(self, n: int, *, gap_after_ms: int) -> bool:
        for _ in range(n):
            self._set_lit(True)
            if not self._sleep_ms(POST_PULSE_MS):
                return False
            self._set_lit(False)
            if not self._sleep_ms(POST_PULSE_MS):
                return False
        self._set_lit(False)
        return self._sleep_ms(gap_after_ms)

    def _heartbeat(self, on_off_ms: int, duration_s: float) -> bool:
        end = time.monotonic() + duration_s
        while time.monotonic() < end:
            self._set_lit(True)
            if not self._sleep_ms(on_off_ms):
                return False
            self._set_lit(False)
            if not self._sleep_ms(on_off_ms):
                return False
        return True

    def _run(self) -> None:
        while not self._stop.is_set():
            with self._lock:
                mode = self._mode
                n = self._post_n
                fails = sorted(self._soft_fails)
            if mode == "off":
                self._set_lit(False)
                if not self._sleep_ms(200):
                    break
            elif mode == "post":
                if not self._pulse_count(n, gap_after_ms=POST_GAP_MS):
                    break
            elif mode == "healthy":
                self._set_lit(True)
                if not self._sleep_ms(HEALTHY_MS):
                    break
                self._set_lit(False)
                if not self._sleep_ms(HEALTHY_MS):
                    break
            elif mode == "degraded":
                if not self._heartbeat(DEGRADED_MS, DEGRADED_HB_S):
                    break
                if fails:
                    for i, fn in enumerate(fails):
                        if not self._pulse_count(
                            fn,
                            gap_after_ms=SOFT_CODE_GAP_MS if i < len(fails) - 1 else POST_GAP_MS,
                        ):
                            return
                else:
                    if not self._sleep_ms(POST_GAP_MS):
                        break
            else:
                if not self._sleep_ms(200):
                    break


_GLOBAL: Optional[HatStatusLed] = None
_GLOBAL_LOCK = threading.Lock()


def get_df_led(*, force_new: bool = False) -> HatStatusLed:
    global _GLOBAL
    with _GLOBAL_LOCK:
        if force_new and _GLOBAL is not None:
            _GLOBAL.close()
            _GLOBAL = None
        if _GLOBAL is None:
            _GLOBAL = HatStatusLed(resolve_bcm(), consumer="df-status-led")
        return _GLOBAL


def phase(n: int) -> None:
    get_df_led().set_phase(n)


def soft_fail(n: int) -> None:
    get_df_led().mark_soft_fail(n)


def healthy() -> None:
    get_df_led().enter_healthy()


def degraded(*ns: int) -> None:
    led = get_df_led()
    if ns:
        led.enter_degraded(ns)
    else:
        led.enter_degraded()


def shutdown() -> None:
    global _GLOBAL
    with _GLOBAL_LOCK:
        if _GLOBAL is not None:
            _GLOBAL.close()
            _GLOBAL = None


def main(argv: Optional[list[str]] = None) -> int:
    ap = argparse.ArgumentParser(description="HAT status LED2 (DyzineFrame)")
    ap.add_argument("--bcm", type=int, default=None)
    ap.add_argument("cmd", choices=("phase", "soft-fail", "healthy", "degraded", "off"))
    ap.add_argument("n", nargs="*", type=int)
    args = ap.parse_args(argv)
    if args.bcm is not None:
        os.environ["DF_STATUS_LED_GPIO"] = str(args.bcm)
    led = get_df_led(force_new=True)
    if args.cmd == "phase":
        led.set_phase(args.n[0] if args.n else 1)
    elif args.cmd == "soft-fail":
        for x in args.n or [1]:
            led.mark_soft_fail(x)
        led.enter_degraded()
    elif args.cmd == "healthy":
        led.enter_healthy()
    elif args.cmd == "degraded":
        led.enter_degraded(args.n or None)
    try:
        while True:
            time.sleep(3600)
    except KeyboardInterrupt:
        pass
    finally:
        shutdown()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
