// This package enables creating simple animations by drawing onto an image, using ffmpeg which could be in the system path.

package animate

import (
	"cmp"
	"fmt"
	"image"
	"image/color"
	"image/png"
	"io"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strings"
	"time"
)

// Animation implements the [draw.Image] interface; you can draw to it and then invoke [EmitFrame].
//
// That said, some implementations might be optimized for known image types, in which case, passing a pointer to the
// [NRGBA] field might be more efficient.
type Animation struct {
	image.NRGBA

	// the first error where we encountered during writing
	err error

	// the pipe to ffmpeg's stdin, nil if the animation is closed
	w io.WriteCloser

	// the ffmpeg process, only applicable if w above is not nil
	c *exec.Cmd

	// the name of the file we're writing to,
	// and where we'll move it to if nothing explodes
	tmp, out string

	// framerate
	rate float64

	// the current frame number
	frame int
}

// EmitFrame writes the image as it presently appears as a frame to the animation.
//
// If this is successful, then the frame counter is advanced. The frame buffer isn't cleared.
func (a *Animation) EmitFrame() error {
	// if we've already encountered an error, return it
	if a.err != nil {
		return a.err
	}

	// if the pipe is closed, then we can't write, obviously.
	if a.w == nil {
		return os.ErrClosed
	}

	// try to write the frame to the pipe,
	// if if that fails, record the error and close the animation.
	if _, err := a.w.Write(a.Pix); err != nil {
		a.err = err
		return a.Close()
	}

	a.frame++

	// success!
	return nil
}

// Frame returns the current frame number, with the first frame being 0.
func (a *Animation) Frame() int {
	return a.frame
}

// Elapsed returns the timestamp for the current frame, with first frame having a timestamp of 0.
func (a *Animation) Elapsed() time.Duration {
	return time.Duration(float64(a.frame) * float64(time.Second) / a.rate)
}

// Close finishes writing the animation, and cleans up any resources.
func (a *Animation) Close() error {
	// the pipe is open, shut everything down.
	if a.w != nil {
		// no longer needs to be finalized
		runtime.SetFinalizer(a, nil)

		// close the pipe and wait for ffmpeg to finish,
		// setting a.err to the first error encountered.
		a.err = cmp.Or(a.err, a.w.Close(), a.c.Wait())
		a.w, a.c = nil, nil

		// if there are no errors so far, attempt to move the file to the destination.
		if a.err == nil {
			a.err = os.Rename(a.tmp, a.out)
		}

		// if there was an error, delete the temporary file we wrote.
		if a.err != nil {
			os.Remove(a.tmp)
		}
	}

	// return any error we may have encountered.
	return a.err
}

func paletteToFile(palette color.Palette) (*os.File, error) {
	r, w, err := os.Pipe()
	if err != nil {
		return nil, err
	}

	go func() {
		defer w.Close()

		img := image.NewPaletted(image.Rect(0, 0, 16, 16), palette)

		for i := 0; i < 256; i++ {
			img.Pix[i] = uint8(min(i, len(palette)-1))
		}

		// I'm not going to bother dealing with possible write errors,
		// those would be because ffmpeg terminated unexpectedly,
		// and has nothing to do with us.
		png.Encode(w, img)
	}()

	return r, nil
}

func finalize(a *Animation) {
	err := a.Close()
	log.Printf("animation was finalized without being closed; err=%v", err)
}

func new(r image.Rectangle, rate float64, palette color.Palette, out string, fmtArgs []string) (a *Animation, err error) {
	tmp, err := os.CreateTemp(filepath.Dir(out), "."+strings.TrimSuffix(filepath.Base(out), filepath.Ext(out))+"*.tmp")

	if err != nil {
		return nil, err
	}

	defer func() {
		// if things go wrong, delete the file we wrote.
		if err != nil {
			os.Remove(tmp.Name())
		}
	}()

	// we don't need the file open, we just want a safe place for ffmpeg to write to.
	if err := tmp.Close(); err != nil {
		return nil, err
	}

	var args []string

	args = append(args,
		"-hide_banner",
		"-loglevel", "error",
		"-f", "rawvideo",
		"-pixel_format", "rgba",
		"-video_size", fmt.Sprintf("%dx%d", r.Dx(), r.Dy()),
		"-framerate", fmt.Sprint(rate),
		"-i", "pipe:0",
	)

	var extraFiles []*os.File

	if palette != nil {
		f, err := paletteToFile(palette)
		if err != nil {
			return nil, err
		}

		args = append(args,
			"-i", fmt.Sprintf("pipe:%d", len(extraFiles)+3),
			"-filter_complex", "[0:v][1:v]paletteuse=dither=bayer",
		)

		extraFiles = append(extraFiles, f)
	}

	args = append(args, fmtArgs...)

	args = append(args, "-y", "file:"+tmp.Name())

	cmd := exec.Command("ffmpeg", args...)

	cmd.ExtraFiles = extraFiles
	cmd.Stderr = os.Stderr

	w, err := cmd.StdinPipe()
	if err != nil {
		return nil, err
	}

	err = cmd.Start()

	// close our copies of the file descriptors. The ffmpeg process, if it even started, has inherited them.
	// and if it didn't, we'd needed to close them ourselves.
	for _, f := range extraFiles {
		f.Close()
	}

	if err != nil {
		return nil, err
	}

	animation := &Animation{
		NRGBA: image.NRGBA{
			Pix:    make([]uint8, r.Dx()*r.Dy()*4),
			Stride: r.Dx() * 4,
			Rect:   r,
		},
		w:     w,
		c:     cmd,
		tmp:   tmp.Name(),
		out:   out,
		rate:  rate,
		frame: 0,
	}

	// failing to close the animation will leave temporary files on the filesystem, possibly leave the ffmpeg process hanging,
	// so let's try not to let that happen.
	runtime.SetFinalizer(animation, finalize)

	return animation, nil
}

// New begins a new animation at the given size and frame rate, to be written to the specified output file.
//
// The output filename must have one of the following extensions:
// - .gif (lossy, uses a default fixed palette)
// - .png / .apng (lossless)
// - .mp4 (lossless RGB, doesn't support transparency)
// - .mkv (lossless RGB, doesn't support transparency)
// - .webp (lossy YUV colorspace conversion, alpha supported)
// - .webm (lossy YUV colorspace conversion, alpha supported)
//
// Note that the output file won't be created immediately, the animation will be written to a temporary file and then renamed
// by the [Close] method if no errors are encountered.
func New(r image.Rectangle, rate float64, out string) (*Animation, error) {
	switch ext := filepath.Ext(out); ext {
	case ".gif":
		return new(r, rate, defaultPalette, out, []string{
			"-loop", "0",
			"-f", "gif",
		})
	case ".png", ".apng":
		return new(r, rate, nil, out, []string{
			"-vcodec", "apng",
			"-plays", "0",
			"-f", "apng",
		})
	case ".mp4":
		return new(r, rate, nil, out, []string{
			"-vcodec", "libx264rgb",
			"-preset", "ultrafast",
			"-qp", "0",
			"-f", "mp4",
		})
	case ".mkv":
		return new(r, rate, nil, out, []string{
			"-vcodec", "libx264rgb",
			"-preset", "ultrafast",
			"-qp", "0",
			"-f", "matroska",
		})
	case ".webp":
		return new(r, rate, nil, out, []string{
			"-vcodec", "libwebp_anim",
			"-lossless", "1",
			"-loop", "0",
			"-f", "webp",
		})
	case ".webm":
		return new(r, rate, nil, out, []string{
			"-vcodec", "vp9",
			"-lossless", "1",
			"-f", "webm",
		})
	default:
		return nil, fmt.Errorf("unsupported extension: %q", ext)
	}
}