package api

import (
	"context"
	"encoding/json"
	"errors"
	"strings"
	"sync"
	"time"

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

// MembyRatings rides on the item payload so a card can draw its ratings the moment the
// row does. The dedicated /v1/items/{id}/ratings endpoint remains for anything the
// database has never seen — this field is only ever what was already stored, so
// attaching it costs one indexed read for a whole launcher and never an external
// request on the request path.
const ratingsItemField = "MembyRatings"

// A launcher is a few hundred cards at most. The cap is a guard against a future row
// type asking for a thousand, not a limit anything reaches today.
const ratingsAttachItemLimit = 600

const (
	// The warmer's pace. MDBList sells a daily allowance rather than a rate, so the
	// interval only stops a burst of navigation becoming a burst of requests, and the
	// daily budget is what actually protects the quota.
	ratingsWarmInterval    = 2 * time.Second
	ratingsWarmDailyBudget = 400
	// A refused request means the allowance is gone; asking again inside the same hour
	// only spends the operator's rate limit on nothing.
	ratingsWarmBackoff = time.Hour
	ratingsWarmQueue   = 512
)

// How long the operator's ratings settings are trusted in memory. Every row, search and
// detail response now consults them, and instant search asks once per keystroke — that
// is a Postgres round trip per key press for a document changed a few times a year. The
// admin write clears this, so a change still takes effect at once.
const mdblistSettingsTTL = 30 * time.Second

type mdblistSettingsCache struct {
	mu       sync.Mutex
	settings store.MDBListSettings
	loadedAt time.Time
}

// mdblistSettings reports the ratings configuration, and whether ratings are on at all.
func (s *Server) mdblistSettings(ctx context.Context) (store.MDBListSettings, bool) {
	if s.store == nil {
		return store.MDBListSettings{}, false
	}
	c := &s.mdblistSettingsCache
	now := time.Now()
	c.mu.Lock()
	if !c.loadedAt.IsZero() && now.Sub(c.loadedAt) < mdblistSettingsTTL {
		settings := c.settings
		c.mu.Unlock()
		return settings, settings.Enabled && settings.APIKey != "" && len(settings.Sources) > 0
	}
	c.mu.Unlock()

	settings, err := s.store.MDBListSettings(ctx)
	if err != nil {
		s.loggerFor(ctx).Warn("MDBList settings unavailable", "error", err)
		return store.MDBListSettings{}, false
	}
	c.mu.Lock()
	c.settings, c.loadedAt = settings, now
	c.mu.Unlock()
	return settings, settings.Enabled && settings.APIKey != "" && len(settings.Sources) > 0
}

// forgetMDBListSettings is called by the admin write so an operator's change is live on
// the next request rather than at the end of the cache window.
func (s *Server) forgetMDBListSettings() {
	c := &s.mdblistSettingsCache
	c.mu.Lock()
	c.loadedAt = time.Time{}
	c.mu.Unlock()
}

// ratingsWarmer renews and fills the durable cache behind the viewer.
//
// Navigation is what feeds it: every row that reaches a television reports the titles it
// could not decorate, and those are fetched once and kept. The queue is bounded and
// deliberately lossy — a title dropped now is offered again the next time somebody
// scrolls past it, which is a far better failure than a growing backlog of requests
// against a daily allowance.
type ratingsWarmer struct {
	once   sync.Once
	queue  chan store.RatingKey
	mu     sync.Mutex
	queued map[store.RatingKey]bool

	spent      int
	windowFrom time.Time
	blockedTil time.Time
}

// warmRatings offers titles to the background warmer. It never blocks the caller.
func (s *Server) warmRatings(keys ...store.RatingKey) {
	if s.mdblist == nil || s.store == nil || len(keys) == 0 {
		return
	}
	w := &s.ratingsWarm
	w.once.Do(func() {
		w.queue = make(chan store.RatingKey, ratingsWarmQueue)
		w.queued = map[store.RatingKey]bool{}
		go s.ratingsWarmLoop()
	})
	for _, key := range keys {
		if key.MediaType == "" || key.Provider == "" || key.ProviderID == "" {
			continue
		}
		w.mu.Lock()
		already := w.queued[key]
		if !already {
			w.queued[key] = true
		}
		w.mu.Unlock()
		if already {
			continue
		}
		select {
		case w.queue <- key:
		default:
			// Full. Forget it rather than wait: the next row that shows this title will
			// offer it again, and a blocked handler would be paying for a cache fill.
			w.mu.Lock()
			delete(w.queued, key)
			w.mu.Unlock()
		}
	}
}

func (s *Server) ratingsWarmLoop() {
	w := &s.ratingsWarm
	for key := range w.queue {
		w.mu.Lock()
		delete(w.queued, key)
		w.mu.Unlock()
		s.warmOne(key)
		time.Sleep(ratingsWarmInterval)
	}
}

func (s *Server) warmOne(key store.RatingKey) {
	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel()
	settings, enabled := s.mdblistSettings(ctx)
	if !enabled {
		return
	}
	if !s.claimRatingsBudget(time.Now()) {
		return
	}
	if _, err := s.fetchAndStoreRatings(ctx, settings.APIKey, key); err != nil {
		var apiErr *mdblist.APIError
		if errors.As(err, &apiErr) && (apiErr.StatusCode == 429 || apiErr.StatusCode == 402) {
			s.blockRatingsWarming(time.Now().Add(ratingsWarmBackoff))
		}
		if s.log != nil {
			s.log.Debug("MDBList warm failed",
				"component", "ratings", "provider", key.Provider, "id", key.ProviderID, "error", err)
		}
		return
	}
	if s.log != nil {
		s.log.Debug("MDBList rating stored",
			"component", "ratings", "provider", key.Provider, "id", key.ProviderID)
	}
}

// claimRatingsBudget spends one of the day's allowed external requests. The window is
// in-process: a restart forgives what was already spent, which is the right way round
// for a counter whose only job is to stop a runaway fill.
func (s *Server) claimRatingsBudget(now time.Time) bool {
	w := &s.ratingsWarm
	w.mu.Lock()
	defer w.mu.Unlock()
	if now.Before(w.blockedTil) {
		return false
	}
	if w.windowFrom.IsZero() || now.Sub(w.windowFrom) >= 24*time.Hour {
		w.windowFrom = now
		w.spent = 0
	}
	if w.spent >= ratingsWarmDailyBudget {
		return false
	}
	w.spent++
	return true
}

func (s *Server) blockRatingsWarming(until time.Time) {
	w := &s.ratingsWarm
	w.mu.Lock()
	defer w.mu.Unlock()
	w.blockedTil = until
}

// rememberRatingRef records an item's external identity without delaying the response
// that discovered it.
func (s *Server) rememberRatingRef(ctx context.Context, itemID string, key store.RatingKey) {
	if s.store == nil || itemID == "" {
		return
	}
	ctx = context.WithoutCancel(ctx)
	go func() {
		writeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
		defer cancel()
		if err := s.store.SaveItemRatingRef(writeCtx, itemID, key); err != nil && s.log != nil {
			s.loggerFor(ctx).Debug("rating reference write failed", "item", itemID, "error", err)
		}
	}()
}

// decorateHomeRatings attaches stored ratings to every collection of items in one pass.
//
// One pass rather than one per row: the fixed rows and the composed rows share their
// backing arrays, so decorating each separately would resolve the same ids repeatedly,
// and writing back in place is what keeps both views agreeing.
func (s *Server) decorateHomeRatings(ctx context.Context, out *homeResponse) {
	collections := make([][]json.RawMessage, 0, len(out.Rows)+4)
	for _, row := range out.Rows {
		collections = append(collections, row.Items)
	}
	collections = append(collections,
		out.ContinueWatching, out.NextUp, out.Favorites, out.LatestMovies)
	s.decorateItemRatings(ctx, collections...)
}

// decorateItemRatings rewrites each item in place with whatever the database already
// holds for it, and offers the rest to the warmer.
func (s *Server) decorateItemRatings(ctx context.Context, collections ...[]json.RawMessage) {
	settings, enabled := s.mdblistSettings(ctx)
	if !enabled {
		return
	}
	ids := itemIDsIn(collections, ratingsAttachItemLimit)
	if len(ids) == 0 {
		return
	}
	keys := s.ratingKeysForItems(ctx, ids)
	if len(keys) == 0 {
		return
	}
	stored, err := s.store.MediaRatingsBatch(ctx, distinctRatingKeys(keys))
	if err != nil {
		s.loggerFor(ctx).Warn("stored ratings read failed", "error", err)
		return
	}
	now := time.Now()
	byItem := make(map[string][]movieRating, len(keys))
	warm := make([]store.RatingKey, 0)
	warmed := make(map[store.RatingKey]bool, len(keys))
	for itemID, key := range keys {
		entry, ok := stored[key]
		var ratings []mdblist.Rating
		if ok {
			if json.Unmarshal(entry.Ratings, &ratings) != nil {
				ratings = nil
			}
			byItem[itemID] = selectedMovieRatings(settings.Sources, ratings)
		}
		if (!ok || ratingsNeedRefresh(ratings, entry.FetchedAt, now)) && !warmed[key] {
			warmed[key] = true
			warm = append(warm, key)
		}
	}
	for _, items := range collections {
		for index, raw := range items {
			id := itemIDOf(raw)
			ratings, ok := byItem[id]
			if !ok || len(ratings) == 0 {
				continue
			}
			items[index] = injectItemRatings(raw, ratings)
		}
	}
	// Only what the household actually looked at is warmed, which is what keeps a large
	// library from being imported into MDBList's quota all at once.
	s.warmRatings(warm...)
}

// decorateRowRatings is the row-shaped entry point used outside Home.
func (s *Server) decorateRowRatings(ctx context.Context, rows []recommend.Row) {
	collections := make([][]json.RawMessage, 0, len(rows))
	for _, row := range rows {
		collections = append(collections, row.Items)
	}
	s.decorateItemRatings(ctx, collections...)
}

// ratingKeysForItems resolves Emby ids to external titles, preferring the index built by
// navigation and falling back to the imported library.
func (s *Server) ratingKeysForItems(ctx context.Context, ids []string) map[string]store.RatingKey {
	keys, err := s.store.ItemRatingRefs(ctx, ids)
	if err != nil {
		s.loggerFor(ctx).Warn("rating references read failed", "error", err)
		keys = map[string]store.RatingKey{}
	}
	missing := make([]string, 0, len(ids))
	for _, id := range ids {
		if _, ok := keys[id]; !ok {
			missing = append(missing, id)
		}
	}
	if len(missing) == 0 {
		return keys
	}
	refs, err := s.store.LibraryProviderIDs(ctx, missing)
	if err != nil {
		s.loggerFor(ctx).Warn("library provider ids read failed", "error", err)
		return keys
	}
	for id, ref := range refs {
		if key, ok := ratingKeyFor(ref.Type, ref.ProviderIDs); ok {
			keys[id] = key
		}
	}
	return keys
}

// ratingKeyFor applies the same rule the live lookup does: MDBList rates films and
// shows, an episode is rated as its series, and tmdb wins over imdb.
func ratingKeyFor(itemType string, ids map[string]string) (store.RatingKey, bool) {
	var mediaType string
	switch {
	case strings.EqualFold(itemType, "Movie"):
		mediaType = "movie"
	case strings.EqualFold(itemType, "Series"), strings.EqualFold(itemType, "Episode"):
		mediaType = "show"
	default:
		return store.RatingKey{}, false
	}
	provider, providerID := movieProvider(ids)
	if providerID == "" {
		return store.RatingKey{}, false
	}
	return store.RatingKey{MediaType: mediaType, Provider: provider, ProviderID: providerID}, true
}

func distinctRatingKeys(keys map[string]store.RatingKey) []store.RatingKey {
	seen := make(map[store.RatingKey]bool, len(keys))
	out := make([]store.RatingKey, 0, len(keys))
	for _, key := range keys {
		if seen[key] {
			continue
		}
		seen[key] = true
		out = append(out, key)
	}
	return out
}

// injectItemRatings adds the ratings field to one item payload. Emby's JSON is otherwise
// forwarded verbatim, so this decodes into raw members and re-encodes rather than
// through any struct: a field this build does not know about must survive the round trip.
func injectItemRatings(raw json.RawMessage, ratings []movieRating) json.RawMessage {
	if len(ratings) == 0 {
		return raw
	}
	var members map[string]json.RawMessage
	if json.Unmarshal(raw, &members) != nil || members == nil {
		return raw
	}
	encoded, err := json.Marshal(ratings)
	if err != nil {
		return raw
	}
	members[ratingsItemField] = encoded
	out, err := json.Marshal(members)
	if err != nil {
		return raw
	}
	return out
}

func itemIDOf(raw json.RawMessage) string {
	var item struct {
		ID string `json:"Id"`
	}
	if json.Unmarshal(raw, &item) != nil {
		return ""
	}
	return item.ID
}

func itemIDsIn(collections [][]json.RawMessage, limit int) []string {
	seen := make(map[string]bool)
	ids := make([]string, 0, limit)
	for _, items := range collections {
		for _, raw := range items {
			id := itemIDOf(raw)
			if id == "" || seen[id] {
				continue
			}
			seen[id] = true
			ids = append(ids, id)
			if len(ids) >= limit {
				return ids
			}
		}
	}
	return ids
}
