package memory

import (
	"context"
	"sort"
	"strings"
	"sync"
)

// NonRecallPrefix — rows whose id starts with this are excluded from Search by
// every backend. Speaker voiceprints live in the same vector table as notes and
// facts (internal/speaker writes them under this prefix), and they are not
// recall material: a voiceprint has no text to read back and surfacing one as a
// note hit leaks a name attached to a biometric.
//
// Reading them through Catalog.ByPrefix was documented as what keeps them out
// of recall. It is not. It controls how speaker code reads its own rows and
// says nothing about what Search scores. What actually kept them out was that
// cosine returns 0 on a width mismatch, so a 192-dim voiceprint scored 0
// against a 384-dim query. That is a coincidence of two model choices — some
// x-vector exports are 384-dim — and not an invariant. This is the invariant.
const NonRecallPrefix = "speaker:"

// Result is a single search hit.
type Result struct {
	ID    string
	Score float64
	Meta  map[string]string
}

// Store is a vector memory interface.
type Store interface {
	Insert(ctx context.Context, id string, vec []float32, meta map[string]string) error
	Search(ctx context.Context, vec []float32, topK int) ([]Result, error)
}

// Record is a stored vector read back whole — id, vector and metadata — as
// opposed to Result, which is a search hit and carries a score instead of the
// vector.
type Record struct {
	ID   string
	Vec  []float32
	Meta map[string]string
}

// Catalog is a Store that can also be enumerated by id prefix and deleted from.
//
// Search is not enough for every user of the vector table. Speaker profiles
// (internal/speaker) need to list exactly their own rows without scoring
// anything, because listing enrolled voices is not a similarity question, and
// they need Delete because a voiceprint is data about a person and "forget this
// voice" has to actually remove it. Note and fact recall use plain Store and are
// unaffected.
type Catalog interface {
	Store
	// ByPrefix returns every row whose id starts with prefix, in no particular
	// order. An empty prefix returns everything.
	ByPrefix(ctx context.Context, prefix string) ([]Record, error)
	// Delete removes one row by id. Deleting a row that is not there is not an
	// error: the caller asked for it to be gone and it is gone.
	Delete(ctx context.Context, id string) error
}

// item is a single stored vector with metadata.
type item struct {
	id   string
	vec  []float32
	meta map[string]string
}

// InMemoryStore implements Store with cosine similarity search.
type InMemoryStore struct {
	mu    sync.RWMutex
	items []item
}

// compile-time check: InMemoryStore satisfies Catalog.
var _ Catalog = (*InMemoryStore)(nil)

func NewInMemoryStore() *InMemoryStore {
	return &InMemoryStore{}
}

// Insert upserts by id, matching the persistent store.MemoryStore: a repeated
// id replaces the prior row rather than accumulating a second copy. Re-indexing
// a note is an update, and re-enrolling a voice must replace the old voiceprint
// rather than leave it searchable.
func (s *InMemoryStore) Insert(_ context.Context, id string, vec []float32, meta map[string]string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	for i := range s.items {
		if s.items[i].id == id {
			s.items[i] = item{id: id, vec: vec, meta: meta}
			return nil
		}
	}
	s.items = append(s.items, item{id: id, vec: vec, meta: meta})
	return nil
}

// ByPrefix implements Catalog.
func (s *InMemoryStore) ByPrefix(_ context.Context, prefix string) ([]Record, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	var out []Record
	for _, it := range s.items {
		if !strings.HasPrefix(it.id, prefix) {
			continue
		}
		// Copy the metadata too. Returning it.meta by reference let a caller
		// mutating the returned map edit the stored row, and the persistent
		// backend unmarshals fresh, so the two disagreed.
		meta := make(map[string]string, len(it.meta))
		for k, v := range it.meta {
			meta[k] = v
		}
		out = append(out, Record{ID: it.id, Vec: append([]float32(nil), it.vec...), Meta: meta})
	}
	return out, nil
}

// Delete implements Catalog.
func (s *InMemoryStore) Delete(_ context.Context, id string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	for i := range s.items {
		if s.items[i].id == id {
			s.items = append(s.items[:i], s.items[i+1:]...)
			return nil
		}
	}
	return nil
}

// DeletePrefix removes every row whose id starts with prefix and returns how
// many went. It matches store.MemoryStore's method so a test double and the
// real index agree about superseding a fact (Vikunja #493) — a double that
// silently kept the old vectors would pass a test the daemon fails.
func (s *InMemoryStore) DeletePrefix(_ context.Context, prefix string) (int64, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	kept := s.items[:0]
	var n int64
	for _, it := range s.items {
		if strings.HasPrefix(it.id, prefix) {
			n++
			continue
		}
		kept = append(kept, it)
	}
	s.items = kept
	return n, nil
}

func (s *InMemoryStore) Search(_ context.Context, vec []float32, topK int) ([]Result, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()

	if topK <= 0 {
		topK = 10
	}

	type scored struct {
		id    string
		score float64
		meta  map[string]string
	}

	scores := make([]scored, 0, len(s.items))
	for _, it := range s.items {
		if strings.HasPrefix(it.id, NonRecallPrefix) {
			continue
		}
		score := cosine(vec, it.vec)
		scores = append(scores, scored{id: it.id, score: score, meta: it.meta})
	}

	sort.Slice(scores, func(i, j int) bool {
		return scores[i].score > scores[j].score // descending
	})

	if topK > len(scores) {
		topK = len(scores)
	}

	out := make([]Result, topK)
	for i := 0; i < topK; i++ {
		out[i] = Result{
			ID:    scores[i].id,
			Score: scores[i].score,
			Meta:  scores[i].meta,
		}
	}
	return out, nil
}

// cosine similarity (dot product, assumes L2-normalized vectors).
func cosine(a, b []float32) float64 {
	if len(a) != len(b) || len(a) == 0 {
		return 0
	}
	var dot float64
	for i := range a {
		dot += float64(a[i]) * float64(b[i])
	}
	return dot
}
