package backend

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/c4pt0r/agfs/agfs-server/pkg/filesystem"
	"github.com/mem9-ai/drive9/pkg/datastore"
	"github.com/mem9-ai/drive9/pkg/metrics"
)

type failOnceAudioExtractor struct {
	calls  int
	failAt int
}

type emptyThenTextAudioExtractor struct {
	calls int
}

type emptyThenFailOnceAudioExtractor struct {
	calls int
}

type largeAudioExtractor struct {
	calls int
}

type meteredFailOnceAudioExtractor struct {
	calls int
}

type sequenceAudioExtractor struct {
	responses []string
	errs      []error
	calls     int
}

func (e *sequenceAudioExtractor) ExtractAudioText(_ context.Context, _ AudioExtractRequest) (string, AudioExtractUsage, error) {
	index := e.calls
	e.calls++
	var response string
	if index < len(e.responses) {
		response = e.responses[index]
	}
	if index < len(e.errs) {
		return response, AudioExtractUsage{}, e.errs[index]
	}
	return response, AudioExtractUsage{}, nil
}

func (e *emptyThenTextAudioExtractor) ExtractAudioText(_ context.Context, _ AudioExtractRequest) (string, AudioExtractUsage, error) {
	e.calls++
	if e.calls == 1 {
		return "", AudioExtractUsage{}, errAudioExtractEmptyText
	}
	return fmt.Sprintf("segment call %d", e.calls), AudioExtractUsage{}, nil
}

func (e *emptyThenFailOnceAudioExtractor) ExtractAudioText(_ context.Context, _ AudioExtractRequest) (string, AudioExtractUsage, error) {
	e.calls++
	switch e.calls {
	case 1:
		return "", AudioExtractUsage{}, errAudioExtractEmptyText
	case 2:
		return "", AudioExtractUsage{}, errors.New("temporary provider failure")
	default:
		return fmt.Sprintf("segment call %d", e.calls), AudioExtractUsage{}, nil
	}
}

func (e *failOnceAudioExtractor) ExtractAudioText(_ context.Context, _ AudioExtractRequest) (string, AudioExtractUsage, error) {
	e.calls++
	if e.calls == e.failAt {
		return "", AudioExtractUsage{}, errors.New("temporary provider failure")
	}
	return fmt.Sprintf("segment call %d", e.calls), AudioExtractUsage{}, nil
}

func (e *largeAudioExtractor) ExtractAudioText(_ context.Context, _ AudioExtractRequest) (string, AudioExtractUsage, error) {
	e.calls++
	return fmt.Sprintf("segment-%d-%s", e.calls, strings.Repeat(string(rune('a'+e.calls-1)), 60)), AudioExtractUsage{}, nil
}

func (e *meteredFailOnceAudioExtractor) ExtractAudioText(_ context.Context, _ AudioExtractRequest) (string, AudioExtractUsage, error) {
	e.calls++
	if e.calls == 2 {
		return "", AudioExtractUsage{}, errors.New("temporary provider failure")
	}
	return fmt.Sprintf("segment call %d", e.calls), AudioExtractUsage{DurationSeconds: 60}, nil
}

func installAudioFFmpegSegmentStub(t *testing.T, segmentCount int) {
	t.Helper()
	binDir := t.TempDir()
	script := fmt.Sprintf(`#!/bin/sh
out=""
for arg in "$@"; do out="$arg"; done
i=0
while [ "$i" -lt %d ]; do
  suffix=$(printf '%%03d' "$i")
  path=$(printf '%%s' "$out" | sed "s/%%03d/$suffix/")
  printf x > "$path"
  i=$((i + 1))
done
`, segmentCount)
	if err := os.WriteFile(filepath.Join(binDir, "ffmpeg"), []byte(script), 0o700); err != nil {
		t.Fatal(err)
	}
	t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
	installAudioFFprobeStub(t)
}

func installAudioFFprobeStub(t *testing.T) {
	t.Helper()
	binDir := t.TempDir()
	if err := os.WriteFile(filepath.Join(binDir, "ffprobe"), []byte("#!/bin/sh\nprintf '60\\n'\n"), 0o700); err != nil {
		t.Fatal(err)
	}
	t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
}

func TestValidateAudioProviderRejectsLoopbackWithDefaultClient(t *testing.T) {
	err := ValidateAudioProvider(context.Background(), MediaProviderProtocolOpenAI, AudioTextExtractorConfig{
		BaseURL: "https://127.0.0.1:9443", APIKey: "secret", Model: "whisper-1", Timeout: time.Second,
	})
	if err == nil || !errors.Is(err, errTenantExtractorEndpointPolicy) {
		t.Fatalf("ValidateAudioProvider error = %v, want endpoint policy rejection", err)
	}
	var netErr net.Error
	if errors.As(err, &netErr) && netErr.Timeout() {
		t.Fatalf("loopback validation timed out instead of being rejected: %v", err)
	}
}

func TestValidateAudioProviderAcceptsSuccessfulEmptyTranscript(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{"text":""}`))
	}))
	defer server.Close()
	err := ValidateAudioProvider(context.Background(), MediaProviderProtocolOpenAI, AudioTextExtractorConfig{
		BaseURL: server.URL, APIKey: "key", Model: "whisper-1", Client: server.Client(),
	})
	if err != nil {
		t.Fatalf("ValidateAudioProvider empty transcript error=%v, want success", err)
	}
}

func TestAudioExtractorEmptyTranscriptUsesTypedSentinel(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{"text":""}`))
	}))
	defer server.Close()
	extractor, err := NewOpenAIAudioTextExtractor(OpenAIAudioTextExtractorConfig{
		BaseURL: server.URL, APIKey: "key", Model: "whisper-1", Client: server.Client(),
	})
	if err != nil {
		t.Fatal(err)
	}
	_, _, err = extractor.ExtractAudioText(context.Background(), AudioExtractRequest{})
	if !errors.Is(err, errAudioExtractEmptyText) {
		t.Fatalf("empty transcript error=%v, want errAudioExtractEmptyText", err)
	}
}

func TestResumeAudioCheckpointRejectsMismatchedPrefix(t *testing.T) {
	start, text := resumeAudioCheckpoint(AudioExtractCheckpoint{CompletedSegments: 4, Text: "stale prefix"}, 3)
	if start != 0 || text != "" {
		t.Fatalf("resume=(%d, %q), want reset checkpoint", start, text)
	}
	start, text = resumeAudioCheckpoint(AudioExtractCheckpoint{CompletedSegments: 2, Text: "valid prefix"}, 3)
	if start != 2 || text != "valid prefix" {
		t.Fatalf("resume=(%d, %q), want valid checkpoint", start, text)
	}
	start, text = resumeAudioCheckpoint(AudioExtractCheckpoint{CompletedSegments: 1}, 3)
	if start != 1 || text != "" {
		t.Fatalf("silent resume=(%d, %q), want completed silent segment", start, text)
	}
}

func TestCompleteAudioCheckpointAdvancesWithJSONBoundedPrefix(t *testing.T) {
	full := strings.Repeat("x", audioCheckpointTextMaxJSONBytes+1)
	checkpoint := completeAudioCheckpoint(3, full, audioCheckpointTextMaxJSONBytes)
	if checkpoint.CompletedSegments != 3 || len(checkpoint.Text) != audioCheckpointTextMaxJSONBytes-2 {
		t.Fatalf("oversized checkpoint = %+v; want progress with JSON-bounded text", checkpoint)
	}
	checkpoint = completeAudioCheckpoint(2, "complete prefix", audioCheckpointTextMaxJSONBytes)
	if checkpoint.CompletedSegments != 2 || checkpoint.Text != "complete prefix" {
		t.Fatalf("bounded checkpoint = %+v", checkpoint)
	}
}

func TestBoundCheckpointTextAccountsForJSONEscapingAndUTF8(t *testing.T) {
	const budget = 128
	text := strings.Repeat("\x00", 100) + "\u754c"
	bounded := boundCheckpointText(text, budget)
	encoded, err := json.Marshal(bounded)
	if err != nil {
		t.Fatal(err)
	}
	if len(encoded) > budget {
		t.Fatalf("encoded checkpoint bytes=%d, want <= %d", len(encoded), budget)
	}
	if !strings.HasPrefix(text, bounded) {
		t.Fatalf("bounded text is not an exact prefix")
	}
}

func TestCheckpointAudioProgressDoesNotAdvancePastDurableTextBudget(t *testing.T) {
	var combined strings.Builder
	combined.WriteString(strings.Repeat("\x00", 100))
	called := false
	runtime := AudioExtractRuntime{
		CheckpointTextMaxJSONBytes: 128,
		Checkpoint: func(next AudioExtractCheckpoint) error {
			called = true
			return nil
		},
	}
	if err := (&Dat9Backend{}).checkpointAudioProgress("audio_extract", runtime, 3, &combined, nil, false); err != nil {
		t.Fatal(err)
	}
	if called {
		t.Fatal("oversized transcript advanced the durable cursor")
	}
	if got := combined.String(); got != strings.Repeat("\x00", 100) {
		t.Fatalf("live transcript was truncated to %d bytes", len(got))
	}
}

func TestCheckpointAudioProgressDoesNotFailWhenFullPayloadIsTooLarge(t *testing.T) {
	var combined strings.Builder
	combined.WriteString("complete segment")
	runtime := AudioExtractRuntime{
		Checkpoint: func(AudioExtractCheckpoint) error {
			return datastore.ErrSemanticTaskPayloadTooLarge
		},
	}
	if err := (&Dat9Backend{}).checkpointAudioProgress("audio_extract", runtime, 1, &combined, nil, false); err != nil {
		t.Fatalf("checkpoint error=%v, want oversized payload to keep the previous cursor", err)
	}
}

func TestExtractAudioDataKeepsFullOutputBeyondCheckpointBudget(t *testing.T) {
	installAudioFFmpegSegmentStub(t, 3)
	workspace, err := NewMediaExtractWorkspace(MediaExtractWorkspaceOptions{RootDir: t.TempDir()})
	if err != nil {
		t.Fatal(err)
	}
	taskWorkspace, err := workspace.NewTask(context.Background(), "large-transcript")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = taskWorkspace.Close() })
	sourcePath := filepath.Join(taskWorkspace.Dir(), "source.wav")
	if err := os.WriteFile(sourcePath, make([]byte, 100<<10), 0o600); err != nil {
		t.Fatal(err)
	}
	extractor := &largeAudioExtractor{}
	var checkpoint AudioExtractCheckpoint
	runtime := AudioExtractRuntime{
		Extractor: extractor, MaxAudioBytes: 40 << 10, MaxExtractTextBytes: DefaultAudioExtractMaxTextBytes,
		CheckpointTextMaxJSONBytes: 128,
		Checkpoint: func(next AudioExtractCheckpoint) error {
			checkpoint = next
			return nil
		},
	}
	b := newTestBackendWithOptions(t, Options{DatabaseAutoEmbedding: true})
	text, _, _, _, err := b.extractAudioDataWithTags(context.Background(), AudioExtractTaskSpec{Path: "/large.wav"}, "audio/wav", nil, sourcePath, taskWorkspace, runtime)
	if err != nil {
		t.Fatal(err)
	}
	if len(text) <= runtime.CheckpointTextMaxJSONBytes || !strings.Contains(text, "segment-1-") || !strings.Contains(text, "segment-3-") {
		t.Fatalf("full transcript len=%d text=%q, want all segments beyond checkpoint budget", len(text), text)
	}
	if checkpoint.CompletedSegments >= 3 {
		t.Fatalf("checkpoint=%+v advanced beyond recoverable durable text", checkpoint)
	}
}

func TestProcessAudioExtractTaskTimeoutBoundsFFmpeg(t *testing.T) {
	binDir := t.TempDir()
	if err := os.WriteFile(filepath.Join(binDir, "ffmpeg"), []byte("#!/bin/sh\nwhile :; do sleep 1; done\n"), 0o700); err != nil {
		t.Fatal(err)
	}
	t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
	installAudioFFprobeStub(t)
	workspace, err := NewMediaExtractWorkspace(MediaExtractWorkspaceOptions{RootDir: t.TempDir()})
	if err != nil {
		t.Fatal(err)
	}
	b := newTestBackendWithOptions(t, Options{DatabaseAutoEmbedding: true})
	fileID := insertImageFileForExtractTest(t, b, "/rec/blocked.wav", "audio/wav", make([]byte, 100<<10))
	start := time.Now()
	result, err := b.ProcessAudioExtractTaskWithRuntime(context.Background(), AudioExtractTaskSpec{
		FileID: fileID, Path: "/rec/blocked.wav", ContentType: "audio/wav", Revision: 1,
	}, AudioExtractRuntime{
		Extractor: &staticAudioExtractor{text: "unused"}, Timeout: 500 * time.Millisecond,
		MaxAudioBytes: 40 << 10, MaxExtractTextBytes: DefaultAudioExtractMaxTextBytes, Workspace: workspace,
	})
	if result != AudioExtractResultExtractError || !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("result=%q err=%v, want extract_error deadline exceeded", result, err)
	}
	if elapsed := time.Since(start); elapsed > 2*time.Second {
		t.Fatalf("ffmpeg timeout took %v", elapsed)
	}
}

func TestAudioFFmpegArgsBoundDurationSegmentsAndDerivedFormat(t *testing.T) {
	args := audioFFmpegArgs("input.m4a", "segment_%03d.mp3", 7<<20)
	joined := strings.Join(args, " ")
	if strings.Contains(joined, "-t ") {
		t.Fatalf("args=%q must not silently truncate audio duration", joined)
	}
	for _, want := range []string{"-c:a libmp3lame", "-b:a 32k", "segment_%03d.mp3"} {
		if !strings.Contains(joined, want) {
			t.Fatalf("args=%q missing %q", joined, want)
		}
	}
}

func TestProcessAudioExtractTaskUsesRuntimeSourceLimitAboveLegacyCap(t *testing.T) {
	b := newTestBackendWithOptions(t, Options{DatabaseAutoEmbedding: true})
	fileID := insertImageFileForExtractTest(t, b, "/rec/large-source.mp3", "audio/mpeg", []byte{1})
	const sourceSize = int64(201 << 20)
	if _, err := b.Store().DB().Exec(`UPDATE inodes SET size_bytes = ? WHERE inode_id = ?`, sourceSize, fileID); err != nil {
		t.Fatal(err)
	}

	result, err := b.ProcessAudioExtractTaskWithRuntime(context.Background(), AudioExtractTaskSpec{
		FileID: fileID, Path: "/rec/large-source.mp3", ContentType: "audio/mpeg", Revision: 1,
	}, AudioExtractRuntime{
		Extractor:      &staticAudioExtractor{text: "large source accepted"},
		MaxSourceBytes: 300 << 20,
		MaxAudioBytes:  300 << 20,
	})
	if err != nil {
		t.Fatal(err)
	}
	if result != AudioExtractResultWritten {
		t.Fatalf("result=%q, want %q", result, AudioExtractResultWritten)
	}
}

func TestProcessAudioExtractTaskRejectsSourceAboveRuntimeLimit(t *testing.T) {
	b := newTestBackendWithOptions(t, Options{DatabaseAutoEmbedding: true})
	fileID := insertImageFileForExtractTest(t, b, "/rec/oversize-source.mp3", "audio/mpeg", []byte{1})
	if _, err := b.Store().DB().Exec(`UPDATE inodes SET size_bytes = ? WHERE inode_id = ?`, 101, fileID); err != nil {
		t.Fatal(err)
	}

	result, err := b.ProcessAudioExtractTaskWithRuntime(context.Background(), AudioExtractTaskSpec{
		FileID: fileID, Path: "/rec/oversize-source.mp3", ContentType: "audio/mpeg", Revision: 1,
	}, AudioExtractRuntime{
		Extractor:      &staticAudioExtractor{text: "must not run"},
		MaxSourceBytes: 100,
		MaxAudioBytes:  100,
	})
	if err != nil {
		t.Fatal(err)
	}
	if result != AudioExtractResultTooLarge {
		t.Fatalf("result=%q, want %q", result, AudioExtractResultTooLarge)
	}
}

func TestProcessAudioExtractTaskWithoutWorkspaceUsesProviderScaleMemoryLimit(t *testing.T) {
	b := newTestBackendWithS3AndOptions(t, Options{DatabaseAutoEmbedding: true})
	fileID := insertS3ImageFileForExtractTest(t, b, "/rec/no-workspace.mp3", "audio/mpeg", []byte{1})
	if _, err := b.Store().DB().Exec(`UPDATE inodes SET size_bytes = ? WHERE inode_id = ?`, (1<<20)+1, fileID); err != nil {
		t.Fatal(err)
	}
	extractor := &sequenceAudioExtractor{responses: []string{"must not run"}}
	result, err := b.ProcessAudioExtractTaskWithRuntime(context.Background(), AudioExtractTaskSpec{
		FileID: fileID, Path: "/rec/no-workspace.mp3", ContentType: "audio/mpeg", Revision: 1,
	}, AudioExtractRuntime{
		Extractor: extractor, MaxSourceBytes: 1 << 30, MaxAudioBytes: 1 << 20,
	})
	if result != AudioExtractResultLoadError || !errors.Is(err, ErrMediaExtractWorkspaceRequired) || extractor.calls != 0 {
		t.Fatalf("result=%q err=%v extractor calls=%d, want retryable workspace-required without provider call", result, err, extractor.calls)
	}
}

func TestProcessAudioExtractTaskRejectsDurationOverTwoHours(t *testing.T) {
	binDir := t.TempDir()
	if err := os.WriteFile(filepath.Join(binDir, "ffprobe"), []byte("#!/bin/sh\nprintf '7201\\n'\n"), 0o700); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(binDir, "ffmpeg"), []byte("#!/bin/sh\nexit 99\n"), 0o700); err != nil {
		t.Fatal(err)
	}
	t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
	workspace, err := NewMediaExtractWorkspace(MediaExtractWorkspaceOptions{RootDir: t.TempDir()})
	if err != nil {
		t.Fatal(err)
	}
	b := newTestBackendWithOptions(t, Options{DatabaseAutoEmbedding: true})
	fileID := insertImageFileForExtractTest(t, b, "/rec/too-long.mp3", "audio/mpeg", make([]byte, 1024))

	result, err := b.ProcessAudioExtractTaskWithRuntime(context.Background(), AudioExtractTaskSpec{
		FileID: fileID, Path: "/rec/too-long.mp3", ContentType: "audio/mpeg", Revision: 1,
	}, AudioExtractRuntime{
		Extractor:      &staticAudioExtractor{text: "must not run"},
		MaxSourceBytes: 1 << 20,
		MaxAudioBytes:  512,
		Workspace:      workspace,
	})
	if err != nil {
		t.Fatal(err)
	}
	if result != AudioExtractResultTooLong {
		t.Fatalf("result=%q, want %q", result, AudioExtractResultTooLong)
	}
}

func TestProcessAudioExtractTaskFailsClosedWhenDurationProbeFails(t *testing.T) {
	binDir := t.TempDir()
	if err := os.WriteFile(filepath.Join(binDir, "ffprobe"), []byte("#!/bin/sh\nexit 42\n"), 0o700); err != nil {
		t.Fatal(err)
	}
	t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
	workspace, err := NewMediaExtractWorkspace(MediaExtractWorkspaceOptions{RootDir: t.TempDir()})
	if err != nil {
		t.Fatal(err)
	}

	for _, tc := range []struct {
		name          string
		providerLimit int64
	}{
		{name: "direct", providerLimit: 2 << 10},
		{name: "segmented", providerLimit: 512},
	} {
		t.Run(tc.name, func(t *testing.T) {
			b := newTestBackendWithOptions(t, Options{DatabaseAutoEmbedding: true})
			fileID := insertImageFileForExtractTest(t, b, "/rec/probe-fails-"+tc.name+".mp3", "audio/mpeg", make([]byte, 1024))
			extractor := &sequenceAudioExtractor{responses: []string{"must not run"}}
			result, err := b.ProcessAudioExtractTaskWithRuntime(context.Background(), AudioExtractTaskSpec{
				FileID: fileID, Path: "/rec/probe-fails-" + tc.name + ".mp3", ContentType: "audio/mpeg", Revision: 1,
			}, AudioExtractRuntime{
				Extractor: extractor, MaxSourceBytes: 1 << 20, MaxAudioBytes: tc.providerLimit, Workspace: workspace,
			})
			if result != AudioExtractResultExtractError || err == nil || !strings.Contains(err.Error(), "probe audio duration") {
				t.Fatalf("result=%q err=%v, want retryable duration probe failure", result, err)
			}
			if extractor.calls != 0 {
				t.Fatalf("extractor calls=%d, want no provider call", extractor.calls)
			}
		})
	}
}

func TestValidateAudioSegmentsRejectsCountAndDerivedBytes(t *testing.T) {
	dir := t.TempDir()
	paths := make([]string, maxAudioExtractSegments+1)
	for i := range paths {
		paths[i] = filepath.Join(dir, fmt.Sprintf("segment_%03d.mp3", i))
		if err := os.WriteFile(paths[i], []byte("x"), 0o600); err != nil {
			t.Fatal(err)
		}
	}
	if err := validateAudioSegments(paths); !errors.Is(err, ErrMediaExtractDerivedTooLarge) {
		t.Fatalf("count error=%v, want derived-too-large", err)
	}
	large := filepath.Join(dir, "large.mp3")
	if err := os.WriteFile(large, nil, 0o600); err != nil {
		t.Fatal(err)
	}
	if err := os.Truncate(large, maxAudioExtractDerivedBytes+1); err != nil {
		t.Fatal(err)
	}
	if err := validateAudioSegments([]string{large}); !errors.Is(err, ErrMediaExtractDerivedTooLarge) {
		t.Fatalf("bytes error=%v, want derived-too-large", err)
	}
}

func TestProcessAudioExtractTaskRetriesFromCompletedSegment(t *testing.T) {
	installAudioFFmpegSegmentStub(t, 3)
	workspace, err := NewMediaExtractWorkspace(MediaExtractWorkspaceOptions{RootDir: t.TempDir()})
	if err != nil {
		t.Fatal(err)
	}
	extractor := &failOnceAudioExtractor{failAt: 2}
	b := newTestBackendWithOptions(t, Options{DatabaseAutoEmbedding: true})
	fileID := insertImageFileForExtractTest(t, b, "/rec/retry.wav", "audio/wav", make([]byte, 100<<10))
	var checkpoint AudioExtractCheckpoint
	runtime := AudioExtractRuntime{
		Extractor: extractor, MaxAudioBytes: 40 << 10, MaxExtractTextBytes: DefaultAudioExtractMaxTextBytes,
		Workspace: workspace,
		Checkpoint: func(next AudioExtractCheckpoint) error {
			checkpoint = next
			return nil
		},
	}
	task := AudioExtractTaskSpec{FileID: fileID, Path: "/rec/retry.wav", ContentType: "audio/wav", Revision: 1}

	result, err := b.ProcessAudioExtractTaskWithRuntime(context.Background(), task, runtime)
	if err == nil || result != AudioExtractResultExtractError {
		t.Fatalf("first attempt result=%q err=%v, want retryable extract_error", result, err)
	}
	if checkpoint.CompletedSegments != 1 || !strings.Contains(checkpoint.Text, "segment call 1") {
		t.Fatalf("checkpoint=%+v, want completed first segment", checkpoint)
	}
	nf, statErr := b.store.Stat(context.Background(), "/rec/retry.wav")
	if statErr != nil || nf.File == nil {
		t.Fatalf("stat retry file: %v", statErr)
	}
	if got := nf.File.ContentText; got != "" {
		t.Fatalf("content_text=%q, want no writeback before retry exhaustion", got)
	}

	runtime.CheckpointState = checkpoint
	result, err = b.ProcessAudioExtractTaskWithRuntime(context.Background(), task, runtime)
	if err != nil || result != AudioExtractResultWritten {
		t.Fatalf("second attempt result=%q err=%v, want written", result, err)
	}
	if extractor.calls != 4 {
		t.Fatalf("extractor calls=%d, want 4 (segment 1 once, segment 2 twice, segment 3 once)", extractor.calls)
	}
	got := waitForContentText(t, b, "/rec/retry.wav", time.Second)
	if !strings.Contains(got, "segment call 1") || !strings.Contains(got, "segment call 4") {
		t.Fatalf("content_text=%q, want checkpoint prefix and resumed suffix", got)
	}
}

func TestProcessAudioExtractTaskRecordsSuccessfulUsageBeforeRetry(t *testing.T) {
	installAudioFFmpegSegmentStub(t, 3)
	workspace, err := NewMediaExtractWorkspace(MediaExtractWorkspaceOptions{RootDir: t.TempDir()})
	if err != nil {
		t.Fatal(err)
	}
	extractor := &meteredFailOnceAudioExtractor{}
	b := newTestBackendWithOptions(t, Options{
		DatabaseAutoEmbedding: true,
		LLMCostBudget: LLMCostBudgetOptions{
			MaxMonthlyMillicents: -1, WhisperCostPerMinuteMillicents: 60, FallbackAudioCostMillicents: 10,
		},
	})
	fileID := insertImageFileForExtractTest(t, b, "/rec/metered-retry.wav", "audio/wav", make([]byte, 100<<10))
	cleanupAudioUsageForTask(t, b, fileID)
	var checkpoint AudioExtractCheckpoint
	runtime := AudioExtractRuntime{
		Extractor: extractor, MaxAudioBytes: 40 << 10, MaxExtractTextBytes: DefaultAudioExtractMaxTextBytes,
		Workspace: workspace,
		Checkpoint: func(next AudioExtractCheckpoint) error {
			checkpoint = next
			return nil
		},
	}
	task := AudioExtractTaskSpec{FileID: fileID, Path: "/rec/metered-retry.wav", ContentType: "audio/wav", Revision: 1}
	if result, err := b.ProcessAudioExtractTaskWithRuntime(context.Background(), task, runtime); result != AudioExtractResultExtractError || err == nil {
		t.Fatalf("first result=%q err=%v, want retryable extract_error", result, err)
	}
	if extractor.calls != 2 || checkpoint.CompletedSegments != 1 {
		t.Fatalf("first attempt calls=%d checkpoint=%+v, want one successful segment before failure", extractor.calls, checkpoint)
	}
	assertAudioUsageMillis(t, b, fileID, 60_000)

	runtime.CheckpointState = checkpoint
	if result, err := b.ProcessAudioExtractTaskWithRuntime(context.Background(), task, runtime); result != AudioExtractResultWritten || err != nil {
		t.Fatalf("retry result=%q err=%v, want written", result, err)
	}
	assertAudioUsageMillis(t, b, fileID, 180_000)

	// A completed checkpoint performs no provider call and must not add a
	// fallback charge merely because the task is retried after writeback.
	resumeID := insertImageFileForExtractTest(t, b, "/rec/metered-complete.wav", "audio/wav", make([]byte, 100<<10))
	cleanupAudioUsageForTask(t, b, resumeID)
	runtime.CheckpointState = AudioExtractCheckpoint{CompletedSegments: 3, Text: "complete"}
	if result, err := b.ProcessAudioExtractTaskWithRuntime(context.Background(), AudioExtractTaskSpec{
		FileID: resumeID, Path: "/rec/metered-complete.wav", ContentType: "audio/wav", Revision: 1,
	}, runtime); result != AudioExtractResultWritten || err != nil {
		t.Fatalf("completed resume result=%q err=%v, want written", result, err)
	}
	assertAudioUsageMillis(t, b, resumeID, 0)
}

func cleanupAudioUsageForTask(t *testing.T, b *Dat9Backend, taskID string) {
	t.Helper()
	t.Cleanup(func() {
		if _, err := b.store.DB().Exec(`DELETE FROM llm_usage WHERE task_type = 'audio_extract_text' AND task_id = ?`, taskID); err != nil {
			t.Errorf("clean audio usage for task %s: %v", taskID, err)
		}
	})
}

func assertAudioUsageMillis(t *testing.T, b *Dat9Backend, taskID string, want int64) {
	t.Helper()
	var got int64
	if err := b.store.DB().QueryRow(`SELECT COALESCE(SUM(raw_units), 0) FROM llm_usage WHERE task_type = 'audio_extract_text' AND task_id = ?`, taskID).Scan(&got); err != nil {
		t.Fatal(err)
	}
	if got != want {
		t.Fatalf("audio usage milliseconds=%d, want %d", got, want)
	}
}

func TestProcessAudioExtractTaskContinuesAfterEmptySegment(t *testing.T) {
	workspace, err := NewMediaExtractWorkspace(MediaExtractWorkspaceOptions{RootDir: t.TempDir()})
	if err != nil {
		t.Fatal(err)
	}
	// Stub ffmpeg only to exercise the segment loop; the production command
	// still performs the real split. Two tiny outputs model a multi-segment
	// source without requiring an encoded fixture.
	ffmpeg := filepath.Join(t.TempDir(), "ffmpeg")
	if err := os.WriteFile(ffmpeg, []byte("#!/bin/sh\nout=\"\"\nfor arg in \"$@\"; do out=\"$arg\"; done\np1=$(printf '%s' \"$out\" | sed 's/%03d/001/')\np2=$(printf '%s' \"$out\" | sed 's/%03d/002/')\nprintf x > \"$p1\"\nprintf x > \"$p2\"\n"), 0o700); err != nil {
		t.Fatal(err)
	}
	t.Setenv("PATH", filepath.Dir(ffmpeg)+string(os.PathListSeparator)+os.Getenv("PATH"))
	installAudioFFprobeStub(t)
	extractor := &emptyThenTextAudioExtractor{}
	b := newTestBackendWithOptions(t, Options{DatabaseAutoEmbedding: true})
	sourcePath := filepath.Join(t.TempDir(), "source.wav")
	if err := os.WriteFile(sourcePath, make([]byte, 100<<10), 0o600); err != nil {
		t.Fatal(err)
	}
	taskWorkspace, err := workspace.NewTask(context.Background(), "empty-first")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = taskWorkspace.Close() })
	runtime := AudioExtractRuntime{
		Extractor: extractor, MaxAudioBytes: 40 << 10, MaxExtractTextBytes: DefaultAudioExtractMaxTextBytes,
		Workspace: workspace,
	}
	text, _, _, _, err := b.extractAudioDataWithTags(context.Background(), AudioExtractTaskSpec{Path: "/rec/empty-first.wav"}, "audio/wav", nil, sourcePath, taskWorkspace, runtime)
	if err != nil {
		t.Fatalf("extractAudioData err=%v, want continuation after empty segment", err)
	}
	if extractor.calls < 2 {
		t.Fatalf("extractor calls=%d, want later segment processing", extractor.calls)
	}
	if !strings.Contains(text, "segment call 2") {
		t.Fatalf("text=%q, want transcript from segment after empty result", text)
	}
}

func TestProcessAudioExtractTaskResumesAfterSilentSegment(t *testing.T) {
	workspace, err := NewMediaExtractWorkspace(MediaExtractWorkspaceOptions{RootDir: t.TempDir()})
	if err != nil {
		t.Fatal(err)
	}
	ffmpeg := filepath.Join(t.TempDir(), "ffmpeg")
	if err := os.WriteFile(ffmpeg, []byte("#!/bin/sh\nout=\"\"\nfor arg in \"$@\"; do out=\"$arg\"; done\np1=$(printf '%s' \"$out\" | sed 's/%03d/001/')\np2=$(printf '%s' \"$out\" | sed 's/%03d/002/')\nprintf x > \"$p1\"\nprintf x > \"$p2\"\n"), 0o700); err != nil {
		t.Fatal(err)
	}
	t.Setenv("PATH", filepath.Dir(ffmpeg)+string(os.PathListSeparator)+os.Getenv("PATH"))
	installAudioFFprobeStub(t)
	extractor := &emptyThenFailOnceAudioExtractor{}
	b := newTestBackendWithOptions(t, Options{DatabaseAutoEmbedding: true})
	sourcePath := filepath.Join(t.TempDir(), "source.wav")
	if err := os.WriteFile(sourcePath, make([]byte, 100<<10), 0o600); err != nil {
		t.Fatal(err)
	}
	taskWorkspace, err := workspace.NewTask(context.Background(), "silent-retry")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = taskWorkspace.Close() })
	var checkpoint AudioExtractCheckpoint
	runtime := AudioExtractRuntime{
		Extractor: extractor, MaxAudioBytes: 40 << 10, MaxExtractTextBytes: DefaultAudioExtractMaxTextBytes,
		Workspace: workspace,
		Checkpoint: func(next AudioExtractCheckpoint) error {
			checkpoint = next
			return nil
		},
	}
	task := AudioExtractTaskSpec{Path: "/rec/silent-retry.wav"}

	if _, _, _, _, err := b.extractAudioDataWithTags(context.Background(), task, "audio/wav", nil, sourcePath, taskWorkspace, runtime); err == nil {
		t.Fatal("first attempt succeeded, want transient second-segment failure")
	}
	if checkpoint.CompletedSegments != 1 || checkpoint.Text != "" {
		t.Fatalf("checkpoint=%+v, want one completed silent segment", checkpoint)
	}

	runtime.CheckpointState = checkpoint
	text, _, _, _, err := b.extractAudioDataWithTags(context.Background(), task, "audio/wav", nil, sourcePath, taskWorkspace, runtime)
	if err != nil {
		t.Fatalf("retry failed: %v", err)
	}
	if extractor.calls != 3 {
		t.Fatalf("extractor calls=%d, want 3 so completed silent segment is not resent", extractor.calls)
	}
	if !strings.Contains(text, "segment call 3") {
		t.Fatalf("text=%q, want resumed second-segment transcript", text)
	}
}

func TestProcessAudioExtractTaskWritesPartialOnlyOnFinalAttempt(t *testing.T) {
	installAudioFFmpegSegmentStub(t, 3)
	workspace, err := NewMediaExtractWorkspace(MediaExtractWorkspaceOptions{RootDir: t.TempDir()})
	if err != nil {
		t.Fatal(err)
	}
	extractor := &failOnceAudioExtractor{failAt: 2}
	b := newTestBackendWithOptions(t, Options{DatabaseAutoEmbedding: true})
	fileID := insertImageFileForExtractTest(t, b, "/rec/final.wav", "audio/wav", make([]byte, 100<<10))
	runtime := AudioExtractRuntime{
		Extractor: extractor, MaxAudioBytes: 40 << 10, MaxExtractTextBytes: DefaultAudioExtractMaxTextBytes,
		Workspace: workspace, FinalAttempt: true,
	}
	result, err := b.ProcessAudioExtractTaskWithRuntime(context.Background(), AudioExtractTaskSpec{
		FileID: fileID, Path: "/rec/final.wav", ContentType: "audio/wav", Revision: 1,
	}, runtime)
	if err != nil || result != AudioExtractResultPartialSuccess {
		t.Fatalf("result=%q err=%v, want partial_success on final attempt", result, err)
	}
	got := waitForContentText(t, b, "/rec/final.wav", time.Second)
	if !strings.Contains(got, "segment call 1") {
		t.Fatalf("content_text=%q, want completed prefix", got)
	}
}

func TestAudioSegmentSecondsRespectsProviderByteLimit(t *testing.T) {
	limit := int64(7 << 20)
	seconds := audioSegmentSeconds(limit)
	if seconds != 10*60 {
		t.Fatalf("segment seconds = %d, want 600 so two hours fits the 12-segment cap", seconds)
	}
	if got := seconds*int64(32_000/8) + 4096; got > limit {
		t.Fatalf("MP3 segment estimate %d exceeds provider limit %d", got, limit)
	}
}

func TestExtractAudioSegmentsUsesAbsoluteRangesAndClampsFinalRange(t *testing.T) {
	dir := t.TempDir()
	segments := make([]string, 3)
	for i := range segments {
		segments[i] = filepath.Join(dir, fmt.Sprintf("segment_%03d.mp3", i))
		if err := os.WriteFile(segments[i], []byte("audio"), 0o600); err != nil {
			t.Fatal(err)
		}
	}
	b := newTestBackendWithOptions(t, Options{DatabaseAutoEmbedding: true})
	text, _, _, _, err := b.extractAudioSegmentsWithTags(
		context.Background(),
		AudioExtractTaskSpec{Path: "/rec/timeline.mp3"},
		segments,
		7<<20,
		1250,
		AudioExtractRuntime{
			Extractor:           &sequenceAudioExtractor{responses: []string{"first", "second", "third"}},
			MaxExtractTextBytes: DefaultAudioExtractMaxTextBytes,
		},
		true,
		true,
	)
	if err != nil {
		t.Fatal(err)
	}
	want := "[Audio 00:00:00-00:10:00]\nfirst\n" +
		"[Audio 00:10:00-00:20:00]\nsecond\n" +
		"[Audio 00:20:00-00:20:50]\nthird"
	if text != want {
		t.Fatalf("text=%q, want %q", text, want)
	}
}

func TestExtractUnsplitAudioUsesZeroToSourceDurationRange(t *testing.T) {
	installAudioFFprobeStub(t)
	sourcePath := filepath.Join(t.TempDir(), "source.mp3")
	if err := os.WriteFile(sourcePath, []byte("audio"), 0o600); err != nil {
		t.Fatal(err)
	}
	b := newTestBackendWithOptions(t, Options{DatabaseAutoEmbedding: true})
	text, _, _, _, err := b.extractAudioDataWithTags(
		context.Background(),
		AudioExtractTaskSpec{Path: "/rec/source.mp3"},
		"audio/mpeg",
		nil,
		sourcePath,
		nil,
		AudioExtractRuntime{
			Extractor:           &sequenceAudioExtractor{responses: []string{"complete transcript"}},
			MaxAudioBytes:       7 << 20,
			MaxExtractTextBytes: DefaultAudioExtractMaxTextBytes,
		},
	)
	if err != nil {
		t.Fatal(err)
	}
	want := "[Audio 00:00:00-00:01:00]\ncomplete transcript"
	if text != want {
		t.Fatalf("text=%q, want %q", text, want)
	}
}

type staticAudioExtractor struct {
	text string
	err  error
}

func (e *staticAudioExtractor) ExtractAudioText(ctx context.Context, req AudioExtractRequest) (string, AudioExtractUsage, error) {
	if e.err != nil {
		return "", AudioExtractUsage{}, e.err
	}
	return e.text, AudioExtractUsage{}, nil
}

type recordingAudioExtractor struct {
	text           string
	err            error
	gotContentType string
}

func (e *recordingAudioExtractor) ExtractAudioText(ctx context.Context, req AudioExtractRequest) (string, AudioExtractUsage, error) {
	e.gotContentType = req.ContentType
	if e.err != nil {
		return "", AudioExtractUsage{}, e.err
	}
	return e.text, AudioExtractUsage{}, nil
}

func readBackendMetrics() string {
	recorder := httptest.NewRecorder()
	metrics.WritePrometheus(recorder)
	return recorder.Body.String()
}

func TestProcessAudioExtractTaskWritesContentText(t *testing.T) {
	installAudioFFprobeStub(t)
	b := newTestBackendWithOptions(t, Options{
		DatabaseAutoEmbedding: true,
		AsyncAudioExtract: AsyncAudioExtractOptions{
			Enabled:   true,
			Extractor: &staticAudioExtractor{text: "spoken keyword in transcript"},
		},
	})
	fileID := insertImageFileForExtractTest(t, b, "/rec/clip.mp3", "audio/mpeg", []byte{0xff, 0xf3, 0x80})
	result, err := b.ProcessAudioExtractTask(context.Background(), AudioExtractTaskSpec{
		FileID:      fileID,
		Path:        "/rec/clip.mp3",
		ContentType: "audio/mpeg",
		Revision:    1,
	})
	if err != nil {
		t.Fatal(err)
	}
	if result != AudioExtractResultWritten {
		t.Fatalf("result=%q, want %q", result, AudioExtractResultWritten)
	}
	got := waitForContentText(t, b, "/rec/clip.mp3", time.Second)
	if !strings.Contains(got, "keyword") {
		t.Fatalf("unexpected content_text: %q", got)
	}
}

func TestAsyncAudioExtractMetricsExposed(t *testing.T) {
	installAudioFFprobeStub(t)
	b := newTestBackendWithOptions(t, Options{
		DatabaseAutoEmbedding: true,
		AsyncAudioExtract: AsyncAudioExtractOptions{
			Enabled:             true,
			MaxAudioBytes:       4096,
			TaskTimeout:         3 * time.Second,
			MaxExtractTextBytes: 777,
			Extractor:           &staticAudioExtractor{text: "spoken keyword in transcript"},
		},
	})
	fileID := insertImageFileForExtractTest(t, b, "/rec/metrics.mp3", "audio/mpeg", []byte{0xff, 0xf3, 0x80})
	result, err := b.ProcessAudioExtractTask(context.Background(), AudioExtractTaskSpec{
		FileID:      fileID,
		Path:        "/rec/metrics.mp3",
		ContentType: "audio/mpeg",
		Revision:    1,
	})
	if err != nil {
		t.Fatal(err)
	}
	if result != AudioExtractResultWritten {
		t.Fatalf("result=%q, want %q", result, AudioExtractResultWritten)
	}

	metricsText := readBackendMetrics()
	if !strings.Contains(metricsText, "drive9_module_up{module=\"audio_extract\"} 1") {
		t.Fatalf("metrics missing audio_extract module availability: %s", metricsText)
	}
	if !strings.Contains(metricsText, "drive9_service_gauge{component=\"audio_extract\",name=\"max_audio_bytes\",tenant_id=\"\",tidbcloud_org_id=\"guest\"} 4096") {
		t.Fatalf("metrics missing audio_extract runtime gauge: %s", metricsText)
	}
	if !strings.Contains(metricsText, "drive9_service_operations_total{component=\"audio_extract\",operation=\"process\",result=\"ok\"}") {
		t.Fatalf("metrics missing audio_extract process counter: %s", metricsText)
	}
	if !strings.Contains(metricsText, "drive9_service_operation_duration_seconds_count{component=\"audio_extract\",operation=\"process\",result=\"ok\"}") {
		t.Fatalf("metrics missing audio_extract duration histogram: %s", metricsText)
	}
}

func TestEffectiveAudioMIMEAllowlistedMP3WAVMP4AndM4A(t *testing.T) {
	tests := []struct {
		path, ct, want string
	}{
		{"/x.mp3", "audio/mpeg", "audio/mpeg"},
		{"/x.wav", "audio/wav", "audio/wav"},
		{"/x.wav", "audio/wave", "audio/wav"},
		{"/x.wav", "audio/vnd.wave", "audio/wav"},
		{"/x.wav", "audio/x-pn-wav", "audio/wav"},
		{"/x.wav", "audio/x-wav", "audio/x-wav"},
		{"/x.mp4", "audio/mp4", "audio/mp4"},
		{"/x.mp4", "video/mp4", "audio/mp4"},
		{"/x.mp4", "application/mp4", "audio/mp4"},
		{"/sub/clip.mp4", "application/octet-stream", "audio/mp4"},
		{"/sub/clip.mp3", "application/octet-stream", "audio/mpeg"},
		{"/sub/clip.wav", "application/octet-stream", "audio/wav"},
		{"/sub/clip.m4a", "application/octet-stream", "audio/mp4"},
		{"/x.m4a", "audio/mp4", "audio/mp4"},
		{"/x.m4a", "audio/x-m4a", "audio/x-m4a"},
		{"/x.m4a", "audio/mp4a-latm", "audio/mp4"},
	}
	for _, tc := range tests {
		got := effectiveAudioMIME(tc.path, tc.ct)
		if got != tc.want {
			t.Fatalf("effectiveAudioMIME(%q,%q)=%q, want %q", tc.path, tc.ct, got, tc.want)
		}
	}
}

func TestProcessAudioExtractTaskPreservesMP4ContainerMIMEForExtractor(t *testing.T) {
	installAudioFFprobeStub(t)
	extractor := &recordingAudioExtractor{text: "spoken mp4 transcript"}
	b := newTestBackendWithOptions(t, Options{
		DatabaseAutoEmbedding: true,
		AsyncAudioExtract: AsyncAudioExtractOptions{
			Enabled:   true,
			Extractor: extractor,
		},
	})
	fileID := insertImageFileForExtractTest(t, b, "/rec/clip.mp4", "video/mp4", []byte{0x00, 0x00, 0x00, 0x18})
	result, err := b.ProcessAudioExtractTask(context.Background(), AudioExtractTaskSpec{
		FileID:      fileID,
		Path:        "/rec/clip.mp4",
		ContentType: "video/mp4",
		Revision:    1,
	})
	if err != nil {
		t.Fatal(err)
	}
	if result != AudioExtractResultWritten {
		t.Fatalf("result=%q, want %q", result, AudioExtractResultWritten)
	}
	if extractor.gotContentType != "video/mp4" {
		t.Fatalf("extractor content type=%q, want video/mp4", extractor.gotContentType)
	}
	got := waitForContentText(t, b, "/rec/clip.mp4", time.Second)
	if !strings.Contains(got, "mp4 transcript") {
		t.Fatalf("unexpected content_text: %q", got)
	}
}

func TestProcessAudioExtractTaskStaleRevision(t *testing.T) {
	b := newTestBackendWithOptions(t, Options{
		DatabaseAutoEmbedding: true,
		AsyncAudioExtract: AsyncAudioExtractOptions{
			Enabled:   true,
			Extractor: &staticAudioExtractor{text: "should not apply"},
		},
	})
	fileID := insertImageFileForExtractTest(t, b, "/rec/stale.mp3", "audio/mpeg", []byte{1, 2, 3})
	if _, err := b.Store().DB().Exec(`UPDATE files SET revision = 2 WHERE file_id = ?`, fileID); err != nil {
		t.Fatal(err)
	}
	if _, err := b.Store().DB().Exec(`UPDATE inodes SET revision = 2 WHERE inode_id = ?`, fileID); err != nil {
		t.Fatal(err)
	}
	result, err := b.ProcessAudioExtractTask(context.Background(), AudioExtractTaskSpec{
		FileID:      fileID,
		Path:        "/rec/stale.mp3",
		ContentType: "audio/mpeg",
		Revision:    1,
	})
	if err != nil {
		t.Fatal(err)
	}
	if result != AudioExtractResultStale {
		t.Fatalf("result=%q, want %q", result, AudioExtractResultStale)
	}
}

func TestProcessAudioExtractTaskStaleBeforeNotAudioWhenTypeChanged(t *testing.T) {
	b := newTestBackendWithOptions(t, Options{
		DatabaseAutoEmbedding: true,
		AsyncAudioExtract: AsyncAudioExtractOptions{
			Enabled:   true,
			Extractor: &staticAudioExtractor{text: "must not apply"},
		},
	})
	fileID := insertImageFileForExtractTest(t, b, "/rec/type-changed.mp3", "audio/mpeg", []byte{1, 2, 3})
	if _, err := b.Store().DB().Exec(`UPDATE files SET revision = 2, content_type = ? WHERE file_id = ?`, "text/plain", fileID); err != nil {
		t.Fatal(err)
	}
	if _, err := b.Store().DB().Exec(`UPDATE inodes SET revision = 2 WHERE inode_id = ?`, fileID); err != nil {
		t.Fatal(err)
	}
	if _, err := b.Store().DB().Exec(`UPDATE contents SET content_type = ? WHERE inode_id = ?`, "text/plain", fileID); err != nil {
		t.Fatal(err)
	}
	result, err := b.ProcessAudioExtractTask(context.Background(), AudioExtractTaskSpec{
		FileID:      fileID,
		Path:        "/rec/type-changed.mp3",
		ContentType: "audio/mpeg",
		Revision:    1,
	})
	if err != nil {
		t.Fatal(err)
	}
	if result != AudioExtractResultStale {
		t.Fatalf("result=%q, want %q (revision mismatch must win over not_audio)", result, AudioExtractResultStale)
	}
}

func TestProcessAudioExtractTaskUsesPayloadMIMEWhenStoredGenericAndExtensionless(t *testing.T) {
	installAudioFFprobeStub(t)
	b := newTestBackendWithOptions(t, Options{
		DatabaseAutoEmbedding: true,
		AsyncAudioExtract: AsyncAudioExtractOptions{
			Enabled:   true,
			Extractor: &staticAudioExtractor{text: "payload hint transcript"},
		},
	})
	fileID := insertImageFileForExtractTest(t, b, "/rec/extensionless-audio", "application/octet-stream", []byte{0xff, 0xf3, 0x80})
	result, err := b.ProcessAudioExtractTask(context.Background(), AudioExtractTaskSpec{
		FileID:      fileID,
		Path:        "/rec/extensionless-audio",
		ContentType: "audio/mpeg",
		Revision:    1,
	})
	if err != nil {
		t.Fatal(err)
	}
	if result != AudioExtractResultWritten {
		t.Fatalf("result=%q, want %q (stored generic MIME must combine with payload hint)", result, AudioExtractResultWritten)
	}
	got := waitForContentText(t, b, "/rec/extensionless-audio", time.Second)
	if !strings.Contains(got, "payload hint") {
		t.Fatalf("unexpected content_text: %q", got)
	}
}

func TestProcessAudioExtractTaskNonGenericStoredIgnoresConflictingPayloadMIME(t *testing.T) {
	b := newTestBackendWithOptions(t, Options{
		DatabaseAutoEmbedding: true,
		AsyncAudioExtract: AsyncAudioExtractOptions{
			Enabled:   true,
			Extractor: &staticAudioExtractor{text: "must not run"},
		},
	})
	fileID := insertImageFileForExtractTest(t, b, "/rec/wrong-type.bin", "image/png", []byte{1, 2, 3, 4})
	result, err := b.ProcessAudioExtractTask(context.Background(), AudioExtractTaskSpec{
		FileID:      fileID,
		Path:        "/rec/wrong-type.bin",
		ContentType: "audio/mpeg",
		Revision:    1,
	})
	if err != nil {
		t.Fatal(err)
	}
	if result != AudioExtractResultNotAudio {
		t.Fatalf("result=%q, want %q (specific non-generic stored type must not be overridden by payload)", result, AudioExtractResultNotAudio)
	}
}

func TestProcessAudioExtractTaskZeroByteSourceNotReportedAsTooLarge(t *testing.T) {
	installAudioFFprobeStub(t)
	b := newTestBackendWithOptions(t, Options{
		DatabaseAutoEmbedding: true,
		AsyncAudioExtract: AsyncAudioExtractOptions{
			Enabled:   true,
			Extractor: &staticAudioExtractor{text: "empty blob ok"},
		},
	})
	fileID := insertImageFileForExtractTest(t, b, "/rec/zero.mp3", "audio/mpeg", []byte{})
	result, err := b.ProcessAudioExtractTask(context.Background(), AudioExtractTaskSpec{
		FileID:      fileID,
		Path:        "/rec/zero.mp3",
		ContentType: "audio/mpeg",
		Revision:    1,
	})
	if err != nil {
		t.Fatal(err)
	}
	if result != AudioExtractResultWritten {
		t.Fatalf("result=%q, want %q (0-byte payload is not oversize)", result, AudioExtractResultWritten)
	}
}

func TestProcessAudioExtractTaskNotAudio(t *testing.T) {
	b := newTestBackendWithOptions(t, Options{
		DatabaseAutoEmbedding: true,
		AsyncAudioExtract: AsyncAudioExtractOptions{
			Enabled:   true,
			Extractor: &staticAudioExtractor{text: "x"},
		},
	})
	fileID := insertImageFileForExtractTest(t, b, "/rec/note.txt", "text/plain", []byte("hello"))
	result, err := b.ProcessAudioExtractTask(context.Background(), AudioExtractTaskSpec{
		FileID:      fileID,
		Path:        "/rec/note.txt",
		ContentType: "text/plain",
		Revision:    1,
	})
	if err != nil {
		t.Fatal(err)
	}
	if result != AudioExtractResultNotAudio {
		t.Fatalf("result=%q, want %q", result, AudioExtractResultNotAudio)
	}
}

func TestStageMediaFileRejectsAudioSourceOverRuntimeCapBeforeRead(t *testing.T) {
	b := &Dat9Backend{}
	file := &datastore.File{
		StorageType: datastore.StorageS3,
		SizeBytes:   DefaultAudioExtractMaxSourceBytes + 1,
	}
	_, _, err := b.stageMediaFile(context.Background(), nil, file, "source.mp3", DefaultAudioExtractMaxSourceBytes)
	if !errors.Is(err, ErrMediaExtractSourceTooLarge) {
		t.Fatalf("stageMediaFile error = %v, want ErrMediaExtractSourceTooLarge", err)
	}
}

func TestProcessAudioExtractTaskEmptyText(t *testing.T) {
	installAudioFFprobeStub(t)
	b := newTestBackendWithOptions(t, Options{
		DatabaseAutoEmbedding: true,
		AsyncAudioExtract: AsyncAudioExtractOptions{
			Enabled:   true,
			Extractor: &staticAudioExtractor{text: "   \n  "},
		},
	})
	fileID := insertImageFileForExtractTest(t, b, "/rec/empty.mp3", "audio/mpeg", []byte{1})
	result, err := b.ProcessAudioExtractTask(context.Background(), AudioExtractTaskSpec{
		FileID:      fileID,
		Path:        "/rec/empty.mp3",
		ContentType: "audio/mpeg",
		Revision:    1,
	})
	if err != nil {
		t.Fatal(err)
	}
	if result != AudioExtractResultEmptyText {
		t.Fatalf("result=%q, want %q", result, AudioExtractResultEmptyText)
	}
}

func TestProcessAudioExtractTaskTypedEmptyText(t *testing.T) {
	provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{"text":""}`))
	}))
	defer provider.Close()
	extractor, err := NewOpenAIAudioTextExtractor(OpenAIAudioTextExtractorConfig{
		BaseURL: provider.URL, APIKey: "key", Model: "whisper-1", Client: provider.Client(),
	})
	if err != nil {
		t.Fatal(err)
	}
	b := newTestBackendWithOptions(t, Options{DatabaseAutoEmbedding: true})
	fileID := insertImageFileForExtractTest(t, b, "/rec/silent.mp3", "audio/mpeg", []byte{1})
	result, err := b.ProcessAudioExtractTaskWithRuntime(context.Background(), AudioExtractTaskSpec{
		FileID: fileID, Path: "/rec/silent.mp3", ContentType: "audio/mpeg", Revision: 1,
	}, AudioExtractRuntime{Extractor: extractor, MaxAudioBytes: 8 << 20, Timeout: time.Second})
	if err != nil {
		t.Fatalf("typed empty transcript returned error: %v", err)
	}
	if result != AudioExtractResultEmptyText {
		t.Fatalf("result=%q, want %q", result, AudioExtractResultEmptyText)
	}
}

func TestProcessAudioExtractTaskRuntimeNotConfigured(t *testing.T) {
	b := newTestBackendWithOptions(t, Options{DatabaseAutoEmbedding: true})
	fileID := insertImageFileForExtractTest(t, b, "/rec/nort.mp3", "audio/mpeg", []byte{1})
	result, err := b.ProcessAudioExtractTask(context.Background(), AudioExtractTaskSpec{
		FileID:      fileID,
		Path:        "/rec/nort.mp3",
		ContentType: "audio/mpeg",
		Revision:    1,
	})
	if result != AudioExtractResultRuntimeNotConfigured {
		t.Fatalf("result=%q, want %q", result, AudioExtractResultRuntimeNotConfigured)
	}
	if err == nil {
		t.Fatal("expected retryable error")
	}
}

func TestProcessAudioExtractTaskExtractErrorRetries(t *testing.T) {
	b := newTestBackendWithOptions(t, Options{
		DatabaseAutoEmbedding: true,
		AsyncAudioExtract: AsyncAudioExtractOptions{
			Enabled:   true,
			Extractor: &staticAudioExtractor{err: context.Canceled},
		},
	})
	fileID := insertImageFileForExtractTest(t, b, "/rec/fail.mp3", "audio/mpeg", []byte{1})
	result, err := b.ProcessAudioExtractTask(context.Background(), AudioExtractTaskSpec{
		FileID:      fileID,
		Path:        "/rec/fail.mp3",
		ContentType: "audio/mpeg",
		Revision:    1,
	})
	if result != AudioExtractResultExtractError {
		t.Fatalf("result=%q, want %q", result, AudioExtractResultExtractError)
	}
	if err == nil {
		t.Fatal("expected error")
	}
}

func TestProcessAudioExtractTaskLoadsFromS3(t *testing.T) {
	installAudioFFprobeStub(t)
	b := newTestBackendWithOptions(t, Options{
		DatabaseAutoEmbedding: true,
		AsyncAudioExtract: AsyncAudioExtractOptions{
			Enabled:   true,
			Extractor: &staticAudioExtractor{text: "s3 keyword"},
		},
	})
	data := make([]byte, 60_000)
	for i := range data {
		data[i] = byte(i % 251)
	}
	if _, err := b.Write("/rec/huge.mp3", data, 0, filesystem.WriteFlagCreate); err != nil {
		t.Fatal(err)
	}
	fileID, rev, _, _ := mustFileForPath(t, b, "/rec/huge.mp3")
	result, err := b.ProcessAudioExtractTask(context.Background(), AudioExtractTaskSpec{
		FileID:      fileID,
		Path:        "/rec/huge.mp3",
		ContentType: "audio/mpeg",
		Revision:    rev,
	})
	if err != nil {
		t.Fatal(err)
	}
	if result != AudioExtractResultWritten {
		t.Fatalf("result=%q, want %q", result, AudioExtractResultWritten)
	}
	got := waitForContentText(t, b, "/rec/huge.mp3", 2*time.Second)
	if !strings.Contains(got, "s3 keyword") {
		t.Fatalf("unexpected content_text: %q", got)
	}
}

func TestGrepFindsAudioTranscript(t *testing.T) {
	installAudioFFprobeStub(t)
	b := newTestBackendWithOptions(t, Options{
		DatabaseAutoEmbedding: true,
		AsyncAudioExtract: AsyncAudioExtractOptions{
			Enabled:   true,
			Extractor: &staticAudioExtractor{text: "unique transcript token zyx"},
		},
	})
	if _, err := b.Write("/vo/clip.mp3", []byte{0xff, 0xf3, 0x80}, 0, filesystem.WriteFlagCreate); err != nil {
		t.Fatal(err)
	}
	fileID, rev, _, _ := mustFileForPath(t, b, "/vo/clip.mp3")
	if _, err := b.ProcessAudioExtractTask(context.Background(), AudioExtractTaskSpec{
		FileID: fileID, Path: "/vo/clip.mp3", ContentType: "audio/mpeg", Revision: rev,
	}); err != nil {
		t.Fatal(err)
	}
	results, err := b.Grep(context.Background(), "zyx", "/vo/", 20)
	if err != nil {
		t.Fatal(err)
	}
	found := false
	for _, r := range results {
		if r.Path == "/vo/clip.mp3" {
			found = true
			break
		}
	}
	if !found {
		t.Fatalf("grep should find audio transcript path, got %+v", results)
	}
}
