package lobby

import (
	"context"
	"math/rand/v2"
	"time"

	"github.com/google/uuid"
	"go.uber.org/zap"

	"scrabble/backend/internal/engine"
	"scrabble/backend/internal/game"
	"scrabble/backend/internal/gamelimits"
	"scrabble/backend/internal/notify"
)

// GameMatcher is the slice of the game domain the matchmaker drives: opening or
// joining an auto-match game, substituting a robot into one whose wait elapsed, and
// reading a player's view to enrich the opponent_joined event. game.Service satisfies
// it.
type GameMatcher interface {
	// OpenOrJoin joins the caller into another waiting player's open game or opens a fresh
	// one; exclude lists account IDs whose open game must never be joined (the caller's
	// per-user block set), so a block keeps the pair out of the same anonymous game.
	OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time, exclude []uuid.UUID) (game.Game, bool, error)
	AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, displayName string) (game.Game, bool, error)
	ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, error)
	InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
	// Create seats the given accounts in an active game at once; the AI-match path uses
	// it to start a game already seated with a robot (no open/wait phase).
	Create(ctx context.Context, params game.CreateParams) (game.Game, error)
}

// Matchmaker turns an auto-match enqueue into a real game the player enters at once:
// it opens a game with an empty opponent seat, or joins the caller into another
// player's waiting one. A background reaper substitutes a pooled robot for any open
// game whose wait window has elapsed, guaranteeing every game gets an opponent. All
// matchmaking state is the open games in the database, so it survives a restart; the
// Matchmaker holds only the wait policy and the live-event publisher, and is safe for
// concurrent use.
//
// Auto-match is anonymous, but it still consults per-user blocks: a player is never
// paired into a game whose waiting opponent they have blocked, or who has blocked them
// (either direction), so a block keeps two players apart everywhere.
type Matchmaker struct {
	games   GameMatcher
	robots  RobotProvider
	minWait time.Duration
	jitter  time.Duration
	clock   func() time.Time
	pub     notify.Publisher
	blocker Blocker
	log     *zap.Logger
}

// NewMatchmaker constructs a Matchmaker that opens auto-match games through games and,
// after a per-game wait of minWait plus a random jitter in [0, jitter), substitutes a
// pooled robot from robots when no human has joined.
func NewMatchmaker(games GameMatcher, robots RobotProvider, minWait, jitter time.Duration, log *zap.Logger) *Matchmaker {
	if log == nil {
		log = zap.NewNop()
	}
	return &Matchmaker{
		games:   games,
		robots:  robots,
		minWait: minWait,
		jitter:  jitter,
		clock:   func() time.Time { return time.Now().UTC() },
		pub:     notify.Nop{},
		log:     log,
	}
}

// SetNotifier installs the live-event publisher used to push opponent_joined to a
// waiting starter when a human or a robot takes the empty seat. It must be called
// during startup wiring, before the reaper runs; the default is notify.Nop (no live
// events).
func (m *Matchmaker) SetNotifier(p notify.Publisher) {
	if p != nil {
		m.pub = p
	}
}

// SetBlocker installs the per-user block source used to keep auto-match from pairing a
// player with someone they have a block with (either direction). It must be called
// during startup wiring; the default is no blocker, in which case auto-match pairs
// anonymously (no block exclusion).
func (m *Matchmaker) SetBlocker(b Blocker) {
	if b != nil {
		m.blocker = b
	}
}

// EnqueueResult is the outcome of an auto-match enqueue: the game the caller now plays
// in, and whether it already had an opponent (they joined a waiting game) rather than
// being freshly opened and still awaiting one.
type EnqueueResult struct {
	Matched bool
	Game    game.Game
}

// Enqueue resolves an auto-match request for accountID under variant and the per-turn
// word rule (multipleWords) into the game they enter immediately — a freshly opened
// game awaiting an opponent, or another player's open game they just joined. A
// re-enqueue while already waiting opens another game rather than returning the
// caller's own. When the caller joins an existing game, opponent_joined is pushed to
// that game's waiting starter.
func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) {
	// Exclude every account the caller has a block with (either direction) from the
	// candidate open games, so auto-match never pairs a blocked pair into one game.
	var exclude []uuid.UUID
	if m.blocker != nil {
		var err error
		exclude, err = m.blocker.BlockedWith(ctx, accountID)
		if err != nil {
			return EnqueueResult{}, err
		}
	}
	g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline(), exclude)
	if err != nil {
		return EnqueueResult{}, err
	}
	if joined {
		m.announceOpponent(ctx, g, accountID)
	}
	return EnqueueResult{Matched: joined, Game: g}, nil
}

// StartVsAI creates an honest-AI game for accountID against a pooled robot, returning
// the game the player enters at once. Unlike Enqueue it never opens or waits: it picks
// a robot for the variant, seats the human and the robot in a random order (seat 0
// moves first), and creates an active game flagged vs_ai. The flag makes the robot
// reply immediately and suppresses chat, nudge and statistics; the robot's first move
// (when it is seated first) follows on the game service's fast-move trigger.
func (m *Matchmaker) StartVsAI(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) {
	robotID, err := m.robots.Pick(variant)
	if err != nil {
		return EnqueueResult{}, err
	}
	params := aiMatchParams(variant, multipleWords)
	params.Seats = []uuid.UUID{accountID, robotID}
	if rand.IntN(2) == 1 {
		params.Seats = []uuid.UUID{robotID, accountID}
	}
	params.Kind = gamelimits.KindVsAI
	g, err := m.games.Create(ctx, params)
	if err != nil {
		return EnqueueResult{}, err
	}
	return EnqueueResult{Matched: true, Game: g}, nil
}

// RunReaper substitutes a robot for any open game past its wait window, scanning every
// interval until ctx is cancelled. It is started once from main.
func (m *Matchmaker) RunReaper(ctx context.Context, interval time.Duration) {
	ticker := time.NewTicker(interval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			m.Reap(ctx, m.clock())
		}
	}
}

// Reap substitutes a robot into every open game whose wait window elapsed by now and
// pushes opponent_joined to its starter. RunReaper calls it on a timer; it takes now
// explicitly so tests and ops can drive a single pass at a chosen instant. A game for
// which no robot is available is left for a later tick.
func (m *Matchmaker) Reap(ctx context.Context, now time.Time) {
	due, err := m.games.ExpiredOpen(ctx, now)
	if err != nil {
		m.log.Warn("scan open games", zap.Error(err))
		return
	}
	for _, og := range due {
		robotID, name, err := m.robots.PickNamed(og.Variant)
		if err != nil {
			m.log.Warn("robot substitution deferred", zap.Error(err))
			continue
		}
		g, attached, err := m.games.AttachRobot(ctx, og.ID, robotID, name)
		if err != nil {
			m.log.Warn("robot substitution failed", zap.String("game", og.ID.String()), zap.Error(err))
			continue
		}
		if !attached {
			continue // a human joined first between the scan and the substitution
		}
		m.announceOpponent(ctx, g, robotID)
	}
}

// announceOpponent pushes opponent_joined to the game's waiting starter — the seat
// that is not joinerID — so its client fills the opponent card and re-enables resign
// and chat in place. Routed by the game's language, like every game push.
func (m *Matchmaker) announceOpponent(ctx context.Context, g game.Game, joinerID uuid.UUID) {
	starter, ok := otherSeat(g, joinerID)
	if !ok {
		return
	}
	state, err := m.games.InitialState(ctx, g.ID, starter)
	if err != nil {
		m.log.Warn("opponent_joined initial state",
			zap.String("game", g.ID.String()), zap.String("account", starter.String()), zap.Error(err))
		return
	}
	intent := notify.OpponentJoined(starter, g.ID, state)
	m.pub.Publish(intent)
}

// openDeadline is when the reaper substitutes a robot for a game opened now: a fixed
// minimum wait plus a random jitter, so the substitution time varies per game.
func (m *Matchmaker) openDeadline() time.Time {
	d := m.minWait
	if m.jitter > 0 {
		d += rand.N(m.jitter)
	}
	return m.clock().Add(d)
}

// otherSeat returns the account at the seat that is not accountID — the open game's
// starter when accountID is the joiner — and false when no seat differs or it is still
// empty.
func otherSeat(g game.Game, accountID uuid.UUID) (uuid.UUID, bool) {
	for _, s := range g.Seats {
		if s.AccountID != accountID && s.AccountID != uuid.Nil {
			return s.AccountID, true
		}
	}
	return uuid.Nil, false
}

// autoMatchParams builds the create parameters for a two-player auto-match with the
// casual defaults; the game service assembles the seats and pins the bag seed.
func autoMatchParams(variant engine.Variant, multipleWords bool) game.CreateParams {
	return game.CreateParams{
		Variant:              variant,
		TurnTimeout:          game.DefaultTurnTimeout,
		HintsAllowed:         autoMatchHintsAllowed,
		HintsPerPlayer:       autoMatchHintsPerPlayer,
		MultipleWordsPerTurn: multipleWords,
	}
}

// aiMatchParams builds the create parameters for an honest-AI two-player game: the
// same casual auto-match defaults plus the vs_ai flag. The 7-day inactivity clock is
// applied by the game service for vs_ai games, so TurnTimeout is left zero; the caller
// fills Seats with the human and the picked robot.
func aiMatchParams(variant engine.Variant, multipleWords bool) game.CreateParams {
	return game.CreateParams{
		Variant:              variant,
		HintsAllowed:         autoMatchHintsAllowed,
		HintsPerPlayer:       autoMatchHintsPerPlayer,
		MultipleWordsPerTurn: multipleWords,
		VsAI:                 true,
	}
}
