package api

import (
	"context"
	"encoding/json"
	"net/url"
	"strings"
	"time"

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

// Continue Watching and Next Up answer the same question — "what am I in the middle
// of?" — and splitting them across two rows meant a show moved between them the moment
// an episode ended. Finishing something is exactly when a viewer is most likely to want
// the next one, and that was the moment it left the first row on the launcher for one
// further down it. They are one row now.
//
// Merging them is not concatenation, because both lists are ordered by recency and the
// interesting case is a series that has just moved from one to the other. The order has
// to be "most recently watched first" across both, which needs a time for each card:
//
//   - A resume item carries its own UserData.LastPlayedDate.
//   - A Next Up episode carries nothing — it is unwatched, so its play date is zero. What
//     places it is when its *series* was last watched, which is what
//     recentlyPlayedSeries goes and asks for.
const (
	// How far back to look for the play that puts a Next Up episode in order. This is a
	// household's recent viewing, not its history: a series nobody has touched in this
	// many plays is not competing for the front of the row anyway.
	continuePlayLookback = 120
)

// recentlyPlayedSeries maps series id to the last time anything in it was played.
//
// It is one narrow, image-free list request beside the four the launcher already makes.
// Emby returns it most-recently-played first, so the first entry seen for a series is
// the one that counts.
func (s *Server) recentlyPlayedSeries(
	ctx context.Context,
	cred emby.Credentials,
) (map[string]time.Time, error) {
	result, err := s.emby.Items(ctx, cred, url.Values{
		"IncludeItemTypes":       {"Episode"},
		"Recursive":              {"true"},
		"Filters":                {"IsPlayed"},
		"SortBy":                 {"DatePlayed"},
		"SortOrder":              {"Descending"},
		"Limit":                  {itoa(continuePlayLookback)},
		"Fields":                 {"SeriesId"},
		"EnableImages":           {"false"},
		"EnableUserData":         {"true"},
		"EnableTotalRecordCount": {"false"},
	})
	if err != nil {
		return nil, err
	}
	played := make(map[string]time.Time, len(result.Items))
	for _, raw := range result.Items {
		seriesID, at, ok := seriesPlayedAt(raw)
		if !ok {
			continue
		}
		if _, seen := played[seriesID]; !seen {
			played[seriesID] = at
		}
	}
	return played, nil
}

type continueItem struct {
	raw   json.RawMessage
	at    time.Time
	dated bool
}

// mergeContinueWatching interleaves the resume list and the Next Up list into one row,
// most recently watched first.
//
// Two properties are load-bearing and pinned by tests. Each source keeps its own
// relative order — Emby's ordering within a list is the useful part and this must never
// re-rank it — so this is a merge of two sorted lists, never a sort of their union. And
// a card with no usable date never displaces one that has: an unknown time falls back to
// the resume list first, which is the half that is definitely in progress.
//
// A series present in both is represented once, by its resume item: somebody who is
// eleven minutes into an episode wants that episode, not the one after it.
func mergeContinueWatching(
	resume []json.RawMessage,
	nextUp []json.RawMessage,
	seriesPlayed map[string]time.Time,
) []json.RawMessage {
	seenItems := make(map[string]bool, len(resume))
	seenSeries := make(map[string]bool, len(resume))
	inProgress := make([]continueItem, 0, len(resume))
	for _, raw := range resume {
		id, seriesID, at, dated := continueItemFields(raw, seriesPlayed)
		if id != "" {
			seenItems[id] = true
		}
		if seriesID != "" {
			seenSeries[seriesID] = true
		}
		inProgress = append(inProgress, continueItem{raw: raw, at: at, dated: dated})
	}
	upNext := make([]continueItem, 0, len(nextUp))
	for _, raw := range nextUp {
		id, seriesID, at, dated := continueItemFields(raw, seriesPlayed)
		if (id != "" && seenItems[id]) || (seriesID != "" && seenSeries[seriesID]) {
			continue
		}
		if id != "" {
			seenItems[id] = true
		}
		if seriesID != "" {
			seenSeries[seriesID] = true
		}
		upNext = append(upNext, continueItem{raw: raw, at: at, dated: dated})
	}

	merged := make([]json.RawMessage, 0, len(inProgress)+len(upNext))
	left, right := 0, 0
	for left < len(inProgress) && right < len(upNext) {
		next := upNext[right]
		current := inProgress[left]
		if next.dated && (!current.dated || next.at.After(current.at)) {
			merged = append(merged, next.raw)
			right++
			continue
		}
		merged = append(merged, current.raw)
		left++
	}
	for ; left < len(inProgress); left++ {
		merged = append(merged, inProgress[left].raw)
	}
	for ; right < len(upNext); right++ {
		merged = append(merged, upNext[right].raw)
	}
	return merged
}

// continueItemFields reads the identity and the time that places one card. An episode is
// timed by its own play date where it has one and by its series' otherwise, which is what
// puts a freshly unlocked Next Up episode ahead of a resume from last week.
func continueItemFields(
	raw json.RawMessage,
	seriesPlayed map[string]time.Time,
) (id string, seriesID string, at time.Time, dated bool) {
	var item struct {
		ID       string `json:"Id"`
		SeriesID string `json:"SeriesId"`
		UserData struct {
			LastPlayedDate string `json:"LastPlayedDate"`
		} `json:"UserData"`
	}
	if json.Unmarshal(raw, &item) != nil {
		return "", "", time.Time{}, false
	}
	if parsed, ok := parsePlayedAt(item.UserData.LastPlayedDate); ok {
		return item.ID, item.SeriesID, parsed, true
	}
	if item.SeriesID != "" {
		if parsed, ok := seriesPlayed[item.SeriesID]; ok {
			return item.ID, item.SeriesID, parsed, true
		}
	}
	return item.ID, item.SeriesID, time.Time{}, false
}

func seriesPlayedAt(raw json.RawMessage) (string, time.Time, bool) {
	var item struct {
		SeriesID string `json:"SeriesId"`
		UserData struct {
			LastPlayedDate string `json:"LastPlayedDate"`
		} `json:"UserData"`
	}
	if json.Unmarshal(raw, &item) != nil || item.SeriesID == "" {
		return "", time.Time{}, false
	}
	at, ok := parsePlayedAt(item.UserData.LastPlayedDate)
	if !ok {
		return "", time.Time{}, false
	}
	return item.SeriesID, at, true
}

// parsePlayedAt reads Emby's play timestamps. It writes .NET-style seven-digit fractional
// seconds, which RFC 3339 parsing handles, but older records and some plugins write a
// bare "yyyy-MM-ddTHH:mm:ssZ" — an unparseable date must leave the card undated rather
// than dropping it out of the row.
func parsePlayedAt(value string) (time.Time, bool) {
	value = strings.TrimSpace(value)
	// Emby's "never played" sentinel. A real date it is not, and treating it as one would
	// order a card by a year nobody watched anything in.
	if value == "" || strings.HasPrefix(value, "0001-01-01") {
		return time.Time{}, false
	}
	for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05"} {
		if parsed, err := time.Parse(layout, value); err == nil {
			return parsed, true
		}
	}
	return time.Time{}, false
}
