use std::collections::HashMap;

use super::test_ledger;
use crate::{
    ranking::{
        executor::ProfileExecutor,
        profile::Sort,
        // Shared fixtures (DRY-S, M0-M10 review): `make_profile` and `ledger_with`
        // were per-module duplicates of `profile_with_sort` and `ledger_for`.
        test_fixtures::{ledger_for, profile_with_sort as make_profile},
    },
    schema::{EntityId, Timestamp, Window},
    signals::SignalLedger,
};

// -- M6p3: New sort variant unit tests ────────────────────────────────

#[test]
fn sort_alphabetical_asc_a_before_z() {
    let ledger = test_ledger();
    let mut meta: HashMap<u64, HashMap<String, String>> = HashMap::new();
    meta.insert(1, [("title".into(), "Banana".into())].into());
    meta.insert(2, [("title".into(), "Apple".into())].into());
    meta.insert(3, [("title".into(), "Cherry".into())].into());

    let executor = ProfileExecutor::new(&ledger).with_item_metadata(&meta);
    let candidates: Vec<EntityId> = (1..=3).map(EntityId::new).collect();
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000);
    let profile = make_profile(Sort::AlphabeticalAsc);
    let result = executor.score(&candidates, &profile, now).unwrap();

    assert_eq!(result[0].entity_id, EntityId::new(2)); // Apple
    assert_eq!(result[1].entity_id, EntityId::new(1)); // Banana
    assert_eq!(result[2].entity_id, EntityId::new(3)); // Cherry
}

#[test]
fn sort_alphabetical_desc_z_before_a() {
    let ledger = test_ledger();
    let mut meta: HashMap<u64, HashMap<String, String>> = HashMap::new();
    meta.insert(1, [("title".into(), "Banana".into())].into());
    meta.insert(2, [("title".into(), "Apple".into())].into());
    meta.insert(3, [("title".into(), "Cherry".into())].into());

    let executor = ProfileExecutor::new(&ledger).with_item_metadata(&meta);
    let candidates: Vec<EntityId> = (1..=3).map(EntityId::new).collect();
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000);
    let profile = make_profile(Sort::AlphabeticalDesc);
    let result = executor.score(&candidates, &profile, now).unwrap();

    assert_eq!(result[0].entity_id, EntityId::new(3)); // Cherry
    assert_eq!(result[1].entity_id, EntityId::new(1)); // Banana
    assert_eq!(result[2].entity_id, EntityId::new(2)); // Apple
}

#[test]
fn sort_alphabetical_missing_title_last() {
    let ledger = test_ledger();
    let mut meta: HashMap<u64, HashMap<String, String>> = HashMap::new();
    meta.insert(1, [("title".into(), "Banana".into())].into());
    // Entity 2 has no title.
    meta.insert(2, HashMap::new());
    meta.insert(3, [("title".into(), "Apple".into())].into());

    let executor = ProfileExecutor::new(&ledger).with_item_metadata(&meta);
    let candidates: Vec<EntityId> = (1..=3).map(EntityId::new).collect();
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000);
    let profile = make_profile(Sort::AlphabeticalAsc);
    let result = executor.score(&candidates, &profile, now).unwrap();

    // Entity 2 (no title) should be last.
    assert_eq!(result[2].entity_id, EntityId::new(2));
}

#[test]
fn sort_alphabetical_8char_prefix_collision_is_deterministic() {
    // Regression: titles sharing the first 8 bytes ("introduc") must order
    // deterministically (not arbitrarily by the unstable sort) and must keep
    // distinct keys so they never collapse to a tie.
    let ledger = test_ledger();
    let mut meta: HashMap<u64, HashMap<String, String>> = HashMap::new();
    meta.insert(1, [("title".into(), "introduction to rust".into())].into());
    meta.insert(2, [("title".into(), "introduction to go".into())].into());
    meta.insert(3, [("title".into(), "introduction to zig".into())].into());

    let executor = ProfileExecutor::new(&ledger).with_item_metadata(&meta);
    let candidates: Vec<EntityId> = (1..=3).map(EntityId::new).collect();
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000);
    let profile = make_profile(Sort::AlphabeticalAsc);

    // Run twice with a shuffled candidate input order: the output order must be
    // identical, proving the tie-break is deterministic rather than arbitrary.
    let r1 = executor.score(&candidates, &profile, now).unwrap();
    let shuffled = vec![EntityId::new(3), EntityId::new(1), EntityId::new(2)];
    let r2 = executor.score(&shuffled, &profile, now).unwrap();

    let order1: Vec<u64> = r1.iter().map(|c| c.entity_id.as_u64()).collect();
    let order2: Vec<u64> = r2.iter().map(|c| c.entity_id.as_u64()).collect();
    assert_eq!(
        order1, order2,
        "prefix-colliding titles must order deterministically regardless of input order"
    );
    // All three survive with distinct positions (no collapse to a single tie).
    assert_eq!(r1.len(), 3);
    let mut sorted = order1;
    sorted.sort_unstable();
    sorted.dedup();
    assert_eq!(
        sorted.len(),
        3,
        "prefix-colliding titles must keep distinct keys"
    );
}

#[test]
fn sort_shortest_first() {
    let ledger = test_ledger();
    let mut meta: HashMap<u64, HashMap<String, String>> = HashMap::new();
    meta.insert(1, [("duration".into(), "120".into())].into());
    meta.insert(2, [("duration".into(), "60".into())].into());
    meta.insert(3, [("duration".into(), "300".into())].into());

    let executor = ProfileExecutor::new(&ledger).with_item_metadata(&meta);
    let candidates: Vec<EntityId> = (1..=3).map(EntityId::new).collect();
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000);
    let profile = make_profile(Sort::Shortest);
    let result = executor.score(&candidates, &profile, now).unwrap();

    assert_eq!(result[0].entity_id, EntityId::new(2)); // 60s
    assert_eq!(result[1].entity_id, EntityId::new(1)); // 120s
    assert_eq!(result[2].entity_id, EntityId::new(3)); // 300s
}

#[test]
fn sort_longest_first() {
    let ledger = test_ledger();
    let mut meta: HashMap<u64, HashMap<String, String>> = HashMap::new();
    meta.insert(1, [("duration".into(), "120".into())].into());
    meta.insert(2, [("duration".into(), "60".into())].into());
    meta.insert(3, [("duration".into(), "300".into())].into());

    let executor = ProfileExecutor::new(&ledger).with_item_metadata(&meta);
    let candidates: Vec<EntityId> = (1..=3).map(EntityId::new).collect();
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000);
    let profile = make_profile(Sort::Longest);
    let result = executor.score(&candidates, &profile, now).unwrap();

    assert_eq!(result[0].entity_id, EntityId::new(3)); // 300s
    assert_eq!(result[1].entity_id, EntityId::new(1)); // 120s
    assert_eq!(result[2].entity_id, EntityId::new(2)); // 60s
}

#[test]
fn sort_missing_duration_last() {
    let ledger = test_ledger();
    let mut meta: HashMap<u64, HashMap<String, String>> = HashMap::new();
    meta.insert(1, [("duration".into(), "120".into())].into());
    meta.insert(2, HashMap::new()); // no duration
    meta.insert(3, [("duration".into(), "60".into())].into());

    let executor = ProfileExecutor::new(&ledger).with_item_metadata(&meta);
    let candidates: Vec<EntityId> = (1..=3).map(EntityId::new).collect();
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000);
    let profile = make_profile(Sort::Shortest);
    let result = executor.score(&candidates, &profile, now).unwrap();

    // Entity 2 (no duration) should be last.
    assert_eq!(result[2].entity_id, EntityId::new(2));

    // Regression (Accuracy-W): the normalized score must stay monotonic with
    // rank. `Shortest` scores present items as `-duration` (negative), and the
    // missing item used to be clamped to `0.0` *before* the min/max range was
    // taken -- which made it the maximum on the negated scale and let the
    // last-ranked item report the highest score `1.0`. With the fix, the
    // missing-metadata sentinel folds to the bottom of the output range, so the
    // last result never outscores the first.
    assert!(
        result.last().unwrap().score <= result.first().unwrap().score,
        "last-ranked score {} must not exceed first-ranked score {}",
        result.last().unwrap().score,
        result.first().unwrap().score
    );
    // Scores are monotonically non-increasing across the whole result, including
    // the missing item at the tail.
    for w in result.windows(2) {
        assert!(
            w[0].score >= w[1].score,
            "scores not monotonic with rank: {} then {}",
            w[0].score,
            w[1].score
        );
    }
    // The present items keep their relative order: the 60s item (shortest) is
    // first and outscores the 120s item.
    assert_eq!(result[0].entity_id, EntityId::new(3)); // 60s
    assert_eq!(result[1].entity_id, EntityId::new(1)); // 120s
    assert!(result[0].score >= result[1].score);
    // The missing item folds to the bottom of the range.
    assert_eq!(
        result[2].score, 0.0,
        "missing-duration item must report the bottom score 0.0, not 1.0"
    );
}

#[test]
fn sort_longest_missing_duration_score_monotonic_with_rank() {
    // Sibling of `sort_missing_duration_last` for `Longest`: present items score
    // `+duration` (positive), the missing item is `NEG_INFINITY`. Verify the
    // missing item ranks last AND reports the bottom normalized score, so the
    // score stays monotonic with rank across the whole class of ±Inf-sentinel
    // sorts (not just `Shortest`).
    let ledger = test_ledger();
    let mut meta: HashMap<u64, HashMap<String, String>> = HashMap::new();
    meta.insert(1, [("duration".into(), "120".into())].into());
    meta.insert(2, HashMap::new()); // no duration
    meta.insert(3, [("duration".into(), "300".into())].into());

    let executor = ProfileExecutor::new(&ledger).with_item_metadata(&meta);
    let candidates: Vec<EntityId> = (1..=3).map(EntityId::new).collect();
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000);
    let profile = make_profile(Sort::Longest);
    let result = executor.score(&candidates, &profile, now).unwrap();

    assert_eq!(result[0].entity_id, EntityId::new(3)); // 300s (longest)
    assert_eq!(result[1].entity_id, EntityId::new(1)); // 120s
    assert_eq!(result[2].entity_id, EntityId::new(2)); // missing -> last
    assert!(result.last().unwrap().score <= result.first().unwrap().score);
    for w in result.windows(2) {
        assert!(w[0].score >= w[1].score);
    }
    assert_eq!(result[2].score, 0.0);
}

#[test]
fn sort_most_commented() {
    let ledger = ledger_for(&["view", "share", "like", "comment"]);
    let now = Timestamp::now();
    // Entity 1: 3 comments, Entity 2: 1 comment, Entity 3: 5 comments
    for _ in 0..3 {
        ledger
            .record_signal("comment", EntityId::new(1), 1.0, now)
            .unwrap();
    }
    ledger
        .record_signal("comment", EntityId::new(2), 1.0, now)
        .unwrap();
    for _ in 0..5 {
        ledger
            .record_signal("comment", EntityId::new(3), 1.0, now)
            .unwrap();
    }

    let executor = ProfileExecutor::new(&ledger);
    let candidates: Vec<EntityId> = (1..=3).map(EntityId::new).collect();
    // Use AllTime to avoid wall-clock-relative bucket staleness.
    let profile = make_profile(Sort::MostCommented {
        window: Window::AllTime,
    });
    let result = executor.score(&candidates, &profile, now).unwrap();

    assert_eq!(result[0].entity_id, EntityId::new(3)); // 5 comments
    assert_eq!(result[1].entity_id, EntityId::new(1)); // 3 comments
    assert_eq!(result[2].entity_id, EntityId::new(2)); // 1 comment
}

#[test]
fn sort_most_shared() {
    let ledger = ledger_for(&["view", "share", "like"]);
    let now = Timestamp::now();
    // Entity 1: 1 share, Entity 2: 4 shares, Entity 3: 7 shares
    for _ in 0..1 {
        ledger
            .record_signal("share", EntityId::new(1), 1.0, now)
            .unwrap();
    }
    for _ in 0..4 {
        ledger
            .record_signal("share", EntityId::new(2), 1.0, now)
            .unwrap();
    }
    for _ in 0..7 {
        ledger
            .record_signal("share", EntityId::new(3), 1.0, now)
            .unwrap();
    }

    let executor = ProfileExecutor::new(&ledger);
    let candidates: Vec<EntityId> = (1..=3).map(EntityId::new).collect();
    // Use AllTime to avoid wall-clock-relative bucket staleness.
    let profile = make_profile(Sort::MostShared {
        window: Window::AllTime,
    });
    let result = executor.score(&candidates, &profile, now).unwrap();

    // Entity 3 has 7 shares (highest). Should be first.
    assert_eq!(result[0].entity_id, EntityId::new(3));
    assert_eq!(result[1].entity_id, EntityId::new(2));
    assert_eq!(result[2].entity_id, EntityId::new(1));
}

#[test]
fn sort_live_viewer_count() {
    // Needs a short (300s) half-life on `viewer_count` so decay scores stay live,
    // which the uniform 7-day `ledger_for` fixture cannot express -- built inline.
    use std::time::Duration;

    use crate::{
        schema::{DecaySpec, EntityKind, SchemaBuilder},
        signals::NoopWalWriter,
    };

    let mut builder = SchemaBuilder::new();
    let _ = builder
        .signal(
            "viewer_count",
            EntityKind::Item,
            DecaySpec::Exponential {
                half_life: Duration::from_secs(300),
            },
        )
        .windows(&[Window::AllTime])
        .velocity(false)
        .add();
    // Need at least one more signal for the builder.
    let _ = builder
        .signal(
            "view",
            EntityKind::Item,
            DecaySpec::Exponential {
                half_life: Duration::from_secs(3600),
            },
        )
        .windows(&[Window::OneHour])
        .velocity(false)
        .add();
    let schema = builder.build().unwrap();
    let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));

    // Use current time so decay scores are still live (5-min half-life).
    let now = Timestamp::now();
    // Entity 1: 10 viewers, Entity 2: 50 viewers, Entity 3: 5 viewers
    for _ in 0..10 {
        ledger
            .record_signal("viewer_count", EntityId::new(1), 1.0, now)
            .unwrap();
    }
    for _ in 0..50 {
        ledger
            .record_signal("viewer_count", EntityId::new(2), 1.0, now)
            .unwrap();
    }
    for _ in 0..5 {
        ledger
            .record_signal("viewer_count", EntityId::new(3), 1.0, now)
            .unwrap();
    }

    let executor = ProfileExecutor::new(&ledger);
    let candidates: Vec<EntityId> = (1..=3).map(EntityId::new).collect();
    let profile = make_profile(Sort::LiveViewerCount);
    let result = executor.score(&candidates, &profile, now).unwrap();

    assert_eq!(result[0].entity_id, EntityId::new(2)); // 50 viewers
    assert_eq!(result[1].entity_id, EntityId::new(1)); // 10 viewers
    assert_eq!(result[2].entity_id, EntityId::new(3)); // 5 viewers
}

#[test]
fn sort_date_saved_latest_first() {
    let ledger = test_ledger();
    let user_state = crate::entities::UserStateIndex::new();
    user_state.add_save_timestamped(42, 1, 1000);
    user_state.add_save_timestamped(42, 2, 3000);
    user_state.add_save_timestamped(42, 3, 2000);

    let executor = ProfileExecutor::new(&ledger).with_date_saved_context(&user_state, 42);
    let candidates: Vec<EntityId> = (1..=3).map(EntityId::new).collect();
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000);
    let profile = make_profile(Sort::DateSaved);
    let result = executor.score(&candidates, &profile, now).unwrap();

    assert_eq!(result[0].entity_id, EntityId::new(2)); // saved at 3000 (latest)
    assert_eq!(result[1].entity_id, EntityId::new(3)); // saved at 2000
    assert_eq!(result[2].entity_id, EntityId::new(1)); // saved at 1000
}

#[test]
fn sort_controversial_balanced_outranks_one_sided() {
    // controversial_score = (like*dislike)/(like+dislike)^2 peaks at like==dislike.
    let ledger = ledger_for(&["like", "dislike", "view"]);
    let now = Timestamp::now();
    // Entity 1: 50/50 split (most controversial). Entity 2: heavily one-sided.
    // Entity 3: mild split.
    for _ in 0..50 {
        ledger
            .record_signal("like", EntityId::new(1), 1.0, now)
            .unwrap();
        ledger
            .record_signal("dislike", EntityId::new(1), 1.0, now)
            .unwrap();
    }
    for _ in 0..95 {
        ledger
            .record_signal("like", EntityId::new(2), 1.0, now)
            .unwrap();
    }
    for _ in 0..5 {
        ledger
            .record_signal("dislike", EntityId::new(2), 1.0, now)
            .unwrap();
    }
    for _ in 0..30 {
        ledger
            .record_signal("like", EntityId::new(3), 1.0, now)
            .unwrap();
    }
    for _ in 0..70 {
        ledger
            .record_signal("dislike", EntityId::new(3), 1.0, now)
            .unwrap();
    }

    let executor = ProfileExecutor::new(&ledger);
    let candidates: Vec<EntityId> = (1..=3).map(EntityId::new).collect();
    let result = executor
        .score(&candidates, &make_profile(Sort::Controversial), now)
        .unwrap();

    // Entity 1 (50/50) is most controversial -> ranks first.
    assert_eq!(result[0].entity_id, EntityId::new(1));
    // Entity 2 (95/5) is least controversial -> ranks last.
    assert_eq!(result[2].entity_id, EntityId::new(2));
}

#[test]
fn sort_hidden_gems_low_views_high_quality_first() {
    // hidden_gems_score = completion_decay / log10(view_count + 10).
    let ledger = ledger_for(&["completion", "view"]);
    let now = Timestamp::now();
    // Entity 1: high quality, few views (the gem).
    // Entity 2: high quality, many views (already discovered).
    // Entity 3: low quality, few views.
    for _ in 0..5 {
        ledger
            .record_signal("completion", EntityId::new(1), 1.0, now)
            .unwrap();
    }
    for _ in 0..3 {
        ledger
            .record_signal("view", EntityId::new(1), 1.0, now)
            .unwrap();
    }
    for _ in 0..5 {
        ledger
            .record_signal("completion", EntityId::new(2), 1.0, now)
            .unwrap();
    }
    for _ in 0..5000 {
        ledger
            .record_signal("view", EntityId::new(2), 1.0, now)
            .unwrap();
    }
    ledger
        .record_signal("completion", EntityId::new(3), 1.0, now)
        .unwrap();
    for _ in 0..3 {
        ledger
            .record_signal("view", EntityId::new(3), 1.0, now)
            .unwrap();
    }

    let executor = ProfileExecutor::new(&ledger);
    let candidates: Vec<EntityId> = (1..=3).map(EntityId::new).collect();
    let result = executor
        .score(&candidates, &make_profile(Sort::HiddenGems), now)
        .unwrap();

    // Entity 1 (high quality, low views) is the gem -> ranks first.
    assert_eq!(result[0].entity_id, EntityId::new(1));
    // Entity 2 (high quality but heavily viewed) is demoted below the gem.
    let pos2 = result
        .iter()
        .position(|c| c.entity_id == EntityId::new(2))
        .unwrap();
    let pos1 = result
        .iter()
        .position(|c| c.entity_id == EntityId::new(1))
        .unwrap();
    assert!(pos1 < pos2, "low-view gem must outrank the high-view item");
}

#[test]
fn sort_rising_accelerating_item_first() {
    // Rising = view_velocity(1h) / view_velocity(24h). The ratio reduces to
    // `24 * (fraction of the day's activity that landed in the last hour)`, so an
    // item whose engagement is *concentrated* in the last hour outranks one whose
    // equal-or-larger engagement is spread across the day. Rising reads only
    // windowed counts (no wall-clock decay), so fixed timestamps make it fully
    // deterministic. The 12h gap rotates the old tranche out of the 1h (minute)
    // window while keeping it inside the 24h window.
    let ledger = ledger_for(&["view"]);
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000);
    let twelve_hours_ago = Timestamp::from_nanos(now.as_nanos() - 12 * 3_600_000_000_000);

    // Entity 1: 0 old, 60 recent -> all in last hour -> ratio = 24.
    for _ in 0..60 {
        ledger
            .record_signal("view", EntityId::new(1), 1.0, now)
            .unwrap();
    }
    // Entity 2: 55 old (12h ago), 5 recent -> ratio = 24 * 5/60 = 2.
    for _ in 0..55 {
        ledger
            .record_signal("view", EntityId::new(2), 1.0, twelve_hours_ago)
            .unwrap();
    }
    for _ in 0..5 {
        ledger
            .record_signal("view", EntityId::new(2), 1.0, now)
            .unwrap();
    }

    let executor = ProfileExecutor::new(&ledger);
    let candidates: Vec<EntityId> = (1..=2).map(EntityId::new).collect();
    let result = executor
        .score(&candidates, &make_profile(Sort::Rising), now)
        .unwrap();

    // Entity 1's activity is fully concentrated in the last hour -> higher ratio.
    assert_eq!(
        result[0].entity_id,
        EntityId::new(1),
        "the item whose engagement is concentrated in the last hour should rank first under Rising"
    );
}

#[test]
fn sort_top_window_orders_by_weighted_engagement() {
    // TopWindow = view*0.3 + like*0.3 + share*0.2 + completion*view*0.1, all
    // counts within the requested window. Higher total engagement -> higher rank.
    let ledger = ledger_for(&["view", "like", "share", "completion"]);
    let now = Timestamp::now();
    // Entity 1: high across the board. Entity 2: low. Entity 3: medium.
    for _ in 0..100 {
        ledger
            .record_signal("view", EntityId::new(1), 1.0, now)
            .unwrap();
        ledger
            .record_signal("like", EntityId::new(1), 1.0, now)
            .unwrap();
    }
    for _ in 0..2 {
        ledger
            .record_signal("view", EntityId::new(2), 1.0, now)
            .unwrap();
    }
    for _ in 0..40 {
        ledger
            .record_signal("view", EntityId::new(3), 1.0, now)
            .unwrap();
        ledger
            .record_signal("like", EntityId::new(3), 1.0, now)
            .unwrap();
    }

    let executor = ProfileExecutor::new(&ledger);
    let candidates: Vec<EntityId> = (1..=3).map(EntityId::new).collect();
    let profile = make_profile(Sort::TopWindow {
        window: Window::AllTime,
    });
    let result = executor.score(&candidates, &profile, now).unwrap();

    assert_eq!(result[0].entity_id, EntityId::new(1)); // highest engagement
    assert_eq!(result[1].entity_id, EntityId::new(3)); // medium
    assert_eq!(result[2].entity_id, EntityId::new(2)); // lowest
}

#[test]
fn sort_date_saved_no_save_sorted_last() {
    let ledger = test_ledger();
    let user_state = crate::entities::UserStateIndex::new();
    user_state.add_save_timestamped(42, 1, 1000);
    // Entity 2 has no save.
    user_state.add_save_timestamped(42, 3, 2000);

    let executor = ProfileExecutor::new(&ledger).with_date_saved_context(&user_state, 42);
    let candidates: Vec<EntityId> = (1..=3).map(EntityId::new).collect();
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000);
    let profile = make_profile(Sort::DateSaved);
    let result = executor.score(&candidates, &profile, now).unwrap();

    // Entity 2 (no save timestamp) should be last.
    assert_eq!(result[2].entity_id, EntityId::new(2));
    // Sibling of `sort_missing_duration_last` for the `DateSaved` ±Inf sentinel:
    // the missing item folds to the bottom of the range and stays monotonic with
    // rank rather than reporting the spurious maximum `1.0`.
    assert!(result.last().unwrap().score <= result.first().unwrap().score);
    for w in result.windows(2) {
        assert!(w[0].score >= w[1].score);
    }
    assert_eq!(result[2].score, 0.0);
}
