package bbr

// LOCAL test (not from upstream): covers the recovery-window floor that
// ProfileRealtime adds. See PROVENANCE.md.

import (
	"testing"
	"time"

	"github.com/apernet/quic-go/congestion"
	"github.com/apernet/quic-go/monotime"
)

type fakeClock struct{ now monotime.Time }

func (c *fakeClock) Now() monotime.Time { return c.now }

type stubRTT struct{ min time.Duration }

func (s stubRTT) MinRTT() time.Duration                       { return s.min }
func (s stubRTT) LatestRTT() time.Duration                    { return s.min }
func (s stubRTT) SmoothedRTT() time.Duration                  { return s.min }
func (s stubRTT) MeanDeviation() time.Duration                { return 0 }
func (s stubRTT) MaxAckDelay() time.Duration                  { return 25 * time.Millisecond }
func (s stubRTT) PTO(bool) time.Duration                      { return 3 * s.min }
func (s stubRTT) UpdateRTT(sendDelta, ackDelay time.Duration) {}
func (s stubRTT) SetMaxAckDelay(time.Duration)                {}
func (s stubRTT) SetInitialRTT(time.Duration)                 {}

const (
	testPacket congestion.ByteCount = 1200
	testRTT                         = 50 * time.Millisecond
	// 10 Mbps: the delivery rate the w3cs bridge measured on the lossy
	// path that motivated the floor.
	testBandwidth Bandwidth = 10_000_000 * BitsPerSecond
)

// burstyLossEvent models the w3cs bridge between frames: a sender whose
// STARTUP is long over, with almost nothing in flight when one packet of
// the last small burst is declared lost. Returns the sender right after
// that loss event.
func burstyLossEvent(t *testing.T, profile Profile) *bbrSender {
	t.Helper()
	clock := &fakeClock{now: monotime.Now()}
	b := newBbrSender(clock, testPacket,
		initialCongestionWindowPackets*testPacket,
		congestion.MaxCongestionWindowPackets*testPacket, profile)
	b.SetRTTStatsProvider(stubRTT{min: testRTT})
	// Steady-state model: the pipe was filled once (resource bootstrap),
	// so the bandwidth filter and min RTT are populated and the sender sits
	// in PROBE_BW, where upstream's loss recovery is armed.
	b.maxBandwidth.Update(testBandwidth, b.roundTripCount)
	b.minRtt = testRTT
	b.minRttTimestamp = clock.now
	b.isAtFullBandwidth = true
	b.enterProbeBandwidthMode(clock.now)
	b.congestionWindow = b.getTargetCongestionWindow(b.congestionWindowGain)

	// A five-packet burst (a small frame), then nothing.
	var inFlight congestion.ByteCount
	for pn := congestion.PacketNumber(1); pn <= 5; pn++ {
		inFlight += testPacket
		b.OnPacketSent(clock.now, inFlight, pn, testPacket, true)
	}
	clock.now = clock.now.Add(testRTT)
	// One RTT later: 1-3 acked, 4 lost, 5 still outstanding.
	acked := []congestion.AckedPacketInfo{
		{PacketNumber: 1, BytesAcked: testPacket, ReceivedTime: clock.now},
		{PacketNumber: 2, BytesAcked: testPacket, ReceivedTime: clock.now},
		{PacketNumber: 3, BytesAcked: testPacket, ReceivedTime: clock.now},
	}
	lost := []congestion.LostPacketInfo{{PacketNumber: 4, BytesLost: testPacket}}
	b.OnCongestionEventEx(inFlight, clock.now, acked, lost)
	if !b.InRecovery() {
		t.Fatalf("profile %s: expected the loss to enter recovery", profile)
	}
	return b
}

// Upstream behaviour, kept as the regression reference: with one packet in
// flight plus three acked, packet conservation caps the window at the
// four-packet minimum — the collapse measured on the bridge.
func TestStandardProfileCollapsesRecoveryWindowOnBurstySender(t *testing.T) {
	b := burstyLossEvent(t, ProfileStandard)
	if got, want := b.GetCongestionWindow(), b.minCongestionWindow; got != want {
		t.Fatalf("standard window in recovery = %d, want the %d minimum",
			got, want)
	}
}

func TestRealtimeProfileFloorsRecoveryWindowAtOneBDP(t *testing.T) {
	b := burstyLossEvent(t, ProfileRealtime)
	bdp := b.getTargetCongestionWindow(1.0)
	// 10 Mbps x 50 ms = 62,500 bytes; guard the model itself so the
	// assertion below cannot pass on a degenerate zero.
	if bdp < 60_000 || bdp > 65_000 {
		t.Fatalf("model BDP = %d bytes, expected ~62,500", bdp)
	}
	got := b.GetCongestionWindow()
	if got < bdp {
		t.Fatalf("realtime window in recovery = %d, below the %d BDP floor",
			got, bdp)
	}
	if got > b.congestionWindow {
		t.Fatalf("realtime window in recovery = %d exceeds the ordinary "+
			"window %d; the floor must not out-rank the model", got,
			b.congestionWindow)
	}
	// Packet conservation still applies above the floor: a second loss in
	// the same round removes its bytes but cannot push under the floor.
	b.calculateRecoveryWindow(0, 40*testPacket)
	if got := b.GetCongestionWindow(); got < bdp {
		t.Fatalf("after a large loss the window fell to %d, below the %d "+
			"floor", got, bdp)
	}
}

// Realtime is the conservative profile plus the two local knobs: the
// conservative base brings Chromium's overestimate avoidance in the
// bandwidth sampler and the 1.75 cwnd gain, which is what keeps an
// ack-aggregating path from reading the pacing rate back as delivery rate
// (measured 2026-08-22 on the standard base: 16 -> 51 Mbps "bandwidth"
// while delivering 7-14, srtt 500 ms).
func TestRealtimeProfileParsesAndKeepsConservativeGains(t *testing.T) {
	profile, err := ParseProfile("realtime")
	if err != nil || profile != ProfileRealtime {
		t.Fatalf("ParseProfile(realtime) = %q, %v", profile, err)
	}
	conservative := configForProfile(ProfileConservative)
	realtime := configForProfile(ProfileRealtime)
	if realtime.recoveryWindowGain != 1.0 {
		t.Fatalf("realtime recoveryWindowGain = %v, want 1.0",
			realtime.recoveryWindowGain)
	}
	if realtime.ackHeightGain != 1.0 {
		t.Fatalf("realtime ackHeightGain = %v, want 1.0", realtime.ackHeightGain)
	}
	if !realtime.enableOverestimateAvoidance {
		t.Fatal("realtime must enable the sampler's overestimate avoidance")
	}
	realtime.recoveryWindowGain = 0
	realtime.ackHeightGain = 0
	if realtime != conservative {
		t.Fatalf("realtime differs from conservative beyond the local knobs:"+
			"\n%+v\n%+v", realtime, conservative)
	}
	for _, upstream := range []Profile{ProfileStandard, ProfileConservative,
		ProfileAggressive} {
		cfg := configForProfile(upstream)
		if cfg.recoveryWindowGain != 0 || cfg.ackHeightGain != 0 {
			t.Fatalf("%s must keep upstream behaviour (gains 0), got %+v",
				upstream, cfg)
		}
	}
}

// The ack-aggregation allowance: upstream adds the raw max ack height to
// the window, which on a shallow-buffered path measured 4-6 BDP and fed a
// standing queue. Realtime caps it at one BDP.
func TestRealtimeProfileCapsAckAggregationAllowance(t *testing.T) {
	for _, testCase := range []struct {
		profile Profile
		wantCap bool
	}{{ProfileStandard, false}, {ProfileRealtime, true}} {
		b := burstyLossEvent(t, testCase.profile)
		bdp := b.getTargetCongestionWindow(1.0)
		inflated := 5 * bdp
		b.sampler.maxAckHeightTracker.maxAckHeightFilter.Update(
			extraAckedEvent{extraAcked: inflated, round: b.roundTripCount},
			b.roundTripCount)
		if got := b.sampler.MaxAckHeight(); got != inflated {
			t.Fatalf("%s: test setup: max ack height = %d, want %d",
				testCase.profile, got, inflated)
		}
		got := b.ackAggregationAllowance()
		if testCase.wantCap && got != bdp {
			t.Fatalf("%s: allowance = %d, want the %d BDP cap",
				testCase.profile, got, bdp)
		}
		if !testCase.wantCap && got != inflated {
			t.Fatalf("%s: allowance = %d, want the uncapped %d",
				testCase.profile, got, inflated)
		}
		// The window itself follows: grown by acks toward the target, so
		// feed it generously and check where it lands.
		b.calculateCongestionWindow(1<<20, 0)
		target := b.getTargetCongestionWindow(b.congestionWindowGain)
		if testCase.wantCap && b.congestionWindow != target+bdp {
			t.Fatalf("%s: cwnd = %d, want target %d + BDP %d",
				testCase.profile, b.congestionWindow, target, bdp)
		}
		if !testCase.wantCap && b.congestionWindow != target+inflated {
			t.Fatalf("%s: cwnd = %d, want target %d + %d",
				testCase.profile, b.congestionWindow, target, inflated)
		}
	}
}
