package api

import (
	"encoding/json"
	"errors"
	"net/http"
	"time"

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

// fieldsRelated adds Studios to the detail set: the explanation layer names the studio a
// viewer keeps returning to, and Emby omits it unless asked.
const fieldsRelated = fieldsDetail + ",Studios"

// relatedResponse is what the detail page renders: a strip of short reasons under the
// description, and the carousel beneath the page.
type relatedResponse struct {
	Reasons []string          `json:"reasons"`
	Items   []json.RawMessage `json:"items"`
}

// handleRelated explains one title to one viewer and lists what resembles it.
//
// Building the taste profile costs the same Emby fan-out the home rows pay for, so the
// whole answer is cached per user and item. It is deliberately *not* folded into
// `/v1/items/{id}`: that response is shared with the screensaver and the player, and this
// one is only ever needed once a detail page is open.
func (s *Server) handleRelated(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
	}
	key := cache.UserKey(sess.EmbyUserID, "related:v2:"+itemID)
	if raw, err := s.cache.Get(ctx, key); err == nil {
		w.Header().Set("X-Memby-Cache", "hit")
		writeRaw(w, http.StatusOK, raw)
		return
	}

	item, ok := s.relatedSubject(w, r, sess, itemID)
	if !ok {
		return
	}

	reasons, related, err := s.recommender.RelatedTo(ctx, credentials(sess), item, relatedRowSize)
	if err != nil {
		// RelatedTo degrades rather than failing, so the only error it returns is the
		// viewer having navigated on. That is the television saving work, not a fault.
		if expectedClientDisconnect(r, err) {
			s.loggerFor(ctx).Debug("related request abandoned", "item", itemID)
			return
		}
		s.writeUpstreamError(ctx, w, err, "could not load related titles")
		return
	}

	items := nonNilRaws(recommend.Raws(related))
	s.decorateItemRatings(ctx, items)
	body, err := json.Marshal(relatedResponse{
		Reasons: nonNilStrings(reasons),
		Items:   items,
	})
	if err != nil {
		writeError(w, http.StatusInternalServerError, "could not encode related titles")
		return
	}
	// An empty carousel is cached briefly rather than for the item lifetime: it usually
	// means something upstream was unwell, and the ten-minute answer would otherwise
	// outlive the minute of trouble that produced it.
	ttl := s.cfg.ItemTTL
	if len(items) == 0 {
		ttl = relatedEmptyTTL
	}
	if err := s.cache.Set(ctx, key, body, ttl); err != nil {
		s.loggerFor(ctx).Warn("related cache write failed", "error", err)
	}
	w.Header().Set("X-Memby-Cache", "miss")
	writeRaw(w, http.StatusOK, body)
}

// relatedRowSize is a carousel's worth. The strip scrolls, but a viewer who reaches the
// twelfth card has stopped looking for something like this one.
const relatedRowSize = 12

// relatedEmptyTTL keeps a carousel-less answer only long enough to stop a page that is
// being scrolled past from asking twice.
const relatedEmptyTTL = time.Minute

// relatedSubject resolves the title the page is about, and is the first place this
// endpoint refuses to fail: the imported catalogue holds the same payload Emby would
// have returned, so a detail page opened while Emby is unwell still gets its genres —
// which is all `genreNeighbours` needs to fill the strip from Postgres alone.
//
// It writes the response itself when there is nothing to answer with, and reports
// whether the caller should carry on.
func (s *Server) relatedSubject(
	w http.ResponseWriter, r *http.Request, sess store.Session, itemID string,
) (recommend.Item, bool) {
	ctx := r.Context()
	raw, err := s.emby.Item(ctx, credentials(sess), itemID, fieldsRelated)
	if err == nil {
		if decoded := recommend.Decode([]json.RawMessage{raw}); len(decoded) > 0 {
			return decoded[0], true
		}
	}

	// A rejected session is the viewer's problem to act on and must reach the TV as a
	// 401; an abandoned request is nobody's. Neither is worth a catalogue read.
	if err != nil {
		if expectedClientDisconnect(r, err) {
			s.loggerFor(ctx).Debug("related request abandoned", "item", itemID)
			return recommend.Item{}, false
		}
		var apiErr *emby.APIError
		if errors.As(err, &apiErr) &&
			(apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden) {
			writeError(w, http.StatusUnauthorized, "emby rejected the session")
			return recommend.Item{}, false
		}
	}

	if raws, storeErr := s.store.LibraryItemsByID(ctx, []string{itemID}); storeErr == nil {
		if decoded := recommend.Decode(raws); len(decoded) > 0 {
			s.loggerFor(ctx).Warn(
				"related item served from the imported catalogue", "item", itemID, "error", err,
			)
			return decoded[0], true
		}
	}

	if err == nil {
		writeError(w, http.StatusNotFound, "item not found")
		return recommend.Item{}, false
	}
	s.writeUpstreamError(ctx, w, err, "could not load the item")
	return recommend.Item{}, false
}

func nonNilRaws(values []json.RawMessage) []json.RawMessage {
	if values == nil {
		return []json.RawMessage{}
	}
	return values
}
