pub fn detect_audio_onsets(
    samples: &[i16],
    sample_rate_hz: u32,
    window_ms: u32,
) -> Result<Vec<f64>> {
    Ok(detect_audio_segments(samples, sample_rate_hz, window_ms)?
        .into_iter()
        .map(|segment| segment.start_s)
        .collect())
}

pub(crate) fn detect_audio_segments(
    samples: &[i16],
    sample_rate_hz: u32,
    window_ms: u32,
) -> Result<Vec<PulseSegment>> {
    detect_audio_segments_with_optional_codes(samples, sample_rate_hz, window_ms, &[], 0.0)
}

/// Keeps `detect_coded_audio_segments` explicit because it sits on sync-probe analysis, where small timestamp or pairing mistakes can hide real A/V skew.
/// Inputs are the typed parameters; output is the return value or side effect.
pub(crate) fn detect_coded_audio_segments(
    samples: &[i16],
    sample_rate_hz: u32,
    window_ms: u32,
    event_codes: &[u32],
    pulse_width_s: f64,
) -> Result<Vec<PulseSegment>> {
    if pulse_width_s <= 0.0 {
        bail!("pulse width must stay positive");
    }
    if event_codes.is_empty() {
        bail!("event code list must not be empty");
    }
    if let Some(unsupported) = event_codes
        .iter()
        .find(|code| audio_frequency_for_event_code(**code).is_none())
    {
        bail!("event code {unsupported} has no audio tone signature");
    }

    detect_audio_segments_with_optional_codes(
        samples,
        sample_rate_hz,
        window_ms,
        event_codes,
        pulse_width_s,
    )
}

/// Keeps `detect_audio_segments_with_optional_codes` explicit because it sits on sync-probe analysis, where small timestamp or pairing mistakes can hide real A/V skew.
/// Inputs are the typed parameters; output is the return value or side effect.
fn detect_audio_segments_with_optional_codes(
    samples: &[i16],
    sample_rate_hz: u32,
    window_ms: u32,
    event_codes: &[u32],
    pulse_width_s: f64,
) -> Result<Vec<PulseSegment>> {
    if samples.is_empty() {
        bail!("capture did not contain any audio samples");
    }
    if sample_rate_hz == 0 {
        bail!("audio sample rate must stay positive");
    }
    if window_ms == 0 {
        bail!("audio analysis window must stay positive");
    }

    let window_samples = ((sample_rate_hz as usize * window_ms as usize) / 1000).max(1);
    let amplitude_envelope = samples
        .chunks(window_samples)
        .map(|chunk| {
            let total: u64 = chunk
                .iter()
                .map(|sample| i32::from(*sample).unsigned_abs() as u64)
                .sum();
            total as f64 / chunk.len() as f64
        })
        .collect::<Vec<_>>();
    let tone_windows = samples
        .chunks(window_samples)
        .map(|chunk| strongest_probe_tone_window(chunk, sample_rate_hz, event_codes))
        .collect::<Vec<_>>();
    let tone_envelope = tone_windows
        .iter()
        .map(|window| window.level)
        .collect::<Vec<_>>();
    let envelope = choose_audio_detection_envelope(&amplitude_envelope, &tone_envelope);
    let peak = envelope.iter().copied().fold(0.0_f64, f64::max);
    if peak < MIN_AUDIO_PROBE_PEAK {
        bail!("audio probe peaks are too quiet to detect sync pulses");
    }
    let baseline = median(envelope.clone());
    let threshold = baseline + ((peak - baseline) * AUDIO_ENVELOPE_THRESHOLD_FRACTION);
    let sample_abs = samples
        .iter()
        .map(|sample| i32::from(*sample).unsigned_abs() as f64)
        .collect::<Vec<_>>();
    let sample_peak = sample_abs.iter().copied().fold(0.0_f64, f64::max);
    let sample_baseline = median(sample_abs.clone());
    let sample_threshold =
        sample_baseline + ((sample_peak - sample_baseline) * AUDIO_SAMPLE_THRESHOLD_FRACTION);
    let mut segments = Vec::new();
    let mut previous_active = false;
    let mut segment_start = 0usize;
    let mut segment_codes = Vec::<u32>::new();
    for (index, level) in envelope.iter().copied().enumerate() {
        let active = level >= threshold;
        if active && !previous_active {
            segment_start = index;
            segment_codes.clear();
        }
        if !event_codes.is_empty()
            && active
            && let Some(code) = tone_windows.get(index).and_then(|window| window.code)
        {
            segment_codes.push(code);
        }
        if previous_active && !active {
            push_audio_segment(
                &mut segments,
                samples,
                sample_rate_hz,
                window_samples,
                segment_start,
                index,
                sample_threshold,
                dominant_event_code(&segment_codes).map(|code| pulse_width_s * f64::from(code)),
            );
            segment_codes.clear();
        }
        previous_active = active;
    }
    if previous_active {
        push_audio_segment(
            &mut segments,
            samples,
            sample_rate_hz,
            window_samples,
            segment_start,
            envelope.len(),
            sample_threshold,
            dominant_event_code(&segment_codes).map(|code| pulse_width_s * f64::from(code)),
        );
    }

    if event_codes.is_empty() {
        Ok(merge_nearby_audio_segments(segments))
    } else {
        Ok(merge_nearby_coded_audio_segments(segments))
    }
}

/// Keeps `choose_audio_detection_envelope` explicit because it sits on sync-probe analysis, where small timestamp or pairing mistakes can hide real A/V skew.
/// Inputs are the typed parameters; output is the return value or side effect.
fn choose_audio_detection_envelope(amplitude_envelope: &[f64], tone_envelope: &[f64]) -> Vec<f64> {
    let smoothed_amplitude = smooth_envelope(amplitude_envelope);
    let smoothed_tone = smooth_envelope(tone_envelope);
    let amplitude_peak = smoothed_amplitude.iter().copied().fold(0.0_f64, f64::max);
    let amplitude_baseline = median(smoothed_amplitude.clone());
    let tone_peak = smoothed_tone.iter().copied().fold(0.0_f64, f64::max);
    let tone_baseline = median(smoothed_tone.clone());
    let amplitude_contrast = (amplitude_peak - amplitude_baseline).max(0.0);
    let tone_contrast = (tone_peak - tone_baseline).max(0.0);

    if tone_peak >= MIN_TONE_ENVELOPE_PEAK
        && tone_contrast >= amplitude_contrast * MIN_TONE_CONTRAST_FRACTION_OF_AMPLITUDE
    {
        smoothed_tone
    } else {
        smoothed_amplitude
    }
}

#[derive(Clone, Copy, Debug)]
struct ProbeToneWindow {
    code: Option<u32>,
    level: f64,
}

/// Keeps `strongest_probe_tone_window` explicit because it sits on sync-probe analysis, where small timestamp or pairing mistakes can hide real A/V skew.
/// Inputs are the typed parameters; output is the return value or side effect.
fn strongest_probe_tone_window(
    samples: &[i16],
    sample_rate_hz: u32,
    event_codes: &[u32],
) -> ProbeToneWindow {
    let code_iter: Box<dyn Iterator<Item = u32> + '_> = if event_codes.is_empty() {
        Box::new(1..=MAX_EVENT_CODE)
    } else {
        Box::new(event_codes.iter().copied())
    };
    let mut candidates = code_iter
        .filter_map(|code| {
            audio_frequency_for_event_code(code)
                .map(|frequency_hz| (code, goertzel_level(samples, sample_rate_hz, frequency_hz)))
        })
        .collect::<Vec<_>>();
    candidates.sort_by(|(_, left_level), (_, right_level)| right_level.total_cmp(left_level));

    let Some((code, level)) = candidates.first().copied() else {
        return ProbeToneWindow {
            code: None,
            level: 0.0,
        };
    };
    let runner_up = candidates.get(1).map(|(_, level)| *level).unwrap_or(0.0);
    ProbeToneWindow {
        code: (level >= MIN_TONE_ENVELOPE_PEAK
            && level >= runner_up * MIN_TONE_CODE_DOMINANCE_RATIO)
            .then_some(code),
        level,
    }
}

fn audio_frequency_for_event_code(code: u32) -> Option<f64> {
    probe_audio_frequency_for_event_code(code)
}

/// Keeps `goertzel_level` explicit because it sits on sync-probe analysis, where small timestamp or pairing mistakes can hide real A/V skew.
/// Inputs are the typed parameters; output is the return value or side effect.
fn goertzel_level(samples: &[i16], sample_rate_hz: u32, frequency_hz: f64) -> f64 {
    if samples.is_empty() || sample_rate_hz == 0 {
        return 0.0;
    }
    let omega = 2.0 * std::f64::consts::PI * frequency_hz / f64::from(sample_rate_hz);
    let coefficient = 2.0 * omega.cos();
    let mut q1 = 0.0_f64;
    let mut q2 = 0.0_f64;
    for sample in samples {
        let q0 = f64::from(*sample) + coefficient * q1 - q2;
        q2 = q1;
        q1 = q0;
    }
    let power = q1 * q1 + q2 * q2 - coefficient * q1 * q2;
    power.max(0.0).sqrt() / samples.len() as f64
}

/// Keeps `smooth_envelope` explicit because it sits on sync-probe analysis, where small timestamp or pairing mistakes can hide real A/V skew.
/// Inputs are the typed parameters; output is the return value or side effect.
fn smooth_envelope(envelope: &[f64]) -> Vec<f64> {
    if envelope.len() < 3 {
        return envelope.to_vec();
    }

    (0..envelope.len())
        .map(|index| {
            let start = index.saturating_sub(1);
            let end = (index + 2).min(envelope.len());
            envelope[start..end].iter().sum::<f64>() / (end - start) as f64
        })
        .collect()
}

pub(super) fn edge_midpoint(previous_s: f64, current_s: f64) -> f64 {
    previous_s + ((current_s - previous_s) / 2.0)
}

/// Keeps `window_segment` explicit because it sits on sync-probe analysis, where small timestamp or pairing mistakes can hide real A/V skew.
/// Inputs are the typed parameters; output is the return value or side effect.
pub(super) fn window_segment(
    samples: &[i16],
    sample_rate_hz: u32,
    window_samples: usize,
    start_window_index: usize,
    end_window_index_exclusive: usize,
    sample_threshold: f64,
) -> PulseSegment {
    let start_sample = start_window_index.saturating_mul(window_samples);
    let end_sample = end_window_index_exclusive
        .saturating_mul(window_samples)
        .min(samples.len());

    let refined_start_sample = samples[start_sample..end_sample]
        .iter()
        .position(|sample| i32::from(*sample).unsigned_abs() as f64 >= sample_threshold)
        .map(|offset| start_sample + offset)
        .unwrap_or(start_sample);
    let refined_end_sample = samples[start_sample..end_sample]
        .iter()
        .rposition(|sample| i32::from(*sample).unsigned_abs() as f64 >= sample_threshold)
        .map(|offset| start_sample + offset + 1)
        .unwrap_or(end_sample);

    let start_s = refined_start_sample as f64 / f64::from(sample_rate_hz);
    let end_s = refined_end_sample.max(refined_start_sample + 1) as f64 / f64::from(sample_rate_hz);
    PulseSegment {
        start_s,
        end_s,
        duration_s: end_s - start_s,
    }
}

#[allow(clippy::too_many_arguments)]
/// Keeps `push_audio_segment` explicit because it sits on sync-probe analysis, where small timestamp or pairing mistakes can hide real A/V skew.
/// Inputs are the typed parameters; output is the return value or side effect.
fn push_audio_segment(
    segments: &mut Vec<PulseSegment>,
    samples: &[i16],
    sample_rate_hz: u32,
    window_samples: usize,
    start_window_index: usize,
    end_window_index_exclusive: usize,
    sample_threshold: f64,
    encoded_duration_s: Option<f64>,
) {
    let mut segment = window_segment(
        samples,
        sample_rate_hz,
        window_samples,
        start_window_index,
        end_window_index_exclusive,
        sample_threshold,
    );
    if let Some(encoded_duration_s) = encoded_duration_s {
        segment.duration_s = encoded_duration_s;
    }
    segments.push(segment);
}

/// Keeps `merge_nearby_coded_audio_segments` explicit because it sits on sync-probe analysis, where small timestamp or pairing mistakes can hide real A/V skew.
/// Inputs are the typed parameters; output is the return value or side effect.
fn merge_nearby_coded_audio_segments(segments: Vec<PulseSegment>) -> Vec<PulseSegment> {
    let mut merged = Vec::<PulseSegment>::new();
    for segment in segments {
        match merged.last_mut() {
            Some(prior) if segment.start_s - prior.end_s <= MAX_AUDIO_PULSE_INTERNAL_GAP_S => {
                prior.end_s = segment.end_s;
                prior.duration_s = prior.duration_s.max(segment.duration_s);
            }
            _ => merged.push(segment),
        }
    }
    merged
}
