package foryou

import (
	"bytes"
	"encoding/json"
	"testing"
	"time"

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

func TestRankPreparedForTimeMovesTypicalPosterAheadWithinRelevantPool(t *testing.T) {
	location := time.UTC
	contextProfile := recommend.NewContextAffinityProfile()
	comedyHistory := recommend.Item{Genres: []string{"Comedy"}}
	for i := 0; i < 5; i++ {
		contextProfile.Add(
			comedyHistory,
			time.Date(2026, 6, 29+i*7, 20, 0, 0, 0, location),
			1,
			i,
			location,
		)
	}
	rawContext, err := json.Marshal(contextProfile)
	if err != nil {
		t.Fatal(err)
	}
	items := []store.PreparedForYouItem{
		{
			ItemID: "drama", BaseRank: 1, BaseScore: 5,
			Payload:         json.RawMessage(`{"Id":"drama","Genres":["Drama"]}`),
			ContextAffinity: rawContext,
		},
		{
			ItemID: "comedy", BaseRank: 2, BaseScore: 4.8,
			Payload:              json.RawMessage(`{"Id":"comedy","Genres":["Comedy"]}`),
			RecommendationReason: "Matches your viewing",
			ContextAffinity:      rawContext,
		},
	}

	ranked := rankPreparedForTime(
		items,
		time.Date(2026, 7, 27, 20, 0, 0, 0, location),
		location,
	)
	if ranked[0].ItemID != "comedy" {
		t.Fatalf("first contextual poster = %q, want comedy", ranked[0].ItemID)
	}
	if ranked[0].RecommendationReason !=
		"Matches your viewing · fits what you watch around this time" {
		t.Fatalf("context reason = %q", ranked[0].RecommendationReason)
	}
}

func TestImportedSessionUsesStableKeyAndRecommendationFingerprint(t *testing.T) {
	session := tracearr.Session{
		ID: "session-1", MediaType: "movie", MediaTitle: "Arrival",
		ProgressMs: 500, TotalDurationMs: 1000,
	}
	session.User.ID = "user-1"
	session.User.Username = "Matt"

	first, ok := importedSession(session, "configured-server")
	if !ok {
		t.Fatal("session was rejected")
	}
	if first.ServerID != "configured-server" || first.SessionID != "session-1" {
		t.Fatalf("key = %q/%q", first.ServerID, first.SessionID)
	}
	second, _ := importedSession(session, "configured-server")
	if !bytes.Equal(first.SourceFingerprint, second.SourceFingerprint) {
		t.Fatal("unchanged session did not produce a stable fingerprint")
	}
	session.ProgressMs = 750
	updated, _ := importedSession(session, "configured-server")
	if bytes.Equal(first.SourceFingerprint, updated.SourceFingerprint) {
		t.Fatal("updated progress was not detected by the fingerprint")
	}
}

func TestTracearrSessionTerminalRequiresCompletedState(t *testing.T) {
	active := store.TracearrSession{State: "playing"}
	if tracearrSessionTerminal(active) {
		t.Fatal("active session was terminal")
	}
	stopped := active
	stopped.State = "stopped"
	if !tracearrSessionTerminal(stopped) {
		t.Fatal("stopped session was not terminal")
	}
	watched := active
	watched.Watched = true
	if !tracearrSessionTerminal(watched) {
		t.Fatal("watched session was not terminal")
	}
}

func TestImportDueMeasuresElapsedTimeRatherThanProcessUptime(t *testing.T) {
	now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
	at := func(d time.Duration) *time.Time {
		value := now.Add(-d)
		return &value
	}
	const incremental = 5 * time.Minute
	const full = 24 * time.Hour

	for _, testCase := range []struct {
		name     string
		state    store.TracearrImportState
		wantFull bool
		wantDue  bool
		wantSkip bool
	}{
		{
			name:  "never imported runs a full pass",
			state: store.TracearrImportState{}, wantFull: true, wantDue: true,
		},
		{
			// The restart case: without persisted stamps every boot re-imported.
			name: "recent import is not repeated after a restart",
			state: store.TracearrImportState{
				LastIncrementalAt: at(30 * time.Second), LastFullAt: at(2 * time.Hour),
			},
			wantSkip: true,
		},
		{
			name: "elapsed incremental interval is due",
			state: store.TracearrImportState{
				LastIncrementalAt: at(6 * time.Minute), LastFullAt: at(2 * time.Hour),
			},
			wantDue: true,
		},
		{
			// A gateway restarted daily never reconciled: the full ticker restarted too.
			name: "elapsed full interval wins over the incremental one",
			state: store.TracearrImportState{
				LastIncrementalAt: at(6 * time.Minute), LastFullAt: at(25 * time.Hour),
			},
			wantFull: true, wantDue: true,
		},
		{
			name: "a stamp in the future does not strand the importer",
			state: store.TracearrImportState{
				LastIncrementalAt: at(-time.Hour), LastFullAt: at(-time.Hour),
			},
			wantFull: true, wantDue: true,
		},
	} {
		t.Run(testCase.name, func(t *testing.T) {
			gotFull, gotDue := importDue(testCase.state, now, incremental, full)
			if testCase.wantSkip {
				if gotDue {
					t.Fatal("import ran when none was owed")
				}
				return
			}
			if gotDue != testCase.wantDue || gotFull != testCase.wantFull {
				t.Fatalf("full=%v due=%v, want full=%v due=%v",
					gotFull, gotDue, testCase.wantFull, testCase.wantDue)
			}
		})
	}
}

func TestImportDueRespectsDisabledIntervals(t *testing.T) {
	now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
	if _, due := importDue(store.TracearrImportState{}, now, 0, 0); due {
		t.Fatal("import was due with scheduling turned off")
	}
	stale := now.Add(-48 * time.Hour)
	state := store.TracearrImportState{LastIncrementalAt: &stale, LastFullAt: &stale}
	full, due := importDue(state, now, 5*time.Minute, 0)
	if !due || full {
		t.Fatalf("full=%v due=%v, want an incremental pass with full reconciliation off",
			full, due)
	}
}

func TestNextDailyRebuildUsesConfiguredLocalHour(t *testing.T) {
	location := time.FixedZone("NZST", 12*60*60)
	now := time.Date(2026, 7, 31, 5, 30, 0, 0, location)
	next := nextDailyRebuild(now, location, 4)
	want := time.Date(2026, 8, 1, 4, 0, 0, 0, location)
	if !next.Equal(want) {
		t.Fatalf("next rebuild = %v, want %v", next, want)
	}
}

func TestStoredResultCapsCandidatePoolAndPersistsAlgorithmVersion(t *testing.T) {
	result := recommend.PreparedResult{
		Candidates: make([]recommend.PreparedCandidate, maxPreparedCandidates+50),
	}
	for i := range result.Candidates {
		result.Candidates[i].ItemID = itoaForTest(i + 1)
	}
	profile, candidates, err := storedResult("user", time.Now(), result)
	if err != nil {
		t.Fatal(err)
	}
	if len(candidates) != maxPreparedCandidates {
		t.Fatalf("candidate count = %d, want %d", len(candidates), maxPreparedCandidates)
	}
	if profile.AlgorithmVersion != preparedAlgorithmVersion {
		t.Fatalf("algorithm version = %q", profile.AlgorithmVersion)
	}
}

func TestBuildPreparedRowsUsesMultipleSourcesAndDeduplicatesTitles(t *testing.T) {
	items := make([]store.PreparedForYouItem, 0, 120)
	for i := 0; i < 120; i++ {
		sourceID, sourceTitle := "six-feet-under", "Six Feet Under"
		genre := "Drama"
		if i%2 == 1 {
			sourceID, sourceTitle = "arrival", "Arrival"
			genre = "Science Fiction"
		}
		id := "item-" + itoaForTest(i)
		items = append(items, store.PreparedForYouItem{
			ItemID: id, BaseRank: i + 1,
			Payload:              json.RawMessage(`{"Id":"` + id + `"}`),
			CompatibilityScore:   0.8,
			CompatibilityLabel:   "Direct plays well on this TV",
			RecommendationReason: "Because you finished " + sourceTitle,
			ReasonGenre:          genre, ReasonSourceItemID: sourceID,
			ReasonSourceTitle: sourceTitle,
		})
	}

	rows := buildPreparedRows(items, 0, 4)
	if len(rows) < 4 {
		t.Fatalf("expected several prepared rows, got %d", len(rows))
	}
	titles := map[string]bool{}
	itemIDs := map[string]bool{}
	for _, row := range rows {
		titles[row.Title] = true
		for _, raw := range row.Items {
			var item struct {
				ID string `json:"Id"`
			}
			if err := json.Unmarshal(raw, &item); err != nil {
				t.Fatal(err)
			}
			if itemIDs[item.ID] {
				t.Fatalf("item %q appeared in more than one row", item.ID)
			}
			itemIDs[item.ID] = true
		}
	}
	if !titles["Because you finished Six Feet Under"] ||
		!titles["Because you finished Arrival"] {
		t.Fatalf("completed-title rows = %+v", titles)
	}
}

func TestBuildPreparedRowsKeepsASingleGenuinePickup(t *testing.T) {
	rows := buildPreparedRows([]store.PreparedForYouItem{
		{
			ItemID: "show-1", BaseRank: 1,
			Payload:              json.RawMessage(`{"Id":"show-1"}`),
			ReasonKind:           "pick-up",
			RecommendationReason: "You left this in season 1 · pick it up again",
		},
		{
			ItemID: "show-2", BaseRank: 2,
			Payload:              json.RawMessage(`{"Id":"show-2"}`),
			ReasonKind:           "pick-up",
			RecommendationReason: "You made it through season 2 · season 3 is waiting",
		},
	}, 0, 4)

	if len(rows) != 1 || rows[0].ID != "for-you:pick-up" ||
		rows[0].Title != "Pick this show up again" || len(rows[0].Items) != 1 {
		t.Fatalf("pickup rows = %+v", rows)
	}
}

func itoaForTest(value int) string {
	if value == 0 {
		return "0"
	}
	var digits [20]byte
	position := len(digits)
	for value > 0 {
		position--
		digits[position] = byte('0' + value%10)
		value /= 10
	}
	return string(digits[position:])
}
