package api

import (
	"context"
	"encoding/json"
	"net/http"
	"net/url"
	"sort"
	"strings"
	"time"

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

func recommendationRow(id string) bool {
	return id == "recommended" ||
		strings.HasPrefix(id, "for-you:") ||
		strings.HasPrefix(id, "curated:") ||
		strings.HasPrefix(id, "similar:")
}

// filterRecommendationPermissions makes Emby, using this viewer's token, the final
// eligibility authority. The shared imported catalogue can suggest candidates but can
// never broaden library access or bypass parental controls.
func (s *Server) filterRecommendationPermissions(
	ctx context.Context,
	sess store.Session,
	rows []recommend.Row,
) []recommend.Row {
	ids := []string{}
	for _, row := range rows {
		if !recommendationRow(row.ID) {
			continue
		}
		for _, item := range recommend.Decode(row.Items) {
			ids = append(ids, item.ID)
		}
	}
	if len(ids) == 0 {
		return rows
	}
	allowed := map[string]bool{}
	cred := credentials(sess)
	for start := 0; start < len(ids); start += 100 {
		end := min(start+100, len(ids))
		result, err := s.emby.Items(ctx, cred, rowParams(url.Values{
			"Ids":              {strings.Join(ids[start:end], ",")},
			"Recursive":        {"true"},
			"IncludeItemTypes": {"Movie,Series"},
			"Limit":            {itoa(end - start)},
		}, fieldsRow))
		if err != nil {
			s.log.Warn("recommendation permission check failed; hiding candidates",
				"user", sess.EmbyUserID, "error", err)
			for index := range rows {
				if recommendationRow(rows[index].ID) {
					rows[index].Items = []json.RawMessage{}
				}
			}
			return rows
		}
		for _, raw := range result.Items {
			decoded := recommend.Decode([]json.RawMessage{raw})
			if len(decoded) == 1 {
				allowed[decoded[0].ID] = true
			}
		}
	}
	for index := range rows {
		if !recommendationRow(rows[index].ID) {
			continue
		}
		filtered := []json.RawMessage{}
		for _, item := range recommend.Decode(rows[index].Items) {
			if allowed[item.ID] {
				filtered = append(filtered, item.Raw)
			}
		}
		rows[index].Items = filtered
	}
	return rows
}

func (s *Server) rankingContext(
	ctx context.Context,
	userID string,
) (recommend.WeightedProfile, map[string]recommend.ItemExposure, map[string]float64) {
	profile := recommend.WeightedProfile{}
	if s.store == nil {
		return profile, nil, nil
	}
	if raw, err := s.store.WeightedRecommendationProfile(ctx, userID); err == nil {
		_ = json.Unmarshal(raw, &profile)
	} else {
		s.log.Warn("weighted profile unavailable", "user", userID, "error", err)
	}
	if profile.ExplicitPositive == nil {
		profile.ExplicitPositive = map[string]bool{}
	}
	if profile.ExplicitNegative == nil {
		profile.ExplicitNegative = map[string]bool{}
	}
	if raw, err := s.store.RecommendationOnboarding(ctx, userID); err == nil {
		var preferences recommend.OnboardingPreferences
		if json.Unmarshal(raw, &preferences) == nil {
			profile.ApplyOnboarding(preferences, s.weightedConfig().MinimumEvidence)
			ids := make([]string, 0, len(preferences.Ratings))
			for id, rating := range preferences.Ratings {
				if strings.TrimSpace(id) != "" && rating >= 1 && rating <= 5 {
					ids = append(ids, id)
				}
			}
			if raws, itemErr := s.store.LibraryItemsByID(ctx, ids); itemErr == nil {
				for _, item := range recommend.Decode(raws) {
					profile.ApplyOnboardingRating(
						item, preferences.Ratings[item.ID],
						s.weightedConfig().MinimumEvidence,
					)
				}
			}
		}
	}
	if actions, err := s.store.RecommendationActions(ctx, userID); err == nil {
		ids := make([]string, 0, len(actions))
		byID := make(map[string]string, len(actions))
		for _, action := range actions {
			ids = append(ids, action.ItemID)
			byID[action.ItemID] = action.Action
			switch action.Action {
			case "more_like_this":
				profile.ExplicitPositive[action.ItemID] = true
			case "not_for_me":
				profile.ExplicitNegative[action.ItemID] = true
			}
		}
		if raws, itemErr := s.store.LibraryItemsByID(ctx, ids); itemErr == nil {
			for _, item := range recommend.Decode(raws) {
				profile.ApplyExplicitPreference(item, byID[item.ID] == "more_like_this")
			}
		}
	}
	exposures := map[string]recommend.ItemExposure{}
	if values, err := s.store.UserItemExposures(
		ctx, userID, time.Now().Add(-45*24*time.Hour),
	); err == nil {
		for _, value := range values {
			exposures[value.ItemID] = recommend.ItemExposure{
				Impressions: value.Impressions, Focuses: value.Focuses,
				Selects: value.Selects, LastShown: value.LastShown,
			}
		}
	}
	household, err := s.store.HouseholdCompletionScores(ctx, time.Now().Add(-180*24*time.Hour))
	if err != nil {
		household = map[string]float64{}
	}
	return profile, exposures, household
}

func (s *Server) personalizeTitles(
	ctx context.Context,
	sess store.Session,
	rows []recommend.Row,
) []recommend.Row {
	if len(rows) == 0 {
		return rows
	}
	profile, exposures, household := s.rankingContext(ctx, sess.EmbyUserID)
	cfg := s.weightedConfig()
	now := time.Now()
	location := s.cfg.SonarrLocation
	for index := range rows {
		row := &rows[index]
		if progressRow(row.ID) {
			continue
		}
		compatibility := map[string]float64{}
		for _, raw := range row.Items {
			var marker struct {
				ID            string `json:"Id"`
				Compatibility string `json:"MembyCompatibility"`
			}
			if json.Unmarshal(raw, &marker) == nil {
				switch {
				case strings.Contains(strings.ToLower(marker.Compatibility), "direct"):
					compatibility[marker.ID] = 1
				case strings.Contains(strings.ToLower(marker.Compatibility), "transcod"):
					compatibility[marker.ID] = -1
				}
			}
		}
		intent := recommend.RankIntent{
			ID: row.ID, Now: now, Location: location, HouseholdScores: household,
			Compatibility: compatibility,
		}
		switch {
		case row.ID == "latest-movies":
			intent.NewReleasesOnly = true
		case strings.Contains(row.ID, "one-episode"), strings.Contains(row.ID, "late-night"):
			intent.PreferShort = true
			intent.MaxRuntimeMins = 60
		case strings.Contains(row.ID, "hidden"):
			intent.HiddenLibrary = true
			intent.UnseenOnly = true
		}
		ranked := recommend.WeightedRank(
			profile, recommend.Decode(row.Items), exposures, intent, cfg, len(row.Items),
		)
		items := make([]json.RawMessage, 0, len(ranked))
		for _, value := range ranked {
			items = append(items, recommend.EnrichRankedItem(value))
		}
		row.Items = items
	}
	return rows
}

// progressRow marks the row that answers "what was I watching?" rather than "what might
// I like?" — and it is the only row whose order is not ours to decide.
//
// Continue Watching is Emby's resume list interleaved with Next Up, most-recently-watched
// first, which is the whole usefulness of it. Ranking it by taste reordered that: an
// episode carries none of the studio, cast or collection fields a film's payload does,
// its Type has no affinity evidence behind it, and a 22-minute runtime fits a household
// session profile built from features badly — so films sorted to the front and the show
// somebody was two episodes into sorted past the visible cards. Finishing an episode then
// looked like the series had vanished, because the one place it could be found was ordered
// by something other than having just been watched. The diversity caps and the exploration
// shuffle compound it for the same reason.
func progressRow(id string) bool {
	return id == "continue"
}

func (s *Server) personalizeSearch(
	ctx context.Context,
	sess store.Session,
	term string,
	raws []json.RawMessage,
	limit int,
) []json.RawMessage {
	profile, exposures, household := s.rankingContext(ctx, sess.EmbyUserID)
	items := recommend.Decode(raws)
	relevance := make(map[string]float64, len(items))
	wanted := strings.ToLower(strings.TrimSpace(term))
	for _, item := range items {
		name := strings.ToLower(strings.TrimSpace(item.Name))
		switch {
		case name == wanted:
			relevance[item.ID] = 20
		case strings.HasPrefix(name, wanted):
			relevance[item.ID] = 12
		case strings.Contains(name, wanted):
			relevance[item.ID] = 8
		default:
			relevance[item.ID] = 4
		}
	}
	ranked := recommend.WeightedRank(profile, items, exposures, recommend.RankIntent{
		ID: "search", Now: time.Now(), Location: s.cfg.SonarrLocation,
		SearchRelevance: relevance, HouseholdScores: household,
	}, s.weightedConfig(), limit)
	out := make([]json.RawMessage, 0, len(ranked))
	for _, value := range ranked {
		out = append(out, recommend.EnrichRankedItem(value))
	}
	return out
}

func (s *Server) weightedConfig() recommend.WeightedConfig {
	cfg := recommend.DefaultWeightedConfig()
	if s.cfg.RecommendationWeights != "" {
		_ = json.Unmarshal([]byte(s.cfg.RecommendationWeights), &cfg)
	}
	return cfg
}

// deduplicateRows gives the earliest row ownership of a title. Continue Watching keeps
// its landmarks; later discovery shelves fill with their remaining unique posters.
func deduplicateRows(rows []recommend.Row) []recommend.Row {
	seen := map[string]bool{}
	for rowIndex := range rows {
		items := recommend.Decode(rows[rowIndex].Items)
		filtered := make([]json.RawMessage, 0, len(items))
		for _, item := range items {
			key := item.ID
			if item.SeriesID != "" {
				key = item.SeriesID
			}
			if key == "" || seen[key] {
				continue
			}
			seen[key] = true
			filtered = append(filtered, item.Raw)
		}
		rows[rowIndex].Items = filtered
	}
	return rows
}

// personalizeRowsByTitleScores uses the same title scores to order discovery shelves.
// Mandatory shelves receive stable anchors; all other rows compete on the average of
// their leading posters, which makes row ordering change with the same profile evidence
// that changes poster ordering.
func personalizeRowsByTitleScores(rows []recommend.Row) []recommend.Row {
	type scoredRow struct {
		row      recommend.Row
		score    float64
		position int
	}
	ranked := make([]scoredRow, 0, len(rows))
	for position, row := range rows {
		score := 0.0
		count := 0
		for _, raw := range row.Items {
			var payload struct {
				Score float64 `json:"MembyRecommendationScore"`
			}
			if json.Unmarshal(raw, &payload) == nil {
				score += payload.Score
				count++
			}
			if count == 6 {
				break
			}
		}
		if count > 0 {
			score /= float64(count)
		}
		// A small authored-position prior avoids reshuffling ties and cold starts.
		score += 0.05 / float64(position+1)
		switch row.ID {
		case "continue":
			score = 1_000
		case "latest-movies":
			score = 90
		}
		ranked = append(ranked, scoredRow{row: row, score: score, position: position})
	}
	sort.SliceStable(ranked, func(i, j int) bool {
		if ranked[i].score != ranked[j].score {
			return ranked[i].score > ranked[j].score
		}
		return ranked[i].position < ranked[j].position
	})
	out := make([]recommend.Row, 0, len(ranked))
	for _, value := range ranked {
		out = append(out, value.row)
	}
	return out
}

func selectPersonalizedRows(rows []recommend.Row) []recommend.Row {
	out := make([]recommend.Row, 0, len(rows))
	for _, row := range rows {
		switch row.ID {
		case "continue", "latest-movies", "favorites":
			out = append(out, row)
			continue
		}
		if len(row.Items) == 0 {
			continue
		}
		total, count := 0.0, 0
		for _, raw := range row.Items {
			var payload struct {
				Score float64 `json:"MembyRecommendationScore"`
			}
			if json.Unmarshal(raw, &payload) == nil {
				total += payload.Score
				count++
			}
			if count == 6 {
				break
			}
		}
		if count > 0 && total/float64(count) < -0.25 {
			continue
		}
		out = append(out, row)
	}
	return out
}

type recommendationActionRequest struct {
	Action string `json:"action"`
}

func (s *Server) handleRecommendationAction(
	w http.ResponseWriter,
	r *http.Request,
	sess store.Session,
) {
	itemID := strings.TrimSpace(r.PathValue("id"))
	if itemID == "" {
		writeError(w, http.StatusBadRequest, "item id is required")
		return
	}
	var err error
	if r.Method == http.MethodDelete {
		err = s.store.ClearRecommendationAction(r.Context(), sess.EmbyUserID, itemID)
	} else {
		var req recommendationActionRequest
		if decodeErr := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req); decodeErr != nil {
			writeError(w, http.StatusBadRequest, "invalid recommendation action")
			return
		}
		err = s.store.SetRecommendationAction(
			r.Context(), sess.EmbyUserID, itemID, strings.TrimSpace(req.Action),
		)
	}
	if err != nil {
		writeError(w, http.StatusBadRequest, err.Error())
		return
	}
	_ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID)
	if s.forYou != nil {
		s.forYou.MarkDirty(context.WithoutCancel(r.Context()), sess)
		s.forYou.RefreshAsync(sess, false)
	}
	w.WriteHeader(http.StatusNoContent)
}

func (s *Server) handleRecommendationPreferences(
	w http.ResponseWriter,
	r *http.Request,
	sess store.Session,
) {
	if r.Method == http.MethodGet {
		s.handleRecommendationPreferencesGet(w, r, sess)
		return
	}
	var preferences recommend.OnboardingPreferences
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&preferences); err != nil {
		writeError(w, http.StatusBadRequest, "invalid onboarding preferences")
		return
	}
	if len(preferences.Ratings) > 40 {
		writeError(w, http.StatusBadRequest, "too many onboarding ratings")
		return
	}
	for id, rating := range preferences.Ratings {
		if strings.TrimSpace(id) == "" || rating < 1 || rating > 5 {
			writeError(w, http.StatusBadRequest, "ratings must be between 1 and 5")
			return
		}
	}
	peopleCount := len(preferences.Actors) + len(preferences.Actresses) + len(preferences.Directors)
	if peopleCount > 60 {
		writeError(w, http.StatusBadRequest, "too many onboarding people")
		return
	}
	for _, names := range [][]string{preferences.Actors, preferences.Actresses, preferences.Directors} {
		for _, name := range names {
			if strings.TrimSpace(name) == "" || len(name) > 160 {
				writeError(w, http.StatusBadRequest, "invalid onboarding person")
				return
			}
		}
	}
	preferences.Completed = true
	preferences.Prompted = true
	raw, _ := json.Marshal(preferences)
	if err := s.store.SetRecommendationOnboarding(
		r.Context(), sess.EmbyUserID, raw,
	); err != nil {
		writeError(w, http.StatusInternalServerError, "could not save onboarding preferences")
		return
	}
	_ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID)
	if s.forYou != nil {
		s.forYou.MarkDirty(context.WithoutCancel(r.Context()), sess)
		s.forYou.RefreshAsync(sess, false)
	}
	w.WriteHeader(http.StatusNoContent)
}

type recommendationOnboardingResponse struct {
	Completed bool                             `json:"completed"`
	Prompted  bool                             `json:"prompted"`
	Ratings   map[string]int                   `json:"ratings"`
	Items     []json.RawMessage                `json:"items"`
	Movies    []json.RawMessage                `json:"movies"`
	Shows     []json.RawMessage                `json:"shows"`
	Actors    []recommendationOnboardingPerson `json:"actors"`
	Actresses []recommendationOnboardingPerson `json:"actresses"`
	Directors []recommendationOnboardingPerson `json:"directors"`
}

type recommendationOnboardingPerson struct {
	ID       string `json:"id"`
	Name     string `json:"name"`
	ImageTag string `json:"imageTag"`
}

func (s *Server) handleRecommendationPreferencesGet(
	w http.ResponseWriter,
	r *http.Request,
	sess store.Session,
) {
	preferences := recommend.OnboardingPreferences{}
	if raw, err := s.store.RecommendationOnboarding(r.Context(), sess.EmbyUserID); err == nil {
		_ = json.Unmarshal(raw, &preferences)
	}
	if preferences.Ratings == nil {
		preferences.Ratings = map[string]int{}
	}
	// Older TVs only understand completed. Treat an uninvited profile as complete on the
	// wire so server-side prompt control also suppresses the legacy automatic flow. The
	// stored value remains false; queueing a prompt changes Prompted and the next request
	// receives the real incomplete state.
	if preferences.Completed || !preferences.Prompted {
		writeJSON(w, http.StatusOK, recommendationOnboardingResponse{
			Completed: true, Prompted: preferences.Prompted, Ratings: preferences.Ratings, Items: []json.RawMessage{},
			Movies: []json.RawMessage{}, Shows: []json.RawMessage{},
			Actors: []recommendationOnboardingPerson{}, Actresses: []recommendationOnboardingPerson{},
			Directors: []recommendationOnboardingPerson{},
		})
		return
	}
	raws, err := s.store.AllRecommendationCandidates(r.Context())
	if err != nil {
		writeError(w, http.StatusInternalServerError, "could not load rating choices")
		return
	}
	candidates := recommendationOnboardingCandidates(recommend.Decode(raws), 40)
	row := recommend.Row{ID: "for-you:onboarding", Kind: "for-you"}
	for _, item := range candidates {
		row.Items = append(row.Items, item.Raw)
	}
	filtered := s.filterRecommendationPermissions(
		r.Context(), sess, []recommend.Row{row},
	)
	items := []json.RawMessage{}
	if len(filtered) == 1 {
		items = filtered[0].Items
		if len(items) > 32 {
			items = items[:32]
		}
	}
	movies, shows := []json.RawMessage{}, []json.RawMessage{}
	visible := recommend.Decode(items)
	for _, item := range visible {
		if strings.EqualFold(item.Type, "Movie") && len(movies) < 16 {
			movies = append(movies, item.Raw)
		}
		if strings.EqualFold(item.Type, "Series") && len(shows) < 16 {
			shows = append(shows, item.Raw)
		}
	}
	actors, actresses, directors := recommendationOnboardingPeople(visible, 16)
	writeJSON(w, http.StatusOK, recommendationOnboardingResponse{
		Completed: preferences.Completed,
		Prompted:  preferences.Prompted,
		Ratings:   preferences.Ratings,
		Items:     items,
		Movies:    movies, Shows: shows,
		Actors: actors, Actresses: actresses, Directors: directors,
	})
}

// recommendationOnboardingPeople turns the cast and crew already visible to this user
// into recognisable portrait choices. Emby identifies all performers as Actor, so the
// actress split uses a deliberately curated, case-insensitive list; unfamiliar names
// remain in Actors rather than being guessed from a name.
func recommendationOnboardingPeople(items []recommend.Item, limit int) (
	[]recommendationOnboardingPerson, []recommendationOnboardingPerson, []recommendationOnboardingPerson,
) {
	type candidate struct {
		person      recommendationOnboardingPerson
		appearances int
		bestRating  float64
	}
	groups := [3]map[string]*candidate{{}, {}, {}}
	for _, item := range items {
		seen := map[string]bool{}
		for _, person := range item.People {
			name := strings.TrimSpace(person.Name)
			key := strings.ToLower(name)
			if name == "" || seen[key] {
				continue
			}
			seen[key] = true
			group := -1
			switch strings.ToLower(strings.TrimSpace(person.Type)) {
			case "director":
				group = 2
			case "actor":
				if onboardingActresses[key] {
					group = 1
				} else {
					group = 0
				}
			}
			if group < 0 {
				continue
			}
			value := groups[group][key]
			if value == nil {
				value = &candidate{person: recommendationOnboardingPerson{ID: person.ID, Name: name, ImageTag: person.PrimaryImageTag}}
				groups[group][key] = value
			}
			value.appearances++
			if item.CommunityRating > value.bestRating {
				value.bestRating = item.CommunityRating
			}
			if value.person.ID == "" && person.ID != "" {
				value.person.ID, value.person.ImageTag = person.ID, person.PrimaryImageTag
			}
		}
	}
	output := func(values map[string]*candidate) []recommendationOnboardingPerson {
		all := make([]*candidate, 0, len(values))
		for _, value := range values {
			all = append(all, value)
		}
		sort.Slice(all, func(i, j int) bool {
			if all[i].appearances != all[j].appearances {
				return all[i].appearances > all[j].appearances
			}
			if all[i].bestRating != all[j].bestRating {
				return all[i].bestRating > all[j].bestRating
			}
			return strings.ToLower(all[i].person.Name) < strings.ToLower(all[j].person.Name)
		})
		if len(all) > limit {
			all = all[:limit]
		}
		out := make([]recommendationOnboardingPerson, 0, len(all))
		for _, value := range all {
			out = append(out, value.person)
		}
		return out
	}
	return output(groups[0]), output(groups[1]), output(groups[2])
}

var onboardingActresses = map[string]bool{
	"amy adams": true, "cate blanchett": true, "viola davis": true, "zendaya": true,
	"michelle yeoh": true, "lupita nyong'o": true, "florence pugh": true, "saoirse ronan": true,
	"margot robbie": true, "emma stone": true, "scarlett johansson": true, "natalie portman": true,
	"jessica chastain": true, "octavia spencer": true, "regina king": true, "taraji p. henson": true,
	"tilda swinton": true, "frances mcdormand": true, "olivia colman": true, "kate winslet": true,
	"nicole kidman": true, "toni collette": true, "kirsten dunst": true, "rachel weisz": true,
	"ana de armas": true, "anya taylor-joy": true, "aunjanue ellis-taylor": true, "danai gurira": true,
	"gemma chan": true, "greta lee": true, "janelle monáe": true, "kerry washington": true,
	"ming-na wen": true, "rosamund pike": true, "ruth negga": true, "sandra oh": true,
	"salma hayek": true, "sonoya mizuno": true, "tessa thompson": true,
	"thandiwe newton": true, "zoë saldaña": true, "meryl streep": true, "jodie foster": true,
	"sigourney weaver": true, "angela bassett": true, "gillian anderson": true, "elisabeth moss": true,
	"jennifer coolidge": true, "quinta brunson": true, "ayo edebiri": true, "bella ramsey": true,
	"emily blunt": true, "jennifer lawrence": true, "anne hathaway": true, "rachel mcadams": true,
	"charlize theron": true, "halle berry": true, "brie larson": true, "rebecca ferguson": true,
	"julia roberts": true, "sandra bullock": true, "reese witherspoon": true, "jennifer aniston": true,
	"laura dern": true, "julianne moore": true, "glenn close": true, "helen mirren": true,
	"judi dench": true, "maggie smith": true, "kathy bates": true, "carey mulligan": true,
	"alicia vikander": true, "noomi rapace": true, "marion cotillard": true, "léa seydoux": true,
	"penélope cruz": true, "deepika padukone": true, "priyanka chopra jonas": true, "awkwafina": true,
	"constance wu": true, "zoë kravitz": true, "gwendoline christie": true, "emilia clarke": true,
	"lena headey": true, "sarah snook": true, "jodie comer": true, "issa rae": true,
	"uzo aduba": true, "natasha lyonne": true, "catherine o'hara": true, "jean smart": true,
	"melanie lynskey": true, "lucy lawless": true, "rose mciver": true, "thomasin mckenzie": true,
	"keisha castle-hughes": true, "rena owen": true, "elizabeth debicki": true, "sarah paulson": true,
	"jenna ortega": true, "hailee steinfeld": true, "millie bobby brown": true, "kristen stewart": true,
}

// recommendationOnboardingCandidates selects recognisable, well-rated titles while
// keeping movies, series and primary genres mixed. It is deterministic so returning to
// an unfinished onboarding screen does not reshuffle the choices.
func recommendationOnboardingCandidates(items []recommend.Item, limit int) []recommend.Item {
	sort.SliceStable(items, func(i, j int) bool {
		if items[i].CommunityRating != items[j].CommunityRating {
			return items[i].CommunityRating > items[j].CommunityRating
		}
		if items[i].ProductionYear != items[j].ProductionYear {
			return items[i].ProductionYear > items[j].ProductionYear
		}
		return items[i].Name < items[j].Name
	})
	buckets := map[string][]recommend.Item{"movie": {}, "series": {}}
	typeCounts := map[string]int{}
	genreCounts := map[string]int{}
	eraCounts := map[string]int{}
	perType := max(1, limit/2)
	for _, item := range items {
		kind := strings.ToLower(strings.TrimSpace(item.Type))
		if kind != "movie" && kind != "series" || item.CommunityRating <= 0 ||
			typeCounts[kind] >= perType {
			continue
		}
		genre := ""
		if len(item.Genres) > 0 {
			genre = strings.ToLower(strings.TrimSpace(item.Genres[0]))
		}
		genreKey := kind + ":" + genre
		era := "classic"
		if item.ProductionYear >= 2020 {
			era = "current"
		} else if item.ProductionYear >= 2000 {
			era = "modern"
		} else if item.ProductionYear >= 1980 {
			era = "catalogue"
		}
		eraKey := kind + ":" + era
		if genre != "" && genreCounts[genreKey] >= max(2, perType/4) {
			continue
		}
		if eraCounts[eraKey] >= max(3, perType/2) {
			continue
		}
		buckets[kind] = append(buckets[kind], item)
		typeCounts[kind]++
		genreCounts[genreKey]++
		eraCounts[eraKey]++
		if typeCounts["movie"]+typeCounts["series"] == limit {
			break
		}
	}
	out := make([]recommend.Item, 0, limit)
	for index := 0; len(out) < limit; index++ {
		added := false
		for _, kind := range []string{"movie", "series"} {
			if index < len(buckets[kind]) {
				out = append(out, buckets[kind][index])
				added = true
			}
		}
		if !added {
			break
		}
	}
	return out
}
