package main

import (
	"database/sql"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/meilisearch/meilisearch-go"
	"github.com/pocketbase/dbx"
	"github.com/pocketbase/pocketbase"
	"github.com/pocketbase/pocketbase/apis"
	"github.com/pocketbase/pocketbase/core"
	"github.com/pocketbase/pocketbase/plugins/migratecmd"
	"github.com/pocketbase/pocketbase/tools/filesystem"
	"github.com/pocketbase/pocketbase/tools/security"
	"github.com/spf13/cast"

	"pocketbase/commands"
	"pocketbase/federation"
	"pocketbase/integrations/komoot"
	"pocketbase/integrations/strava"

	_ "pocketbase/migrations"
	"pocketbase/util"

	pub "github.com/go-ap/activitypub"
	"github.com/microcosm-cc/bluemonday"
)

const (
	defaultPocketBaseEncryptionKey = "fde406459dc1f6ca6f348e1f44a9a2af"
	defaultMeiliMasterKey          = "vODkljPcfFANYNepCHyDyGjzAMPcdHnrb6X5KyXQPWo"
)

// verifySettings checks if the required environment variables are set.
// If they are not set, it logs a warning.
func verifySettings(app core.App) {
	encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")

	if len(encryptionKey) != 32 {
		// terminate if the encryption key is not set or is not exactly 32 bytes long,
		// as this is a requirement for PocketBase to function properly.
		log.Fatal("POCKETBASE_ENCRYPTION_KEY must be exactly 32 bytes long- See https://wanderer.to/run/installation/#prerequisites for more information")
	}

	if encryptionKey == defaultPocketBaseEncryptionKey {
		app.Logger().Warn("POCKETBASE_ENCRYPTION_KEY is still set to the default value. Please change it to a secure value")
	}

	meiliMasterKey := os.Getenv("MEILI_MASTER_KEY")

	if len(meiliMasterKey) < 32 {
		app.Logger().Warn("MEILI_MASTER_KEY not set or is shorter than 32 bytes")
	}

	if meiliMasterKey == defaultMeiliMasterKey {
		app.Logger().Warn("MEILI_MASTER_KEY is still set to the default value. Please change it to a secure value")
	}
}

func main() {

	app := pocketbase.New()
	client := initializeMeiliSearch()

	verifySettings(app)

	registerMigrations(app)
	setupEventHandlers(app, client)

	setupCommands(app)

	if err := app.Start(); err != nil {
		log.Fatal(err)
	}
}

func initializeMeiliSearch() meilisearch.ServiceManager {
	return meilisearch.New(
		os.Getenv("MEILI_URL"),
		meilisearch.WithAPIKey(os.Getenv("MEILI_MASTER_KEY")),
	)
}

func registerMigrations(app *pocketbase.PocketBase) {
	migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
		Dir:         "migrations",
		Automigrate: true,
	})
}

func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceManager) {
	app.OnRecordAfterCreateSuccess("users").BindFunc(createUserHandler(client))

	app.OnRecordAfterCreateSuccess("trails").BindFunc(createTrailHandler(client))
	app.OnRecordAfterUpdateSuccess("trails").BindFunc(updateTrailHandler(client))
	app.OnRecordAfterDeleteSuccess("trails").BindFunc(deleteTrailHandler(client))

	app.OnRecordCreateRequest("summit_logs").BindFunc(createSummitLogHandler(client))
	app.OnRecordUpdateRequest("summit_logs").BindFunc(updateSummitLogHandler())
	app.OnRecordDeleteRequest("summit_logs").BindFunc(deleteSummitLogHandler(client))

	app.OnRecordCreateRequest("comments").BindFunc(createCommentHandler())
	app.OnRecordUpdateRequest("comments").BindFunc(updateCommentHandler())
	app.OnRecordDeleteRequest("comments").BindFunc(deleteCommentHandler(client))

	app.OnRecordCreateRequest("trail_share").BindFunc(createTrailShareHandler(client))
	app.OnRecordDeleteRequest("trail_share").BindFunc(deleteTrailShareHandler(client))

	app.OnRecordAfterCreateSuccess("trail_like").BindFunc(createTrailLikeHandler(client))
	app.OnRecordAfterDeleteSuccess("trail_like").BindFunc(deleteTrailLikeHandler(client))

	app.OnRecordAfterCreateSuccess("lists").BindFunc(createListHandler(client))
	app.OnRecordAfterUpdateSuccess("lists").BindFunc(updateListHandler(client))
	app.OnRecordAfterDeleteSuccess("lists").BindFunc(deleteListHandler(client))

	app.OnRecordCreateRequest("list_share").BindFunc(createListShareHandler(client))
	app.OnRecordDeleteRequest("list_share").BindFunc(deleteListShareHandler(client))

	app.OnRecordCreateRequest("follows").BindFunc(createFollowHandler())
	app.OnRecordDeleteRequest("follows").BindFunc(deleteFollowHandler())

	app.OnRecordsListRequest("integrations").BindFunc(listIntegrationHandler())
	app.OnRecordCreate("integrations").BindFunc(createIntegrationHandler())
	app.OnRecordAfterCreateSuccess("integrations").BindFunc(createUpdateIntegrationSuccessHandler())
	app.OnRecordUpdate("integrations").BindFunc(updateIntegrationHandler())
	app.OnRecordAfterUpdateSuccess("integrations").BindFunc(createUpdateIntegrationSuccessHandler())

	app.OnRecordsListRequest("feed", "profile_feed").BindFunc(listFeedHandler())

	app.OnRecordCreateRequest().BindFunc(sanitizeHTML())
	app.OnRecordUpdateRequest().BindFunc(sanitizeHTML())

	app.OnRecordRequestEmailChangeRequest("users").BindFunc(changeUserEmailHandler())
	app.OnServe().BindFunc(onBeforeServeHandler(client))

	app.OnBootstrap().BindFunc(onBootstrapHandler())
}

func setupCommands(app *pocketbase.PocketBase) {
	app.RootCmd.AddCommand(commands.Dedup(app))
}

func sanitizeHTML() func(e *core.RecordRequestEvent) error {
	return func(e *core.RecordRequestEvent) error {
		fieldsToSanitize := map[string][]string{
			"lists":       {"description"},
			"settings":    {"bio"},
			"summit_logs": {"text"},
			"trails":      {"description"},
			"comments":    {"text"},
			"waypoints":   {"description"},
		}
		collection := e.Collection.Name
		fields, ok := fieldsToSanitize[collection]
		if !ok {
			return e.Next()
		}

		p := bluemonday.NewPolicy()
		p.AllowStandardAttributes()
		p.AllowStandardURLs()
		p.AllowLists()
		p.AllowElements("br", "div", "hr", "p", "span", "wbr")
		p.AllowElements("b", "strong", "em", "u", "blockquote", "a")
		p.AllowAttrs("href").OnElements("a")
		p.AllowAttrs("target").OnElements("a")
		p.AllowAttrs("class").OnElements("a")

		for _, field := range fields {
			if val, ok := e.Record.Get(field).(string); ok {
				sanitizedValue := p.Sanitize(val)
				e.Record.Set(field, sanitizedValue)
			}
		}

		return e.Next()
	}
}

func createUserHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
	return func(e *core.RecordEvent) error {
		userId := e.Record.Id

		err := createDefaultUserSettings(e.App, e.Record.Id)
		if err != nil {
			return err
		}

		actor, err := util.ActorFromUser(e.App, e.Record)
		if err != nil {
			return err
		}

		searchRules := map[string]interface{}{
			"lists": map[string]string{
				"filter": "public = true OR author = " + actor.Id + " OR shares = " + userId,
			},
			"trails": map[string]string{
				"filter": "public = true OR author = " + actor.Id + " OR shares = " + userId,
			},
		}

		token, err := util.GenerateMeilisearchToken(searchRules, client)
		if err != nil {
			return err
		}
		e.Record.Set("token", token)
		if err := e.App.Save(e.Record); err != nil {
			return err
		}

		return e.Next()
	}
}

func createDefaultUserSettings(app core.App, userId string) error {
	collection, err := app.FindCollectionByNameOrId("settings")
	if err != nil {
		return err
	}
	settings := core.NewRecord(collection)
	settings.Set("language", "en")
	settings.Set("unit", "metric")
	settings.Set("mapFocus", "trails")
	settings.Set("user", userId)
	return app.Save(settings)
}

func createTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
	return func(e *core.RecordEvent) error {
		record := e.Record
		author, err := e.App.FindRecordById("activitypub_actors", record.GetString(("author")))
		if err != nil {
			return err
		}
		if err := util.IndexTrails(e.App, []*core.Record{record}, client); err != nil {
			return err
		}
		if !author.GetBool("isLocal") {
			// this happens if someone fetches a remote trail
			// we create a stub trail record for later reference
			// no need to create an activity for that
			return e.Next()
		}

		err = e.Next()
		if err != nil {
			return err
		}

		err = federation.CreateTrailActivity(e.App, author, e.Record, pub.CreateType)
		if err != nil {
			return err
		}

		_, err = util.InsertIntoFeed(e.App, author.Id, author.Id, record.Id, util.TrailFeed)
		if err != nil {
			return err
		}

		return nil
	}
}

func updateTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
	return func(e *core.RecordEvent) error {
		record := e.Record
		author, err := e.App.FindRecordById("activitypub_actors", record.GetString(("author")))
		if err != nil {
			return err
		}
		err = util.UpdateTrail(e.App, record, author, client)
		if err != nil {
			return err
		}
		if !author.GetBool("isLocal") {
			// this happens if someone fetches a remote trail
			// we create a stub trail record for later reference
			// no need to create an activity for that
			return e.Next()
		}

		err = e.Next()
		if err != nil {
			return err
		}

		err = federation.CreateTrailActivity(e.App, author, e.Record, pub.UpdateType)
		if err != nil {
			return err
		}

		return nil
	}
}

func deleteTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
	return func(e *core.RecordEvent) error {
		record := e.Record
		task, err := client.Index("trails").DeleteDocument(record.Id)
		if err != nil {
			return err
		}

		interval := 500 * time.Millisecond
		_, err = client.WaitForTask(task.TaskUID, interval)
		if err != nil {
			log.Fatalf("Error waiting for task completion: %v", err)
		}

		err = federation.CreateTrailDeleteActivity(e.App, e.Record)
		if err != nil {
			return err
		}

		err = util.DeleteFromFeed(e.App, record.Id)
		if err != nil {
			return err
		}

		return e.Next()
	}
}

func createSummitLogHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
	return func(e *core.RecordRequestEvent) error {

		err := e.Next()
		if err != nil {
			return err
		}

		userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
		if err != nil {
			return err
		}

		trail, err := e.App.FindRecordById("trails", e.Record.GetString("trail"))
		if err != nil {
			return err
		}

		if err := util.IndexTrails(e.App, []*core.Record{trail}, client); err != nil {
			return err
		}

		err = federation.CreateSummitLogActivity(e.App, userActor, e.Record, pub.CreateType)
		if err != nil {
			return err
		}

		return nil
	}
}

func updateSummitLogHandler() func(e *core.RecordRequestEvent) error {
	return func(e *core.RecordRequestEvent) error {

		err := e.Next()
		if err != nil {
			return err
		}

		userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
		if err != nil {
			return err
		}

		err = federation.CreateSummitLogActivity(e.App, userActor, e.Record, pub.UpdateType)
		if err != nil {
			return err
		}
		return nil
	}
}

func deleteSummitLogHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
	return func(e *core.RecordRequestEvent) error {
		err := e.Next()
		if err != nil {
			return err
		}

		trail, err := e.App.FindRecordById("trails", e.Record.GetString("trail"))
		if err != nil {
			return err
		}

		if err := util.IndexTrails(e.App, []*core.Record{trail}, client); err != nil {
			return err
		}

		err = federation.CreateSummitLogDeleteActivity(e.App, e.Record)
		if err != nil {
			return err
		}
		return nil
	}
}

func createCommentHandler() func(e *core.RecordRequestEvent) error {
	return func(e *core.RecordRequestEvent) error {

		e.Next()

		userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
		if err != nil {
			return err
		}

		err = federation.CreateCommentActivity(e.App, userActor, e.Record, pub.CreateType)
		if err != nil {
			return err
		}
		return nil
	}
}

func updateCommentHandler() func(e *core.RecordRequestEvent) error {
	return func(e *core.RecordRequestEvent) error {
		userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
		if err != nil {
			return err
		}

		err = federation.CreateCommentActivity(e.App, userActor, e.Record, pub.UpdateType)
		if err != nil {
			return err
		}
		return e.Next()

	}
}

func deleteCommentHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
	return func(e *core.RecordRequestEvent) error {

		err := federation.CreateCommentDeleteActivity(e.App, client, e.Record)
		if err != nil {
			return err
		}
		return e.Next()
	}
}

func createTrailShareHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
	return func(e *core.RecordRequestEvent) error {
		err := e.Next()
		if err != nil {
			return err
		}

		record := e.Record

		trailId := record.GetString("trail")
		shares, err := e.App.FindAllRecords("trail_share",
			dbx.NewExp("trail = {:trailId}", dbx.Params{"trailId": trailId}),
		)
		if err != nil {
			return err
		}
		actorIds := make([]string, len(shares))
		for i, r := range shares {
			actorIds[i] = r.GetString("actor")
		}
		err = util.UpdateTrailShares(trailId, actorIds, client)
		if err != nil {
			return err
		}

		err = federation.CreateAnnounceActivity(e.App, record, federation.TrailAnnounceType)
		if err != nil {
			return err
		}

		return nil
	}
}

func deleteTrailShareHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
	return func(e *core.RecordRequestEvent) error {
		record := e.Record

		trailId := record.GetString("trail")
		err := util.UpdateTrailShares(trailId, []string{}, client)
		if err != nil {
			return err
		}
		return e.Next()
	}
}

func createTrailLikeHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
	return func(e *core.RecordEvent) error {
		err := e.Next()
		if err != nil {
			return err
		}

		record := e.Record

		trailId := record.GetString("trail")
		actorId := record.GetString("actor")
		actor, err := e.App.FindRecordById("activitypub_actors", actorId)
		if err != nil {
			return err
		}
		trail, err := e.App.FindRecordById("trails", trailId)
		if err != nil {
			return err
		}
		likes, err := e.App.FindAllRecords("trail_like",
			dbx.NewExp("trail = {:trailId}", dbx.Params{"trailId": trailId}),
		)
		if err != nil {
			return err
		}

		trail.Set("like_count", len(likes))
		err = e.App.UnsafeWithoutHooks().Save(trail)
		if err != nil {
			return err
		}

		actorIds := make([]string, len(likes))
		for i, r := range likes {
			actorIds[i] = r.GetString("actor")
		}
		err = util.UpdateTrailLikes(trailId, actorIds, client)
		if err != nil {
			return err
		}

		if !actor.GetBool("isLocal") {
			// this happens if someone likes a remote trail
			// we create a local copy
			// no need to create an activity for that
			return nil
		}

		err = federation.CreateLikeActivity(e.App, record)
		if err != nil {
			return err
		}

		return nil
	}
}

func deleteTrailLikeHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
	return func(e *core.RecordEvent) error {

		record := e.Record

		trailId := record.GetString("trail")
		actorId := record.GetString("actor")
		actor, err := e.App.FindRecordById("activitypub_actors", actorId)
		if err != nil {
			return err
		}
		// trail might deleted be already if this is called as part of a cascade
		trail, err := e.App.FindRecordById("trails", trailId)
		if err != nil && err == sql.ErrNoRows {
			return nil
		} else if err != nil {
			return err
		}
		likes, err := e.App.CountRecords("trail_like", dbx.NewExp("trail={:trail}", dbx.Params{"trail": trailId}))
		if err != nil {
			return err
		}

		trail.Set("like_count", likes)
		err = e.App.UnsafeWithoutHooks().Save(trail)
		if err != nil {
			return err
		}

		err = util.UpdateTrailLikes(trailId, []string{}, client)
		if err != nil {
			return err
		}

		if !actor.GetBool("isLocal") {
			// this happens if someone likes a remote trail
			// we create a local copy
			// no need to create an activity for that
			return nil
		}

		err = federation.CreateUnlikeActivity(e.App, record)
		if err != nil {
			return err
		}

		return e.Next()
	}
}

func createListHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
	return func(e *core.RecordEvent) error {
		record := e.Record

		author, err := e.App.FindRecordById("activitypub_actors", record.GetString(("author")))
		if err != nil {
			return err
		}

		if err := util.IndexLists(e.App, []*core.Record{record}, client); err != nil {
			return err
		}

		if !author.GetBool("isLocal") {
			// this happens if someone fetches a remote list
			// we create a stub list record for later reference
			// no need to create an activity for that
			return e.Next()
		}

		err = e.Next()
		if err != nil {
			return err
		}

		err = federation.CreateListActivity(e.App, e.Record, pub.CreateType)
		if err != nil {
			return err
		}

		_, err = util.InsertIntoFeed(e.App, author.Id, author.Id, record.Id, util.ListFeed)
		if err != nil {
			return err
		}

		return nil
	}
}

func updateListHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
	return func(e *core.RecordEvent) error {
		record := e.Record
		author, err := e.App.FindRecordById("activitypub_actors", record.GetString(("author")))
		if err != nil {
			return err
		}

		err = util.UpdateList(e.App, record, author, client)
		if err != nil {
			return err
		}

		if !author.GetBool("isLocal") {
			// this happens if someone fetches a remote list
			// we create a stub list record for later reference
			// no need to create an activity for that
			return e.Next()
		}

		err = e.Next()
		if err != nil {
			return err
		}

		err = federation.CreateListActivity(e.App, e.Record, pub.CreateType)
		if err != nil {
			return err
		}

		return nil
	}
}

func deleteListHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
	return func(e *core.RecordEvent) error {
		record := e.Record
		_, err := client.Index("lists").DeleteDocument(record.Id)
		if err != nil {
			return err
		}

		err = federation.CreateListDeleteActivity(e.App, record)
		if err != nil {
			return err
		}

		err = util.DeleteFromFeed(e.App, record.Id)
		if err != nil {
			return err
		}

		return e.Next()
	}
}

func createListShareHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
	return func(e *core.RecordRequestEvent) error {
		err := e.Next()
		if err != nil {
			return err
		}

		record := e.Record
		listId := record.GetString("list")
		shares, err := e.App.FindAllRecords("list_share",
			dbx.NewExp("list = {:listId}", dbx.Params{"listId": listId}),
		)
		if err != nil {
			return err
		}
		actorIds := make([]string, len(shares))
		for i, r := range shares {
			actorIds[i] = r.GetString("actor")
		}
		err = util.UpdateListShares(listId, actorIds, client)

		if err != nil {
			return err
		}

		err = federation.CreateAnnounceActivity(e.App, record, federation.ListAnnounceType)
		if err != nil {
			return err
		}

		return nil
	}
}

func deleteListShareHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
	return func(e *core.RecordRequestEvent) error {
		record := e.Record
		listId := record.GetString("list")
		err := util.UpdateListShares(listId, []string{}, client)
		if err != nil {
			return err
		}
		return e.Next()
	}
}

func createFollowHandler() func(e *core.RecordRequestEvent) error {
	return func(e *core.RecordRequestEvent) error {
		e.Next()
		federation.CreateFollowActivity(e.App, e.Record)

		return nil
	}
}

func deleteFollowHandler() func(e *core.RecordRequestEvent) error {
	return func(e *core.RecordRequestEvent) error {
		federation.CreateUnfollowActivity(e.App, e.Record)

		return e.Next()
	}
}

func listIntegrationHandler() func(e *core.RecordsListRequestEvent) error {
	return func(e *core.RecordsListRequestEvent) error {
		if e.HasSuperuserAuth() {
			return e.Next()
		}
		for _, r := range e.Records {

			err := censorIntegrationSecrets(r)
			if err != nil {
				return err
			}
		}

		return e.Next()
	}
}

func createIntegrationHandler() func(e *core.RecordEvent) error {
	return func(e *core.RecordEvent) error {
		err := encryptIntegrationSecrets(e.App, e.Record)
		if err != nil {
			return err
		}

		return e.Next()
	}
}

func createUpdateIntegrationSuccessHandler() func(e *core.RecordEvent) error {
	return func(e *core.RecordEvent) error {
		err := censorIntegrationSecrets(e.Record)
		if err != nil {
			return err
		}
		return e.Next()
	}
}

func updateIntegrationHandler() func(e *core.RecordEvent) error {
	return func(e *core.RecordEvent) error {
		err := encryptIntegrationSecrets(e.App, e.Record)
		if err != nil {
			return err
		}

		return e.Next()
	}
}
func censorIntegrationSecrets(r *core.Record) error {
	secrets := map[string][]string{
		"strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"},
		"komoot": {"password"},
	}
	for key, secretKeys := range secrets {
		if integrationString := r.GetString(key); integrationString != "" {
			var integration map[string]interface{}
			if err := json.Unmarshal([]byte(integrationString), &integration); err != nil {
				return err
			}
			if integration == nil {
				continue
			}
			for _, secretKey := range secretKeys {
				integration[secretKey] = ""
			}
			b, err := json.Marshal(integration)
			if err != nil {
				return err
			}
			r.Set(key, string(b))
		}
	}

	return nil
}

func encryptIntegrationSecrets(app core.App, r *core.Record) error {
	encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
	if len(encryptionKey) == 0 {
		return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
	}

	secrets := map[string][]string{
		"strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"},
		"komoot": {"password"},
	}

	original, _ := app.FindRecordById("integrations", r.Id)

	for key, secretKeys := range secrets {
		if integrationString := r.GetString(key); integrationString != "" {
			var integration map[string]interface{}
			if err := json.Unmarshal([]byte(integrationString), &integration); err != nil {
				return err
			}

			for _, secretKey := range secretKeys {
				// If the secret is already encrypted, we don't re-encrypt it.
				// TODO: This is a bit of a hack, we should handle this in a more robust way (e.g.
				// storing flag on the record or prefixing encrypted strings with enc: or smilar).
				// Doing that would also potentially allow us to support key rotation in the future.
				if secret, ok := integration[secretKey].(string); ok && len(secret) > 0 && !util.CanDecryptSecret(secret) {
					encryptedSecret, err := security.Encrypt([]byte(secret), encryptionKey)
					if err != nil {
						return err
					}
					integration[secretKey] = encryptedSecret
				} else if original != nil {

					originalString := original.GetString(key)
					var originalIntegration map[string]interface{}
					if err := json.Unmarshal([]byte(originalString), &originalIntegration); err != nil {
						return err
					}
					if integration == nil {
						continue
					}
					integration[secretKey] = originalIntegration[secretKey]
				}
			}

			b, err := json.Marshal(integration)
			if err != nil {
				return err
			}
			r.Set(key, string(b))
		}
	}

	return nil
}

func changeUserEmailHandler() func(e *core.RecordRequestEmailChangeRequestEvent) error {
	return func(e *core.RecordRequestEmailChangeRequestEvent) error {

		e.Record.Set("email", e.NewEmail)
		if err := e.App.Save(e.Record); err != nil {
			return err
		}
		return nil
	}
}

func listFeedHandler() func(e *core.RecordsListRequestEvent) error {
	return func(e *core.RecordsListRequestEvent) error {

		for _, r := range e.Records {
			var item *core.Record
			var err error

			typ := r.GetString("type")
			typ = strings.Trim(typ, "\"")

			itemId := r.GetString("item")
			itemId = strings.Trim(itemId, "\"")

			switch typ {
			case string(util.TrailFeed):
				item, err = e.App.FindRecordById("trails", itemId)
			case string(util.ListFeed):
				item, err = e.App.FindRecordById("lists", itemId)
			case string(util.SummitLogFeed):
				item, err = e.App.FindRecordById("summit_logs", itemId)
			}

			if err != nil {
				continue
			}

			if item == nil {
				continue
			}

			errs := e.App.ExpandRecord(item, []string{"author"}, nil)
			if len(errs) > 0 {
				return fmt.Errorf("failed to expand author: %v", errs)
			}

			if typ == string(util.TrailFeed) {
				errs := e.App.ExpandRecord(item, []string{"category"}, nil)
				if len(errs) > 0 {
					return fmt.Errorf("failed to expand category: %v", errs)
				}
			}

			r.MergeExpand(map[string]any{"item": item})
		}
		return e.Next()
	}
}

func onBeforeServeHandler(client meilisearch.ServiceManager) func(se *core.ServeEvent) error {
	return func(se *core.ServeEvent) error {
		registerRoutes(se, client)
		registerCronJobs(se.App)
		bootstrapData(se.App, client)

		return se.Next()
	}

}

func onBootstrapHandler() func(se *core.BootstrapEvent) error {
	return func(e *core.BootstrapEvent) error {
		if err := e.Next(); err != nil {
			return err
		}

		if e.App.Settings().Meta.AppName == "Acme" {
			e.App.Settings().Meta.AppName = "wanderer"
		}
		if v := os.Getenv("ORIGIN"); v != "" {
			e.App.Settings().Meta.AppURL = v
		}
		if v := os.Getenv("POCKETBASE_SMTP_SENDER_ADRESS"); v != "" {
			e.App.Settings().Meta.SenderAddress = v
		}
		if v := os.Getenv("POCKETBASE_SMTP_SENDER_NAME"); v != "" {
			e.App.Settings().Meta.SenderName = v
		}
		if v := os.Getenv("POCKETBASE_SMTP_ENABLED"); v != "" {
			e.App.Settings().SMTP.Enabled = cast.ToBool(v)
		}
		if v := os.Getenv("POCKETBASE_SMTP_HOST"); v != "" {
			e.App.Settings().SMTP.Host = v
		}
		if v := os.Getenv("POCKETBASE_SMTP_PORT"); v != "" {
			e.App.Settings().SMTP.Port = cast.ToInt(v)
		}
		if v := os.Getenv("POCKETBASE_SMTP_USERNAME"); v != "" {
			e.App.Settings().SMTP.Username = v
		}
		if v := os.Getenv("POCKETBASE_SMTP_PASSWORD"); v != "" {
			e.App.Settings().SMTP.Password = v
		}

		return e.App.Save(e.App.Settings())
	}
}

func registerRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) {
	se.Router.GET("/health", func(e *core.RequestEvent) error {
		return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
	})

	se.Router.GET("/public/search/token", func(e *core.RequestEvent) error {
		searchRules := map[string]interface{}{
			"lists": map[string]string{
				"filter": "public = true",
			},
			"trails": map[string]string{
				"filter": "public = true",
			},
		}
		token, err := util.GenerateMeilisearchToken(searchRules, client)
		if err != nil {
			return err
		}
		return e.JSON(http.StatusOK, map[string]string{"token": token})
	})

	se.Router.POST("/integration/strava/token", func(e *core.RequestEvent) error {
		encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
		if len(encryptionKey) == 0 {
			return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
		}

		var data strava.TokenRequest
		if err := e.BindBody(&data); err != nil {
			return apis.NewBadRequestError("Failed to read request data", err)
		}

		userId := ""
		if e.Auth != nil {
			userId = e.Auth.Id
		}

		integrations, err := e.App.FindAllRecords("integrations", dbx.NewExp("user = {:id}", dbx.Params{"id": userId}))
		if err != nil {
			return err
		}
		if len(integrations) == 0 {
			return apis.NewBadRequestError("user has no integration", nil)
		}
		integration := integrations[0]
		stravaString := integration.GetString("strava")
		if len(stravaString) == 0 {
			return apis.NewBadRequestError("strava integration missing", nil)
		}
		var stravaIntegration strava.StravaIntegration
		err = json.Unmarshal([]byte(stravaString), &stravaIntegration)
		if err != nil {
			return err
		}
		decryptedSecret, err := security.Decrypt(stravaIntegration.ClientSecret, encryptionKey)
		if err != nil {
			return err
		}

		request := strava.TokenRequest{
			ClientID:     stravaIntegration.ClientID,
			ClientSecret: string(decryptedSecret),
			Code:         data.Code,
			GrantType:    "authorization_code",
		}
		r, err := strava.GetStravaToken(request)
		if err != nil {
			return err
		}
		if r.AccessToken != "" {
			stravaIntegration.AccessToken = r.AccessToken
		}
		if r.RefreshToken != "" {
			stravaIntegration.RefreshToken = r.RefreshToken
		}
		if r.AccessToken != "" {
			stravaIntegration.ExpiresAt = r.ExpiresAt
		}

		stravaIntegration.Active = true

		b, err := json.Marshal(stravaIntegration)
		if err != nil {
			return err
		}
		integration.Set("strava", string(b))
		err = e.App.Save(integration)
		if err != nil {
			return err
		}
		return e.JSON(http.StatusOK, nil)
	})

	se.Router.GET("/integration/komoot/login", func(e *core.RequestEvent) error {
		encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
		if len(encryptionKey) == 0 {
			return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
		}

		userId := ""
		if e.Auth != nil {
			userId = e.Auth.Id
		}

		integrations, err := e.App.FindAllRecords("integrations", dbx.NewExp("user = {:id}", dbx.Params{"id": userId}))
		if err != nil {
			return err
		}
		if len(integrations) == 0 {
			return apis.NewBadRequestError("user has no integration", nil)
		}
		integration := integrations[0]
		komootString := integration.GetString("komoot")
		if len(komootString) == 0 {
			return apis.NewBadRequestError("komoot integration missing", nil)
		}
		var komootIntegration komoot.KomootIntegration
		err = json.Unmarshal([]byte(komootString), &komootIntegration)
		if err != nil {
			return err
		}
		decryptedPassword, err := security.Decrypt(komootIntegration.Password, encryptionKey)
		if err != nil {
			return err
		}

		k := &komoot.KomootApi{}

		err = k.Login(komootIntegration.Email, string(decryptedPassword))
		if err != nil {
			return apis.NewUnauthorizedError("invalid credentials", nil)
		}

		return e.JSON(http.StatusOK, nil)
	})
	se.Router.POST("/activitypub/activity/process", federation.ProcessActivity)
	se.Router.GET("/activitypub/actor", func(e *core.RequestEvent) error {
		resource := e.Request.URL.Query().Get("resource")
		resource = strings.TrimPrefix(resource, "acct:")

		iri := e.Request.URL.Query().Get("iri")
		follows := e.Request.URL.Query().Get("follows") == "true"

		var userActor *core.Record
		var err error
		if e.Auth != nil {
			userActor, err = e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
			if err != nil {
				return err
			}
		}

		var actor *core.Record
		if resource != "" {
			actor, err = federation.GetActorByHandle(e.App, userActor, resource, follows)
		} else {
			actor, err = federation.GetActorByIRI(e.App, userActor, iri, follows)
		}
		if err != nil && actor == nil {
			if strings.HasPrefix(err.Error(), "webfinger") {
				return e.NotFoundError("Not found", err)
			}
			return err
		} else if err != nil && actor != nil {
			if err.Error() == "profile is private" {
				// this is our own profile
				if e.Auth != nil && actor.GetString("user") == e.Auth.Id {
					return e.JSON(http.StatusOK, map[string]any{"actor": actor, "error": nil})
				} else {
					return e.JSON(http.StatusNotFound, map[string]any{"error": "profile is private"})
				}
			}
			// we could not fetch the remote actor so we return our local cached copy
			return e.JSON(http.StatusOK, map[string]any{"actor": actor, "error": err.Error()})
		}

		return e.JSON(http.StatusOK, map[string]any{"actor": actor, "error": nil})
	})
	se.Router.GET("/activitypub/actor/{id}/{follow}", func(e *core.RequestEvent) error {
		id := e.Request.PathValue("id")
		followType := e.Request.PathValue("follow")
		page := e.Request.URL.Query().Get("page")
		intPage := 0

		if page != "" {
			var err error
			intPage, err = strconv.Atoi(page)
			if err != nil {
				return err
			}
		}

		actor, err := e.App.FindRecordById("activitypub_actors", id)
		if err != nil {
			return err
		}

		var userActor *core.Record
		if e.Auth != nil {
			userActor, err = e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
			if err != nil {
				return err
			}
		}

		url := actor.GetString(followType)

		if url == "" {
			return e.BadRequestError("unknown type: "+followType, nil)
		}
		collection, err := federation.FetchCollection(userActor, fmt.Sprintf("%s?page=%d", url, intPage))
		if err != nil {
			if err.Error() == "profile is private" {
				return e.JSON(http.StatusNotFound, map[string]any{"error": "profile is private"})
			}
			return err
		}
		return e.JSON(http.StatusOK, collection)
	})
	se.Router.GET("/activitypub/trail/{id}", func(e *core.RequestEvent) error {
		id := e.Request.PathValue("id")

		trail, err := e.App.FindRecordById("trails", id)
		if err != nil {
			return err
		}

		trailObject, err := util.ObjectFromTrail(e.App, trail, nil)
		if err != nil {
			return err
		}
		return e.JSON(http.StatusOK, trailObject)
	})
	se.Router.GET("/activitypub/comment/{id}", func(e *core.RequestEvent) error {
		id := e.Request.PathValue("id")

		comment, err := e.App.FindRecordById("comments", id)
		if err != nil {
			return err
		}

		commentObject, err := util.ObjectFromComment(e.App, comment, nil)
		if err != nil {
			return err
		}
		return e.JSON(http.StatusOK, commentObject)
	})
}

func registerCronJobs(app core.App) {
	schedule := os.Getenv("POCKETBASE_CRON_SYNC_SCHEDULE")
	if len(schedule) == 0 {
		schedule = "0 2 * * *"
	}

	app.Cron().MustAdd("integrations", schedule, func() {
		err := strava.SyncStrava(app)
		if err != nil {
			warning := fmt.Sprintf("Error syncing with strava: %v", err)
			fmt.Println(warning)
			app.Logger().Error(warning)
		}
		err = komoot.SyncKomoot(app)
		if err != nil {
			warning := fmt.Sprintf("Error syncing with komoot: %v", err)
			fmt.Println(warning)
			app.Logger().Error(warning)
		}
	})
}

func bootstrapData(app core.App, client meilisearch.ServiceManager) error {
	bootstrapCategories(app)
	go bootstrapMeilisearchDocuments(app, client)
	return nil
}

func bootstrapCategories(app core.App) error {
	query := app.RecordQuery("categories")
	records := []*core.Record{}

	if err := query.All(&records); err != nil {
		return err
	}
	if len(records) == 0 {
		collection, _ := app.FindCollectionByNameOrId("categories")

		categories := []string{"Hiking", "Walking", "Climbing", "Skiing", "Canoeing", "Biking"}
		for _, element := range categories {
			record := core.NewRecord(collection)
			record.Set("name", element)
			f, _ := filesystem.NewFileFromPath("migrations/initial_data/" + strings.ToLower(element) + ".jpg")
			record.Set("img", f)
			err := app.Save(record)
			if err != nil {
				return err
			}
		}
	}
	return nil
}

func bootstrapMeilisearchDocuments(app core.App, client meilisearch.ServiceManager) error {
	// --- Trails ---
	const pageSize int64 = 100
	var page int64 = 0

	// Clear index before re-indexing
	if _, err := client.Index("trails").DeleteAllDocuments(); err != nil {
		return err
	}

	for {
		trails := []*core.Record{}
		err := app.RecordQuery("trails").
			Limit(pageSize).
			Offset(page * pageSize).
			All(&trails)
		if err != nil {
			return err
		}
		if len(trails) == 0 {
			break
		}

		if err := util.IndexTrails(app, trails, client); err != nil {
			app.Logger().Warn(fmt.Sprintf("Unable to index trails page %d: %v", page, err))
			continue
		}

		page++
	}

	// --- Lists ---
	if _, err := client.Index("lists").DeleteAllDocuments(); err != nil {
		return err
	}

	page = 0
	for {
		lists := []*core.Record{}
		err := app.RecordQuery("lists").
			Limit(pageSize).
			Offset(page * pageSize).
			All(&lists)
		if err != nil {
			return err
		}
		if len(lists) == 0 {
			break
		}

		if err := util.IndexLists(app, lists, client); err != nil {
			app.Logger().Warn(fmt.Sprintf("Unable to index list page %d: %v", page, err))
			continue
		}

		page++
	}

	return nil
}
