package game

import (
	"context"
	"time"

	"go.uber.org/zap"

	"scrabble/backend/internal/account"
)

// effectiveDeadline is the instant a turn auto-resigns. It is the raw deadline
// (turn start plus the per-game timeout) unless that instant falls inside the
// acting player's away window (a daily local-time interval in loc), in which case
// it is pushed to the end of that window — so a player is never timed out while
// asleep. awayStartMin and awayEndMin are minutes since local midnight; an empty
// window (start == end) disables the grace. The function is pure and total.
func effectiveDeadline(turnStartedAt time.Time, timeout time.Duration, loc *time.Location, awayStartMin, awayEndMin int) time.Time {
	raw := turnStartedAt.Add(timeout)
	if awayStartMin == awayEndMin {
		return raw
	}
	local := raw.In(loc)
	dlMin := local.Hour()*60 + local.Minute()
	in, endToday := inAwayWindow(dlMin, awayStartMin, awayEndMin)
	if !in {
		return raw
	}
	y, m, d := local.Date()
	end := time.Date(y, m, d, awayEndMin/60, awayEndMin%60, 0, 0, loc)
	if !endToday {
		end = end.AddDate(0, 0, 1)
	}
	return end
}

// inAwayWindow reports whether the minute-of-day dlMin lies inside the window
// [start, end) (which may wrap past midnight) and whether the window's end falls
// on the same local day as dlMin.
func inAwayWindow(dlMin, start, end int) (in, endToday bool) {
	if start < end {
		inside := dlMin >= start && dlMin < end
		return inside, inside // a non-wrapping window always ends the same day
	}
	// Wraps past midnight: [start, 1440) on the evening side, [0, end) on the
	// morning side.
	switch {
	case dlMin >= start:
		return true, false // evening: the window ends the next local day
	case dlMin < end:
		return true, true // morning: the window ends later today
	default:
		return false, false
	}
}

// minutesOfDay returns a time-of-day value's minutes since midnight.
func minutesOfDay(t time.Time) int {
	return t.Hour()*60 + t.Minute()
}

// loadLocation resolves a stored timezone (an IANA name or a "±HH:MM" offset),
// falling back to UTC when it is empty or unknown (so a bad profile value never
// breaks the sweeper). It defers to account.ResolveZone, the single source of truth.
func loadLocation(name string) *time.Location {
	return account.ResolveZone(name)
}

// SweepTimeouts auto-resigns every active game whose current turn has exceeded
// its effective deadline as of now. It cheaply filters games past the raw
// deadline, then defers to timeoutGame, which confirms the away-window-adjusted
// deadline under the per-game lock. It returns the number of games timed out; a
// per-game failure is logged and skipped so one bad game does not stall the
// sweep.
func (svc *Service) SweepTimeouts(ctx context.Context, now time.Time) (int, error) {
	games, err := svc.store.ActiveGames(ctx)
	if err != nil {
		return 0, err
	}
	var timedOut int
	for _, ag := range games {
		if now.Before(ag.turnStartedAt.Add(time.Duration(ag.turnTimeoutSecs) * time.Second)) {
			continue // not even past the raw deadline
		}
		did, err := svc.timeoutGame(ctx, ag.gameID, now)
		if err != nil {
			svc.log.Warn("timeout sweep", zap.String("game", ag.gameID.String()), zap.Error(err))
			continue
		}
		if did {
			timedOut++
		}
	}
	return timedOut, nil
}

// RunSweeper drives SweepTimeouts and evicts idle games from the cache on each
// tick until ctx is cancelled. It is started once from main.
func (svc *Service) RunSweeper(ctx context.Context, interval time.Duration) {
	ticker := time.NewTicker(interval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			if n, err := svc.SweepTimeouts(ctx, svc.clock()); err != nil {
				svc.log.Warn("timeout sweep failed", zap.Error(err))
			} else if n > 0 {
				svc.log.Info("timed out games", zap.Int("count", n))
			}
			svc.cache.sweep()
		}
	}
}
