// Package recommend turns a user's Emby watch history into home-screen rows.
//
// The scoring here is deliberately simple and explainable — genre and studio affinity
// weighted by recency, penalised for what the user has already seen. It runs against one
// household's library, where a heavier model would have neither the data to learn from
// nor a way to show its work when a row looks wrong.
package recommend

import (
	"encoding/json"
	"math"
	"sort"
	"strconv"
	"strings"
	"time"
	"unicode"
)

// recencyDecay is applied per position down the history list. At 0.94, the 12th item
// carries about half the weight of the most recent one, so tastes can shift without the
// rows lagging weeks behind.
const recencyDecay = 0.94

// favoriteWeight is what an explicit favourite contributes. Deliberately below a fresh
// play: favouriting is a durable signal, but what someone watched last night is a better
// predictor of what they want tonight.
const favoriteWeight = 0.6

// Item is the slice of an Emby item this package reasons about. The raw payload rides
// along so rows can be emitted without re-fetching or re-encoding.
type Item struct {
	ID                string   `json:"Id"`
	Name              string   `json:"Name"`
	Type              string   `json:"Type"`
	SeriesID          string   `json:"SeriesId"`
	SeriesName        string   `json:"SeriesName"`
	ProductionYear    int      `json:"ProductionYear"`
	Genres            []string `json:"Genres"`
	CommunityRating   float64  `json:"CommunityRating"`
	RunTimeTicks      int64    `json:"RunTimeTicks"`
	OfficialRating    string   `json:"OfficialRating"`
	CollectionName    string   `json:"CollectionName"`
	PremiereDate      string   `json:"PremiereDate"`
	DateCreated       string   `json:"DateCreated"`
	IndexNumber       int      `json:"IndexNumber"`
	ParentIndexNumber int      `json:"ParentIndexNumber"`
	Container         string   `json:"Container"`
	MediaStreams      []struct {
		Type  string `json:"Type"`
		Codec string `json:"Codec"`
	} `json:"MediaStreams"`
	Studios []struct {
		Name string `json:"Name"`
	} `json:"Studios"`
	People   []Person `json:"People"`
	UserData struct {
		Played                bool  `json:"Played"`
		PlayCount             int   `json:"PlayCount"`
		PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
		IsFavorite            bool  `json:"IsFavorite"`
	} `json:"UserData"`

	Raw json.RawMessage `json:"-"`
}

func (i Item) RuntimeMinutes() int {
	if i.RunTimeTicks <= 0 {
		return 0
	}
	return int(i.RunTimeTicks / 600_000_000)
}

func (i Item) TitleKey() string {
	value := i.Name
	if i.Type == "Episode" && strings.TrimSpace(i.SeriesName) != "" {
		value = i.SeriesName
	}
	var b strings.Builder
	for _, r := range strings.ToLower(value) {
		if unicode.IsLetter(r) || unicode.IsDigit(r) {
			b.WriteRune(r)
		}
	}
	return b.String()
}

// SeenKey is a title-level fallback for imported catalogue records, whose payloads
// deliberately contain no per-user UserData. Movies include their year so watching an
// older film does not hide a remake with the same name; episodes collapse to series.
func (i Item) SeenKey() string {
	key := i.TitleKey()
	if key != "" && strings.EqualFold(i.Type, "Movie") && i.ProductionYear > 0 {
		return key + "|" + strconv.Itoa(i.ProductionYear)
	}
	return key
}

// Seed is a title recent enough to anchor a "Because you watched …" row.
type Seed struct {
	ID   string
	Name string
}

// Profile is what the engine learned about one user.
type Profile struct {
	GenreWeights  map[string]float64
	StudioWeights map[string]float64
	// PersonWeights is read only by the explanation layer, never by Score. Casting is a
	// good reason to *tell* someone about a title and a poor reason to rank by it: two
	// films sharing an actor are often nothing alike.
	PersonWeights map[string]float64
	// Seen holds item ids *and* series ids already watched or in progress, so a
	// recommendation never suggests something the user is already partway through.
	Seen       map[string]bool
	SeenTitles map[string]bool
	Seeds      []Seed
}

func (p Profile) IsEmpty() bool { return len(p.GenreWeights) == 0 && len(p.Seeds) == 0 }

// Decode parses raw Emby items, keeping the original payload attached.
func Decode(raws []json.RawMessage) []Item {
	items := make([]Item, 0, len(raws))
	for _, raw := range raws {
		var item Item
		if err := json.Unmarshal(raw, &item); err != nil || item.ID == "" {
			continue
		}
		item.Raw = raw
		items = append(items, item)
	}
	return items
}

// BuildProfile weights history by recency and folds in favourites.
//
// history must be ordered most-recent-first; favourites are unordered and all carry the
// same weight.
func BuildProfile(history, favorites []Item) Profile {
	profile := Profile{
		GenreWeights:  map[string]float64{},
		StudioWeights: map[string]float64{},
		PersonWeights: map[string]float64{},
		Seen:          map[string]bool{},
		SeenTitles:    map[string]bool{},
	}

	seedSeen := map[string]bool{}
	tasteSeen := map[string]bool{}
	for i, item := range history {
		profile.markSeen(item)

		// Several episodes of one series are evidence for one taste, not several
		// independent tastes. Keep the newest occurrence's recency weight and still
		// mark every item/series identifier as seen.
		tasteID := item.ID
		if item.SeriesID != "" {
			tasteID = item.SeriesID
		}
		if !tasteSeen[tasteID] {
			tasteSeen[tasteID] = true
			profile.absorbTaste(item, math.Pow(recencyDecay, float64(i)))
		}

		// An episode seeds its series, not itself: "Because you watched Severance"
		// reads better than "Because you watched Good News".
		seedID, seedName := item.ID, item.Name
		if item.SeriesID != "" {
			seedID, seedName = item.SeriesID, item.SeriesName
		}
		if seedID != "" && seedName != "" && !seedSeen[seedID] {
			seedSeen[seedID] = true
			profile.Seeds = append(profile.Seeds, Seed{ID: seedID, Name: seedName})
		}
	}

	for _, item := range favorites {
		profile.absorb(item, favoriteWeight)
	}
	return profile
}

func (p *Profile) absorb(item Item, weight float64) {
	p.markSeen(item)
	p.absorbTaste(item, weight)
}

func (p *Profile) markSeen(item Item) {
	if item.ID != "" {
		p.Seen[item.ID] = true
	}
	if item.SeriesID != "" {
		p.Seen[item.SeriesID] = true
	}
	if key := item.SeenKey(); key != "" {
		p.SeenTitles[key] = true
	}
}

// absorbTaste learns affinity without marking the item watched. This is used for
// browsing signals: lingering on a card is meaningful, but must not hide that card.
func (p *Profile) absorbTaste(item Item, weight float64) {
	for _, genre := range item.Genres {
		if g := strings.TrimSpace(genre); g != "" {
			p.GenreWeights[g] += weight
		}
	}
	for _, studio := range item.Studios {
		if s := strings.TrimSpace(studio.Name); s != "" {
			// Studio is a weaker signal than genre: people follow what a thing *is*
			// more reliably than who made it.
			p.StudioWeights[s] += weight * 0.4
		}
	}
	for _, person := range item.People {
		if !isExplainablePerson(person.Type) {
			continue
		}
		if name := strings.TrimSpace(person.Name); name != "" {
			if p.PersonWeights == nil {
				p.PersonWeights = map[string]float64{}
			}
			p.PersonWeights[name] += weight
		}
	}
}

// isExplainablePerson keeps the cast list down to the roles a viewer would recognise as
// a reason. A gaffer in common is not why anyone picks a film.
func isExplainablePerson(role string) bool {
	switch strings.ToLower(strings.TrimSpace(role)) {
	case "actor", "director", "writer":
		return true
	}
	return false
}

// TopGenres returns the n heaviest genres, highest first. Ties break alphabetically so
// the Emby query — and therefore the cached row — is stable between calls.
func (p Profile) TopGenres(n int) []string {
	type kv struct {
		genre  string
		weight float64
	}
	pairs := make([]kv, 0, len(p.GenreWeights))
	for genre, weight := range p.GenreWeights {
		pairs = append(pairs, kv{genre, weight})
	}
	sort.Slice(pairs, func(i, j int) bool {
		if pairs[i].weight != pairs[j].weight {
			return pairs[i].weight > pairs[j].weight
		}
		return pairs[i].genre < pairs[j].genre
	})
	if n > len(pairs) {
		n = len(pairs)
	}
	out := make([]string, 0, n)
	for _, pair := range pairs[:n] {
		out = append(out, pair.genre)
	}
	return out
}

// Score rates a candidate against the profile. A negative score means "exclude".
func (p Profile) Score(candidate Item) float64 {
	if p.Seen[candidate.ID] {
		return -1
	}
	if candidate.SeriesID != "" && p.Seen[candidate.SeriesID] {
		return -1
	}
	if p.SeenTitles[candidate.SeenKey()] {
		return -1
	}
	if candidate.UserData.Played || candidate.UserData.PlaybackPositionTicks > 0 {
		return -1
	}

	var genreScore float64
	for _, genre := range candidate.Genres {
		genreScore += p.GenreWeights[strings.TrimSpace(genre)]
	}
	// Divide by sqrt(genre count) so a title tagged with eight genres cannot outrank a
	// focused match simply by touching more of the profile.
	if n := len(candidate.Genres); n > 1 {
		genreScore /= math.Sqrt(float64(n))
	}

	var studioScore float64
	for _, studio := range candidate.Studios {
		studioScore += p.StudioWeights[strings.TrimSpace(studio.Name)]
	}

	// A mild quality nudge, capped so a beloved genre still beats a well-rated stranger.
	ratingScore := candidate.CommunityRating / 10 * 0.5

	// New catalogue arrivals should surface without overpowering established taste.
	// Production year is consistently available from both the imported library and
	// Emby, unlike DateCreated on deliberately narrow recommendation payloads.
	var freshnessScore float64
	age := time.Now().Year() - candidate.ProductionYear
	switch {
	case candidate.ProductionYear <= 0:
	case age <= 1:
		freshnessScore = 0.25
	case age <= 3:
		freshnessScore = 0.12
	}

	return genreScore + studioScore + ratingScore + freshnessScore
}

// CollectionAffinity decides which curated shelf appears first for this user.
func (p Profile) CollectionAffinity(genres, studios []string) float64 {
	var score float64
	for _, genre := range genres {
		score += weightFold(p.GenreWeights, genre)
	}
	for _, studio := range studios {
		score += weightFold(p.StudioWeights, studio)
	}
	return score
}

func weightFold(weights map[string]float64, wanted string) float64 {
	for key, value := range weights {
		if strings.EqualFold(strings.TrimSpace(key), strings.TrimSpace(wanted)) {
			return value
		}
	}
	return 0
}

// Rank scores, filters and truncates candidates, dropping duplicates by id.
func Rank(profile Profile, candidates []Item, limit int) []Item {
	return rank(profile, candidates, limit, false)
}

// RankCollection keeps unseen candidates with no affinity/rating score at the end,
// ensuring a curated shelf remains useful for a new profile or unrated library.
func RankCollection(profile Profile, candidates []Item, limit int) []Item {
	return rank(profile, candidates, limit, true)
}

func rank(profile Profile, candidates []Item, limit int, includeZero bool) []Item {
	type scored struct {
		item  Item
		score float64
	}

	seen := map[string]bool{}
	ranked := make([]scored, 0, len(candidates))
	for _, candidate := range candidates {
		if seen[candidate.ID] {
			continue
		}
		seen[candidate.ID] = true
		if score := profile.Score(candidate); score > 0 || includeZero && score == 0 {
			ranked = append(ranked, scored{candidate, score})
		}
	}

	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].item.Name < ranked[j].item.Name
	})

	if limit > 0 && len(ranked) > limit {
		ranked = ranked[:limit]
	}
	out := make([]Item, 0, len(ranked))
	for _, entry := range ranked {
		out = append(out, entry.item)
	}
	return out
}

// FilterUnseen keeps only what the user has not watched, preserving Emby's ordering.
// Used for "Because you watched …", where Emby's own similarity ranking is better than
// anything this package would compute.
func FilterUnseen(profile Profile, candidates []Item, limit int) []Item {
	out := make([]Item, 0, len(candidates))
	seen := map[string]bool{}
	for _, candidate := range candidates {
		if seen[candidate.ID] || profile.Score(candidate) < 0 {
			continue
		}
		seen[candidate.ID] = true
		out = append(out, candidate)
		if limit > 0 && len(out) >= limit {
			break
		}
	}
	return out
}

// Raws unwraps items back to the payloads the TV will receive.
func Raws(items []Item) []json.RawMessage {
	out := make([]json.RawMessage, 0, len(items))
	for _, item := range items {
		out = append(out, item.Raw)
	}
	return out
}
