use serde::Serialize;

const DEFAULT_AUDIO_WINDOW_MS: u32 = 5;
const DEFAULT_MAX_PAIR_GAP_S: f64 = 0.5;
const DEFAULT_PULSE_PERIOD_S: f64 = 1.0;
const DEFAULT_PULSE_WIDTH_S: f64 = 0.12;
const DEFAULT_MARKER_TICK_PERIOD: u32 = 5;
const CALIBRATION_MIN_PAIRED_EVENTS: usize = 13;
const CALIBRATION_MAX_DRIFT_MS: f64 = 40.0;
const CALIBRATION_MAX_START_MEDIAN_DISAGREEMENT_MS: f64 = 250.0;
const CALIBRATION_SETTLED_SKEW_MS: f64 = 5.0;
const RAW_ACTIVITY_CONFIRMS_PAIR_MAX_DISAGREEMENT_MS: f64 = 250.0;
const VERDICT_MIN_PAIRED_EVENTS: usize = 3;
const VERDICT_PREFERRED_P95_ABS_SKEW_MS: f64 = 35.0;
const VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS: f64 = 80.0;
const VERDICT_GROSS_FAILURE_P95_ABS_SKEW_MS: f64 = 250.0;
const VERDICT_CATASTROPHIC_MAX_ABS_SKEW_MS: f64 = 1_000.0;

#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct SyncAnalysisReport {
    pub video_event_count: usize,
    pub audio_event_count: usize,
    pub paired_event_count: usize,
    pub coded_events: bool,
    pub activity_start_delta_ms: f64,
    pub raw_first_video_activity_s: f64,
    pub raw_first_audio_activity_s: f64,
    pub first_skew_ms: f64,
    pub last_skew_ms: f64,
    pub mean_skew_ms: f64,
    pub median_skew_ms: f64,
    pub max_abs_skew_ms: f64,
    pub drift_ms: f64,
    pub skews_ms: Vec<f64>,
    pub video_onsets_s: Vec<f64>,
    pub audio_onsets_s: Vec<f64>,
    pub paired_events: Vec<SyncEventPair>,
}

#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct SyncEventPair {
    pub event_id: usize,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub server_event_id: Option<usize>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub event_code: Option<u32>,
    pub video_time_s: f64,
    pub audio_time_s: f64,
    pub skew_ms: f64,
    pub confidence: f64,
}

#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct SyncAnalysisVerdict {
    pub status: String,
    pub passed: bool,
    pub p95_abs_skew_ms: f64,
    pub max_abs_skew_ms: f64,
    pub preferred_p95_abs_skew_ms: f64,
    pub acceptable_p95_abs_skew_ms: f64,
    pub gross_failure_p95_abs_skew_ms: f64,
    pub catastrophic_max_abs_skew_ms: f64,
    pub reason: String,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct SyncCalibrationRecommendation {
    pub ready: bool,
    pub recommended_audio_offset_adjust_us: i64,
    pub recommended_video_offset_adjust_us: i64,
    pub note: String,
}

impl SyncAnalysisReport {
    #[must_use]
    /// Keeps `verdict` 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 fn verdict(&self) -> SyncAnalysisVerdict {
        let p95_abs_skew_ms = percentile_abs(&self.skews_ms, 0.95);
        let base = SyncAnalysisVerdict {
            status: String::new(),
            passed: false,
            p95_abs_skew_ms,
            max_abs_skew_ms: self.max_abs_skew_ms,
            preferred_p95_abs_skew_ms: VERDICT_PREFERRED_P95_ABS_SKEW_MS,
            acceptable_p95_abs_skew_ms: VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS,
            gross_failure_p95_abs_skew_ms: VERDICT_GROSS_FAILURE_P95_ABS_SKEW_MS,
            catastrophic_max_abs_skew_ms: VERDICT_CATASTROPHIC_MAX_ABS_SKEW_MS,
            reason: String::new(),
        };

        if self.paired_event_count < VERDICT_MIN_PAIRED_EVENTS {
            return SyncAnalysisVerdict {
                status: "insufficient_data".to_string(),
                reason: format!(
                    "need at least {VERDICT_MIN_PAIRED_EVENTS} paired events; saw {}",
                    self.paired_event_count
                ),
                ..base
            };
        }

        if self.max_abs_skew_ms >= VERDICT_CATASTROPHIC_MAX_ABS_SKEW_MS {
            return SyncAnalysisVerdict {
                status: "catastrophic_failure".to_string(),
                reason: format!(
                    "max skew {:.1} ms is at or above the {:.1} ms catastrophic boundary",
                    self.max_abs_skew_ms, VERDICT_CATASTROPHIC_MAX_ABS_SKEW_MS
                ),
                ..base
            };
        }

        if self.activity_start_delta_ms.abs() >= VERDICT_CATASTROPHIC_MAX_ABS_SKEW_MS
            && self.raw_activity_start_is_verdict_relevant()
        {
            return SyncAnalysisVerdict {
                status: "catastrophic_failure".to_string(),
                reason: format!(
                    "audio/video activity starts are separated by {:+.1} ms, at or above the {:.1} ms catastrophic boundary",
                    self.activity_start_delta_ms, VERDICT_CATASTROPHIC_MAX_ABS_SKEW_MS
                ),
                ..base
            };
        }

        if p95_abs_skew_ms > VERDICT_GROSS_FAILURE_P95_ABS_SKEW_MS {
            return SyncAnalysisVerdict {
                status: "gross_failure".to_string(),
                reason: format!(
                    "p95 skew {:.1} ms exceeds the {:.1} ms gross-failure boundary",
                    p95_abs_skew_ms, VERDICT_GROSS_FAILURE_P95_ABS_SKEW_MS
                ),
                ..base
            };
        }

        let raw_activity_note = self.raw_activity_note();
        if p95_abs_skew_ms <= VERDICT_PREFERRED_P95_ABS_SKEW_MS {
            return SyncAnalysisVerdict {
                status: "preferred".to_string(),
                passed: true,
                reason: format!(
                    "p95 skew {:.1} ms is inside the preferred {:.1} ms band{}",
                    p95_abs_skew_ms, VERDICT_PREFERRED_P95_ABS_SKEW_MS, raw_activity_note
                ),
                ..base
            };
        }

        if p95_abs_skew_ms <= VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS {
            return SyncAnalysisVerdict {
                status: "acceptable".to_string(),
                passed: true,
                reason: format!(
                    "p95 skew {:.1} ms is inside the acceptable {:.1} ms band{}",
                    p95_abs_skew_ms, VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS, raw_activity_note
                ),
                ..base
            };
        }

        SyncAnalysisVerdict {
            status: "gross_failure".to_string(),
            reason: format!(
                "p95 skew {:.1} ms exceeds the {:.1} ms acceptable band",
                p95_abs_skew_ms, VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS
            ),
            ..base
        }
    }

    #[must_use]
    /// Keeps `calibration_recommendation` 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 fn calibration_recommendation(&self) -> SyncCalibrationRecommendation {
        if self.paired_event_count < CALIBRATION_MIN_PAIRED_EVENTS {
            return SyncCalibrationRecommendation {
                ready: false,
                recommended_audio_offset_adjust_us: 0,
                recommended_video_offset_adjust_us: 0,
                note: format!(
                    "need at least {CALIBRATION_MIN_PAIRED_EVENTS} paired pulses; saw {}",
                    self.paired_event_count
                ),
            };
        }

        if self.drift_ms.abs() > CALIBRATION_MAX_DRIFT_MS {
            return SyncCalibrationRecommendation {
                ready: false,
                recommended_audio_offset_adjust_us: 0,
                recommended_video_offset_adjust_us: 0,
                note: format!(
                    "drift {:.1} ms exceeds the {:.1} ms calibration limit",
                    self.drift_ms, CALIBRATION_MAX_DRIFT_MS
                ),
            };
        }

        let start_median_disagreement_ms = self.activity_start_pair_disagreement_ms();
        if self.raw_activity_start_is_verdict_relevant()
            && start_median_disagreement_ms.abs() > CALIBRATION_MAX_START_MEDIAN_DISAGREEMENT_MS
        {
            return SyncCalibrationRecommendation {
                ready: false,
                recommended_audio_offset_adjust_us: 0,
                recommended_video_offset_adjust_us: 0,
                note: format!(
                    "activity start delta {:+.1} ms disagrees with median skew {:+.1} ms by {:.1} ms, above the {:.1} ms calibration-safe band",
                    self.activity_start_delta_ms,
                    self.median_skew_ms,
                    start_median_disagreement_ms.abs(),
                    CALIBRATION_MAX_START_MEDIAN_DISAGREEMENT_MS
                ),
            };
        }

        let recommended_audio_offset_adjust_us = (-(self.median_skew_ms * 1_000.0)).round() as i64;
        let recommended_video_offset_adjust_us = -recommended_audio_offset_adjust_us;
        let raw_activity_note = self.raw_activity_calibration_note();
        let note = if self.median_skew_ms.abs() <= CALIBRATION_SETTLED_SKEW_MS {
            format!(
                "median skew {:.1} ms is already within the settled {:.1} ms band{}",
                self.median_skew_ms, CALIBRATION_SETTLED_SKEW_MS, raw_activity_note
            )
        } else {
            format!(
                "apply the audio offset adjustment to move median skew from {:+.1} ms toward 0.0 ms{}",
                self.median_skew_ms, raw_activity_note
            )
        };

        SyncCalibrationRecommendation {
            ready: true,
            recommended_audio_offset_adjust_us,
            recommended_video_offset_adjust_us,
            note,
        }
    }

    #[must_use]
    pub fn activity_start_pair_disagreement_ms(&self) -> f64 {
        self.activity_start_delta_ms - self.median_skew_ms
    }

    #[must_use]
    pub fn raw_activity_start_confirms_pairs(&self) -> bool {
        self.activity_start_pair_disagreement_ms().abs()
            <= RAW_ACTIVITY_CONFIRMS_PAIR_MAX_DISAGREEMENT_MS
    }

    /// Keeps `raw_activity_start_is_verdict_relevant` 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 fn raw_activity_start_is_verdict_relevant(&self) -> bool {
        if self.raw_activity_start_confirms_pairs() {
            return true;
        }
        if self.coded_events || self.paired_event_count < VERDICT_MIN_PAIRED_EVENTS {
            return false;
        }
        // Cadence-only pairing can alias by a whole pulse period and look
        // deceptively good. Use raw activity as a guard only when paired
        // evidence otherwise appears passable; if paired pulses already fail,
        // report that direct failure instead of promoting a noisy raw edge.
        percentile_abs(&self.skews_ms, 0.95) <= VERDICT_ACCEPTABLE_P95_ABS_SKEW_MS
    }

    /// Keeps `raw_activity_note` 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 raw_activity_note(&self) -> String {
        if self.activity_start_delta_ms.abs() < VERDICT_CATASTROPHIC_MAX_ABS_SKEW_MS
            || self.raw_activity_start_is_verdict_relevant()
        {
            String::new()
        } else {
            format!(
                "; raw activity start delta {:+.1} ms is reported separately because it disagrees with paired pulses by {:.1} ms",
                self.activity_start_delta_ms,
                self.activity_start_pair_disagreement_ms().abs()
            )
        }
    }

    /// Keeps `raw_activity_calibration_note` 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 raw_activity_calibration_note(&self) -> String {
        if self.raw_activity_start_is_verdict_relevant() {
            String::new()
        } else {
            format!(
                "; raw activity start delta {:+.1} ms is not used for calibration because paired pulses disagree by {:.1} ms",
                self.activity_start_delta_ms,
                self.activity_start_pair_disagreement_ms().abs()
            )
        }
    }
}

/// Keeps `percentile_abs` 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 percentile_abs(values: &[f64], percentile: f64) -> f64 {
    if values.is_empty() {
        return 0.0;
    }

    let mut sorted = values.iter().copied().map(f64::abs).collect::<Vec<_>>();
    sorted.sort_by(|left, right| left.total_cmp(right));
    let index = ((sorted.len() as f64 * percentile).ceil() as usize)
        .saturating_sub(1)
        .min(sorted.len() - 1);
    sorted[index]
}

#[derive(Clone, Debug, PartialEq)]
pub struct SyncAnalysisOptions {
    pub audio_window_ms: u32,
    pub max_pair_gap_s: f64,
    pub pulse_period_s: f64,
    pub pulse_width_s: f64,
    pub marker_tick_period: u32,
    pub event_width_codes: Vec<u32>,
    pub analysis_start_s: Option<f64>,
    pub analysis_end_s: Option<f64>,
}

impl Default for SyncAnalysisOptions {
    /// Keeps `default` 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 default() -> Self {
        Self {
            audio_window_ms: DEFAULT_AUDIO_WINDOW_MS,
            max_pair_gap_s: DEFAULT_MAX_PAIR_GAP_S,
            pulse_period_s: DEFAULT_PULSE_PERIOD_S,
            pulse_width_s: DEFAULT_PULSE_WIDTH_S,
            marker_tick_period: DEFAULT_MARKER_TICK_PERIOD,
            event_width_codes: Vec::new(),
            analysis_start_s: None,
            analysis_end_s: None,
        }
    }
}

#[cfg(test)]
#[path = "report/tests/mod.rs"]
mod tests;
