package leg

import (
	"context"
	"encoding/binary"
	"fmt"
	"io"
	"log/slog"
	"math"
	"math/rand/v2"
	"net"
	"runtime/debug"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"errors"

	"github.com/VoiceBlender/voiceblender/internal/codec"
	"github.com/VoiceBlender/voiceblender/internal/codec/t140"
	"github.com/VoiceBlender/voiceblender/internal/jitter"
	sipmod "github.com/VoiceBlender/voiceblender/internal/sip"
	"github.com/emiago/sipgo/sip"
	"github.com/google/uuid"
	"github.com/pion/rtp"
)

const rtpTimeout = 30 * time.Second

// defaultRTTRedundancy is the RFC 2198 redundancy depth used for outgoing
// T.140 packets. Two generations is the level Linphone and other reference
// RFC 4103 implementations negotiate by default.
const defaultRTTRedundancy = 2

// SIPLeg wraps a SIP dialog (inbound or outbound) with RTP media handling.
type SIPLeg struct {
	id      string
	legType LegType
	state   LegState
	mu      sync.RWMutex

	// Exactly one of these is set.
	inbound  *sipmod.InboundCall
	outbound *sipmod.OutboundCall

	ctx           context.Context
	cancel        context.CancelFunc
	roomID        string
	appID         string
	role          string
	muted         atomic.Bool
	deaf          atomic.Bool
	acceptDTMF    atomic.Bool
	createdAt     time.Time
	answeredAt    time.Time     // zero if never answered
	answerCh      chan struct{} // signaled by REST answer endpoint (inbound only)
	connectedCh   chan struct{} // closed when leg reaches connected state
	connectedOnce sync.Once     // ensures connectedCh is closed exactly once
	onDTMF        func(digit rune)
	lastDTMFTS    uint32 // timestamp of last fired end-of-event (dedup RFC 4733 retransmits)
	onRTPTimeout  func() // called when no RTP received within timeout
	onHold        func() // called when leg is put on hold
	onUnhold      func() // called when leg is taken off hold

	callID    string      // SIP Call-ID for re-INVITE matching
	held      bool        // true when call is on hold
	holdTimer *time.Timer // 2-hour auto-hangup timer

	// Session timer (RFC 4028)
	sessionInterval  uint32      // negotiated interval in seconds (0 = no session timer)
	sessionRefresher string      // "uac" or "uas"
	sessionTimer     *time.Timer // refresh or expiry timer
	onSessionExpired func()      // called when session expires without refresh

	// Media
	rtpSess   *sipmod.RTPSession
	codecType codec.CodecType
	rtpPT     uint8 // RTP payload type we receive on (echoed in our SDP)
	// rtpSendPT is the PT used when sending RTP. For dynamic codecs where the
	// peer picks its own PT (AMR-WB), this is the remote PT and differs from
	// rtpPT; 0 means "use rtpPT" (the symmetric case for all other codecs).
	rtpSendPT uint8
	// DTMF (RFC 4733 telephone-event) send parameters, derived from the remote
	// SDP after negotiation. dtmfSendPT is the telephone-event PT to transmit
	// on (the PT the remote advertised); 0 means the default 101.
	// dtmfClockRate is the negotiated telephone-event clock rate — typically
	// the rate the peer advertised, not necessarily the audio codec's rate
	// (phones like Fanvil pin telephone-event at 8 kHz with an AMR-WB body).
	// 0 means the default 8kHz.
	dtmfSendPT    uint8
	dtmfClockRate int
	// AMR-WB negotiated parameters (only meaningful when codecType is AMR-WB).
	amrwbOctetAligned bool
	amrwbMode         int    // transmit mode (config ceiling clamped to peer mode-set)
	amrwbModeSet      string // peer's negotiated mode-set, echoed in our answer ("" = none)

	amrnbOctetAligned bool
	amrnbMode         int    // transmit mode (config ceiling clamped to peer mode-set)
	amrnbModeSet      string // peer's negotiated mode-set, echoed in our answer ("" = none)
	encoder           codec.Encoder
	decoder           codec.Decoder
	inFrames          chan []byte // decoded native-rate PCM from readLoop (or jitter-buffer popLoop)
	outFrames         chan []byte // native-rate PCM to encode in writeLoop
	dtmfCh            chan string // DTMF digits to send in writeLoop

	// Optional ingress jitter buffer. When non-nil, readLoop pushes decoded
	// PCM into jb keyed by RTP sequence number, and popLoop drains jb at a
	// fixed cadence into inFrames. When nil, readLoop pushes directly to
	// inFrames (passthrough — zero added latency, no reordering).
	jb           *jitter.Buffer
	jbTargetMs   int // target delay in ms; 0 = disabled
	jbMaxMs      int // max queue depth in ms
	jbFrameBytes int // native-rate 20ms frame size in bytes (silence size on underrun)

	earlyMediaSDP    []byte            // SDP sent in 183, reused in 200 OK on Answer
	sipHeaders       map[string]string // X-* headers from inbound INVITE or outbound request
	preferredCodec   codec.CodecType   // optional codec hint set via SignalAnswer; CodecUnknown = no preference
	disconnectReason string            // optional override for leg.disconnected reason; set by Reject() before dialog cancel

	// RTT (Real-Time Text, ITU-T T.140 / RFC 4103) — second RTP session and
	// a text codec layered on top of pion/rtp. rttNegotiated is set once SDP
	// agreed on m=text (so SendText can succeed). All non-pointer fields are
	// only written from within the leg's setup paths.
	textRtpSess   *sipmod.RTPSession
	textT140PT    uint8
	textREDPT     uint8
	textEncoder   *t140.Encoder
	textDecoder   *t140.Decoder
	textInCh      chan rttIn
	textOutCh     chan string
	acceptText    atomic.Bool
	onText        func(text string, lossMarker bool)
	rttNegotiated atomic.Bool
	rttRedundancy int
	rttBufferMs   int
	rttLocalPort  int // bound local UDP port for the text session

	// Terminate-once gates: racing termination paths converge here.
	byeOnce        sync.Once
	rejectOnce     sync.Once
	disconnectDone atomic.Bool

	engine          *sipmod.Engine    // for sending re-INVITEs
	localIP         string            // for SDP answer generation
	supportedCodecs []codec.CodecType // from engine config

	// Optional taps for recording on standalone legs.
	inTap  io.Writer // copy of decoded incoming PCM (before inFrames)
	outTap io.Writer // copy of outgoing PCM (from writeLoop, including silence)

	// AMD tap — separate from inTap so recording and AMD can coexist.
	amdTap io.Writer

	// Speaking detection tap — receives decoded incoming PCM for voice
	// activity detection. Separate from other taps so all can coexist.
	speakingTap io.Writer

	// Inbound RTP stream statistics for MOS calculation (protected by rtpStatsMu).
	rtpStatsMu     sync.Mutex
	rtpReceived    uint32
	rtpFirstSeq    uint16
	rtpLastSeq     uint16
	rtpHasFirst    bool
	rtpJitter      float64 // running jitter in RTP clock units (RFC 3550 §A.8)
	rtpLastTransit int64   // last transit time in RTP clock units

	log *slog.Logger
}

// rttIn is the queue entry for inbound RTT packets handed from textReadLoop
// to the on-text callback dispatcher. seq is per-leg monotonic for the
// rtt.received event payload (independent of RTP sequence numbers).
type rttIn struct {
	seq        uint64
	text       string
	lossMarker bool
}

// SetJitterBuffer configures the SIP ingress jitter buffer. targetMs is the
// target play-out delay (e.g. 60 for 60 ms); 0 disables the buffer entirely
// (passthrough). maxMs caps the queue depth; 0 means "use a sensible
// default" (300 ms). Call before the leg's media pipeline is established.
func (l *SIPLeg) SetJitterBuffer(targetMs, maxMs int) {
	if targetMs < 0 {
		targetMs = 0
	}
	if maxMs <= 0 {
		maxMs = 300
	}
	if maxMs < targetMs {
		maxMs = targetMs
	}
	l.mu.Lock()
	l.jbTargetMs = targetMs
	l.jbMaxMs = maxMs
	l.mu.Unlock()
}

// JitterBufferMs returns the configured target delay in milliseconds (0 =
// disabled).
func (l *SIPLeg) JitterBufferMs() int {
	l.mu.RLock()
	defer l.mu.RUnlock()
	return l.jbTargetMs
}

func NewSIPInboundLeg(call *sipmod.InboundCall, engine *sipmod.Engine, log *slog.Logger) *SIPLeg {
	ctx, cancel := context.WithCancel(call.Dialog.Context())

	// Extract X-* headers from the inbound INVITE request.
	var hdrs map[string]string
	if call.Request != nil {
		for _, h := range call.Request.Headers() {
			name := h.Name()
			if strings.HasPrefix(name, "X-") {
				if hdrs == nil {
					hdrs = make(map[string]string)
				}
				hdrs[name] = h.Value()
			}
		}
	}

	// Extract Call-ID for re-INVITE matching.
	var callID string
	if cid := call.Request.CallID(); cid != nil {
		callID = cid.Value()
	}

	offerFamily := ""
	if call.RemoteSDP != nil {
		offerFamily = call.RemoteSDP.AddressFamily
	}
	l := &SIPLeg{
		id:              uuid.New().String(),
		legType:         TypeSIPInbound,
		state:           StateRinging,
		createdAt:       time.Now(),
		inbound:         call,
		sipHeaders:      hdrs,
		ctx:             ctx,
		cancel:          cancel,
		answerCh:        make(chan struct{}),
		connectedCh:     make(chan struct{}),
		callID:          callID,
		engine:          engine,
		localIP:         engine.AdvertisedIPForFamily(offerFamily),
		supportedCodecs: engine.Codecs(),
		rttRedundancy:   defaultRTTRedundancy,
		rttBufferMs:     t140.DefaultBufferMs,
		log:             log,
	}
	l.acceptDTMF.Store(true)

	// Copy session timer params from inbound call.
	if call.SessionTimer != nil {
		l.sessionInterval = call.SessionTimer.Interval
		l.sessionRefresher = call.SessionTimer.Refresher
	}

	return l
}

func NewSIPOutboundLeg(call *sipmod.OutboundCall, engine *sipmod.Engine, log *slog.Logger) *SIPLeg {
	now := time.Now()
	ctx, cancel := context.WithCancel(call.Dialog.Context())
	l := &SIPLeg{
		id:         uuid.New().String(),
		legType:    TypeSIPOutbound,
		state:      StateConnected,
		createdAt:  now,
		answeredAt: now,
		outbound:   call,
		rtpSess:    call.RTPSess,
		engine:     engine,
		ctx:        ctx,
		cancel:     cancel,
		log:        log,
	}
	l.acceptDTMF.Store(true)

	// Negotiate codec from the remote answer SDP
	negotiated, remotePT, ok := sipmod.NegotiateCodec(call.RemoteSDP, engine.Codecs())
	if !ok {
		log.Error("no common codec with remote for outbound leg")
		return l
	}
	l.codecType = negotiated
	// As the offerer we receive on OUR payload type (from the offer SDP); for
	// dynamic codecs whose answerer PT differs (AMR-WB) the send PT is set from
	// the remote answer by configureAMRWB.
	l.rtpPT = negotiated.PayloadType()
	l.configureAMRWB(call.RemoteSDP, remotePT)
	l.configureAMRNB(call.RemoteSDP, remotePT)
	l.configureDTMF(call.RemoteSDP)
	l.setupMedia()
	l.adoptOutboundTextSession(call.RemoteSDP, call.TextRTPSess)
	l.setupTextMedia()
	return l
}

// NewSIPOutboundPendingLeg creates an outbound leg in ringing state with its
// own context. Call ConnectOutbound after the INVITE succeeds.
// If codecs is non-empty it overrides the engine's default codec list.
func NewSIPOutboundPendingLeg(engine *sipmod.Engine, codecs []codec.CodecType, log *slog.Logger) *SIPLeg {
	supported := engine.Codecs()
	if len(codecs) > 0 {
		supported = codecs
	}
	ctx, cancel := context.WithCancel(context.Background())
	l := &SIPLeg{
		id:              uuid.New().String(),
		legType:         TypeSIPOutbound,
		state:           StateRinging,
		createdAt:       time.Now(),
		ctx:             ctx,
		cancel:          cancel,
		engine:          engine,
		localIP:         engine.BindIP(),
		supportedCodecs: supported,
		rttRedundancy:   defaultRTTRedundancy,
		rttBufferMs:     t140.DefaultBufferMs,
		log:             log,
	}
	l.acceptDTMF.Store(true)
	return l
}

// SetupEarlyMediaOutbound configures the media pipeline from a 183 response's
// SDP before the call is answered. Only valid in StateRinging.
func (l *SIPLeg) SetupEarlyMediaOutbound(remoteSDP *sipmod.SDPMedia, rtpSess *sipmod.RTPSession) error {
	l.mu.Lock()
	if l.state != StateRinging {
		st := l.state
		l.mu.Unlock()
		return fmt.Errorf("leg is %s, not ringing", st)
	}
	l.mu.Unlock()

	negotiated, remotePT, ok := sipmod.NegotiateCodec(remoteSDP, l.supportedCodecs)
	if !ok {
		return fmt.Errorf("no common codec with remote")
	}

	l.mu.Lock()
	l.rtpSess = rtpSess
	l.codecType = negotiated
	l.rtpPT = negotiated.PayloadType()
	l.configureAMRWB(remoteSDP, remotePT)
	l.configureAMRNB(remoteSDP, remotePT)
	l.configureDTMF(remoteSDP)
	l.mu.Unlock()

	l.setupMedia()
	l.setupTextMedia()
	l.setState(StateEarlyMedia)
	return nil
}

// ConnectOutbound sets the answered outbound call on the leg, negotiates the
// codec, starts media, and transitions to connected.
func (l *SIPLeg) ConnectOutbound(call *sipmod.OutboundCall) error {
	l.mu.Lock()
	st := l.state
	if st != StateRinging && st != StateEarlyMedia {
		l.mu.Unlock()
		return fmt.Errorf("leg is %s, expected ringing or early_media", st)
	}
	l.outbound = call
	if st == StateRinging {
		l.rtpSess = call.RTPSess
	}
	// Extract Call-ID for re-INVITE matching.
	if call.Dialog.InviteRequest != nil {
		if cid := call.Dialog.InviteRequest.CallID(); cid != nil {
			l.callID = cid.Value()
		}
	}
	l.mu.Unlock()

	if st == StateRinging {
		negotiated, remotePT, ok := sipmod.NegotiateCodec(call.RemoteSDP, l.supportedCodecs)
		if !ok {
			return fmt.Errorf("no common codec with remote")
		}
		l.codecType = negotiated
		l.rtpPT = negotiated.PayloadType()
		l.configureAMRWB(call.RemoteSDP, remotePT)
		l.configureAMRNB(call.RemoteSDP, remotePT)
		l.configureDTMF(call.RemoteSDP)
		l.setupMedia()
		l.adoptOutboundTextSession(call.RemoteSDP, call.TextRTPSess)
		l.setupTextMedia()
	} else if !l.RTTNegotiated() {
		// Coming from early-media: adopt the text session if it wasn't
		// already adopted by SetupEarlyMediaOutbound.
		l.adoptOutboundTextSession(call.RemoteSDP, call.TextRTPSess)
		l.setupTextMedia()
	}

	// Pick up session timer params from the outbound call's 200 OK.
	if call.SessionTimer != nil {
		l.mu.Lock()
		l.sessionInterval = call.SessionTimer.Interval
		l.sessionRefresher = call.SessionTimer.Refresher
		l.mu.Unlock()
	}

	l.mu.Lock()
	l.answeredAt = time.Now()
	l.mu.Unlock()
	l.setState(StateConnected)
	l.startSessionTimer()
	return nil
}

func (l *SIPLeg) ID() string      { return l.id }
func (l *SIPLeg) Type() LegType   { return l.legType }
func (l *SIPLeg) SampleRate() int { return l.codecType.SampleRate() }

func (l *SIPLeg) State() LegState {
	l.mu.RLock()
	defer l.mu.RUnlock()
	return l.state
}

func (l *SIPLeg) setState(s LegState) {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.state = s
	if s == StateConnected && l.connectedCh != nil {
		l.connectedOnce.Do(func() { close(l.connectedCh) })
	}
}

func (l *SIPLeg) Context() context.Context { return l.ctx }

func (l *SIPLeg) RoomID() string {
	l.mu.RLock()
	defer l.mu.RUnlock()
	return l.roomID
}

func (l *SIPLeg) SetRoomID(id string) {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.roomID = id
}

func (l *SIPLeg) AppID() string {
	l.mu.RLock()
	defer l.mu.RUnlock()
	return l.appID
}

func (l *SIPLeg) SetAppID(id string) {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.appID = id
}

func (l *SIPLeg) Role() string {
	l.mu.RLock()
	defer l.mu.RUnlock()
	return l.role
}

func (l *SIPLeg) SetRole(r string) {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.role = r
}

func (l *SIPLeg) IsMuted() bool             { return l.muted.Load() }
func (l *SIPLeg) SetMuted(m bool)           { l.muted.Store(m) }
func (l *SIPLeg) AcceptDTMF() bool          { return l.acceptDTMF.Load() }
func (l *SIPLeg) SetAcceptDTMF(accept bool) { l.acceptDTMF.Store(accept) }
func (l *SIPLeg) IsDeaf() bool              { return l.deaf.Load() }
func (l *SIPLeg) SetDeaf(d bool)            { l.deaf.Store(d) }

func (l *SIPLeg) CreatedAt() time.Time { return l.createdAt }

func (l *SIPLeg) AnsweredAt() time.Time {
	l.mu.RLock()
	defer l.mu.RUnlock()
	return l.answeredAt
}

func (l *SIPLeg) SIPHeaders() map[string]string { return l.sipHeaders }
func (l *SIPLeg) Headers() map[string]string    { return l.sipHeaders }

// AnswerCh returns the channel that is closed when the REST answer endpoint is called.
func (l *SIPLeg) AnswerCh() <-chan struct{} {
	return l.answerCh
}

// WaitConnected blocks until the leg reaches connected state or the context
// is cancelled. Returns nil if connected, or the context error otherwise.
func (l *SIPLeg) WaitConnected(ctx context.Context) error {
	if l.connectedCh == nil {
		return nil
	}
	select {
	case <-l.connectedCh:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

// SignalAnswer signals the leg to answer (called from REST API). preferred
// is an optional codec hint passed to Answer; pass CodecUnknown for none.
func (l *SIPLeg) SignalAnswer(preferred codec.CodecType) {
	l.mu.Lock()
	l.preferredCodec = preferred
	l.mu.Unlock()
	select {
	case l.answerCh <- struct{}{}:
	default:
	}
}

// RemoteOfferCodecs returns the codecs offered by the remote in the inbound
// INVITE SDP, in offer order. Returns nil for outbound legs or when no offer
// has been parsed yet.
func (l *SIPLeg) RemoteOfferCodecs() []codec.CodecType {
	if l.inbound == nil || l.inbound.RemoteSDP == nil {
		return nil
	}
	out := make([]codec.CodecType, len(l.inbound.RemoteSDP.Codecs))
	copy(out, l.inbound.RemoteSDP.Codecs)
	return out
}

// SupportedCodecs returns the engine's supported codecs for this leg, in
// preference order. Used by the REST layer to reject answer/early-media
// requests that name a codec the engine wasn't configured for.
func (l *SIPLeg) SupportedCodecs() []codec.CodecType {
	out := make([]codec.CodecType, len(l.supportedCodecs))
	copy(out, l.supportedCodecs)
	return out
}

// Reject sends a final non-2xx response on an unanswered inbound leg,
// terminating the dialog without ever creating a session. statusCode is the
// SIP status code (e.g. 486, 603); reasonPhrase is the SIP reason phrase
// shown after the status code on the response line.
//
// Only valid on inbound legs in StateRinging or StateEarlyMedia. After
// Reject succeeds the dialog context is cancelled by sipgo, so the
// inbound-call goroutine wakes up and publishes leg.disconnected — if the
// caller wants the disconnect event to carry a specific reason, it should
// call SetDisconnectReason first.
func (l *SIPLeg) Reject(ctx context.Context, statusCode int, reasonPhrase string) error {
	if l.inbound == nil {
		return fmt.Errorf("cannot reject outbound leg")
	}
	l.mu.RLock()
	st := l.state
	l.mu.RUnlock()
	if st != StateRinging && st != StateEarlyMedia {
		return fmt.Errorf("leg is %s, expected ringing or early_media", st)
	}
	var respErr error
	l.rejectOnce.Do(func() {
		l.setState(StateHungUp)
		if err := l.engine.DialogRespond(
			l.inbound.Dialog,
			statusCode, reasonPhrase, nil,
			l.engine.ServerHeader(),
		); err != nil {
			respErr = fmt.Errorf("respond %d: %w", statusCode, err)
			return
		}
		l.cancel()
	})
	return respErr
}

// Challenge sends a 401 Unauthorized with a digest WWW-Authenticate challenge
// on an unanswered inbound leg, terminating the dialog. The credentialed
// re-INVITE arrives as a fresh inbound call (same Call-ID) which the engine
// verifies via VerifyInboundAuth. Like Reject, this is a terminal response on
// the INVITE transaction and is guarded by the same once-gate.
//
// Only valid on inbound legs in StateRinging or StateEarlyMedia.
func (l *SIPLeg) Challenge(ctx context.Context, params sipmod.ChallengeParams) error {
	if l.inbound == nil {
		return fmt.Errorf("cannot challenge outbound leg")
	}
	l.mu.RLock()
	st := l.state
	l.mu.RUnlock()
	if st != StateRinging && st != StateEarlyMedia {
		return fmt.Errorf("leg is %s, expected ringing or early_media", st)
	}
	var respErr error
	l.rejectOnce.Do(func() {
		l.setState(StateHungUp)
		if err := l.engine.ChallengeInvite(l.inbound, params); err != nil {
			respErr = fmt.Errorf("respond 401: %w", err)
			return
		}
		l.cancel()
	})
	return respErr
}

// SetDisconnectReason stores a reason that will be used in the next
// leg.disconnected event published for this leg, in place of the goroutine's
// default. Set by the API DELETE handler before calling Reject or Hangup so
// the user-provided cause flows through to the event.
func (l *SIPLeg) SetDisconnectReason(reason string) {
	l.mu.Lock()
	l.disconnectReason = reason
	l.mu.Unlock()
}

// DisconnectReason returns the override set by SetDisconnectReason, or "" if
// none. Consumed by the inbound-call goroutine when publishing
// leg.disconnected.
func (l *SIPLeg) DisconnectReason() string {
	l.mu.RLock()
	defer l.mu.RUnlock()
	return l.disconnectReason
}

// SendRinging sends a 180 Ringing provisional response with no SDP. Only
// valid for inbound legs in StateRinging. May be called multiple times —
// each call emits another 180 (RFC-allowed; receivers tolerate re-sends).
func (l *SIPLeg) SendRinging(ctx context.Context) error {
	if l.inbound == nil {
		return fmt.Errorf("cannot send ringing on outbound leg")
	}
	l.mu.RLock()
	st := l.state
	l.mu.RUnlock()
	if st != StateRinging {
		return fmt.Errorf("leg is %s, not ringing", st)
	}
	if err := l.engine.DialogRespond(
		l.inbound.Dialog,
		sip.StatusRinging, "Ringing", nil,
		l.engine.ServerHeader(),
	); err != nil {
		return fmt.Errorf("send 180: %w", err)
	}
	return nil
}

// EnableEarlyMedia sends 183 Session Progress with SDP and sets up the media
// pipeline so audio can flow before the call is answered. Only valid for
// inbound legs in StateRinging.
//
// preferred biases codec selection: when non-zero and present in both the
// remote offer and the supported list, it wins; otherwise selection falls
// back to the default offer-order preference.
func (l *SIPLeg) EnableEarlyMedia(ctx context.Context, preferred codec.CodecType) error {
	if l.inbound == nil {
		return fmt.Errorf("cannot enable early media on outbound leg")
	}

	l.mu.RLock()
	st := l.state
	l.mu.RUnlock()
	if st != StateRinging {
		return fmt.Errorf("leg is %s, not ringing", st)
	}

	// Negotiate codec from remote offer
	negotiated, pt, ok := sipmod.NegotiateCodecPreferred(l.inbound.RemoteSDP, l.supportedCodecs, preferred)
	if !ok {
		return fmt.Errorf("no common codec negotiated")
	}
	l.codecType = negotiated
	l.rtpPT = pt
	l.configureAMRWB(l.inbound.RemoteSDP, pt)
	l.configureAMRNB(l.inbound.RemoteSDP, pt)
	l.configureDTMF(l.inbound.RemoteSDP)

	// Create RTP session
	rtpSess, err := sipmod.NewRTPSessionFromAllocator(l.engine.PortAllocator())
	if err != nil {
		return fmt.Errorf("create RTP session: %w", err)
	}
	l.rtpSess = rtpSess

	// Set remote RTP address from the offer SDP
	if err := rtpSess.SetRemote(l.inbound.RemoteSDP.RemoteIP, l.inbound.RemoteSDP.RemotePort); err != nil {
		rtpSess.Close()
		return fmt.Errorf("set remote: %w", err)
	}

	// Optionally negotiate RTT (m=text) alongside audio.
	textPort, t140PT, redPT, textRejected := l.setupInboundTextMedia(l.inbound.RemoteSDP)

	// Generate answer SDP — echo the remote's PT and AMR-WB framing.
	answerSDP := sipmod.GenerateAnswer(sipmod.SDPConfig{
		LocalIP:           l.localIP,
		RTPPort:           rtpSess.LocalPort(),
		Codecs:            l.supportedCodecs,
		TextRTPPort:       textPort,
		TextT140PT:        t140PT,
		TextREDPT:         redPT,
		RTTRedundancy:     l.rttRedundancy,
		AMRWBOctetAligned: l.amrwbOctetAligned,
		AMRWBModeSet:      l.amrwbModeSet,
		AMRNBOctetAligned: l.amrnbOctetAligned,
		AMRNBModeSet:      l.amrnbModeSet,
		DTMFPT:            l.dtmfSendPT,
		DTMFClockRate:     l.dtmfClockRate,
	}, negotiated, pt, textRejected)

	// Store SDP for reuse in Answer()
	l.mu.Lock()
	l.earlyMediaSDP = answerSDP
	l.mu.Unlock()

	// Send 183 Session Progress with SDP
	if err := l.engine.DialogRespond(
		l.inbound.Dialog,
		sip.StatusSessionInProgress, "Session Progress",
		answerSDP,
		sip.NewHeader("Content-Type", "application/sdp"),
		l.engine.ServerHeader(),
	); err != nil {
		rtpSess.Close()
		return fmt.Errorf("send 183: %w", err)
	}

	l.setupMedia()
	l.setupTextMedia()
	l.setState(StateEarlyMedia)
	return nil
}

// Answer sends 200 OK to the inbound INVITE. Codec selection respects any
// hint set via SignalAnswer; a CodecUnknown hint falls back to the default
// offer-order preference. The hint is ignored when the leg is already in
// StateEarlyMedia (the codec was locked in at 183).
func (l *SIPLeg) Answer(ctx context.Context) error {
	if l.inbound == nil {
		return fmt.Errorf("cannot answer outbound leg")
	}

	// If early media is active, reuse the existing SDP and RTP session.
	l.mu.RLock()
	sdp := l.earlyMediaSDP
	st := l.state
	preferred := l.preferredCodec
	l.mu.RUnlock()

	if st == StateEarlyMedia && sdp != nil {
		if l.sessionInterval > 0 {
			// Use Respond to include session timer headers.
			if err := l.engine.DialogRespond(l.inbound.Dialog, sip.StatusOK, "OK", sdp,
				sip.NewHeader("Content-Type", "application/sdp"),
				sip.NewHeader("Supported", "timer"),
				sip.NewHeader("Session-Expires", sipmod.FormatSessionExpires(l.sessionInterval, l.sessionRefresher)),
				l.engine.ServerHeader(),
			); err != nil {
				return fmt.Errorf("respond SDP: %w", err)
			}
		} else {
			if err := l.engine.DialogRespond(l.inbound.Dialog, sip.StatusOK, "OK", sdp,
				sip.NewHeader("Content-Type", "application/sdp"),
				l.engine.ServerHeader(),
			); err != nil {
				return fmt.Errorf("respond SDP: %w", err)
			}
		}
		l.mu.Lock()
		l.answeredAt = time.Now()
		l.mu.Unlock()
		l.setState(StateConnected)
		l.startSessionTimer()
		return nil
	}

	// Normal answer path from ringing state.

	// Negotiate codec from remote offer
	negotiated, pt, ok := sipmod.NegotiateCodecPreferred(l.inbound.RemoteSDP, l.supportedCodecs, preferred)
	if !ok {
		return fmt.Errorf("no common codec negotiated")
	}
	l.codecType = negotiated
	l.rtpPT = pt
	l.configureAMRWB(l.inbound.RemoteSDP, pt)
	l.configureAMRNB(l.inbound.RemoteSDP, pt)
	l.configureDTMF(l.inbound.RemoteSDP)

	// Create RTP session
	rtpSess, err := sipmod.NewRTPSessionFromAllocator(l.engine.PortAllocator())
	if err != nil {
		return fmt.Errorf("create RTP session: %w", err)
	}
	l.rtpSess = rtpSess

	// Set remote RTP address from the offer SDP
	if err := rtpSess.SetRemote(l.inbound.RemoteSDP.RemoteIP, l.inbound.RemoteSDP.RemotePort); err != nil {
		rtpSess.Close()
		return fmt.Errorf("set remote: %w", err)
	}

	// Optionally negotiate RTT (m=text) alongside audio.
	textPort, t140PT, redPT, textRejected := l.setupInboundTextMedia(l.inbound.RemoteSDP)

	// Generate answer SDP — echo the remote's PT and AMR-WB framing.
	answerSDP := sipmod.GenerateAnswer(sipmod.SDPConfig{
		LocalIP:           l.localIP,
		RTPPort:           rtpSess.LocalPort(),
		Codecs:            l.supportedCodecs,
		TextRTPPort:       textPort,
		TextT140PT:        t140PT,
		TextREDPT:         redPT,
		RTTRedundancy:     l.rttRedundancy,
		AMRWBOctetAligned: l.amrwbOctetAligned,
		AMRWBModeSet:      l.amrwbModeSet,
		AMRNBOctetAligned: l.amrnbOctetAligned,
		AMRNBModeSet:      l.amrnbModeSet,
		DTMFPT:            l.dtmfSendPT,
		DTMFClockRate:     l.dtmfClockRate,
	}, negotiated, pt, textRejected)

	// Send 200 OK with SDP answer
	if l.sessionInterval > 0 {
		// Include session timer headers in 200 OK.
		if err := l.engine.DialogRespond(l.inbound.Dialog, sip.StatusOK, "OK", answerSDP,
			sip.NewHeader("Content-Type", "application/sdp"),
			sip.NewHeader("Supported", "timer"),
			sip.NewHeader("Session-Expires", sipmod.FormatSessionExpires(l.sessionInterval, l.sessionRefresher)),
			l.engine.ServerHeader(),
		); err != nil {
			rtpSess.Close()
			return fmt.Errorf("respond SDP: %w", err)
		}
	} else {
		if err := l.engine.DialogRespond(l.inbound.Dialog, sip.StatusOK, "OK", answerSDP,
			sip.NewHeader("Content-Type", "application/sdp"),
			l.engine.ServerHeader(),
		); err != nil {
			rtpSess.Close()
			return fmt.Errorf("respond SDP: %w", err)
		}
	}

	l.setupMedia()
	l.setupTextMedia()
	l.mu.Lock()
	l.answeredAt = time.Now()
	l.mu.Unlock()
	l.setState(StateConnected)
	l.startSessionTimer()
	return nil
}

// defaultAMRWBEncoderMode is the AMR-WB speech mode used when the engine does
// not supply one (e.g. legs built without an engine in tests). 8 = 23.85 kbit/s.
const defaultAMRWBEncoderMode = 8

// defaultAMRNBEncoderMode is the AMR-NB speech mode used when the engine does
// not supply one. 7 = 12.2 kbit/s (GSM-EFR-equivalent, highest AMR-NB quality).
const defaultAMRNBEncoderMode = 7

// configureAMRWB records AMR-WB-specific negotiation results: the remote send
// PT and the payload framing (octet-aligned vs bandwidth-efficient) read from
// the peer's fmtp, plus the configured encoder mode. No-op for other codecs.
func (l *SIPLeg) configureAMRWB(remoteSDP *sipmod.SDPMedia, remotePT uint8) {
	if l.codecType != codec.CodecAMRWB {
		return
	}
	l.rtpSendPT = remotePT
	l.amrwbMode = defaultAMRWBEncoderMode
	if l.engine != nil {
		l.amrwbMode = l.engine.AMRWBMode()
	}
	l.amrwbModeSet = ""
	if remoteSDP != nil {
		fmtp := remoteSDP.CodecFmtp[codec.CodecAMRWB]
		l.amrwbOctetAligned = sipmod.AMRWBOctetAligned(fmtp)
		// Honor the peer's mode-set: clamp our (ceiling) transmit mode to it and
		// echo it back in our answer per RFC 4867.
		if modeSet := sipmod.AMRWBModeSet(fmtp); len(modeSet) > 0 {
			l.amrwbMode = sipmod.ClampAMRWBMode(l.amrwbMode, modeSet)
			l.amrwbModeSet = sipmod.FormatAMRWBModeSet(modeSet)
		}
	}
}

// configureAMRNB mirrors configureAMRWB for the narrowband variant. No-op when
// the negotiated codec is not AMR-NB. AMR-NB modes span 0..7.
func (l *SIPLeg) configureAMRNB(remoteSDP *sipmod.SDPMedia, remotePT uint8) {
	if l.codecType != codec.CodecAMRNB {
		return
	}
	l.rtpSendPT = remotePT
	l.amrnbMode = defaultAMRNBEncoderMode
	if l.engine != nil {
		l.amrnbMode = l.engine.AMRNBMode()
	}
	l.amrnbModeSet = ""
	if remoteSDP != nil {
		fmtp := remoteSDP.CodecFmtp[codec.CodecAMRNB]
		l.amrnbOctetAligned = sipmod.AMRNBOctetAligned(fmtp)
		if modeSet := sipmod.AMRNBModeSet(fmtp); len(modeSet) > 0 {
			l.amrnbMode = sipmod.ClampAMRNBMode(l.amrnbMode, modeSet)
			l.amrnbModeSet = sipmod.FormatAMRNBModeSet(modeSet)
		}
	}
}

// configureDTMF picks the telephone-event PT and clock rate for outbound DTMF
// from the remote SDP, honouring whatever the peer advertised (RFC 3264
// offer/answer). RFC 4733 recommends matching the audio codec's clock rate,
// but real phones disagree — MicroSIP pairs AMR-WB with telephone-event/16000,
// Fanvil pairs it with telephone-event/8000 — so we trust the peer rather
// than impose a rate. Falls back to PT 101 at the codec-conventional rate
// when no telephone-event was offered.
func (l *SIPLeg) configureDTMF(remoteSDP *sipmod.SDPMedia) {
	l.dtmfSendPT = 101
	l.dtmfClockRate = sipmod.TelephoneEventClockRate(l.codecType)
	if remoteSDP == nil {
		return
	}
	if pt, rate, ok := remoteSDP.PreferredDTMFEvent(); ok {
		l.dtmfSendPT = pt
		l.dtmfClockRate = rate
	}
}

func (l *SIPLeg) setupMedia() {
	var err error
	if l.codecType == codec.CodecAMRWB {
		l.encoder, err = codec.NewAMRWBEncoder(l.amrwbMode, l.amrwbOctetAligned)
		if err != nil {
			l.log.Error("create encoder failed", "codec", l.codecType, "error", err)
			return
		}
		l.decoder = codec.NewAMRWBDecoder(l.amrwbOctetAligned)
	} else if l.codecType == codec.CodecAMRNB {
		l.encoder, err = codec.NewAMRNBEncoder(l.amrnbMode, l.amrnbOctetAligned)
		if err != nil {
			l.log.Error("create encoder failed", "codec", l.codecType, "error", err)
			return
		}
		l.decoder = codec.NewAMRNBDecoder(l.amrnbOctetAligned)
	} else {
		l.encoder, err = codec.NewEncoder(l.codecType)
		if err != nil {
			l.log.Error("create encoder failed", "codec", l.codecType, "error", err)
			return
		}
		l.decoder, err = codec.NewDecoder(l.codecType)
		if err != nil {
			l.log.Error("create decoder failed", "codec", l.codecType, "error", err)
			return
		}
	}

	l.inFrames = make(chan []byte, 5)
	l.outFrames = make(chan []byte, 5)
	l.dtmfCh = make(chan string, 5)

	// 20 ms of samples at the codec's native rate, 16-bit mono LE.
	l.jbFrameBytes = l.codecType.SampleRate() / 50 * 2

	// Construct the jitter buffer if enabled.
	if l.jbTargetMs > 0 {
		l.jb = jitter.NewMs(l.jbTargetMs, l.jbMaxMs, 20)
	}

	l.log.Info("SIP leg media setup",
		"leg_id", l.id,
		"codec", l.codecType.String(),
		"payload_type", l.rtpPT,
		"sample_rate", l.codecType.SampleRate(),
		"clock_rate", l.codecType.ClockRate(),
		"jitter_buffer_ms", l.jbTargetMs,
	)

	go l.readLoop()
	if l.jb != nil {
		go l.popLoop()
	}
	go l.writeLoop()
}

// popLoop drains the jitter buffer at a fixed 20 ms cadence and pushes the
// resulting PCM frame (or silence on underrun) into inFrames. Runs only
// when an ingress jitter buffer is enabled; otherwise readLoop pushes
// directly.
func (l *SIPLeg) popLoop() {
	ticker := time.NewTicker(20 * time.Millisecond)
	defer ticker.Stop()
	for {
		select {
		case <-l.ctx.Done():
			return
		case <-ticker.C:
		}
		pcm, ok := l.jb.Pop()
		if !ok {
			// Warm-up or underrun — push a silence frame so the mixer's
			// downstream pull still finds something at this tick.
			pcm = make([]byte, l.jbFrameBytes)
		}
		select {
		case l.inFrames <- pcm:
		default:
			// Consumer behind — drop oldest (same overflow policy as the
			// passthrough path used to have).
			select {
			case <-l.inFrames:
			default:
			}
			l.inFrames <- pcm
		}
	}
}

// recoverLoop logs a panic on the named loop goroutine.
func (l *SIPLeg) recoverLoop(loop string) {
	if r := recover(); r != nil {
		l.log.Error(loop+" panic",
			"leg_id", l.id,
			"panic", r,
			"stack", string(debug.Stack()),
		)
	}
}

// recoverLoopAndHangup is recoverLoop for audio-pipeline loops that must
// also hang up the leg after a panic, since further operation is unsafe.
func (l *SIPLeg) recoverLoopAndHangup(loop string) {
	if r := recover(); r != nil {
		l.log.Error(loop+" panic, hanging up leg",
			"leg_id", l.id,
			"panic", r,
			"stack", string(debug.Stack()),
		)
		_ = l.Hangup(context.Background())
	}
}

// readLoop reads RTP packets from the UDP socket, decodes audio, and pushes
// native-rate PCM frames into inFrames.
func (l *SIPLeg) readLoop() {
	defer l.recoverLoopAndHangup("readLoop")
	for {
		// Set read deadline for RTP timeout detection.
		// When held, use a very long deadline (beyond hold timer) to avoid
		// false RTP timeouts since no RTP is expected while on hold.
		l.mu.RLock()
		isHeld := l.held
		l.mu.RUnlock()
		if isHeld {
			l.rtpSess.SetReadDeadline(time.Now().Add(2*time.Hour + time.Minute))
		} else {
			l.rtpSess.SetReadDeadline(time.Now().Add(rtpTimeout))
		}

		pkt, err := l.rtpSess.ReadRTP()
		if err != nil {
			select {
			case <-l.ctx.Done():
				return
			default:
			}
			// Non-RTP packets (RTCP, STUN) fail unmarshal — skip them.
			if errors.Is(err, sipmod.ErrNotRTP) {
				continue
			}
			// Check for read deadline timeout (no RTP received).
			var netErr net.Error
			if errors.As(err, &netErr) && netErr.Timeout() {
				l.log.Info("RTP timeout", "leg_id", l.id, "timeout", rtpTimeout)
				l.mu.RLock()
				cb := l.onRTPTimeout
				l.mu.RUnlock()
				if cb != nil {
					cb()
				}
				return
			}
			l.log.Debug("readLoop: ReadRTP error", "error", err)
			return
		}

		// Track sequence number on ALL valid RTP packets (including DTMF,
		// comfort noise, etc.) since they share the same sequence number space.
		l.trackRTPSeq(pkt.SequenceNumber)

		// Handle DTMF telephone-event (PT 101 = 8kHz, PT 100 = 48kHz for Opus)
		if pkt.PayloadType == 100 || pkt.PayloadType == 101 {
			ev, err := sipmod.DecodeDTMFEvent(pkt.Payload)
			if err != nil {
				continue
			}
			// Only fire callback on end-of-event. RFC 4733 senders retransmit
			// end-of-event 3 times with the same timestamp — deduplicate on it.
			if ev.EndOfEvent && pkt.Timestamp != l.lastDTMFTS {
				l.lastDTMFTS = pkt.Timestamp
				digit, ok := sipmod.DTMFEventToDigit(ev.Event)
				if ok {
					l.mu.RLock()
					cb := l.onDTMF
					l.mu.RUnlock()
					if cb != nil {
						cb(digit)
					}
				}
			}
			continue
		}

		// Skip packets that don't match the negotiated codec PT (e.g.
		// comfort noise, keep-alive, or other non-audio PTs).
		if pkt.PayloadType != l.rtpPT {
			continue
		}

		// Skip empty payloads (some endpoints send marker-only packets).
		if len(pkt.Payload) == 0 {
			continue
		}

		// Update jitter on audio packets only (DTMF retransmits share
		// timestamps, which would spike the jitter estimate).
		l.updateRTPJitter(pkt)

		// Decode audio payload
		samples, err := l.decoder.Decode(pkt.Payload)
		if err != nil {
			head := pkt.Payload
			if len(head) > 8 {
				head = head[:8]
			}
			l.log.Debug("readLoop: decode error", "error", err,
				"pt", pkt.PayloadType, "expected_pt", l.rtpPT,
				"payload_len", len(pkt.Payload), "payload_head", fmt.Sprintf("%x", head),
				"seq", pkt.SequenceNumber)
			continue
		}

		// Convert decoded samples at native rate to PCM bytes
		pcm := samplesToBytes(samples)

		// Write to incoming taps before pushing to inFrames.
		l.mu.RLock()
		tap := l.inTap
		at := l.amdTap
		st := l.speakingTap
		l.mu.RUnlock()
		if tap != nil {
			tap.Write(pcm)
		}
		if at != nil {
			at.Write(pcm)
		}
		if st != nil {
			st.Write(pcm)
		}

		// Route to jitter buffer if enabled, otherwise push directly to
		// inFrames with drop-oldest-on-overflow (preserves legacy
		// passthrough behavior).
		if l.jb != nil {
			l.jb.Push(pkt.SequenceNumber, pcm)
			continue
		}
		select {
		case l.inFrames <- pcm:
		default:
			select {
			case <-l.inFrames:
			default:
			}
			l.inFrames <- pcm
		}
	}
}

// trackRTPSeq updates sequence number and packet count for ALL received RTP
// packets (audio, DTMF, comfort noise) since they share one sequence space.
func (l *SIPLeg) trackRTPSeq(seq uint16) {
	l.rtpStatsMu.Lock()
	l.rtpReceived++
	if !l.rtpHasFirst {
		l.rtpFirstSeq = seq
		l.rtpLastSeq = seq
		l.rtpHasFirst = true
	} else {
		l.rtpLastSeq = seq
	}
	l.rtpStatsMu.Unlock()
}

// updateRTPJitter updates inter-arrival jitter (RFC 3550 §A.8) on audio
// packets only. DTMF end-of-event retransmits share the same RTP timestamp,
// which would spike the jitter estimate if included.
func (l *SIPLeg) updateRTPJitter(pkt *rtp.Packet) {
	clockRate := int64(l.codecType.ClockRate())
	arrival := time.Now().UnixNano() * clockRate / 1e9
	transit := arrival - int64(pkt.Timestamp)

	l.rtpStatsMu.Lock()
	if l.rtpLastTransit != 0 {
		d := transit - l.rtpLastTransit
		if d < 0 {
			d = -d
		}
		l.rtpJitter += (float64(d) - l.rtpJitter) / 16
	}
	l.rtpLastTransit = transit
	l.rtpStatsMu.Unlock()
}

// RTPStats returns inbound stream quality metrics and a MOS estimate.
func (l *SIPLeg) RTPStats() RTPStats {
	l.rtpStatsMu.Lock()
	received := l.rtpReceived
	firstSeq := l.rtpFirstSeq
	lastSeq := l.rtpLastSeq
	jitter := l.rtpJitter
	hasFirst := l.rtpHasFirst
	clockRate := l.codecType.ClockRate()
	l.rtpStatsMu.Unlock()

	if !hasFirst || received < 2 {
		return RTPStats{}
	}

	expected := uint32(lastSeq-firstSeq) + 1
	var lost uint32
	if received < expected {
		lost = expected - received
	}
	lossRate := float64(lost) / float64(expected)
	jitterMs := jitter / float64(clockRate) * 1000

	return RTPStats{
		PacketsReceived: received,
		PacketsLost:     lost,
		JitterMs:        math.Round(jitterMs*100) / 100,
		MOSScore:        calculateMOS(lossRate, jitterMs),
	}
}

// writeLoop sends RTP packets on a 20ms ticker.
func (l *SIPLeg) writeLoop() {
	defer l.recoverLoopAndHangup("writeLoop")

	const ptime = 20 * time.Millisecond

	// PCM frame size at codec's native sample rate: samples per 20ms × 2 bytes
	pcmFrameBytes := l.codecType.SampleRate() / 50 * 2

	// RTP timestamp increment is codec-dependent: clockRate * 20ms
	samplesPerFrame := uint32(l.codecType.ClockRate() / 50)

	// DTMF (RFC 4733) send PT and per-packet duration units, at the negotiated
	// telephone-event clock rate (16kHz for AMR-WB, else 8kHz).
	telephoneEventPT := l.dtmfSendPT
	if telephoneEventPT == 0 {
		telephoneEventPT = 101
	}
	dtmfSamplesPerPkt := uint16(l.dtmfClockRate / 50)
	if dtmfSamplesPerPkt == 0 {
		dtmfSamplesPerPkt = 160
	}

	ticker := time.NewTicker(ptime)
	defer ticker.Stop()

	ssrc := rand.Uint32()
	var seqNum uint16
	var timestamp uint32
	silenceFrame := make([]byte, pcmFrameBytes)
	pt := l.rtpPT
	if l.rtpSendPT != 0 {
		pt = l.rtpSendPT
	}

	for {
		select {
		case <-l.ctx.Done():
			return
		case <-ticker.C:
		}

		// When held, stop sending RTP — the remote is not listening.
		// Drain any queued outFrames so writers don't block.
		l.mu.RLock()
		isHeld := l.held
		l.mu.RUnlock()
		if isHeld {
			select {
			case <-l.outFrames:
			default:
			}
			timestamp += samplesPerFrame
			continue
		}

		// Check for pending DTMF digits.
		var dtmfDigits string
		select {
		case dtmfDigits = <-l.dtmfCh:
		default:
		}

		if dtmfDigits != "" {
			for _, ch := range dtmfDigits {
				pkts := sipmod.GenerateDTMFPackets(ch, telephoneEventPT, ssrc, seqNum, timestamp, dtmfSamplesPerPkt)
				for i, pkt := range pkts {
					if err := l.rtpSess.WriteRTP(pkt); err != nil {
						l.log.Error("writeLoop: DTMF WriteRTP failed", "error", err)
						return
					}
					seqNum++

					// Wait for next tick (except after last packet)
					if i < len(pkts)-1 {
						select {
						case <-l.ctx.Done():
							return
						case <-ticker.C:
						}
					}
				}
				// Advance timestamp past the DTMF event duration
				timestamp += samplesPerFrame * uint32(len(pkts))
			}
			continue
		}

		// Normal audio frame.
		var frame []byte
		select {
		case frame = <-l.outFrames:
		default:
			frame = silenceFrame
		}

		// Write to outgoing tap (for recording) before encoding.
		l.mu.RLock()
		oTap := l.outTap
		l.mu.RUnlock()
		if oTap != nil {
			oTap.Write(frame)
		}

		// Parse PCM bytes to int16 samples (already at native rate)
		samples := bytesToSamples(frame)

		encoded, err := l.encoder.Encode(samples)
		if err != nil {
			l.log.Error("writeLoop: encode failed", "error", err)
			return
		}

		pkt := &rtp.Packet{
			Header: rtp.Header{
				Version:        2,
				PayloadType:    pt,
				SequenceNumber: seqNum,
				Timestamp:      timestamp,
				SSRC:           ssrc,
			},
			Payload: encoded,
		}
		if err := l.rtpSess.WriteRTP(pkt); err != nil {
			l.log.Error("writeLoop: WriteRTP failed", "error", err)
			return
		}
		seqNum++
		timestamp += samplesPerFrame
	}
}

// Hangup transitions the leg to StateHungUp and (exactly once) sends a BYE
// on the underlying SIP dialog. State transition, context cancel, RTP-session
// close, and timer cleanup happen on every call (idempotent operations); the
// SIP BYE is gated by sync.Once so concurrent termination paths cannot emit
// duplicate BYEs.
func (l *SIPLeg) Hangup(ctx context.Context) error {
	l.setState(StateHungUp)
	// Cancel up front so downstream goroutines unblock without waiting on BYE.
	l.cancel()

	l.mu.Lock()
	if l.holdTimer != nil {
		l.holdTimer.Stop()
		l.holdTimer = nil
	}
	l.mu.Unlock()
	l.stopSessionTimer()

	if l.rtpSess != nil {
		l.rtpSess.Close()
	}
	if l.textRtpSess != nil {
		l.textRtpSess.Close()
	}

	// BYE is fire-and-forget so a non-responsive peer can't stall callers.
	l.byeOnce.Do(func() {
		go func(inbound *sipmod.InboundCall, outbound *sipmod.OutboundCall) {
			byeCtx, byeCancel := context.WithTimeout(context.Background(), 5*time.Second)
			defer byeCancel()
			if inbound != nil {
				inbound.Dialog.Bye(byeCtx)
			} else if outbound != nil {
				outbound.Dialog.Bye(byeCtx)
			}
		}(l.inbound, l.outbound)
	})
	return nil
}

// ClaimDisconnect returns true on the first caller and false on every
// subsequent caller. Termination paths use this gate so only one publishes
// leg.disconnected, even when DELETE racing with remote BYE or RTP timeout.
func (l *SIPLeg) ClaimDisconnect() bool {
	return l.disconnectDone.CompareAndSwap(false, true)
}

// sipReader reads PCM frames from the inFrames channel.
type sipReader struct {
	frames <-chan []byte
	ctx    context.Context
	buf    []byte
}

func (r *sipReader) Read(p []byte) (int, error) {
	if len(r.buf) > 0 {
		n := copy(p, r.buf)
		r.buf = r.buf[n:]
		return n, nil
	}
	select {
	case frame := <-r.frames:
		n := copy(p, frame)
		if n < len(frame) {
			r.buf = frame[n:]
		}
		return n, nil
	case <-r.ctx.Done():
		return 0, io.EOF
	}
}

// sipWriter sends PCM frames into SIPLeg's outFrames channel.
type sipWriter struct {
	frames chan<- []byte
	ctx    context.Context
}

func (w *sipWriter) Write(p []byte) (int, error) {
	frame := make([]byte, len(p))
	copy(frame, p)
	select {
	case w.frames <- frame:
		return len(p), nil
	case <-w.ctx.Done():
		return 0, io.ErrClosedPipe
	}
}

func (l *SIPLeg) AudioReader() io.Reader {
	l.mu.RLock()
	defer l.mu.RUnlock()
	if l.inFrames == nil {
		return nil
	}
	return &sipReader{frames: l.inFrames, ctx: l.ctx}
}

func (l *SIPLeg) AudioWriter() io.Writer {
	if l.outFrames == nil {
		return nil
	}
	return &sipWriter{frames: l.outFrames, ctx: l.ctx}
}

// SetInTap sets a writer that receives a copy of every decoded incoming PCM
// frame (before it enters inFrames). Used for standalone leg recording.
func (l *SIPLeg) SetInTap(w io.Writer) {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.inTap = w
}

// ClearInTap removes the incoming tap.
func (l *SIPLeg) ClearInTap() {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.inTap = nil
}

// SetOutTap sets a writer that receives a copy of every outgoing PCM frame
// (including silence) from the writeLoop. Used for standalone leg recording.
func (l *SIPLeg) SetOutTap(w io.Writer) {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.outTap = w
}

// ClearOutTap removes the outgoing tap.
func (l *SIPLeg) ClearOutTap() {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.outTap = nil
}

// SetAMDTap sets a writer that receives decoded incoming PCM for answering
// machine detection. Separate from inTap so AMD and recording can coexist.
func (l *SIPLeg) SetAMDTap(w io.Writer) {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.amdTap = w
}

// ClearAMDTap removes the AMD tap.
func (l *SIPLeg) ClearAMDTap() {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.amdTap = nil
}

// SetSpeakingTap sets a writer that receives decoded incoming PCM for
// voice activity detection.
func (l *SIPLeg) SetSpeakingTap(w io.Writer) {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.speakingTap = w
}

// ClearSpeakingTap removes the speaking detection tap.
func (l *SIPLeg) ClearSpeakingTap() {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.speakingTap = nil
}

func (l *SIPLeg) OnDTMF(f func(digit rune)) {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.onDTMF = f
}

// OnRTPTimeout sets a callback that fires when no RTP packets are received
// within the timeout period (30s). Called at most once.
func (l *SIPLeg) OnRTPTimeout(f func()) {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.onRTPTimeout = f
}

// ReInviteAnswerSDP builds an SDP body for responding to a remote re-INVITE.
// The direction is mirrored: if the remote sent "sendonly" (hold), we respond
// with "recvonly"; if "sendrecv" we respond with "sendrecv".
func (l *SIPLeg) ReInviteAnswerSDP(remoteDirection string) []byte {
	l.mu.RLock()
	defer l.mu.RUnlock()

	// Mirror the direction: remote sendonly → we recvonly, etc.
	ourDirection := "sendrecv"
	switch remoteDirection {
	case "sendonly":
		ourDirection = "recvonly"
	case "recvonly":
		ourDirection = "sendonly"
	case "inactive":
		ourDirection = "inactive"
	}

	if l.rtpSess == nil {
		return nil
	}
	return sipmod.GenerateReInviteSDP(l.sdpConfig(), l.codecType, l.rtpPT, ourDirection)
}

// sdpConfig returns an SDPConfig populated from the leg's current local
// media state (audio + optional text). Assumes l.rtpSess is non-nil.
func (l *SIPLeg) sdpConfig() sipmod.SDPConfig {
	cfg := sipmod.SDPConfig{
		LocalIP:           l.localIP,
		RTPPort:           l.rtpSess.LocalPort(),
		Codecs:            l.supportedCodecs,
		AMRWBOctetAligned: l.amrwbOctetAligned,
		AMRWBModeSet:      l.amrwbModeSet,
		AMRNBOctetAligned: l.amrnbOctetAligned,
		AMRNBModeSet:      l.amrnbModeSet,
		DTMFPT:            l.dtmfSendPT,
		DTMFClockRate:     l.dtmfClockRate,
	}
	if l.textRtpSess != nil {
		cfg.TextRTPPort = l.textRtpSess.LocalPort()
		cfg.TextT140PT = l.textT140PT
		cfg.TextREDPT = l.textREDPT
		cfg.RTTRedundancy = l.rttRedundancy
	}
	return cfg
}

// CallID returns the SIP Call-ID for this leg's dialog.
func (l *SIPLeg) CallID() string {
	l.mu.RLock()
	defer l.mu.RUnlock()
	return l.callID
}

// IsHeld returns true if the call is currently on hold.
func (l *SIPLeg) IsHeld() bool {
	l.mu.RLock()
	defer l.mu.RUnlock()
	return l.held
}

// OnHold registers a callback for when the leg is put on hold.
func (l *SIPLeg) OnHold(f func()) {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.onHold = f
}

// OnUnhold registers a callback for when the leg is taken off hold.
func (l *SIPLeg) OnUnhold(f func()) {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.onUnhold = f
}

// SetHeld updates hold state and manages the 2-hour auto-hangup timer.
func (l *SIPLeg) SetHeld(held bool) {
	l.mu.Lock()
	if l.held == held {
		l.mu.Unlock()
		return
	}
	l.held = held

	if held {
		l.state = StateHeld
		// Start 2-hour hold timer for auto-hangup.
		l.holdTimer = time.AfterFunc(2*time.Hour, func() {
			l.log.Info("hold timeout", "leg_id", l.id)
			l.mu.RLock()
			cb := l.onRTPTimeout
			l.mu.RUnlock()
			if cb != nil {
				cb()
			}
		})
	} else {
		l.state = StateConnected
		// Cancel hold timer.
		if l.holdTimer != nil {
			l.holdTimer.Stop()
			l.holdTimer = nil
		}
	}
	l.mu.Unlock()
}

// Hold initiates hold by sending a re-INVITE with sendonly SDP.
func (l *SIPLeg) Hold(ctx context.Context) error {
	l.mu.RLock()
	st := l.state
	isHeld := l.held
	l.mu.RUnlock()

	if isHeld {
		return nil // already held
	}
	if st != StateConnected {
		return fmt.Errorf("leg is %s, must be connected to hold", st)
	}

	sdpBody := sipmod.GenerateReInviteSDP(l.sdpConfig(), l.codecType, l.rtpPT, "sendonly")

	var dialog interface{}
	if l.inbound != nil {
		dialog = l.inbound.Dialog
	} else if l.outbound != nil {
		dialog = l.outbound.Dialog
	} else {
		return fmt.Errorf("no dialog available")
	}

	if err := l.engine.SendReInvite(ctx, dialog, sdpBody); err != nil {
		return fmt.Errorf("hold re-INVITE: %w", err)
	}

	l.SetHeld(true)
	l.mu.RLock()
	cb := l.onHold
	l.mu.RUnlock()
	if cb != nil {
		cb()
	}
	return nil
}

// Unhold resumes the call by sending a re-INVITE with sendrecv SDP.
func (l *SIPLeg) Unhold(ctx context.Context) error {
	l.mu.RLock()
	isHeld := l.held
	l.mu.RUnlock()

	if !isHeld {
		return nil // not held
	}

	sdpBody := sipmod.GenerateReInviteSDP(l.sdpConfig(), l.codecType, l.rtpPT, "sendrecv")

	var dialog interface{}
	if l.inbound != nil {
		dialog = l.inbound.Dialog
	} else if l.outbound != nil {
		dialog = l.outbound.Dialog
	} else {
		return fmt.Errorf("no dialog available")
	}

	if err := l.engine.SendReInvite(ctx, dialog, sdpBody); err != nil {
		return fmt.Errorf("unhold re-INVITE: %w", err)
	}

	l.SetHeld(false)
	l.mu.RLock()
	cb := l.onUnhold
	l.mu.RUnlock()
	if cb != nil {
		cb()
	}
	return nil
}

// HandleRemoteHold processes a remote re-INVITE's SDP direction for hold/unhold.
func (l *SIPLeg) HandleRemoteHold(direction string) {
	switch direction {
	case "sendonly", "inactive":
		l.SetHeld(true)
		l.mu.RLock()
		cb := l.onHold
		l.mu.RUnlock()
		if cb != nil {
			cb()
		}
	case "sendrecv", "recvonly":
		l.SetHeld(false)
		l.mu.RLock()
		cb := l.onUnhold
		l.mu.RUnlock()
		if cb != nil {
			cb()
		}
	}
}

// SessionTimerParams returns the negotiated session timer interval and refresher.
// Returns (0, "") if session timers are not active.
func (l *SIPLeg) SessionTimerParams() (interval uint32, refresher string) {
	l.mu.RLock()
	defer l.mu.RUnlock()
	return l.sessionInterval, l.sessionRefresher
}

// OnSessionExpired registers a callback for when the session timer expires
// without a refresh. Typically used to hang up the call.
func (l *SIPLeg) OnSessionExpired(f func()) {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.onSessionExpired = f
}

// startSessionTimer starts the session refresh or expiry timer after the call
// is answered. Must only be called once.
func (l *SIPLeg) startSessionTimer() {
	l.mu.RLock()
	interval := l.sessionInterval
	refresher := l.sessionRefresher
	l.mu.RUnlock()

	if interval == 0 {
		return
	}

	if refresher == "uas" {
		// We are the refresher — send re-INVITE at half the interval.
		refreshAt := time.Duration(interval) * time.Second / 2
		l.log.Info("session timer: uas refresher", "leg_id", l.id, "interval", interval, "refresh_at", refreshAt)
		l.mu.Lock()
		l.sessionTimer = time.AfterFunc(refreshAt, l.sessionRefresh)
		l.mu.Unlock()
	} else {
		// Remote is the refresher — guard timer at full interval + 32s grace
		// (RFC 4028 §10 recommends a grace period of at least 32 seconds).
		guardAt := time.Duration(interval)*time.Second + 32*time.Second
		l.log.Info("session timer: uac refresher", "leg_id", l.id, "interval", interval, "guard_at", guardAt)
		l.mu.Lock()
		l.sessionTimer = time.AfterFunc(guardAt, l.sessionExpired)
		l.mu.Unlock()
	}
}

// sessionRefresh fires when we (UAS) need to refresh the session.
func (l *SIPLeg) sessionRefresh() {
	l.mu.RLock()
	st := l.state
	interval := l.sessionInterval
	l.mu.RUnlock()

	if st == StateHungUp {
		return
	}

	l.log.Info("session timer: sending refresh re-INVITE", "leg_id", l.id)

	sdpBody := l.ReInviteAnswerSDP("sendrecv")
	if sdpBody == nil {
		l.log.Error("session timer: could not generate refresh SDP", "leg_id", l.id)
		return
	}

	var dialog interface{}
	if l.inbound != nil {
		dialog = l.inbound.Dialog
	} else if l.outbound != nil {
		dialog = l.outbound.Dialog
	}
	if dialog == nil {
		return
	}

	if err := l.engine.SendReInvite(l.ctx, dialog, sdpBody); err != nil {
		l.log.Error("session timer: refresh re-INVITE failed", "leg_id", l.id, "error", err)
		// On failure, try once more at 75% of remaining interval.
		remaining := time.Duration(interval) * time.Second / 4
		l.mu.Lock()
		l.sessionTimer = time.AfterFunc(remaining, l.sessionExpired)
		l.mu.Unlock()
		return
	}

	// Schedule next refresh.
	refreshAt := time.Duration(interval) * time.Second / 2
	l.mu.Lock()
	l.sessionTimer = time.AfterFunc(refreshAt, l.sessionRefresh)
	l.mu.Unlock()
}

// sessionExpired fires when the remote failed to refresh the session in time.
func (l *SIPLeg) sessionExpired() {
	l.log.Info("session timer expired", "leg_id", l.id)
	l.mu.RLock()
	cb := l.onSessionExpired
	l.mu.RUnlock()
	if cb != nil {
		cb()
	}
}

// ResetSessionTimer resets the session timer, called when a refresh re-INVITE
// is received from the remote UA.
func (l *SIPLeg) ResetSessionTimer() {
	l.mu.Lock()
	interval := l.sessionInterval
	refresher := l.sessionRefresher
	t := l.sessionTimer
	l.mu.Unlock()

	if interval == 0 || t == nil {
		return
	}

	l.log.Debug("session timer reset", "leg_id", l.id, "interval", interval, "refresher", refresher)

	if refresher == "uas" {
		t.Reset(time.Duration(interval) * time.Second / 2)
	} else {
		t.Reset(time.Duration(interval)*time.Second + 32*time.Second)
	}
}

// stopSessionTimer stops the session timer. Called on hangup.
func (l *SIPLeg) stopSessionTimer() {
	l.mu.Lock()
	if l.sessionTimer != nil {
		l.sessionTimer.Stop()
		l.sessionTimer = nil
	}
	l.mu.Unlock()
}

func (l *SIPLeg) SendDTMF(ctx context.Context, digits string) error {
	if l.dtmfCh == nil {
		return fmt.Errorf("no DTMF channel available")
	}
	select {
	case l.dtmfCh <- digits:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

// setupInboundTextMedia allocates an RTP session for RTT whenever the remote
// offer carries a usable m=text section. Returns the local port the SDP
// answer must advertise (0 means no RTT in the answer). When the remote
// advertised m=text but we cannot honor it (allocator exhausted, malformed
// payload type), the returned answerHasRejected is true so the SDP answer
// includes a port=0 m=text section per RFC 3264.
func (l *SIPLeg) setupInboundTextMedia(remote *sipmod.SDPMedia) (port int, t140PT, redPT uint8, answerHasRejected bool) {
	if remote == nil || remote.Text == nil {
		return 0, 0, 0, false
	}
	if remote.Text.T140PT == 0 {
		return 0, 0, 0, true
	}

	rs, err := sipmod.NewRTPSessionFromAllocator(l.engine.PortAllocator())
	if err != nil {
		l.log.Warn("RTT: allocate text RTP session failed", "leg_id", l.id, "error", err)
		return 0, 0, 0, true
	}
	if err := rs.SetRemote(remote.Text.RemoteIP, remote.Text.RemotePort); err != nil {
		l.log.Warn("RTT: set remote on text RTP session failed", "leg_id", l.id, "error", err)
		rs.Close()
		return 0, 0, 0, true
	}

	l.mu.Lock()
	l.textRtpSess = rs
	l.textT140PT = remote.Text.T140PT
	l.textREDPT = remote.Text.REDPT
	l.rttLocalPort = rs.LocalPort()
	l.mu.Unlock()
	return rs.LocalPort(), remote.Text.T140PT, remote.Text.REDPT, false
}

// adoptOutboundTextSession takes ownership of the text RTPSession allocated
// by Engine.Invite when the answer accepts RTT.
func (l *SIPLeg) adoptOutboundTextSession(remote *sipmod.SDPMedia, rs *sipmod.RTPSession) {
	if rs == nil || remote == nil || remote.Text == nil || remote.Text.T140PT == 0 {
		if rs != nil {
			rs.Close()
		}
		return
	}
	l.mu.Lock()
	l.textRtpSess = rs
	l.textT140PT = remote.Text.T140PT
	l.textREDPT = remote.Text.REDPT
	l.rttLocalPort = rs.LocalPort()
	l.mu.Unlock()
}

// setupTextMedia constructs the encoder/decoder and starts the read/write
// loops once a text RTP session has been wired up. No-op when there is no
// text session.
func (l *SIPLeg) setupTextMedia() {
	l.mu.RLock()
	rs := l.textRtpSess
	t140PT := l.textT140PT
	redPT := l.textREDPT
	redundancy := l.rttRedundancy
	l.mu.RUnlock()
	if rs == nil {
		return
	}
	if redPT == 0 {
		redundancy = 0
	}
	l.textEncoder = t140.NewEncoder(redundancy, t140PT)
	l.textDecoder = t140.NewDecoder()
	l.textInCh = make(chan rttIn, 32)
	l.textOutCh = make(chan string, 32)
	l.acceptText.Store(true)
	l.rttNegotiated.Store(true)
	go l.textReadLoop()
	go l.textWriteLoop()
	go l.textDispatchLoop()
	l.log.Info("RTT enabled on leg",
		"leg_id", l.id,
		"t140_pt", l.textT140PT,
		"red_pt", l.textREDPT,
		"redundancy", redundancy,
		"local_port", l.rttLocalPort,
	)
}

// textReadLoop reads RTP packets from the text RTP session, decodes them,
// and forwards the resulting text to the dispatch loop.
func (l *SIPLeg) textReadLoop() {
	defer l.recoverLoop("textReadLoop")

	rs := l.textRtpSess
	t140PT := l.textT140PT
	redPT := l.textREDPT
	var rttSeq atomic.Uint64

	for {
		// Use the same hold/RTP-timeout policy as the audio path: a long
		// deadline when held; the regular rtpTimeout otherwise.
		l.mu.RLock()
		isHeld := l.held
		l.mu.RUnlock()
		if isHeld {
			rs.SetReadDeadline(time.Now().Add(2*time.Hour + time.Minute))
		} else {
			rs.SetReadDeadline(time.Now().Add(rtpTimeout))
		}

		pkt, err := rs.ReadRTP()
		if err != nil {
			select {
			case <-l.ctx.Done():
				return
			default:
			}
			if errors.Is(err, sipmod.ErrNotRTP) {
				continue
			}
			var netErr net.Error
			if errors.As(err, &netErr) && netErr.Timeout() {
				// Don't propagate text-side timeouts as session timeouts —
				// audio path owns timeout semantics.
				continue
			}
			return
		}

		// Only honor packets carrying one of the negotiated PTs. Anything
		// else (mux'd RTCP that slipped past the lower-layer filter, or
		// foreign streams sharing the port) would otherwise be treated as
		// plain T.140 and produce garbage text.
		if pkt.PayloadType != t140PT && (redPT == 0 || pkt.PayloadType != redPT) {
			l.log.Debug("RTT skip non-text PT", "leg_id", l.id, "pt", pkt.PayloadType)
			continue
		}

		text, lost, derr := l.textDecoder.DecodePacket(pkt.SequenceNumber, pkt.Timestamp, pkt.PayloadType, t140PT, redPT, pkt.Payload)
		if derr != nil {
			l.log.Debug("RTT decode error", "leg_id", l.id, "error", derr)
			continue
		}
		if text == "" && !lost {
			continue
		}
		if !l.acceptText.Load() {
			continue
		}
		seq := rttSeq.Add(1)
		in := rttIn{seq: seq, text: text, lossMarker: lost}
		select {
		case l.textInCh <- in:
		default:
			// Buffer full: drop oldest then re-attempt. Both ops are
			// non-blocking so the read loop can never wedge on a stalled
			// dispatcher, regardless of writer/reader cardinality.
			select {
			case <-l.textInCh:
			default:
			}
			select {
			case l.textInCh <- in:
			default:
			}
		}
	}
}

// textDispatchLoop fires the OnTextReceived callback in a goroutine separate
// from textReadLoop so a slow consumer can't block RTP reads.
func (l *SIPLeg) textDispatchLoop() {
	for {
		select {
		case <-l.ctx.Done():
			return
		case in := <-l.textInCh:
			l.mu.RLock()
			cb := l.onText
			l.mu.RUnlock()
			if cb != nil {
				cb(in.text, in.lossMarker)
			}
		}
	}
}

// textWriteLoop drains the outbound text channel at the configured buffer
// interval and emits one RTP packet per tick if there are pending bytes.
func (l *SIPLeg) textWriteLoop() {
	defer l.recoverLoop("textWriteLoop")

	rs := l.textRtpSess
	enc := l.textEncoder
	bufferMs := l.rttBufferMs
	if bufferMs <= 0 {
		bufferMs = t140.DefaultBufferMs
	}
	t140PT := l.textT140PT
	redPT := l.textREDPT
	ssrc := rand.Uint32()
	seqNum := uint16(rand.Uint32()) // RFC 3550: random initial sequence number.
	tsBase := rand.Uint32()         // RFC 3550: random initial timestamp.
	firstPacket := true
	startNs := time.Now().UnixNano()

	ticker := time.NewTicker(time.Duration(bufferMs) * time.Millisecond)
	defer ticker.Stop()

	flush := func() {
		// Drain everything currently on the queue into the encoder.
		for {
			select {
			case s := <-l.textOutCh:
				enc.Push(s)
				continue
			default:
			}
			break
		}
		if !enc.HasPending() {
			return
		}
		// 1 kHz clock — milliseconds since loop start, offset by random base.
		ts := tsBase + uint32((time.Now().UnixNano()-startNs)/int64(time.Millisecond))
		payload, useRED := enc.Flush(ts)
		if payload == nil {
			return
		}
		pt := t140PT
		if useRED && redPT != 0 {
			pt = redPT
		}
		pkt := &rtp.Packet{
			Header: rtp.Header{
				Version:        2,
				PayloadType:    pt,
				SequenceNumber: seqNum,
				Timestamp:      ts,
				SSRC:           ssrc,
				Marker:         firstPacket, // RFC 4103 §4.2: marker bit on first packet.
			},
			Payload: payload,
		}
		if err := rs.WriteRTP(pkt); err != nil {
			l.log.Debug("RTT WriteRTP failed", "leg_id", l.id, "error", err)
			return
		}
		l.log.Debug("rtt sent",
			"leg_id", l.id,
			"pt", pt,
			"seq", seqNum,
			"ts", ts,
			"red", useRED,
			"bytes", len(payload),
		)
		seqNum++
		firstPacket = false
	}

	for {
		select {
		case <-l.ctx.Done():
			return
		case <-ticker.C:
			flush()
		case s := <-l.textOutCh:
			enc.Push(s)
		}
	}
}

// SendText queues UTF-8 text for transmission as RFC 4103 RTP. Returns
// ErrRTTNotNegotiated when the SDP exchange did not agree on m=text.
func (l *SIPLeg) SendText(ctx context.Context, text string) error {
	if !l.rttNegotiated.Load() {
		return ErrRTTNotNegotiated
	}
	if text == "" {
		return nil
	}
	select {
	case l.textOutCh <- text:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

// OnTextReceived registers a callback for inbound T.140 text. The callback
// runs on the dispatcher goroutine so the caller can do moderately expensive
// work (e.g. publish webhook events) without blocking the RTP read loop.
func (l *SIPLeg) OnTextReceived(f func(text string, lossMarker bool)) {
	l.mu.Lock()
	l.onText = f
	l.mu.Unlock()
}

// AcceptText reports whether this leg currently accepts inbound RTT events.
func (l *SIPLeg) AcceptText() bool { return l.acceptText.Load() }

// SetAcceptText toggles inbound RTT event acceptance on this leg.
func (l *SIPLeg) SetAcceptText(accept bool) { l.acceptText.Store(accept) }

// RTTNegotiated reports whether SDP agreed on an m=text section for this leg.
func (l *SIPLeg) RTTNegotiated() bool { return l.rttNegotiated.Load() }

// samplesToBytes converts int16 samples to 16-bit LE PCM bytes.
func samplesToBytes(samples []int16) []byte {
	out := make([]byte, len(samples)*2)
	for i, s := range samples {
		binary.LittleEndian.PutUint16(out[i*2:], uint16(s))
	}
	return out
}

// bytesToSamples converts 16-bit LE PCM bytes to int16 samples.
func bytesToSamples(pcm []byte) []int16 {
	n := len(pcm) / 2
	out := make([]int16, n)
	for i := 0; i < n; i++ {
		out[i] = int16(binary.LittleEndian.Uint16(pcm[i*2:]))
	}
	return out
}
