package attachment

import (
	"bytes"
	"fmt"
	"image"
	"image/color"
	"image/jpeg"
	"image/png"

	_ "image/gif"

	"golang.org/x/image/draw"
)

// AvatarThumbnailFit mirrors the two client render contracts. Human and Agent
// avatars use square object-cover slots, while managed App/service icons use
// object-contain and must keep the whole mark visible.
type AvatarThumbnailFit uint8

const (
	AvatarThumbnailFitCover AvatarThumbnailFit = iota
	AvatarThumbnailFitContain
)

// Avatar thumbnail variants. Small covers every inline avatar render (the
// largest regular avatar is 64 CSS px ≈ 128 physical px); Large covers the
// click-to-enlarge preview modal (256 CSS px ≈ 512 physical px). Fit is part
// of the variant identity so a square cover crop can never collide with a
// full-frame contain derivative for the same immutable avatar object.
const (
	AvatarThumbnailSmallCoverVariant   = "avatar-cover-128-v1"
	AvatarThumbnailSmallContainVariant = "avatar-contain-128-v1"
	AvatarThumbnailSmallEdge           = 128
	AvatarThumbnailLargeCoverVariant   = "avatar-cover-512-v1"
	AvatarThumbnailLargeContainVariant = "avatar-contain-512-v1"
	AvatarThumbnailLargeEdge           = 512
)

// avatarUploadMaxEdge caps the stored original avatar: no client renders an
// avatar above the 512px preview tier, so anything larger is downscaled at
// upload time instead of being served as a multi-megabyte original.
const avatarUploadMaxEdge = 1024

const avatarThumbnailJPEGQuality = 85

// GeneratedAvatarThumbnail is the bytes + dimensions produced by
// GenerateAvatarThumbnail.
type GeneratedAvatarThumbnail struct {
	Blob        []byte
	ContentType string
	Width       int
	Height      int
}

// AvatarThumbnailSpec resolves the ?size= query value accepted by the avatar
// thumbnail routes to its variant name and pixel target.
func AvatarThumbnailSpec(size string, fit AvatarThumbnailFit) (variant string, edge int, ok bool) {
	switch fit {
	case AvatarThumbnailFitCover:
		switch size {
		case "", "128":
			return AvatarThumbnailSmallCoverVariant, AvatarThumbnailSmallEdge, true
		case "512":
			return AvatarThumbnailLargeCoverVariant, AvatarThumbnailLargeEdge, true
		}
	case AvatarThumbnailFitContain:
		switch size {
		case "", "128":
			return AvatarThumbnailSmallContainVariant, AvatarThumbnailSmallEdge, true
		case "512":
			return AvatarThumbnailLargeContainVariant, AvatarThumbnailLargeEdge, true
		}
	}
	return "", 0, false
}

// GenerateAvatarThumbnail decodes and re-encodes one avatar derivative. Cover
// variants center-crop to a square before scaling so a 128px/512px avatar slot
// never has to enlarge one axis. Contain variants preserve the whole image and
// fit its longest edge to the tier. Neither mode upscales small sources. PNG
// sources remain PNG for transparency; other supported raster sources become
// JPEG. GIF is rejected so animated avatars fall back to the original.
func GenerateAvatarThumbnail(source []byte, contentType string, edge int, fit AvatarThumbnailFit) (GeneratedAvatarThumbnail, error) {
	src, format, err := decodeAvatarImage(source)
	if err != nil {
		return GeneratedAvatarThumbnail{}, err
	}
	if format == "gif" {
		// GIF avatars are usually animated; the static derivative would
		// flatten them. Fall back to the original via the caller's redirect.
		return GeneratedAvatarThumbnail{}, ErrThumbnailNotSupported
	}
	preservePNG := format == "png"
	// Bake EXIF orientation before measuring: re-encoding drops the tag, so
	// the pixels must already point upright (phone JPEGs are commonly stored
	// rotated).
	src = applyExifOrientation(src, readExifOrientation(source))
	bounds := src.Bounds()
	srcW, srcH := bounds.Dx(), bounds.Dy()
	if srcW <= 0 || srcH <= 0 {
		return GeneratedAvatarThumbnail{}, ErrThumbnailNotSupported
	}

	sourceBounds := bounds
	var dstW, dstH int
	switch fit {
	case AvatarThumbnailFitCover:
		cropSide := min(srcW, srcH)
		cropX := bounds.Min.X + (srcW-cropSide)/2
		cropY := bounds.Min.Y + (srcH-cropSide)/2
		sourceBounds = image.Rect(cropX, cropY, cropX+cropSide, cropY+cropSide)
		dstW = min(cropSide, edge)
		dstH = dstW
	case AvatarThumbnailFitContain:
		dstW, dstH = fitWithin(srcW, srcH, edge)
	default:
		return GeneratedAvatarThumbnail{}, ErrThumbnailNotSupported
	}
	dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
	if !preservePNG {
		// Composite over white so transparent regions do not turn black in
		// JPEG, mirroring the attachment thumbnail pipeline.
		draw.Draw(dst, dst.Bounds(), &image.Uniform{C: color.White}, image.Point{}, draw.Src)
	}
	draw.CatmullRom.Scale(dst, dst.Bounds(), src, sourceBounds, draw.Over, nil)

	var buf bytes.Buffer
	outputContentType := "image/jpeg"
	if preservePNG {
		if err := png.Encode(&buf, dst); err != nil {
			return GeneratedAvatarThumbnail{}, fmt.Errorf("encode avatar thumbnail: %w", err)
		}
		outputContentType = "image/png"
	} else if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: avatarThumbnailJPEGQuality}); err != nil {
		return GeneratedAvatarThumbnail{}, fmt.Errorf("encode avatar thumbnail: %w", err)
	}
	return GeneratedAvatarThumbnail{
		Blob:        buf.Bytes(),
		ContentType: outputContentType,
		Width:       dstW,
		Height:      dstH,
	}, nil
}

// NormalizeAvatarUpload compresses an uploaded avatar before it is stored.
// Images larger than avatarUploadMaxEdge are downscaled to that cap; PNGs are
// re-encoded losslessly, JPEGs are re-encoded at high quality (visually
// lossless for avatar-sized renders). JPEGs already within the cap are stored
// byte-for-byte to avoid generational re-compression. Undecodable or animated
// formats are stored unchanged; recognized formats always use their canonical
// MIME even when the bytes pass through unchanged.
func NormalizeAvatarUpload(contentType string, blob []byte) (string, []byte) {
	if len(blob) == 0 {
		return contentType, blob
	}
	src, format, err := decodeAvatarImage(blob)
	if err != nil {
		return contentType, blob
	}
	canonicalContentType := contentType
	isPNG := false
	switch format {
	case "gif":
		// Decide from decoded bytes, not the caller-declared MIME type. A GIF
		// mislabeled as image/jpeg must still pass through byte-for-byte rather
		// than being flattened into a static JPEG when it exceeds the size cap.
		return "image/gif", blob
	case "png":
		canonicalContentType = "image/png"
		isPNG = true
	case "jpeg":
		canonicalContentType = "image/jpeg"
	default:
		return contentType, blob
	}
	bounds := src.Bounds()
	srcW, srcH := bounds.Dx(), bounds.Dy()
	if srcW <= 0 || srcH <= 0 {
		return canonicalContentType, blob
	}

	// Bake EXIF orientation before measuring: any re-encode below drops the
	// tag. In-cap JPEGs that stay byte-identical keep their EXIF and simply
	// no-op here (readExifOrientation returns 0 for PNG bytes).
	src = applyExifOrientation(src, readExifOrientation(blob))
	bounds = src.Bounds()
	srcW, srcH = bounds.Dx(), bounds.Dy()

	dstW, dstH := fitWithin(srcW, srcH, avatarUploadMaxEdge)
	downscaled := dstW != srcW || dstH != srcH
	if isPNG {
		dst := src
		if downscaled {
			dst = scaleToRGBA(src, dstW, dstH)
		}
		var buf bytes.Buffer
		if err := png.Encode(&buf, dst); err != nil {
			return canonicalContentType, blob
		}
		return canonicalContentType, buf.Bytes()
	}
	if !downscaled {
		return canonicalContentType, blob
	}
	dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
	draw.Draw(dst, dst.Bounds(), &image.Uniform{C: color.White}, image.Point{}, draw.Src)
	draw.CatmullRom.Scale(dst, dst.Bounds(), src, bounds, draw.Over, nil)
	var buf bytes.Buffer
	if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: avatarUploadJPEGQuality}); err != nil {
		return canonicalContentType, blob
	}
	return canonicalContentType, buf.Bytes()
}

const avatarUploadJPEGQuality = 90

func decodeAvatarImage(source []byte) (image.Image, string, error) {
	if len(source) == 0 {
		return nil, "", ErrThumbnailNotSupported
	}
	// Guard against decompression bombs before the full decode.
	config, format, err := image.DecodeConfig(bytes.NewReader(source))
	if err != nil {
		return nil, "", ErrThumbnailNotSupported
	}
	if config.Width <= 0 || config.Height <= 0 {
		return nil, "", ErrThumbnailNotSupported
	}
	if config.Width*config.Height > thumbnailMaxPixels {
		return nil, "", ErrThumbnailNotSupported
	}
	src, _, err := image.Decode(bytes.NewReader(source))
	if err != nil {
		return nil, "", ErrThumbnailNotSupported
	}
	return src, format, nil
}

func fitWithin(width int, height int, maxEdge int) (int, int) {
	longest := max(width, height)
	if longest <= maxEdge {
		return width, height
	}
	scale := float64(maxEdge) / float64(longest)
	return max(1, int(float64(width)*scale)), max(1, int(float64(height)*scale))
}

func scaleToRGBA(src image.Image, dstW int, dstH int) *image.RGBA {
	dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
	draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
	return dst
}
