//
//  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"
	"path/filepath"
	"ragflow/internal/common"
	"ragflow/internal/dao"
	"ragflow/internal/engine"
	"ragflow/internal/entity"
	"ragflow/internal/entity/models"
	"ragflow/internal/storage"
	"ragflow/internal/tokenizer"
	"strings"
	"time"

	"go.uber.org/zap"
)

// SkillVersionInfo represents a skill version in the file system
type SkillVersionInfo struct {
	SkillName   string   `json:"skill_name"`
	Version     string   `json:"version"`
	Description string   `json:"description"`
	Tags        []string `json:"tags"`
	Content     string   `json:"content"`
}

// FileSystemClient defines the interface for accessing skill files
type FileSystemClient interface {
	ListSkills(ctx context.Context, tenantID string) ([]SkillVersionInfo, error)
	GetSkillContent(ctx context.Context, tenantID, skillName string) (*SkillVersionInfo, error)
}

// defaultMaxLength is a safe default for embedding model max input length
const defaultMaxLength = 8191

// SkillIndexerService handles skill indexing operations
type SkillIndexerService struct {
	configDAO     *dao.SkillSearchConfigDAO
	fileDAO       *dao.FileDAO
	spaceDAO      *dao.SkillSpaceDAO
	modelProvider *ModelProviderService
}

// NewSkillIndexerService creates a new SkillIndexerService instance
func NewSkillIndexerService() *SkillIndexerService {
	return &SkillIndexerService{
		configDAO:     dao.NewSkillSearchConfigDAO(),
		fileDAO:       dao.NewFileDAO(),
		spaceDAO:      dao.NewSkillSpaceDAO(),
		modelProvider: NewModelProviderService(),
	}
}

// isElasticsearch checks if the engine is Elasticsearch
func isElasticsearch(docEngine engine.DocEngine) bool {
	return docEngine.GetType() == "elasticsearch"
}

// IndexSkill indexes a single skill
// Uses skill_id as doc_id for direct mapping, with version control for incremental updates
// For ES: xxx fields store original content, xxx_tks fields store RAG-tokenized content (space-separated)
// For Infinity: only xxx fields with built-in rag-analyzer
func (s *SkillIndexerService) IndexSkill(ctx context.Context, tenantID, spaceID string, skill SkillInfo, docEngine engine.DocEngine, embdID string) error {
	spaceID = normalizeSpaceID(spaceID)

	config, err := s.configDAO.GetOrCreate(tenantID, spaceID, embdID)
	if err != nil {
		return fmt.Errorf("failed to get config: %w", err)
	}

	// Get field config
	fieldConfig := entity.DefaultFieldConfig()
	if config.FieldConfig != nil {
		if fcJSON, err := json.Marshal(config.FieldConfig); err == nil {
			json.Unmarshal(fcJSON, &fieldConfig)
		}
	}

	// Build vector text from enabled fields
	vectorText := BuildVectorText(skill.Name, skill.Description, skill.Tags, skill.Content, fieldConfig)

	// Generate embedding (optional - continue on failure)
	vector, err := s.generateEmbedding(ctx, vectorText, embdID, tenantID)
	if err != nil {
		common.Warn(fmt.Sprintf("Failed to generate embedding for skill %s: %v. Continuing with text-only index.", skill.ID, err))
	}

	// Build document with RAG tokenization for ES
	now := time.Now()
	timestamp := now.UnixMilli()

	// Get embedding dimension by calling embedding API with test text
	// This follows Python's approach: get dimension from actual embedding result
	dimension, err := s.getEmbeddingDimension(ctx, tenantID, embdID)
	if err != nil {
		return fmt.Errorf("failed to get embedding dimension: %w", err)
	}
	vectorField := fmt.Sprintf("q_%d_vec", dimension)

	// Determine engine type
	isES := isElasticsearch(docEngine)

	// Build base document
	// Use skill.Version if available, otherwise use config.IndexVersion as fallback
	skillVersion := skill.Version
	if skillVersion == "" {
		skillVersion = "1.0.0"
	}
	doc := map[string]interface{}{
		"skill_id":    skill.ID,
		"space_id":    spaceID,
		"folder_id":   skill.FolderID,
		"name":        skill.Name,
		"tags":        strings.Join(skill.Tags, ", "),
		"description": skill.Description,
		"content":     skill.Content,
		"version":     skillVersion,
		"status":      "1",
		"create_time": timestamp,
		"update_time": timestamp,
	}

	// Add vector if available
	if vector != nil {
		doc[vectorField] = vector
	} else if docEngine.GetType() == "infinity" {
		// For Infinity: use zero vector as placeholder
		doc[vectorField] = make([]float64, dimension)
	}

	// For ES: add tokenized fields for BM25 search
	// For Infinity: fields have built-in analyzer, no need for xxx_tks
	if isES {
		nameTokens, _ := tokenizer.Tokenize(skill.Name)
		tagsText := strings.Join(skill.Tags, " ")
		tagsTokens, _ := tokenizer.Tokenize(tagsText)

		doc["name_tks"] = nameTokens
		doc["tags_tks"] = tagsTokens

		if fieldConfig.Description.Enabled {
			descTokens, _ := tokenizer.Tokenize(skill.Description)
			doc["description_tks"] = descTokens
		}
		if fieldConfig.Content.Enabled {
			contentTokens, _ := tokenizer.Tokenize(skill.Content)
			doc["content_tks"] = contentTokens
		}
	}

	indexName := getSkillIndexName(tenantID, spaceID)

	// For Infinity: ensure table exists with correct dimension BEFORE inserting
	if docEngine.GetType() == "infinity" {
		exists, _ := docEngine.ChunkStoreExists(ctx, indexName, "skill")
		if !exists {
			common.Info(fmt.Sprintf("Creating Infinity table with dimension %d", dimension))
			if err := s.createIndexWithDimension(ctx, tenantID, spaceID, docEngine, embdID, dimension); err != nil {
				return fmt.Errorf("failed to create index with dimension %d: %w", dimension, err)
			}
		}
	}

	// Delete old versions (both new format and old format with version suffix)
	// This ensures only the latest version is indexed
	common.Debug(fmt.Sprintf("Deleting old versions of skill if exists: indexName=%s, skillName=%s", indexName, skill.Name))
	if err := s.DeleteSkillByName(ctx, tenantID, spaceID, skill.Name, docEngine); err != nil {
		common.Debug(fmt.Sprintf("No existing document to delete for skill %s (this is normal for new skills)", skill.Name))
	}

	// ES document ID cannot contain '/' - replace with '_'
	docID := strings.ReplaceAll(skill.ID, "/", "_")

	common.Info(fmt.Sprintf("Calling IndexDocument: indexName=%s, docID=%s, engineType=%s", indexName, docID, docEngine.GetType()))
	if err := docEngine.IndexDocument(ctx, indexName, docID, doc); err != nil {
		common.Error(fmt.Sprintf("IndexDocument failed: indexName=%s, docID=%s", indexName, docID), err)
		return fmt.Errorf("failed to index document: %w", err)
	}
	common.Info(fmt.Sprintf("IndexDocument succeeded: indexName=%s, docID=%s", indexName, docID))

	return nil
}

// BatchIndexSkills indexes multiple skills in batch
// Optimized to use batch embedding API for better performance
func (s *SkillIndexerService) BatchIndexSkills(ctx context.Context, tenantID, spaceID string, skills []SkillInfo, docEngine engine.DocEngine, embdID string) error {
	spaceID = normalizeSpaceID(spaceID)
	if len(skills) == 0 {
		return nil
	}

	config, err := s.configDAO.GetOrCreate(tenantID, spaceID, embdID)
	if err != nil {
		return fmt.Errorf("failed to get config: %w", err)
	}

	// Get field config
	fieldConfig := entity.DefaultFieldConfig()
	if config.FieldConfig != nil {
		if fcJSON, err := json.Marshal(config.FieldConfig); err == nil {
			json.Unmarshal(fcJSON, &fieldConfig)
		}
	}

	// Build vector texts for all skills
	vectorTexts := make([]string, len(skills))
	for i, skill := range skills {
		vectorTexts[i] = BuildVectorText(skill.Name, skill.Description, skill.Tags, skill.Content, fieldConfig)
	}

	// Get embedding dimension FIRST by calling embedding API with test text
	// This follows Python's approach: must get dimension before creating table
	dimension, err := s.getEmbeddingDimension(ctx, tenantID, embdID)
	if err != nil {
		return fmt.Errorf("failed to get embedding dimension: %w", err)
	}
	common.Info(fmt.Sprintf("Using embedding dimension: %d", dimension))
	vectorField := fmt.Sprintf("q_%d_vec", dimension)

	// Generate embeddings in batch
	common.Info(fmt.Sprintf("Generating embeddings for %d skills with embdID=%s", len(skills), embdID))
	var vectors []models.EmbeddingData
	vectors, err = s.generateEmbeddings(ctx, vectorTexts, embdID, tenantID)
	if err != nil {
		common.Warn(fmt.Sprintf("Failed to generate embeddings: %v. Continuing with text-only index.", err))
		vectors = nil // Continue without vectors
	} else {
		common.Info(fmt.Sprintf("Generated %d vectors", len(vectors)))
	}

	// Ensure index exists with correct dimension
	indexName := getSkillIndexName(tenantID, spaceID)
	if docEngine.GetType() == "infinity" {
		// For Infinity: must ensure table exists with correct dimension BEFORE inserting
		common.Info(fmt.Sprintf("Checking if index exists: %s", indexName))
		exists, err := docEngine.ChunkStoreExists(ctx, indexName, "skill")
		if err != nil {
			common.Warn(fmt.Sprintf("Error checking index existence: %v", err))
		}
		common.Info(fmt.Sprintf("Index exists: %v", exists))

		if !exists {
			// Only create if table doesn't exist
			common.Info(fmt.Sprintf("Creating index with actual dimension %d", dimension))
			if err := s.createIndexWithDimension(ctx, tenantID, spaceID, docEngine, embdID, dimension); err != nil {
				return fmt.Errorf("failed to create index with dimension %d: %w", dimension, err)
			}
			common.Info("Index created successfully")
		} else {
			common.Info("Index already exists, skipping creation")
		}
	} else {
		// For ES: just ensure index exists
		if err := s.EnsureIndex(ctx, tenantID, spaceID, docEngine, embdID); err != nil {
			return fmt.Errorf("failed to ensure index exists: %w", err)
		}
	}

	// Index all skills
	now := time.Now()
	timestamp := now.UnixMilli()
	isES := isElasticsearch(docEngine)

	var indexErrors []string
	for i, skill := range skills {
		// Delete old versions (both new format and old format with version suffix)
		// This ensures only the latest version is indexed
		if err := s.DeleteSkillByName(ctx, tenantID, spaceID, skill.Name, docEngine); err != nil {
			common.Debug(fmt.Sprintf("No existing document to delete for skill %s (this is normal for new skills)", skill.Name))
		}

		// ES document ID cannot contain '/' - replace with '_'
		docID := strings.ReplaceAll(skill.ID, "/", "_")

		// Use skill.Version if available, otherwise default to "1.0.0"
		skillVersion := skill.Version
		if skillVersion == "" {
			skillVersion = "1.0.0"
		}

		doc := map[string]interface{}{
			"skill_id":    skill.ID,
			"space_id":    spaceID,
			"folder_id":   skill.FolderID,
			"name":        skill.Name,
			"tags":        strings.Join(skill.Tags, ", "),
			"description": skill.Description,
			"content":     skill.Content,
			"version":     skillVersion,
			"status":      "1",
			"create_time": timestamp,
			"update_time": timestamp,
		}

		// Add vector only if available
		if vectors != nil && i < len(vectors) {
			doc[vectorField] = vectors[i].Embedding
		} else {
			common.Info(fmt.Sprintf("No vector for skill %s, creating text-only index", skill.ID))
			// For Infinity: use zero vector as placeholder (table schema requires vector column)
			if docEngine.GetType() == "infinity" {
				zeroVector := make([]float64, dimension)
				doc[vectorField] = zeroVector
			}
		}

		// For ES: add tokenized fields for BM25 search
		if isES {
			nameTokens, _ := tokenizer.Tokenize(skill.Name)
			tagsText := strings.Join(skill.Tags, " ")
			tagsTokens, _ := tokenizer.Tokenize(tagsText)

			doc["name_tks"] = nameTokens
			doc["tags_tks"] = tagsTokens

			if fieldConfig.Description.Enabled {
				descTokens, _ := tokenizer.Tokenize(skill.Description)
				doc["description_tks"] = descTokens
			}
			if fieldConfig.Content.Enabled {
				contentTokens, _ := tokenizer.Tokenize(skill.Content)
				doc["content_tks"] = contentTokens
			}
		}

		common.Info("Batch: Calling IndexDocument", zap.String("indexName", indexName), zap.String("docID", docID), zap.Int("index", i))
		if err := docEngine.IndexDocument(ctx, indexName, docID, doc); err != nil {
			common.Error(fmt.Sprintf("Failed to index skill %s", skill.ID), err)
			indexErrors = append(indexErrors, fmt.Sprintf("%s: %v", skill.ID, err))
			continue
		}
	}

	if len(indexErrors) > 0 {
		return fmt.Errorf("failed to index %d skill(s): %s", len(indexErrors), strings.Join(indexErrors, "; "))
	}

	return nil
}

// DeleteSkillIndex deletes a skill's index by skill ID
// Returns nil if the document doesn't exist (idempotent delete)
func (s *SkillIndexerService) DeleteSkillIndex(ctx context.Context, tenantID, spaceID, skillID string, docEngine engine.DocEngine) error {
	spaceID = normalizeSpaceID(spaceID)
	indexName := getSkillIndexName(tenantID, spaceID)
	// ES document ID cannot contain '/' - replace with '_'
	docID := strings.ReplaceAll(skillID, "/", "_")
	if err := docEngine.DeleteDocument(ctx, indexName, docID); err != nil {
		// Check if it's a "not found" error - this is OK, document might not have been indexed
		if strings.Contains(err.Error(), "not found") {
			common.Debug(fmt.Sprintf("Document %s not found in index %s, treating as already deleted", skillID, indexName))
			return nil
		}
		common.Error(fmt.Sprintf("Failed to delete document %s from index %s", skillID, indexName), err)
		return err
	}
	return nil
}

// DeleteSkillByName deletes a skill's index by skill name
// Deletes all versions: both new format (skillname) and old format (skillname_x.x.x)
func (s *SkillIndexerService) DeleteSkillByName(ctx context.Context, tenantID, spaceID, skillName string, docEngine engine.DocEngine) error {
	spaceID = normalizeSpaceID(spaceID)
	indexName := getSkillIndexName(tenantID, spaceID)

	docID := strings.ReplaceAll(skillName, "/", "_")
	if err := docEngine.DeleteDocument(ctx, indexName, docID); err != nil {
		common.Debug(fmt.Sprintf("Document %s not found in index %s", skillName, indexName))
	}

	return nil
}

// UpdateSkillVersion updates a skill's index when version changes
// Deletes old version and indexes new version
func (s *SkillIndexerService) UpdateSkillVersion(ctx context.Context, tenantID, spaceID string, skill SkillInfo, docEngine engine.DocEngine, embdID string) error {
	// Delete old version first (upsert behavior)
	if err := s.DeleteSkillByName(ctx, tenantID, spaceID, skill.Name, docEngine); err != nil {
		// Log but don't fail - the document might not exist
		common.Debug(fmt.Sprintf("No existing index to delete for skill %s", skill.Name))
	}

	// Index new version
	return s.IndexSkill(ctx, tenantID, spaceID, skill, docEngine, embdID)
}

// ReindexAll reindexes all skills for a tenant
// Increments semantic version, deletes old table, and reindexes all skills from file system
// For Infinity: if embedding model changed (different dimension), recreates the table
// Behavior:
//  1. Delete the existing table
//  2. Traverse all skill folders under the space
//  3. For each skill, get the latest version
//  4. Reindex all skills
func (s *SkillIndexerService) ReindexAll(ctx context.Context, tenantID, spaceID string, docEngine engine.DocEngine, embdID string) (map[string]interface{}, error) {
	spaceID = normalizeSpaceID(spaceID)
	// Get current config and increment semantic version
	config, err := s.configDAO.GetOrCreate(tenantID, spaceID, embdID)
	if err != nil {
		return nil, fmt.Errorf("failed to get config: %w", err)
	}

	// Increment semantic version (e.g., "1.0.0" -> "1.0.1" or "1.0.9" -> "1.1.0")
	newVersion := incrementSemanticVersion(config.IndexVersion)
	if err := s.configDAO.UpdateByTenantID(tenantID, spaceID, map[string]interface{}{
		"index_version": newVersion,
	}); err != nil {
		return nil, fmt.Errorf("failed to update version: %w", err)
	}

	// Get new embedding dimension first (needed for index creation)
	newDimension, err := s.getEmbeddingDimension(ctx, tenantID, embdID)
	if err != nil {
		return nil, fmt.Errorf("failed to get new embedding dimension: %w", err)
	}
	common.Info(fmt.Sprintf("ReindexAll: new embedding dimension is %d", newDimension))

	// Delete existing index and recreate with new dimension (for both ES and Infinity)
	indexName := getSkillIndexName(tenantID, spaceID)
	exists, _ := docEngine.ChunkStoreExists(ctx, indexName, "skill")
	if exists {
		common.Info(fmt.Sprintf("ReindexAll: deleting existing index %s", indexName))
		if err := docEngine.DropChunkStore(ctx, indexName, "skill"); err != nil {
			common.Warn(fmt.Sprintf("ReindexAll: failed to delete existing index: %v", err))
		}
	}

	// Create new index with correct dimension
	common.Info(fmt.Sprintf("ReindexAll: creating new index %s with dimension %d", indexName, newDimension))
	if err := s.createIndexWithDimension(ctx, tenantID, spaceID, docEngine, embdID, newDimension); err != nil {
		return nil, fmt.Errorf("failed to create index with dimension %d: %w", newDimension, err)
	}

	// Get space info to find folder ID
	space, err := s.spaceDAO.GetByID(spaceID)
	if err != nil {
		return nil, fmt.Errorf("failed to get space: %w", err)
	}
	if space.TenantID != tenantID {
		return nil, fmt.Errorf("space not found")
	}

	// Find the actual space folder ID by space name (consistent with frontend behavior)
	// Frontend uses space name to find folder, not space.FolderID which may be outdated
	spaceFolderID, err := s.getSpaceFolderIDByName(tenantID, space.Name)
	if err != nil {
		return nil, fmt.Errorf("failed to find space folder: %w", err)
	}
	common.Info(fmt.Sprintf("ReindexAll: found space folder ID %s for space %s (stored FolderID was %s)", spaceFolderID, space.Name, space.FolderID))

	// Traverse all skill folders under the space
	skills, err := s.getSkillsFromFileSystem(ctx, tenantID, spaceFolderID, spaceID)
	if err != nil {
		return nil, fmt.Errorf("failed to get skills from file system: %w", err)
	}

	common.Info(fmt.Sprintf("ReindexAll: found %d skills to index", len(skills)))

	// Index all skills with new version using batch indexing for better performance
	if len(skills) > 0 {
		common.Info(fmt.Sprintf("ReindexAll: batch indexing %d skills", len(skills)))
		if err := s.BatchIndexSkills(ctx, tenantID, spaceID, skills, docEngine, embdID); err != nil {
			common.Error("ReindexAll: batch indexing failed", err)
			return nil, fmt.Errorf("failed to batch index skills: %w", err)
		}
	}

	// Clean up old version documents
	if err := s.cleanupOldVersions(ctx, tenantID, spaceID, newVersion, docEngine); err != nil {
		common.Error("Failed to cleanup old versions", err)
	}

	result := map[string]interface{}{
		"indexed_count": len(skills),
		"total_skills":  len(skills),
		"version":       newVersion,
		"failed_count":  0,
	}

	return result, nil
}

// getSkillsFromFileSystem traverses the space folder and gets all skills with their latest version
func (s *SkillIndexerService) getSkillsFromFileSystem(ctx context.Context, tenantID, spaceFolderID, spaceID string) ([]SkillInfo, error) {
	var skills []SkillInfo

	// Get all skill folders under the space
	skillFolders, err := s.fileDAO.ListByParentID(spaceFolderID)
	if err != nil {
		return nil, fmt.Errorf("failed to list skill folders: %w", err)
	}

	common.Info(fmt.Sprintf("getSkillsFromFileSystem: found %d skill folders in space %s", len(skillFolders), spaceID))

	for _, skillFolder := range skillFolders {
		if skillFolder.Type != "folder" {
			continue
		}

		// Get all versions of this skill
		versions, err := s.fileDAO.ListByParentID(skillFolder.ID)
		if err != nil {
			common.Warn(fmt.Sprintf("failed to list versions for skill %s: %v", skillFolder.Name, err))
			continue
		}

		if len(versions) == 0 {
			common.Info(fmt.Sprintf("no versions found for skill %s", skillFolder.Name))
			continue
		}

		// Find the latest version (highest semantic version)
		latestVersion := s.findLatestVersion(versions)
		if latestVersion == nil {
			common.Warn(fmt.Sprintf("no valid version found for skill %s", skillFolder.Name))
			continue
		}

		// Get skill content from the latest version folder
		skillInfo, err := s.getSkillContentFromFolder(ctx, tenantID, skillFolder, latestVersion, spaceID)
		if err != nil {
			common.Warn(fmt.Sprintf("failed to get skill content for %s: %v", skillFolder.Name, err))
			continue
		}

		skills = append(skills, *skillInfo)
		common.Info(fmt.Sprintf("added skill %s version %s for indexing", skillFolder.Name, latestVersion.Name))
	}

	return skills, nil
}

// findLatestVersion finds the latest semantic version from a list of version folders
func (s *SkillIndexerService) findLatestVersion(versions []*entity.File) *entity.File {
	if len(versions) == 0 {
		return nil
	}

	var latest *entity.File
	latestVersionNum := []int{-1, -1, -1} // major, minor, patch

	for _, v := range versions {
		if v.Type != "folder" {
			continue
		}

		// Parse semantic version (e.g., "1.0.0")
		parts := strings.Split(v.Name, ".")
		if len(parts) != 3 {
			// Not a valid semver, skip
			continue
		}

		var major, minor, patch int
		fmt.Sscanf(parts[0], "%d", &major)
		fmt.Sscanf(parts[1], "%d", &minor)
		fmt.Sscanf(parts[2], "%d", &patch)

		// Compare versions
		if major > latestVersionNum[0] ||
			(major == latestVersionNum[0] && minor > latestVersionNum[1]) ||
			(major == latestVersionNum[0] && minor == latestVersionNum[1] && patch > latestVersionNum[2]) {
			latest = v
			latestVersionNum = []int{major, minor, patch}
		}
	}

	return latest
}

// getSkillContentFromFolder reads skill content from the version folder
func (s *SkillIndexerService) getSkillContentFromFolder(ctx context.Context, tenantID string, skillFolder, versionFolder *entity.File, spaceID string) (*SkillInfo, error) {
	// Get all files in the version folder
	files, err := s.fileDAO.ListByParentID(versionFolder.ID)
	if err != nil {
		return nil, fmt.Errorf("failed to list files in version folder: %w", err)
	}

	var contentBuilder strings.Builder
	var skillMdContent string

	for _, file := range files {
		if file.Type == "folder" {
			continue
		}

		// Check if it's a text file
		if !isTextFileForSkill(file.Name) {
			continue
		}

		// Get file content (this might need to be implemented based on your storage system)
		fileContent, err := s.getFileContent(ctx, tenantID, file)
		if err != nil {
			common.Warn(fmt.Sprintf("failed to get content for file %s: %v", file.Name, err))
			continue
		}

		if len(fileContent) == 0 {
			continue
		}

		// Check if this is SKILL.md
		if strings.ToLower(file.Name) == "skill.md" {
			skillMdContent = string(fileContent)
		}

		contentBuilder.WriteString(fmt.Sprintf("\n=== %s ===\n", file.Name))
		contentBuilder.Write(fileContent)
	}

	// Parse SKILL.md for metadata
	name, description, tags := s.parseSkillMetadata(skillMdContent, skillFolder.Name)

	// Use skill name as ID (without version suffix)
	// This ensures all versions of the same skill share the same index document
	skillID := name
	if skillID == "" {
		skillID = skillFolder.Name
	}

	skillInfo := &SkillInfo{
		ID:          skillID,
		Name:        name,
		Description: description,
		Tags:        tags,
		Content:     contentBuilder.String(),
		FolderID:    skillFolder.ID,
	}

	return skillInfo, nil
}

// isTextFileForSkill checks if a file is a text file that should be indexed
func isTextFileForSkill(fileName string) bool {
	ext := strings.ToLower(filepath.Ext(fileName))
	if ext != "" {
		ext = ext[1:] // Remove leading dot
	}

	textFileExtensions := map[string]bool{
		"md": true, "mdx": true, "txt": true, "json": true, "json5": true,
		"yaml": true, "yml": true, "toml": true, "js": true, "cjs": true, "mjs": true,
		"ts": true, "tsx": true, "jsx": true, "py": true, "sh": true, "rb": true,
		"go": true, "rs": true, "swift": true, "kt": true, "java": true, "cs": true,
		"cpp": true, "c": true, "h": true, "hpp": true, "sql": true, "csv": true,
		"ini": true, "cfg": true, "env": true, "xml": true, "html": true,
		"css": true, "scss": true, "sass": true, "svg": true,
	}

	return textFileExtensions[ext]
}

// getSpaceFolderIDByName finds the space folder ID by space name (consistent with frontend behavior)
// Frontend finds space folder by listing folders under skills folder and matching by name
func (s *SkillIndexerService) getSpaceFolderIDByName(tenantID, spaceName string) (string, error) {
	// Get root folder
	rootFolder, err := s.fileDAO.GetRootFolder(tenantID)
	if err != nil {
		return "", fmt.Errorf("failed to get root folder: %w", err)
	}

	// Find skills folder under root
	files, _, err := s.fileDAO.GetByPfID(tenantID, rootFolder.ID, 0, 0, "name", false, "")
	if err != nil {
		return "", fmt.Errorf("failed to list root folder contents: %w", err)
	}

	var skillsFolderID string
	for _, file := range files {
		if file.Type == "folder" && file.Name == "skills" {
			skillsFolderID = file.ID
			break
		}
	}

	if skillsFolderID == "" {
		return "", fmt.Errorf("skills folder not found for tenant %s", tenantID)
	}

	// Find space folder by name under skills folder
	spaceFolders, _, err := s.fileDAO.GetByPfID(tenantID, skillsFolderID, 0, 0, "name", false, "")
	if err != nil {
		return "", fmt.Errorf("failed to list skills folder contents: %w", err)
	}

	for _, folder := range spaceFolders {
		if folder.Type == "folder" && folder.Name == spaceName {
			return folder.ID, nil
		}
	}

	return "", fmt.Errorf("space folder '%s' not found under skills folder", spaceName)
}

// parseSkillMetadata parses SKILL.md content to extract metadata
func (s *SkillIndexerService) parseSkillMetadata(content, defaultName string) (name, description string, tags []string) {
	name = defaultName

	if content == "" {
		return name, "", nil
	}

	// Parse YAML frontmatter
	lines := strings.Split(content, "\n")
	if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
		return name, "", nil
	}

	var endIndex int
	found := false
	for i := 1; i < len(lines); i++ {
		if strings.TrimSpace(lines[i]) == "---" {
			endIndex = i
			found = true
			break
		}
	}

	if !found {
		return name, "", nil
	}

	// Parse frontmatter lines
	for i := 1; i < endIndex; i++ {
		line := lines[i]
		if strings.HasPrefix(line, "name:") {
			name = strings.TrimSpace(strings.TrimPrefix(line, "name:"))
		} else if strings.HasPrefix(line, "description:") {
			description = strings.TrimSpace(strings.TrimPrefix(line, "description:"))
		} else if strings.HasPrefix(line, "tags:") {
			// Parse tags array
			tagsLine := strings.TrimSpace(strings.TrimPrefix(line, "tags:"))
			if strings.HasPrefix(tagsLine, "[") && strings.HasSuffix(tagsLine, "]") {
				// Array format: [tag1, tag2]
				tagsStr := strings.Trim(tagsLine, "[]")
				tags = strings.Split(tagsStr, ",")
				for i, tag := range tags {
					tags[i] = strings.TrimSpace(tag)
				}
			} else if tagsLine != "" {
				// Single tag or dash list
				tags = []string{tagsLine}
			}
		}
	}

	return name, description, tags
}

// getFileContent retrieves the content of a file from storage
func (s *SkillIndexerService) getFileContent(ctx context.Context, tenantID string, file *entity.File) ([]byte, error) {
	if file.Location == nil || *file.Location == "" {
		return nil, fmt.Errorf("file location is empty")
	}

	storageImpl := storage.GetStorageFactory().GetStorage()
	if storageImpl == nil {
		return nil, fmt.Errorf("storage not initialized")
	}

	// Get file content from storage using parent folder ID as bucket (consistent with Python)
	// Python: settings.STORAGE_IMPL.put(last_folder.id, location, blob)
	// Go: should use file.ParentID as bucket, not tenantID
	bucket := file.ParentID
	if bucket == "" {
		// Fallback to tenantID if ParentID is empty (should not happen)
		bucket = tenantID
	}
	content, err := storageImpl.Get(bucket, *file.Location)
	if err != nil {
		return nil, fmt.Errorf("failed to get file from storage (bucket=%s, location=%s): %w", bucket, *file.Location, err)
	}

	return content, nil
}

// incrementSemanticVersion increments the patch version of a semantic version string
// Supports format: "major.minor.patch" (e.g., "1.0.0" -> "1.0.1")
// If version is empty or invalid, returns "1.0.0"
func incrementSemanticVersion(version string) string {
	if version == "" {
		return "1.0.0"
	}

	parts := strings.Split(version, ".")
	if len(parts) != 3 {
		// Invalid format, reset to 1.0.0
		return "1.0.0"
	}

	// Try to parse patch version
	var major, minor, patch int
	fmt.Sscanf(parts[0], "%d", &major)
	fmt.Sscanf(parts[1], "%d", &minor)
	fmt.Sscanf(parts[2], "%d", &patch)

	// Increment patch version
	patch++
	if patch > 999 {
		patch = 0
		minor++
		if minor > 999 {
			minor = 0
			major++
		}
	}

	return fmt.Sprintf("%d.%d.%d", major, minor, patch)
}

// cleanupOldVersions removes documents with version less than current version
func (s *SkillIndexerService) cleanupOldVersions(ctx context.Context, tenantID, spaceID string, currentVersion string, docEngine engine.DocEngine) error {
	// This is a placeholder - actual implementation would:
	// 1. Search for documents where version < currentVersion (semantic version comparison)
	// 2. Delete those documents
	// The actual implementation depends on the search engine's query capabilities
	// For now, we rely on the fact that skill_id is used as doc_id,
	// so re-indexing the same skill_id will overwrite the document
	return nil
}

// InitializeIndex initializes the skill search index for a tenant
func (s *SkillIndexerService) InitializeIndex(ctx context.Context, tenantID, spaceID string, docEngine engine.DocEngine, embdID string) error {
	// Check if index exists
	indexName := getSkillIndexName(tenantID, spaceID)

	common.Info("Checking skill index existence", zap.String("indexName", indexName), zap.String("tenantID", tenantID), zap.String("spaceID", spaceID))

	exists, err := docEngine.ChunkStoreExists(ctx, indexName, "skill")
	if err != nil {
		common.Error("Failed to check index existence", err)
		return fmt.Errorf("failed to check index existence: %w", err)
	}

	if !exists {
		common.Info("Skill index does not exist, creating...", zap.String("indexName", indexName))
		return s.createIndex(ctx, tenantID, spaceID, docEngine, embdID)
	}

	common.Info("Skill search index already exists", zap.String("indexName", indexName))
	return nil
}

// createIndex creates the skill index using mapping files
func (s *SkillIndexerService) createIndex(ctx context.Context, tenantID, spaceID string, docEngine engine.DocEngine, embdID string) error {
	// Get embedding dimension by calling embedding API with test text
	dimension, err := s.getEmbeddingDimension(ctx, tenantID, embdID)
	if err != nil {
		return fmt.Errorf("failed to get embedding dimension: %w", err)
	}
	return s.createIndexWithDimension(ctx, tenantID, spaceID, docEngine, embdID, dimension)
}

// createIndexWithDimension creates the skill index with a specific vector dimension
func (s *SkillIndexerService) createIndexWithDimension(ctx context.Context, tenantID, spaceID string, docEngine engine.DocEngine, embdID string, dimension int) error {
	indexName := getSkillIndexName(tenantID, spaceID)

	common.Info(fmt.Sprintf("Creating skill index with dimension %d", dimension),
		zap.String("indexName", indexName),
		zap.String("spaceID", spaceID),
		zap.Int("dimension", dimension),
		zap.String("engineType", docEngine.GetType()))

	// For Infinity: check if table exists and needs recreation (dimension mismatch)
	if docEngine.GetType() == "infinity" {
		exists, err := docEngine.ChunkStoreExists(ctx, indexName, "skill")
		if err != nil {
			common.Warn(fmt.Sprintf("Error checking if index exists: %v", err))
		}
		if exists {
			common.Info(fmt.Sprintf("Index exists, deleting for recreation with dimension %d", dimension),
				zap.String("indexName", indexName))
			if err := docEngine.DropChunkStore(ctx, indexName, "skill"); err != nil {
				common.Warn(fmt.Sprintf("Failed to delete existing index: %v", err))
			}
		}
	}

	// Use the doc engine's CreateChunkStore method with skill-specific mapping
	// The mapping file is loaded from conf/skill_es_mapping.json or conf/skill_infinity_mapping.json
	err := docEngine.CreateChunkStore(ctx, indexName, "skill", dimension, "")
	if err != nil {
		common.Error("Failed to create skill index", err)
		return err
	}
	common.Info("Successfully created skill index", zap.String("indexName", indexName))
	return nil
}

// EnsureIndex ensures the skill index exists for a tenant
func (s *SkillIndexerService) EnsureIndex(ctx context.Context, tenantID, spaceID string, docEngine engine.DocEngine, embdID string) error {
	return s.InitializeIndex(ctx, tenantID, spaceID, docEngine, embdID)
}

// generateEmbedding generates embedding for text using the specified model
func (s *SkillIndexerService) generateEmbedding(ctx context.Context, text, embdID, tenantID string) ([]float64, error) {
	if s.modelProvider == nil {
		return nil, fmt.Errorf("model provider not set")
	}

	if embdID == "" {
		return nil, fmt.Errorf("embedding model ID not configured")
	}

	embeddingModel, err := s.modelProvider.GetEmbeddingModel(tenantID, embdID)
	if err != nil {
		return nil, fmt.Errorf("failed to get embedding model: %w", err)
	}

	// Truncate text to prevent exceeding model's max input length
	maxLen := embeddingModel.MaxTokens
	if maxLen <= 0 {
		maxLen = defaultMaxLength
	}
	truncatedText := truncate(text, maxLen-10)

	var response []models.EmbeddingData
	response, err = embeddingModel.ModelDriver.Embed(embeddingModel.ModelName, []string{truncatedText}, embeddingModel.APIConfig, nil)
	if err != nil {
		return nil, fmt.Errorf("failed to encode text: %w", err)
	}
	if len(response) == 0 {
		return nil, fmt.Errorf("embedding returned empty result")
	}

	return response[0].Embedding, nil
}

// generateEmbeddings generates embeddings for multiple texts in batch
// This is more efficient than calling generateEmbedding individually
func (s *SkillIndexerService) generateEmbeddings(ctx context.Context, texts []string, embdID, tenantID string) ([]models.EmbeddingData, error) {
	common.Info(fmt.Sprintf("generateEmbeddings called: texts=%d, embdID=%s, tenantID=%s", len(texts), embdID, tenantID))

	if s.modelProvider == nil {
		return nil, fmt.Errorf("model provider not set")
	}

	if embdID == "" {
		return nil, fmt.Errorf("embedding model ID not configured")
	}

	common.Info(fmt.Sprintf("Getting embedding model for %s", embdID))
	embeddingModel, err := s.modelProvider.GetEmbeddingModel(tenantID, embdID)
	if err != nil {
		common.Error(fmt.Sprintf("Failed to get embedding model: %v", err), err)
		return nil, fmt.Errorf("failed to get embedding model: %w", err)
	}

	// Truncate texts to prevent exceeding model's max input length
	maxLen := embeddingModel.MaxTokens
	if maxLen <= 0 {
		maxLen = defaultMaxLength
	}
	truncatedTexts := make([]string, len(texts))
	for i, text := range texts {
		truncatedTexts[i] = truncate(text, maxLen-10)
	}

	common.Info(fmt.Sprintf("Encoding %d texts", len(truncatedTexts)))
	// Use batch encode API (consistent with Python's encode(texts: list))
	var response []models.EmbeddingData
	response, err = embeddingModel.ModelDriver.Embed(embeddingModel.ModelName, truncatedTexts, embeddingModel.APIConfig, nil)
	if err != nil {
		common.Error(fmt.Sprintf("Failed to encode texts: %v", err), err)
		return nil, fmt.Errorf("failed to encode texts: %w", err)
	}

	common.Info(fmt.Sprintf("Encoded successfully, got %d vectors", len(response)))
	if len(response) > 0 {
		common.Info(fmt.Sprintf("Vector dimension: %d", len(response[0].Embedding)))
	}

	return response, nil
}

// truncate truncates text to maxLen characters
// Similar to Python's truncate function in rag/llm/embedding_model.py
func truncate(text string, maxLen int) string {
	if maxLen <= 0 {
		return text
	}
	runes := []rune(text)
	if len(runes) <= maxLen {
		return text
	}
	return string(runes[:maxLen])
}

// getEmbeddingDimension gets the embedding dimension by calling the embedding API with test text
// This follows Python's approach: use actual embedding result to determine dimension
// If embedding API fails, returns error (cannot create table without knowing dimension)
func (s *SkillIndexerService) getEmbeddingDimension(ctx context.Context, tenantID, embdID string) (int, error) {
	if s.modelProvider == nil {
		return 0, fmt.Errorf("model provider not set")
	}

	if embdID == "" {
		return 0, fmt.Errorf("embedding model ID not configured")
	}

	embeddingModel, err := s.modelProvider.GetEmbeddingModel(tenantID, embdID)
	if err != nil {
		return 0, fmt.Errorf("failed to get embedding model: %w", err)
	}

	// Use simple test text like Python does: embedding_model.encode(["ok"])
	testText := "ok"
	var response []models.EmbeddingData
	response, err = embeddingModel.ModelDriver.Embed(embeddingModel.ModelName, []string{testText}, embeddingModel.APIConfig, nil)
	if err != nil {
		return 0, fmt.Errorf("failed to encode test text: %w", err)
	}

	if len(response) == 0 || len(response[0].Embedding) == 0 {
		return 0, fmt.Errorf("embedding returned empty vector")
	}

	dimension := len(response[0].Embedding)
	common.Info(fmt.Sprintf("Got embedding dimension from API: %d", dimension))
	return dimension, nil
}
