package api

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

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

// Fixing a subtitle's timing.
//
// The whole of the work is in internal/subsync, which is pure. What lives here is the part
// that cannot be: choosing which track to measure against, reading both of them out of
// wherever they happen to live, and storing the result as an ordinary sidecar so it is a
// track on every later playback rather than something that exists only in this response.
//
// It is deliberately a separate route from the download one. A viewer whose subtitle is
// out of sync already *has* the file they want, and sending them through a provider search
// to get another copy of it — which may be equally out — is the wrong answer to what they
// asked.

// subtitleFixSuffix marks the repaired copy. It is part of the stored id, so fixing the
// same track twice replaces the earlier attempt instead of growing a third row that a
// viewer has to tell apart from the other two by guessing.
const subtitleFixSuffix = "fixed"

type subtitleFixRequest struct {
	SubtitleID string `json:"subtitleId"`
}

type subtitleFixResponse struct {
	// SubtitleID is the new track to select, absent when nothing needed changing.
	SubtitleID string `json:"subtitleId,omitempty"`
	Message    string `json:"message"`
	// Changed is false when the track was already in sync. It is on the wire rather than
	// inferred from an empty id because "already right" is a success a viewer should be
	// told about plainly, not a silent no-op that reads as the button having failed.
	Changed bool `json:"changed"`
	// OffsetMs and Reference are what was done and what it was judged against. A viewer
	// deciding whether to trust the result needs both.
	OffsetMs  int64  `json:"offsetMs"`
	Reference string `json:"reference,omitempty"`
}

// subtitleFixAvailable answers the smaller question the playback response needs: whether
// opening the timing action can lead anywhere for this title. The handler remains
// authoritative because a track can disappear or fail to parse between playback and the
// press, but not advertising an impossible action avoids a guaranteed refusal in the menu.
func (s *Server) subtitleFixAvailable(tracks []playableSubtitle) bool {
	if s.store == nil {
		return false
	}
	for _, target := range tracks {
		if len(referenceCandidates(target, tracks)) > 0 {
			return true
		}
	}
	return false
}

func (s *Server) handleSubtitleFix(w http.ResponseWriter, r *http.Request, sess store.Session) {
	ctx := r.Context()
	itemID := r.PathValue("id")
	if s.store == nil {
		writeError(w, http.StatusServiceUnavailable, "this server cannot fix subtitle timing")
		return
	}

	var req subtitleFixRequest
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "invalid subtitle fix request")
		return
	}
	if strings.TrimSpace(req.SubtitleID) == "" {
		writeError(w, http.StatusBadRequest, "no subtitle was named")
		return
	}

	cred := credentials(sess)
	tracks, mediaSourceID, _, _, _ := s.playbackSubtitles(
		ctx, cred, itemID, 0, nil, "", false, sessionPlaybackCapabilities(sess),
	)

	target, ok := trackByID(tracks, req.SubtitleID)
	if !ok {
		writeError(w, http.StatusNotFound, "that subtitle is no longer on this title")
		return
	}

	log := s.loggerFor(ctx)
	fixed, err := s.fixSubtitleTiming(ctx, cred, itemID, mediaSourceID, target, tracks)
	if err != nil {
		var refusal *subsync.ErrNoAlignment
		if errors.As(err, &refusal) {
			// A refusal is the ordinary answer, not a fault: most of subsync's job is
			// declining to guess. It is reported as a 200 carrying the reason, because
			// the viewer needs the sentence and an error status would have the client
			// print its own generic one over the top of it.
			log.Info("subtitle timing not fixed", "item_id", itemID,
				"subtitle", req.SubtitleID, "reason", refusal.Reason,
				"score", refusal.Score, "margin", refusal.Margin)
			writeJSON(w, http.StatusOK, subtitleFixResponse{Message: refusal.Reason})
			return
		}
		log.Warn("subtitle fix failed", "item_id", itemID, "subtitle", req.SubtitleID, "error", err)
		writeError(w, http.StatusBadGateway, subtitleFixFailureMessage(err))
		return
	}

	log.Info("subtitle timing fixed", "item_id", itemID, "subtitle", req.SubtitleID,
		"reference", fixed.Reference, "correction", fixed.Correction,
		"score", fixed.Score, "changed", fixed.Changed)
	writeJSON(w, http.StatusOK, fixed.response())
}

type subtitleFixOutcome struct {
	StoredID   string
	Reference  string
	Correction string
	OffsetMs   int64
	Score      float64
	Changed    bool
}

func (o subtitleFixOutcome) response() subtitleFixResponse {
	if !o.Changed {
		return subtitleFixResponse{
			Message:   "This subtitle is already in time with " + o.Reference + ".",
			Reference: o.Reference,
		}
	}
	return subtitleFixResponse{
		SubtitleID: o.StoredID,
		Message: fmt.Sprintf("Timing corrected by %s against %s. The fixed copy is in the list.",
			o.Correction, o.Reference),
		Changed:   true,
		OffsetMs:  o.OffsetMs,
		Reference: o.Reference,
	}
}

// fixSubtitleTiming reads both tracks, aligns them, and stores the result.
func (s *Server) fixSubtitleTiming(
	ctx context.Context, cred emby.Credentials, itemID, mediaSourceID string,
	target playableSubtitle, tracks []playableSubtitle,
) (subtitleFixOutcome, error) {
	brokenRaw, err := s.subtitleContent(ctx, cred, itemID, mediaSourceID, target)
	if err != nil {
		return subtitleFixOutcome{}, fmt.Errorf("read the subtitle being fixed: %w", err)
	}
	broken, err := subsync.Parse(brokenRaw)
	if err != nil {
		return subtitleFixOutcome{}, fmt.Errorf("parse the subtitle being fixed: %w", err)
	}

	reference, referenceTrack, err := s.referenceTrack(ctx, cred, itemID, mediaSourceID, target, tracks)
	if err != nil {
		return subtitleFixOutcome{}, err
	}

	opts := subsync.DefaultOptions()
	result, err := subsync.Align(broken, reference, opts)
	if err != nil {
		return subtitleFixOutcome{}, err
	}

	outcome := subtitleFixOutcome{
		Reference:  subtitleTrackName(referenceTrack),
		Correction: result.String(),
		OffsetMs:   result.Offset.Milliseconds(),
		Score:      result.Score,
		Changed:    result.Correction(),
	}
	if !outcome.Changed {
		return outcome, nil
	}

	stored := store.DownloadedSubtitle{
		ID:              fixedSubtitleID(itemID, target),
		ItemID:          itemID,
		Language:        target.Language,
		Label:           fixedSubtitleLabel(target),
		Forced:          target.IsForced,
		HearingImpaired: target.IsHearingImpaired,
		Format:          "srt",
		Provider:        store.SubtitleProviderMemby,
		Content:         subsync.FormatSRT(subsync.Shift(broken, result.Offset, result.Scale)),
	}
	if err := s.store.PutDownloadedSubtitle(ctx, stored); err != nil {
		return subtitleFixOutcome{}, fmt.Errorf("store the fixed subtitle: %w", err)
	}
	outcome.StoredID = stored.ID
	return outcome, nil
}

// referenceTrack picks and reads the yardstick.
//
// Reading is the expensive half — an embedded track is an Emby request each — so the
// candidates are read lazily in the order subsync.Reference would rank them, and the first
// one that parses wins. A track that will not parse is simply not a reference; failing the
// whole fix because the third-choice yardstick is malformed would be perverse.
func (s *Server) referenceTrack(
	ctx context.Context, cred emby.Credentials, itemID, mediaSourceID string,
	target playableSubtitle, tracks []playableSubtitle,
) ([]subsync.Cue, playableSubtitle, error) {
	candidates := referenceCandidates(target, tracks)
	if len(candidates) == 0 {
		return nil, playableSubtitle{}, &subsync.ErrNoAlignment{
			Reason: "there is no other subtitle on this title to check the timing against",
		}
	}
	for _, candidate := range candidates {
		raw, err := s.subtitleContent(ctx, cred, itemID, mediaSourceID, candidate)
		if err != nil {
			s.loggerFor(ctx).Debug("reference subtitle unreadable",
				"item_id", itemID, "subtitle", candidate.ID, "error", err)
			continue
		}
		cues, err := subsync.Parse(raw)
		if err != nil || len(cues) < subsync.DefaultOptions().MinCues {
			continue
		}
		return cues, candidate, nil
	}
	return nil, playableSubtitle{}, &subsync.ErrNoAlignment{
		Reason: "none of the other subtitles on this title could be read as a timing reference",
	}
}

// referenceCandidates orders the tracks worth measuring against, best first.
//
// The rules are about what makes a usable yardstick rather than a good subtitle. A forced
// track carries only what is foreign to the film's own audio, so it is mostly silence and
// would agree with almost any shift — it is excluded rather than ranked last. A track the
// gateway already fixed is preferred, since it has been checked against something. After
// that, a track in the same language is the closest match in line breaks and therefore in
// timing, and an embedded track outranks a downloaded one because it shipped with this
// copy of the film.
func referenceCandidates(target playableSubtitle, tracks []playableSubtitle) []playableSubtitle {
	out := make([]playableSubtitle, 0, len(tracks))
	for _, track := range tracks {
		if track.ID == target.ID || track.IsForced {
			continue
		}
		if !isStoredSubtitleID(track.ID) && track.URL == "" {
			// An embedded track Emby will not deliver as text: it is burned in or needs a
			// transcode, and there is nothing to read.
			continue
		}
		out = append(out, track)
	}
	rank := func(track playableSubtitle) int {
		switch {
		case strings.HasSuffix(track.ID, ":"+subtitleFixSuffix):
			return 0
		case target.Language != "" && strings.EqualFold(track.Language, target.Language):
			return 1
		case !isStoredSubtitleID(track.ID):
			return 2
		default:
			return 3
		}
	}
	// A stable sort, so tracks of equal rank keep the order Emby listed them in — which
	// puts the default track first, and the default is usually the one that is right.
	sort.SliceStable(out, func(i, j int) bool { return rank(out[i]) < rank(out[j]) })
	return out
}

// subtitleContent reads a track from wherever it lives: the gateway's own table for one it
// fetched, Emby for one that came with the film.
func (s *Server) subtitleContent(
	ctx context.Context, cred emby.Credentials, itemID, mediaSourceID string,
	track playableSubtitle,
) ([]byte, error) {
	if isStoredSubtitleID(track.ID) {
		stored, err := s.store.DownloadedSubtitle(ctx, track.ID)
		if err != nil {
			return nil, err
		}
		return stored.Content, nil
	}
	index, err := strconv.Atoi(track.ID)
	if err != nil {
		return nil, fmt.Errorf("subtitle id %q is not an Emby stream index", track.ID)
	}
	return s.emby.SubtitleBytes(ctx, cred, itemID, mediaSourceID, index)
}

func trackByID(tracks []playableSubtitle, id string) (playableSubtitle, bool) {
	for _, track := range tracks {
		if track.ID == id {
			return track, true
		}
	}
	return playableSubtitle{}, false
}

// fixedSubtitleID keeps the repaired copy in the gateway's own namespace and distinct from
// a downloaded file for the same language, so fixing a subtitle never overwrites the one it
// was made from — a correction can be wrong, and the original has to still be there.
func fixedSubtitleID(itemID string, target playableSubtitle) string {
	language := target.Language
	if language == "" {
		language = "und"
	}
	return strings.Join([]string{
		"gw", itemID, language, subtitleFixSuffix, sanitiseIDPart(target.ID),
	}, ":")
}

// sanitiseIDPart keeps a source track's id usable inside another id. A stored track's own
// id already contains colons, and nesting them would make the parts ambiguous.
func sanitiseIDPart(id string) string {
	return strings.ReplaceAll(strings.TrimPrefix(id, storedSubtitleIDPrefix), ":", "-")
}

func fixedSubtitleLabel(target playableSubtitle) string {
	label := strings.TrimSpace(target.Label)
	if label == "" {
		label = subtitleLanguageLabel(target.Language)
	}
	// Named rather than silently substituted: this track sits in the menu beside the one
	// it was made from, and the two are otherwise indistinguishable.
	return label + " (timing fixed)"
}

func subtitleTrackName(track playableSubtitle) string {
	if label := strings.TrimSpace(track.Label); label != "" {
		return label
	}
	if language := subtitleLanguageLabel(track.Language); language != "" {
		return language
	}
	return "another subtitle"
}

// subtitleFixFailureMessage is the whole diagnosis. A television has no log and no support
// channel, so the sentence has to say what happened and whether pressing again would help.
func subtitleFixFailureMessage(err error) string {
	switch {
	case errors.Is(err, subsync.ErrNoCues):
		return "That subtitle could not be read, so its timing cannot be fixed."
	case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled):
		return "Fixing the timing took too long. Try again."
	default:
		return "The timing could not be fixed just now."
	}
}
