package services

import (
	"context"
	"crypto/sha256"
	"encoding/json"
	"fmt"
	"log"
	"math"
	"net/http"
	"net/url"
	"sort"
	"strings"
	"sync"
	"time"

	"wa-assistant/backend/database"
	"wa-assistant/backend/models"

	openai "github.com/sashabaranov/go-openai"
	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

// KBItem = satu knowledge dengan vektor embedding yang sudah di-parse (siap pakai).
type KBItem struct {
	K   models.Knowledge
	Vec []float32
}

// Cache knowledge per-agent di memori: hindari query DB + unmarshal JSON tiap pesan masuk.
var (
	kbCache   = map[uint][]KBItem{}
	kbDirty   = map[uint]bool{}
	kbVersion = map[uint]uint64{}
	kbMu      sync.RWMutex
)

// InvalidateKB menandai cache knowledge sebuah agent perlu dimuat ulang (dipanggil saat ada perubahan).
func InvalidateKB(agentID uint) {
	kbMu.Lock()
	kbDirty[agentID] = true
	kbVersion[agentID]++
	kbMu.Unlock()
}

// KnowledgeFor mengembalikan knowledge agent dari cache memori (embedding sudah di-parse).
// DB hanya di-query saat pertama kali atau setelah ada perubahan (create/update/delete).
func KnowledgeFor(agentID uint) []KBItem { return KnowledgeForContext(context.Background(), agentID) }

// Legacy convenience API. Agentic callers use LoadKnowledgeForContext so a
// failed read cannot be mistaken for a successful search with no matches.
func KnowledgeForContext(ctx context.Context, agentID uint) []KBItem {
	items, _ := LoadKnowledgeForContext(ctx, agentID)
	return items
}

func LoadKnowledgeForContext(ctx context.Context, agentID uint) ([]KBItem, error) {
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()
	if err := ctx.Err(); err != nil {
		return nil, err
	}
	if agentID == 0 || database.DB == nil {
		return nil, errKnowledgeUnavailable
	}
	kbMu.RLock()
	items, ok := kbCache[agentID]
	dirty := kbDirty[agentID]
	version := kbVersion[agentID]
	kbMu.RUnlock()
	if ok && !dirty {
		if err := ctx.Err(); err != nil {
			return nil, err
		}
		return effectiveKnowledgeItems(items, time.Now()), nil
	}

	var rows []models.Knowledge
	if err := database.DB.WithContext(ctx).Where("agent_id = ? AND active = ? AND review_status = ?", agentID, true, "published").Find(&rows).Error; err != nil {
		if ctx.Err() != nil {
			return nil, ctx.Err()
		}
		return nil, errKnowledgeUnavailable // Raw SQL/driver errors must not become model context.
	}
	items = make([]KBItem, 0, len(rows))
	if err := ResolveKnowledgeSubjects(database.DB.WithContext(ctx), agentID, rows); err != nil {
		return nil, errKnowledgeUnavailable
	}
	for _, r := range rows {
		var vec []float32
		if r.Embedding != "" {
			_ = json.Unmarshal([]byte(r.Embedding), &vec)
		}
		r.Embedding = "" // hemat memori: vektor sudah disimpan terpisah di Vec
		items = append(items, KBItem{K: r, Vec: vec})
	}
	if err := ctx.Err(); err != nil {
		return nil, err
	}
	kbMu.Lock()
	if kbVersion[agentID] != version {
		kbMu.Unlock()
		return nil, errKnowledgeChanged
	} // publication won the race; never cache stale facts
	kbCache[agentID] = items
	kbDirty[agentID] = false
	kbMu.Unlock()
	return effectiveKnowledgeItems(items, time.Now()), nil
}

// effectiveKnowledgeItems difilter setiap retrieval (bukan hanya saat cache
// dimuat) agar knowledge otomatis mulai/berhenti berlaku tepat waktu.
func effectiveKnowledgeItems(items []KBItem, now time.Time) []KBItem {
	out := make([]KBItem, 0, len(items))
	for _, item := range items {
		if item.K.ReviewStatus != "" && item.K.ReviewStatus != "published" {
			continue
		}
		if item.K.EffectiveFrom != nil && item.K.EffectiveFrom.After(now) {
			continue
		}
		if item.K.EffectiveUntil != nil && item.K.EffectiveUntil.Before(now) {
			continue
		}
		out = append(out, item)
	}
	return out
}

var (
	embClient  *openai.Client
	embModel   string
	embDims    int
	embEnabled bool
	embMu      sync.RWMutex
)

const defaultEmbeddingModel = "openai/text-embedding-3-small"

// InitEmbedding memakai API key OpenRouter yang sama dengan seluruh fitur AI.
// Model dipilih dari dashboard dan dapat dimuat ulang tanpa restart backend.
func InitEmbedding() {
	key := apiKeyFromDB("api_key", "OPENROUTER_API_KEY")
	model := apiConfigFromDB("embedding_model", "", defaultEmbeddingModel)
	embMu.Lock()
	defer embMu.Unlock()
	if key == "" {
		embClient = nil
		embModel = model
		embDims = 0
		embEnabled = false
		log.Println("Embedding: API key OpenRouter kosong -> semantic search nonaktif (pakai keyword match)")
		return
	}
	cfg := openai.DefaultConfig(key)
	cfg.BaseURL = openRouterBase
	embClient = openai.NewClientWithConfig(cfg)
	embModel = model
	// Gunakan dimensi native model agar pergantian model dari dashboard aman.
	embDims = 0
	embEnabled = true
	log.Printf("Embedding OpenRouter aktif: model=%s", embModel)
}

func EmbeddingEnabled() bool {
	embMu.RLock()
	defer embMu.RUnlock()
	return embEnabled
}

// embSignature mengidentifikasi konfigurasi embedding aktif (model + dimensi). Disimpan
// bersama tiap vektor agar perubahan model/dimensi terdeteksi & knowledge di-embed ulang
// otomatis — mencegah retrieval "mati senyap" karena dimensi vektor tak cocok.
func embSignature() string {
	embMu.RLock()
	model, dims := embModel, embDims
	embMu.RUnlock()
	return embeddingSignature(model, dims)
}

func embeddingSignature(model string, dims int) string {
	if dims > 0 {
		return fmt.Sprintf("%s:%d", model, dims)
	}
	return model
}

// Equal dimensions alone do not make vectors from different models comparable.
// Unknown legacy signatures retain keyword retrieval until they are re-indexed.
func embeddingMatchesSignature(stored, query string, vector, queryVector []float32) bool {
	return stored != "" && stored == query && len(queryVector) > 0 && len(vector) == len(queryVector)
}

// Embed menghitung vektor embedding untuk satu teks.
func Embed(text string) ([]float32, error) { return EmbedContext(context.Background(), text) }

func EmbedContext(ctx context.Context, text string) ([]float32, error) {
	vec, _, err := embedContextWithSignature(ctx, text)
	return vec, err
}

// Return the configuration used by this request, including when an admin changes
// the active model while the request is in flight. Never label an old vector with
// the new model or compare it with that model's vectors.
func embedContextWithSignature(ctx context.Context, text string) ([]float32, string, error) {
	ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
	defer cancel()
	embMu.RLock()
	client, model, dims, enabled := embClient, embModel, embDims, embEnabled
	embMu.RUnlock()
	if !enabled || client == nil {
		return nil, "", fmt.Errorf("embedding OpenRouter belum dikonfigurasi")
	}
	req := openai.EmbeddingRequest{
		Input: []string{text},
		Model: openai.EmbeddingModel(model),
	}
	if dims > 0 {
		req.Dimensions = dims
	}
	resp, err := client.CreateEmbeddings(ctx, req)
	if err != nil {
		return nil, "", err
	}
	if len(resp.Data) == 0 {
		return nil, "", fmt.Errorf("embedding kosong")
	}
	return resp.Data[0].Embedding, embeddingSignature(model, dims), nil
}

// EmbeddingModelInfo adalah opsi model yang dibaca langsung dari katalog OpenRouter.
type EmbeddingModelInfo struct {
	ID            string `json:"id"`
	Name          string `json:"name"`
	ContextLength int    `json:"context_length,omitempty"`
}

// ListOpenRouterEmbeddingModels mengambil katalog terbaru sehingga pilihan model
// di dashboard tidak ditanam permanen di kode maupun environment.
func ListOpenRouterEmbeddingModels(ctx context.Context) ([]EmbeddingModelInfo, error) {
	key := apiKeyFromDB("api_key", "OPENROUTER_API_KEY")
	if key == "" {
		return nil, fmt.Errorf("API key OpenRouter belum dikonfigurasi")
	}
	ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
	defer cancel()
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, openRouterBase+"/embeddings/models", nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+key)
	req.Header.Set("Accept", "application/json")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("gagal mengambil model embedding OpenRouter: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return nil, fmt.Errorf("OpenRouter mengembalikan status %d", resp.StatusCode)
	}
	var payload struct {
		Data []EmbeddingModelInfo `json:"data"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
		return nil, fmt.Errorf("respons katalog model OpenRouter tidak valid: %w", err)
	}
	sort.Slice(payload.Data, func(i, j int) bool {
		return payload.Data[i].Name < payload.Data[j].Name
	})
	return payload.Data, nil
}

// cosineSim menghitung kemiripan kosinus dua vektor (-1..1).
func cosineSim(a, b []float32) float32 {
	if len(a) != len(b) || len(a) == 0 {
		return 0
	}
	var dot, na, nb float64
	for i := range a {
		dot += float64(a[i]) * float64(b[i])
		na += float64(a[i]) * float64(a[i])
		nb += float64(b[i]) * float64(b[i])
	}
	if na == 0 || nb == 0 {
		return 0
	}
	return float32(dot / (math.Sqrt(na) * math.Sqrt(nb)))
}

func knowledgeText(k *models.Knowledge) string {
	text := k.Question + "\n" + k.Answer + "\n" + k.Tags
	if subject, _ := knowledgeSubject(*k); subject != "" {
		u, _ := url.Parse(subject)
		label := u.Path[strings.LastIndex(u.Path, "/")+1:]
		label = strings.Join(strings.Fields(strings.NewReplacer("-", " ", "_", " ").Replace(label)), " ")
		// Generic FAQs such as "Berapa harga produk ini?" need the same product
		// context as the query. Metadata identifies the product, never its facts.
		text = "Produk: " + label + "\n" + text
	}
	return text
}

// Version the document format and resolved product identity independently from
// the provider model. Old context-free vectors must be rebuilt, even when their
// dimensions match. The fixed-length signature fits the existing DB column.
func knowledgeEmbeddingSignature(k models.Knowledge, modelSignature string) string {
	if subject, _ := knowledgeSubject(k); subject != "" && modelSignature != "" {
		digest := sha256.Sum256([]byte(modelSignature + "\x00" + subject))
		return fmt.Sprintf("kb-subject-v1:%x", digest[:16])
	}
	return modelSignature
}

// Database collations may ignore case, accents, or trailing spaces. Snapshot
// guards compare bytes so even those edits invalidate an in-flight index. The
// field names below come from internal literals and are quoted as identifiers;
// source text always stays in bound parameters.
func matchEmbeddingSnapshotText(db *gorm.DB, fields map[string]string) *gorm.DB {
	var castType string
	switch db.Dialector.Name() {
	case "mysql":
		castType = "BINARY"
	case "sqlite":
		castType = "BLOB"
	default:
		_ = db.AddError(fmt.Errorf("embedding snapshot comparison: unsupported database dialect %q", db.Dialector.Name()))
		return db
	}
	columns := make([]string, 0, len(fields))
	for column := range fields {
		columns = append(columns, column)
	}
	sort.Strings(columns)
	for _, column := range columns {
		db = db.Where("CAST(COALESCE(?, '') AS "+castType+") = CAST(? AS "+castType+")", clause.Column{Name: column}, fields[column])
	}
	return db
}

// IndexKnowledge menghitung embedding satu knowledge lalu menyimpannya ke kolom embedding.
func IndexKnowledge(k *models.Knowledge) {
	if k == nil || k.ID == 0 {
		return
	}
	snapshot := *k
	InvalidateKB(snapshot.AgentID) // isi knowledge berubah -> cache agent ini perlu di-refresh
	if !snapshot.Active || (snapshot.ReviewStatus != "" && snapshot.ReviewStatus != "published") || !EmbeddingEnabled() {
		return
	}
	resolved := []models.Knowledge{snapshot}
	if err := ResolveKnowledgeSubjects(database.DB, snapshot.AgentID, resolved); err != nil {
		log.Printf("Embedding: konteks produk knowledge #%d belum bisa dibaca", snapshot.ID)
		return
	}
	snapshot = resolved[0]
	vec, signature, err := embedContextWithSignature(context.Background(), knowledgeText(&snapshot))
	if err != nil {
		log.Printf("Embedding: gagal embed knowledge #%d: %v", snapshot.ID, err)
		return
	}
	signature = knowledgeEmbeddingSignature(snapshot, signature)
	b, _ := json.Marshal(vec)
	// The provider request ran outside the database. Commit only if the input
	// and publication are still the same; timestamps alone miss some edits.
	result := matchEmbeddingSnapshotText(database.DB.Model(&snapshot).
		Where("agent_id = ? AND active = ? AND COALESCE(supersedes_id, 0) = ?", snapshot.AgentID, snapshot.Active, snapshot.SupersedesID), map[string]string{
		"review_status": snapshot.ReviewStatus, "question": snapshot.Question, "answer": snapshot.Answer, "tags": snapshot.Tags, "source_url": snapshot.SourceURL,
	}).
		Updates(map[string]any{"embedding": string(b), "embedding_model": signature})
	InvalidateKB(snapshot.AgentID)
	if result.Error != nil {
		log.Printf("Embedding: gagal simpan embedding knowledge #%d: %v", snapshot.ID, result.Error)
		return
	}
	if result.RowsAffected == 0 {
		return // The source changed or was removed; discard this stale vector.
	}
	k.Embedding, k.EmbeddingModel, k.UpdatedAt = string(b), signature, snapshot.UpdatedAt
	k.SubjectURL = snapshot.SubjectURL
}

// BackfillEmbeddings mengisi embedding untuk knowledge yang belum punya, ATAU yang dibuat
// dengan model/dimensi berbeda dari konfigurasi sekarang (mis. model dashboard diganti) —
// supaya retrieval tidak mati senyap akibat dimensi vektor tak cocok. Dipanggil di startup.
func BackfillEmbeddings() {
	if !EmbeddingEnabled() {
		return
	}
	sig := embSignature()
	var rows []models.Knowledge
	if err := database.DB.Where("active = ? AND review_status = ?", true, "published").Find(&rows).Error; err != nil {
		log.Print("Embedding: pengetahuan belum bisa dibaca untuk pembaruan indeks")
		return
	}
	byAgent := map[uint][]models.Knowledge{}
	for _, row := range rows {
		byAgent[row.AgentID] = append(byAgent[row.AgentID], row)
	}
	for agentID, scoped := range byAgent {
		if err := ResolveKnowledgeSubjects(database.DB, agentID, scoped); err != nil {
			log.Printf("Embedding: konteks asisten #%d belum bisa dibaca", agentID)
			continue
		}
		for i := range scoped {
			if scoped[i].Embedding != "" && scoped[i].EmbeddingModel == knowledgeEmbeddingSignature(scoped[i], sig) {
				continue
			}
			IndexKnowledge(&scoped[i])
		}
	}
	BackfillProductEmbeddings()
}

// ProductItem = satu produk dengan vektor embedding ter-parse untuk hybrid retrieval katalog.
type ProductItem struct {
	P   models.Product
	Vec []float32
}

var (
	productCache   = map[uint][]ProductItem{}
	productDirty   = map[uint]bool{}
	productVersion = map[uint]uint64{}
	productMu      sync.RWMutex
)

// InvalidateProducts menandai cache produk agent perlu dimuat ulang.
func InvalidateProducts(agentID uint) {
	productMu.Lock()
	productDirty[agentID] = true
	productVersion[agentID]++
	productMu.Unlock()
}

// ProductsFor mengembalikan katalog agent dari cache memori (embedding sudah di-parse).
func ProductsFor(agentID uint) []ProductItem {
	productMu.RLock()
	items, ok := productCache[agentID]
	dirty := productDirty[agentID]
	version := productVersion[agentID]
	productMu.RUnlock()
	if ok && !dirty {
		return items
	}

	var rows []models.Product
	if err := database.DB.Where("agent_id = ?", agentID).Order("id desc").Limit(200).Find(&rows).Error; err != nil {
		return nil // A failed read is not an empty catalog and must not be cached.
	}
	items = make([]ProductItem, 0, len(rows))
	for _, r := range rows {
		var vec []float32
		if r.Embedding != "" {
			_ = json.Unmarshal([]byte(r.Embedding), &vec)
		}
		r.Embedding = ""
		items = append(items, ProductItem{P: r, Vec: vec})
	}
	productMu.Lock()
	if productVersion[agentID] != version {
		productMu.Unlock()
		return nil // A product update won the race; the next read loads it again.
	}
	productCache[agentID] = items
	productDirty[agentID] = false
	productMu.Unlock()
	return items
}

func productEmbedText(p *models.Product) string {
	parts := []string{
		strings.TrimSpace(p.Name),
		strings.TrimSpace(p.ProductType),
		strings.TrimSpace(p.Price),
		strings.TrimSpace(p.Description),
		productDetailsText(p.DetailsJSON),
		strings.TrimSpace(p.Knowledge),
	}
	var b strings.Builder
	for _, part := range parts {
		if part == "" {
			continue
		}
		if b.Len() > 0 {
			b.WriteByte('\n')
		}
		b.WriteString(part)
	}
	return b.String()
}

// IndexProduct menghitung embedding satu produk lalu menyimpannya ke DB + invalidate cache.
func IndexProduct(p *models.Product) {
	if p == nil || p.ID == 0 {
		return
	}
	snapshot := *p
	InvalidateProducts(snapshot.AgentID)
	if !EmbeddingEnabled() {
		return
	}
	text := productEmbedText(&snapshot)
	if strings.TrimSpace(text) == "" {
		return
	}
	vec, signature, err := embedContextWithSignature(context.Background(), text)
	if err != nil {
		log.Printf("Embedding: gagal embed produk #%d: %v", snapshot.ID, err)
		return
	}
	b, _ := json.Marshal(vec)
	result := matchEmbeddingSnapshotText(database.DB.Model(&snapshot).
		Where("agent_id = ?", snapshot.AgentID), map[string]string{
		"name": snapshot.Name, "product_type": snapshot.ProductType, "price": snapshot.Price,
		"description": snapshot.Description, "details_json": snapshot.DetailsJSON, "knowledge": snapshot.Knowledge,
	}).
		Updates(map[string]any{"embedding": string(b), "embedding_model": signature})
	InvalidateProducts(snapshot.AgentID)
	if result.Error != nil {
		log.Printf("Embedding: gagal simpan embedding produk #%d: %v", snapshot.ID, result.Error)
		return
	}
	if result.RowsAffected == 0 {
		return
	}
	p.Embedding, p.EmbeddingModel, p.UpdatedAt = string(b), signature, snapshot.UpdatedAt
}

// BackfillProductEmbeddings mengisi/re-index embedding katalog produk.
func BackfillProductEmbeddings() {
	if !EmbeddingEnabled() {
		return
	}
	sig := embSignature()
	var rows []models.Product
	database.DB.Where("embedding = '' OR embedding IS NULL OR embedding_model IS NULL OR embedding_model <> ?", sig).Find(&rows)
	if len(rows) == 0 {
		return
	}
	log.Printf("Embedding: backfill/re-index %d produk (signature=%s)...", len(rows), sig)
	for i := range rows {
		IndexProduct(&rows[i])
	}
	log.Println("Embedding: backfill produk selesai")
}
