//! Portable "when to snapshot" predicate (Phase 3 7c.1).
//!
//! A bake captures the guest at the first readiness point on a priority-ordered
//! list of signals. That decision — *which* trigger fires, given the observable
//! guest state — is pure logic, identical across backends, and was previously
//! inlined as a five-branch `if` chain in the HVF dispatch loop. Here it is one
//! tested function both backends can call; the ACTION it triggers (quiesce +
//! capture) stays per-backend.
//!
//! Priority order (first match wins), highest first:
//!   1. heartbeat   — the guest init has logged ≥ N heartbeat markers
//!   2. pre-exec    — init-oci is parked just before exec'ing the workload
//!   3. listener    — the workload has bound a TSI listener (service is up)
//!   4. parked      — the workload forked+exited; PID 1 is idle, no listener
//!   5. wall-clock  — `after_ms` elapsed (fallback for a never-ready guest)

/// Which readiness signals are enabled for this bake, from the bake config.
#[derive(Clone, Copy, Debug, Default)]
pub struct SnapshotTriggerConfig {
    /// Fire once the guest's heartbeat count reaches this target.
    pub at_heartbeat: Option<u64>,
    /// Fire when init-oci signals it is parked just before workload exec.
    pub on_pre_exec: bool,
    /// Enable the listener-ready and workload-parked triggers (service images
    /// bind a listener; non-service images park PID 1 instead).
    pub on_listener: bool,
    /// Wall-clock fallback: fire once this many ms have elapsed since dispatch
    /// start, regardless of readiness.
    pub after_ms: Option<u64>,
}

/// The observable guest signals sampled at one dispatch iteration.
#[derive(Clone, Copy, Debug)]
pub struct TriggerSignals {
    /// `serial.heartbeat_count` — init heartbeat markers seen so far.
    pub heartbeat_count: u64,
    /// `serial.pre_exec_ready` — init-oci parked just before workload exec.
    pub pre_exec_ready: bool,
    /// `serial.workload_parked` — PID 1 went idle (workload forked+exited).
    pub workload_parked: bool,
    /// `vsock.muxer().listener_count()` — TSI listeners the guest has bound.
    pub listener_count: usize,
    /// Milliseconds since the dispatch loop started.
    pub elapsed_ms: u64,
}

/// Which trigger fired, identified for logging/capture.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TriggerFire {
    /// Stable label for the capture log (`"heartbeat"`, `"pre-exec"`, …).
    pub reason: &'static str,
    /// Whether a quiesce-to-WFI window should precede the capture. The two
    /// readiness points that can fire mid-activity (heartbeat, listener-ready)
    /// quiesce first; the points that are already idle (pre-exec, parked,
    /// wall-clock fallback) capture immediately.
    pub needs_quiesce: bool,
}

impl SnapshotTriggerConfig {
    /// Evaluate the priority-ordered triggers against the current signals,
    /// returning the highest-priority one that fires (or `None`). Pure: no I/O,
    /// no side effects — the caller performs the backend-specific quiesce +
    /// capture for the returned [`TriggerFire`].
    pub fn evaluate(&self, sig: &TriggerSignals) -> Option<TriggerFire> {
        // 1. heartbeat — a known guest-init point, not mid-handshake.
        if let Some(target) = self.at_heartbeat {
            if sig.heartbeat_count >= target {
                return Some(TriggerFire {
                    reason: "heartbeat",
                    needs_quiesce: true,
                });
            }
        }
        // 2. pre-exec — vCPU is in clean WFI (init nanosleeping pre-exec).
        if self.on_pre_exec && sig.pre_exec_ready {
            return Some(TriggerFire {
                reason: "pre-exec",
                needs_quiesce: false,
            });
        }
        // 3. listener-ready — the workload has bound/listen'd.
        if self.on_listener && sig.listener_count > 0 {
            return Some(TriggerFire {
                reason: "listener-ready",
                needs_quiesce: true,
            });
        }
        // 4. workload-parked — non-service image: PID 1 idle, no listener.
        if self.on_listener && sig.listener_count == 0 && sig.workload_parked {
            return Some(TriggerFire {
                reason: "workload-parked",
                needs_quiesce: false,
            });
        }
        // 5. wall-clock fallback.
        if let Some(after_ms) = self.after_ms {
            if sig.elapsed_ms >= after_ms {
                return Some(TriggerFire {
                    reason: "wall-clock",
                    needs_quiesce: false,
                });
            }
        }
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sig() -> TriggerSignals {
        TriggerSignals {
            heartbeat_count: 0,
            pre_exec_ready: false,
            workload_parked: false,
            listener_count: 0,
            elapsed_ms: 0,
        }
    }

    #[test]
    fn nothing_fires_when_no_signal_is_ready() {
        let cfg = SnapshotTriggerConfig {
            at_heartbeat: Some(3),
            on_pre_exec: true,
            on_listener: true,
            after_ms: Some(7000),
        };
        assert_eq!(cfg.evaluate(&sig()), None);
    }

    #[test]
    fn heartbeat_fires_at_or_past_target_and_quiesces() {
        let cfg = SnapshotTriggerConfig {
            at_heartbeat: Some(3),
            ..Default::default()
        };
        assert_eq!(
            cfg.evaluate(&TriggerSignals {
                heartbeat_count: 2,
                ..sig()
            }),
            None,
            "below target"
        );
        let f = cfg
            .evaluate(&TriggerSignals {
                heartbeat_count: 3,
                ..sig()
            })
            .unwrap();
        assert_eq!(f.reason, "heartbeat");
        assert!(f.needs_quiesce);
    }

    #[test]
    fn priority_order_is_respected() {
        // All signals ready at once: heartbeat (highest) wins.
        let cfg = SnapshotTriggerConfig {
            at_heartbeat: Some(1),
            on_pre_exec: true,
            on_listener: true,
            after_ms: Some(0),
        };
        let all = TriggerSignals {
            heartbeat_count: 5,
            pre_exec_ready: true,
            workload_parked: true,
            listener_count: 2,
            elapsed_ms: 9999,
        };
        assert_eq!(cfg.evaluate(&all).unwrap().reason, "heartbeat");

        // Without heartbeat configured, pre-exec wins next.
        let cfg2 = SnapshotTriggerConfig {
            on_pre_exec: true,
            on_listener: true,
            after_ms: Some(0),
            ..Default::default()
        };
        assert_eq!(cfg2.evaluate(&all).unwrap().reason, "pre-exec");
    }

    #[test]
    fn listener_ready_beats_parked_and_wall_clock() {
        let cfg = SnapshotTriggerConfig {
            on_listener: true,
            after_ms: Some(0),
            ..Default::default()
        };
        let f = cfg
            .evaluate(&TriggerSignals {
                listener_count: 1,
                workload_parked: true,
                elapsed_ms: 9999,
                ..sig()
            })
            .unwrap();
        assert_eq!(f.reason, "listener-ready");
        assert!(f.needs_quiesce);
    }

    #[test]
    fn parked_fires_only_with_no_listener_and_no_quiesce() {
        let cfg = SnapshotTriggerConfig {
            on_listener: true,
            ..Default::default()
        };
        // With a listener present, parked does NOT fire (listener-ready would,
        // but here listener_count>0 so that branch wins instead).
        let f = cfg
            .evaluate(&TriggerSignals {
                listener_count: 1,
                workload_parked: true,
                ..sig()
            })
            .unwrap();
        assert_eq!(f.reason, "listener-ready");
        // No listener + parked → workload-parked, fires immediately.
        let f = cfg
            .evaluate(&TriggerSignals {
                listener_count: 0,
                workload_parked: true,
                ..sig()
            })
            .unwrap();
        assert_eq!(f.reason, "workload-parked");
        assert!(!f.needs_quiesce);
    }

    #[test]
    fn wall_clock_is_the_last_resort() {
        let cfg = SnapshotTriggerConfig {
            on_listener: true,
            after_ms: Some(7000),
            ..Default::default()
        };
        assert_eq!(
            cfg.evaluate(&TriggerSignals {
                elapsed_ms: 6999,
                ..sig()
            }),
            None
        );
        let f = cfg
            .evaluate(&TriggerSignals {
                elapsed_ms: 7000,
                ..sig()
            })
            .unwrap();
        assert_eq!(f.reason, "wall-clock");
        assert!(!f.needs_quiesce);
    }

    #[test]
    fn disabled_triggers_never_fire() {
        // on_listener off → neither listener-ready nor parked fire.
        let cfg = SnapshotTriggerConfig {
            on_listener: false,
            ..Default::default()
        };
        assert_eq!(
            cfg.evaluate(&TriggerSignals {
                listener_count: 5,
                workload_parked: true,
                ..sig()
            }),
            None
        );
    }
}
