package lobby

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

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

// IssueInviteInput is the parameter struct for Service.IssueInvite.
type IssueInviteInput struct {
	GameID         uuid.UUID
	InviterUserID  uuid.UUID
	InvitedUserID  *uuid.UUID
	RaceName       string
	ExpiresAt      *time.Time
}

// IssueInvite creates a new pending invite. When InvitedUserID is set
// the invite is user-bound; otherwise the service generates a hex code
// for code-based redemption. The game must be a private game owned by
// inviterUserID and in `enrollment_open` (or `draft`/`ready_to_start`).
func (s *Service) IssueInvite(ctx context.Context, in IssueInviteInput) (Invite, error) {
	game, err := s.GetGame(ctx, in.GameID)
	if err != nil {
		return Invite{}, err
	}
	if game.Visibility != VisibilityPrivate {
		return Invite{}, fmt.Errorf("%w: only private games accept invites", ErrConflict)
	}
	if err := s.checkOwner(game, &in.InviterUserID, false); err != nil {
		return Invite{}, err
	}
	switch game.Status {
	case GameStatusDraft, GameStatusEnrollmentOpen, GameStatusReadyToStart:
	default:
		return Invite{}, fmt.Errorf("%w: cannot issue invite while game is %q", ErrConflict, game.Status)
	}
	displayName := strings.TrimSpace(in.RaceName)
	if displayName != "" {
		validated, err := ValidateDisplayName(displayName)
		if err != nil {
			return Invite{}, err
		}
		displayName = validated
	}
	now := s.deps.Now().UTC()
	expires := now.Add(s.deps.Config.InviteDefaultTTL)
	if in.ExpiresAt != nil {
		expires = in.ExpiresAt.UTC()
	}
	if !expires.After(now) {
		return Invite{}, fmt.Errorf("%w: expires_at must be in the future", ErrInvalidInput)
	}
	var code string
	if in.InvitedUserID == nil {
		generated, err := generateInviteCode()
		if err != nil {
			return Invite{}, err
		}
		code = generated
	}
	invite, err := s.deps.Store.InsertInvite(ctx, inviteInsert{
		InviteID:      uuid.New(),
		GameID:        in.GameID,
		InviterUserID: in.InviterUserID,
		InvitedUserID: in.InvitedUserID,
		Code:          code,
		RaceName:      displayName,
		ExpiresAt:     expires,
	})
	if err != nil {
		return Invite{}, err
	}
	if in.InvitedUserID != nil {
		intent := LobbyNotification{
			Kind:           NotificationLobbyInviteReceived,
			IdempotencyKey: "invite-received:" + invite.InviteID.String(),
			Recipients:     []uuid.UUID{*in.InvitedUserID},
			Payload: map[string]any{
				"game_id":         game.GameID.String(),
				"inviter_user_id": in.InviterUserID.String(),
			},
		}
		if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
			s.deps.Logger.Warn("invite issued notification failed",
				zap.String("invite_id", invite.InviteID.String()),
				zap.Error(pubErr))
		}
	}
	return invite, nil
}

// RedeemInvite turns a pending invite into a membership for redeemerUserID.
// User-bound invites require the recipient to match
// `invited_user_id`; code-based invites accept any caller.
func (s *Service) RedeemInvite(ctx context.Context, redeemerUserID uuid.UUID, gameID, inviteID uuid.UUID) (Invite, error) {
	invite, err := s.deps.Store.LoadInvite(ctx, inviteID)
	if err != nil {
		return Invite{}, err
	}
	if invite.GameID != gameID {
		return Invite{}, ErrNotFound
	}
	if invite.Status != InviteStatusPending {
		return Invite{}, fmt.Errorf("%w: invite is %q", ErrConflict, invite.Status)
	}
	now := s.deps.Now().UTC()
	if !invite.ExpiresAt.After(now) {
		return Invite{}, fmt.Errorf("%w: invite expired at %s", ErrConflict, invite.ExpiresAt.UTC().Format(time.RFC3339))
	}
	if invite.InvitedUserID != nil && *invite.InvitedUserID != redeemerUserID {
		return Invite{}, fmt.Errorf("%w: invite is bound to a different user", ErrForbidden)
	}
	game, err := s.GetGame(ctx, gameID)
	if err != nil {
		return Invite{}, err
	}
	switch game.Status {
	case GameStatusDraft, GameStatusEnrollmentOpen, GameStatusReadyToStart:
	default:
		return Invite{}, fmt.Errorf("%w: cannot redeem invite while game is %q", ErrConflict, game.Status)
	}
	displayName := invite.RaceName
	if displayName == "" {
		return Invite{}, fmt.Errorf("%w: invite carries no race_name; ask issuer to re-issue", ErrInvalidInput)
	}
	canonical, err := s.deps.Policy.Canonical(displayName)
	if err != nil {
		return Invite{}, err
	}
	if err := s.assertRaceNameAvailable(ctx, canonical, redeemerUserID, gameID); err != nil {
		return Invite{}, err
	}
	if _, err := s.deps.Store.InsertRaceName(ctx, raceNameInsert{
		Name:        displayName,
		Canonical:   canonical,
		Status:      RaceNameStatusReservation,
		OwnerUserID: redeemerUserID,
		GameID:      gameID,
		ReservedAt:  &now,
	}); err != nil {
		return Invite{}, err
	}
	membership, err := s.deps.Store.InsertMembership(ctx, membershipInsert{
		MembershipID: uuid.New(),
		GameID:       gameID,
		UserID:       redeemerUserID,
		RaceName:     displayName,
		CanonicalKey: canonical,
	})
	if err != nil {
		_ = s.deps.Store.DeleteRaceName(ctx, canonical, gameID)
		return Invite{}, err
	}
	updated, err := s.deps.Store.UpdateInviteStatus(ctx, inviteID, InviteStatusRedeemed, now)
	if err != nil {
		return Invite{}, err
	}
	s.deps.Cache.PutMembership(membership)
	s.deps.Cache.PutRaceName(RaceNameEntry{
		Name:        displayName,
		Canonical:   canonical,
		Status:      RaceNameStatusReservation,
		OwnerUserID: redeemerUserID,
		GameID:      gameID,
		ReservedAt:  &now,
	})
	return updated, nil
}

// DeclineInvite transitions a pending recipient-bound invite to
// `declined`. Code-based invites cannot be declined (the code holder
// just never redeems them).
func (s *Service) DeclineInvite(ctx context.Context, callerUserID uuid.UUID, gameID, inviteID uuid.UUID) (Invite, error) {
	invite, err := s.deps.Store.LoadInvite(ctx, inviteID)
	if err != nil {
		return Invite{}, err
	}
	if invite.GameID != gameID {
		return Invite{}, ErrNotFound
	}
	if invite.InvitedUserID == nil {
		return Invite{}, fmt.Errorf("%w: code-based invites cannot be declined", ErrConflict)
	}
	if *invite.InvitedUserID != callerUserID {
		return Invite{}, fmt.Errorf("%w: caller is not the invite recipient", ErrForbidden)
	}
	if invite.Status != InviteStatusPending {
		return Invite{}, fmt.Errorf("%w: invite is %q", ErrConflict, invite.Status)
	}
	now := s.deps.Now().UTC()
	return s.deps.Store.UpdateInviteStatus(ctx, inviteID, InviteStatusDeclined, now)
}

// RevokeInvite transitions a pending invite to `revoked`. Only the
// inviter (or admin) may revoke.
func (s *Service) RevokeInvite(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID, inviteID uuid.UUID) (Invite, error) {
	invite, err := s.deps.Store.LoadInvite(ctx, inviteID)
	if err != nil {
		return Invite{}, err
	}
	if invite.GameID != gameID {
		return Invite{}, ErrNotFound
	}
	if !callerIsAdmin {
		if callerUserID == nil || invite.InviterUserID != *callerUserID {
			return Invite{}, fmt.Errorf("%w: caller is not the inviter", ErrForbidden)
		}
	}
	if invite.Status != InviteStatusPending {
		return Invite{}, fmt.Errorf("%w: invite is %q", ErrConflict, invite.Status)
	}
	now := s.deps.Now().UTC()
	updated, err := s.deps.Store.UpdateInviteStatus(ctx, inviteID, InviteStatusRevoked, now)
	if err != nil {
		return Invite{}, err
	}
	if invite.InvitedUserID != nil {
		intent := LobbyNotification{
			Kind:           NotificationLobbyInviteRevoked,
			IdempotencyKey: "invite-revoked:" + inviteID.String(),
			Recipients:     []uuid.UUID{*invite.InvitedUserID},
			Payload: map[string]any{
				"game_id": gameID.String(),
			},
		}
		if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
			s.deps.Logger.Warn("invite revoked notification failed",
				zap.String("invite_id", inviteID.String()),
				zap.Error(pubErr))
		}
	}
	return updated, nil
}

// ListMyInvites returns every invite where userID is the recipient.
func (s *Service) ListMyInvites(ctx context.Context, userID uuid.UUID) ([]Invite, error) {
	return s.deps.Store.ListMyInvites(ctx, userID)
}
