# SPDX-License-Identifier: Apache-2.0
"""The hardware boundary: a clock, GPIO in and out, SPI and I2C. Nothing else in the firmware touches the board.

Two implementations:
- here, `sim`: in-memory, with a clock that only moves when told to, so tests and the laptop demo are exact;
- `hw_linux.py`: the Radxa ZERO 3E under Debian, through python-periphery (imported lazily).

Times are seconds on one monotonic clock. Edges carry the time they happened, not the time they were read,
so a slow loop doesn't change how long a press looked.
"""
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Callable, Protocol


@dataclass(frozen=True)
class Edge:
    t: float          # when the line changed, on the board's monotonic clock
    level: bool       # the line's level after the change (True = high)


class Clock(Protocol):
    def now(self) -> float: ...                # monotonic seconds
    def wall(self) -> float: ...               # Unix seconds (system time, set from the PCF8523 at boot)
    def sleep(self, s: float) -> None: ...


class Input(Protocol):
    def read(self) -> bool: ...
    def edges(self) -> list[Edge]: ...         # every edge since the last call, oldest first


class Output(Protocol):
    def write(self, on: bool) -> None: ...


class Spi(Protocol):
    def write(self, data: bytes) -> None: ...  # one transfer, MOSI only


class I2c(Protocol):
    def transfer(self, addr: int, write: bytes, read: int = 0) -> bytes: ...   # write, then a repeated-start read


@dataclass
class Board:
    """Everything the daemon drives. `slide` and `keep_alive` are None until the B-44 wiring exists."""
    clock: Clock
    eye: Input                       # pin 16, pull-up, switch to GND: low = pressed
    motor: Output                    # pin 18 → PN2222A → ERM disc
    spi: Spi                         # pin 19 MOSI → icon LED → 50-LED strip
    i2c: I2c                         # pins 3/5: ATECC608 0x60, PCF8523 0x68, ST25DV 0x53/0x57
    slide: Input | None = None       # power slide sense (B-44, not wired in v01)
    keep_alive: Output | None = None # holds the Pololu 2810 on while shutting down (B-44, not wired in v01)
    battery: Callable[[], float | None] = lambda: None   # charge 0–1; v01 has no gauge, so None


# --- sim ------------------------------------------------------------------------------
@dataclass
class SimClock:
    t: float = 0.0
    wall0: float = 1_791_936_000.0   # 2026-10-14 00:00 UTC, so day numbers are realistic

    def now(self) -> float:
        return self.t

    def wall(self) -> float:
        return self.wall0 + self.t

    def sleep(self, s: float) -> None:
        self.t += max(0.0, s)


@dataclass
class SimInput:
    """A line whose future is scripted: `at(t, level)` queues an edge; it happens when the clock reaches t."""
    clock: SimClock
    level: bool = True
    _queue: list[Edge] = field(default_factory=list)

    def at(self, t: float, level: bool) -> None:
        self._queue.append(Edge(t, level))
        self._queue.sort(key=lambda e: e.t)

    def edges(self) -> list[Edge]:
        due = [e for e in self._queue if e.t <= self.clock.now()]
        self._queue = self._queue[len(due):]
        out = []
        for e in due:
            if e.level != self.level:
                self.level = e.level
                out.append(e)
        return out

    def read(self) -> bool:
        self.edges()                 # an unread edge still changes the level
        return self.level

    def pending(self) -> bool:
        return bool(self._queue)


@dataclass
class SimOutput:
    clock: SimClock
    on: bool = False
    log: list[tuple[float, bool]] = field(default_factory=list)

    def write(self, on: bool) -> None:
        if on != self.on or not self.log:
            self.log.append((self.clock.now(), on))
        self.on = on


@dataclass
class SimSpi:
    writes: list[bytes] = field(default_factory=list)

    def write(self, data: bytes) -> None:
        self.writes.append(bytes(data))


@dataclass
class SimMemory:
    """An I2C device with 16-bit memory addresses (the ST25DV's user memory and dynamic registers)."""
    size: int = 0x10000
    mem: bytearray = field(default_factory=bytearray)

    def __post_init__(self) -> None:
        self.mem = bytearray(self.size)

    def transfer(self, write: bytes, read: int) -> bytes:
        a = int.from_bytes(write[:2], "big")
        data = write[2:]
        self.mem[a:a + len(data)] = data
        return bytes(self.mem[a:a + read])


@dataclass
class SimI2c:
    devices: dict = field(default_factory=dict)       # addr → object with transfer(write, read) -> bytes

    def transfer(self, addr: int, write: bytes, read: int = 0) -> bytes:
        if addr not in self.devices:
            raise OSError(f"no ACK from 0x{addr:02x}")
        return self.devices[addr].transfer(bytes(write), read)


def sim_board(battery: float | None = 0.8, slide: bool = True) -> Board:
    """A board in memory: the eye released, an ST25DV on the bus, the B-44 lines present so power.py can be tried."""
    c = SimClock()
    i2c = SimI2c({0x53: SimMemory()})
    b = Board(clock=c, eye=SimInput(c, True), motor=SimOutput(c), spi=SimSpi(), i2c=i2c,
              slide=SimInput(c, True) if slide else None, keep_alive=SimOutput(c) if slide else None)
    b.battery = lambda: battery
    return b
