// Full-text index over string node properties: boolean retrieval + BM25.
// Tokenization is lowercase + split on non-alphanumeric runs (Unicode-
// aware); stopword removal and stemming are deferred, matching the Rust v1.

package chickpeas

import (
	
	
	

	
	
)

const (
	bm25K1 = 1.2  // term-frequency saturation
	bm25B  = 0.75 // length normalization
)

type termPostings struct {
	docs *roaring.Bitmap
	tf   map[uint32]uint32
}

// FullTextField is the inverted index for a single (label, property) text
// field. Build directly or query through Snapshot.FullTextSearch, which
// builds and caches per field lazily.
type FullTextField struct {
	postings map[string]*termPostings
	docLen   map[uint32]uint32
	totalLen uint64
}

// Tokenize splits text into lowercased alphanumeric runs -- the shared
// tokenizer of the build and query paths.
func ( string) []string {
	 := strings.FieldsFunc(, func( rune) bool {
		return !unicode.IsLetter() && !unicode.IsNumber()
	})
	for ,  := range  {
		[] = strings.ToLower()
	}
	return 
}

// BuildFullTextField indexes already label-filtered (node, text) documents.
func ( func( func( uint32,  string) bool)) *FullTextField {
	 := &FullTextField{postings: map[string]*termPostings{}, docLen: map[uint32]uint32{}}
	(func( uint32,  string) bool {
		 := uint32(0)
		for ,  := range Tokenize() {
			++
			,  := .postings[]
			if ! {
				 = &termPostings{docs: roaring.New(), tf: map[uint32]uint32{}}
				.postings[] = 
			}
			.docs.Add()
			.tf[]++
		}
		if  > 0 {
			.docLen[] += 
			.totalLen += uint64()
		}
		return true
	})
	return 
}

// Query returns the nodes whose text contains EVERY token in query
// (boolean AND). An empty query, or any token absent from the field,
// yields the empty set.
func ( *FullTextField) ( string) *nodeset.Set {
	var  *roaring.Bitmap
	 := Tokenize()
	if len() == 0 {
		return nodeset.New()
	}
	for ,  := range  {
		,  := .postings[]
		if ! {
			return nodeset.New()
		}
		if  == nil {
			 = .docs.Clone()
		} else {
			.And(.docs)
		}
		if .IsEmpty() {
			return nodeset.New()
		}
	}
	return nodeset.FromBitmap()
}

// RankedHit is one QueryRanked result.
type RankedHit struct {
	Node  uint32
	Score float32
}

// QueryRanked returns the top k nodes by BM25 relevance (disjunctive: a
// node scores for every query token it contains), sorted by score
// descending, ties by ascending node id.
func ( *FullTextField) ( string,  int) []RankedHit {
	 := len(.docLen)
	if  == 0 ||  <= 0 {
		return nil
	}
	 := float32(.totalLen) / float32()
	 := map[uint32]float32{}
	for ,  := range Tokenize() {
		,  := .postings[]
		if ! {
			continue
		}
		 := float32(.docs.GetCardinality())
		 := ln32(1 + (float32()-+0.5)/(+0.5))
		for ,  := range .tf {
			 := float32()
			 := float32(.docLen[])
			 :=  + bm25K1*(1-bm25B+bm25B*/)
			[] +=  * ( * (bm25K1 + 1)) / 
		}
	}
	 := make([]RankedHit, 0, len())
	for ,  := range  {
		 = append(, RankedHit{Node: , Score: })
	}
	sort.Slice(, func(,  int) bool {
		if [].Score != [].Score {
			return [].Score > [].Score
		}
		return [].Node < [].Node
	})
	return [:min(, len())]
}

// TermCount is the number of distinct indexed tokens.
func ( *FullTextField) () int { return len(.postings) }

// DocCount is the number of indexed documents (nodes with non-empty text).
func ( *FullTextField) () int { return len(.docLen) }