package api

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"image/jpeg"
	"net/http"
	"strconv"
	"strings"
	"time"

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

const (
	// trickplayWidth is the thumbnail width to ask Emby for. Emby generates preview images
	// at the widths its own settings name and answers any other with an empty file, so
	// this is not a free parameter: 320 is what Emby writes by default and the only width
	// present on this library. Asking for one it has not generated does not fail — it
	// returns a well-formed BIF with no frames, which reads as "this title has no
	// previews" and is indistinguishable from a title that genuinely has none.
	trickplayWidth = 320

	// trickplayIndexWindow is how much of the front of the file to read while looking for
	// the index. It covers a title of about twenty-two hours at ten seconds a frame, so in
	// practice one request settles it; anything longer costs a second, exact read rather
	// than being refused.
	trickplayIndexWindow = 64 << 10

	// trickplayIndexTTL keeps a parsed index warm for a day. The file only changes when
	// the media does, and every frame request needs it.
	trickplayIndexTTL = 24 * time.Hour

	// trickplayMissingTTL is how long "this title has no previews" is remembered. Shorter
	// than the index, because Emby generates thumbnails on a schedule: a film imported this
	// afternoon should not be answered from a day-old no.
	trickplayMissingTTL = time.Hour
)

// trickplayManifest is what a television needs to draw previews: how much of the title
// each thumbnail covers, how many there are, and what shape they are.
//
// Frame URLs are not listed. There are hundreds of them, they are formed by a rule the
// client already knows, and a list of them would be most of the response.
type trickplayManifest struct {
	Available  bool  `json:"available"`
	IntervalMs int64 `json:"intervalMs,omitempty"`
	Count      int   `json:"count,omitempty"`
	Width      int   `json:"width,omitempty"`
	Height     int   `json:"height,omitempty"`
}

// handleTrickplay answers whether a title has seek previews, and how they are laid out.
//
// It is deliberately its own request rather than a field on /v1/items/{id}/playback:
// reading the index costs a round trip to Emby, and the playback response is the single
// thing standing between a Play press and a decoder starting. The television asks for this
// once the first frame is up.
func (s *Server) handleTrickplay(w http.ResponseWriter, r *http.Request, sess store.Session) {
	itemID := r.PathValue("id")
	if itemID == "" {
		writeError(w, http.StatusBadRequest, "item id is required")
		return
	}
	if !s.trickplayEnabled(r.Context()) {
		writeJSON(w, http.StatusOK, trickplayManifest{})
		return
	}

	index, err := s.trickplayIndex(r.Context(), sess, itemID)
	if err != nil {
		// A title with no previews is the ordinary case and is answered above; reaching
		// here means Emby would not say. Answer "none" rather than an error: the seek
		// indicator has a perfectly good wordless form, and a failure the viewer cannot
		// act on is not worth a red line in the log every time somebody presses Right.
		s.loggerFor(r.Context()).Debug("trickplay index unavailable",
			"item_id", itemID, "error", err)
		writeJSON(w, http.StatusOK, trickplayManifest{})
		return
	}
	if !index.Available() {
		writeJSON(w, http.StatusOK, trickplayManifest{})
		return
	}
	// Previews are worth caching on the television for as long as the index is, and the
	// answer is the same for everyone in the house.
	w.Header().Set("Cache-Control", "private, max-age=3600")
	writeJSON(w, http.StatusOK, trickplayManifest{
		Available:  true,
		IntervalMs: index.IntervalMs,
		Count:      index.Count,
		Width:      index.Width,
		Height:     index.Height,
	})
}

// handleTrickplayFrame serves one thumbnail.
//
// The gateway reads the frame's byte range out of Emby's file and writes it on; it never
// holds the file and never re-encodes the image. A frame is about seven kilobytes, which
// is what makes a preview affordable while somebody is still moving the seek target.
func (s *Server) handleTrickplayFrame(w http.ResponseWriter, r *http.Request, sess store.Session) {
	itemID := r.PathValue("id")
	// The ".jpg" is for the benefit of anything downstream that sniffs a URL rather than a
	// content type — an image loader's disk cache, a proxy — and carries no meaning here.
	frame, err := strconv.Atoi(strings.TrimSuffix(r.PathValue("frame"), ".jpg"))
	if itemID == "" || err != nil || frame < 0 {
		writeError(w, http.StatusNotFound, "unknown frame")
		return
	}
	if !s.trickplayEnabled(r.Context()) {
		writeError(w, http.StatusNotFound, "unknown frame")
		return
	}

	index, err := s.trickplayIndex(r.Context(), sess, itemID)
	if err != nil {
		s.writeUpstreamError(r.Context(), w, err, "could not load the preview")
		return
	}
	start, end, ok := index.Frame(frame)
	if !ok {
		writeError(w, http.StatusNotFound, "unknown frame")
		return
	}

	body, err := s.emby.TrickplayBytes(
		r.Context(), credentials(sess), itemID, trickplayWidth, start, end-1,
	)
	if err != nil {
		s.writeUpstreamError(r.Context(), w, err, "could not load the preview")
		return
	}

	w.Header().Set("Content-Type", "image/jpeg")
	w.Header().Set("Content-Length", strconv.Itoa(len(body)))
	// A frame's bytes only change when the media is re-encoded, which also renumbers the
	// index, so this is safe to keep. It is not "immutable" like a tag-addressed image:
	// there is no tag in the URL to make a new file a new address.
	w.Header().Set("Cache-Control", "private, max-age=86400")
	w.WriteHeader(http.StatusOK)
	copyImage(w, r, bytes.NewReader(body), s.loggerFor(r.Context()),
		"source", "emby", "item_id", itemID, "frame", frame)
}

func (s *Server) trickplayEnabled(ctx context.Context) bool {
	return s.emby != nil && s.featureEnabled(ctx, featureTrickplay)
}

func trickplayIndexKey(itemID string) string {
	return "tp:v1:" + strconv.Itoa(trickplayWidth) + ":" + itemID
}

// trickplayIndex reads the index off the front of a title's BIF, remembering it.
//
// A title with no previews is cached too, as a zero-frame index. It is the common case in
// a library where thumbnails are still being generated, and without it every press of
// Right on such a title would be a fresh request to Emby for the same no.
func (s *Server) trickplayIndex(
	ctx context.Context, sess store.Session, itemID string,
) (*trickplay.Index, error) {
	key := trickplayIndexKey(itemID)
	if raw, err := s.cache.Get(ctx, key); err == nil {
		var cached trickplay.Index
		if json.Unmarshal(raw, &cached) == nil {
			return &cached, nil
		}
	}

	cred := credentials(sess)
	head, err := s.emby.TrickplayBytes(ctx, cred, itemID, trickplayWidth, 0, trickplayIndexWindow-1)
	if err != nil {
		return nil, err
	}
	index, err := trickplay.ParseIndex(head)
	if errors.Is(err, trickplay.ErrShort) {
		// A title long enough that its index runs past the window. Now the count is known,
		// so the second read is exact.
		count, _, headerErr := trickplay.ParseHeader(head)
		if headerErr != nil {
			return nil, headerErr
		}
		var full []byte
		full, err = s.emby.TrickplayBytes(
			ctx, cred, itemID, trickplayWidth, 0, int64(trickplay.IndexLength(count))-1,
		)
		if err != nil {
			return nil, err
		}
		index, err = trickplay.ParseIndex(full)
	}
	if err != nil {
		return nil, err
	}

	ttl := trickplayMissingTTL
	if index.Available() {
		ttl = trickplayIndexTTL
		// The frames' dimensions are not in the header, and the television needs them to
		// give the preview a place on screen before the first one has arrived — otherwise
		// the seek indicator grows a thumbnail-shaped hole mid-press. One frame is read to
		// find out, once per title per day.
		index.Width, index.Height = s.trickplayFrameSize(ctx, cred, itemID, index)
	}
	if raw, err := json.Marshal(index); err == nil {
		_ = s.cache.Set(ctx, key, raw, ttl)
	}
	return index, nil
}

// trickplayFrameSize reads the first thumbnail's dimensions.
//
// Only the JPEG's header is decoded, never its pixels. A zero pair is a perfectly usable
// answer — the client falls back to the aspect it draws by default — so a frame that will
// not parse costs the exact sizing and nothing else.
func (s *Server) trickplayFrameSize(
	ctx context.Context, cred emby.Credentials, itemID string, index *trickplay.Index,
) (int, int) {
	start, end, ok := index.Frame(0)
	if !ok {
		return 0, 0
	}
	body, err := s.emby.TrickplayBytes(ctx, cred, itemID, trickplayWidth, start, end-1)
	if err != nil {
		return 0, 0
	}
	config, err := jpeg.DecodeConfig(bytes.NewReader(body))
	if err != nil {
		return 0, 0
	}
	return config.Width, config.Height
}
