package main

import (
	"bytes"
	"math/big"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------

const hdr = `{"version":1,"command":["demo.sh"],"started_at":"2026-01-01T00:00:00Z","shell":"/bin/sh","width":80,"height":24,"title":"fixture"}`

// castText assembles a cast file body from event lines and an optional footer.
func castText(footer string, events ...string) string {
	var b strings.Builder
	b.WriteString(hdr)
	b.WriteByte('\n')
	for _, e := range events {
		b.WriteString(e)
		b.WriteByte('\n')
	}
	if footer != "" {
		b.WriteString(footer)
		b.WriteByte('\n')
	}
	return b.String()
}

// evenCast has one event per second from 0 to n-1 inclusive and a footer that
// says the recording ran for exactly n-1 seconds.
func evenCast(n int) string {
	var evs []string
	for i := 0; i < n; i++ {
		evs = append(evs, `[`+formatMicros(int64(i)*microsPerSecond)+`, "o", "line`+string(rune('a'+i))+`\n"]`)
	}
	return castText(`{"exit_code":0,"duration":`+formatMicros(int64(n-1)*microsPerSecond)+`}`, evs...)
}

func mustRead(t *testing.T, text string) *cast {
	t.Helper()
	c, err := readCast(strings.NewReader(text), "test.jsonl")
	if err != nil {
		t.Fatalf("readCast: %v", err)
	}
	return c
}

func mustEDL(t *testing.T, text string) *edl {
	t.Helper()
	e, err := parseEDL([]byte(text))
	if err != nil {
		t.Fatalf("parseEDL(%q): %v", text, err)
	}
	if err := e.validate(); err != nil {
		t.Fatalf("validate(%q): %v", text, err)
	}
	return e
}

func mustApply(t *testing.T, castBody, edlText string) *editResult {
	t.Helper()
	res, err := applyEDL(mustRead(t, castBody), mustEDL(t, edlText))
	if err != nil {
		t.Fatalf("applyEDL(%q): %v", edlText, err)
	}
	return res
}

func writeString(t *testing.T, c *cast) string {
	t.Helper()
	var buf bytes.Buffer
	if err := writeCast(&buf, c); err != nil {
		t.Fatalf("writeCast: %v", err)
	}
	return buf.String()
}

// ---------------------------------------------------------------------------
// 1. Lossless round trip
// ---------------------------------------------------------------------------

func TestRoundTripIsByteIdentical(t *testing.T) {
	tests := []struct {
		name string
		body string
	}{
		{"plain", castText(`{"exit_code":0,"duration":3.004391}`,
			`[0.001227, "o", "first\n"]`,
			`[1.002926, "o", "second\n"]`,
			`[3.004161, "o", "third\n"]`)},
		{"no footer", castText("",
			`[0.000000, "o", "a"]`,
			`[0.500000, "e", "b"]`)},
		{"stderr stream", castText(`{"exit_code":1,"duration":1.000000}`,
			`[0.000000, "e", "boom\n"]`)},
		{"base64 payload", castText(`{"exit_code":0,"duration":0.010000}`,
			`[0.008778, "o", "AAECAwQF", "b64"]`)},
		{"invalid utf8 must stay base64", castText(`{"exit_code":0,"duration":0.010000}`,
			`[0.001000, "o", "/w==", "b64"]`)},
		// < > and & are written \u003c \u003e \u0026: that is what encoding/json
		// produces, and therefore what SessionForge itself writes.
		{"escapes and unicode", castText(`{"exit_code":0,"duration":2.000000}`,
			`[0.000000, "o", "\u001b[1;31mred\u001b[0m \u003chtml\u003e \u0026 \"quotes\"\n"]`,
			`[1.000000, "o", "héllo → 世界\n"]`)},
		{"signal footer", castText(`{"exit_code":-1,"duration":2.494000,"signal":"terminated","interrupted":true}`,
			`[0.000000, "o", "x"]`)},
		{"zero events", castText(`{"exit_code":0,"duration":0.000000}`)},
		{"long fractions", castText(`{"exit_code":0,"duration":9.999999}`,
			`[0.000001, "o", "a"]`,
			`[9.999999, "o", "b"]`)},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			c := mustRead(t, tc.body)
			got := writeString(t, c)
			if got != tc.body {
				t.Errorf("round trip is not byte identical\n--- want ---\n%s\n--- got ---\n%s", tc.body, got)
			}
		})
	}
}

// An identity EDL must also be byte identical: apply with no operations must
// not disturb a single byte, including the footer.
func TestIdentityEditIsByteIdentical(t *testing.T) {
	body := evenCast(6)
	res := mustApply(t, body, "")
	if got := writeString(t, res.Cast); got != body {
		t.Errorf("identity edit changed the file\n--- want ---\n%s\n--- got ---\n%s", body, got)
	}
}

// The real recording made with the SessionForge binary, if it is present.
func TestRoundTripRecordedFixture(t *testing.T) {
	path := filepath.Join("fixtures", "deploy.jsonl")
	want, err := os.ReadFile(path)
	if err != nil {
		t.Skipf("fixture not present: %v", err)
	}
	c, err := loadCast(path)
	if err != nil {
		t.Fatalf("loadCast: %v", err)
	}
	if got := writeString(t, c); got != string(want) {
		t.Errorf("recorded fixture does not round trip byte for byte")
	}
	res, err := applyEDL(c, &edl{Version: 1})
	if err != nil {
		t.Fatalf("applyEDL: %v", err)
	}
	if got := writeString(t, res.Cast); got != string(want) {
		t.Errorf("identity edit of the recorded fixture is not byte identical")
	}
}

// ---------------------------------------------------------------------------
// 2. Monotonic timestamps after every edit type
// ---------------------------------------------------------------------------

func TestTimestampsStayMonotonic(t *testing.T) {
	body := evenCast(11) // events at 0..10s
	tests := []struct {
		name string
		edl  string
	}{
		{"identity", ""},
		{"cut", "cut 2-4"},
		{"cut at zero", "cut 0-3"},
		{"cut to the end", "cut 7-10"},
		{"two cuts", "cut 1-2\ncut 5-6"},
		{"trim", "trim --start 2 --end 8"},
		{"trim head only", "trim --start 3"},
		{"speed", "speed 2-6 x4"},
		{"speed slower", "speed 2-6 x0.5"},
		{"speed odd factor", "speed 1-4 x3"},
		{"gap", "gap --max 0.25"},
		{"gap larger than every pause", "gap --max 30"},
		{"hold", "hold 4 --for 2"},
		{"hold at zero", "hold 0 --for 1"},
		{"two holds", "hold 2 --for 1\nhold 6 --for 3"},
		{"everything", "trim --start 1 --end 9\ngap --max 0.5\ncut 2-3\nspeed 5-7 x3\nhold 8 --for 1.25"},
		{"cut then speed adjacent", "cut 2-4\nspeed 4-6 x2"},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			res := mustApply(t, body, tc.edl)
			prev := rZero()
			for i, ev := range res.Cast.Events {
				if ev.T.Sign() < 0 {
					t.Fatalf("event %d has a negative timestamp %s", i, rString(ev.T))
				}
				if ev.T.Cmp(prev) < 0 {
					t.Fatalf("event %d at %s goes backwards from %s", i, rString(ev.T), rString(prev))
				}
				prev = ev.T
			}
			if res.OutDur.Cmp(res.OutSpan) > 0 {
				t.Fatalf("last event %s is past the end of the timeline %s", rString(res.OutDur), rString(res.OutSpan))
			}
			// The written file must be monotonic too, after quantisation.
			written := mustRead(t, writeString(t, res.Cast))
			prevUS := int64(-1)
			for i, ev := range written.Events {
				us := rMicros(ev.T)
				if us < prevUS {
					t.Fatalf("written event %d at %d us goes backwards from %d us", i, us, prevUS)
				}
				prevUS = us
			}
		})
	}
}

// ---------------------------------------------------------------------------
// 3. Exact duration arithmetic. No epsilon anywhere in this test.
// ---------------------------------------------------------------------------

func TestDurationIsExact(t *testing.T) {
	body := evenCast(13) // events at 0..12s, footer duration 12s
	tests := []struct {
		name string
		edl  string
		want *big.Rat
	}{
		{"identity", "", big.NewRat(12, 1)},
		{"one cut", "cut 2-4", big.NewRat(10, 1)},
		{"two cuts", "cut 2-4\ncut 8-9", big.NewRat(9, 1)},
		{"speed x2", "speed 0-4 x2", big.NewRat(10, 1)},
		{"speed x4", "speed 4-12 x4", big.NewRat(6, 1)},
		{"speed x3 is one third exactly", "speed 0-1 x3", big.NewRat(34, 3)},
		{"half speed doubles", "speed 0-2 x1/2", big.NewRat(14, 1)},
		{"trim", "trim --start 2 --end 9", big.NewRat(7, 1)},
		{"hold adds exactly", "hold 5 --for 3/2", big.NewRat(27, 2)},
		// A chain: every operation compounds, and the answer is a rational
		// that no float64 can hold.
		{"chain of speeds and cuts",
			"speed 0-1 x3\ncut 2-3\nspeed 5-8 x7",
			big.NewRat(163, 21)},
		{"chain with trim and hold",
			"trim --start 1 --end 11\nspeed 2-5 x3\ncut 6-7\nhold 9 --for 1/3",
			// window 10s, speed 3s -> 1s (-2), cut -1, hold +1/3
			new(big.Rat).Add(big.NewRat(7, 1), big.NewRat(1, 3))},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			res := mustApply(t, body, tc.edl)
			if res.OutSpan.Cmp(tc.want) != 0 {
				t.Fatalf("duration = %s, want exactly %s", res.OutSpan.RatString(), tc.want.RatString())
			}
			// The duration must also equal the sum of the retained-and-scaled
			// intervals, recomputed here from the plan's segments alone.
			sum := rZero()
			for _, s := range res.Plan.Segments {
				sum = rAdd(sum, s.outLen())
			}
			sum = rSub(sum, res.Plan.gapShiftAt(res.Plan.RawEnd))
			sum = rAdd(sum, res.Plan.holdShiftTotal())
			if sum.Cmp(res.OutSpan) != 0 {
				t.Fatalf("sum of scaled intervals %s != duration %s", sum.RatString(), res.OutSpan.RatString())
			}
		})
	}
}

// A thousand chained microsecond-scale speed changes must not drift by so much
// as one microsecond: this is the test a float64 implementation cannot pass.
func TestNoDriftOverManyEvents(t *testing.T) {
	var evs []string
	const n = 1000
	for i := 0; i < n; i++ {
		evs = append(evs, `[`+formatMicros(int64(i)*1000)+`, "o", "."]`)
	}
	body := castText(`{"exit_code":0,"duration":`+formatMicros(int64(n-1)*1000)+`}`, evs...)
	res := mustApply(t, body, "speed 0-0.999 x3")
	// 0.999s of source at x3 is 333/1000 s, exactly.
	want := big.NewRat(333, 1000)
	if res.OutSpan.Cmp(want) != 0 {
		t.Fatalf("duration = %s, want exactly %s", res.OutSpan.RatString(), want.RatString())
	}
	// Every event i lands on exactly i/3 milliseconds.
	for i, ev := range res.Cast.Events {
		want := new(big.Rat).SetFrac64(int64(i), 3000)
		if ev.T.Cmp(want) != 0 {
			t.Fatalf("event %d at %s, want exactly %s", i, ev.T.RatString(), want.RatString())
		}
	}
}

// ---------------------------------------------------------------------------
// 4. Gap collapsing
// ---------------------------------------------------------------------------

func TestGapCollapse(t *testing.T) {
	// Pauses of 0.5, 5, 0.25 and 3 seconds, then a 2 second tail after the
	// final event that the footer records.
	body := castText(`{"exit_code":0,"duration":10.750000}`,
		`[0.500000, "o", "a"]`,
		`[5.500000, "o", "b"]`,
		`[5.750000, "o", "c"]`,
		`[8.750000, "o", "d"]`)

	tests := []struct {
		name     string
		max      string
		wantSpan *big.Rat
		wantAt   []*big.Rat
	}{
		{"cap at 1s", "1",
			// 0.5 + 1 + 0.25 + 1 + tail(2 -> 1) = 3.75
			big.NewRat(15, 4),
			[]*big.Rat{big.NewRat(1, 2), big.NewRat(3, 2), big.NewRat(7, 4), big.NewRat(11, 4)}},
		{"cap at 0.25s", "0.25",
			big.NewRat(5, 4),
			[]*big.Rat{big.NewRat(1, 4), big.NewRat(1, 2), big.NewRat(3, 4), big.NewRat(1, 1)}},
		{"cap above every pause", "9",
			big.NewRat(43, 4),
			[]*big.Rat{big.NewRat(1, 2), big.NewRat(11, 2), big.NewRat(23, 4), big.NewRat(35, 4)}},
		{"cap at zero removes all dead air", "0",
			rZero(),
			[]*big.Rat{rZero(), rZero(), rZero(), rZero()}},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			res := mustApply(t, body, "gap --max "+tc.max)
			if res.OutSpan.Cmp(tc.wantSpan) != 0 {
				t.Fatalf("span = %s, want %s", res.OutSpan.RatString(), tc.wantSpan.RatString())
			}
			if len(res.Cast.Events) != len(tc.wantAt) {
				t.Fatalf("got %d events, want %d", len(res.Cast.Events), len(tc.wantAt))
			}
			for i, ev := range res.Cast.Events {
				if ev.T.Cmp(tc.wantAt[i]) != 0 {
					t.Errorf("event %d at %s, want %s", i, ev.T.RatString(), tc.wantAt[i].RatString())
				}
			}
			// No remaining gap may exceed the cap.
			max, _ := new(big.Rat).SetString(tc.max)
			prev := rZero()
			for i, ev := range res.Cast.Events {
				if g := rSub(ev.T, prev); g.Cmp(max) > 0 {
					t.Errorf("gap before event %d is %s, above the cap %s", i, g.RatString(), max.RatString())
				}
				prev = ev.T
			}
		})
	}
}

// Gap collapsing must not touch a pause that a hold deliberately created.
func TestGapDoesNotEatAHold(t *testing.T) {
	body := castText(`{"exit_code":0,"duration":4.000000}`,
		`[0.000000, "o", "a"]`,
		`[1.000000, "o", "b"]`,
		`[2.000000, "o", "c"]`)
	res := mustApply(t, body, "gap --max 0.5\nhold 1 --for 3")
	// gaps 1,1 capped to 0.5 each, tail 2 -> 0.5, plus a 3s hold after t=1.
	want := new(big.Rat).Add(big.NewRat(3, 2), big.NewRat(3, 1))
	if res.OutSpan.Cmp(want) != 0 {
		t.Fatalf("span = %s, want %s", res.OutSpan.RatString(), want.RatString())
	}
	got := rSub(res.Cast.Events[2].T, res.Cast.Events[1].T)
	wantGap := new(big.Rat).Add(big.NewRat(1, 2), big.NewRat(3, 1))
	if got.Cmp(wantGap) != 0 {
		t.Fatalf("gap across the hold is %s, want %s", got.RatString(), wantGap.RatString())
	}
}

// ---------------------------------------------------------------------------
// 5. Bad EDLs are rejected with a clear error
// ---------------------------------------------------------------------------

func TestEDLRejectsBadInput(t *testing.T) {
	tests := []struct {
		name    string
		edl     string
		wantSub string
	}{
		{"overlapping cuts", "cut 1-5\ncut 4-8", "overlaps"},
		{"overlapping cut and speed", "cut 1-5\nspeed 3-9 x2", "overlaps"},
		{"identical ranges", "cut 1-5\ncut 1-5", "overlaps"},
		{"out of order", "cut 5-8\ncut 1-3", "out of order"},
		{"reversed range", "cut 8-5", "out of order"},
		{"empty range", "cut 5-5", "out of order"},
		{"reversed trim", "trim --start 9 --end 2", "not before"},
		{"two trims", "trim --start 1 --end 5\ntrim --start 2 --end 6", "second trim"},
		{"two gaps", "gap --max 1\ngap --max 2", "second gap"},
		{"gap without max", "gap", "needs --max"},
		{"speed without factor", "speed 1-4", "needs a factor"},
		{"speed zero factor", "speed 1-4 x0", "greater than zero"},
		{"speed negative factor", "speed 1-4 x-2", "greater than zero"},
		{"hold without duration", "hold 4", "needs --for"},
		{"hold zero duration", "hold 4 --for 0", "greater than zero"},
		{"hold inside a cut", "cut 2-6\nhold 4 --for 1", "falls inside cut"},
		{"negative start", "cut --start -1 --end 3", "negative"},
		{"unknown op", "fade 1-2", "unknown operation"},
		{"unknown flag", "cut --middle 4", "unknown flag"},
		{"unparseable time", "cut abc-def", "cannot parse time"},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			e, err := parseEDL([]byte(tc.edl))
			if err == nil {
				err = e.validate()
			}
			if err == nil {
				t.Fatalf("expected an error for %q, got none", tc.edl)
			}
			if !strings.Contains(err.Error(), tc.wantSub) {
				t.Fatalf("error %q does not mention %q", err.Error(), tc.wantSub)
			}
		})
	}
}

// The same rejections must hold for the JSON form of an EDL.
func TestEDLJSONRejectsBadInput(t *testing.T) {
	tests := []struct {
		name    string
		edl     string
		wantSub string
	}{
		{"overlap", `{"version":1,"ops":[{"op":"cut","start":1,"end":5},{"op":"cut","start":"4","end":"8"}]}`, "overlaps"},
		{"out of order", `{"version":1,"ops":[{"op":"cut","start":5,"end":8},{"op":"cut","start":1,"end":3}]}`, "out of order"},
		{"bad version", `{"version":9,"ops":[]}`, "unsupported EDL version"},
		{"bad json", `{"version":1,"ops":[`, "malformed EDL JSON"},
		{"bad time", `{"version":1,"ops":[{"op":"cut","start":"nope","end":"8"}]}`, "cannot parse time"},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			e, err := parseEDL([]byte(tc.edl))
			if err == nil {
				err = e.validate()
			}
			if err == nil {
				t.Fatalf("expected an error for %q, got none", tc.edl)
			}
			if !strings.Contains(err.Error(), tc.wantSub) {
				t.Fatalf("error %q does not mention %q", err.Error(), tc.wantSub)
			}
		})
	}
}

// An operation whose whole range is trimmed away is a mistake, not a no-op.
func TestRangeOutsideTrimWindowIsRejected(t *testing.T) {
	_, err := applyEDL(mustRead(t, evenCast(11)), mustEDL(t, "trim --start 0 --end 5\ncut 7-9"))
	if err == nil || !strings.Contains(err.Error(), "outside the trim window") {
		t.Fatalf("want an outside-the-window error, got %v", err)
	}
}

func TestJSONAndCompactFormsAgree(t *testing.T) {
	body := evenCast(13)
	a := mustApply(t, body, "trim --start 1 --end 11\ncut 3-4\nspeed 6-9 x3\nhold 10 --for 2\ngap --max 1.5")
	b := mustApply(t, body, `{"version":1,"ops":[
		{"op":"trim","start":"1","end":"11"},
		{"op":"cut","start":3,"end":4},
		{"op":"speed","start":"0:06","end":"0:09","factor":"x3"},
		{"op":"hold","at":10,"for":"2s"},
		{"op":"gap","max":"1500ms"}]}`)
	if a.OutSpan.Cmp(b.OutSpan) != 0 {
		t.Fatalf("compact form gives %s, JSON form gives %s", a.OutSpan.RatString(), b.OutSpan.RatString())
	}
	if len(a.Cast.Events) != len(b.Cast.Events) {
		t.Fatalf("event counts differ: %d vs %d", len(a.Cast.Events), len(b.Cast.Events))
	}
	for i := range a.Cast.Events {
		if a.Cast.Events[i].T.Cmp(b.Cast.Events[i].T) != 0 {
			t.Fatalf("event %d differs: %s vs %s", i, a.Cast.Events[i].T.RatString(), b.Cast.Events[i].T.RatString())
		}
	}
}

// ---------------------------------------------------------------------------
// 6. Cut-boundary prelude correctness
// ---------------------------------------------------------------------------

func TestCutPreludeRestoresTerminalState(t *testing.T) {
	tests := []struct {
		name    string
		events  []string
		edl     string
		want    string // exact prelude bytes, "" for no prelude at all
		wantIdx int    // index in the edited cast where the prelude must sit
	}{
		{
			name: "colour and cursor both change",
			events: []string{
				`[0.000000, "o", "hello\n"]`,
				`[1.000000, "o", "\u001b[1;31mALERT"]`,
				`[2.000000, "o", " continues\n"]`,
			},
			edl:     "cut 1-2",
			want:    "\x1b[2;6H\x1b[0;1;31m",
			wantIdx: 1,
		},
		{
			name: "only the colour changes",
			events: []string{
				`[0.000000, "o", "\u001b[32m"]`,
				`[1.000000, "o", "\u001b[0m"]`,
				`[2.000000, "o", "x"]`,
			},
			edl:     "cut 1-2",
			want:    "\x1b[0m",
			wantIdx: 1,
		},
		{
			name: "only the cursor moves",
			events: []string{
				`[0.000000, "o", "abc\n"]`,
				`[1.000000, "o", "defgh"]`,
				`[2.000000, "o", "!"]`,
			},
			edl:     "cut 1-2",
			want:    "\x1b[2;6H",
			wantIdx: 1,
		},
		{
			name: "nothing changes so nothing is emitted",
			events: []string{
				`[0.000000, "o", "abc\n"]`,
				`[1.000000, "o", "hello\rworld\r"]`,
				`[2.000000, "o", "!"]`,
			},
			edl:  "cut 1-2",
			want: "",
		},
		{
			name: "256 colour survives the cut",
			events: []string{
				`[0.000000, "o", "\u001b[38;5;208mA\u001b[0m\n"]`,
				`[1.000000, "o", "\u001b[38;5;27m"]`,
				`[2.000000, "o", "B"]`,
			},
			edl:     "cut 1-2",
			want:    "\x1b[0;38;5;27m",
			wantIdx: 1,
		},
		{
			name: "truecolor background and underline survive",
			events: []string{
				`[0.000000, "o", "start\n"]`,
				`[1.000000, "o", "\u001b[4;48;2;10;20;30m"]`,
				`[2.000000, "o", "B"]`,
			},
			edl:     "cut 1-2",
			want:    "\x1b[0;4;48;2;10;20;30m",
			wantIdx: 1,
		},
		{
			name: "absolute cursor addressing inside the removed region",
			events: []string{
				`[0.000000, "o", "top\n"]`,
				`[1.000000, "o", "\u001b[10;40Hmid"]`,
				`[2.000000, "o", "tail"]`,
			},
			edl:     "cut 1-2",
			want:    "\x1b[10;43H",
			wantIdx: 1,
		},
		{
			name: "trimming the head also gets a prelude",
			events: []string{
				`[0.000000, "o", "\u001b[33mwarn\n"]`,
				`[1.000000, "o", "kept"]`,
			},
			edl:     "trim --start 0.5",
			want:    "\x1b[2;1H\x1b[0;33m",
			wantIdx: 0,
		},
		{
			name: "a cut at the very end needs no prelude",
			events: []string{
				`[0.000000, "o", "\u001b[33ma"]`,
				`[1.000000, "o", "\u001b[31mb"]`,
			},
			edl:  "cut 1-2",
			want: "",
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			res := mustApply(t, castText(`{"exit_code":0,"duration":3.000000}`, tc.events...), tc.edl)
			var synth []castEvent
			for _, ev := range res.Cast.Events {
				if ev.Synth {
					synth = append(synth, ev)
				}
			}
			if tc.want == "" {
				if len(synth) != 0 {
					t.Fatalf("expected no prelude, got %q", visible(synth[0].Data))
				}
				return
			}
			if len(synth) != 1 {
				t.Fatalf("expected exactly one prelude, got %d", len(synth))
			}
			if string(synth[0].Data) != tc.want {
				t.Fatalf("prelude = %q, want %q", visible(synth[0].Data), visible([]byte(tc.want)))
			}
			if synth[0].Stream != "o" {
				t.Fatalf("prelude is on stream %q, want \"o\"", synth[0].Stream)
			}
			if res.Cast.Events[tc.wantIdx].Synth != true {
				t.Fatalf("prelude is not at index %d", tc.wantIdx)
			}
			// It must share a timestamp with the event it precedes, so the
			// timeline stays monotonic and no time is invented.
			if tc.wantIdx+1 < len(res.Cast.Events) {
				if synth[0].T.Cmp(res.Cast.Events[tc.wantIdx+1].T) != 0 {
					t.Fatalf("prelude at %s does not share the next event's time %s",
						synth[0].T.RatString(), res.Cast.Events[tc.wantIdx+1].T.RatString())
				}
			}
		})
	}
}

// The prelude must land the model in exactly the state the removed bytes left
// it in: replaying [surviving prefix + prelude] must equal replaying
// [surviving prefix + removed bytes].
func TestPreludeIsEquivalentToTheRemovedBytes(t *testing.T) {
	cases := [][]string{
		{"plain\n", "\x1b[1;4;35mfancy", "tail"},
		{"\x1b[42m", "\x1b[10;5Hx\x1b[38;2;1;2;3m", "tail"},
		{"a\tb\n", "\x1b[3A\x1b[7mrev", "tail"},
		{"x", "\x1b7\x1b[9;9H\x1b8", "tail"},
	}
	for _, cs := range cases {
		full := newTermState(80, 24)
		full.feed([]byte(cs[0]))
		before := full.clone()
		full.feed([]byte(cs[1]))

		p := prelude(before, full)
		patched := before.clone()
		patched.feed(p)

		if patched.Row != full.Row || patched.Col != full.Col {
			t.Errorf("%q: prelude left the cursor at %d,%d, want %d,%d",
				visible([]byte(cs[1])), patched.Row, patched.Col, full.Row, full.Col)
		}
		if patched.SGR != full.SGR {
			t.Errorf("%q: prelude left SGR %+v, want %+v", visible([]byte(cs[1])), patched.SGR, full.SGR)
		}
	}
}

func TestTerminalStateModel(t *testing.T) {
	tests := []struct {
		name     string
		in       string
		wantRow  int
		wantCol  int
		checkSGR func(sgrState) bool
	}{
		{"plain text", "abc", 0, 3, nil},
		{"newline is CRLF", "abc\ndef", 1, 3, nil},
		{"carriage return", "abcdef\rx", 0, 1, nil},
		{"backspace", "abc\b\b", 0, 1, nil},
		{"tab", "a\tb", 0, 9, nil},
		{"wrap at the right margin", strings.Repeat("x", 85), 1, 5, nil},
		{"cursor up", "\n\n\n\x1b[2A", 1, 0, nil},
		{"cursor forward and back", "\x1b[10C\x1b[3D", 0, 7, nil},
		{"absolute position", "\x1b[5;9H", 4, 8, nil},
		{"column absolute", "abc\x1b[1G", 0, 0, nil},
		{"row clamps at the bottom", strings.Repeat("\n", 100), 23, 0, nil},
		{"erase does not move the cursor", "abc\x1b[2K\x1b[2J", 0, 3, nil},
		{"save and restore", "abc\x1b7\ndef\x1b8", 0, 3, nil},
		{"csi save and restore", "abc\x1b[s\ndef\x1b[u", 0, 3, nil},
		{"private modes are ignored", "\x1b[?25labc", 0, 3, nil},
		{"osc is consumed whole", "\x1b]0;a title\x07abc", 0, 3, nil},
		{"osc terminated by st", "\x1b]0;t\x1b\\abc", 0, 3, nil},
		{"utf8 counts one cell per rune", "héllo", 0, 5, nil},
		{"bold on", "\x1b[1m", 0, 0, func(s sgrState) bool { return s.Bold }},
		{"bold off", "\x1b[1m\x1b[22m", 0, 0, func(s sgrState) bool { return !s.Bold }},
		{"reset by empty sgr", "\x1b[1;31m\x1b[m", 0, 0, func(s sgrState) bool { return s == sgrState{} }},
		{"fg colour", "\x1b[31m", 0, 0, func(s sgrState) bool { return s.FG == colour{Kind: colBasic, N: 31} }},
		{"bright fg colour", "\x1b[93m", 0, 0, func(s sgrState) bool { return s.FG == colour{Kind: colBasic, N: 93} }},
		{"default fg", "\x1b[31m\x1b[39m", 0, 0, func(s sgrState) bool { return s.FG.Kind == colDefault }},
		{"bg colour", "\x1b[44m", 0, 0, func(s sgrState) bool { return s.BG == colour{Kind: colBasic, N: 44} }},
		{"256 fg", "\x1b[38;5;99m", 0, 0, func(s sgrState) bool { return s.FG == colour{Kind: col256, N: 99} }},
		{"rgb bg", "\x1b[48;2;1;2;3m", 0, 0, func(s sgrState) bool { return s.BG == colour{Kind: colRGB, R: 1, G: 2, B: 3} }},
		{"unknown sequences do not desync", "\x1b[>4;2m\x1babc", 0, 2, nil},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			st := newTermState(80, 24)
			st.feed([]byte(tc.in))
			if st.Row != tc.wantRow || st.Col != tc.wantCol {
				t.Errorf("cursor = %d,%d want %d,%d", st.Row, st.Col, tc.wantRow, tc.wantCol)
			}
			if tc.checkSGR != nil && !tc.checkSGR(st.SGR) {
				t.Errorf("SGR state %+v failed its check", st.SGR)
			}
		})
	}
}

func TestSGRSequenceRoundTrips(t *testing.T) {
	states := []string{
		"\x1b[1m", "\x1b[1;4;7m", "\x1b[31;44m", "\x1b[38;5;208;48;5;17m",
		"\x1b[38;2;9;8;7m", "\x1b[3;9;95;101m",
	}
	for _, s := range states {
		a := newTermState(80, 24)
		a.feed([]byte(s))
		b := newTermState(80, 24)
		b.feed([]byte(sgrSequence(a.SGR)))
		if a.SGR != b.SGR {
			t.Errorf("%q: sgrSequence produced %q which parses back to a different state", visible([]byte(s)), visible([]byte(sgrSequence(a.SGR))))
		}
	}
}

// ---------------------------------------------------------------------------
// 7. Output safety
// ---------------------------------------------------------------------------

func TestRefusesToOverwriteOutput(t *testing.T) {
	dir := t.TempDir()
	out := filepath.Join(dir, "out.jsonl")
	const guard = "DO NOT CLOBBER ME\n"
	if err := os.WriteFile(out, []byte(guard), 0o644); err != nil {
		t.Fatal(err)
	}
	c := mustRead(t, evenCast(4))

	err := writeOut(out, c, false)
	if err == nil {
		t.Fatal("writeOut overwrote an existing file without --force")
	}
	if !strings.Contains(err.Error(), "already exists") || !strings.Contains(err.Error(), "--force") {
		t.Fatalf("error %q does not explain how to proceed", err.Error())
	}
	got, _ := os.ReadFile(out)
	if string(got) != guard {
		t.Fatalf("the existing file was modified despite the refusal")
	}

	if err := writeOut(out, c, true); err != nil {
		t.Fatalf("writeOut with force: %v", err)
	}
	got, _ = os.ReadFile(out)
	if string(got) == guard {
		t.Fatal("--force did not overwrite the file")
	}
	if string(got) != evenCast(4) {
		t.Fatalf("forced write produced unexpected content:\n%s", got)
	}

	// A brand new path must work without --force.
	fresh := filepath.Join(dir, "fresh.jsonl")
	if err := writeOut(fresh, c, false); err != nil {
		t.Fatalf("writeOut to a new path: %v", err)
	}
}

func TestSameFileDetection(t *testing.T) {
	dir := t.TempDir()
	a := filepath.Join(dir, "a.jsonl")
	if err := os.WriteFile(a, []byte(evenCast(3)), 0o644); err != nil {
		t.Fatal(err)
	}
	if !sameFile(a, a) {
		t.Error("sameFile should match a path against itself")
	}
	if !sameFile(a, filepath.Join(dir, ".", "a.jsonl")) {
		t.Error("sameFile should match equivalent paths")
	}
	if sameFile(a, filepath.Join(dir, "b.jsonl")) {
		t.Error("sameFile matched two different paths")
	}
}

func TestInputIsNeverModified(t *testing.T) {
	dir := t.TempDir()
	in := filepath.Join(dir, "in.jsonl")
	body := evenCast(9)
	if err := os.WriteFile(in, []byte(body), 0o444); err != nil { // read only on purpose
		t.Fatal(err)
	}
	c, err := loadCast(in)
	if err != nil {
		t.Fatal(err)
	}
	res, err := applyEDL(c, mustEDL(t, "cut 2-4\nspeed 5-8 x2\ngap --max 0.5"))
	if err != nil {
		t.Fatal(err)
	}
	if err := writeOut(filepath.Join(dir, "out.jsonl"), res.Cast, false); err != nil {
		t.Fatal(err)
	}
	after, err := os.ReadFile(in)
	if err != nil {
		t.Fatal(err)
	}
	if string(after) != body {
		t.Fatal("the input file changed during an edit")
	}
}

// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------

func TestParseTime(t *testing.T) {
	tests := []struct {
		in   string
		want *big.Rat
		bad  bool
	}{
		{in: "12", want: big.NewRat(12, 1)},
		{in: "12.5", want: big.NewRat(25, 2)},
		{in: "12.5s", want: big.NewRat(25, 2)},
		{in: "500ms", want: big.NewRat(1, 2)},
		{in: "250us", want: big.NewRat(1, 4000)},
		{in: "2m", want: big.NewRat(120, 1)},
		{in: "1:30", want: big.NewRat(90, 1)},
		{in: "1:30.250", want: big.NewRat(361, 4)},
		{in: "0:01:05", want: big.NewRat(65, 1)},
		{in: "1/3", want: big.NewRat(1, 3)},
		{in: "-2", want: big.NewRat(-2, 1)},
		{in: "", bad: true},
		{in: "abc", bad: true},
		{in: "1:2:3:4", bad: true},
	}
	for _, tc := range tests {
		got, err := parseTime(tc.in)
		if tc.bad {
			if err == nil {
				t.Errorf("parseTime(%q) should have failed", tc.in)
			}
			continue
		}
		if err != nil {
			t.Errorf("parseTime(%q): %v", tc.in, err)
			continue
		}
		if got.Cmp(tc.want) != 0 {
			t.Errorf("parseTime(%q) = %s, want %s", tc.in, got.RatString(), tc.want.RatString())
		}
	}
}

func TestParseFactor(t *testing.T) {
	tests := []struct {
		in   string
		want *big.Rat
		bad  bool
	}{
		{in: "4", want: big.NewRat(4, 1)},
		{in: "x4", want: big.NewRat(4, 1)},
		{in: "X4", want: big.NewRat(4, 1)},
		{in: "4x", want: big.NewRat(4, 1)},
		{in: "*4", want: big.NewRat(4, 1)},
		{in: "×4", want: big.NewRat(4, 1)},
		{in: "1.5", want: big.NewRat(3, 2)},
		{in: "3/2", want: big.NewRat(3, 2)},
		{in: "0", bad: true},
		{in: "-2", bad: true},
		{in: "fast", bad: true},
	}
	for _, tc := range tests {
		got, err := parseFactor(tc.in)
		if tc.bad {
			if err == nil {
				t.Errorf("parseFactor(%q) should have failed", tc.in)
			}
			continue
		}
		if err != nil {
			t.Errorf("parseFactor(%q): %v", tc.in, err)
			continue
		}
		if got.Cmp(tc.want) != 0 {
			t.Errorf("parseFactor(%q) = %s, want %s", tc.in, got.RatString(), tc.want.RatString())
		}
	}
}

func TestMicrosecondQuantisation(t *testing.T) {
	tests := []struct {
		in   *big.Rat
		want int64
	}{
		{big.NewRat(1, 1), 1000000},
		{big.NewRat(1, 3), 333333},  // 333333.33 -> down
		{big.NewRat(2, 3), 666667},  // 666666.67 -> up
		{big.NewRat(1, 2000000), 1}, // exactly half a microsecond -> away from zero
		{big.NewRat(-1, 2000000), -1},
		{rZero(), 0},
	}
	for _, tc := range tests {
		if got := rMicros(tc.in); got != tc.want {
			t.Errorf("rMicros(%s) = %d, want %d", tc.in.RatString(), got, tc.want)
		}
	}
	if !rIsExactMicros(rFromMicros(12345)) {
		t.Error("a whole microsecond count should be exact")
	}
	if rIsExactMicros(big.NewRat(1, 3)) {
		t.Error("one third of a second is not a whole microsecond")
	}
	if got := formatMicros(3004161); got != "3.004161" {
		t.Errorf("formatMicros = %q, want %q", got, "3.004161")
	}
}

func TestMalformedCastsAreRejected(t *testing.T) {
	tests := []struct {
		name    string
		body    string
		wantSub string
	}{
		{"empty", "", "no cast header"},
		{"no header", "[0.0, \"o\", \"a\"]\n", "expected a header object"},
		{"bad version", `{"version":7}` + "\n", "unsupported cast version"},
		{"bad event", hdr + "\n[0.0, \"z\", \"a\"]\n", "unknown stream tag"},
		{"short event", hdr + "\n[0.0, \"o\"]\n", "at least 3 fields"},
		{"bad base64", hdr + "\n[0.0, \"o\", \"!!!!\", \"b64\"]\n", "bad base64"},
		{"backwards time", hdr + "\n[2.0, \"o\", \"a\"]\n[1.0, \"o\", \"b\"]\n", "goes backwards"},
		{"negative time", hdr + "\n[-1.0, \"o\", \"a\"]\n", "negative timestamp"},
		{"junk line", hdr + "\nnonsense\n", "unrecognised record"},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			_, err := readCast(strings.NewReader(tc.body), "x.jsonl")
			if err == nil {
				t.Fatalf("expected an error, got none")
			}
			if !strings.Contains(err.Error(), tc.wantSub) {
				t.Fatalf("error %q does not mention %q", err.Error(), tc.wantSub)
			}
		})
	}
}

// ---------------------------------------------------------------------------
// Content preservation
// ---------------------------------------------------------------------------

// Editing the timeline must never edit the bytes. Everything that survives a
// cut must come through unchanged, in order, on its original stream.
func TestSurvivingBytesAreUntouched(t *testing.T) {
	body := castText(`{"exit_code":0,"duration":6.000000}`,
		`[0.000000, "o", "alpha\n"]`,
		`[1.000000, "e", "beta\n"]`,
		`[2.000000, "o", "AAECAwQF", "b64"]`,
		`[3.000000, "o", "gamma → δ\n"]`,
		`[4.000000, "o", "delta\n"]`)
	src := mustRead(t, body)
	res := mustApply(t, body, "cut 1-2\ngap --max 0.5\nspeed 3-4 x2")

	var want []castEvent
	for _, ev := range src.Events {
		if ev.T.Cmp(big.NewRat(1, 1)) == 0 {
			continue // the cut one
		}
		want = append(want, ev)
	}
	var got []castEvent
	for _, ev := range res.Cast.Events {
		if !ev.Synth {
			got = append(got, ev)
		}
	}
	if len(got) != len(want) {
		t.Fatalf("kept %d events, want %d", len(got), len(want))
	}
	for i := range want {
		if !bytes.Equal(got[i].Data, want[i].Data) {
			t.Errorf("event %d payload changed: %q -> %q", i, want[i].Data, got[i].Data)
		}
		if got[i].Stream != want[i].Stream {
			t.Errorf("event %d stream changed: %q -> %q", i, want[i].Stream, got[i].Stream)
		}
	}
}

func TestPreviewRulersRenderSomething(t *testing.T) {
	body := evenCast(11)
	c := mustRead(t, body)
	res := mustApply(t, body, "cut 2-4\nspeed 5-7 x2\nhold 9 --for 2\ngap --max 0.5")
	marks := res.Plan.buildMarks()
	src := rulerSource(c, res.Plan, marks, 40)
	out := rulerEdited(res, marks, 40)
	if len(src) != 40 || len(out) != 40 {
		t.Fatalf("rulers are %d and %d columns, want 40", len(src), len(out))
	}
	if !strings.ContainsRune(src, 'x') {
		t.Errorf("source ruler %q does not show the cut", src)
	}
	if !strings.ContainsRune(src, '~') {
		t.Errorf("source ruler %q does not show the speed change", src)
	}
	if !strings.ContainsRune(out, 'H') {
		t.Errorf("edited ruler %q does not show the hold", out)
	}
}
