package catalog

import (
	"context"
	"errors"
	"fmt"
	"slices"
	"strings"
	"time"

	"github.com/google/uuid"
)

type sectionSeed struct {
	Kind     string
	Title    string
	Subtitle string
	ItemIDs  []int
}

type Repository interface {
	Discover(ctx context.Context, params DiscoverParams) ([]DiscoverSection, error)
	SectionItems(ctx context.Context, kind string, limit int) ([]MediaItem, error)
	SearchMedia(ctx context.Context, params SearchParams) ([]MediaItem, error)
	ListWatchLater(ctx context.Context, userID uuid.UUID) ([]MediaItem, error)
	AddWatchLater(ctx context.Context, userID uuid.UUID, mediaID int) error
	RemoveWatchLater(ctx context.Context, userID uuid.UUID, mediaID int) error
	ListContinueWatching(ctx context.Context, userID uuid.UUID, limit int) ([]ContinueWatchingItem, error)
	UpsertProgress(ctx context.Context, userID uuid.UUID, input ProgressUpdateInput) error
}

type GameLookup interface {
	SearchGames(ctx context.Context, query string, limit int) ([]MediaItem, error)
}

var (
	ErrInvalidInput  = errors.New("invalid input")
	ErrMediaNotFound = errors.New("media not found")
)

type Service struct {
	repo             Repository
	gameLookup       GameLookup
	media            map[int]MediaItem
	allMedia         []MediaItem
	sections         []sectionSeed
	dashboard        DashboardPayload
	continueWatching []ContinueWatchingItem
}

func NewService(repo ...Repository) *Service {
	var selected Repository
	if len(repo) > 0 {
		selected = repo[0]
	}

	allMedia := []MediaItem{
		newMedia(1, MediaProviderTMDB, 10001, "Neon Divide", MediaTypeMovie, []string{"Sci-Fi", "Thriller"}, nil, "2025-05-14", 8.5, 118),
		newMedia(2, MediaProviderTMDB, 10002, "Last Light Harbor", MediaTypeShow, []string{"Drama", "Mystery"}, nil, "2024-09-02", 8.1, 52),
		newMedia(3, MediaProviderTMDB, 10003, "Orbitline", MediaTypeMovie, []string{"Sci-Fi", "Adventure"}, nil, "2025-02-21", 7.9, 131),
		newMedia(4, MediaProviderTMDB, 10004, "Static Bloom", MediaTypeShow, []string{"Comedy", "Drama"}, nil, "2023-11-12", 7.8, 42),
		newMedia(5, MediaProviderTMDB, 10005, "Kingdom Ash", MediaTypeMovie, []string{"Fantasy", "Action"}, nil, "2024-12-08", 8.7, 143),
		newMedia(6, MediaProviderTMDB, 10006, "Pulse District", MediaTypeShow, []string{"Crime", "Thriller"}, nil, "2025-03-18", 8.0, 48),
		newMedia(7, MediaProviderTMDB, 10007, "The Glass Relay", MediaTypeMovie, []string{"Action", "Thriller"}, nil, "2024-07-01", 7.6, 109),
		newMedia(8, MediaProviderTMDB, 10008, "Summer in Vanta", MediaTypeShow, []string{"Romance", "Drama"}, nil, "2025-06-30", 7.5, 44),
		newMedia(9, MediaProviderTMDB, 10009, "Zero Meridian", MediaTypeMovie, []string{"Sci-Fi", "Action"}, nil, "2026-01-10", 8.9, 127),
		newMedia(10, MediaProviderTMDB, 10010, "Northline 13", MediaTypeShow, []string{"Mystery", "Crime"}, nil, "2025-10-19", 8.3, 50),
		newMedia(11, MediaProviderTMDB, 10011, "Paper Falcons", MediaTypeMovie, []string{"Adventure", "Family"}, nil, "2024-03-05", 7.4, 101),
		newMedia(12, MediaProviderTMDB, 10012, "Hollow Anthem", MediaTypeMovie, []string{"Drama", "Music"}, nil, "2025-08-22", 8.2, 114),
		newMedia(13, MediaProviderTMDB, 10013, "Riptide Avenue", MediaTypeShow, []string{"Action", "Drama"}, nil, "2023-04-09", 7.7, 55),
		newMedia(14, MediaProviderTMDB, 10014, "Night Air Index", MediaTypeMovie, []string{"Mystery", "Thriller"}, nil, "2026-04-03", 8.4, 122),
		newMedia(15, MediaProviderTMDB, 10015, "Shoreline Math", MediaTypeShow, []string{"Comedy", "Family"}, nil, "2024-06-17", 7.3, 37),
		newMedia(16, MediaProviderTMDB, 10016, "Arcadia Wire", MediaTypeMovie, []string{"Fantasy", "Drama"}, nil, "2025-12-02", 8.6, 136),
		newMedia(17, MediaProviderTMDB, 10017, "Abyss Echo", MediaTypeShow, []string{"Sci-Fi", "Mystery"}, nil, "2025-01-27", 8.8, 53),
		newMedia(18, MediaProviderTMDB, 10018, "Delta Murmur", MediaTypeMovie, []string{"Horror", "Thriller"}, nil, "2024-10-29", 7.2, 96),
		newMedia(19, MediaProviderTMDB, 10019, "Pine Weather", MediaTypeShow, []string{"Drama", "Romance"}, nil, "2025-04-11", 7.9, 46),
		newMedia(20, MediaProviderTMDB, 10020, "Copper Atlas", MediaTypeMovie, []string{"Adventure", "Action"}, nil, "2025-09-09", 8.0, 111),
		newMedia(21, MediaProviderTMDB, 10021, "Moonset Terminal", MediaTypeMovie, []string{"Sci-Fi", "Drama"}, nil, "2026-02-14", 8.4, 124),
		newMedia(22, MediaProviderTMDB, 10022, "Marble Sea", MediaTypeShow, []string{"Fantasy", "Adventure"}, nil, "2024-01-22", 7.6, 49),
		newMedia(23, MediaProviderTMDB, 10023, "Tangent Room", MediaTypeMovie, []string{"Mystery", "Drama"}, nil, "2025-11-03", 8.1, 119),
		newMedia(24, MediaProviderTMDB, 10024, "Signal Orchard", MediaTypeShow, []string{"Thriller", "Drama"}, nil, "2025-07-18", 8.2, 51),
		newMedia(25, MediaProviderIGDB, 20025, "Star Circuit Zero", MediaTypeGame, []string{"Action", "Racing"}, []string{"PC", "PS5", "Xbox Series X|S"}, "2026-09-18", 8.8, 900),
		newMedia(26, MediaProviderIGDB, 20026, "Verdant Protocol", MediaTypeGame, []string{"Strategy", "Simulation"}, []string{"PC"}, "2026-11-06", 8.4, 1260),
		newMedia(27, MediaProviderIGDB, 20027, "Mythic Drift", MediaTypeGame, []string{"Racing", "Adventure"}, []string{"PS5", "Xbox Series X|S"}, "2026-07-24", 8.2, 720),
		newMedia(28, MediaProviderIGDB, 20028, "Ashen Vale", MediaTypeGame, []string{"RPG", "Adventure"}, []string{"PC", "PS5"}, "2026-05-15", 9.0, 1680),
		newMedia(29, MediaProviderIGDB, 20029, "Signal Breaker", MediaTypeGame, []string{"Shooter", "Sci-Fi"}, []string{"PC", "Xbox Series X|S"}, "2026-02-28", 8.1, 840),
		newMedia(30, MediaProviderIGDB, 20030, "Luma Forge", MediaTypeGame, []string{"Indie", "Puzzle"}, []string{"Nintendo Switch", "PC"}, "2026-01-16", 8.3, 360),
		newMedia(31, MediaProviderIGDB, 20031, "Citadel Dawn", MediaTypeGame, []string{"Strategy", "RPG"}, []string{"PC"}, "2026-03-05", 8.6, 1500),
		newMedia(32, MediaProviderIGDB, 20032, "Harbor Tactics", MediaTypeGame, []string{"Strategy", "Simulation"}, []string{"PC"}, "2025-11-21", 7.8, 1080),
		newMedia(33, MediaProviderIGDB, 20033, "Ghostline Kyoto", MediaTypeGame, []string{"Action", "Adventure"}, []string{"PS5", "PC"}, "2026-02-14", 8.7, 1020),
		newMedia(34, MediaProviderIGDB, 20034, "Snowfall County", MediaTypeGame, []string{"Simulation", "Adventure"}, []string{"Nintendo Switch", "PC"}, "2025-12-12", 7.9, 540),
		newMedia(35, MediaProviderIGDB, 20035, "Titan Relay", MediaTypeGame, []string{"Shooter", "Action"}, []string{"PC", "PS5"}, "2026-04-22", 8.5, 780),
		newMedia(36, MediaProviderIGDB, 20036, "Wild Circuit Stories", MediaTypeGame, []string{"Racing", "Indie"}, []string{"Nintendo Switch", "Xbox Series X|S"}, "2026-08-07", 8.0, 420),
	}

	mediaByID := make(map[int]MediaItem, len(allMedia))
	for _, item := range allMedia {
		mediaByID[item.ID] = item
	}

	sections := []sectionSeed{
		{Kind: "trending", Title: "Trending", Subtitle: "Hot picks across screens and launchers", ItemIDs: []int{9, 25, 33, 21, 6, 17, 28, 23}},
		{Kind: "popular", Title: "Popular", Subtitle: "High-signal releases people keep returning to", ItemIDs: []int{1, 2, 33, 5, 28, 8, 10, 31}},
		{Kind: "top-rated", Title: "Top Rated", Subtitle: "Highest community ratings across all media", ItemIDs: []int{9, 17, 25, 5, 33, 16, 28, 21}},
		{Kind: "upcoming", Title: "Upcoming", Subtitle: "Near-term drops on your radar", ItemIDs: []int{21, 25, 26, 14, 27, 23, 35, 36}},
		{Kind: "now-playing", Title: "Now Playing", Subtitle: "Freshly added movies, episodes, and game launches", ItemIDs: []int{3, 6, 12, 29, 33, 30, 2, 17}},
		{Kind: "airing-today", Title: "Airing Today", Subtitle: "Episodes and drops available now", ItemIDs: []int{6, 10, 17, 19, 24, 22, 4, 15}},
		{Kind: "recently-released-games", Title: "Recently Released Games", Subtitle: "New launches worth checking this month", ItemIDs: []int{33, 31, 29, 30, 34, 32}},
		{Kind: "most-anticipated-games", Title: "Most Anticipated Games", Subtitle: "Upcoming releases with strong momentum", ItemIDs: []int{25, 26, 27, 28, 35, 36, 31, 29}},
		{Kind: "indie-highlights", Title: "Indie Highlights", Subtitle: "Smaller teams shipping sharper ideas", ItemIDs: []int{30, 36, 34, 32, 28, 26}},
	}

	dashboard := DashboardPayload{
		WatchLater:  mustPick(mediaByID, []int{25, 9, 14, 33, 21, 28}),
		GameBacklog: mustPick(mediaByID, []int{25, 33, 28, 31}),
		ActiveDownloads: []DownloadJob{
			{ID: "dl-1024", Title: "Zero Meridian (2160p HDR)", Status: "downloading", ProgressPercent: 47, DownloadSpeedMbps: 23.8, EtaMinutes: 19, SourceType: "magnet"},
			{ID: "dl-1025", Title: "Star Circuit Zero preload", Status: "queued", ProgressPercent: 0, DownloadSpeedMbps: 0, EtaMinutes: 34, SourceType: "http"},
			{ID: "dl-1026", Title: "Arcadia Wire (1080p)", Status: "stalled", ProgressPercent: 74, DownloadSpeedMbps: 0.2, EtaMinutes: 120, SourceType: "torrent"},
		},
		Recommendations: []RecommendationItem{
			{ID: 28, Reason: "Because your queue trends toward expansive fantasy worlds", Score: 93, Media: mustGet(mediaByID, 28)},
			{ID: 24, Reason: "Because you finish serial thrillers quickly", Score: 89, Media: mustGet(mediaByID, 24)},
			{ID: 33, Reason: "Because cinematic action games align with your recent picks", Score: 91, Media: mustGet(mediaByID, 33)},
			{ID: 1, Reason: "Because your recent watches trend sci-fi", Score: 88, Media: mustGet(mediaByID, 1)},
		},
		Trending:        mustPick(mediaByID, []int{9, 25, 33, 21, 16, 14}),
		Upcoming:        mustPick(mediaByID, []int{21, 25, 26, 14, 35}),
		RecentlyWatched: mustPick(mediaByID, []int{2, 6, 17, 33, 29}),
	}

	continueWatching := []ContinueWatchingItem{
		{Item: mustGet(mediaByID, 2), Progress: EpisodeProgress{ItemID: 2, SeasonNumber: 1, EpisodeNumber: 7, ProgressPercent: 63, LastWatchedAt: "2026-03-09T21:40:00Z"}},
		{Item: mustGet(mediaByID, 17), Progress: EpisodeProgress{ItemID: 17, SeasonNumber: 2, EpisodeNumber: 2, ProgressPercent: 28, LastWatchedAt: "2026-03-08T23:16:00Z"}},
		{Item: mustGet(mediaByID, 6), Progress: EpisodeProgress{ItemID: 6, SeasonNumber: 1, EpisodeNumber: 11, ProgressPercent: 82, LastWatchedAt: "2026-03-07T18:10:00Z"}},
	}

	return &Service{
		repo:             selected,
		media:            mediaByID,
		allMedia:         slices.Clone(allMedia),
		sections:         slices.Clone(sections),
		dashboard:        dashboard,
		continueWatching: slices.Clone(continueWatching),
	}
}

func (s *Service) SetGameLookup(lookup GameLookup) {
	s.gameLookup = lookup
}

func (s *Service) Dashboard() DashboardPayload {
	if s.repo != nil {
		ctx := context.Background()

		watchLater, err := s.repo.SectionItems(ctx, "top-rated", 5)
		if err == nil {
			gameBacklog, gameBacklogErr := s.repo.SectionItems(ctx, "most-anticipated-games", 4)
			trending, trendingErr := s.repo.SectionItems(ctx, "trending", 6)
			upcoming, upcomingErr := s.repo.SectionItems(ctx, "upcoming", 5)
			recentlyWatched, recentlyErr := s.repo.SectionItems(ctx, "now-playing", 5)

			if gameBacklogErr == nil && trendingErr == nil && upcomingErr == nil && recentlyErr == nil {
				payload := DashboardPayload{
					WatchLater:      cloneMediaItems(watchLater),
					GameBacklog:     cloneMediaItems(gameBacklog),
					ActiveDownloads: slices.Clone(s.dashboard.ActiveDownloads),
					Recommendations: slices.Clone(s.dashboard.Recommendations),
					Trending:        cloneMediaItems(trending),
					Upcoming:        cloneMediaItems(upcoming),
					RecentlyWatched: cloneMediaItems(recentlyWatched),
				}
				return payload
			}
		}
	}

	return DashboardPayload{
		WatchLater:      slices.Clone(s.dashboard.WatchLater),
		GameBacklog:     slices.Clone(s.dashboard.GameBacklog),
		ActiveDownloads: slices.Clone(s.dashboard.ActiveDownloads),
		Recommendations: slices.Clone(s.dashboard.Recommendations),
		Trending:        slices.Clone(s.dashboard.Trending),
		Upcoming:        slices.Clone(s.dashboard.Upcoming),
		RecentlyWatched: slices.Clone(s.dashboard.RecentlyWatched),
	}
}

func (s *Service) WatchLater(userID uuid.UUID) ([]MediaItem, error) {
	if userID == uuid.Nil {
		return nil, ErrInvalidInput
	}

	if s.repo != nil {
		items, err := s.repo.ListWatchLater(context.Background(), userID)
		if err == nil {
			return cloneMediaItems(items), nil
		}
	}

	return cloneMediaItems(s.dashboard.WatchLater), nil
}

func (s *Service) AddWatchLater(userID uuid.UUID, mediaID int) ([]MediaItem, error) {
	if userID == uuid.Nil || mediaID < 1 {
		return nil, ErrInvalidInput
	}

	if s.repo != nil {
		if err := s.repo.AddWatchLater(context.Background(), userID, mediaID); err != nil {
			if errors.Is(err, ErrMediaNotFound) {
				return nil, ErrMediaNotFound
			}

			return nil, err
		}

		items, err := s.repo.ListWatchLater(context.Background(), userID)
		if err != nil {
			return nil, err
		}
		return cloneMediaItems(items), nil
	}

	updated, err := addToWatchLater(s.dashboard.WatchLater, s.media, mediaID)
	if err != nil {
		return nil, err
	}
	s.dashboard.WatchLater = updated
	return cloneMediaItems(s.dashboard.WatchLater), nil
}

func (s *Service) RemoveWatchLater(userID uuid.UUID, mediaID int) ([]MediaItem, error) {
	if userID == uuid.Nil || mediaID < 1 {
		return nil, ErrInvalidInput
	}

	if s.repo != nil {
		if err := s.repo.RemoveWatchLater(context.Background(), userID, mediaID); err != nil {
			return nil, err
		}

		items, err := s.repo.ListWatchLater(context.Background(), userID)
		if err != nil {
			return nil, err
		}
		return cloneMediaItems(items), nil
	}

	s.dashboard.WatchLater = removeFromWatchLater(s.dashboard.WatchLater, mediaID)
	return cloneMediaItems(s.dashboard.WatchLater), nil
}

func (s *Service) ContinueWatching(userID uuid.UUID) ([]ContinueWatchingItem, error) {
	if userID == uuid.Nil {
		return nil, ErrInvalidInput
	}

	if s.repo != nil {
		items, err := s.repo.ListContinueWatching(context.Background(), userID, 12)
		if err != nil {
			return nil, err
		}
		return cloneContinueWatching(items), nil
	}

	return cloneContinueWatching(s.continueWatching), nil
}

func (s *Service) UpdateProgress(userID uuid.UUID, input ProgressUpdateInput) ([]ContinueWatchingItem, error) {
	if userID == uuid.Nil || input.MediaID < 1 {
		return nil, ErrInvalidInput
	}

	normalized := normalizeProgressInput(input)
	if normalized.ProgressPercent < 0 || normalized.ProgressPercent > 100 {
		return nil, ErrInvalidInput
	}

	if s.repo != nil {
		if err := s.repo.UpsertProgress(context.Background(), userID, normalized); err != nil {
			if errors.Is(err, ErrMediaNotFound) {
				return nil, ErrMediaNotFound
			}
			return nil, err
		}

		items, err := s.repo.ListContinueWatching(context.Background(), userID, 12)
		if err != nil {
			return nil, err
		}
		return cloneContinueWatching(items), nil
	}

	updated, err := upsertContinueWatchingInMemory(s.continueWatching, s.media, normalized)
	if err != nil {
		return nil, err
	}

	s.continueWatching = updated
	return cloneContinueWatching(s.continueWatching), nil
}

func (s *Service) Discover(params DiscoverParams) []DiscoverSection {
	sanitized := sanitizeDiscoverParams(params)

	if s.repo != nil {
		sections, err := s.repo.Discover(context.Background(), sanitized)
		if err == nil {
			return withGenreInSectionTitle(sections, sanitized.Genre)
		}
	}

	sections := make([]DiscoverSection, 0, len(s.sections))

	for _, section := range s.sections {
		candidates := mustPick(s.media, section.ItemIDs)
		filtered := filterMedia(candidates, sanitized.Query, sanitized.Genre, sanitized.MediaType)
		paged := page(filtered, sanitized.Page, sanitized.PageSize)
		if len(paged) == 0 {
			continue
		}

		title := section.Title
		if sanitized.Genre != "" {
			title = fmt.Sprintf("%s · %s", title, sanitized.Genre)
		}

		sections = append(sections, DiscoverSection{
			Kind:     section.Kind,
			Title:    title,
			Subtitle: section.Subtitle,
			Items:    paged,
		})
	}

	return sections
}

func (s *Service) Search(params SearchParams) []SearchResult {
	query := strings.TrimSpace(strings.ToLower(params.Query))
	if query == "" {
		return []SearchResult{}
	}

	if s.repo != nil {
		filtered, err := s.repo.SearchMedia(context.Background(), params)
		if err == nil {
			return buildSearchResults(filtered, query)
		}
	}

	filtered := filterMedia(s.allMedia, query, strings.TrimSpace(params.Genre), strings.TrimSpace(params.MediaType))
	if s.gameLookup != nil && shouldIncludeGameLookup(params.MediaType) {
		remote, err := s.gameLookup.SearchGames(context.Background(), params.Query, 12)
		if err == nil {
			filtered = mergeMediaItems(
				filtered,
				filterMedia(remote, query, strings.TrimSpace(params.Genre), string(MediaTypeGame)),
			)
		}
	}

	return buildSearchResults(filtered, query)
}

func buildSearchResults(items []MediaItem, query string) []SearchResult {
	results := make([]SearchResult, 0, len(items))
	for _, item := range items {
		subtitle := fmt.Sprintf("%s · %s", strings.Join(item.Genres, " • "), releaseYear(item.ReleaseDate))
		if item.Type == MediaTypeGame && len(item.Platforms) > 0 {
			subtitle = fmt.Sprintf("%s · %s · %s", strings.Join(item.Genres, " • "), strings.Join(item.Platforms, " • "), releaseYear(item.ReleaseDate))
		}

		results = append(results, SearchResult{
			ID:        item.ID,
			MediaType: string(item.Type),
			Title:     item.Title,
			Subtitle:  subtitle,
			Genres:    slices.Clone(item.Genres),
			Score:     score(item.Title, query, item.Rating),
		})
	}

	slices.SortFunc(results, func(left SearchResult, right SearchResult) int {
		return right.Score - left.Score
	})

	if len(results) > 12 {
		return slices.Clone(results[:12])
	}

	return results
}

func releaseYear(releaseDate string) string {
	if len(releaseDate) >= 4 {
		return releaseDate[:4]
	}

	return releaseDate
}

func shouldIncludeGameLookup(mediaType string) bool {
	cleanType := strings.ToLower(strings.TrimSpace(mediaType))
	return cleanType == "" || cleanType == "all" || cleanType == string(MediaTypeGame)
}

func mergeMediaItems(existing []MediaItem, incoming []MediaItem) []MediaItem {
	if len(incoming) == 0 {
		return existing
	}

	seen := make(map[string]struct{}, len(existing)+len(incoming))
	merged := make([]MediaItem, 0, len(existing)+len(incoming))

	for _, item := range existing {
		key := fmt.Sprintf("%s:%d", item.Provider, item.ProviderID)
		seen[key] = struct{}{}
		merged = append(merged, item)
	}

	for _, item := range incoming {
		key := fmt.Sprintf("%s:%d", item.Provider, item.ProviderID)
		if _, exists := seen[key]; exists {
			continue
		}

		seen[key] = struct{}{}
		merged = append(merged, item)
	}

	return merged
}

func withGenreInSectionTitle(sections []DiscoverSection, genre string) []DiscoverSection {
	cleanGenre := strings.TrimSpace(genre)
	if cleanGenre == "" {
		return sections
	}

	updated := make([]DiscoverSection, 0, len(sections))
	for _, section := range sections {
		updated = append(updated, DiscoverSection{
			Kind:     section.Kind,
			Title:    fmt.Sprintf("%s · %s", section.Title, cleanGenre),
			Subtitle: section.Subtitle,
			Items:    cloneMediaItems(section.Items),
		})
	}

	return updated
}

func cloneMediaItems(items []MediaItem) []MediaItem {
	if len(items) == 0 {
		return []MediaItem{}
	}

	cloned := make([]MediaItem, 0, len(items))
	for _, item := range items {
		cloned = append(cloned, MediaItem{
			ID:             item.ID,
			Provider:       item.Provider,
			ProviderID:     item.ProviderID,
			Title:          item.Title,
			Overview:       item.Overview,
			Type:           item.Type,
			ReleaseDate:    item.ReleaseDate,
			Genres:         cloneStringSlice(item.Genres),
			Platforms:      cloneStringSlice(item.Platforms),
			Rating:         item.Rating,
			RuntimeMinutes: item.RuntimeMinutes,
			ArtworkKey:     item.ArtworkKey,
		})
	}

	return cloned
}

func cloneContinueWatching(items []ContinueWatchingItem) []ContinueWatchingItem {
	if len(items) == 0 {
		return []ContinueWatchingItem{}
	}

	cloned := make([]ContinueWatchingItem, 0, len(items))
	for _, item := range items {
		cloned = append(cloned, ContinueWatchingItem{
			Item: MediaItem{
				ID:             item.Item.ID,
				Provider:       item.Item.Provider,
				ProviderID:     item.Item.ProviderID,
				Title:          item.Item.Title,
				Overview:       item.Item.Overview,
				Type:           item.Item.Type,
				ReleaseDate:    item.Item.ReleaseDate,
				Genres:         cloneStringSlice(item.Item.Genres),
				Platforms:      cloneStringSlice(item.Item.Platforms),
				Rating:         item.Item.Rating,
				RuntimeMinutes: item.Item.RuntimeMinutes,
				ArtworkKey:     item.Item.ArtworkKey,
			},
			Progress: EpisodeProgress{
				ItemID:          item.Progress.ItemID,
				SeasonNumber:    item.Progress.SeasonNumber,
				EpisodeNumber:   item.Progress.EpisodeNumber,
				ProgressPercent: item.Progress.ProgressPercent,
				LastWatchedAt:   item.Progress.LastWatchedAt,
			},
		})
	}

	return cloned
}

func normalizeProgressInput(input ProgressUpdateInput) ProgressUpdateInput {
	season := input.SeasonNumber
	if season < 1 {
		season = 1
	}

	episode := input.EpisodeNumber
	if episode < 1 {
		episode = 1
	}

	return ProgressUpdateInput{
		MediaID:         input.MediaID,
		SeasonNumber:    season,
		EpisodeNumber:   episode,
		ProgressPercent: input.ProgressPercent,
	}
}

func upsertContinueWatchingInMemory(
	existing []ContinueWatchingItem,
	catalogMap map[int]MediaItem,
	input ProgressUpdateInput,
) ([]ContinueWatchingItem, error) {
	next := make([]ContinueWatchingItem, 0, len(existing)+1)

	for _, entry := range existing {
		if entry.Item.ID == input.MediaID &&
			entry.Progress.SeasonNumber == input.SeasonNumber &&
			entry.Progress.EpisodeNumber == input.EpisodeNumber {
			continue
		}

		next = append(next, entry)
	}

	if input.ProgressPercent <= 0 || input.ProgressPercent >= 100 {
		return next, nil
	}

	media, ok := catalogMap[input.MediaID]
	if !ok {
		return nil, ErrMediaNotFound
	}

	newEntry := ContinueWatchingItem{
		Item: media,
		Progress: EpisodeProgress{
			ItemID:          input.MediaID,
			SeasonNumber:    input.SeasonNumber,
			EpisodeNumber:   input.EpisodeNumber,
			ProgressPercent: input.ProgressPercent,
			LastWatchedAt:   time.Now().UTC().Format(time.RFC3339),
		},
	}

	next = append([]ContinueWatchingItem{newEntry}, next...)
	if len(next) > 12 {
		return next[:12], nil
	}

	return next, nil
}

func addToWatchLater(existing []MediaItem, catalogMap map[int]MediaItem, mediaID int) ([]MediaItem, error) {
	for _, item := range existing {
		if item.ID == mediaID {
			return existing, nil
		}
	}

	media, ok := catalogMap[mediaID]
	if !ok {
		return nil, ErrMediaNotFound
	}

	next := make([]MediaItem, 0, len(existing)+1)
	next = append(next, media)
	next = append(next, cloneMediaItems(existing)...)
	return next, nil
}

func removeFromWatchLater(existing []MediaItem, mediaID int) []MediaItem {
	next := make([]MediaItem, 0, len(existing))
	for _, item := range existing {
		if item.ID == mediaID {
			continue
		}
		next = append(next, item)
	}

	return next
}

func sanitizeDiscoverParams(params DiscoverParams) DiscoverParams {
	page := params.Page
	if page < 1 {
		page = 1
	}

	pageSize := params.PageSize
	if pageSize < 1 {
		pageSize = 6
	}

	return DiscoverParams{
		Page:      page,
		PageSize:  pageSize,
		Query:     strings.TrimSpace(params.Query),
		Genre:     strings.TrimSpace(params.Genre),
		MediaType: strings.TrimSpace(params.MediaType),
	}
}

func filterMedia(items []MediaItem, query string, genre string, mediaType string) []MediaItem {
	queryLower := strings.ToLower(strings.TrimSpace(query))
	genreLower := strings.ToLower(strings.TrimSpace(genre))
	mediaTypeLower := strings.ToLower(strings.TrimSpace(mediaType))

	result := make([]MediaItem, 0, len(items))
	for _, item := range items {
		if mediaTypeLower != "" && mediaTypeLower != "all" && mediaTypeLower != string(item.Type) {
			continue
		}

		if genreLower != "" && !hasGenre(item, genreLower) {
			continue
		}

		if queryLower != "" && !matchesQuery(item, queryLower) {
			continue
		}

		result = append(result, item)
	}

	return result
}

func hasGenre(item MediaItem, genre string) bool {
	for _, existing := range item.Genres {
		if strings.ToLower(existing) == genre {
			return true
		}
	}
	return false
}

func matchesQuery(item MediaItem, query string) bool {
	if strings.Contains(strings.ToLower(item.Title), query) {
		return true
	}

	if strings.Contains(strings.ToLower(item.Overview), query) {
		return true
	}

	for _, genre := range item.Genres {
		if strings.Contains(strings.ToLower(genre), query) {
			return true
		}
	}

	return false
}

func page(items []MediaItem, pageNumber int, pageSize int) []MediaItem {
	start := (pageNumber - 1) * pageSize
	if start >= len(items) {
		return []MediaItem{}
	}

	end := start + pageSize
	if end > len(items) {
		end = len(items)
	}

	return slices.Clone(items[start:end])
}

func cloneStringSlice(values []string) []string {
	if len(values) == 0 {
		return []string{}
	}

	return slices.Clone(values)
}

func score(title string, query string, rating float64) int {
	cleanTitle := strings.ToLower(strings.TrimSpace(title))
	if cleanTitle == query {
		return min(100, int(70+rating*3))
	}

	if strings.HasPrefix(cleanTitle, query) {
		return min(98, int(60+rating*3))
	}

	return min(95, int(45+rating*4))
}

func min(a int, b int) int {
	if a < b {
		return a
	}
	return b
}

func mustPick(media map[int]MediaItem, ids []int) []MediaItem {
	items := make([]MediaItem, 0, len(ids))
	for _, id := range ids {
		items = append(items, mustGet(media, id))
	}
	return items
}

func mustGet(media map[int]MediaItem, id int) MediaItem {
	item, ok := media[id]
	if !ok {
		panic(fmt.Sprintf("missing media item %d", id))
	}
	return item
}

func newMedia(
	id int,
	provider MediaProvider,
	providerID int,
	title string,
	mediaType MediaType,
	genres []string,
	platforms []string,
	releaseDate string,
	rating float64,
	runtimeMinutes int,
) MediaItem {
	overview := fmt.Sprintf("%s is a premium catalog title in your Seen library.", title)
	if mediaType == MediaTypeGame {
		overview = fmt.Sprintf("%s is a high-signal game release tracked through your IGDB-powered backlog.", title)
	}

	return MediaItem{
		ID:             id,
		Provider:       provider,
		ProviderID:     providerID,
		Title:          title,
		Overview:       overview,
		Type:           mediaType,
		ReleaseDate:    releaseDate,
		Genres:         cloneStringSlice(genres),
		Platforms:      cloneStringSlice(platforms),
		Rating:         rating,
		RuntimeMinutes: runtimeMinutes,
		ArtworkKey:     fmt.Sprintf("%s-%d", title, id),
	}
}
