package postgres

import (
	"context"
	"errors"
	"fmt"
	"strconv"
	"strings"
	"time"

	"github.com/jackc/pgx/v5"

	"github.com/denn-gubsky/loomcycle/internal/store"
)

// Vector Memory — Postgres pgvector implementation.
//
// Wire format: pgvector accepts its native text representation
// `[1.0,2.0,3.0]::vector`. We encode/decode that ourselves rather
// than pulling in pgvector-go as a dependency — the format is
// trivial and the round-trip stays bytes-equal.
//
// Distance function: cosine (`<=>`). pgvector's cosine_distance is
// `1 - cos(theta)`, range [0, 2]. We convert to similarity via
// `1.0 - distance` and surface that as MemorySearchEntry.Score, in
// [0, 1] for non-negative real vectors.
//
// Dimension-mismatch handling: we pre-check by reading the first
// matching row's `dimension` column and comparing against the
// query vector's length BEFORE running the cosine query. This gives
// a typed ErrDimensionMismatch instead of pgvector's opaque
// "different vector dimensions" runtime error.

// SupportsVectors reports whether vector ops are available. True only when
// Config.PgvectorEnabled was set AND Open()'s probes saw BOTH the `vector`
// extension loaded AND the memory_embeddings table present. The table is a
// distinct condition, not a redundant one: a deployment that installed
// pgvector after already migrating past 0017 has the extension but no table
// (0017 skipped it and golang-migrate never re-applies a migration — see
// migration 0062), and reporting true there would send every query at a
// relation that does not exist.
func (s *Store) SupportsVectors() bool {
	return s.pgvectorEnabled
}

// SupportsFullText reports whether the keyword (full-text) retrieval leg is
// available (RFC BL). It tracks SupportsVectors exactly: migration 0060's
// tsvector column and its GIN index live ON memory_embeddings, so full-text
// is available precisely when that table is — including the extension-present-
// but-table-missing case, where both correctly report false.
func (s *Store) SupportsFullText() bool {
	return s.pgvectorEnabled
}

// MemoryEmbedSet upserts the embedding for one (scope, scope_id, key).
// The base memory row must exist — the FK enforces this; absent
// base row surfaces as a Postgres `foreign_key_violation`.
func (s *Store) MemoryEmbedSet(ctx context.Context, tenantID string, scope store.MemoryScope, scopeID, key string, e store.MemoryEmbedding) error {
	if !s.pgvectorEnabled {
		return store.ErrVectorUnsupported
	}
	if e.Dimension <= 0 {
		return errors.New("memory_embeddings: dimension must be > 0")
	}
	if len(e.Vector) != e.Dimension {
		return fmt.Errorf("memory_embeddings: vector length %d != declared dimension %d", len(e.Vector), e.Dimension)
	}
	vecText := encodePgvector(e.Vector)
	createdAt := e.CreatedAt
	if createdAt.IsZero() {
		createdAt = time.Now().UTC()
	}
	_, err := s.pool.Exec(ctx,
		`INSERT INTO memory_embeddings(tenant_id, scope, scope_id, key, provider, model, dimension, embedding, embed_text, created_at)
		 VALUES ($1, $2, $3, $4, $5, $6, $7, $8::vector, $9, $10)
		 ON CONFLICT (tenant_id, scope, scope_id, key) DO UPDATE SET
		    provider   = excluded.provider,
		    model      = excluded.model,
		    dimension  = excluded.dimension,
		    embedding  = excluded.embedding,
		    embed_text = excluded.embed_text,
		    created_at = excluded.created_at`,
		tenantID, string(scope), scopeID, key, e.Provider, e.Model, e.Dimension, vecText, e.EmbedText, createdAt,
	)
	if err != nil {
		return fmt.Errorf("MemoryEmbedSet: %w", err)
	}
	return nil
}

// MemoryEmbedGet returns the stored embedding, decoding the
// pgvector text representation back to float32.
// MemoryEmbedDelete removes one row's embedding, leaving the base memory row. See
// the Store interface for why this exists and why it is idempotent.
func (s *Store) MemoryEmbedDelete(ctx context.Context, tenantID string, scope store.MemoryScope, scopeID, key string) error {
	if !s.pgvectorEnabled {
		return store.ErrVectorUnsupported
	}
	// No rows-affected check: an absent embedding is a successful no-op, so a purge
	// sweep can walk every row without probing first.
	if _, err := s.pool.Exec(ctx,
		`DELETE FROM memory_embeddings
		  WHERE tenant_id = $1 AND scope = $2 AND scope_id = $3 AND key = $4`,
		tenantID, string(scope), scopeID, key,
	); err != nil {
		return fmt.Errorf("MemoryEmbedDelete: %w", err)
	}
	return nil
}

func (s *Store) MemoryEmbedGet(ctx context.Context, tenantID string, scope store.MemoryScope, scopeID, key string) (store.MemoryEmbedding, error) {
	if !s.pgvectorEnabled {
		return store.MemoryEmbedding{}, store.ErrVectorUnsupported
	}
	var (
		provider, model, embedText, embeddingText string
		dimension                                 int
		createdAt                                 time.Time
	)
	err := s.pool.QueryRow(ctx,
		`SELECT provider, model, dimension, embedding::text, embed_text, created_at
		 FROM memory_embeddings
		 WHERE tenant_id = $1 AND scope = $2 AND scope_id = $3 AND key = $4`,
		tenantID, string(scope), scopeID, key,
	).Scan(&provider, &model, &dimension, &embeddingText, &embedText, &createdAt)
	if errors.Is(err, pgx.ErrNoRows) {
		return store.MemoryEmbedding{}, &store.ErrNotFound{Kind: "memory_embedding", ID: key}
	}
	if err != nil {
		return store.MemoryEmbedding{}, fmt.Errorf("MemoryEmbedGet: %w", err)
	}
	vec, err := decodePgvector(embeddingText)
	if err != nil {
		return store.MemoryEmbedding{}, fmt.Errorf("MemoryEmbedGet: decode vector: %w", err)
	}
	return store.MemoryEmbedding{
		Provider:  provider,
		Model:     model,
		Dimension: dimension,
		Vector:    vec,
		EmbedText: embedText,
		CreatedAt: createdAt,
	}, nil
}

// MemoryEmbedSearch runs Top-K cosine similarity. Pre-checks the
// scope's stored dimension against the query vector dimension — a
// mismatch returns ErrDimensionMismatch rather than letting pgvector
// raise its own runtime error.
//
// Empty (scope, scope_id) returns ([], nil) — explicit non-error so
// "no rows yet" doesn't pollute the agent's error path.
// provenanceCondition renders the RFC BW phase-2 provenance constraint.
//
// coalesce, because `origin` is nullable: legacy rows predate the column and a bare
// `origin <> ”` would silently drop every one of them from a facts-only search.
func provenanceCondition(c store.MemoryProvenanceConstraint) string {
	switch c {
	case store.ProvenanceRequired:
		return " AND coalesce(m.origin, '') <> ''"
	case store.ProvenanceAbsent:
		return " AND coalesce(m.origin, '') = ''"
	}
	return ""
}

// likePrefixPattern turns a literal key prefix into a LIKE pattern.
//
// ESCAPED, because a prefix is caller-supplied and LIKE treats % and _ as wildcards:
// an unescaped "a_b" would also match "axb". Today's callers pass literals without
// them (DocumentChunkKeyPrefix is "doc.chunk:"), so this changes no current result —
// it stops the next caller's prefix from silently matching more than it named.
func likePrefixPattern(prefix string) string {
	r := strings.NewReplacer(`\`, `\\`, "%", `\%`, "_", `\_`)
	return r.Replace(prefix) + "%"
}

func (s *Store) MemoryEmbedSearch(ctx context.Context, tenantID string, scope store.MemoryScope, scopeID string, filter store.MemorySearchFilter, query []float32, topK int) ([]store.MemorySearchEntry, error) {
	if !s.pgvectorEnabled {
		return nil, store.ErrVectorUnsupported
	}
	if len(query) == 0 {
		return nil, errors.New("MemoryEmbedSearch: query vector is empty")
	}
	if topK <= 0 {
		topK = 10
	}
	// Defensive upper cap. Agents may request at most 50 per the
	// RFC; the tool layer surfaces 50 as its hard max. We accept 51
	// internally so the tool layer's "topK+1" truncation probe at
	// the boundary still returns the extra row used to detect
	// overflow.
	if topK > 51 {
		topK = 51
	}

	// Dimension pre-check. Read one row's dimension under (scope,
	// scope_id). If none exists, return empty results (the "empty
	// scope" non-error path). If exists, compare against the query.
	// A scope may hold rows at MORE THAN ONE dimension — migration 0017 calls that
	// the typical steady state ("migrating from text-embedding-3-small to large;
	// not everything re-embedded yet") — so this asks two questions: does the scope
	// have rows at all, and does it have any at the QUERY's dimension.
	//
	// Reading one arbitrary row's dimension (LIMIT 1, no ORDER BY) was wrong in both
	// directions. If that row matched the query, the pre-check passed and the <=>
	// operator then met a row of another dimension, so postgres aborted the whole
	// statement with a raw `different vector dimensions 8 and 4` (SQLSTATE 22000) —
	// recall entirely broken for the scope, surfaced as a driver error instead of
	// the typed, actionable refusal. If it did not match, the search was refused
	// even though searchable rows existed. Which happened depended on row order.
	var total, atDim int
	var otherDim *int
	err := s.pool.QueryRow(ctx,
		`SELECT count(*),
		        count(*) FILTER (WHERE dimension = $4),
		        min(dimension) FILTER (WHERE dimension <> $4)
		   FROM memory_embeddings
		  WHERE tenant_id = $1 AND scope = $2 AND scope_id = $3`,
		tenantID, string(scope), scopeID, len(query),
	).Scan(&total, &atDim, &otherDim)
	if err != nil {
		return nil, fmt.Errorf("MemoryEmbedSearch dim probe: %w", err)
	}
	if total == 0 {
		return nil, nil
	}
	if atDim == 0 {
		// Rows exist but NONE are searchable at this dimension. Returning empty
		// would read as "this scope knows nothing" rather than "this scope needs
		// re-embedding", so this stays the typed refusal.
		other := 0
		if otherDim != nil {
			other = *otherDim
		}
		return nil, &store.MemoryError{
			Code: store.ErrDimensionMismatch.Code,
			Msg:  fmt.Sprintf("memory: query embedding dimension %d does not match stored rows' dimension %d — run /v1/_memory/reembed to migrate", len(query), other),
		}
	}
	queryText := encodePgvector(query)

	// Filter expired base rows in the WHERE via a JOIN. The base
	// memory row's expires_at column is the source of truth; the
	// CASCADE handles deletes but not TTL-expired-not-yet-swept.
	// We JOIN + filter so stale rows don't leak into search results.
	prefixCondition := ""
	// Restricting to the query's dimension is what makes a mid-migration scope
	// DEGRADE instead of failing: rows that can be compared are compared, and the
	// rest are skipped rather than aborting the statement. Without it, ONE row at
	// another dimension breaks recall for the whole scope.
	args := []any{tenantID, string(scope), scopeID, queryText, topK, len(query)}
	if filter.KeyPrefix != "" {
		args = append(args, likePrefixPattern(filter.KeyPrefix))
		prefixCondition += " AND me.key LIKE $" + strconv.Itoa(len(args))
	}
	// RFC BW: the exclusion is what keeps document bodies out of fact recall. It is
	// applied HERE rather than by the caller because the LIMIT below must see the
	// filtered set — the pool is capped at 51, and on a scope that is mostly
	// documents a post-filter would leave a caller asking for ten facts with two.
	if filter.ExcludeKeyPrefix != "" {
		args = append(args, likePrefixPattern(filter.ExcludeKeyPrefix))
		prefixCondition += " AND me.key NOT LIKE $" + strconv.Itoa(len(args))
	}
	prefixCondition += provenanceCondition(filter.Provenance)
	sql := `SELECT me.key, m.value, m.expires_at, m.created_at, m.updated_at,
	               1.0 - (me.embedding <=> $4::vector) AS score,
	               me.provider, me.model, me.embedding::text AS embedding_text,
	               m.access_count, coalesce(m.origin, '') AS origin
	         FROM memory_embeddings me
	         JOIN memory m
	            ON me.tenant_id = m.tenant_id
	           AND me.scope    = m.scope
	           AND me.scope_id = m.scope_id
	           AND me.key      = m.key
	         WHERE me.tenant_id = $1
	           AND me.scope = $2
	           AND me.scope_id = $3
	           AND (m.expires_at IS NULL OR m.expires_at > now())
	           AND m.superseded_at IS NULL
			   AND me.dimension = $6` + prefixCondition + `
	         ORDER BY me.embedding <=> $4::vector
	         LIMIT $5`
	rows, err := s.pool.Query(ctx, sql, args...)
	if err != nil {
		return nil, fmt.Errorf("MemoryEmbedSearch query: %w", err)
	}
	defer rows.Close()

	out := []store.MemorySearchEntry{}
	for rows.Next() {
		var (
			key                string
			valueBytes         []byte
			expiresAt          *time.Time
			createdAt          time.Time
			updatedAt          time.Time
			score              float64
			provider, modelStr string
			embeddingText      string
			accessCount        int64
			origin             string
		)
		if err := rows.Scan(&key, &valueBytes, &expiresAt, &createdAt, &updatedAt, &score, &provider, &modelStr, &embeddingText, &accessCount, &origin); err != nil {
			return nil, fmt.Errorf("MemoryEmbedSearch scan: %w", err)
		}
		entry := store.MemorySearchEntry{
			MemoryEntry: store.MemoryEntry{
				Key:       key,
				Value:     valueBytes,
				CreatedAt: createdAt,
				UpdatedAt: updatedAt,
			},
			Score:       score,
			AccessCount: accessCount,
			Origin:      origin,
		}
		if expiresAt != nil {
			entry.ExpiresAt = *expiresAt
		}
		entry.EmbeddedWith.Provider = provider
		entry.EmbeddedWith.Model = modelStr
		// Retain the stored vector for client-side search-time dedup (RFC I
		// MR-5 / Decision 2). Reuse the same text codec MemoryEmbedGet uses.
		// A decode failure is non-fatal for dedup (an empty Vector means the
		// row is simply never deduped), so we drop the vector and continue
		// rather than failing the whole search.
		if vec, derr := decodePgvector(embeddingText); derr == nil {
			entry.Vector = vec
		}
		out = append(out, entry)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("MemoryEmbedSearch iter: %w", err)
	}
	return out, nil
}

// MemoryFullTextSearch runs the keyword (lexical) retrieval leg over the
// generated tsvector on memory_embeddings.embed_text (migration 0060),
// ranked by ts_rank. Same result shape + tenant/prefix/expiry scoping as
// MemoryEmbedSearch. The in-process backend fuses this ordering with the
// vector ordering via RRF (rank-based), so ts_rank's magnitude — which is
// not comparable to cosine — only serves to ORDER this leg.
//
// Without pgvector the memory_embeddings table (hence the tsvector column)
// doesn't exist, so we degrade to (nil, nil) rather than erroring; the
// caller then runs pure-vector, which is itself unavailable without
// pgvector, so this path is effectively Postgres+pgvector only.
func (s *Store) MemoryFullTextSearch(ctx context.Context, tenantID string, scope store.MemoryScope, scopeID string, filter store.MemorySearchFilter, queryText string, topK int) ([]store.MemorySearchEntry, error) {
	if !s.pgvectorEnabled {
		return nil, nil
	}
	if topK <= 0 {
		topK = 10
	}
	// Match MemoryEmbedSearch's defensive cap so the +1 truncation probe at
	// the tool boundary still returns the extra row.
	if topK > 51 {
		topK = 51
	}

	prefixCondition := ""
	args := []any{tenantID, string(scope), scopeID, queryText, topK}
	if filter.KeyPrefix != "" {
		args = append(args, likePrefixPattern(filter.KeyPrefix))
		prefixCondition += " AND me.key LIKE $" + strconv.Itoa(len(args))
	}
	// The lexical leg has to apply the SAME exclusion as the vector leg: the two are
	// fused by RRF, so a document row admitted by either one reaches the caller.
	if filter.ExcludeKeyPrefix != "" {
		args = append(args, likePrefixPattern(filter.ExcludeKeyPrefix))
		prefixCondition += " AND me.key NOT LIKE $" + strconv.Itoa(len(args))
	}
	prefixCondition += provenanceCondition(filter.Provenance)
	// plainto_tsquery normalises arbitrary user text into a safe tsquery
	// (never errors on input, no injection surface), returning the empty
	// query — which matches nothing — when the text yields no lexemes.
	sql := `SELECT me.key, m.value, m.expires_at, m.created_at, m.updated_at,
	               ts_rank(me.embed_text_tsv, plainto_tsquery('english', $4)) AS score,
	               me.provider, me.model, me.embedding::text AS embedding_text,
	               m.access_count, coalesce(m.origin, '') AS origin
	         FROM memory_embeddings me
	         JOIN memory m
	            ON me.tenant_id = m.tenant_id
	           AND me.scope    = m.scope
	           AND me.scope_id = m.scope_id
	           AND me.key      = m.key
	         WHERE me.tenant_id = $1
	           AND me.scope = $2
	           AND me.scope_id = $3
	           AND (m.expires_at IS NULL OR m.expires_at > now())
	           AND m.superseded_at IS NULL
	           AND me.embed_text_tsv @@ plainto_tsquery('english', $4)` + prefixCondition + `
	         ORDER BY score DESC
	         LIMIT $5`
	rows, err := s.pool.Query(ctx, sql, args...)
	if err != nil {
		return nil, fmt.Errorf("MemoryFullTextSearch query: %w", err)
	}
	defer rows.Close()

	out := []store.MemorySearchEntry{}
	for rows.Next() {
		var (
			key                string
			valueBytes         []byte
			expiresAt          *time.Time
			createdAt          time.Time
			updatedAt          time.Time
			score              float64
			provider, modelStr string
			embeddingText      string
			accessCount        int64
			origin             string
		)
		if err := rows.Scan(&key, &valueBytes, &expiresAt, &createdAt, &updatedAt, &score, &provider, &modelStr, &embeddingText, &accessCount, &origin); err != nil {
			return nil, fmt.Errorf("MemoryFullTextSearch scan: %w", err)
		}
		entry := store.MemorySearchEntry{
			MemoryEntry: store.MemoryEntry{
				Key:       key,
				Value:     valueBytes,
				CreatedAt: createdAt,
				UpdatedAt: updatedAt,
			},
			Score:       score,
			AccessCount: accessCount,
			Origin:      origin,
		}
		if expiresAt != nil {
			entry.ExpiresAt = *expiresAt
		}
		entry.EmbeddedWith.Provider = provider
		entry.EmbeddedWith.Model = modelStr
		// Carry the stored vector so an FTS-only survivor still participates
		// in the shared dedup pass (mirrors MemoryEmbedSearch). A decode
		// failure is non-fatal — the entry is simply never deduped.
		if vec, derr := decodePgvector(embeddingText); derr == nil {
			entry.Vector = vec
		}
		out = append(out, entry)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("MemoryFullTextSearch iter: %w", err)
	}
	return out, nil
}

// MemoryBumpAccessBatch applies access-count deltas additively. See the
// Store interface for the semantics. Uses a single pipelined batch so a
// flush of N rows is one round-trip; each per-row UPDATE is atomic, so two
// replicas' concurrent bumps to the same row sum (row lock serialises them).
func (s *Store) MemoryBumpAccessBatch(ctx context.Context, bumps []store.MemoryAccessBump) error {
	if len(bumps) == 0 {
		return nil
	}
	batch := &pgx.Batch{}
	for _, b := range bumps {
		batch.Queue(
			`UPDATE memory
			    SET access_count = access_count + $5,
			        last_accessed_at = GREATEST(last_accessed_at, $6)
			  WHERE tenant_id = $1 AND scope = $2 AND scope_id = $3 AND key = $4`,
			b.TenantID, string(b.Scope), b.ScopeID, b.Key, b.CountDelta, b.LastAccess,
		)
	}
	br := s.pool.SendBatch(ctx, batch)
	defer br.Close()
	for range bumps {
		if _, err := br.Exec(); err != nil {
			return fmt.Errorf("MemoryBumpAccessBatch: %w", err)
		}
	}
	return nil
}

// MemoryEmbedListByModel returns base-memory rows whose embedding
// is under a DIFFERENT (provider, model) than the supplied current
// pair. Drives the reembed admin endpoint (PR 4).
func (s *Store) MemoryEmbedListByModel(ctx context.Context, tenantID string, scope store.MemoryScope, scopeID, currentProvider, currentModel string, limit int) ([]store.MemoryEntry, error) {
	if !s.pgvectorEnabled {
		return nil, store.ErrVectorUnsupported
	}
	if limit <= 0 {
		limit = 1000
	}
	rows, err := s.pool.Query(ctx,
		`SELECT me.key, m.value, m.expires_at, m.created_at, m.updated_at
		 FROM memory_embeddings me
		 JOIN memory m
		    ON me.tenant_id = m.tenant_id AND me.scope = m.scope AND me.scope_id = m.scope_id AND me.key = m.key
		 WHERE me.tenant_id = $1 AND me.scope = $2 AND me.scope_id = $3
		   AND (me.provider <> $4 OR me.model <> $5)
		   AND (m.expires_at IS NULL OR m.expires_at > now())
		 ORDER BY me.key
		 LIMIT $6`,
		tenantID, string(scope), scopeID, currentProvider, currentModel, limit,
	)
	if err != nil {
		return nil, fmt.Errorf("MemoryEmbedListByModel: %w", err)
	}
	defer rows.Close()

	out := []store.MemoryEntry{}
	for rows.Next() {
		var (
			key                  string
			valueBytes           []byte
			expiresAt            *time.Time
			createdAt, updatedAt time.Time
		)
		if err := rows.Scan(&key, &valueBytes, &expiresAt, &createdAt, &updatedAt); err != nil {
			return nil, fmt.Errorf("MemoryEmbedListByModel scan: %w", err)
		}
		e := store.MemoryEntry{
			Key:       key,
			Value:     valueBytes,
			CreatedAt: createdAt,
			UpdatedAt: updatedAt,
		}
		if expiresAt != nil {
			e.ExpiresAt = *expiresAt
		}
		out = append(out, e)
	}
	return out, rows.Err()
}

// MemoryEmbedStats returns per-(provider, model) row counts +
// total embedding bytes for the scope. Embedding bytes estimated
// as dimension * 4 (float32) per row; not exact (pgvector's on-disk
// size includes some metadata) but close enough for operator
// dashboards.
func (s *Store) MemoryEmbedStats(ctx context.Context, tenantID string, scope store.MemoryScope) (store.MemoryEmbedStats, error) {
	if !s.pgvectorEnabled {
		return store.MemoryEmbedStats{}, store.ErrVectorUnsupported
	}
	rows, err := s.pool.Query(ctx,
		`SELECT provider, model, dimension, COUNT(*)
		 FROM memory_embeddings
		 WHERE tenant_id = $1 AND scope = $2
		 GROUP BY provider, model, dimension
		 ORDER BY provider, model`,
		tenantID, string(scope),
	)
	if err != nil {
		return store.MemoryEmbedStats{}, fmt.Errorf("MemoryEmbedStats: %w", err)
	}
	defer rows.Close()

	out := store.MemoryEmbedStats{Scope: scope, Models: []store.MemoryEmbedModelStats{}}
	for rows.Next() {
		var (
			provider, model string
			dimension       int
			rowCount        int
		)
		if err := rows.Scan(&provider, &model, &dimension, &rowCount); err != nil {
			return store.MemoryEmbedStats{}, fmt.Errorf("MemoryEmbedStats scan: %w", err)
		}
		out.Models = append(out.Models, store.MemoryEmbedModelStats{
			Provider:  provider,
			Model:     model,
			Dimension: dimension,
			RowCount:  rowCount,
		})
		out.TotalEmbeddingBytes += int64(rowCount) * int64(dimension) * 4
	}
	return out, rows.Err()
}

// encodePgvector formats a float32 slice as pgvector's text shape
// "[1.0,2.0,3.0]". Uses strconv.FormatFloat with -1 precision so
// the round-trip is byte-stable for the bit-pattern (matches what
// pgvector's text-output uses).
func encodePgvector(v []float32) string {
	if len(v) == 0 {
		return "[]"
	}
	var b strings.Builder
	b.Grow(len(v) * 12)
	b.WriteByte('[')
	for i, f := range v {
		if i > 0 {
			b.WriteByte(',')
		}
		b.WriteString(strconv.FormatFloat(float64(f), 'g', -1, 32))
	}
	b.WriteByte(']')
	return b.String()
}

// decodePgvector parses pgvector's text representation "[1,2,3]"
// back to []float32. Tolerates whitespace between elements.
func decodePgvector(s string) ([]float32, error) {
	s = strings.TrimSpace(s)
	if !strings.HasPrefix(s, "[") || !strings.HasSuffix(s, "]") {
		return nil, fmt.Errorf("decodePgvector: missing brackets in %q", s)
	}
	body := s[1 : len(s)-1]
	if body == "" {
		return []float32{}, nil
	}
	parts := strings.Split(body, ",")
	out := make([]float32, len(parts))
	for i, p := range parts {
		f, err := strconv.ParseFloat(strings.TrimSpace(p), 32)
		if err != nil {
			return nil, fmt.Errorf("decodePgvector: parse %q: %w", p, err)
		}
		out[i] = float32(f)
	}
	return out, nil
}

// MemoryEmbedListMissing returns live rows with NO embedding, optionally under a
// key prefix. See the Store interface for why MemoryEmbedListByModel cannot serve
// this (it INNER JOINs the table these rows are absent from) and why paging by
// re-calling is resumable without a cursor.
func (s *Store) MemoryEmbedListMissing(ctx context.Context, tenantID string, scope store.MemoryScope, scopeID, keyPrefix, afterKey string, limit int) ([]store.MemoryEntry, error) {
	if !s.pgvectorEnabled {
		return nil, store.ErrVectorUnsupported
	}
	// CLAMP, do not reset. This used to send limit>1000 back to the 200 default, so
	// asking for a bigger page silently returned a SMALLER one — a caller widening
	// the window to escape the starvation described on the interface got the
	// opposite of what it asked for.
	if limit <= 0 {
		limit = 200
	}
	if limit > 1000 {
		limit = 1000
	}
	// LEFT JOIN + IS NULL, the inverse of MemoryEmbedListByModel's inner join.
	// Ordered by key so successive pages are stable while rows are being embedded
	// out from under the query — an unordered scan could revisit or skip.
	sql := `SELECT m.key, m.value, m.expires_at, m.created_at, m.updated_at
	          FROM memory m
	          LEFT JOIN memory_embeddings me
	            ON me.tenant_id = m.tenant_id
	           AND me.scope     = m.scope
	           AND me.scope_id  = m.scope_id
	           AND me.key       = m.key
	         WHERE m.tenant_id = $1 AND m.scope = $2 AND m.scope_id = $3
	           AND me.key IS NULL
	           AND (m.expires_at IS NULL OR m.expires_at > now())
	           AND m.superseded_at IS NULL`
	args := []any{tenantID, string(scope), scopeID}
	if keyPrefix != "" {
		args = append(args, keyPrefix+"%")
		sql += ` AND m.key LIKE $` + strconv.Itoa(len(args))
	}
	if afterKey != "" {
		args = append(args, afterKey)
		sql += ` AND m.key > $` + strconv.Itoa(len(args))
	}
	sql += ` ORDER BY m.key LIMIT ` + strconv.Itoa(limit)

	rows, err := s.pool.Query(ctx, sql, args...)
	if err != nil {
		return nil, fmt.Errorf("MemoryEmbedListMissing query: %w", err)
	}
	defer rows.Close()
	out := []store.MemoryEntry{}
	for rows.Next() {
		var (
			e         store.MemoryEntry
			valueByte []byte
			expiresAt *time.Time
		)
		if err := rows.Scan(&e.Key, &valueByte, &expiresAt, &e.CreatedAt, &e.UpdatedAt); err != nil {
			return nil, fmt.Errorf("MemoryEmbedListMissing scan: %w", err)
		}
		e.Value = valueByte
		// ExpiresAt is a plain time.Time; NULL stays the zero value, which is how
		// the rest of this tier represents "no expiry".
		if expiresAt != nil {
			e.ExpiresAt = *expiresAt
		}
		out = append(out, e)
	}
	return out, rows.Err()
}
