package api

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"strconv"
	"strings"

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

const ticksPerMillisecond = 10_000

type playbackResponse struct {
	ItemID            string             `json:"itemId"`
	Title             string             `json:"title"`
	Overview          string             `json:"overview,omitempty"`
	SeriesName        string             `json:"seriesName,omitempty"`
	EpisodeCode       string             `json:"episodeCode,omitempty"`
	RuntimeMs         int64              `json:"runtimeMs,omitempty"`
	PrerollEnabled    bool               `json:"prerollEnabled"`
	PrerollDurationMs int64              `json:"prerollDurationMs"`
	URL               string             `json:"url"`
	ResumePositionMs  int64              `json:"resumePositionMs"`
	Subtitles         []playableSubtitle `json:"subtitles"`
	// Which of those tracks to turn on, decided from the viewer's synced settings rather
	// than by the television. Empty with SubtitlesEnabled true means "nothing suitable".
	SubtitlesEnabled   bool   `json:"subtitlesEnabled"`
	SelectedSubtitleID string `json:"selectedSubtitleId,omitempty"`
	MediaSourceID      string `json:"mediaSourceId"`
	PlaySessionID      string `json:"playSessionId"`
	PlayMethod         string `json:"playMethod"`
	// Whether this gateway can fetch a subtitle the title does not have. It rides here
	// rather than on /v1/status because the drop-up is the only thing that asks, it
	// already holds this response, and one boolean on a request that is made once per
	// playback is cheaper than a field on the poll every open TV makes every ten seconds.
	SubtitleDownloadAvailable bool `json:"subtitleDownloadAvailable"`
	// Whether at least one subtitle on this title can be checked against another readable
	// text track. Like the download flag, this rides on the playback response because only
	// the subtitle drop-up needs it, and defaults to false for older gateways on the client.
	SubtitleFixAvailable bool `json:"subtitleFixAvailable"`
	// Whether it is worth asking this gateway for seek previews. Only the answer rides
	// here; the manifest itself does not, because reading it costs a round trip to Emby
	// and this response is the one thing standing between a Play press and a decoder
	// starting. The television asks for the manifest once the first frame is up.
	TrickplayAvailable bool `json:"trickplayAvailable"`
	// Whether it is worth asking this gateway where the title sequence is. Only the answer
	// rides here, for the same reason the previews' does: reading the markers costs a round
	// trip to Emby, and nothing about a skip button is needed before the first frame. The
	// television asks for the segment itself once playback has settled.
	SkipIntroAvailable bool `json:"skipIntroAvailable"`
	// Whether it is worth asking where the closing credits begin. Same reasoning again, and
	// deliberately a second boolean rather than a reuse of SkipIntroAvailable: the two are
	// separate features with separate switches, and a house that has turned the skip button
	// off has not asked to lose the credits pane with it.
	EndCreditsAvailable bool `json:"endCreditsAvailable"`
}

type playableSubtitle struct {
	ID                string `json:"id"`
	URL               string `json:"url"`
	MimeType          string `json:"mimeType"`
	Language          string `json:"language,omitempty"`
	Label             string `json:"label,omitempty"`
	IsDefault         bool   `json:"isDefault"`
	IsForced          bool   `json:"isForced"`
	IsHearingImpaired bool   `json:"isHearingImpaired"`
	DeliveryMethod    string `json:"deliveryMethod"`
	Codec             string `json:"codec,omitempty"`
}

type playbackReport struct {
	ItemID        string `json:"itemId"`
	PositionMs    int64  `json:"positionMs"`
	DurationMs    int64  `json:"durationMs,omitempty"`
	IsPaused      bool   `json:"isPaused"`
	MediaSourceID string `json:"mediaSourceId"`
	PlaySessionID string `json:"playSessionId"`
	PlayMethod    string `json:"playMethod"`
	EventName     string `json:"eventName,omitempty"`
}

type playbackReportResponse struct {
	AutoFollowedShowTitle string `json:"autoFollowedShowTitle,omitempty"`
}

// nextEpisodeResponse carries the episode that follows the one being watched. Item is
// Emby's own item JSON, forwarded verbatim like every other item the gateway returns, so
// the client decodes it into the same BaseItem it uses everywhere else.
type nextEpisodeResponse struct {
	Item               json.RawMessage    `json:"item"`
	Title              string             `json:"title"`
	URL                string             `json:"url"`
	ResumePositionMs   int64              `json:"resumePositionMs"`
	Subtitles          []playableSubtitle `json:"subtitles"`
	SubtitlesEnabled   bool               `json:"subtitlesEnabled"`
	SelectedSubtitleID string             `json:"selectedSubtitleId,omitempty"`
	MediaSourceID      string             `json:"mediaSourceId"`
	PlaySessionID      string             `json:"playSessionId"`
	PlayMethod         string             `json:"playMethod"`
}

// handlePlayback resolves what to actually play.
//
// This is logic the TV used to carry: a series resolves to its next-up episode (falling
// back to the first), and the returned URL points straight at Emby so the video stream
// never traverses the gateway.
func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess store.Session) {
	ctx := r.Context()
	itemID := r.PathValue("id")
	if itemID == "" {
		writeError(w, http.StatusBadRequest, "item id is required")
		return
	}
	cred := credentials(sess)

	item, hinted := playbackHint(r, itemID)
	if !hinted {
		raw, err := s.emby.Item(ctx, cred, itemID, "RunTimeTicks,SeriesName")
		if err != nil {
			s.writeUpstreamError(ctx, w, err, "could not load the item")
			return
		}
		item, err = emby.Summarise(raw)
		if err != nil {
			writeError(w, http.StatusBadGateway, "unreadable item from emby")
			return
		}
	}

	target := item
	title := item.Name

	if strings.EqualFold(item.Type, "Series") {
		episode, err := s.firstPlayableEpisode(ctx, cred, item.ID)
		if err != nil {
			s.writeUpstreamError(ctx, w, err, "could not find an episode to play")
			return
		}
		if episode == nil {
			writeError(w, http.StatusNotFound, "no episodes found for this series")
			return
		}
		target = *episode
		if episode.Name != "" {
			title = item.Name + " – " + episode.Name
		}
	}

	var subtitleIndex *int
	if raw := strings.TrimSpace(r.URL.Query().Get("subtitleIndex")); raw != "" {
		if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 0 {
			subtitleIndex = &parsed
		}
	}
	subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles(
		ctx, cred, target.ID, target.UserData.PlaybackPositionTicks, subtitleIndex, "",
		queryBool(r, "forceTranscode"), s.effectivePlaybackCapabilities(ctx, sess),
	)
	streamURL := s.emby.StreamURL(cred, target.ID)
	if negotiatedURL != "" {
		streamURL = negotiatedURL
	}
	subtitlesEnabled, subtitleLanguage := s.subtitlePreferenceFor(ctx, sess)
	selectedSubtitleID := selectSubtitle(subtitles, subtitlesEnabled, subtitleLanguage)
	// An explicit index is the viewer choosing a burned-in track in the player. It is a
	// decision already made, so it outranks the stored preference for this stream.
	if subtitleIndex != nil {
		subtitlesEnabled, selectedSubtitleID = true, strconv.Itoa(*subtitleIndex)
	}
	playbackPolicy := store.DefaultPlaybackPolicy()
	if s.store != nil {
		if policy, policyErr := s.store.PlaybackPolicy(ctx); policyErr == nil {
			playbackPolicy = policy
		} else {
			s.loggerFor(ctx).Warn("playback policy unavailable", "error", policyErr)
		}
	}
	// The one event that says what somebody actually tried to watch. It is logged even
	// though the request line already records the route, because the route carries an
	// item id and nobody can read an item id.
	s.playbackTitles.remember(target.ID, title)
	s.loggerFor(ctx).Info("playback requested",
		"title", title,
		"item", target.ID,
		"type", target.Type,
		"play_method", clientLogValue(playMethod),
		"resume", millisecondDuration(target.UserData.PlaybackPositionTicks/ticksPerMillisecond),
		"runtime", millisecondDuration(target.RunTimeTicks/ticksPerMillisecond),
		"subtitles", len(subtitles),
		"subtitle_track", clientLogValue(selectedSubtitleID),
		"subtitle_language", clientLogValue(subtitleLanguage),
	)

	writeJSON(w, http.StatusOK, playbackResponse{
		ItemID:                    target.ID,
		Title:                     title,
		Overview:                  target.Overview,
		SeriesName:                target.SeriesName,
		EpisodeCode:               episodeCode(target),
		RuntimeMs:                 max64(target.RunTimeTicks/ticksPerMillisecond, 0),
		PrerollEnabled:            playbackPolicy.PrerollEnabled && s.featureEnabled(ctx, featureSonarrPreroll),
		PrerollDurationMs:         playbackPolicy.PrerollDurationMs,
		URL:                       streamURL,
		ResumePositionMs:          max64(target.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
		Subtitles:                 subtitles,
		SubtitlesEnabled:          subtitlesEnabled,
		SelectedSubtitleID:        selectedSubtitleID,
		MediaSourceID:             mediaSourceID,
		PlaySessionID:             playSessionID,
		PlayMethod:                playMethod,
		SubtitleDownloadAvailable: s.subtitleDownloadAvailable(ctx),
		SubtitleFixAvailable:      s.subtitleFixAvailable(subtitles),
		TrickplayAvailable:        s.trickplayEnabled(ctx),
		SkipIntroAvailable:        s.skipIntroEnabled(ctx),
		EndCreditsAvailable:       s.endCreditsEnabled(ctx),
	})
}

// Current clients already know the selected item's type, title and cached resume point.
// Accepting those as hints removes one serial Emby request from every launch. Older
// clients omit them and retain the authoritative lookup above.
func playbackHint(r *http.Request, itemID string) (emby.Summary, bool) {
	itemType := strings.TrimSpace(r.URL.Query().Get("type"))
	switch {
	case strings.EqualFold(itemType, "Movie"):
		itemType = "Movie"
	case strings.EqualFold(itemType, "Episode"):
		itemType = "Episode"
	case strings.EqualFold(itemType, "Series"):
		itemType = "Series"
	default:
		return emby.Summary{}, false
	}
	resumeMs, _ := strconv.ParseInt(r.URL.Query().Get("resumePositionMs"), 10, 64)
	item := emby.Summary{
		ID:   itemID,
		Name: strings.TrimSpace(r.URL.Query().Get("title")),
		Type: itemType,
	}
	item.UserData.PlaybackPositionTicks = max64(resumeMs, 0) * ticksPerMillisecond
	return item, true
}

// firstPlayableEpisode prefers the server's next-up choice and falls back to episode one.
func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials, seriesID string) (*emby.Summary, error) {
	nextUp, err := s.emby.NextUp(ctx, cred, url.Values{
		"SeriesId":       {seriesID},
		"Limit":          {"1"},
		"Fields":         {"Overview,RunTimeTicks,SeriesName,ParentIndexNumber,IndexNumber"},
		"EnableUserData": {"true"},
	})
	if err == nil && len(nextUp.Items) > 0 {
		if summary, err := emby.Summarise(nextUp.Items[0]); err == nil {
			return &summary, nil
		}
	}

	episodes, err := s.emby.Episodes(ctx, cred, seriesID, url.Values{
		"Limit":          {"1"},
		"Fields":         {"Overview,RunTimeTicks,SeriesName,ParentIndexNumber,IndexNumber"},
		"EnableUserData": {"true"},
	})
	if err != nil {
		return nil, err
	}
	if len(episodes.Items) == 0 {
		return nil, nil
	}
	summary, err := emby.Summarise(episodes.Items[0])
	if err != nil {
		return nil, err
	}
	return &summary, nil
}

func episodeCode(item emby.Summary) string {
	if !strings.EqualFold(item.Type, "Episode") || item.ParentIndexNumber < 0 || item.IndexNumber <= 0 {
		return ""
	}
	return fmt.Sprintf("S%02dE%02d", item.ParentIndexNumber, item.IndexNumber)
}

// handleNextEpisode resolves the episode that follows the one being watched, so the player
// can offer a "next up" countdown without the TV needing to know how Emby orders a series.
//
// "Nothing follows this" is a normal answer, not a failure: a movie, a series finale and an
// unreadable series all come back as 404 and the client simply shows no banner.
func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess store.Session) {
	ctx := r.Context()
	itemID := r.PathValue("id")
	if itemID == "" {
		writeError(w, http.StatusBadRequest, "item id is required")
		return
	}
	cred := credentials(sess)

	// The client already knows which series it launched, so accepting it as a hint keeps
	// this off Emby for one round trip. Older clients omit it and we look it up.
	seriesID := strings.TrimSpace(r.URL.Query().Get("seriesId"))
	if seriesID == "" {
		raw, err := s.emby.Item(ctx, cred, itemID, "SeriesId")
		if err != nil {
			s.writeUpstreamError(ctx, w, err, "could not load the item")
			return
		}
		var parsed struct {
			SeriesID string `json:"SeriesId"`
		}
		if err := json.Unmarshal(raw, &parsed); err != nil {
			writeError(w, http.StatusBadGateway, "unreadable item from emby")
			return
		}
		seriesID = parsed.SeriesID
	}
	if seriesID == "" {
		writeError(w, http.StatusNotFound, "this item is not part of a series")
		return
	}

	episodes, err := s.emby.Episodes(ctx, cred, seriesID, url.Values{
		"AdjacentTo":       {itemID},
		"Fields":           {"RunTimeTicks,Overview,SeriesName"},
		"EnableUserData":   {"true"},
		"EnableImageTypes": {"Primary,Thumb"},
	})
	if err != nil {
		s.writeUpstreamError(ctx, w, err, "could not load the next episode")
		return
	}

	raw, next, ok := episodeAfter(episodes.Items, itemID)
	if !ok {
		writeError(w, http.StatusNotFound, "no episode follows this one")
		return
	}

	title := next.Name
	if series := strings.TrimSpace(seriesNameOf(raw)); series != "" && title != "" {
		title = series + " – " + title
	}
	subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles(
		ctx, cred, next.ID, next.UserData.PlaybackPositionTicks, nil, "", false,
		s.effectivePlaybackCapabilities(ctx, sess),
	)
	streamURL := s.emby.StreamURL(cred, next.ID)
	if negotiatedURL != "" {
		streamURL = negotiatedURL
	}
	// The same choice as the episode the viewer is already watching, made the same way:
	// auto-advance must not quietly drop the subtitles they had on a minute ago.
	subtitlesEnabled, subtitleLanguage := s.subtitlePreferenceFor(ctx, sess)
	selectedSubtitleID := selectSubtitle(subtitles, subtitlesEnabled, subtitleLanguage)
	// The player asks for this ~30s before an episode ends, so the line is also the
	// record of an auto-advance about to happen.
	s.playbackTitles.remember(next.ID, title)
	s.loggerFor(ctx).Info("next episode resolved",
		"title", title,
		"item", next.ID,
		"after_item", itemID,
		"play_method", clientLogValue(playMethod),
	)

	writeJSON(w, http.StatusOK, nextEpisodeResponse{
		Item:               raw,
		Title:              title,
		URL:                streamURL,
		ResumePositionMs:   max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
		Subtitles:          subtitles,
		SubtitlesEnabled:   subtitlesEnabled,
		SelectedSubtitleID: selectedSubtitleID,
		MediaSourceID:      mediaSourceID,
		PlaySessionID:      playSessionID,
		PlayMethod:         playMethod,
	})
}

func (s *Server) playbackSubtitles(
	ctx context.Context, cred emby.Credentials, itemID string, startTicks int64,
	subtitleIndex *int, currentPlaySessionID string, forceTranscode bool,
	capabilities emby.PlaybackCapabilities,
) ([]playableSubtitle, string, string, string, string) {
	info, err := s.emby.PlaybackInfo(
		ctx, cred, itemID, startTicks, subtitleIndex, currentPlaySessionID, forceTranscode,
		capabilities,
	)
	if err != nil {
		s.loggerFor(ctx).Warn("could not load subtitle metadata", "item_id", itemID, "error", err)
		return []playableSubtitle{}, itemID, "", "", "DirectPlay"
	}
	if len(info.MediaSources) == 0 {
		return []playableSubtitle{}, itemID, info.PlaySessionID, "", "DirectPlay"
	}
	out := make([]playableSubtitle, 0)
	seen := make(map[string]bool)
	source := info.MediaSources[0]
	for _, stream := range source.MediaStreams {
		if !strings.EqualFold(stream.Type, "Subtitle") || stream.Index < 0 {
			continue
		}
		method := stream.DeliveryMethod
		if method == "" {
			if stream.IsTextSubtitleStream {
				method = "External"
			} else {
				method = "Encode"
			}
		}
		delivery := ""
		mimeType := subtitleMIME(stream.Codec, stream.DeliveryURL)
		if strings.EqualFold(method, "External") && mimeType != "" {
			if stream.DeliveryURL != "" {
				delivery = s.emby.DeliveryURL(cred, stream.DeliveryURL)
			} else {
				delivery = s.emby.SubtitleURL(cred, itemID, source.ID, stream.Index, subtitleExtension(stream.Codec))
			}
		}
		key := strconv.Itoa(stream.Index)
		if seen[key] {
			continue
		}
		seen[key] = true
		label := strings.TrimSpace(stream.DisplayTitle)
		if label == "" {
			label = strings.TrimSpace(stream.Title)
		}
		out = append(out, playableSubtitle{
			ID:        strconv.Itoa(stream.Index),
			URL:       delivery,
			MimeType:  mimeType,
			Language:  strings.TrimSpace(stream.Language),
			Label:     label,
			IsDefault: stream.IsDefault,
			IsForced:  stream.IsForced,
			IsHearingImpaired: stream.IsHearingImpaired ||
				strings.Contains(strings.ToLower(stream.Title+" "+stream.DisplayTitle), "sdh") ||
				strings.Contains(strings.ToLower(stream.Title+" "+stream.DisplayTitle), "hearing"),
			DeliveryMethod: method,
			Codec:          stream.Codec,
		})
	}
	// Anything the gateway fetched itself joins the list here, so a subtitle downloaded
	// from a provider that cannot write beside the media file is an ordinary track on
	// every later playback — not something that exists only in the response to the
	// download that produced it.
	out = mergeSubtitleTracks(out, s.storedSubtitlesFor(ctx, itemID))
	delivery, playMethod := selectPlaybackDelivery(source, forceTranscode || subtitleIndex != nil)
	if delivery != "" {
		delivery = s.emby.DeliveryURL(cred, delivery)
	}
	return out, source.ID, info.PlaySessionID, delivery, playMethod
}

func sessionPlaybackCapabilities(sess store.Session) emby.PlaybackCapabilities {
	capabilities := emby.PlaybackCapabilities{}
	for _, value := range sess.ClientCapabilities {
		switch value {
		case "video_h264_profile_baseline":
			capabilities.H264Profiles = append(capabilities.H264Profiles, "baseline")
		case "video_h264_profile_constrained_baseline":
			capabilities.H264Profiles = append(capabilities.H264Profiles, "constrained baseline")
		case "video_h264_profile_main":
			capabilities.H264Profiles = append(capabilities.H264Profiles, "main")
		case "video_h264_profile_high":
			capabilities.H264Profiles = append(capabilities.H264Profiles, "high")
		case "video_h264_profile_high10":
			capabilities.H264Profiles = append(capabilities.H264Profiles, "high 10")
		case "video_hevc_decode":
			capabilities.HEVC = true
		case "video_hevc_profile_main":
			capabilities.HEVCMain = true
		case "video_hevc_profile_main10":
			capabilities.HEVCMain10 = true
		case "video_hevc_hdr10":
			capabilities.HEVCHDR10 = true
		case "video_hevc_hdr10plus":
			capabilities.HEVCHDR10Plus = true
		case "video_hevc_dolby_vision":
			capabilities.HEVCDolbyVision = true
		default:
			parsePlaybackCapabilityValue(value, &capabilities)
		}
	}
	return capabilities
}

func (s *Server) effectivePlaybackCapabilities(
	ctx context.Context, sess store.Session,
) emby.PlaybackCapabilities {
	capabilities := sessionPlaybackCapabilities(sess)
	if !s.featureEnabled(ctx, featureHEVCDirectPlay) {
		capabilities.HEVC = false
		capabilities.HEVCMain = false
		capabilities.HEVCMain10 = false
		capabilities.HEVCMainLevel = 0
		capabilities.HEVCMain10Level = 0
		capabilities.HEVCMaxWidth = 0
		capabilities.HEVCMaxHeight = 0
		capabilities.HEVCHDR10 = false
		capabilities.HEVCHDR10Plus = false
		capabilities.HEVCDolbyVision = false
	}
	return capabilities
}

func parsePlaybackCapabilityValue(value string, capabilities *emby.PlaybackCapabilities) {
	parseIntCapability(value, "video_h264_level_", &capabilities.H264Level)
	parseIntCapability(value, "video_h264_high10_level_", &capabilities.H264High10Level)
	parseIntCapability(value, "video_hevc_main_level_", &capabilities.HEVCMainLevel)
	parseIntCapability(value, "video_hevc_main10_level_", &capabilities.HEVCMain10Level)
	parseResolutionCapability(
		value, "video_h264_max_", &capabilities.H264MaxWidth, &capabilities.H264MaxHeight,
	)
	parseResolutionCapability(
		value, "video_hevc_max_", &capabilities.HEVCMaxWidth, &capabilities.HEVCMaxHeight,
	)
}

func parseIntCapability(value, prefix string, destination *int) {
	if !strings.HasPrefix(value, prefix) {
		return
	}
	parsed, err := strconv.Atoi(strings.TrimPrefix(value, prefix))
	if err == nil && parsed > 0 {
		*destination = parsed
	}
}

func parseResolutionCapability(value, prefix string, width, height *int) {
	if !strings.HasPrefix(value, prefix) {
		return
	}
	parts := strings.Split(strings.TrimPrefix(value, prefix), "x")
	if len(parts) != 2 {
		return
	}
	parsedWidth, widthErr := strconv.Atoi(parts[0])
	parsedHeight, heightErr := strconv.Atoi(parts[1])
	if widthErr == nil && heightErr == nil && parsedWidth > 0 && parsedHeight > 0 {
		*width, *height = parsedWidth, parsedHeight
	}
}

func selectPlaybackDelivery(source emby.MediaSourceInfo, forceTranscode bool) (string, string) {
	if forceTranscode && source.TranscodingURL != "" {
		return source.TranscodingURL, "Transcode"
	}
	if source.SupportsDirectPlay {
		return "", "DirectPlay"
	}
	if source.SupportsDirectStream && source.DirectStreamURL != "" {
		return source.DirectStreamURL, "DirectStream"
	}
	if source.SupportsTranscoding && source.TranscodingURL != "" {
		return source.TranscodingURL, "Transcode"
	}
	if source.DirectStreamURL != "" {
		return source.DirectStreamURL, "DirectStream"
	}
	if source.TranscodingURL != "" {
		return source.TranscodingURL, "Transcode"
	}
	return "", "DirectPlay"
}

func queryBool(r *http.Request, name string) bool {
	value, err := strconv.ParseBool(strings.TrimSpace(r.URL.Query().Get(name)))
	return err == nil && value
}

func subtitleExtension(codec string) string {
	switch strings.ToLower(strings.TrimSpace(codec)) {
	case "subrip":
		return "srt"
	case "webvtt":
		return "vtt"
	case "tx3g":
		return "mov_text"
	default:
		if strings.TrimSpace(codec) == "" {
			return "vtt"
		}
		return strings.ToLower(strings.TrimSpace(codec))
	}
}

func subtitleMIME(codec, delivery string) string {
	value := strings.ToLower(strings.TrimSpace(codec))
	if value == "" {
		path := delivery
		if parsed, err := url.Parse(delivery); err == nil {
			path = parsed.Path
		}
		if dot := strings.LastIndex(path, "."); dot >= 0 {
			value = strings.ToLower(path[dot+1:])
		}
	}
	switch value {
	case "srt", "subrip":
		return "application/x-subrip"
	case "vtt", "webvtt":
		return "text/vtt"
	case "ass", "ssa":
		return "text/x-ssa"
	case "ttml", "dfxp":
		return "application/ttml+xml"
	case "tx3g", "mov_text":
		return "application/x-quicktime-tx3g"
	default:
		return ""
	}
}

// episodeAfter picks the episode following currentID out of an AdjacentTo result, which
// Emby returns in running order as [previous, current, next] minus whichever ends do not
// exist — so the position of the current episode is what identifies the next one, not the
// length of the list.
func episodeAfter(items []json.RawMessage, currentID string) (json.RawMessage, emby.Summary, bool) {
	for i, raw := range items {
		summary, err := emby.Summarise(raw)
		if err != nil || summary.ID != currentID {
			continue
		}
		if i+1 >= len(items) {
			return nil, emby.Summary{}, false
		}
		next, err := emby.Summarise(items[i+1])
		if err != nil || next.ID == "" {
			return nil, emby.Summary{}, false
		}
		return items[i+1], next, true
	}
	return nil, emby.Summary{}, false
}

func seriesNameOf(raw json.RawMessage) string {
	var parsed struct {
		SeriesName string `json:"SeriesName"`
	}
	if err := json.Unmarshal(raw, &parsed); err != nil {
		return ""
	}
	return parsed.SeriesName
}

// handlePlaybackReport forwards progress to Emby. Stopping invalidates the user's cache
// so Continue Watching reflects the new position on the next home load.
func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, sess store.Session) {
	phase := r.PathValue("phase")
	switch phase {
	case "started", "progress", "stopped":
	default:
		writeError(w, http.StatusNotFound, "unknown playback phase")
		return
	}

	var report playbackReport
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&report); err != nil {
		writeError(w, http.StatusBadRequest, "malformed request body")
		return
	}
	if report.ItemID == "" {
		writeError(w, http.StatusBadRequest, "itemId is required")
		return
	}

	log := s.loggerFor(r.Context()).With(
		"title", s.playbackTitles.name(report.ItemID),
		"item", report.ItemID,
	)

	err := s.emby.ReportPlayback(
		r.Context(), credentials(sess), phase, report.ItemID, report.MediaSourceID,
		report.PlaySessionID, report.PlayMethod, report.EventName,
		max64(report.PositionMs, 0)*ticksPerMillisecond, report.IsPaused,
	)
	if err != nil {
		// A dropped progress report is not worth failing playback over; log and accept.
		log.Warn("playback report failed", "phase", phase, "error", err)
	}

	// Start and stop are the shape of an evening's viewing and belong in the normal log.
	// Progress arrives every ten seconds for the length of a film, so it is DEBUG: useful
	// when investigating one stall, ruinous as a default.
	switch phase {
	case "started":
		log.Info("playback started",
			"position", millisecondDuration(report.PositionMs),
			"play_method", clientLogValue(report.PlayMethod),
		)
	case "stopped":
		log.Info("playback stopped",
			"position", millisecondDuration(report.PositionMs),
			"runtime", millisecondDuration(report.DurationMs),
			"watched", watchedPercent(report.PositionMs, report.DurationMs),
		)
	default:
		log.Debug("playback progress",
			"position", millisecondDuration(report.PositionMs),
			"paused", report.IsPaused,
		)
	}

	if phase == "stopped" {
		if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
			s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err)
		}
		// Recommendation taste changes slowly. Tracearr marks this user's prepared
		// profile dirty only when the session first becomes terminal; the daily builder
		// then refreshes it without turning every player exit into catalogue-wide work.
	}

	response := playbackReportResponse{}
	if shouldAutoFollowShow(phase, report.PositionMs, report.DurationMs) &&
		s.featureEnabled(r.Context(), featureAutomaticMyShows) {
		response.AutoFollowedShowTitle = s.autoFollowContinuingShow(r.Context(), sess, report.ItemID)
	}
	writeJSON(w, http.StatusOK, response)
}

// Half an episode is a meaningful intent signal without making somebody finish an
// episode they dislike. The insert below is the durable deduplication boundary, so
// later ten-second progress reports are harmless.
func shouldAutoFollowShow(phase string, positionMs, durationMs int64) bool {
	return phase != "started" && durationMs > 0 && positionMs >= (durationMs+1)/2
}

func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Session, episodeID string) string {
	if s.sonarr == nil || s.store == nil {
		return ""
	}
	rawEpisode, err := s.emby.Item(ctx, credentials(sess), episodeID, "SeriesId")
	if err != nil {
		s.loggerFor(ctx).Warn("auto-follow episode lookup failed", "error", err)
		return ""
	}
	var episode struct {
		Type     string `json:"Type"`
		SeriesID string `json:"SeriesId"`
	}
	if json.Unmarshal(rawEpisode, &episode) != nil || !strings.EqualFold(episode.Type, "Episode") || episode.SeriesID == "" {
		return ""
	}
	rawSeries, err := s.emby.Item(ctx, credentials(sess), episode.SeriesID, "ProductionYear")
	if err != nil {
		s.loggerFor(ctx).Warn("auto-follow series lookup failed", "error", err)
		return ""
	}
	var seriesItem struct {
		Name           string            `json:"Name"`
		ProductionYear *int              `json:"ProductionYear"`
		ImageTags      map[string]string `json:"ImageTags"`
	}
	if json.Unmarshal(rawSeries, &seriesItem) != nil || strings.TrimSpace(seriesItem.Name) == "" {
		return ""
	}
	sonarrSeries, err := s.sonarrSeriesCatalogue(ctx)
	if err != nil {
		s.loggerFor(ctx).Warn("auto-follow Sonarr lookup failed", "error", err)
		return ""
	}
	matched := matchSonarrSeries(store.UserShow{Title: seriesItem.Name, Year: seriesItem.ProductionYear}, sonarrSeries)
	if matched == nil || !isContinuingSonarrStatus(matched.Status) {
		return ""
	}
	show := store.UserShow{
		ItemID: episode.SeriesID, Title: seriesItem.Name, Year: seriesItem.ProductionYear,
		ImageTag: seriesItem.ImageTags["Primary"],
	}
	inserted, err := s.store.SaveUserShowIfAbsent(ctx, sess.EmbyUserID, show)
	if err != nil {
		s.loggerFor(ctx).Warn("auto-follow save failed", "error", err)
		return ""
	}
	if !inserted {
		return ""
	}
	prefs, err := s.store.NotificationPreferences(ctx, sess.EmbyUserID)
	if err != nil {
		s.loggerFor(ctx).Warn("auto-follow notification preferences unavailable", "error", err)
		return ""
	}
	if prefs.Enabled && s.featureEnabled(ctx, featureMyShowsNotification) {
		_ = s.store.UpsertNotification(
			ctx, sess.EmbyUserID, "auto-follow:"+episode.SeriesID, "auto-follow",
			episode.SeriesID, "Added to My Shows",
			seriesItem.Name+" was added because you started watching it and it is still continuing.", nil,
		)
		return seriesItem.Name
	}
	return ""
}

func max64(v, floor int64) int64 {
	if v < floor {
		return floor
	}
	return v
}
