package core

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"log"
	"log/slog"
	"os"
	"path/filepath"
	"regexp"
	"runtime"
	"strings"
	"syscall"
	"time"

	"github.com/fatih/color"
	"github.com/pocketbase/dbx"
	"github.com/pocketbase/pocketbase/tools/cron"
	"github.com/pocketbase/pocketbase/tools/filesystem"
	"github.com/pocketbase/pocketbase/tools/hook"
	"github.com/pocketbase/pocketbase/tools/logger"
	"github.com/pocketbase/pocketbase/tools/mailer"
	"github.com/pocketbase/pocketbase/tools/routine"
	"github.com/pocketbase/pocketbase/tools/store"
	"github.com/pocketbase/pocketbase/tools/subscriptions"
	"github.com/pocketbase/pocketbase/tools/types"
)

const (
	DefaultDataMaxOpenConns int           = 120
	DefaultDataMaxIdleConns int           = 15
	DefaultAuxMaxOpenConns  int           = 20
	DefaultAuxMaxIdleConns  int           = 3
	DefaultQueryTimeout     time.Duration = 30 * time.Second

	LocalStorageDirName       string = "storage"
	LocalBackupsDirName       string = "backups"
	LocalTempDirName          string = ".pb_temp_to_delete" // temp pb_data sub directory that will be deleted on each app.Bootstrap()
	LocalAutocertCacheDirName string = ".autocert_cache"
)

// FilesManager defines an interface with common methods that files manager models should implement.
type FilesManager interface {
	// BaseFilesPath returns the storage dir path used by the interface instance.
	BaseFilesPath() string
}

// DBConnectFunc defines a database connection initialization function.
type DBConnectFunc func(dbPath string) (*dbx.DB, error)

// BaseAppConfig defines a BaseApp configuration option
type BaseAppConfig struct {
	DBConnect        DBConnectFunc
	DataDir          string
	EncryptionEnv    string
	QueryTimeout     time.Duration
	DataMaxOpenConns int
	DataMaxIdleConns int
	AuxMaxOpenConns  int
	AuxMaxIdleConns  int
	IsDev            bool
}

// ensures that the BaseApp implements the App interface.
var _ App = (*BaseApp)(nil)

// BaseApp implements core.App and defines the base PocketBase app structure.
type BaseApp struct {
	config              *BaseAppConfig
	txInfo              *txAppInfo
	store               *store.Store[any]
	cron                *cron.Cron
	settings            *Settings
	subscriptionsBroker *subscriptions.Broker
	logger              *slog.Logger
	concurrentDB        dbx.Builder
	nonconcurrentDB     dbx.Builder
	auxConcurrentDB     dbx.Builder
	auxNonconcurrentDB  dbx.Builder

	// app event hooks
	onBootstrap     *hook.Hook[*BootstrapEvent]
	onServe         *hook.Hook[*ServeEvent]
	onTerminate     *hook.Hook[*TerminateEvent]
	onBackupCreate  *hook.Hook[*BackupEvent]
	onBackupRestore *hook.Hook[*BackupEvent]

	// db model hooks
	onModelValidate           *hook.Hook[*ModelEvent]
	onModelCreate             *hook.Hook[*ModelEvent]
	onModelCreateExecute      *hook.Hook[*ModelEvent]
	onModelAfterCreateSuccess *hook.Hook[*ModelEvent]
	onModelAfterCreateError   *hook.Hook[*ModelErrorEvent]
	onModelUpdate             *hook.Hook[*ModelEvent]
	onModelUpdateWrite        *hook.Hook[*ModelEvent]
	onModelAfterUpdateSuccess *hook.Hook[*ModelEvent]
	onModelAfterUpdateError   *hook.Hook[*ModelErrorEvent]
	onModelDelete             *hook.Hook[*ModelEvent]
	onModelDeleteExecute      *hook.Hook[*ModelEvent]
	onModelAfterDeleteSuccess *hook.Hook[*ModelEvent]
	onModelAfterDeleteError   *hook.Hook[*ModelErrorEvent]

	// db record hooks
	onRecordEnrich             *hook.Hook[*RecordEnrichEvent]
	onRecordValidate           *hook.Hook[*RecordEvent]
	onRecordCreate             *hook.Hook[*RecordEvent]
	onRecordCreateExecute      *hook.Hook[*RecordEvent]
	onRecordAfterCreateSuccess *hook.Hook[*RecordEvent]
	onRecordAfterCreateError   *hook.Hook[*RecordErrorEvent]
	onRecordUpdate             *hook.Hook[*RecordEvent]
	onRecordUpdateExecute      *hook.Hook[*RecordEvent]
	onRecordAfterUpdateSuccess *hook.Hook[*RecordEvent]
	onRecordAfterUpdateError   *hook.Hook[*RecordErrorEvent]
	onRecordDelete             *hook.Hook[*RecordEvent]
	onRecordDeleteExecute      *hook.Hook[*RecordEvent]
	onRecordAfterDeleteSuccess *hook.Hook[*RecordEvent]
	onRecordAfterDeleteError   *hook.Hook[*RecordErrorEvent]

	// db collection hooks
	onCollectionValidate           *hook.Hook[*CollectionEvent]
	onCollectionCreate             *hook.Hook[*CollectionEvent]
	onCollectionCreateExecute      *hook.Hook[*CollectionEvent]
	onCollectionAfterCreateSuccess *hook.Hook[*CollectionEvent]
	onCollectionAfterCreateError   *hook.Hook[*CollectionErrorEvent]
	onCollectionUpdate             *hook.Hook[*CollectionEvent]
	onCollectionUpdateExecute      *hook.Hook[*CollectionEvent]
	onCollectionAfterUpdateSuccess *hook.Hook[*CollectionEvent]
	onCollectionAfterUpdateError   *hook.Hook[*CollectionErrorEvent]
	onCollectionDelete             *hook.Hook[*CollectionEvent]
	onCollectionDeleteExecute      *hook.Hook[*CollectionEvent]
	onCollectionAfterDeleteSuccess *hook.Hook[*CollectionEvent]
	onCollectionAfterDeleteError   *hook.Hook[*CollectionErrorEvent]

	// mailer event hooks
	onMailerSend                    *hook.Hook[*MailerEvent]
	onMailerRecordPasswordResetSend *hook.Hook[*MailerRecordEvent]
	onMailerRecordVerificationSend  *hook.Hook[*MailerRecordEvent]
	onMailerRecordEmailChangeSend   *hook.Hook[*MailerRecordEvent]
	onMailerRecordOTPSend           *hook.Hook[*MailerRecordEvent]
	onMailerRecordAuthAlertSend     *hook.Hook[*MailerRecordEvent]

	// realtime api event hooks
	onRealtimeConnectRequest   *hook.Hook[*RealtimeConnectRequestEvent]
	onRealtimeMessageSend      *hook.Hook[*RealtimeMessageEvent]
	onRealtimeSubscribeRequest *hook.Hook[*RealtimeSubscribeRequestEvent]

	// settings event hooks
	onSettingsListRequest   *hook.Hook[*SettingsListRequestEvent]
	onSettingsUpdateRequest *hook.Hook[*SettingsUpdateRequestEvent]
	onSettingsReload        *hook.Hook[*SettingsReloadEvent]

	// file api event hooks
	onFileDownloadRequest *hook.Hook[*FileDownloadRequestEvent]
	onFileTokenRequest    *hook.Hook[*FileTokenRequestEvent]

	// record auth API event hooks
	onRecordAuthRequest                 *hook.Hook[*RecordAuthRequestEvent]
	onRecordAuthWithPasswordRequest     *hook.Hook[*RecordAuthWithPasswordRequestEvent]
	onRecordAuthWithOAuth2Request       *hook.Hook[*RecordAuthWithOAuth2RequestEvent]
	onRecordAuthRefreshRequest          *hook.Hook[*RecordAuthRefreshRequestEvent]
	onRecordRequestPasswordResetRequest *hook.Hook[*RecordRequestPasswordResetRequestEvent]
	onRecordConfirmPasswordResetRequest *hook.Hook[*RecordConfirmPasswordResetRequestEvent]
	onRecordRequestVerificationRequest  *hook.Hook[*RecordRequestVerificationRequestEvent]
	onRecordConfirmVerificationRequest  *hook.Hook[*RecordConfirmVerificationRequestEvent]
	onRecordRequestEmailChangeRequest   *hook.Hook[*RecordRequestEmailChangeRequestEvent]
	onRecordConfirmEmailChangeRequest   *hook.Hook[*RecordConfirmEmailChangeRequestEvent]
	onRecordRequestOTPRequest           *hook.Hook[*RecordCreateOTPRequestEvent]
	onRecordAuthWithOTPRequest          *hook.Hook[*RecordAuthWithOTPRequestEvent]

	// record crud API event hooks
	onRecordsListRequest  *hook.Hook[*RecordsListRequestEvent]
	onRecordViewRequest   *hook.Hook[*RecordRequestEvent]
	onRecordCreateRequest *hook.Hook[*RecordRequestEvent]
	onRecordUpdateRequest *hook.Hook[*RecordRequestEvent]
	onRecordDeleteRequest *hook.Hook[*RecordRequestEvent]

	// collection API event hooks
	onCollectionsListRequest   *hook.Hook[*CollectionsListRequestEvent]
	onCollectionViewRequest    *hook.Hook[*CollectionRequestEvent]
	onCollectionCreateRequest  *hook.Hook[*CollectionRequestEvent]
	onCollectionUpdateRequest  *hook.Hook[*CollectionRequestEvent]
	onCollectionDeleteRequest  *hook.Hook[*CollectionRequestEvent]
	onCollectionsImportRequest *hook.Hook[*CollectionsImportRequestEvent]

	onBatchRequest *hook.Hook[*BatchRequestEvent]
}

// NewBaseApp creates and returns a new BaseApp instance
// configured with the provided arguments.
//
// To initialize the app, you need to call `app.Bootstrap()`.
func NewBaseApp(config BaseAppConfig) *BaseApp {
	app := &BaseApp{
		settings:            newDefaultSettings(),
		store:               store.New[any](nil),
		cron:                cron.New(),
		subscriptionsBroker: subscriptions.NewBroker(),
		config:              &config,
	}

	// apply config defaults
	if app.config.DBConnect == nil {
		app.config.DBConnect = dbConnect
	}
	if app.config.DataMaxOpenConns <= 0 {
		app.config.DataMaxOpenConns = DefaultDataMaxOpenConns
	}
	if app.config.DataMaxIdleConns <= 0 {
		app.config.DataMaxIdleConns = DefaultDataMaxIdleConns
	}
	if app.config.AuxMaxOpenConns <= 0 {
		app.config.AuxMaxOpenConns = DefaultAuxMaxOpenConns
	}
	if app.config.AuxMaxIdleConns <= 0 {
		app.config.AuxMaxIdleConns = DefaultAuxMaxIdleConns
	}
	if app.config.QueryTimeout <= 0 {
		app.config.QueryTimeout = DefaultQueryTimeout
	}

	app.initHooks()
	app.registerBaseHooks()

	return app
}

// initHooks initializes all app hook handlers.
func (app *BaseApp) initHooks() {
	// app event hooks
	app.onBootstrap = &hook.Hook[*BootstrapEvent]{}
	app.onServe = &hook.Hook[*ServeEvent]{}
	app.onTerminate = &hook.Hook[*TerminateEvent]{}
	app.onBackupCreate = &hook.Hook[*BackupEvent]{}
	app.onBackupRestore = &hook.Hook[*BackupEvent]{}

	// db model hooks
	app.onModelValidate = &hook.Hook[*ModelEvent]{}
	app.onModelCreate = &hook.Hook[*ModelEvent]{}
	app.onModelCreateExecute = &hook.Hook[*ModelEvent]{}
	app.onModelAfterCreateSuccess = &hook.Hook[*ModelEvent]{}
	app.onModelAfterCreateError = &hook.Hook[*ModelErrorEvent]{}
	app.onModelUpdate = &hook.Hook[*ModelEvent]{}
	app.onModelUpdateWrite = &hook.Hook[*ModelEvent]{}
	app.onModelAfterUpdateSuccess = &hook.Hook[*ModelEvent]{}
	app.onModelAfterUpdateError = &hook.Hook[*ModelErrorEvent]{}
	app.onModelDelete = &hook.Hook[*ModelEvent]{}
	app.onModelDeleteExecute = &hook.Hook[*ModelEvent]{}
	app.onModelAfterDeleteSuccess = &hook.Hook[*ModelEvent]{}
	app.onModelAfterDeleteError = &hook.Hook[*ModelErrorEvent]{}

	// db record hooks
	app.onRecordEnrich = &hook.Hook[*RecordEnrichEvent]{}
	app.onRecordValidate = &hook.Hook[*RecordEvent]{}
	app.onRecordCreate = &hook.Hook[*RecordEvent]{}
	app.onRecordCreateExecute = &hook.Hook[*RecordEvent]{}
	app.onRecordAfterCreateSuccess = &hook.Hook[*RecordEvent]{}
	app.onRecordAfterCreateError = &hook.Hook[*RecordErrorEvent]{}
	app.onRecordUpdate = &hook.Hook[*RecordEvent]{}
	app.onRecordUpdateExecute = &hook.Hook[*RecordEvent]{}
	app.onRecordAfterUpdateSuccess = &hook.Hook[*RecordEvent]{}
	app.onRecordAfterUpdateError = &hook.Hook[*RecordErrorEvent]{}
	app.onRecordDelete = &hook.Hook[*RecordEvent]{}
	app.onRecordDeleteExecute = &hook.Hook[*RecordEvent]{}
	app.onRecordAfterDeleteSuccess = &hook.Hook[*RecordEvent]{}
	app.onRecordAfterDeleteError = &hook.Hook[*RecordErrorEvent]{}

	// db collection hooks
	app.onCollectionValidate = &hook.Hook[*CollectionEvent]{}
	app.onCollectionCreate = &hook.Hook[*CollectionEvent]{}
	app.onCollectionCreateExecute = &hook.Hook[*CollectionEvent]{}
	app.onCollectionAfterCreateSuccess = &hook.Hook[*CollectionEvent]{}
	app.onCollectionAfterCreateError = &hook.Hook[*CollectionErrorEvent]{}
	app.onCollectionUpdate = &hook.Hook[*CollectionEvent]{}
	app.onCollectionUpdateExecute = &hook.Hook[*CollectionEvent]{}
	app.onCollectionAfterUpdateSuccess = &hook.Hook[*CollectionEvent]{}
	app.onCollectionAfterUpdateError = &hook.Hook[*CollectionErrorEvent]{}
	app.onCollectionDelete = &hook.Hook[*CollectionEvent]{}
	app.onCollectionAfterDeleteSuccess = &hook.Hook[*CollectionEvent]{}
	app.onCollectionDeleteExecute = &hook.Hook[*CollectionEvent]{}
	app.onCollectionAfterDeleteError = &hook.Hook[*CollectionErrorEvent]{}

	// mailer event hooks
	app.onMailerSend = &hook.Hook[*MailerEvent]{}
	app.onMailerRecordPasswordResetSend = &hook.Hook[*MailerRecordEvent]{}
	app.onMailerRecordVerificationSend = &hook.Hook[*MailerRecordEvent]{}
	app.onMailerRecordEmailChangeSend = &hook.Hook[*MailerRecordEvent]{}
	app.onMailerRecordOTPSend = &hook.Hook[*MailerRecordEvent]{}
	app.onMailerRecordAuthAlertSend = &hook.Hook[*MailerRecordEvent]{}

	// realtime API event hooks
	app.onRealtimeConnectRequest = &hook.Hook[*RealtimeConnectRequestEvent]{}
	app.onRealtimeMessageSend = &hook.Hook[*RealtimeMessageEvent]{}
	app.onRealtimeSubscribeRequest = &hook.Hook[*RealtimeSubscribeRequestEvent]{}

	// settings event hooks
	app.onSettingsListRequest = &hook.Hook[*SettingsListRequestEvent]{}
	app.onSettingsUpdateRequest = &hook.Hook[*SettingsUpdateRequestEvent]{}
	app.onSettingsReload = &hook.Hook[*SettingsReloadEvent]{}

	// file API event hooks
	app.onFileDownloadRequest = &hook.Hook[*FileDownloadRequestEvent]{}
	app.onFileTokenRequest = &hook.Hook[*FileTokenRequestEvent]{}

	// record auth API event hooks
	app.onRecordAuthRequest = &hook.Hook[*RecordAuthRequestEvent]{}
	app.onRecordAuthWithPasswordRequest = &hook.Hook[*RecordAuthWithPasswordRequestEvent]{}
	app.onRecordAuthWithOAuth2Request = &hook.Hook[*RecordAuthWithOAuth2RequestEvent]{}
	app.onRecordAuthRefreshRequest = &hook.Hook[*RecordAuthRefreshRequestEvent]{}
	app.onRecordRequestPasswordResetRequest = &hook.Hook[*RecordRequestPasswordResetRequestEvent]{}
	app.onRecordConfirmPasswordResetRequest = &hook.Hook[*RecordConfirmPasswordResetRequestEvent]{}
	app.onRecordRequestVerificationRequest = &hook.Hook[*RecordRequestVerificationRequestEvent]{}
	app.onRecordConfirmVerificationRequest = &hook.Hook[*RecordConfirmVerificationRequestEvent]{}
	app.onRecordRequestEmailChangeRequest = &hook.Hook[*RecordRequestEmailChangeRequestEvent]{}
	app.onRecordConfirmEmailChangeRequest = &hook.Hook[*RecordConfirmEmailChangeRequestEvent]{}
	app.onRecordRequestOTPRequest = &hook.Hook[*RecordCreateOTPRequestEvent]{}
	app.onRecordAuthWithOTPRequest = &hook.Hook[*RecordAuthWithOTPRequestEvent]{}

	// record crud API event hooks
	app.onRecordsListRequest = &hook.Hook[*RecordsListRequestEvent]{}
	app.onRecordViewRequest = &hook.Hook[*RecordRequestEvent]{}
	app.onRecordCreateRequest = &hook.Hook[*RecordRequestEvent]{}
	app.onRecordUpdateRequest = &hook.Hook[*RecordRequestEvent]{}
	app.onRecordDeleteRequest = &hook.Hook[*RecordRequestEvent]{}

	// collection API event hooks
	app.onCollectionsListRequest = &hook.Hook[*CollectionsListRequestEvent]{}
	app.onCollectionViewRequest = &hook.Hook[*CollectionRequestEvent]{}
	app.onCollectionCreateRequest = &hook.Hook[*CollectionRequestEvent]{}
	app.onCollectionUpdateRequest = &hook.Hook[*CollectionRequestEvent]{}
	app.onCollectionDeleteRequest = &hook.Hook[*CollectionRequestEvent]{}
	app.onCollectionsImportRequest = &hook.Hook[*CollectionsImportRequestEvent]{}

	app.onBatchRequest = &hook.Hook[*BatchRequestEvent]{}
}

// @todo consider caching the created instance?
//
// UnsafeWithoutHooks returns a shallow copy of the current app WITHOUT any registered hooks.
//
// NB! Note that using the returned app instance may cause data integrity errors
// since the Record validations and data normalizations (including files uploads)
// rely on the app hooks to work.
func (app *BaseApp) UnsafeWithoutHooks() App {
	clone := *app

	// reset all hook handlers
	clone.initHooks()

	return &clone
}

// Logger returns the default app logger.
//
// If the application is not bootstrapped yet, fallbacks to slog.Default().
func (app *BaseApp) Logger() *slog.Logger {
	if app.logger == nil {
		return slog.Default()
	}

	return app.logger
}

// IsTransactional checks if the current app instance is part of a transaction.
func (app *BaseApp) IsTransactional() bool {
	return app.txInfo != nil
}

// IsBootstrapped checks if the application was initialized
// (aka. whether Bootstrap() was called).
func (app *BaseApp) IsBootstrapped() bool {
	return app.concurrentDB != nil && app.auxConcurrentDB != nil
}

// Bootstrap initializes the application
// (aka. create data dir, open db connections, load settings, etc.).
//
// It will call ResetBootstrapState() if the application was already bootstrapped.
func (app *BaseApp) Bootstrap() error {
	event := &BootstrapEvent{}
	event.App = app

	err := app.OnBootstrap().Trigger(event, func(e *BootstrapEvent) error {
		// clear resources of previous core state (if any)
		if err := app.ResetBootstrapState(); err != nil {
			return err
		}

		// ensure that data dir exist
		if err := os.MkdirAll(app.DataDir(), os.ModePerm); err != nil {
			return err
		}

		if err := app.initDataDB(); err != nil {
			return err
		}

		if err := app.initAuxDB(); err != nil {
			return err
		}

		if err := app.initLogger(); err != nil {
			return err
		}

		if err := app.RunSystemMigrations(); err != nil {
			return err
		}

		if err := app.ReloadCachedCollections(); err != nil {
			return err
		}

		if err := app.ReloadSettings(); err != nil {
			return err
		}

		// try to cleanup the pb_data temp directory (if any)
		_ = os.RemoveAll(filepath.Join(app.DataDir(), LocalTempDirName))

		return nil
	})

	// add a more user friendly message in case users forgot to call
	// e.Next() as part of their bootstrap hook
	if err == nil && !app.IsBootstrapped() {
		app.Logger().Warn("OnBootstrap hook didn't fail but the app is still not bootstrapped - maybe missing e.Next()?")
	}

	return err
}

type closer interface {
	Close() error
}

// ResetBootstrapState releases the initialized core app resources
// (closing db connections, stopping cron ticker, etc.).
func (app *BaseApp) ResetBootstrapState() error {
	app.Cron().Stop()

	var errs []error

	dbs := []*dbx.Builder{
		&app.concurrentDB,
		&app.nonconcurrentDB,
		&app.auxConcurrentDB,
		&app.auxNonconcurrentDB,
	}

	for _, db := range dbs {
		if db == nil {
			continue
		}
		if v, ok := (*db).(closer); ok {
			if err := v.Close(); err != nil {
				errs = append(errs, err)
			}
		}
		*db = nil
	}

	if len(errs) > 0 {
		return errors.Join(errs...)
	}

	return nil
}

// DB returns the default app data db instance (pb_data/data.db).
func (app *BaseApp) DB() dbx.Builder {
	return app.concurrentDB
}

// NonconcurrentDB returns the nonconcurrent app data db instance (pb_data/data.db).
//
// The returned db instance is limited only to a single open connection,
// meaning that it can process only 1 db operation at a time (other operations will be queued up).
//
// This method is used mainly internally and in the tests to execute write
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
//
// For the majority of cases you would want to use the regular DB() method
// since it allows concurrent db read operations.
//
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
func (app *BaseApp) NonconcurrentDB() dbx.Builder {
	return app.nonconcurrentDB
}

// AuxDB returns the default app auxiliary db instance (pb_data/auxiliary.db).
func (app *BaseApp) AuxDB() dbx.Builder {
	return app.auxConcurrentDB
}

// AuxNonconcurrentDB returns the nonconcurrent app auxiliary db instance (pb_data/auxiliary.db).
//
// The returned db instance is limited only to a single open connection,
// meaning that it can process only 1 db operation at a time (other operations will be queued up).
//
// This method is used mainly internally and in the tests to execute write
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
//
// For the majority of cases you would want to use the regular DB() method
// since it allows concurrent db read operations.
//
// In a transaction the AuxNonconcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
func (app *BaseApp) AuxNonconcurrentDB() dbx.Builder {
	return app.auxNonconcurrentDB
}

// DataDir returns the app data directory path.
func (app *BaseApp) DataDir() string {
	return app.config.DataDir
}

// EncryptionEnv returns the name of the app secret env key
// (currently used primarily for optional settings encryption but this may change in the future).
func (app *BaseApp) EncryptionEnv() string {
	return app.config.EncryptionEnv
}

// IsDev returns whether the app is in dev mode.
//
// When enabled logs, executed sql statements, etc. are printed to the stderr.
func (app *BaseApp) IsDev() bool {
	return app.config.IsDev
}

// Settings returns the loaded app settings.
func (app *BaseApp) Settings() *Settings {
	return app.settings
}

// Store returns the app runtime store.
func (app *BaseApp) Store() *store.Store[any] {
	return app.store
}

// Cron returns the app cron instance.
func (app *BaseApp) Cron() *cron.Cron {
	return app.cron
}

// SubscriptionsBroker returns the app realtime subscriptions broker instance.
func (app *BaseApp) SubscriptionsBroker() *subscriptions.Broker {
	return app.subscriptionsBroker
}

// NewMailClient creates and returns a new SMTP or Sendmail client
// based on the current app settings.
func (app *BaseApp) NewMailClient() mailer.Mailer {
	var client mailer.Mailer

	// init mailer client
	if app.Settings().SMTP.Enabled {
		client = &mailer.SMTPClient{
			Host:       app.Settings().SMTP.Host,
			Port:       app.Settings().SMTP.Port,
			Username:   app.Settings().SMTP.Username,
			Password:   app.Settings().SMTP.Password,
			TLS:        app.Settings().SMTP.TLS,
			AuthMethod: app.Settings().SMTP.AuthMethod,
			LocalName:  app.Settings().SMTP.LocalName,
		}
	} else {
		client = &mailer.Sendmail{}
	}

	// register the app level hook
	if h, ok := client.(mailer.SendInterceptor); ok {
		h.OnSend().Bind(&hook.Handler[*mailer.SendEvent]{
			Id: "__pbMailerOnSend__",
			Func: func(e *mailer.SendEvent) error {
				appEvent := new(MailerEvent)
				appEvent.App = app
				appEvent.Mailer = client
				appEvent.Message = e.Message

				return app.OnMailerSend().Trigger(appEvent, func(ae *MailerEvent) error {
					e.Message = ae.Message

					// print the mail in the console to assist with the debugging
					if app.IsDev() {
						logDate := new(strings.Builder)
						log.New(logDate, "", log.LstdFlags).Print()

						mailLog := new(strings.Builder)
						mailLog.WriteString(strings.TrimSpace(logDate.String()))
						mailLog.WriteString(" Mail sent\n")
						fmt.Fprintf(mailLog, "├─ From: %v\n", ae.Message.From)
						fmt.Fprintf(mailLog, "├─ To: %v\n", ae.Message.To)
						fmt.Fprintf(mailLog, "├─ Cc: %v\n", ae.Message.Cc)
						fmt.Fprintf(mailLog, "├─ Bcc: %v\n", ae.Message.Bcc)
						fmt.Fprintf(mailLog, "├─ Subject: %v\n", ae.Message.Subject)

						if len(ae.Message.Attachments) > 0 {
							attachmentKeys := make([]string, 0, len(ae.Message.Attachments))
							for k := range ae.Message.Attachments {
								attachmentKeys = append(attachmentKeys, k)
							}
							fmt.Fprintf(mailLog, "├─ Attachments: %v\n", attachmentKeys)
						}

						const indentation = "        "
						if ae.Message.Text != "" {
							textParts := strings.Split(strings.TrimSpace(ae.Message.Text), "\n")
							textIndented := indentation + strings.Join(textParts, "\n"+indentation)
							fmt.Fprintf(mailLog, "└─ Text:\n%s", textIndented)
						} else {
							htmlParts := strings.Split(strings.TrimSpace(ae.Message.HTML), "\n")
							htmlIndented := indentation + strings.Join(htmlParts, "\n"+indentation)
							fmt.Fprintf(mailLog, "└─ HTML:\n%s", htmlIndented)
						}

						color.HiBlack("%s", mailLog.String())
					}

					// send the email with the new mailer in case it was replaced
					if client != ae.Mailer {
						return ae.Mailer.Send(e.Message)
					}

					return e.Next()
				})
			},
		})
	}

	return client
}

// NewFilesystem creates a new local or S3 filesystem instance
// for managing regular app files (ex. record uploads)
// based on the current app settings.
//
// NB! Make sure to call Close() on the returned result
// after you are done working with it.
func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
	if app.settings != nil && app.settings.S3.Enabled {
		return filesystem.NewS3(
			app.settings.S3.Bucket,
			app.settings.S3.Region,
			app.settings.S3.Endpoint,
			app.settings.S3.AccessKey,
			app.settings.S3.Secret,
			app.settings.S3.ForcePathStyle,
		)
	}

	// fallback to local filesystem
	return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalStorageDirName))
}

// NewFilesystem creates a new local or S3 filesystem instance
// for managing app backups based on the current app settings.
//
// NB! Make sure to call Close() on the returned result
// after you are done working with it.
func (app *BaseApp) NewBackupsFilesystem() (*filesystem.System, error) {
	if app.settings != nil && app.settings.Backups.S3.Enabled {
		return filesystem.NewS3(
			app.settings.Backups.S3.Bucket,
			app.settings.Backups.S3.Region,
			app.settings.Backups.S3.Endpoint,
			app.settings.Backups.S3.AccessKey,
			app.settings.Backups.S3.Secret,
			app.settings.Backups.S3.ForcePathStyle,
		)
	}

	// fallback to local filesystem
	return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalBackupsDirName))
}

// Restart restarts (aka. replaces) the current running application process.
//
// NB! It relies on execve which is supported only on UNIX based systems.
func (app *BaseApp) Restart() error {
	if runtime.GOOS == "windows" {
		return errors.New("restart is not supported on windows")
	}

	execPath, err := os.Executable()
	if err != nil {
		return err
	}

	event := &TerminateEvent{}
	event.App = app
	event.IsRestart = true

	return app.OnTerminate().Trigger(event, func(e *TerminateEvent) error {
		_ = e.App.ResetBootstrapState()

		// attempt to restart the bootstrap process in case execve returns an error for some reason
		defer func() {
			if err := e.App.Bootstrap(); err != nil {
				app.Logger().Error("Failed to rebootstrap the application after failed app.Restart()", "error", err)
			}
		}()

		return syscall.Exec(execPath, os.Args, os.Environ())
	})
}

// RunSystemMigrations applies all new migrations registered in the [core.SystemMigrations] list.
func (app *BaseApp) RunSystemMigrations() error {
	_, err := NewMigrationsRunner(app, SystemMigrations).Up()
	return err
}

// RunAppMigrations applies all new migrations registered in the [core.AppMigrations] list.
func (app *BaseApp) RunAppMigrations() error {
	_, err := NewMigrationsRunner(app, AppMigrations).Up()
	return err
}

// RunAllMigrations applies all system and app migrations
// (aka. from both [core.SystemMigrations] and [core.AppMigrations]).
func (app *BaseApp) RunAllMigrations() error {
	list := MigrationsList{}
	list.Copy(SystemMigrations)
	list.Copy(AppMigrations)
	_, err := NewMigrationsRunner(app, list).Up()
	return err
}

// -------------------------------------------------------------------
// App event hooks
// -------------------------------------------------------------------

func (app *BaseApp) OnBootstrap() *hook.Hook[*BootstrapEvent] {
	return app.onBootstrap
}

func (app *BaseApp) OnServe() *hook.Hook[*ServeEvent] {
	return app.onServe
}

func (app *BaseApp) OnTerminate() *hook.Hook[*TerminateEvent] {
	return app.onTerminate
}

func (app *BaseApp) OnBackupCreate() *hook.Hook[*BackupEvent] {
	return app.onBackupCreate
}

func (app *BaseApp) OnBackupRestore() *hook.Hook[*BackupEvent] {
	return app.onBackupRestore
}

// ---------------------------------------------------------------

func (app *BaseApp) OnModelCreate(tags ...string) *hook.TaggedHook[*ModelEvent] {
	return hook.NewTaggedHook(app.onModelCreate, tags...)
}

func (app *BaseApp) OnModelCreateExecute(tags ...string) *hook.TaggedHook[*ModelEvent] {
	return hook.NewTaggedHook(app.onModelCreateExecute, tags...)
}

func (app *BaseApp) OnModelAfterCreateSuccess(tags ...string) *hook.TaggedHook[*ModelEvent] {
	return hook.NewTaggedHook(app.onModelAfterCreateSuccess, tags...)
}

func (app *BaseApp) OnModelAfterCreateError(tags ...string) *hook.TaggedHook[*ModelErrorEvent] {
	return hook.NewTaggedHook(app.onModelAfterCreateError, tags...)
}

func (app *BaseApp) OnModelUpdate(tags ...string) *hook.TaggedHook[*ModelEvent] {
	return hook.NewTaggedHook(app.onModelUpdate, tags...)
}

func (app *BaseApp) OnModelUpdateExecute(tags ...string) *hook.TaggedHook[*ModelEvent] {
	return hook.NewTaggedHook(app.onModelUpdateWrite, tags...)
}

func (app *BaseApp) OnModelAfterUpdateSuccess(tags ...string) *hook.TaggedHook[*ModelEvent] {
	return hook.NewTaggedHook(app.onModelAfterUpdateSuccess, tags...)
}

func (app *BaseApp) OnModelAfterUpdateError(tags ...string) *hook.TaggedHook[*ModelErrorEvent] {
	return hook.NewTaggedHook(app.onModelAfterUpdateError, tags...)
}

func (app *BaseApp) OnModelValidate(tags ...string) *hook.TaggedHook[*ModelEvent] {
	return hook.NewTaggedHook(app.onModelValidate, tags...)
}

func (app *BaseApp) OnModelDelete(tags ...string) *hook.TaggedHook[*ModelEvent] {
	return hook.NewTaggedHook(app.onModelDelete, tags...)
}

func (app *BaseApp) OnModelDeleteExecute(tags ...string) *hook.TaggedHook[*ModelEvent] {
	return hook.NewTaggedHook(app.onModelDeleteExecute, tags...)
}

func (app *BaseApp) OnModelAfterDeleteSuccess(tags ...string) *hook.TaggedHook[*ModelEvent] {
	return hook.NewTaggedHook(app.onModelAfterDeleteSuccess, tags...)
}

func (app *BaseApp) OnModelAfterDeleteError(tags ...string) *hook.TaggedHook[*ModelErrorEvent] {
	return hook.NewTaggedHook(app.onModelAfterDeleteError, tags...)
}

func (app *BaseApp) OnRecordEnrich(tags ...string) *hook.TaggedHook[*RecordEnrichEvent] {
	return hook.NewTaggedHook(app.onRecordEnrich, tags...)
}

func (app *BaseApp) OnRecordValidate(tags ...string) *hook.TaggedHook[*RecordEvent] {
	return hook.NewTaggedHook(app.onRecordValidate, tags...)
}

func (app *BaseApp) OnRecordCreate(tags ...string) *hook.TaggedHook[*RecordEvent] {
	return hook.NewTaggedHook(app.onRecordCreate, tags...)
}

func (app *BaseApp) OnRecordCreateExecute(tags ...string) *hook.TaggedHook[*RecordEvent] {
	return hook.NewTaggedHook(app.onRecordCreateExecute, tags...)
}

func (app *BaseApp) OnRecordAfterCreateSuccess(tags ...string) *hook.TaggedHook[*RecordEvent] {
	return hook.NewTaggedHook(app.onRecordAfterCreateSuccess, tags...)
}

func (app *BaseApp) OnRecordAfterCreateError(tags ...string) *hook.TaggedHook[*RecordErrorEvent] {
	return hook.NewTaggedHook(app.onRecordAfterCreateError, tags...)
}

func (app *BaseApp) OnRecordUpdate(tags ...string) *hook.TaggedHook[*RecordEvent] {
	return hook.NewTaggedHook(app.onRecordUpdate, tags...)
}

func (app *BaseApp) OnRecordUpdateExecute(tags ...string) *hook.TaggedHook[*RecordEvent] {
	return hook.NewTaggedHook(app.onRecordUpdateExecute, tags...)
}

func (app *BaseApp) OnRecordAfterUpdateSuccess(tags ...string) *hook.TaggedHook[*RecordEvent] {
	return hook.NewTaggedHook(app.onRecordAfterUpdateSuccess, tags...)
}

func (app *BaseApp) OnRecordAfterUpdateError(tags ...string) *hook.TaggedHook[*RecordErrorEvent] {
	return hook.NewTaggedHook(app.onRecordAfterUpdateError, tags...)
}

func (app *BaseApp) OnRecordDelete(tags ...string) *hook.TaggedHook[*RecordEvent] {
	return hook.NewTaggedHook(app.onRecordDelete, tags...)
}

func (app *BaseApp) OnRecordDeleteExecute(tags ...string) *hook.TaggedHook[*RecordEvent] {
	return hook.NewTaggedHook(app.onRecordDeleteExecute, tags...)
}

func (app *BaseApp) OnRecordAfterDeleteSuccess(tags ...string) *hook.TaggedHook[*RecordEvent] {
	return hook.NewTaggedHook(app.onRecordAfterDeleteSuccess, tags...)
}

func (app *BaseApp) OnRecordAfterDeleteError(tags ...string) *hook.TaggedHook[*RecordErrorEvent] {
	return hook.NewTaggedHook(app.onRecordAfterDeleteError, tags...)
}

func (app *BaseApp) OnCollectionValidate(tags ...string) *hook.TaggedHook[*CollectionEvent] {
	return hook.NewTaggedHook(app.onCollectionValidate, tags...)
}

func (app *BaseApp) OnCollectionCreate(tags ...string) *hook.TaggedHook[*CollectionEvent] {
	return hook.NewTaggedHook(app.onCollectionCreate, tags...)
}

func (app *BaseApp) OnCollectionCreateExecute(tags ...string) *hook.TaggedHook[*CollectionEvent] {
	return hook.NewTaggedHook(app.onCollectionCreateExecute, tags...)
}

func (app *BaseApp) OnCollectionAfterCreateSuccess(tags ...string) *hook.TaggedHook[*CollectionEvent] {
	return hook.NewTaggedHook(app.onCollectionAfterCreateSuccess, tags...)
}

func (app *BaseApp) OnCollectionAfterCreateError(tags ...string) *hook.TaggedHook[*CollectionErrorEvent] {
	return hook.NewTaggedHook(app.onCollectionAfterCreateError, tags...)
}

func (app *BaseApp) OnCollectionUpdate(tags ...string) *hook.TaggedHook[*CollectionEvent] {
	return hook.NewTaggedHook(app.onCollectionUpdate, tags...)
}

func (app *BaseApp) OnCollectionUpdateExecute(tags ...string) *hook.TaggedHook[*CollectionEvent] {
	return hook.NewTaggedHook(app.onCollectionUpdateExecute, tags...)
}

func (app *BaseApp) OnCollectionAfterUpdateSuccess(tags ...string) *hook.TaggedHook[*CollectionEvent] {
	return hook.NewTaggedHook(app.onCollectionAfterUpdateSuccess, tags...)
}

func (app *BaseApp) OnCollectionAfterUpdateError(tags ...string) *hook.TaggedHook[*CollectionErrorEvent] {
	return hook.NewTaggedHook(app.onCollectionAfterUpdateError, tags...)
}

func (app *BaseApp) OnCollectionDelete(tags ...string) *hook.TaggedHook[*CollectionEvent] {
	return hook.NewTaggedHook(app.onCollectionDelete, tags...)
}

func (app *BaseApp) OnCollectionDeleteExecute(tags ...string) *hook.TaggedHook[*CollectionEvent] {
	return hook.NewTaggedHook(app.onCollectionDeleteExecute, tags...)
}

func (app *BaseApp) OnCollectionAfterDeleteSuccess(tags ...string) *hook.TaggedHook[*CollectionEvent] {
	return hook.NewTaggedHook(app.onCollectionAfterDeleteSuccess, tags...)
}

func (app *BaseApp) OnCollectionAfterDeleteError(tags ...string) *hook.TaggedHook[*CollectionErrorEvent] {
	return hook.NewTaggedHook(app.onCollectionAfterDeleteError, tags...)
}

// -------------------------------------------------------------------
// Mailer event hooks
// -------------------------------------------------------------------

func (app *BaseApp) OnMailerSend() *hook.Hook[*MailerEvent] {
	return app.onMailerSend
}

func (app *BaseApp) OnMailerRecordPasswordResetSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
	return hook.NewTaggedHook(app.onMailerRecordPasswordResetSend, tags...)
}

func (app *BaseApp) OnMailerRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
	return hook.NewTaggedHook(app.onMailerRecordVerificationSend, tags...)
}

func (app *BaseApp) OnMailerRecordEmailChangeSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
	return hook.NewTaggedHook(app.onMailerRecordEmailChangeSend, tags...)
}

func (app *BaseApp) OnMailerRecordOTPSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
	return hook.NewTaggedHook(app.onMailerRecordOTPSend, tags...)
}

func (app *BaseApp) OnMailerRecordAuthAlertSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
	return hook.NewTaggedHook(app.onMailerRecordAuthAlertSend, tags...)
}

// -------------------------------------------------------------------
// Realtime API event hooks
// -------------------------------------------------------------------

func (app *BaseApp) OnRealtimeConnectRequest() *hook.Hook[*RealtimeConnectRequestEvent] {
	return app.onRealtimeConnectRequest
}

func (app *BaseApp) OnRealtimeMessageSend() *hook.Hook[*RealtimeMessageEvent] {
	return app.onRealtimeMessageSend
}

func (app *BaseApp) OnRealtimeSubscribeRequest() *hook.Hook[*RealtimeSubscribeRequestEvent] {
	return app.onRealtimeSubscribeRequest
}

// -------------------------------------------------------------------
// Settings API event hooks
// -------------------------------------------------------------------

func (app *BaseApp) OnSettingsListRequest() *hook.Hook[*SettingsListRequestEvent] {
	return app.onSettingsListRequest
}

func (app *BaseApp) OnSettingsUpdateRequest() *hook.Hook[*SettingsUpdateRequestEvent] {
	return app.onSettingsUpdateRequest
}

func (app *BaseApp) OnSettingsReload() *hook.Hook[*SettingsReloadEvent] {
	return app.onSettingsReload
}

// -------------------------------------------------------------------
// File API event hooks
// -------------------------------------------------------------------

func (app *BaseApp) OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadRequestEvent] {
	return hook.NewTaggedHook(app.onFileDownloadRequest, tags...)
}

func (app *BaseApp) OnFileTokenRequest() *hook.Hook[*FileTokenRequestEvent] {
	return app.onFileTokenRequest
}

// -------------------------------------------------------------------
// Record auth API event hooks
// -------------------------------------------------------------------

func (app *BaseApp) OnRecordAuthRequest(tags ...string) *hook.TaggedHook[*RecordAuthRequestEvent] {
	return hook.NewTaggedHook(app.onRecordAuthRequest, tags...)
}

func (app *BaseApp) OnRecordAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordRequestEvent] {
	return hook.NewTaggedHook(app.onRecordAuthWithPasswordRequest, tags...)
}

func (app *BaseApp) OnRecordAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2RequestEvent] {
	return hook.NewTaggedHook(app.onRecordAuthWithOAuth2Request, tags...)
}

func (app *BaseApp) OnRecordAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshRequestEvent] {
	return hook.NewTaggedHook(app.onRecordAuthRefreshRequest, tags...)
}

func (app *BaseApp) OnRecordRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetRequestEvent] {
	return hook.NewTaggedHook(app.onRecordRequestPasswordResetRequest, tags...)
}

func (app *BaseApp) OnRecordConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetRequestEvent] {
	return hook.NewTaggedHook(app.onRecordConfirmPasswordResetRequest, tags...)
}

func (app *BaseApp) OnRecordRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationRequestEvent] {
	return hook.NewTaggedHook(app.onRecordRequestVerificationRequest, tags...)
}

func (app *BaseApp) OnRecordConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationRequestEvent] {
	return hook.NewTaggedHook(app.onRecordConfirmVerificationRequest, tags...)
}

func (app *BaseApp) OnRecordRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordRequestEmailChangeRequestEvent] {
	return hook.NewTaggedHook(app.onRecordRequestEmailChangeRequest, tags...)
}

func (app *BaseApp) OnRecordConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeRequestEvent] {
	return hook.NewTaggedHook(app.onRecordConfirmEmailChangeRequest, tags...)
}

func (app *BaseApp) OnRecordRequestOTPRequest(tags ...string) *hook.TaggedHook[*RecordCreateOTPRequestEvent] {
	return hook.NewTaggedHook(app.onRecordRequestOTPRequest, tags...)
}

func (app *BaseApp) OnRecordAuthWithOTPRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithOTPRequestEvent] {
	return hook.NewTaggedHook(app.onRecordAuthWithOTPRequest, tags...)
}

// -------------------------------------------------------------------
// Record CRUD API event hooks
// -------------------------------------------------------------------

func (app *BaseApp) OnRecordsListRequest(tags ...string) *hook.TaggedHook[*RecordsListRequestEvent] {
	return hook.NewTaggedHook(app.onRecordsListRequest, tags...)
}

func (app *BaseApp) OnRecordViewRequest(tags ...string) *hook.TaggedHook[*RecordRequestEvent] {
	return hook.NewTaggedHook(app.onRecordViewRequest, tags...)
}

func (app *BaseApp) OnRecordCreateRequest(tags ...string) *hook.TaggedHook[*RecordRequestEvent] {
	return hook.NewTaggedHook(app.onRecordCreateRequest, tags...)
}

func (app *BaseApp) OnRecordUpdateRequest(tags ...string) *hook.TaggedHook[*RecordRequestEvent] {
	return hook.NewTaggedHook(app.onRecordUpdateRequest, tags...)
}

func (app *BaseApp) OnRecordDeleteRequest(tags ...string) *hook.TaggedHook[*RecordRequestEvent] {
	return hook.NewTaggedHook(app.onRecordDeleteRequest, tags...)
}

// -------------------------------------------------------------------
// Collection API event hooks
// -------------------------------------------------------------------

func (app *BaseApp) OnCollectionsListRequest() *hook.Hook[*CollectionsListRequestEvent] {
	return app.onCollectionsListRequest
}

func (app *BaseApp) OnCollectionViewRequest() *hook.Hook[*CollectionRequestEvent] {
	return app.onCollectionViewRequest
}

func (app *BaseApp) OnCollectionCreateRequest() *hook.Hook[*CollectionRequestEvent] {
	return app.onCollectionCreateRequest
}

func (app *BaseApp) OnCollectionUpdateRequest() *hook.Hook[*CollectionRequestEvent] {
	return app.onCollectionUpdateRequest
}

func (app *BaseApp) OnCollectionDeleteRequest() *hook.Hook[*CollectionRequestEvent] {
	return app.onCollectionDeleteRequest
}

func (app *BaseApp) OnCollectionsImportRequest() *hook.Hook[*CollectionsImportRequestEvent] {
	return app.onCollectionsImportRequest
}

func (app *BaseApp) OnBatchRequest() *hook.Hook[*BatchRequestEvent] {
	return app.onBatchRequest
}

// -------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------

func (app *BaseApp) initDataDB() error {
	dbPath := filepath.Join(app.DataDir(), "data.db")

	concurrentDB, err := app.config.DBConnect(dbPath)
	if err != nil {
		return err
	}
	concurrentDB.DB().SetMaxOpenConns(app.config.DataMaxOpenConns)
	concurrentDB.DB().SetMaxIdleConns(app.config.DataMaxIdleConns)
	concurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)

	nonconcurrentDB, err := app.config.DBConnect(dbPath)
	if err != nil {
		return err
	}
	nonconcurrentDB.DB().SetMaxOpenConns(1)
	nonconcurrentDB.DB().SetMaxIdleConns(1)
	nonconcurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)

	if app.IsDev() {
		nonconcurrentDB.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
			color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), normalizeSQLLog(sql))
		}
		nonconcurrentDB.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
			color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), normalizeSQLLog(sql))
		}
		concurrentDB.QueryLogFunc = nonconcurrentDB.QueryLogFunc
		concurrentDB.ExecLogFunc = nonconcurrentDB.ExecLogFunc
	}

	app.concurrentDB = concurrentDB
	app.nonconcurrentDB = nonconcurrentDB

	return nil
}

var sqlLogReplacements = map[string]string{
	"{{":    "`",
	"}}":    "`",
	"[[":    "`",
	"]]":    "`",
	"<nil>": "NULL",
}
var sqlLogPrefixedTableIdentifierPattern = regexp.MustCompile(`\[\[(.+)\.(.+)\]\]`)
var sqlLogPrefixedColumnIdentifierPattern = regexp.MustCompile(`\{\{(.+)\.(.+)\}\}`)

// normalizeSQLLog replaces common query builder charactes with their plain SQL version for easier debugging.
// The query is still not suitable for execution and should be used only for log and debug purposes
// (the normalization is done here to avoid breaking changes in dbx).
func normalizeSQLLog(sql string) string {
	sql = sqlLogPrefixedTableIdentifierPattern.ReplaceAllString(sql, "`$1`.`$2`")

	sql = sqlLogPrefixedColumnIdentifierPattern.ReplaceAllString(sql, "`$1`.`$2`")

	for old, new := range sqlLogReplacements {
		sql = strings.ReplaceAll(sql, old, new)
	}

	return sql
}

func (app *BaseApp) initAuxDB() error {
	// note: renamed to "auxiliary" because "aux" is a reserved Windows filename
	// (see https://github.com/pocketbase/pocketbase/issues/5607)
	dbPath := filepath.Join(app.DataDir(), "auxiliary.db")

	concurrentDB, err := app.config.DBConnect(dbPath)
	if err != nil {
		return err
	}
	concurrentDB.DB().SetMaxOpenConns(app.config.AuxMaxOpenConns)
	concurrentDB.DB().SetMaxIdleConns(app.config.AuxMaxIdleConns)
	concurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)

	nonconcurrentDB, err := app.config.DBConnect(dbPath)
	if err != nil {
		return err
	}
	nonconcurrentDB.DB().SetMaxOpenConns(1)
	nonconcurrentDB.DB().SetMaxIdleConns(1)
	nonconcurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)

	app.auxConcurrentDB = concurrentDB
	app.auxNonconcurrentDB = nonconcurrentDB

	return nil
}

func (app *BaseApp) registerBaseHooks() {
	deletePrefix := func(prefix string) error {
		fs, err := app.NewFilesystem()
		if err != nil {
			return err
		}
		defer fs.Close()

		failed := fs.DeletePrefix(prefix)
		if len(failed) > 0 {
			return errors.New("failed to delete the files at " + prefix)
		}

		return nil
	}

	// try to delete the storage files from deleted Collection, Records, etc. model
	app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*ModelEvent]{
		Id: "__pbFilesManagerDelete__",
		Func: func(e *ModelEvent) error {
			if m, ok := e.Model.(FilesManager); ok && m.BaseFilesPath() != "" {
				// ensure that there is a trailing slash so that the list iterator could start walking from the prefix dir
				// (https://github.com/pocketbase/pocketbase/discussions/5246#discussioncomment-10128955)
				prefix := strings.TrimRight(m.BaseFilesPath(), "/") + "/"

				// run in the background for "optimistic" delete to avoid
				// blocking the delete transaction
				routine.FireAndForget(func() {
					if err := deletePrefix(prefix); err != nil {
						app.Logger().Error(
							"Failed to delete storage prefix (non critical error; usually could happen because of S3 api limits)",
							slog.String("prefix", prefix),
							slog.String("error", err.Error()),
						)
					}
				})
			}

			return e.Next()
		},
		Priority: -99,
	})

	app.OnServe().Bind(&hook.Handler[*ServeEvent]{
		Id: "__pbCronStart__",
		Func: func(e *ServeEvent) error {
			app.Cron().Start()

			return e.Next()
		},
		Priority: 999,
	})

	app.Cron().Add("__pbPragmaOptimize__", "0 0 * * *", func() {
		_, execErr := app.DB().NewQuery("PRAGMA optimize").Execute()
		if execErr != nil {
			app.Logger().Warn("Failed to run periodic PRAGMA optimize", slog.String("error", execErr.Error()))
		}
	})

	app.registerSettingsHooks()
	app.registerAutobackupHooks()
	app.registerCollectionHooks()
	app.registerRecordHooks()
	app.registerSuperuserHooks()
	app.registerExternalAuthHooks()
	app.registerMFAHooks()
	app.registerOTPHooks()
	app.registerAuthOriginHooks()
}

// getLoggerMinLevel returns the logger min level based on the
// app configurations (dev mode, settings, etc.).
//
// If not in dev mode - returns the level from the app settings.
//
// If the app is in dev mode it returns -9999 level allowing to print
// practically all logs to the terminal.
// In this case DB logs are still filtered but the checks for the min level are done
// in the BatchOptions.BeforeAddFunc instead of the slog.Handler.Enabled() method.
func getLoggerMinLevel(app App) slog.Level {
	var minLevel slog.Level

	if app.IsDev() {
		minLevel = -99999
	} else if app.Settings() != nil {
		minLevel = slog.Level(app.Settings().Logs.MinLevel)
	}

	return minLevel
}

func (app *BaseApp) initLogger() error {
	duration := 3 * time.Second
	ticker := time.NewTicker(duration)
	done := make(chan bool)

	handler := logger.NewBatchHandler(logger.BatchOptions{
		Level:     getLoggerMinLevel(app),
		BatchSize: 200,
		BeforeAddFunc: func(ctx context.Context, log *logger.Log) bool {
			if app.IsDev() {
				printLog(log)

				// manually check the log level and skip if necessary
				if log.Level < slog.Level(app.Settings().Logs.MinLevel) {
					return false
				}
			}

			ticker.Reset(duration)

			return app.Settings().Logs.MaxDays > 0
		},
		WriteFunc: func(ctx context.Context, logs []*logger.Log) error {
			if !app.IsBootstrapped() || app.Settings().Logs.MaxDays == 0 {
				return nil
			}

			// write the accumulated logs
			// (note: based on several local tests there is no significant performance difference between small number of separate write queries vs 1 big INSERT)
			app.AuxRunInTransaction(func(txApp App) error {
				model := &Log{}
				for _, l := range logs {
					model.MarkAsNew()
					model.Id = GenerateDefaultRandomId()
					model.Level = int(l.Level)
					model.Message = l.Message
					model.Data = l.Data
					model.Created, _ = types.ParseDateTime(l.Time)

					if err := txApp.AuxSave(model); err != nil {
						log.Println("Failed to write log", model, err)
					}
				}

				return nil
			})

			return nil
		},
	})

	go func() {
		ctx := context.Background()

		for {
			select {
			case <-done:
				return
			case <-ticker.C:
				handler.WriteAll(ctx)
			}
		}
	}()

	app.logger = slog.New(handler)

	// write all remaining logs before ticker.Stop to avoid races with ResetBootstrap user calls
	app.OnTerminate().Bind(&hook.Handler[*TerminateEvent]{
		Id: "__pbAppLoggerOnTerminate__",
		Func: func(e *TerminateEvent) error {
			handler.WriteAll(context.Background())

			ticker.Stop()

			done <- true

			return e.Next()
		},
		Priority: -999,
	})

	// reload log handler level (if initialized)
	app.OnSettingsReload().Bind(&hook.Handler[*SettingsReloadEvent]{
		Id: "__pbAppLoggerOnSettingsReload__",
		Func: func(e *SettingsReloadEvent) error {
			err := e.Next()
			if err != nil {
				return err
			}

			if e.App.Logger() != nil {
				if h, ok := e.App.Logger().Handler().(*logger.BatchHandler); ok {
					h.SetLevel(getLoggerMinLevel(e.App))
				}
			}

			// try to clear old logs not matching the new settings
			createdBefore := types.NowDateTime().AddDate(0, 0, -1*e.App.Settings().Logs.MaxDays)
			expr := dbx.NewExp("[[created]] <= {:date} OR [[level]] < {:level}", dbx.Params{
				"date":  createdBefore.String(),
				"level": e.App.Settings().Logs.MinLevel,
			})
			_, err = e.App.AuxNonconcurrentDB().Delete((&Log{}).TableName(), expr).Execute()
			if err != nil {
				e.App.Logger().Debug("Failed to cleanup old logs", "error", err)
			}

			// no logs are allowed -> try to reclaim preserved disk space after the previous delete operation
			if e.App.Settings().Logs.MaxDays == 0 {
				err = e.App.AuxVacuum()
				if err != nil {
					e.App.Logger().Debug("Failed to VACUUM aux database", "error", err)
				}
			}

			return nil
		},
		Priority: -999,
	})

	// cleanup old logs
	app.Cron().Add("__pbLogsCleanup__", "0 */6 * * *", func() {
		deleteErr := app.DeleteOldLogs(time.Now().AddDate(0, 0, -1*app.Settings().Logs.MaxDays))
		if deleteErr != nil {
			app.Logger().Warn("Failed to delete old logs", "error", deleteErr)
		}
	})

	return nil
}