package api

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

	"github.com/ponzischeme89/memby/server/internal/mdblist"
	"github.com/ponzischeme89/memby/server/internal/store"
)

const (
	// The Redis entry is only the faster first hop. Postgres is the durable cache and is
	// what decides whether an external request happens at all.
	mdblistRatingsTTL = 24 * time.Hour

	// How old a stored response may be before it is refreshed. Critics' scores move
	// slowly and the operator's quota is a daily allowance, so a stored value is always
	// served immediately and any refresh happens behind the request.
	ratingsRefreshInterval = 30 * 24 * time.Hour

	// A title MDBList had nothing for is retried sooner: a film released this week
	// genuinely gains scores, and the empty answer is cheap to have been wrong about.
	ratingsEmptyRefreshInterval = 3 * 24 * time.Hour
)

// ratingsNeedRefresh decides whether a stored response should be renewed. It never
// decides whether one is *served* — a stored value, however old, beats an empty strip.
func ratingsNeedRefresh(ratings []mdblist.Rating, fetchedAt, now time.Time) bool {
	if fetchedAt.IsZero() {
		return true
	}
	age := now.Sub(fetchedAt)
	if len(ratings) == 0 {
		return age >= ratingsEmptyRefreshInterval
	}
	return age >= ratingsRefreshInterval
}

type movieRating struct {
	Source string `json:"source"`
	Name   string `json:"name"`
	Score  string `json:"score"`
	Scale  string `json:"scale"`
}

type movieRatingsResponse struct {
	Ratings []movieRating `json:"ratings"`
}

type ratingSource struct {
	Name    string
	Scale   string
	Maximum float64
}

var movieRatingSources = map[string]ratingSource{
	"imdb":       {Name: "IMDb", Scale: "/10", Maximum: 10},
	"tomatoes":   {Name: "Rotten Tomatoes", Scale: "%", Maximum: 100},
	"audience":   {Name: "Rotten Tomatoes Audience", Scale: "%", Maximum: 100},
	"metacritic": {Name: "Metacritic", Scale: "/100", Maximum: 100},
	"letterboxd": {Name: "Letterboxd", Scale: "/5", Maximum: 5},
	"rogerebert": {Name: "Roger Ebert", Scale: "/4", Maximum: 4},
	"tmdb":       {Name: "TMDb", Scale: "/10", Maximum: 10},
	"trakt":      {Name: "Trakt", Scale: "%", Maximum: 100},
	"mal":        {Name: "MyAnimeList", Scale: "/10", Maximum: 10},
	"anilist":    {Name: "AniList", Scale: "%", Maximum: 100},
	"anidb":      {Name: "AniDB", Scale: "/10", Maximum: 10},
	"kitsu":      {Name: "Kitsu", Scale: "%", Maximum: 100},
	"score":      {Name: "MDBList Score", Scale: "/100", Maximum: 100},
	"score_average": {
		Name: "MDBList Average", Scale: "/100", Maximum: 100,
	},
}

var movieRatingAliases = map[string]string{
	"imdb": "imdb", "tomatoes": "tomatoes", "rottentomatoes": "tomatoes",
	"rtomatoes": "tomatoes", "rttomatoes": "tomatoes",
	"audience": "audience", "tomatoesaudience": "audience", "rtaudience": "audience",
	"metacritic": "metacritic", "letterboxd": "letterboxd",
	"rogerebert": "rogerebert", "roger_ebert": "rogerebert",
	"tmdb": "tmdb", "trakt": "trakt", "mal": "mal", "myanimelist": "mal",
	"anilist": "anilist", "anidb": "anidb", "kitsu": "kitsu",
	"score": "score", "score_average": "score_average", "scoreaverage": "score_average",
}

type ratingsEmbyItem struct {
	Type        string            `json:"Type"`
	SeriesID    string            `json:"SeriesId"`
	ProviderIDs map[string]string `json:"ProviderIds"`
}

// handleMovieRatings is deliberately separate from handleItem. External ratings arrive
// after the essential metadata, and every failure path returns an empty successful
// response so MDBList can never prevent a detail page from opening.
func (s *Server) handleMovieRatings(w http.ResponseWriter, r *http.Request, sess store.Session) {
	empty := movieRatingsResponse{Ratings: []movieRating{}}
	itemID := strings.TrimSpace(r.PathValue("id"))
	if itemID == "" {
		writeJSON(w, http.StatusOK, empty)
		return
	}
	if s.store == nil || s.emby == nil || s.mdblist == nil {
		writeJSON(w, http.StatusOK, empty)
		return
	}
	settings, enabled := s.mdblistSettings(r.Context())
	if !enabled {
		writeJSON(w, http.StatusOK, empty)
		return
	}

	rawItem, err := s.emby.Item(r.Context(), credentials(sess), itemID, "ProviderIds,SeriesId")
	if err != nil {
		s.logMDBListFailure(r.Context(), "movie identifiers unavailable", itemID, err)
		writeJSON(w, http.StatusOK, empty)
		return
	}
	var item ratingsEmbyItem
	if json.Unmarshal(rawItem, &item) != nil {
		writeJSON(w, http.StatusOK, empty)
		return
	}
	mediaType := "movie"
	if strings.EqualFold(item.Type, "Series") {
		mediaType = "show"
	} else if strings.EqualFold(item.Type, "Episode") && item.SeriesID != "" {
		seriesRaw, seriesErr := s.emby.Item(r.Context(), credentials(sess), item.SeriesID, "ProviderIds")
		if seriesErr != nil || json.Unmarshal(seriesRaw, &item) != nil {
			writeJSON(w, http.StatusOK, empty)
			return
		}
		mediaType = "show"
	} else if !strings.EqualFold(item.Type, "Movie") {
		writeJSON(w, http.StatusOK, empty)
		return
	}
	provider, providerID := movieProvider(item.ProviderIDs)
	if providerID == "" {
		writeJSON(w, http.StatusOK, empty)
		return
	}
	key := store.RatingKey{MediaType: mediaType, Provider: provider, ProviderID: providerID}
	// Remember what this item is called externally. A row can then attach its rating
	// from the database without spending an Emby request per card, and the index fills
	// in as the household browses rather than waiting on a full library import.
	s.rememberRatingRef(r.Context(), itemID, key)

	ratings, err := s.loadMDBListRatings(r.Context(), settings.APIKey, key)
	if err != nil {
		s.logMDBListFailure(r.Context(), "ratings unavailable", itemID, err)
		writeJSON(w, http.StatusOK, empty)
		return
	}
	writeJSON(w, http.StatusOK, movieRatingsResponse{
		Ratings: selectedMovieRatings(settings.Sources, ratings),
	})
}

// loadMDBListRatings answers from the durable cache whenever it holds anything at all.
// An external request happens only for a title never seen before; an ageing one is
// renewed behind the viewer by the warmer, so browsing never waits on MDBList and a
// household's daily quota is spent on new titles rather than on repeat visits.
func (s *Server) loadMDBListRatings(
	ctx context.Context, apiKey string, key store.RatingKey,
) ([]mdblist.Rating, error) {
	cacheKey := ratingsCacheKey(key)
	if ratings, ok := s.cachedMDBListRatings(ctx, cacheKey); ok {
		return ratings, nil
	}
	stored, fetchedAt, hasPersistent := s.persistedMDBListRatings(ctx, key)
	if hasPersistent {
		s.cacheMDBListRatings(ctx, cacheKey, stored)
		if ratingsNeedRefresh(stored, fetchedAt, time.Now()) {
			s.warmRatings(key)
		}
		return stored, nil
	}
	// A viewer can move focus rapidly and open the same title before the first request
	// finishes. Double-checking under the lock keeps that from spending quota twice.
	s.mdblistMu.Lock()
	defer s.mdblistMu.Unlock()
	if ratings, ok := s.cachedMDBListRatings(ctx, cacheKey); ok {
		return ratings, nil
	}
	// Recheck Postgres under the lock: another request may have populated it while this
	// request waited. Postgres is the durable cache; Redis is only the faster first hop.
	if stored, _, hasPersistent = s.persistedMDBListRatings(ctx, key); hasPersistent {
		s.cacheMDBListRatings(ctx, cacheKey, stored)
		return stored, nil
	}
	return s.fetchAndStoreRatings(ctx, apiKey, key)
}

// fetchAndStoreRatings is the only place an external request is made, so the durable
// write and the hot cache can never disagree about what was fetched.
func (s *Server) fetchAndStoreRatings(
	ctx context.Context, apiKey string, key store.RatingKey,
) ([]mdblist.Rating, error) {
	ratings, err := s.mdblist.Media(ctx, apiKey, key.Provider, key.ProviderID, key.MediaType)
	if err != nil {
		return nil, err
	}
	if ratings == nil {
		ratings = []mdblist.Rating{}
	}
	raw, marshalErr := json.Marshal(ratings)
	if marshalErr != nil {
		return ratings, nil
	}
	if saveErr := s.store.SaveMediaRatings(
		ctx, key.MediaType, key.Provider, key.ProviderID, raw,
	); saveErr != nil && s.log != nil {
		s.loggerFor(ctx).Warn("MDBList rating database write failed", "error", saveErr)
	}
	s.cacheMDBListRatings(ctx, ratingsCacheKey(key), ratings)
	return ratings, nil
}

func ratingsCacheKey(key store.RatingKey) string {
	return "mdblist:ratings:v2:" + key.MediaType + ":" + key.Provider + ":" + key.ProviderID
}

func (s *Server) cachedMDBListRatings(ctx context.Context, key string) ([]mdblist.Rating, bool) {
	if s.cache == nil {
		return nil, false
	}
	raw, err := s.cache.Get(ctx, key)
	if err != nil {
		return nil, false
	}
	var ratings []mdblist.Rating
	if json.Unmarshal(raw, &ratings) != nil {
		return nil, false
	}
	if ratings == nil {
		ratings = []mdblist.Rating{}
	}
	return ratings, true
}

func (s *Server) persistedMDBListRatings(
	ctx context.Context, key store.RatingKey,
) ([]mdblist.Rating, time.Time, bool) {
	raw, fetchedAt, err := s.store.MediaRatings(ctx, key.MediaType, key.Provider, key.ProviderID)
	if err != nil {
		if err != store.ErrMediaRatingsNotFound && s.log != nil {
			s.loggerFor(ctx).Warn("MDBList rating database read failed", "error", err)
		}
		return nil, time.Time{}, false
	}
	var ratings []mdblist.Rating
	if json.Unmarshal(raw, &ratings) != nil {
		return nil, time.Time{}, false
	}
	if ratings == nil {
		ratings = []mdblist.Rating{}
	}
	return ratings, fetchedAt, true
}

func (s *Server) cacheMDBListRatings(ctx context.Context, key string, ratings []mdblist.Rating) {
	if s.cache == nil {
		return
	}
	raw, err := json.Marshal(ratings)
	if err != nil {
		return
	}
	if err := s.cache.Set(ctx, key, raw, mdblistRatingsTTL); err != nil && s.log != nil {
		s.loggerFor(ctx).Warn("MDBList rating cache write failed", "error", err)
	}
}

func (s *Server) logMDBListFailure(ctx context.Context, message, itemID string, err error) {
	if s.log != nil {
		s.loggerFor(ctx).Debug("MDBList "+message, "item", itemID, "error", err)
	}
}

func movieProvider(ids map[string]string) (string, string) {
	if id := strings.TrimSpace(providerID(ids, "tmdb")); id != "" {
		return "tmdb", id
	}
	if id := strings.TrimSpace(providerID(ids, "imdb")); id != "" {
		return "imdb", id
	}
	return "", ""
}

func selectedMovieRatings(selected []string, available []mdblist.Rating) []movieRating {
	values := make(map[string]float64, len(available))
	for _, rating := range available {
		canonical := movieRatingAliases[strings.ToLower(strings.TrimSpace(rating.Source))]
		source, known := movieRatingSources[canonical]
		if !known || rating.Value <= 0 || rating.Value > source.Maximum {
			continue
		}
		if _, exists := values[canonical]; !exists {
			values[canonical] = rating.Value
		}
	}
	result := make([]movieRating, 0, len(selected))
	seen := map[string]bool{}
	for _, id := range selected {
		id = strings.ToLower(strings.TrimSpace(id))
		source, known := movieRatingSources[id]
		value, available := values[id]
		if !known || !available || seen[id] {
			continue
		}
		seen[id] = true
		result = append(result, movieRating{
			Source: id, Name: source.Name, Score: formatRatingScore(value, source), Scale: source.Scale,
		})
	}
	if result == nil {
		return []movieRating{}
	}
	return result
}

// formatRatingScore writes a score the way its own scale is read. A fractional scale
// always shows its decimal — IMDb 7 is written "7.0", because "7" beside an "8.2" reads
// as a different kind of number rather than the same one that happens to be round — and
// a scale measured in whole points (percentages, /100) never grows one.
func formatRatingScore(value float64, source ratingSource) string {
	if source.Maximum <= 10 {
		return strconv.FormatFloat(value, 'f', 1, 64)
	}
	return strconv.FormatFloat(value, 'f', 0, 64)
}
