package api

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"sort"
	"strconv"
	"strings"

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

// Which backends may be asked for a subtitle, and how a candidate finds its way home.
//
// There are two providers now and they are not the same shape. Bazarr writes the file
// beside the media file, so the gateway asks and forgets; OpenSubtitles hands back bytes,
// which the gateway has to keep and serve itself. Everything above this file is written
// against one vocabulary — search, download, a candidate carrying its source — and the
// difference lives here and in `downloaded_subtitles`.
//
// The operator's switches are `store.SubtitlePolicy`, not environment variables, because
// the two answer different questions and a household changes its mind about them. A
// provider is offered only when it is configured *and* switched on *and* the
// `subtitle_download` feature is on: an unconfigured deployment must never draw a row that
// leads to a request nothing can answer.

// subtitleSources is which providers this request may use. Nothing downstream branches on
// a client, a viewer or a title — a source is on for the household or it is not.
type subtitleSources struct {
	Bazarr        bool
	OpenSubtitles bool
}

func (s subtitleSources) any() bool { return s.Bazarr || s.OpenSubtitles }

// subtitlePolicy reads the operator's document, falling back to the defaults rather than
// to nothing: a store that will not answer must cost the console its switches, not a
// household its subtitles.
func (s *Server) subtitlePolicy(ctx context.Context) store.SubtitlePolicy {
	if s.store == nil {
		return store.DefaultSubtitlePolicy()
	}
	policy, err := s.store.SubtitlePolicy(ctx)
	if err != nil {
		s.loggerFor(ctx).Warn("subtitle policy unavailable; using defaults", "error", err)
		return store.DefaultSubtitlePolicy()
	}
	return policy
}

func (s *Server) subtitleSources(ctx context.Context) subtitleSources {
	if !s.featureEnabled(ctx, featureSubtitleDownload) {
		return subtitleSources{}
	}
	policy := s.subtitlePolicy(ctx)
	return subtitleSources{
		// Bazarr needs an address, which is deployment configuration and stays an
		// environment variable — it is a service the household runs, not a credential
		// somebody pastes into a console.
		Bazarr: s.bazarr != nil && policy.BazarrEnabled,
		// OpenSubtitles needs only a key, which the console holds, so it can be turned on
		// without a redeployment. The store already refuses to record it as on with no key.
		OpenSubtitles: policy.OpenSubtitlesEnabled && policy.OpenSubtitlesAPIKey != "",
	}
}

// openSubtitlesClient returns a client for the credentials currently saved, rebuilding it
// when they change.
//
// It is cached rather than constructed per request for one reason that matters: the client
// holds a login token, and logging in per download would spend a different allowance than
// the one being conserved. The fingerprint is a hash so a credential never reaches a log
// line or a comparison in a debugger.
func (s *Server) openSubtitlesClient(ctx context.Context) *opensubtitles.Client {
	policy := s.subtitlePolicy(ctx)
	if !policy.OpenSubtitlesEnabled || policy.OpenSubtitlesAPIKey == "" {
		return nil
	}
	fingerprint := credentialFingerprint(
		policy.OpenSubtitlesAPIKey, policy.OpenSubtitlesUsername, policy.OpenSubtitlesPassword,
	)
	s.openSubtitlesMu.Lock()
	defer s.openSubtitlesMu.Unlock()
	if s.openSubtitles != nil && s.openSubtitlesKey == fingerprint {
		return s.openSubtitles
	}
	s.openSubtitles = opensubtitles.New(
		policy.OpenSubtitlesAPIKey, openSubtitlesUserAgent(),
		policy.OpenSubtitlesUsername, policy.OpenSubtitlesPassword,
		s.cfg.BazarrTimeout,
	)
	s.openSubtitlesKey = fingerprint
	return s.openSubtitles
}

// openSubtitlesUserAgent names Memby to the provider. It carries the gateway's own version
// rather than a television's: the request is the gateway's, and the API asks a consumer to
// identify the build so it can be told when one misbehaves.
func openSubtitlesUserAgent() string {
	return "Memby/" + buildinfo.Version()
}

func credentialFingerprint(values ...string) string {
	sum := sha256.Sum256([]byte(strings.Join(values, "\x00")))
	return hex.EncodeToString(sum[:8])
}

// subtitleTarget is one Emby item resolved onto everything either provider needs. It is
// resolved once per search or download, because both providers want the same three facts
// about a title and reading them twice would double the Emby traffic of a feature that
// runs while somebody's film is paused.
type subtitleTarget struct {
	Title string
	// Bazarr's ids. Exactly one of RadarrID and EpisodeID is set when Bazarr can be used.
	bazarr    bazarrTarget
	hasBazarr bool
	// OpenSubtitles' identity, which is an external id rather than a title. Empty when
	// Emby knows no provider id for the item or its series.
	query    opensubtitles.Query
	hasQuery bool
}

// providerSubtitles searches every enabled provider at once and merges the answers.
//
// Concurrently, because a manual search is a live provider query measured in seconds and
// running two in turn would double a wait somebody is standing in front of. A provider
// that fails is dropped rather than failing the search: one working provider is a better
// answer than an error, and the caller says so when both are empty.
func (s *Server) providerSubtitles(
	ctx context.Context, sources subtitleSources, target subtitleTarget, language string,
) ([]subtitleCandidate, []error) {
	type outcome struct {
		candidates []subtitleCandidate
		err        error
	}
	results := make(chan outcome, 2)
	requested := 0

	if sources.Bazarr && target.hasBazarr {
		requested++
		go func() {
			found, err := s.searchBazarr(ctx, target.bazarr)
			results <- outcome{candidates: bazarrCandidates(found), err: err}
		}()
	}
	if sources.OpenSubtitles && target.hasQuery {
		requested++
		client := s.openSubtitlesClient(ctx)
		go func() {
			if client == nil {
				results <- outcome{}
				return
			}
			query := target.query
			query.Languages = openSubtitlesLanguages(language)
			found, err := client.Search(ctx, query)
			results <- outcome{candidates: openSubtitlesCandidates(found), err: err}
		}()
	}

	var candidates []subtitleCandidate
	var failures []error
	for range requested {
		result := <-results
		if result.err != nil {
			failures = append(failures, result.err)
			continue
		}
		candidates = append(candidates, result.candidates...)
	}
	return candidates, failures
}

// openSubtitlesLanguages is what a search asks for.
//
// English rides along with whatever the viewer chose, deliberately. A household that has
// never set a language gets the auto value, and a search restricted to nothing comes back
// with every language on earth ordered by somebody else's idea of relevance — where a
// search that names one or two produces a list a person can read on a television.
func openSubtitlesLanguages(language string) []string {
	normalized := normalizeSubtitleLanguage(language)
	if normalized == "" || normalized == subtitleLanguageAuto {
		return []string{"en"}
	}
	if normalized == "en" {
		return []string{"en"}
	}
	return []string{normalized, "en"}
}

func bazarrCandidates(found []bazarr.Subtitle) []subtitleCandidate {
	out := make([]subtitleCandidate, 0, len(found))
	for _, subtitle := range found {
		if strings.TrimSpace(subtitle.Token) == "" {
			continue
		}
		out = append(out, subtitleCandidate{
			Source:          store.SubtitleProviderBazarr,
			Token:           subtitle.Token,
			Language:        normalizeSubtitleLanguage(subtitle.Language),
			LanguageLabel:   subtitleLanguageLabel(subtitle.Language),
			Provider:        subtitle.Provider,
			Score:           clampPercent(subtitle.Score),
			Forced:          subtitle.Forced,
			HearingImpaired: subtitle.HearingImpaired,
			OriginalFormat:  subtitle.OriginalFormat,
		})
	}
	return out
}

func openSubtitlesCandidates(found []opensubtitles.Subtitle) []subtitleCandidate {
	out := make([]subtitleCandidate, 0, len(found))
	for _, subtitle := range found {
		if subtitle.FileID <= 0 {
			continue
		}
		out = append(out, subtitleCandidate{
			Source: store.SubtitleProviderOpenSubtitles,
			// The token is the file id as a string, so one field carries both providers'
			// opaque handles and nothing above this file has to know the difference.
			Token:           strconv.Itoa(subtitle.FileID),
			Language:        normalizeSubtitleLanguage(subtitle.Language),
			LanguageLabel:   subtitleLanguageLabel(subtitle.Language),
			Provider:        "OpenSubtitles",
			Score:           openSubtitlesScore(subtitle),
			Forced:          subtitle.Forced,
			HearingImpaired: subtitle.HearingImpaired,
			MachineOnly:     subtitle.MachineOnly,
			Release:         subtitle.Release,
			format:          subtitle.Format,
		})
	}
	return out
}

// openSubtitlesScore turns the provider's two numbers into the one Bazarr already gives,
// so a merged list can be ordered by a single figure that means roughly the same thing on
// every row: how much confidence there is in this file.
//
// The rating is the meaningful half and it is what a viewer would look at; the download
// count only breaks ties, because it measures age as much as quality — a subtitle uploaded
// last week for a film from 1994 cannot out-download one that has been there for years.
// A file nobody has rated is not a bad file, so it lands mid-scale rather than at the
// bottom, the same judgement `heroUnratedScore` makes.
func openSubtitlesScore(subtitle opensubtitles.Subtitle) int {
	score := 55
	if subtitle.Rating > 0 {
		score = int(subtitle.Rating * 10)
	}
	if subtitle.FromTrusted {
		score += 5
	}
	if subtitle.Downloads >= 1000 {
		score += 3
	}
	// A machine translation is a real answer and sometimes the only one, so it is offered
	// — below everything a person wrote, and saying so on its own row.
	if subtitle.MachineOnly {
		score -= 25
	}
	return clampPercent(score)
}

// resolveSubtitleTarget reads the item once and works out what each provider needs.
//
// Failure is per provider rather than for the whole request: a film Bazarr has never heard
// of may still have an imdb id, and a title with no provider id at all may still be in
// Bazarr's list. Only both failing is a failure.
func (s *Server) resolveSubtitleTarget(
	ctx context.Context, cred emby.Credentials, itemID string, sources subtitleSources,
) (subtitleTarget, error) {
	raw, err := s.emby.Item(ctx, cred, itemID,
		"ProductionYear,SeriesName,ParentIndexNumber,IndexNumber,ProviderIds")
	if err != nil {
		return subtitleTarget{}, err
	}
	var item struct {
		Name              string            `json:"Name"`
		Type              string            `json:"Type"`
		SeriesID          string            `json:"SeriesId"`
		SeriesName        string            `json:"SeriesName"`
		ProductionYear    int               `json:"ProductionYear"`
		ParentIndexNumber int               `json:"ParentIndexNumber"`
		IndexNumber       int               `json:"IndexNumber"`
		ProviderIDs       map[string]string `json:"ProviderIds"`
	}
	if err := json.Unmarshal(raw, &item); err != nil {
		return subtitleTarget{}, fmt.Errorf("unreadable item from emby: %w", err)
	}

	target := subtitleTarget{Title: strings.TrimSpace(item.Name)}
	episode := strings.EqualFold(item.Type, "Episode")
	if !episode && !strings.EqualFold(item.Type, "Movie") {
		return subtitleTarget{}, fmt.Errorf("subtitles cannot be fetched for a %q", item.Type)
	}

	if sources.Bazarr {
		if resolved, err := s.resolveBazarrTarget(ctx, cred, itemID); err == nil {
			target.bazarr, target.hasBazarr = resolved, true
			target.Title = resolved.Title
		} else {
			s.loggerFor(ctx).Debug("no bazarr target", "item", itemID, "error", err)
		}
	}
	if sources.OpenSubtitles {
		query := opensubtitles.Query{
			IMDBID: providerID(item.ProviderIDs, "imdb"),
			TMDBID: providerID(item.ProviderIDs, "tmdb"),
			Query:  strings.TrimSpace(item.Name),
			Type:   "movie",
		}
		if episode {
			query.Type = "episode"
			query.Season = item.ParentIndexNumber
			query.Episode = item.IndexNumber
			query.Query = strings.TrimSpace(item.SeriesName)
			// A show carries an id far more often than each of its episodes does, and the
			// API takes the series' id with a season and episode number, so the parent is
			// read whenever there is one to read.
			if item.SeriesID != "" {
				parents := s.itemProviderIDs(ctx, cred, item.SeriesID)
				query.ParentIMDBID = providerID(parents, "imdb")
				query.ParentTMDBID = providerID(parents, "tmdb")
			}
			if target.Title == "" || item.SeriesName != "" {
				target.Title = fmt.Sprintf("%s S%02dE%02d",
					item.SeriesName, item.ParentIndexNumber, item.IndexNumber)
			}
		}
		// searchParams answers nil for a query with no identity, which is the same rule
		// stated in one place; asking it here is what keeps this honest.
		target.query, target.hasQuery = query, opensubtitles.CanSearch(query)
	}

	if !target.hasBazarr && !target.hasQuery {
		return subtitleTarget{}, fmt.Errorf("no subtitle provider can identify %q", item.Name)
	}
	return target, nil
}

// itemProviderIDs reads one item's external ids. A failure is not fatal — it costs the
// episode its parent's identity and the search falls back to the title.
func (s *Server) itemProviderIDs(
	ctx context.Context, cred emby.Credentials, itemID string,
) map[string]string {
	raw, err := s.emby.Item(ctx, cred, itemID, "ProviderIds")
	if err != nil {
		return nil
	}
	var parsed struct {
		ProviderIDs map[string]string `json:"ProviderIds"`
	}
	if json.Unmarshal(raw, &parsed) != nil {
		return nil
	}
	return parsed.ProviderIDs
}

// fetchSubtitle carries out one candidate's download and reports what the viewer should be
// told. Which provider it goes to is the candidate's own `Source`; an unknown one is
// refused rather than guessed at, since guessing means handing one provider's opaque token
// to another.
func (s *Server) fetchSubtitle(
	ctx context.Context, cred emby.Credentials, itemID string,
	target subtitleTarget, candidate subtitleCandidate,
) (fetchedSubtitle, error) {
	switch candidate.Source {
	case store.SubtitleProviderOpenSubtitles:
		return s.fetchFromOpenSubtitles(ctx, itemID, candidate)
	case store.SubtitleProviderBazarr, "":
		return s.fetchFromBazarr(ctx, cred, itemID, target, candidate)
	default:
		return fetchedSubtitle{}, fmt.Errorf("unknown subtitle source %q", candidate.Source)
	}
}

// fetchedSubtitle is what a download produced. StoredID is set only by the provider that
// hands back bytes — it names the row the gateway now serves — and is what lets the
// response point the player at the new track rather than at whatever Emby happened to
// return in the same language.
type fetchedSubtitle struct {
	StoredID string
	// RefreshEmby is true for a provider that wrote a file Emby has not noticed. Bazarr
	// needs it; a subtitle the gateway serves itself does not, and refreshing anyway would
	// spend a couple of seconds of somebody's film waiting for nothing.
	RefreshEmby bool
}

func (s *Server) fetchFromBazarr(
	ctx context.Context, cred emby.Credentials, itemID string,
	target subtitleTarget, candidate subtitleCandidate,
) (fetchedSubtitle, error) {
	if s.bazarr == nil || !target.hasBazarr {
		return fetchedSubtitle{}, fmt.Errorf("bazarr cannot fetch for this title")
	}
	subtitle := bazarr.Subtitle{
		Language:        candidate.Language,
		Provider:        candidate.Provider,
		Token:           candidate.Token,
		Forced:          candidate.Forced,
		HearingImpaired: candidate.HearingImpaired,
		OriginalFormat:  candidate.OriginalFormat,
	}
	var err error
	if target.bazarr.EpisodeID > 0 {
		err = s.bazarr.DownloadEpisode(ctx, target.bazarr.SeriesID, target.bazarr.EpisodeID, subtitle)
	} else {
		err = s.bazarr.DownloadMovie(ctx, target.bazarr.RadarrID, subtitle)
	}
	if err != nil {
		return fetchedSubtitle{}, err
	}
	_ = cred // the refresh the caller makes needs it; the download does not.
	return fetchedSubtitle{RefreshEmby: true}, nil
}

// fetchFromOpenSubtitles is the half of this feature that is not Bazarr-shaped: the
// provider returns a file, the gateway keeps it, and it is served back as a sidecar. The
// bytes are stored before anything is reported as successful, because a download that
// spent the household's allowance and then lost the file is the worst outcome available.
func (s *Server) fetchFromOpenSubtitles(
	ctx context.Context, itemID string, candidate subtitleCandidate,
) (fetchedSubtitle, error) {
	client := s.openSubtitlesClient(ctx)
	if client == nil {
		return fetchedSubtitle{}, fmt.Errorf("opensubtitles is not configured")
	}
	fileID, err := strconv.Atoi(strings.TrimSpace(candidate.Token))
	if err != nil || fileID <= 0 {
		return fetchedSubtitle{}, fmt.Errorf("unusable opensubtitles file id")
	}
	name, content, err := client.Download(ctx, fileID)
	if err != nil {
		return fetchedSubtitle{}, err
	}
	format := candidate.format
	if format == "" {
		format = subtitleFormatFromName(name)
	}
	stored := store.DownloadedSubtitle{
		ID:              storedSubtitleID(itemID, candidate),
		ItemID:          itemID,
		Language:        candidate.Language,
		Label:           storedSubtitleLabel(candidate),
		Forced:          candidate.Forced,
		HearingImpaired: candidate.HearingImpaired,
		Format:          format,
		Provider:        store.SubtitleProviderOpenSubtitles,
		Content:         content,
	}
	if err := s.store.PutDownloadedSubtitle(ctx, stored); err != nil {
		return fetchedSubtitle{}, err
	}
	return fetchedSubtitle{StoredID: stored.ID}, nil
}

// storedSubtitleID names one file. It is derived from what was asked for rather than being
// random, so fetching the same language for the same title twice replaces the file instead
// of growing a second track a viewer has to tell apart by guessing.
func storedSubtitleID(itemID string, candidate subtitleCandidate) string {
	variant := "plain"
	switch {
	case candidate.Forced:
		variant = "forced"
	case candidate.HearingImpaired:
		variant = "sdh"
	}
	language := candidate.Language
	if language == "" {
		language = "und"
	}
	return strings.Join([]string{"gw", itemID, language, variant}, ":")
}

// storedSubtitleIDPrefix is what marks a track as one the gateway serves rather than one
// Emby knows about. The player matches on the id it was handed, so the two namespaces must
// not be able to collide: Emby's are stream indices, which are plain numbers.
const storedSubtitleIDPrefix = "gw:"

func isStoredSubtitleID(id string) bool {
	return strings.HasPrefix(id, storedSubtitleIDPrefix)
}

func storedSubtitleLabel(candidate subtitleCandidate) string {
	label := candidate.LanguageLabel
	if strings.TrimSpace(label) == "" {
		label = subtitleLanguageLabel(candidate.Language)
	}
	switch {
	case candidate.Forced:
		label += " · Forced"
	case candidate.HearingImpaired:
		label += " · Hearing impaired"
	}
	// Named for where it came from, because a viewer looking at a track list should be
	// able to see which one arrived a minute ago and which was always in the file.
	return label + " · Downloaded"
}

func subtitleFormatFromName(name string) string {
	if index := strings.LastIndex(name, "."); index >= 0 && index < len(name)-1 {
		switch extension := strings.ToLower(name[index+1:]); extension {
		case "srt", "vtt", "ass", "ssa":
			return extension
		}
	}
	return "srt"
}

func storedSubtitleMIME(format string) string {
	switch strings.ToLower(strings.TrimSpace(format)) {
	case "vtt":
		return "text/vtt"
	case "ass", "ssa":
		return "text/x-ssa"
	default:
		return "application/x-subrip"
	}
}

// storedSubtitlesFor turns what the gateway holds for an item into playable tracks.
//
// The URL is a path rather than an absolute address on purpose: the gateway does not
// reliably know its own externally reachable name, and the television does — it is talking
// to it. The client resolves a relative subtitle URL against the gateway it is signed into
// and appends its own token, exactly as it already does for artwork.
func (s *Server) storedSubtitlesFor(ctx context.Context, itemID string) []playableSubtitle {
	if s.store == nil {
		return nil
	}
	held, err := s.store.DownloadedSubtitlesFor(ctx, itemID)
	if err != nil {
		s.loggerFor(ctx).Warn("stored subtitles unavailable", "item", itemID, "error", err)
		return nil
	}
	out := make([]playableSubtitle, 0, len(held))
	for _, subtitle := range held {
		out = append(out, playableSubtitle{
			ID:                subtitle.ID,
			URL:               storedSubtitlePath(subtitle),
			MimeType:          storedSubtitleMIME(subtitle.Format),
			Language:          subtitle.Language,
			Label:             subtitle.Label,
			IsForced:          subtitle.Forced,
			IsHearingImpaired: subtitle.HearingImpaired,
			DeliveryMethod:    "External",
			Codec:             subtitle.Format,
		})
	}
	return out
}

// storedSubtitlePath is the route the file is served from. The extension is on the end
// because media3 sniffs one when a MIME type is missing or wrong, and a subtitle served
// from a path with no extension is the kind of thing that works on one decoder.
func storedSubtitlePath(subtitle store.DownloadedSubtitle) string {
	format := strings.ToLower(strings.TrimSpace(subtitle.Format))
	if format == "" {
		format = "srt"
	}
	return "/v1/subtitles/" + url.PathEscape(subtitle.ID) + "." + format
}

// handleStoredSubtitle serves one file the gateway fetched.
//
// It is authenticated like everything else under /v1 — the token arrives in the query
// string, the way artwork's does, because a media player fetching a sidecar sends no
// headers of Memby's. The response is immutable: an id names one fetch, and a re-fetch
// writes a new body under the same id only when a viewer deliberately downloads the same
// language again, so a long cache is right and a revalidation per playback is not.
func (s *Server) handleStoredSubtitle(w http.ResponseWriter, r *http.Request, _ store.Session) {
	name := r.PathValue("file")
	id := name
	if index := strings.LastIndex(name, "."); index > 0 {
		id = name[:index]
	}
	unescaped, err := url.PathUnescape(id)
	if err != nil || !isStoredSubtitleID(unescaped) {
		writeError(w, http.StatusNotFound, "unknown subtitle")
		return
	}
	subtitle, err := s.store.DownloadedSubtitle(r.Context(), unescaped)
	if err != nil {
		writeError(w, http.StatusNotFound, "unknown subtitle")
		return
	}
	w.Header().Set("Content-Type", storedSubtitleMIME(subtitle.Format))
	w.Header().Set("Content-Length", strconv.Itoa(len(subtitle.Content)))
	w.Header().Set("Cache-Control", "private, max-age=86400")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write(subtitle.Content)
}

// mergeSubtitleTracks puts the gateway's own tracks beside Emby's, dropping any it already
// covers.
//
// The overlap is real and it is the reason this is not an append: a subtitle fetched
// through Bazarr becomes an Emby track, and a household that has Bazarr on may still have
// fetched the same language here first. Two identically labelled rows in a drop-up is the
// kind of thing that makes a viewer distrust the whole menu, so where both exist Emby's
// wins — it is the one in the file, and it survives this gateway being replaced.
func mergeSubtitleTracks(embyTracks, stored []playableSubtitle) []playableSubtitle {
	if len(stored) == 0 {
		return embyTracks
	}
	covered := map[string]bool{}
	for _, track := range embyTracks {
		covered[subtitleVariantKey(track)] = true
	}
	out := embyTracks
	for _, track := range stored {
		if covered[subtitleVariantKey(track)] {
			continue
		}
		out = append(out, track)
	}
	return out
}

func subtitleVariantKey(track playableSubtitle) string {
	return fmt.Sprintf("%s|%t|%t",
		normalizeSubtitleLanguage(track.Language), track.IsForced, track.IsHearingImpaired)
}

// rankMergedCandidates orders what the viewer sees across both providers and caps the list.
//
// The viewer's language comes first, because it is the only thing they asked for. Within a
// language a plain track beats a forced or hearing-impaired one, for the reason the
// selection rule already gives — somebody who chose Italian wants the dialogue, not the
// signs — and a machine translation sinks below everything a person wrote. Only then does
// the score decide, so a provider cannot buy its way to the top of somebody's list with a
// confident number about the wrong language.
func rankMergedCandidates(found []subtitleCandidate, language string) []subtitleCandidate {
	preferred := normalizeSubtitleLanguage(language)
	if preferred == subtitleLanguageAuto {
		preferred = ""
	}
	ordered := make([]subtitleCandidate, len(found))
	copy(ordered, found)
	sort.SliceStable(ordered, func(i, j int) bool {
		left, right := ordered[i], ordered[j]
		leftPreferred := preferred != "" && left.Language == preferred
		rightPreferred := preferred != "" && right.Language == preferred
		if leftPreferred != rightPreferred {
			return leftPreferred
		}
		if left.MachineOnly != right.MachineOnly {
			return right.MachineOnly
		}
		if leftRank, rightRank := candidateVariantRank(left), candidateVariantRank(right); leftRank != rightRank {
			return leftRank < rightRank
		}
		return left.Score > right.Score
	})
	if len(ordered) > maxSubtitleResults {
		ordered = ordered[:maxSubtitleResults]
	}
	for i := range ordered {
		ordered[i].Label = mergedCandidateLabel(ordered[i])
	}
	return ordered
}

func candidateVariantRank(candidate subtitleCandidate) int {
	switch {
	case candidate.Forced:
		return 2
	case candidate.HearingImpaired:
		return 1
	default:
		return 0
	}
}

// mergedCandidateLabel is what one row says. It is composed here rather than on the
// television so an older app renders a new wording correctly, the same reason alert labels
// are the gateway's.
//
// The provider is named now, where the single-provider version deliberately did not: with
// two backends configured the same language appears twice and "which of these is which" is
// a question the row has to answer. A machine translation says so, because it is the one
// property of a subtitle that changes whether somebody wants it at all.
func mergedCandidateLabel(candidate subtitleCandidate) string {
	label := candidate.LanguageLabel
	if strings.TrimSpace(label) == "" {
		label = subtitleLanguageLabel(candidate.Language)
	}
	switch {
	case candidate.Forced:
		label += " · Forced"
	case candidate.HearingImpaired:
		label += " · Hearing impaired"
	}
	if candidate.MachineOnly {
		label += " · Machine translated"
	}
	if candidate.Score > 0 {
		label += fmt.Sprintf(" · %d%% match", clampPercent(candidate.Score))
	}
	return label
}

// subtitleFailureMessage turns what went wrong into the sentence printed over an empty
// list. A television has no log and no support channel, so this is the whole diagnosis —
// and the quota case is separated out because it is the only one where pressing the button
// again is definitely not the answer.
func subtitleFailureMessage(failures []error) string {
	for _, err := range failures {
		if _, ok := err.(*opensubtitles.QuotaError); ok {
			return "Today's subtitle downloads have been used up."
		}
	}
	if len(failures) > 0 {
		return "The subtitle service did not answer."
	}
	return "No subtitles were found for this release."
}
