package backend

import (
	"context"
	"database/sql"
	"fmt"
	"strings"
	"testing"
	"time"

	"github.com/mem9-ai/drive9/pkg/datastore"
)

func TestPrepareMediaExtractWritebackParsesAttributesAndPreservesPlainText(t *testing.T) {
	structured := `{"text":"spoken summary","attributes":{"topic":"meeting","count":3,"enabled":true}}`
	writeback := prepareMediaExtractWriteback(structured, 32<<10)
	if writeback.text != "spoken summary" {
		t.Fatalf("text=%q, want spoken summary", writeback.text)
	}
	want := map[string]string{"topic": "meeting", "count": "3", "enabled": "true"}
	if len(writeback.tags) != len(want) {
		t.Fatalf("tags=%+v, want %+v", writeback.tags, want)
	}
	for key, value := range want {
		if writeback.tags[key] != value {
			t.Fatalf("tag %q=%q, want %q", key, writeback.tags[key], value)
		}
	}

	plain := "ordinary transcript without JSON"
	plainWriteback := prepareMediaExtractWriteback(plain, 32<<10)
	if plainWriteback.text != plain || len(plainWriteback.tags) != 0 {
		t.Fatalf("plain writeback=%+v, want unchanged text and no tags", plainWriteback)
	}
	textOnly := prepareMediaExtractWriteback(`{"text":"text-only response"}`, 32<<10)
	if textOnly.text != "text-only response" || len(textOnly.tags) != 0 {
		t.Fatalf("text-only writeback=%+v, want extracted text and no tags", textOnly)
	}
}

func TestPrepareMediaExtractWritebackAppliesImageAttributeRules(t *testing.T) {
	var builder strings.Builder
	builder.WriteString(`{"text":"summary","attributes":{"ok":"yes","drive9.secret":"drop","bad":null`)
	for i := 0; i < maxImageExtractAttributes+5; i++ {
		builder.WriteString(`,"key` + strings.Repeat("x", i) + `":"value"`)
	}
	builder.WriteString("}}")
	writeback := prepareMediaExtractWriteback(builder.String(), 32<<10)
	if writeback.tags["drive9.secret"] != "" {
		t.Fatalf("reserved attribute survived: %+v", writeback.tags)
	}
	if len(writeback.tags) > maxImageExtractAttributes {
		t.Fatalf("attribute count=%d, want <=%d", len(writeback.tags), maxImageExtractAttributes)
	}
}

func TestCloneMediaExtractTagsReturnsWritableEmptyMap(t *testing.T) {
	tags := cloneMediaExtractTags(nil)
	tags["topic"] = "meeting"
	if tags["topic"] != "meeting" {
		t.Fatalf("tags=%+v, want writable clone", tags)
	}
}

func TestMergeVideoExtractTagsCapsKeysAndAudioWins(t *testing.T) {
	visual := map[string]string{"00-shared": "visual"}
	audio := map[string]string{"00-shared": "audio"}
	for i := 0; i < maxImageExtractAttributes; i++ {
		visual[fmt.Sprintf("visual-%02d", i)] = "value"
		audio[fmt.Sprintf("audio-%02d", i)] = "value"
	}
	tags := mergeVideoExtractTags(visual, audio)
	if len(tags) != maxImageExtractAttributes {
		t.Fatalf("tag count=%d, want %d", len(tags), maxImageExtractAttributes)
	}
	if tags["00-shared"] != "audio" {
		t.Fatalf("shared tag=%q, want deterministic audio precedence", tags["00-shared"])
	}
}

func TestMergeMediaExtractTagsReportsOnlyUniqueKeyLimit(t *testing.T) {
	current := make(map[string]string, maxImageExtractAttributes)
	for i := 0; i < maxImageExtractAttributes; i++ {
		current[fmt.Sprintf("key-%02d", i)] = "current"
	}
	_, limited := mergeMediaExtractTagsWithLimit(current, map[string]string{"key-00": "updated"})
	if limited {
		t.Fatal("overlapping update must not be reported as an attribute-limit skip")
	}
	_, limited = mergeMediaExtractTagsWithLimit(current, map[string]string{"new-key": "value"})
	if !limited {
		t.Fatal("new key above the attribute limit must be reported as skipped")
	}
}

func TestProcessAudioExtractTaskWritesAndFindsGeneratedTags(t *testing.T) {
	b := newTestBackendWithOptions(t, Options{DatabaseAutoEmbedding: true})
	fileID := insertImageFileForExtractTest(t, b, "/audio/tags.mp3", "audio/mpeg", []byte{1, 2, 3})
	if err := b.Store().InTx(context.Background(), func(tx *sql.Tx) error {
		if err := b.Store().ReplaceFileTagsTx(tx, fileID, map[string]string{"topic": "user-value", "album": "voice"}); err != nil {
			return err
		}
		_, err := b.Store().InsertFileTagsWithSourceTx(tx, fileID, datastore.TagSourceAudioExtract, map[string]string{"old": "stale"})
		return err
	}); err != nil {
		t.Fatal(err)
	}

	result, err := b.ProcessAudioExtractTaskWithRuntime(context.Background(), AudioExtractTaskSpec{
		FileID: fileID, Path: "/audio/tags.mp3", ContentType: "audio/mpeg", Revision: 1,
	}, AudioExtractRuntime{
		Extractor:     &staticAudioExtractor{text: `{"text":"meeting transcript","attributes":{"topic":"model-value","language":"en","mood":"focused"}}`},
		MaxAudioBytes: 8 << 20, MaxExtractTextBytes: DefaultAudioExtractMaxTextBytes,
	})
	if err != nil || result != AudioExtractResultWritten {
		t.Fatalf("result=%q err=%v, want written", result, err)
	}
	tags, err := b.Store().GetFileTags(context.Background(), fileID)
	if err != nil {
		t.Fatal(err)
	}
	if tags["topic"] != "user-value" || tags["language"] != "en" || tags["mood"] != "focused" || tags["album"] != "voice" {
		t.Fatalf("tags=%+v, want user-wins plus generated tags", tags)
	}
	if _, ok := tags["old"]; ok {
		t.Fatalf("stale audio generated tag was not removed: %+v", tags)
	}

	results, err := b.Find(context.Background(), &datastore.FindFilter{TagKey: "language", TagValue: "en", Limit: 10})
	if err != nil {
		t.Fatal(err)
	}
	if len(results) != 1 || results[0].Path != "/audio/tags.mp3" {
		t.Fatalf("find results=%+v, want generated-tagged audio file", results)
	}

	// A successful structured response with empty attributes clears the old
	// audio-generated rows while preserving explicit user tags.
	result, err = b.ProcessAudioExtractTaskWithRuntime(context.Background(), AudioExtractTaskSpec{
		FileID: fileID, Path: "/audio/tags.mp3", ContentType: "audio/mpeg", Revision: 1,
	}, AudioExtractRuntime{
		Extractor:     &staticAudioExtractor{text: `{"text":"","attributes":{}}`},
		MaxAudioBytes: 8 << 20, MaxExtractTextBytes: DefaultAudioExtractMaxTextBytes,
	})
	if err != nil || result != AudioExtractResultWritten {
		t.Fatalf("empty-attributes result=%q err=%v, want written", result, err)
	}
	tags, err = b.Store().GetFileTags(context.Background(), fileID)
	if err != nil {
		t.Fatal(err)
	}
	if tags["topic"] != "user-value" || tags["album"] != "voice" {
		t.Fatalf("tags after empty attributes=%+v, want only user tags", tags)
	}
	if _, ok := tags["language"]; ok {
		t.Fatalf("generated language tag survived empty attributes: %+v", tags)
	}
}

func TestProcessAudioExtractTaskClearsGeneratedTagsAfterSilentSuccess(t *testing.T) {
	b := newTestBackendWithOptions(t, Options{})
	fileID := insertImageFileForExtractTest(t, b, "/audio/silent.mp3", "audio/mpeg", []byte{1, 2, 3})
	if updated, err := b.Store().UpdateFileSearchText(context.Background(), fileID, 1, "old transcript"); err != nil || !updated {
		t.Fatalf("seed content_text updated=%v err=%v", updated, err)
	}
	setStoredEmbeddingState(t, b, fileID, 1)
	if err := b.Store().InTx(context.Background(), func(tx *sql.Tx) error {
		if err := b.Store().ReplaceFileTagsTx(tx, fileID, map[string]string{"owner": "user"}); err != nil {
			return err
		}
		_, err := b.Store().InsertFileTagsWithSourceTx(tx, fileID, datastore.TagSourceAudioExtract, map[string]string{"old": "stale"})
		return err
	}); err != nil {
		t.Fatal(err)
	}

	result, err := b.ProcessAudioExtractTaskWithRuntime(context.Background(), AudioExtractTaskSpec{
		FileID: fileID, Path: "/audio/silent.mp3", ContentType: "audio/mpeg", Revision: 1,
	}, AudioExtractRuntime{
		Extractor: &staticAudioExtractor{err: errAudioExtractEmptyText}, MaxAudioBytes: 8 << 20,
		MaxExtractTextBytes: DefaultAudioExtractMaxTextBytes,
	})
	if err != nil || result != AudioExtractResultEmptyText {
		t.Fatalf("result=%q err=%v, want empty_text", result, err)
	}
	tags, err := b.Store().GetFileTags(context.Background(), fileID)
	if err != nil {
		t.Fatal(err)
	}
	if _, ok := tags["old"]; ok {
		t.Fatalf("stale audio tag survived silent success: %+v", tags)
	}
	if tags["owner"] != "user" {
		t.Fatalf("user tag changed during silent cleanup: %+v", tags)
	}
	assertContentEmbeddingCleared(t, b, fileID)
}

func TestProcessVideoExtractTaskWritesGeneratedTags(t *testing.T) {
	b := newTestBackendWithOptions(t, Options{DatabaseAutoEmbedding: true})
	fileID := insertImageFileForExtractTest(t, b, "/video/tags.mp4", "video/mp4", []byte("video"))
	if err := b.Store().InTx(context.Background(), func(tx *sql.Tx) error {
		return b.Store().ReplaceFileTagsTx(tx, fileID, map[string]string{"scene": "user-scene"})
	}); err != nil {
		t.Fatal(err)
	}

	result, err := b.ProcessVideoExtractTaskWithRuntime(context.Background(), VideoExtractTaskSpec{
		FileID: fileID, Path: "/video/tags.mp4", ContentType: "video/mp4", Revision: 1,
	}, VideoExtractRuntime{
		Extractor:      &staticVideoExtractor{text: `{"caption_en":"A city street updated","attributes":{"scene":"city street","weather":"sunny"}}`},
		MaxSourceBytes: 1 << 20, MaxExtractTextBytes: DefaultVideoExtractMaxTextBytes,
	})
	if err != nil || result != VideoExtractResultWritten {
		t.Fatalf("result=%q err=%v, want written", result, err)
	}
	tags, err := b.Store().GetFileTags(context.Background(), fileID)
	if err != nil {
		t.Fatal(err)
	}
	if tags["scene"] != "user-scene" || tags["weather"] != "sunny" {
		t.Fatalf("tags=%+v, want user-wins plus generated video tags", tags)
	}
	if err := b.Store().InTx(context.Background(), func(tx *sql.Tx) error {
		_, err := b.Store().InsertFileTagsWithSourceTx(tx, fileID, datastore.TagSourceVideoExtract, map[string]string{"stale": "value"})
		return err
	}); err != nil {
		t.Fatal(err)
	}
	if _, err := b.ProcessVideoExtractTaskWithRuntime(context.Background(), VideoExtractTaskSpec{
		FileID: fileID, Path: "/video/tags.mp4", ContentType: "video/mp4", Revision: 1,
	}, VideoExtractRuntime{
		Extractor:      &staticVideoExtractor{text: `{"caption_en":"A city street","attributes":{"scene":"city street","weather":"sunny"}}`},
		MaxSourceBytes: 1 << 20, MaxExtractTextBytes: DefaultVideoExtractMaxTextBytes,
	}); err != nil {
		t.Fatal(err)
	}
	tags, err = b.Store().GetFileTags(context.Background(), fileID)
	if err != nil {
		t.Fatal(err)
	}
	if _, ok := tags["stale"]; ok {
		t.Fatalf("stale video generated tag was not removed: %+v", tags)
	}
	contentText := waitForContentText(t, b, "/video/tags.mp4", time.Second)
	if !strings.Contains(contentText, "城市") && !strings.Contains(contentText, "city") {
		t.Fatalf("content_text does not contain structured visual output")
	}
}

func TestProcessVideoExtractTaskClearsGeneratedTagsAfterEmptySuccess(t *testing.T) {
	b := newTestBackendWithOptions(t, Options{})
	fileID := insertImageFileForExtractTest(t, b, "/video/empty.mp4", "video/mp4", []byte("video"))
	if updated, err := b.Store().UpdateFileSearchText(context.Background(), fileID, 1, "old summary"); err != nil || !updated {
		t.Fatalf("seed content_text updated=%v err=%v", updated, err)
	}
	setStoredEmbeddingState(t, b, fileID, 1)
	if err := b.Store().InTx(context.Background(), func(tx *sql.Tx) error {
		if err := b.Store().ReplaceFileTagsTx(tx, fileID, map[string]string{"owner": "user"}); err != nil {
			return err
		}
		_, err := b.Store().InsertFileTagsWithSourceTx(tx, fileID, datastore.TagSourceVideoExtract, map[string]string{"old": "stale"})
		return err
	}); err != nil {
		t.Fatal(err)
	}

	result, err := b.ProcessVideoExtractTaskWithRuntime(context.Background(), VideoExtractTaskSpec{
		FileID: fileID, Path: "/video/empty.mp4", ContentType: "video/mp4", Revision: 1,
	}, VideoExtractRuntime{
		Extractor: &staticVideoExtractor{}, MaxSourceBytes: 1 << 20,
		MaxExtractTextBytes: DefaultVideoExtractMaxTextBytes,
	})
	if err != nil || result != VideoExtractResultEmptyText {
		t.Fatalf("result=%q err=%v, want empty_text", result, err)
	}
	tags, err := b.Store().GetFileTags(context.Background(), fileID)
	if err != nil {
		t.Fatal(err)
	}
	if _, ok := tags["old"]; ok {
		t.Fatalf("stale video tag survived empty success: %+v", tags)
	}
	if tags["owner"] != "user" {
		t.Fatalf("user tag changed during empty video cleanup: %+v", tags)
	}
	assertContentEmbeddingCleared(t, b, fileID)
}

func assertContentEmbeddingCleared(t *testing.T, b *Dat9Backend, fileID string) {
	t.Helper()
	var legacyText string
	var legacyEmbeddingNull, legacyRevisionNull bool
	if err := b.Store().DB().QueryRow(`SELECT COALESCE(content_text, ''), embedding IS NULL, embedding_revision IS NULL FROM files WHERE file_id = ?`, fileID).
		Scan(&legacyText, &legacyEmbeddingNull, &legacyRevisionNull); err != nil {
		t.Fatal(err)
	}
	var semanticText string
	var semanticEmbeddingNull, semanticRevisionNull bool
	if err := b.Store().DB().QueryRow(`SELECT COALESCE(content_text, ''), embedding IS NULL, embedding_revision IS NULL FROM semantic WHERE inode_id = ?`, fileID).
		Scan(&semanticText, &semanticEmbeddingNull, &semanticRevisionNull); err != nil {
		t.Fatal(err)
	}
	if legacyText != "" || semanticText != "" || !legacyEmbeddingNull || !legacyRevisionNull || !semanticEmbeddingNull || !semanticRevisionNull {
		t.Fatalf("content embedding state not cleared: files=(%q,%v,%v) semantic=(%q,%v,%v)",
			legacyText, legacyEmbeddingNull, legacyRevisionNull, semanticText, semanticEmbeddingNull, semanticRevisionNull)
	}
}

func TestProcessVideoExtractTaskUsesStableAudioTagPrecedence(t *testing.T) {
	b := newTestBackendWithOptions(t, Options{DatabaseAutoEmbedding: true})
	fileID := insertImageFileForExtractTest(t, b, "/video/precedence.mp4", "video/mp4", []byte("video"))
	result, err := b.ProcessVideoExtractTaskWithRuntime(context.Background(), VideoExtractTaskSpec{
		FileID: fileID, Path: "/video/precedence.mp4", ContentType: "video/mp4", Revision: 1,
	}, VideoExtractRuntime{
		Extractor:      &staticVideoExtractor{text: `{"caption_en":"visual","attributes":{"topic":"visual"}}`},
		MaxSourceBytes: 1 << 20, MaxExtractTextBytes: DefaultVideoExtractMaxTextBytes,
		CheckpointState: VideoExtractCheckpoint{
			AudioDone: true, AudioPresent: true, AudioText: "speech",
			AudioTags: map[string]string{"topic": "audio"},
		},
		AudioRuntime: &AudioExtractRuntime{Extractor: &staticAudioExtractor{text: "must not run"}},
	})
	if err != nil || result != VideoExtractResultWritten {
		t.Fatalf("result=%q err=%v, want written", result, err)
	}
	tags, err := b.Store().GetFileTags(context.Background(), fileID)
	if err != nil {
		t.Fatal(err)
	}
	if tags["topic"] != "audio" {
		t.Fatalf("tags=%+v, want audio to win regardless of completion order", tags)
	}
}
