package apis

import (
	"context"
	"encoding/json"
	"errors"
	"log/slog"
	"net/http"
	"strings"
	"time"

	"github.com/labstack/echo/v5"
	"github.com/pocketbase/dbx"
	"github.com/pocketbase/pocketbase/core"
	"github.com/pocketbase/pocketbase/forms"
	"github.com/pocketbase/pocketbase/models"
	"github.com/pocketbase/pocketbase/resolvers"
	"github.com/pocketbase/pocketbase/tools/rest"
	"github.com/pocketbase/pocketbase/tools/routine"
	"github.com/pocketbase/pocketbase/tools/search"
	"github.com/pocketbase/pocketbase/tools/subscriptions"
	"github.com/spf13/cast"
)

// bindRealtimeApi registers the realtime api endpoints.
func bindRealtimeApi(app core.App, rg *echo.Group) {
	api := realtimeApi{app: app}

	subGroup := rg.Group("/realtime")
	subGroup.GET("", api.connect)
	subGroup.POST("", api.setSubscriptions, ActivityLogger(app))

	api.bindEvents()
}

type realtimeApi struct {
	app core.App
}

func (api *realtimeApi) connect(c echo.Context) error {
	cancelCtx, cancelRequest := context.WithCancel(c.Request().Context())
	defer cancelRequest()
	c.SetRequest(c.Request().Clone(cancelCtx))

	// register new subscription client
	client := subscriptions.NewDefaultClient()
	api.app.SubscriptionsBroker().Register(client)
	defer func() {
		disconnectEvent := &core.RealtimeDisconnectEvent{
			HttpContext: c,
			Client:      client,
		}

		if err := api.app.OnRealtimeDisconnectRequest().Trigger(disconnectEvent); err != nil {
			api.app.Logger().Debug(
				"OnRealtimeDisconnectRequest error",
				slog.String("clientId", client.Id()),
				slog.String("error", err.Error()),
			)
		}

		api.app.SubscriptionsBroker().Unregister(client.Id())
	}()

	c.Response().Header().Set("Content-Type", "text/event-stream")
	c.Response().Header().Set("Cache-Control", "no-store")
	// https://github.com/pocketbase/pocketbase/discussions/480#discussioncomment-3657640
	// https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering
	c.Response().Header().Set("X-Accel-Buffering", "no")

	connectEvent := &core.RealtimeConnectEvent{
		HttpContext: c,
		Client:      client,
		IdleTimeout: 5 * time.Minute,
	}

	if err := api.app.OnRealtimeConnectRequest().Trigger(connectEvent); err != nil {
		return err
	}

	api.app.Logger().Debug("Realtime connection established.", slog.String("clientId", client.Id()))

	// signalize established connection (aka. fire "connect" message)
	connectMsgEvent := &core.RealtimeMessageEvent{
		HttpContext: c,
		Client:      client,
		Message: &subscriptions.Message{
			Name: "PB_CONNECT",
			Data: []byte(`{"clientId":"` + client.Id() + `"}`),
		},
	}
	connectMsgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(connectMsgEvent, func(e *core.RealtimeMessageEvent) error {
		w := e.HttpContext.Response()
		w.Write([]byte("id:" + client.Id() + "\n"))
		w.Write([]byte("event:" + e.Message.Name + "\n"))
		w.Write([]byte("data:"))
		w.Write(e.Message.Data)
		w.Write([]byte("\n\n"))
		w.Flush()
		return api.app.OnRealtimeAfterMessageSend().Trigger(e)
	})
	if connectMsgErr != nil {
		api.app.Logger().Debug(
			"Realtime connection closed (failed to deliver PB_CONNECT)",
			slog.String("clientId", client.Id()),
			slog.String("error", connectMsgErr.Error()),
		)
		return nil
	}

	// start an idle timer to keep track of inactive/forgotten connections
	idleTimeout := connectEvent.IdleTimeout
	idleTimer := time.NewTimer(idleTimeout)
	defer idleTimer.Stop()

	for {
		select {
		case <-idleTimer.C:
			cancelRequest()
		case msg, ok := <-client.Channel():
			if !ok {
				// channel is closed
				api.app.Logger().Debug(
					"Realtime connection closed (closed channel)",
					slog.String("clientId", client.Id()),
				)
				return nil
			}

			msgEvent := &core.RealtimeMessageEvent{
				HttpContext: c,
				Client:      client,
				Message:     &msg,
			}
			msgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(msgEvent, func(e *core.RealtimeMessageEvent) error {
				w := e.HttpContext.Response()
				w.Write([]byte("id:" + e.Client.Id() + "\n"))
				w.Write([]byte("event:" + e.Message.Name + "\n"))
				w.Write([]byte("data:"))
				w.Write(e.Message.Data)
				w.Write([]byte("\n\n"))
				w.Flush()
				return api.app.OnRealtimeAfterMessageSend().Trigger(msgEvent)
			})
			if msgErr != nil {
				api.app.Logger().Debug(
					"Realtime connection closed (failed to deliver message)",
					slog.String("clientId", client.Id()),
					slog.String("error", msgErr.Error()),
				)
				return nil
			}

			idleTimer.Stop()
			idleTimer.Reset(idleTimeout)
		case <-c.Request().Context().Done():
			// connection is closed
			api.app.Logger().Debug(
				"Realtime connection closed (cancelled request)",
				slog.String("clientId", client.Id()),
			)
			return nil
		}
	}
}

// note: in case of reconnect, clients will have to resubmit all subscriptions again
func (api *realtimeApi) setSubscriptions(c echo.Context) error {
	form := forms.NewRealtimeSubscribe()

	// read request data
	if err := c.Bind(form); err != nil {
		return NewBadRequestError("", err)
	}

	// validate request data
	if err := form.Validate(); err != nil {
		return NewBadRequestError("", err)
	}

	// find subscription client
	client, err := api.app.SubscriptionsBroker().ClientById(form.ClientId)
	if err != nil {
		return NewNotFoundError("Missing or invalid client id.", err)
	}

	// check if the previous request was authorized
	oldAuthId := extractAuthIdFromGetter(client)
	newAuthId := extractAuthIdFromGetter(c)
	if oldAuthId != "" && oldAuthId != newAuthId {
		return NewForbiddenError("The current and the previous request authorization don't match.", nil)
	}

	event := &core.RealtimeSubscribeEvent{
		HttpContext:   c,
		Client:        client,
		Subscriptions: form.Subscriptions,
	}

	return api.app.OnRealtimeBeforeSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
		// update auth state
		e.Client.Set(ContextAdminKey, e.HttpContext.Get(ContextAdminKey))
		e.Client.Set(ContextAuthRecordKey, e.HttpContext.Get(ContextAuthRecordKey))

		// unsubscribe from any previous existing subscriptions
		e.Client.Unsubscribe()

		// subscribe to the new subscriptions
		e.Client.Subscribe(e.Subscriptions...)

		api.app.Logger().Debug(
			"Realtime subscriptions updated.",
			slog.String("clientId", e.Client.Id()),
			slog.Any("subscriptions", e.Subscriptions),
		)

		return api.app.OnRealtimeAfterSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
			if e.HttpContext.Response().Committed {
				return nil
			}

			return e.HttpContext.NoContent(http.StatusNoContent)
		})
	})
}

// updateClientsAuthModel updates the existing clients auth model with the new one (matched by ID).
func (api *realtimeApi) updateClientsAuthModel(contextKey string, newModel models.Model) error {
	for _, client := range api.app.SubscriptionsBroker().Clients() {
		clientModel, _ := client.Get(contextKey).(models.Model)
		if clientModel != nil &&
			clientModel.TableName() == newModel.TableName() &&
			clientModel.GetId() == newModel.GetId() {
			client.Set(contextKey, newModel)
		}
	}

	return nil
}

// unregisterClientsByAuthModel unregister all clients that has the provided auth model.
func (api *realtimeApi) unregisterClientsByAuthModel(contextKey string, model models.Model) error {
	for _, client := range api.app.SubscriptionsBroker().Clients() {
		clientModel, _ := client.Get(contextKey).(models.Model)
		if clientModel != nil &&
			clientModel.TableName() == model.TableName() &&
			clientModel.GetId() == model.GetId() {
			api.app.SubscriptionsBroker().Unregister(client.Id())
		}
	}

	return nil
}

func (api *realtimeApi) bindEvents() {
	// update the clients that has admin or auth record association
	api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
		if record := api.resolveRecord(e.Model); record != nil && record.Collection().IsAuth() {
			return api.updateClientsAuthModel(ContextAuthRecordKey, record)
		}

		if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
			return api.updateClientsAuthModel(ContextAdminKey, admin)
		}

		return nil
	})

	// remove the client(s) associated to the deleted admin or auth record
	api.app.OnModelAfterDelete().PreAdd(func(e *core.ModelEvent) error {
		if collection := api.resolveRecordCollection(e.Model); collection != nil && collection.IsAuth() {
			return api.unregisterClientsByAuthModel(ContextAuthRecordKey, e.Model)
		}

		if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
			return api.unregisterClientsByAuthModel(ContextAdminKey, admin)
		}

		return nil
	})

	api.app.OnModelAfterCreate().PreAdd(func(e *core.ModelEvent) error {
		if record := api.resolveRecord(e.Model); record != nil {
			if err := api.broadcastRecord("create", record, false); err != nil {
				api.app.Logger().Debug(
					"Failed to broadcast record create",
					slog.String("id", record.Id),
					slog.String("collectionName", record.Collection().Name),
					slog.String("error", err.Error()),
				)
			}
		}
		return nil
	})

	api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
		if record := api.resolveRecord(e.Model); record != nil {
			if err := api.broadcastRecord("update", record, false); err != nil {
				api.app.Logger().Debug(
					"Failed to broadcast record update",
					slog.String("id", record.Id),
					slog.String("collectionName", record.Collection().Name),
					slog.String("error", err.Error()),
				)
			}
		}
		return nil
	})

	api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error {
		if record := api.resolveRecord(e.Model); record != nil {
			if err := api.broadcastRecord("delete", record, true); err != nil {
				api.app.Logger().Debug(
					"Failed to dry cache record delete",
					slog.String("id", record.Id),
					slog.String("collectionName", record.Collection().Name),
					slog.String("error", err.Error()),
				)
			}
		}
		return nil
	})

	api.app.OnModelAfterDelete().Add(func(e *core.ModelEvent) error {
		if record := api.resolveRecord(e.Model); record != nil {
			if err := api.broadcastDryCachedRecord("delete", record); err != nil {
				api.app.Logger().Debug(
					"Failed to broadcast record delete",
					slog.String("id", record.Id),
					slog.String("collectionName", record.Collection().Name),
					slog.String("error", err.Error()),
				)
			}
		}
		return nil
	})
}

// resolveRecord converts *if possible* the provided model interface to a Record.
// This is usually helpful if the provided model is a custom Record model struct.
func (api *realtimeApi) resolveRecord(model models.Model) (record *models.Record) {
	record, _ = model.(*models.Record)

	// check if it is custom Record model struct (ignore "private" tables)
	if record == nil && !strings.HasPrefix(model.TableName(), "_") {
		record, _ = api.app.Dao().FindRecordById(model.TableName(), model.GetId())
	}

	return record
}

// resolveRecordCollection extracts *if possible* the Collection model from the provided model interface.
// This is usually helpful if the provided model is a custom Record model struct.
func (api *realtimeApi) resolveRecordCollection(model models.Model) (collection *models.Collection) {
	if record, ok := model.(*models.Record); ok {
		collection = record.Collection()
	} else if !strings.HasPrefix(model.TableName(), "_") {
		// check if it is custom Record model struct (ignore "private" tables)
		collection, _ = api.app.Dao().FindCollectionByNameOrId(model.TableName())
	}

	return collection
}

// recordData represents the broadcasted record subscrition message data.
type recordData struct {
	Record any    `json:"record"` /* map or models.Record */
	Action string `json:"action"`
}

func (api *realtimeApi) broadcastRecord(action string, record *models.Record, dryCache bool) error {
	collection := record.Collection()
	if collection == nil {
		return errors.New("[broadcastRecord] Record collection not set.")
	}

	clients := api.app.SubscriptionsBroker().Clients()
	if len(clients) == 0 {
		return nil // no subscribers
	}

	subscriptionRuleMap := map[string]*string{
		(collection.Name + "/" + record.Id + "?"): collection.ViewRule,
		(collection.Id + "/" + record.Id + "?"):   collection.ViewRule,
		(collection.Name + "/*?"):                 collection.ListRule,
		(collection.Id + "/*?"):                   collection.ListRule,
		// @deprecated: the same      as the wildcard topic but kept for backward compatibility
		(collection.Name + "?"): collection.ListRule,
		(collection.Id + "?"):   collection.ListRule,
	}

	dryCacheKey := action + "/" + record.Id

	for _, client := range clients {
		client := client

		// note: not executed concurrently to avoid races and to ensure
		// that the access checks are applied for the current record db state
		for prefix, rule := range subscriptionRuleMap {
			subs := client.Subscriptions(prefix)
			if len(subs) == 0 {
				continue
			}

			for sub, options := range subs {
				// create a clean record copy without expand and unknown fields
				// because we don't know yet which exact fields the client subscription has permissions to access
				cleanRecord := record.CleanCopy()

				// mock request data
				requestInfo := &models.RequestInfo{
					Context: models.RequestInfoContextRealtime,
					Method:  "GET",
					Query:   options.Query,
					Headers: options.Headers,
				}
				requestInfo.Admin, _ = client.Get(ContextAdminKey).(*models.Admin)
				requestInfo.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record)

				if !api.canAccessRecord(cleanRecord, requestInfo, rule) {
					continue
				}

				rawExpand := cast.ToString(options.Query[expandQueryParam])
				if rawExpand != "" {
					expandErrs := api.app.Dao().ExpandRecord(cleanRecord, strings.Split(rawExpand, ","), expandFetch(api.app.Dao(), requestInfo))
					if len(expandErrs) > 0 {
						api.app.Logger().Debug(
							"[broadcastRecord] expand errors",
							slog.String("id", cleanRecord.Id),
							slog.String("collectionName", cleanRecord.Collection().Name),
							slog.String("sub", sub),
							slog.String("expand", rawExpand),
							slog.Any("errors", expandErrs),
						)
					}
				}

				// ignore the auth record email visibility checks
				// for auth owner, admin or manager
				if collection.IsAuth() {
					authId := extractAuthIdFromGetter(client)
					if authId == cleanRecord.Id {
						if api.canAccessRecord(cleanRecord, requestInfo, collection.AuthOptions().ManageRule) {
							cleanRecord.IgnoreEmailVisibility(true)
						}
					}
				}

				data := &recordData{
					Action: action,
					Record: cleanRecord,
				}

				// check fields
				rawFields := cast.ToString(options.Query[fieldsQueryParam])
				if rawFields != "" {
					decoded, err := rest.PickFields(cleanRecord, rawFields)
					if err == nil {
						data.Record = decoded
					} else {
						api.app.Logger().Debug(
							"[broadcastRecord] pick fields error",
							slog.String("id", cleanRecord.Id),
							slog.String("collectionName", cleanRecord.Collection().Name),
							slog.String("sub", sub),
							slog.String("fields", rawFields),
							slog.String("error", err.Error()),
						)
					}
				}

				dataBytes, err := json.Marshal(data)
				if err != nil {
					api.app.Logger().Debug(
						"[broadcastRecord] data marshal error",
						slog.String("id", cleanRecord.Id),
						slog.String("collectionName", cleanRecord.Collection().Name),
						slog.String("error", err.Error()),
					)
					continue
				}

				msg := subscriptions.Message{
					Name: sub,
					Data: dataBytes,
				}

				if dryCache {
					messages, ok := client.Get(dryCacheKey).([]subscriptions.Message)
					if !ok {
						messages = []subscriptions.Message{msg}
					} else {
						messages = append(messages, msg)
					}
					client.Set(dryCacheKey, messages)
				} else {
					routine.FireAndForget(func() {
						client.Send(msg)
					})
				}
			}
		}
	}

	return nil
}

// broadcastDryCachedRecord broadcasts all cached record related messages.
func (api *realtimeApi) broadcastDryCachedRecord(action string, record *models.Record) error {
	key := action + "/" + record.Id

	clients := api.app.SubscriptionsBroker().Clients()

	for _, client := range clients {
		messages, ok := client.Get(key).([]subscriptions.Message)
		if !ok {
			continue
		}

		client.Unset(key)

		client := client

		routine.FireAndForget(func() {
			for _, msg := range messages {
				client.Send(msg)
			}
		})
	}

	return nil
}

type getter interface {
	Get(string) any
}

func extractAuthIdFromGetter(val getter) string {
	record, _ := val.Get(ContextAuthRecordKey).(*models.Record)
	if record != nil {
		return record.Id
	}

	admin, _ := val.Get(ContextAdminKey).(*models.Admin)
	if admin != nil {
		return admin.Id
	}

	return ""
}

// canAccessRecord checks if the subscription client has access to the specified record model.
func (api *realtimeApi) canAccessRecord(
	record *models.Record,
	requestInfo *models.RequestInfo,
	accessRule *string,
) bool {
	// check the access rule
	// ---
	if ok, _ := api.app.Dao().CanAccessRecord(record, requestInfo, accessRule); !ok {
		return false
	}

	// check the subscription client-side filter (if any)
	// ---
	filter := cast.ToString(requestInfo.Query[search.FilterQueryParam])
	if filter == "" {
		return true // no further checks needed
	}

	if err := checkForAdminOnlyRuleFields(requestInfo); err != nil {
		return false
	}

	ruleFunc := func(q *dbx.SelectQuery) error {
		resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestInfo, false)

		expr, err := search.FilterData(filter).BuildExpr(resolver)
		if err != nil {
			return err
		}
		q.AndWhere(expr)

		resolver.UpdateQuery(q)

		return nil
	}

	_, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc)

	return err == nil
}
