package api

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

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

// Fetching a subtitle a title does not have.
//
// Two providers answer this now and they are not the same shape. **Bazarr** writes the
// subtitle file beside the media file, so the gateway asks and forgets — Emby finds the
// result on a refresh and the track arrives down the same PlaybackInfo path as an embedded
// one, which is why `playableSubtitle` needed no new shape. **OpenSubtitles** hands back
// bytes, and the gateway has no reach into the media directory, so a file fetched there is
// stored by the gateway and served back as a sidecar. `subtitle_providers.go` holds that
// difference and everything here is written against the one vocabulary.
//
// The hard part is not the download, it is the identity, and the two providers make
// opposite trades on it. Bazarr keys everything on the *arr's id (`radarrid` for a film,
// Sonarr's `episodeid` for an episode) and Emby knows nothing about either, so an item has
// to be matched onto one by title, year and — for an episode — season and episode number.
// That matching is pure and unit-tested (`bazarrMovieFor`, `bazarrEpisodeFor`), because it
// is where a wrong answer is worst: a mismatch downloads a subtitle for the wrong film and
// writes it next to this one. OpenSubtitles keys on an imdb or tmdb id, which Memby
// already holds — the library import asks Emby for `ProviderIds` so external ratings can
// be looked up — so there is no guessing at all on that path.

const (
	bazarrMoviesCacheKey   = "bazarr:movies"
	bazarrSeriesCacheKey   = "bazarr:series"
	bazarrEpisodesCacheKey = "bazarr:episodes:"
	// maxSubtitleResults caps what a television is shown. A manual search can return
	// dozens of near-identical releases, and a D-pad list is not the place to read them.
	maxSubtitleResults = 12
	// embyRefreshSettleDelay is how long Emby is given to notice the new file before the
	// gateway re-reads the item's streams. A refresh is asynchronous, so returning
	// immediately would reliably report the track as missing on the one request that is
	// certain to be looking for it.
	embyRefreshSettleDelay = 1500 * time.Millisecond
)

// subtitleCandidate is one row a viewer can choose from.
//
// Token is Bazarr's opaque provider handle and it round-trips through the television
// untouched — nothing on either side parses it. Keeping it on the wire rather than in a
// server-side cache means a viewer who reads the list slowly cannot have their choice
// expire underneath them, which on a set being operated by remote control is the more
// likely failure of the two.
type subtitleCandidate struct {
	// Source is which backend produced this row, and it is what the download call
	// dispatches on. It round-trips through the television with the token, because the two
	// providers' tokens are opaque in different ways and handing one to the other is a
	// mistake nothing downstream could detect. Empty means Bazarr: an app built before
	// there was a second provider sends no source, and its rows all came from one place.
	Source          string `json:"source,omitempty"`
	Token           string `json:"token"`
	Language        string `json:"language"`
	LanguageLabel   string `json:"languageLabel"`
	Provider        string `json:"provider"`
	Score           int    `json:"score"`
	Forced          bool   `json:"forced"`
	HearingImpaired bool   `json:"hearingImpaired"`
	OriginalFormat  bool   `json:"originalFormat"`
	// MachineOnly marks a translation nobody wrote. It is on the wire rather than folded
	// into the label alone because it is the one property that changes whether a viewer
	// wants the row at all, and the ranking sinks it below everything a person wrote.
	MachineOnly bool `json:"machineOnly,omitempty"`
	// Release is the file's own release string, carried for the log rather than the screen
	// — "Interstellar.2014.1080p.BluRay" means nothing across a lounge.
	Release string `json:"-"`
	// format is the extension the provider's file carries. Lower case and unexported: it
	// is the gateway's own bookkeeping for a file it is about to store, and there is
	// nothing for a television to do with it.
	format string
	// Label is what the drop-up prints. It is composed here rather than on the TV so an
	// older app renders a new wording correctly, the same reason alert labels are the
	// gateway's.
	Label string `json:"label"`
}

type subtitleSearchResponse struct {
	Results []subtitleCandidate `json:"results"`
	// Message is what to say when there are none. A search that reached the providers and
	// found nothing is a different answer from one that could not identify the title, and
	// a viewer standing in front of the set deserves to be told which.
	Message string `json:"message,omitempty"`
}

type subtitleDownloadRequest struct {
	// The candidate exactly as it was handed out. Only Token, Language, Provider and the
	// three flags are read; anything else the client echoes is ignored.
	Candidate subtitleCandidate `json:"candidate"`
}

type subtitleDownloadResponse struct {
	Message string `json:"message"`
	// The item's subtitle tracks re-read from Emby after the refresh, so the player can
	// swap its media item and turn the new track on without a second round trip.
	Subtitles []playableSubtitle `json:"subtitles"`
	// Which of them is the one that was just fetched, when it can be identified. Empty
	// means "it is in the list somewhere" — the client falls back to its own rule rather
	// than guessing, exactly as it does for the server's ordinary selection.
	SelectedSubtitleID string `json:"selectedSubtitleId,omitempty"`
	MediaSourceID      string `json:"mediaSourceId"`
	PlaySessionID      string `json:"playSessionId"`
	URL                string `json:"url"`
}

// subtitleDownloadAvailable is the one thing the television needs to know: whether to
// offer the option at all. It is true when the feature is on and at least one provider is
// both configured and switched on — a deployment with neither must never draw a row that
// leads to a request nothing can answer.
func (s *Server) subtitleDownloadAvailable(ctx context.Context) bool {
	return s.subtitleSources(ctx).any()
}

func (s *Server) handleSubtitleSearch(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
	}
	sources := s.subtitleSources(ctx)
	if !sources.any() {
		writeError(w, http.StatusNotFound, "subtitle downloads are not available")
		return
	}

	target, err := s.resolveSubtitleTarget(ctx, credentials(sess), itemID, sources)
	if err != nil {
		s.loggerFor(ctx).Warn("subtitle target lookup failed", "item", itemID, "error", err)
		writeJSON(w, http.StatusOK, subtitleSearchResponse{
			Results: []subtitleCandidate{},
			Message: "Memby could not work out which title this is.",
		})
		return
	}

	language := strings.TrimSpace(r.URL.Query().Get("language"))
	if language == "" {
		_, language = s.subtitlePreferenceFor(ctx, sess)
	}
	found, failures := s.providerSubtitles(ctx, sources, target, language)
	for _, failure := range failures {
		s.loggerFor(ctx).Warn("subtitle search failed",
			"item", itemID, "title", target.Title, "error", failure,
		)
	}
	results := rankMergedCandidates(found, language)
	s.loggerFor(ctx).Info("subtitle search",
		"title", target.Title,
		"item", itemID,
		"language", clientLogValue(language),
		"bazarr", sources.Bazarr && target.hasBazarr,
		"opensubtitles", sources.OpenSubtitles && target.hasQuery,
		"found", len(found),
		"offered", len(results),
	)
	response := subtitleSearchResponse{Results: results}
	if len(results) == 0 {
		// Which of the two empty answers this is matters to somebody standing in front of
		// the set: providers that found nothing is a different thing from providers that
		// did not answer, and an exhausted allowance is a third.
		response.Message = subtitleFailureMessage(failures)
	}
	writeJSON(w, http.StatusOK, response)
}

func (s *Server) handleSubtitleDownload(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
	}
	sources := s.subtitleSources(ctx)
	if !sources.any() {
		writeError(w, http.StatusNotFound, "subtitle downloads are not available")
		return
	}

	var request subtitleDownloadRequest
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&request); err != nil {
		writeError(w, http.StatusBadRequest, "malformed request body")
		return
	}
	candidate := request.Candidate
	candidate.Language = normalizeSubtitleLanguage(candidate.Language)
	if strings.TrimSpace(candidate.Token) == "" {
		writeError(w, http.StatusBadRequest, "a subtitle is required")
		return
	}

	cred := credentials(sess)
	target, err := s.resolveSubtitleTarget(ctx, cred, itemID, sources)
	if err != nil {
		s.loggerFor(ctx).Warn("subtitle target lookup failed", "item", itemID, "error", err)
		writeError(w, http.StatusNotFound, "Memby could not work out which title this is")
		return
	}

	fetched, err := s.fetchSubtitle(ctx, cred, itemID, target, candidate)
	if err != nil {
		s.loggerFor(ctx).Warn("subtitle download failed",
			"title", target.Title, "item", itemID,
			"source", clientLogValue(candidate.Source),
			"provider", clientLogValue(candidate.Provider), "error", err,
		)
		writeError(w, http.StatusBadGateway, subtitleDownloadFailureMessage(err))
		return
	}

	// Bazarr has written a file Emby does not know exists, and refreshing is what makes
	// the track appear. It is best-effort: if it fails the file is still on disk and the
	// next ordinary scan picks it up, so the viewer is told to try again rather than told
	// the download failed when it did not. A subtitle the gateway serves itself needs none
	// of this, and waiting anyway would spend a couple of seconds of somebody's film on
	// nothing.
	if fetched.RefreshEmby {
		if refreshErr := s.emby.RefreshItem(ctx, cred, itemID); refreshErr != nil {
			s.loggerFor(ctx).Warn("emby refresh after subtitle download failed",
				"item", itemID, "error", refreshErr,
			)
		}
		select {
		case <-time.After(embyRefreshSettleDelay):
		case <-ctx.Done():
			return
		}
	}

	subtitles, mediaSourceID, playSessionID, negotiatedURL, _ := s.playbackSubtitles(
		ctx, cred, itemID, 0, nil, "", false, s.effectivePlaybackCapabilities(ctx, sess),
	)
	streamURL := s.emby.StreamURL(cred, itemID)
	if negotiatedURL != "" {
		streamURL = negotiatedURL
	}
	// A file the gateway stored names itself, so the player can be pointed at exactly the
	// track that was just fetched. Only Bazarr's path has to guess, and it says so by
	// answering empty.
	selected := fetched.StoredID
	if selected == "" {
		selected = newestSubtitleID(subtitles, candidate)
	}

	s.loggerFor(ctx).Info("subtitle downloaded",
		"title", target.Title,
		"item", itemID,
		"language", clientLogValue(candidate.Language),
		"source", clientLogValue(candidate.Source),
		"provider", clientLogValue(candidate.Provider),
		"release", clientLogValue(candidate.Release),
		"subtitles", len(subtitles),
	)
	writeJSON(w, http.StatusOK, subtitleDownloadResponse{
		Message:            downloadedSubtitleMessage(candidate),
		Subtitles:          subtitles,
		SelectedSubtitleID: selected,
		MediaSourceID:      mediaSourceID,
		PlaySessionID:      playSessionID,
		URL:                streamURL,
	})
}

// subtitleDownloadFailureMessage is the sentence a television prints when a fetch fails.
// The allowance running out keeps its own wording for the reason the search's does:
// pressing the button again will not fix it, and nothing else on the set can say so.
func subtitleDownloadFailureMessage(err error) string {
	if _, ok := err.(*opensubtitles.QuotaError); ok {
		return "today's subtitle downloads have been used up"
	}
	return "the subtitle could not be downloaded"
}

// bazarrTarget is an Emby item resolved onto the ids Bazarr keys on. Exactly one of
// RadarrID and EpisodeID is set.
type bazarrTarget struct {
	Title     string
	RadarrID  int
	SeriesID  int
	EpisodeID int
}

func (s *Server) searchBazarr(ctx context.Context, target bazarrTarget) ([]bazarr.Subtitle, error) {
	if target.EpisodeID > 0 {
		return s.bazarr.SearchEpisode(ctx, target.EpisodeID)
	}
	return s.bazarr.SearchMovie(ctx, target.RadarrID)
}

// resolveBazarrTarget turns an Emby item id into Bazarr's idea of the same thing.
func (s *Server) resolveBazarrTarget(
	ctx context.Context, cred emby.Credentials, itemID string,
) (bazarrTarget, error) {
	raw, err := s.emby.Item(ctx, cred, itemID, "ProductionYear,SeriesName,ParentIndexNumber,IndexNumber")
	if err != nil {
		return bazarrTarget{}, err
	}
	var item struct {
		Name              string `json:"Name"`
		Type              string `json:"Type"`
		ProductionYear    int    `json:"ProductionYear"`
		SeriesName        string `json:"SeriesName"`
		ParentIndexNumber int    `json:"ParentIndexNumber"`
		IndexNumber       int    `json:"IndexNumber"`
	}
	if err := json.Unmarshal(raw, &item); err != nil {
		return bazarrTarget{}, fmt.Errorf("unreadable item from emby: %w", err)
	}

	if strings.EqualFold(item.Type, "Episode") {
		series, err := s.bazarrSeries(ctx)
		if err != nil {
			return bazarrTarget{}, err
		}
		// An episode carries its series' name but not its year, so the year is looked up
		// on the series item rather than guessed from the episode's air date — a show that
		// ran for years would otherwise match the wrong entry of a remake pair.
		matched := bazarrSeriesFor(item.SeriesName, s.seriesYear(ctx, cred, raw), series)
		if matched == nil {
			return bazarrTarget{}, fmt.Errorf("no bazarr series for %q", item.SeriesName)
		}
		episodes, err := s.bazarrEpisodes(ctx, matched.SonarrSeriesID)
		if err != nil {
			return bazarrTarget{}, err
		}
		episode := bazarrEpisodeFor(episodes, item.ParentIndexNumber, item.IndexNumber)
		if episode == nil {
			return bazarrTarget{}, fmt.Errorf(
				"no bazarr episode for %q S%02dE%02d",
				item.SeriesName, item.ParentIndexNumber, item.IndexNumber,
			)
		}
		title := fmt.Sprintf("%s S%02dE%02d", item.SeriesName, episode.Season, episode.Episode)
		return bazarrTarget{
			Title: title, SeriesID: matched.SonarrSeriesID, EpisodeID: episode.SonarrEpisodeID,
		}, nil
	}

	if !strings.EqualFold(item.Type, "Movie") {
		return bazarrTarget{}, fmt.Errorf("subtitles cannot be fetched for a %q", item.Type)
	}
	movies, err := s.bazarrMovies(ctx)
	if err != nil {
		return bazarrTarget{}, err
	}
	matched := bazarrMovieFor(item.Name, item.ProductionYear, movies)
	if matched == nil {
		return bazarrTarget{}, fmt.Errorf("no bazarr movie for %q", item.Name)
	}
	return bazarrTarget{Title: item.Name, RadarrID: matched.RadarrID}, nil
}

// seriesYear reads the parent series' production year, which is what disambiguates a
// remake from its original. A failure is not fatal: zero means "match on title alone".
func (s *Server) seriesYear(ctx context.Context, cred emby.Credentials, episode json.RawMessage) int {
	var parsed struct {
		SeriesID string `json:"SeriesId"`
	}
	if json.Unmarshal(episode, &parsed) != nil || parsed.SeriesID == "" {
		return 0
	}
	raw, err := s.emby.Item(ctx, cred, parsed.SeriesID, "ProductionYear")
	if err != nil {
		return 0
	}
	var series struct {
		ProductionYear int `json:"ProductionYear"`
	}
	if json.Unmarshal(raw, &series) != nil {
		return 0
	}
	return series.ProductionYear
}

// bazarrMovieFor matches an Emby film onto Bazarr's list.
//
// Title alone is not enough — a remake and its original share one — so a year that both
// sides know must agree. A year only one side knows is not treated as a disagreement,
// because Bazarr carries the year as a string and an empty one is common.
func bazarrMovieFor(title string, year int, movies []bazarr.Movie) *bazarr.Movie {
	key := normalizedShowTitle(title)
	if key == "" {
		return nil
	}
	var fallback *bazarr.Movie
	for i := range movies {
		candidate := &movies[i]
		if normalizedShowTitle(candidate.Title) != key {
			continue
		}
		candidateYear, _ := strconv.Atoi(strings.TrimSpace(candidate.Year))
		if year > 0 && candidateYear > 0 && year == candidateYear {
			return candidate
		}
		if fallback == nil && (year == 0 || candidateYear == 0) {
			fallback = candidate
		}
	}
	return fallback
}

// bazarrSeriesFor is the same rule for a show. It is deliberately a separate function
// rather than a generic one: the two lists have different field names and a shared helper
// would have to take accessors, which is more machinery than two short loops.
func bazarrSeriesFor(title string, year int, series []bazarr.Series) *bazarr.Series {
	key := normalizedShowTitle(title)
	if key == "" {
		return nil
	}
	var fallback *bazarr.Series
	for i := range series {
		candidate := &series[i]
		if normalizedShowTitle(candidate.Title) != key {
			continue
		}
		candidateYear, _ := strconv.Atoi(strings.TrimSpace(candidate.Year))
		if year > 0 && candidateYear > 0 && year == candidateYear {
			return candidate
		}
		if fallback == nil && (year == 0 || candidateYear == 0) {
			fallback = candidate
		}
	}
	return fallback
}

// bazarrEpisodeFor matches on season and episode number, which both Emby and Sonarr agree
// on. Titles are not compared: they differ between the two often enough (translations,
// two-parters named differently) that they would reject correct matches.
//
// Season 0 is a legitimate season — specials — so a zero season number is only rejected
// when the episode number is also missing.
func bazarrEpisodeFor(episodes []bazarr.Episode, season, number int) *bazarr.Episode {
	if number <= 0 || season < 0 {
		return nil
	}
	for i := range episodes {
		if episodes[i].Season == season && episodes[i].Episode == number {
			return &episodes[i]
		}
	}
	return nil
}

func clampPercent(value int) int {
	if value < 0 {
		return 0
	}
	if value > 100 {
		return 100
	}
	return value
}

// subtitleLanguageLabel names a language for display, falling back to the code itself
// rather than to a blank when Memby has no entry for it — an unnamed row is worse than an
// unfamiliar one.
func subtitleLanguageLabel(raw string) string {
	normalized := normalizeSubtitleLanguage(raw)
	for _, language := range subtitleLanguages {
		if language.Value == normalized {
			return language.Label
		}
	}
	if normalized == "" {
		return "Unknown"
	}
	return strings.ToUpper(normalized)
}

func downloadedSubtitleMessage(candidate subtitleCandidate) string {
	label := candidate.LanguageLabel
	if strings.TrimSpace(label) == "" {
		label = subtitleLanguageLabel(candidate.Language)
	}
	return label + " subtitles downloaded."
}

// newestSubtitleID picks the track the download produced out of the refreshed list.
//
// Emby gives a sidecar no marker saying when it arrived, so this matches on what was
// asked for: an external track in the right language, preferring one whose forced and
// hearing-impaired flags agree with the candidate. It is allowed to fail — an empty answer
// means the client applies its own rule, which is the same thing it does on every other
// playback response.
func newestSubtitleID(subtitles []playableSubtitle, candidate subtitleCandidate) string {
	wanted := normalizeSubtitleLanguage(candidate.Language)
	if wanted == "" {
		return ""
	}
	best, bestRank := "", 0
	for _, subtitle := range subtitles {
		if normalizeSubtitleLanguage(subtitle.Language) != wanted {
			continue
		}
		if !strings.EqualFold(subtitle.DeliveryMethod, "External") {
			continue
		}
		rank := 1
		if subtitle.IsForced == candidate.Forced {
			rank++
		}
		if subtitle.IsHearingImpaired == candidate.HearingImpaired {
			rank++
		}
		if rank > bestRank {
			best, bestRank = subtitle.ID, rank
		}
	}
	return best
}

// The three listings exist only to turn an Emby item into an *arr id, so they are cached
// for minutes: a household's library does not change between one subtitle search and the
// next, and a manual search is already slow enough without three list requests in front
// of it. A cache miss is not an error — it just costs the request.

func (s *Server) bazarrMovies(ctx context.Context) ([]bazarr.Movie, error) {
	var movies []bazarr.Movie
	if s.cachedBazarr(ctx, bazarrMoviesCacheKey, &movies) {
		return movies, nil
	}
	s.bazarrMu.Lock()
	defer s.bazarrMu.Unlock()
	if s.cachedBazarr(ctx, bazarrMoviesCacheKey, &movies) {
		return movies, nil
	}
	movies, err := s.bazarr.Movies(ctx)
	if err != nil {
		return nil, err
	}
	s.storeBazarr(ctx, bazarrMoviesCacheKey, movies)
	return movies, nil
}

func (s *Server) bazarrSeries(ctx context.Context) ([]bazarr.Series, error) {
	var series []bazarr.Series
	if s.cachedBazarr(ctx, bazarrSeriesCacheKey, &series) {
		return series, nil
	}
	s.bazarrMu.Lock()
	defer s.bazarrMu.Unlock()
	if s.cachedBazarr(ctx, bazarrSeriesCacheKey, &series) {
		return series, nil
	}
	series, err := s.bazarr.Series(ctx)
	if err != nil {
		return nil, err
	}
	s.storeBazarr(ctx, bazarrSeriesCacheKey, series)
	return series, nil
}

func (s *Server) bazarrEpisodes(ctx context.Context, seriesID int) ([]bazarr.Episode, error) {
	key := bazarrEpisodesCacheKey + strconv.Itoa(seriesID)
	var episodes []bazarr.Episode
	if s.cachedBazarr(ctx, key, &episodes) {
		return episodes, nil
	}
	s.bazarrMu.Lock()
	defer s.bazarrMu.Unlock()
	if s.cachedBazarr(ctx, key, &episodes) {
		return episodes, nil
	}
	episodes, err := s.bazarr.Episodes(ctx, seriesID)
	if err != nil {
		return nil, err
	}
	s.storeBazarr(ctx, key, episodes)
	return episodes, nil
}

func (s *Server) cachedBazarr(ctx context.Context, key string, out any) bool {
	raw, err := s.cache.Get(ctx, key)
	if err != nil {
		return false
	}
	return json.Unmarshal(raw, out) == nil
}

func (s *Server) storeBazarr(ctx context.Context, key string, value any) {
	body, err := json.Marshal(value)
	if err != nil {
		return
	}
	if err := s.cache.Set(ctx, key, body, s.cfg.BazarrTTL); err != nil {
		s.loggerFor(ctx).Warn("bazarr cache write failed", "key", key, "error", err)
	}
}
