//
//  Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
//  Licensed under the Apache License, Version 2.0 (the "License");
//  you may not use this file except in compliance with the License.
//  You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
//  Unless required by applicable law or agreed to in writing, software
//  distributed under the License is distributed on an "AS IS" BASIS,
//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//  See the License for the specific language governing permissions and
//  limitations under the License.
//

package service

import (
	"context"
	"encoding/json"
	"fmt"
	"ragflow/internal/common"
	"ragflow/internal/engine/redis"
	"sort"
	"strings"
	"time"

	"go.uber.org/zap"

	"ragflow/internal/dao"
	"ragflow/internal/engine/types"
	"ragflow/internal/entity"
	"ragflow/internal/service/nlp"

	"github.com/cespare/xxhash/v2"
)

// getTagsCacheKey generates a cache key from kb_ids using xxhash64
func getTagsCacheKey(kbIDs []string) string {
	// Normalize: unique + sorted so the key is set-stable regardless of caller order.
	seen := make(map[string]struct{}, len(kbIDs))
	norm := make([]string, 0, len(kbIDs))
	for _, id := range kbIDs {
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		norm = append(norm, id)
	}
	sort.Strings(norm)
	hasher := xxhash.New()
	hasher.Write([]byte(strings.Join(norm, "\x00")))
	return fmt.Sprintf("%x", hasher.Sum64())
}

// GetTagsFromCache retrieves cached tags for given kb_ids
// Returns nil if not found (cache miss)
func GetTagsFromCache(kbIDs []string) (map[string]float64, error) {
	if len(kbIDs) == 0 {
		return nil, nil
	}

	redisClient := redis.Get()
	if redisClient == nil {
		common.Warn("Redis client not available, skipping cache lookup")
		return nil, nil
	}

	key := getTagsCacheKey(kbIDs)
	data, err := redisClient.Get(key)
	if err != nil || data == "" {
		// Cache miss or error
		return nil, nil
	}

	var tags map[string]float64
	if err := json.Unmarshal([]byte(data), &tags); err != nil {
		common.Warn("Failed to unmarshal cached tags", zap.Error(err))
		return nil, nil
	}

	return tags, nil
}

// SetTagsToCache stores tags in cache for given kb_ids with 10 minute expiry
func SetTagsToCache(kbIDs []string, tags map[string]float64) error {
	if len(kbIDs) == 0 || tags == nil {
		return nil
	}

	redisClient := redis.Get()
	if redisClient == nil {
		common.Warn("Redis client not available, skipping cache store")
		return nil
	}

	key := getTagsCacheKey(kbIDs)
	data, err := json.Marshal(tags)
	if err != nil {
		return fmt.Errorf("failed to marshal tags for cache: %w", err)
	}

	// Cache for 10 minutes (600 seconds)
	ok := redisClient.Set(key, string(data), 10*time.Minute)
	if !ok {
		common.Warn("Failed to set tags cache")
		return fmt.Errorf("failed to set tags cache")
	}

	return nil
}

// Knowledgebase type alias for entity.Knowledgebase
type Knowledgebase = entity.Knowledgebase

// GetAllTagsInPortion returns all tag_kwd values and their occurrence counts
// for documents belonging to the given kbIDs.
func (s *MetadataService) GetAllTagsInPortion(ctx context.Context, tenantID string, kbIDs []string) (map[string]float64, error) {
	if len(kbIDs) == 0 {
		return make(map[string]float64), nil
	}

	indexName := fmt.Sprintf("ragflow_%s", tenantID)

	searchReq := &types.SearchRequest{
		IndexNames: []string{indexName},
		KbIDs:      kbIDs,
		Offset:     0,
		// Python passes limit=0 ("unlimited") which Go SearchRequest treats
		// as engine default (Infinity/ES: 30), so use an explicit large cap.
		Limit:        100000,
		SelectFields: []string{"tag_kwd"},
	}

	searchResp, err := s.docEngine.Search(ctx, searchReq)
	if err != nil {
		return nil, err
	}

	// Use GetAggregation for tag counting
	tagAgg := s.docEngine.GetAggregation(searchResp.Chunks, "tag_kwd")
	if len(tagAgg) == 0 {
		return make(map[string]float64), nil
	}

	// Calculate total count for proportion calculation
	total := 0
	for _, tc := range tagAgg {
		total += tc["count"].(int)
	}
	if total == 0 {
		return make(map[string]float64), nil
	}

	// Calculate tag proportions: (count + 1) / (total + 1000)
	S := 1000.0
	allTags := make(map[string]float64)
	for _, tc := range tagAgg {
		allTags[tc["key"].(string)] = float64(tc["count"].(int)+1) / (float64(total) + S)
	}

	return allTags, nil
}

// TagQuery returns weighted tag features for a question
func (s *MetadataService) TagQuery(ctx context.Context, question string, tenantIDs []string, kbIDs []string, allTags map[string]float64, topnTags int) (map[string]float64, error) {
	if len(kbIDs) == 0 || len(allTags) == 0 || len(tenantIDs) == 0 {
		return make(map[string]float64), nil
	}

	// Build index names for all tenant IDs
	indexNames := make([]string, len(tenantIDs))
	for i, tenantID := range tenantIDs {
		indexNames[i] = fmt.Sprintf("ragflow_%s", tenantID)
	}

	// Process question to get match text
	queryBuilder := nlp.GetQueryBuilder()
	matchTextExpr, warns := queryBuilder.Question(question, "qa", 0.0) // min_match=0.0
	if len(warns) > 0 {
		common.Warn("TagQuery: failed to build match text", zap.Any("warnings", warns))
		return make(map[string]float64), nil
	}
	matchText := matchTextExpr.MatchingText

	common.Debug("TagQuery match_text", zap.String("match_text", matchText))

	// Search with match text to get relevant docs
	searchReq := &types.SearchRequest{
		IndexNames: indexNames,
		KbIDs:      kbIDs,
		Offset:     0,
		Limit:      1000,
		MatchExprs: []interface{}{matchTextExpr},
	}

	searchResp, err := s.docEngine.Search(ctx, searchReq)
	if err != nil {
		return nil, err
	}

	// Use GetAggregation for tag counting
	aggs := s.docEngine.GetAggregation(searchResp.Chunks, "tag_kwd")
	if len(aggs) == 0 {
		return make(map[string]float64), nil
	}

	// Calculate total count
	cnt := 0
	for _, agg := range aggs {
		cnt += agg["count"].(int)
	}
	if cnt == 0 {
		return make(map[string]float64), nil
	}

	// Calculate weighted tag features
	// Formula: 0.1 * (c + 1) / (cnt + S) / max(1e-6, all_tags.get(a, 0.0001))
	S := 1000.0
	type tagScore struct {
		tag   string
		score float64
	}
	scoredTags := make([]tagScore, 0, len(aggs))

	for _, agg := range aggs {
		tag := agg["key"].(string)
		c := agg["count"].(int)
		allTagValue := allTags[tag]
		if allTagValue <= 0 {
			allTagValue = 0.0001
		}
		score := 0.1 * float64(c+1) / (float64(cnt) + S) / max(1e-6, allTagValue)
		scoredTags = append(scoredTags, tagScore{tag: tag, score: score})
	}

	// Sort by score descending
	sort.Slice(scoredTags, func(i, j int) bool {
		return scoredTags[i].score > scoredTags[j].score
	})

	// Take top N tags and normalize dot notation
	resultTags := make(map[string]float64)
	for i := 0; i < topnTags && i < len(scoredTags); i++ {
		normalizedTag := strings.ReplaceAll(scoredTags[i].tag, ".", "_")
		score := max(1.0, scoredTags[i].score)
		if existing, ok := resultTags[normalizedTag]; !ok || score > existing {
			resultTags[normalizedTag] = score
		}
	}

	return resultTags, nil
}

// LabelQuestion returns rank features for a question based on KB's tag configuration.
//
// Flow:
//  1. Collect tag_kb_ids from KBs' parser_config
//  2. Try to get all_tags from cache (via GetTagsFromCache)
//  3. If cache miss, call GetAllTagsInPortion and cache the result (via SetTagsToCache)
//  4. Get tag KBs by IDs
//  5. Call TagQuery to get weighted tag features for the question
func (s *MetadataService) LabelQuestion(ctx context.Context, question string, kbs []*Knowledgebase) map[string]float64 {
	if len(kbs) == 0 || question == "" {
		return nil
	}

	// Collect tag_kb_ids from KBs' parser_config and track last KB
	var tagKBIDs []string
	var lastKB *Knowledgebase
	for _, kb := range kbs {
		if kb.ParserConfig == nil {
			continue
		}
		lastKB = kb
		if rawTagKBIDs, ok := kb.ParserConfig["tag_kb_ids"].([]interface{}); ok {
			for _, id := range rawTagKBIDs {
				if idStr, ok := id.(string); ok {
					tagKBIDs = append(tagKBIDs, idStr)
				}
			}
		}
	}

	if len(tagKBIDs) == 0 {
		return nil
	}

	common.Debug("tag_kb_ids found in parser_config", zap.Strings("tag_kb_ids", tagKBIDs))

	// Get all tags from cache or compute and cache
	allTags, err := GetTagsFromCache(tagKBIDs)
	if err != nil {
		common.Warn("Failed to get tags from cache", zap.Error(err))
	}
	if allTags == nil {
		// Cache miss - compute all_tags_in_portion
		allTags, err = s.GetAllTagsInPortion(ctx, lastKB.TenantID, tagKBIDs)
		if err != nil {
			common.Warn("Failed to get all tags in portion", zap.Error(err))
			return nil
		}
		// Store in cache for future lookups
		if err = SetTagsToCache(tagKBIDs, allTags); err != nil {
			common.Warn("Failed to set tags cache", zap.Error(err))
		}
	}

	// Get tag_kbs by IDs
	kbDAO := dao.NewKnowledgebaseDAO()
	tagKBs, err := kbDAO.GetByIDs(ctx, dao.DB, tagKBIDs)
	if err != nil || len(tagKBs) == 0 {
		// Return nil if no tag_kbs found
		return nil
	}

	// Get unique tenant IDs from tag_kbs
	tenantIDSet := make(map[string]bool)
	for _, kb := range tagKBs {
		tenantIDSet[kb.TenantID] = true
	}
	var uniqueTenantIDs []string
	for tid := range tenantIDSet {
		uniqueTenantIDs = append(uniqueTenantIDs, tid)
	}
	if len(uniqueTenantIDs) == 0 {
		return nil
	}

	// Get topn_tags from last KB's parser_config
	// JSON-decoded numbers arrive as float64; also tolerate int/int64/json.Number for safety
	topnTags := 3
	if lastKB != nil && lastKB.ParserConfig != nil {
		switch v := lastKB.ParserConfig["topn_tags"].(type) {
		case float64:
			topnTags = int(v)
		case int:
			topnTags = v
		case int64:
			topnTags = int(v)
		case json.Number:
			if n, err := v.Int64(); err == nil {
				topnTags = int(n)
			}
		}
	}

	// Query tags for the question using unique tenant IDs
	tagFeatures, err := s.TagQuery(ctx, question, uniqueTenantIDs, tagKBIDs, allTags, topnTags)
	if err != nil {
		return nil
	}
	if len(tagFeatures) == 0 {
		// Tag kb exists but returned no matching tags - return empty map (not nil)
		// so caller knows tag kb was configured vs not configured at all
		return make(map[string]float64)
	}

	return tagFeatures
}
