#![cfg_attr(not(test), allow(dead_code))]
//! Timing helpers for the shared-clock sync probe.

use std::time::Duration;

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PulseSchedule {
    warmup: Duration,
    pulse_period: Duration,
    pulse_width: Duration,
    marker_tick_period: u32,
    event_width_codes: Vec<u32>,
}

impl PulseSchedule {
    pub fn new(
        warmup: Duration,
        pulse_period: Duration,
        pulse_width: Duration,
        marker_tick_period: u32,
    ) -> Self {
        assert!(!pulse_period.is_zero(), "pulse period must stay positive");
        assert!(!pulse_width.is_zero(), "pulse width must stay positive");
        assert!(
            pulse_width < pulse_period,
            "pulse width must stay smaller than the pulse period"
        );
        assert!(
            marker_tick_period > 0,
            "marker tick period must stay positive"
        );

        Self {
            warmup,
            pulse_period,
            pulse_width,
            marker_tick_period,
            event_width_codes: Vec::new(),
        }
    }

    /// Build a schedule whose pulses carry explicit event identities.
    ///
    /// Inputs: timing parameters plus a finite code sequence.
    /// Outputs: a schedule that emits one normal-width pulse per code.
    /// Why: client-to-RCT tests need identity-rich pulses so final capture
    /// analysis can join observations back to the client timeline after startup
    /// drops.
    pub fn with_event_width_codes(
        warmup: Duration,
        pulse_period: Duration,
        pulse_width: Duration,
        marker_tick_period: u32,
        event_width_codes: Vec<u32>,
    ) -> Self {
        /// Validate the event code sequence before it reaches renderers.
        ///
        /// Inputs: event code slice.
        /// Outputs: panic on invalid operator/test configuration.
        /// Why: a zero code has no stable audio/video signature, and failing
        /// here is clearer than letting the final RCT analyzer miss events.
        fn validate_event_width_codes(event_width_codes: &[u32]) {
            assert!(
                !event_width_codes.is_empty(),
                "event width code list must not be empty"
            );
            assert!(
                event_width_codes.iter().all(|code| *code > 0),
                "event width codes must be positive"
            );
        }

        validate_event_width_codes(&event_width_codes);
        let schedule = Self::new(warmup, pulse_period, pulse_width, marker_tick_period);
        Self {
            event_width_codes,
            ..schedule
        }
    }

    pub fn warmup(&self) -> Duration {
        self.warmup
    }

    pub fn pulse_period(&self) -> Duration {
        self.pulse_period
    }

    pub fn marker_tick_period(&self) -> u32 {
        self.marker_tick_period
    }

    pub fn pulse_width(&self) -> Duration {
        self.pulse_width
    }

    /// Return the explicit event identity sequence for coded probes.
    ///
    /// Inputs: schedule state.
    /// Outputs: event code slice, empty for legacy marker-mode probes.
    /// Why: the generator and timeline writer must use the same code sequence
    /// that the RCT analyzer will use for final event pairing.
    pub fn event_width_codes(&self) -> &[u32] {
        &self.event_width_codes
    }

    pub fn pulse_index(&self, pts: Duration) -> u64 {
        if pts < self.warmup_boundary() {
            return 0;
        }
        let pts = pts - self.warmup_boundary();
        let period_ns = self.pulse_period.as_nanos().max(1);
        (pts.as_nanos() / period_ns) as u64
    }

    pub fn pulse_offset(&self, pts: Duration) -> Duration {
        if pts < self.warmup_boundary() {
            return pts;
        }
        let pts = pts - self.warmup_boundary();
        let period_ns = self.pulse_period.as_nanos().max(1);
        let offset_ns = (pts.as_nanos() % period_ns) as u64;
        Duration::from_nanos(offset_ns)
    }

    /// Decide whether a timestamp belongs to a legacy marker pulse.
    ///
    /// Inputs: media PTS in the synthetic source timeline.
    /// Outputs: `true` only for widened marker pulses in non-coded mode.
    /// Why: coded probes use explicit color/tone identity, so marker widening
    /// must be disabled to avoid mixing two identity schemes.
    pub fn pulse_is_marker(&self, pts: Duration) -> bool {
        if !self.event_width_codes.is_empty() {
            return false;
        }
        pts >= self.warmup_boundary()
            && self
                .pulse_index(pts)
                .is_multiple_of(u64::from(self.marker_tick_period.max(1)))
    }

    pub fn marker_pulse_width(&self) -> Duration {
        let widened = self.pulse_width.saturating_mul(2);
        widened.min(self.pulse_period.saturating_sub(Duration::from_millis(1)))
    }

    pub fn flash_active(&self, pts: Duration) -> bool {
        if pts < self.warmup_boundary() {
            return false;
        }
        let width = self.active_pulse_width(pts);
        if width.is_zero() {
            return false;
        }
        self.pulse_offset(pts) < width
    }

    /// Return the event code active at a timestamp.
    ///
    /// Inputs: media PTS in the synthetic source timeline.
    /// Outputs: one-based event code for coded probes, or `None`.
    /// Why: audio and video renderers need a shared identity decision per PTS
    /// so the same client-origin event reaches the server as one bundled unit.
    pub fn event_code(&self, pts: Duration) -> Option<u32> {
        if pts < self.warmup_boundary() {
            return None;
        }
        self.event_width_codes
            .get(self.pulse_index(pts) as usize)
            .copied()
    }

    /// Return the physical pulse width active at a timestamp.
    ///
    /// Inputs: media PTS in the synthetic source timeline.
    /// Outputs: actual flash/tone gate width.
    /// Why: coded probes carry identity through color and tone frequency, not by
    /// making late events multi-second-long and destroying cadence.
    pub fn active_pulse_width(&self, pts: Duration) -> Duration {
        if !self.event_width_codes.is_empty() {
            return self
                .event_code(pts)
                .map(|_| self.pulse_width)
                .unwrap_or(Duration::ZERO);
        }
        if self.pulse_is_marker(pts) {
            self.marker_pulse_width()
        } else {
            self.pulse_width
        }
    }

    pub fn warmup_boundary(&self) -> Duration {
        if self.warmup.is_zero() {
            return Duration::ZERO;
        }
        let period_ns = self.pulse_period.as_nanos().max(1) as u64;
        let warmup_ns = self.warmup.as_nanos() as u64;
        let rounded = warmup_ns.div_ceil(period_ns) * period_ns;
        Duration::from_nanos(rounded)
    }

    pub fn audio_gate_open_at(&self) -> Duration {
        let lead = self.pulse_width.min(Duration::from_millis(200));
        self.warmup_boundary().saturating_sub(lead)
    }

    pub fn frame_pts(&self, frame_index: u64, fps: u32) -> Duration {
        let frame_step_ns = 1_000_000_000u64 / u64::from(fps.max(1));
        Duration::from_nanos(frame_index.saturating_mul(frame_step_ns))
    }
}

#[cfg(test)]
mod tests {
    use super::PulseSchedule;
    use std::time::Duration;

    #[test]
    fn flash_windows_follow_the_expected_pulse_boundaries() {
        let schedule = PulseSchedule::new(
            Duration::from_secs(4),
            Duration::from_millis(1_000),
            Duration::from_millis(120),
            5,
        );

        assert!(!schedule.flash_active(Duration::from_millis(3_999)));
        assert!(schedule.flash_active(Duration::from_millis(4_000)));
        assert!(schedule.flash_active(Duration::from_millis(4_200)));
        assert!(!schedule.flash_active(Duration::from_millis(4_240)));
        assert!(!schedule.flash_active(Duration::from_millis(4_999)));
        assert!(schedule.flash_active(Duration::from_millis(5_000)));
        assert!(!schedule.flash_active(Duration::from_millis(5_120)));
    }

    #[test]
    fn pulse_index_and_offset_repeat_cleanly() {
        let schedule = PulseSchedule::new(
            Duration::from_secs(2),
            Duration::from_millis(750),
            Duration::from_millis(90),
            3,
        );

        assert_eq!(schedule.pulse_index(Duration::from_millis(0)), 0);
        assert_eq!(schedule.warmup_boundary(), Duration::from_millis(2_250));
        assert_eq!(schedule.pulse_index(Duration::from_millis(2_999)), 0);
        assert_eq!(schedule.pulse_index(Duration::from_millis(3_000)), 1);
        assert_eq!(
            schedule.pulse_offset(Duration::from_millis(2_320)),
            Duration::from_millis(70)
        );
        assert_eq!(
            schedule.pulse_offset(Duration::from_millis(3_750)),
            Duration::from_millis(0)
        );
    }

    #[test]
    fn frame_pts_respects_requested_framerate() {
        let schedule = PulseSchedule::new(
            Duration::ZERO,
            Duration::from_secs(1),
            Duration::from_millis(100),
            5,
        );

        assert_eq!(schedule.frame_pts(0, 30), Duration::from_nanos(0));
        assert_eq!(schedule.frame_pts(1, 30), Duration::from_nanos(33_333_333));
        assert_eq!(
            schedule.frame_pts(30, 30),
            Duration::from_nanos(999_999_990)
        );
    }

    #[test]
    fn getters_and_zero_fps_fallback_stay_stable() {
        let schedule = PulseSchedule::new(
            Duration::from_secs(3),
            Duration::from_millis(800),
            Duration::from_millis(90),
            7,
        );

        assert_eq!(schedule.warmup(), Duration::from_secs(3));
        assert_eq!(schedule.warmup_boundary(), Duration::from_millis(3_200));
        assert_eq!(schedule.audio_gate_open_at(), Duration::from_millis(3_110));
        assert_eq!(schedule.pulse_period(), Duration::from_millis(800));
        assert_eq!(schedule.pulse_width(), Duration::from_millis(90));
        assert_eq!(schedule.marker_tick_period(), 7);
        assert_eq!(schedule.frame_pts(1, 0), Duration::from_secs(1));
    }

    #[test]
    fn marker_pulses_are_detectably_wider_than_regular_pulses() {
        let schedule = PulseSchedule::new(
            Duration::from_secs(1),
            Duration::from_millis(1_000),
            Duration::from_millis(120),
            5,
        );

        assert!(schedule.pulse_is_marker(Duration::from_millis(1_000)));
        assert_eq!(schedule.marker_pulse_width(), Duration::from_millis(240));
        assert!(schedule.flash_active(Duration::from_millis(1_200)));
        assert!(!schedule.flash_active(Duration::from_millis(1_241)));
        assert!(!schedule.pulse_is_marker(Duration::from_millis(2_000)));
        assert!(!schedule.flash_active(Duration::from_millis(2_200)));
    }

    /// Verifies coded schedules keep identity without widening physical pulses.
    ///
    /// Inputs: synthetic timing schedule.
    /// Outputs: assertions on code lookup, pulse width, and end-of-code gating.
    /// Why: final RCT analysis depends on color/tone identity while preserving
    /// a regular one-second cadence.
    #[test]
    fn coded_pulses_preserve_identity_and_duration() {
        let schedule = PulseSchedule::with_event_width_codes(
            Duration::from_secs(1),
            Duration::from_millis(1_000),
            Duration::from_millis(100),
            5,
            vec![1, 3],
        );

        assert_eq!(schedule.event_width_codes(), &[1, 3]);
        assert_eq!(schedule.event_code(Duration::from_millis(1_000)), Some(1));
        assert_eq!(
            schedule.active_pulse_width(Duration::from_millis(1_000)),
            Duration::from_millis(100)
        );
        assert_eq!(schedule.event_code(Duration::from_millis(2_000)), Some(3));
        assert_eq!(
            schedule.active_pulse_width(Duration::from_millis(2_000)),
            Duration::from_millis(100)
        );
        assert!(!schedule.pulse_is_marker(Duration::from_millis(1_000)));
        assert!(!schedule.flash_active(Duration::from_millis(3_000)));
    }

    #[test]
    #[should_panic(expected = "pulse period must stay positive")]
    fn constructor_rejects_zero_period() {
        let _ = PulseSchedule::new(Duration::ZERO, Duration::ZERO, Duration::from_millis(50), 1);
    }

    #[test]
    #[should_panic(expected = "pulse width must stay smaller than the pulse period")]
    fn constructor_rejects_width_not_smaller_than_period() {
        let _ = PulseSchedule::new(
            Duration::ZERO,
            Duration::from_millis(100),
            Duration::from_millis(100),
            1,
        );
    }
}
