#!/usr/bin/env python3
"""ft-eyes-record: record a live session from the shared frames, so it can be replayed and
scored later (ft-eyes-score, ft-eyes-e2e), without root and alongside ft-eyes.

Reads /dev/shm/frametop-eyes-cams (ft-eyegrab, frametop-eyegrab.service) and writes every
frame of both cameras to a new recording NAME in eyes_lab.CAPTURES: frames.raw, index.txt
("<n> <slot> <camera> <time>", as ft-eyegrab --rec), and clocks.txt (wall clock and
CLOCK_MONOTONIC_RAW, read together). It touches /dev/shm/frametop-eyes-want every second, so
ft-eyegrab copies frames even without ft-eyes. About 2 GB a minute. Stops after SECONDS
(default 900), on Ctrl-C or SIGTERM, or when the disk gets below MIN_FREE_GB. The probe's
clicks are added later, on the Frame: `lab/py lab/ft-eyes-score --clicks NAME`.

Usage: lab/ft-eyes-record NAME [SECONDS]   (host Python is enough: no numpy)
"""
import mmap
import os
import shutil
import signal
import struct
import sys
import time
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from eyes_lab import new_capture  # noqa: E402

CAMS = os.environ.get("FT_EYES_CAMS", "/dev/shm/frametop-eyes-cams")
WANT = "/dev/shm/frametop-eyes-want"
W, H = 512, 400
MIN_FREE_GB = 20


class Share:
    """The shared frames. Layout: ft-eyegrab.c, share_head_t/share_entry_t."""

    def __init__(self):
        fd = os.open(CAMS, os.O_RDONLY)
        try:
            self.ino = os.fstat(fd).st_ino
            self.mm = mmap.mmap(fd, 0, prot=mmap.PROT_READ)
        finally:
            os.close(fd)
        magic, version, w, h, self.slots, self.esize = struct.unpack_from("<6I", self.mm, 0)
        if magic != 0x31434546 or version != 1 or (w, h) != (W, H):
            raise RuntimeError(f"{CAMS}: unexpected header")

    def replaced(self):
        try:
            return os.stat(CAMS).st_ino != self.ino
        except OSError:
            return True

    def count(self, cam):
        return struct.unpack_from("<Q", self.mm, 24 + 8 * cam)[0]

    def frame(self, cam, n):
        """(time, slot, bytes) of frame n of a camera, or None if it was overwritten."""
        off = 64 + (cam * self.slots + n % self.slots) * self.esize
        for _ in range(3):
            seq = struct.unpack_from("<Q", self.mm, off)[0]
            if seq & 1:
                continue
            data = self.mm[off + 64:off + 64 + W * H]
            t, got, _cam, slot = struct.unpack_from("<dQII", self.mm, off + 8)
            if struct.unpack_from("<Q", self.mm, off)[0] == seq and got == n:
                return t, slot, data
        return None


def touch_want():
    """Tell ft-eyegrab someone wants frames (it idles otherwise)."""
    if "FT_EYES_CAMS" in os.environ:
        return
    try:
        fd = os.open(WANT, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600)
        os.utime(fd)
        os.close(fd)
    except OSError:
        pass


def open_share(deadline):
    while time.monotonic() < deadline:
        touch_want()
        try:
            return Share()
        except (OSError, ValueError, RuntimeError):
            time.sleep(0.5)
    sys.exit(f"ft-eyes-record: no {CAMS} (is frametop-eyegrab.service running? gaze/tracker/install.sh)")


def main(argv):
    if not argv or argv[0].startswith("-"):
        sys.exit(__doc__)
    secs = float(argv[1]) if len(argv) > 1 else 900.0
    out = new_capture(argv[0])
    stop = []
    for sig in (signal.SIGINT, signal.SIGTERM):
        signal.signal(sig, lambda *_: stop.append(1))
    share = open_share(time.monotonic() + 10)
    (out / "clocks.txt").write_text(f"{time.time()} {time.clock_gettime(time.CLOCK_MONOTONIC_RAW)}\n")
    seen = [share.count(0), share.count(1)]
    written = dropped = 0
    end = time.monotonic() + secs
    check = touched = time.monotonic()
    with open(out / "frames.raw", "wb") as frames, open(out / "index.txt", "w") as index:
        while not stop and time.monotonic() < end:
            new = []
            for c in (0, 1):
                n = share.count(c)
                if n - seen[c] > share.slots:  # fell behind: those frames are gone
                    dropped += n - seen[c] - share.slots
                    seen[c] = n - share.slots
                for i in range(seen[c], n):
                    f = share.frame(c, i)
                    if f is None:
                        dropped += 1
                    else:
                        new.append((f[0], f[1], c, f[2]))
                seen[c] = n
            for t, slot, c, data in sorted(new, key=lambda f: f[0]):
                frames.write(data)
                index.write(f"{written} {slot} {c} {t:.6f}\n")
                written += 1
            if not new:
                time.sleep(0.002)
            if time.monotonic() - touched > 1:
                touched = time.monotonic()
                touch_want()
            if time.monotonic() - check > 5:
                check = time.monotonic()
                if shutil.disk_usage(out).free < MIN_FREE_GB * 1e9:
                    print(f"ft-eyes-record: under {MIN_FREE_GB} GB free, stopping", file=sys.stderr)
                    break
                if share.replaced():
                    print("ft-eyes-record: the frame share was restarted; following it", file=sys.stderr)
                    share = open_share(time.monotonic() + 10)
                    seen = [share.count(0), share.count(1)]
    print(f"ft-eyes-record: {written} frames ({dropped} dropped) in {out}", file=sys.stderr)


if __name__ == "__main__":
    main(sys.argv[1:])
