# SPDX-License-Identifier: Apache-2.0
"""Clean shutdown (backlog B-44): when the power slide goes off, finish the meeting before the power goes.

**v01 can't do this yet.** In wiring.md the power slide drives the Pololu 2810's control terminal directly, so
switching off cuts the Radxa at once: no GPIO sees the slide and nothing can hold the power on. This module is the
state machine for the wiring B-44 adds, tested in sim:

- **sense:** SW1 is an SPDT slide and only two of its legs are used. Its spare throw goes to a GPIO with the
  internal pull-up, so the line reads low while the slide is off (`off_low`);
- **keep-alive:** a second PN2222A, collector to the 2810 control terminal, emitter to GND, base from a GPIO
  through 4.7 kΩ with 100 kΩ base to GND. While the GPIO is high it holds the control the way the slide's
  "on" position does, so the power stays on after the slide moves. (VERIFY: the 2810's control polarity, and that
  the RK3566's GPIO bank loses power at halt, so the pull-down drops the keep-alive and the 2810 turns off.)

Until then the same clean path runs on SIGTERM (`poweroff` over SSH, systemd), and a slide bumped off loses
whatever was not yet written.

States: on → (slide off for `settle` s, a low battery, or a request) → shutting-down → done. The shutdown steps
run in order, each guarded, and stop at `deadline` so a stuck write can never keep the device on for good.
"""
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Callable

ON, SHUTTING_DOWN, DONE = "on", "shutting-down", "done"


@dataclass(frozen=True)
class PowerTimes:
    settle: float = 0.050        # a slide switch's own bounce; a bumped slide that springs back doesn't shut down
    deadline: float = 30.0       # the ST25DV takes ≈ 5 ms per 4 bytes, so a full 8 KB tag is ≈ 10 s (VERIFY)
    low_battery: float = 0.03    # charge at which to shut down (no gauge in v01: see battery in hw.Board)


@dataclass
class Power:
    slide: object | None = None              # hw.Input, or None when the sense line isn't wired
    keep_alive: object | None = None         # hw.Output, or None when the keep-alive isn't wired
    times: PowerTimes = field(default_factory=PowerTimes)
    off_low: bool = True            # low = slide off (the spare throw grounds the pulled-up line)
    state: str = ON
    reason: str | None = None
    log: list[tuple[str, str]] = field(default_factory=list)   # (step, "ok" | error text | "skipped: deadline")
    _off_since: float | None = None

    @property
    def can_hold(self) -> bool:
        """True when a bumped slide can be caught: both B-44 lines exist."""
        return self.slide is not None and self.keep_alive is not None

    def start(self) -> None:
        if self.keep_alive is not None:
            self.keep_alive.write(True)

    def request(self, reason: str) -> None:
        if self.state == ON:
            self.state, self.reason = SHUTTING_DOWN, reason

    def poll(self, now: float, battery: float | None = None) -> bool:
        """Call every loop. True once a shutdown is due."""
        if self.slide is not None:
            for e in self.slide.edges():
                off = e.level != self.off_low
                self._off_since = (e.t if self._off_since is None else self._off_since) if off else None
            if self._off_since is not None and now - self._off_since >= self.times.settle:
                self.request("slide")
        if battery is not None and battery <= self.times.low_battery:
            self.request("battery")
        return self.state == SHUTTING_DOWN

    def slide_on(self) -> bool | None:
        if self.slide is None:
            return None
        return self.slide.read() == self.off_low

    def shutdown(self, steps: list[tuple[str, Callable[[], object]]], now: Callable[[], float]) -> str:
        """Run the steps (flush, end the meeting, seal, write the tag, sync), then say what the OS should do:
        `poweroff`, or `reboot` if the slide is back on (power would stay on with the board halted)."""
        self.request(self.reason or "request")
        t0 = now()
        for name, fn in steps:
            if now() - t0 > self.times.deadline:
                self.log.append((name, "skipped: deadline"))
                continue
            try:
                fn()
                self.log.append((name, "ok"))
            except Exception as e:  # noqa: BLE001  one failed step must not stop the rest
                self.log.append((name, f"{type(e).__name__}: {e}"))
        self.state = DONE
        return "reboot" if self.slide_on() else "poweroff"
