package user

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

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

// SanctionCode values mirror the closed MVP set from
// `backend/README.md` and the CHECK constraint on
// `sanction_records`.
const (
	SanctionCodePermanentBlock = "permanent_block"
)

// ActiveSanction is the read-side projection of a row in
// `sanction_active` joined with the audit columns from the underlying
// `sanction_records` row. It mirrors the OpenAPI `ActiveSanction`
// schema.
type ActiveSanction struct {
	SanctionCode string
	Scope string
	ReasonCode string
	Actor ActorRef
	AppliedAt time.Time
	ExpiresAt *time.Time
}

// ApplySanctionInput carries the admin-supplied parameters of
// `POST /api/v1/admin/users/{user_id}/sanctions`.
type ApplySanctionInput struct {
	UserID uuid.UUID
	SanctionCode string
	Scope string
	ReasonCode string
	Actor ActorRef
	ExpiresAt *time.Time
}

// ApplySanction persists a fresh `sanction_records` row, upserts
// `sanction_active`, and — when sanction_code == "permanent_block" —
// flips `accounts.permanent_block = true` in the same transaction.
// After commit it revokes every active session for the user (if a
// SessionRevoker is wired) and fires the lobby on-user-blocked
// cascade.
//
// Errors from the post-commit cascade are joined and logged; they do
// not roll back the persisted sanction.
func (s *Service) ApplySanction(ctx context.Context, input ApplySanctionInput) (Account, error) {
	if input.UserID == uuid.Nil {
		return Account{}, ErrAccountNotFound
	}
	if err := validateSanctionCode(input.SanctionCode); err != nil {
		return Account{}, err
	}
	if err := input.Actor.Validate(); err != nil {
		return Account{}, err
	}
	if strings.TrimSpace(input.Scope) == "" {
		return Account{}, fmt.Errorf("%w: scope must be non-empty", ErrInvalidInput)
	}
	if strings.TrimSpace(input.ReasonCode) == "" {
		return Account{}, fmt.Errorf("%w: reason_code must be non-empty", ErrInvalidInput)
	}

	now := s.deps.Now().UTC()
	expiresAt := input.ExpiresAt
	if expiresAt != nil {
		t := expiresAt.UTC()
		expiresAt = &t
	}

	flipPermanent := input.SanctionCode == SanctionCodePermanentBlock
	if err := s.deps.Store.ApplySanctionTx(ctx, sanctionInsert{
		UserID:        input.UserID,
		SanctionCode:  input.SanctionCode,
		Scope:         input.Scope,
		ReasonCode:    input.ReasonCode,
		Actor:         input.Actor,
		AppliedAt:     now,
		ExpiresAt:     expiresAt,
		FlipPermanent: flipPermanent,
	}); err != nil {
		if errors.Is(err, ErrAccountNotFound) {
			return Account{}, err
		}
		return Account{}, fmt.Errorf("user apply sanction: %w", err)
	}

	if flipPermanent {
		if err := s.cascadePermanentBlock(ctx, input.UserID, input.Actor, input.ReasonCode); err != nil {
			s.deps.Logger.Warn("permanent-block cascade returned error",
				zap.String("user_id", input.UserID.String()),
				zap.Error(err),
			)
		}
	}
	return s.GetAccount(ctx, input.UserID)
}

func validateSanctionCode(code string) error {
	switch strings.TrimSpace(code) {
	case SanctionCodePermanentBlock:
		return nil
	default:
		return fmt.Errorf("%w: %q", ErrInvalidSanctionCode, code)
	}
}

// cascadePermanentBlock revokes every active session and fires the
// lobby on-user-blocked hook. Both calls are best-effort — they run
// after the database commit and only join errors for the caller to
// log.
func (s *Service) cascadePermanentBlock(ctx context.Context, userID uuid.UUID, actor ActorRef, reasonCode string) error {
	var joined error
	if s.deps.SessionRevoker != nil {
		revokeActor := SessionRevokeActor{
			Kind:   SessionRevokeActorAdminSanction,
			ID:     actor.ID,
			Reason: SanctionCodePermanentBlock + ":" + reasonCode,
		}
		if err := s.deps.SessionRevoker.RevokeAllForUser(ctx, userID, revokeActor); err != nil {
			joined = errors.Join(joined, fmt.Errorf("session revoke: %w", err))
		}
	}
	if s.deps.Lobby != nil {
		if err := s.deps.Lobby.OnUserBlocked(ctx, userID); err != nil {
			joined = errors.Join(joined, fmt.Errorf("lobby on-user-blocked: %w", err))
		}
	}
	return joined
}
