// Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of
// mono PCM samples into decoded text.
//
// This is the second generation of the decoder. The first one worked on clean
// machine keying but fell apart on real signals; every stage below exists to
// fix a specific failure of that version (and of naïve Goertzel decoders in
// general):
//
//	audio ─ Hamming-windowed Goertzel bank ─ pitch lock ─ dB envelope with
//	separate noise-floor / peak trackers ─ hysteresis slicer + SNR squelch ─
//	DEBOUNCED mark/space stream ─ two-cluster dit/dah length tracking ─
//	element / character / word segmentation ─ Morse table ─ text
//
// The fixes that matter, in order of impact:
//
//  1. Debounced transitions BOTH ways. The old decoder rejected too-short
//     marks but not too-short SPACES, so a one-hop fade inside a dah (QSB,
//     static crash) split it into two dits — the single biggest source of
//     garbage on real signals. Here a state flip must persist for a glitch
//     time (~0.3 dit) before it is committed; shorter flips are folded back
//     into the surrounding element.
//
//  2. Two-cluster timing. Dit and dah lengths are tracked as two separate
//     moving centres with the decision boundary at their geometric mean,
//     instead of one EMA "dot length" that both classifies marks and is
//     updated by that same classification (a feedback loop that spiralled to
//     "all dits at 60 WPM" the moment it started mis-classifying). The
//     inter-element gaps also feed the dit centre — spaces are timing
//     evidence too, and hand keying is often more regular in its gaps than
//     in its dits.
//
//  3. dB-domain envelope. Peak and noise floor are tracked in dB with
//     asymmetric attack/decay, the slicer runs at 55%/38% of the span with
//     hysteresis, and a minimum-span squelch (6 dB) keeps pure noise from
//     keying at all. In the old linear-magnitude scheme weak signals lived
//     in the bottom few percent of the scale and QSB swallowed them.
//
//  4. Windowed Goertzel. A Hamming window tames spectral leakage so a strong
//     tone doesn't bleed across the whole bank and corrupt both the noise
//     estimate and the lock choice.
//
// Kept from the first version because they were right: the pitch LOCK (decode
// one tone, ignore QRM at other pitches), pitch targeting (follow the radio's
// known CW pitch instead of searching — SetTarget), tiered acquisition
// (strong signals lock on the first hop so their opening dit isn't eaten),
// the floor frozen during key-down (a rising floor mid-dah fragments it), and
// the end-of-over flush when the lock releases.
//
// Deliberately dependency-free and fed by plain []int16 so the whole pipeline
// is unit-tested with synthetic signals (see cwdecode_test.go: clean keying at
// several speeds, added noise, QSB fading, QRM on a nearby pitch, and a
// noise-only squelch test).
//
// Honest expectations: on clean or moderately noisy signals this decodes
// solidly; very weak signals in heavy QRM remain hard for any envelope
// decoder — the tools that shine there (CW Skimmer, SDC) use probabilistic
// sequence estimation on top. This stage is designed so such a layer could be
// added later without touching the DSP.
package cwdecode

import (
	"math"
	"sort"
	"sync/atomic"
)

// Status is a periodic snapshot for the UI (pitch lock, speed, signal).
type Status struct {
	WPM    int     `json:"wpm"`
	Pitch  int     `json:"pitch"`  // Hz of the locked tone (0 = not locked)
	Level  float64 `json:"level"`  // 0..1 input audio level (RMS) for the meter
	Active bool    `json:"active"` // a tone is currently keyed down
}

// Decoder consumes PCM and emits decoded characters via onChar (one or more
// characters at a time, including " " for word gaps) and periodic onStatus.
// Process must be called from a single goroutine; SetTarget is safe to call
// concurrently.
type Decoder struct {
	fs     int
	hop    int     // samples between analyses (~6 ms)
	win    int     // Goertzel window length (~20 ms)
	hopMs  float64 // hop duration in ms
	biasMs float64 // envelope widening caused by the analysis window (see below)

	window []float64 // Hamming window, len win
	ring   []float64 // circular raw-sample buffer, len win
	rpos   int       // next write position in ring
	filled int       // samples written so far (until >= win)
	acc    int       // samples since last analysis

	ws []float64 // scratch: windowed samples for this hop

	// Search bank (only run while unlocked / untargeted).
	freqs  []float64
	coeffs []float64
	mags   []float64 // dB per bin
	nbuf   []float64 // scratch for the median

	// Fixed-pitch target (Hz). 0 = auto-search; >0 = decode exactly this pitch
	// and ignore everything else (e.g. follow the radio's CW pitch). Set live
	// from another goroutine, so it's atomic.
	targetHz    atomic.Int32
	targetFor   float64 // freq the cached target coeff was computed for
	targetCoeff float64

	// Pitch lock.
	lockIdx    int // bin index while auto-locked; -1 = unlocked
	candIdx    int
	candHops   int
	quietHops  int // consecutive key-up hops while locked (drives release)
	bankTick   int // hops since the bank last ran while locked
	betterHops int // evidence that a different bin is the real signal

	// Envelope (dB domain) on the locked/target tone.
	floorDB, peakDB float64
	bankNoiseDB     float64 // broadband reference: EMA of the bank median
	haveBankNoise   bool
	envSeeded       bool
	rawKey          bool // slicer output this hop

	// Debounced mark/space state machine.
	key        bool // committed state (true = mark)
	stableHops int  // hops in the committed state
	pendHops   int  // consecutive hops the raw state has disagreed

	// Two-cluster element timing (ms).
	// muLetterGap is this operator's typical gap BETWEEN LETTERS (ms), so the
	// word boundary can follow a wide fist instead of chopping up callsigns.
	// Zero until enough gaps have been seen.
	muLetterGap  float64
	muDit, muDah float64
	marksSeen    int

	// Character assembly. Element DURATIONS are stored and only classified
	// into dits/dahs when the character is flushed: by then the character's
	// own marks are all known, and a bimodal batch carries its own dit/dah
	// boundary — so even the very first character of an over decodes
	// correctly at any speed, before the global clusters have converged.
	elemMs      []float64
	charEmitted bool
	wordEmitted bool
	textSince   bool // something was decoded since lock (guards leading spaces)

	lastPitch float64
	lastRMS   float64

	statusEvery int
	sinceStatus int

	onChar   func(string)
	onStatus func(Status)
}

var morse = map[string]byte{
	".-": 'A', "-...": 'B', "-.-.": 'C', "-..": 'D', ".": 'E', "..-.": 'F',
	"--.": 'G', "....": 'H', "..": 'I', ".---": 'J', "-.-": 'K', ".-..": 'L',
	"--": 'M', "-.": 'N', "---": 'O', ".--.": 'P', "--.-": 'Q', ".-.": 'R',
	"...": 'S', "-": 'T', "..-": 'U', "...-": 'V', ".--": 'W', "-..-": 'X',
	"-.--": 'Y', "--..": 'Z',
	"-----": '0', ".----": '1', "..---": '2', "...--": '3', "....-": '4',
	".....": '5', "-....": '6', "--...": '7', "---..": '8', "----.": '9',
	".-.-.-": '.', "--..--": ',', "..--..": '?', "-..-.": '/', "-...-": '=',
	".-.-.": '+', "-.-.--": '!', "---...": ':', "-....-": '-', ".--.-.": '@',
}

// Tunables (hops are ~6 ms).
const (
	minDitMs = 20.0  // 60 WPM ceiling
	maxDitMs = 240.0 // 5 WPM floor
	seedDit  = 60.0  // 20 WPM seed before any marks are seen

	acqStrongDB = 12.0 // lock on the FIRST hop above this SNR (don't eat the opening dit)
	acqWeakDB   = 8.5  // lock after sustained hops above this SNR
	// Successive analysis windows overlap ~70%, so consecutive hops are highly
	// correlated — a noise spike easily "persists" 2–3 hops. Requiring ~2 full
	// window lengths of persistence makes a false noise lock genuinely rare.
	acqWeakHops = 6

	squelchDB = 6.0 // minimum peak-floor span to key at all
	// The span alone can't reject pure noise: a noise bin's dB level swings
	// ±5–6 dB, which the peak/floor trackers happily turn into a keyable
	// span. The second squelch is ABSOLUTE: the envelope peak must stand well
	// above the broadband noise reference (the bank median) — a keyed tone
	// does, noise never sustainably does.
	snrSquelchDB = 9.5

	// The slicer's span is CAPPED: with a very quiet background (high SNR, or
	// digitally silent test signals) the raw floor sits so far below the peak
	// that the off-threshold lands in the analysis window's skirts — every
	// mark then stretches by almost a full window and every gap shrinks,
	// until character gaps fall below the segmentation threshold and letters
	// merge. Capping the usable span keeps the slicer crossing near the
	// signal edges regardless of how quiet the background is.
	spanCapDB = 30.0

	onFrac  = 0.55 // slicer thresholds as a fraction of the (capped) span…
	offFrac = 0.38 // …with hysteresis

	charGapDits = 2.2 // gap > this ⇒ character boundary (geom. mean of 1 & 3 ≈ 1.7, plus margin for sloppy fists)
	wordGapDits = 4.6 // gap > this ⇒ word boundary (geom. mean of 3 & 7)

	// newOverFloorMs is the shortest silence that may end an over — see newOverMs.
	// Past it the speed estimate is dropped: the next voice on the frequency is
	// probably somebody else, and the last operator's speed is not evidence
	// about them. Two seconds is far longer than any word gap (7 dits is 0.42 s
	// even at 10 wpm) and far shorter than a pause between overs.
	newOverFloorMs = 600.0

	// wordGapRatio places the word boundary relative to the letter gaps this
	// operator ACTUALLY sends, for fists whose letter spacing runs wide. The
	// fixed 4.6-dit rule turned a 5-dit letter gap into a word, so a callsign
	// arrived as "O Y 1 C T" — which is worse than two words run together.
	wordGapRatio = 1.6
)

// New builds a decoder for the given sample rate. onChar receives decoded text
// incrementally; onStatus receives ~10 snapshots/second. Either may be nil.
func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
	if sampleRate <= 0 {
		sampleRate = 16000
	}
	d := &Decoder{
		fs:       sampleRate,
		hop:      sampleRate * 5 / 1000,  // 5 ms — resolves dits up to ~50 WPM
		win:      sampleRate * 16 / 1000, // 16 ms window (≈80 Hz bandwidth with Hamming; a 20 ms window left 40 WPM inter-element gaps with almost no envelope dip)
		lockIdx:  -1,
		candIdx:  -1,
		muDit:    seedDit,
		muDah:    3 * seedDit,
		onChar:   onChar,
		onStatus: onStatus,
	}
	if d.hop < 1 {
		d.hop = 1
	}
	if d.win < 4*d.hop {
		d.win = 4 * d.hop
	}
	d.hopMs = float64(d.hop) / float64(d.fs) * 1000
	// Even with the span cap, the analysis window widens every mark and
	// narrows every gap by roughly half a window on each edge (the tone leaks
	// into windows that straddle an edge). Durations are de-biased by this
	// constant so the timing clusters and gap thresholds see true lengths.
	d.biasMs = 0.6 * float64(d.win) / float64(d.fs) * 1000
	d.statusEvery = int(math.Max(1, 100/d.hopMs)) // ~10 Hz
	d.ring = make([]float64, d.win)
	d.ws = make([]float64, d.win)
	// Hamming window: −43 dB sidelobes keep a strong tone from bleeding across
	// the bank (rectangular Goertzel leaks at −13 dB, enough to fool the lock).
	d.window = make([]float64, d.win)
	for i := range d.window {
		d.window[i] = 0.54 - 0.46*math.Cos(2*math.Pi*float64(i)/float64(d.win-1))
	}
	// Candidate CW tones: 400–1000 Hz every 30 Hz. Deliberately NOT lower:
	// low-frequency hum/rumble rises toward DC and would win the argmax.
	for f := 400.0; f <= 1000.0; f += 30 {
		d.freqs = append(d.freqs, f)
		d.coeffs = append(d.coeffs, 2*math.Cos(2*math.Pi*f/float64(d.fs)))
	}
	d.mags = make([]float64, len(d.freqs))
	d.nbuf = make([]float64, len(d.freqs))
	return d
}

// SetTarget fixes the decode pitch to hz (an exact-frequency detector, not the
// nearest search bin), or returns to auto-search when hz <= 0. Safe to call
// concurrently.
func (d *Decoder) SetTarget(hz int) { d.targetHz.Store(int32(hz)) }

// Reset clears decode state (e.g. when the user re-arms the decoder).
func (d *Decoder) Reset() {
	d.rpos, d.filled, d.acc = 0, 0, 0
	d.lockIdx, d.candIdx, d.candHops, d.quietHops = -1, -1, 0, 0
	d.envSeeded, d.rawKey, d.key = false, false, false
	d.haveBankNoise = false
	d.stableHops, d.pendHops = 0, 0
	d.bankTick, d.betterHops = 0, 0
	d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0
	d.muLetterGap = 0
	d.elemMs = d.elemMs[:0]
	d.charEmitted, d.wordEmitted, d.textSince = true, true, false
}

// Process feeds a block of mono samples through the decoder.
func (d *Decoder) Process(samples []int16) {
	for _, s := range samples {
		d.ring[d.rpos] = float64(s)
		d.rpos++
		if d.rpos == d.win {
			d.rpos = 0
		}
		if d.filled < d.win {
			d.filled++
		}
		d.acc++
		if d.acc >= d.hop && d.filled >= d.win {
			d.acc = 0
			d.hopStep()
		}
	}
}

// goertzelDB returns the tone power (dB, arbitrary reference) of the windowed
// scratch buffer at the detector coefficient c.
func (d *Decoder) goertzelDB(c float64) float64 {
	var s1, s2 float64
	for _, x := range d.ws {
		s0 := x + c*s1 - s2
		s2 = s1
		s1 = s0
	}
	p := s1*s1 + s2*s2 - c*s1*s2
	if p < 1e-12 {
		p = 1e-12
	}
	return 10 * math.Log10(p)
}

// hopStep runs one analysis hop: tone detection, envelope, slicer, timing.
func (d *Decoder) hopStep() {
	// Materialize the window (oldest→newest; order is irrelevant for power)
	// and the RMS level for the UI meter.
	var sumSq float64
	for i := 0; i < d.win; i++ {
		x := d.ring[(d.rpos+i)%d.win]
		d.ws[i] = x * d.window[i]
		sumSq += x * x
	}
	d.lastRMS = math.Min(1, math.Sqrt(sumSq/float64(d.win))/32768*4)

	toneDB, haveTone := 0.0, false

	if th := float64(d.targetHz.Load()); th > 0 {
		// Fixed pitch: one exact-frequency detector, like a skimmer channel.
		if th != d.targetFor {
			d.targetFor = th
			d.targetCoeff = 2 * math.Cos(2*math.Pi*th/float64(d.fs))
			d.envSeeded = false // re-seed the envelope for the new channel
		}
		toneDB = d.goertzelDB(d.targetCoeff)
		d.lastPitch = th
		d.lockIdx = -1 // targeting supersedes the auto lock
		haveTone = true
		// Refresh the broadband noise reference for the absolute squelch.
		d.bankTick++
		if d.bankTick >= 8 {
			d.bankTick = 0
			for i, c := range d.coeffs {
				d.mags[i] = d.goertzelDB(c)
			}
			copy(d.nbuf, d.mags)
			sort.Float64s(d.nbuf)
			d.updateBankNoise(d.nbuf[len(d.nbuf)/2])
		}
	} else if d.lockIdx >= 0 {
		// Auto-locked: only the locked bin is needed per hop.
		toneDB = d.goertzelDB(d.coeffs[d.lockIdx])
		d.lastPitch = d.freqs[d.lockIdx]
		haveTone = true
		// Supervised re-lock: periodically sweep the whole bank anyway. If a
		// clearly stronger tone lives on a DIFFERENT pitch and keeps doing so,
		// the current lock is wrong (locked onto noise, or the operator moved)
		// — jump to the real signal instead of decoding garbage until the
		// quiet-release finally fires.
		d.bankTick++
		if d.bankTick >= 8 {
			d.bankTick = 0
			bestIdx, bestDB := -1, math.Inf(-1)
			for i, c := range d.coeffs {
				m := d.goertzelDB(c)
				d.mags[i] = m
				if i >= d.lockIdx-1 && i <= d.lockIdx+1 {
					continue
				}
				if m > bestDB {
					bestDB, bestIdx = m, i
				}
			}
			copy(d.nbuf, d.mags)
			sort.Float64s(d.nbuf)
			d.updateBankNoise(d.nbuf[len(d.nbuf)/2])
			if bestIdx >= 0 && bestDB > toneDB+6 {
				d.betterHops += 2
			} else if d.betterHops > 0 {
				d.betterHops--
			}
			if d.betterHops >= 24 && bestIdx >= 0 { // ~12 confirmations over ~1 s
				d.flushPending()
				copy(d.nbuf, d.mags)
				sort.Float64s(d.nbuf)
				d.lockIdx = bestIdx
				// Seed the envelope honestly: floor from the bank median, NOT
				// peak−cap — fabricating a full span would let a noise re-lock
				// key freely until the trackers converged.
				d.floorDB, d.peakDB = d.nbuf[len(d.nbuf)/2], bestDB
				d.quietHops, d.bankTick, d.betterHops = 0, 0, 0
				d.marksSeen = 0
				d.elemMs = d.elemMs[:0]
				d.charEmitted, d.wordEmitted, d.textSince = true, true, false
				d.key, d.stableHops, d.pendHops = false, 0, 0
				toneDB = d.goertzelDB(d.coeffs[d.lockIdx])
				d.lastPitch = d.freqs[d.lockIdx]
			}
		}
	} else {
		// Unlocked: run the whole bank, estimate noise, hunt for a tone.
		maxIdx, maxDB := 0, math.Inf(-1)
		for i, c := range d.coeffs {
			m := d.goertzelDB(c)
			d.mags[i] = m
			if m > maxDB {
				maxDB, maxIdx = m, i
			}
		}
		copy(d.nbuf, d.mags)
		sort.Float64s(d.nbuf)
		noise := d.nbuf[len(d.nbuf)/2] // median: robust to a few strong tones
		d.updateBankNoise(noise)
		snr := maxDB - noise

		near := d.candIdx >= 0 && maxIdx >= d.candIdx-1 && maxIdx <= d.candIdx+1
		if near {
			d.candHops++
		} else {
			d.candIdx, d.candHops = maxIdx, 1
		}
		// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so
		// its opening dit isn't eaten), a marginal one must persist a few hops
		// (so we don't lock onto a noise spike).
		if snr > acqStrongDB || (d.candHops >= acqWeakHops && snr > acqWeakDB) {
			d.lockIdx = maxIdx
			d.floorDB, d.peakDB = noise, maxDB
			d.envSeeded = true
			d.quietHops = 0
			d.bankTick, d.betterHops = 0, 0
			d.marksSeen = 0 // relearn speed quickly for this signal (keep muDit as seed)
			d.elemMs = d.elemMs[:0]
			d.charEmitted, d.wordEmitted, d.textSince = true, true, false
			d.key, d.stableHops, d.pendHops = false, 0, 0
			toneDB = maxDB
			d.lastPitch = d.freqs[maxIdx]
			haveTone = true
		} else {
			d.lastPitch = 0
		}
	}

	if !haveTone {
		d.rawKey = false
		d.emitStatus()
		return
	}
	if !d.envSeeded {
		// First hop on a targeted channel: seed from the current reading.
		d.floorDB, d.peakDB = toneDB, toneDB+squelchDB
		d.envSeeded = true
	}

	// ---- Envelope: separate floor / peak trackers, dB domain. ----
	// Floor drops fast, but only RISES while keyed up: a floor creeping up
	// under a long dah shrinks the span until the dah fragments into dits.
	if toneDB < d.floorDB {
		d.floorDB += (toneDB - d.floorDB) * 0.3
	} else if !d.rawKey {
		d.floorDB += (toneDB - d.floorDB) * 0.015
	}
	// Peak attacks fast and decays slowly toward current conditions (~1.5 s),
	// so QSB is followed without collapsing across ordinary word gaps.
	if toneDB > d.peakDB {
		d.peakDB += (toneDB - d.peakDB) * 0.4
	} else {
		d.peakDB += (toneDB - d.peakDB) * 0.004
	}
	if d.peakDB < d.floorDB {
		d.peakDB = d.floorDB
	}

	// ---- Slicer with hysteresis + SNR squelch. ----
	if d.peakDB-d.floorDB < squelchDB ||
		(d.haveBankNoise && d.peakDB < d.bankNoiseDB+snrSquelchDB) {
		d.rawKey = false
	} else {
		// Cap the usable span so the slicer crossings stay near the keying
		// edges even over a dead-quiet background (see spanCapDB).
		effFloor := math.Max(d.floorDB, d.peakDB-spanCapDB)
		span := d.peakDB - effFloor
		if d.rawKey {
			d.rawKey = toneDB > effFloor+offFrac*span
		} else {
			d.rawKey = toneDB > effFloor+onFrac*span
		}
	}

	// ---- Auto-lock release after a long quiet spell. ----
	if d.lockIdx >= 0 {
		if d.rawKey {
			d.quietHops = 0
		} else {
			d.quietHops++
			// Long enough to survive slow-speed word gaps (7 dits), short
			// enough to retune to a new signal within a few seconds.
			release := int(math.Max(2000, 12*d.muDit) / d.hopMs)
			if d.quietHops > release {
				d.flushPending()
				d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
				d.rawKey = false
				d.lastPitch = 0
			}
		}
	}

	d.timing()
	d.emitStatus()
}

// updateBankNoise folds a fresh bank-median reading into the broadband noise
// reference used by the absolute squelch.
func (d *Decoder) updateBankNoise(medianDB float64) {
	if !d.haveBankNoise {
		d.bankNoiseDB, d.haveBankNoise = medianDB, true
		return
	}
	d.bankNoiseDB += (medianDB - d.bankNoiseDB) * 0.2
}

// glitchHops is the debounce time in hops: ~0.3 dit, clamped to 1..3 hops
// (6–19 ms) so it neither swallows fast dits nor passes static crashes.
func (d *Decoder) glitchHops() int {
	g := int(math.Round(0.3 * d.muDit / d.hopMs))
	if g < 1 {
		g = 1
	}
	if g > 3 {
		g = 3
	}
	return g
}

// timing runs the debounced mark/space state machine for one hop.
func (d *Decoder) timing() {
	if d.rawKey == d.key {
		// Agreement folds any pending flip back into the committed state:
		// a sub-glitch dropout inside a dah (or spike inside a gap) vanishes.
		d.stableHops += 1 + d.pendHops
		d.pendHops = 0
	} else {
		d.pendHops++
		if d.pendHops > d.glitchHops() {
			seg := d.stableHops
			wasMark := d.key
			d.key = d.rawKey
			d.stableHops = d.pendHops
			d.pendHops = 0
			if wasMark {
				d.endMark(seg)
			} else {
				d.endSpace(seg)
			}
			if d.key {
				d.charEmitted, d.wordEmitted = false, false
			}
		}
	}
	if !d.key {
		d.spaceProgress()
	}
}

// endMark stores a finished key-down run for the character batch. Cluster
// updates deliberately do NOT happen here: attributing a mark to the dit or
// dah centre with the still-converging global boundary poisons the clusters
// (a dah-led character at an unexpected speed lands its dahs in the dit
// centre, which then oscillates). Both classification AND cluster updates
// happen per character in flushChar, where the batch's own contrast makes
// the attribution reliable.
func (d *Decoder) endMark(hops int) {
	ms := float64(hops)*d.hopMs - d.biasMs // de-bias the window widening
	if ms < 0.5*minDitMs {
		return // debounce residue — not a credible element
	}
	d.elemMs = append(d.elemMs, ms)
	// Bootstrap: a first mark SHORTER than the seeded dit can only be a dit of
	// a faster sender — jump the estimate straight there instead of easing in.
	if d.marksSeen == 0 && ms < d.muDit {
		d.muDit = math.Max(ms, minDitMs)
	}
	d.marksSeen++
	if len(d.elemMs) > 8 {
		d.elemMs = d.elemMs[:0] // no Morse character is that long — noise run
	}
}

// endSpace runs when a mark begins: the finished gap, if it was clearly an
// inter-element one, is extra evidence for the dit length (gaps are often
// steadier than dits in hand keying).
func (d *Decoder) endSpace(hops int) {
	ms := float64(hops)*d.hopMs + d.biasMs // gaps shrink by what marks gained

	// A long silence ends the over — see newOverMs. Forget the speed: coming back at 14 wpm
	// after following someone at 32, every element of the newcomer measured
	// longer than the stale dah threshold and the first words decoded as a run
	// of T's. Starting from the seed instead, the estimate re-converges within a
	// character — which is exactly how a freshly started decoder behaves, and
	// that case was always fine.
	if d.marksSeen > 0 && ms > d.newOverMs() {
		d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0
		d.muLetterGap = 0
		d.elemMs = d.elemMs[:0]
		return
	}

	// Letter gaps: everything between a character boundary and a word boundary.
	// Kept so the word boundary can follow this operator's own spacing.
	if d.marksSeen > 0 && ms > charGapDits*d.muDit && ms < wordGapDits*d.muDit*2 {
		if d.muLetterGap == 0 {
			d.muLetterGap = ms
		} else {
			d.muLetterGap += (ms - d.muLetterGap) * 0.2
		}
	}

	if d.marksSeen < 1 || ms < 0.35*d.muDit || ms > 1.7*d.muDit {
		return
	}
	// Early on, gaps are the FASTEST way to find the true dit length (the very
	// first inter-element gap is one, whatever the first mark was); once the
	// clusters have settled they are just a gentle refinement.
	alpha := 0.08
	if d.marksSeen < 8 {
		alpha = 0.35
	}
	d.muDit += (ms - d.muDit) * alpha
	d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs)
}

// newOverMs is the silence above which the speed estimate is dropped.
//
// It cannot be a fixed duration: 0.75 s is a quick turnaround at 30 wpm but is
// barely a word gap at 10 wpm (7 dits = 0.84 s). So it scales with the current
// estimate, with a floor so a fast operator pausing to think is not mistaken
// for a new station on every hesitation.
func (d *Decoder) newOverMs() float64 {
	if v := 12 * d.muDit; v > newOverFloorMs {
		return v
	}
	return newOverFloorMs
}

// wordGapMs is the silence above which a word boundary is declared.
//
// The textbook answer is 4.6 dits — the geometric mean of a 3-dit letter gap
// and a 7-dit word gap. It assumes the operator sends textbook spacing. Many do
// not: a fist that leaves 5 dits between letters had every letter turned into a
// word, so callsigns arrived in pieces.
//
// So the boundary also follows the letter gaps actually observed. Whichever is
// larger wins: a textbook fist keeps the textbook boundary, a wide one gets a
// wider boundary. The cost is that a wide sender's words may run together —
// which is a far smaller price than a callsign broken into letters.
func (d *Decoder) wordGapMs() float64 {
	fixed := wordGapDits * d.muDit
	if d.muLetterGap > 0 {
		if adaptive := wordGapRatio * d.muLetterGap; adaptive > fixed {
			return adaptive
		}
	}
	return fixed
}

// spaceProgress emits the pending character / word space LIVE once the current
// gap crosses each boundary (instead of waiting for the next mark), so text
// appears as it is sent.
func (d *Decoder) spaceProgress() {
	gapMs := float64(d.stableHops)*d.hopMs + d.biasMs
	if !d.charEmitted && gapMs > charGapDits*d.muDit {
		d.flushChar()
		d.charEmitted = true
	}
	if !d.wordEmitted && gapMs > d.wordGapMs() {
		if d.textSince && d.onChar != nil {
			d.onChar(" ")
		}
		d.wordEmitted = true
	}
}

// flushChar classifies the accumulated mark durations into dits/dahs and
// emits the character. Classification happens HERE, not as marks arrive: a
// character whose own marks are clearly bimodal carries its own dit/dah
// boundary (their geometric mean), which decodes correctly even before the
// global clusters have converged — the first character of an over included.
// Unimodal characters (EEE, TTT, 555…) fall back to the global boundary.
func (d *Decoder) flushChar() {
	if len(d.elemMs) == 0 {
		return
	}
	lo, hi := d.elemMs[0], d.elemMs[0]
	for _, v := range d.elemMs[1:] {
		lo = math.Min(lo, v)
		hi = math.Max(hi, v)
	}
	b := math.Sqrt(d.muDit * d.muDah)
	if hi >= 2*lo {
		b = math.Sqrt(lo * hi) // the batch is bimodal: trust its own contrast
	}
	alpha := 0.18
	if d.marksSeen <= 8 {
		alpha = 0.45 // converge fast on a new sender
	}
	pat := make([]byte, len(d.elemMs))
	for i, v := range d.elemMs {
		if v < b {
			pat[i] = '.'
			d.muDit += (v - d.muDit) * alpha
		} else {
			pat[i] = '-'
			d.muDah += (v - d.muDah) * alpha
		}
	}
	// Ratio guards: dah stays 2–4.8 dits; dit stays within speed limits.
	d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs)
	if d.muDah < 2.0*d.muDit {
		d.muDah = 2.0 * d.muDit
	}
	if d.muDah > 4.8*d.muDit {
		d.muDah = 4.8 * d.muDit
	}
	d.elemMs = d.elemMs[:0]
	if c, ok := morse[string(pat)]; ok {
		if d.onChar != nil {
			d.onChar(string(c))
		}
		d.textSince = true
	} else if len(pat) <= 7 {
		// Morse-shaped but unknown → flag it; longer runs are noise, drop.
		if d.onChar != nil {
			d.onChar("?")
		}
		d.textSince = true
	}
}

// flushPending finishes the in-progress character and word at end-of-over
// (lock release), so the last word isn't left hanging until the next signal.
func (d *Decoder) flushPending() {
	if !d.charEmitted {
		d.flushChar()
		d.charEmitted = true
	}
	if !d.wordEmitted && d.textSince && d.onChar != nil {
		d.onChar(" ")
		d.wordEmitted = true
	}
}

func (d *Decoder) emitStatus() {
	d.sinceStatus++
	if d.sinceStatus < d.statusEvery || d.onStatus == nil {
		return
	}
	d.sinceStatus = 0
	wpm := 0
	if d.muDit > 0 && (d.lockIdx >= 0 || d.targetHz.Load() > 0) {
		wpm = int(math.Round(1200 / d.muDit))
	}
	d.onStatus(Status{
		WPM:    wpm,
		Pitch:  int(math.Round(d.lastPitch)),
		Level:  d.lastRMS,
		Active: d.rawKey,
	})
}
