// Package dsp — CompositeClipper implements a broadcast-grade iterative
// composite MPX clipper with optional soft-knee clipping and look-ahead
// peak limiting.
//
// # Signal flow
//
//   audioMPX (mono + stereo sub, no pilot/RDS)
//     → Look-ahead pre-limiter (optional, reduces peak:average before clipping)
//     → N iterations of: Clip → Notch19kHz → Notch57kHz
//     → Final hard-clip safety net
//     → output
//
// Each iteration reduces spectral splatter into the pilot (19 kHz) and RDS
// (57 kHz) guard bands caused by the previous clip, while the subsequent
// clip catches the filter overshoot. After 2-3 iterations the signal
// converges: peaks stay below ceiling AND protected bands are clean.
//
// The look-ahead limiter uses a delay line so the envelope detector sees
// peaks before they reach the clipper. This allows smooth gain reduction
// instead of hard clipping, producing fewer harmonics per iteration.
//
// # Comparable to
//
// Omnia.11 composite processing, Orban Optimod 8700 composite clipper,
// and similar ITU-R SM.1268-compliant broadcast processors.
package dsp

import "math"

// CompositeClipperConfig holds the parameters for NewCompositeClipper.
type CompositeClipperConfig struct {
	// Ceiling is the peak output level. Typically 1.0 (= 100% modulation
	// for the audio portion). Pilot and RDS are added externally after.
	Ceiling float64

	// Iterations is the number of clip-filter-clip passes (1-5).
	// More iterations = cleaner guard bands, slightly more latency from
	// filter group delay. 3 is a good default; 1 is conservative, 5 is
	// aggressive (Omnia "brick wall" territory).
	Iterations int

	// SoftKnee sets the width of the soft-clip transition zone below
	// ceiling, in linear amplitude. 0 = hard clip, 0.15 = moderate,
	// 0.3 = gentle. The soft clipper uses tanh() compression above
	// (ceiling - softKnee), producing fewer harmonics per iteration
	// at the cost of slightly lower peak density.
	SoftKnee float64

	// LookaheadMs sets the look-ahead delay in milliseconds. 0 = disabled.
	// Typical values: 0.5-2.0 ms. The limiter pre-reduces peaks before
	// they hit the clipper, so the clipper does less work and generates
	// fewer harmonics. Introduces LookaheadMs of audio latency.
	LookaheadMs float64

	// SampleRate must match the composite DSP rate (typically 228000 Hz).
	SampleRate float64
}

// CompositeClipper is a stateful per-sample processor. Not thread-safe —
// call Process from the single DSP goroutine only.
type CompositeClipper struct {
	ceiling    float64
	softKnee   float64
	iterations int

	// Per-iteration filter banks — each iteration needs independent state
	// because it processes a different signal (output of previous clip).
	notch19 []*FilterChain
	notch57 []*FilterChain

	// Look-ahead limiter state
	laEnabled bool
	laDelay   []float64 // circular delay buffer
	laLen     int       // delay length in samples
	laWrite   int       // write position
	laEnv     float64   // peak envelope (tracks input peaks)
	laGain    float64   // current gain multiplier (applied to delayed output)
	laAttack  float64   // envelope/gain attack coefficient
	laRelease float64   // envelope/gain release coefficient
}

// NewCompositeClipper creates a ready-to-use composite clipper.
// Returns nil if cfg is invalid or sample rate is zero.
func NewCompositeClipper(cfg CompositeClipperConfig) *CompositeClipper {
	if cfg.SampleRate <= 0 {
		return nil
	}
	if cfg.Ceiling <= 0 {
		cfg.Ceiling = 1.0
	}
	if cfg.Iterations < 1 {
		cfg.Iterations = 1
	}
	if cfg.Iterations > 5 {
		cfg.Iterations = 5
	}
	if cfg.SoftKnee < 0 {
		cfg.SoftKnee = 0
	}

	c := &CompositeClipper{
		ceiling:    cfg.Ceiling,
		softKnee:   cfg.SoftKnee,
		iterations: cfg.Iterations,
		notch19:    make([]*FilterChain, cfg.Iterations),
		notch57:    make([]*FilterChain, cfg.Iterations),
		laGain:     1.0,
	}

	// Build per-iteration filter pairs.
	// Double-cascade notch at each frequency for deep rejection.
	// Q=15 at 19 kHz → narrow (~1.3 kHz), preserves stereo sub.
	// Q=10 at 57 kHz → slightly wider (~5.7 kHz), covers RDS bandwidth.
	for i := 0; i < cfg.Iterations; i++ {
		c.notch19[i] = &FilterChain{
			Stages: []Biquad{
				*NewNotch(19000, cfg.SampleRate, 15),
				*NewNotch(19000, cfg.SampleRate, 15),
			},
		}
		c.notch57[i] = &FilterChain{
			Stages: []Biquad{
				*NewNotch(57000, cfg.SampleRate, 10),
				*NewNotch(57000, cfg.SampleRate, 10),
			},
		}
	}

	// Look-ahead limiter
	if cfg.LookaheadMs > 0 {
		c.laEnabled = true
		c.laLen = int(math.Round(cfg.LookaheadMs * cfg.SampleRate / 1000))
		if c.laLen < 1 {
			c.laLen = 1
		}
		c.laDelay = make([]float64, c.laLen)

		// Attack: ramp down over half the look-ahead window so gain is
		// fully reduced by the time the peak exits the delay line.
		// Using half the window gives ~86% reduction at exit (2 time constants).
		attackSamples := float64(c.laLen) / 2
		if attackSamples < 1 {
			attackSamples = 1
		}
		c.laAttack = 1.0 - math.Exp(-1.0/attackSamples)

		// Release: 20ms — fast enough to recover between transients,
		// slow enough to avoid gain pumping on dense material.
		releaseSamples := 0.020 * cfg.SampleRate
		if releaseSamples < 1 {
			releaseSamples = 1
		}
		c.laRelease = 1.0 - math.Exp(-1.0/releaseSamples)
	}

	return c
}

// Process runs one composite sample through the full chain.
//
// Input: audio-only MPX (mono + stereo sub, no pilot, no RDS).
// Output: peak-limited, spectrally clean MPX. Never exceeds ceiling.
//
// The caller adds pilot and RDS to the output AFTER this call.
func (c *CompositeClipper) Process(audioMPX float64) float64 {
	x := audioMPX

	// --- Stage 1: Look-ahead pre-limiting ---
	// Reduces peaks smoothly before the clipper sees them.
	// Fewer hard-clip events → fewer harmonics → cleaner output.
	if c.laEnabled {
		x = c.processLookahead(x)
	}

	// --- Stage 2: Iterative clip → notch → notch ---
	// Each pass:
	//  - Clip catches peaks (from input or previous filter overshoot)
	//  - Notch19 removes energy at pilot frequency
	//  - Notch57 removes energy at RDS frequency
	//  - Filter ringing creates small overshoots → next pass catches them
	// After N iterations the overshoot converges to near-zero.
	for i := 0; i < c.iterations; i++ {
		x = c.clip(x)
		x = c.notch19[i].Process(x)
		x = c.notch57[i].Process(x)
	}

	// --- Stage 3: Final safety hard-clip ---
	// Catches residual overshoot from the last iteration's notch filters.
	// Even with 3+ iterations there can be sub-0.1 dB overshoot from the
	// final notch; this guarantees the output never exceeds ceiling.
	x = HardClip(x, c.ceiling)

	return x
}

// processLookahead applies look-ahead peak limiting.
//
// The input enters a delay line. The envelope detector runs on the INPUT
// (not the delayed output), so it sees peaks LookaheadMs before they exit
// the delay. Gain is reduced smoothly over the look-ahead window, so when
// the peak arrives at the output, the gain is already low enough.
func (c *CompositeClipper) processLookahead(in float64) float64 {
	// Read delayed sample (from laLen samples ago)
	out := c.laDelay[c.laWrite]
	// Write new input into delay line
	c.laDelay[c.laWrite] = in
	c.laWrite++
	if c.laWrite >= c.laLen {
		c.laWrite = 0
	}

	// Envelope follower on the raw input — sees peaks before they exit
	absIn := math.Abs(in)
	if absIn > c.laEnv {
		// Attack: fast rise toward the peak
		c.laEnv += c.laAttack * (absIn - c.laEnv)
	} else {
		// Release: slow decay back down
		c.laEnv += c.laRelease * (absIn - c.laEnv)
	}

	// Target gain to keep output at ceiling
	targetGain := 1.0
	if c.laEnv > c.ceiling {
		targetGain = c.ceiling / c.laEnv
	}

	// Smooth gain transitions (same attack/release as envelope)
	if targetGain < c.laGain {
		c.laGain += c.laAttack * (targetGain - c.laGain)
	} else {
		c.laGain += c.laRelease * (targetGain - c.laGain)
	}

	return out * c.laGain
}

// clip applies either soft-knee or hard clipping depending on config.
func (c *CompositeClipper) clip(x float64) float64 {
	if c.softKnee <= 0 {
		return HardClip(x, c.ceiling)
	}
	return SoftClip(x, c.ceiling, c.softKnee)
}

// SoftClip applies tanh-based soft clipping with a configurable knee.
//
// Below (ceiling - knee): linear, no distortion.
// Above (ceiling - knee): tanh compression, asymptotically approaching ceiling.
// The transition is C1-continuous (slope = 1.0 at the knee boundary).
//
// This generates significantly fewer harmonics than hard clipping, which
// means the notch filters have less work to do and produce less overshoot.
func SoftClip(x, ceiling, knee float64) float64 {
	if knee <= 0 {
		return HardClip(x, ceiling)
	}

	threshold := ceiling - knee
	if threshold < 0 {
		threshold = 0
	}

	ax := math.Abs(x)
	if ax <= threshold {
		return x // linear region — no distortion
	}

	s := 1.0
	if x < 0 {
		s = -1.0
	}

	// tanh compression: excess above threshold is compressed toward knee.
	// At excess=0: output = threshold, slope = 1.0 (C1 continuous).
	// At excess→∞: output → threshold + knee = ceiling.
	excess := ax - threshold
	compressed := threshold + knee*math.Tanh(excess/knee)
	return s * compressed
}

// Reset clears all filter and look-ahead state, as if freshly constructed.
func (c *CompositeClipper) Reset() {
	for i := range c.notch19 {
		c.notch19[i].Reset()
		c.notch57[i].Reset()
	}
	if c.laEnabled {
		for i := range c.laDelay {
			c.laDelay[i] = 0
		}
		c.laWrite = 0
		c.laEnv = 0
		c.laGain = 1.0
	}
}

// Stats returns diagnostic values for monitoring/logging.
func (c *CompositeClipper) Stats() CompositeClipperStats {
	return CompositeClipperStats{
		LookaheadGain: c.laGain,
		Envelope:      c.laEnv,
	}
}

// CompositeClipperStats exposes internal state for diagnostics.
type CompositeClipperStats struct {
	LookaheadGain float64 `json:"lookaheadGain"`
	Envelope      float64 `json:"envelope"`
}
