//! Static achievement definitions + evaluation.
//!
//! `solitaire_core` cannot import from `solitaire_data`, so conditions are
//! not given `StatsSnapshot` directly — the engine packages the relevant
//! stats fields into an [`AchievementContext`] at evaluation time.
//!
//! Evaluation is called once per [`GameWonEvent`] in the engine: the engine
//! walks `ALL_ACHIEVEMENTS`, evaluates each `condition`, and emits an
//! unlock event for any `AchievementDef` whose record is not yet unlocked.

use serde::{Deserialize, Serialize};

/// Fields needed by achievement conditions. Constructed by the engine from
/// `StatsSnapshot`, the final `GameState`, and wall-clock time.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AchievementContext {
    /// Total number of games played (after this win has been recorded).
    pub games_played: u32,
    /// Total number of games won (after this win has been recorded).
    pub games_won: u32,
    /// Current consecutive win streak (after this win has been recorded).
    pub win_streak_current: u32,
    /// Highest single-game score ever achieved.
    pub best_single_score: u32,
    /// Cumulative score across all games ever played.
    pub lifetime_score: u64,
    /// Total wins completed in Draw 3 mode.
    pub draw_three_wins: u32,

    /// Current daily-challenge completion streak (consecutive days).
    pub daily_challenge_streak: u32,

    /// Score achieved in the just-won game.
    pub last_win_score: i32,
    /// Elapsed seconds for the just-won game.
    pub last_win_time_seconds: u64,
    /// `true` if `undo()` was called at least once during the won game.
    pub last_win_used_undo: bool,

    /// Local hour (0–23) at the time of win. `None` if unknown.
    pub wall_clock_hour: Option<u32>,

    /// Number of times waste was recycled back to stock during the won game.
    pub last_win_recycle_count: u32,
    /// `true` if the game was played in Zen mode.
    pub last_win_is_zen: bool,
}

/// Reward granted when an achievement is first unlocked.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reward {
    /// Unlocks a card-back design at the given index (0 is always unlocked).
    CardBack(usize),
    /// Unlocks a background design at the given index (0 is always unlocked).
    Background(usize),
    /// Awards bonus XP on top of the standard win XP.
    BonusXp(u64),
    /// A visual badge — no gameplay effect.
    Badge,
}

/// A single achievement's static metadata + unlock condition.
#[derive(Debug, Clone, Copy)]
pub struct AchievementDef {
    /// Unique string identifier for this achievement (e.g. `"first_win"`).
    pub id: &'static str,
    /// Human-readable display name shown in the achievements screen.
    pub name: &'static str,
    /// Flavour text describing how to unlock the achievement.
    pub description: &'static str,
    /// Hidden from the achievements screen until unlocked.
    pub secret: bool,
    /// Reward granted on first unlock. `None` for cosmetic-only recognition.
    pub reward: Option<Reward>,
    /// Predicate evaluated on every `GameWonEvent` and `StateChangedEvent`. Returns true when the achievement should unlock.
    pub condition: fn(&AchievementContext) -> bool,
}

impl AchievementDef {
    pub fn is_unlocked_by(&self, ctx: &AchievementContext) -> bool {
        (self.condition)(ctx)
    }
}

// ---------------------------------------------------------------------------
// Condition predicates
// ---------------------------------------------------------------------------

fn first_win(c: &AchievementContext) -> bool {
    c.games_won >= 1
}
fn on_a_roll(c: &AchievementContext) -> bool {
    c.win_streak_current >= 3
}
fn unstoppable(c: &AchievementContext) -> bool {
    c.win_streak_current >= 10
}
fn century(c: &AchievementContext) -> bool {
    c.games_played >= 100
}
fn veteran(c: &AchievementContext) -> bool {
    c.games_played >= 500
}
fn speed_demon(c: &AchievementContext) -> bool {
    c.last_win_time_seconds < 180
}
fn lightning(c: &AchievementContext) -> bool {
    c.last_win_time_seconds < 90
}
fn high_scorer(c: &AchievementContext) -> bool {
    c.best_single_score >= 5_000
}
fn point_machine(c: &AchievementContext) -> bool {
    c.lifetime_score >= 50_000
}
fn no_undo(c: &AchievementContext) -> bool {
    !c.last_win_used_undo
}
fn draw_three_master(c: &AchievementContext) -> bool {
    c.draw_three_wins >= 10
}
fn night_owl(c: &AchievementContext) -> bool {
    // Late-night session: 22:00–02:59 local time.
    matches!(c.wall_clock_hour, Some(h) if !(3..22).contains(&h))
}
fn early_bird(c: &AchievementContext) -> bool {
    // Early-morning session: 05:00–06:59 local time.
    matches!(c.wall_clock_hour, Some(h) if (5..7).contains(&h))
}
fn speed_and_skill(c: &AchievementContext) -> bool {
    c.last_win_time_seconds < 90 && !c.last_win_used_undo
}
fn daily_devotee(c: &AchievementContext) -> bool {
    c.daily_challenge_streak >= 7
}
fn perfectionist(c: &AchievementContext) -> bool {
    !c.last_win_used_undo && c.last_win_score >= 5_000
}
fn comeback(c: &AchievementContext) -> bool {
    c.last_win_recycle_count >= 3
}
fn zen_winner(c: &AchievementContext) -> bool {
    c.last_win_is_zen
}
/// Cinephile is event-driven: it unlocks when the engine observes a
/// `ReplayPlaybackState` transition from `Playing` to `Completed`, not on
/// any field of [`AchievementContext`]. The condition predicate therefore
/// always returns false so [`check_achievements`] never unlocks it from a
/// `GameWonEvent` / `StateChangedEvent` cycle — the unlock is driven by
/// `AchievementUnlockedEvent` written directly from the engine's
/// replay-playback observer.
fn cinephile_never(_c: &AchievementContext) -> bool {
    false
}

/// All currently-evaluable achievements. Order is stable so persistence files
/// remain readable across versions (new achievements append).
pub const ALL_ACHIEVEMENTS: &[AchievementDef] = &[
    AchievementDef {
        id: "first_win",
        name: "First Win",
        description: "Win your first game",
        secret: false,
        reward: None,
        condition: first_win,
    },
    AchievementDef {
        id: "on_a_roll",
        name: "On a Roll",
        description: "Win 3 games in a row",
        secret: false,
        reward: Some(Reward::CardBack(1)),
        condition: on_a_roll,
    },
    AchievementDef {
        id: "unstoppable",
        name: "Unstoppable",
        description: "Win 10 games in a row",
        secret: false,
        reward: Some(Reward::Background(1)),
        condition: unstoppable,
    },
    AchievementDef {
        id: "century",
        name: "Century",
        description: "Play 100 games",
        secret: false,
        reward: None,
        condition: century,
    },
    AchievementDef {
        id: "veteran",
        name: "Veteran",
        description: "Play 500 games",
        secret: false,
        reward: Some(Reward::Badge),
        condition: veteran,
    },
    AchievementDef {
        id: "speed_demon",
        name: "Speed Demon",
        description: "Win in under 3 minutes",
        secret: false,
        reward: None,
        condition: speed_demon,
    },
    AchievementDef {
        id: "lightning",
        name: "Lightning",
        description: "Win in under 90 seconds",
        secret: false,
        reward: Some(Reward::CardBack(2)),
        condition: lightning,
    },
    AchievementDef {
        id: "high_scorer",
        name: "High Scorer",
        description: "Score at least 5,000 in one game",
        secret: false,
        reward: None,
        condition: high_scorer,
    },
    AchievementDef {
        id: "point_machine",
        name: "Point Machine",
        description: "Accumulate 50,000 lifetime points",
        secret: false,
        reward: Some(Reward::Background(2)),
        condition: point_machine,
    },
    AchievementDef {
        id: "no_undo",
        name: "No Undo",
        description: "Win a game without using undo",
        secret: false,
        reward: Some(Reward::BonusXp(25)),
        condition: no_undo,
    },
    AchievementDef {
        id: "draw_three_master",
        name: "Draw 3 Master",
        description: "Win 10 games in Draw 3 mode",
        secret: false,
        reward: Some(Reward::CardBack(3)),
        condition: draw_three_master,
    },
    AchievementDef {
        id: "night_owl",
        name: "Night Owl",
        description: "Win a game between 10pm and 3am",
        secret: false,
        reward: None,
        condition: night_owl,
    },
    AchievementDef {
        id: "early_bird",
        name: "Early Bird",
        description: "Win a game between 5am and 7am",
        secret: false,
        reward: None,
        condition: early_bird,
    },
    AchievementDef {
        id: "speed_and_skill",
        name: "???",
        description: "A secret achievement",
        secret: true,
        reward: Some(Reward::CardBack(4)),
        condition: speed_and_skill,
    },
    AchievementDef {
        id: "daily_devotee",
        name: "Daily Devotee",
        description: "Complete the daily challenge 7 days in a row",
        secret: false,
        reward: Some(Reward::Background(3)),
        condition: daily_devotee,
    },
    AchievementDef {
        id: "perfectionist",
        name: "Perfectionist",
        description: "Win without undo and score at least 5,000",
        secret: false,
        reward: Some(Reward::Badge),
        condition: perfectionist,
    },
    AchievementDef {
        id: "comeback",
        name: "???",
        description: "A secret achievement",
        secret: true,
        reward: Some(Reward::Background(4)),
        condition: comeback,
    },
    AchievementDef {
        id: "zen_winner",
        name: "???",
        description: "A secret achievement",
        secret: true,
        reward: Some(Reward::Badge),
        condition: zen_winner,
    },
    AchievementDef {
        id: "cinephile",
        name: "Cinephile",
        description: "Watch a saved replay all the way through",
        secret: false,
        reward: None,
        // Event-driven unlock: the engine's replay-playback observer fires
        // `AchievementUnlockedEvent("cinephile")` directly on a Playing →
        // Completed transition. `cinephile_never` keeps the condition path
        // a no-op so a `GameWonEvent` evaluation cycle cannot unlock it.
        condition: cinephile_never,
    },
];

/// Return every `AchievementDef` whose condition is satisfied by `ctx`.
pub fn check_achievements(ctx: &AchievementContext) -> Vec<&'static AchievementDef> {
    ALL_ACHIEVEMENTS
        .iter()
        .filter(|d| d.is_unlocked_by(ctx))
        .collect()
}

/// Look up an achievement definition by ID.
pub fn achievement_by_id(id: &str) -> Option<&'static AchievementDef> {
    ALL_ACHIEVEMENTS.iter().find(|d| d.id == id)
}

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

    fn ctx() -> AchievementContext {
        AchievementContext {
            games_played: 0,
            games_won: 0,
            win_streak_current: 0,
            best_single_score: 0,
            lifetime_score: 0,
            draw_three_wins: 0,
            daily_challenge_streak: 0,
            last_win_score: 0,
            last_win_time_seconds: u64::MAX,
            last_win_used_undo: true,
            wall_clock_hour: None,
            last_win_recycle_count: 0,
            last_win_is_zen: false,
        }
    }

    #[test]
    fn all_ids_are_unique() {
        let mut ids: Vec<&str> = ALL_ACHIEVEMENTS.iter().map(|d| d.id).collect();
        ids.sort();
        let len = ids.len();
        ids.dedup();
        assert_eq!(
            ids.len(),
            len,
            "duplicate achievement ID in ALL_ACHIEVEMENTS"
        );
    }

    #[test]
    fn no_achievements_unlocked_at_default() {
        let c = ctx();
        assert!(check_achievements(&c).is_empty());
    }

    #[test]
    fn first_win_unlocks_on_first_won_game() {
        let mut c = ctx();
        c.games_won = 1;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(ids.contains(&"first_win"));
    }

    #[test]
    fn lightning_requires_under_90_seconds() {
        let mut c = ctx();
        c.games_won = 1;
        c.last_win_time_seconds = 89;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(ids.contains(&"lightning"));
        assert!(ids.contains(&"speed_demon"));

        c.last_win_time_seconds = 90;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(!ids.contains(&"lightning"));
        assert!(ids.contains(&"speed_demon"));
    }

    #[test]
    fn no_undo_requires_clean_win() {
        let mut c = ctx();
        c.games_won = 1;
        c.last_win_used_undo = false;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(ids.contains(&"no_undo"));

        c.last_win_used_undo = true;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(!ids.contains(&"no_undo"));
    }

    #[test]
    fn secret_speed_and_skill_requires_both_clean_and_fast() {
        let mut c = ctx();
        c.games_won = 1;
        c.last_win_time_seconds = 60;
        c.last_win_used_undo = false;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(ids.contains(&"speed_and_skill"));

        c.last_win_used_undo = true;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(!ids.contains(&"speed_and_skill"));
    }

    #[test]
    fn night_owl_triggers_in_late_night_window() {
        let mut c = ctx();
        c.games_won = 1;
        // Late night: 22:00–02:59
        for hour in [22u32, 23, 0, 1, 2] {
            c.wall_clock_hour = Some(hour);
            let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
            assert!(
                ids.contains(&"night_owl"),
                "expected night_owl at hour {hour}"
            );
        }
        // Daytime hours must not trigger.
        for hour in [3u32, 7, 12, 20, 21] {
            c.wall_clock_hour = Some(hour);
            let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
            assert!(
                !ids.contains(&"night_owl"),
                "unexpected night_owl at hour {hour}"
            );
        }
    }

    #[test]
    fn early_bird_triggers_in_morning_window() {
        let mut c = ctx();
        c.games_won = 1;
        // Early morning: 05:00–06:59
        for hour in [5u32, 6] {
            c.wall_clock_hour = Some(hour);
            let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
            assert!(
                ids.contains(&"early_bird"),
                "expected early_bird at hour {hour}"
            );
        }
        // Outside the window must not trigger.
        for hour in [0u32, 3, 4, 7, 12, 23] {
            c.wall_clock_hour = Some(hour);
            let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
            assert!(
                !ids.contains(&"early_bird"),
                "unexpected early_bird at hour {hour}"
            );
        }
    }

    #[test]
    fn daily_devotee_requires_7_day_streak() {
        let mut c = ctx();
        c.daily_challenge_streak = 6;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(!ids.contains(&"daily_devotee"));

        c.daily_challenge_streak = 7;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(ids.contains(&"daily_devotee"));
    }

    #[test]
    fn perfectionist_requires_no_undo_and_high_score() {
        let mut c = ctx();
        c.last_win_used_undo = false;
        c.last_win_score = 5_000;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(ids.contains(&"perfectionist"));

        c.last_win_used_undo = true;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(!ids.contains(&"perfectionist"));

        c.last_win_used_undo = false;
        c.last_win_score = 4_999;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(!ids.contains(&"perfectionist"));
    }

    #[test]
    fn comeback_requires_at_least_three_recycles() {
        let mut c = ctx();
        c.last_win_recycle_count = 2;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(!ids.contains(&"comeback"));

        c.last_win_recycle_count = 3;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(ids.contains(&"comeback"));
    }

    #[test]
    fn zen_winner_requires_zen_mode() {
        let mut c = ctx();
        c.last_win_is_zen = false;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(!ids.contains(&"zen_winner"));

        c.last_win_is_zen = true;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(ids.contains(&"zen_winner"));
    }

    #[test]
    fn achievement_by_id_finds_known_and_returns_none_for_unknown() {
        assert_eq!(
            achievement_by_id("first_win").map(|d| d.name),
            Some("First Win")
        );
        assert!(achievement_by_id("nonexistent").is_none());
    }

    // -----------------------------------------------------------------------
    // Direct predicate tests via ctx_defaults()
    // -----------------------------------------------------------------------

    /// Baseline context representing a single clean one-minute win in Draw-One mode.
    fn ctx_defaults() -> AchievementContext {
        AchievementContext {
            games_played: 1,
            games_won: 1,
            win_streak_current: 1,
            best_single_score: 0,
            lifetime_score: 0,
            draw_three_wins: 0,
            daily_challenge_streak: 0,
            last_win_score: 0,
            last_win_time_seconds: 600,
            last_win_used_undo: false,
            wall_clock_hour: Some(12),
            last_win_recycle_count: 0,
            last_win_is_zen: false,
        }
    }

    #[test]
    fn speed_demon_true_when_under_three_minutes() {
        let mut c = ctx_defaults();
        c.last_win_time_seconds = 179;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(
            ids.contains(&"speed_demon"),
            "speed_demon should unlock at 179s"
        );
    }

    #[test]
    fn speed_demon_false_when_over_three_minutes() {
        let mut c = ctx_defaults();
        c.last_win_time_seconds = 181;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(
            !ids.contains(&"speed_demon"),
            "speed_demon must not unlock at 181s"
        );
    }

    #[test]
    fn lightning_true_when_under_90_seconds() {
        let mut c = ctx_defaults();
        c.last_win_time_seconds = 89;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(ids.contains(&"lightning"), "lightning should unlock at 89s");
    }

    #[test]
    fn lightning_false_at_exactly_90_seconds() {
        let mut c = ctx_defaults();
        c.last_win_time_seconds = 90;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(
            !ids.contains(&"lightning"),
            "lightning must not unlock at exactly 90s"
        );
    }

    #[test]
    fn no_undo_true_when_zero_undos() {
        let mut c = ctx_defaults();
        c.last_win_used_undo = false;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(
            ids.contains(&"no_undo"),
            "no_undo should unlock when undo was not used"
        );
    }

    #[test]
    fn no_undo_false_when_undo_used() {
        let mut c = ctx_defaults();
        c.last_win_used_undo = true;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(
            !ids.contains(&"no_undo"),
            "no_undo must not unlock when undo was used"
        );
    }

    #[test]
    fn high_scorer_true_when_score_5000_or_more() {
        let mut c = ctx_defaults();
        c.best_single_score = 5_000;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(
            ids.contains(&"high_scorer"),
            "high_scorer should unlock at best_single_score=5000"
        );
    }

    #[test]
    fn high_scorer_false_when_below_5000() {
        let mut c = ctx_defaults();
        c.best_single_score = 4_999;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(
            !ids.contains(&"high_scorer"),
            "high_scorer must not unlock at best_single_score=4999"
        );
    }

    #[test]
    fn on_a_roll_true_at_streak_3() {
        let mut c = ctx_defaults();
        c.win_streak_current = 3;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(
            ids.contains(&"on_a_roll"),
            "on_a_roll should unlock at streak=3"
        );
    }

    #[test]
    fn comeback_true_when_three_or_more_recycles() {
        let mut c = ctx_defaults();
        c.last_win_recycle_count = 3;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(
            ids.contains(&"comeback"),
            "comeback should unlock at last_win_recycle_count=3"
        );
    }

    #[test]
    fn on_a_roll_requires_streak_of_3() {
        let mut c = ctx();
        c.win_streak_current = 2;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(!ids.contains(&"on_a_roll"));

        c.win_streak_current = 3;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(ids.contains(&"on_a_roll"));
    }

    #[test]
    fn unstoppable_requires_streak_of_10() {
        let mut c = ctx();
        c.win_streak_current = 9;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(!ids.contains(&"unstoppable"));
        assert!(
            ids.contains(&"on_a_roll"),
            "streak 9 must still satisfy on_a_roll"
        );

        c.win_streak_current = 10;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(ids.contains(&"unstoppable"));
        assert!(
            ids.contains(&"on_a_roll"),
            "streak 10 must also satisfy on_a_roll"
        );
    }

    #[test]
    fn century_requires_100_games_played() {
        let mut c = ctx();
        c.games_played = 99;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(!ids.contains(&"century"));

        c.games_played = 100;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(ids.contains(&"century"));
    }

    #[test]
    fn veteran_requires_500_games_played() {
        let mut c = ctx();
        c.games_played = 499;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(!ids.contains(&"veteran"));
        assert!(
            ids.contains(&"century"),
            "499 games must also satisfy century"
        );

        c.games_played = 500;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(ids.contains(&"veteran"));
        assert!(
            ids.contains(&"century"),
            "500 games must also satisfy century"
        );
    }

    #[test]
    fn high_scorer_requires_best_single_score_of_5000() {
        let mut c = ctx();
        c.best_single_score = 4_999;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(!ids.contains(&"high_scorer"));

        c.best_single_score = 5_000;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(ids.contains(&"high_scorer"));
    }

    #[test]
    fn point_machine_requires_50000_lifetime_score() {
        let mut c = ctx();
        c.lifetime_score = 49_999;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(!ids.contains(&"point_machine"));

        c.lifetime_score = 50_000;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(ids.contains(&"point_machine"));
    }

    #[test]
    fn draw_three_master_requires_10_draw_three_wins() {
        let mut c = ctx();
        c.draw_three_wins = 9;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(!ids.contains(&"draw_three_master"));

        c.draw_three_wins = 10;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(ids.contains(&"draw_three_master"));
    }

    #[test]
    fn speed_demon_boundary_at_180_seconds() {
        let mut c = ctx();
        c.games_won = 1;
        c.last_win_time_seconds = 179;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(ids.contains(&"speed_demon"));

        c.last_win_time_seconds = 180;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(!ids.contains(&"speed_demon"));
    }

    #[test]
    fn check_achievements_returns_multiple_when_conditions_met() {
        // A context where first_win, on_a_roll, and no_undo all trigger at once.
        let mut c = ctx();
        c.games_won = 1;
        c.win_streak_current = 3;
        c.last_win_used_undo = false;
        c.last_win_time_seconds = 999;

        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(ids.contains(&"first_win"), "first_win should unlock");
        assert!(ids.contains(&"on_a_roll"), "on_a_roll should unlock");
        assert!(ids.contains(&"no_undo"), "no_undo should unlock");
        assert!(
            ids.len() >= 3,
            "at least 3 achievements must fire simultaneously"
        );
    }

    #[test]
    fn perfectionist_implies_no_undo_both_fire_together() {
        // perfectionist requires !used_undo && score >= 5000, which is a strict
        // superset of no_undo's condition. Both must appear in the result.
        let mut c = ctx();
        c.games_won = 1;
        c.last_win_used_undo = false;
        c.last_win_score = 5_000;
        c.last_win_time_seconds = 999;

        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(ids.contains(&"perfectionist"), "perfectionist must unlock");
        assert!(
            ids.contains(&"no_undo"),
            "no_undo must also unlock when perfectionist does"
        );
    }

    #[test]
    fn cinephile_achievement_in_canonical_list() {
        let def = achievement_by_id("cinephile").expect("cinephile must be registered");
        assert_eq!(def.id, "cinephile");
        assert_eq!(def.name, "Cinephile");
        assert!(!def.secret, "cinephile is not a secret achievement");
        // Event-driven: the predicate is a sentinel that always returns
        // false. `check_achievements` must never unlock cinephile from a
        // GameWonEvent context, even one that satisfies every other gate.
        let mut c = ctx();
        c.games_won = 1;
        c.win_streak_current = 999;
        c.last_win_time_seconds = 1;
        c.last_win_used_undo = false;
        c.best_single_score = 99_999;
        c.lifetime_score = u64::MAX;
        c.last_win_is_zen = true;
        c.last_win_recycle_count = 99;
        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(
            !ids.contains(&"cinephile"),
            "cinephile must never unlock via condition evaluation; got {ids:?}",
        );
    }

    #[test]
    fn perfectionist_score_well_above_threshold_still_passes() {
        let mut c = ctx();
        c.games_won = 1;
        c.last_win_used_undo = false;
        c.last_win_score = 50_000;

        let ids: Vec<&str> = check_achievements(&c).iter().map(|d| d.id).collect();
        assert!(
            ids.contains(&"perfectionist"),
            "score far above threshold must pass"
        );
    }
}
