package forms

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

	validation "github.com/go-ozzo/ozzo-validation/v4"
	"github.com/go-ozzo/ozzo-validation/v4/is"
	"github.com/pocketbase/pocketbase/core"
	"github.com/pocketbase/pocketbase/daos"
	"github.com/pocketbase/pocketbase/models"
	"github.com/pocketbase/pocketbase/tools/auth"
	"github.com/pocketbase/pocketbase/tools/security"
	"golang.org/x/oauth2"
)

// RecordOAuth2LoginData defines the OA
type RecordOAuth2LoginData struct {
	ExternalAuth *models.ExternalAuth
	Record       *models.Record
	OAuth2User   *auth.AuthUser
}

// BeforeOAuth2RecordCreateFunc defines a callback function that will
// be called before OAuth2 new Record creation.
type BeforeOAuth2RecordCreateFunc func(createForm *RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error

// RecordOAuth2Login is an auth record OAuth2 login form.
type RecordOAuth2Login struct {
	app        core.App
	dao        *daos.Dao
	collection *models.Collection

	beforeOAuth2RecordCreateFunc BeforeOAuth2RecordCreateFunc

	// Optional auth record that will be used if no external
	// auth relation is found (if it is from the same collection)
	loggedAuthRecord *models.Record

	// The name of the OAuth2 client provider (eg. "google")
	Provider string `form:"provider" json:"provider"`

	// The authorization code returned from the initial request.
	Code string `form:"code" json:"code"`

	// The code verifier sent with the initial request as part of the code_challenge.
	CodeVerifier string `form:"codeVerifier" json:"codeVerifier"`

	// The redirect url sent with the initial request.
	RedirectUrl string `form:"redirectUrl" json:"redirectUrl"`

	// Additional data that will be used for creating a new auth record
	// if an existing OAuth2 account doesn't exist.
	CreateData map[string]any `form:"createData" json:"createData"`
}

// NewRecordOAuth2Login creates a new [RecordOAuth2Login] form with
// initialized with from the provided [core.App] instance.
//
// If you want to submit the form as part of a transaction,
// you can change the default Dao via [SetDao()].
func NewRecordOAuth2Login(app core.App, collection *models.Collection, optAuthRecord *models.Record) *RecordOAuth2Login {
	form := &RecordOAuth2Login{
		app:              app,
		dao:              app.Dao(),
		collection:       collection,
		loggedAuthRecord: optAuthRecord,
	}

	return form
}

// SetDao replaces the default form Dao instance with the provided one.
func (form *RecordOAuth2Login) SetDao(dao *daos.Dao) {
	form.dao = dao
}

// SetBeforeNewRecordCreateFunc sets a before OAuth2 record create callback handler.
func (form *RecordOAuth2Login) SetBeforeNewRecordCreateFunc(f BeforeOAuth2RecordCreateFunc) {
	form.beforeOAuth2RecordCreateFunc = f
}

// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *RecordOAuth2Login) Validate() error {
	return validation.ValidateStruct(form,
		validation.Field(&form.Provider, validation.Required, validation.By(form.checkProviderName)),
		validation.Field(&form.Code, validation.Required),
		validation.Field(&form.CodeVerifier, validation.Required),
		validation.Field(&form.RedirectUrl, validation.Required, is.URL),
	)
}

func (form *RecordOAuth2Login) checkProviderName(value any) error {
	name, _ := value.(string)

	config, ok := form.app.Settings().NamedAuthProviderConfigs()[name]
	if !ok || !config.Enabled {
		return validation.NewError("validation_invalid_provider", fmt.Sprintf("%q is missing or is not enabled.", name))
	}

	return nil
}

// Submit validates and submits the form.
//
// If an auth record doesn't exist, it will make an attempt to create it
// based on the fetched OAuth2 profile data via a local [RecordUpsert] form.
// You can intercept/modify the Record create form with [form.SetBeforeNewRecordCreateFunc()].
//
// You can also optionally provide a list of InterceptorFunc to
// further modify the form behavior before persisting it.
//
// On success returns the authorized record model and the fetched provider's data.
func (form *RecordOAuth2Login) Submit(
	interceptors ...InterceptorFunc[*RecordOAuth2LoginData],
) (*models.Record, *auth.AuthUser, error) {
	if err := form.Validate(); err != nil {
		return nil, nil, err
	}

	if !form.collection.AuthOptions().AllowOAuth2Auth {
		return nil, nil, errors.New("OAuth2 authentication is not allowed for the auth collection.")
	}

	provider, err := auth.NewProviderByName(form.Provider)
	if err != nil {
		return nil, nil, err
	}

	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(30*time.Second))
	defer cancel()

	provider.SetContext(ctx)

	// load provider configuration
	providerConfig := form.app.Settings().NamedAuthProviderConfigs()[form.Provider]
	if err := providerConfig.SetupProvider(provider); err != nil {
		return nil, nil, err
	}

	provider.SetRedirectUrl(form.RedirectUrl)

	// fetch token
	token, err := provider.FetchToken(
		form.Code,
		oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier),
	)
	if err != nil {
		return nil, nil, err
	}

	// fetch external auth user
	authUser, err := provider.FetchAuthUser(token)
	if err != nil {
		return nil, nil, err
	}

	var authRecord *models.Record

	// check for existing relation with the auth record
	rel, _ := form.dao.FindExternalAuthByProvider(form.Provider, authUser.Id)
	switch {
	case rel != nil:
		authRecord, err = form.dao.FindRecordById(form.collection.Id, rel.RecordId)
		if err != nil {
			return nil, authUser, err
		}
	case form.loggedAuthRecord != nil && form.loggedAuthRecord.Collection().Id == form.collection.Id:
		// fallback to the logged auth record (if any)
		authRecord = form.loggedAuthRecord
	case authUser.Email != "":
		// look for an existing auth record by the external auth record's email
		authRecord, _ = form.dao.FindAuthRecordByEmail(form.collection.Id, authUser.Email)
	}

	interceptorData := &RecordOAuth2LoginData{
		ExternalAuth: rel,
		Record:       authRecord,
		OAuth2User:   authUser,
	}

	interceptorsErr := runInterceptors(interceptorData, func(newData *RecordOAuth2LoginData) error {
		return form.submit(newData)
	}, interceptors...)

	if interceptorsErr != nil {
		return nil, interceptorData.OAuth2User, interceptorsErr
	}

	return interceptorData.Record, interceptorData.OAuth2User, nil
}

func (form *RecordOAuth2Login) submit(data *RecordOAuth2LoginData) error {
	return form.dao.RunInTransaction(func(txDao *daos.Dao) error {
		if data.Record == nil {
			data.Record = models.NewRecord(form.collection)
			data.Record.RefreshId()
			data.Record.MarkAsNew()
			createForm := NewRecordUpsert(form.app, data.Record)
			createForm.SetFullManageAccess(true)
			createForm.SetDao(txDao)
			if data.OAuth2User.Username != "" &&
				len(data.OAuth2User.Username) >= 3 &&
				len(data.OAuth2User.Username) <= 150 &&
				usernameRegex.MatchString(data.OAuth2User.Username) {
				createForm.Username = form.dao.SuggestUniqueAuthRecordUsername(
					form.collection.Id,
					data.OAuth2User.Username,
				)
			}

			// load custom data
			createForm.LoadData(form.CreateData)

			// load the OAuth2 profile data as fallback
			if createForm.Email == "" {
				createForm.Email = data.OAuth2User.Email
			}
			createForm.Verified = false
			if createForm.Email == data.OAuth2User.Email {
				// mark as verified as long as it matches the OAuth2 data (even if the email is empty)
				createForm.Verified = true
			}
			if createForm.Password == "" {
				createForm.Password = security.RandomString(30)
				createForm.PasswordConfirm = createForm.Password
			}

			if form.beforeOAuth2RecordCreateFunc != nil {
				if err := form.beforeOAuth2RecordCreateFunc(createForm, data.Record, data.OAuth2User); err != nil {
					return err
				}
			}

			// create the new auth record
			if err := createForm.Submit(); err != nil {
				return err
			}
		} else {
			// update the existing auth record empty email if the data.OAuth2User has one
			// (this is in case previously the auth record was created
			// with an OAuth2 provider that didn't return an email address)
			if data.Record.Email() == "" && data.OAuth2User.Email != "" {
				data.Record.SetEmail(data.OAuth2User.Email)
				if err := txDao.SaveRecord(data.Record); err != nil {
					return err
				}
			}

			// update the existing auth record verified state
			// (only if the auth record doesn't have an email or the auth record email match with the one in data.OAuth2User)
			if !data.Record.Verified() && (data.Record.Email() == "" || data.Record.Email() == data.OAuth2User.Email) {
				data.Record.SetVerified(true)
				if err := txDao.SaveRecord(data.Record); err != nil {
					return err
				}
			}
		}

		// create ExternalAuth relation if missing
		if data.ExternalAuth == nil {
			data.ExternalAuth = &models.ExternalAuth{
				CollectionId: data.Record.Collection().Id,
				RecordId:     data.Record.Id,
				Provider:     form.Provider,
				ProviderId:   data.OAuth2User.Id,
			}
			if err := txDao.SaveExternalAuth(data.ExternalAuth); err != nil {
				return err
			}
		}

		return nil
	})
}
