package api

import (
	"context"
	"encoding/json"
	"errors"
	"log/slog"
	"net/http"
	"strconv"
	"strings"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgtype"

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

// RadioResponse is the body of GET /api/radio.
type RadioResponse struct {
	Tracks []TrackRef `json:"tracks"`
}

// handleRadio implements GET /api/radio?seed_track=<uuid>&limit=<int>.
//
// Returns the seed at index 0, followed by up to limit-1 weighted-shuffle
// picks from the user's library, scored by recommendation.Score. The
// scoring formula folds in contextual_match_score using the user's current
// session vector (read from the most recent open play_event).
func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
	user, ok := requireUser(w, r)
	if !ok {
		return
	}
	raw := strings.TrimSpace(r.URL.Query().Get("seed_track"))
	if raw == "" {
		writeErr(w, apierror.BadRequest("bad_request", "seed_track is required"))
		return
	}
	seedID, ok := parseUUID(raw)
	if !ok {
		writeErr(w, apierror.BadRequest("bad_request", "invalid seed_track id"))
		return
	}
	limit := h.recCfg.RadioSize
	if v := r.URL.Query().Get("limit"); v != "" {
		n, err := strconv.Atoi(v)
		if err != nil || n < 1 {
			writeErr(w, apierror.BadRequest("bad_request", "invalid limit"))
			return
		}
		limit = n
	}
	if limit > h.recCfg.RadioSizeMax {
		limit = h.recCfg.RadioSizeMax
	}
	q := dbq.New(h.pool)
	track, err := q.GetTrackByID(r.Context(), seedID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			writeErr(w, apierror.NotFound("seed_track"))
			return
		}
		h.logger.Error("api: get radio seed track failed", "err", err)
		writeErr(w, apierror.InternalMsg("lookup failed", err))
		return
	}
	album, err := q.GetAlbumByID(r.Context(), track.AlbumID)
	if err != nil {
		h.logger.Error("api: get radio seed album failed", "err", err)
		writeErr(w, apierror.InternalMsg("lookup failed", err))
		return
	}
	artist, err := q.GetArtistByID(r.Context(), track.ArtistID)
	if err != nil {
		h.logger.Error("api: get radio seed artist failed", "err", err)
		writeErr(w, apierror.InternalMsg("lookup failed", err))
		return
	}

	currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger)
	// Condition on the current device (#1551): the latest play's device is a
	// cheap, request-free proxy for what the user is on right now.
	currentVec.DeviceClass = latestDeviceClass(r.Context(), q, user.ID, h.logger)

	exclude := parseExcludeParam(r.URL.Query().Get("exclude"))
	limits := recommendation.DefaultCandidateSourceLimits()
	candidates, err := recommendation.LoadCandidatesFromSimilarity(
		r.Context(), q, user.ID, seedID,
		h.recCfg.RecentlyPlayedHours, currentVec, exclude, limits,
	)
	if err != nil {
		h.logger.Warn("api: radio: similarity-pool failed; falling back to whole-library", "err", err)
		candidates, err = recommendation.LoadCandidates(
			r.Context(), q, user.ID, seedID,
			h.recCfg.RecentlyPlayedHours, currentVec,
		)
		if err != nil {
			h.logger.Error("api: radio: load candidates fallback failed", "err", err)
			writeErr(w, apierror.InternalMsg("candidate load failed", err))
			return
		}
	}

	// Scoring weights come from the DB-backed tuning lab (#1250) —
	// read per request so an admin change takes effect live.
	weights := h.recSettings.Weights(recsettings.ScopeRadio)
	picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)

	out := make([]TrackRef, 0, len(picks)+1)
	out = append(out, trackRefFrom(track, album.Title, artist.Name))
	for _, p := range picks {
		al, err := q.GetAlbumByID(r.Context(), p.Track.AlbumID)
		if err != nil {
			h.logger.Error("api: radio: resolve album", "err", err)
			writeErr(w, apierror.InternalMsg("resolve failed", err))
			return
		}
		ar, err := q.GetArtistByID(r.Context(), p.Track.ArtistID)
		if err != nil {
			h.logger.Error("api: radio: resolve artist", "err", err)
			writeErr(w, apierror.InternalMsg("resolve failed", err))
			return
		}
		out = append(out, trackRefFrom(p.Track, al.Title, ar.Name))
	}
	writeJSON(w, http.StatusOK, RadioResponse{Tracks: out})
}

// loadCurrentSessionVector returns the user's most recent active session
// vector, or a Seed=true sentinel if none exists / the column is NULL /
// the JSON fails to unmarshal. Sentinel short-circuits ContextualMatchScore
// to 0 so the contextual term contributes nothing in cold-start cases.
func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUID, logger *slog.Logger) recommendation.SessionVector {
	raw, err := q.GetCurrentSessionVectorForUser(r.Context(), userID)
	if err != nil {
		// pgx.ErrNoRows is the common path: no active session yet.
		if !errors.Is(err, pgx.ErrNoRows) {
			logger.Warn("api: radio: load current session vector", "err", err)
		}
		return recommendation.SessionVector{Seed: true}
	}
	if len(raw) == 0 {
		return recommendation.SessionVector{Seed: true}
	}
	var v recommendation.SessionVector
	if jerr := json.Unmarshal(raw, &v); jerr != nil {
		logger.Warn("api: radio: bad session_vector_at_play json", "err", jerr)
		return recommendation.SessionVector{Seed: true}
	}
	return v
}

// latestDeviceClass returns the device_class of the user's most recent play as
// the "current device" for context conditioning (#1551), or "" when unknown
// (no plays yet, or the latest play predates device capture). Best-effort: a
// lookup failure yields a device-agnostic ("") affinity cell.
func latestDeviceClass(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, logger *slog.Logger) string {
	dc, err := q.GetLatestPlayDeviceClassForUser(ctx, userID)
	if err != nil {
		if !errors.Is(err, pgx.ErrNoRows) {
			logger.Warn("api: radio: latest device class", "err", err)
		}
		return ""
	}
	if dc == nil {
		return ""
	}
	return *dc
}

// parseExcludeParam parses a comma-separated list of UUIDs from the
// `exclude` query string, silently dropping malformed entries. Returns
// nil for empty or all-malformed input.
func parseExcludeParam(raw string) []pgtype.UUID {
	if raw == "" {
		return nil
	}
	parts := strings.Split(raw, ",")
	out := make([]pgtype.UUID, 0, len(parts))
	for _, p := range parts {
		p = strings.TrimSpace(p)
		if p == "" {
			continue
		}
		id, ok := parseUUID(p)
		if !ok {
			continue
		}
		out = append(out, id)
	}
	return out
}
