package api

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

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

// Bump when the authored row contract changes so an older ordering cannot survive a
// deployment until the previous daily cache expires.
const sonarrCalendarCachePrefix = "sonarr:calendar:v4:"
const sonarrPrerollCachePrefix = "sonarr:preroll:v2:"
const sonarrSeriesCacheKey = "sonarr:series:v1"
const sonarrScheduleDays = 5

// sonarrSeriesCatalogue is Sonarr's whole series list, cached the way the calendar is.
//
// Every caller here wants the same thing — the lifecycle, monitored flag and next airing
// for one or two shows — and each was paying `/api/v3/series` in full to get it. On a
// household with a few hundred followed shows that is a large response Sonarr assembles
// from its own database, and it sat in front of things a viewer is waiting on: adding a
// show to My Shows, which fetches it *after* the write, and the season-finale lookup on a
// detail page. This is where the second or two came from.
//
// It is shared rather than per user — Sonarr's catalogue belongs to the household, not to
// whoever asked — and it takes MEMBY_SONARR_TTL, the same five minutes the calendar rows
// take. Short enough that following a show in Sonarr shows up on the next visit, long
// enough that a viewer working through My Shows pays for it once.
//
// Every failure degrades to asking Sonarr directly: a cache that is down must cost latency,
// never the answer.
func (s *Server) sonarrSeriesCatalogue(ctx context.Context) ([]sonarr.Series, error) {
	if s.sonarr == nil {
		return nil, fmt.Errorf("sonarr: not configured")
	}
	if series := s.cachedSonarrSeries(ctx); series != nil {
		return series, nil
	}

	// The same kind of shared lock the calendar takes, for the same reason: several
	// televisions opening together must not each stampede Sonarr on the one miss. Its own
	// mutex rather than sonarrMu, so an add to My Shows never waits behind a launcher
	// rebuilding the schedule row.
	s.sonarrSeriesMu.Lock()
	defer s.sonarrSeriesMu.Unlock()
	if series := s.cachedSonarrSeries(ctx); series != nil {
		return series, nil
	}

	series, err := s.sonarr.Series(ctx)
	if err != nil {
		return nil, err
	}
	if body, marshalErr := json.Marshal(series); marshalErr == nil {
		if cacheErr := s.cache.Set(ctx, sonarrSeriesCacheKey, body, s.cfg.SonarrTTL); cacheErr != nil {
			s.loggerFor(ctx).Warn("sonarr series cache write failed", "error", cacheErr)
		}
	}
	return series, nil
}

func (s *Server) cachedSonarrSeries(ctx context.Context) []sonarr.Series {
	raw, err := s.cache.Get(ctx, sonarrSeriesCacheKey)
	if err != nil {
		return nil
	}
	var series []sonarr.Series
	if json.Unmarshal(raw, &series) != nil {
		return nil
	}
	return series
}

type prerollScheduleResponse struct {
	Today    []prerollScheduleEntry `json:"today"`
	ThisWeek []prerollScheduleEntry `json:"thisWeek"`
}

type prerollScheduleEntry struct {
	ItemID       string `json:"itemId"`
	ImageType    string `json:"imageType,omitempty"`
	Series       string `json:"series"`
	Episode      string `json:"episode"`
	EpisodeCode  string `json:"episodeCode"`
	Schedule     string `json:"schedule"`
	Availability string `json:"availability,omitempty"`
}

func (s *Server) handlePreroll(w http.ResponseWriter, r *http.Request, _ store.Session) {
	if s.sonarr == nil {
		writeJSON(w, http.StatusOK, emptyPrerollSchedule())
		return
	}
	schedule, err := s.sonarrPrerollSchedule(r.Context())
	if err != nil {
		// Pre-roll is decorative and must never become a playback dependency.
		s.loggerFor(r.Context()).Warn("preroll schedule unavailable", "error", err)
		writeJSON(w, http.StatusOK, emptyPrerollSchedule())
		return
	}
	writeJSON(w, http.StatusOK, schedule)
}

func (s *Server) sonarrPrerollSchedule(ctx context.Context) (prerollScheduleResponse, error) {
	location := s.cfg.SonarrLocation
	if location == nil {
		location = time.Local
	}
	now := time.Now().In(location)
	dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location)
	key := sonarrPrerollCachePrefix + dayStart.Format("2006-01-02")
	if raw, err := s.cache.Get(ctx, key); err == nil {
		var cached prerollScheduleResponse
		if json.Unmarshal(raw, &cached) == nil {
			return normalizePrerollSchedule(cached), nil
		}
	}

	s.sonarrMu.Lock()
	defer s.sonarrMu.Unlock()
	if raw, err := s.cache.Get(ctx, key); err == nil {
		var cached prerollScheduleResponse
		if json.Unmarshal(raw, &cached) == nil {
			return normalizePrerollSchedule(cached), nil
		}
	}

	episodes, err := s.sonarr.Calendar(ctx, dayStart, dayStart.AddDate(0, 0, 7))
	if err != nil {
		return emptyPrerollSchedule(), err
	}
	schedule := buildPrerollSchedule(episodes, now, location)
	if raw, err := json.Marshal(schedule); err == nil {
		if cacheErr := s.cache.Set(ctx, key, raw, s.cfg.SonarrTTL); cacheErr != nil {
			s.loggerFor(ctx).Warn("preroll schedule cache write failed", "error", cacheErr)
		}
	}
	return schedule, nil
}

func buildPrerollSchedule(
	episodes []sonarr.Episode,
	now time.Time,
	location *time.Location,
) prerollScheduleResponse {
	sort.SliceStable(episodes, func(i, j int) bool {
		if episodes[i].AirDateUTC == nil {
			return false
		}
		if episodes[j].AirDateUTC == nil {
			return true
		}
		return episodes[i].AirDateUTC.Before(*episodes[j].AirDateUTC)
	})
	todayEnd := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location).AddDate(0, 0, 1)
	result := emptyPrerollSchedule()
	for _, episode := range episodes {
		if episode.AirDateUTC == nil {
			continue
		}
		airTime := episode.AirDateUTC.In(location)
		seriesID := episode.Series.ID
		if seriesID == 0 {
			seriesID = episode.SeriesID
		}
		entry := prerollScheduleEntry{
			ItemID:       fmt.Sprintf("sonarr:%d:%d", seriesID, episode.ID),
			Series:       episode.Series.Title,
			Episode:      episode.Title,
			EpisodeCode:  fmt.Sprintf("S%02dE%02d", episode.SeasonNumber, episode.EpisodeNumber),
			Availability: prerollAvailability(episode),
		}
		if hasCover(episode.Series.Images, "fanart") {
			entry.ImageType = "backdrop"
		} else if hasCover(episode.Series.Images, "poster") {
			entry.ImageType = "primary"
		}
		if airTime.Before(todayEnd) {
			entry.Schedule = airTime.Format("3:04 PM")
			if len(result.Today) < 4 {
				result.Today = append(result.Today, entry)
			}
		} else {
			entry.Schedule = airTime.Format("Monday · 3:04 PM")
			if len(result.ThisWeek) < 6 {
				result.ThisWeek = append(result.ThisWeek, entry)
			}
		}
	}
	return result
}

func prerollAvailability(episode sonarr.Episode) string {
	switch {
	case episode.HasFile:
		return "Downloaded"
	case episode.Grabbed:
		return "Downloading"
	default:
		return ""
	}
}

func emptyPrerollSchedule() prerollScheduleResponse {
	return prerollScheduleResponse{
		Today:    []prerollScheduleEntry{},
		ThisWeek: []prerollScheduleEntry{},
	}
}

func normalizePrerollSchedule(value prerollScheduleResponse) prerollScheduleResponse {
	if value.Today == nil {
		value.Today = []prerollScheduleEntry{}
	}
	if value.ThisWeek == nil {
		value.ThisWeek = []prerollScheduleEntry{}
	}
	return value
}

// sonarrScheduleItem deliberately looks enough like an Emby item to reuse the fast home
// card renderer, while its Memby fields mark it as informational and non-playable.
type sonarrScheduleItem struct {
	ID                    string            `json:"Id"`
	Name                  string            `json:"Name"`
	Type                  string            `json:"Type"`
	Overview              string            `json:"Overview,omitempty"`
	ProductionYear        int               `json:"ProductionYear,omitempty"`
	RunTimeTicks          int64             `json:"RunTimeTicks,omitempty"`
	Genres                []string          `json:"Genres"`
	ImageTags             map[string]string `json:"ImageTags"`
	BackdropImageTags     []string          `json:"BackdropImageTags"`
	MembySource           string            `json:"MembySource"`
	MembyEpisodeTitle     string            `json:"MembyEpisodeTitle"`
	MembyEpisodeCode      string            `json:"MembyEpisodeCode"`
	MembyAirsAt           string            `json:"MembyAirsAt,omitempty"`
	MembyAddedAt          string            `json:"MembyAddedAt,omitempty"`
	MembyAirDayLabel      string            `json:"MembyAirDayLabel"`
	MembyAirLabel         string            `json:"MembyAirLabel"`
	MembyAvailability     string            `json:"MembyAvailability"`
	MembyAvailabilityText string            `json:"MembyAvailabilityText"`
	// Sonarr's lifecycle for the *show*, distinct from this episode's availability: one
	// says whether more episodes are coming at all, the other whether this one is here yet.
	MembyLifecycle     string `json:"MembyLifecycle,omitempty"`
	MembyLifecycleText string `json:"MembyLifecycleText,omitempty"`
	MembyPlayable      bool   `json:"MembyPlayable"`
	// The Emby series this episode belongs to, when the library holds it. It is what lets
	// the card open the show's own page; a show Sonarr follows but Emby has never imported
	// simply carries none, and the card stays informational as it always was.
	MembySeriesItemID string `json:"MembySeriesItemId,omitempty"`
}

// seriesIndex resolves a Sonarr series title and year onto an Emby item id. It is a map
// rather than a store call per episode: one row can carry a dozen episodes of the same
// show, and the answer is the same for all of them.
type seriesIndex map[string]string

// embySeriesIndex builds the lookup for one row build. A failure is not fatal — the row
// is about what is *about* to air, and losing the link only costs the card its page.
func (s *Server) embySeriesIndex(ctx context.Context) seriesIndex {
	if s.store == nil {
		return nil
	}
	refs, err := s.store.SeriesRefs(ctx)
	if err != nil {
		s.loggerFor(ctx).Warn("emby series index unavailable for schedule row", "error", err)
		return nil
	}
	index := make(seriesIndex, len(refs)*2)
	for _, ref := range refs {
		key := normalizedShowTitle(ref.Name)
		if key == "" {
			continue
		}
		// The year-qualified key is written first and never overwritten, so a remake
		// cannot claim the original's page when both are in the library.
		if ref.Year > 0 {
			if _, seen := index[seriesIndexKey(key, ref.Year)]; !seen {
				index[seriesIndexKey(key, ref.Year)] = ref.ID
			}
		}
		if _, seen := index[key]; !seen {
			index[key] = ref.ID
		}
	}
	return index
}

func seriesIndexKey(normalizedTitle string, year int) string {
	return fmt.Sprintf("%s|%d", normalizedTitle, year)
}

// lookup prefers the title/year pair and falls back to the title alone, because Sonarr
// and Emby disagree about a show's year more often than they disagree about its name.
func (index seriesIndex) lookup(title string, year int) string {
	key := normalizedShowTitle(title)
	if key == "" || len(index) == 0 {
		return ""
	}
	if year > 0 {
		if id, ok := index[seriesIndexKey(key, year)]; ok {
			return id
		}
	}
	return index[key]
}

func (s *Server) sonarrAiringTodayRow(ctx context.Context) (*recommend.Row, error) {
	if s.sonarr == nil {
		return nil, nil
	}
	location := s.cfg.SonarrLocation
	if location == nil {
		location = time.Local
	}
	now := time.Now().In(location)
	dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location)
	cacheKey := sonarrCalendarCachePrefix + dayStart.Format("2006-01-02")

	if row := s.cachedSonarrRow(ctx, cacheKey); row != nil {
		return row, nil
	}

	// A shared lock prevents several users opening the app together from stampeding
	// Sonarr on the one cache miss each day.
	s.sonarrMu.Lock()
	defer s.sonarrMu.Unlock()
	if row := s.cachedSonarrRow(ctx, cacheKey); row != nil {
		return row, nil
	}

	episodes, err := s.sonarr.Calendar(
		ctx,
		dayStart,
		dayStart.AddDate(0, 0, sonarrScheduleDays),
	)
	if err != nil {
		return nil, err
	}
	row, err := buildSonarrRow(episodes, now, location, s.embySeriesIndex(ctx))
	if err != nil {
		return nil, err
	}
	body, err := json.Marshal(row)
	if err == nil {
		if cacheErr := s.cache.Set(ctx, cacheKey, body, s.cfg.SonarrTTL); cacheErr != nil {
			s.loggerFor(ctx).Warn("sonarr calendar cache write failed", "error", cacheErr)
		}
	}
	return row, nil
}

func (s *Server) cachedSonarrRow(ctx context.Context, key string) *recommend.Row {
	raw, err := s.cache.Get(ctx, key)
	if err != nil {
		return nil
	}
	var row recommend.Row
	if json.Unmarshal(raw, &row) != nil {
		return nil
	}
	return &row
}

func buildSonarrRow(
	episodes []sonarr.Episode,
	now time.Time,
	location *time.Location,
	series seriesIndex,
) (*recommend.Row, error) {
	sort.SliceStable(episodes, func(i, j int) bool {
		if episodes[i].AirDateUTC == nil {
			return false
		}
		if episodes[j].AirDateUTC == nil {
			return true
		}
		return episodes[i].AirDateUTC.Before(*episodes[j].AirDateUTC)
	})

	items := make([]json.RawMessage, 0, len(episodes))
	dayStart := localDayStart(now, location)
	windowEnd := dayStart.AddDate(0, 0, sonarrScheduleDays)
	for _, episode := range episodes {
		if episode.AirDateUTC == nil {
			continue
		}
		localAirTime := episode.AirDateUTC.In(location)
		if localAirTime.Before(dayStart) || !localAirTime.Before(windowEnd) {
			continue
		}
		item := toSonarrScheduleItem(episode, now, location, series)
		raw, err := json.Marshal(item)
		if err != nil {
			return nil, err
		}
		items = append(items, raw)
	}
	return &recommend.Row{
		ID:    "sonarr-airing-today",
		Title: "Shows airing in the next 5 days",
		Kind:  "schedule",
		Items: items,
	}, nil
}

func toSonarrScheduleItem(
	episode sonarr.Episode,
	now time.Time,
	location *time.Location,
	series seriesIndex,
) sonarrScheduleItem {
	seriesID := episode.SeriesID
	if episode.Series.ID > 0 {
		seriesID = episode.Series.ID
	}
	item := sonarrScheduleItem{
		ID:                fmt.Sprintf("sonarr:%d:%d", seriesID, episode.ID),
		Name:              episode.Series.Title,
		Type:              "MembySonarrEpisode",
		Overview:          episode.Overview,
		ProductionYear:    episode.Series.Year,
		RunTimeTicks:      int64(episode.Runtime) * 600_000_000,
		Genres:            nonNilStrings(episode.Series.Genres),
		ImageTags:         map[string]string{},
		BackdropImageTags: []string{},
		MembySource:       "sonarr",
		MembyEpisodeTitle: episode.Title,
		MembyEpisodeCode:  fmt.Sprintf("S%02dE%02d", episode.SeasonNumber, episode.EpisodeNumber),
		MembyPlayable:     false,
	}
	item.MembySeriesItemID = series.lookup(episode.Series.Title, episode.Series.Year)
	lifecycle := seriesLifecycleTag(episode.Series.Status)
	item.MembyLifecycle, item.MembyLifecycleText = lifecycle.Status, lifecycle.Label
	if hasCover(episode.Series.Images, "poster") {
		item.ImageTags["Primary"] = "sonarr"
	}
	if hasCover(episode.Series.Images, "fanart") {
		item.BackdropImageTags = []string{"sonarr"}
	}

	if episode.AirDateUTC != nil {
		airTime := episode.AirDateUTC.In(location)
		item.MembyAirsAt = airTime.Format(time.RFC3339)
		item.MembyAirDayLabel = scheduleAirDayLabel(airTime, now, location)
		item.MembyAirLabel = scheduleAirLabel(airTime, now, location)
	} else {
		item.MembyAirDayLabel = "Upcoming"
		item.MembyAirLabel = "Coming up"
	}

	addedAt := episodeFileAddedAt(episode)
	switch {
	case episode.HasFile:
		item.MembyAvailability = "available"
		item.MembyAvailabilityText = "Downloaded"
		if addedAt != nil {
			localAdded := addedAt.In(location)
			item.MembyAddedAt = localAdded.Format(time.RFC3339)
			item.MembyAvailabilityText = "Added at " + localAdded.Format("3:04 PM")
		}
	case episode.Grabbed:
		item.MembyAvailability = "downloading"
		item.MembyAvailabilityText = "Downloading"
	case !episode.Monitored:
		item.MembyAvailability = "unmonitored"
		item.MembyAvailabilityText = "Not monitored"
	case episode.AirDateUTC != nil && episode.AirDateUTC.Before(now):
		item.MembyAvailability = "awaiting"
		item.MembyAvailabilityText = "Awaiting download"
	default:
		item.MembyAvailability = "upcoming"
		item.MembyAvailabilityText = "Upcoming"
	}
	return item
}

func scheduleAirDayLabel(airTime, now time.Time, location *time.Location) string {
	airTime = airTime.In(location)
	now = now.In(location)
	today := localDayStart(now, location)
	airDay := localDayStart(airTime, location)
	switch {
	case airDay.Equal(today):
		return "Today"
	case airDay.Equal(today.AddDate(0, 0, 1)):
		return "Tomorrow"
	default:
		return airTime.Format("Monday")
	}
}

func scheduleAirLabel(airTime, now time.Time, location *time.Location) string {
	airTime = airTime.In(location)
	now = now.In(location)
	clock := airTime.Format("3:04 PM")
	today := localDayStart(now, location)
	airDay := localDayStart(airTime, location)
	switch {
	case airDay.Equal(today) && !airTime.After(now):
		return "Aired today at " + clock
	case airDay.Equal(today):
		remaining := airTime.Sub(now)
		if remaining < time.Hour {
			minutes := int((remaining + time.Minute - 1) / time.Minute)
			return fmt.Sprintf("In %d minutes (%s)", minutes, clock)
		}
		hours := int((remaining + time.Hour - 1) / time.Hour)
		return fmt.Sprintf("In %d hours (%s)", hours, clock)
	case airDay.Equal(today.AddDate(0, 0, 1)):
		return "Tomorrow: " + clock
	default:
		return airTime.Format("Monday") + ": " + clock
	}
}

func localDayStart(value time.Time, location *time.Location) time.Time {
	value = value.In(location)
	return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, location)
}

func episodeFileAddedAt(episode sonarr.Episode) *time.Time {
	if episode.EpisodeFile == nil {
		return nil
	}
	return episode.EpisodeFile.DateAdded
}

func hasCover(images []sonarr.Image, coverType string) bool {
	for _, image := range images {
		if strings.EqualFold(image.CoverType, coverType) {
			return true
		}
	}
	return false
}

func nonNilStrings(values []string) []string {
	if values == nil {
		return []string{}
	}
	return values
}
