package lobby

import (
	"context"
	"fmt"
	"slices"
	"strings"
	"time"

	"galaxy/cronutil"

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

// CreateGameInput is the parameter struct for Service.CreateGame.
type CreateGameInput struct {
	OwnerUserID *uuid.UUID
	Visibility string
	GameName string
	Description string
	MinPlayers int32
	MaxPlayers int32
	StartGapHours int32
	StartGapPlayers int32
	EnrollmentEndsAt time.Time
	TurnSchedule string
	TargetEngineVersion string
}

// Validate normalises the request and rejects malformed values. It is
// called by Service.CreateGame before any Postgres write.
func (in *CreateGameInput) Validate(now time.Time) error {
	in.GameName = strings.TrimSpace(in.GameName)
	in.TurnSchedule = strings.TrimSpace(in.TurnSchedule)
	in.TargetEngineVersion = strings.TrimSpace(in.TargetEngineVersion)
	if in.GameName == "" {
		return fmt.Errorf("%w: game_name must not be empty", ErrInvalidInput)
	}
	if in.Visibility != VisibilityPublic && in.Visibility != VisibilityPrivate {
		return fmt.Errorf("%w: visibility must be 'public' or 'private'", ErrInvalidInput)
	}
	if in.Visibility == VisibilityPrivate && in.OwnerUserID == nil {
		return fmt.Errorf("%w: private games require owner_user_id", ErrInvalidInput)
	}
	if in.Visibility == VisibilityPublic && in.OwnerUserID != nil {
		return fmt.Errorf("%w: public games must not carry an owner_user_id", ErrInvalidInput)
	}
	if in.MinPlayers <= 0 || in.MaxPlayers <= 0 {
		return fmt.Errorf("%w: min_players and max_players must be positive", ErrInvalidInput)
	}
	if in.MinPlayers > in.MaxPlayers {
		return fmt.Errorf("%w: min_players must not exceed max_players", ErrInvalidInput)
	}
	if in.StartGapHours < 0 || in.StartGapPlayers < 0 {
		return fmt.Errorf("%w: start_gap_hours and start_gap_players must be non-negative", ErrInvalidInput)
	}
	if in.EnrollmentEndsAt.Before(now) {
		return fmt.Errorf("%w: enrollment_ends_at must be in the future", ErrInvalidInput)
	}
	if in.TurnSchedule == "" {
		return fmt.Errorf("%w: turn_schedule must not be empty", ErrInvalidInput)
	}
	if _, err := cronutil.Parse(in.TurnSchedule); err != nil {
		return fmt.Errorf("%w: turn_schedule must parse as a five-field cron expression: %v", ErrInvalidInput, err)
	}
	if in.TargetEngineVersion == "" {
		return fmt.Errorf("%w: target_engine_version must not be empty", ErrInvalidInput)
	}
	return nil
}

// CreateGame persists a fresh `draft` game and returns it. The caller
// is responsible for setting OwnerUserID = nil (public games) or the
// authenticated user_id (private games).
func (s *Service) CreateGame(ctx context.Context, in CreateGameInput) (GameRecord, error) {
	now := s.deps.Now().UTC()
	if err := (&in).Validate(now); err != nil {
		return GameRecord{}, err
	}
	rec, err := s.deps.Store.InsertGame(ctx, gameInsert{
		GameID: uuid.New(),
		OwnerUserID: in.OwnerUserID,
		Visibility: in.Visibility,
		GameName: in.GameName,
		Description: in.Description,
		MinPlayers: in.MinPlayers,
		MaxPlayers: in.MaxPlayers,
		StartGapHours: in.StartGapHours,
		StartGapPlayers: in.StartGapPlayers,
		EnrollmentEndsAt: in.EnrollmentEndsAt.UTC(),
		TurnSchedule: in.TurnSchedule,
		TargetEngineVersion: in.TargetEngineVersion,
	})
	if err != nil {
		return GameRecord{}, err
	}
	s.deps.Cache.PutGame(rec)
	return rec, nil
}

// UpdateGameInput is the parameter struct for Service.UpdateGame. Nil
// pointers leave the corresponding column alone.
type UpdateGameInput struct {
	GameName *string
	Description *string
	EnrollmentEndsAt *time.Time
	TurnSchedule *string
	TargetEngineVersion *string
	MinPlayers *int32
	MaxPlayers *int32
	StartGapHours *int32
	StartGapPlayers *int32
}

// UpdateGame patches the supplied fields on a game. Only the owner of a
// private game (or admin via callerIsAdmin=true) can run this.
func (s *Service) UpdateGame(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID, in UpdateGameInput) (GameRecord, error) {
	game, err := s.GetGame(ctx, gameID)
	if err != nil {
		return GameRecord{}, err
	}
	if err := s.checkOwner(game, callerUserID, callerIsAdmin); err != nil {
		return GameRecord{}, err
	}
	now := s.deps.Now().UTC()
	patch := gameUpdate{
		Description: in.Description,
		MinPlayers: in.MinPlayers,
		MaxPlayers: in.MaxPlayers,
		StartGapHours: in.StartGapHours,
		StartGapPlayers: in.StartGapPlayers,
	}
	if in.GameName != nil {
		trimmed := strings.TrimSpace(*in.GameName)
		if trimmed == "" {
			return GameRecord{}, fmt.Errorf("%w: game_name must not be empty", ErrInvalidInput)
		}
		patch.GameName = &trimmed
	}
	if in.TurnSchedule != nil {
		trimmed := strings.TrimSpace(*in.TurnSchedule)
		if trimmed == "" {
			return GameRecord{}, fmt.Errorf("%w: turn_schedule must not be empty", ErrInvalidInput)
		}
		if _, err := cronutil.Parse(trimmed); err != nil {
			return GameRecord{}, fmt.Errorf("%w: turn_schedule must parse: %v", ErrInvalidInput, err)
		}
		patch.TurnSchedule = &trimmed
	}
	if in.TargetEngineVersion != nil {
		trimmed := strings.TrimSpace(*in.TargetEngineVersion)
		if trimmed == "" {
			return GameRecord{}, fmt.Errorf("%w: target_engine_version must not be empty", ErrInvalidInput)
		}
		patch.TargetEngineVersion = &trimmed
	}
	if in.EnrollmentEndsAt != nil {
		t := in.EnrollmentEndsAt.UTC()
		patch.EnrollmentEndsAt = &t
	}
	if patch.MinPlayers != nil && patch.MaxPlayers != nil && *patch.MinPlayers > *patch.MaxPlayers {
		return GameRecord{}, fmt.Errorf("%w: min_players must not exceed max_players", ErrInvalidInput)
	}
	if patch.MinPlayers != nil && patch.MaxPlayers == nil && *patch.MinPlayers > game.MaxPlayers {
		return GameRecord{}, fmt.Errorf("%w: min_players must not exceed max_players", ErrInvalidInput)
	}
	if patch.MaxPlayers != nil && patch.MinPlayers == nil && *patch.MaxPlayers < game.MinPlayers {
		return GameRecord{}, fmt.Errorf("%w: max_players must not be less than min_players", ErrInvalidInput)
	}
	updated, err := s.deps.Store.UpdateGame(ctx, gameID, patch, now)
	if err != nil {
		return GameRecord{}, err
	}
	s.deps.Cache.PutGame(updated)
	_ = now
	return updated, nil
}

// GetGame returns the game record for gameID. Cache-first; falls back
// to Postgres on miss.
func (s *Service) GetGame(ctx context.Context, gameID uuid.UUID) (GameRecord, error) {
	if rec, ok := s.deps.Cache.GetGame(gameID); ok {
		return rec, nil
	}
	rec, err := s.deps.Store.LoadGame(ctx, gameID)
	if err != nil {
		return GameRecord{}, err
	}
	s.deps.Cache.PutGame(rec)
	return rec, nil
}

// ListPublicGames returns the requested page of public games.
type GamePage struct {
	Items []GameRecord
	Page int
	PageSize int
	Total int
}

func (s *Service) ListPublicGames(ctx context.Context, page, pageSize int) (GamePage, error) {
	if page <= 0 {
		page = 1
	}
	if pageSize <= 0 {
		pageSize = 50
	}
	games, total, err := s.deps.Store.ListPublicGames(ctx, page, pageSize)
	if err != nil {
		return GamePage{}, err
	}
	return GamePage{Items: games, Page: page, PageSize: pageSize, Total: total}, nil
}

// ListAdminGames returns the requested page of every game (admin view).
func (s *Service) ListAdminGames(ctx context.Context, page, pageSize int) (GamePage, error) {
	if page <= 0 {
		page = 1
	}
	if pageSize <= 0 {
		pageSize = 50
	}
	games, total, err := s.deps.Store.ListAdminGames(ctx, page, pageSize)
	if err != nil {
		return GamePage{}, err
	}
	return GamePage{Items: games, Page: page, PageSize: pageSize, Total: total}, nil
}

// ListMyGames returns the games where the caller has an active
// membership.
func (s *Service) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameRecord, error) {
	return s.deps.Store.ListMyGames(ctx, userID)
}

// ListFinishedGamesBefore returns every game whose status is
// `finished` or `cancelled` and whose `finished_at` is strictly older
// than cutoff. The result walks the store through the admin-paged
// query with a 200-row batch size; the caller is expected to invoke
// this from rare admin workflows (diplomail bulk cleanup) rather
// than hot-path reads.
func (s *Service) ListFinishedGamesBefore(ctx context.Context, cutoff time.Time) ([]GameRecord, error) {
	const pageSize = 200
	page := 1
	var out []GameRecord
	for {
		batch, _, err := s.deps.Store.ListAdminGames(ctx, page, pageSize)
		if err != nil {
			return nil, fmt.Errorf("lobby: list finished games before %s: %w", cutoff, err)
		}
		if len(batch) == 0 {
			break
		}
		for _, g := range batch {
			if g.Status != GameStatusFinished && g.Status != GameStatusCancelled {
				continue
			}
			if g.FinishedAt == nil || !g.FinishedAt.Before(cutoff) {
				continue
			}
			out = append(out, g)
		}
		if len(batch) < pageSize {
			break
		}
		page++
	}
	return out, nil
}

// DeleteGame removes the game and every referencing row (memberships,
// applications, invites, runtime_records, player_mappings) via the
// `ON DELETE CASCADE` constraints declared in `00001_init.sql`.
// Idempotent: returns nil when no game matches.
//
// Phase 14 introduces this method for the dev-sandbox bootstrap so a
// terminal "Dev Sandbox" tile from a previous local-dev session can
// be scrubbed before a fresh game spawns. Production callers must
// stay on the regular cancel / finish lifecycle — `DeleteGame` is
// destructive and bypasses the cascade-notification machinery.
func (s *Service) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
	if err := s.deps.Store.DeleteGame(ctx, gameID); err != nil {
		return err
	}
	s.deps.Cache.RemoveGame(gameID)
	return nil
}

// State-machine transition handlers below take the same shape: load the
// game (cache or store), check owner, validate the current status, run
// the transition write, refresh the cache, optionally tell the runtime
// gateway, and return the updated record.

// OpenEnrollment moves a `draft` game to `enrollment_open`.
func (s *Service) OpenEnrollment(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID) (GameRecord, error) {
	return s.transition(ctx, callerUserID, callerIsAdmin, gameID, transitionRule{
		From: []string{GameStatusDraft},
		To: GameStatusEnrollmentOpen,
		Reason: "open enrollment",
		Notification: nil,
	})
}

// ReadyToStart moves an `enrollment_open` game to `ready_to_start`. The
// transition succeeds only when the game has at least `min_players`
// active memberships.
func (s *Service) ReadyToStart(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID) (GameRecord, error) {
	return s.transition(ctx, callerUserID, callerIsAdmin, gameID, transitionRule{
		From: []string{GameStatusEnrollmentOpen},
		To: GameStatusReadyToStart,
		Reason: "ready to start",
		Precondition: func(ctx context.Context, game GameRecord) error {
			active, err := s.deps.Store.CountActiveMemberships(ctx, game.GameID)
			if err != nil {
				return err
			}
			if int32(active) < game.MinPlayers {
				return fmt.Errorf("%w: approved_count (%d) must be >= min_players (%d)", ErrConflict, active, game.MinPlayers)
			}
			return nil
		},
	})
}

// Start kicks off the engine container; the lobby flips status to
// `starting` and asks RuntimeGateway. The implementation will transition the
// game to `running` via OnRuntimeSnapshot.
func (s *Service) Start(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID) (GameRecord, error) {
	return s.transition(ctx, callerUserID, callerIsAdmin, gameID, transitionRule{
		From: []string{GameStatusReadyToStart},
		To: GameStatusStarting,
		Reason: "start",
		PostCommit: func(ctx context.Context, game GameRecord) error {
			if err := s.deps.Runtime.StartGame(ctx, game.GameID); err != nil {
				return fmt.Errorf("runtime start: %w", err)
			}
			return nil
		},
	})
}

// Pause moves a `running` game to `paused`.
func (s *Service) Pause(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID) (GameRecord, error) {
	return s.transition(ctx, callerUserID, callerIsAdmin, gameID, transitionRule{
		From: []string{GameStatusRunning},
		To: GameStatusPaused,
		Reason: "pause",
		PostCommit: func(ctx context.Context, game GameRecord) error {
			return s.deps.Runtime.PauseGame(ctx, game.GameID)
		},
	})
}

// Resume moves a `paused` game back to `running`.
func (s *Service) Resume(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID) (GameRecord, error) {
	return s.transition(ctx, callerUserID, callerIsAdmin, gameID, transitionRule{
		From: []string{GameStatusPaused},
		To: GameStatusRunning,
		Reason: "resume",
		PostCommit: func(ctx context.Context, game GameRecord) error {
			return s.deps.Runtime.ResumeGame(ctx, game.GameID)
		},
	})
}

// Cancel moves any non-terminal game to `cancelled`. The runtime is
// asked to stop a running container if any.
func (s *Service) Cancel(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID) (GameRecord, error) {
	return s.transition(ctx, callerUserID, callerIsAdmin, gameID, transitionRule{
		From: []string{
			GameStatusDraft, GameStatusEnrollmentOpen, GameStatusReadyToStart,
			GameStatusStarting, GameStatusStartFailed, GameStatusRunning, GameStatusPaused,
		},
		To: GameStatusCancelled,
		Reason: "cancel",
		PostCommit: func(ctx context.Context, game GameRecord) error {
			switch game.Status {
			case GameStatusRunning, GameStatusPaused, GameStatusStarting:
				return s.deps.Runtime.StopGame(ctx, game.GameID)
			}
			return nil
		},
	})
}

// RetryStart moves a `start_failed` game back to `ready_to_start` so a
// subsequent /start call can re-attempt the runtime job.
func (s *Service) RetryStart(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID) (GameRecord, error) {
	return s.transition(ctx, callerUserID, callerIsAdmin, gameID, transitionRule{
		From: []string{GameStatusStartFailed},
		To: GameStatusReadyToStart,
		Reason: "retry start",
	})
}

// AdminForceStart moves any pre-running game to `starting`, bypassing
// the owner-only and min_players precondition checks.
func (s *Service) AdminForceStart(ctx context.Context, gameID uuid.UUID) (GameRecord, error) {
	return s.transition(ctx, nil, true, gameID, transitionRule{
		From: []string{
			GameStatusDraft, GameStatusEnrollmentOpen, GameStatusReadyToStart,
			GameStatusStartFailed,
		},
		To: GameStatusStarting,
		Reason: "admin force-start",
		PostCommit: func(ctx context.Context, game GameRecord) error {
			return s.deps.Runtime.StartGame(ctx, game.GameID)
		},
	})
}

// AdminForceStop moves a running/paused game to `cancelled`.
func (s *Service) AdminForceStop(ctx context.Context, gameID uuid.UUID) (GameRecord, error) {
	return s.transition(ctx, nil, true, gameID, transitionRule{
		From: []string{GameStatusRunning, GameStatusPaused, GameStatusStarting},
		To: GameStatusCancelled,
		Reason: "admin force-stop",
		PostCommit: func(ctx context.Context, game GameRecord) error {
			return s.deps.Runtime.StopGame(ctx, game.GameID)
		},
	})
}

// transitionRule captures the inputs to Service.transition so the
// per-handler code stays declarative. From is the set of statuses the
// transition accepts; To is the target status. Precondition runs
// before the write (e.g., approved_count >= min_players); PostCommit
// runs after a successful write/cache update (e.g., RuntimeGateway).
// Errors from PostCommit are joined into the returned error so the
// caller can decide whether to surface them; the canonical state
// remains the post-commit row.
type transitionRule struct {
	From []string
	To string
	Reason string
	Precondition func(ctx context.Context, game GameRecord) error
	PostCommit func(ctx context.Context, game GameRecord) error
	Notification *LobbyNotification
}

func (s *Service) transition(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID, rule transitionRule) (GameRecord, error) {
	game, err := s.GetGame(ctx, gameID)
	if err != nil {
		return GameRecord{}, err
	}
	if err := s.checkOwner(game, callerUserID, callerIsAdmin); err != nil {
		return GameRecord{}, err
	}
	if !slices.Contains(rule.From, game.Status) {
		return GameRecord{}, fmt.Errorf("%w: cannot %s game in status %q", ErrConflict, rule.Reason, game.Status)
	}
	if rule.Precondition != nil {
		if err := rule.Precondition(ctx, game); err != nil {
			return GameRecord{}, err
		}
	}
	now := s.deps.Now().UTC()
	upd := statusUpdate{NewStatus: rule.To, UpdatedAt: now}
	switch rule.To {
	case GameStatusRunning:
		if game.StartedAt == nil {
			upd.SetStarted = true
			upd.StartedAt = now
		}
	case GameStatusFinished:
		upd.SetFinished = true
		upd.FinishedAt = now
	}
	updated, err := s.deps.Store.UpdateGameStatus(ctx, gameID, upd)
	if err != nil {
		return GameRecord{}, err
	}
	s.deps.Cache.PutGame(updated)
	if rule.PostCommit != nil {
		if err := rule.PostCommit(ctx, updated); err != nil {
			return updated, fmt.Errorf("post-commit %s: %w", rule.Reason, err)
		}
	}
	s.emitGameLifecycleMail(ctx, updated, callerIsAdmin, rule)
	return updated, nil
}

// emitGameLifecycleMail asks the diplomail publisher to drop a
// system-mail entry whenever a state change is user-visible. Only
// the `paused` and `cancelled` transitions emit mail today (the spec
// names them explicitly); `running`/`finished`/etc. are signalled by
// other channels and do not need a durable inbox entry.
func (s *Service) emitGameLifecycleMail(ctx context.Context, game GameRecord, callerIsAdmin bool, rule transitionRule) {
	var kind string
	switch rule.To {
	case GameStatusPaused:
		kind = LifecycleKindGamePaused
	case GameStatusCancelled:
		kind = LifecycleKindGameCancelled
	default:
		return
	}
	actor := "the game owner"
	if callerIsAdmin {
		actor = "an administrator"
	}
	ev := LifecycleEvent{
		GameID: game.GameID,
		Kind:   kind,
		Actor:  actor,
		Reason: rule.Reason,
	}
	if err := s.deps.Diplomail.PublishLifecycle(ctx, ev); err != nil {
		s.deps.Logger.Warn("publish lifecycle mail failed",
			zap.String("game_id", game.GameID.String()),
			zap.String("kind", kind),
			zap.Error(err))
	}
}

// checkOwner enforces ownership semantics:
//
// - callerIsAdmin == true → always allowed (admin force-start, etc.).
// - private games → callerUserID must equal game.OwnerUserID.
// - public games → callerIsAdmin is required.
func (s *Service) checkOwner(game GameRecord, callerUserID *uuid.UUID, callerIsAdmin bool) error {
	if callerIsAdmin {
		return nil
	}
	if game.Visibility == VisibilityPublic {
		return fmt.Errorf("%w: public games require admin authority", ErrForbidden)
	}
	if callerUserID == nil || game.OwnerUserID == nil || *game.OwnerUserID != *callerUserID {
		return fmt.Errorf("%w: caller is not the owner", ErrForbidden)
	}
	return nil
}

