// Admin recommendation-tuning endpoints (#1250): the defaults-
// discovery lab. GET returns current values + shipped defaults for
// every scope; PATCH applies a partial update to one scope; reset
// restores a scope to shipped defaults. Every change writes an audit
// row (consumed by the metrics trend view, #1251).
package api

import (
	"encoding/json"
	"errors"
	"net/http"

	"github.com/go-chi/chi/v5"

	"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
	"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
	"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
)

// weightsResp is one weight profile on the wire, keyed by the same
// snake_case field names the PATCH body accepts.
type weightsResp struct {
	BaseWeight        float64 `json:"base_weight"`
	LikeBoost         float64 `json:"like_boost"`
	RecencyWeight     float64 `json:"recency_weight"`
	SkipPenalty       float64 `json:"skip_penalty"`
	JitterMagnitude   float64 `json:"jitter_magnitude"`
	ContextWeight     float64 `json:"context_weight"`
	SimilarityWeight  float64 `json:"similarity_weight"`
	TasteWeight       float64 `json:"taste_weight"`
	ContextTimeWeight float64 `json:"context_time_weight"`
}

func weightsRespFrom(w recommendation.ScoringWeights) weightsResp {
	return weightsResp{
		BaseWeight:        w.BaseWeight,
		LikeBoost:         w.LikeBoost,
		RecencyWeight:     w.RecencyWeight,
		SkipPenalty:       w.SkipPenalty,
		JitterMagnitude:   w.JitterMagnitude,
		ContextWeight:     w.ContextWeight,
		SimilarityWeight:  w.SimilarityWeight,
		TasteWeight:       w.TasteWeight,
		ContextTimeWeight: w.ContextTimeWeight,
	}
}

type tasteTuningResp struct {
	HalfLifeDays       float64 `json:"half_life_days"`
	EngagementHardSkip float64 `json:"engagement_hard_skip"`
	EngagementNeutral  float64 `json:"engagement_neutral"`
	EngagementFull     float64 `json:"engagement_full"`
	EnrichedTagScale   float64 `json:"enriched_tag_scale"`
	EraScale           float64 `json:"era_scale"`
	MoodScale          float64 `json:"mood_scale"`
}

func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
	return tasteTuningResp{
		HalfLifeDays:       t.HalfLifeDays,
		EngagementHardSkip: t.EngagementHardSkip,
		EngagementNeutral:  t.EngagementNeutral,
		EngagementFull:     t.EngagementFull,
		EnrichedTagScale:   t.EnrichedTagScale,
		EraScale:           t.EraScale,
		MoodScale:          t.MoodScale,
	}
}

// discoverTuningResp is the Discover scope on the wire (#2377).
type discoverTuningResp struct {
	TagOverlapWeight float64 `json:"tag_overlap_weight"`
	SnoozeDays       float64 `json:"snooze_days"`
}

func discoverRespFrom(d recsettings.DiscoverTuning) discoverTuningResp {
	return discoverTuningResp{
		TagOverlapWeight: d.TagOverlapWeight,
		SnoozeDays:       d.SnoozeDays,
	}
}

// tuningSnapshot is both the GET response and the post-mutation echo:
// current values alongside shipped defaults so the card can mark
// which knobs deviate.
type tuningSnapshot struct {
	Profiles map[string]weightsResp `json:"profiles"`
	Taste    tasteTuningResp        `json:"taste"`
	Discover discoverTuningResp     `json:"discover"`
	Shipped  struct {
		Profiles map[string]weightsResp `json:"profiles"`
		Taste    tasteTuningResp        `json:"taste"`
		Discover discoverTuningResp     `json:"discover"`
	} `json:"shipped"`
}

func (h *handlers) tuningSnapshot() tuningSnapshot {
	var out tuningSnapshot
	out.Profiles = map[string]weightsResp{
		recsettings.ScopeRadio:    weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)),
		recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
	}
	out.Taste = tasteRespFrom(h.recSettings.Taste())
	out.Discover = discoverRespFrom(h.recSettings.Discover())
	out.Shipped.Profiles = map[string]weightsResp{
		recsettings.ScopeRadio:    weightsRespFrom(recsettings.ShippedRadioWeights()),
		recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
	}
	out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
	out.Shipped.Discover = discoverRespFrom(recsettings.ShippedDiscoverTuning())
	return out
}

// handleGetRecommendationTuning implements GET /api/admin/recommendation-tuning.
func (h *handlers) handleGetRecommendationTuning(w http.ResponseWriter, _ *http.Request) {
	writeJSON(w, http.StatusOK, h.tuningSnapshot())
}

// patchTuningReq carries the partial update: field name → new value,
// using the same snake_case names the GET response emits.
type patchTuningReq struct {
	Values map[string]float64 `json:"values"`
}

// handlePatchRecommendationTuning implements
// PATCH /api/admin/recommendation-tuning/{scope}.
func (h *handlers) handlePatchRecommendationTuning(w http.ResponseWriter, r *http.Request) {
	scope := chi.URLParam(r, "scope")
	var body patchTuningReq
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
		return
	}
	if len(body.Values) == 0 {
		writeErr(w, apierror.BadRequest("bad_body", "values is empty"))
		return
	}

	var err error
	switch scope {
	case recsettings.ScopeTaste:
		err = h.recSettings.UpdateTaste(r.Context(), body.Values)
	case recsettings.ScopeDiscover:
		err = h.recSettings.UpdateDiscover(r.Context(), body.Values)
	default:
		err = h.recSettings.UpdateProfile(r.Context(), scope, body.Values)
	}
	if err != nil {
		writeTuningErr(w, h, scope, err)
		return
	}
	writeJSON(w, http.StatusOK, h.tuningSnapshot())
}

// handleResetRecommendationTuning implements
// POST /api/admin/recommendation-tuning/{scope}/reset.
func (h *handlers) handleResetRecommendationTuning(w http.ResponseWriter, r *http.Request) {
	scope := chi.URLParam(r, "scope")
	if err := h.recSettings.Reset(r.Context(), scope); err != nil {
		writeTuningErr(w, h, scope, err)
		return
	}
	writeJSON(w, http.StatusOK, h.tuningSnapshot())
}

// writeTuningErr maps recsettings validation errors to 400s and
// everything else to a logged 500.
func writeTuningErr(w http.ResponseWriter, h *handlers, scope string, err error) {
	switch {
	case errors.Is(err, recsettings.ErrUnknownScope):
		writeErr(w, &apierror.Error{
			Status: http.StatusNotFound, Code: "not_found", Message: "no such tuning scope",
		})
	case errors.Is(err, recsettings.ErrUnknownField), errors.Is(err, recsettings.ErrOutOfRange):
		writeErr(w, apierror.BadRequest("invalid_tuning", err.Error()))
	default:
		h.logger.Error("admin: recommendation tuning", "scope", scope, "err", err)
		writeErr(w, apierror.InternalMsg("tuning update failed", err))
	}
}
