/// Keeps `best_coded_pairs_for_raw_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 best_coded_pairs_for_raw_segments(
    video_segments: &[PulseSegment],
    audio_segments: &[PulseSegment],
    pulse_period_s: f64,
    pulse_width_s: f64,
    event_width_codes: &[u32],
    max_pair_gap_s: f64,
) -> Option<(Vec<PulseSegment>, Vec<PulseSegment>, Vec<MatchedOnsetPair>)> {
    if video_segments.is_empty() || audio_segments.is_empty() {
        return None;
    }

    let video_onsets_s = video_segments
        .iter()
        .map(|segment| segment.start_s)
        .collect::<Vec<_>>();
    let audio_onsets_s = audio_segments
        .iter()
        .map(|segment| segment.start_s)
        .collect::<Vec<_>>();
    let (_, _, common_window) =
        trim_onsets_to_common_activity_window(&video_onsets_s, &audio_onsets_s, max_pair_gap_s);
    let filtered_video_segments = filter_segments_to_window(video_segments, common_window);
    let filtered_audio_segments = filter_segments_to_window(audio_segments, common_window);
    if filtered_video_segments.is_empty() || filtered_audio_segments.is_empty() {
        return None;
    }

    let expected_start_skew_ms =
        (filtered_audio_segments[0].start_s - filtered_video_segments[0].start_s) * 1000.0;
    let video_indexed = index_coded_segments_by_spacing(
        &filtered_video_segments,
        pulse_period_s,
        pulse_width_s,
        event_width_codes,
    );
    let audio_indexed = index_coded_segments_by_spacing(
        &filtered_audio_segments,
        pulse_period_s,
        pulse_width_s,
        event_width_codes,
    );
    let index_pairs = best_coded_pairs_for_index_offsets(
        &video_indexed,
        &audio_indexed,
        &candidate_coded_index_offsets(&video_indexed, &audio_indexed),
        event_width_codes,
        max_pair_gap_s,
        expected_start_skew_ms,
    );
    let time_pairs = best_coded_pairs_by_time(
        &filtered_video_segments,
        &filtered_audio_segments,
        pulse_width_s,
        event_width_codes,
        max_pair_gap_s,
    );
    let pairs = if time_pairs.len() > index_pairs.len() {
        time_pairs
    } else {
        index_pairs
    };

    Some((filtered_video_segments, filtered_audio_segments, pairs))
}

#[derive(Clone, Copy)]
struct CommonActivityWindow {
    start_s: f64,
    end_s: f64,
}

impl CommonActivityWindow {
    fn filter_onsets(self, onsets_s: &[f64]) -> &[f64] {
        let start = onsets_s.partition_point(|onset_s| *onset_s < self.start_s);
        let end = onsets_s.partition_point(|onset_s| *onset_s <= self.end_s);
        &onsets_s[start..end]
    }
}

/// Keeps `trim_onsets_to_common_activity_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 trim_onsets_to_common_activity_window<'a>(
    video_onsets_s: &'a [f64],
    audio_onsets_s: &'a [f64],
    max_pair_gap_s: f64,
) -> (&'a [f64], &'a [f64], CommonActivityWindow) {
    let common_window = CommonActivityWindow {
        start_s: (video_onsets_s
            .first()
            .copied()
            .expect("validated video onset list is not empty")
            .max(
                audio_onsets_s
                    .first()
                    .copied()
                    .expect("validated audio onset list is not empty"),
            )
            - max_pair_gap_s)
            .max(0.0),
        end_s: video_onsets_s
            .last()
            .copied()
            .expect("validated video onset list is not empty")
            .min(
                audio_onsets_s
                    .last()
                    .copied()
                    .expect("validated audio onset list is not empty"),
            )
            + max_pair_gap_s,
    };
    let trimmed_video_onsets_s = common_window.filter_onsets(video_onsets_s);
    let trimmed_audio_onsets_s = common_window.filter_onsets(audio_onsets_s);
    if trimmed_video_onsets_s.is_empty() || trimmed_audio_onsets_s.is_empty() {
        return (video_onsets_s, audio_onsets_s, common_window);
    }

    (
        trimmed_video_onsets_s,
        trimmed_audio_onsets_s,
        common_window,
    )
}

fn filter_segments_to_window(
    segments: &[PulseSegment],
    common_window: CommonActivityWindow,
) -> Vec<PulseSegment> {
    segments
        .iter()
        .copied()
        .filter(|segment| {
            segment.start_s >= common_window.start_s && segment.start_s <= common_window.end_s
        })
        .collect()
}

fn segment_phase_tolerance(pulse_period_s: f64, pulse_width_s: f64, max_pair_gap_s: f64) -> f64 {
    (pulse_width_s * PHASE_TOLERANCE_WIDTH_MULTIPLIER)
        .max(max_pair_gap_s.min(pulse_period_s / 3.0))
        .min(pulse_period_s / 2.5)
}

/// Keeps `estimate_phase` 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 estimate_phase(onsets_s: &[f64], pulse_period_s: f64) -> f64 {
    let (sum_sin, sum_cos) =
        onsets_s
            .iter()
            .copied()
            .fold((0.0_f64, 0.0_f64), |(sum_sin, sum_cos), onset| {
                let wrapped = onset.rem_euclid(pulse_period_s);
                let angle = (wrapped / pulse_period_s) * std::f64::consts::TAU;
                (sum_sin + angle.sin(), sum_cos + angle.cos())
            });

    let mean_angle = sum_sin.atan2(sum_cos).rem_euclid(std::f64::consts::TAU);
    (mean_angle / std::f64::consts::TAU) * pulse_period_s
}

/// Keeps `index_onsets_by_spacing` 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 index_onsets_by_spacing(onsets_s: &[f64], pulse_period_s: f64) -> BTreeMap<i64, f64> {
    let mut indexed = BTreeMap::new();
    let Some(first_onset) = onsets_s.first().copied() else {
        return indexed;
    };

    let mut pulse_index = 0_i64;
    let mut previous_onset = first_onset;
    indexed.insert(pulse_index, first_onset);
    for onset in onsets_s.iter().copied().skip(1) {
        let pulse_steps = ((onset - previous_onset) / pulse_period_s).round().max(1.0) as i64;
        pulse_index += pulse_steps;
        indexed.insert(pulse_index, onset);
        previous_onset = onset;
    }
    indexed
}

