"""
nodelink.sync
~~~~~~~~~~~~~
State synchronization engine.
Handles delta sync, full snapshots, tick-rate based auto-broadcasting,
per-tick callbacks (for game logic), and shared server state.
"""

import asyncio
import logging
import time
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional

if TYPE_CHECKING:
    from .server import ServerState
    from .player import Player

logger = logging.getLogger("nodelink.sync")


class SyncEngine:
    """
    Manages state synchronization across all connected players.

    Runs an internal tick loop that broadcasts only changed state
    at a configurable rate (default 20 ticks/sec).

    Optional *tick_callback* is called every tick with the elapsed time
    (in seconds) so server-side game logic can run in sync.

    Optional *server_state* is a :class:`~nodelink.server.ServerState`
    whose changes are automatically appended to each sync payload under
    the ``"server"`` key.
    """

    def __init__(
        self,
        broadcast_fn: Callable,
        tick_rate: int = 20,
        delta_only: bool = True,
        tick_callback: Optional[Callable] = None,
        server_state: Optional[Any] = None,
    ):
        self._broadcast = broadcast_fn
        self.tick_rate = tick_rate
        self.delta_only = delta_only
        self._tick_callback = tick_callback
        self._server_state = server_state

        self._players: Dict[str, "Player"] = {}
        self._dirty: set = set()
        self._running = False
        self._task: Optional[asyncio.Task] = None
        self._sync_count: int = 0
        self._last_sync: float = 0.0

    def register(self, player: "Player") -> None:
        self._players[player.id] = player
        self._dirty.add(player.id)

    def unregister(self, player_id: str) -> None:
        self._players.pop(player_id, None)
        self._dirty.discard(player_id)

    def mark_dirty(self, player: "Player") -> None:
        """Flag a player's state for inclusion in the next sync."""
        self._dirty.add(player.id)

    def mark_all_dirty(self) -> None:
        self._dirty.update(self._players.keys())

    def start(self) -> None:
        if self._running:
            return
        self._running = True
        self._task = asyncio.ensure_future(self._sync_loop())
        logger.debug(f"Sync engine started at {self.tick_rate} ticks/sec")

    def stop(self) -> None:
        self._running = False
        if self._task:
            self._task.cancel()
            self._task = None

    async def _sync_loop(self) -> None:
        interval = 1.0 / self.tick_rate
        last_tick = time.monotonic()

        while self._running:
            start = time.monotonic()
            dt = start - last_tick
            last_tick = start

            try:
                # Fire tick handlers first so game logic runs before the sync
                if self._tick_callback:
                    result = self._tick_callback(dt)
                    if asyncio.iscoroutine(result):
                        await result

                await self._do_sync()
            except Exception as e:
                logger.error(f"Sync error: {e}", exc_info=True)

            elapsed = time.monotonic() - start
            await asyncio.sleep(max(0.0, interval - elapsed))

    async def _do_sync(
        self,
        force_server_state: bool = False,
        full_players: bool = False,
    ) -> None:
        # ── Player deltas ──────────────────────────────────────────────
        # full_players=True sends every player's complete state (used for
        # full-world snapshots).  Otherwise only changed players are sent
        # and each player's delta() is used when delta_only is set.
        if full_players:
            changed = dict(self._players)
        elif self.delta_only:
            changed = {
                pid: p for pid, p in self._players.items()
                if p.state_changed()
            }
        else:
            changed = dict(self._players)

        # ── Server state ───────────────────────────────────────────────
        server_payload: Optional[Dict] = None
        if self._server_state is not None:
            if force_server_state:
                # Full sync — send everything regardless of changes
                server_payload = dict(self._server_state._data)
            elif self._server_state._changed():
                server_payload = (
                    self._server_state._delta()
                    if self.delta_only
                    else dict(self._server_state._data)
                )

        # Nothing to broadcast
        if not changed and server_payload is None:
            return

        payload: Dict[str, Any] = {
            "t": time.time(),
            "players": {
                pid: (p.state if full_players else (p.delta() if self.delta_only else p.state))
                for pid, p in changed.items()
            },
        }
        if server_payload is not None:
            payload["server"] = server_payload

        await self._broadcast("__state_sync__", payload)

        for p in changed.values():
            p.mark_synced()
        self._dirty -= set(changed.keys())

        if server_payload is not None and self._server_state is not None:
            self._server_state._mark_synced()

        self._last_sync = time.time()
        self._sync_count += 1

    async def sync_now(self, full: bool = False) -> None:
        """Force an immediate sync outside the normal tick.

        Args:
            full: send complete state (players + server) instead of just
                  the delta.  Pass ``True`` when a new player joins so they
                  receive the full world snapshot.
        """
        self.mark_all_dirty()
        if full:
            await self._do_sync(force_server_state=True, full_players=True)
        else:
            await self._do_sync()

    @property
    def stats(self) -> dict:
        return {
            "tick_rate":       self.tick_rate,
            "delta_only":      self.delta_only,
            "players_tracked": len(self._players),
            "total_syncs":     self._sync_count,
            "last_sync":       self._last_sync,
        }
