package api

import "testing"

// The credits rule, pinned against the same cases as the television's copy (`CreditsTest`
// in app/src/test). The two exist separately because with no gateway there is nobody to
// ask, and the picture must not start shrinking at a different moment depending on whether
// the container is up — so when one of these changes, the other has to change with it.
//
// The cases come from a survey of a real 20,000-item library, and the shape of that survey is
// why the rule looks the way it does. Emby 4.10 wrote **no `CreditsStart` at all** — only
// `Chapter`, `IntroStart` and `IntroEnd` — while 216 items carried a chapter *named* like
// credits at 90–98% of runtime. Two of them carried "Opening Credits" at 0–1%, which is the
// case the position floor exists for and the one worth never regressing.

func named(seconds int64, name string) embyChapter {
	return embyChapter{
		StartPositionTicks: seconds * 1_000 * ticksPerMillisecond,
		MarkerType:         "Chapter",
		Name:               name,
	}
}

// A two-thousand-second episode, so a percentage of runtime reads as a round number.
const testRuntimeMs = 2_000_000

func TestCreditsFromChapters(t *testing.T) {
	cases := []struct {
		name      string
		chapters  []embyChapter
		runtimeMs int64
		want      int64
		ok        bool
	}{
		{
			// A real episode as the server holds it: chapters, the intro pair, more
			// chapters, and the credits marker near the end.
			name: "a real episode",
			chapters: []embyChapter{
				chapter(0, "Chapter"),
				chapter(463, "IntroStart"),
				chapter(583, "IntroEnd"),
				chapter(1200, "Chapter"),
				chapter(1900, "CreditsStart"),
			},
			want: 1_900_000,
			ok:   true,
		},
		{
			// A film, which commonly has the credits marker and no intro at all. This is
			// the case that would have been lost had the two features shared one flag.
			name: "credits with no intro",
			chapters: []embyChapter{
				chapter(0, "Chapter"),
				chapter(1400, "Chapter"),
				chapter(1850, "CreditsStart"),
			},
			want: 1_850_000,
			ok:   true,
		},
		{
			name:     "no markers at all",
			chapters: []embyChapter{chapter(0, "Chapter"), chapter(300, "Chapter")},
		},
		{
			name:     "no chapters at all",
			chapters: nil,
		},
		{
			// An intro pair is a different feature and must never be read as credits.
			name: "intro markers are not credits",
			chapters: []embyChapter{
				chapter(463, "IntroStart"),
				chapter(583, "IntroEnd"),
			},
		},
		{
			// A marker at zero says the whole file is credits, which is not something Emby
			// means and not something worth shrinking a picture for.
			name:     "a marker at the very beginning",
			chapters: []embyChapter{chapter(0, "CreditsStart"), chapter(300, "Chapter")},
		},
		{
			// The last marker wins, where the intro rule takes the first. Two starts mean
			// the markers are untrustworthy, and the two features are damaged in opposite
			// directions: an intro skip that fires late throws somebody past the story, so
			// the earlier marker is safer there; the credits pane firing early runs the last
			// scene past somebody at double speed, so the later marker is safer here.
			name: "two starts, the later one wins",
			chapters: []embyChapter{
				chapter(1700, "CreditsStart"),
				chapter(1900, "CreditsStart"),
			},
			want: 1_900_000,
			ok:   true,
		},
		{
			// Order in the array is not trusted to be sorted, so a later marker earlier in
			// the list still loses to the one further into the film.
			name: "the later marker wins whatever order they arrive in",
			chapters: []embyChapter{
				chapter(1900, "CreditsStart"),
				chapter(1700, "CreditsStart"),
			},
			want: 1_700_000,
			ok:   true,
		},
		{
			// Squid Game, as the library actually holds it: no marker of any kind, one
			// ordinary chapter named "Credits" at 90% of runtime. This is where every bit of
			// this feature's coverage comes from today.
			name: "a chapter named Credits, which is all Emby 4.10 gives",
			chapters: []embyChapter{
				chapter(0, "Chapter"),
				chapter(463, "IntroStart"),
				chapter(583, "IntroEnd"),
				named(1800, "Credits"),
			},
			want: 1_800_000,
			ok:   true,
		},
		{
			name:     "a chapter named End Credits",
			chapters: []embyChapter{named(1920, "End Credits")},
			want:     1_920_000,
			ok:       true,
		},
		{
			// THE case. Belfast carries "Opening Credits" at 1% of runtime and Game of Thrones
			// at 0%. Matching a name without testing the position starts the pane in the first
			// minute of a film and runs its opening at double speed — the worst thing this
			// feature could do, and the reason creditsMinimumPositionFraction exists.
			name: "Opening Credits at the start of a film is never the credit roll",
			chapters: []embyChapter{
				named(20, "Opening Credits"),
				chapter(600, "Chapter"),
			},
		},
		{
			// Belfast in full: both chapters present. The opening one must be rejected and the
			// closing one found, which the position floor does on its own.
			name: "an opening and a closing credit chapter in one film",
			chapters: []embyChapter{
				named(20, "Opening Credits"),
				chapter(600, "Chapter"),
				named(1900, "End Credits"),
			},
			want: 1_900_000,
			ok:   true,
		},
		{
			// "The Pitt" carries both, seconds apart, describing one roll. The *earliest*
			// qualifying chapter wins here — the opposite of the marker rule above — because
			// the roll begins at the first of them and taking the last would skip part of it.
			name: "two credits chapters describing one roll take the earlier",
			chapters: []embyChapter{
				named(1920, "End Credits"),
				named(1900, "Credits"),
			},
			want: 1_900_000,
			ok:   true,
		},
		{
			// Anything in the first three quarters is refused however it is worded. A position
			// test catches wordings nobody thought of; a list of words only catches the listed.
			name:     "a credits-named chapter too early to be the roll",
			chapters: []embyChapter{named(900, "Credits")},
		},
		{
			// An explicit marker outranks a name, and is honoured even with no runtime to
			// measure against: it is Emby asserting a position rather than this inferring one.
			name:      "a marker is honoured when the runtime is unknown",
			chapters:  []embyChapter{chapter(1900, "CreditsStart")},
			runtimeMs: -1,
			want:      1_900_000,
			ok:        true,
		},
		{
			// A name is not. Without a runtime there is no way to tell an opening credit
			// sequence from a closing one, and guessing is what this whole guard refuses.
			name:      "a name is refused when the runtime is unknown",
			chapters:  []embyChapter{named(1900, "End Credits")},
			runtimeMs: -1,
		},
		{
			// A marker below the floor is a mis-detection whoever wrote it, so it gives way to
			// a name that does qualify rather than being honoured on authority.
			name: "a marker below the floor falls through to a name that qualifies",
			chapters: []embyChapter{
				chapter(200, "CreditsStart"),
				named(1900, "End Credits"),
			},
			want: 1_900_000,
			ok:   true,
		},
		{
			// The intro's own chapters are named "Intro Start"/"Intro End" in this library, and
			// an episode whose titles run late must never have them read as a credit roll.
			name: "chapters named for the intro are never credits",
			chapters: []embyChapter{
				named(1800, "Intro Start"),
				named(1900, "Intro End"),
			},
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			// 0 means "the ordinary case, use the default"; -1 means "deliberately
			// unknown", which is a case of its own rather than an absent field.
			runtime := tc.runtimeMs
			switch runtime {
			case 0:
				runtime = testRuntimeMs
			case -1:
				runtime = 0
			}
			got, ok := creditsFromChapters(tc.chapters, runtime)
			if ok != tc.ok {
				t.Fatalf("available = %v, want %v (start %d)", ok, tc.ok, got)
			}
			if ok && got != tc.want {
				t.Fatalf("start = %d, want %d", got, tc.want)
			}
		})
	}
}

// The two halves of one reading are independent. A film with credits and no intro must not
// report an intro starting at zero, which is what a shared flag would have produced.
func TestMarkersResponseKeepsTheHalvesApart(t *testing.T) {
	response := markersResponse(chapterMarkers{creditsStart: 6_840_000, creditsFound: true})
	if response.Available || response.StartMs != 0 || response.EndMs != 0 {
		t.Fatalf("intro = %+v, want an absent intro on a title that has none", response)
	}
	if !response.CreditsAvailable || response.CreditsStartMs != 6_840_000 {
		t.Fatalf("credits = %+v, want the marker that was found", response)
	}

	response = markersResponse(chapterMarkers{
		intro: introSegment{StartMs: 463_000, EndMs: 583_000}, introFound: true,
	})
	if response.CreditsAvailable || response.CreditsStartMs != 0 {
		t.Fatalf("credits = %+v, want none on a title with only an intro", response)
	}
}

// An operator turning one feature off must not take the other with it, and must not poison
// the cache: masking happens on the way out, so the entry behind it still holds the truth.
func TestMaskMarkersWithholdsOnlyTheDisabledHalf(t *testing.T) {
	full := introResponse{
		Available: true, StartMs: 463_000, EndMs: 583_000,
		CreditsAvailable: true, CreditsStartMs: 2_704_000,
	}

	withoutIntro := maskMarkers(full, false, true)
	if withoutIntro.Available || withoutIntro.StartMs != 0 || withoutIntro.EndMs != 0 {
		t.Fatalf("intro = %+v, want it withheld", withoutIntro)
	}
	if !withoutIntro.CreditsAvailable || withoutIntro.CreditsStartMs != 2_704_000 {
		t.Fatal("turning the skip button off must not cost the credits pane as well")
	}

	withoutCredits := maskMarkers(full, true, false)
	if withoutCredits.CreditsAvailable || withoutCredits.CreditsStartMs != 0 {
		t.Fatalf("credits = %+v, want them withheld", withoutCredits)
	}
	if !withoutCredits.Available || withoutCredits.StartMs != 463_000 {
		t.Fatal("turning the credits pane off must not cost the skip button as well")
	}
}

// The toggle a television is told to obey has to be one this build knows.
func TestSpeedUpCreditsIsCatalogued(t *testing.T) {
	definition, ok := preferenceDefinitionFor("speedUpCredits")
	if !ok {
		t.Fatal("speedUpCredits is missing from the preference catalogue")
	}
	if definition.Kind != preferenceToggle {
		t.Fatalf("kind = %v, want a toggle", definition.Kind)
	}
	if definition.Default != true {
		t.Fatalf("default = %v, want true — the feature is that it happens unasked, and it "+
			"is visible and reversible in a way an automatic seek is not", definition.Default)
	}

	// An illegal value must come back as the default rather than reaching a player.
	normalised := normalizePreferences(map[string]any{"speedUpCredits": "sometimes"})
	if normalised["speedUpCredits"] != true {
		t.Fatalf("normalised = %v, want true", normalised["speedUpCredits"])
	}
}
