package main

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"time"
)

func init() {
	thumbnailers = append(thumbnailers, &videoThumbnailer{})
}

// ffmpegPath is resolved once at startup. Empty means ffmpeg is not installed,
// in which case the video thumbnailer disables itself and videos keep their
// icon. ffmpeg is a graceful soft-dependency, not a hard requirement: the
// binary still runs standalone without it.
var ffmpegPath, _ = exec.LookPath("ffmpeg")

type videoThumbnailer struct{}

func isVideoExt(ext string) bool {
	switch ext {
	case ".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v":
		return true
	}
	return false
}

// isVideoFile reports whether name is a video container we generate previews
// for. Independent of ffmpeg availability — used to tag tiles with a play badge.
func isVideoFile(name string) bool {
	return isVideoExt(strings.ToLower(filepath.Ext(name)))
}

func (vt *videoThumbnailer) CanHandle(ext string) bool {
	return ffmpegPath != "" && isVideoExt(ext)
}

// Generate satisfies Thumbnailer but is never used: handleThumb routes video
// through the PathThumbnailer branch (GenerateFromPath), since ffmpeg needs a
// seekable file path and we must not read large videos into memory.
func (vt *videoThumbnailer) Generate(src io.Reader, dst io.Writer, width int) error {
	return fmt.Errorf("video thumbnails require a file path; use GenerateFromPath")
}

// GenerateFromPath extracts a single frame and writes it to dst as JPEG. It
// prefers a frame from the middle of the clip (see seekOffsets) to avoid the
// black/fade-in frames common at the very start, falling back to earlier
// offsets. The (small) JPEG is buffered first so a failed attempt never leaves
// partial bytes in dst.
func (vt *videoThumbnailer) GenerateFromPath(srcPath string, dst io.Writer, width int) error {
	for _, seek := range seekOffsets(srcPath) {
		var buf bytes.Buffer
		if err := runFFmpegFrame(srcPath, &buf, width, seek); err == nil && buf.Len() > 0 {
			_, err = dst.Write(buf.Bytes())
			return err
		}
	}
	return fmt.Errorf("ffmpeg produced no frame for %s", srcPath)
}

// seekOffsets returns seek targets (in seconds) to try in order. When the clip
// duration is known the midpoint comes first — a representative frame that
// dodges black intros and end credits. "1" and "0" are fallbacks for when the
// duration is unknown or the midpoint seek yields nothing (very short clips, or
// a keyframe gap at the midpoint).
func seekOffsets(srcPath string) []string {
	if d := videoDurationSec(srcPath); d > 2 {
		return []string{strconv.FormatFloat(d/2, 'f', 2, 64), "1", "0"}
	}
	return []string{"1", "0"}
}

// videoDurationSec parses the clip duration from ffmpeg's own stderr, so it
// depends only on ffmpeg (ffprobe is not installed everywhere we deploy).
// `ffmpeg -i <file>` with no output exits non-zero but prints the container
// metadata, including a "Duration: HH:MM:SS.ss" line. Returns 0 when the
// duration can't be determined, so the caller falls back to a fixed offset.
func videoDurationSec(srcPath string) float64 {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	cmd := exec.CommandContext(ctx, ffmpegPath, "-i", srcPath)
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	_ = cmd.Run() // expected to "fail": no output file specified. We want stderr.
	return parseFFmpegDuration(stderr.String())
}

// parseFFmpegDuration extracts seconds from an ffmpeg log containing a line like
// "  Duration: 00:01:23.45, start: 0.000000, bitrate: 1234 kb/s".
func parseFFmpegDuration(log string) float64 {
	_, after, ok := strings.Cut(log, "Duration:")
	if !ok {
		return 0
	}
	field := strings.TrimSpace(after)
	if c := strings.IndexByte(field, ','); c >= 0 {
		field = field[:c]
	}
	parts := strings.Split(strings.TrimSpace(field), ":")
	if len(parts) != 3 {
		return 0
	}
	h, err1 := strconv.ParseFloat(parts[0], 64)
	m, err2 := strconv.ParseFloat(parts[1], 64)
	s, err3 := strconv.ParseFloat(parts[2], 64)
	if err1 != nil || err2 != nil || err3 != nil {
		return 0
	}
	return h*3600 + m*60 + s
}

func runFFmpegFrame(srcPath string, dst io.Writer, width int, seek string) error {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	cmd := exec.CommandContext(ctx, ffmpegPath,
		"-ss", seek,
		"-i", srcPath,
		"-frames:v", "1",
		// Cap width at the requested size but never upscale (matches the image
		// thumbnailer); -2 keeps the height even. The comma in min() is escaped
		// so ffmpeg does not read it as a filter separator.
		"-vf", "scale='min(iw\\,"+strconv.Itoa(width)+")':-2",
		"-f", "mjpeg",
		"-q:v", "5",
		"pipe:1",
	)
	cmd.Stdout = dst
	return cmd.Run()
}
