package knowledge

import (
	"context"
	"fmt"
	"math"
	"sort"
	"strings"
	"unicode"
	"unicode/utf8"
)

// SearchMode aramanın nasıl yapılacağını belirler (PROJE.MD: semantic | keyword | hybrid).
type SearchMode string

const (
	// ModeHybrid varsayılan: vektör varsa semantik skor + anahtar kelime skoru, yoksa yalnızca
	// anahtar kelime. Embedding sağlayıcısı çökse bile arama çalışmaya devam eder.
	ModeHybrid SearchMode = "hybrid"
	// ModeSemantic yalnızca vektör benzerliği; sorgu embedding'i üretilemezse hata döner.
	ModeSemantic SearchMode = "semantic"
	// ModeKeyword yalnızca anahtar kelime eşleşmesi; embedding gerektirmez.
	ModeKeyword SearchMode = "keyword"
)

const (
	DefaultSearchLimit = 5
	MaxSearchLimit     = 50

	// DefaultMaxPerDocument bir belgeden sonuca girebilecek en çok parça: tek uzun belge
	// bütün sonuçları doldurup diğer kaynakları gizlemesin (yeniden sıralama adımı).
	DefaultMaxPerDocument = 2

	// Hibrit skor ağırlıkları (toplam 1): skill aramasıyla aynı.
	semanticWeight = 0.8
	keywordWeight  = 0.2

	// phraseBonus sorgu ifadesi parçada AYNEN geçiyorsa eklenen skor.
	phraseBonus = 0.1
	// rerankPool yeniden sıralamaya giren aday sayısı = limit × rerankPool.
	rerankPool = 4
)

// SearchOptions arama parametreleridir. UserID zorunludur: başkasının belgesi asla aranmaz.
type SearchOptions struct {
	UserID         string
	Query          string
	Mode           SearchMode // boş = hybrid
	Limit          int        // <=0: DefaultSearchLimit; MaxSearchLimit ile sınırlanır
	MinScore       float64    // bu skorun altındakiler elenir (0..1)
	ProjectID      string
	DocumentIDs    []string
	Tags           []string
	MaxPerDocument int // 0: DefaultMaxPerDocument; <0: sınırsız
}

// Hit bir arama sonucudur (parça + belge üst verisi). Skor 0..1; yüksek = daha ilgili.
type Hit struct {
	ChunkID    string   `json:"chunkId"`
	DocumentID string   `json:"documentId"`
	Index      int      `json:"index"`
	Title      string   `json:"title"`
	Source     string   `json:"source,omitempty"`
	Tags       []string `json:"tags"`
	Text       string   `json:"text"`
	Score      float64  `json:"score"`
}

// Search kullanıcının belgelerinde sorguya en uygun parçaları döndürür (skora göre azalan).
//
// Hat: sorgu embedding'i → aday parçalar → skorlama (semantik/anahtar kelime/hibrit) →
// yeniden sıralama (ifade bonusu + belge başına çeşitlilik sınırı) → ilk Limit sonuç.
func (s *Service) Search(ctx context.Context, opt SearchOptions) ([]Hit, error) {
	query := strings.TrimSpace(opt.Query)
	var verrs []FieldError
	if strings.TrimSpace(opt.UserID) == "" {
		verrs = append(verrs, FieldError{Field: "userId", Message: "kullanıcı gerekli"})
	}
	if query == "" {
		verrs = append(verrs, FieldError{Field: "query", Message: "arama metni gerekli"})
	}
	mode := opt.Mode
	switch mode {
	case "":
		mode = ModeHybrid
	case ModeHybrid, ModeSemantic, ModeKeyword:
	default:
		verrs = append(verrs, FieldError{Field: "mode", Message: "mode semantic, keyword veya hybrid olmalı"})
	}
	if opt.MinScore < 0 || opt.MinScore > 1 {
		verrs = append(verrs, FieldError{Field: "minScore", Message: "minScore 0..1 aralığında olmalı"})
	}
	if len(verrs) > 0 {
		return nil, &ValidationError{Errors: verrs}
	}
	limit := opt.Limit
	if limit <= 0 {
		limit = DefaultSearchLimit
	}
	if limit > MaxSearchLimit {
		limit = MaxSearchLimit
	}

	emb, model := s.embedderSnapshot()
	var qvec []float32
	if mode != ModeKeyword {
		switch {
		case emb == nil && mode == ModeSemantic:
			return nil, ErrNoEmbedder
		case emb != nil:
			ectx, cancel := context.WithTimeout(ctx, s.opt.EmbedTimeout)
			v, err := emb.Embed(ectx, query)
			cancel()
			if err != nil {
				if mode == ModeSemantic {
					return nil, fmt.Errorf("knowledge: sorgu embedding'i üretilemedi: %w", err)
				}
				if ctx.Err() != nil {
					return nil, ctx.Err()
				}
				s.log.Warn("sorgu embedding'i üretilemedi; arama anahtar kelimeye düştü", "err", err)
			} else {
				qvec = v
			}
		}
	}

	filter := ChunkFilter{
		UserID: opt.UserID, ProjectID: opt.ProjectID, DocumentIDs: opt.DocumentIDs,
		Tags: lowerAll(opt.Tags),
	}
	qTokens := tokenize(query)
	foldedQuery := fold(query)

	// Aday yolu: depo vektör aramayı destekliyorsa (MongoDB $vectorSearch) skorlama veritabanında
	// yapılır; desteklemiyorsa, mongot yoksa ya da hata verirse Go tarafı tarama (tüm parçalar) çalışır.
	var scored []Hit
	vecOK := false
	if qvec != nil && mode != ModeKeyword {
		scored, vecOK = s.scoreByVector(ctx, filter, mode, qvec, model, qTokens, opt.MinScore, limit)
	}
	if !vecOK {
		var err error
		if scored, err = s.scoreByScan(ctx, filter, mode, qvec, model, qTokens, opt.MinScore); err != nil {
			return nil, err
		}
	}

	sortHits(scored)
	if pool := limit * rerankPool; len(scored) > pool {
		scored = scored[:pool]
	}
	return rerank(scored, foldedQuery, limit, opt.MaxPerDocument), nil
}

// scoreByScan kullanıcının tüm aday parçalarını depodan çeker (embedding dahil) ve skorlamayı
// Go'da yapar. Her depoda çalışır (bellek içi, Mongo); vektör arama yolunun güvenli düşüş hattıdır.
func (s *Service) scoreByScan(ctx context.Context, filter ChunkFilter, mode SearchMode, qvec []float32,
	model string, qTokens []string, minScore float64) ([]Hit, error) {
	cands, err := s.repo.SearchableChunks(ctx, filter)
	if err != nil {
		return nil, err
	}
	scored := make([]Hit, 0, len(cands))
	for i := range cands {
		c := &cands[i]
		kw := keywordScore(qTokens, fold(c.Title+" "+c.Text))
		var score float64
		switch {
		case mode == ModeKeyword:
			score = kw
		case qvec != nil && c.HasEmbedding(model) && len(c.Embedding) == len(qvec):
			sem := cosine(qvec, c.Embedding)
			if mode == ModeSemantic {
				score = sem
			} else {
				score = semanticWeight*sem + keywordWeight*kw
			}
		case mode == ModeHybrid:
			// Vektörü olmayan (henüz işlenmemiş ya da bayat) parça hibritte yalnızca anahtar kelimeyle bulunur.
			score = kw
		default:
			continue // semantic modda vektörsüz parça aranamaz
		}
		if score <= 0 || score < minScore {
			continue
		}
		scored = append(scored, hitOf(c, score))
	}

	return scored, nil
}

// sortHits skora göre azalan, eşitlikte belge/parça sırasıyla (belirli) sıralar.
func sortHits(h []Hit) {
	sort.SliceStable(h, func(i, j int) bool {
		if h[i].Score != h[j].Score {
			return h[i].Score > h[j].Score
		}
		if h[i].DocumentID != h[j].DocumentID {
			return h[i].DocumentID < h[j].DocumentID
		}
		return h[i].Index < h[j].Index
	})
}

// rerank aday havuzunu iki kuralla yeniden sıralar ve ilk limit sonucu döndürür:
//
//  1. İfade bonusu: sorgu (katlanmış hâliyle) parçada AYNEN geçiyorsa skor artar.
//     Vektör benzerliği "aynı konu"yu bulur, tam ifade ise "aynı şeyi söyleyen" parçayı öne çıkarır.
//  2. Çeşitlilik: bir belgeden en çok maxPerDoc parça (0: varsayılan, <0: sınırsız).
func rerank(h []Hit, foldedQuery string, limit, maxPerDoc int) []Hit {
	if maxPerDoc == 0 {
		maxPerDoc = DefaultMaxPerDocument
	}
	if utf8.RuneCountInString(foldedQuery) >= 4 {
		for i := range h {
			if strings.Contains(fold(h[i].Text), foldedQuery) {
				h[i].Score = math.Min(1, h[i].Score+phraseBonus)
			}
		}
		sortHits(h)
	}
	out := make([]Hit, 0, limit)
	perDoc := map[string]int{}
	for _, x := range h {
		if len(out) >= limit {
			break
		}
		if maxPerDoc > 0 && perDoc[x.DocumentID] >= maxPerDoc {
			continue
		}
		perDoc[x.DocumentID]++
		out = append(out, x)
	}
	return out
}

func lowerAll(in []string) []string {
	if len(in) == 0 {
		return nil
	}
	out := make([]string, 0, len(in))
	for _, s := range in {
		if s = strings.ToLower(strings.TrimSpace(s)); s != "" {
			out = append(out, s)
		}
	}
	return out
}

// cosine iki vektörün kosinüs benzerliğidir; negatif değerler 0'a çekilir (ilgisiz = 0).
// Boyutlar farklıysa ya da bir vektör sıfırsa 0 döner.
func cosine(a, b []float32) float64 {
	if len(a) == 0 || len(a) != len(b) {
		return 0
	}
	var dot, na, nb float64
	for i := range a {
		x, y := float64(a[i]), float64(b[i])
		dot += x * y
		na += x * x
		nb += y * y
	}
	if na == 0 || nb == 0 {
		return 0
	}
	c := dot / (math.Sqrt(na) * math.Sqrt(nb))
	switch {
	case c < 0:
		return 0
	case c > 1:
		return 1
	}
	return c
}

// turkishFold Türkçe harfleri ASCII karşılıklarına indirger: kullanıcı "tezgahi" yazsa da
// metindeki "tezgâhı" bulunur (ses tanıma ve klavye farkları için).
var turkishFold = strings.NewReplacer(
	"ı", "i", "İ", "i", "ş", "s", "Ş", "s", "ğ", "g", "Ğ", "g",
	"ü", "u", "Ü", "u", "ö", "o", "Ö", "o", "ç", "c", "Ç", "c",
	"â", "a", "Â", "a", "î", "i", "Î", "i", "û", "u", "Û", "u",
)

// fold küçük harfe çevirir ve Türkçe harfleri katlar. Büyük "I" → "i" (Türkçe "ı" da "i" olur),
// böylece "ISPARTA" ile "ısparta" aynı anahtara iner.
func fold(s string) string { return strings.ToLower(turkishFold.Replace(s)) }

// stopwords sorgu skorunu şişiren, her metinde geçen sözcüklerdir (katlanmış biçimde).
var stopwords = func() map[string]struct{} {
	raw := []string{
		"ve", "bir", "bu", "su", "ne", "mi", "mu", "da", "de", "ile", "icin", "ama", "gibi",
		"cok", "daha", "en", "her", "ben", "sen", "biz", "siz", "ki", "ya", "veya", "olan",
		"nedir", "nasil", "hakkinda", "var", "yok", "the", "and", "for", "what", "how",
	}
	m := make(map[string]struct{}, len(raw))
	for _, w := range raw {
		m[fold(w)] = struct{}{}
	}
	return m
}()

// tokenize sorguyu katlanmış, en az 2 karakterlik, tekrarsız sözcüklere böler ve
// anlamsız sözcükleri (stopword) ayıklar. Hepsi stopword ise ayıklama yapılmaz.
func tokenize(q string) []string {
	fields := strings.FieldsFunc(fold(q), func(r rune) bool {
		return !unicode.IsLetter(r) && !unicode.IsDigit(r)
	})
	all := make([]string, 0, len(fields))
	seen := make(map[string]struct{}, len(fields))
	for _, f := range fields {
		if utf8.RuneCountInString(f) < 2 {
			continue
		}
		if _, dup := seen[f]; dup {
			continue
		}
		seen[f] = struct{}{}
		all = append(all, f)
	}
	kept := make([]string, 0, len(all))
	for _, t := range all {
		if _, stop := stopwords[t]; !stop {
			kept = append(kept, t)
		}
	}
	if len(kept) == 0 {
		return all
	}
	return kept
}

// stemMin köklenmiş (eki kesilmiş) sözcüğün en kısa uzunluğudur; daha kısa kökler çok sayıda
// ilgisiz sözcüğe eşleşir.
const stemMin = 4

// matchToken sözcük metinde geçiyor mu? Türkçe sondan eklemeli olduğu için "planeleri" sorgusu
// metindeki "plane"yi bulabilsin: 1-4 karakter kesilen kökler (en az stemMin uzunlukta) de denenir.
// Ters yön (sorgu "plane", metin "planeleri") zaten alt dize eşleşmesiyle bulunur.
func matchToken(foldedText, tok string) bool {
	if strings.Contains(foldedText, tok) {
		return true
	}
	rs := []rune(tok)
	for cut := 1; cut <= 4; cut++ {
		if len(rs)-cut < stemMin {
			break
		}
		if strings.Contains(foldedText, string(rs[:len(rs)-cut])) {
			return true
		}
	}
	return false
}

// keywordScore sorgu sözcüklerinin ne kadarının (katlanmış) metinde geçtiğidir (0..1).
func keywordScore(qTokens []string, foldedText string) float64 {
	if len(qTokens) == 0 {
		return 0
	}
	hit := 0
	for _, t := range qTokens {
		if matchToken(foldedText, t) {
			hit++
		}
	}
	return float64(hit) / float64(len(qTokens))
}
