package api

import (
	"context"
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"strconv"
	"strings"
	"sync"

	"github.com/go-chi/chi/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/lidarr"
	"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
)

// suggestionView is the wire shape returned by GET /api/discover/suggestions.
type suggestionView struct {
	MBID        string                 `json:"mbid"`
	Name        string                 `json:"name"`
	Score       float64                `json:"score"`
	Attribution []seedContributionView `json:"attribution"`
	// MatchedTags are the candidate's tags that overlap the user's taste
	// profile, strongest first (#2377) — the "matches: shoegaze, melancholic"
	// line. Omitted when empty, which is common: tag coverage for
	// out-of-library artists is permanently partial (#2376), and the card
	// falls back to the seed attribution it has always shown.
	MatchedTags []string `json:"matched_tags,omitempty"`
	// ImageURL is resolved on-demand from Lidarr (out-of-library
	// artists have no local art row). Omitted when Lidarr is disabled
	// or has no match — the client falls back to a placeholder. Not
	// cached: a remote URL Lidarr surfaced, fetched by the browser.
	ImageURL string `json:"image_url,omitempty"`
}

type seedContributionView struct {
	ArtistID     pgtype.UUID `json:"artist_id"`
	Name         string      `json:"name"`
	Contribution float64     `json:"contribution"`
	IsLiked      bool        `json:"is_liked"`
	PlayCount    int64       `json:"play_count"`
}

// handleListSuggestions implements GET /api/discover/suggestions.
//
// Query params:
//   - limit (default 12, capped at 50)
//   - half_life_days (default 30)
//
// Returns 200 with a JSON array (possibly empty). Read-only; no admin gate.
func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request) {
	user, ok := requireUser(w, r)
	if !ok {
		return
	}
	limit := 12
	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
	}
	halfLife := 30.0
	if v := r.URL.Query().Get("half_life_days"); v != "" {
		f, err := strconv.ParseFloat(v, 64)
		if err != nil || f <= 0 {
			writeErr(w, apierror.BadRequest("bad_request", "invalid half_life_days"))
			return
		}
		halfLife = f
	}

	// Read the tuned weight per request so an admin change takes effect on the
	// next refresh, no restart (rule #25).
	tagWeight := h.recSettings.Discover().TagOverlapWeight
	suggestions, err := recommendation.SuggestArtists(
		r.Context(), h.pool, user.ID, halfLife, limit, tagWeight)
	if err != nil {
		h.logger.Error("api: list suggestions", "err", err)
		writeErr(w, apierror.InternalMsg("failed to load suggestions", err))
		return
	}

	out := make([]suggestionView, 0, len(suggestions))
	for _, s := range suggestions {
		attr := make([]seedContributionView, 0, len(s.Attribution))
		for _, a := range s.Attribution {
			attr = append(attr, seedContributionView{
				ArtistID:     a.ArtistID,
				Name:         a.Name,
				Contribution: a.Contribution,
				IsLiked:      a.IsLiked,
				PlayCount:    a.PlayCount,
			})
		}
		out = append(out, suggestionView{
			MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr,
			MatchedTags: s.MatchedTags,
		})
	}
	h.resolveSuggestionArt(r.Context(), out)
	writeJSON(w, http.StatusOK, out)
}

// maxSnoozeDays caps a client-supplied duration. The DEFAULT is not here: it's
// a DB-backed knob on the admin tuning card (rule #25), read per request via
// recSettings.Discover().SnoozeDays. See #2377.
const maxSnoozeDays = 365.0

// snoozeRequest is the POST body. Both fields are optional in the JSON sense
// (an absent body snoozes for the default), but Name is required in practice:
// candidates are out-of-library, so the server has no artists row to resolve a
// display name from and the un-snooze list would have nothing to show. The
// client always has it — it just rendered the card.
type snoozeRequest struct {
	Name string  `json:"name"`
	Days float64 `json:"days"`
}

// snoozeView is one row of GET /api/discover/snoozes.
type snoozeView struct {
	MBID         string             `json:"mbid"`
	Name         string             `json:"name"`
	SnoozedUntil pgtype.Timestamptz `json:"snoozed_until"`
	CreatedAt    pgtype.Timestamptz `json:"created_at"`
}

// handleSnoozeSuggestion implements
// POST /api/discover/suggestions/{mbid}/snooze.
//
// Parks a candidate for `days` (default 90, capped at 365). Idempotent:
// snoozing an already-snoozed candidate extends it rather than conflicting.
//
// This is NOT negative feedback. It records no verdict on the artist and is
// never read by internal/taste — see 0049_suggestion_snoozes.up.sql for the
// rule #101 reasoning. Returns 204.
func (h *handlers) handleSnoozeSuggestion(w http.ResponseWriter, r *http.Request) {
	user, ok := requireUser(w, r)
	if !ok {
		return
	}
	mbid := strings.TrimSpace(chi.URLParam(r, "mbid"))
	if mbid == "" {
		writeErr(w, apierror.BadRequest("invalid_id", "missing mbid"))
		return
	}

	// An empty body is a valid "snooze this for the default period", so EOF
	// is not an error here — decodeBody would reject it as a malformed body.
	var body snoozeRequest
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
		writeErr(w, apierror.BadRequest("invalid_body", ""))
		return
	}
	name := strings.TrimSpace(body.Name)
	if name == "" {
		writeErr(w, apierror.BadRequest("invalid_body", "name is required"))
		return
	}
	days := body.Days
	if days <= 0 {
		days = h.recSettings.Discover().SnoozeDays
	}
	if days > maxSnoozeDays {
		// Clamp rather than reject: a client asking for longer than we allow
		// still means "park this", and failing the write would leave the card
		// sitting there as if the tap did nothing.
		days = maxSnoozeDays
	}

	q := dbq.New(h.pool)
	if err := q.SnoozeSuggestion(r.Context(), dbq.SnoozeSuggestionParams{
		UserID:        user.ID,
		CandidateMbid: mbid,
		CandidateName: name,
		Column4:       days,
	}); err != nil {
		h.logger.Error("api: snooze suggestion", "err", err)
		writeErr(w, apierror.InternalMsg("failed to snooze suggestion", err))
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// handleUnsnoozeSuggestion implements
// DELETE /api/discover/suggestions/{mbid}/snooze.
//
// Brings a parked candidate back immediately. 404s an MBID this user never
// snoozed, so the client can tell "undone" from "there was nothing there".
func (h *handlers) handleUnsnoozeSuggestion(w http.ResponseWriter, r *http.Request) {
	user, ok := requireUser(w, r)
	if !ok {
		return
	}
	mbid := strings.TrimSpace(chi.URLParam(r, "mbid"))
	if mbid == "" {
		writeErr(w, apierror.BadRequest("invalid_id", "missing mbid"))
		return
	}
	q := dbq.New(h.pool)
	rows, err := q.UnsnoozeSuggestion(r.Context(), dbq.UnsnoozeSuggestionParams{
		UserID:        user.ID,
		CandidateMbid: mbid,
	})
	if err != nil {
		h.logger.Error("api: unsnooze suggestion", "err", err)
		writeErr(w, apierror.InternalMsg("failed to unsnooze suggestion", err))
		return
	}
	if rows == 0 {
		writeErr(w, apierror.NotFound("snooze"))
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// handleListSuggestionSnoozes implements GET /api/discover/snoozes.
//
// The un-snooze surface needs this: a parked candidate is by definition
// absent from the suggestion deck, so without a list there is no way to
// reach the DELETE above. Scoped to the caller (rule #47). Expired rows are
// already filtered by the query — the hourly gc sweep only reclaims space.
func (h *handlers) handleListSuggestionSnoozes(w http.ResponseWriter, r *http.Request) {
	user, ok := requireUser(w, r)
	if !ok {
		return
	}
	rows, err := dbq.New(h.pool).ListActiveSuggestionSnoozes(r.Context(), user.ID)
	if err != nil {
		h.logger.Error("api: list suggestion snoozes", "err", err)
		writeErr(w, apierror.InternalMsg("failed to load snoozes", err))
		return
	}
	out := make([]snoozeView, 0, len(rows))
	for _, row := range rows {
		out = append(out, snoozeView{
			MBID:         row.CandidateMbid,
			Name:         row.CandidateName,
			SnoozedUntil: row.SnoozedUntil,
			CreatedAt:    row.CreatedAt,
		})
	}
	writeJSON(w, http.StatusOK, out)
}

// resolveSuggestionArt fills ImageURL on-demand from Lidarr's artist
// lookup, matched by MBID (foreignArtistId). Best-effort and cache-free:
// Lidarr is the only source — when it's disabled, unreachable, or has
// no match for a candidate, that entry keeps an empty ImageURL and the
// client renders its placeholder. Lookups run with bounded concurrency
// so a full Discover page doesn't serialize ~12 round-trips. Never
// fails the request; the suggestions list is the contract, art is a
// nicety.
func (h *handlers) resolveSuggestionArt(ctx context.Context, views []suggestionView) {
	if len(views) == 0 {
		return
	}
	cfg, err := h.lidarrCfg.Get(ctx)
	if err != nil || !cfg.Enabled || cfg.BaseURL == "" || cfg.APIKey == "" {
		return // Lidarr off / unconfigured → placeholders only
	}
	client := lidarr.NewClient(cfg.BaseURL, cfg.APIKey)

	const maxConcurrent = 6
	sem := make(chan struct{}, maxConcurrent)
	var wg sync.WaitGroup
	for i := range views {
		if views[i].MBID == "" || views[i].Name == "" {
			continue
		}
		wg.Add(1)
		sem <- struct{}{}
		go func(idx int) {
			defer wg.Done()
			defer func() { <-sem }()
			results, lerr := client.LookupArtist(ctx, views[idx].Name)
			if lerr != nil {
				return // best-effort: no art on lookup failure
			}
			for _, res := range results {
				if res.MBID == views[idx].MBID && res.ImageURL != "" {
					// Distinct slice index per goroutine → race-free.
					views[idx].ImageURL = res.ImageURL
					return
				}
			}
		}(i)
	}
	wg.Wait()
}
