package jobs

import (
	"bytes"
	"context"
	"fmt"
	"html"
	"image"
	"image/color"
	"image/draw"
	_ "image/gif"
	"image/jpeg"
	_ "image/png"
	"io"
	"log/slog"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"time"

	"golang.org/x/image/font"
	"golang.org/x/image/font/basicfont"
	"golang.org/x/image/math/fixed"
	_ "golang.org/x/image/webp"
	"warpbox.dev/backend/libs/config"
	"warpbox.dev/backend/libs/services"
)

type ThumbnailJobResult struct {
	Scanned   int
	Generated int
	Failed    int
}

func GenerateThumbnailsForBoxAsync(uploadService *services.UploadService, logger *slog.Logger, boxID string) {
	go func() {
		box, err := uploadService.GetBox(boxID)
		if err != nil {
			logger.Warn("thumbnail box lookup failed", "source", "thumbnail", "severity", "warn", "code", 4204, "box_id", boxID, "error", err.Error())
			return
		}

		result, err := generateMissingThumbnailsForBox(uploadService, logger, box)
		if err != nil {
			logger.Warn("thumbnail one-shot job failed", "source", "thumbnail", "severity", "warn", "code", 4205, "box_id", boxID, "error", err.Error())
			return
		}
		if result.Generated > 0 || result.Failed > 0 {
			logger.Info("thumbnail one-shot job complete", "source", "thumbnail", "severity", "user_activity", "code", 2205, "box_id", boxID, "generated", result.Generated, "failed", result.Failed)
		}
	}()
}

func newThumbnailsJob(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService) job {
	return job{
		name:     "thumbnail",
		enabled:  cfg.ThumbnailEnabled,
		interval: cfg.ThumbnailEvery,
		run: func() {
			result, err := generateMissingThumbnails(uploadService, logger)
			if err != nil {
				logger.Warn("thumbnail job failed", "source", "thumbnail", "severity", "warn", "code", 4203, "error", err.Error())
				return
			}
			if result.Generated > 0 || result.Failed > 0 {
				logger.Info("thumbnail job complete", "source", "thumbnail", "severity", "user_activity", "code", 2204, "generated", result.Generated, "failed", result.Failed)
			}
		},
	}
}

func RunThumbnailsNow(uploadService *services.UploadService, logger *slog.Logger) (ThumbnailJobResult, error) {
	return generateMissingThumbnails(uploadService, logger)
}

func generateMissingThumbnails(uploadService *services.UploadService, logger *slog.Logger) (ThumbnailJobResult, error) {
	boxes, err := uploadService.ListBoxes(0)
	if err != nil {
		return ThumbnailJobResult{}, err
	}

	var result ThumbnailJobResult
	now := time.Now().UTC()
	for _, box := range boxes {
		if !box.ExpiresAt.After(now) {
			continue
		}

		boxResult, err := generateMissingThumbnailsForBox(uploadService, logger, box)
		result.Scanned += boxResult.Scanned
		result.Generated += boxResult.Generated
		result.Failed += boxResult.Failed
		if err != nil {
			return result, err
		}
	}

	return result, nil
}

func generateMissingThumbnailsForBox(uploadService *services.UploadService, logger *slog.Logger, box services.Box) (ThumbnailJobResult, error) {
	var result ThumbnailJobResult
	if !box.ExpiresAt.After(time.Now().UTC()) {
		return result, nil
	}

	changed := false
	for i := range box.Files {
		file := &box.Files[i]
		needsPrimary := file.Thumbnail == "" && needsThumbnail(*file)
		needsScenes := file.SceneThumbnail == "" && needsVideoScenes(*file)
		if !needsPrimary && !needsScenes {
			continue
		}
		result.Scanned++

		if needsPrimary {
			thumbnail, err := generateThumbnail(uploadService, box, *file)
			if err != nil {
				logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error())
				result.Failed++
			} else if thumbnail == "" {
				result.Failed++
			} else {
				file.Thumbnail = thumbnail
				changed = true
				result.Generated++
			}
		}

		if needsScenes {
			sceneThumbnail, err := generateVideoScenesThumbnail(uploadService, box, *file)
			if err != nil {
				logger.Warn("video scenes preview generation failed", "source", "thumbnail", "severity", "warn", "code", 4104, "file_id", file.ID, "error", err.Error())
				result.Failed++
			} else if sceneThumbnail == "" {
				result.Failed++
			} else {
				file.SceneThumbnail = sceneThumbnail
				changed = true
				result.Generated++
			}
		}
	}

	if changed {
		if err := uploadService.SaveBox(box); err != nil {
			return result, err
		}
	}
	return result, nil
}

func needsThumbnail(file services.File) bool {
	return file.PreviewKind == "image" || file.PreviewKind == "video" || isTextThumbnailCandidate(file)
}

func needsVideoScenes(file services.File) bool {
	return file.PreviewKind == "video" || strings.HasPrefix(strings.ToLower(file.ContentType), "video/")
}

func NeedsThumbnail(file services.File) bool {
	return needsThumbnail(file)
}

func NeedsVideoScenes(file services.File) bool {
	return needsVideoScenes(file)
}

func GenerateThumbnailForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
	return generateThumbnail(uploadService, box, file)
}

func GenerateVideoScenesForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
	return generateVideoScenesThumbnail(uploadService, box, file)
}

func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
	thumbnailName := "@thumb@" + file.ID + ".jpg"
	object, err := uploadService.OpenFileObject(context.Background(), box, file)
	if err != nil {
		return "", err
	}
	defer object.Body.Close()

	switch {
	case strings.HasPrefix(file.ContentType, "image/"):
		data, err := createImageThumbnail(object.Body)
		if err != nil {
			return "", err
		}
		_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
		return thumbnailName, err
	case strings.HasPrefix(file.ContentType, "video/"):
		data, err := createVideoThumbnail(object.Body)
		if err != nil {
			return "", err
		}
		_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
		return thumbnailName, err
	case isTextThumbnailCandidate(file):
		data, err := createTextThumbnail(file, object.Body)
		if err != nil {
			return "", err
		}
		_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
		return thumbnailName, err
	default:
		return "", nil
	}
}

func generateVideoScenesThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
	if !needsVideoScenes(file) {
		return "", nil
	}
	sceneName := "@scene@" + file.ID + ".jpg"
	object, err := uploadService.OpenFileObject(context.Background(), box, file)
	if err != nil {
		return "", err
	}
	defer object.Body.Close()

	data, err := createVideoScenesThumbnail(file, object.Body)
	if err != nil {
		return "", err
	}
	_, err = uploadService.PutThumbnailObject(context.Background(), box, sceneName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
	return sceneName, err
}

func isTextThumbnailCandidate(file services.File) bool {
	contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
	if i := strings.IndexByte(contentType, ';'); i >= 0 {
		contentType = strings.TrimSpace(contentType[:i])
	}
	if strings.HasPrefix(contentType, "text/") {
		return true
	}
	switch contentType {
	case "application/json", "application/ld+json", "application/xml", "application/javascript", "application/x-javascript", "application/markdown":
		return true
	}
	ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
	switch ext {
	case "c", "cc", "conf", "cpp", "cs", "css", "csv", "diff", "dockerfile", "go", "h", "hpp", "htm", "html", "ini", "java", "js", "json", "jsx", "kt", "log", "lua", "md", "mdown", "markdown", "php", "pl", "properties", "py", "rb", "rs", "sh", "sql", "swift", "toml", "ts", "tsx", "txt", "xml", "yaml", "yml", "zig":
		return true
	default:
		return false
	}
}

func createImageThumbnail(source io.Reader) ([]byte, error) {
	img, _, err := image.Decode(source)
	if err != nil {
		return nil, err
	}

	thumb := resizeNearest(img, 360, 240)
	var target bytes.Buffer
	err = jpeg.Encode(&target, thumb, &jpeg.Options{Quality: 82})
	if err != nil {
		return nil, err
	}
	return target.Bytes(), nil
}

func createVideoThumbnail(source io.Reader) ([]byte, error) {
	sourceFile, err := os.CreateTemp("", "warpbox-video-*")
	if err != nil {
		return nil, err
	}
	defer os.Remove(sourceFile.Name())
	if _, err := io.Copy(sourceFile, source); err != nil {
		sourceFile.Close()
		return nil, err
	}
	if err := sourceFile.Close(); err != nil {
		return nil, err
	}
	sourcePath := sourceFile.Name()
	candidates := []string{"00:00:01", "00:00:03", "00:00:06"}
	var fallback []byte
	for _, timestamp := range candidates {
		targetFile, err := os.CreateTemp("", "warpbox-thumb-*.jpg")
		if err != nil {
			return nil, err
		}
		targetPath := targetFile.Name()
		targetFile.Close()
		if err := extractVideoFrame(sourcePath, timestamp, targetPath, "scale=360:-1"); err != nil {
			os.Remove(targetPath)
			continue
		}
		data, err := os.ReadFile(targetPath)
		os.Remove(targetPath)
		if err != nil {
			continue
		}
		if len(fallback) == 0 {
			fallback = data
		}
		if usableVideoFrame(data) {
			return data, nil
		}
	}

	scenes, err := createVideoScenesThumbnailFromPath(services.File{Name: "video", ContentType: "video"}, sourcePath)
	if err == nil {
		img, err := jpeg.Decode(bytes.NewReader(scenes))
		if err == nil {
			thumb := resizeNearest(img, 360, 240)
			var target bytes.Buffer
			if err := jpeg.Encode(&target, thumb, &jpeg.Options{Quality: 82}); err == nil {
				return target.Bytes(), nil
			}
		}
	}
	if len(fallback) > 0 {
		return fallback, nil
	}
	return nil, fmt.Errorf("could not extract a usable video thumbnail")
}

func createVideoScenesThumbnail(file services.File, source io.Reader) ([]byte, error) {
	sourceFile, err := os.CreateTemp("", "warpbox-video-*")
	if err != nil {
		return nil, err
	}
	defer os.Remove(sourceFile.Name())
	if _, err := io.Copy(sourceFile, source); err != nil {
		sourceFile.Close()
		return nil, err
	}
	if err := sourceFile.Close(); err != nil {
		return nil, err
	}
	return createVideoScenesThumbnailFromPath(file, sourceFile.Name())
}

func createVideoScenesThumbnailFromPath(file services.File, sourcePath string) ([]byte, error) {
	info := probeVideoInfo(sourcePath, file)
	timestamps := videoSceneTimestamps(info.Duration)
	frames := make([]videoSceneFrame, 0, len(timestamps))

	for _, timestamp := range timestamps {
		targetFile, err := os.CreateTemp("", "warpbox-scene-*.jpg")
		if err != nil {
			continue
		}
		targetPath := targetFile.Name()
		targetFile.Close()
		if err := extractVideoFrame(sourcePath, timestamp, targetPath, "scale=640:-1"); err != nil {
			os.Remove(targetPath)
			continue
		}
		data, err := os.ReadFile(targetPath)
		os.Remove(targetPath)
		if err != nil {
			continue
		}
		img, err := jpeg.Decode(bytes.NewReader(data))
		if err != nil {
			continue
		}
		frames = append(frames, videoSceneFrame{Timestamp: timestamp, Image: img})
	}

	return renderVideoScenesThumbnail(file, info, frames), nil
}

func extractVideoFrame(sourcePath, timestamp, targetPath, scaleFilter string) error {
	return exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", timestamp, "-i", sourcePath, "-frames:v", "1", "-vf", scaleFilter, targetPath).Run()
}

type videoSceneFrame struct {
	Timestamp string
	Image     image.Image
}

type videoInfo struct {
	Codec     string
	Width     int
	Height    int
	Duration  float64
	FrameRate string
}

func probeVideoInfo(sourcePath string, file services.File) videoInfo {
	info := videoInfo{Codec: "unknown", FrameRate: "unknown"}
	output, err := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name,width,height,duration,avg_frame_rate", "-of", "default=noprint_wrappers=1", sourcePath).Output()
	if err != nil {
		if file.ContentType != "" {
			info.Codec = file.ContentType
		}
		return info
	}
	for _, line := range strings.Split(string(output), "\n") {
		key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
		if !ok || value == "" || value == "N/A" {
			continue
		}
		switch key {
		case "codec_name":
			info.Codec = value
		case "width":
			info.Width, _ = strconv.Atoi(value)
		case "height":
			info.Height, _ = strconv.Atoi(value)
		case "duration":
			info.Duration, _ = strconv.ParseFloat(value, 64)
		case "avg_frame_rate":
			info.FrameRate = simplifyFrameRate(value)
		}
	}
	return info
}

func simplifyFrameRate(value string) string {
	if value == "0/0" || value == "" {
		return "unknown"
	}
	parts := strings.Split(value, "/")
	if len(parts) != 2 {
		return value
	}
	n, errN := strconv.ParseFloat(parts[0], 64)
	d, errD := strconv.ParseFloat(parts[1], 64)
	if errN != nil || errD != nil || d == 0 {
		return value
	}
	return fmt.Sprintf("%.2f fps", n/d)
}

func videoSceneTimestamps(duration float64) []string {
	if duration > 4 {
		points := []float64{0.12, 0.33, 0.58, 0.82}
		timestamps := make([]string, 0, len(points))
		for _, point := range points {
			seconds := duration * point
			if seconds < 1 {
				seconds = 1
			}
			timestamps = append(timestamps, secondsToTimestamp(seconds))
		}
		return timestamps
	}
	return []string{"00:00:01", "00:00:03", "00:00:06", "00:00:10"}
}

func secondsToTimestamp(seconds float64) string {
	total := int(seconds + 0.5)
	hours := total / 3600
	minutes := total % 3600 / 60
	secs := total % 60
	return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, secs)
}

func usableVideoFrame(data []byte) bool {
	img, err := jpeg.Decode(bytes.NewReader(data))
	if err != nil {
		return false
	}
	return averageLuma(img) >= 18
}

func averageLuma(img image.Image) float64 {
	bounds := img.Bounds()
	width := bounds.Dx()
	height := bounds.Dy()
	if width <= 0 || height <= 0 {
		return 0
	}
	stepX := max(1, width/80)
	stepY := max(1, height/80)
	var total float64
	var samples int
	for y := bounds.Min.Y; y < bounds.Max.Y; y += stepY {
		for x := bounds.Min.X; x < bounds.Max.X; x += stepX {
			r, g, b, _ := img.At(x, y).RGBA()
			total += 0.2126*float64(r>>8) + 0.7152*float64(g>>8) + 0.0722*float64(b>>8)
			samples++
		}
	}
	if samples == 0 {
		return 0
	}
	return total / float64(samples)
}

func renderVideoScenesThumbnail(file services.File, info videoInfo, frames []videoSceneFrame) []byte {
	canvas := image.NewRGBA(image.Rect(0, 0, 1200, 630))
	drawSolid(canvas, canvas.Bounds(), color.RGBA{R: 0x0b, G: 0x0b, B: 0x12, A: 0xff})
	drawSolid(canvas, image.Rect(0, 0, 1200, 630), color.RGBA{R: 0x10, G: 0x13, B: 0x1f, A: 0xff})
	drawSolid(canvas, image.Rect(36, 36, 1164, 594), color.RGBA{R: 0x17, G: 0x17, B: 0x22, A: 0xff})
	drawSolid(canvas, image.Rect(36, 36, 1164, 96), color.RGBA{R: 0x20, G: 0x1b, B: 0x34, A: 0xff})
	drawSolid(canvas, image.Rect(36, 96, 1164, 100), color.RGBA{R: 0x7c, G: 0x3a, B: 0xed, A: 0xff})

	face := basicfont.Face7x13
	drawThumbText(canvas, face, "VIDEO SCENES PREVIEW", 62, 63, color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff})
	drawThumbText(canvas, face, trimThumbnailText(file.Name, 72), 62, 84, color.RGBA{R: 0xff, G: 0xfb, B: 0xeb, A: 0xff})

	meta := videoMetaLines(file, info)
	y := 122
	for _, line := range meta {
		drawThumbText(canvas, face, line, 62, y, color.RGBA{R: 0xcb, G: 0xd5, B: 0xe1, A: 0xff})
		y += 20
	}

	cells := []image.Rectangle{
		image.Rect(62, 212, 586, 388),
		image.Rect(614, 212, 1138, 388),
		image.Rect(62, 414, 586, 566),
		image.Rect(614, 414, 1138, 566),
	}
	for i, rect := range cells {
		drawSolid(canvas, rect, color.RGBA{R: 0x0f, G: 0x17, B: 0x22, A: 0xff})
		if i < len(frames) {
			drawImageCover(canvas, rect, frames[i].Image)
			drawSolid(canvas, image.Rect(rect.Min.X, rect.Min.Y, rect.Min.X+88, rect.Min.Y+24), color.RGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xcc})
			drawThumbText(canvas, face, frames[i].Timestamp, rect.Min.X+10, rect.Min.Y+17, color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
		} else {
			drawThumbText(canvas, face, "No frame available", rect.Min.X+18, rect.Min.Y+34, color.RGBA{R: 0x94, G: 0xa3, B: 0xb8, A: 0xff})
		}
	}

	var target bytes.Buffer
	_ = jpeg.Encode(&target, canvas, &jpeg.Options{Quality: 86})
	return target.Bytes()
}

func videoMetaLines(file services.File, info videoInfo) []string {
	resolution := "unknown resolution"
	if info.Width > 0 && info.Height > 0 {
		resolution = fmt.Sprintf("%dx%d", info.Width, info.Height)
	}
	duration := "unknown duration"
	if info.Duration > 0 {
		duration = secondsToHumanDuration(info.Duration)
	}
	contentType := file.ContentType
	if contentType == "" {
		contentType = "video"
	}
	return []string{
		"Duration: " + duration + "     Codec: " + info.Codec,
		"Resolution: " + resolution + "     Frame rate: " + info.FrameRate,
		"Type: " + contentType + "     Generated by Warpbox",
	}
}

func secondsToHumanDuration(seconds float64) string {
	total := int(seconds + 0.5)
	hours := total / 3600
	minutes := total % 3600 / 60
	secs := total % 60
	if hours > 0 {
		return fmt.Sprintf("%d:%02d:%02d", hours, minutes, secs)
	}
	return fmt.Sprintf("%d:%02d", minutes, secs)
}

func drawImageCover(dst *image.RGBA, rect image.Rectangle, src image.Image) {
	bounds := src.Bounds()
	srcW := bounds.Dx()
	srcH := bounds.Dy()
	dstW := rect.Dx()
	dstH := rect.Dy()
	if srcW <= 0 || srcH <= 0 || dstW <= 0 || dstH <= 0 {
		return
	}

	srcRatio := float64(srcW) / float64(srcH)
	dstRatio := float64(dstW) / float64(dstH)
	crop := bounds
	if srcRatio > dstRatio {
		newW := int(float64(srcH) * dstRatio)
		x0 := bounds.Min.X + (srcW-newW)/2
		crop = image.Rect(x0, bounds.Min.Y, x0+newW, bounds.Max.Y)
	} else if srcRatio < dstRatio {
		newH := int(float64(srcW) / dstRatio)
		y0 := bounds.Min.Y + (srcH-newH)/2
		crop = image.Rect(bounds.Min.X, y0, bounds.Max.X, y0+newH)
	}

	for y := rect.Min.Y; y < rect.Max.Y; y++ {
		for x := rect.Min.X; x < rect.Max.X; x++ {
			u := float64(x-rect.Min.X) / float64(dstW)
			v := float64(y-rect.Min.Y) / float64(dstH)
			srcX := crop.Min.X + min(crop.Dx()-1, int(u*float64(crop.Dx())))
			srcY := crop.Min.Y + min(crop.Dy()-1, int(v*float64(crop.Dy())))
			dst.Set(x, y, src.At(srcX, srcY))
		}
	}
}

func createTextThumbnail(file services.File, source io.Reader) ([]byte, error) {
	data, err := io.ReadAll(io.LimitReader(source, 128*1024))
	if err != nil {
		return nil, err
	}
	sourceText := strings.ReplaceAll(string(data), "\r\n", "\n")
	sourceText = strings.ReplaceAll(sourceText, "\r", "\n")

	mode := textThumbnailMode(file)
	title := strings.ToUpper(mode)
	var lines []string
	if mode == "HTML" {
		lines = renderedHTMLThumbnailLines(sourceText)
	} else if mode == "MARKDOWN" {
		lines = renderedMarkdownThumbnailLines(sourceText)
	} else {
		title = "CODE"
		lines = codeThumbnailLines(sourceText)
	}
	return renderTextThumbnail(file.Name, title, lines), nil
}

func textThumbnailMode(file services.File) string {
	contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
	if i := strings.IndexByte(contentType, ';'); i >= 0 {
		contentType = strings.TrimSpace(contentType[:i])
	}
	ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
	if ext == "html" || ext == "htm" || contentType == "text/html" {
		return "HTML"
	}
	if ext == "md" || ext == "mdown" || ext == "markdown" || contentType == "text/markdown" || contentType == "application/markdown" {
		return "MARKDOWN"
	}
	return "CODE"
}

func renderedHTMLThumbnailLines(source string) []string {
	text := regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`).ReplaceAllString(source, " ")
	text = regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`).ReplaceAllString(text, " ")
	text = regexp.MustCompile(`(?i)</?(p|div|section|article|main|header|footer|br|li|ul|ol|h[1-6]|tr|table|blockquote|pre|code)[^>]*>`).ReplaceAllString(text, "\n")
	text = regexp.MustCompile(`(?s)<[^>]+>`).ReplaceAllString(text, " ")
	text = html.UnescapeString(text)
	return documentThumbnailLines(text)
}

func renderedMarkdownThumbnailLines(source string) []string {
	text := regexp.MustCompile("(?s)```.*?```").ReplaceAllStringFunc(source, func(block string) string {
		block = strings.Trim(block, "` \n\t")
		lines := strings.Split(block, "\n")
		if len(lines) > 1 {
			lines = lines[1:]
		}
		return "\n" + strings.Join(lines, "\n") + "\n"
	})
	text = regexp.MustCompile(`(?m)^#{1,6}\s*`).ReplaceAllString(text, "")
	text = regexp.MustCompile(`!\[([^\]]*)\]\([^)]+\)`).ReplaceAllString(text, "$1")
	text = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(text, "$1")
	text = regexp.MustCompile("`([^`]+)`").ReplaceAllString(text, "$1")
	text = strings.NewReplacer("**", "", "__", "", "*", "", "_", "", "~~", "").Replace(text)
	return documentThumbnailLines(text)
}

func documentThumbnailLines(source string) []string {
	source = regexp.MustCompile(`[ \t]+`).ReplaceAllString(source, " ")
	rawLines := strings.Split(source, "\n")
	lines := make([]string, 0, 9)
	for _, raw := range rawLines {
		raw = strings.TrimSpace(raw)
		if raw == "" {
			continue
		}
		for _, line := range wrapTextThumbnailLine(raw, 43) {
			lines = append(lines, line)
			if len(lines) >= 9 {
				return lines
			}
		}
	}
	if len(lines) == 0 {
		return []string{"Rendered preview is empty."}
	}
	return lines
}

func codeThumbnailLines(source string) []string {
	rawLines := strings.Split(source, "\n")
	lines := make([]string, 0, 10)
	for _, raw := range rawLines {
		raw = strings.ReplaceAll(raw, "\t", "  ")
		raw = strings.TrimRight(raw, " ")
		if strings.TrimSpace(raw) == "" && len(lines) == 0 {
			continue
		}
		if len(raw) > 48 {
			raw = raw[:45] + "..."
		}
		lines = append(lines, raw)
		if len(lines) >= 10 {
			break
		}
	}
	if len(lines) == 0 {
		return []string{"(empty file)"}
	}
	return lines
}

func renderTextThumbnail(name, mode string, lines []string) []byte {
	canvas := image.NewRGBA(image.Rect(0, 0, 360, 240))
	drawSolid(canvas, canvas.Bounds(), color.RGBA{R: 0x0b, G: 0x0b, B: 0x16, A: 0xff})
	drawSolid(canvas, image.Rect(10, 10, 350, 230), color.RGBA{R: 0x17, G: 0x14, B: 0x2d, A: 0xff})
	drawSolid(canvas, image.Rect(10, 10, 350, 16), color.RGBA{R: 0xa7, G: 0x8b, B: 0xfa, A: 0xff})

	face := basicfont.Face7x13
	drawThumbText(canvas, face, trimThumbnailText(name, 38), 22, 36, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
	drawThumbText(canvas, face, mode+" PREVIEW", 22, 55, color.RGBA{R: 0x67, G: 0xe8, B: 0xf9, A: 0xff})

	codePane := image.Rect(22, 72, 338, 210)
	if mode == "CODE" {
		drawSolid(canvas, codePane, color.RGBA{R: 0x0f, G: 0x11, B: 0x1a, A: 0xff})
	} else {
		drawSolid(canvas, codePane, color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff})
	}

	y := 91
	for _, line := range lines {
		drawThumbText(canvas, face, line, 32, y, color.RGBA{R: 0xf8, G: 0xfa, B: 0xfc, A: 0xff})
		y += 14
		if y > 202 {
			break
		}
	}

	var target bytes.Buffer
	_ = jpeg.Encode(&target, canvas, &jpeg.Options{Quality: 84})
	return target.Bytes()
}

func drawSolid(dst *image.RGBA, rect image.Rectangle, c color.Color) {
	draw.Draw(dst, rect, &image.Uniform{c}, image.Point{}, draw.Src)
}

func drawThumbText(dst *image.RGBA, face font.Face, text string, x, y int, c color.Color) {
	d := font.Drawer{
		Dst:  dst,
		Src:  image.NewUniform(c),
		Face: face,
		Dot:  fixed.P(x, y),
	}
	d.DrawString(text)
}

func wrapTextThumbnailLine(text string, maxChars int) []string {
	if len(text) <= maxChars {
		return []string{text}
	}
	words := strings.Fields(text)
	if len(words) == 0 {
		return []string{text[:maxChars-3] + "..."}
	}
	lines := []string{}
	current := ""
	for _, word := range words {
		if current == "" {
			current = word
			continue
		}
		if len(current)+1+len(word) <= maxChars {
			current += " " + word
			continue
		}
		lines = append(lines, trimThumbnailText(current, maxChars))
		current = word
	}
	if current != "" {
		lines = append(lines, trimThumbnailText(current, maxChars))
	}
	return lines
}

func trimThumbnailText(text string, maxChars int) string {
	if len(text) <= maxChars {
		return text
	}
	if maxChars <= 3 {
		return text[:maxChars]
	}
	return strings.TrimSpace(text[:maxChars-3]) + "..."
}

func resizeNearest(src image.Image, maxWidth, maxHeight int) *image.RGBA {
	bounds := src.Bounds()
	width := bounds.Dx()
	height := bounds.Dy()
	if width <= 0 || height <= 0 {
		return image.NewRGBA(image.Rect(0, 0, 1, 1))
	}

	scale := min(float64(maxWidth)/float64(width), float64(maxHeight)/float64(height))
	if scale > 1 {
		scale = 1
	}
	targetWidth := max(1, int(float64(width)*scale))
	targetHeight := max(1, int(float64(height)*scale))
	dst := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight))

	for y := 0; y < targetHeight; y++ {
		for x := 0; x < targetWidth; x++ {
			srcX := bounds.Min.X + int(float64(x)/scale)
			srcY := bounds.Min.Y + int(float64(y)/scale)
			dst.Set(x, y, src.At(srcX, srcY))
		}
	}

	return dst
}
