#!/usr/bin/env python3
"""Watch current-game.json via Linux inotify (Bookworm kernel; no pip deps)."""

from __future__ import annotations

import ctypes
import errno
import os
import select
import struct
import sys
import threading
from typing import Callable

from current_game_ipc import (
    game_index_from_payload,
    read_current_game,
    resolve_current_game_path,
)

IN_MODIFY = 0x00000002
IN_CLOSE_WRITE = 0x00000008
IN_MOVED_FROM = 0x00000040
IN_MOVED_TO = 0x00000080
IN_CREATE = 0x00000100
IN_DELETE = 0x00000200

_DIR_MASK = IN_CREATE | IN_DELETE | IN_MODIFY | IN_CLOSE_WRITE | IN_MOVED_FROM | IN_MOVED_TO


def _libc() -> ctypes.CDLL:
    if sys.platform.startswith("linux"):
        return ctypes.CDLL("libc.so.6", use_errno=True)
    raise OSError("inotify requires Linux")


def _inotify_init() -> int:
    fd = _libc().inotify_init()
    if fd < 0:
        err = ctypes.get_errno()
        raise OSError(err, os.strerror(err), "inotify_init")
    return fd


def _inotify_add_watch(fd: int, path: str, mask: int) -> int:
    watch = _libc().inotify_add_watch(fd, path.encode("utf-8"), mask)
    if watch < 0:
        err = ctypes.get_errno()
        raise OSError(err, os.strerror(err), f"inotify_add_watch({path})")
    return watch


def _inotify_rm_watch(fd: int, wd: int) -> None:
    if _libc().inotify_rm_watch(fd, wd) < 0:
        err = ctypes.get_errno()
        if err != errno.EINVAL:
            raise OSError(err, os.strerror(err), "inotify_rm_watch")


def _iter_inotify_events(buf: bytes):
    offset = 0
    while offset + 16 <= len(buf):
        _wd, mask, _cookie, name_len = struct.unpack_from("iIII", buf, offset)
        offset += 16
        name = b""
        if name_len:
            name = buf[offset : offset + name_len].split(b"\x00", 1)[0]
            offset += name_len
        yield mask, name.decode("utf-8", errors="replace")


class CurrentGameWatcher:
    """Daemon thread: inotify on EtherGold dir; callback when game_index changes."""

    def __init__(
        self,
        on_game: Callable[[dict], None],
        *,
        request_path: str | None = None,
        poll_fallback_s: float = 0.5,
    ):
        self._on_game = on_game
        self._request_path = request_path or resolve_current_game_path()
        self._watch_dir = os.path.dirname(self._request_path) or "."
        self._basename = os.path.basename(self._request_path)
        self._poll_fallback_s = max(0.1, float(poll_fallback_s))
        self._stop = threading.Event()
        self._thread: threading.Thread | None = None
        self._last_index: int | None = None
        self._last_utc: str | None = None
        self._last_mtime = 0.0

    def start(self) -> None:
        if self._thread is not None:
            return
        try:
            self._last_mtime = os.path.getmtime(self._request_path)
        except OSError:
            self._last_mtime = 0.0
        payload = read_current_game(self._request_path)
        idx = game_index_from_payload(payload)
        if idx is not None:
            self._last_index = idx
        if payload and isinstance(payload.get("updated_utc"), str):
            self._last_utc = payload["updated_utc"]
        self._thread = threading.Thread(target=self._run, name="current-game-inotify", daemon=True)
        self._thread.start()

    def stop(self) -> None:
        self._stop.set()

    def _dispatch_if_new(self) -> None:
        payload = read_current_game(self._request_path)
        if not payload:
            return
        idx = game_index_from_payload(payload)
        if idx is None:
            return
        utc = payload.get("updated_utc") if isinstance(payload.get("updated_utc"), str) else None
        if idx == self._last_index and utc == self._last_utc:
            return
        self._last_index = idx
        self._last_utc = utc
        self._on_game(payload)

    def _poll_mtime_if_changed(self) -> None:
        try:
            mtime = os.path.getmtime(self._request_path)
        except OSError:
            return
        if mtime > self._last_mtime:
            self._last_mtime = mtime
            self._dispatch_if_new()

    def _event_targets_request(self, name: str) -> bool:
        if not name:
            return True
        if name == self._basename:
            return True
        return name.startswith(".current-game.") and name.endswith(".tmp")

    def _run(self) -> None:
        try:
            self._run_inotify()
        except OSError as exc:
            print(f"current-game watcher: inotify unavailable ({exc}); mtime poll only", flush=True)
            self._run_mtime_poll()

    def _run_inotify(self) -> None:
        os.makedirs(self._watch_dir, exist_ok=True)
        fd = _inotify_init()
        wd = _inotify_add_watch(fd, self._watch_dir, _DIR_MASK)
        print(
            f"current-game watcher: inotify+mtime on {self._watch_dir} -> {self._basename}",
            flush=True,
        )
        try:
            while not self._stop.is_set():
                ready, _, _ = select.select([fd], [], [], self._poll_fallback_s)
                self._poll_mtime_if_changed()
                if not ready:
                    continue
                buf = os.read(fd, 4096)
                for event_mask, name in _iter_inotify_events(buf):
                    if not self._event_targets_request(name):
                        continue
                    if event_mask & _DIR_MASK:
                        self._poll_mtime_if_changed()
        finally:
            try:
                _inotify_rm_watch(fd, wd)
            except OSError:
                pass
            os.close(fd)

    def _run_mtime_poll(self) -> None:
        print(
            f"current-game watcher: mtime poll on {self._request_path} "
            f"every {self._poll_fallback_s:.1f}s",
            flush=True,
        )
        while not self._stop.is_set():
            self._poll_mtime_if_changed()
            self._stop.wait(self._poll_fallback_s)
