package photo

import (
	"bytes"
	"errors"
	"fmt"
	"image"
	"image/jpeg"
	"image/png"
	"io"
	"os"
	"path/filepath"
	"strconv"
	"strings"

	"git.kirsle.net/apps/gosocial/pkg/config"
	"git.kirsle.net/apps/gosocial/pkg/log"
	"git.kirsle.net/apps/gosocial/pkg/models"
	"github.com/edwvee/exiffix"
	"golang.org/x/image/draw"
)

type UploadConfig struct {
	User      *models.User
	Extension string // 'jpg' or 'png' only.
	Data      []byte
	Crop      []int // x, y, w, h
}

// UploadPhoto handles an incoming photo to add to a user's account.
//
// Returns:
// - NewFilename() of the created photo file on disk.
// - NewFilename() of the cropped version, or "" if not cropping.
// - error on errors
func UploadPhoto(cfg UploadConfig) (string, string, error) {
	// Validate and normalize the extension.
	var extension = strings.ToLower(cfg.Extension)
	switch extension {
	case ".jpg":
		fallthrough
	case ".jpe":
		fallthrough
	case ".jpeg":
		extension = ".jpg"
	case ".png":
		extension = ".png"
	default:
		return "", "", errors.New("unsupported image extension, must be jpg or png")
	}

	// Decide on a filename for this photo.
	var (
		filename     = NewFilename(extension)
		cropFilename = NewFilename(extension)
	)

	// Decode the image using exiffix, which will auto-rotate jpeg images
	// based on their EXIF tags.
	reader := bytes.NewReader(cfg.Data)
	origImage, _, err := exiffix.Decode(reader)
	if err != nil {
		return "", "", err
	}

	// Read the config to get the image width.
	reader.Seek(0, io.SeekStart)
	var width, height = origImage.Bounds().Max.X, origImage.Bounds().Max.Y

	// Find the longest edge, if it's too large (over 1280px)
	// cap it to the max and scale the other dimension proportionally.
	log.Debug("UploadPhoto: taking a w=%d by h=%d image to name it %s", width, height, filename)
	if width >= height {
		log.Debug("Its width(%d) is >= its height (%d)", width, height)
		if width > config.MaxPhotoWidth {
			newWidth := config.MaxPhotoWidth
			log.Debug("\tnewWidth=%d", newWidth)
			log.Debug("\tnewHeight=(%d / %d) * %d", width, height, newWidth)
			height = int((float64(height) / float64(width)) * float64(newWidth))
			width = newWidth
			log.Debug("Its longest is width, scale to %dx%d", width, height)
		}
	} else {
		if height > config.MaxPhotoWidth {
			newHeight := config.MaxPhotoWidth
			width = int((float64(width) / float64(height)) * float64(newHeight))
			height = newHeight
			log.Debug("Its longest is height, scale to %sx%s", width, height)
		}
	}

	// Scale the image.
	scaledImg := Scale(origImage, image.Rect(0, 0, width, height), draw.ApproxBiLinear)

	// Write the image to disk.
	if err := ToDisk(filename, extension, scaledImg); err != nil {
		return "", "", err
	}

	// Are we producing a cropped image, too?
	log.Error("Are we to crop? %+v", cfg.Crop)
	if cfg.Crop != nil && len(cfg.Crop) >= 4 {
		log.Debug("Also cropping this image to %+v", cfg.Crop)
		var (
			x = cfg.Crop[0]
			y = cfg.Crop[1]
			w = cfg.Crop[2]
			h = cfg.Crop[3]
		)
		croppedImg := Crop(origImage, x, y, w, h)

		// Write that to disk, too.
		log.Debug("Writing cropped image to disk: %s", cropFilename)
		if err := ToDisk(cropFilename, extension, croppedImg); err != nil {
			return filename, "", err
		}

		// Return both filenames!
		return filename, cropFilename, nil
	}

	// Not cropping, return only the first filename.
	return filename, "", nil
}

// Scale down an image. Example:
//
// scaled := Scale(src, image.Rect(0, 0, 200, 200), draw.ApproxBiLinear)
func Scale(src image.Image, rect image.Rectangle, scale draw.Scaler) image.Image {
	dst := image.NewRGBA(rect)
	copyRect := image.Rect(
		rect.Min.X,
		rect.Min.Y,
		rect.Min.X+rect.Max.X,
		rect.Min.Y+rect.Max.Y,
	)
	scale.Scale(dst, copyRect, src, src.Bounds(), draw.Over, nil)
	return dst
}

// Crop an image, returning the new image. Example:
//
// cropped := Crop()
func Crop(src image.Image, x, y, w, h int) image.Image {
	dst := image.NewRGBA(image.Rect(0, 0, w, h))
	srcrect := image.Rect(x, y, x+w, y+h)
	draw.Copy(dst, image.ZP, src, srcrect, draw.Over, nil)
	return dst
}

// ReCrop an image, loading the original image from disk. Returns the newly created filename.
func ReCrop(filename string, x, y, w, h int) (string, error) {
	var (
		ext          = filepath.Ext(filename)
		cropFilename = NewFilename(ext)
	)

	fh, err := os.Open(DiskPath(filename))
	if err != nil {
		return "", err
	}

	// Decode the image.
	var img image.Image
	switch ext {
	case ".jpg":
		img, err = jpeg.Decode(fh)
		if err != nil {
			return "", err
		}
	case ".png":
		img, err = png.Decode(fh)
		if err != nil {
			return "", err
		}
	default:
		return "", errors.New("unsupported file type")
	}

	// Crop it.
	croppedImg := Crop(img, x, y, w, h)

	// Write it.
	err = ToDisk(cropFilename, ext, croppedImg)
	return cropFilename, err
}

// ParseCropCoords splits a string of x,y,w,h values into proper crop coordinates, or nil.
func ParseCropCoords(coords string) []int {
	// Parse and validate crop coordinates.
	var crop []int
	if len(coords) > 0 {
		aints := strings.Split(coords, ",")
		if len(aints) >= 4 {
			crop = []int{}
			for i, aint := range aints {
				if number, err := strconv.Atoi(strings.TrimSpace(aint)); err == nil {
					crop = append(crop, number)
				} else {
					log.Error("Failure to parse crop coordinates ('%s') at number %d: %s", coords, i, err)
				}
			}
		}
	}
	return crop
}

// ToDisk commits a photo image to disk in the right file format.
//
// Filename is like NewFilename() and it goes to e.g. "./web/static/photos/"
func ToDisk(filename string, extension string, img image.Image) error {
	if path, err := EnsurePath(filename); err == nil {
		fh, err := os.Create(path)
		if err != nil {
			return err
		}
		defer fh.Close()

		switch extension {
		case ".jpg":
			jpeg.Encode(fh, img, &jpeg.Options{
				Quality: config.JpegQuality,
			})
		case ".png":
			png.Encode(fh, img)
		}
	} else {
		return fmt.Errorf("couldn't EnsurePath: %s", err)
	}

	return nil
}

// Delete a photo from disk.
func Delete(filename string) error {
	if len(filename) > 0 {
		return os.Remove(DiskPath(filename))
	}
	return errors.New("filename is required")
}
