package schema

import (
	"context"

	"entgo.io/ent"
	"entgo.io/ent/dialect/entsql"
	"entgo.io/ent/schema/edge"
	"entgo.io/ent/schema/field"
	"entgo.io/ent/schema/index"
	"github.com/getsentry/sentry-go"
	"github.com/google/uuid"
	"github.com/rs/zerolog/log"

	"github.com/42atomys/stud42/internal/models/gotype"
	"github.com/42atomys/stud42/internal/pkg/searchengine"
)

type User struct {
	ent.Schema
}

func (User) Fields() []ent.Field {
	return []ent.Field{
		field.UUID("id", uuid.UUID{}).Unique().Immutable().Default(uuid.New).Annotations(entsql.Annotation{
			Default: "uuid_generate_v4()",
		}),
		field.String("email").Unique().NotEmpty().MaxLen(255).Validate(func(email string) (err error) {
			// TODO: ParseAddress return an error on Update
			// generated: validator failed for field \"User.email\": mail: no angle-addr
			// ( email failed : xx@42xxxxxx.xxx )
			// _, err = mail.ParseAddress(email)
			return
		}),
		field.String("duo_login").Unique().NotEmpty().MaxLen(255),
		field.Int("duo_id").Unique().NonNegative(),
		field.String("first_name").NotEmpty().MaxLen(255),
		field.String("usual_first_name").Nillable().Optional().MaxLen(255),
		field.String("last_name").NotEmpty().MaxLen(255),
		field.Enum("pronoun").GoType(gotype.UserPronoun("")).Default(gotype.UserPronounPrivate.String()),
		field.String("pool_year").Optional().Nillable(),
		field.String("pool_month").Optional().Nillable(),
		field.String("nickname").Optional().Nillable().Unique().MaxLen(255),
		field.String("duo_avatar_url").Optional().Nillable().MaxLen(255),
		field.String("duo_avatar_small_url").Optional().Nillable().MaxLen(255),
		field.String("avatar_url").Optional().Nillable().MaxLen(255),
		field.String("cover_url").Optional().Nillable().MaxLen(255),
		field.UUID("current_location_id", uuid.UUID{}).Nillable().Optional(),
		field.UUID("last_location_id", uuid.UUID{}).Nillable().Optional(),
		field.UUID("current_campus_id", uuid.UUID{}).Nillable().Optional(),
		field.Bool("is_staff").Default(false),
		field.Bool("is_a_user").Default(false),
		field.JSON("flags", []gotype.UserFlag{}).Default(gotype.DefaultUserFlag).Optional().StorageKey("flags_list"),
		field.JSON("settings", gotype.Settings{}).Default(gotype.DefaultSettings).Optional(),
	}
}

func (User) Edges() []ent.Edge {
	return []ent.Edge{
		edge.To("accounts", Account.Type).Annotations(entsql.Annotation{OnDelete: entsql.Cascade}),
		// Friends / Follows related
		edge.To("follows_groups", FollowsGroup.Type).Annotations(entsql.Annotation{OnDelete: entsql.Cascade}),
		edge.To("followings", User.Type).
			Through("follows", Follow.Type).
			StorageKey(edge.Table("follows"), edge.Columns("user_id", "follow_id")),
		edge.From("followers", User.Type).Ref("followings"),

		// Locations related
		edge.To("locations", Location.Type).Annotations(entsql.Annotation{OnDelete: entsql.Cascade}),
		edge.To("current_location", Location.Type).
			Unique().
			Field("current_location_id").
			Annotations(entsql.Annotation{OnDelete: entsql.SetNull}),
		edge.To("last_location", Location.Type).
			Unique().
			Field("last_location_id").
			Annotations(entsql.Annotation{OnDelete: entsql.SetNull}),
		edge.To("current_campus", Campus.Type).
			Unique().
			Field("current_campus_id"),

		// Notices related
		edge.To("readed_notices", Notice.Type).
			Through("notices_users", NoticesUser.Type),
	}
}

func (User) Indexes() []ent.Index {
	return []ent.Index{
		index.Fields("email").Unique(),
		index.Fields("duo_login").Unique(),
		index.Fields("duo_id").Unique(),
		index.Fields("nickname").Unique(),
	}
}

func (User) Hooks() []ent.Hook {
	return []ent.Hook{
		// Hooks related to MeiliSearch
		meilisearchUpdateHook,
		meilisearchDeleteHook,
	}
}

// meilisearchUpdateHook is a hook that is triggered before an entity is created
// or updated. It will update the MeiliSearch index.
func meilisearchUpdateHook(next ent.Mutator) ent.Mutator {
	return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
		v, err := next.Mutate(ctx, m)

		if !m.Op().Is(ent.OpCreate|ent.OpUpdate|ent.OpUpdateOne) || err != nil {
			return v, err
		}

		// WORKARROUND: We need some part of the data to be able to update the index
		// so we need to fetch the entity from the database.
		// We check if the entity sended by the mutation is a User and more
		// specifically if it has a `UserMutation`. We don't use directly the
		// `UserMutation` because it doesn't implement the `Mutation` interface and
		// can't be used in the `m.Mutation()` function call.
		//
		// The usage of the `UserFunc` generated by Ent cannot be used due to how we
		// use and generate the code (ignored generated code). When we use the
		// `UserFunc` in an environment where the code is not yet generated, it
		// will return an error and the code will never be generated.
		userMutation, ok := m.(interface {
			ID() (id uuid.UUID, exists bool)
			FirstName() (firstName string, exists bool)
			UsualFirstName() (usualFirstName string, exists bool)
			LastName() (lastName string, exists bool)
			DuoLogin() (duoLogin string, exists bool)
			CurrentCampusID() (currentCampusID uuid.UUID, exists bool)
			CurrentLocationID() (currentLocationID uuid.UUID, exists bool)
			CurrentLocationIDCleared() bool
			CurrentLocationCleared() bool
		})
		if !ok {
			log.Error().Err(err).Msg("cannot update MeiliSearch index: mutation is not a User")
			return v, err
		}

		// Get the ID of the entity. If the ID doesn't exists, it means that the
		// mutation is not really related to a Create or an Update.
		userID, ok := userMutation.ID()
		if !ok {
			// No ID means that the mutation is not really related to an entity
			// is more like a "bulk" mutation.
			return v, err
		}

		log.Debug().Msgf("Update MeiliSearch index for user %s", userID)
		// Create the base document that will be used to update the index.
		document := &searchengine.UserDocument{
			ID: userID,
		}

		// Get and set only fields updated in current mutation (if any).
		// If the field doesn't exists, it means that it wasn't updated and we
		// don't need to update it.
		if firstName, ok := userMutation.FirstName(); ok {
			document.FirstName = firstName
		}

		if lastName, ok := userMutation.LastName(); ok {
			document.LastName = lastName
		}

		if usualFirstName, ok := userMutation.UsualFirstName(); ok {
			document.UsualFirstName = &usualFirstName
		}

		if duoLogin, ok := userMutation.DuoLogin(); ok {
			document.DuoLogin = duoLogin
		}

		if currentCampusID, ok := userMutation.CurrentCampusID(); ok {
			document.CurrentCampusID = &currentCampusID
		}

		if currentLocationID, ok := userMutation.CurrentLocationID(); ok ||
			userMutation.CurrentLocationIDCleared() ||
			userMutation.CurrentLocationCleared() {
			hasOnline := currentLocationID != uuid.Nil
			document.HasOnline = &hasOnline
		}

		// Update the index.
		go func() {
			err = searchengine.NewClient().UpdateUserDocument(ctx, document)
			if err != nil {
				log.Error().Err(err).Msg("Failed to update user document in MeiliSearch")
				sentry.CaptureException(err)
			}
		}()
		return v, err
	})
}

// meilisearchDeleteHook is a hook that is triggered before an entity is deleted.
// It will delete the entity from the MeiliSearch index.
func meilisearchDeleteHook(next ent.Mutator) ent.Mutator {
	return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
		v, err := next.Mutate(ctx, m)

		if !m.Op().Is(ent.OpDelete|ent.OpDeleteOne) || err != nil {
			return v, err
		}

		// WORKARROUND: We need some part of the data to be able to update the index
		// so we need to fetch the entity from the database.
		// We check if the entity sended by the mutation is a User and more
		// specifically if it has a `UserMutation`. We don't use directly the
		// `UserMutation` because it doesn't implement the `Mutation` interface and
		// can't be used in the `m.Mutation()` function call.
		//
		// The usage of the `UserFunc` generated by Ent cannot be used due to how we
		// use and generate the code (ignored generated code). When we use the
		// `UserFunc` in an environment where the code is not yet generated, it
		// will return an error and the code will never be generated.
		userMutation, ok := m.(interface {
			ID() (id uuid.UUID, exists bool)
		})
		if !ok {
			log.Error().Err(err).Msg("cannot delete MeiliSearch index: mutation is not a User")
			return v, err
		}

		// Get the ID of the entity.
		userID, ok := userMutation.ID()
		if !ok {
			// No ID means that the mutation is not really related to an entity
			// is more like a "bulk" mutation.
			return v, err
		}

		// Delete the entity from the index.
		go func() {
			err := searchengine.NewClient().DeleteUserDocument(userID)
			if err != nil {
				log.Error().Err(err).Msg("Failed to delete user document in MeiliSearch")
				sentry.CaptureException(err)
			}
		}()
		return v, err
	})
}
