package usersignup

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

	"gorm.io/gorm"
	"gorm.io/gorm/clause"

	"github.com/pocket-id/pocket-id/backend/internal/common"
	"github.com/pocket-id/pocket-id/backend/internal/dto"
	"github.com/pocket-id/pocket-id/backend/internal/model"
	datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
	"github.com/pocket-id/pocket-id/backend/internal/utils"
)

// authenticationMethodOneTimePassword identifies one-time-password authentication, used for the initial admin setup token
// It must match the value emitted by the JWT service in the access token's "amr" claim
const authenticationMethodOneTimePassword = "otp"

type Service struct {
	db          *gorm.DB
	userCreator UserCreator
	signer      TokenService
	auditLog    AuditLogger
	appConfig   AppConfigProvider
}

func newService(deps Dependencies) *Service {
	return &Service{
		db:          deps.DB,
		userCreator: deps.UserCreator,
		signer:      deps.Signer,
		auditLog:    deps.AuditLog,
		appConfig:   deps.AppConfig,
	}
}

func (s *Service) SignUp(ctx context.Context, signupData signUpDto, ipAddress, userAgent string) (model.User, string, error) {
	tx := s.db.Begin()
	defer func() {
		tx.Rollback()
	}()

	tokenProvided := signupData.Token != ""

	config := s.appConfig.GetDbConfig()
	if config.AllowUserSignups.Value != "open" && !tokenProvided {
		return model.User{}, "", &common.OpenSignupDisabledError{}
	}

	var signupToken SignupToken
	var userGroupIDs []string
	if tokenProvided {
		err := tx.
			WithContext(ctx).
			Preload("UserGroups").
			Where("token = ?", signupData.Token).
			Clauses(clause.Locking{Strength: "UPDATE"}).
			First(&signupToken).
			Error
		if err != nil {
			if errors.Is(err, gorm.ErrRecordNotFound) {
				return model.User{}, "", &common.TokenInvalidOrExpiredError{}
			}
			return model.User{}, "", err
		}

		if !signupToken.IsValid() {
			return model.User{}, "", &common.TokenInvalidOrExpiredError{}
		}

		for _, group := range signupToken.UserGroups {
			userGroupIDs = append(userGroupIDs, group.ID)
		}
	}

	userToCreate := dto.UserCreateDto{
		Username:      signupData.Username,
		Email:         signupData.Email,
		FirstName:     signupData.FirstName,
		LastName:      signupData.LastName,
		DisplayName:   strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
		UserGroupIds:  userGroupIDs,
		EmailVerified: s.appConfig.GetDbConfig().EmailsVerified.IsTrue(),
	}

	user, err := s.userCreator.CreateUserInternal(ctx, userToCreate, false, tx)
	if err != nil {
		return model.User{}, "", err
	}

	accessToken, err := s.signer.GenerateAccessToken(user, "")
	if err != nil {
		return model.User{}, "", err
	}

	if tokenProvided {
		s.auditLog.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
			"signupToken": signupToken.Token,
		}, tx)

		signupToken.UsageCount++

		err = tx.WithContext(ctx).Save(&signupToken).Error
		if err != nil {
			return model.User{}, "", err
		}
	} else {
		s.auditLog.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
			"method": "open_signup",
		}, tx)
	}

	err = tx.Commit().Error
	if err != nil {
		return model.User{}, "", err
	}

	return user, accessToken, nil
}

func (s *Service) SignUpInitialAdmin(ctx context.Context, signUpData signUpDto) (model.User, string, error) {
	tx := s.db.Begin()
	defer func() {
		tx.Rollback()
	}()

	setupCompleted, err := s.isInitialAdminSetupCompleted(ctx, tx)
	if err != nil {
		return model.User{}, "", err
	}
	if setupCompleted {
		return model.User{}, "", &common.SetupNotAvailableError{}
	}

	userToCreate := dto.UserCreateDto{
		FirstName:   signUpData.FirstName,
		LastName:    signUpData.LastName,
		DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
		Username:    signUpData.Username,
		Email:       signUpData.Email,
		IsAdmin:     true,
	}

	user, err := s.userCreator.CreateUserInternal(ctx, userToCreate, false, tx)
	if err != nil {
		return model.User{}, "", err
	}

	token, err := s.signer.GenerateAccessToken(user, authenticationMethodOneTimePassword)
	if err != nil {
		return model.User{}, "", err
	}

	err = tx.Commit().Error
	if err != nil {
		return model.User{}, "", err
	}

	return user, token, nil
}

func (s *Service) IsInitialAdminSetupCompleted(ctx context.Context) (bool, error) {
	return s.isInitialAdminSetupCompleted(ctx, s.db)
}

func (s *Service) isInitialAdminSetupCompleted(ctx context.Context, db *gorm.DB) (bool, error) {
	var userCount int64
	if err := db.WithContext(ctx).Model(&model.User{}).
		Where("id != ?", common.StaticApiKeyUserID).
		Count(&userCount).Error; err != nil {
		return false, err
	}

	return userCount != 0, nil
}

func (s *Service) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]SignupToken, utils.PaginationResponse, error) {
	var tokens []SignupToken
	query := s.db.WithContext(ctx).Preload("UserGroups").Model(&SignupToken{})

	pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &tokens)
	return tokens, pagination, err
}

func (s *Service) DeleteSignupToken(ctx context.Context, tokenID string) error {
	return s.db.WithContext(ctx).Delete(&SignupToken{}, "id = ?", tokenID).Error
}

func (s *Service) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (SignupToken, error) {
	signupToken, err := newSignupToken(ttl, usageLimit)
	if err != nil {
		return SignupToken{}, err
	}

	var userGroups []model.UserGroup
	err = s.db.WithContext(ctx).
		Where("id IN ?", userGroupIDs).
		Find(&userGroups).
		Error
	if err != nil {
		return SignupToken{}, err
	}
	signupToken.UserGroups = userGroups

	err = s.db.WithContext(ctx).Create(signupToken).Error
	if err != nil {
		return SignupToken{}, err
	}

	return *signupToken, nil
}

func newSignupToken(ttl time.Duration, usageLimit int) (*SignupToken, error) {
	// Generate a random token
	randomString, err := utils.GenerateRandomAlphanumericString(16)
	if err != nil {
		return nil, err
	}

	now := time.Now().Round(time.Second)
	token := &SignupToken{
		Token:      randomString,
		ExpiresAt:  datatype.DateTime(now.Add(ttl)),
		UsageLimit: usageLimit,
		UsageCount: 0,
	}

	return token, nil
}
