//! Flipbook animation (v0.2 step 11): a timer, an index, and a mode.
//!
//! Animation is not motion, it is bookkeeping: the sprite may not move at
//! all, but WHICH rectangle of the sprite sheet is showing changes several
//! times a second, and your eye assembles a walk. Two pieces of state cover
//! it: the current frame and a timer that decides when to swap.
//!
//! Plain data + two methods, pure logic, ungated: use it as a field on a
//! `Game` (the facade) or bundle it in a component and drive it from a
//! ten-line system (the ECS) - same struct, both altitudes, zero rewrites.

/// How the frame index moves. Variants carry exactly the data they need:
/// `PingPong` owns its live direction; `Once` and `Loop` physically cannot
/// have one. In C++ this is a tagged union plus a comment begging people
/// not to read the bool in the wrong state; here `match` checks every case.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AnimationMode {
    /// Play to the last frame, then hold it (`done` becomes true).
    Once,
    /// Wrap back to the first frame, forever.
    Loop,
    /// Bounce: 0 1 2 3 2 1 0 ... The payload is the live direction.
    PingPong { forward: bool },
}

/// A flipbook over a range of sprite-sheet frames.
#[derive(Debug, Clone)]
pub struct Animation {
    /// Which sheet frames this clip plays: `8..14` is "walk".
    pub frames: std::ops::Range<u32>,
    /// Seconds one frame stays up: 1.0 / fps. The animation's own fixed
    /// timestep, independent of the render frame rate.
    pub frame_time: f32,
    /// Time carried toward the next swap (the accumulator).
    pub elapsed: f32,
    /// 0-based index INTO the range.
    pub current: u32,
    pub mode: AnimationMode,
    /// `Once` only: true when the hold begins.
    pub done: bool,
}

impl Animation {
    /// `new` takes fps and computes the division itself so `frame_time`
    /// cannot arrive as 0.0 (which would make `update`'s while-loop spin
    /// forever); the debug_assert below guards the field being edited.
    pub fn new(frames: std::ops::Range<u32>, fps: f32) -> Self {
        Self {
            frames,
            frame_time: 1.0 / fps,
            elapsed: 0.0,
            current: 0,
            mode: AnimationMode::Loop,
            done: false,
        }
    }

    /// Builder-style mode override: `Animation::new(0..6, 10.0).with_mode(..)`.
    pub fn with_mode(mut self, mode: AnimationMode) -> Self {
        self.mode = mode;
        self
    }

    /// The sheet frame to show right now: feed this to
    /// [`SpriteSheet::frame_rect`](crate::content::SpriteSheet::frame_rect).
    pub fn frame(&self) -> u32 {
        self.frames.start + self.current
    }

    /// Accumulate and carry. A hitchy frame (dt = 3.5 frame_times) owes the
    /// animation three swaps; the while pays them ALL and keeps the 0.5
    /// remainder. An `if` would drop time and every hitch would slow the
    /// walk - the loss compounds and the game feels sticky in a way nobody
    /// can name. This is Gaffer's fixed-timestep accumulator in miniature.
    pub fn update(&mut self, dt: f32) {
        debug_assert!(self.frame_time > 0.0); // or the while below never ends
        self.elapsed += dt;
        while self.elapsed >= self.frame_time {
            self.elapsed -= self.frame_time;
            self.advance();
        }
    }

    fn advance(&mut self) {
        let count = self.frames.end - self.frames.start;
        if count <= 1 {
            self.done = true; // degenerate: nothing to swap
            return;
        }
        let last = count - 1;
        match &mut self.mode {
            // A match guard: the arm applies only while frames remain.
            AnimationMode::Once if self.current < last => self.current += 1,
            AnimationMode::Once => self.done = true,
            AnimationMode::Loop => self.current = (self.current + 1) % count,
            AnimationMode::PingPong { forward } => {
                // Matching through &mut: `forward` is a &mut bool INTO the
                // payload, edited in place.
                if *forward && self.current == last {
                    *forward = false;
                }
                if !*forward && self.current == 0 {
                    *forward = true;
                }
                if *forward {
                    self.current += 1
                } else {
                    self.current -= 1
                }
            }
        }
    }
}

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

    #[test]
    fn carries_the_remainder() {
        // 10 fps = 0.1s per frame. A 0.35s hitch owes THREE swaps + 0.05 carry.
        let mut a = Animation::new(0..6, 10.0);
        a.update(0.35);
        assert_eq!(a.frame(), 3);
        assert!((a.elapsed - 0.05).abs() < 1e-6);
        // The if-version would sit at frame 1 here and drop 0.25s on the floor.
    }

    #[test]
    fn loop_wraps() {
        let mut a = Animation::new(8..11, 10.0); // frames 8, 9, 10
        a.update(0.30); // three swaps: 9, 10, wrap to 8
        assert_eq!(a.frame(), 8);
        assert!(!a.done);
    }

    #[test]
    fn once_holds_the_last_frame() {
        let mut a = Animation::new(0..3, 10.0).with_mode(AnimationMode::Once);
        a.update(1.0); // way past the end
        assert_eq!(a.frame(), 2); // held, not wrapped
        assert!(a.done);
    }

    #[test]
    fn ping_pong_bounces() {
        let mut a = Animation::new(0..4, 10.0).with_mode(AnimationMode::PingPong { forward: true });
        let seq: Vec<u32> = (0..8)
            .map(|_| {
                a.update(0.1);
                a.frame()
            })
            .collect();
        assert_eq!(seq, vec![1, 2, 3, 2, 1, 0, 1, 2]);
    }
}
