// Package lobby owns the platform-side game lifecycle of the Galaxy
// `backend` service. It implements the substage 5.4 surface documented in
// `backend/PLAN.md` §5.4 and `backend/README.md`:
//
// - Games CRUD with the enrollment/start/finish state machine.
// - Applications, invites, and memberships with their lifecycles.
// - Race Name Directory: registered, reservation, pending_registration
// tiers with platform-wide canonical-key uniqueness.
// - User-blocked and user-deleted cascades wired into `internal/user`
// through the `LobbyCascade` interface.
// - Inbound runtime hooks (`OnRuntimeSnapshot`, `OnGameFinished`) called
// by `internal/runtime` once The implementation lands.
// - A periodic sweeper goroutine that releases expired
// `pending_registration` rows and auto-closes enrollment-expired
// games.
//
// Stages 5.5 / 5.7 inject the real RuntimeGateway and
// NotificationPublisher; until then `NewNoopRuntimeGateway` and
// `NewNoopNotificationPublisher` keep the package callable end-to-end.
package lobby

import (
	"crypto/rand"
	"encoding/hex"
	"errors"
	"fmt"
	"time"

	"galaxy/backend/internal/config"

	"github.com/jackc/pgx/v5/pgconn"
	"go.uber.org/zap"
)

// pgErrCodeUniqueViolation is the SQLSTATE value Postgres emits on a
// UNIQUE constraint violation. Duplicated from `internal/user` and
// `internal/admin` so the lobby package does not import either.
const pgErrCodeUniqueViolation = "23505"

// pgErrCodeCheckViolation is the SQLSTATE value Postgres emits when a
// CHECK constraint rejects a row. Used to map invalid status writes to
// ErrInvalidInput at the boundary.
const pgErrCodeCheckViolation = "23514"

// inviteCodeBytes is the half-byte length of a generated invite code.
// Each byte yields two hex characters, so the wire string is 16 chars.
const inviteCodeBytes = 8

// Visibility values stored verbatim in `games.visibility`.
const (
	VisibilityPublic = "public"
	VisibilityPrivate = "private"
)

// Game status vocabulary mirrors `games_status_chk` in
// `backend/internal/postgres/migrations/00001_init.sql`.
const (
	GameStatusDraft = "draft"
	GameStatusEnrollmentOpen = "enrollment_open"
	GameStatusReadyToStart = "ready_to_start"
	GameStatusStarting = "starting"
	GameStatusStartFailed = "start_failed"
	GameStatusRunning = "running"
	GameStatusPaused = "paused"
	GameStatusFinished = "finished"
	GameStatusCancelled = "cancelled"
)

// Application status vocabulary mirrors `applications_status_chk`.
const (
	ApplicationStatusPending = "pending"
	ApplicationStatusApproved = "approved"
	ApplicationStatusRejected = "rejected"
)

// Invite status vocabulary mirrors `invites_status_chk`.
const (
	InviteStatusPending = "pending"
	InviteStatusRedeemed = "redeemed"
	InviteStatusDeclined = "declined"
	InviteStatusRevoked = "revoked"
	InviteStatusExpired = "expired"
)

// Membership status vocabulary mirrors `memberships_status_chk`.
const (
	MembershipStatusActive = "active"
	MembershipStatusRemoved = "removed"
	MembershipStatusBlocked = "blocked"
)

// Race-name status vocabulary mirrors `race_names_status_chk`.
const (
	RaceNameStatusRegistered = "registered"
	RaceNameStatusReservation = "reservation"
	RaceNameStatusPendingRegistration = "pending_registration"
)

// Notification kinds emitted by lobby. Mirrors
// `backend/README.md` §10, where the channel mapping is documented.
const (
	NotificationLobbyInviteReceived = "lobby.invite.received"
	NotificationLobbyInviteRevoked = "lobby.invite.revoked"
	NotificationLobbyApplicationSubmitted = "lobby.application.submitted"
	NotificationLobbyApplicationApproved = "lobby.application.approved"
	NotificationLobbyApplicationRejected = "lobby.application.rejected"
	NotificationLobbyMembershipRemoved = "lobby.membership.removed"
	NotificationLobbyMembershipBlocked = "lobby.membership.blocked"
	NotificationLobbyRaceNameRegistered = "lobby.race_name.registered"
	NotificationLobbyRaceNamePending = "lobby.race_name.pending"
	NotificationLobbyRaceNameExpired = "lobby.race_name.expired"
	NotificationGameTurnReady = "game.turn.ready"
	NotificationGamePaused = "game.paused"
)

// Deps aggregates every collaborator the lobby Service depends on.
//
// Store and Cache are required. Logger and Now default to zap.NewNop /
// time.Now when nil. Runtime, Notification, Entitlement and Policy fall
// back to safe defaults (no-op publishers and a default-locale Policy)
// so unit tests can construct a Service with only Store + Cache populated.
type Deps struct {
	Store *Store
	Cache *Cache
	Runtime RuntimeGateway
	Notification NotificationPublisher
	Diplomail DiplomailPublisher
	Entitlement EntitlementProvider
	Policy *Policy
	Config config.LobbyConfig
	Logger *zap.Logger
	Now func() time.Time
}

// Service is the lobby-domain entry point. Every public method is
// goroutine-safe; concurrency safety is delegated to Postgres for
// persisted state and to `*Cache` for the in-memory projection.
type Service struct {
	deps Deps
}

// NewService constructs a Service from deps. Logger and Now are
// defaulted; Store and Cache must be non-nil — calling any method with
// a nil Store/Cache will panic at first use (matching how main.go
// signals missing wiring).
func NewService(deps Deps) (*Service, error) {
	if deps.Logger == nil {
		deps.Logger = zap.NewNop()
	}
	deps.Logger = deps.Logger.Named("lobby")
	if deps.Now == nil {
		deps.Now = time.Now
	}
	if deps.Runtime == nil {
		deps.Runtime = NewNoopRuntimeGateway(deps.Logger)
	}
	if deps.Notification == nil {
		deps.Notification = NewNoopNotificationPublisher(deps.Logger)
	}
	if deps.Diplomail == nil {
		deps.Diplomail = NewNoopDiplomailPublisher(deps.Logger)
	}
	if deps.Policy == nil {
		policy, err := NewPolicy()
		if err != nil {
			return nil, fmt.Errorf("lobby: build default race-name policy: %w", err)
		}
		deps.Policy = policy
	}
	if deps.Config.SweeperInterval <= 0 {
		deps.Config.SweeperInterval = 60 * time.Second
	}
	if deps.Config.PendingRegistrationTTL <= 0 {
		deps.Config.PendingRegistrationTTL = 30 * 24 * time.Hour
	}
	if deps.Config.InviteDefaultTTL <= 0 {
		deps.Config.InviteDefaultTTL = 7 * 24 * time.Hour
	}
	return &Service{deps: deps}, nil
}

// Logger exposes the named logger used by the service. Mainly useful for
// tests asserting on log output.
func (s *Service) Logger() *zap.Logger {
	if s == nil {
		return zap.NewNop()
	}
	return s.deps.Logger
}

// Cache returns the in-memory projection. Used by main.go for the
// readiness probe and by tests.
func (s *Service) Cache() *Cache {
	if s == nil {
		return nil
	}
	return s.deps.Cache
}

// Config returns the lobby-side runtime configuration. Used by the
// sweeper to read the tick interval and by tests to assert the
// pending-registration TTL.
func (s *Service) Config() config.LobbyConfig {
	if s == nil {
		return config.LobbyConfig{}
	}
	return s.deps.Config
}

// generateInviteCode produces an `inviteCodeBytes`-byte hex code used
// for code-based invites. The function uses `crypto/rand`; a failure to
// read entropy is propagated to the caller.
func generateInviteCode() (string, error) {
	buf := make([]byte, inviteCodeBytes)
	if _, err := rand.Read(buf); err != nil {
		return "", fmt.Errorf("lobby: generate invite code: %w", err)
	}
	return hex.EncodeToString(buf), nil
}

// isUniqueViolation reports whether err is a Postgres UNIQUE violation,
// optionally restricted to a specific constraint name. When
// constraintName is empty any UNIQUE violation matches.
func isUniqueViolation(err error, constraintName string) bool {
	var pgErr *pgconn.PgError
	if !errors.As(err, &pgErr) {
		return false
	}
	if pgErr.Code != pgErrCodeUniqueViolation {
		return false
	}
	if constraintName == "" {
		return true
	}
	return pgErr.ConstraintName == constraintName
}

// isCheckViolation reports whether err is a Postgres CHECK constraint
// violation, optionally restricted to a specific constraint name.
func isCheckViolation(err error, constraintName string) bool {
	var pgErr *pgconn.PgError
	if !errors.As(err, &pgErr) {
		return false
	}
	if pgErr.Code != pgErrCodeCheckViolation {
		return false
	}
	if constraintName == "" {
		return true
	}
	return pgErr.ConstraintName == constraintName
}
