package web

import (
	"context"
	"encoding/json"
	"errors"
	"log/slog"
	"time"

	"pete/internal/storage"
)

// The turn clock: the first goroutine in Pete that has ever mutated game state.
//
// Every other background loop here reads, refills or prunes. This one plays. When
// a human sits at a felt and walks away mid-hand, three other people are waiting
// on a decision that is never coming, and something has to make it for them. That
// something is this.
//
// It is built on one discipline and one guard.
//
// The discipline is rule 1: **collect the due tables and close the rows before
// taking any lock.** The scan reads *sql.Rows from the one-connection pool; a lock
// taken while those rows are open would hold the connection the rows need, and the
// process would wedge. So DueTables returns a plain slice and the connection is
// free before the first table is touched.
//
// The guard is the version. The scan records each table's version; the clock acts
// on a table only if it is *still* that version by the time it holds the lock and
// has reloaded. Without that check the clock and a real move race and both land:
// Bob raises in the same second his clock expires, the action moves to Cara, and
// the clock — still believing the seat that ran out of time is to act — folds
// Cara, who had twenty-five seconds left. The version check turns that into a
// no-op: Bob's move bumped the version, the reload shows the new one, and the
// clock steps aside.

// clockInterval is how often the clock looks for expired turns. Sub-second
// precision buys nothing at a card table and would only spin the CPU.
const clockInterval = time.Second

// reaperInterval is how often idle sessions are cashed out. The reaper is a
// slow-moving safety net — a player who wandered off half an hour ago is not in a
// hurry — so it runs far less often than the clock.
const reaperInterval = time.Minute

// games returns the multiplayer engines by their storage key. It is the registry
// the clock and the handlers both dispatch through. Empty until an engine is
// wired; a clock over no games is a loop that finds nothing, which is correct.
func (s *Server) games() map[string]tableGame {
	out := make(map[string]tableGame)
	for _, g := range s.tableGames {
		out[g.name()] = g
	}
	return out
}

// StartTableClock launches the turn clock and the session reaper if the casino is
// on. Safe to call unconditionally.
func (s *Server) StartTableClock(ctx context.Context) {
	if !s.gamesReady() {
		return
	}
	// A deploy just took every table's clock down with it. Shove the live deadlines
	// out so the first tick does not auto-act the whole room at once.
	if err := storage.PushDeadlines(bootGrace); err != nil {
		slog.Error("games: push deadlines on boot", "err", err)
	}
	go s.runTableClock(ctx)
	go s.runSessionReaper(ctx)
	go s.runTableReaper(ctx)
}

func (s *Server) runTableClock(ctx context.Context) {
	slog.Info("games: turn clock started", "interval", clockInterval)
	ticker := time.NewTicker(clockInterval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			s.tickClock()
		}
	}
}

// tickClock finds the tables whose turn has expired and acts on each. The scan is
// done and the connection released before any table is locked — rule 1.
func (s *Server) tickClock() {
	due, err := storage.DueTables(time.Now().Unix())
	if err != nil {
		slog.Error("games: due tables", "err", err)
		return
	}
	for _, ref := range due {
		s.runClockTable(ref)
	}
}

// runClockTable acts for the walked-away player at one table, but only if the
// table is still at the version the scan saw.
//
// The whole read-modify-write is done under the table's stripe, which is what
// keeps the clock from racing a second copy of itself — but the stripe is only an
// optimisation. The version check inside CommitTable is the real thing: even
// across a redeploy, when two processes hold two different stripes over this row,
// the one whose write lands first bumps the version and the other's write finds
// zero rows and rolls back.
func (s *Server) runClockTable(ref storage.TableRef) {
	err := s.tableLocks.withTable(ref.ID, func() error {
		t, seats, err := storage.LoadTable(ref.ID)
		if errors.Is(err, storage.ErrNoSuchTable) {
			return nil // closed out from under us; nothing to do
		}
		if err != nil {
			return err
		}
		// Somebody moved between the scan and now. Their move set a fresh deadline (or
		// cleared it), so this expiry is stale — step aside.
		if t.Version != ref.Version {
			return nil
		}
		// A deadline in the future means the scan is looking at an old view; leave it.
		if t.Deadline == 0 || t.Deadline > time.Now().Unix() {
			return nil
		}

		game := s.games()[t.Game]
		if game == nil {
			slog.Error("games: clock over unknown game", "game", t.Game, "table", t.ID)
			return nil
		}

		st, newSeats, err := game.timeout(t.State, seats)
		if errors.Is(err, errNotDue) {
			return nil // the race resolved without us; nothing to act on
		}
		if err != nil {
			slog.Error("games: clock timeout", "table", t.ID, "err", err)
			return nil
		}

		t.State, t.Phase, t.HandNo, t.Deadline = st.State, st.Phase, st.HandNo, st.Deadline
		if err := storage.CommitTable(storage.TableCommit{Table: t, Seats: newSeats, Audit: st.Audit}); err != nil {
			if errors.Is(err, storage.ErrStaleTable) {
				return nil // lost the race after all; the winner handled it
			}
			return err
		}
		s.publishTable(ref.ID)
		return nil
	})
	if err != nil {
		slog.Error("games: clock table", "table", ref.ID, "err", err)
	}
}

// publishTable pushes the current table view to everyone watching it. It reads
// the table fresh (the authoritative state) and fans an opaque frame out through
// the hub. Called after every committed write, under no lock the hub cares about
// — the hub's sends are non-blocking, so this never stalls a caller.
//
// A view here is deliberately seat-blind: it carries only what every seat may see
// (the version and the public table shape), and each subscriber's own stream
// redacts and re-renders for the seat that is watching. That keeps a hole card
// from ever entering a frame that fans to the whole table.
func (s *Server) publishTable(tableID string) {
	if s.hub.watchers(tableID) == 0 {
		return
	}
	t, _, err := storage.LoadTable(tableID)
	if errors.Is(err, storage.ErrNoSuchTable) {
		return
	}
	if err != nil {
		slog.Error("games: publish table load", "table", tableID, "err", err)
		return
	}
	// The frame is just a nudge carrying the version: a subscriber that sees a gap
	// refetches the authoritative, per-seat table. So the payload can be minimal.
	// The type tells the browser a table changed (come and look) from a chat line
	// (render it in place) — both ride the one stream.
	data, _ := json.Marshal(map[string]any{"type": "table", "version": t.Version, "phase": t.Phase})
	s.hub.publish(tableID, hubFrame{Version: t.Version, Data: data})
}

// runSessionReaper cashes out players who wandered off, on a timer. This is the
// loop the plan noted never existed: ReapIdleSessions has always been safe to run
// and nothing ever ran it, so chips in abandoned *solo* sessions sat in limbo.
//
// A seated player is invisible to it — their chips are inside a table blob, not on
// their game_chips stack — so it only ever reaps a player who is genuinely idle
// with loose chips. Getting up from a table returns them to the stack, and then
// this is what eventually sends them home.
func (s *Server) runSessionReaper(ctx context.Context) {
	slog.Info("games: session reaper started", "interval", reaperInterval, "idle", storage.SessionIdleAfter)
	ticker := time.NewTicker(reaperInterval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			n, err := storage.ReapIdleSessions(storage.SessionIdleAfter)
			if err != nil {
				slog.Error("games: reap idle sessions", "err", err)
				continue
			}
			if n > 0 {
				slog.Info("games: reaped idle sessions", "count", n)
			}
		}
	}
}
