//! Player progression — XP, level, unlocks, daily/weekly progress.
//!
//! Persisted to `progress.json` next to `stats.json` and `achievements.json`.
//!
//! [`PlayerProgress`] is defined in `solitaire_sync` (so the server can use
//! the same type) and re-exported here along with file I/O helpers.

use std::fs;
use std::io;
use std::path::{Path, PathBuf};

use chrono::{Datelike, NaiveDate};

pub use solitaire_sync::PlayerProgress;
pub use solitaire_sync::progress::level_for_xp;

const FILE_NAME: &str = "progress.json";

/// Deterministic seed derived from a date, identical for all players globally.
/// Used as the RNG seed for the daily-challenge deal.
pub fn daily_seed_for(date: NaiveDate) -> u64 {
    let y = date.year() as u64;
    let m = date.month() as u64;
    let d = date.day() as u64;
    y * 10_000 + m * 100 + d
}

/// Component breakdown of the XP awarded for a win.
///
/// This is the single source of truth for win-XP scoring: [`xp_for_win`] sums
/// it for the total, and UI that displays the individual lines (the win-summary
/// modal) reads the parts from here so the breakdown can never drift from the
/// total.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct XpBreakdown {
    /// Flat base XP granted for any win.
    pub base: u64,
    /// Scaled fast-win bonus (10..=50 for sub-2-minute wins, else 0).
    pub speed_bonus: u64,
    /// Bonus for winning without using undo (25, else 0).
    pub no_undo_bonus: u64,
}

impl XpBreakdown {
    /// Total XP awarded: `base + speed_bonus + no_undo_bonus`.
    pub fn total(self) -> u64 {
        self.base + self.speed_bonus + self.no_undo_bonus
    }
}

/// Component breakdown of the XP awarded for a win.
///
/// Base 50 + scaled fast-win bonus (10..=50 for sub-2-minute wins) + 25 if
/// the player did not use undo.
pub fn xp_breakdown(time_seconds: u64, used_undo: bool) -> XpBreakdown {
    let speed_bonus: u64 = if time_seconds >= 120 {
        0
    } else {
        // Linearly scale 50 → 10 across 0..=120 seconds.
        // 0s → 50, 60s → 30, 120s → 10.
        let scaled = 50_u64.saturating_sub(time_seconds.saturating_mul(40) / 120);
        scaled.max(10)
    };
    XpBreakdown {
        base: 50,
        speed_bonus,
        no_undo_bonus: if used_undo { 0 } else { 25 },
    }
}

/// XP awarded for winning a game. See [`xp_breakdown`] for the components.
pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
    xp_breakdown(time_seconds, used_undo).total()
}

/// Platform-specific default path for `progress.json`.
pub fn progress_file_path() -> Option<PathBuf> {
    crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(FILE_NAME))
}

/// Load progress from an explicit path. Returns `default()` if missing/corrupt.
pub fn load_progress_from(path: &Path) -> PlayerProgress {
    let Ok(data) = fs::read(path) else {
        return PlayerProgress::default();
    };
    serde_json::from_slice(&data).unwrap_or_default()
}

/// Save progress to an explicit path using an atomic write.
pub fn save_progress_to(path: &Path, progress: &PlayerProgress) -> io::Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    let json = serde_json::to_string_pretty(progress).map_err(io::Error::other)?;
    let tmp = path.with_extension("json.tmp");
    fs::write(&tmp, json.as_bytes())?;
    fs::rename(&tmp, path)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::Duration;
    use std::env;

    fn tmp_path(name: &str) -> PathBuf {
        env::temp_dir().join(format!("solitaire_progress_test_{name}.json"))
    }

    // --- Level formula ---

    #[test]
    fn level_for_xp_at_breakpoints() {
        assert_eq!(level_for_xp(0), 0);
        assert_eq!(level_for_xp(499), 0);
        assert_eq!(level_for_xp(500), 1);
        assert_eq!(level_for_xp(4_999), 9);
        assert_eq!(level_for_xp(5_000), 10);
        assert_eq!(level_for_xp(5_999), 10);
        assert_eq!(level_for_xp(6_000), 11);
        assert_eq!(level_for_xp(15_000), 20);
    }

    // --- XP-for-win formula ---

    #[test]
    fn xp_for_slow_win_with_undo_is_just_base() {
        assert_eq!(xp_for_win(300, true), 50);
    }

    #[test]
    fn xp_for_no_undo_win_adds_25() {
        assert_eq!(xp_for_win(300, false), 75);
    }

    #[test]
    fn xp_for_instant_win_includes_max_speed_bonus() {
        // base 50 + speed 50 = 100 with undo, +25 without
        assert_eq!(xp_for_win(0, true), 100);
        assert_eq!(xp_for_win(0, false), 125);
    }

    #[test]
    fn xp_speed_bonus_scales_linearly_to_120s() {
        // At 60s: 50 - (60*40/120) = 50 - 20 = 30
        assert_eq!(xp_for_win(60, true), 50 + 30);
        // At 119s: 50 - (119*40/120) = 50 - 39 = 11, but floored at 10
        assert!(xp_for_win(119, true) >= 60);
    }

    #[test]
    fn xp_no_speed_bonus_at_or_above_120s() {
        assert_eq!(xp_for_win(120, true), 50);
        assert_eq!(xp_for_win(180, true), 50);
    }

    // --- PlayerProgress.add_xp ---

    #[test]
    fn add_xp_returns_previous_level_and_recomputes() {
        let mut p = PlayerProgress::default();
        let prev = p.add_xp(500);
        assert_eq!(prev, 0);
        assert_eq!(p.total_xp, 500);
        assert_eq!(p.level, 1);
    }

    #[test]
    fn level_up_detection_works() {
        let mut p = PlayerProgress::default();
        let prev = p.add_xp(450);
        assert!(!p.leveled_up_from(prev), "no level change at 450 xp");
        let prev = p.add_xp(60);
        assert!(p.leveled_up_from(prev), "0 → 1 at 510 xp");
    }

    #[test]
    fn add_xp_saturates_on_overflow() {
        let mut p = PlayerProgress {
            total_xp: u64::MAX - 5,
            ..Default::default()
        };
        p.add_xp(100);
        assert_eq!(p.total_xp, u64::MAX);
    }

    #[test]
    fn default_unlocks_include_first_card_back_and_background() {
        let p = PlayerProgress::default();
        assert!(p.unlocked_card_backs.contains(&0));
        assert!(p.unlocked_backgrounds.contains(&0));
    }

    // --- Persistence ---

    #[test]
    fn load_from_missing_file_returns_default() {
        let path = tmp_path("missing_xyz");
        let _ = fs::remove_file(&path);
        let p = load_progress_from(&path);
        assert_eq!(p, PlayerProgress::default());
    }

    #[test]
    fn load_from_corrupt_file_returns_default() {
        let path = tmp_path("corrupt");
        fs::write(&path, b"garbage").expect("write");
        let p = load_progress_from(&path);
        assert_eq!(p, PlayerProgress::default());
    }

    #[test]
    fn save_cleans_up_tmp_file() {
        let path = tmp_path("atomic");
        save_progress_to(&path, &PlayerProgress::default()).expect("save");
        assert!(!path.with_extension("json.tmp").exists());
    }

    // --- Daily challenge ---

    #[test]
    fn daily_seed_is_deterministic_per_date() {
        let d = NaiveDate::from_ymd_opt(2026, 4, 24).unwrap();
        assert_eq!(daily_seed_for(d), daily_seed_for(d));
    }

    #[test]
    fn daily_seed_differs_across_dates() {
        let a = NaiveDate::from_ymd_opt(2026, 4, 24).unwrap();
        let b = NaiveDate::from_ymd_opt(2026, 4, 25).unwrap();
        assert_ne!(daily_seed_for(a), daily_seed_for(b));
    }

    #[test]
    fn first_daily_completion_starts_streak_at_1() {
        let mut p = PlayerProgress::default();
        let d = NaiveDate::from_ymd_opt(2026, 4, 24).unwrap();
        let recorded = p.record_daily_completion(d);
        assert!(recorded);
        assert_eq!(p.daily_challenge_streak, 1);
        assert_eq!(p.daily_challenge_last_completed, Some(d));
    }

    #[test]
    fn consecutive_days_increment_streak() {
        let mut p = PlayerProgress::default();
        let d1 = NaiveDate::from_ymd_opt(2026, 4, 24).unwrap();
        let d2 = d1 + Duration::days(1);
        let d3 = d2 + Duration::days(1);
        p.record_daily_completion(d1);
        p.record_daily_completion(d2);
        p.record_daily_completion(d3);
        assert_eq!(p.daily_challenge_streak, 3);
    }

    #[test]
    fn skipped_day_resets_streak_to_1() {
        let mut p = PlayerProgress::default();
        let d1 = NaiveDate::from_ymd_opt(2026, 4, 24).unwrap();
        let d3 = d1 + Duration::days(2); // skipped d2
        p.record_daily_completion(d1);
        p.record_daily_completion(d3);
        assert_eq!(p.daily_challenge_streak, 1);
    }

    // --- Weekly goals ---

    #[test]
    fn first_week_roll_initializes_key_and_returns_true() {
        let mut p = PlayerProgress::default();
        let rolled = p.roll_weekly_goals_if_new_week("2026-W17");
        assert!(rolled);
        assert_eq!(p.weekly_goal_week_iso.as_deref(), Some("2026-W17"));
    }

    #[test]
    fn same_week_roll_is_noop() {
        let mut p = PlayerProgress::default();
        p.roll_weekly_goals_if_new_week("2026-W17");
        p.weekly_goal_progress.insert("g1".into(), 3);
        let rolled = p.roll_weekly_goals_if_new_week("2026-W17");
        assert!(!rolled);
        assert_eq!(p.weekly_goal_progress.get("g1"), Some(&3));
    }

    #[test]
    fn new_week_roll_clears_progress_and_updates_key() {
        let mut p = PlayerProgress::default();
        p.roll_weekly_goals_if_new_week("2026-W17");
        p.weekly_goal_progress.insert("g1".into(), 3);
        let rolled = p.roll_weekly_goals_if_new_week("2026-W18");
        assert!(rolled);
        assert!(p.weekly_goal_progress.is_empty());
        assert_eq!(p.weekly_goal_week_iso.as_deref(), Some("2026-W18"));
    }

    #[test]
    fn record_weekly_progress_returns_true_only_on_completion_step() {
        let mut p = PlayerProgress::default();
        assert!(!p.record_weekly_progress("g1", 3));
        assert!(!p.record_weekly_progress("g1", 3));
        assert!(p.record_weekly_progress("g1", 3), "third tick completes");
        // Further ticks should not re-fire completion.
        assert!(!p.record_weekly_progress("g1", 3));
        assert_eq!(p.weekly_goal_progress.get("g1"), Some(&3));
    }

    #[test]
    fn same_day_completion_is_idempotent() {
        let mut p = PlayerProgress::default();
        let d = NaiveDate::from_ymd_opt(2026, 4, 24).unwrap();
        p.record_daily_completion(d);
        let recorded_again = p.record_daily_completion(d);
        assert!(!recorded_again, "same-day completion must report no-op");
        assert_eq!(p.daily_challenge_streak, 1);
    }

    // --- Daily challenge history & longest streak ---

    #[test]
    fn record_daily_completion_appends_to_history() {
        // Recording a completion adds the date to history, preserving the
        // pre-call length + 1, and the new entry is the chronological tail.
        let mut p = PlayerProgress::default();
        let prev_len = p.daily_challenge_history.len();
        let today = NaiveDate::from_ymd_opt(2026, 5, 5).unwrap();
        let recorded = p.record_daily_completion(today);
        assert!(recorded);
        assert_eq!(p.daily_challenge_history.len(), prev_len + 1);
        assert_eq!(p.daily_challenge_history.last().copied(), Some(today));
    }

    #[test]
    fn record_daily_completion_updates_longest_streak() {
        // A streak of 4 must lift `daily_challenge_longest_streak` from 2 to 4
        // (we seed the previous best at 2 and watch it get overtaken).
        let mut p = PlayerProgress {
            daily_challenge_longest_streak: 2,
            ..Default::default()
        };
        let d = NaiveDate::from_ymd_opt(2026, 5, 1).unwrap();
        p.record_daily_completion(d);
        p.record_daily_completion(d + Duration::days(1));
        p.record_daily_completion(d + Duration::days(2));
        // 3rd consecutive day equals the previous best; longest should match.
        assert_eq!(p.daily_challenge_streak, 3);
        assert_eq!(p.daily_challenge_longest_streak, 3);
        // 4th consecutive day overtakes the previous best.
        p.record_daily_completion(d + Duration::days(3));
        assert_eq!(p.daily_challenge_streak, 4);
        assert_eq!(p.daily_challenge_longest_streak, 4);
    }

    #[test]
    fn legacy_progress_without_history_deserializes_to_empty() {
        // A progress.json file produced before the history fields existed
        // must still round-trip through serde::from_slice without error,
        // with the new fields landing on their `#[serde(default)]` values.
        let path = tmp_path("legacy_no_history");
        let _ = fs::remove_file(&path);
        let legacy_json = br#"{
            "total_xp": 1500,
            "level": 3,
            "daily_challenge_last_completed": null,
            "daily_challenge_streak": 0,
            "weekly_goal_progress": {},
            "unlocked_card_backs": [0],
            "unlocked_backgrounds": [0],
            "last_modified": "2026-04-29T12:00:00Z"
        }"#;
        fs::write(&path, legacy_json).expect("write");
        let p = load_progress_from(&path);
        assert_eq!(p.total_xp, 1500);
        assert!(
            p.daily_challenge_history.is_empty(),
            "legacy file lacking daily_challenge_history must default to empty"
        );
        assert_eq!(
            p.daily_challenge_longest_streak, 0,
            "legacy file lacking daily_challenge_longest_streak must default to 0"
        );
    }
}
