package lobby

import (
	"context"
	"fmt"

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

// SubmitApplicationInput is the parameter struct for
// Service.SubmitApplication.
type SubmitApplicationInput struct {
	GameID          uuid.UUID
	ApplicantUserID uuid.UUID
	RaceName        string
}

// SubmitApplication creates a new application bound to (gameID,
// applicantUserID, raceName). The game must be `enrollment_open`. The
// race name is recorded for context but the per-game canonical
// reservation is created at approval time.
func (s *Service) SubmitApplication(ctx context.Context, in SubmitApplicationInput) (Application, error) {
	displayName, err := ValidateDisplayName(in.RaceName)
	if err != nil {
		return Application{}, err
	}
	game, err := s.GetGame(ctx, in.GameID)
	if err != nil {
		return Application{}, err
	}
	if game.Visibility != VisibilityPublic {
		return Application{}, fmt.Errorf("%w: only public games accept applications", ErrConflict)
	}
	if game.Status != GameStatusEnrollmentOpen {
		return Application{}, fmt.Errorf("%w: game is not in enrollment_open", ErrConflict)
	}
	app, err := s.deps.Store.InsertApplication(ctx, applicationInsert{
		ApplicationID:   uuid.New(),
		GameID:          in.GameID,
		ApplicantUserID: in.ApplicantUserID,
		RaceName:        displayName,
	})
	if err != nil {
		return Application{}, err
	}
	intent := LobbyNotification{
		Kind:           NotificationLobbyApplicationSubmitted,
		IdempotencyKey: "application:" + app.ApplicationID.String(),
		Payload: map[string]any{
			"game_id":        game.GameID.String(),
			"application_id": app.ApplicationID.String(),
		},
	}
	if game.OwnerUserID != nil {
		intent.Recipients = []uuid.UUID{*game.OwnerUserID}
	}
	if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
		// Notification failures never roll back the canonical write.
		s.deps.Logger.Warn("application submitted notification failed",
			zap.String("application_id", app.ApplicationID.String()),
			zap.Error(pubErr))
	}
	return app, nil
}

// ApproveApplication transitions a pending application to `approved`,
// creates the matching membership, and reserves the race-name canonical
// in the Race Name Directory.
func (s *Service) ApproveApplication(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID, applicationID uuid.UUID) (Application, error) {
	app, err := s.deps.Store.LoadApplication(ctx, applicationID)
	if err != nil {
		return Application{}, err
	}
	if app.GameID != gameID {
		return Application{}, ErrNotFound
	}
	game, err := s.GetGame(ctx, gameID)
	if err != nil {
		return Application{}, err
	}
	if err := s.checkGameAdminOrOwner(game, callerUserID, callerIsAdmin); err != nil {
		return Application{}, err
	}
	if app.Status != ApplicationStatusPending {
		return Application{}, fmt.Errorf("%w: application status is %q", ErrConflict, app.Status)
	}
	if game.Status != GameStatusEnrollmentOpen {
		return Application{}, fmt.Errorf("%w: game is not in enrollment_open", ErrConflict)
	}
	canonical, err := s.deps.Policy.Canonical(app.RaceName)
	if err != nil {
		return Application{}, err
	}
	if err := s.assertRaceNameAvailable(ctx, canonical, app.ApplicantUserID, gameID); err != nil {
		return Application{}, err
	}
	now := s.deps.Now().UTC()
	if _, err := s.deps.Store.InsertRaceName(ctx, raceNameInsert{
		Name:        app.RaceName,
		Canonical:   canonical,
		Status:      RaceNameStatusReservation,
		OwnerUserID: app.ApplicantUserID,
		GameID:      gameID,
		ReservedAt:  &now,
	}); err != nil {
		return Application{}, err
	}
	membership, err := s.deps.Store.InsertMembership(ctx, membershipInsert{
		MembershipID: uuid.New(),
		GameID:       gameID,
		UserID:       app.ApplicantUserID,
		RaceName:     app.RaceName,
		CanonicalKey: canonical,
	})
	if err != nil {
		// Best-effort cleanup of the race-name reservation if the
		// membership insert lost the race; the cascade still records
		// the rejection.
		_ = s.deps.Store.DeleteRaceName(ctx, canonical, gameID)
		return Application{}, err
	}
	updated, err := s.deps.Store.UpdateApplicationStatus(ctx, applicationID, ApplicationStatusApproved, now)
	if err != nil {
		return Application{}, err
	}
	s.deps.Cache.PutMembership(membership)
	s.deps.Cache.PutRaceName(RaceNameEntry{
		Name:        app.RaceName,
		Canonical:   canonical,
		Status:      RaceNameStatusReservation,
		OwnerUserID: app.ApplicantUserID,
		GameID:      gameID,
		ReservedAt:  &now,
	})
	intent := LobbyNotification{
		Kind:           NotificationLobbyApplicationApproved,
		IdempotencyKey: "application-approved:" + applicationID.String(),
		Recipients:     []uuid.UUID{app.ApplicantUserID},
		Payload: map[string]any{
			"game_id": gameID.String(),
		},
	}
	if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
		s.deps.Logger.Warn("application approved notification failed",
			zap.String("application_id", updated.ApplicationID.String()),
			zap.Error(pubErr))
	}
	return updated, nil
}

// RejectApplication transitions a pending application to `rejected`.
func (s *Service) RejectApplication(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID, applicationID uuid.UUID) (Application, error) {
	app, err := s.deps.Store.LoadApplication(ctx, applicationID)
	if err != nil {
		return Application{}, err
	}
	if app.GameID != gameID {
		return Application{}, ErrNotFound
	}
	game, err := s.GetGame(ctx, gameID)
	if err != nil {
		return Application{}, err
	}
	if err := s.checkGameAdminOrOwner(game, callerUserID, callerIsAdmin); err != nil {
		return Application{}, err
	}
	if app.Status != ApplicationStatusPending {
		return Application{}, fmt.Errorf("%w: application status is %q", ErrConflict, app.Status)
	}
	now := s.deps.Now().UTC()
	updated, err := s.deps.Store.UpdateApplicationStatus(ctx, applicationID, ApplicationStatusRejected, now)
	if err != nil {
		return Application{}, err
	}
	intent := LobbyNotification{
		Kind:           NotificationLobbyApplicationRejected,
		IdempotencyKey: "application-rejected:" + applicationID.String(),
		Recipients:     []uuid.UUID{app.ApplicantUserID},
		Payload: map[string]any{
			"game_id": gameID.String(),
		},
	}
	if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
		s.deps.Logger.Warn("application rejected notification failed",
			zap.String("application_id", updated.ApplicationID.String()),
			zap.Error(pubErr))
	}
	return updated, nil
}

// ListMyApplications returns every application owned by userID.
func (s *Service) ListMyApplications(ctx context.Context, userID uuid.UUID) ([]Application, error) {
	return s.deps.Store.ListMyApplications(ctx, userID)
}

// checkGameAdminOrOwner enforces that the caller is either an admin or
// (for private games) the owner. Public games admin-only — same rule as
// transition().
func (s *Service) checkGameAdminOrOwner(game GameRecord, callerUserID *uuid.UUID, callerIsAdmin bool) error {
	return s.checkOwner(game, callerUserID, callerIsAdmin)
}

// assertRaceNameAvailable returns nil when canonical is free for
// userID inside gameID. Free means: no `registered` / `reservation` /
// `pending_registration` owned by anyone else.
func (s *Service) assertRaceNameAvailable(ctx context.Context, canonical CanonicalKey, userID, gameID uuid.UUID) error {
	_ = gameID
	rows, err := s.deps.Store.FindRaceNameByCanonical(ctx, canonical)
	if err != nil {
		return err
	}
	for _, r := range rows {
		if r.OwnerUserID == userID {
			// Same user already binds this canonical — the per-game PK
			// handles same-game collisions, and a user is allowed to
			// hold the same canonical across multiple active games.
			continue
		}
		switch r.Status {
		case RaceNameStatusRegistered, RaceNameStatusReservation, RaceNameStatusPendingRegistration:
			return fmt.Errorf("%w: race name held by another user", ErrRaceNameTaken)
		}
	}
	return nil
}
