package backend

import (
	"bytes"
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"image"
	"image/draw"
	"image/jpeg"
	_ "image/png"
	"io"
	"math"
	"math/bits"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"
)

const (
	defaultVideoExtractPrompt = `你正在看一段视频的截图帧。请综合所有帧，描述视频的视觉内容，输出用于 Drive9 文件搜索的结构化 JSON。只输出一个 JSON 对象，不要 Markdown，不要解释。字段固定为：
{
  "caption_zh": "中文一句话摘要，<=120字",
  "description_zh": "中文详细描述，覆盖场景、人物、动作、物体、颜色、风格、地点线索，<=1200字",
  "caption_en": "one concise English caption, <=30 words",
  "description_en": "English description covering scenes, people, actions, objects, colors, style, location clues, <=260 words",
  "tags_zh": ["中文搜索标签，5-30个，短词"],
  "tags_en": ["English search tags, 5-30, lowercase short phrases"],
  "search_queries_zh": ["中文自然搜索短语，3-12个"],
  "search_queries_en": ["English natural search phrases, 3-12"]
}
要求：
- 综合所有帧描述整段视频，不要逐帧列举。
- 中英文都要覆盖可搜索信息。
- 标签优先使用具体物体、场景、动作、风格。
- 若不确定不要编造。
- 整个 JSON 控制在 16000 字符以内。`

	// DefaultOpenAIVideoExtractMaxTokens leaves enough completion budget for
	// structured bilingual video extraction.
	DefaultOpenAIVideoExtractMaxTokens = 4096

	// defaultVideoFrameInterval is the interval in seconds between sampled frames.
	defaultVideoFrameInterval = 5

	defaultVideoVisualSegmentSeconds = 5 * 60
	defaultVideoVisualMaxSegments    = 24
	defaultLegacyVideoMaxFrames      = 10
	maxVideoFrameDimension           = 1920
	maxVideoFramePixels              = maxVideoFrameDimension * maxVideoFrameDimension
	maxVideoBatchSummaryBytes        = 16 << 10
)

var (
	ErrVideoFrameTooLarge           = errors.New("video frame exceeds configured dimensions")
	ErrVideoProviderPayloadTooLarge = errors.New("video provider payload exceeds configured bytes")
	ErrVideoDurationTooLong         = errors.New("video duration exceeds supported visual timeline")
	errVideoSegmentNoFrames         = errors.New("video segment produced no frames")
)

// OpenAIVideoTextExtractorConfig configures an OpenAI-compatible vision endpoint
// for video frame extraction.
type OpenAIVideoTextExtractorConfig struct {
	BaseURL       string
	APIKey        string
	Model         string
	Prompt        string
	MaxTokens     int
	Timeout       time.Duration
	Client        *http.Client
	FrameInterval int // seconds between sampled frames
	// MaxProviderPayloadBytes bounds the complete JSON body of one provider
	// request, including base64 expansion and prompt text.
	MaxProviderPayloadBytes int64
	FFmpegPath              string
}

// OpenAIVideoTextExtractor extracts searchable text from video bytes by:
// 1. Writing video to a temp file
// 2. Using FFmpeg to extract frames at regular intervals
// 3. Sending frames to a Vision model for description
type OpenAIVideoTextExtractor struct {
	endpoint                string
	apiKey                  string
	model                   string
	prompt                  string
	maxTokens               int
	client                  *http.Client
	frameInterval           int
	maxProviderPayloadBytes int64
	ffmpegPath              string
	operationTimeout        time.Duration
}

func NewOpenAIVideoTextExtractor(cfg OpenAIVideoTextExtractorConfig) (*OpenAIVideoTextExtractor, error) {
	base := strings.TrimRight(cfg.BaseURL, "/")
	if base == "" {
		return nil, fmt.Errorf("openai video extractor base url is required")
	}
	if cfg.APIKey == "" {
		return nil, fmt.Errorf("openai video extractor api key is required")
	}
	if cfg.Model == "" {
		return nil, fmt.Errorf("openai video extractor model is required")
	}
	var endpoint string
	if strings.HasSuffix(base, "/v1") {
		endpoint = base + "/chat/completions"
	} else {
		endpoint = base + "/v1/chat/completions"
	}
	cfg.Prompt = ComposeVideoExtractPrompt(cfg.Prompt)
	if cfg.MaxTokens <= 0 {
		cfg.MaxTokens = DefaultOpenAIVideoExtractMaxTokens
	}
	if cfg.FrameInterval <= 0 {
		cfg.FrameInterval = defaultVideoFrameInterval
	}
	if cfg.MaxProviderPayloadBytes <= 0 {
		cfg.MaxProviderPayloadBytes = DefaultVideoExtractMaxProviderPayloadBytes
	}
	ffmpegPath := cfg.FFmpegPath
	if ffmpegPath == "" {
		ffmpegPath = "ffmpeg"
	}
	timeout := cfg.Timeout
	if timeout <= 0 {
		timeout = 120 * time.Second
	}
	client := cfg.Client
	if client == nil {
		client = &http.Client{Timeout: timeout}
	}
	return &OpenAIVideoTextExtractor{
		endpoint:                endpoint,
		apiKey:                  cfg.APIKey,
		model:                   cfg.Model,
		prompt:                  cfg.Prompt,
		maxTokens:               cfg.MaxTokens,
		client:                  client,
		frameInterval:           cfg.FrameInterval,
		maxProviderPayloadBytes: cfg.MaxProviderPayloadBytes,
		ffmpegPath:              ffmpegPath,
		operationTimeout:        timeout,
	}, nil
}

func (e *OpenAIVideoTextExtractor) ExtractVideoText(ctx context.Context, req VideoExtractRequest) (string, VideoExtractUsage, error) {
	frameCtx, cancelFrames := e.operationContext(ctx)
	frames, originalFrameCount, err := e.extractFrames(frameCtx, req.Data, req.ContentType, req.SourcePath)
	cancelFrames()
	if err != nil {
		// Provider-facing failures are classified by the worker; keep this
		// extractor free of unbounded provider text in metrics.
		return "", VideoExtractUsage{}, fmt.Errorf("extract frames: %w", err)
	}
	if len(frames) == 0 {
		return "", VideoExtractUsage{}, fmt.Errorf("no frames extracted from video")
	}
	// Frame counters intentionally use fixed operation/result labels; no tenant
	// or provider response data is placed in metric labels.
	text, usage, err := e.describeFramesInBatches(ctx, frames, req.BeforeProviderCall)
	usage.FramesExtracted = originalFrameCount
	if err != nil {
		return "", usage, err
	}
	return text, usage, nil
}

// ProbeVideoDuration reads only container metadata and performs no provider
// request. Staged sources are preferred so large videos stay off the Go heap.
func (e *OpenAIVideoTextExtractor) ProbeVideoDuration(ctx context.Context, req VideoExtractRequest) (float64, error) {
	probeCtx, cancelProbe := e.operationContext(ctx)
	defer cancelProbe()
	if req.SourcePath != "" {
		return probeVideoDuration(probeCtx, e.ffmpegPath, req.SourcePath)
	}
	tmp, err := os.CreateTemp("", "drive9-video-probe-*"+videoMIMEToExt(req.ContentType))
	if err != nil {
		return 0, fmt.Errorf("create video probe source: %w", err)
	}
	path := tmp.Name()
	defer func() { _ = os.Remove(path) }()
	if _, err = tmp.Write(req.Data); err != nil {
		_ = tmp.Close()
		return 0, fmt.Errorf("write video probe source: %w", err)
	}
	if err = tmp.Close(); err != nil {
		return 0, fmt.Errorf("close video probe source: %w", err)
	}
	return probeVideoDuration(probeCtx, e.ffmpegPath, path)
}

// ExtractVideoSegment samples and describes one bounded timeline range.
func (e *OpenAIVideoTextExtractor) ExtractVideoSegment(ctx context.Context, req VideoExtractRequest, segment VideoVisualSegment) (string, VideoExtractUsage, error) {
	frameCtx, cancelFrames := e.operationContext(ctx)
	frames, originalFrameCount, err := e.extractFramesForSegment(frameCtx, req.Data, req.ContentType, req.SourcePath, segment)
	cancelFrames()
	if err != nil {
		return "", VideoExtractUsage{}, fmt.Errorf("extract segment frames: %w", err)
	}
	if len(frames) == 0 {
		return "", VideoExtractUsage{}, fmt.Errorf("no frames extracted from video segment %d", segment.Index)
	}
	text, usage, err := e.describeFramesInBatches(ctx, frames, req.BeforeProviderCall)
	usage.FramesExtracted = originalFrameCount
	if err != nil {
		return "", usage, err
	}
	return text, usage, nil
}

// SynthesizeVideoText combines ordered bounded segment summaries without
// resending images. It uses the same OpenAI-compatible endpoint and model.
func (e *OpenAIVideoTextExtractor) SynthesizeVideoText(ctx context.Context, req VideoExtractRequest, summaries string) (string, VideoExtractUsage, error) {
	prompt := "You are receiving ordered summaries for consecutive video timeline segments, not images. Apply the extraction instructions and output contract below to synthesize one result for the complete timeline. Preserve meaningful changes, avoid repetition, and do not invent details.\n\n" + e.prompt + "\n\nTimeline segment summaries:\n" + summaries
	return e.describeContent(ctx, []map[string]any{{"type": "text", "text": prompt}}, req.BeforeProviderCall)
}

func buildVideoContactSheet(frames [][]byte, tile, cols, rows int) []byte {
	canvas := image.NewRGBA(image.Rect(0, 0, cols*tile, rows*tile))
	valid := 0
	for i, data := range frames {
		img, _, err := image.Decode(bytes.NewReader(data))
		if err != nil {
			continue
		}
		thumb := fitVideoThumbnail(img, tile, tile)
		x := (i % cols) * tile
		y := (i / cols) * tile
		draw.Draw(canvas, image.Rect(x, y, x+tile, y+tile), thumb, image.Point{}, draw.Src)
		valid++
	}
	if valid == 0 {
		return nil
	}
	var encoded bytes.Buffer
	if err := jpeg.Encode(&encoded, canvas, &jpeg.Options{Quality: 75}); err != nil {
		return nil
	}
	return encoded.Bytes()
}

func fitVideoThumbnail(src image.Image, maxWidth, maxHeight int) image.Image {
	b := src.Bounds()
	if b.Dx() <= 0 || b.Dy() <= 0 {
		return src
	}
	scale := float64(maxWidth) / float64(b.Dx())
	if h := float64(b.Dy()) * scale; h > float64(maxHeight) {
		scale = float64(maxHeight) / float64(b.Dy())
	}
	w := max(1, int(float64(b.Dx())*scale))
	h := max(1, int(float64(b.Dy())*scale))
	dst := image.NewRGBA(image.Rect(0, 0, w, h))
	for y := 0; y < h; y++ {
		for x := 0; x < w; x++ {
			sx := b.Min.X + x*b.Dx()/w
			sy := b.Min.Y + y*b.Dy()/h
			dst.Set(x, y, src.At(sx, sy))
		}
	}
	return dst
}

func videoFrameDHash(data []byte) uint64 {
	if err := validateVideoFrame(data); err != nil {
		return 0
	}
	img, _, err := image.Decode(bytes.NewReader(data))
	if err != nil {
		return 0
	}
	var gray [9 * 8]uint8
	bounds := img.Bounds()
	for y := 0; y < 8; y++ {
		sy := bounds.Min.Y + (y*bounds.Dy())/8
		for x := 0; x < 9; x++ {
			sx := bounds.Min.X + (x*bounds.Dx())/9
			r, g, b, _ := img.At(sx, sy).RGBA()
			gray[y*9+x] = uint8((299*r + 587*g + 114*b) / 1000 >> 8)
		}
	}
	var hash uint64
	for y := 0; y < 8; y++ {
		for x := 0; x < 8; x++ {
			hash <<= 1
			if gray[y*9+x] > gray[y*9+x+1] {
				hash++
			}
		}
	}
	return hash
}

func validateVideoFrame(data []byte) error {
	cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
	if err != nil {
		return err
	}
	if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxVideoFrameDimension || cfg.Height > maxVideoFrameDimension || int64(cfg.Width)*int64(cfg.Height) > maxVideoFramePixels {
		return ErrVideoFrameTooLarge
	}
	return nil
}

func videoHashNearDuplicate(hash uint64, previous []uint64) bool {
	for _, old := range previous {
		if bits.OnesCount64(hash^old) <= 8 {
			return true
		}
	}
	return false
}

func minInt(a, b int) int {
	if a < b {
		return a
	}
	return b
}

// extractFrames writes video bytes to a temp file and uses FFmpeg to extract
// JPEG frames at the configured interval.
func (e *OpenAIVideoTextExtractor) extractFrames(ctx context.Context, data []byte, contentType, stagedSource string) ([][]byte, int, error) {
	return e.extractFramesForSegment(ctx, data, contentType, stagedSource, VideoVisualSegment{})
}

func (e *OpenAIVideoTextExtractor) extractFramesForSegment(ctx context.Context, data []byte, contentType, stagedSource string, segment VideoVisualSegment) ([][]byte, int, error) {
	root := ""
	if stagedSource != "" {
		root = filepath.Dir(stagedSource)
	}
	tmpDir, err := os.MkdirTemp(root, "drive9-video-extract-*")
	if err != nil {
		return nil, 0, fmt.Errorf("create temp dir: %w", err)
	}
	defer func() { _ = os.RemoveAll(tmpDir) }()

	ext := videoMIMEToExt(contentType)
	videoPath := stagedSource
	if videoPath == "" {
		videoPath = filepath.Join(tmpDir, "input"+ext)
		if err := os.WriteFile(videoPath, data, 0o600); err != nil {
			return nil, 0, fmt.Errorf("write temp video: %w", err)
		}
	}

	outputPattern := filepath.Join(tmpDir, "frame_%04d.jpg")
	// Sample across the complete timeline. The old fixed-fps-only path stopped
	// after maxFrames and therefore described only the beginning of long videos.
	// When ffprobe is unavailable, retain the configured interval as a safe
	// fallback rather than failing an otherwise valid extraction.
	if segment.EndSeconds > segment.StartSeconds {
		if err := extractVideoFramesForTimelineSegment(ctx, e.ffmpegPath, videoPath, outputPattern, segment, float64(e.frameInterval)); err != nil {
			return nil, 0, err
		}
	} else {
		duration, probeErr := probeVideoDuration(ctx, e.ffmpegPath, videoPath)
		if probeErr != nil {
			return nil, 0, fmt.Errorf("probe legacy video duration: %w", probeErr)
		}
		if _, err := videoVisualSegments(duration); err != nil {
			return nil, 0, err
		}
		timestamps := videoSampleTimestamps(duration, float64(e.frameInterval), defaultLegacyVideoMaxFrames)
		if err := extractVideoFramesAtTimestamps(ctx, e.ffmpegPath, videoPath, tmpDir, timestamps); err != nil {
			return nil, 0, err
		}
	}

	entries, err := os.ReadDir(tmpDir)
	if err != nil {
		return nil, 0, fmt.Errorf("read frame dir: %w", err)
	}
	var framePaths []string
	for _, entry := range entries {
		if strings.HasPrefix(entry.Name(), "frame_") && strings.HasSuffix(entry.Name(), ".jpg") {
			framePaths = append(framePaths, filepath.Join(tmpDir, entry.Name()))
		}
	}
	sort.Strings(framePaths)
	if len(framePaths) == 0 && segment.EndSeconds > segment.StartSeconds {
		return nil, 0, errVideoSegmentNoFrames
	}

	providerFrames, retained := loadDeduplicatedVideoProviderFrames(framePaths)
	return providerFrames, retained, nil
}

func loadDeduplicatedVideoProviderFrames(framePaths []string) ([][]byte, int) {
	const (
		tile     = 512
		cols     = 2
		rows     = 2
		perSheet = cols * rows
	)
	var (
		hashes         []uint64
		pending        [][]byte
		providerFrames [][]byte
		retained       int
	)
	flushSheet := func() {
		if sheet := buildVideoContactSheet(pending, tile, cols, rows); len(sheet) > 0 {
			providerFrames = append(providerFrames, sheet)
		}
		pending = pending[:0]
	}
	for _, framePath := range framePaths {
		frame, err := os.ReadFile(framePath)
		_ = os.Remove(framePath)
		if err != nil || len(frame) == 0 || validateVideoFrame(frame) != nil {
			continue
		}
		hash := videoFrameDHash(frame)
		if videoHashNearDuplicate(hash, hashes) {
			continue
		}
		hashes = append(hashes, hash)
		retained++
		pending = append(pending, frame)
		if len(pending) == perSheet {
			flushSheet()
		}
	}
	if retained == 1 && len(providerFrames) == 0 && len(pending) == 1 {
		return pending, retained
	}
	if len(pending) > 0 {
		flushSheet()
	}
	return providerFrames, retained
}

func videoSampleTimestamps(duration, interval float64, maxFrames ...int) []float64 {
	if duration <= 0 {
		return nil
	}
	if interval <= 0 {
		interval = defaultVideoFrameInterval
	}
	nearEnd := max(0, duration-min(0.1, duration/2))
	if len(maxFrames) > 0 && maxFrames[0] > 0 {
		count := min(maxFrames[0], max(2, int(math.Ceil(duration/interval))+1))
		timestamps := make([]float64, count)
		for i := range timestamps {
			timestamps[i] = nearEnd * float64(i) / float64(count-1)
		}
		return timestamps
	}
	count := max(1, int(math.Ceil(duration/interval)))
	timestamps := make([]float64, count)
	for i := range timestamps {
		timestamps[i] = float64(i) * interval
	}
	if timestamps[len(timestamps)-1] < nearEnd {
		timestamps = append(timestamps, nearEnd)
	}
	return timestamps
}

func videoVisualSegments(duration float64) ([]VideoVisualSegment, error) {
	if duration <= 0 || math.IsNaN(duration) || math.IsInf(duration, 0) {
		return nil, errors.New("video duration must be positive")
	}
	segmentSeconds := float64(defaultVideoVisualSegmentSeconds)
	if duration > segmentSeconds*float64(defaultVideoVisualMaxSegments) {
		return nil, ErrVideoDurationTooLong
	}
	count := int(math.Ceil(duration / segmentSeconds))
	segments := make([]VideoVisualSegment, count)
	for i := range segments {
		start := float64(i) * segmentSeconds
		segments[i] = VideoVisualSegment{
			Index:        i,
			StartSeconds: start,
			EndSeconds:   min(duration, start+segmentSeconds),
		}
	}
	return segments, nil
}

func videoSegmentSampleTimestamps(segment VideoVisualSegment, interval float64) []float64 {
	if segment.EndSeconds <= segment.StartSeconds {
		return nil
	}
	relative := videoSampleTimestamps(segment.EndSeconds-segment.StartSeconds, interval)
	for i := range relative {
		relative[i] += segment.StartSeconds
	}
	return relative
}

func extractVideoFramesForTimelineSegment(
	ctx context.Context,
	ffmpegPath, videoPath, outputPattern string,
	segment VideoVisualSegment,
	interval float64,
) error {
	timestamps := videoSegmentSampleTimestamps(segment, interval)
	if len(timestamps) == 0 {
		return errors.New("no video segment sample timestamps")
	}
	sampleInterval := segment.EndSeconds - segment.StartSeconds
	if len(timestamps) > 1 {
		sampleInterval = timestamps[1] - timestamps[0]
	}
	args := []string{
		"-ss", strconv.FormatFloat(segment.StartSeconds, 'f', 3, 64),
		"-i", videoPath,
		"-t", strconv.FormatFloat(segment.EndSeconds-segment.StartSeconds, 'f', 3, 64),
		"-vf", videoFrameFilter(sampleInterval),
		"-q:v", "2",
		"-f", "image2",
		"-y", outputPattern,
	}
	cmd := exec.CommandContext(ctx, ffmpegPath, args...)
	cmd.Stderr = io.Discard
	cmd.Stdout = io.Discard
	if err := cmd.Run(); err != nil {
		return fmt.Errorf("ffmpeg segment %.3f-%.3fs: %w", segment.StartSeconds, segment.EndSeconds, err)
	}
	nearEnd := max(segment.StartSeconds, segment.EndSeconds-min(0.1, (segment.EndSeconds-segment.StartSeconds)/2))
	outputPath := filepath.Join(filepath.Dir(outputPattern), "frame_9999.jpg")
	return extractVideoFrameAtTimestamp(ctx, ffmpegPath, videoPath, outputPath, nearEnd)
}

func extractVideoFrameAtTimestamp(ctx context.Context, ffmpegPath, videoPath, outputPath string, timestamp float64) error {
	args := []string{
		"-ss", strconv.FormatFloat(timestamp, 'f', 3, 64),
		"-i", videoPath,
		"-frames:v", "1",
		"-vf", videoScaleFilter(),
		"-q:v", "2",
		"-y", outputPath,
	}
	cmd := exec.CommandContext(ctx, ffmpegPath, args...)
	cmd.Stderr = io.Discard
	cmd.Stdout = io.Discard
	if err := cmd.Run(); err != nil {
		return fmt.Errorf("ffmpeg sample at %.3fs: %w", timestamp, err)
	}
	return nil
}

func extractVideoFramesAtTimestamps(ctx context.Context, ffmpegPath, videoPath, outputDir string, timestamps []float64) error {
	if len(timestamps) == 0 {
		return errors.New("no video sample timestamps")
	}
	for i, timestamp := range timestamps {
		outputPath := filepath.Join(outputDir, fmt.Sprintf("frame_%04d.jpg", i+1))
		if err := extractVideoFrameAtTimestamp(ctx, ffmpegPath, videoPath, outputPath, timestamp); err != nil {
			return err
		}
	}
	return nil
}

func videoFrameFilter(interval float64) string {
	return fmt.Sprintf("fps=1/%g,%s", interval, videoScaleFilter())
}

func videoScaleFilter() string {
	return fmt.Sprintf("scale='min(%d,iw)':'min(%d,ih)':force_original_aspect_ratio=decrease", maxVideoFrameDimension, maxVideoFrameDimension)
}

func probeVideoDuration(ctx context.Context, ffmpegPath, videoPath string) (float64, error) {
	probePath := "ffprobe"
	if strings.HasSuffix(ffmpegPath, "ffmpeg") {
		probePath = strings.TrimSuffix(ffmpegPath, "ffmpeg") + "ffprobe"
	}
	cmd := exec.CommandContext(ctx, probePath, "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", videoPath)
	out, err := cmd.Output()
	if err != nil {
		if ctx.Err() != nil {
			return 0, ctx.Err()
		}
		return 0, err
	}
	value, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
	if err != nil || value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) {
		return 0, fmt.Errorf("invalid video duration %q", strings.TrimSpace(string(out)))
	}
	return value, nil
}

func (e *OpenAIVideoTextExtractor) describeFramesInBatches(ctx context.Context, frames [][]byte, beforeProviderCall func()) (string, VideoExtractUsage, error) {
	batches, err := e.partitionVideoProviderFrames(frames)
	if err != nil {
		return "", VideoExtractUsage{}, err
	}
	var total VideoExtractUsage
	total.FramesExtracted = len(frames)
	summaries := make([]string, 0, len(batches))
	for _, batch := range batches {
		text, usage, err := e.describeFrameBatch(ctx, batch, beforeProviderCall)
		if err != nil {
			return "", total, err
		}
		accumulateVideoExtractUsage(&total, usage)
		summaries = append(summaries, sanitizeExtractedText(text, maxVideoBatchSummaryBytes))
	}
	if len(summaries) == 1 {
		return summaries[0], total, nil
	}
	prompt := "Synthesize these ordered visual batch summaries into one result for the same video segment. Apply the output contract below, preserve meaningful changes, avoid repetition, and do not invent details.\n\n" + e.prompt + "\n\nBatch summaries:\n" + strings.Join(summaries, "\n")
	text, usage, err := e.describeContent(ctx, []map[string]any{{"type": "text", "text": prompt}}, beforeProviderCall)
	if err != nil {
		return "", total, err
	}
	accumulateVideoExtractUsage(&total, usage)
	return text, total, nil
}

func (e *OpenAIVideoTextExtractor) describeFrameBatch(ctx context.Context, frames [][]byte, beforeProviderCall func()) (string, VideoExtractUsage, error) {
	return e.describeContent(ctx, videoFrameContent(e.prompt, frames), beforeProviderCall)
}

func (e *OpenAIVideoTextExtractor) partitionVideoProviderFrames(frames [][]byte) ([][][]byte, error) {
	if len(frames) == 0 {
		return nil, errors.New("no video provider frames")
	}
	textOnlyBody, err := e.marshalDescribeContent(videoFrameContent(e.prompt, nil))
	if err != nil {
		return nil, err
	}
	textOnlySize := int64(len(textOnlyBody))
	var batches [][][]byte
	current := make([][]byte, 0, len(frames))
	currentSize := textOnlySize
	for _, frame := range frames {
		singleFrameBody, err := e.marshalDescribeContent(videoFrameContent(e.prompt, [][]byte{frame}))
		if err != nil {
			return nil, err
		}
		// The single-frame delta includes the image object and its array comma,
		// so it composes exactly without re-encoding the accumulated batch.
		framePayloadDelta := int64(len(singleFrameBody)) - textOnlySize
		if currentSize+framePayloadDelta <= e.maxProviderPayloadBytes {
			current = append(current, frame)
			currentSize += framePayloadDelta
			continue
		}
		if len(current) == 0 {
			return nil, ErrVideoProviderPayloadTooLarge
		}
		batches = append(batches, current)
		current = [][]byte{frame}
		currentSize = textOnlySize + framePayloadDelta
		if currentSize > e.maxProviderPayloadBytes {
			return nil, ErrVideoProviderPayloadTooLarge
		}
	}
	if len(current) > 0 {
		batches = append(batches, current)
	}
	return batches, nil
}

func videoFrameContent(prompt string, frames [][]byte) []map[string]any {
	content := []map[string]any{{"type": "text", "text": prompt}}
	for _, frame := range frames {
		content = append(content, map[string]any{
			"type":      "image_url",
			"image_url": map[string]any{"url": "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(frame)},
		})
	}
	return content
}

func accumulateVideoExtractUsage(total *VideoExtractUsage, usage VideoExtractUsage) {
	total.PromptTokens += usage.PromptTokens
	total.CompletionTokens += usage.CompletionTokens
}

func (e *OpenAIVideoTextExtractor) describeContent(ctx context.Context, content []map[string]any, beforeProviderCall func()) (string, VideoExtractUsage, error) {
	requestCtx, cancelRequest := e.operationContext(ctx)
	defer cancelRequest()
	body, err := e.marshalDescribeContent(content)
	if err != nil {
		return "", VideoExtractUsage{}, err
	}
	if int64(len(body)) > e.maxProviderPayloadBytes {
		return "", VideoExtractUsage{}, ErrVideoProviderPayloadTooLarge
	}
	httpReq, err := http.NewRequestWithContext(requestCtx, http.MethodPost, e.endpoint, bytes.NewReader(body))
	if err != nil {
		return "", VideoExtractUsage{}, err
	}
	httpReq.Header.Set("Authorization", "Bearer "+e.apiKey)
	httpReq.Header.Set("Content-Type", "application/json")

	if beforeProviderCall != nil {
		beforeProviderCall()
	}
	resp, err := e.client.Do(httpReq)
	if err != nil {
		return "", VideoExtractUsage{}, imageProviderTransportError(err)
	}
	defer func() { _ = resp.Body.Close() }()

	raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
	if err != nil {
		return "", VideoExtractUsage{}, err
	}
	var parsed struct {
		Error *struct {
			Message string `json:"message"`
		} `json:"error"`
		Choices []struct {
			Message struct {
				Content any `json:"content"`
			} `json:"message"`
		} `json:"choices"`
		Usage *struct {
			PromptTokens     int `json:"prompt_tokens"`
			CompletionTokens int `json:"completion_tokens"`
		} `json:"usage"`
	}
	if err := json.Unmarshal(raw, &parsed); err != nil {
		if resp.StatusCode >= 300 {
			return "", VideoExtractUsage{}, imageProviderHTTPError(resp.StatusCode)
		}
		return "", VideoExtractUsage{}, invalidImageProviderResponse(resp.StatusCode, err)
	}
	if resp.StatusCode >= 300 {
		return "", VideoExtractUsage{}, imageProviderHTTPError(resp.StatusCode)
	}
	if len(parsed.Choices) == 0 {
		return "", VideoExtractUsage{}, invalidImageProviderResponse(resp.StatusCode, errors.New("vision api returned no choices"))
	}
	text := extractOpenAIContentText(parsed.Choices[0].Message.Content)
	if strings.TrimSpace(text) == "" {
		return "", VideoExtractUsage{}, invalidImageProviderResponse(resp.StatusCode, errors.New("vision api returned empty text"))
	}
	var usage VideoExtractUsage
	if parsed.Usage != nil {
		usage.PromptTokens = parsed.Usage.PromptTokens
		usage.CompletionTokens = parsed.Usage.CompletionTokens
	}
	return text, usage, nil
}

func (e *OpenAIVideoTextExtractor) operationContext(ctx context.Context) (context.Context, context.CancelFunc) {
	if e.operationTimeout <= 0 {
		return context.WithCancel(ctx)
	}
	return context.WithTimeout(ctx, e.operationTimeout)
}

func (e *OpenAIVideoTextExtractor) marshalDescribeContent(content []map[string]any) ([]byte, error) {
	payload := map[string]any{
		"model": e.model,
		"messages": []map[string]any{
			{
				"role":    "user",
				"content": content,
			},
		},
		"temperature": 0,
		"max_tokens":  e.maxTokens,
	}
	return json.Marshal(payload)
}

func videoMIMEToExt(mime string) string {
	switch stripMIMEParams(mime) {
	case "video/mp4":
		return ".mp4"
	case "video/quicktime":
		return ".mov"
	case "video/x-msvideo":
		return ".avi"
	case "video/webm":
		return ".webm"
	case "video/x-matroska":
		return ".mkv"
	default:
		return ".mp4"
	}
}
