// Package imaging produces the two derived variants from an original:
// thumb (512px long edge, JPEG q78) for the grid and preview (2048px long
// edge, JPEG q85) for the lightbox. Originals are never modified.
// Re-encoding strips EXIF (including GPS) from variants; orientation is
// applied to pixels first.
package imaging

import (
	"fmt"
	"image"
	"image/jpeg"
	"io"
	"os"
	"time"

	_ "image/gif"
	_ "image/png"

	"github.com/rwcarlsen/goexif/exif"
	"golang.org/x/image/draw"
	_ "golang.org/x/image/webp"
)

const (
	ThumbLongEdge   = 512
	ThumbQuality    = 78
	PreviewLongEdge = 2048
	PreviewQuality  = 85
)

type Result struct {
	Width   int // of the original, after orientation
	Height  int
	TakenAt time.Time // zero when EXIF has no usable timestamp
}

// ProcessFile decodes the image at path (JPEG/PNG/GIF/WebP — HEIC must be
// converted to JPEG first), applies EXIF orientation, and writes both
// variants as JPEG to thumbPath and previewPath.
func ProcessFile(path, thumbPath, previewPath string) (Result, error) {
	f, err := os.Open(path)
	if err != nil {
		return Result{}, err
	}
	orientation, takenAt := readExif(f)
	if _, err := f.Seek(0, io.SeekStart); err != nil {
		f.Close()
		return Result{}, err
	}
	img, _, err := image.Decode(f)
	f.Close()
	if err != nil {
		return Result{}, fmt.Errorf("decode: %w", err)
	}

	img = applyOrientation(img, orientation)
	bounds := img.Bounds()
	res := Result{Width: bounds.Dx(), Height: bounds.Dy(), TakenAt: takenAt}

	if err := writeVariant(img, previewPath, PreviewLongEdge, PreviewQuality); err != nil {
		return res, fmt.Errorf("preview: %w", err)
	}
	if err := writeVariant(img, thumbPath, ThumbLongEdge, ThumbQuality); err != nil {
		return res, fmt.Errorf("thumb: %w", err)
	}
	return res, nil
}

func writeVariant(img image.Image, path string, longEdge, quality int) error {
	out, err := os.Create(path)
	if err != nil {
		return err
	}
	defer out.Close()
	return jpeg.Encode(out, resize(img, longEdge), &jpeg.Options{Quality: quality})
}

// resize scales so the long edge is at most longEdge, never upscaling.
func resize(img image.Image, longEdge int) image.Image {
	b := img.Bounds()
	w, h := b.Dx(), b.Dy()
	long := max(w, h)
	if long <= longEdge {
		return img
	}
	scale := float64(longEdge) / float64(long)
	nw, nh := max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale))
	dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
	draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
	return dst
}

func readExif(r io.Reader) (orientation int, takenAt time.Time) {
	orientation = 1
	x, err := exif.Decode(r)
	if err != nil {
		return orientation, takenAt
	}
	if tag, err := x.Get(exif.Orientation); err == nil {
		if v, err := tag.Int(0); err == nil && v >= 1 && v <= 8 {
			orientation = v
		}
	}
	if t, err := x.DateTime(); err == nil {
		takenAt = t
	}
	return orientation, takenAt
}

// applyOrientation bakes the EXIF orientation into pixels (values 2-8 per
// the EXIF spec; 1 is identity).
func applyOrientation(img image.Image, orientation int) image.Image {
	switch orientation {
	case 2:
		return transform(img, func(x, y, w, h int) (int, int) { return w - 1 - x, y })
	case 3:
		return transform(img, func(x, y, w, h int) (int, int) { return w - 1 - x, h - 1 - y })
	case 4:
		return transform(img, func(x, y, w, h int) (int, int) { return x, h - 1 - y })
	case 5:
		return transformSwap(img, func(x, y, w, h int) (int, int) { return y, x })
	case 6:
		return transformSwap(img, func(x, y, w, h int) (int, int) { return y, h - 1 - x })
	case 7:
		return transformSwap(img, func(x, y, w, h int) (int, int) { return w - 1 - y, h - 1 - x })
	case 8:
		return transformSwap(img, func(x, y, w, h int) (int, int) { return w - 1 - y, x })
	default:
		return img
	}
}

// transform maps destination (x,y) to source coordinates, same dimensions.
func transform(img image.Image, srcAt func(x, y, w, h int) (int, int)) image.Image {
	b := img.Bounds()
	w, h := b.Dx(), b.Dy()
	dst := image.NewRGBA(image.Rect(0, 0, w, h))
	for y := 0; y < h; y++ {
		for x := 0; x < w; x++ {
			sx, sy := srcAt(x, y, w, h)
			dst.Set(x, y, img.At(b.Min.X+sx, b.Min.Y+sy))
		}
	}
	return dst
}

// transformSwap is for orientations that swap width and height.
func transformSwap(img image.Image, srcAt func(x, y, w, h int) (int, int)) image.Image {
	b := img.Bounds()
	w, h := b.Dx(), b.Dy()
	dst := image.NewRGBA(image.Rect(0, 0, h, w))
	for y := 0; y < w; y++ {
		for x := 0; x < h; x++ {
			sx, sy := srcAt(x, y, w, h)
			dst.Set(x, y, img.At(b.Min.X+sx, b.Min.Y+sy))
		}
	}
	return dst
}
