package recommend

import (
	"context"
	"encoding/json"
	"errors"
	"io"
	"log/slog"
	"testing"

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

func relatedSubject() Item {
	decoded := Decode([]json.RawMessage{
		json.RawMessage(`{"Id":"subject","Name":"The Subject","Type":"Movie","Genres":["Thriller"]}`),
	})
	return decoded[0]
}

func relatedEngine(source Source) *Engine {
	engine := NewEngine(source, slog.New(slog.NewTextHandler(io.Discard, nil)))
	engine.MinRowItems = 2
	return engine
}

func relatedCatalogue(prefix string, count int) []json.RawMessage {
	out := make([]json.RawMessage, 0, count)
	for i := 0; i < count; i++ {
		id := prefix + string(rune('a'+i))
		out = append(out, json.RawMessage(
			`{"Id":"`+id+`","Name":"`+id+`","Type":"Movie","Genres":["Thriller"]}`,
		))
	}
	return out
}

func relatedIDs(items []Item) []string {
	out := make([]string, 0, len(items))
	for _, item := range items {
		out = append(out, item.ID)
	}
	return out
}

// The taste profile costs the same Emby fan-out the home rows pay for, and it is the
// half of this answer a detail page can do without. Losing it must cost the reasons, not
// the carousel — before this the whole request 502'd and the page said it had failed.
func TestRelatedSurvivesAProfileThatCannotBeBuilt(t *testing.T) {
	source := &fakeSource{
		itemsErr: errors.New("emby is unwell"),
		similar:  map[string][]json.RawMessage{"subject": relatedCatalogue("similar-", 4)},
	}

	reasons, related, err := relatedEngine(source).RelatedTo(
		context.Background(), emby.Credentials{UserID: "u1"}, relatedSubject(), 12,
	)
	if err != nil {
		t.Fatalf("expected a degraded answer, got error: %v", err)
	}
	if len(related) != 4 {
		t.Fatalf("expected Emby's similar list, got %v", relatedIDs(related))
	}
	for _, reason := range reasons {
		if reason == "" {
			t.Fatal("a reason must never be blank")
		}
	}
}

// A viewer who navigated on is not a fault, and continuing to fan out on their behalf
// spends Emby requests nobody is waiting for.
func TestRelatedReportsAnAbandonedRequest(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	source := &fakeSource{itemsErr: context.Canceled}
	_, _, err := relatedEngine(source).RelatedTo(
		ctx, emby.Credentials{UserID: "u1"}, relatedSubject(), 12,
	)
	if !errors.Is(err, context.Canceled) {
		t.Fatalf("expected the cancellation to be reported, got %v", err)
	}
}

// Emby erroring and Emby honestly knowing nothing similar look identical on a TV, so both
// fall through to the household's own catalogue in the same genres.
func TestRelatedFallsBackToTheImportedCatalogue(t *testing.T) {
	for name, source := range map[string]*fakeSource{
		"similar lookup failed": {similarErr: errors.New("emby is unwell")},
		"nothing similar known": {similar: map[string][]json.RawMessage{}},
	} {
		t.Run(name, func(t *testing.T) {
			engine := relatedEngine(source)
			engine.Library = &fakeForYouLibrary{items: relatedCatalogue("library-", 3)}

			_, related, err := engine.RelatedTo(
				context.Background(), emby.Credentials{UserID: "u1"}, relatedSubject(), 12,
			)
			if err != nil {
				t.Fatalf("the fallback must not fail: %v", err)
			}
			if len(related) != 3 {
				t.Fatalf("expected the genre pool, got %v", relatedIDs(related))
			}
		})
	}
}

// The catalogue always contains the title the page is about, and Emby's own similarity
// list occasionally does.
func TestRelatedNeverIncludesItsOwnSubject(t *testing.T) {
	subject := json.RawMessage(
		`{"Id":"subject","Name":"The Subject","Type":"Movie","Genres":["Thriller"]}`,
	)
	source := &fakeSource{similar: map[string][]json.RawMessage{
		"subject": append([]json.RawMessage{subject}, relatedCatalogue("similar-", 3)...),
	}}
	engine := relatedEngine(source)
	engine.Library = &fakeForYouLibrary{items: []json.RawMessage{subject}}

	_, related, err := engine.RelatedTo(
		context.Background(), emby.Credentials{UserID: "u1"}, relatedSubject(), 12,
	)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	for _, item := range related {
		if item.ID == "subject" {
			t.Fatalf("the page's own title was returned to it: %v", relatedIDs(related))
		}
	}
}

// With no imported catalogue there is nothing to fall back to, and an empty carousel is
// still an answer the page can open with.
func TestRelatedWithoutALibraryStillAnswers(t *testing.T) {
	source := &fakeSource{similarErr: errors.New("emby is unwell")}

	reasons, related, err := relatedEngine(source).RelatedTo(
		context.Background(), emby.Credentials{UserID: "u1"}, relatedSubject(), 12,
	)
	if err != nil {
		t.Fatalf("expected an empty answer, got error: %v", err)
	}
	if len(related) != 0 {
		t.Fatalf("expected nothing, got %v", relatedIDs(related))
	}
	if len(reasons) == 0 {
		t.Fatal("catalogue facts should still explain the title")
	}
}
