# SPDX-License-Identifier: Apache-2.0
"""The firmware in a scripted meeting, on a sim board (ADR-0019).

One runner for two callers:
- the site's Meeting tab runs it in the browser under Pyodide: the real daemon, fed the script's speech and the
  visitor's eye presses, and the ring drawn from the SPI bytes it sends;
- the flow test (`tools/flow_check.py`, `tests/test_flow.py`, validate.py "Firmware flow") replays the same script
  at the site's captions-only timing and checks what comes out of the pins, the SPI bus and the tag.

The script is `v01/firmware/scenarios/meeting.json`, read by both, so they can't drift.

Nothing here is device code: the daemon, eye, ring, haptic, tag and protocol code it drives are. What this module
adds is the stand-in for what isn't built yet:
- the audio pipeline (B-35), as `speaking()` every `vad_s` while a line is spoken, then `hear()` with the line's span
  and the speaker's bearing when it ends;
- the distiller, as the script's hand-written paraphrase, gist and outcome lines, filtered by the level each line was
  filed at.
"""
from __future__ import annotations

import json
from dataclasses import dataclass, field
from pathlib import Path

from .daemon import READER, Daemon
from .eye import press_edges
from .handoff import unpack
from .hw import sim_board
from .ring import decode
from .st25dv import RF_MNGT_DYN, USER_ADDR

SCRIPT = Path(__file__).resolve().parents[1] / "scenarios" / "meeting.json"


def load_script(path: Path | str | None = None) -> dict:
    return json.loads(Path(path or SCRIPT).read_text())


def line_seconds(text: str, timing: dict) -> float:
    """How long a line lasts on the site with captions only (workbench: max(1800, words × 330) ms)."""
    return max(timing["min_s"], len(text.split()) * timing["s_per_word"])


@dataclass
class TimedSpi:
    """An SPI bus that remembers when each transfer happened (all of them, or only the last)."""
    clock: object
    keep: bool = False
    writes: list = field(default_factory=list)      # (t, bytes)
    count: int = 0

    def write(self, data: bytes) -> None:
        self.count += 1
        if self.keep:
            self.writes.append((self.clock.now(), bytes(data)))
        else:
            self.writes[:] = [(self.clock.now(), bytes(data))]


def read_tag(mem: bytes) -> str | None:
    """The URL in an ST25DV image (type 5 CC, NDEF TLV, one URI record), or None if there is no NDEF message.
    Written from the NFC Forum layout, not from handoff.ndef_tag_image, so it checks the writer independently."""
    if mem[0] != 0xE2:
        return None
    cc = 8 if mem[2] == 0 else 4                     # extended CC when its MLEN byte is 0
    if mem[cc] != 0x03:
        return None
    n, body = (mem[cc + 1], cc + 2) if mem[cc + 1] != 0xFF else (int.from_bytes(mem[cc + 2:cc + 4], "big"), cc + 4)
    rec = bytes(mem[body:body + n])
    sr = rec[0] & 0x10
    tl = rec[1]
    plen, at = (rec[2], 3) if sr else (int.from_bytes(rec[2:6], "big"), 6)
    if rec[0] & 0x07 != 0x01 or rec[at:at + tl] != b"U":
        return None
    payload = rec[at + tl:at + tl + plen]
    prefix = {0x00: "", 0x01: "http://www.", 0x02: "https://www.", 0x03: "http://", 0x04: "https://"}[payload[0]]
    return prefix + payload[1:].decode()


class SimRun:
    """The daemon on a sim board, driven on the board's own clock. All times are seconds on that clock."""

    def __init__(self, script: dict | None = None, bearings: list | None = None, battery: float | None = 0.8,
                 keep_frames: bool = False, reader: str = READER, fps: float | None = None):
        self.script = script or load_script()
        b = self.board = sim_board(battery=battery)
        b.spi = TimedSpi(b.clock, keep_frames)
        self.log: list[str] = []
        self.d = Daemon(b, reader=reader, say=self.log.append, distil=self._distil, bearings=bearings)
        self.fps = fps or self.d.times.fps
        self.lines: dict[int, tuple] = {}             # id(segment) → (segment, script line), for the stand-in distiller
        self.events: list[tuple] = []                 # (t, kind, value): eye outcomes and buzzes, in order
        self._shown = 0                               # events already handed to the page
        self.keep = keep_frames
        self.trace: list[tuple] = []                  # with keep_frames: (t, room off, provisional left, battery shown) per pass
        eye_down, play = self.d.g.eye_down, self.d.haptic.play

        def eye_down_(t, lifted=False):               # record what each press did, then let the device do it
            out = eye_down(t, lifted)
            self.events.append((t, "eye", out))
            return out

        def play_(name, now):
            if name:
                self.events.append((now, "buzz", name))
            return play(name, now)
        self.d.g.eye_down, self.d.haptic.play = eye_down_, play_
        self._step()

    def _step(self) -> None:
        self.d.step()
        if self.keep:
            d, now = self.d, self.now()
            self.trace.append((now, d.g.room_off, d.g.provisional_left(now), d.showing_battery or now < d.battery_until))

    # --- time -------------------------------------------------------------------------
    def now(self) -> float:
        return self.board.clock.now()

    def advance(self, to: float) -> None:
        """Run the main loop until `to`, one pass every 1/fps s, as daemon.run() does on the device."""
        c = self.board.clock
        while c.now() < to - 1e-9:
            c.sleep(min(1 / self.fps, to - c.now()))
            self._step()

    def skip(self, seconds: float) -> None:
        """Jump the clock and run one pass (nothing happens in between: a long silence, or a tab in the background)."""
        self.board.clock.sleep(seconds)
        self._step()

    # --- inputs -----------------------------------------------------------------------
    def bearing(self, name: str) -> float | None:
        a = self.script.get("seats", {}).get(name)
        return abs(a) if isinstance(a, (int, float)) else None

    def speaking(self, name: str) -> None:
        self.d.speaking(name, bearing=self.bearing(name))

    def hear(self, name: str, text: str, t0: float, line: dict | None = None) -> dict:
        """`name` said `text` from t0 until now. Returns the outcomes, the level the line was filed at, and the
        speaker's level now."""
        out = self.d.hear(name, text, span=(t0, self.now()), bearing=self.bearing(name))
        seg = self.d.last_segment
        if seg is not None:
            self.lines[id(seg)] = (seg, line or {"s": name, "v": text})
        s = self.d.g.session
        self._step()
        return {"outcomes": out, "filed": None if seg is None else seg.level, "level": s.applied(name)}

    def eye(self, down: bool) -> None:
        """The eye goes down or comes up now (the switch closes to ground: down = low)."""
        self.board.eye.at(self.now(), not down)

    def press(self, held: float = 0.12, at: float | None = None, bounce: int = 3) -> None:
        """One whole press with contact bounce, queued on the eye line; it happens as the clock reaches it."""
        for e in press_edges(self.now() if at is None else at, held, bounce=bounce):
            self.board.eye.at(*e)

    def end_meeting(self) -> str | None:
        """End the meeting now, as the session gap or a clean switch-off does: the note goes onto the tag."""
        url = self.d.end_meeting(self.now())
        self._step()
        return url

    # --- outputs ----------------------------------------------------------------------
    def chain(self, data: bytes | None = None) -> list[tuple[int, int, int]]:
        """What each LED in the chain received in the last SPI transfer (icon first), decoded from the bytes."""
        if data is None:
            data = self.board.spi.writes[-1][1]
        return decode(data)

    def tag(self) -> dict:
        mem = self.board.i2c.devices[USER_ADDR].mem
        url = read_tag(mem)
        note = unpack(url.split("#", 1)[1]) if url and "#" in url else None
        return {"url": url, "note": note, "rf_on": mem[RF_MNGT_DYN] & 1 == 0, "writes": self.d.tag_writes}

    def state(self) -> dict:
        """Everything the page draws, from the firmware's own outputs: the chain as decoded from the last SPI
        transfer, the session's voices and levels, and what happened since the last call."""
        s, now = self.d.g.session, self.now()
        voices = sorted(s.voices.values(), key=lambda v: v.slot)
        chain = self.chain()
        new, self._shown = self.events[self._shown:], len(self.events)
        if not self.keep:                             # the page keeps nothing it has been shown
            self.events, self._shown = [], 0
        return {"t": now, "icon": chain[0] != (0, 0, 0), "ring": [list(c) for c in chain[1:]], "spi": self.board.spi.count,
                "names": [v.label for v in voices], "levels": [s.applied(v.label) for v in voices], "room": s.applied(None),
                "room_off": self.d.g.room_off, "provisional": self.d.g.provisional_left(now),
                "window_left": None if not s.provisional else max(0.0, s.provisional["opened"] + s.profile.objection_window - now),
                "battery": self.d.showing_battery or now < self.d.battery_until, "note_ready": self.d.g.note_ready(now),
                "gap_s": s.profile.session_gap,
                "events": [list(e) for e in new]}

    # the page calls these through Pyodide: JSON strings in and out, so no Python objects leak into JavaScript
    def state_json(self) -> str:
        return json.dumps(self.state())

    def hear_json(self, name: str, text: str, t0: float, line_json: str = "null") -> str:
        return json.dumps(self.hear(name, text, t0, json.loads(line_json)))

    def tag_json(self) -> str:
        return json.dumps(self.tag())

    # --- the distiller's stand-in -----------------------------------------------------
    def _distil(self, meeting: dict) -> dict:
        """The script's hand-written lines for each filed segment, at the level it was filed (held while provisional)."""
        out = {1: [], 2: [], 3: []}
        segs = sorted((sg for rec in meeting["records"].values() for sg in rec), key=lambda sg: sg.t0)
        for sg in segs:
            line = self.lines.get(id(sg), (None, None))[1]
            if line is None:
                continue
            lvl = sg.level if sg.held is None else min(sg.level, sg.held)
            if lvl >= 3:
                out[3].append(f"{line['s']} {line.get('p') or 'spoke about their level of detail.'}")
            if lvl >= 2 and line.get("g"):
                out[2].append(line["g"])
            if lvl >= 1 and line.get("o"):
                out[1].append(line["o"])
        return out


def digest(result: dict) -> str:
    """One hash of everything the replay put out (each SPI transfer and its time, the motor pin, the tag's memory),
    so two Python runtimes (CPython here, Pyodide in the browser) can be shown to produce the same device."""
    import hashlib
    run, h = result["run"], hashlib.sha256()
    for t, data in run.board.spi.writes:
        h.update(f"{t:.6f}".encode() + data)
    for t, on in run.board.motor.log:
        h.update(f"{t:.6f}{int(on)}".encode())
    h.update(bytes(run.board.i2c.devices[USER_ADDR].mem))
    return h.hexdigest()


def run_script(script: dict | None = None, presses: bool = True, keep_frames: bool = True) -> dict:
    """Replay the script at the site's captions-only timing, with the script's presses standing in for the visitor's,
    then wait out the session gap so the meeting ends on its own. Returns the run and what happened, turn by turn."""
    script = script or load_script()
    run = SimRun(script, keep_frames=keep_frames)
    T = script["timing"]
    queue, starts, t = [], [], T["start_s"]           # (time, order at equal times, action, data)
    for i, line in enumerate(script["turns"]):
        dur = line_seconds(line["v"], T)
        starts.append((t, dur))
        queue += [(t + k * T["vad_s"], 1, "vad", i) for k in range(int(dur // T["vad_s"]) + 1) if k * T["vad_s"] < dur]
        queue.append((t + dur, 2, "hear", (i, t)))
        t += dur + T["gap_s"]
    for p in script.get("presses", []) if presses else []:
        t0, dur = starts[p["after_turn"]]
        queue.append((t0 + dur + p.get("wait_s", 0.0), 0, "press", p))
    turns, presses_done = [], []
    queue.sort(key=lambda a: (a[0], a[1]))
    for at, _, kind, x in queue:
        run.advance(at)
        if kind == "vad":
            run.speaking(script["turns"][x]["s"])
        elif kind == "hear":
            i, t0 = x
            line = script["turns"][i]
            r = run.hear(line["s"], line["v"], t0, line)
            s = run.d.g.session
            turns.append({"i": i, "t0": t0, "t1": run.now(), "s": line["s"], **r,
                          "requested": s.voices[line["s"]].requested if line["s"] in s.voices else None,
                          "levels": {v.label: s.applied(v.label) for v in s.voices.values()},
                          "room_off": run.d.g.room_off, "provisional": s.provisional is not None,
                          "segment": run.d.last_segment, "frame": list(run.d.last_frame), "chain": run.chain()})
        else:
            g = x["gesture"]
            if g == "press":
                run.press(0.12)
            elif g == "double":
                run.press(0.10)
                run.press(0.10, at=run.now() + 0.30)
            elif g == "hold":
                run.press(x.get("held_s", 2.5))
            presses_done.append({"t": run.now(), **x})
    last = run.d.g.session.last_heard
    tag_before = run.tag()
    gap_end = last + run.d.g.profile.session_gap
    run.advance(gap_end + 5.0)                       # the silence after the meeting: the gap ends it, the tag write and the buzz finish
    return {"run": run, "turns": turns, "presses": presses_done, "last_heard": last, "gap_end": gap_end,
            "tag_before": tag_before}
