package offline

import (
	"context"
	"encoding/binary"
	"fmt"
	"log"
	"math"
	"path/filepath"
	"sync/atomic"
	"time"

	"github.com/jan/fm-rds-tx/internal/audio"
	cfgpkg "github.com/jan/fm-rds-tx/internal/config"
	"github.com/jan/fm-rds-tx/internal/dsp"
	"github.com/jan/fm-rds-tx/internal/license"
	"github.com/jan/fm-rds-tx/internal/mpx"
	"github.com/jan/fm-rds-tx/internal/output"
	"github.com/jan/fm-rds-tx/internal/rds"
	"github.com/jan/fm-rds-tx/internal/stereo"
	"github.com/jan/fm-rds-tx/internal/watermark"
)

type frameSource interface {
	NextFrame() audio.Frame
}

// LiveParams carries DSP parameters that can be hot-swapped at runtime.
// Loaded once per chunk via atomic pointer — zero per-sample overhead.
type LiveParams struct {
	OutputDrive    float64
	StereoEnabled  bool
	StereoMode     string
	PilotLevel     float64
	RDSInjection   float64
	RDSEnabled     bool
	LimiterEnabled bool
	LimiterCeiling float64
	MpxGain        float64 // hardware calibration factor for composite output
	// Tone + gain: live-patchable without DSP chain reinit.
	ToneLeftHz    float64
	ToneRightHz   float64
	ToneAmplitude float64
	AudioGain     float64
	// Composite clipper: live-toggleable without DSP chain reinit.
	CompositeClipperEnabled bool
}

// PreEmphasizedSource wraps an audio source and applies pre-emphasis.
// The source is expected to already output at composite rate (resampled
// upstream). Pre-emphasis is applied per-sample at that rate.
type PreEmphasizedSource struct {
	src  frameSource
	preL *dsp.PreEmphasis
	preR *dsp.PreEmphasis
	gain float64
}

func NewPreEmphasizedSource(src frameSource, tauUS, sampleRate, gain float64) *PreEmphasizedSource {
	p := &PreEmphasizedSource{src: src, gain: gain}
	if tauUS > 0 {
		p.preL = dsp.NewPreEmphasis(tauUS, sampleRate)
		p.preR = dsp.NewPreEmphasis(tauUS, sampleRate)
	}
	return p
}

func (p *PreEmphasizedSource) NextFrame() audio.Frame {
	f := p.src.NextFrame()
	l := float64(f.L) * p.gain
	r := float64(f.R) * p.gain
	if p.preL != nil {
		l = p.preL.Process(l)
		r = p.preR.Process(r)
	}
	return audio.NewFrame(audio.Sample(l), audio.Sample(r))
}

type SourceInfo struct {
	Kind       string
	SampleRate float64
	Detail     string
}

type MeasurementFlags struct {
	StereoEnabled           bool   `json:"stereoEnabled"`
	StereoMode              string `json:"stereoMode"`
	RDSEnabled              bool   `json:"rdsEnabled"`
	RDS2Enabled             bool   `json:"rds2Enabled"`
	BS412Enabled            bool   `json:"bs412Enabled"`
	CompositeClipperEnabled bool   `json:"compositeClipperEnabled"`
	WatermarkEnabled        bool   `json:"watermarkEnabled"`
	LicenseInjectionActive  bool   `json:"licenseInjectionActive"`
}

type LRPreEncodePostWatermarkMeasurement struct {
	LRms       float64 `json:"lRms"`
	RRms       float64 `json:"rRms"`
	LPeakAbs   float64 `json:"lPeakAbs"`
	RPeakAbs   float64 `json:"rPeakAbs"`
	LRBalanceDB float64 `json:"lrBalanceDb"`
	LClipEvents uint32 `json:"lClipEvents"`
	RClipEvents uint32 `json:"rClipEvents"`
}

type AudioMPXPreBS412Measurement struct {
	RMS                     float64 `json:"rms"`
	PeakAbs                 float64 `json:"peakAbs"`
	MonoRMS                 float64 `json:"monoRms"`
	StereoRMS               float64 `json:"stereoRms"`
	CrestFactor             float64 `json:"crestFactor"`
	ClipperLookaheadGain    float64 `json:"clipperLookaheadGain"`
	ClipperEnvelope         float64 `json:"clipperEnvelope"`
	ClipperOrProtectionActive bool  `json:"clipperOrProtectionActive"`
}

type AudioMPXPostBS412Measurement struct {
	RMS               float64 `json:"rms"`
	PeakAbs           float64 `json:"peakAbs"`
	BS412GainApplied  float64 `json:"bs412GainApplied"`
	BS412AttenuationDB float64 `json:"bs412AttenuationDb"`
	EstimatedAudioPower float64 `json:"estimatedAudioPower"`
}

type CompositeFinalPreIQMeasurement struct {
	RMS                            float64 `json:"rms"`
	PeakAbs                        float64 `json:"peakAbs"`
	PilotRMS                       float64 `json:"pilotRms"`
	PilotPeakAbs                   float64 `json:"pilotPeakAbs"`
	PilotInjectionEquivalentPercent float64 `json:"pilotInjectionEquivalentPercent"`
	RDSRMS                         float64 `json:"rdsRms"`
	RDSPeakAbs                     float64 `json:"rdsPeakAbs"`
	OverNominalEvents              uint32  `json:"overNominalEvents"`
	OverHeadroomEvents             uint32  `json:"overHeadroomEvents"`
}

type MeasurementSnapshot struct {
	Timestamp               time.Time                           `json:"timestamp"`
	SampleRateHz            float64                             `json:"sampleRateHz"`
	ChunkSamples            int                                 `json:"chunkSamples"`
	ChunkDurationMs         float64                             `json:"chunkDurationMs"`
	Sequence                uint64                              `json:"sequence"`
	Stale                   bool                                `json:"stale"`
	NoData                  bool                                `json:"noData"`
	Flags                   MeasurementFlags                    `json:"flags"`
	LRPreEncodePostWatermark LRPreEncodePostWatermarkMeasurement `json:"lrPreEncodePostWatermark"`
	AudioMPXPreBS412        AudioMPXPreBS412Measurement         `json:"audioMpxPreBs412"`
	AudioMPXPostBS412       AudioMPXPostBS412Measurement        `json:"audioMpxPostBs412"`
	CompositeFinalPreIQ     CompositeFinalPreIQMeasurement      `json:"compositeFinalPreIq"`
}

type Generator struct {
	cfg cfgpkg.Config

	// Persistent DSP state across GenerateFrame calls
	source            *PreEmphasizedSource
	stereoEncoder     stereo.StereoEncoder
	appliedStereoMode string // canonical mode currently applied to stereoEncoder; DSP goroutine only
	rdsEnc            *rds.Encoder
	rds2Enc           *rds.RDS2Encoder
	combiner          mpx.DefaultCombiner
	fmMod             *dsp.FMModulator
	sampleRate        float64
	initialized       bool
	frameSeq          uint64

	// Broadcast-standard clip-filter-clip chain (per channel L/R):
	//
	//   PreEmph → LPF₁(14kHz) → Notch(19kHz) → ×Drive
	//   → StereoLimiter (slow AGC: raises average level)
	//   → Clip₁ → LPF₂(14kHz) [cleanup] → Clip₂ [catches LPF overshoots]
	//   → Stereo Encode → Composite Clip → Notch₁₉ → Notch₅₇
	//   → + Pilot → + RDS → FM
	//
	audioLPF_L    *dsp.FilterChain // 14kHz 8th-order (pre-clip)
	audioLPF_R    *dsp.FilterChain
	pilotNotchL   *dsp.FilterChain // 19kHz double-notch (guard band)
	pilotNotchR   *dsp.FilterChain
	limiter       *dsp.StereoLimiter // slow compressor (raises average, clips catch peaks)
	cleanupLPF_L  *dsp.FilterChain   // 14kHz 8th-order (post-clip cleanup)
	cleanupLPF_R  *dsp.FilterChain
	mpxNotch19    *dsp.FilterChain // composite clipper protection
	mpxNotch57    *dsp.FilterChain
	bs412         *dsp.BS412Limiter     // ITU-R BS.412 MPX power limiter (optional)
	compositeClip *dsp.CompositeClipper // ITU-R SM.1268 iterative composite clipper (optional)

	// Pre-allocated frame buffer — reused every GenerateFrame call.
	frameBuf *output.CompositeFrame
	bufCap   int

	// Live-updatable DSP parameters — written by control API, read per chunk.
	liveParams atomic.Pointer[LiveParams]

	// Optional external audio source (e.g. StreamResampler for live audio).
	// When set, takes priority over WAV/tones in sourceFor().
	externalSource frameSource

	// Tone source reference — non-nil when a ToneSource is the active audio input.
	// Allows live-updating tone parameters via LiveParams each chunk.
	toneSource *audio.ToneSource

	// License: jingle injection when unlicensed.
	licenseState *license.State
	jingleFrames []license.JingleFrame

	// Watermark: explicit opt-in STFT-domain spread-spectrum (Kirovski & Malvar 2003).
	watermarkEnabled bool
	watermarkKey     string
	stftEmbedder     *watermark.STFTEmbedder
	wmDecimLPF       *dsp.FilterChain // anti-alias LPF for composite→12k decimation
	wmInterpLPF      *dsp.FilterChain // image-rejection LPF for 12k→composite upsample

	latestMeasurement atomic.Pointer[MeasurementSnapshot]
}

func NewGenerator(cfg cfgpkg.Config) *Generator {
	return &Generator{cfg: cfg}
}

// SetLicense configures license state for evaluation-jingle behavior only.
// It intentionally does not enable or instantiate the audio watermark path.
func (g *Generator) SetLicense(state *license.State) {
	g.licenseState = state
}

// ConfigureWatermark explicitly enables or disables the optional STFT watermark.
// Must be called before the first GenerateFrame.
func (g *Generator) ConfigureWatermark(enabled bool, key string) {
	g.watermarkEnabled = enabled
	g.watermarkKey = key
	if !enabled {
		g.stftEmbedder = nil
		g.wmDecimLPF = nil
		g.wmInterpLPF = nil
		return
	}
	g.stftEmbedder = watermark.NewSTFTEmbedder(key)
}

// SetExternalSource sets a live audio source (e.g. StreamResampler) that
// takes priority over WAV/tone sources. Must be called before the first
// GenerateFrame() call; calling it after init() has no effect because
// g.source is already wired to the old source.
func (g *Generator) SetExternalSource(src frameSource) error {
	if g.initialized {
		return fmt.Errorf("generator: SetExternalSource called after GenerateFrame; call it before the engine starts")
	}
	g.externalSource = src
	return nil
}

// UpdateLive hot-swaps DSP parameters. Thread-safe — called from control API.
// The DSP goroutine applies mode changes at the next chunk boundary.
func (g *Generator) UpdateLive(p LiveParams) {
	p.StereoMode = canonicalStereoMode(p.StereoMode)
	g.liveParams.Store(&p)
}

// CurrentLiveParams returns the current live parameter snapshot.
// Used by Engine.UpdateConfig to do read-modify-write on the params.
func (g *Generator) CurrentLiveParams() LiveParams {
	if lp := g.liveParams.Load(); lp != nil {
		return *lp
	}
	return LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0, MpxGain: 1.0}
}

func canonicalStereoMode(mode string) string {
	return stereo.ParseMode(mode).String()
}

// RDSEncoder returns the live RDS encoder, or nil if RDS is disabled.
// Used by the Engine to forward text updates.
func (g *Generator) RDSEncoder() *rds.Encoder {
	return g.rdsEnc
}

func (g *Generator) LatestMeasurement() *MeasurementSnapshot {
	if m := g.latestMeasurement.Load(); m != nil {
		copy := *m
		return &copy
	}
	return nil
}

func (g *Generator) resetSource() {
	rawSource, _ := g.sourceFor(g.sampleRate)
	g.source = NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, g.sampleRate, g.cfg.Audio.Gain)
}

func (g *Generator) resetRDS2Encoder() {
	if !g.cfg.RDS.Enabled || !g.cfg.RDS.RDS2Enabled {
		g.rds2Enc = nil
		return
	}
	g.rds2Enc = rds.NewRDS2Encoder(g.sampleRate)
	g.rds2Enc.Enable(true)
	if g.cfg.RDS.StationLogoPath != "" {
		if err := g.rds2Enc.LoadLogo(g.cfg.RDS.StationLogoPath); err != nil {
			log.Printf("rds2: failed to load station logo: %v", err)
		}
	}
}

// Reset clears stateful DSP/runtime state so the next run starts from a clean baseline
// without changing the current live parameters or feature enablement.
func (g *Generator) Reset() {
	if !g.initialized {
		return
	}

	g.resetSource()

	mode := g.appliedStereoMode
	if lp := g.liveParams.Load(); lp != nil {
		mode = canonicalStereoMode(lp.StereoMode)
	}
	g.stereoEncoder = stereo.NewStereoEncoder(g.sampleRate)
	g.stereoEncoder.SetMode(stereo.ParseMode(mode), g.sampleRate)
	g.appliedStereoMode = mode

	if g.rdsEnc != nil {
		g.rdsEnc.Reset()
	}
	g.resetRDS2Encoder()

	if g.audioLPF_L != nil {
		g.audioLPF_L.Reset()
	}
	if g.audioLPF_R != nil {
		g.audioLPF_R.Reset()
	}
	if g.pilotNotchL != nil {
		g.pilotNotchL.Reset()
	}
	if g.pilotNotchR != nil {
		g.pilotNotchR.Reset()
	}
	if g.limiter != nil {
		g.limiter.Reset()
	}
	if g.cleanupLPF_L != nil {
		g.cleanupLPF_L.Reset()
	}
	if g.cleanupLPF_R != nil {
		g.cleanupLPF_R.Reset()
	}
	if g.mpxNotch19 != nil {
		g.mpxNotch19.Reset()
	}
	if g.mpxNotch57 != nil {
		g.mpxNotch57.Reset()
	}
	if g.bs412 != nil {
		g.bs412.Reset()
	}
	if g.compositeClip != nil {
		g.compositeClip.Reset()
	}
	if g.fmMod != nil {
		g.fmMod.Reset()
	}

	if g.watermarkEnabled {
		g.stftEmbedder = watermark.NewSTFTEmbedder(g.watermarkKey)
		g.wmDecimLPF = dsp.NewLPF4(5500, g.sampleRate)
		g.wmInterpLPF = dsp.NewLPF4(5500, g.sampleRate)
	} else {
		g.stftEmbedder = nil
		g.wmDecimLPF = nil
		g.wmInterpLPF = nil
	}
}

func (g *Generator) init() {
	if g.initialized {
		return
	}
	g.sampleRate = float64(g.cfg.FM.CompositeRateHz)
	if g.sampleRate <= 0 {
		g.sampleRate = 228000
	}
	if g.watermarkEnabled {
		if g.stftEmbedder == nil {
			g.stftEmbedder = watermark.NewSTFTEmbedder(g.watermarkKey)
		}
	} else {
		g.stftEmbedder = nil
		g.wmDecimLPF = nil
		g.wmInterpLPF = nil
	}
	rawSource, _ := g.sourceFor(g.sampleRate)
	g.source = NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, g.sampleRate, g.cfg.Audio.Gain)
	g.stereoEncoder = stereo.NewStereoEncoder(g.sampleRate)
	g.appliedStereoMode = canonicalStereoMode(g.cfg.FM.StereoMode)
	g.stereoEncoder.SetMode(stereo.ParseMode(g.appliedStereoMode), g.sampleRate)
	g.combiner = mpx.DefaultCombiner{
		MonoGain: 1.0, StereoGain: 1.0,
		PilotGain: g.cfg.FM.PilotLevel, RDSGain: g.cfg.FM.RDSInjection,
	}
	if g.cfg.RDS.Enabled {
		piCode, _ := cfgpkg.ParsePI(g.cfg.RDS.PI)

		// Build EON entries
		var eonEntries []rds.EONEntry
		for _, e := range g.cfg.RDS.EON {
			eonPI, _ := cfgpkg.ParsePI(e.PI)
			eonEntries = append(eonEntries, rds.EONEntry{
				PI: eonPI, PS: e.PS, PTY: uint8(e.PTY),
				TP: e.TP, TA: e.TA, AF: e.AF,
			})
		}

		sep := g.cfg.RDS.RTPlusSeparator
		if sep == "" {
			sep = " - "
		}

		g.rdsEnc, _ = rds.NewEncoder(rds.RDSConfig{
			PI: piCode, PS: g.cfg.RDS.PS, RT: g.cfg.RDS.RadioText,
			PTY: uint8(g.cfg.RDS.PTY), SampleRate: g.sampleRate,
			TP: g.cfg.RDS.TP, TA: g.cfg.RDS.TA,
			MS: g.cfg.RDS.MS, DI: g.cfg.RDS.DI,
			AF:                g.cfg.RDS.AF,
			CTEnabled:         g.cfg.RDS.CTEnabled,
			CTOffsetHalfHours: g.cfg.RDS.CTOffsetHalfHours,
			PTYN:              g.cfg.RDS.PTYN,
			LPS:               g.cfg.RDS.LPS,
			RTPlusEnabled:     g.cfg.RDS.RTPlusEnabled,
			RTPlusSeparator:   sep,
			ERTEnabled:        g.cfg.RDS.ERTEnabled,
			ERT:               g.cfg.RDS.ERT,
			ERTGroupType:      12, // default: Group 12A
			EON:               eonEntries,
		})

		// RDS2: additional subcarriers (66.5, 71.25, 76 kHz)
		g.resetRDS2Encoder()
	}
	ceiling := g.cfg.FM.LimiterCeiling
	if ceiling <= 0 {
		ceiling = 1.0
	}

	// Broadcast clip-filter-clip chain:
	// Pre-clip: 14kHz LPF (8th-order) + 19kHz double-notch (per channel)
	g.audioLPF_L = dsp.NewAudioLPF(g.sampleRate)
	g.audioLPF_R = dsp.NewAudioLPF(g.sampleRate)
	g.pilotNotchL = dsp.NewPilotNotch(g.sampleRate)
	g.pilotNotchR = dsp.NewPilotNotch(g.sampleRate)
	// Slow compressor: 5ms attack / 200ms release. Brings average level UP.
	// The clips after it catch the peaks the limiter's attack time misses.
	// This is the "slow-to-fast progression" from broadcast processing:
	// slow limiter → fast clips.
	// Burst-masking-optimized limiter (Bonello, JAES 2007):
	// 2ms attack lets initial transient peaks clip for <5ms (burst-masked).
	// 150ms release avoids audible pumping on sustained passages.
	g.limiter = dsp.NewStereoLimiter(ceiling, 2, 150, g.sampleRate)
	// Post-clip cleanup: second 14kHz LPF pass (removes clip harmonics)
	g.cleanupLPF_L = dsp.NewAudioLPF(g.sampleRate)
	g.cleanupLPF_R = dsp.NewAudioLPF(g.sampleRate)
	// Composite clipper protection: double-notch at 19kHz + 57kHz
	g.mpxNotch19, g.mpxNotch57 = dsp.NewCompositeProtection(g.sampleRate)
	// ITU-R SM.1268 iterative composite clipper (optional, replaces simple clip+notch)
	// Always created so it can be live-toggled via CompositeClipperEnabled.
	g.compositeClip = dsp.NewCompositeClipper(dsp.CompositeClipperConfig{
		Ceiling:     ceiling,
		Iterations:  g.cfg.FM.CompositeClipper.Iterations,
		SoftKnee:    g.cfg.FM.CompositeClipper.SoftKnee,
		LookaheadMs: g.cfg.FM.CompositeClipper.LookaheadMs,
		SampleRate:  g.sampleRate,
	})
	// BS.412 MPX power limiter (EU/CH requirement for licensed FM)
	if g.cfg.FM.BS412Enabled {
		// chunkSec is not known at init time (Engine.chunkDuration may differ).
		// Pass 0 here; GenerateFrame computes the actual chunk duration from
		// the real sample count and updates BS.412 accordingly.
		g.bs412 = dsp.NewBS412Limiter(
			g.cfg.FM.BS412ThresholdDBr,
			g.cfg.FM.PilotLevel,
			g.cfg.FM.RDSInjection,
			0,
		)
	}
	if g.cfg.FM.FMModulationEnabled {
		g.fmMod = dsp.NewFMModulator(g.sampleRate)
		maxDev := g.cfg.FM.MaxDeviationHz
		if maxDev > 0 {
			if g.cfg.FM.MpxGain > 0 && g.cfg.FM.MpxGain != 1.0 {
				maxDev *= g.cfg.FM.MpxGain
			}
			g.fmMod.MaxDeviation = maxDev
		}
	}

	// Seed initial live params from config
	g.liveParams.Store(&LiveParams{
		OutputDrive:             g.cfg.FM.OutputDrive,
		StereoEnabled:           g.cfg.FM.StereoEnabled,
		StereoMode:              g.appliedStereoMode,
		PilotLevel:              g.cfg.FM.PilotLevel,
		RDSInjection:            g.cfg.FM.RDSInjection,
		RDSEnabled:              g.cfg.RDS.Enabled,
		LimiterEnabled:          g.cfg.FM.LimiterEnabled,
		LimiterCeiling:          ceiling,
		MpxGain:                 g.cfg.FM.MpxGain,
		ToneLeftHz:              g.cfg.Audio.ToneLeftHz,
		ToneRightHz:             g.cfg.Audio.ToneRightHz,
		ToneAmplitude:           g.cfg.Audio.ToneAmplitude,
		AudioGain:               g.cfg.Audio.Gain,
		CompositeClipperEnabled: g.cfg.FM.CompositeClipper.Enabled,
	})

	if g.licenseState != nil {
		frames, err := license.LoadJingleFrames(license.JingleWAV(), g.sampleRate)
		if err != nil {
			log.Printf("license: jingle load failed: %v", err)
		} else {
			g.jingleFrames = frames
		}
	}

	// STFT watermark: anti-alias LPF for decimation to WMRate (12 kHz).
	// Nyquist at 12 kHz = 6 kHz. Cut at 5.5 kHz with margin.
	if g.stftEmbedder != nil {
		g.wmDecimLPF = dsp.NewLPF4(5500, g.sampleRate)
		g.wmInterpLPF = dsp.NewLPF4(5500, g.sampleRate) // separate instance for upsample
	}

	g.initialized = true
}

func (g *Generator) sourceFor(sampleRate float64) (frameSource, SourceInfo) {
	if g.externalSource != nil {
		return g.externalSource, SourceInfo{Kind: "stream", SampleRate: sampleRate, Detail: "live audio"}
	}
	if g.cfg.Audio.InputPath != "" {
		if src, err := audio.LoadWAVSource(g.cfg.Audio.InputPath); err == nil {
			return audio.NewResampledSource(src, sampleRate), SourceInfo{Kind: "wav", SampleRate: float64(src.SampleRate), Detail: g.cfg.Audio.InputPath}
		}
		ts := audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude)
		g.toneSource = ts
		return ts, SourceInfo{Kind: "tone-fallback", SampleRate: sampleRate, Detail: g.cfg.Audio.InputPath}
	}
	ts := audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude)
	g.toneSource = ts
	return ts, SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"}
}

func clamp01(v float64) float64 {
	if v < 0 {
		return 0
	}
	if v > 1 {
		return 1
	}
	return v
}

func safeRMS(sumSquares float64, n int) float64 {
	if n <= 0 {
		return 0
	}
	return math.Sqrt(sumSquares / float64(n))
}

func safeDBRatio(a, b float64) float64 {
	if a <= 0 || b <= 0 {
		return 0
	}
	return 20 * math.Log10(a/b)
}

func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame {
	g.init()

	samples := int(duration.Seconds() * g.sampleRate)
	if samples <= 0 {
		samples = int(g.sampleRate / 10)
	}

	// Reuse buffer — grow only if needed, never shrink
	if g.frameBuf == nil || g.bufCap < samples {
		g.frameBuf = &output.CompositeFrame{
			Samples: make([]output.IQSample, samples),
		}
		g.bufCap = samples
	}
	frame := g.frameBuf
	frame.Samples = frame.Samples[:samples]
	frame.SampleRateHz = g.sampleRate
	frame.Timestamp = time.Now().UTC()
	g.frameSeq++
	frame.Sequence = g.frameSeq

	// L/R buffers for two-pass processing (STFT watermark between stages 3 and 4)
	lBuf := make([]float64, samples)
	rBuf := make([]float64, samples)

	var lrLSumSq, lrRSumSq float64
	var lrLPeak, lrRPeak float64
	var lrLClip, lrRClip uint32
	var preMonoSumSq, preStereoSumSq, preAudioMpxSumSq float64
	var preAudioMpxPeak float64
	var postAudioMpxSumSq float64
	var postAudioMpxPeak float64
	var finalCompositeSumSq float64
	var finalCompositePeak float64
	var pilotSumSq, rdsSumSq float64
	var pilotPeak, rdsPeak float64
	var overNominal, overHeadroom uint32
	licenseInjectionActive := false

	// Load live params once per chunk — single atomic read, zero per-sample cost
	lp := g.liveParams.Load()
	if lp == nil {
		// Fallback: should never happen after init(), but be safe
		lp = &LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0, MpxGain: 1.0}
	}

	if mode := canonicalStereoMode(lp.StereoMode); mode != g.appliedStereoMode {
		g.stereoEncoder.SetMode(stereo.ParseMode(mode), g.sampleRate)
		g.appliedStereoMode = mode
	}

	// Apply live tone and gain updates each chunk. GenerateFrame runs on a
	// single goroutine so these field writes are safe without additional locking.
	if g.toneSource != nil {
		g.toneSource.LeftFreq = lp.ToneLeftHz
		g.toneSource.RightFreq = lp.ToneRightHz
		g.toneSource.Amplitude = lp.ToneAmplitude
	}
	if g.source != nil {
		g.source.gain = lp.AudioGain
	}

	// Broadcast clip-filter-clip FM MPX signal chain:
	//
	//   Audio L/R → PreEmphasis
	//     → LPF₁ (14kHz, 8th-order)  → 19kHz Notch (double)
	//     → × OutputDrive             → HardClip₁ (ceiling)
	//     → LPF₂ (14kHz, 8th-order)  [removes clip₁ harmonics]
	//     → HardClip₂ (ceiling)       [catches LPF₂ overshoots]
	//     → Stereo Encode
	//   Audio MPX (mono + stereo sub)
	//     → HardClip₃ (ceiling)       [composite deviation control]
	//     → 19kHz Notch (double)      [protect pilot band]
	//     → 57kHz Notch (double)      [protect RDS band]
	//   + Pilot 19kHz (fixed, NEVER clipped)
	//   + RDS 57kHz (fixed, NEVER clipped)
	//   → FM Modulator
	//
	// Guard band depth at 19kHz: LPF₁(-21dB) + Notch(-60dB) + LPF₂(-21dB)
	//   + CompNotch(-60dB) → broadband floor -42dB, exact 19kHz >-90dB
	ceiling := lp.LimiterCeiling
	if ceiling <= 0 {
		ceiling = 1.0
	}
	// Pilot and RDS are FIXED injection levels, independent of OutputDrive.
	// Config values directly represent percentage of ±75kHz deviation:
	//   pilotLevel: 0.09 = 9% = ±6.75kHz (ITU standard)
	//   rdsInjection: 0.04 = 4% = ±3.0kHz (typical)
	pilotAmp := lp.PilotLevel
	rdsAmp := lp.RDSInjection

	// BS.412 MPX power limiter: uses previous chunk's measurement to set gain.
	// Power is measured during this chunk and fed back at the end.
	bs412Gain := 1.0
	var bs412PowerAccum float64
	if g.bs412 != nil {
		bs412Gain = g.bs412.CurrentGain()
	}

	if g.licenseState != nil {
		g.licenseState.Tick()
	}

	for i := 0; i < samples; i++ {
		in := g.source.NextFrame()

		// --- Stage 1: Band-limit pre-emphasized audio ---
		l := g.audioLPF_L.Process(float64(in.L))
		l = g.pilotNotchL.Process(l)
		r := g.audioLPF_R.Process(float64(in.R))
		r = g.pilotNotchR.Process(r)

		// --- Stage 2: Drive + Compress + Clip₁ ---
		l *= lp.OutputDrive
		r *= lp.OutputDrive
		if lp.LimiterEnabled && g.limiter != nil {
			l, r = g.limiter.Process(l, r)
		}
		l = dsp.HardClip(l, ceiling)
		r = dsp.HardClip(r, ceiling)

		// --- Stage 3: Cleanup LPF + Clip₂ (overshoot compensator) ---
		l = g.cleanupLPF_L.Process(l)
		r = g.cleanupLPF_R.Process(r)
		l = dsp.HardClip(l, ceiling)
		r = dsp.HardClip(r, ceiling)

		lBuf[i] = l
		rBuf[i] = r
	}

	// --- STFT Watermark: decimate → embed → upsample → add to L/R ---
	if g.stftEmbedder != nil {
		decimFactor := int(g.sampleRate) / watermark.WMRate // 228000/12000 = 19
		if decimFactor < 1 {
			decimFactor = 1
		}
		nDown := samples / decimFactor

		// Anti-alias: LPF ALL composite-rate samples, THEN decimate.
		// The LPF must see every sample for correct IIR state update.
		mono12k := make([]float64, nDown)
		lpfState := 0.0
		decimCount := 0
		outIdx := 0
		for i := 0; i < samples && outIdx < nDown; i++ {
			mono := (lBuf[i] + rBuf[i]) / 2
			if g.wmDecimLPF != nil {
				lpfState = g.wmDecimLPF.Process(mono)
			} else {
				lpfState = mono
			}
			decimCount++
			if decimCount >= decimFactor {
				decimCount = 0
				mono12k[outIdx] = lpfState
				outIdx++
			}
		}

		// STFT embed at 12 kHz
		embedded := g.stftEmbedder.ProcessBlock(mono12k)

		// Extract watermark signal (difference) and upsample via ZOH + LPF.
		// ZOH creates spectral images at 12k, 24k, 36k... Hz.
		// The interpolation LPF removes these, keeping only 0-5.5 kHz.
		// Without this, the images leak into pilot (19k) and stereo sub (38k).
		for i := 0; i < samples; i++ {
			wmIdx := i / decimFactor
			if wmIdx >= nDown {
				wmIdx = nDown - 1
			}
			wmSig := embedded[wmIdx] - mono12k[wmIdx]
			if g.wmInterpLPF != nil {
				wmSig = g.wmInterpLPF.Process(wmSig)
			}
			lBuf[i] += wmSig
			rBuf[i] += wmSig
		}
	}

	// --- Pass 2: Stereo encode + composite processing ---

	// Feed RDS2 groups once per frame (not per sample)
	if g.rds2Enc != nil && g.rds2Enc.Enabled() {
		g.rds2Enc.FeedGroups()
	}

	for i := 0; i < samples; i++ {
		l := lBuf[i]
		r := rBuf[i]
		lrLSumSq += l * l
		lrRSumSq += r * r
		if abs := math.Abs(l); abs > lrLPeak {
			lrLPeak = abs
		}
		if abs := math.Abs(r); abs > lrRPeak {
			lrRPeak = abs
		}
		if math.Abs(l) >= ceiling {
			lrLClip++
		}
		if math.Abs(r) >= ceiling {
			lrRClip++
		}

		// --- Stage 4: Stereo encode ---
		limited := audio.NewFrame(audio.Sample(l), audio.Sample(r))
		comps := g.stereoEncoder.Encode(limited)

		// --- Stage 5: Composite clip + protection ---
		monoComponent := float64(comps.Mono)
		stereoComponent := 0.0
		if lp.StereoEnabled {
			stereoComponent = float64(comps.Stereo)
		}
		audioMPX := monoComponent + stereoComponent
		if lp.CompositeClipperEnabled && g.compositeClip != nil {
			// ITU-R SM.1268 iterative clipper: look-ahead + N×(clip→notch→notch) + final clip
			audioMPX = g.compositeClip.Process(audioMPX)
		} else {
			// Legacy single-pass: one clip, then notch, no final safety clip
			audioMPX = dsp.HardClip(audioMPX, ceiling)
			audioMPX = g.mpxNotch19.Process(audioMPX)
			audioMPX = g.mpxNotch57.Process(audioMPX)
		}

		preAudioMpxSumSq += audioMPX * audioMPX
		preMonoSumSq += monoComponent * monoComponent
		preStereoSumSq += stereoComponent * stereoComponent
		if abs := math.Abs(audioMPX); abs > preAudioMpxPeak {
			preAudioMpxPeak = abs
		}

		// BS.412: apply gain and measure power
		if bs412Gain < 1.0 {
			audioMPX *= bs412Gain
		}
		postAudioMpxSumSq += audioMPX * audioMPX
		if abs := math.Abs(audioMPX); abs > postAudioMpxPeak {
			postAudioMpxPeak = abs
		}
		bs412PowerAccum += audioMPX * audioMPX

		// --- Stage 6: Add protected components ---
		composite := audioMPX
		if lp.StereoEnabled {
			composite += pilotAmp * comps.Pilot
		}
		rdsContribution := 0.0
		if g.rdsEnc != nil && lp.RDSEnabled {
			rdsCarrier := g.stereoEncoder.RDSCarrier()
			rdsValue := g.rdsEnc.NextSampleWithCarrier(rdsCarrier)
			rdsContribution = rdsAmp * rdsValue
			composite += rdsContribution
		}

		// RDS2: three additional subcarriers (66.5, 71.25, 76 kHz)
		// Each at ≤3.5% injection (ITU-R BS.450-3 limit: 10% total for all RDS)
		if g.rds2Enc != nil && g.rds2Enc.Enabled() {
			pilotPhase := g.stereoEncoder.PilotPhase()
			rds2Value := g.rds2Enc.NextSampleWithPilot(pilotPhase)
			// rds2Injection: ~3% per stream × 3 streams, split evenly
			composite += rdsAmp * 0.75 * rds2Value // 75% of RDS injection per stream
		}

		// Jingle: injected when unlicensed, bypasses drive/gain controls.
		if g.licenseState != nil && len(g.jingleFrames) > 0 {
			jingleContribution := g.licenseState.NextSample(g.jingleFrames)
			if jingleContribution != 0 {
				licenseInjectionActive = true
			}
			composite += jingleContribution
		}
		pilotContribution := 0.0
		if lp.StereoEnabled {
			pilotContribution = pilotAmp * comps.Pilot
		}
		pilotSumSq += pilotContribution * pilotContribution
		if abs := math.Abs(pilotContribution); abs > pilotPeak {
			pilotPeak = abs
		}
		rdsSumSq += rdsContribution * rdsContribution
		if abs := math.Abs(rdsContribution); abs > rdsPeak {
			rdsPeak = abs
		}
		finalCompositeSumSq += composite * composite
		if abs := math.Abs(composite); abs > finalCompositePeak {
			finalCompositePeak = abs
		}
		if math.Abs(composite) > 1.0 {
			overNominal++
		}
		if math.Abs(composite) > 1.1 {
			overHeadroom++
		}
		if g.fmMod != nil {
			iq_i, iq_q := g.fmMod.Modulate(composite)
			frame.Samples[i] = output.IQSample{I: float32(iq_i), Q: float32(iq_q)}
		} else {
			frame.Samples[i] = output.IQSample{I: float32(composite), Q: 0}
		}
	}

	preAudioRMS := safeRMS(preAudioMpxSumSq, samples)
	postAudioRMS := safeRMS(postAudioMpxSumSq, samples)
	lRMS := safeRMS(lrLSumSq, samples)
	rRMS := safeRMS(lrRSumSq, samples)
	monoRMS := safeRMS(preMonoSumSq, samples)
	stereoRMS := safeRMS(preStereoSumSq, samples)
	finalCompositeRMS := safeRMS(finalCompositeSumSq, samples)
	pilotRMS := safeRMS(pilotSumSq, samples)
	rdsRMS := safeRMS(rdsSumSq, samples)
	lrBalanceDB := safeDBRatio(lRMS, rRMS)
	clipperStats := dsp.CompositeClipperStats{}
	if g.compositeClip != nil {
		clipperStats = g.compositeClip.Stats()
	}
	measurement := &MeasurementSnapshot{
		Timestamp:       frame.Timestamp,
		SampleRateHz:    g.sampleRate,
		ChunkSamples:    samples,
		ChunkDurationMs: float64(samples) / g.sampleRate * 1000,
		Sequence:        frame.Sequence,
		Flags: MeasurementFlags{
			StereoEnabled:           lp.StereoEnabled,
			StereoMode:              g.appliedStereoMode,
			RDSEnabled:              lp.RDSEnabled,
			RDS2Enabled:             g.rds2Enc != nil && g.rds2Enc.Enabled(),
			BS412Enabled:            g.bs412 != nil,
			CompositeClipperEnabled: lp.CompositeClipperEnabled,
			WatermarkEnabled:        g.stftEmbedder != nil,
			LicenseInjectionActive:  licenseInjectionActive,
		},
		LRPreEncodePostWatermark: LRPreEncodePostWatermarkMeasurement{
			LRms:        lRMS,
			RRms:        rRMS,
			LPeakAbs:    lrLPeak,
			RPeakAbs:    lrRPeak,
			LRBalanceDB: lrBalanceDB,
			LClipEvents: lrLClip,
			RClipEvents: lrRClip,
		},
		AudioMPXPreBS412: AudioMPXPreBS412Measurement{
			RMS:                      preAudioRMS,
			PeakAbs:                  preAudioMpxPeak,
			MonoRMS:                  monoRMS,
			StereoRMS:                stereoRMS,
			CrestFactor:              func() float64 { if preAudioRMS > 0 { return preAudioMpxPeak / preAudioRMS }; return 0 }(),
			ClipperLookaheadGain:     clipperStats.LookaheadGain,
			ClipperEnvelope:          clipperStats.Envelope,
			ClipperOrProtectionActive: clipperStats.LookaheadGain < 0.999 || clipperStats.Envelope > 1.0,
		},
		AudioMPXPostBS412: AudioMPXPostBS412Measurement{
			RMS:                postAudioRMS,
			PeakAbs:            postAudioMpxPeak,
			BS412GainApplied:   bs412Gain,
			BS412AttenuationDB: func() float64 { if bs412Gain > 0 { return 20 * math.Log10(bs412Gain) }; return 0 }(),
			EstimatedAudioPower: func() float64 { if samples > 0 { return bs412PowerAccum / float64(samples) }; return 0 }(),
		},
		CompositeFinalPreIQ: CompositeFinalPreIQMeasurement{
			RMS:                            finalCompositeRMS,
			PeakAbs:                        finalCompositePeak,
			PilotRMS:                       pilotRMS,
			PilotPeakAbs:                   pilotPeak,
			PilotInjectionEquivalentPercent: clamp01(pilotPeak) * 100,
			RDSRMS:                         rdsRMS,
			RDSPeakAbs:                     rdsPeak,
			OverNominalEvents:              overNominal,
			OverHeadroomEvents:             overHeadroom,
		},
	}
	g.latestMeasurement.Store(measurement)

	// BS.412: feed this chunk's actual duration and average audio power for
	// the next chunk's gain calculation. Using the real sample count avoids
	// the error that occurred when chunkSec was hardcoded to 0.05 — any
	// SetChunkDuration() call from the engine would silently miscalibrate
	// the ITU-R BS.412 power measurement window.
	if g.bs412 != nil && samples > 0 {
		chunkSec := float64(samples) / g.sampleRate
		g.bs412.UpdateChunkDuration(chunkSec)
		g.bs412.ProcessChunk(bs412PowerAccum / float64(samples))
	}

	return frame
}

func (g *Generator) WriteFile(path string, duration time.Duration) error {
	if path == "" {
		path = g.cfg.Backend.OutputPath
	}
	if path == "" {
		path = filepath.Join("build", "offline", "composite.iqf32")
	}
	backend, err := output.NewFileBackend(path, binary.LittleEndian, output.BackendInfo{
		Name:        "offline-file",
		Description: "offline composite file backend",
	})
	if err != nil {
		return err
	}
	defer backend.Close(context.Background())

	if err := backend.Configure(context.Background(), output.BackendConfig{
		SampleRateHz: float64(g.cfg.FM.CompositeRateHz),
		Channels:     2,
		IQLevel:      float32(g.cfg.FM.OutputDrive),
	}); err != nil {
		return err
	}

	frame := g.GenerateFrame(duration)
	if _, err := backend.Write(context.Background(), frame); err != nil {
		return err
	}
	return backend.Flush(context.Background())
}

func (g *Generator) Summary(duration time.Duration) string {
	sampleRate := float64(g.cfg.FM.CompositeRateHz)
	if sampleRate <= 0 {
		sampleRate = 228000
	}
	_, info := g.sourceFor(sampleRate)
	preemph := "off"
	if g.cfg.FM.PreEmphasisTauUS > 0 {
		preemph = fmt.Sprintf("%.0fµs", g.cfg.FM.PreEmphasisTauUS)
	}
	modMode := "composite"
	if g.cfg.FM.FMModulationEnabled {
		modMode = fmt.Sprintf("FM-IQ(±%.0fHz)", g.cfg.FM.MaxDeviationHz)
	}
	return fmt.Sprintf("offline frame: freq=%.1fMHz rate=%d duration=%s drive=%.2f stereo=%t rds=%t preemph=%s limiter=%t output=%s source=%s detail=%s",
		g.cfg.FM.FrequencyMHz, g.cfg.FM.CompositeRateHz, duration.String(),
		g.cfg.FM.OutputDrive, g.cfg.FM.StereoEnabled, g.cfg.RDS.Enabled,
		preemph, g.cfg.FM.LimiterEnabled, modMode, info.Kind, info.Detail)
}
