package main

import (
	"errors"
	"net/http"
	"strings"
	"time"

	"github.com/gin-gonic/gin"
	"gorm.io/gorm"
)

// validColors is the four-color palette mule-image always shipped. The
// empty string is the explicit "clear color" sentinel.
var validColors = map[string]struct{}{
	"red":    {},
	"orange": {},
	"yellow": {},
	"green":  {},
}

// markPatch is the request body for all three mutating mark endpoints.
// Pointers distinguish "field omitted" from "field set to zero" — a PUT
// with `{"rating": 0}` clears the rating, but a PUT with `{"color": "red"}`
// alone must NOT wipe an existing rating.
type markPatch struct {
	Rating *int    `json:"rating,omitempty"`
	Color  *string `json:"color,omitempty"`
}

func (p *markPatch) sanitize() error {
	if p.Rating != nil {
		r := *p.Rating
		if r < 0 || r > 5 {
			return errors.New("rating out of range")
		}
	}
	if p.Color != nil {
		c := strings.ToLower(strings.TrimSpace(*p.Color))
		if c != "" {
			if _, ok := validColors[c]; !ok {
				return errors.New("invalid color")
			}
		}
		*p.Color = c
	}
	return nil
}

// apply merges the patch onto an existing row (or a fresh zero-value
// Mark for an upsert). Returns true if anything in the row still has a
// non-empty value — false signals "delete the row" to the caller.
func (p *markPatch) apply(m *Mark) bool {
	if p.Rating != nil {
		if *p.Rating > 0 {
			r := *p.Rating
			m.Rating = &r
		} else {
			m.Rating = nil
		}
	}
	if p.Color != nil {
		if *p.Color != "" {
			c := *p.Color
			m.Color = &c
		} else {
			m.Color = nil
		}
	}
	return m.Rating != nil || (m.Color != nil && *m.Color != "")
}

// allMarksJSON renders the entire `marks` table as the wire shape
// `{"<uid>": {"rating": …, "color": …, "updatedAt": …}, …}`. Used by
// GET /photos/marks which the web client calls once on session start.
func allMarksJSON(db *gorm.DB) (map[string]map[string]any, error) {
	var rows []Mark
	if err := db.Find(&rows).Error; err != nil {
		return nil, err
	}
	out := make(map[string]map[string]any, len(rows))
	for i := range rows {
		out[rows[i].PhotoUID] = rows[i].asJSON()
	}
	return out, nil
}

func handleMarksAll(db *gorm.DB) gin.HandlerFunc {
	return func(c *gin.Context) {
		marks, err := allMarksJSON(db)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		c.JSON(http.StatusOK, marks)
	}
}

func handleMarkGet(db *gorm.DB) gin.HandlerFunc {
	return func(c *gin.Context) {
		uid := c.Param("uid")
		var m Mark
		err := db.Where("photo_uid = ?", uid).First(&m).Error
		if errors.Is(err, gorm.ErrRecordNotFound) {
			c.JSON(http.StatusOK, gin.H{})
			return
		}
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		c.JSON(http.StatusOK, m.asJSON())
	}
}

// upsert applies the patch and writes back. Returns the resulting JSON
// shape (empty map if the row was deleted).
func upsert(db *gorm.DB, uid string, patch *markPatch) (map[string]any, error) {
	var m Mark
	err := db.Where("photo_uid = ?", uid).First(&m).Error
	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
		return nil, err
	}
	m.PhotoUID = uid
	keep := patch.apply(&m)
	m.UpdatedAt = time.Now().UTC()
	if !keep {
		// Drop the row entirely so a re-fetch returns {}.
		if err := db.Where("photo_uid = ?", uid).Delete(&Mark{}).Error; err != nil {
			return nil, err
		}
		return map[string]any{}, nil
	}
	if err := db.Save(&m).Error; err != nil {
		return nil, err
	}
	return m.asJSON(), nil
}

func handleMarkPut(db *gorm.DB) gin.HandlerFunc {
	return func(c *gin.Context) {
		uid := c.Param("uid")
		var patch markPatch
		if err := c.ShouldBindJSON(&patch); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid patch"})
			return
		}
		if err := patch.sanitize(); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		out, err := upsert(db, uid, &patch)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		c.JSON(http.StatusOK, out)
	}
}

type bulkBody struct {
	IDs   []string  `json:"ids"`
	Patch markPatch `json:"patch"`
}

func handleMarkBulk(db *gorm.DB) gin.HandlerFunc {
	return func(c *gin.Context) {
		var body bulkBody
		if err := c.ShouldBindJSON(&body); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
			return
		}
		if len(body.IDs) == 0 {
			c.JSON(http.StatusBadRequest, gin.H{"error": "ids[] required"})
			return
		}
		if err := body.Patch.sanitize(); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		applied := make(map[string]map[string]any, len(body.IDs))
		// Single transaction so a partial failure rolls back. The client
		// expects atomic semantics for a bulk star/colour stamp.
		err := db.Transaction(func(tx *gorm.DB) error {
			for _, uid := range body.IDs {
				if uid == "" {
					continue
				}
				out, err := upsert(tx, uid, &body.Patch)
				if err != nil {
					return err
				}
				applied[uid] = out
			}
			return nil
		})
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		c.JSON(http.StatusOK, gin.H{
			"count": len(applied),
			"marks": applied,
		})
	}
}
