🏡 index : quine-global/quintodrome.git

package tests

import (
	"cmp"
	"context"
	"slices"
	"sync"
	"time"

	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/utils/slice"
)

type MockArtworkQueueRepo struct {
	model.ArtworkQueueRepository
	// mu guards Data: the worker drains this mock concurrently.
	mu   sync.Mutex
	Data map[string]model.ArtworkQueueItem // keyed by iaKey(kind, id, imageType)
	Err  error
	// ItemArtworkSource, when set, backs the set-difference insert with real item_artwork state.
	ItemArtworkSource *MockArtworkRepo
	// ExistingIDs is keyed by item_kind; a nil per-kind map means PurgeDangling keeps that kind.
	ExistingIDs map[string]map[string]bool
}

func CreateMockArtworkQueueRepo() *MockArtworkQueueRepo {
	return &MockArtworkQueueRepo{Data: map[string]model.ArtworkQueueItem{}}
}

func (m *MockArtworkQueueRepo) Get(_ context.Context, kind model.Kind, id, imageType string) (*model.ArtworkQueueItem, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.Err != nil {
		return nil, m.Err
	}
	it, ok := m.Data[iaKey(kind.Prefix(), id, imageType)]
	if !ok {
		return nil, model.ErrNotFound
	}
	return &it, nil
}

func (m *MockArtworkQueueRepo) Enqueue(_ context.Context, items ...model.ArtworkQueueItem) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.Err != nil {
		return m.Err
	}
	m.enqueueLocked(items)
	return nil
}

func (m *MockArtworkQueueRepo) enqueueLocked(items []model.ArtworkQueueItem) {
	now := time.Now()
	for _, it := range items {
		if it.ImageType == "" {
			it.ImageType = model.ImageTypePrimary
		}
		k := iaKey(it.ItemKind, it.ItemID, it.ImageType)
		// Mirror the SQL: retry_at/enqueued_at are server-set, never taken from the caller.
		if prev, ok := m.Data[k]; ok {
			prev.Priority = max(prev.Priority, it.Priority)
			prev.RetryAt = now
			prev.Attempts = 0
			prev.EnqueuedAt = now
			m.Data[k] = prev
			continue
		}
		it.Attempts = 0
		it.RetryAt = now
		it.EnqueuedAt = now
		m.Data[k] = it
	}
}

// EnqueueIfMissing mirrors the SQL anti-join: skip anything that already has an item_artwork row.
func (m *MockArtworkQueueRepo) EnqueueIfMissing(_ context.Context, items ...model.ArtworkQueueItem) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.Err != nil {
		return m.Err
	}
	var fresh []model.ArtworkQueueItem
	for _, it := range items {
		k := iaKey(it.ItemKind, it.ItemID, cmp.Or(it.ImageType, model.ImageTypePrimary))
		if m.ItemArtworkSource != nil {
			if _, ok := m.ItemArtworkSource.ItemData[k]; ok {
				continue
			}
		}
		if _, ok := m.Data[k]; ok { // DO NOTHING
			continue
		}
		fresh = append(fresh, it)
	}
	m.enqueueLocked(fresh)
	return nil
}

func (m *MockArtworkQueueRepo) DequeueBatch(_ context.Context, n int, kinds ...string) ([]model.ArtworkQueueItem, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.Err != nil {
		return nil, m.Err
	}
	var res []model.ArtworkQueueItem
	now := time.Now()
	for _, it := range m.Data {
		if !it.RetryAt.After(now) && (len(kinds) == 0 || slices.Contains(kinds, it.ItemKind)) {
			res = append(res, it)
		}
	}
	slices.SortFunc(res, func(a, b model.ArtworkQueueItem) int {
		return cmp.Or(cmp.Compare(b.Priority, a.Priority), a.EnqueuedAt.Compare(b.EnqueuedAt))
	})
	if len(res) > n {
		res = res[:n]
	}
	return res, nil
}

func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(_ context.Context, kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.Err != nil {
		return m.Err
	}
	k := iaKey(kind, id, imageType)
	if it, ok := m.Data[k]; ok && it.RetryAt.Equal(seenRetryAt) {
		it.Attempts++
		it.RetryAt = retryAt
		it.Trace = trace
		m.Data[k] = it
	}
	return nil
}

func (m *MockArtworkQueueRepo) DeleteIfUnchanged(_ context.Context, kind, id, imageType string, retryAt time.Time) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.Err != nil {
		return m.Err
	}
	k := iaKey(kind, id, imageType)
	if it, ok := m.Data[k]; ok && it.RetryAt.Equal(retryAt) {
		delete(m.Data, k)
	}
	return nil
}

func (m *MockArtworkQueueRepo) PurgeDangling(context.Context) (int64, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.Err != nil {
		return 0, m.Err
	}
	var purged int64
	for k, it := range m.Data {
		existing := m.ExistingIDs[it.ItemKind]
		if existing == nil {
			continue
		}
		if !existing[it.ItemID] {
			delete(m.Data, k)
			purged++
		}
	}
	return purged, nil
}

// queueFilterMatches mirrors artworkQueueFilter, so the mock cannot let a preview and a delete disagree.
func queueFilterMatches(it model.ArtworkQueueItem, kinds []model.Kind, priorities []int) bool {
	prefixes := model.KindPrefixes(kinds)
	return (len(prefixes) == 0 || slices.Contains(prefixes, it.ItemKind)) &&
		(len(priorities) == 0 || slices.Contains(priorities, it.Priority))
}

func (m *MockArtworkQueueRepo) PurgeQueued(_ context.Context, kinds []model.Kind, priorities []int) (int64, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.Err != nil {
		return 0, m.Err
	}
	var purged int64
	for k, it := range m.Data {
		if !queueFilterMatches(it, kinds, priorities) {
			continue
		}
		delete(m.Data, k)
		purged++
	}
	return purged, nil
}

func (m *MockArtworkQueueRepo) Count(context.Context) (int64, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.Err != nil {
		return 0, m.Err
	}
	return int64(len(m.Data)), nil
}

func (m *MockArtworkQueueRepo) CountQueued(_ context.Context, kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.Err != nil {
		return nil, m.Err
	}
	var res []model.ArtworkQueueStat
	for _, it := range m.Data {
		if !queueFilterMatches(it, kinds, priorities) {
			continue
		}
		i := slices.IndexFunc(res, func(s model.ArtworkQueueStat) bool {
			return s.ItemKind == it.ItemKind && s.Priority == it.Priority
		})
		if i < 0 {
			res = append(res, model.ArtworkQueueStat{ItemKind: it.ItemKind, Priority: it.Priority, Count: 1})
			continue
		}
		res[i].Count++
	}
	slices.SortFunc(res, func(a, b model.ArtworkQueueStat) int {
		return cmp.Or(cmp.Compare(a.ItemKind, b.ItemKind), cmp.Compare(b.Priority, a.Priority))
	})
	return res, nil
}

func (m *MockArtworkQueueRepo) EnqueuePreservingBackoff(_ context.Context, items ...model.ArtworkQueueItem) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.Err != nil {
		return m.Err
	}
	now := time.Now()
	for _, it := range items {
		if it.ImageType == "" {
			it.ImageType = model.ImageTypePrimary
		}
		k := iaKey(it.ItemKind, it.ItemID, it.ImageType)
		// Preserve an existing row's retry_at: a bump raises priority without resetting backoff.
		if prev, ok := m.Data[k]; ok {
			prev.Priority = max(prev.Priority, it.Priority)
			m.Data[k] = prev
			continue
		}
		it.Attempts = 0
		it.RetryAt = now
		it.EnqueuedAt = now
		m.Data[k] = it
	}
	return nil
}

// matchingSource mirrors the SQL filter: no sources means every source, "" the absent state, and
// ArtworkSourceFailed the absent states that gave up.
func (m *MockArtworkQueueRepo) matchingSource(kind model.Kind, sources []string) []model.ItemArtwork {
	if m.ItemArtworkSource == nil {
		return nil
	}
	matches := func(ia model.ItemArtwork) bool {
		if len(sources) == 0 {
			return true
		}
		if slices.Contains(sources, ia.Source) {
			return true
		}
		return slices.Contains(sources, model.ArtworkSourceFailed) && ia.Hash == "" && ia.LastFailure != ""
	}
	var res []model.ItemArtwork
	for _, ia := range m.ItemArtworkSource.ItemData {
		if ia.ItemKind == kind.Prefix() && matches(ia) {
			res = append(res, ia)
		}
	}
	return res
}

func (m *MockArtworkQueueRepo) CountBySource(_ context.Context, kind model.Kind, sources []string) (int64, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.Err != nil {
		return 0, m.Err
	}
	return int64(len(m.matchingSource(kind, sources))), nil
}

func (m *MockArtworkQueueRepo) SourcesInUse(_ context.Context, kind model.Kind) ([]string, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.Err != nil {
		return nil, m.Err
	}
	sources := slice.Map(m.matchingSource(kind, nil), func(ia model.ItemArtwork) string { return ia.Source })
	return slice.Unique(sources), nil
}

func (m *MockArtworkQueueRepo) EnqueueBySource(_ context.Context, kind model.Kind, sources []string, priority int) (int64, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.Err != nil {
		return 0, m.Err
	}
	now := time.Now()
	var inserted int64
	for _, ia := range m.matchingSource(kind, sources) {
		k := iaKey(ia.ItemKind, ia.ItemID, ia.ImageType)
		if _, ok := m.Data[k]; ok { // DO NOTHING: never touch existing queue rows
			continue
		}
		m.Data[k] = model.ArtworkQueueItem{
			ItemKind:   ia.ItemKind,
			ItemID:     ia.ItemID,
			ImageType:  ia.ImageType,
			Priority:   priority,
			RetryAt:    now,
			EnqueuedAt: now,
		}
		inserted++
	}
	return inserted, nil
}

// EnqueueAllMissing mirrors the SQL set-difference insert: ExistingIDs[kind] minus ItemArtworkSource.
func (m *MockArtworkQueueRepo) EnqueueAllMissing(_ context.Context, kind model.Kind, priority int) (int64, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.Err != nil {
		return 0, m.Err
	}
	hasRow := func(id string) bool {
		if m.ItemArtworkSource == nil {
			return false
		}
		for _, ia := range m.ItemArtworkSource.ItemData {
			if ia.ItemKind == kind.Prefix() && ia.ItemID == id {
				return true
			}
		}
		return false
	}
	now := time.Now()
	var inserted int64
	for id := range m.ExistingIDs[kind.Prefix()] {
		if hasRow(id) {
			continue
		}
		k := iaKey(kind.Prefix(), id, model.ImageTypePrimary)
		if _, ok := m.Data[k]; ok { // DO NOTHING: never touch existing queue rows
			continue
		}
		m.Data[k] = model.ArtworkQueueItem{
			ItemKind:   kind.Prefix(),
			ItemID:     id,
			ImageType:  model.ImageTypePrimary,
			Priority:   priority,
			RetryAt:    now,
			EnqueuedAt: now,
		}
		inserted++
	}
	return inserted, nil
}