package store

import (
	"context"
	"fmt"
	"math/rand"
	"sort"
	"testing"

	"github.com/kami/maven/internal/memory"
)

// naiveSearch is the implementation Search replaced: score every row into a
// slice, sort the whole slice, truncate. It stays in the test file as the
// reference the bounded-heap version is judged against, because "recall must
// not change" is a claim about output, not about the code that produces it.
func naiveSearch(t *testing.T, m *MemoryStore, vec []float32, topK int) []memory.Result {
	t.Helper()
	rows, err := m.db.QueryContext(context.Background(),
		`SELECT id, vec FROM memory_vectors WHERE id NOT LIKE ? ESCAPE '\'`,
		escapeLike(memory.NonRecallPrefix)+"%")
	if err != nil {
		t.Fatalf("naive scan: %v", err)
	}
	defer rows.Close()
	var out []memory.Result
	for rows.Next() {
		var id string
		var blob []byte
		if err := rows.Scan(&id, &blob); err != nil {
			t.Fatalf("naive row: %v", err)
		}
		out = append(out, memory.Result{ID: id, Score: dot(vec, decodeVec(blob))})
	}
	if err := rows.Err(); err != nil {
		t.Fatalf("naive rows: %v", err)
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Score > out[j].Score })
	if topK < len(out) {
		out = out[:topK]
	}
	return out
}

// TestMemoryStoreSearchMatchesNaive is the constraint on V-643: the bounded
// heap must return exactly what a full scan and sort returned. Distinct random
// vectors, so no two scores tie and the ranking is total — a mismatch here is
// arithmetic or heap logic, not a tie-break difference.
func TestMemoryStoreSearchMatchesNaive(t *testing.T) {
	ctx := context.Background()
	m := newMemTestStore(t).VectorMemory()

	rng := rand.New(rand.NewSource(7))
	const rows, dim = 500, 64
	for i := 0; i < rows; i++ {
		if err := m.Insert(ctx, fmt.Sprintf("n%d", i), randUnitVec(rng, dim), map[string]string{
			"text": fmt.Sprintf("note %d", i),
		}); err != nil {
			t.Fatalf("Insert %d: %v", i, err)
		}
	}

	for _, topK := range []int{1, 3, 10, 50, rows, rows + 100} {
		q := randUnitVec(rng, dim)
		got, err := m.Search(ctx, q, topK)
		if err != nil {
			t.Fatalf("Search topK=%d: %v", topK, err)
		}
		want := naiveSearch(t, m, q, topK)
		if len(got) != len(want) {
			t.Fatalf("topK=%d: got %d results, naive returned %d", topK, len(got), len(want))
		}
		for i := range want {
			if got[i].ID != want[i].ID {
				t.Errorf("topK=%d rank %d: got %q, naive says %q", topK, i, got[i].ID, want[i].ID)
			}
			if got[i].Score != want[i].Score {
				t.Errorf("topK=%d rank %d (%s): score %v, naive says %v",
					topK, i, got[i].ID, got[i].Score, want[i].Score)
			}
		}
		if len(got) > 0 && got[0].Meta["text"] == "" {
			t.Errorf("topK=%d: survivor %s has no meta — it was never unmarshalled", topK, got[0].ID)
		}
	}
}

// TestDotBlobMatchesDot pins the claim in dotBlob's doc comment: reading the
// vector out of its stored bytes is bit-identical to decoding it first. Scores
// feed a gate with a 0.008 margin, so "close enough" is not the bar.
func TestDotBlobMatchesDot(t *testing.T) {
	rng := rand.New(rand.NewSource(11))
	for i := 0; i < 200; i++ {
		a := randUnitVec(rng, 384)
		b := randUnitVec(rng, 384)
		if got, want := dotBlob(a, encodeVec(b)), dot(a, b); got != want {
			t.Fatalf("dotBlob = %v, dot = %v", got, want)
		}
	}
	// Length mismatch is 0 in both, and so is an empty vector.
	if got := dotBlob([]float32{1, 0}, encodeVec([]float32{1, 0, 0})); got != 0 {
		t.Errorf("mismatched lengths scored %v, want 0", got)
	}
	if got := dotBlob(nil, nil); got != 0 {
		t.Errorf("empty scored %v, want 0", got)
	}
}
