use std::collections::{BTreeSet, HashSet};
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::sync::Once;

use chrono::Datelike;
use keydr::engine::key_stats::{
    KeyStatsStore, MASTERY_MAX_ERROR_RATE_EMA, MASTERY_MIN_SAMPLES, MASTERY_MIN_SPEED_CONFIDENCE,
};
use keydr::engine::scoring::level_from_score;
use keydr::engine::skill_tree::{ALL_BRANCHES, BranchId, BranchStatus, DrillScope, SkillTree};
use keydr::store::json_store::JsonStore;
use keydr::store::schema::ExportData;

const ALL_PROFILES: &[&str] = &[
    "01-brand-new.json",
    "02-early-lowercase.json",
    "03-mid-lowercase.json",
    "03-near-lowercase-complete.json",
    "04-lowercase-complete.json",
    "05-multi-branch.json",
    "06-advanced.json",
    "07-fully-complete.json",
];

static GENERATE: Once = Once::new();

/// Ensure test-profiles/ exists by running the generator binary (once per test run).
fn ensure_profiles_generated() {
    GENERATE.call_once(|| {
        let status = Command::new("cargo")
            .args(["run", "--bin", "generate_test_profiles"])
            .status()
            .expect("failed to run generate_test_profiles");
        assert!(
            status.success(),
            "generate_test_profiles exited with {status}"
        );
    });
}

fn load_profile(name: &str) -> ExportData {
    ensure_profiles_generated();
    let path = format!("test-profiles/{name}");
    let json = fs::read_to_string(&path).unwrap_or_else(|e| panic!("Failed to read {path}: {e}"));
    serde_json::from_str(&json).unwrap_or_else(|e| panic!("Failed to parse {path}: {e}"))
}

/// Get all keys for levels in completed branches.
fn completed_branch_keys(data: &ExportData) -> HashSet<char> {
    let mut keys = HashSet::new();
    for branch_def in ALL_BRANCHES {
        let bp = data.profile.skill_tree.branches.get(branch_def.id.to_key());
        let is_complete = matches!(bp, Some(bp) if bp.status == BranchStatus::Complete);
        if is_complete {
            for level in branch_def.levels {
                for &key in level.keys {
                    keys.insert(key);
                }
            }
        }
    }
    keys
}

/// Get all unlocked keys via SkillTree engine.
fn unlocked_keys_set(data: &ExportData) -> HashSet<char> {
    let tree = SkillTree::new(data.profile.skill_tree.clone());
    tree.unlocked_keys(DrillScope::Global).into_iter().collect()
}

/// Collect keys that are in the current in-progress level (not yet completed).
fn in_progress_level_keys(data: &ExportData) -> HashSet<char> {
    let mut keys = HashSet::new();
    for branch_def in ALL_BRANCHES {
        let bp = match data.profile.skill_tree.branches.get(branch_def.id.to_key()) {
            Some(bp) => bp,
            None => continue,
        };
        if bp.status != BranchStatus::InProgress {
            continue;
        }
        if branch_def.id == BranchId::Lowercase {
            // Lowercase progressive unlock: keys at indices [completed_count..unlocked_count]
            // current_level = number of keys beyond initial 6
            let unlocked_count = 6 + bp.current_level;
            let all_keys = branch_def.levels[0].keys;
            // The "frontier" keys that were most recently unlocked and may be partial
            // For in-progress check, we consider keys that aren't necessarily all mastered yet
            // The last few unlocked keys are the current learning frontier
            if unlocked_count <= all_keys.len() {
                // Keys in the last unlocked batch (the ones most likely < 1.0)
                let frontier_start = unlocked_count.saturating_sub(4).max(6);
                for &k in &all_keys[frontier_start..unlocked_count] {
                    keys.insert(k);
                }
            }
        } else if bp.current_level < branch_def.levels.len() {
            for &k in branch_def.levels[bp.current_level].keys {
                keys.insert(k);
            }
        }
    }
    keys
}

/// Collect keys from ranked drills.
fn ranked_drill_keys(data: &ExportData) -> HashSet<char> {
    let mut keys = HashSet::new();
    for drill in &data.drill_history.drills {
        if drill.ranked {
            for kt in &drill.per_key_times {
                keys.insert(kt.key);
            }
        }
    }
    keys
}

// ── Per-profile structural validation ────────────────────────────────────

fn assert_profile_valid(name: &str) {
    let data = load_profile(name);

    // Invariant #3: total_drills == drills.len()
    assert_eq!(
        data.profile.total_drills as usize,
        data.drill_history.drills.len(),
        "{name}: total_drills mismatch"
    );

    // Invariant #1: all stats keys are subset of unlocked keys
    let unlocked = unlocked_keys_set(&data);
    for &key in data.key_stats.stats.stats.keys() {
        assert!(
            unlocked.contains(&key),
            "{name}: key '{key}' in key_stats is not in the unlocked set"
        );
    }

    // Invariant #1: all keys in stats have sample_count > 0
    for (&key, stat) in &data.key_stats.stats.stats {
        assert!(
            stat.sample_count > 0,
            "{name}: key '{key}' has sample_count 0"
        );
    }

    // Invariant #2 + #8: all keys in completed branches are sticky-mastered
    // and completed branches have stats for all their keys.
    //
    // Progression invariants are checked against `ranked_key_stats`: that is the
    // only authoritative store for mastery. Unranked `key_stats` shares the type
    // but must never carry the sticky bit.
    let completed_keys = completed_branch_keys(&data);
    for &key in &completed_keys {
        assert!(
            data.ranked_key_stats.stats.stats.contains_key(&key),
            "{name}: key '{key}' in completed branch has no ranked stats entry"
        );
        let stat = &data.ranked_key_stats.stats.stats[&key];
        assert!(
            stat.mastered,
            "{name}: key '{key}' in completed branch must have mastered == true, got stat with confidence {} sample_count {} error_rate_ema {}",
            stat.confidence, stat.sample_count, stat.error_rate_ema
        );
        // Deliberately NOT asserting qualifies_for_mastery() here: mastery is
        // sticky, so a key that earned it may currently sit below the gate (a
        // recent error spikes error_rate_ema, a slow stretch drops confidence).
        // What must hold is that the key accumulated real evidence.
        assert!(
            stat.sample_count >= MASTERY_MIN_SAMPLES,
            "{name}: key '{key}' in completed branch is mastered with only {} samples",
            stat.sample_count
        );
    }

    // Only ranked activity may promote mastery.
    for (&key, stat) in &data.key_stats.stats.stats {
        assert!(
            !stat.mastered,
            "{name}: unranked key_stats entry '{key}' must not carry the sticky mastery bit"
        );
    }

    // Invariant #9: timestamps are monotonically increasing
    for i in 1..data.drill_history.drills.len() {
        assert!(
            data.drill_history.drills[i].timestamp >= data.drill_history.drills[i - 1].timestamp,
            "{name}: drill timestamps not monotonic at index {i}"
        );
    }

    // Invariant #6: drill per_key_times only reference keys from the unlocked set
    for (i, drill) in data.drill_history.drills.iter().enumerate() {
        for kt in &drill.per_key_times {
            assert!(
                unlocked.contains(&kt.key),
                "{name}: drill {i} references key '{}' not in unlocked set",
                kt.key
            );
        }
    }
}

#[test]
fn profile_01_brand_new_valid() {
    assert_profile_valid("01-brand-new.json");
}

#[test]
fn profile_02_early_lowercase_valid() {
    assert_profile_valid("02-early-lowercase.json");
}

#[test]
fn profile_03_mid_lowercase_valid() {
    assert_profile_valid("03-mid-lowercase.json");
}

#[test]
fn profile_03_near_lowercase_complete_valid() {
    assert_profile_valid("03-near-lowercase-complete.json");
}

#[test]
fn profile_04_lowercase_complete_valid() {
    assert_profile_valid("04-lowercase-complete.json");
}

#[test]
fn profile_05_multi_branch_valid() {
    assert_profile_valid("05-multi-branch.json");
}

#[test]
fn profile_06_advanced_valid() {
    assert_profile_valid("06-advanced.json");
}

#[test]
fn profile_07_fully_complete_valid() {
    assert_profile_valid("07-fully-complete.json");
}

// ── Invariant #7: ranked stats presence/population ───────────────────────

#[test]
fn profile_01_has_empty_ranked_stats() {
    let data = load_profile("01-brand-new.json");
    assert!(
        data.ranked_key_stats.stats.stats.is_empty(),
        "01-brand-new.json: ranked_key_stats should be empty"
    );
    let ranked_count = data
        .drill_history
        .drills
        .iter()
        .filter(|d| d.ranked)
        .count();
    assert_eq!(
        ranked_count, 0,
        "01-brand-new.json: should have no ranked drills"
    );
}

#[test]
fn profiles_02_to_07_have_ranked_stats_and_ranked_drills() {
    for name in &ALL_PROFILES[1..] {
        let data = load_profile(name);
        assert!(
            !data.ranked_key_stats.stats.stats.is_empty(),
            "{name}: ranked_key_stats should not be empty"
        );
        let ranked_count = data
            .drill_history
            .drills
            .iter()
            .filter(|d| d.ranked)
            .count();
        assert!(
            ranked_count > 0,
            "{name}: expected at least one ranked drill to populate ranked stores"
        );
    }
}

// ── Invariant #7: ranked stats cover ranked drill keys ───────────────────

#[test]
fn ranked_stats_cover_ranked_drill_keys() {
    for name in &ALL_PROFILES[1..] {
        let data = load_profile(name);
        let drill_keys = ranked_drill_keys(&data);
        let ranked_stat_keys: HashSet<char> =
            data.ranked_key_stats.stats.stats.keys().copied().collect();

        for &key in &drill_keys {
            assert!(
                ranked_stat_keys.contains(&key),
                "{name}: key '{key}' appears in ranked drills but not in ranked_key_stats"
            );
        }
    }
}

// ── Invariant #2: in-progress keys have mastered == false ──────────────

#[test]
fn in_progress_keys_are_not_mastered() {
    // Profiles 2, 3, 5, 6 have in-progress branches with partial keys
    for name in &[
        "02-early-lowercase.json",
        "03-mid-lowercase.json",
        "03-near-lowercase-complete.json",
        "05-multi-branch.json",
        "06-advanced.json",
    ] {
        let data = load_profile(name);
        let ip_keys = in_progress_level_keys(&data);

        // At least some in-progress keys should not yet be sticky-mastered.
        // Mastery is only authoritative in ranked stats.
        let partial_count = ip_keys
            .iter()
            .filter(|&&k| {
                data.ranked_key_stats
                    .stats
                    .stats
                    .get(&k)
                    .is_some_and(|s| !s.mastered)
            })
            .count();
        assert!(
            partial_count > 0,
            "{name}: expected some in-progress keys with mastered == false, \
             but all {} in-progress keys are already mastered",
            ip_keys.len()
        );
    }
}

// ── Invariant #4: synthetic score produces reasonable level ──────────────

#[test]
fn synthetic_score_level_in_expected_range() {
    let expected: &[(&str, u32, u32)] = &[
        ("01-brand-new.json", 1, 1),
        ("02-early-lowercase.json", 1, 3),
        ("03-mid-lowercase.json", 2, 4),
        ("03-near-lowercase-complete.json", 3, 5),
        ("04-lowercase-complete.json", 4, 6),
        ("05-multi-branch.json", 6, 8),
        ("06-advanced.json", 10, 14),
        ("07-fully-complete.json", 16, 20),
    ];

    for &(name, min_level, max_level) in expected {
        let data = load_profile(name);
        let level = level_from_score(data.profile.total_score);
        assert!(
            level >= min_level && level <= max_level,
            "{name}: level_from_score({}) = {level}, expected [{min_level}, {max_level}]",
            data.profile.total_score
        );
    }
}

// ── Invariant #5: streak/date consistency ────────────────────────────────

/// Compute trailing consecutive-day streak from drill timestamps.
fn compute_trailing_streak(data: &ExportData) -> u32 {
    let drills = &data.drill_history.drills;
    if drills.is_empty() {
        return 0;
    }

    // Collect unique drill dates (YYYY-MM-DD as ordinal days for easy comparison)
    let unique_dates: BTreeSet<i32> = drills
        .iter()
        .map(|d| d.timestamp.num_days_from_ce())
        .collect();

    let dates_vec: Vec<i32> = unique_dates.into_iter().collect();
    let last_date = *dates_vec.last().unwrap();

    // Count consecutive days backwards from the last date
    let mut streak = 1u32;
    for i in (0..dates_vec.len() - 1).rev() {
        if dates_vec[i] == last_date - streak as i32 {
            streak += 1;
        } else {
            break;
        }
    }
    streak
}

#[test]
fn streak_and_last_practice_date_consistent_with_history() {
    for name in ALL_PROFILES {
        let data = load_profile(name);
        let drills = &data.drill_history.drills;

        if drills.is_empty() {
            assert!(
                data.profile.last_practice_date.is_none(),
                "{name}: empty history should have no last_practice_date"
            );
            assert_eq!(
                data.profile.streak_days, 0,
                "{name}: empty history should have 0 streak"
            );
        } else {
            // last_practice_date should match the last drill's date
            let last_drill_date = drills
                .last()
                .unwrap()
                .timestamp
                .format("%Y-%m-%d")
                .to_string();
            assert_eq!(
                data.profile.last_practice_date.as_deref(),
                Some(last_drill_date.as_str()),
                "{name}: last_practice_date doesn't match last drill timestamp"
            );

            // streak_days should exactly equal trailing consecutive days from history
            let computed_streak = compute_trailing_streak(&data);
            assert_eq!(
                data.profile.streak_days, computed_streak,
                "{name}: streak_days ({}) doesn't match computed trailing streak ({computed_streak})",
                data.profile.streak_days
            );
        }
    }
}

/// Mastery assertions read `ranked_key_stats` — the authoritative progression
/// store. Unranked `key_stats` never carries the sticky bit.
fn assert_sticky_mastered(name: &str, key: char, data: &ExportData) {
    let stat = &data.ranked_key_stats.stats.stats[&key];
    assert!(
        stat.mastered,
        "{name}: key '{key}' should have mastered == true"
    );
    // Sticky mastery survives later dips, so assert durable evidence rather than
    // that the key still clears the gate right now.
    assert!(
        stat.sample_count >= MASTERY_MIN_SAMPLES,
        "{name}: key '{key}' is mastered with only {} samples",
        stat.sample_count
    );
}

fn assert_not_mastered(name: &str, key: char, data: &ExportData) {
    let stat = &data.ranked_key_stats.stats.stats[&key];
    assert!(
        !stat.mastered,
        "{name}: key '{key}' should have mastered == false"
    );
    assert!(
        !stat.qualifies_for_mastery(),
        "{name}: key '{key}' should miss the mastery gate"
    );
}

// ── Profile-specific mastery semantics ───────────────────────────────────

#[test]
fn profile_specific_mastery_semantics() {
    // Profile 02: s,h,r,d are partial; e,t,a,o,i,n are sticky-mastered.
    {
        let data = load_profile("02-early-lowercase.json");
        for &k in &['e', 't', 'a', 'o', 'i', 'n'] {
            assert_sticky_mastered("02", k, &data);
        }
        for &k in &['s', 'h', 'r', 'd'] {
            assert_not_mastered("02", k, &data);
        }
    }

    // Profile 03: first 14 keys mastered; w,f,g,y remain partial.
    {
        let data = load_profile("03-mid-lowercase.json");
        let all_lc: Vec<char> = "etaoinshrdlcum".chars().collect();
        for &k in &all_lc {
            assert_sticky_mastered("03", k, &data);
        }
        for &k in &['w', 'f', 'g', 'y'] {
            assert_not_mastered("03", k, &data);
        }
    }

    // Profile 03-near: first 24 lowercase keys mastered; one key misses only speed.
    {
        let data = load_profile("03-near-lowercase-complete.json");
        let stats = &data.ranked_key_stats.stats.stats;
        let almost_all_lc: Vec<char> = "etaoinshrdlcumwfgypbvkjx".chars().collect();
        for &k in &almost_all_lc {
            assert_sticky_mastered("03-near", k, &data);
        }
        let q_stat = &stats[&'q'];
        assert!(
            !q_stat.mastered,
            "03-near: key 'q' should not yet be sticky-mastered"
        );
        assert!(
            q_stat.sample_count >= MASTERY_MIN_SAMPLES,
            "03-near: key 'q' should already meet the sample gate"
        );
        assert!(
            q_stat.error_rate_ema <= MASTERY_MAX_ERROR_RATE_EMA,
            "03-near: key 'q' should already meet the accuracy gate"
        );
        assert!(
            q_stat.confidence < MASTERY_MIN_SPEED_CONFIDENCE && q_stat.confidence >= 1.0,
            "03-near: key 'q' should miss only the speed gate, got confidence {}",
            q_stat.confidence
        );
    }

    // Profile 05: capitals L2 partial (J,D,R,C), numbers partial (1,2,3),
    // punctuation partial (.,',').
    {
        let data = load_profile("05-multi-branch.json");
        for &k in &['J', 'D', 'R', 'C'] {
            assert_not_mastered("05", k, &data);
        }
        for &k in &['1', '2', '3'] {
            assert_not_mastered("05", k, &data);
        }
        for &k in &['.', ',', '\''] {
            assert_not_mastered("05", k, &data);
        }
    }

    // Profile 06: code symbols L3 partial (&,|,^,~)
    {
        let data = load_profile("06-advanced.json");
        for &k in &['&', '|', '^', '~'] {
            assert_not_mastered("06", k, &data);
        }
        assert_sticky_mastered("06", '!', &data);
    }
}

/// A branch parked at an in-progress level claims that level is still being
/// learned. If its keys were already mastered in the authoritative store, the
/// saved progression and the ranked stats would contradict each other.
#[test]
fn current_level_keys_are_not_mastered_in_ranked_stats() {
    for name in ALL_PROFILES {
        let data = load_profile(name);
        for branch_def in ALL_BRANCHES {
            // Lowercase unlocks key-by-key rather than by level, so its frontier
            // legitimately mixes mastered and unmastered keys.
            if branch_def.id == BranchId::Lowercase {
                continue;
            }
            let Some(bp) = data.profile.skill_tree.branches.get(branch_def.id.to_key()) else {
                continue;
            };
            if bp.status != BranchStatus::InProgress || bp.current_level >= branch_def.levels.len()
            {
                continue;
            }
            // Keys shared with an already-completed branch are legitimately mastered.
            let completed = completed_branch_keys(&data);
            for &key in branch_def.levels[bp.current_level].keys {
                if completed.contains(&key) {
                    continue;
                }
                if let Some(stat) = data.ranked_key_stats.stats.stats.get(&key) {
                    assert!(
                        !stat.mastered,
                        "{name}: key '{key}' is in {:?}'s current (in-progress) level {} \
                         but is already mastered in ranked stats",
                        branch_def.id, bp.current_level
                    );
                }
            }
        }
    }
}

/// The persisted stores must be reproducible from the fixture's own history.
///
/// Replaying `drill_history` the way the app does has to yield exactly the
/// persisted `key_stats`/`ranked_key_stats` — including the sticky `mastered`
/// bits — otherwise a fixture encodes a state the engine could never produce.
#[test]
fn fixture_stats_match_replay_of_their_drill_history() {
    for name in ALL_PROFILES {
        let data = load_profile(name);

        let mut replayed_global = KeyStatsStore {
            target_cpm: data.key_stats.stats.target_cpm,
            ..KeyStatsStore::default()
        };
        let mut replayed_ranked = KeyStatsStore {
            target_cpm: data.ranked_key_stats.stats.target_cpm,
            ..KeyStatsStore::default()
        };

        for drill in &data.drill_history.drills {
            for kt in &drill.per_key_times {
                if kt.correct {
                    replayed_global.update_key(kt.key, kt.time_ms);
                } else {
                    replayed_global.update_key_error(kt.key);
                }
                if drill.ranked {
                    if kt.correct {
                        replayed_ranked.update_key_ranked(kt.key, kt.time_ms);
                    } else {
                        replayed_ranked.update_key_error_ranked(kt.key);
                    }
                }
            }
        }

        assert_stores_match(
            name,
            "ranked_key_stats",
            &replayed_ranked,
            &data.ranked_key_stats.stats,
        );
        assert_stores_match(name, "key_stats", &replayed_global, &data.key_stats.stats);
    }
}

/// Compare a replayed store against a persisted one.
///
/// Discrete state (mastery, counts) must match exactly. Float fields are
/// compared with a tolerance: the persisted values made a round trip through
/// JSON before being recomputed, so requiring bit-identical results would be
/// asserting something about float formatting rather than about the engine.
fn assert_stores_match(
    name: &str,
    label: &str,
    replayed: &KeyStatsStore,
    persisted: &KeyStatsStore,
) {
    let replayed_keys: BTreeSet<char> = replayed.stats.keys().copied().collect();
    let persisted_keys: BTreeSet<char> = persisted.stats.keys().copied().collect();
    assert_eq!(
        replayed_keys, persisted_keys,
        "{name}: {label} covers different keys than a replay of its drill history"
    );

    for (&key, expected) in &persisted.stats {
        let actual = &replayed.stats[&key];
        assert_eq!(
            actual.mastered, expected.mastered,
            "{name}: {label} key '{key}' mastered bit disagrees with replay"
        );
        assert_eq!(
            actual.sample_count, expected.sample_count,
            "{name}: {label} key '{key}' sample_count disagrees with replay"
        );
        assert_eq!(
            actual.error_count, expected.error_count,
            "{name}: {label} key '{key}' error_count disagrees with replay"
        );
        assert_eq!(
            actual.total_count, expected.total_count,
            "{name}: {label} key '{key}' total_count disagrees with replay"
        );
        for (field, a, e) in [
            ("confidence", actual.confidence, expected.confidence),
            (
                "filtered_time_ms",
                actual.filtered_time_ms,
                expected.filtered_time_ms,
            ),
            ("best_time_ms", actual.best_time_ms, expected.best_time_ms),
            (
                "error_rate_ema",
                actual.error_rate_ema,
                expected.error_rate_ema,
            ),
        ] {
            assert!(
                (a - e).abs() <= 1e-9 * e.abs().max(1.0),
                "{name}: {label} key '{key}' {field} disagrees with replay: {a} vs {e}"
            );
        }
    }
}

/// Replaying a fixture's ranked history through the skill tree must reproduce
/// the progression state the fixture ships, at least to the extent that every
/// completed branch is backed by genuinely mastered keys.
#[test]
fn fixture_progression_is_backed_by_replayed_mastery() {
    for name in ALL_PROFILES {
        let data = load_profile(name);

        let mut replayed = KeyStatsStore {
            target_cpm: data.ranked_key_stats.stats.target_cpm,
            ..KeyStatsStore::default()
        };
        for drill in data.drill_history.drills.iter().filter(|d| d.ranked) {
            for kt in &drill.per_key_times {
                if kt.correct {
                    replayed.update_key_ranked(kt.key, kt.time_ms);
                } else {
                    replayed.update_key_error_ranked(kt.key);
                }
            }
        }

        for &key in &completed_branch_keys(&data) {
            assert!(
                replayed.is_mastered(key),
                "{name}: key '{key}' is in a completed branch but replaying the fixture's \
                 ranked history does not earn mastery for it"
            );
        }
    }
}

// ── Import via JsonStore ─────────────────────────────────────────────────

#[test]
fn imports_all_profiles_into_temp_store() {
    for name in ALL_PROFILES {
        let data = load_profile(name);
        let tmp_dir = tempfile::tempdir().unwrap();
        let store =
            JsonStore::with_base_dir(PathBuf::from(tmp_dir.path())).expect("create temp store");

        store
            .import_all(&data)
            .unwrap_or_else(|e| panic!("{name}: import_all failed: {e}"));

        // Verify we can reload the imported data
        let profile = store.load_profile();
        assert!(profile.is_some(), "{name}: profile not found after import");
        let profile = profile.unwrap();
        assert_eq!(
            profile.total_drills, data.profile.total_drills,
            "{name}: imported profile total_drills mismatch"
        );

        let key_stats = store.load_key_stats();
        assert_eq!(
            key_stats.stats.stats.len(),
            data.key_stats.stats.stats.len(),
            "{name}: imported key_stats entry count mismatch"
        );

        let drill_history = store.load_drill_history();
        assert_eq!(
            drill_history.drills.len(),
            data.drill_history.drills.len(),
            "{name}: imported drill_history count mismatch"
        );
    }
}
