package forms

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"regexp"
	"strconv"

	"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/list"
	"github.com/pocketbase/pocketbase/tools/rest"
	"github.com/spf13/cast"
)

// RecordUpsert defines a Record upsert form.
type RecordUpsert struct {
	app    core.App
	record *models.Record

	isCreate      bool
	filesToDelete []string // names list
	filesToUpload []*rest.UploadedFile

	Data map[string]any `json:"data"`
}

// NewRecordUpsert creates a new Record upsert form.
// (pass a new Record model instance (`models.NewRecord(...)`) for create).
func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert {
	form := &RecordUpsert{
		app:           app,
		record:        record,
		isCreate:      !record.HasId(),
		filesToDelete: []string{},
		filesToUpload: []*rest.UploadedFile{},
	}

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

	return form
}

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) extractRequestData(r *http.Request) (map[string]any, error) {
	switch form.getContentType(r) {
	case "application/json":
		return form.extractJsonData(r)
	case "multipart/form-data":
		return form.extractMultipartFormData(r)
	default:
		return nil, errors.New("Unsupported request Content-Type.")
	}
}

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

	err := rest.ReadJsonBodyCopy(r, &result)

	return result, err
}

func (form *RecordUpsert) extractMultipartFormData(r *http.Request) (map[string]any, error) {
	result := map[string]any{}

	// parse form data (if not already)
	if err := r.ParseMultipartForm(rest.DefaultMaxMemory); err != nil {
		return result, err
	}

	arrayValueSupportTypes := schema.ArraybleFieldTypes()

	for key, values := range r.PostForm {
		if len(values) == 0 {
			result[key] = nil
			continue
		}

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

	return result, nil
}

func (form *RecordUpsert) normalizeData() error {
	for _, field := range form.record.Collection().Schema.Fields() {
		if v, ok := form.Data[field.Name]; ok {
			form.Data[field.Name] = field.PrepareValue(v)
		}
	}

	return nil
}

// LoadData loads and normalizes json OR multipart/form-data request data.
//
// File upload is supported only via multipart/form-data.
//
// To REPLACE previously uploaded file(s) you can suffix the field name
// with the file index (eg. `myfile.0`) and set the new value.
// For single file upload fields, you can skip the index and directly
// assign the file value to the field name (eg. `myfile`).
//
// To DELETE previously uploaded file(s) you can suffix the field name
// with the file index (eg. `myfile.0`) and set it to null or empty string.
// For single file upload fields, you can skip the index and directly
// reset the field using its field name (eg. `myfile`).
func (form *RecordUpsert) LoadData(r *http.Request) error {
	requestData, err := form.extractRequestData(r)
	if err != nil {
		return err
	}

	// extend base data with the extracted one
	extendedData := form.record.Data()
	rawData, err := json.Marshal(requestData)
	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 := extendedData[key]
		value = field.PrepareValue(value)

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

		options, _ := field.Options.(*schema.FileOptions)
		oldNames := list.ToUniqueStringSlice(form.Data[key])

		// delete previously uploaded file(s)
		if options.MaxSelect == 1 {
			// search for unset zero indexed key as a fallback
			indexedKeyValue, hasIndexedKey := extendedData[key+".0"]

			if cast.ToString(value) == "" || (hasIndexedKey && cast.ToString(indexedKeyValue) == "") {
				if len(oldNames) > 0 {
					form.filesToDelete = append(form.filesToDelete, oldNames...)
				}
				form.Data[key] = ""
			}
		} else if options.MaxSelect > 1 {
			// search for individual file index to delete (eg. "file.0")
			keyExp, _ := regexp.Compile(`^` + regexp.QuoteMeta(key) + `\.\d+$`)
			indexesToDelete := make([]int, 0, len(extendedData))
			for indexedKey := range extendedData {
				if keyExp.MatchString(indexedKey) && cast.ToString(extendedData[indexedKey]) == "" {
					index, indexErr := strconv.Atoi(indexedKey[len(key)+1:])
					if indexErr != nil || index >= len(oldNames) {
						continue
					}
					indexesToDelete = append(indexesToDelete, index)
				}
			}

			// slice to fill only with the non-deleted indexes
			nonDeleted := make([]string, 0, len(oldNames))
			for i, name := range oldNames {
				// not marked for deletion
				if !list.ExistInSlice(i, indexesToDelete) {
					nonDeleted = append(nonDeleted, name)
					continue
				}

				// store the id to actually delete the file later
				form.filesToDelete = append(form.filesToDelete, name)
			}
			form.Data[key] = nonDeleted
		}

		// check if there are any new uploaded form files
		files, err := rest.FindUploadedFiles(r, key)
		if err != nil {
			continue // skip invalid or missing file(s)
		}

		// refresh oldNames list
		oldNames = list.ToUniqueStringSlice(form.Data[key])

		if options.MaxSelect == 1 {
			// delete previous file(s) before replacing
			if len(oldNames) > 0 {
				form.filesToDelete = list.ToUniqueStringSlice(append(form.filesToDelete, oldNames...))
			}
			form.filesToUpload = append(form.filesToUpload, files[0])
			form.Data[key] = files[0].Name()
		} else if options.MaxSelect > 1 {
			// append the id of each uploaded file instance
			form.filesToUpload = append(form.filesToUpload, files...)
			for _, file := range files {
				oldNames = append(oldNames, file.Name())
			}
			form.Data[key] = oldNames
		}
	}

	return form.normalizeData()
}

// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *RecordUpsert) Validate() error {
	dataValidator := validators.NewRecordDataValidator(
		form.app.Dao(),
		form.record,
		form.filesToUpload,
	)

	return dataValidator.Validate(form.Data)
}

// 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 {
	if err := form.Validate(); err != nil {
		return err
	}

	// bulk load form data
	if err := form.record.Load(form.Data); err != nil {
		return err
	}

	return form.app.Dao().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()
		txDao.BeforeCreateFunc = nil
		txDao.AfterCreateFunc = nil
		txDao.BeforeUpdateFunc = nil
		txDao.AfterUpdateFunc = nil

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

		return callback(txDao)
	})
}

// 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) error {
	if err := form.Validate(); err != nil {
		return err
	}

	// bulk load form data
	if err := form.record.Load(form.Data); err != nil {
		return err
	}

	return runInterceptors(func() error {
		return form.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
			// persist record model
			if err := txDao.SaveRecord(form.record); err != nil {
				return err
			}

			// upload new files (if any)
			if err := form.processFilesToUpload(); err != nil {
				return err
			}

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

			return nil
		})
	}, interceptors...)
}

func (form *RecordUpsert) processFilesToUpload() error {
	if len(form.filesToUpload) == 0 {
		return nil // nothing to upload
	}

	if !form.record.HasId() {
		return errors.New("The record is not persisted yet.")
	}

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

	var uploadErrors []error
	for i := len(form.filesToUpload) - 1; i >= 0; i-- {
		file := form.filesToUpload[i]
		path := form.record.BaseFilesPath() + "/" + file.Name()

		if err := fs.Upload(file.Bytes(), path); err == nil {
			// remove the uploaded file from the list
			form.filesToUpload = append(form.filesToUpload[:i], form.filesToUpload[i+1:]...)
		} else {
			// store the upload error
			uploadErrors = append(uploadErrors, fmt.Errorf("File %d: %v", i, err))
		}
	}

	if len(uploadErrors) > 0 {
		return fmt.Errorf("Failed to upload all files: %v", uploadErrors)
	}

	return nil
}

func (form *RecordUpsert) processFilesToDelete() error {
	if len(form.filesToDelete) == 0 {
		return nil // nothing to delete
	}

	if !form.record.HasId() {
		return errors.New("The record is not persisted yet.")
	}

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

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

		if err := fs.Delete(path); err == nil {
			// remove the deleted file from the list
			form.filesToDelete = append(form.filesToDelete[:i], form.filesToDelete[i+1:]...)
		} else {
			// store the delete error
			deleteErrors = append(deleteErrors, fmt.Errorf("File %d: %v", i, err))
		}

		// try to delete the related file thumbs (if any)
		fs.DeletePrefix(form.record.BaseFilesPath() + "/thumbs_" + filename + "/")
	}

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

	return nil
}
