// Copyright 2023 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package sendsidebwe

import (
	"fmt"
	"sync"
	"time"

	"github.com/livekit/livekit-server/pkg/sfu/bwe"
	"github.com/livekit/livekit-server/pkg/sfu/ccutils"
	"github.com/livekit/protocol/logger"
	"github.com/livekit/protocol/utils/mono"
	"github.com/pion/rtcp"
	"go.uber.org/zap/zapcore"
)

// -------------------------------------------------------------------------------

type CongestionSignalConfig struct {
	MinNumberOfGroups int           `yaml:"min_number_of_groups,omitempty"`
	MinDuration       time.Duration `yaml:"min_duration,omitempty"`
}

func (c CongestionSignalConfig) IsTriggered(numGroups int, duration int64) bool {
	return numGroups >= c.MinNumberOfGroups && duration >= c.MinDuration.Microseconds()
}

var (
	defaultQueuingDelayEarlyWarningJQRConfig = CongestionSignalConfig{
		MinNumberOfGroups: 2,
		MinDuration:       200 * time.Millisecond,
	}

	defaultQueuingDelayEarlyWarningDQRConfig = CongestionSignalConfig{
		MinNumberOfGroups: 3,
		MinDuration:       300 * time.Millisecond,
	}

	defaultLossEarlyWarningJQRConfig = CongestionSignalConfig{
		MinNumberOfGroups: 3,
		MinDuration:       300 * time.Millisecond,
	}

	defaultLossEarlyWarningDQRConfig = CongestionSignalConfig{
		MinNumberOfGroups: 4,
		MinDuration:       400 * time.Millisecond,
	}

	defaultQueuingDelayCongestedJQRConfig = CongestionSignalConfig{
		MinNumberOfGroups: 4,
		MinDuration:       400 * time.Millisecond,
	}

	defaultQueuingDelayCongestedDQRConfig = CongestionSignalConfig{
		MinNumberOfGroups: 5,
		MinDuration:       500 * time.Millisecond,
	}

	defaultLossCongestedJQRConfig = CongestionSignalConfig{
		MinNumberOfGroups: 6,
		MinDuration:       600 * time.Millisecond,
	}

	defaultLossCongestedDQRConfig = CongestionSignalConfig{
		MinNumberOfGroups: 6,
		MinDuration:       600 * time.Millisecond,
	}
)

// -------------------------------------------------------------------------------

type ProbeSignalConfig struct {
	MinBytesRatio    float64 `yaml:"min_bytes_ratio,omitempty"`
	MinDurationRatio float64 `yaml:"min_duration_ratio,omitempty"`

	JQRMinDelay time.Duration `yaml:"jqr_min_delay,omitempty"`
	DQRMaxDelay time.Duration `yaml:"dqr_max_delay,omitempty"`

	WeightedLoss       WeightedLossConfig `yaml:"weighted_loss,omitempty"`
	JQRMinWeightedLoss float64            `yaml:"jqr_min_weighted_loss,omitempty"`
	DQRMaxWeightedLoss float64            `yaml:"dqr_max_weighted_loss,omitempty"`
}

func (p ProbeSignalConfig) IsValid(pci ccutils.ProbeClusterInfo) bool {
	return pci.Result.Bytes() > int(p.MinBytesRatio*float64(pci.Goal.DesiredBytes)) && pci.Result.Duration() > time.Duration(p.MinDurationRatio*float64(pci.Goal.Duration))
}

func (p ProbeSignalConfig) ProbeSignal(ppg *probePacketGroup) (ccutils.ProbeSignal, int64) {
	ts := newTrafficStats(trafficStatsParams{
		Config: p.WeightedLoss,
	})
	ts.Merge(ppg.Traffic())

	pqd := ppg.PropagatedQueuingDelay()
	if pqd > p.JQRMinDelay.Microseconds() || ts.WeightedLoss() > p.JQRMinWeightedLoss {
		return ccutils.ProbeSignalCongesting, ts.AcknowledgedBitrate()
	}

	if pqd < p.DQRMaxDelay.Microseconds() && ts.WeightedLoss() < p.DQRMaxWeightedLoss {
		return ccutils.ProbeSignalNotCongesting, ts.AcknowledgedBitrate()
	}

	return ccutils.ProbeSignalInconclusive, ts.AcknowledgedBitrate()
}

var (
	defaultProbeSignalConfig = ProbeSignalConfig{
		MinBytesRatio:    0.5,
		MinDurationRatio: 0.5,

		JQRMinDelay: 50 * time.Millisecond,
		DQRMaxDelay: 20 * time.Millisecond,

		WeightedLoss:       defaultWeightedLossConfig,
		JQRMinWeightedLoss: 0.25,
		DQRMaxWeightedLoss: 0.1,
	}
)

// -------------------------------------------------------------------------------

type queuingRegion int

const (
	queuingRegionDQR queuingRegion = iota
	queuingRegionIndeterminate
	queuingRegionJQR
)

func (q queuingRegion) String() string {
	switch q {
	case queuingRegionDQR:
		return "DQR"
	case queuingRegionIndeterminate:
		return "INDETERMINATE"
	case queuingRegionJQR:
		return "JQR"
	default:
		return fmt.Sprintf("%d", int(q))
	}
}

// -------------------------------------------------------------------------------

type congestionReason int

const (
	congestionReasonNone congestionReason = iota
	congestionReasonQueuingDelay
	congestionReasonLoss
)

func (c congestionReason) String() string {
	switch c {
	case congestionReasonNone:
		return "NONE"
	case congestionReasonQueuingDelay:
		return "QUEUING_DELAY"
	case congestionReasonLoss:
		return "LOSS"
	default:
		return fmt.Sprintf("%d", int(c))
	}
}

// -------------------------------------------------------------------------------

type qdMeasurement struct {
	jqrConfig              CongestionSignalConfig
	dqrConfig              CongestionSignalConfig
	jqrMinDelay            int64
	jqrMinTrendCoefficient float64
	dqrMaxDelay            int64

	numGroups               int
	numJQRGroups            int
	numDQRGroups            int
	minSendTime             int64
	maxSendTime             int64
	propagatedQueuingDelays []int64

	isSealed    bool
	minGroupIdx int
	maxGroupIdx int

	queuingRegion queuingRegion
}

func newQDMeasurement(jqrConfig CongestionSignalConfig, dqrConfig CongestionSignalConfig, jqrMinDelay int64, jqrMinTrendCoefficient float64, dqrMaxDelay int64) *qdMeasurement {
	return &qdMeasurement{
		jqrConfig:              jqrConfig,
		dqrConfig:              dqrConfig,
		jqrMinDelay:            jqrMinDelay,
		jqrMinTrendCoefficient: jqrMinTrendCoefficient,
		dqrMaxDelay:            dqrMaxDelay,
		queuingRegion:          queuingRegionIndeterminate,
	}
}

func (q *qdMeasurement) ProcessPacketGroup(pg *packetGroup, groupIdx int) {
	if q.isSealed {
		return
	}

	pqd, pqdOk := pg.FinalizedPropagatedQueuingDelay()
	if !pqdOk {
		return
	}

	q.numGroups++
	if q.minGroupIdx == 0 || q.minGroupIdx > groupIdx {
		q.minGroupIdx = groupIdx
	}
	q.maxGroupIdx = max(q.maxGroupIdx, groupIdx)

	minSendTime, maxSendTime := pg.SendWindow()
	if q.minSendTime == 0 || minSendTime < q.minSendTime {
		q.minSendTime = minSendTime
	}
	q.maxSendTime = max(q.maxSendTime, maxSendTime)

	q.propagatedQueuingDelays = append(q.propagatedQueuingDelays, pqd)

	switch {
	case pqd < q.dqrMaxDelay:
		q.numDQRGroups++
		if q.numJQRGroups > 0 {
			// broken continuity, seal
			q.isSealed = true
		} else if q.dqrConfig.IsTriggered(q.numDQRGroups, q.maxSendTime-q.minSendTime) {
			q.isSealed = true
			q.queuingRegion = queuingRegionDQR
		}

	case pqd > q.jqrMinDelay:
		q.numJQRGroups++
		if q.numDQRGroups > 0 {
			// broken continuity, seal
			q.isSealed = true
		} else if q.jqrConfig.IsTriggered(q.numJQRGroups, q.maxSendTime-q.minSendTime) && q.trendCoefficient() > q.jqrMinTrendCoefficient {
			q.isSealed = true
			q.queuingRegion = queuingRegionJQR
		}

	default:
		if q.numDQRGroups > 0 || q.numJQRGroups > 0 {
			// broken continuity, seal
			q.isSealed = true
		}
	}
}

func (q *qdMeasurement) IsSealed() bool {
	return q.isSealed
}

func (q *qdMeasurement) QueuingRegion() queuingRegion {
	return q.queuingRegion
}

func (q *qdMeasurement) GroupRange() (int, int) {
	return max(0, q.minGroupIdx), max(0, q.maxGroupIdx)
}

func (q *qdMeasurement) MarshalLogObject(e zapcore.ObjectEncoder) error {
	if q == nil {
		return nil
	}

	e.AddInt("numGroups", q.numGroups)
	e.AddInt("numJQRGroups", q.numJQRGroups)
	e.AddInt("numDQRGroups", q.numDQRGroups)
	e.AddInt64("minSendTime", q.minSendTime)
	e.AddInt64("maxSendTime", q.maxSendTime)
	e.AddArray("propagatedQueuingDelays", logger.Int64Slice(q.propagatedQueuingDelays))
	e.AddFloat64("trendCoefficient", q.trendCoefficient())
	e.AddDuration("duration", time.Duration((q.maxSendTime-q.minSendTime)*1000))
	e.AddBool("isSealed", q.isSealed)
	e.AddInt("minGroupIdx", q.minGroupIdx)
	e.AddInt("maxGroupIdx", q.maxGroupIdx)
	e.AddString("queuingRegion", q.queuingRegion.String())
	return nil
}

func (q *qdMeasurement) trendCoefficient() float64 {
	concordantPairs := 0
	discordantPairs := 0

	// the packet groups are processed from newest to oldest,
	// so a concordant pair is when the value drops,
	// i. e. the propagated queuing delay is increasing if newer (earlier entity in slice) is higher than older (later entity in slice)
	for i := 0; i < len(q.propagatedQueuingDelays)-1; i++ {
		for j := i + 1; j < len(q.propagatedQueuingDelays); j++ {
			if q.propagatedQueuingDelays[i] > q.propagatedQueuingDelays[j] {
				concordantPairs++
			} else if q.propagatedQueuingDelays[i] < q.propagatedQueuingDelays[j] {
				discordantPairs++
			}
		}
	}

	if (concordantPairs + discordantPairs) == 0 {
		// if the min requirements is only one sample, trend calculation is not possible, declare highest trend value
		if len(q.propagatedQueuingDelays) == 1 {
			return 1.0
		}

		return 0.0
	}

	return (float64(concordantPairs) - float64(discordantPairs)) / (float64(concordantPairs) + float64(discordantPairs))
}

// -------------------------------------------------------------------------------

type lossMeasurement struct {
	jqrConfig  CongestionSignalConfig
	dqrConfig  CongestionSignalConfig
	jqrMinLoss float64
	dqrMaxLoss float64

	numGroups int
	ts        *trafficStats

	isJQRSealed bool
	isDQRSealed bool
	minGroupIdx int
	maxGroupIdx int

	weightedLoss float64

	queuingRegion queuingRegion
}

func newLossMeasurement(
	jqrConfig CongestionSignalConfig,
	dqrConfig CongestionSignalConfig,
	weightedLossConfig WeightedLossConfig,
	jqrMinLoss float64,
	dqrMaxLoss float64,
	logger logger.Logger,
) *lossMeasurement {
	return &lossMeasurement{
		jqrConfig:  jqrConfig,
		dqrConfig:  dqrConfig,
		jqrMinLoss: jqrMinLoss,
		dqrMaxLoss: dqrMaxLoss,
		ts: newTrafficStats(trafficStatsParams{
			Config: weightedLossConfig,
			Logger: logger,
		}),
		queuingRegion: queuingRegionIndeterminate,
	}
}

func (l *lossMeasurement) ProcessPacketGroup(pg *packetGroup, groupIdx int) {
	if (l.isJQRSealed && l.isDQRSealed) || !pg.IsFinalized() {
		return
	}

	l.numGroups++
	if l.minGroupIdx == 0 || l.minGroupIdx > groupIdx {
		l.minGroupIdx = groupIdx
	}
	l.maxGroupIdx = max(l.maxGroupIdx, groupIdx)

	l.ts.Merge(pg.Traffic())

	if !l.isJQRSealed && l.jqrConfig.IsTriggered(l.numGroups, l.ts.Duration()) {
		l.isJQRSealed = true

		weightedLoss := l.ts.WeightedLoss()
		if weightedLoss > l.jqrMinLoss {
			l.weightedLoss = weightedLoss
			l.queuingRegion = queuingRegionJQR
			l.isDQRSealed = true // seal DQR also as queuing region has been determined
			return
		}
	}

	if !l.isDQRSealed && l.dqrConfig.IsTriggered(l.numGroups, l.ts.Duration()) {
		l.isDQRSealed = true

		weightedLoss := l.ts.WeightedLoss()
		if weightedLoss < l.dqrMaxLoss {
			l.weightedLoss = weightedLoss
			l.queuingRegion = queuingRegionDQR
			l.isJQRSealed = true // seal JQR also as queuing region has been determined
			return
		}
	}
}

func (l *lossMeasurement) IsSealed() bool {
	return l.isJQRSealed && l.isDQRSealed
}

func (l *lossMeasurement) QueuingRegion() queuingRegion {
	return l.queuingRegion
}

func (l *lossMeasurement) GroupRange() (int, int) {
	return max(0, l.minGroupIdx), max(0, l.maxGroupIdx)
}

func (l *lossMeasurement) MarshalLogObject(e zapcore.ObjectEncoder) error {
	if l == nil {
		return nil
	}

	e.AddInt("numGroups", l.numGroups)
	e.AddObject("ts", l.ts)
	e.AddBool("isJQRSealed", l.isJQRSealed)
	e.AddBool("isDQRSealed", l.isDQRSealed)
	e.AddInt("minGroupIdx", l.minGroupIdx)
	e.AddInt("maxGroupIdx", l.maxGroupIdx)
	e.AddFloat64("weightedLoss", l.weightedLoss)
	e.AddString("queuingRegion", l.queuingRegion.String())
	return nil
}

// -------------------------------------------------------------------------------

type CongestionDetectorConfig struct {
	PacketGroup       PacketGroupConfig `yaml:"packet_group,omitempty"`
	PacketGroupMaxAge time.Duration     `yaml:"packet_group_max_age,omitempty"`

	ProbePacketGroup ProbePacketGroupConfig       `yaml:"probe_packet_group,omitempty"`
	ProbeRegulator   ccutils.ProbeRegulatorConfig `yaml:"probe_regulator,omitempty"`
	ProbeSignal      ProbeSignalConfig            `yaml:"probe_signal,omitempty"`

	JQRMinDelay            time.Duration `yaml:"jqr_min_delay,omitempty"`
	JQRMinTrendCoefficient float64       `yaml:"jqr_min_trend_coefficient,omitempty"`
	DQRMaxDelay            time.Duration `yaml:"dqr_max_delay,omitempty"`

	WeightedLoss       WeightedLossConfig `yaml:"weighted_loss,omitempty"`
	JQRMinWeightedLoss float64            `yaml:"jqr_min_weighted_loss,omitempty"`
	DQRMaxWeightedLoss float64            `yaml:"dqr_max_weighted_loss,omitempty"`

	QueuingDelayEarlyWarningJQR CongestionSignalConfig `yaml:"queuing_delay_early_warning_jqr,omitempty"`
	QueuingDelayEarlyWarningDQR CongestionSignalConfig `yaml:"queuing_delay_early_warning_dqr,omitempty"`
	LossEarlyWarningJQR         CongestionSignalConfig `yaml:"loss_early_warning_jqr,omitempty"`
	LossEarlyWarningDQR         CongestionSignalConfig `yaml:"loss_early_warning_dqr,omitempty"`

	QueuingDelayCongestedJQR CongestionSignalConfig `yaml:"queuing_delay_congested_jqr,omitempty"`
	QueuingDelayCongestedDQR CongestionSignalConfig `yaml:"queuing_delay_congested_dqr,omitempty"`
	LossCongestedJQR         CongestionSignalConfig `yaml:"loss_congested_jqr,omitempty"`
	LossCongestedDQR         CongestionSignalConfig `yaml:"loss_congested_dqr,omitempty"`

	CongestedCTRTrend    ccutils.TrendDetectorConfig `yaml:"congested_ctr_trend,omitempty"`
	CongestedCTREpsilon  float64                     `yaml:"congested_ctr_epsilon,omitempty"`
	CongestedPacketGroup PacketGroupConfig           `yaml:"congested_packet_group,omitempty"`

	EstimationWindowDuration time.Duration `yaml:"estimaton_window_duration,omitempty"`
}

var (
	defaultTrendDetectorConfigCongestedCTR = ccutils.TrendDetectorConfig{
		RequiredSamples:        4,
		RequiredSamplesMin:     2,
		DownwardTrendThreshold: -0.5,
		DownwardTrendMaxWait:   2 * time.Second,
		CollapseThreshold:      500 * time.Millisecond,
		ValidityWindow:         10 * time.Second,
	}

	defaultCongestedPacketGroupConfig = PacketGroupConfig{
		MinPackets:        20,
		MaxWindowDuration: 150 * time.Millisecond,
	}

	defaultCongestionDetectorConfig = CongestionDetectorConfig{
		PacketGroup:       defaultPacketGroupConfig,
		PacketGroupMaxAge: 10 * time.Second,

		ProbePacketGroup: defaultProbePacketGroupConfig,
		ProbeRegulator:   ccutils.DefaultProbeRegulatorConfig,
		ProbeSignal:      defaultProbeSignalConfig,

		JQRMinDelay:            50 * time.Millisecond,
		JQRMinTrendCoefficient: 0.8,
		DQRMaxDelay:            20 * time.Millisecond,

		WeightedLoss:       defaultWeightedLossConfig,
		JQRMinWeightedLoss: 0.25,
		DQRMaxWeightedLoss: 0.1,

		QueuingDelayEarlyWarningJQR: defaultQueuingDelayEarlyWarningJQRConfig,
		QueuingDelayEarlyWarningDQR: defaultQueuingDelayEarlyWarningDQRConfig,
		LossEarlyWarningJQR:         defaultLossEarlyWarningJQRConfig,
		LossEarlyWarningDQR:         defaultLossEarlyWarningDQRConfig,

		QueuingDelayCongestedJQR: defaultQueuingDelayCongestedJQRConfig,
		QueuingDelayCongestedDQR: defaultQueuingDelayCongestedDQRConfig,
		LossCongestedJQR:         defaultLossCongestedJQRConfig,
		LossCongestedDQR:         defaultLossCongestedDQRConfig,

		CongestedCTRTrend:    defaultTrendDetectorConfigCongestedCTR,
		CongestedCTREpsilon:  0.05,
		CongestedPacketGroup: defaultCongestedPacketGroupConfig,

		EstimationWindowDuration: time.Second,
	}
)

// -------------------------------------------------------------------------------

type congestionDetectorParams struct {
	Config CongestionDetectorConfig
	Logger logger.Logger
}

type congestionDetector struct {
	params congestionDetectorParams

	lock sync.Mutex

	rtt float64

	*packetTracker
	twccFeedback *twccFeedback

	packetGroups []*packetGroup

	probePacketGroup *probePacketGroup
	probeRegulator   *ccutils.ProbeRegulator

	estimatedAvailableChannelCapacity int64
	estimateTrafficStats              *trafficStats

	congestionState           bwe.CongestionState
	congestionStateSwitchedAt time.Time

	congestedCTRTrend     *ccutils.TrendDetector[float64]
	congestedTrafficStats *trafficStats
	congestedPacketGroup  *packetGroup

	congestionReason congestionReason
	qdMeasurement    *qdMeasurement
	lossMeasurement  *lossMeasurement

	bweListener bwe.BWEListener
}

func newCongestionDetector(params congestionDetectorParams) *congestionDetector {
	c := &congestionDetector{
		params:        params,
		packetTracker: newPacketTracker(packetTrackerParams{Logger: params.Logger}),
		twccFeedback:  newTWCCFeedback(twccFeedbackParams{Logger: params.Logger}),
	}
	c.Reset()

	return c
}

func (c *congestionDetector) Reset() {
	c.lock.Lock()
	defer c.lock.Unlock()

	c.rtt = bwe.DefaultRTT

	c.packetGroups = nil

	c.probePacketGroup = nil
	c.probeRegulator = ccutils.NewProbeRegulator(ccutils.ProbeRegulatorParams{
		Config: c.params.Config.ProbeRegulator,
		Logger: c.params.Logger,
	})

	c.estimatedAvailableChannelCapacity = 100_000_000
	c.estimateTrafficStats = nil

	c.congestionState = bwe.CongestionStateNone
	c.congestionStateSwitchedAt = mono.Now()

	c.clearCTRTrend()

	c.congestionReason = congestionReasonNone
	c.qdMeasurement = nil
	c.lossMeasurement = nil
}

func (c *congestionDetector) SetBWEListener(bweListener bwe.BWEListener) {
	c.lock.Lock()
	defer c.lock.Unlock()

	c.bweListener = bweListener
}

func (c *congestionDetector) getBWEListener() bwe.BWEListener {
	c.lock.Lock()
	defer c.lock.Unlock()

	return c.bweListener
}

func (c *congestionDetector) HandleTWCCFeedback(report *rtcp.TransportLayerCC) {
	c.lock.Lock()
	recvRefTime, _ := c.twccFeedback.ProcessReport(report, mono.Now())

	if len(c.packetGroups) == 0 {
		c.packetGroups = append(
			c.packetGroups,
			newPacketGroup(
				packetGroupParams{
					Config:       c.params.Config.PacketGroup,
					WeightedLoss: c.params.Config.WeightedLoss,
					Logger:       c.params.Logger,
				},
				0,
			),
		)
	}

	pg := c.packetGroups[len(c.packetGroups)-1]
	trackPacketGroup := func(pi *packetInfo, sendDelta, recvDelta int64, isLost bool) {
		if pi == nil {
			return
		}

		c.updateCTRTrend(pi, sendDelta, recvDelta, isLost)

		if c.probePacketGroup != nil {
			c.probePacketGroup.Add(pi, sendDelta, recvDelta, isLost)
		}

		err := pg.Add(pi, sendDelta, recvDelta, isLost)
		if err == nil {
			return
		}

		if err == errGroupFinalized {
			// previous group ended, start a new group
			pg = newPacketGroup(
				packetGroupParams{
					Config:       c.params.Config.PacketGroup,
					WeightedLoss: c.params.Config.WeightedLoss,
					Logger:       c.params.Logger,
				},
				pg.PropagatedQueuingDelay(),
			)
			c.packetGroups = append(c.packetGroups, pg)

			if err = pg.Add(pi, sendDelta, recvDelta, isLost); err != nil {
				c.params.Logger.Warnw("send side bwe: could not add packet to new packet group", err, "packetInfo", pi, "packetGroup", pg)
			}
			return
		}

		// try an older group
		for idx := len(c.packetGroups) - 2; idx >= 0; idx-- {
			opg := c.packetGroups[idx]
			if err := opg.Add(pi, sendDelta, recvDelta, isLost); err == nil {
				return
			} else if err == errGroupFinalized {
				c.params.Logger.Infow("send side bwe: unexpected finalized group", "packetInfo", pi, "packetGroup", opg)
			}
		}
	}

	// 1. go through the TWCC feedback report and record receive time as reported by remote
	// 2. process acknowledged packet and group them
	//
	// losses are not recorded if a feedback report is completely lost.
	// RFC recommends treating lost reports by ignoring packets that would have been in it.
	// -----------------------------------------------------------------------------------
	// | From a congestion control perspective, lost feedback messages are               |
	// | handled by ignoring packets which would have been reported as lost or           |
	// | received in the lost feedback messages.  This behavior is similar to            |
	// | how a lost RTCP receiver report is handled.                                     |
	// -----------------------------------------------------------------------------------
	// Reference: https://datatracker.ietf.org/doc/html/draft-holmer-rmcat-transport-wide-cc-extensions-01#page-4
	sequenceNumber := report.BaseSequenceNumber
	endSequenceNumberExclusive := sequenceNumber + report.PacketStatusCount
	deltaIdx := 0
	processSymbol := func(symbol uint16) {
		recvTime := int64(0)
		isLost := false
		if symbol != rtcp.TypeTCCPacketNotReceived {
			recvRefTime += report.RecvDeltas[deltaIdx].Delta
			deltaIdx++

			recvTime = recvRefTime
		} else {
			isLost = true
		}
		pi, sendDelta, recvDelta := c.packetTracker.RecordPacketIndicationFromRemote(sequenceNumber, recvTime)
		if pi.sendTime != 0 {
			trackPacketGroup(&pi, sendDelta, recvDelta, isLost)
		}
		sequenceNumber++
	}
	for _, chunk := range report.PacketChunks {
		if sequenceNumber == endSequenceNumberExclusive {
			break
		}

		switch chunk := chunk.(type) {
		case *rtcp.RunLengthChunk:
			for i := uint16(0); i < chunk.RunLength; i++ {
				if sequenceNumber == endSequenceNumberExclusive {
					break
				}

				processSymbol(chunk.PacketStatusSymbol)
			}

		case *rtcp.StatusVectorChunk:
			for _, symbol := range chunk.SymbolList {
				if sequenceNumber == endSequenceNumberExclusive {
					break
				}

				processSymbol(symbol)
			}
		}
	}

	c.prunePacketGroups()
	shouldNotify, fromState, toState, committedChannelCapacity := c.congestionDetectionStateMachine()
	c.lock.Unlock()

	if shouldNotify {
		if bweListener := c.getBWEListener(); bweListener != nil {
			bweListener.OnCongestionStateChange(fromState, toState, committedChannelCapacity)
		}
	}
}

func (c *congestionDetector) UpdateRTT(rtt float64) {
	c.lock.Lock()
	defer c.lock.Unlock()

	if rtt == 0 {
		c.rtt = bwe.DefaultRTT
	} else {
		if c.rtt == 0 {
			c.rtt = rtt
		} else {
			c.rtt = bwe.RTTSmoothingFactor*rtt + (1.0-bwe.RTTSmoothingFactor)*c.rtt
		}
	}
}

func (c *congestionDetector) CongestionState() bwe.CongestionState {
	c.lock.Lock()
	defer c.lock.Unlock()

	return c.congestionState
}

func (c *congestionDetector) CanProbe() bool {
	c.lock.Lock()
	defer c.lock.Unlock()

	return c.congestionState == bwe.CongestionStateNone && c.probePacketGroup == nil && c.probeRegulator.CanProbe()
}

func (c *congestionDetector) ProbeDuration() time.Duration {
	c.lock.Lock()
	defer c.lock.Unlock()

	return c.probeRegulator.ProbeDuration()
}

func (c *congestionDetector) ProbeClusterStarting(pci ccutils.ProbeClusterInfo) {
	c.lock.Lock()
	defer c.lock.Unlock()

	c.probePacketGroup = newProbePacketGroup(
		probePacketGroupParams{
			Config:       c.params.Config.ProbePacketGroup,
			WeightedLoss: c.params.Config.WeightedLoss,
			Logger:       c.params.Logger,
		},
		pci,
	)

	c.packetTracker.ProbeClusterStarting(pci.Id)
}

func (c *congestionDetector) ProbeClusterDone(pci ccutils.ProbeClusterInfo) {
	c.lock.Lock()
	defer c.lock.Unlock()

	c.packetTracker.ProbeClusterDone(pci.Id)
	if c.probePacketGroup != nil {
		c.probePacketGroup.ProbeClusterDone(pci)
	}
}

func (c *congestionDetector) ProbeClusterIsGoalReached() bool {
	c.lock.Lock()
	defer c.lock.Unlock()

	if c.probePacketGroup == nil || c.congestionState != bwe.CongestionStateNone {
		return false
	}

	pci := c.probePacketGroup.ProbeClusterInfo()
	if !c.params.Config.ProbeSignal.IsValid(pci) {
		return false
	}

	probeSignal, estimatedAvailableChannelCapacity := c.params.Config.ProbeSignal.ProbeSignal(c.probePacketGroup)
	return probeSignal != ccutils.ProbeSignalNotCongesting && estimatedAvailableChannelCapacity > int64(pci.Goal.DesiredBps)
}

func (c *congestionDetector) ProbeClusterFinalize() (ccutils.ProbeSignal, int64, bool) {
	c.lock.Lock()
	defer c.lock.Unlock()

	if c.probePacketGroup == nil {
		return ccutils.ProbeSignalInconclusive, 0, false
	}

	pci, isFinalized := c.probePacketGroup.MaybeFinalizeProbe(c.packetTracker.ProbeMaxSequenceNumber(), c.rtt)
	if !isFinalized {
		return ccutils.ProbeSignalInconclusive, 0, isFinalized
	}

	isSignalValid := c.params.Config.ProbeSignal.IsValid(pci)
	c.params.Logger.Infow(
		"send side bwe: probe finalized",
		"isSignalValid", isSignalValid,
		"probeClusterInfo", pci,
		"probePacketGroup", c.probePacketGroup,
		"congestionState", c.congestionState,
		"rtt", c.rtt,
	)

	// if congestion signal changed during probe, defer to that signal
	if c.congestionState != bwe.CongestionStateNone {
		probeSignal := ccutils.ProbeSignalCongesting
		c.probeRegulator.ProbeSignal(probeSignal, pci.CreatedAt)
		c.probePacketGroup = nil
		return probeSignal, c.estimatedAvailableChannelCapacity, true
	}

	probeSignal, estimatedAvailableChannelCapacity := c.params.Config.ProbeSignal.ProbeSignal(c.probePacketGroup)
	if probeSignal == ccutils.ProbeSignalNotCongesting && estimatedAvailableChannelCapacity > c.estimatedAvailableChannelCapacity {
		c.estimatedAvailableChannelCapacity = estimatedAvailableChannelCapacity
	}

	c.probeRegulator.ProbeSignal(probeSignal, pci.CreatedAt)
	c.probePacketGroup = nil
	return probeSignal, c.estimatedAvailableChannelCapacity, true
}

func (c *congestionDetector) prunePacketGroups() {
	if len(c.packetGroups) == 0 {
		return
	}

	threshold, ok := c.packetTracker.BaseSendTimeThreshold(c.params.Config.PacketGroupMaxAge.Microseconds())
	if !ok {
		return
	}

	for idx, pg := range c.packetGroups {
		if mst := pg.MinSendTime(); mst > threshold {
			c.packetGroups = c.packetGroups[idx:]
			return
		}
	}
}

func (c *congestionDetector) updateCongestionSignal(
	qdJQRConfig CongestionSignalConfig,
	qdDQRConfig CongestionSignalConfig,
	lossJQRConfig CongestionSignalConfig,
	lossDQRConfig CongestionSignalConfig,
) queuingRegion {
	c.qdMeasurement = newQDMeasurement(
		qdJQRConfig,
		qdDQRConfig,
		c.params.Config.JQRMinDelay.Microseconds(),
		c.params.Config.JQRMinTrendCoefficient,
		c.params.Config.DQRMaxDelay.Microseconds(),
	)
	c.lossMeasurement = newLossMeasurement(
		lossJQRConfig,
		lossDQRConfig,
		c.params.Config.WeightedLoss,
		c.params.Config.JQRMinWeightedLoss,
		c.params.Config.DQRMaxWeightedLoss,
		c.params.Logger,
	)

	var idx int
	for idx = len(c.packetGroups) - 1; idx >= 0; idx-- {
		pg := c.packetGroups[idx]
		c.qdMeasurement.ProcessPacketGroup(pg, idx)
		c.lossMeasurement.ProcessPacketGroup(pg, idx)

		// if both measurements have enough data to make a decision, stop processing groups
		if c.qdMeasurement.IsSealed() && c.lossMeasurement.IsSealed() {
			break
		}
	}

	qr := queuingRegionIndeterminate
	qdQueuingRegion := c.qdMeasurement.QueuingRegion()
	lossQueuingRegion := c.lossMeasurement.QueuingRegion()
	switch {
	case qdQueuingRegion == queuingRegionJQR:
		qr = queuingRegionJQR
		c.congestionReason = congestionReasonQueuingDelay
	case lossQueuingRegion == queuingRegionJQR:
		qr = queuingRegionJQR
		c.congestionReason = congestionReasonLoss
	case qdQueuingRegion == queuingRegionDQR && lossQueuingRegion == queuingRegionDQR:
		qr = queuingRegionDQR
		c.congestionReason = congestionReasonNone
	}

	return qr
}

func (c *congestionDetector) updateEarlyWarningSignal() queuingRegion {
	return c.updateCongestionSignal(
		c.params.Config.QueuingDelayEarlyWarningJQR,
		c.params.Config.QueuingDelayEarlyWarningDQR,
		c.params.Config.LossEarlyWarningJQR,
		c.params.Config.LossEarlyWarningDQR,
	)
}

func (c *congestionDetector) updateCongestedSignal() queuingRegion {
	return c.updateCongestionSignal(
		c.params.Config.QueuingDelayCongestedJQR,
		c.params.Config.QueuingDelayCongestedDQR,
		c.params.Config.LossCongestedJQR,
		c.params.Config.LossCongestedDQR,
	)
}

func (c *congestionDetector) congestionDetectionStateMachine() (bool, bwe.CongestionState, bwe.CongestionState, int64) {
	fromState := c.congestionState
	toState := c.congestionState

	switch fromState {
	case bwe.CongestionStateNone:
		if c.updateEarlyWarningSignal() == queuingRegionJQR {
			toState = bwe.CongestionStateEarlyWarning
		}

	case bwe.CongestionStateEarlyWarning:
		if c.updateCongestedSignal() == queuingRegionJQR {
			toState = bwe.CongestionStateCongested
		} else if c.updateEarlyWarningSignal() == queuingRegionDQR {
			toState = bwe.CongestionStateNone
		}

	case bwe.CongestionStateCongested:
		if c.updateCongestedSignal() == queuingRegionDQR {
			toState = bwe.CongestionStateNone
		}
	}

	shouldNotify := false
	if toState != fromState {
		c.estimateAvailableChannelCapacity()
		fromState, toState = c.updateCongestionState(toState)
		shouldNotify = true
	}

	if c.congestedCTRTrend != nil && c.congestedCTRTrend.GetDirection() == ccutils.TrendDirectionDownward {
		congestedAckedBitrate := c.congestedTrafficStats.AcknowledgedBitrate()
		if congestedAckedBitrate < c.estimatedAvailableChannelCapacity {
			c.estimatedAvailableChannelCapacity = congestedAckedBitrate

			c.params.Logger.Infow(
				"send side bwe: captured traffic ratio is trending downward",
				"channel", c.congestedCTRTrend,
				"trafficStats", c.congestedTrafficStats,
				"estimatedAvailableChannelCapacity", c.estimatedAvailableChannelCapacity,
			)

			shouldNotify = true
		}

		// reset to get new set of samples for next trend
		c.resetCTRTrend()
	}

	return shouldNotify, fromState, toState, c.estimatedAvailableChannelCapacity
}

func (c *congestionDetector) createCTRTrend() {
	c.resetCTRTrend()
	c.congestedPacketGroup = nil
}

func (c *congestionDetector) resetCTRTrend() {
	c.congestedCTRTrend = ccutils.NewTrendDetector[float64](ccutils.TrendDetectorParams{
		Name:   "ssbwe-ctr",
		Logger: c.params.Logger,
		Config: c.params.Config.CongestedCTRTrend,
	})
	c.congestedTrafficStats = newTrafficStats(trafficStatsParams{
		Config: c.params.Config.WeightedLoss,
		Logger: c.params.Logger,
	})
}

func (c *congestionDetector) clearCTRTrend() {
	c.congestedCTRTrend = nil
	c.congestedTrafficStats = nil
	c.congestedPacketGroup = nil
}

func (c *congestionDetector) updateCTRTrend(pi *packetInfo, sendDelta, recvDelta int64, isLost bool) {
	if c.congestedCTRTrend == nil {
		return
	}

	if c.congestedPacketGroup == nil {
		c.congestedPacketGroup = newPacketGroup(
			packetGroupParams{
				Config:       c.params.Config.CongestedPacketGroup,
				WeightedLoss: c.params.Config.WeightedLoss,
				Logger:       c.params.Logger,
			},
			0,
		)
	}

	if err := c.congestedPacketGroup.Add(pi, sendDelta, recvDelta, isLost); err == errGroupFinalized {
		// progressively keep increasing the window and make measurements over longer windows,
		// if congestion is not relieving, CTR will trend down
		c.congestedTrafficStats.Merge(c.congestedPacketGroup.Traffic())

		ts := newTrafficStats(trafficStatsParams{
			Config: c.params.Config.WeightedLoss,
			Logger: c.params.Logger,
		})
		ts.Merge(c.congestedPacketGroup.Traffic())
		ctr := ts.CapturedTrafficRatio()

		// quantise CTR to filter out small changes
		c.congestedCTRTrend.AddValue(float64(int((ctr+(c.params.Config.CongestedCTREpsilon/2))/c.params.Config.CongestedCTREpsilon)) * c.params.Config.CongestedCTREpsilon)

		c.congestedPacketGroup = newPacketGroup(
			packetGroupParams{
				Config:       c.params.Config.CongestedPacketGroup,
				WeightedLoss: c.params.Config.WeightedLoss,
				Logger:       c.params.Logger,
			},
			c.congestedPacketGroup.PropagatedQueuingDelay(),
		)
	}
}

func (c *congestionDetector) estimateAvailableChannelCapacity() {
	c.estimateTrafficStats = nil
	if len(c.packetGroups) == 0 || c.probePacketGroup != nil {
		return
	}

	// when congested, use contributing groups,
	// else use a time windowed measurement
	useWindow := false
	isAggValid := true
	minGroupIdx := 0
	maxGroupIdx := len(c.packetGroups) - 1
	switch c.congestionReason {
	case congestionReasonQueuingDelay:
		minGroupIdx, maxGroupIdx = c.qdMeasurement.GroupRange()
	case congestionReasonLoss:
		minGroupIdx, maxGroupIdx = c.lossMeasurement.GroupRange()
	default:
		useWindow = true
		isAggValid = false
	}

	agg := newTrafficStats(trafficStatsParams{
		Config: c.params.Config.WeightedLoss,
		Logger: c.params.Logger,
	})
	for idx := maxGroupIdx; idx >= minGroupIdx; idx-- {
		pg := c.packetGroups[idx]
		if !pg.IsFinalized() {
			continue
		}

		agg.Merge(pg.Traffic())
		if useWindow && agg.Duration() > c.params.Config.EstimationWindowDuration.Microseconds() {
			isAggValid = true
			break
		}
	}

	if isAggValid {
		c.estimatedAvailableChannelCapacity = agg.AcknowledgedBitrate()
		c.estimateTrafficStats = agg
	}
}

func (c *congestionDetector) updateCongestionState(state bwe.CongestionState) (bwe.CongestionState, bwe.CongestionState) {
	loggingFields := []any{
		"from", c.congestionState,
		"to", state,
		"congestionReason", c.congestionReason,
		"qdMeasurement", c.qdMeasurement,
		"lossMeasurement", c.lossMeasurement,
		"numPacketGroups", len(c.packetGroups),
		"estimatedAvailableChannelCapacity", c.estimatedAvailableChannelCapacity,
		"estimateTrafficStats", c.estimateTrafficStats,
	}
	if c.congestionReason != congestionReasonNone {
		var minGroupIdx, maxGroupIdx int
		switch c.congestionReason {
		case congestionReasonQueuingDelay:
			minGroupIdx, maxGroupIdx = c.qdMeasurement.GroupRange()
		case congestionReasonLoss:
			minGroupIdx, maxGroupIdx = c.lossMeasurement.GroupRange()
		}
		loggingFields = append(
			loggingFields,
			"contributingGroups", logger.ObjectSlice(c.packetGroups[minGroupIdx:maxGroupIdx+1]),
		)
	}
	c.params.Logger.Infow("send side bwe: congestion state change", loggingFields...)

	if state != c.congestionState {
		c.congestionStateSwitchedAt = mono.Now()
	}

	fromState := c.congestionState
	c.congestionState = state

	// when in congested state, monitor changes in captured traffic ratio (CTR)
	// to ensure allocations are in line with latest estimates, it is possible that
	// the estimate is incorrect when congestion starts and the allocation may be
	// sub-optimal and not enough to reduce/relieve congestion, by monitoring CTR
	// on a continuous basis allocations can be adjusted in the direction of
	// reducing/relieving congestion
	if state == bwe.CongestionStateCongested && fromState != bwe.CongestionStateCongested {
		c.createCTRTrend()
	} else if state != bwe.CongestionStateCongested {
		c.clearCTRTrend()
	}

	return fromState, c.congestionState
}
