//go:build integration
// +build integration

package sqlite_test

import (
	"context"
	"fmt"
	"slices"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"

	"github.com/stashapp/stash/pkg/models"
	"github.com/stashapp/stash/pkg/sliceutil"
	"github.com/stashapp/stash/pkg/sliceutil/intslice"
)

func loadGroupRelationships(ctx context.Context, expected models.Group, actual *models.Group) error {
	if expected.URLs.Loaded() {
		if err := actual.LoadURLs(ctx, db.Group); err != nil {
			return err
		}
	}
	if expected.TagIDs.Loaded() {
		if err := actual.LoadTagIDs(ctx, db.Group); err != nil {
			return err
		}
	}
	if expected.ContainingGroups.Loaded() {
		if err := actual.LoadContainingGroupIDs(ctx, db.Group); err != nil {
			return err
		}
	}
	if expected.SubGroups.Loaded() {
		if err := actual.LoadSubGroupIDs(ctx, db.Group); err != nil {
			return err
		}
	}

	return nil
}

func Test_GroupStore_Create(t *testing.T) {
	var (
		name                       = "name"
		url                        = "url"
		aliases                    = "alias1, alias2"
		director                   = "director"
		rating                     = 60
		duration                   = 34
		synopsis                   = "synopsis"
		date, _                    = models.ParseDate("2003-02-01")
		containingGroupDescription = "containingGroupDescription"
		subGroupDescription        = "subGroupDescription"
		createdAt                  = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
		updatedAt                  = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
	)

	tests := []struct {
		name      string
		newObject models.Group
		wantErr   bool
	}{
		{
			"full",
			models.Group{
				Name:     name,
				Duration: &duration,
				Date:     &date,
				Rating:   &rating,
				StudioID: &studioIDs[studioIdxWithGroup],
				Director: director,
				Synopsis: synopsis,
				URLs:     models.NewRelatedStrings([]string{url}),
				TagIDs:   models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGroup]}),
				ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
					{GroupID: groupIDs[groupIdxWithScene], Description: containingGroupDescription},
				}),
				SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
					{GroupID: groupIDs[groupIdxWithStudio], Description: subGroupDescription},
				}),
				Aliases:   aliases,
				CreatedAt: createdAt,
				UpdatedAt: updatedAt,
			},
			false,
		},
		{
			"invalid tag id",
			models.Group{
				Name:   name,
				TagIDs: models.NewRelatedIDs([]int{invalidID}),
			},
			true,
		},
		{
			"invalid containing group id",
			models.Group{
				Name:             name,
				ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{{GroupID: invalidID}}),
			},
			true,
		},
		{
			"invalid sub group id",
			models.Group{
				Name:      name,
				SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{{GroupID: invalidID}}),
			},
			true,
		},
	}

	qb := db.Group

	for _, tt := range tests {
		runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
			assert := assert.New(t)

			p := tt.newObject
			if err := qb.Create(ctx, &p); (err != nil) != tt.wantErr {
				t.Errorf("GroupStore.Create() error = %v, wantErr = %v", err, tt.wantErr)
			}

			if tt.wantErr {
				assert.Zero(p.ID)
				return
			}

			assert.NotZero(p.ID)

			copy := tt.newObject
			copy.ID = p.ID

			// load relationships
			if err := loadGroupRelationships(ctx, copy, &p); err != nil {
				t.Errorf("loadGroupRelationships() error = %v", err)
				return
			}

			assert.Equal(copy, p)

			// ensure can find the group
			found, err := qb.Find(ctx, p.ID)
			if err != nil {
				t.Errorf("GroupStore.Find() error = %v", err)
			}

			if !assert.NotNil(found) {
				return
			}

			// load relationships
			if err := loadGroupRelationships(ctx, copy, found); err != nil {
				t.Errorf("loadGroupRelationships() error = %v", err)
				return
			}
			assert.Equal(copy, *found)

			return
		})
	}
}

func Test_groupQueryBuilder_Update(t *testing.T) {
	var (
		name                       = "name"
		url                        = "url"
		aliases                    = "alias1, alias2"
		director                   = "director"
		rating                     = 60
		duration                   = 34
		synopsis                   = "synopsis"
		date, _                    = models.ParseDate("2003-02-01")
		containingGroupDescription = "containingGroupDescription"
		subGroupDescription        = "subGroupDescription"
		createdAt                  = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
		updatedAt                  = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
	)

	tests := []struct {
		name          string
		updatedObject models.Group
		wantErr       bool
	}{
		{
			"full",
			models.Group{
				ID:       groupIDs[groupIdxWithTag],
				Name:     name,
				Duration: &duration,
				Date:     &date,
				Rating:   &rating,
				StudioID: &studioIDs[studioIdxWithGroup],
				Director: director,
				Synopsis: synopsis,
				URLs:     models.NewRelatedStrings([]string{url}),
				TagIDs:   models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGroup]}),
				ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
					{GroupID: groupIDs[groupIdxWithScene], Description: containingGroupDescription},
				}),
				SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
					{GroupID: groupIDs[groupIdxWithStudio], Description: subGroupDescription},
				}),
				Aliases:   aliases,
				CreatedAt: createdAt,
				UpdatedAt: updatedAt,
			},
			false,
		},
		{
			"clear tag ids",
			models.Group{
				ID:     groupIDs[groupIdxWithTag],
				Name:   name,
				TagIDs: models.NewRelatedIDs([]int{}),
			},
			false,
		},
		{
			"clear containing ids",
			models.Group{
				ID:               groupIDs[groupIdxWithParent],
				Name:             name,
				ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}),
			},
			false,
		},
		{
			"clear sub ids",
			models.Group{
				ID:        groupIDs[groupIdxWithChild],
				Name:      name,
				SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}),
			},
			false,
		},
		{
			"invalid studio id",
			models.Group{
				ID:       groupIDs[groupIdxWithScene],
				Name:     name,
				StudioID: &invalidID,
			},
			true,
		},
		{
			"invalid tag id",
			models.Group{
				ID:     groupIDs[groupIdxWithScene],
				Name:   name,
				TagIDs: models.NewRelatedIDs([]int{invalidID}),
			},
			true,
		},
		{
			"invalid containing group id",
			models.Group{
				ID:               groupIDs[groupIdxWithScene],
				Name:             name,
				ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{{GroupID: invalidID}}),
			},
			true,
		},
		{
			"invalid sub group id",
			models.Group{
				ID:        groupIDs[groupIdxWithScene],
				Name:      name,
				SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{{GroupID: invalidID}}),
			},
			true,
		},
	}

	qb := db.Group
	for _, tt := range tests {
		runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
			assert := assert.New(t)

			actual := tt.updatedObject
			expected := tt.updatedObject

			if err := qb.Update(ctx, &actual); (err != nil) != tt.wantErr {
				t.Errorf("groupQueryBuilder.Update() error = %v, wantErr %v", err, tt.wantErr)
			}

			if tt.wantErr {
				return
			}

			s, err := qb.Find(ctx, actual.ID)
			if err != nil {
				t.Errorf("groupQueryBuilder.Find() error = %v", err)
			}

			// load relationships
			if err := loadGroupRelationships(ctx, expected, s); err != nil {
				t.Errorf("loadGroupRelationships() error = %v", err)
				return
			}

			assert.Equal(expected, *s)
		})
	}
}

var clearGroupPartial = models.GroupPartial{
	// leave mandatory fields
	Aliases:          models.OptionalString{Set: true, Null: true},
	Synopsis:         models.OptionalString{Set: true, Null: true},
	Director:         models.OptionalString{Set: true, Null: true},
	Duration:         models.OptionalInt{Set: true, Null: true},
	URLs:             &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet},
	Date:             models.OptionalDate{Set: true, Null: true},
	Rating:           models.OptionalInt{Set: true, Null: true},
	StudioID:         models.OptionalInt{Set: true, Null: true},
	TagIDs:           &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet},
	ContainingGroups: &models.UpdateGroupDescriptions{Mode: models.RelationshipUpdateModeSet},
	SubGroups:        &models.UpdateGroupDescriptions{Mode: models.RelationshipUpdateModeSet},
}

func emptyGroup(idx int) models.Group {
	return models.Group{
		ID:               groupIDs[idx],
		Name:             groupNames[idx],
		TagIDs:           models.NewRelatedIDs([]int{}),
		ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}),
		SubGroups:        models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}),
	}
}

func Test_groupQueryBuilder_UpdatePartial(t *testing.T) {
	var (
		name                       = "name"
		url                        = "url"
		aliases                    = "alias1, alias2"
		director                   = "director"
		rating                     = 60
		duration                   = 34
		synopsis                   = "synopsis"
		date, _                    = models.ParseDate("2003-02-01")
		containingGroupDescription = "containingGroupDescription"
		subGroupDescription        = "subGroupDescription"
		createdAt                  = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
		updatedAt                  = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
	)

	tests := []struct {
		name    string
		id      int
		partial models.GroupPartial
		want    models.Group
		wantErr bool
	}{
		{
			"full",
			groupIDs[groupIdxWithScene],
			models.GroupPartial{
				Name:     models.NewOptionalString(name),
				Director: models.NewOptionalString(director),
				Synopsis: models.NewOptionalString(synopsis),
				Aliases:  models.NewOptionalString(aliases),
				URLs: &models.UpdateStrings{
					Values: []string{url},
					Mode:   models.RelationshipUpdateModeSet,
				},
				Date:      models.NewOptionalDate(date),
				Duration:  models.NewOptionalInt(duration),
				Rating:    models.NewOptionalInt(rating),
				StudioID:  models.NewOptionalInt(studioIDs[studioIdxWithGroup]),
				CreatedAt: models.NewOptionalTime(createdAt),
				UpdatedAt: models.NewOptionalTime(updatedAt),
				TagIDs: &models.UpdateIDs{
					IDs:  []int{tagIDs[tagIdx1WithGroup], tagIDs[tagIdx1WithDupName]},
					Mode: models.RelationshipUpdateModeSet,
				},
				ContainingGroups: &models.UpdateGroupDescriptions{
					Groups: []models.GroupIDDescription{
						{GroupID: groupIDs[groupIdxWithStudio], Description: containingGroupDescription},
						{GroupID: groupIDs[groupIdxWithThreeTags], Description: containingGroupDescription},
					},
					Mode: models.RelationshipUpdateModeSet,
				},
				SubGroups: &models.UpdateGroupDescriptions{
					Groups: []models.GroupIDDescription{
						{GroupID: groupIDs[groupIdxWithTag], Description: subGroupDescription},
						{GroupID: groupIDs[groupIdxWithDupName], Description: subGroupDescription},
					},
					Mode: models.RelationshipUpdateModeSet,
				},
			},
			models.Group{
				ID:        groupIDs[groupIdxWithScene],
				Name:      name,
				Director:  director,
				Synopsis:  synopsis,
				Aliases:   aliases,
				URLs:      models.NewRelatedStrings([]string{url}),
				Date:      &date,
				Duration:  &duration,
				Rating:    &rating,
				StudioID:  &studioIDs[studioIdxWithGroup],
				CreatedAt: createdAt,
				UpdatedAt: updatedAt,
				TagIDs:    models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGroup]}),
				ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
					{GroupID: groupIDs[groupIdxWithStudio], Description: containingGroupDescription},
					{GroupID: groupIDs[groupIdxWithThreeTags], Description: containingGroupDescription},
				}),
				SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
					{GroupID: groupIDs[groupIdxWithTag], Description: subGroupDescription},
					{GroupID: groupIDs[groupIdxWithDupName], Description: subGroupDescription},
				}),
			},
			false,
		},
		{
			"clear all",
			groupIDs[groupIdxWithScene],
			clearGroupPartial,
			emptyGroup(groupIdxWithScene),
			false,
		},
		{
			"clear tag ids",
			groupIDs[groupIdxWithTag],
			clearGroupPartial,
			emptyGroup(groupIdxWithTag),
			false,
		},
		{
			"clear group relationships",
			groupIDs[groupIdxWithParentAndChild],
			clearGroupPartial,
			emptyGroup(groupIdxWithParentAndChild),
			false,
		},
		{
			"add containing group",
			groupIDs[groupIdxWithParent],
			models.GroupPartial{
				ContainingGroups: &models.UpdateGroupDescriptions{
					Groups: []models.GroupIDDescription{
						{GroupID: groupIDs[groupIdxWithScene], Description: containingGroupDescription},
					},
					Mode: models.RelationshipUpdateModeAdd,
				},
			},
			models.Group{
				ID:   groupIDs[groupIdxWithParent],
				Name: groupNames[groupIdxWithParent],
				ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
					{GroupID: groupIDs[groupIdxWithChild]},
					{GroupID: groupIDs[groupIdxWithScene], Description: containingGroupDescription},
				}),
			},
			false,
		},
		{
			"add sub group",
			groupIDs[groupIdxWithChild],
			models.GroupPartial{
				SubGroups: &models.UpdateGroupDescriptions{
					Groups: []models.GroupIDDescription{
						{GroupID: groupIDs[groupIdxWithScene], Description: subGroupDescription},
					},
					Mode: models.RelationshipUpdateModeAdd,
				},
			},
			models.Group{
				ID:   groupIDs[groupIdxWithChild],
				Name: groupNames[groupIdxWithChild],
				SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
					{GroupID: groupIDs[groupIdxWithParent]},
					{GroupID: groupIDs[groupIdxWithScene], Description: subGroupDescription},
				}),
			},
			false,
		},
		{
			"remove containing group",
			groupIDs[groupIdxWithParent],
			models.GroupPartial{
				ContainingGroups: &models.UpdateGroupDescriptions{
					Groups: []models.GroupIDDescription{
						{GroupID: groupIDs[groupIdxWithChild]},
					},
					Mode: models.RelationshipUpdateModeRemove,
				},
			},
			models.Group{
				ID:               groupIDs[groupIdxWithParent],
				Name:             groupNames[groupIdxWithParent],
				ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}),
			},
			false,
		},
		{
			"remove sub group",
			groupIDs[groupIdxWithChild],
			models.GroupPartial{
				SubGroups: &models.UpdateGroupDescriptions{
					Groups: []models.GroupIDDescription{
						{GroupID: groupIDs[groupIdxWithParent]},
					},
					Mode: models.RelationshipUpdateModeRemove,
				},
			},
			models.Group{
				ID:        groupIDs[groupIdxWithChild],
				Name:      groupNames[groupIdxWithChild],
				SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}),
			},
			false,
		},
		{
			"invalid id",
			invalidID,
			models.GroupPartial{},
			models.Group{},
			true,
		},
	}
	for _, tt := range tests {
		qb := db.Group

		runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
			assert := assert.New(t)

			got, err := qb.UpdatePartial(ctx, tt.id, tt.partial)
			if (err != nil) != tt.wantErr {
				t.Errorf("groupQueryBuilder.UpdatePartial() error = %v, wantErr %v", err, tt.wantErr)
				return
			}

			if tt.wantErr {
				return
			}

			// load relationships
			if err := loadGroupRelationships(ctx, tt.want, got); err != nil {
				t.Errorf("loadGroupRelationships() error = %v", err)
				return
			}

			assert.Equal(tt.want, *got)

			s, err := qb.Find(ctx, tt.id)
			if err != nil {
				t.Errorf("groupQueryBuilder.Find() error = %v", err)
			}

			// load relationships
			if err := loadGroupRelationships(ctx, tt.want, s); err != nil {
				t.Errorf("loadGroupRelationships() error = %v", err)
				return
			}

			assert.Equal(tt.want, *s)
		})
	}
}

func Test_GroupStore_UpdatePartialCustomFields(t *testing.T) {
	tests := []struct {
		name     string
		id       int
		partial  models.GroupPartial
		expected map[string]interface{} // nil to use the partial
	}{
		{
			"set custom fields",
			groupIDs[groupIdxWithChild],
			models.GroupPartial{
				CustomFields: models.CustomFieldsInput{
					Full: testCustomFields,
				},
			},
			nil,
		},
		{
			"clear custom fields",
			groupIDs[groupIdxWithChild],
			models.GroupPartial{
				CustomFields: models.CustomFieldsInput{
					Full: map[string]interface{}{},
				},
			},
			nil,
		},
		{
			"partial custom fields",
			groupIDs[groupIdxWithTwoTags],
			models.GroupPartial{
				CustomFields: models.CustomFieldsInput{
					Partial: map[string]interface{}{
						"string":    "bbb",
						"new_field": "new",
					},
				},
			},
			map[string]interface{}{
				"int":       int64(3),
				"real":      0.3,
				"string":    "bbb",
				"new_field": "new",
			},
		},
	}
	for _, tt := range tests {
		qb := db.Group

		runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
			assert := assert.New(t)

			_, err := qb.UpdatePartial(ctx, tt.id, tt.partial)
			if err != nil {
				t.Errorf("GroupStore.UpdatePartial() error = %v", err)
				return
			}

			// ensure custom fields are correct
			cf, err := qb.GetCustomFields(ctx, tt.id)
			if err != nil {
				t.Errorf("GroupStore.GetCustomFields() error = %v", err)
				return
			}
			if tt.expected == nil {
				assert.Equal(tt.partial.CustomFields.Full, cf)
			} else {
				assert.Equal(tt.expected, cf)
			}
		})
	}
}

func TestGroupFindByName(t *testing.T) {
	withTxn(func(ctx context.Context) error {
		mqb := db.Group

		name := groupNames[groupIdxWithScene] // find a group by name

		group, err := mqb.FindByName(ctx, name, false)

		if err != nil {
			t.Errorf("Error finding groups: %s", err.Error())
		}

		assert.Equal(t, groupNames[groupIdxWithScene], group.Name)

		name = groupNames[groupIdxWithDupName] // find a group by name nocase

		group, err = mqb.FindByName(ctx, name, true)

		if err != nil {
			t.Errorf("Error finding groups: %s", err.Error())
		}
		// groupIdxWithDupName and groupIdxWithScene should have similar names ( only diff should be Name vs NaMe)
		//group.Name should match with groupIdxWithScene since its ID is before moveIdxWithDupName
		assert.Equal(t, groupNames[groupIdxWithScene], group.Name)
		//group.Name should match with groupIdxWithDupName if the check is not case sensitive
		assert.Equal(t, strings.ToLower(groupNames[groupIdxWithDupName]), strings.ToLower(group.Name))

		return nil
	})
}

func TestGroupFindByNames(t *testing.T) {
	withTxn(func(ctx context.Context) error {
		var names []string

		mqb := db.Group

		names = append(names, groupNames[groupIdxWithScene]) // find groups by names

		groups, err := mqb.FindByNames(ctx, names, false)
		if err != nil {
			t.Errorf("Error finding groups: %s", err.Error())
		}
		assert.Len(t, groups, 1)
		assert.Equal(t, groupNames[groupIdxWithScene], groups[0].Name)

		groups, err = mqb.FindByNames(ctx, names, true) // find groups by names nocase
		if err != nil {
			t.Errorf("Error finding groups: %s", err.Error())
		}
		assert.Len(t, groups, 2) // groupIdxWithScene and groupIdxWithDupName
		assert.Equal(t, strings.ToLower(groupNames[groupIdxWithScene]), strings.ToLower(groups[0].Name))
		assert.Equal(t, strings.ToLower(groupNames[groupIdxWithScene]), strings.ToLower(groups[1].Name))

		return nil
	})
}

func groupsToIDs(i []*models.Group) []int {
	ret := make([]int, len(i))
	for i, v := range i {
		ret[i] = v.ID
	}

	return ret
}

func TestGroupQuery(t *testing.T) {
	var (
		frontImage = "front_image"
		backImage  = "back_image"
	)

	tests := []struct {
		name        string
		findFilter  *models.FindFilterType
		filter      *models.GroupFilterType
		includeIdxs []int
		excludeIdxs []int
		wantErr     bool
	}{
		{
			"is missing front image",
			nil,
			&models.GroupFilterType{
				IsMissing: &frontImage,
			},
			// just ensure that it doesn't error
			nil,
			nil,
			false,
		},
		{
			"is missing back image",
			nil,
			&models.GroupFilterType{
				IsMissing: &backImage,
			},
			// just ensure that it doesn't error
			nil,
			nil,
			false,
		},
		{
			"scene count equals 1",
			nil,
			&models.GroupFilterType{
				SceneCount: &models.IntCriterionInput{
					Value:    1,
					Modifier: models.CriterionModifierEquals,
				},
			},
			[]int{groupIdxWithScene},
			[]int{groupIdxWithParentAndChild},
			false,
		},
		{
			"scene count less than 1",
			nil,
			&models.GroupFilterType{
				SceneCount: &models.IntCriterionInput{
					Value:    1,
					Modifier: models.CriterionModifierLessThan,
				},
			},
			[]int{groupIdxWithParentAndChild},
			[]int{groupIdxWithScene},
			false,
		},
	}

	for _, tt := range tests {
		runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
			assert := assert.New(t)

			results, _, err := db.Group.Query(ctx, tt.filter, tt.findFilter)
			if (err != nil) != tt.wantErr {
				t.Errorf("GroupQueryBuilder.Query() error = %v, wantErr %v", err, tt.wantErr)
				return
			}

			ids := groupsToIDs(results)
			include := indexesToIDs(performerIDs, tt.includeIdxs)
			exclude := indexesToIDs(performerIDs, tt.excludeIdxs)

			for _, i := range include {
				assert.Contains(ids, i)
			}
			for _, e := range exclude {
				assert.NotContains(ids, e)
			}
		})
	}
}

func TestGroupQueryStudio(t *testing.T) {
	withTxn(func(ctx context.Context) error {
		mqb := db.Group
		studioCriterion := models.HierarchicalMultiCriterionInput{
			Value: []string{
				strconv.Itoa(studioIDs[studioIdxWithGroup]),
			},
			Modifier: models.CriterionModifierIncludes,
		}

		groupFilter := models.GroupFilterType{
			Studios: &studioCriterion,
		}

		groups, _, err := mqb.Query(ctx, &groupFilter, nil)
		if err != nil {
			t.Errorf("Error querying group: %s", err.Error())
		}

		assert.Len(t, groups, 1)

		// ensure id is correct
		assert.Equal(t, groupIDs[groupIdxWithStudio], groups[0].ID)

		studioCriterion = models.HierarchicalMultiCriterionInput{
			Value: []string{
				strconv.Itoa(studioIDs[studioIdxWithGroup]),
			},
			Modifier: models.CriterionModifierExcludes,
		}

		q := getGroupStringValue(groupIdxWithStudio, titleField)
		findFilter := models.FindFilterType{
			Q: &q,
		}

		groups, _, err = mqb.Query(ctx, &groupFilter, &findFilter)
		if err != nil {
			t.Errorf("Error querying group: %s", err.Error())
		}
		assert.Len(t, groups, 0)

		return nil
	})
}

func TestGroupQueryURL(t *testing.T) {
	const sceneIdx = 1
	groupURL := getGroupStringValue(sceneIdx, urlField)

	urlCriterion := models.StringCriterionInput{
		Value:    groupURL,
		Modifier: models.CriterionModifierEquals,
	}

	filter := models.GroupFilterType{
		URL: &urlCriterion,
	}

	verifyFn := func(n *models.Group) {
		t.Helper()

		urls := n.URLs.List()
		var url string
		if len(urls) > 0 {
			url = urls[0]
		}

		verifyString(t, url, urlCriterion)
	}

	verifyGroupQuery(t, filter, verifyFn)

	urlCriterion.Modifier = models.CriterionModifierNotEquals
	verifyGroupQuery(t, filter, verifyFn)

	urlCriterion.Modifier = models.CriterionModifierMatchesRegex
	urlCriterion.Value = "group_.*1_URL"
	verifyGroupQuery(t, filter, verifyFn)

	urlCriterion.Modifier = models.CriterionModifierNotMatchesRegex
	verifyGroupQuery(t, filter, verifyFn)

	urlCriterion.Modifier = models.CriterionModifierIsNull
	urlCriterion.Value = ""
	verifyGroupQuery(t, filter, verifyFn)

	urlCriterion.Modifier = models.CriterionModifierNotNull
	verifyGroupQuery(t, filter, verifyFn)
}

func TestGroupQueryURLExcludes(t *testing.T) {
	withRollbackTxn(func(ctx context.Context) error {
		mqb := db.Group

		// create group with two URLs
		group := models.Group{
			Name: "TestGroupQueryURLExcludes",
			URLs: models.NewRelatedStrings([]string{
				"aaa",
				"bbb",
			}),
		}

		err := mqb.Create(ctx, &group)

		if err != nil {
			return fmt.Errorf("Error creating group: %w", err)
		}

		// query for groups that exclude the URL "aaa"
		urlCriterion := models.StringCriterionInput{
			Value:    "aaa",
			Modifier: models.CriterionModifierExcludes,
		}

		nameCriterion := models.StringCriterionInput{
			Value:    group.Name,
			Modifier: models.CriterionModifierEquals,
		}

		filter := models.GroupFilterType{
			URL:  &urlCriterion,
			Name: &nameCriterion,
		}

		groups := queryGroups(ctx, t, &filter, nil)
		assert.Len(t, groups, 0, "Expected no groups to be found")

		// query for groups that exclude the URL "ccc"
		urlCriterion.Value = "ccc"
		groups = queryGroups(ctx, t, &filter, nil)

		if assert.Len(t, groups, 1, "Expected one group to be found") {
			assert.Equal(t, group.Name, groups[0].Name)
		}

		return nil
	})
}

func verifyGroupQuery(t *testing.T, filter models.GroupFilterType, verifyFn func(s *models.Group)) {
	withTxn(func(ctx context.Context) error {
		t.Helper()
		sqb := db.Group

		groups := queryGroups(ctx, t, &filter, nil)

		for _, group := range groups {
			if err := group.LoadURLs(ctx, sqb); err != nil {
				t.Errorf("Error loading group relationships: %v", err)
			}
		}

		// assume it should find at least one
		assert.Greater(t, len(groups), 0)

		for _, m := range groups {
			verifyFn(m)
		}

		return nil
	})
}

func queryGroups(ctx context.Context, t *testing.T, groupFilter *models.GroupFilterType, findFilter *models.FindFilterType) []*models.Group {
	sqb := db.Group
	groups, _, err := sqb.Query(ctx, groupFilter, findFilter)
	if err != nil {
		t.Errorf("Error querying group: %s", err.Error())
	}

	return groups
}

func TestGroupQueryTags(t *testing.T) {
	withTxn(func(ctx context.Context) error {
		tagCriterion := models.HierarchicalMultiCriterionInput{
			Value: []string{
				strconv.Itoa(tagIDs[tagIdxWithGroup]),
				strconv.Itoa(tagIDs[tagIdx1WithGroup]),
			},
			Modifier: models.CriterionModifierIncludes,
		}

		groupFilter := models.GroupFilterType{
			Tags: &tagCriterion,
		}

		// ensure ids are correct
		groups := queryGroups(ctx, t, &groupFilter, nil)
		assert.Len(t, groups, 3)
		for _, group := range groups {
			assert.True(t, group.ID == groupIDs[groupIdxWithTag] || group.ID == groupIDs[groupIdxWithTwoTags] || group.ID == groupIDs[groupIdxWithThreeTags])
		}

		tagCriterion = models.HierarchicalMultiCriterionInput{
			Value: []string{
				strconv.Itoa(tagIDs[tagIdx1WithGroup]),
				strconv.Itoa(tagIDs[tagIdx2WithGroup]),
			},
			Modifier: models.CriterionModifierIncludesAll,
		}

		groups = queryGroups(ctx, t, &groupFilter, nil)

		if assert.Len(t, groups, 2) {
			assert.Equal(t, sceneIDs[groupIdxWithTwoTags], groups[0].ID)
			assert.Equal(t, sceneIDs[groupIdxWithThreeTags], groups[1].ID)
		}

		tagCriterion = models.HierarchicalMultiCriterionInput{
			Value: []string{
				strconv.Itoa(tagIDs[tagIdx1WithGroup]),
			},
			Modifier: models.CriterionModifierExcludes,
		}

		q := getSceneStringValue(groupIdxWithTwoTags, titleField)
		findFilter := models.FindFilterType{
			Q: &q,
		}

		groups = queryGroups(ctx, t, &groupFilter, &findFilter)
		assert.Len(t, groups, 0)

		return nil
	})
}

func TestGroupQueryTagCount(t *testing.T) {
	const tagCount = 1
	tagCountCriterion := models.IntCriterionInput{
		Value:    tagCount,
		Modifier: models.CriterionModifierEquals,
	}

	verifyGroupsTagCount(t, tagCountCriterion)

	tagCountCriterion.Modifier = models.CriterionModifierNotEquals
	verifyGroupsTagCount(t, tagCountCriterion)

	tagCountCriterion.Modifier = models.CriterionModifierGreaterThan
	verifyGroupsTagCount(t, tagCountCriterion)

	tagCountCriterion.Modifier = models.CriterionModifierLessThan
	verifyGroupsTagCount(t, tagCountCriterion)
}

func verifyGroupsTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) {
	withTxn(func(ctx context.Context) error {
		sqb := db.Group
		groupFilter := models.GroupFilterType{
			TagCount: &tagCountCriterion,
		}

		groups := queryGroups(ctx, t, &groupFilter, nil)
		assert.Greater(t, len(groups), 0)

		for _, group := range groups {
			ids, err := sqb.GetTagIDs(ctx, group.ID)
			if err != nil {
				return err
			}
			verifyInt(t, len(ids), tagCountCriterion)
		}

		return nil
	})
}

func TestGroupQuerySorting(t *testing.T) {
	sort := "scenes_count"
	direction := models.SortDirectionEnumDesc
	findFilter := models.FindFilterType{
		Sort:      &sort,
		Direction: &direction,
	}

	withTxn(func(ctx context.Context) error {
		groups := queryGroups(ctx, t, nil, &findFilter)

		// scenes should be in same order as indexes
		firstGroup := groups[0]

		assert.Equal(t, groupIDs[groupIdxWithScene], firstGroup.ID)

		// sort in descending order
		direction = models.SortDirectionEnumAsc

		groups = queryGroups(ctx, t, nil, &findFilter)
		lastGroup := groups[len(groups)-1]

		assert.Equal(t, groupIDs[groupIdxWithParentAndScene], lastGroup.ID)

		return nil
	})
}

func TestGroupQuerySortOrderIndex(t *testing.T) {
	sort := "sub_group_order"
	direction := models.SortDirectionEnumDesc
	findFilter := models.FindFilterType{
		Sort:      &sort,
		Direction: &direction,
	}

	groupFilter := models.GroupFilterType{
		ContainingGroups: &models.HierarchicalMultiCriterionInput{
			Value:    intslice.IntSliceToStringSlice([]int{groupIdxWithChild}),
			Modifier: models.CriterionModifierIncludes,
		},
	}

	withTxn(func(ctx context.Context) error {
		// just ensure there are no errors
		_, _, err := db.Group.Query(ctx, &groupFilter, &findFilter)
		if err != nil {
			t.Errorf("Error querying group: %s", err.Error())
		}

		_, _, err = db.Group.Query(ctx, nil, &findFilter)
		if err != nil {
			t.Errorf("Error querying group: %s", err.Error())
		}

		return nil
	})
}

func TestGroupUpdateFrontImage(t *testing.T) {
	if err := withRollbackTxn(func(ctx context.Context) error {
		qb := db.Group

		// create group to test against
		const name = "TestGroupUpdateGroupImages"
		group := models.Group{
			Name: name,
		}
		err := qb.Create(ctx, &group)
		if err != nil {
			return fmt.Errorf("Error creating group: %s", err.Error())
		}

		return testUpdateImage(t, ctx, group.ID, qb.UpdateFrontImage, qb.GetFrontImage)
	}); err != nil {
		t.Error(err.Error())
	}
}

func TestGroupUpdateBackImage(t *testing.T) {
	if err := withRollbackTxn(func(ctx context.Context) error {
		qb := db.Group

		// create group to test against
		const name = "TestGroupUpdateGroupImages"
		group := models.Group{
			Name: name,
		}
		err := qb.Create(ctx, &group)
		if err != nil {
			return fmt.Errorf("Error creating group: %s", err.Error())
		}

		return testUpdateImage(t, ctx, group.ID, qb.UpdateBackImage, qb.GetBackImage)
	}); err != nil {
		t.Error(err.Error())
	}
}

func TestGroupQueryContainingGroups(t *testing.T) {
	const nameField = "Name"

	type criterion struct {
		valueIdxs []int
		modifier  models.CriterionModifier
		depth     int
	}

	tests := []struct {
		name        string
		c           criterion
		q           string
		includeIdxs []int
	}{
		{
			"includes",
			criterion{
				[]int{groupIdxWithChild},
				models.CriterionModifierIncludes,
				0,
			},
			"",
			[]int{groupIdxWithParent},
		},
		{
			"excludes",
			criterion{
				[]int{groupIdxWithChild},
				models.CriterionModifierExcludes,
				0,
			},
			getGroupStringValue(groupIdxWithParent, nameField),
			nil,
		},
		{
			"includes (all levels)",
			criterion{
				[]int{groupIdxWithGrandChild},
				models.CriterionModifierIncludes,
				-1,
			},
			"",
			[]int{groupIdxWithParentAndChild, groupIdxWithGrandParent},
		},
		{
			"includes (1 level)",
			criterion{
				[]int{groupIdxWithGrandChild},
				models.CriterionModifierIncludes,
				1,
			},
			"",
			[]int{groupIdxWithParentAndChild, groupIdxWithGrandParent},
		},
		{
			"is null",
			criterion{
				nil,
				models.CriterionModifierIsNull,
				0,
			},
			getGroupStringValue(groupIdxWithParent, nameField),
			nil,
		},
		{
			"not null",
			criterion{
				nil,
				models.CriterionModifierNotNull,
				0,
			},
			"",
			[]int{groupIdxWithParentAndChild, groupIdxWithParent, groupIdxWithGrandParent, groupIdxWithParentAndScene},
		},
	}

	qb := db.Group

	for _, tt := range tests {
		valueIDs := indexesToIDs(groupIDs, tt.c.valueIdxs)
		expectedIDs := indexesToIDs(groupIDs, tt.includeIdxs)

		runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
			groupFilter := &models.GroupFilterType{
				ContainingGroups: &models.HierarchicalMultiCriterionInput{
					Value:    intslice.IntSliceToStringSlice(valueIDs),
					Modifier: tt.c.modifier,
				},
			}

			if tt.c.depth != 0 {
				groupFilter.ContainingGroups.Depth = &tt.c.depth
			}

			findFilter := models.FindFilterType{}
			if tt.q != "" {
				findFilter.Q = &tt.q
			}

			groups, _, err := qb.Query(ctx, groupFilter, &findFilter)
			if err != nil {
				t.Errorf("GroupStore.Query() error = %v", err)
				return
			}

			// get ids of groups
			groupIDs := sliceutil.Map(groups, func(g *models.Group) int { return g.ID })
			assert.ElementsMatch(t, expectedIDs, groupIDs)
		})
	}
}

func TestGroupQuerySubGroups(t *testing.T) {
	const nameField = "Name"

	type criterion struct {
		valueIdxs []int
		modifier  models.CriterionModifier
		depth     int
	}

	tests := []struct {
		name         string
		c            criterion
		q            string
		expectedIdxs []int
	}{
		{
			"includes",
			criterion{
				[]int{groupIdxWithParent},
				models.CriterionModifierIncludes,
				0,
			},
			"",
			[]int{groupIdxWithChild},
		},
		{
			"excludes",
			criterion{
				[]int{groupIdxWithParent},
				models.CriterionModifierExcludes,
				0,
			},
			getGroupStringValue(groupIdxWithChild, nameField),
			nil,
		},
		{
			"includes (all levels)",
			criterion{
				[]int{groupIdxWithGrandParent},
				models.CriterionModifierIncludes,
				-1,
			},
			"",
			[]int{groupIdxWithGrandChild, groupIdxWithParentAndChild},
		},
		{
			"includes (1 level)",
			criterion{
				[]int{groupIdxWithGrandParent},
				models.CriterionModifierIncludes,
				1,
			},
			"",
			[]int{groupIdxWithGrandChild, groupIdxWithParentAndChild},
		},
		{
			"is null",
			criterion{
				nil,
				models.CriterionModifierIsNull,
				0,
			},
			getGroupStringValue(groupIdxWithChild, nameField),
			nil,
		},
		{
			"not null",
			criterion{
				nil,
				models.CriterionModifierNotNull,
				0,
			},
			"",
			[]int{groupIdxWithGrandChild, groupIdxWithChild, groupIdxWithParentAndChild, groupIdxWithChildWithScene},
		},
	}

	qb := db.Group

	for _, tt := range tests {
		valueIDs := indexesToIDs(groupIDs, tt.c.valueIdxs)
		expectedIDs := indexesToIDs(groupIDs, tt.expectedIdxs)

		runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
			groupFilter := &models.GroupFilterType{
				SubGroups: &models.HierarchicalMultiCriterionInput{
					Value:    intslice.IntSliceToStringSlice(valueIDs),
					Modifier: tt.c.modifier,
				},
			}

			if tt.c.depth != 0 {
				groupFilter.SubGroups.Depth = &tt.c.depth
			}

			findFilter := models.FindFilterType{}
			if tt.q != "" {
				findFilter.Q = &tt.q
			}

			groups, _, err := qb.Query(ctx, groupFilter, &findFilter)
			if err != nil {
				t.Errorf("GroupStore.Query() error = %v", err)
				return
			}

			// get ids of groups
			groupIDs := sliceutil.Map(groups, func(g *models.Group) int { return g.ID })
			assert.ElementsMatch(t, expectedIDs, groupIDs)
		})
	}
}

func TestGroupQueryContainingGroupCount(t *testing.T) {
	const nameField = "Name"

	tests := []struct {
		name         string
		value        int
		modifier     models.CriterionModifier
		q            string
		expectedIdxs []int
	}{
		{
			"equals",
			1,
			models.CriterionModifierEquals,
			"",
			[]int{groupIdxWithParent, groupIdxWithGrandParent, groupIdxWithParentAndChild, groupIdxWithParentAndScene},
		},
		{
			"not equals",
			1,
			models.CriterionModifierNotEquals,
			getGroupStringValue(groupIdxWithParent, nameField),
			nil,
		},
		{
			"less than",
			1,
			models.CriterionModifierLessThan,
			getGroupStringValue(groupIdxWithParent, nameField),
			nil,
		},
		{
			"greater than",
			0,
			models.CriterionModifierGreaterThan,
			"",
			[]int{groupIdxWithParent, groupIdxWithGrandParent, groupIdxWithParentAndChild, groupIdxWithParentAndScene},
		},
	}

	qb := db.Group

	for _, tt := range tests {
		expectedIDs := indexesToIDs(groupIDs, tt.expectedIdxs)

		runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
			groupFilter := &models.GroupFilterType{
				ContainingGroupCount: &models.IntCriterionInput{
					Value:    tt.value,
					Modifier: tt.modifier,
				},
			}

			findFilter := models.FindFilterType{}
			if tt.q != "" {
				findFilter.Q = &tt.q
			}

			groups, _, err := qb.Query(ctx, groupFilter, &findFilter)
			if err != nil {
				t.Errorf("GroupStore.Query() error = %v", err)
				return
			}

			// get ids of groups
			groupIDs := sliceutil.Map(groups, func(g *models.Group) int { return g.ID })
			assert.ElementsMatch(t, expectedIDs, groupIDs)
		})
	}
}

func TestGroupQuerySubGroupCount(t *testing.T) {
	const nameField = "Name"

	tests := []struct {
		name         string
		value        int
		modifier     models.CriterionModifier
		q            string
		expectedIdxs []int
	}{
		{
			"equals",
			1,
			models.CriterionModifierEquals,
			"",
			[]int{groupIdxWithChild, groupIdxWithGrandChild, groupIdxWithParentAndChild, groupIdxWithChildWithScene},
		},
		{
			"not equals",
			1,
			models.CriterionModifierNotEquals,
			getGroupStringValue(groupIdxWithChild, nameField),
			nil,
		},
		{
			"less than",
			1,
			models.CriterionModifierLessThan,
			getGroupStringValue(groupIdxWithChild, nameField),
			nil,
		},
		{
			"greater than",
			0,
			models.CriterionModifierGreaterThan,
			"",
			[]int{groupIdxWithChild, groupIdxWithGrandChild, groupIdxWithParentAndChild, groupIdxWithChildWithScene},
		},
	}

	qb := db.Group

	for _, tt := range tests {
		expectedIDs := indexesToIDs(groupIDs, tt.expectedIdxs)

		runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
			groupFilter := &models.GroupFilterType{
				SubGroupCount: &models.IntCriterionInput{
					Value:    tt.value,
					Modifier: tt.modifier,
				},
			}

			findFilter := models.FindFilterType{}
			if tt.q != "" {
				findFilter.Q = &tt.q
			}

			groups, _, err := qb.Query(ctx, groupFilter, &findFilter)
			if err != nil {
				t.Errorf("GroupStore.Query() error = %v", err)
				return
			}

			// get ids of groups
			groupIDs := sliceutil.Map(groups, func(g *models.Group) int { return g.ID })
			assert.ElementsMatch(t, expectedIDs, groupIDs)
		})
	}
}

func TestGroupFindInAncestors(t *testing.T) {
	tests := []struct {
		name         string
		ancestorIdxs []int
		idxs         []int
		expectedIdxs []int
	}{
		{
			"basic",
			[]int{groupIdxWithGrandParent},
			[]int{groupIdxWithGrandChild},
			[]int{groupIdxWithGrandChild},
		},
		{
			"same",
			[]int{groupIdxWithScene},
			[]int{groupIdxWithScene},
			[]int{groupIdxWithScene},
		},
		{
			"no matches",
			[]int{groupIdxWithGrandParent},
			[]int{groupIdxWithScene},
			nil,
		},
	}

	qb := db.Group

	for _, tt := range tests {
		ancestorIDs := indexesToIDs(groupIDs, tt.ancestorIdxs)
		ids := indexesToIDs(groupIDs, tt.idxs)
		expectedIDs := indexesToIDs(groupIDs, tt.expectedIdxs)

		runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
			found, err := qb.FindInAncestors(ctx, ancestorIDs, ids)
			if err != nil {
				t.Errorf("GroupStore.FindInAncestors() error = %v", err)
				return
			}

			// get ids of groups
			assert.ElementsMatch(t, found, expectedIDs)
		})
	}
}

func TestGroupReorderSubGroups(t *testing.T) {
	tests := []struct {
		name        string
		subGroupLen int
		idxsToMove  []int
		insertLoc   int
		insertAfter bool
		// order of elements, using original indexes
		expectedIdxs []int
	}{
		{
			"move single back before",
			5,
			[]int{2},
			1,
			false,
			[]int{0, 2, 1, 3, 4},
		},
		{
			"move single forward before",
			5,
			[]int{2},
			4,
			false,
			[]int{0, 1, 3, 2, 4},
		},
		{
			"move multiple back before",
			5,
			[]int{3, 2, 4},
			0,
			false,
			[]int{3, 2, 4, 0, 1},
		},
		{
			"move multiple forward before",
			5,
			[]int{2, 1, 0},
			4,
			false,
			[]int{3, 2, 1, 0, 4},
		},
		{
			"move single back after",
			5,
			[]int{2},
			0,
			true,
			[]int{0, 2, 1, 3, 4},
		},
		{
			"move single forward after",
			5,
			[]int{2},
			4,
			true,
			[]int{0, 1, 3, 4, 2},
		},
		{
			"move multiple back after",
			5,
			[]int{3, 2, 4},
			0,
			false,
			[]int{0, 3, 2, 4, 1},
		},
		{
			"move multiple forward after",
			5,
			[]int{2, 1, 0},
			4,
			false,
			[]int{3, 4, 2, 1, 0},
		},
	}

	qb := db.Group

	for _, tt := range tests {
		runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
			// create the group
			group := models.Group{
				Name: "TestGroupReorderSubGroups",
			}

			if err := qb.Create(ctx, &group); err != nil {
				t.Errorf("GroupStore.Create() error = %v", err)
				return
			}

			// and sub-groups
			idxToId := make([]int, tt.subGroupLen)

			for i := 0; i < tt.subGroupLen; i++ {
				subGroup := models.Group{
					Name: fmt.Sprintf("SubGroup %d", i),
					ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
						{GroupID: group.ID},
					}),
				}

				if err := qb.Create(ctx, &subGroup); err != nil {
					t.Errorf("GroupStore.Create() error = %v", err)
					return
				}

				idxToId[i] = subGroup.ID
			}

			// reorder
			idsToMove := indexesToIDs(idxToId, tt.idxsToMove)
			insertID := idxToId[tt.insertLoc]
			if err := qb.ReorderSubGroups(ctx, group.ID, idsToMove, insertID, tt.insertAfter); err != nil {
				t.Errorf("GroupStore.ReorderSubGroups() error = %v", err)
				return
			}

			// validate the new order
			gd, err := qb.GetSubGroupDescriptions(ctx, group.ID)
			if err != nil {
				t.Errorf("GroupStore.GetSubGroupDescriptions() error = %v", err)
				return
			}

			// get ids of groups
			newIDs := sliceutil.Map(gd, func(gd models.GroupIDDescription) int { return gd.GroupID })
			newIdxs := sliceutil.Map(newIDs, func(id int) int { return slices.Index(idxToId, id) })

			assert.ElementsMatch(t, tt.expectedIdxs, newIdxs)
		})
	}
}

func TestGroupAddSubGroups(t *testing.T) {
	tests := []struct {
		name                string
		existingSubGroupLen int
		insertGroupsLen     int
		insertLoc           int
		// order of elements, using original indexes
		expectedIdxs []int
	}{
		{
			"append single",
			4,
			1,
			999,
			[]int{0, 1, 2, 3, 4},
		},
		{
			"insert single middle",
			4,
			1,
			2,
			[]int{0, 1, 4, 2, 3},
		},
		{
			"insert single start",
			4,
			1,
			0,
			[]int{4, 0, 1, 2, 3},
		},
		{
			"append multiple",
			4,
			2,
			999,
			[]int{0, 1, 2, 3, 4, 5},
		},
		{
			"insert multiple middle",
			4,
			2,
			2,
			[]int{0, 1, 4, 5, 2, 3},
		},
		{
			"insert multiple start",
			4,
			2,
			0,
			[]int{4, 5, 0, 1, 2, 3},
		},
	}

	qb := db.Group

	for _, tt := range tests {
		runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
			// create the group
			group := models.Group{
				Name: "TestGroupReorderSubGroups",
			}

			if err := qb.Create(ctx, &group); err != nil {
				t.Errorf("GroupStore.Create() error = %v", err)
				return
			}

			// and sub-groups
			idxToId := make([]int, tt.existingSubGroupLen+tt.insertGroupsLen)

			for i := 0; i < tt.existingSubGroupLen; i++ {
				subGroup := models.Group{
					Name: fmt.Sprintf("Existing SubGroup %d", i),
					ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
						{GroupID: group.ID},
					}),
				}

				if err := qb.Create(ctx, &subGroup); err != nil {
					t.Errorf("GroupStore.Create() error = %v", err)
					return
				}

				idxToId[i] = subGroup.ID
			}

			// and sub-groups to insert
			for i := 0; i < tt.insertGroupsLen; i++ {
				subGroup := models.Group{
					Name: fmt.Sprintf("Inserted SubGroup %d", i),
				}

				if err := qb.Create(ctx, &subGroup); err != nil {
					t.Errorf("GroupStore.Create() error = %v", err)
					return
				}

				idxToId[i+tt.existingSubGroupLen] = subGroup.ID
			}

			// convert ids to description
			idDescriptions := make([]models.GroupIDDescription, tt.insertGroupsLen)
			for i, id := range idxToId[tt.existingSubGroupLen:] {
				idDescriptions[i] = models.GroupIDDescription{GroupID: id}
			}

			// add
			if err := qb.AddSubGroups(ctx, group.ID, idDescriptions, &tt.insertLoc); err != nil {
				t.Errorf("GroupStore.AddSubGroups() error = %v", err)
				return
			}

			// validate the new order
			gd, err := qb.GetSubGroupDescriptions(ctx, group.ID)
			if err != nil {
				t.Errorf("GroupStore.GetSubGroupDescriptions() error = %v", err)
				return
			}

			// get ids of groups
			newIDs := sliceutil.Map(gd, func(gd models.GroupIDDescription) int { return gd.GroupID })
			newIdxs := sliceutil.Map(newIDs, func(id int) int { return slices.Index(idxToId, id) })

			assert.ElementsMatch(t, tt.expectedIdxs, newIdxs)
		})
	}
}

func TestGroupRemoveSubGroups(t *testing.T) {
	tests := []struct {
		name        string
		subGroupLen int
		removeIdxs  []int
		// order of elements, using original indexes
		expectedIdxs []int
	}{
		{
			"remove last",
			4,
			[]int{3},
			[]int{0, 1, 2},
		},
		{
			"remove first",
			4,
			[]int{0},
			[]int{1, 2, 3},
		},
		{
			"remove middle",
			4,
			[]int{2},
			[]int{0, 1, 3},
		},
		{
			"remove multiple",
			4,
			[]int{1, 3},
			[]int{0, 2},
		},
		{
			"remove all",
			4,
			[]int{0, 1, 2, 3},
			[]int{},
		},
	}

	qb := db.Group

	for _, tt := range tests {
		runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
			// create the group
			group := models.Group{
				Name: "TestGroupReorderSubGroups",
			}

			if err := qb.Create(ctx, &group); err != nil {
				t.Errorf("GroupStore.Create() error = %v", err)
				return
			}

			// and sub-groups
			idxToId := make([]int, tt.subGroupLen)

			for i := 0; i < tt.subGroupLen; i++ {
				subGroup := models.Group{
					Name: fmt.Sprintf("Existing SubGroup %d", i),
					ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
						{GroupID: group.ID},
					}),
				}

				if err := qb.Create(ctx, &subGroup); err != nil {
					t.Errorf("GroupStore.Create() error = %v", err)
					return
				}

				idxToId[i] = subGroup.ID
			}

			idsToRemove := indexesToIDs(idxToId, tt.removeIdxs)
			if err := qb.RemoveSubGroups(ctx, group.ID, idsToRemove); err != nil {
				t.Errorf("GroupStore.RemoveSubGroups() error = %v", err)
				return
			}

			// validate the new order
			gd, err := qb.GetSubGroupDescriptions(ctx, group.ID)
			if err != nil {
				t.Errorf("GroupStore.GetSubGroupDescriptions() error = %v", err)
				return
			}

			// get ids of groups
			newIDs := sliceutil.Map(gd, func(gd models.GroupIDDescription) int { return gd.GroupID })
			newIdxs := sliceutil.Map(newIDs, func(id int) int { return slices.Index(idxToId, id) })

			assert.ElementsMatch(t, tt.expectedIdxs, newIdxs)
		})
	}
}

func TestGroupFindSubGroupIDs(t *testing.T) {
	tests := []struct {
		name               string
		containingGroupIdx int
		subIdxs            []int
		expectedIdxs       []int
	}{
		{
			"overlap",
			groupIdxWithGrandChild,
			[]int{groupIdxWithParentAndChild, groupIdxWithGrandParent},
			[]int{groupIdxWithParentAndChild},
		},
		{
			"non-overlap",
			groupIdxWithGrandChild,
			[]int{groupIdxWithGrandParent},
			[]int{},
		},
		{
			"none",
			groupIdxWithScene,
			[]int{groupIdxWithDupName},
			[]int{},
		},
		{
			"invalid",
			invalidID,
			[]int{invalidID},
			[]int{},
		},
	}

	qb := db.Group

	for _, tt := range tests {
		runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
			subIDs := indexesToIDs(groupIDs, tt.subIdxs)

			id := indexToID(groupIDs, tt.containingGroupIdx)

			found, err := qb.FindSubGroupIDs(ctx, id, subIDs)
			if err != nil {
				t.Errorf("GroupStore.FindSubGroupIDs() error = %v", err)
				return
			}

			// get ids of groups
			foundIdxs := sliceutil.Map(found, func(id int) int { return slices.Index(groupIDs, id) })

			assert.ElementsMatch(t, tt.expectedIdxs, foundIdxs)
		})
	}
}

func TestGroupQueryCustomFields(t *testing.T) {
	tests := []struct {
		name        string
		filter      *models.GroupFilterType
		includeIdxs []int
		excludeIdxs []int
		wantErr     bool
	}{
		{
			"equals",
			&models.GroupFilterType{
				CustomFields: []models.CustomFieldCriterionInput{
					{
						Field:    "string",
						Modifier: models.CriterionModifierEquals,
						Value:    []any{getGroupStringValue(groupIdxWithChild, "custom")},
					},
				},
			},
			[]int{groupIdxWithChild},
			nil,
			false,
		},
		{
			"not equals",
			&models.GroupFilterType{
				Name: &models.StringCriterionInput{
					Value:    getGroupStringValue(groupIdxWithChild, "Name"),
					Modifier: models.CriterionModifierEquals,
				},
				CustomFields: []models.CustomFieldCriterionInput{
					{
						Field:    "string",
						Modifier: models.CriterionModifierNotEquals,
						Value:    []any{getGroupStringValue(groupIdxWithChild, "custom")},
					},
				},
			},
			nil,
			[]int{groupIdxWithChild},
			false,
		},
		{
			"includes",
			&models.GroupFilterType{
				CustomFields: []models.CustomFieldCriterionInput{
					{
						Field:    "string",
						Modifier: models.CriterionModifierIncludes,
						Value:    []any{getGroupStringValue(groupIdxWithChild, "custom")[9:]},
					},
				},
			},
			[]int{groupIdxWithChild},
			nil,
			false,
		},
		{
			"excludes",
			&models.GroupFilterType{
				Name: &models.StringCriterionInput{
					Value:    getGroupStringValue(groupIdxWithChild, "Name"),
					Modifier: models.CriterionModifierEquals,
				},
				CustomFields: []models.CustomFieldCriterionInput{
					{
						Field:    "string",
						Modifier: models.CriterionModifierExcludes,
						Value:    []any{getGroupStringValue(groupIdxWithChild, "custom")[9:]},
					},
				},
			},
			nil,
			[]int{groupIdxWithChild},
			false,
		},
		{
			"regex",
			&models.GroupFilterType{
				CustomFields: []models.CustomFieldCriterionInput{
					{
						Field:    "string",
						Modifier: models.CriterionModifierMatchesRegex,
						Value:    []any{".*11_custom"},
					},
				},
			},
			[]int{groupIdxWithChildWithScene},
			nil,
			false,
		},
		{
			"invalid regex",
			&models.GroupFilterType{
				CustomFields: []models.CustomFieldCriterionInput{
					{
						Field:    "string",
						Modifier: models.CriterionModifierMatchesRegex,
						Value:    []any{"["},
					},
				},
			},
			nil,
			nil,
			true,
		},
		{
			"not matches regex",
			&models.GroupFilterType{
				Name: &models.StringCriterionInput{
					Value:    getGroupStringValue(groupIdxWithChildWithScene, "Name"),
					Modifier: models.CriterionModifierEquals,
				},
				CustomFields: []models.CustomFieldCriterionInput{
					{
						Field:    "string",
						Modifier: models.CriterionModifierNotMatchesRegex,
						Value:    []any{".*11_custom"},
					},
				},
			},
			nil,
			[]int{groupIdxWithChildWithScene},
			false,
		},
		{
			"invalid not matches regex",
			&models.GroupFilterType{
				CustomFields: []models.CustomFieldCriterionInput{
					{
						Field:    "string",
						Modifier: models.CriterionModifierNotMatchesRegex,
						Value:    []any{"["},
					},
				},
			},
			nil,
			nil,
			true,
		},
		{
			"null",
			&models.GroupFilterType{
				Name: &models.StringCriterionInput{
					Value:    getGroupStringValue(groupIdxWithGrandParent, "Name"),
					Modifier: models.CriterionModifierEquals,
				},
				CustomFields: []models.CustomFieldCriterionInput{
					{
						Field:    "not existing",
						Modifier: models.CriterionModifierIsNull,
					},
				},
			},
			[]int{groupIdxWithGrandParent},
			nil,
			false,
		},
		{
			"not null",
			&models.GroupFilterType{
				Name: &models.StringCriterionInput{
					Value:    getGroupStringValue(groupIdxWithGrandParent, "Name"),
					Modifier: models.CriterionModifierEquals,
				},
				CustomFields: []models.CustomFieldCriterionInput{
					{
						Field:    "string",
						Modifier: models.CriterionModifierNotNull,
					},
				},
			},
			[]int{groupIdxWithGrandParent},
			nil,
			false,
		},
		{
			"between",
			&models.GroupFilterType{
				CustomFields: []models.CustomFieldCriterionInput{
					{
						Field:    "real",
						Modifier: models.CriterionModifierBetween,
						Value:    []any{0.15, 0.25},
					},
				},
			},
			[]int{groupIdxWithTag},
			nil,
			false,
		},
		{
			"not between",
			&models.GroupFilterType{
				Name: &models.StringCriterionInput{
					Value:    getGroupStringValue(groupIdxWithTag, "Name"),
					Modifier: models.CriterionModifierEquals,
				},
				CustomFields: []models.CustomFieldCriterionInput{
					{
						Field:    "real",
						Modifier: models.CriterionModifierNotBetween,
						Value:    []any{0.15, 0.25},
					},
				},
			},
			nil,
			[]int{groupIdxWithTag},
			false,
		},
	}

	for _, tt := range tests {
		runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
			assert := assert.New(t)

			groups, _, err := db.Group.Query(ctx, tt.filter, nil)
			if (err != nil) != tt.wantErr {
				t.Errorf("GroupStore.Query() error = %v, wantErr %v", err, tt.wantErr)
			}

			if err != nil {
				return
			}

			ids := groupsToIDs(groups)
			include := indexesToIDs(groupIDs, tt.includeIdxs)
			exclude := indexesToIDs(groupIDs, tt.excludeIdxs)

			for _, i := range include {
				assert.Contains(ids, i)
			}
			for _, e := range exclude {
				assert.NotContains(ids, e)
			}
		})
	}
}

// TODO Update
// TODO Destroy - ensure image is destroyed
// TODO Find
// TODO Count
// TODO All
// TODO Query
