package subsync

import (
	"errors"
	"math/rand"
	"strings"
	"testing"
	"time"
)

// A film's worth of dialogue: irregularly spaced, varied line lengths, long gaps where
// nothing is said. Regular cues would make every shift correlate with every other and
// prove nothing about the search.
func dialogue(count int, seed int64) []Cue {
	rng := rand.New(rand.NewSource(seed))
	cues := make([]Cue, 0, count)
	at := 12 * time.Second
	for range count {
		length := time.Duration(900+rng.Intn(2600)) * time.Millisecond
		cues = append(cues, Cue{Start: at, End: at + length, Text: "line"})
		gap := time.Duration(400+rng.Intn(4000)) * time.Millisecond
		if rng.Intn(11) == 0 {
			gap += time.Duration(6+rng.Intn(25)) * time.Second // a scene with no dialogue
		}
		at += length + gap
	}
	return cues
}

func TestAlignRecoversAKnownOffset(t *testing.T) {
	reference := dialogue(400, 7)
	for _, offset := range []time.Duration{
		-42 * time.Second, -3500 * time.Millisecond, -700 * time.Millisecond,
		2 * time.Second, 11500 * time.Millisecond, 37 * time.Second,
	} {
		t.Run(offset.String(), func(t *testing.T) {
			// The broken track is the reference pushed the wrong way, so the correction
			// that fixes it is the opposite of what was applied.
			broken := Shift(reference, -offset, 1)

			got, err := Align(broken, reference, DefaultOptions())
			if err != nil {
				t.Fatalf("Align: %v", err)
			}
			if got.Scale != 1 {
				t.Fatalf("scale = %v, want 1 for a pure displacement", got.Scale)
			}
			if diff := (got.Offset - offset).Abs(); diff > 100*time.Millisecond {
				t.Fatalf("offset = %v, want %v (out by %v)", got.Offset, offset, diff)
			}
		})
	}
}

// The case a plain offset cannot fix: a track authored against one transfer and played
// against another runs further out as the film goes on.
func TestAlignRecoversAFrameRateStretch(t *testing.T) {
	reference := dialogue(500, 11)
	scale := 25.0 / 24
	broken := Shift(reference, 0, 1/scale)

	got, err := Align(broken, reference, DefaultOptions())
	if err != nil {
		t.Fatalf("Align: %v", err)
	}
	if got.Scale != scale {
		t.Fatalf("scale = %v, want %v", got.Scale, scale)
	}

	// The real test is not the reported numbers but whether applying them lands the last
	// cue where it belongs — a stretch that is right at the start and wrong at the end is
	// exactly the fault being repaired.
	fixed := Shift(broken, got.Offset, got.Scale)
	drift := (fixed[len(fixed)-1].Start - reference[len(reference)-1].Start).Abs()
	if drift > 200*time.Millisecond {
		t.Fatalf("last cue is out by %v after correction", drift)
	}
}

// Timing that survives a translation. Different languages break lines differently and
// their cues do not start on the same frame, so the alignment has to hold when the two
// tracks agree only roughly.
func TestAlignSurvivesATranslatedReference(t *testing.T) {
	reference := dialogue(400, 3)
	rng := rand.New(rand.NewSource(99))
	translated := make([]Cue, 0, len(reference))
	for i, cue := range reference {
		if i%9 == 0 {
			continue // a line the other track merged into its neighbour
		}
		jitter := time.Duration(rng.Intn(500)-250) * time.Millisecond
		translated = append(translated, Cue{
			Start: cue.Start + jitter,
			End:   cue.End + jitter + time.Duration(rng.Intn(600))*time.Millisecond,
			Text:  "ligne",
		})
	}

	offset := -8 * time.Second
	got, err := Align(Shift(translated, -offset, 1), reference, DefaultOptions())
	if err != nil {
		t.Fatalf("Align: %v", err)
	}
	if diff := (got.Offset - offset).Abs(); diff > 300*time.Millisecond {
		t.Fatalf("offset = %v, want %v", got.Offset, offset)
	}
}

// Most of this package's job is producing no answer. A wrong correction is worse than
// none: the viewer is told it worked and has no way to know the file is now further out.
func TestAlignRefusesRatherThanGuess(t *testing.T) {
	reference := dialogue(400, 5)

	t.Run("a different film", func(t *testing.T) {
		_, err := Align(dialogue(400, 6), reference, DefaultOptions())
		var refusal *ErrNoAlignment
		if !errors.As(err, &refusal) {
			t.Fatalf("err = %v, want a refusal", err)
		}
		if refusal.Reason == "" {
			t.Fatal("a refusal must carry something a viewer can read")
		}
	})

	t.Run("too few lines to judge", func(t *testing.T) {
		_, err := Align(dialogue(6, 1), reference, DefaultOptions())
		var refusal *ErrNoAlignment
		if !errors.As(err, &refusal) {
			t.Fatalf("err = %v, want a refusal", err)
		}
	})

	// Evenly spaced cues fit their own reference at every multiple of the spacing, so a
	// dozen shifts score alike and none of them is the answer. This is the case the margin
	// test exists for — the score alone is a perfect 1.0 and would sail through.
	t.Run("timing that fits equally well in several places", func(t *testing.T) {
		metronome := func(offset time.Duration) []Cue {
			cues := make([]Cue, 0, 60)
			for i := range 60 {
				at := offset + time.Duration(i)*4*time.Second
				cues = append(cues, Cue{Start: at, End: at + 2*time.Second, Text: "line"})
			}
			return cues
		}

		_, err := Align(metronome(9*time.Second), metronome(0), DefaultOptions())
		var refusal *ErrNoAlignment
		if !errors.As(err, &refusal) {
			t.Fatalf("err = %v, want a refusal", err)
		}
		if refusal.Score < 0.9 {
			t.Fatalf("score = %.2f — this case must be refused on margin, not on score",
				refusal.Score)
		}
	})
}

// A sparse track is not by itself a bad one. Taking every seventeenth line of a correct
// subtitle leaves something that still lines up in exactly one place, and refusing it
// would cost the fix for a forced or hearing-impaired track that is merely displaced.
// What disqualifies a sparse track is being the *reference*, which Reference handles.
func TestAlignAcceptsASparseButUnambiguousTrack(t *testing.T) {
	reference := dialogue(400, 5)
	sparse := []Cue{}
	for i, cue := range reference {
		if i%17 == 0 {
			sparse = append(sparse, cue)
		}
	}

	got, err := Align(Shift(sparse, 6*time.Second, 1), reference, DefaultOptions())
	if err != nil {
		t.Fatalf("Align: %v", err)
	}
	if diff := (got.Offset + 6*time.Second).Abs(); diff > 100*time.Millisecond {
		t.Fatalf("offset = %v, want -6s", got.Offset)
	}
}

// A track already in sync must come back as no correction rather than a small nudge that
// makes the file different for no reason.
func TestAlignLeavesACorrectTrackAlone(t *testing.T) {
	reference := dialogue(300, 21)
	got, err := Align(reference, reference, DefaultOptions())
	if err != nil {
		t.Fatalf("Align: %v", err)
	}
	if got.Correction() {
		t.Fatalf("a matching track reported a correction of %v", got)
	}
}

func TestShiftClampsRatherThanDroppingTheOpening(t *testing.T) {
	cues := []Cue{
		{Start: time.Second, End: 3 * time.Second, Text: "first"},
		{Start: 10 * time.Second, End: 12 * time.Second, Text: "second"},
	}
	got := Shift(cues, -30*time.Second, 1)
	if len(got) != 2 {
		t.Fatalf("cue count = %d, want 2 — no line may be lost", len(got))
	}
	if got[0].Start < 0 || got[0].End < 0 {
		t.Fatalf("negative timing survived: %+v", got[0])
	}
}

// Which track to measure against is the one choice this package cannot check, so the rules
// that make a track unusable as a yardstick are pinned.
func TestReferencePrefersTheFullestUnforcedTrack(t *testing.T) {
	candidates := [][]Cue{
		dialogue(30, 1),  // 0: the broken one
		dialogue(900, 2), // 1: forced, so unusable however long
		dialogue(400, 3), // 2: the answer
		dialogue(5, 4),   // 3: too short to judge anything by
	}
	forced := []bool{false, true, false, false}

	got, ok := Reference(candidates, 0, forced, 20)
	if !ok || got != 2 {
		t.Fatalf("reference = %d, ok = %v, want index 2", got, ok)
	}

	if _, ok := Reference(candidates[:1], 0, nil, 20); ok {
		t.Fatal("a title whose only track is the broken one has no reference")
	}
}

func TestParseReadsBothFormats(t *testing.T) {
	srt := "1\n00:00:12,500 --> 00:00:14,900\nHello there\n\n" +
		"2\n00:01:02,000 --> 00:01:04,250\nSecond line\nover two rows\n"
	vtt := "WEBVTT\n\n00:00:12.500 --> 00:00:14.900 align:start position:50%\nHello there\n\n" +
		"01:02.000 --> 01:04.250\nSecond line\nover two rows\n"

	for name, data := range map[string]string{"srt": srt, "vtt": vtt} {
		t.Run(name, func(t *testing.T) {
			cues, err := Parse([]byte(data))
			if err != nil {
				t.Fatalf("Parse: %v", err)
			}
			if len(cues) != 2 {
				t.Fatalf("cue count = %d, want 2", len(cues))
			}
			if cues[0].Start != 12500*time.Millisecond || cues[0].End != 14900*time.Millisecond {
				t.Fatalf("first cue = %+v", cues[0])
			}
			if cues[1].Start != 62*time.Second {
				t.Fatalf("second cue start = %v, want 1m2s", cues[1].Start)
			}
			if cues[1].Text != "Second line\nover two rows" {
				t.Fatalf("second cue text = %q", cues[1].Text)
			}
		})
	}
}

func TestParseHandlesFilesPeopleActuallyHave(t *testing.T) {
	t.Run("byte order mark and CRLF", func(t *testing.T) {
		data := "\ufeff1\r\n00:00:01,000 --> 00:00:02,000\r\nHi\r\n"
		cues, err := Parse([]byte(data))
		if err != nil || len(cues) != 1 || cues[0].Text != "Hi" {
			t.Fatalf("cues = %+v, err = %v", cues, err)
		}
	})

	t.Run("no blank line between cues", func(t *testing.T) {
		data := "1\n00:00:01,000 --> 00:00:02,000\nOne\n2\n00:00:03,000 --> 00:00:04,000\nTwo\n"
		cues, err := Parse([]byte(data))
		if err != nil {
			t.Fatalf("Parse: %v", err)
		}
		if len(cues) != 2 || cues[1].Start != 3*time.Second {
			t.Fatalf("cues = %+v", cues)
		}
	})

	t.Run("nothing that looks like a subtitle", func(t *testing.T) {
		if _, err := Parse([]byte("this is not a subtitle at all")); !errors.Is(err, ErrNoCues) {
			t.Fatalf("err = %v, want ErrNoCues", err)
		}
	})
}

func TestFormatSRTRoundTrips(t *testing.T) {
	cues := []Cue{
		{Start: 3661500 * time.Millisecond, End: 3663000 * time.Millisecond, Text: "Late in the film"},
		{Start: 12 * time.Second, End: 14 * time.Second, Text: "Two\nrows"},
	}
	out := FormatSRT(Normalise(cues))
	if !strings.Contains(string(out), "01:01:01,500 --> 01:01:03,000") {
		t.Fatalf("timestamps not written as SubRip:\n%s", out)
	}

	back, err := Parse(out)
	if err != nil {
		t.Fatalf("Parse: %v", err)
	}
	if len(back) != 2 || back[0].Start != 12*time.Second || back[1].Text != "Late in the film" {
		t.Fatalf("round trip changed the cues: %+v", back)
	}
}

// The search runs while somebody is looking at a menu over their film, so its cost is part
// of whether the feature is usable at all.
func BenchmarkAlignFeatureLength(b *testing.B) {
	reference := dialogue(1400, 1)
	broken := Shift(reference, -9*time.Second, 1)
	opts := DefaultOptions()
	b.ResetTimer()
	for range b.N {
		if _, err := Align(broken, reference, opts); err != nil {
			b.Fatal(err)
		}
	}
}
