package web

import (
	"path/filepath"
	"testing"
	"time"

	"pete/internal/storage"
)

// fakeGame is a tableGame that records whether its clock ever fired. It lets the
// runtime tests exercise the lock discipline and the version guard without a real
// engine — the thing under test is the clock, not the cards.
type fakeGame struct {
	fired int
}

func (g *fakeGame) name() string { return "fake" }

func (g *fakeGame) timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error) {
	g.fired++
	// Act: clear the deadline and bump the hand, as a real settle would.
	return step{State: []byte(`{"acted":true}`), Phase: "handover", HandNo: 2, Deadline: 0}, seats, nil
}

func (g *fakeGame) stacks(state []byte) ([]int64, error) { return []int64{0, 0}, nil }

// clockTestServer stands up a Server with just the table machinery wired and a
// fresh DB. Enough to drive the clock, nothing else.
func clockTestServer(t *testing.T, g tableGame) *Server {
	t.Helper()
	// Reset the storage singleton onto a temp DB.
	if err := storage.Init(filepath.Join(t.TempDir(), "clock.db")); err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { storage.Close() })

	s := &Server{hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{g}}
	return s
}

func openClockTable(t *testing.T, id string, deadline int64) storage.Table {
	t.Helper()
	tbl := storage.Table{
		ID: id, Game: "fake", Tier: "1-2", State: []byte(`{}`),
		Seed1: 1, Seed2: 2, Phase: "betting", HandNo: 1, Deadline: deadline,
	}
	seats := []storage.Seat{{Seat: 0, MatrixUser: "@reala:parodia.dev", Name: "reala"}, {Seat: 1, Name: "bot"}}
	if err := storage.OpenTable(tbl, seats); err != nil {
		t.Fatal(err)
	}
	return tbl
}

func TestClock_ActsOnExpiredTable(t *testing.T) {
	g := &fakeGame{}
	s := clockTestServer(t, g)
	openClockTable(t, "t1", time.Now().Unix()-5) // already expired

	s.tickClock()

	if g.fired != 1 {
		t.Fatalf("clock should have fired once, got %d", g.fired)
	}
	after, _, err := storage.LoadTable("t1")
	if err != nil {
		t.Fatal(err)
	}
	if after.Phase != "handover" || after.Deadline != 0 {
		t.Errorf("table should have advanced: %+v", after)
	}
}

func TestClock_IgnoresFutureDeadlines(t *testing.T) {
	g := &fakeGame{}
	s := clockTestServer(t, g)
	openClockTable(t, "t1", time.Now().Unix()+60)

	s.tickClock()

	if g.fired != 0 {
		t.Fatalf("clock should not have fired on a future deadline, got %d", g.fired)
	}
}

// TestClock_VersionGuardStopsTheDoubleMove is the scenario the whole design turns
// on. A move lands in the same tick the clock's scan found the table expired. The
// move bumps the version; the clock, acting on its stale scan, must see the new
// version and step aside rather than acting a second time.
func TestClock_VersionGuardStopsTheDoubleMove(t *testing.T) {
	g := &fakeGame{}
	s := clockTestServer(t, g)
	tbl := openClockTable(t, "t1", time.Now().Unix()-5)

	// The scan saw version 0.
	due, err := storage.DueTables(time.Now().Unix())
	if err != nil {
		t.Fatal(err)
	}
	if len(due) != 1 {
		t.Fatalf("want 1 due table, got %d", len(due))
	}

	// A real move lands first, bumping the version and setting a fresh deadline for
	// the next player.
	tbl.State = []byte(`{"moved":true}`)
	tbl.Deadline = time.Now().Unix() + 30
	if err := storage.CommitTable(storage.TableCommit{Table: tbl}); err != nil {
		t.Fatal(err)
	}

	// Now the clock acts on its stale scan (version 0). It must not fire.
	s.runClockTable(due[0])

	if g.fired != 0 {
		t.Fatalf("the version guard should have stopped the clock, but it fired %d time(s)", g.fired)
	}
	after, _, _ := storage.LoadTable("t1")
	if string(after.State) != `{"moved":true}` {
		t.Errorf("the real move should stand, got %s", after.State)
	}
}

func TestClock_PublishesToWatchers(t *testing.T) {
	g := &fakeGame{}
	s := clockTestServer(t, g)
	openClockTable(t, "t1", time.Now().Unix()-5)

	ch, done := s.hub.subscribe("t1")
	defer done()

	s.tickClock()

	select {
	case f := <-ch:
		if f.Version == 0 {
			t.Errorf("frame should carry the bumped version, got %d", f.Version)
		}
	default:
		t.Fatal("a watcher should have received a frame after the clock acted")
	}
}
