package forms

import (
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/http"
	"regexp"
	"strings"

	validation "github.com/go-ozzo/ozzo-validation/v4"
	"github.com/go-ozzo/ozzo-validation/v4/is"
	"github.com/pocketbase/dbx"
	"github.com/pocketbase/pocketbase/core"
	"github.com/pocketbase/pocketbase/daos"
	"github.com/pocketbase/pocketbase/forms/validators"
	"github.com/pocketbase/pocketbase/models"
	"github.com/pocketbase/pocketbase/models/schema"
	"github.com/pocketbase/pocketbase/tools/filesystem"
	"github.com/pocketbase/pocketbase/tools/list"
	"github.com/pocketbase/pocketbase/tools/rest"
	"github.com/pocketbase/pocketbase/tools/security"
	"github.com/spf13/cast"
)

// username value regex pattern
var usernameRegex = regexp.MustCompile(`^[\w][\w\.]*$`)

// RecordUpsert is a [models.Record] upsert (create/update) form.
type RecordUpsert struct {
	app          core.App
	dao          *daos.Dao
	manageAccess bool
	record       *models.Record

	filesToUpload map[string][]*filesystem.File
	filesToDelete []string // names list

	// base model fields
	Id string `json:"id"`

	// auth collection fields
	// ---
	Username        string `json:"username"`
	Email           string `json:"email"`
	EmailVisibility bool   `json:"emailVisibility"`
	Verified        bool   `json:"verified"`
	Password        string `json:"password"`
	PasswordConfirm string `json:"passwordConfirm"`
	OldPassword     string `json:"oldPassword"`
	// ---

	data map[string]any
}

// NewRecordUpsert creates a new [RecordUpsert] form with initializer
// config created from the provided [core.App] and [models.Record] instances
// (for create you could pass a pointer to an empty Record - models.NewRecord(collection)).
//
// If you want to submit the form as part of a transaction,
// you can change the default Dao via [SetDao()].
func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert {
	form := &RecordUpsert{
		app:           app,
		dao:           app.Dao(),
		record:        record,
		filesToDelete: []string{},
		filesToUpload: map[string][]*filesystem.File{},
	}

	form.loadFormDefaults()

	return form
}

// Data returns the loaded form's data.
func (form *RecordUpsert) Data() map[string]any {
	return form.data
}

// SetFullManageAccess sets the manageAccess bool flag of the current
// form to enable/disable directly changing some system record fields
// (often used with auth collection records).
func (form *RecordUpsert) SetFullManageAccess(fullManageAccess bool) {
	form.manageAccess = fullManageAccess
}

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

func (form *RecordUpsert) loadFormDefaults() {
	form.Id = form.record.Id

	if form.record.Collection().IsAuth() {
		form.Username = form.record.Username()
		form.Email = form.record.Email()
		form.EmailVisibility = form.record.EmailVisibility()
		form.Verified = form.record.Verified()
	}

	form.data = map[string]any{}
	for _, field := range form.record.Collection().Schema.Fields() {
		form.data[field.Name] = form.record.Get(field.Name)
	}
}

func (form *RecordUpsert) getContentType(r *http.Request) string {
	t := r.Header.Get("Content-Type")
	for i, c := range t {
		if c == ' ' || c == ';' {
			return t[:i]
		}
	}
	return t
}

func (form *RecordUpsert) extractRequestInfo(
	r *http.Request,
	keyPrefix string,
) (map[string]any, map[string][]*filesystem.File, error) {
	switch form.getContentType(r) {
	case "application/json":
		return form.extractJsonData(r, keyPrefix)
	case "multipart/form-data":
		return form.extractMultipartFormData(r, keyPrefix)
	default:
		return nil, nil, errors.New("unsupported request content-type")
	}
}

func (form *RecordUpsert) extractJsonData(
	r *http.Request,
	keyPrefix string,
) (map[string]any, map[string][]*filesystem.File, error) {
	data := map[string]any{}

	err := rest.CopyJsonBody(r, &data)

	if keyPrefix != "" {
		parts := strings.Split(keyPrefix, ".")
		for _, part := range parts {
			if data[part] == nil {
				break
			}
			if v, ok := data[part].(map[string]any); ok {
				data = v
			}
		}
	}

	return data, nil, err
}

func (form *RecordUpsert) extractMultipartFormData(
	r *http.Request,
	keyPrefix string,
) (map[string]any, map[string][]*filesystem.File, error) {
	// parse form data (if not already)
	if err := r.ParseMultipartForm(rest.DefaultMaxMemory); err != nil {
		return nil, nil, err
	}

	data := map[string]any{}
	filesToUpload := map[string][]*filesystem.File{}
	arrayValueSupportTypes := schema.ArraybleFieldTypes()

	for fullKey, values := range r.PostForm {
		key := fullKey
		if keyPrefix != "" {
			key = strings.TrimPrefix(key, keyPrefix+".")
		}

		if len(values) == 0 {
			data[key] = nil
			continue
		}

		field := form.record.Collection().Schema.GetFieldByName(key)
		if field != nil && list.ExistInSlice(field.Type, arrayValueSupportTypes) {
			data[key] = values
		} else {
			data[key] = values[0]
		}
	}

	// load uploaded files (if any)
	for _, field := range form.record.Collection().Schema.Fields() {
		if field.Type != schema.FieldTypeFile {
			continue // not a file field
		}

		key := field.Name
		fullKey := key
		if keyPrefix != "" {
			fullKey = keyPrefix + "." + key
		}

		files, err := rest.FindUploadedFiles(r, fullKey)
		if err != nil || len(files) == 0 {
			if err != nil && err != http.ErrMissingFile && form.app.IsDebug() {
				log.Printf("%q uploaded file error: %v\n", fullKey, err)
			}

			// skip invalid or missing file(s)
			continue
		}

		filesToUpload[key] = append(filesToUpload[key], files...)
	}

	return data, filesToUpload, nil
}

// LoadRequest extracts the json or multipart/form-data request data
// and lods it into the form.
//
// File upload is supported only via multipart/form-data.
func (form *RecordUpsert) LoadRequest(r *http.Request, keyPrefix string) error {
	requestInfo, uploadedFiles, err := form.extractRequestInfo(r, keyPrefix)
	if err != nil {
		return err
	}

	if err := form.LoadData(requestInfo); err != nil {
		return err
	}

	for key, files := range uploadedFiles {
		form.AddFiles(key, files...)
	}

	return nil
}

// FilesToUpload returns the parsed request files ready for upload.
func (form *RecordUpsert) FilesToUpload() map[string][]*filesystem.File {
	return form.filesToUpload
}

// FilesToUpload returns the parsed request filenames ready to be deleted.
func (form *RecordUpsert) FilesToDelete() []string {
	return form.filesToDelete
}

// AddFiles adds the provided file(s) to the specified file field.
//
// If the file field is a SINGLE-value file field (aka. "Max Select = 1"),
// then the newly added file will REPLACE the existing one.
// In this case if you pass more than 1 files only the first one will be assigned.
//
// If the file field is a MULTI-value file field (aka. "Max Select > 1"),
// then the newly added file(s) will be APPENDED to the existing one(s).
//
// Example
//
//	f1, _ := filesystem.NewFileFromPath("/path/to/file1.txt")
//	f2, _ := filesystem.NewFileFromPath("/path/to/file2.txt")
//	form.AddFiles("documents", f1, f2)
func (form *RecordUpsert) AddFiles(key string, files ...*filesystem.File) error {
	field := form.record.Collection().Schema.GetFieldByName(key)
	if field == nil || field.Type != schema.FieldTypeFile {
		return errors.New("invalid field key")
	}

	options, ok := field.Options.(*schema.FileOptions)
	if !ok {
		return errors.New("failed to initilize field options")
	}

	if len(files) == 0 {
		return nil // nothing to upload
	}

	if form.filesToUpload == nil {
		form.filesToUpload = map[string][]*filesystem.File{}
	}

	oldNames := list.ToUniqueStringSlice(form.data[key])

	if options.MaxSelect == 1 {
		// mark previous file(s) for deletion before replacing
		if len(oldNames) > 0 {
			form.filesToDelete = list.ToUniqueStringSlice(append(form.filesToDelete, oldNames...))
		}

		// replace
		form.filesToUpload[key] = []*filesystem.File{files[0]}
		form.data[key] = field.PrepareValue(files[0].Name)
	} else {
		// append
		form.filesToUpload[key] = append(form.filesToUpload[key], files...)
		for _, f := range files {
			oldNames = append(oldNames, f.Name)
		}
		form.data[key] = field.PrepareValue(oldNames)
	}

	return nil
}

// RemoveFiles removes a single or multiple file from the specified file field.
//
// NB! If filesToDelete is not set it will remove all existing files
// assigned to the file field (including those assigned with AddFiles)!
//
// Example
//
//	// mark only only 2 files for removal
//	form.AddFiles("documents", "file1_aw4bdrvws6.txt", "file2_xwbs36bafv.txt")
//
//	// mark all "documents" files for removal
//	form.AddFiles("documents")
func (form *RecordUpsert) RemoveFiles(key string, toDelete ...string) error {
	field := form.record.Collection().Schema.GetFieldByName(key)
	if field == nil || field.Type != schema.FieldTypeFile {
		return errors.New("invalid field key")
	}

	existing := list.ToUniqueStringSlice(form.data[key])

	// mark all files for deletion
	if len(toDelete) == 0 {
		toDelete = make([]string, len(existing))
		copy(toDelete, existing)
	}

	// check for existing files
	for i := len(existing) - 1; i >= 0; i-- {
		if list.ExistInSlice(existing[i], toDelete) {
			form.filesToDelete = append(form.filesToDelete, existing[i])
			existing = append(existing[:i], existing[i+1:]...)
		}
	}

	// check for newly uploaded files
	for i := len(form.filesToUpload[key]) - 1; i >= 0; i-- {
		f := form.filesToUpload[key][i]
		if list.ExistInSlice(f.Name, toDelete) {
			form.filesToUpload[key] = append(form.filesToUpload[key][:i], form.filesToUpload[key][i+1:]...)
		}
	}

	form.data[key] = field.PrepareValue(existing)

	return nil
}

// LoadData loads and normalizes the provided regular record data fields into the form.
func (form *RecordUpsert) LoadData(requestInfo map[string]any) error {
	// load base system fields
	if v, ok := requestInfo[schema.FieldNameId]; ok {
		form.Id = cast.ToString(v)
	}

	// load auth system fields
	if form.record.Collection().IsAuth() {
		if v, ok := requestInfo[schema.FieldNameUsername]; ok {
			form.Username = cast.ToString(v)
		}
		if v, ok := requestInfo[schema.FieldNameEmail]; ok {
			form.Email = cast.ToString(v)
		}
		if v, ok := requestInfo[schema.FieldNameEmailVisibility]; ok {
			form.EmailVisibility = cast.ToBool(v)
		}
		if v, ok := requestInfo[schema.FieldNameVerified]; ok {
			form.Verified = cast.ToBool(v)
		}
		if v, ok := requestInfo["password"]; ok {
			form.Password = cast.ToString(v)
		}
		if v, ok := requestInfo["passwordConfirm"]; ok {
			form.PasswordConfirm = cast.ToString(v)
		}
		if v, ok := requestInfo["oldPassword"]; ok {
			form.OldPassword = cast.ToString(v)
		}
	}

	// replace modifiers (if any)
	requestInfo = form.record.ReplaceModifers(requestInfo)

	// create a shallow copy of form.data
	var extendedData = make(map[string]any, len(form.data))
	for k, v := range form.data {
		extendedData[k] = v
	}

	// extend form.data with the request data
	rawData, err := json.Marshal(requestInfo)
	if err != nil {
		return err
	}
	if err := json.Unmarshal(rawData, &extendedData); err != nil {
		return err
	}

	for _, field := range form.record.Collection().Schema.Fields() {
		key := field.Name
		value := field.PrepareValue(extendedData[key])

		if field.Type != schema.FieldTypeFile {
			form.data[key] = value
			continue
		}

		// -----------------------------------------------------------
		// Delete previously uploaded file(s)
		// -----------------------------------------------------------

		oldNames := form.record.GetStringSlice(key)
		submittedNames := list.ToUniqueStringSlice(value)

		// ensure that all submitted names are existing to prevent accidental files deletions
		if len(submittedNames) > len(oldNames) || len(list.SubtractSlice(submittedNames, oldNames)) != 0 {
			return validation.Errors{
				key: validation.NewError(
					"validation_unknown_filenames",
					"The field contains unknown filenames.",
				),
			}
		}

		// if empty value was set, mark all previously uploaded files for deletion
		// otherwise check for "deleted" (aka. unsubmitted) file names
		if len(submittedNames) == 0 && len(oldNames) > 0 {
			form.RemoveFiles(key)
		} else if len(oldNames) > 0 {
			toDelete := []string{}

			for _, name := range oldNames {
				// submitted as a modifier or a new array
				if !list.ExistInSlice(name, submittedNames) {
					toDelete = append(toDelete, name)
					continue
				}
			}

			if len(toDelete) > 0 {
				form.RemoveFiles(key, toDelete...)
			}
		}

		// allow file key reasignments for file names sorting
		// (only if all submitted values already exists)
		if len(submittedNames) > 0 && len(list.SubtractSlice(submittedNames, oldNames)) == 0 {
			form.data[key] = submittedNames
		}
	}

	return nil
}

// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *RecordUpsert) Validate() error {
	// base form fields validator
	baseFieldsRules := []*validation.FieldRules{
		validation.Field(
			&form.Id,
			validation.When(
				form.record.IsNew(),
				validation.Length(models.DefaultIdLength, models.DefaultIdLength),
				validation.Match(idRegex),
				validation.By(validators.UniqueId(form.dao, form.record.TableName())),
			).Else(validation.In(form.record.Id)),
		),
	}

	// auth fields validators
	if form.record.Collection().IsAuth() {
		baseFieldsRules = append(baseFieldsRules,
			validation.Field(
				&form.Username,
				// require only on update, because on create we fallback to auto generated username
				validation.When(!form.record.IsNew(), validation.Required),
				validation.Length(3, 150),
				validation.Match(usernameRegex),
				validation.By(form.checkUniqueUsername),
			),
			validation.Field(
				&form.Email,
				validation.When(
					form.record.Collection().AuthOptions().RequireEmail,
					validation.Required,
				),
				// don't allow direct email change (or unset) if the form doesn't have manage access permissions
				// (aka. allow only admin or authorized auth models to directly update the field)
				validation.When(
					!form.record.IsNew() && !form.manageAccess,
					validation.In(form.record.Email()),
				),
				validation.Length(1, 255),
				is.EmailFormat,
				validation.By(form.checkEmailDomain),
				validation.By(form.checkUniqueEmail),
			),
			validation.Field(
				&form.Verified,
				// don't allow changing verified if the form doesn't have manage access permissions
				// (aka. allow only admin or authorized auth models to directly change the field)
				validation.When(
					!form.manageAccess,
					validation.In(form.record.Verified()),
				),
			),
			validation.Field(
				&form.Password,
				validation.When(
					(form.record.IsNew() || form.PasswordConfirm != "" || form.OldPassword != ""),
					validation.Required,
				),
				validation.Length(form.record.Collection().AuthOptions().MinPasswordLength, 72),
			),
			validation.Field(
				&form.PasswordConfirm,
				validation.When(
					(form.record.IsNew() || form.Password != "" || form.OldPassword != ""),
					validation.Required,
				),
				validation.By(validators.Compare(form.Password)),
			),
			validation.Field(
				&form.OldPassword,
				// require old password only on update when:
				// - form.manageAccess is not set
				// - changing the existing password
				validation.When(
					!form.record.IsNew() && !form.manageAccess && (form.Password != "" || form.PasswordConfirm != ""),
					validation.Required,
					validation.By(form.checkOldPassword),
				),
			),
		)
	}

	if err := validation.ValidateStruct(form, baseFieldsRules...); err != nil {
		return err
	}

	// record data validator
	return validators.NewRecordDataValidator(
		form.dao,
		form.record,
		form.filesToUpload,
	).Validate(form.data)
}

func (form *RecordUpsert) checkUniqueUsername(value any) error {
	v, _ := value.(string)
	if v == "" {
		return nil
	}

	isUnique := form.dao.IsRecordValueUnique(
		form.record.Collection().Id,
		schema.FieldNameUsername,
		v,
		form.record.Id,
	)
	if !isUnique {
		return validation.NewError("validation_invalid_username", "The username is invalid or already in use.")
	}

	return nil
}

func (form *RecordUpsert) checkUniqueEmail(value any) error {
	v, _ := value.(string)
	if v == "" {
		return nil
	}

	isUnique := form.dao.IsRecordValueUnique(
		form.record.Collection().Id,
		schema.FieldNameEmail,
		v,
		form.record.Id,
	)
	if !isUnique {
		return validation.NewError("validation_invalid_email", "The email is invalid or already in use.")
	}

	return nil
}

func (form *RecordUpsert) checkEmailDomain(value any) error {
	val, _ := value.(string)
	if val == "" {
		return nil // nothing to check
	}

	domain := val[strings.LastIndex(val, "@")+1:]
	only := form.record.Collection().AuthOptions().OnlyEmailDomains
	except := form.record.Collection().AuthOptions().ExceptEmailDomains

	// only domains check
	if len(only) > 0 && !list.ExistInSlice(domain, only) {
		return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed.")
	}

	// except domains check
	if len(except) > 0 && list.ExistInSlice(domain, except) {
		return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed.")
	}

	return nil
}

func (form *RecordUpsert) checkOldPassword(value any) error {
	v, _ := value.(string)
	if v == "" {
		return nil // nothing to check
	}

	if !form.record.ValidatePassword(v) {
		return validation.NewError("validation_invalid_old_password", "Missing or invalid old password.")
	}

	return nil
}

func (form *RecordUpsert) ValidateAndFill() error {
	if err := form.Validate(); err != nil {
		return err
	}

	isNew := form.record.IsNew()

	// custom insertion id can be set only on create
	if isNew && form.Id != "" {
		form.record.SetId(form.Id)
		form.record.MarkAsNew()
	}

	// set auth fields
	if form.record.Collection().IsAuth() {
		// generate a default username during create (if missing)
		if form.record.IsNew() && form.Username == "" {
			baseUsername := form.record.Collection().Name + security.RandomStringWithAlphabet(5, "123456789")
			form.Username = form.dao.SuggestUniqueAuthRecordUsername(form.record.Collection().Id, baseUsername)
		}

		if form.Username != "" {
			if err := form.record.SetUsername(form.Username); err != nil {
				return err
			}
		}

		if isNew || form.manageAccess {
			if err := form.record.SetEmail(form.Email); err != nil {
				return err
			}
		}

		if err := form.record.SetEmailVisibility(form.EmailVisibility); err != nil {
			return err
		}

		if form.manageAccess {
			if err := form.record.SetVerified(form.Verified); err != nil {
				return err
			}
		}

		if form.Password != "" && form.Password == form.PasswordConfirm {
			if err := form.record.SetPassword(form.Password); err != nil {
				return err
			}
		}
	}

	// bulk load the remaining form data
	form.record.Load(form.data)

	return nil
}

// DrySubmit performs a form submit within a transaction and reverts it.
// For actual record persistence, check the `form.Submit()` method.
//
// This method doesn't handle file uploads/deletes or trigger any app events!
func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error {
	isNew := form.record.IsNew()

	if err := form.ValidateAndFill(); err != nil {
		return err
	}

	var dryDao *daos.Dao
	if form.dao.ConcurrentDB() == form.dao.NonconcurrentDB() {
		// it is already in a transaction and therefore use the app concurrent db pool
		// to prevent "transaction has already been committed or rolled back" error
		dryDao = daos.New(form.app.Dao().ConcurrentDB())
	} else {
		// otherwise use the form noncurrent dao db pool
		dryDao = daos.New(form.dao.NonconcurrentDB())
	}

	return dryDao.RunInTransaction(func(txDao *daos.Dao) error {
		tx, ok := txDao.DB().(*dbx.Tx)
		if !ok {
			return errors.New("failed to get transaction db")
		}
		defer tx.Rollback()

		if err := txDao.SaveRecord(form.record); err != nil {
			return form.prepareError(err)
		}

		// restore record isNew state
		if isNew {
			form.record.MarkAsNew()
		}

		if callback != nil {
			return callback(txDao)
		}

		return nil
	})
}

// Submit validates the form and upserts the form Record model.
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]) error {
	if err := form.ValidateAndFill(); err != nil {
		return err
	}

	return runInterceptors(form.record, func(record *models.Record) error {
		form.record = record

		if !form.record.HasId() {
			form.record.RefreshId()
			form.record.MarkAsNew()
		}

		dao := form.dao.Clone()

		// upload new files (if any)
		//
		// note: executed after the default BeforeCreateFunc and BeforeUpdateFunc hook actions
		// to allow uploading AFTER the before app model hooks (eg. in case of an id change)
		// but BEFORE the actual record db persistence
		// ---
		dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
			newAction := func() error {
				if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() {
					if err := form.processFilesToUpload(); err != nil {
						return err
					}
				}

				return action()
			}

			if form.dao.BeforeCreateFunc != nil {
				return form.dao.BeforeCreateFunc(eventDao, m, newAction)
			}

			return newAction()
		}

		dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
			newAction := func() error {
				if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() {
					if err := form.processFilesToUpload(); err != nil {
						return err
					}
				}

				return action()
			}

			if form.dao.BeforeUpdateFunc != nil {
				return form.dao.BeforeUpdateFunc(eventDao, m, newAction)
			}

			return newAction()
		}
		// ---

		// persist the record model
		if err := dao.SaveRecord(form.record); err != nil {
			return form.prepareError(err)
		}

		// delete old files (if any)
		//
		// for now fail silently to avoid reupload when `form.Submit()`
		// is called manually (aka. not from an api request)...
		if err := form.processFilesToDelete(); err != nil && form.app.IsDebug() {
			log.Println(err)
		}

		return nil
	}, interceptors...)
}

func (form *RecordUpsert) processFilesToUpload() error {
	if len(form.filesToUpload) == 0 {
		return nil // no parsed file fields
	}

	if !form.record.HasId() {
		return errors.New("the record doesn't have an id")
	}

	fs, err := form.app.NewFilesystem()
	if err != nil {
		return err
	}
	defer fs.Close()

	var uploadErrors []error // list of upload errors
	var uploaded []string    // list of uploaded file paths

	for fieldKey := range form.filesToUpload {
		for i, file := range form.filesToUpload[fieldKey] {
			path := form.record.BaseFilesPath() + "/" + file.Name
			if err := fs.UploadFile(file, path); err == nil {
				// keep track of the already uploaded file
				uploaded = append(uploaded, path)
			} else {
				// store the upload error
				uploadErrors = append(uploadErrors, fmt.Errorf("file %d: %v", i, err))
			}
		}
	}

	if len(uploadErrors) > 0 {
		// cleanup - try to delete the successfully uploaded files (if any)
		form.deleteFilesByNamesList(uploaded)

		return fmt.Errorf("failed to upload all files: %v", uploadErrors)
	}

	return nil
}

func (form *RecordUpsert) processFilesToDelete() (err error) {
	form.filesToDelete, err = form.deleteFilesByNamesList(form.filesToDelete)
	return
}

// deleteFiles deletes a list of record files by their names.
// Returns the failed/remaining files.
func (form *RecordUpsert) deleteFilesByNamesList(filenames []string) ([]string, error) {
	if len(filenames) == 0 {
		return filenames, nil // nothing to delete
	}

	if !form.record.HasId() {
		return filenames, errors.New("the record doesn't have an id")
	}

	fs, err := form.app.NewFilesystem()
	if err != nil {
		return filenames, err
	}
	defer fs.Close()

	var deleteErrors []error

	for i := len(filenames) - 1; i >= 0; i-- {
		filename := filenames[i]
		path := form.record.BaseFilesPath() + "/" + filename

		if err := fs.Delete(path); err == nil {
			// remove the deleted file from the list
			filenames = append(filenames[:i], filenames[i+1:]...)

			// try to delete the related file thumbs (if any)
			fs.DeletePrefix(form.record.BaseFilesPath() + "/thumbs_" + filename + "/")
		} else {
			// store the delete error
			deleteErrors = append(deleteErrors, fmt.Errorf("file %d: %v", i, err))
		}
	}

	if len(deleteErrors) > 0 {
		return filenames, fmt.Errorf("failed to delete all files: %v", deleteErrors)
	}

	return filenames, nil
}

// prepareError parses the provided error and tries to return
// user-friendly validation error(s).
func (form *RecordUpsert) prepareError(err error) error {
	msg := strings.ToLower(err.Error())

	validationErrs := validation.Errors{}

	// check for unique constraint failure
	if strings.Contains(msg, "unique constraint failed") {
		msg = strings.ReplaceAll(strings.TrimSpace(msg), ",", " ")

		c := form.record.Collection()
		for _, f := range c.Schema.Fields() {
			// blank space to unify multi-columns lookup
			if strings.Contains(msg+" ", strings.ToLower(c.Name+"."+f.Name)) {
				validationErrs[f.Name] = validation.NewError("validation_not_unique", "Value must be unique")
			}
		}
	}

	if len(validationErrs) > 0 {
		return validationErrs
	}

	return err
}
