package memorysearch

import (
	"context"
	"fmt"

	"github.com/blevesearch/bleve/v2"
	"github.com/blevesearch/bleve/v2/search/query"
)

// candidate is one Bleve hit, in score-descending order as returned by the
// search. Only the doc ID is needed: the trusted path/heading/lines come
// from Manifest.Chunks[docID] and the rechunked current file, never from
// stored fields (which are untrusted per the security model).
type candidate struct {
	docID string
}

// candidatePoolMultipler widens the Bleve Size beyond the response limit
// so source validation drops (stale/hash-mismatch) and segment-aware path
// filtering still leave enough survivors to fill the response.
const candidatePoolMultipler = 4

// candidatePoolFloor sets a minimum candidate pool size so very small limits
// still have enough headroom for drops.
const candidatePoolFloor = 32

// boost constants from spec §6 — they enter Bleve's unified BM25 scoring
// so raw scores are comparable within one Search call.
const (
	boostIdentifierExact = 4.0
	boostIdentifierParts = 2.5
	boostPathExact       = 3.0
	boostTitle           = 4.0
	boostHeadingText     = 4.0
	boostBody            = 1.0
)

// collectCandidates builds the composite Boolean query, runs a single
// SearchInContext, and returns hits in Bleve's score-descending order.
//
// Query composition (spec §6 + codex3 constraints):
//
//	Conjunction {
//	  Boolean {
//	    should (minShould=1): [
//	      Disjunction[TermQuery(fieldIdentifiers, id)@4]        // exact identifier
//	      Disjunction[TermQuery(fieldIdentifierParts, part)@2.5]// identifier parts
//	      Disjunction[Match(title)@4, Match(heading_text)@4, Match(body)@1] // lexical
//	      TermQuery(fieldPath, queryPath)@3                     // path exact (if applicable)
//	    ]
//	  }
//	  // filters — restrict candidate set, minimal score contribution
//	  Disjunction[TermQuery(fieldKind, k)]  (if kinds non-empty)
//	  TermQuery(fieldArchived, false)       (if !includeArchived)
//	  PrefixQuery(fieldPath, pathPrefix)    (if pathPrefix non-empty)
//	}
//
// The single-Search design is deliberate: raw BM25 scores from different
// query structures are NOT comparable, so all boosts must enter the same
// Search for unified ranking. matchType is derived post-validation from
// the rechunked chunk (see deriveMatchType), not from probe queries.
//
// Segment-aware path filtering happens AFTER Bleve returns candidates:
// PrefixQuery narrows the pool to paths starting with the prefix string,
// then Go-side verification rejects matches that cross a segment boundary
// (e.g. "knowledge/a" must NOT match "knowledge/ab/foo.md").
func collectCandidates(ctx context.Context, gen *Generation, req SearchRequest) ([]candidate, error) {
	idx := gen.Index()
	if idx == nil {
		return nil, fmt.Errorf("memory search: generation has no open index")
	}

	bleveQuery := buildCompositeQuery(req)
	searchReq := bleve.NewSearchRequestOptions(bleveQuery, candidatePoolSize(req.Limit), 0, false)
	// Do not load any stored fields. The doc ID is always available on the
	// hit; all trusted locator data comes from the manifest + rechunk.
	searchReq.Fields = nil

	res, err := idx.SearchInContext(ctx, searchReq)
	if err != nil {
		return nil, fmt.Errorf("memory search: bleve search failed: %w", err)
	}

	manifest := gen.Manifest()
	cands := make([]candidate, 0, len(res.Hits))
	for _, hit := range res.Hits {
		if hit == nil {
			continue
		}
		// Manifest-chain trust gate: a hit whose doc ID is not in the
		// manifest means the index is stale or corrupt. Drop silently —
		// the caller sees fewer candidates, not a leak of untrusted data.
		chunkEntry, ok := manifest.Chunks[hit.ID]
		if !ok {
			continue
		}
		// Segment-aware path prefix verification. PrefixQuery narrowed the
		// Bleve pool; here we enforce the segment boundary so "knowledge/a"
		// does not match "knowledge/ab".
		if req.PathPrefix != "" && !pathHasSegmentPrefix(chunkEntry.Path, req.PathPrefix) {
			continue
		}
		cands = append(cands, candidate{docID: hit.ID})
	}
	return cands, nil
}

// candidatePoolSize computes the Bleve Size from the response limit.
func candidatePoolSize(limit int) int {
	pool := limit * candidatePoolMultipler
	if pool < candidatePoolFloor {
		pool = candidatePoolFloor
	}
	return pool
}

// buildCompositeQuery constructs the Conjunction-wrapped Boolean query.
// See collectCandidates doc for the full composition.
func buildCompositeQuery(req SearchRequest) query.Query {
	shouldClauses := buildShouldClauses(req)

	boolQ := bleve.NewBooleanQuery()
	for _, c := range shouldClauses {
		boolQ.AddShould(c)
	}
	// SetMinMust requires at least one should clause to be satisfied.
	// AddShould lazily initializes the internal DisjunctionQuery, so the
	// type assertion inside SetMinShould now succeeds. If no should
	// clauses were produced (empty query), the BooleanQuery returns no
	// hits — but validateSearchRequest rejects empty queries before we
	// get here.
	boolQ.SetMinShould(1)

	// Filters. These go inside the same Conjunction so Bleve restricts
	// the candidate pool at search time.
	filterClauses := buildFilterClauses(req)
	if len(filterClauses) == 0 {
		return boolQ
	}

	conj := bleve.NewConjunctionQuery()
	conj.AddQuery(boolQ)
	for _, f := range filterClauses {
		conj.AddQuery(f)
	}
	return conj
}

// buildShouldClauses constructs the scoring disjunctions. Each route is a
// DisjunctionQuery carrying route-level boost so individual hit boosts
// compose correctly under BM25.
func buildShouldClauses(req SearchRequest) []query.Query {
	var clauses []query.Query

	// Route 1: exact identifier match on the whole identifier text
	// extracted from the query (e.g. "#2731", "@handle", full URL).
	// Only true identifiers enter this route — plain words do NOT.
	// Identifiers are normalized (lowercased) before the TermQuery so
	// they line up with the normalized values the index stores.
	if ids := ExtractIdentifiers(req.Query); len(ids) > 0 {
		disj := bleve.NewDisjunctionQuery()
		for _, id := range ids {
			tq := bleve.NewTermQuery(normalizedIdentifier(id))
			tq.SetField(fieldIdentifiers)
			tq.SetBoost(boostIdentifierExact)
			disj.AddQuery(tq)
		}
		disj.SetBoost(boostIdentifierExact)
		clauses = append(clauses, disj)
	}

	// Route 2: identifier sub-parts (e.g. "2731" from "#2731", path segments).
	// Lower-scoring than exact identifier but higher than body lexical.
	if parts := ExtractIdentifierParts(req.Query); len(parts) > 0 {
		disj := bleve.NewDisjunctionQuery()
		for _, part := range parts {
			tq := bleve.NewTermQuery(part)
			tq.SetField(fieldIdentifierParts)
			tq.SetBoost(boostIdentifierParts)
			disj.AddQuery(tq)
		}
		disj.SetBoost(boostIdentifierParts)
		clauses = append(clauses, disj)
	}

	// Route 3: lexical BM25 across the high-value text fields. Body carries
	// the primary scoring load (most content), while title and heading_text
	// get boost 4 to rank section-level matches. identifier_parts is not
	// repeated here — it's already in route 2 as exact keyword match.
	lexicalDisj := bleve.NewDisjunctionQuery()

	titleQ := bleve.NewMatchQuery(req.Query)
	titleQ.SetField(fieldTitle)
	titleQ.SetBoost(boostTitle)
	lexicalDisj.AddQuery(titleQ)

	headingQ := bleve.NewMatchQuery(req.Query)
	headingQ.SetField(fieldHeadingText)
	headingQ.SetBoost(boostHeadingText)
	lexicalDisj.AddQuery(headingQ)

	bodyQ := bleve.NewMatchQuery(req.Query)
	bodyQ.SetField(fieldBody)
	bodyQ.SetBoost(boostBody)
	lexicalDisj.AddQuery(bodyQ)

	clauses = append(clauses, lexicalDisj)

	// Route 4: path exact. Only when the query itself looks like a path
	// (contains a slash and matches the path identifier pattern). This is
	// NOT triggered by any "/" in the query — the query must be a path-like
	// identifier per the identifier extractor.
	if pathClause, ok := buildPathExactClause(req.Query); ok {
		clauses = append(clauses, pathClause)
	}

	return clauses
}

// buildPathExactClause constructs a path-exact should clause when the query
// matches the path identifier pattern (slash-separated segments with file
// extension). A plain "/" in the query does NOT qualify — "migration audit"
// is lexical, not a path lookup.
//
// When the query is a path, we add a DisjunctionQuery of:
//   - TermQuery(fieldPath, query) — exact file path match
//   - PrefixQuery(fieldPath, query+"/") — files under that directory
//
// Both carry boost 3.0 per spec §6.
func buildPathExactClause(query string) (query.Query, bool) {
	normalized := normalizePathQuery(query)
	if normalized == "" {
		return nil, false
	}
	disj := bleve.NewDisjunctionQuery()

	exact := bleve.NewTermQuery(normalized)
	exact.SetField(fieldPath)
	exact.SetBoost(boostPathExact)
	disj.AddQuery(exact)

	prefix := bleve.NewPrefixQuery(normalized + "/")
	prefix.SetField(fieldPath)
	prefix.SetBoost(boostPathExact)
	disj.AddQuery(prefix)

	disj.SetBoost(boostPathExact)
	return disj, true
}

// normalizePathQuery returns the query lowercased and trimmed if it matches
// the path identifier pattern (at least one internal slash + file-like tail),
// or "" if the query is not path-shaped.
func normalizePathQuery(query string) string {
	// The identifier extractor already classifies path-shaped substrings.
	// If the WHOLE trimmed query is a single path identifier, treat it as
	// path intent.
	ids := ExtractIdentifiers(query)
	if len(ids) != 1 {
		return ""
	}
	if !looksLikePathIdentifier(ids[0]) {
		return ""
	}
	return normalizeRelPath(ids[0])
}

// looksLikePathIdentifier checks whether a string is a slash-separated path
// with at least one internal slash. This mirrors rePath in identifiers.go
// but operates on a pre-extracted token.
func looksLikePathIdentifier(s string) bool {
	if !containsByte(s, '/') {
		return false
	}
	// Reject if any segment is empty or "."/"..".
	for _, seg := range splitPath(s) {
		if seg == "" || seg == "." || seg == ".." {
			return false
		}
	}
	return true
}

// buildFilterClauses constructs the non-scoring restrictors: kinds, archived,
// pathPrefix. Each returns nil when not applicable.
func buildFilterClauses(req SearchRequest) []query.Query {
	var filters []query.Query

	if len(req.Kinds) > 0 {
		disj := bleve.NewDisjunctionQuery()
		for _, k := range req.Kinds {
			tq := bleve.NewTermQuery(k)
			tq.SetField(fieldKind)
			disj.AddQuery(tq)
		}
		filters = append(filters, disj)
	}

	if !req.IncludeArchived {
		// Boolean field: must use BoolFieldQuery, not TermQuery. TermQuery
		// with "false" matches zero docs because archived is indexed as a
		// boolean (true/false literals), not as an analyzed string.
		aq := bleve.NewBoolFieldQuery(false)
		aq.SetField(fieldArchived)
		filters = append(filters, aq)
	}

	if req.PathPrefix != "" {
		// Narrowing filter. Segment-awareness is re-checked in Go after
		// Bleve returns candidates (see collectCandidates).
		pq := bleve.NewPrefixQuery(req.PathPrefix)
		pq.SetField(fieldPath)
		filters = append(filters, pq)
	}

	return filters
}

// pathHasSegmentPrefix reports whether path starts with prefix on a segment
// boundary. Per the response contract:
//
//   - "knowledge/a" is a segment prefix of "knowledge/a/foo.md" (next char '/'),
//     "knowledge/a.md" (next char '.', the file-extension boundary), and
//     "knowledge/a" (exact match).
//   - "knowledge/a" is NOT a segment prefix of "knowledge/ab/foo.md" because
//     the next char after the prefix is an alphanumeric 'b', meaning the
//     prefix cut mid-segment.
//
// codex3 round 4: the previous implementation only accepted '/' as the
// boundary, which contradicted the public docstring that promised
// "knowledge/a.md" would match. The rule now accepts '/' (new segment) or
// '.' (file-extension boundary) as the boundary character.
func pathHasSegmentPrefix(path, prefix string) bool {
	if path == prefix {
		return true
	}
	if len(path) <= len(prefix) {
		return false
	}
	if path[:len(prefix)] != prefix {
		return false
	}
	next := path[len(prefix)]
	return next == '/' || next == '.'
}

// normalizeRelPath lowercases and trims a relative path. Forward-slash only.
func normalizeRelPath(p string) string {
	for len(p) > 0 && p[0] == '/' {
		p = p[1:]
	}
	return toLowerASCII(p)
}

func toLowerASCII(s string) string {
	b := []byte(s)
	for i, c := range b {
		if c >= 'A' && c <= 'Z' {
			b[i] = c + ('a' - 'A')
		}
	}
	return string(b)
}

func containsByte(s string, r byte) bool {
	for i := 0; i < len(s); i++ {
		if s[i] == r {
			return true
		}
	}
	return false
}

func splitPath(p string) []string {
	var out []string
	start := 0
	for i := 0; i < len(p); i++ {
		if p[i] == '/' {
			out = append(out, p[start:i])
			start = i + 1
		}
	}
	out = append(out, p[start:])
	return out
}
