package service

import (
	"bytes"
	"context"
	"crypto/sha256"
	"errors"
	"fmt"
	"image"
	"image/color"
	"image/draw"
	_ "image/gif"
	_ "image/jpeg"
	"image/png"
	"log/slog"

	"gorm.io/gorm"
	"gorm.io/gorm/clause"

	"github.com/pingcap/agent-git-service/internal/avatarstore"
	"github.com/pingcap/agent-git-service/internal/db"
	"github.com/pingcap/agent-git-service/internal/randutil"
)

const (
	// AvatarMaxUploadBytes and AvatarMaxSourceDimension match GitHub's
	// documented profile-image envelope. Normalized output is a bounded PNG
	// because avatars are rendered at small sizes and must not become a storage
	// or decompression-bomb vector.
	AvatarMaxUploadBytes      = 1 * 1024 * 1024
	AvatarMaxSourceDimension  = 3000
	avatarNormalizedDimension = 512
)

var (
	ErrAvatarTooLarge  = errors.New("avatar image exceeds the 1 MiB limit")
	ErrAvatarInvalid   = errors.New("avatar image is invalid")
	ErrAvatarMediaType = errors.New("avatar image must be a PNG, JPEG, or GIF")
)

// AvatarImage is the rendered image returned by the public avatar route.
type AvatarImage struct {
	Content     []byte
	ContentType string
	Version     uint
	Generated   bool
}

// NormalizeAvatar decodes untrusted input before storing it. It deliberately
// normalizes every accepted source format to one bounded PNG, stripping source
// metadata and avoiding a permanent dependency on an untrusted MIME header.
func NormalizeAvatar(source []byte) ([]byte, error) {
	if len(source) == 0 {
		return nil, ErrAvatarInvalid
	}
	if len(source) > AvatarMaxUploadBytes {
		return nil, ErrAvatarTooLarge
	}

	config, format, err := image.DecodeConfig(bytes.NewReader(source))
	if err != nil {
		return nil, fmt.Errorf("%w: decode image header", ErrAvatarInvalid)
	}
	if format != "png" && format != "jpeg" && format != "gif" {
		return nil, ErrAvatarMediaType
	}
	if config.Width <= 0 || config.Height <= 0 || config.Width > AvatarMaxSourceDimension || config.Height > AvatarMaxSourceDimension {
		return nil, fmt.Errorf("%w: maximum dimensions are %dx%d", ErrAvatarInvalid, AvatarMaxSourceDimension, AvatarMaxSourceDimension)
	}

	decoded, _, err := image.Decode(bytes.NewReader(source))
	if err != nil {
		return nil, fmt.Errorf("%w: decode image", ErrAvatarInvalid)
	}
	normalized := scaleAvatar(decoded, avatarNormalizedDimension)
	var out bytes.Buffer
	encoder := png.Encoder{CompressionLevel: png.BestSpeed}
	if err := encoder.Encode(&out, normalized); err != nil {
		return nil, fmt.Errorf("encode normalized avatar: %w", err)
	}
	return out.Bytes(), nil
}

func scaleAvatar(source image.Image, maxDimension int) image.Image {
	bounds := source.Bounds()
	width, height := bounds.Dx(), bounds.Dy()
	if width <= maxDimension && height <= maxDimension {
		return source
	}
	newWidth, newHeight := width, height
	if width >= height {
		newWidth = maxDimension
		newHeight = max(1, height*maxDimension/width)
	} else {
		newHeight = maxDimension
		newWidth = max(1, width*maxDimension/height)
	}

	// A small avatar does not benefit materially from retaining its original
	// large resolution. Nearest-neighbor scaling keeps this dependency-free and
	// caps every normalized object at 512px on its longest edge.
	dst := image.NewNRGBA(image.Rect(0, 0, newWidth, newHeight))
	for y := 0; y < newHeight; y++ {
		sy := bounds.Min.Y + y*height/newHeight
		for x := 0; x < newWidth; x++ {
			sx := bounds.Min.X + x*width/newWidth
			dst.Set(x, y, source.At(sx, sy))
		}
	}
	return dst
}

// SetAvatar stores a normalized image for a human or organization account.
// The file is prepared before the DB transaction; unsuccessful transactions
// delete that pending object, while a successful replacement deletes the old
// object only after the new database state is durable.
func (s *Service) SetAvatar(ctx context.Context, accountID uint, source []byte) (db.User, error) {
	normalized, err := NormalizeAvatar(source)
	if err != nil {
		return db.User{}, err
	}
	store, err := s.avatarStoreForCtx(ctx)
	if err != nil {
		return db.User{}, err
	}
	key := randutil.Hex(64) + ".png"
	if err := store.Put(ctx, key, normalized); err != nil {
		return db.User{}, err
	}

	var updated db.User
	var previousKey string
	err = s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
		var current db.User
		if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, accountID).Error; err != nil {
			return err
		}
		previousKey = current.AvatarKey
		current.AvatarKey = key
		current.AvatarVersion++
		if err := tx.Save(&current).Error; err != nil {
			return err
		}
		updated = current
		return nil
	})
	if err != nil {
		_ = store.Delete(context.Background(), key)
		return db.User{}, wrapErr(err)
	}
	if previousKey != "" && previousKey != key {
		if err := store.Delete(ctx, previousKey); err != nil {
			slog.WarnContext(ctx, "delete replaced avatar", "account_id", accountID, "error", err)
		}
	}
	return updated, nil
}

// DeleteAvatar clears a custom avatar. The version is incremented so clients
// stop caching the previous image and receive the generated identicon instead.
func (s *Service) DeleteAvatar(ctx context.Context, accountID uint) (db.User, error) {
	store, err := s.avatarStoreForCtx(ctx)
	if err != nil {
		return db.User{}, err
	}

	var updated db.User
	var previousKey string
	err = s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
		var current db.User
		if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, accountID).Error; err != nil {
			return err
		}
		previousKey = current.AvatarKey
		if previousKey != "" {
			current.AvatarKey = ""
			current.AvatarVersion++
			if err := tx.Save(&current).Error; err != nil {
				return err
			}
		}
		updated = current
		return nil
	})
	if err != nil {
		return db.User{}, wrapErr(err)
	}
	if previousKey != "" {
		if err := store.Delete(ctx, previousKey); err != nil {
			slog.WarnContext(ctx, "delete avatar", "account_id", accountID, "error", err)
		}
	}
	return updated, nil
}

// GetAvatar returns the account's normalized image or an immutable generated
// identicon when the account has no custom image or a previous storage write
// was lost. The latter remains user-visible as a valid avatar while surfacing
// an operator log for repair.
func (s *Service) GetAvatar(ctx context.Context, login string) (AvatarImage, error) {
	account, err := s.GetUser(ctx, login)
	if err != nil {
		return AvatarImage{}, err
	}
	if account.AvatarKey == "" {
		return generatedAvatar(account.Login, account.AvatarVersion), nil
	}
	store, err := s.avatarStoreForCtx(ctx)
	if err != nil {
		return AvatarImage{}, err
	}
	content, err := store.Get(ctx, account.AvatarKey)
	if err == nil {
		return AvatarImage{Content: content, ContentType: "image/png", Version: account.AvatarVersion}, nil
	}
	if !errors.Is(err, avatarstore.ErrNotFound) {
		return AvatarImage{}, err
	}
	slog.ErrorContext(ctx, "avatar object missing; serving generated fallback", "account_id", account.ID, "avatar_key", account.AvatarKey)
	return generatedAvatar(account.Login, account.AvatarVersion), nil
}

func (s *Service) avatarStoreForCtx(ctx context.Context) (avatarstore.Store, error) {
	git, err := s.GitForCtx(ctx)
	if err != nil {
		return nil, err
	}
	root := git.CanonicalRoot()
	if root == "" {
		return nil, errors.New("avatar storage root is unavailable")
	}
	return avatarstore.NewFileStore(root), nil
}

func generatedAvatar(login string, version uint) AvatarImage {
	sum := sha256.Sum256([]byte(login))
	foreground := colorFromHash(sum)
	const cell = 16
	canvas := image.NewNRGBA(image.Rect(0, 0, cell*5, cell*5))
	background := color.NRGBA{R: 246, G: 248, B: 250, A: 255}
	draw.Draw(canvas, canvas.Bounds(), &image.Uniform{C: background}, image.Point{}, draw.Src)
	bit := 0
	for y := 0; y < 5; y++ {
		for x := 0; x < 3; x++ {
			set := sum[bit/8]&(1<<uint(bit%8)) != 0
			bit++
			if !set {
				continue
			}
			fillAvatarCell(canvas, x, y, foreground)
			if x != 2 {
				fillAvatarCell(canvas, 4-x, y, foreground)
			}
		}
	}
	var out bytes.Buffer
	_ = png.Encode(&out, canvas)
	return AvatarImage{Content: out.Bytes(), ContentType: "image/png", Version: version, Generated: true}
}

func fillAvatarCell(dst *image.NRGBA, x, y int, c color.NRGBA) {
	const cell = 16
	for py := y * cell; py < (y+1)*cell; py++ {
		for px := x * cell; px < (x+1)*cell; px++ {
			dst.SetNRGBA(px, py, c)
		}
	}
}

func colorFromHash(sum [sha256.Size]byte) color.NRGBA {
	return color.NRGBA{
		R: 64 + sum[20]%128,
		G: 64 + sum[21]%128,
		B: 64 + sum[22]%128,
		A: 255,
	}
}
