package services

import (
	"errors"
	"strings"
	"sync/atomic"
	"testing"

	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"wa-assistant/backend/database"
	"wa-assistant/backend/models"
)

func TestKnowledgeIndexRejectsConcurrentContentChange(t *testing.T) {
	db := agenticTestDB(t)
	k := models.Knowledge{AgentID: 1, Question: "Cara retur?", Answer: "Aturan lama", Tags: "retur", Active: true, ReviewStatus: "published"}
	if err := db.Create(&k).Error; err != nil {
		t.Fatal(err)
	}
	useEmbeddingSignatureFixture(t, func() {
		// UpdateColumns deliberately keeps UpdatedAt unchanged: compare the actual
		// embedding input, not merely timestamps that can have limited precision.
		if err := db.Model(&models.Knowledge{}).Where("id = ?", k.ID).UpdateColumns(map[string]any{"answer": "Aturan terbaru", "embedding": "[0,1]", "embedding_model": "fixture-model-a"}).Error; err != nil {
			t.Error(err)
		}
	})
	IndexKnowledge(&k)
	var current models.Knowledge
	if err := db.First(&current, k.ID).Error; err != nil {
		t.Fatal(err)
	}
	if current.Answer != "Aturan terbaru" || current.Embedding != "[0,1]" {
		t.Fatalf("old content index overwrote the current revision: answer=%q embedding=%q", current.Answer, current.Embedding)
	}
	if k.Embedding != "" {
		t.Fatalf("discarded embedding leaked into caller snapshot: %q", k.Embedding)
	}
}

func TestKnowledgeIndexRejectsConcurrentUnpublish(t *testing.T) {
	db := agenticTestDB(t)
	k := models.Knowledge{AgentID: 1, Question: "Cara retur?", Answer: "Hubungi admin", Active: true, ReviewStatus: "published"}
	if err := db.Create(&k).Error; err != nil {
		t.Fatal(err)
	}
	useEmbeddingSignatureFixture(t, func() {
		if err := db.Model(&models.Knowledge{}).Where("id = ?", k.ID).UpdateColumns(map[string]any{"active": false, "review_status": "draft"}).Error; err != nil {
			t.Error(err)
		}
	})
	IndexKnowledge(&k)
	var current models.Knowledge
	if err := db.First(&current, k.ID).Error; err != nil {
		t.Fatal(err)
	}
	if current.Active || current.ReviewStatus != "draft" || current.Embedding != "" {
		t.Fatalf("retired publication received a stale index: active=%v status=%q embedding=%q", current.Active, current.ReviewStatus, current.Embedding)
	}
}

func productConsistencyTestDB(t *testing.T) *gorm.DB {
	t.Helper()
	db := agenticTestDB(t)
	if err := db.AutoMigrate(&models.Product{}); err != nil {
		t.Fatal(err)
	}
	InvalidateProducts(1)
	InvalidateProducts(2)
	t.Cleanup(func() { InvalidateProducts(1); InvalidateProducts(2) })
	return db
}

func TestProductIndexRejectsConcurrentContentChange(t *testing.T) {
	db := productConsistencyTestDB(t)
	p := models.Product{AgentID: 1, Name: "Jaket", Price: "Rp75.000", Knowledge: "Bahan katun"}
	if err := db.Create(&p).Error; err != nil {
		t.Fatal(err)
	}
	useEmbeddingSignatureFixture(t, func() {
		if err := db.Model(&models.Product{}).Where("id = ?", p.ID).UpdateColumns(map[string]any{"price": "Rp80.000", "embedding": "[0,1]", "embedding_model": "fixture-model-a"}).Error; err != nil {
			t.Error(err)
		}
	})
	IndexProduct(&p)
	var current models.Product
	if err := db.First(&current, p.ID).Error; err != nil {
		t.Fatal(err)
	}
	if current.Price != "Rp80.000" || current.Embedding != "[0,1]" {
		t.Fatalf("old product index overwrote the current revision: price=%q embedding=%q", current.Price, current.Embedding)
	}
	if p.Embedding != "" {
		t.Fatalf("discarded embedding leaked into caller snapshot: %q", p.Embedding)
	}
}

func TestEmbeddingIndexPersistsUnchangedContent(t *testing.T) {
	db := productConsistencyTestDB(t)
	useEmbeddingSignatureFixture(t, nil)
	k := models.Knowledge{AgentID: 1, Question: "Cara retur?", Answer: "Hubungi admin", Active: true, ReviewStatus: "published"}
	p := models.Product{AgentID: 1, Name: "Jaket", Price: "Rp75.000"}
	if err := db.Create(&k).Error; err != nil {
		t.Fatal(err)
	}
	if err := db.Create(&p).Error; err != nil {
		t.Fatal(err)
	}
	IndexKnowledge(&k)
	IndexProduct(&p)
	var actualKnowledge models.Knowledge
	var actualProduct models.Product
	if err := db.First(&actualKnowledge, k.ID).Error; err != nil {
		t.Fatal(err)
	}
	if err := db.First(&actualProduct, p.ID).Error; err != nil {
		t.Fatal(err)
	}
	for _, signature := range []string{k.EmbeddingModel, p.EmbeddingModel, actualKnowledge.EmbeddingModel, actualProduct.EmbeddingModel} {
		if signature != "fixture-model-a" {
			t.Errorf("valid index missing request signature: %q", signature)
		}
	}
	for _, vector := range []string{k.Embedding, p.Embedding, actualKnowledge.Embedding, actualProduct.Embedding} {
		if vector != "[1,0]" {
			t.Errorf("valid index not saved: %q", vector)
		}
	}
}

func TestProductCacheRejectsInvalidatedLoad(t *testing.T) {
	db := productConsistencyTestDB(t)
	p := models.Product{AgentID: 1, Name: "Nama lama"}
	if err := db.Create(&p).Error; err != nil {
		t.Fatal(err)
	}
	var once atomic.Bool
	if err := db.Callback().Query().After("gorm:query").Register("fixture:change_product_after_read", func(tx *gorm.DB) {
		if tx.Statement.Table != "products" || !once.CompareAndSwap(false, true) {
			return
		}
		if err := db.Model(&models.Product{}).Where("id = ?", p.ID).UpdateColumn("name", "Nama baru").Error; err != nil {
			t.Error(err)
		}
		InvalidateProducts(1)
	}); err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = db.Callback().Query().Remove("fixture:change_product_after_read") })
	for _, item := range ProductsFor(1) {
		if item.P.Name == "Nama lama" {
			t.Error("invalidated in-flight load returned stale product")
		}
	}
	got := ProductsFor(1)
	if len(got) != 1 || got[0].P.Name != "Nama baru" {
		t.Fatalf("cache lost invalidation during load: %+v", got)
	}
}

func TestProductCacheDoesNotCacheQueryFailure(t *testing.T) {
	db := productConsistencyTestDB(t)
	p := models.Product{AgentID: 1, Name: "Jaket"}
	if err := db.Create(&p).Error; err != nil {
		t.Fatal(err)
	}
	var failOnce atomic.Bool
	if err := db.Callback().Query().Before("gorm:query").Register("fixture:product_query_failure", func(tx *gorm.DB) {
		if tx.Statement.Table == "products" && failOnce.CompareAndSwap(false, true) {
			_ = tx.AddError(errors.New("synthetic transient product read failure"))
		}
	}); err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = db.Callback().Query().Remove("fixture:product_query_failure") })
	if got := ProductsFor(1); len(got) != 0 {
		t.Fatalf("failed load returned data: %+v", got)
	}
	if got := ProductsFor(1); len(got) != 1 || got[0].P.ID != p.ID {
		t.Fatalf("transient failure poisoned the cache as empty: %+v", got)
	}
}

func TestProductCacheInvalidationRemainsAgentScoped(t *testing.T) {
	db := productConsistencyTestDB(t)
	rows := []models.Product{{AgentID: 1, Name: "A"}, {AgentID: 2, Name: "B"}}
	if err := db.Create(&rows).Error; err != nil {
		t.Fatal(err)
	}
	if got := ProductsFor(2); len(got) != 1 || got[0].P.Name != "B" {
		t.Fatal("wrong initial agent scope")
	}
	var once atomic.Bool
	if err := db.Callback().Query().After("gorm:query").Register("fixture:invalidate_other_product_agent", func(tx *gorm.DB) {
		if tx.Statement.Table == "products" && once.CompareAndSwap(false, true) {
			InvalidateProducts(2)
		}
	}); err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = db.Callback().Query().Remove("fixture:invalidate_other_product_agent") })
	if got := ProductsFor(1); len(got) != 1 || got[0].P.Name != "A" {
		t.Fatalf("other agent invalidation affected current load: %+v", got)
	}
	if got := ProductsFor(2); len(got) != 1 || got[0].P.Name != "B" {
		t.Fatalf("cache crossed agent boundaries: %+v", got)
	}
}

func TestEmbeddingSnapshotTextComparisonsAreByteExact(t *testing.T) {
	t.Run("mysql_generated_sql_only", func(t *testing.T) {
		db := productConsistencyTestDB(t)
		sqlDB, err := db.DB()
		if err != nil {
			t.Fatal(err)
		}
		// Use the MySQL SQL builder over an existing SQLite connection. DryRun,
		// no version query, no ping, and no transaction prevent MySQL access.
		mySQLBuilder, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{DryRun: true, DisableAutomaticPing: true, SkipDefaultTransaction: true})
		if err != nil {
			t.Fatal(err)
		}
		var statements []string
		if err := mySQLBuilder.Callback().Update().After("gorm:update").Register("fixture:capture_embedding_sql", func(tx *gorm.DB) {
			statements = append(statements, tx.Statement.SQL.String())
		}); err != nil {
			t.Fatal(err)
		}
		database.DB = mySQLBuilder
		t.Cleanup(func() { database.DB = db })
		useEmbeddingSignatureFixture(t, nil)
		k := models.Knowledge{ID: 1, AgentID: 1, Question: "Cara beli?", Answer: "https://fixture.test/Buy", Active: true, ReviewStatus: "published"}
		p := models.Product{ID: 1, AgentID: 1, Name: "Jaket", Price: "Rp75.000", ProductType: "physical", Knowledge: "https://fixture.test/Buy"}
		IndexKnowledge(&k)
		IndexProduct(&p)
		if len(statements) != 2 {
			t.Fatalf("wanted both indexing SQL statements, got %d", len(statements))
		}
		for i, columns := range [][]string{{"review_status", "question", "answer", "tags"}, {"name", "product_type", "price", "description", "details_json", "knowledge"}} {
			for _, column := range columns {
				predicate := "CAST(COALESCE(`" + column + "`, '') AS BINARY) = CAST(? AS BINARY)"
				if !strings.Contains(statements[i], predicate) {
					t.Errorf("MySQL %s predicate is not byte-exact: %s", column, statements[i])
				}
			}
			if strings.Contains(statements[i], "fixture.test") {
				t.Error("source content was interpolated into SQL instead of a bound argument")
			}
		}
	})
	for _, change := range []struct{ name, before, after string }{
		{"sqlite_case_only", "https://fixture.test/Buy", "https://fixture.test/buy"},
		{"sqlite_trailing_space", "Hubungi admin", "Hubungi admin "},
	} {
		t.Run(change.name, func(t *testing.T) {
			db := agenticTestDB(t)
			k := models.Knowledge{AgentID: 1, Question: "Cara beli?", Answer: change.before, Active: true, ReviewStatus: "published"}
			if err := db.Create(&k).Error; err != nil {
				t.Fatal(err)
			}
			useEmbeddingSignatureFixture(t, func() {
				if err := db.Model(&models.Knowledge{}).Where("id = ?", k.ID).UpdateColumn("answer", change.after).Error; err != nil {
					t.Error(err)
				}
			})
			IndexKnowledge(&k)
			var actual models.Knowledge
			if err := db.First(&actual, k.ID).Error; err != nil {
				t.Fatal(err)
			}
			if actual.Answer != change.after || actual.Embedding != "" {
				t.Fatalf("byte change not respected: answer=%q embedding=%q", actual.Answer, actual.Embedding)
			}
		})
	}
	t.Run("sqlite_legacy_null_equals_empty_snapshot", func(t *testing.T) {
		db := agenticTestDB(t)
		k := models.Knowledge{AgentID: 1, Question: "Cara beli?", Active: true, ReviewStatus: "published"}
		if err := db.Create(&k).Error; err != nil {
			t.Fatal(err)
		}
		if err := db.Model(&models.Knowledge{}).Where("id = ?", k.ID).UpdateColumn("answer", nil).Error; err != nil {
			t.Fatal(err)
		}
		useEmbeddingSignatureFixture(t, nil)
		IndexKnowledge(&k)
		if k.Embedding != "[1,0]" {
			t.Fatalf("NULL legacy text did not match its empty snapshot: %q", k.Embedding)
		}
	})
}
