use super::*;
use crate::{
    ranking::{
        builtins::register_builtins,
        profile::{Boost, Exclude, Gate, Penalty, ProfileDecay, SignalAgg, Sort},
        registry::ProfileRegistry,
        // Shared fixtures (extracted to de-duplicate the per-module ledger/profile
        // builders across the five ranking test modules; DRY-S, M0-M10 review).
        test_fixtures::{ledger_for, profile_with, seeded_ledger},
    },
    schema::EntityId,
    signals::SignalLedger,
};

/// Re-export the canonical seeded ledger under the historical `test_ledger` name
/// so `sort_tests::use super::test_ledger` keeps resolving without churn.
fn test_ledger() -> SignalLedger {
    seeded_ledger()
}

#[test]
fn score_empty_candidates_returns_empty() {
    let ledger = test_ledger();
    let executor = ProfileExecutor::new(&ledger);
    let profile = profile_with("test", |p| p.sort = Some(Sort::Trending));
    let result = executor
        .score(
            &[],
            &profile,
            Timestamp::from_nanos(1_708_000_000_000_000_000),
        )
        .unwrap();
    assert!(result.is_empty());
}

#[test]
fn score_with_trending_profile() {
    let ledger = test_ledger();
    let mut registry = ProfileRegistry::new();
    register_builtins(&mut registry).unwrap();
    let profile = registry.get("trending").unwrap().clone();
    let executor = ProfileExecutor::new(&ledger);
    let candidates: Vec<EntityId> = (1..=5).map(EntityId::new).collect();
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000);

    let result = executor.score(&candidates, &profile, now).unwrap();
    // May be fewer than 5 due to gates (engagement_ratio gate will filter
    // candidates without that signal). But should not panic.
    assert!(result.len() <= 5);
    for c in &result {
        assert!((0.0..=1.0).contains(&c.score));
    }
}

#[test]
fn score_with_hot_profile() {
    let ledger = test_ledger();
    let mut registry = ProfileRegistry::new();
    register_builtins(&mut registry).unwrap();
    let profile = registry.get("hot").unwrap().clone();
    let executor = ProfileExecutor::new(&ledger);
    let candidates: Vec<EntityId> = (1..=5).map(EntityId::new).collect();
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000);

    let result = executor.score(&candidates, &profile, now).unwrap();
    assert!(result.len() <= 5);
    for c in &result {
        assert!((0.0..=1.0).contains(&c.score));
    }
}

#[test]
fn score_normalization_all_equal() {
    let ledger = test_ledger();
    let executor = ProfileExecutor::new(&ledger);
    // Use a profile with no sort and no boosts -- all candidates get 0.0
    // raw score, a degenerate all-equal set. Per spec §8.2 these normalize to
    // the neutral midpoint 0.5 ("no signal differentiates these"), NOT the
    // maximally-confident 1.0 (M0-M10 review pass 2, ranking SUGGESTION).
    let profile = profile_with("test_equal", |_| {});
    let candidates: Vec<EntityId> = (1..=3).map(EntityId::new).collect();
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000);

    let result = executor.score(&candidates, &profile, now).unwrap();
    assert_eq!(result.len(), 3);
    for c in &result {
        assert_eq!(c.score, 0.5);
    }
}

#[test]
fn score_gate_filters_candidates() {
    let ledger = test_ledger();
    let executor = ProfileExecutor::new(&ledger);
    let profile = profile_with("test_gated", |p| {
        p.gates = vec![Gate {
            signal: "view".into(),
            agg: SignalAgg::Value,
            window: Window::AllTime,
            // Threshold higher than any recorded count -- all filtered.
            min_threshold: f64::MAX,
        }];
        p.sort = Some(Sort::Trending);
    });
    let candidates: Vec<EntityId> = (1..=5).map(EntityId::new).collect();
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000);

    let result = executor.score(&candidates, &profile, now).unwrap();
    assert!(result.is_empty());
}

#[test]
fn score_gate_on_missing_signal_errors_loudly() {
    // A gate referencing a signal the schema does not define is a
    // misconfigured profile: `score` must fail loudly with a schema error
    // rather than silently scoring 0.0 and filtering every candidate. The schema
    // defines only "view", so "nonexistent_signal" is genuinely absent.
    let ledger = ledger_for(&["view"]);
    let executor = ProfileExecutor::new(&ledger);

    let candidates: Vec<EntityId> = (1..=5).map(EntityId::new).collect();
    let profile = profile_with("test_gate", |p| {
        p.gates = vec![Gate {
            signal: "nonexistent_signal".into(),
            agg: SignalAgg::Value,
            window: Window::AllTime,
            min_threshold: 1.0,
        }];
        p.sort = Some(Sort::New);
    });
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000u64);
    let result = executor.score(&candidates, &profile, now);
    assert!(
        matches!(result, Err(crate::TidalError::Schema(_))),
        "a gate on an unknown signal must surface a schema error, got {result:?}"
    );
}

#[test]
fn score_nan_does_not_propagate() {
    // Verify that all result scores are finite after scoring.
    // The NaN guard in normalize() prevents non-finite scores from
    // leaking to callers.
    let ledger = test_ledger();
    let executor = ProfileExecutor::new(&ledger);
    let candidates: Vec<EntityId> = (1..=5).map(EntityId::new).collect();
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000u64);

    // Test with Trending sort (exercises velocity reads).
    let profile = profile_with("test_nan", |p| p.sort = Some(Sort::Trending));
    let result = executor.score(&candidates, &profile, now).unwrap();
    for c in &result {
        assert!(
            c.score.is_finite(),
            "score {} for entity {} is not finite",
            c.score,
            c.entity_id.as_u64()
        );
    }
}

#[test]
fn score_new_ranks_higher_ids_first() {
    let ledger = test_ledger();
    let mut registry = ProfileRegistry::new();
    register_builtins(&mut registry).unwrap();
    let profile = registry.get("new").unwrap().clone();
    let executor = ProfileExecutor::new(&ledger);
    // Sort::New scores by entity_id descending (higher ID = more recent proxy).
    let candidates = vec![EntityId::new(1), EntityId::new(5), EntityId::new(10)];
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000);
    let result = executor.score(&candidates, &profile, now).unwrap();
    assert_eq!(result.len(), 3);
    assert_eq!(result[0].entity_id, EntityId::new(10));
    assert_eq!(result[1].entity_id, EntityId::new(5));
    assert_eq!(result[2].entity_id, EntityId::new(1));
}

// -- Personalized scoring tests -------------------------------------------

#[test]
fn score_personalized_boosts_interacted_creators() {
    // Items with creator interaction boosts should rank higher than those without.
    let ledger = test_ledger();
    let executor = ProfileExecutor::new(&ledger);

    // All candidates get the same base score (no sort, no boosts).
    let profile = profile_with("test_personalized", |_| {});

    let candidates: Vec<EntityId> = (1..=5).map(EntityId::new).collect();
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000);

    // Entity 3 gets a boost (from an interacted creator).
    let mut boosts = HashMap::new();
    boosts.insert(3u32, 1.0); // max normalized weight

    let user_ctx = UserContext {
        user_id: 42,
        creator_interaction_boosts: boosts,
        preference_boosts: HashMap::new(),
    };

    let result = executor
        .score_personalized(
            &candidates,
            &profile,
            now,
            None,
            &user_ctx,
            &HashMap::new(),
            None,
        )
        .unwrap();
    assert_eq!(result.len(), 5);

    // Entity 3 should be ranked first because it has an interaction boost
    // while all others have the same base score (0.0).
    assert_eq!(
        result[0].entity_id,
        EntityId::new(3),
        "entity with interaction boost should rank first"
    );
    // The boosted entity gets score 1.0 (highest after normalization).
    assert!(
        result[0].score > result[1].score,
        "boosted entity should have higher score"
    );
}

#[test]
fn score_personalized_applies_preference_boost() {
    // A candidate with a positive preference cosine should rank above one with a
    // negative cosine, proving the preference_boosts map is consumed (not a no-op).
    let ledger = test_ledger();
    let executor = ProfileExecutor::new(&ledger);

    let profile = profile_with("test_preference", |_| {});

    let candidates: Vec<EntityId> = (1..=3).map(EntityId::new).collect();
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000);

    // Entity 2 strongly aligned with the preference vector; entity 3 opposed.
    let mut preference_boosts = HashMap::new();
    // Keyed by the full u64 entity id (no truncation): regression for the
    // preference-boost aliasing finding (M0-M10 review pass 2).
    preference_boosts.insert(2u64, 0.9);
    preference_boosts.insert(3u64, -0.9);

    let user_ctx = UserContext {
        user_id: 7,
        creator_interaction_boosts: HashMap::new(),
        preference_boosts,
    };

    let result = executor
        .score_personalized(
            &candidates,
            &profile,
            now,
            None,
            &user_ctx,
            &HashMap::new(),
            None,
        )
        .unwrap();
    assert_eq!(result.len(), 3);

    // Entity 2 (positive cosine) ranks first; entity 3 (negative cosine) last.
    assert_eq!(
        result[0].entity_id,
        EntityId::new(2),
        "candidate aligned with preference vector should rank first"
    );
    assert_eq!(
        result[2].entity_id,
        EntityId::new(3),
        "candidate opposed to preference vector should rank last"
    );
    assert!(
        result[0].score > result[2].score,
        "preference-aligned candidate must outscore the opposed one"
    );
    // The boost is recorded in the explainability snapshot.
    assert!(
        result[0]
            .signal_snapshot
            .iter()
            .any(|(k, _)| k == "preference_affinity"),
        "preference boost should appear in the signal snapshot"
    );
}

#[test]
fn score_personalized_without_boosts_matches_base() {
    // An empty UserContext should produce the same ranking as score().
    let ledger = test_ledger();
    let executor = ProfileExecutor::new(&ledger);

    let profile = profile_with("test_no_boost", |p| p.sort = Some(Sort::New));

    let candidates: Vec<EntityId> = (1..=5).map(EntityId::new).collect();
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000);

    let user_ctx = UserContext {
        user_id: 42,
        creator_interaction_boosts: HashMap::new(),
        preference_boosts: HashMap::new(),
    };

    let base = executor.score(&candidates, &profile, now).unwrap();
    let personalized = executor
        .score_personalized(
            &candidates,
            &profile,
            now,
            None,
            &user_ctx,
            &HashMap::new(),
            None,
        )
        .unwrap();

    assert_eq!(base.len(), personalized.len());
    for (b, p) in base.iter().zip(personalized.iter()) {
        assert_eq!(
            b.entity_id, p.entity_id,
            "ranking order should match with empty user context"
        );
        assert!(
            (b.score - p.score).abs() < f64::EPSILON,
            "scores should match: base={}, personalized={}",
            b.score,
            p.score
        );
    }
}

/// SCORE-3: `finite_score` neutralizes `NaN` (which has no defined order and
/// would scramble the sort) while preserving the `±Inf` "sort last / first"
/// sentinels that `total_cmp` orders correctly.
#[test]
fn finite_score_neutralizes_nan_preserves_infinities() {
    assert_eq!(finite_score(f64::NAN), 0.0, "NaN must collapse to 0.0");
    assert_eq!(finite_score(0.0 * f64::INFINITY), 0.0, "0*Inf NaN -> 0.0");
    assert_eq!(
        finite_score(f64::INFINITY - f64::INFINITY),
        0.0,
        "Inf-Inf NaN -> 0.0"
    );
    // Infinities pass through unchanged: total_cmp orders them, normalize folds them.
    assert_eq!(finite_score(f64::NEG_INFINITY), f64::NEG_INFINITY);
    assert_eq!(finite_score(f64::INFINITY), f64::INFINITY);
    // Finite values are untouched.
    assert_eq!(finite_score(-12.5), -12.5);
    assert_eq!(finite_score(0.0), 0.0);
}

/// SCORE-3 end-to-end: a `NaN`-producing interaction boost must not scramble
/// the ranking. The old `partial_cmp(...).unwrap_or(Equal)` sort treated every
/// comparison against a `NaN` score as "equal", which could leave a high-real-
/// score candidate stranded behind a `NaN` one. With NaN neutralized at
/// construction and a `total_cmp` sort, the result is a clean total order.
#[test]
fn nan_interaction_boost_does_not_scramble_ranking() {
    let ledger = test_ledger();
    let executor = ProfileExecutor::new(&ledger);

    // `New` scores entity-id descending, so without any boost the order is 5,4,3,2,1.
    let profile = profile_with("nan_boost", |p| p.sort = Some(Sort::New));

    let candidates: Vec<EntityId> = (1..=5).map(EntityId::new).collect();
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000);

    // Inject a NaN boost for entity 3 (weight * INTERACTION_BOOST_WEIGHT = NaN).
    let mut boosts = HashMap::new();
    boosts.insert(3u32, f64::NAN);
    let user_ctx = UserContext {
        user_id: 1,
        creator_interaction_boosts: boosts,
        preference_boosts: HashMap::new(),
    };

    let result = executor
        .score_personalized(
            &candidates,
            &profile,
            now,
            None,
            &user_ctx,
            &HashMap::new(),
            None,
        )
        .unwrap();

    // Every candidate survives, every score is finite, and the order is a clean
    // total order (descending) -- no NaN stranded a high-score candidate.
    assert_eq!(result.len(), 5);
    for c in &result {
        assert!(
            c.score.is_finite(),
            "score for {:?} must be finite after NaN neutralization, got {}",
            c.entity_id,
            c.score
        );
    }
    for w in result.windows(2) {
        assert!(
            w[0].score >= w[1].score,
            "ranking must be descending (total order): {} then {}",
            w[0].score,
            w[1].score
        );
    }
}

// -- Penalty / exclude / decay tests (BLOCKER: negative signals are first-class) --
// (`ledger_for` and `profile_with` now come from `ranking::test_fixtures`.)

#[test]
fn penalty_reduces_score_for_skipped_item() {
    // A `Penalty` on the "skip" signal must demote an item that accumulates skips
    // below an otherwise-identical item with none. Without Stage 5 this is inert.
    let ledger = ledger_for(&["view", "skip"]);
    let now = Timestamp::now();
    // Both items have equal views; only entity 1 is skipped.
    for _ in 0..10 {
        ledger
            .record_signal("view", EntityId::new(1), 1.0, now)
            .unwrap();
        ledger
            .record_signal("view", EntityId::new(2), 1.0, now)
            .unwrap();
    }
    for _ in 0..8 {
        ledger
            .record_signal("skip", EntityId::new(1), 1.0, now)
            .unwrap();
    }

    let executor = ProfileExecutor::new(&ledger);
    let candidates = vec![EntityId::new(1), EntityId::new(2)];

    // Profile: boost view, penalize skip (positive weight, applied as -weight).
    let profile = profile_with("penalized", |p| {
        p.boosts = vec![Boost {
            signal: "view".into(),
            agg: SignalAgg::Value,
            window: Window::AllTime,
            weight: 1.0,
        }];
        p.penalties = vec![Penalty {
            signal: "skip".into(),
            agg: SignalAgg::Value,
            window: Window::AllTime,
            weight: 1.0,
        }];
    });

    let result = executor.score(&candidates, &profile, now).unwrap();
    assert_eq!(result.len(), 2);
    // Entity 2 (no skips) must rank above entity 1 (skipped), proving the penalty
    // subtracted from entity 1's score.
    assert_eq!(
        result[0].entity_id,
        EntityId::new(2),
        "the un-skipped item must outrank the skipped one"
    );
    assert!(result[0].score > result[1].score);
    // The penalty appears in the loser's explainability snapshot (negative value).
    let penalized = result
        .iter()
        .find(|c| c.entity_id == EntityId::new(1))
        .unwrap();
    assert!(
        penalized
            .signal_snapshot
            .iter()
            .any(|(k, v)| k == "skip_penalty" && *v < 0.0),
        "the penalty must be recorded as a negative snapshot entry"
    );
}

#[test]
fn exclude_removes_hidden_candidate() {
    // An `Exclude` on "hide" with `above: 0.0` must hard-remove any item the user
    // has hidden at all. Without Stage 2 this is inert and hidden content leaks.
    let ledger = ledger_for(&["view", "hide"]);
    let now = Timestamp::now();
    for id in 1..=3 {
        for _ in 0..5 {
            ledger
                .record_signal("view", EntityId::new(id), 1.0, now)
                .unwrap();
        }
    }
    // Entity 2 is hidden.
    ledger
        .record_signal("hide", EntityId::new(2), 1.0, now)
        .unwrap();

    let executor = ProfileExecutor::new(&ledger);
    let candidates: Vec<EntityId> = (1..=3).map(EntityId::new).collect();

    let profile = profile_with("excluded", |p| {
        p.excludes = vec![Exclude {
            signal: "hide".into(),
            agg: SignalAgg::Value,
            window: Window::AllTime,
            above: 0.0,
        }];
    });

    let result = executor.score(&candidates, &profile, now).unwrap();
    // Entity 2 is gone; the other two survive.
    assert_eq!(result.len(), 2);
    assert!(
        result.iter().all(|c| c.entity_id != EntityId::new(2)),
        "a hidden item must be hard-excluded from the result set"
    );
}

#[test]
fn exclude_at_threshold_keeps_candidate() {
    // `above` is a strict upper bound: a count exactly equal to `above` survives,
    // a count strictly greater is removed (mirrors the > comparison).
    let ledger = ledger_for(&["view", "report"]);
    let now = Timestamp::now();
    for id in 1..=2 {
        ledger
            .record_signal("view", EntityId::new(id), 1.0, now)
            .unwrap();
    }
    // Entity 1: exactly 2 reports (== above). Entity 2: 3 reports (> above).
    for _ in 0..2 {
        ledger
            .record_signal("report", EntityId::new(1), 1.0, now)
            .unwrap();
    }
    for _ in 0..3 {
        ledger
            .record_signal("report", EntityId::new(2), 1.0, now)
            .unwrap();
    }

    let executor = ProfileExecutor::new(&ledger);
    let candidates = vec![EntityId::new(1), EntityId::new(2)];
    let profile = profile_with("report_exclude", |p| {
        p.excludes = vec![Exclude {
            signal: "report".into(),
            agg: SignalAgg::Value,
            window: Window::AllTime,
            above: 2.0,
        }];
    });

    let result = executor.score(&candidates, &profile, now).unwrap();
    assert_eq!(result.len(), 1);
    assert_eq!(
        result[0].entity_id,
        EntityId::new(1),
        "count == above survives; count > above is removed"
    );
}

#[test]
fn profile_decay_demotes_aged_item() {
    // `ProfileDecay` scales the composite score by the named signal's running
    // decay score. An item whose decay signal last fired long ago has a near-zero
    // decay score and is demoted below a freshly-active item with equal base.
    //
    // `read_decay_score` decays forward to wall-clock now, so we record entity 1's
    // freshness signal far in the past and entity 2's at now. A short half-life
    // makes the gap decisive and deterministic.
    //
    // This test needs two signals with *different* half-lives (7-day `view`,
    // 60-second `freshness`), which the shared `ledger_for` fixture (uniform 7-day
    // half-life) cannot express -- so it builds its schema inline.
    use std::time::Duration;

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

    let mut builder = SchemaBuilder::new();
    let _ = builder
        .signal(
            "view",
            EntityKind::Item,
            DecaySpec::Exponential {
                half_life: Duration::from_secs(7 * 24 * 3600),
            },
        )
        .windows(&[Window::AllTime])
        .velocity(false)
        .add();
    let _ = builder
        .signal(
            "freshness",
            EntityKind::Item,
            // 60-second half-life: a signal from an hour ago has decayed to ~0.
            DecaySpec::Exponential {
                half_life: Duration::from_secs(60),
            },
        )
        .windows(&[Window::AllTime])
        .velocity(false)
        .add();
    let schema = builder.build().unwrap();
    let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));

    let now = Timestamp::now();
    // Equal base via equal views.
    for id in 1..=2 {
        for _ in 0..10 {
            ledger
                .record_signal("view", EntityId::new(id), 1.0, now)
                .unwrap();
        }
    }
    // Entity 1's freshness fired one hour ago (60 half-lives -> ~0 decay score).
    let one_hour_ago = Timestamp::from_nanos(now.as_nanos().saturating_sub(3_600_000_000_000));
    ledger
        .record_signal("freshness", EntityId::new(1), 1.0, one_hour_ago)
        .unwrap();
    // Entity 2's freshness fired now (full decay score).
    ledger
        .record_signal("freshness", EntityId::new(2), 1.0, now)
        .unwrap();

    let executor = ProfileExecutor::new(&ledger);
    let candidates = vec![EntityId::new(1), EntityId::new(2)];
    let profile = profile_with("decayed", |p| {
        p.boosts = vec![Boost {
            signal: "view".into(),
            agg: SignalAgg::Value,
            window: Window::AllTime,
            weight: 1.0,
        }];
        p.decay = Some(ProfileDecay {
            signal: "freshness".into(),
            half_life_secs: 60,
            weight: 1.0,
        });
    });

    let result = executor.score(&candidates, &profile, now).unwrap();
    assert_eq!(result.len(), 2);
    // The fresh item (entity 2) outranks the aged one (entity 1) despite equal base.
    assert_eq!(
        result[0].entity_id,
        EntityId::new(2),
        "the freshly-active item must outrank the aged one under ProfileDecay"
    );
    assert!(result[0].score > result[1].score);
}

#[test]
fn profile_decay_weight_zero_is_noop() {
    // With `weight: 0.0` the decay factor collapses to 1.0 (no scaling), so the
    // ranking matches the same profile without decay. Proves the weight blend.
    let ledger = ledger_for(&["view", "freshness"]);
    let now = Timestamp::now();
    for _ in 0..5 {
        ledger
            .record_signal("view", EntityId::new(1), 1.0, now)
            .unwrap();
    }
    for _ in 0..3 {
        ledger
            .record_signal("view", EntityId::new(2), 1.0, now)
            .unwrap();
    }
    ledger
        .record_signal("freshness", EntityId::new(2), 1.0, now)
        .unwrap();

    let executor = ProfileExecutor::new(&ledger);
    let candidates = vec![EntityId::new(1), EntityId::new(2)];
    let base = profile_with("base", |p| {
        p.boosts = vec![Boost {
            signal: "view".into(),
            agg: SignalAgg::Value,
            window: Window::AllTime,
            weight: 1.0,
        }];
    });
    let with_zero_decay = profile_with("zero_decay", |p| {
        p.boosts = base.boosts.clone();
        p.decay = Some(ProfileDecay {
            signal: "freshness".into(),
            half_life_secs: 60,
            weight: 0.0,
        });
    });

    let r_base = executor.score(&candidates, &base, now).unwrap();
    let r_zero = executor.score(&candidates, &with_zero_decay, now).unwrap();
    assert_eq!(r_base.len(), r_zero.len());
    for (b, z) in r_base.iter().zip(r_zero.iter()) {
        assert_eq!(b.entity_id, z.entity_id, "weight 0 decay must not reorder");
        assert!(
            (b.score - z.score).abs() < f64::EPSILON,
            "weight 0 decay must not rescale"
        );
    }
}

// -- Retrieval relevance seed test (CRITICAL: thread fused RRF score) --

#[test]
fn retrieval_scores_seed_dominates_light_boosts() {
    // The fused RRF relevance score must anchor the order: a highly-relevant but
    // lightly-signaled item must outrank a barely-relevant but heavily-liked one.
    // Mirrors the `search` profile contract (light DecayScore overlay): the fused
    // RRF order is the primary anchor; the profile's light boosts only break ties
    // among near-equal-relevance candidates.
    let ledger = ledger_for(&["view", "like"]);
    let now = Timestamp::now();
    // Entity 1: barely relevant but slightly more popular (a couple of light
    // signals). Entity 2: highly relevant, no engagement. Without the seed the
    // marginally-more-popular item wins; with it, the relevant item anchors first.
    for _ in 0..2 {
        ledger
            .record_signal("like", EntityId::new(1), 1.0, now)
            .unwrap();
        ledger
            .record_signal("view", EntityId::new(1), 1.0, now)
            .unwrap();
    }

    let executor = ProfileExecutor::new(&ledger);
    let candidates = vec![EntityId::new(1), EntityId::new(2)];
    // The `search`-style profile: light DecayScore boosts, no sort.
    let profile = profile_with("search_like", |p| {
        p.boosts = vec![
            Boost {
                signal: "view".into(),
                agg: SignalAgg::DecayScore,
                window: Window::AllTime,
                weight: 0.5,
            },
            Boost {
                signal: "like".into(),
                agg: SignalAgg::DecayScore,
                window: Window::AllTime,
                weight: 0.8,
            },
        ];
    });

    // Normalized fused relevance: entity 2 is the most relevant document.
    let mut retrieval: HashMap<u64, f64> = HashMap::new();
    retrieval.insert(1, 0.05); // barely matches
    retrieval.insert(2, 1.0); // most relevant

    // Without the seed (None): popularity wins.
    let unseeded = executor
        .score_with_session(&candidates, &profile, now, None, &HashMap::new(), None)
        .unwrap();
    assert_eq!(
        unseeded[0].entity_id,
        EntityId::new(1),
        "without a relevance seed, the popular item wins (the bug)"
    );

    // With the seed: relevance anchors the order.
    let seeded = executor
        .score_with_session(
            &candidates,
            &profile,
            now,
            None,
            &HashMap::new(),
            Some(&retrieval),
        )
        .unwrap();
    assert_eq!(
        seeded[0].entity_id,
        EntityId::new(2),
        "the fused RRF relevance seed must make the most-relevant item rank first"
    );
    // The relevance contribution is recorded for explainability.
    let top = seeded
        .iter()
        .find(|c| c.entity_id == EntityId::new(2))
        .unwrap();
    assert!(
        top.signal_snapshot.iter().any(|(k, _)| k == "relevance"),
        "the relevance seed must appear in the signal snapshot"
    );
}

// -- Query-clock determinism + sort/boost interaction regressions --
// (M0-M10 review pass 2, ranking zone C2.)

/// Accuracy-W regression: scoring must be a pure function of `(ledger snapshot,
/// now)`. Re-running the identical query against an unchanged ledger with the
/// same `now` must produce byte-identical scores. Before the fix, every
/// time-dependent read called `Timestamp::now()` internally, so two runs of the
/// same query decayed/aged each candidate to a slightly different clock and the
/// reported scores drifted between runs.
#[test]
fn scoring_is_deterministic_for_fixed_now() {
    let ledger = ledger_for(&["view", "share"]);
    // A fixed-but-recent-relative-to-the-data clock so the 24h velocity window
    // actually selects buckets (and the read is genuinely time-dependent).
    let now = Timestamp::now();
    // Spread signals over the last few hours so the velocity reads are non-zero.
    for id in 1..=4u64 {
        for i in 0..(id * 3) {
            let t = Timestamp::from_nanos(
                now.as_nanos().saturating_sub(i * 600_000_000_000), // 10-minute steps back
            );
            ledger
                .record_signal("view", EntityId::new(id), 1.0, t)
                .unwrap();
            if id % 2 == 0 {
                ledger
                    .record_signal("share", EntityId::new(id), 1.0, t)
                    .unwrap();
            }
        }
    }

    let executor = ProfileExecutor::new(&ledger);
    // Trending exercises the velocity reads (`read_velocity_at`), the most
    // clock-sensitive path.
    let profile = profile_with("determinism", |p| p.sort = Some(Sort::Trending));
    let candidates: Vec<EntityId> = (1..=4).map(EntityId::new).collect();

    let r1 = executor.score(&candidates, &profile, now).unwrap();
    let r2 = executor.score(&candidates, &profile, now).unwrap();

    assert_eq!(r1.len(), r2.len());
    for (a, b) in r1.iter().zip(r2.iter()) {
        assert_eq!(
            a.entity_id, b.entity_id,
            "the same query against an unchanged ledger must return the same order"
        );
        assert_eq!(
            a.score.to_bits(),
            b.score.to_bits(),
            "scores must be byte-identical across runs for a fixed `now` (entity {})",
            a.entity_id.as_u64()
        );
    }
}

/// Spec §11.9 regression: a formula sort replaces stages 4-5 ONLY for the boosts
/// it SUBSUMES (a boost whose signal/agg/window the formula itself reads). The
/// `trending` builtin sets `Sort::Trending` AND `view`/`share` velocity-24h
/// boosts on the same signals the formula reads (kept on the profile only as the
/// cohort-rescore definition); re-applying them on the global path would
/// double-count (`2*view_vel + 4*share_vel`), so those are skipped. A
/// COMPLEMENTARY boost/penalty (one the formula does NOT read, e.g. a `like`
/// count boost or a `skip` penalty) is an overlay and MUST still apply — this is
/// what keeps `for_you`/`following`/`related`/`notification` engagement weighting
/// alive under their sort seeds.
#[test]
fn sort_subsumes_duplicating_boost_but_keeps_complementary() {
    let ledger = ledger_for(&["view", "share", "like", "skip"]);
    let now = Timestamp::now();
    for _ in 0..5 {
        ledger
            .record_signal("view", EntityId::new(1), 1.0, now)
            .unwrap();
        ledger
            .record_signal("share", EntityId::new(1), 1.0, now)
            .unwrap();
        ledger
            .record_signal("like", EntityId::new(1), 1.0, now)
            .unwrap();
        ledger
            .record_signal("skip", EntityId::new(1), 1.0, now)
            .unwrap();
    }

    let executor = ProfileExecutor::new(&ledger);
    // Sort::Trending with: a DUPLICATING view-velocity-24h boost (subsumed by the
    // formula), a COMPLEMENTARY like-count boost (not read by Trending), and a
    // complementary skip penalty.
    let profile = profile_with("sort_overrides", |p| {
        p.sort = Some(Sort::Trending);
        p.boosts = vec![
            Boost {
                signal: "view".into(),
                agg: SignalAgg::Velocity,
                window: Window::TwentyFourHours,
                weight: 1.0,
            },
            Boost {
                signal: "like".into(),
                agg: SignalAgg::Value,
                window: Window::AllTime,
                weight: 1.0,
            },
        ];
        p.penalties = vec![Penalty {
            signal: "skip".into(),
            agg: SignalAgg::Value,
            window: Window::AllTime,
            weight: 1.0,
        }];
    });

    let scored = executor.score(&[EntityId::new(1)], &profile, now).unwrap();
    assert_eq!(scored.len(), 1);
    let snap = &scored[0].signal_snapshot;
    // The duplicating view-velocity boost is subsumed by the Trending formula.
    assert!(
        !snap.iter().any(|(k, _)| k == "view_boost"),
        "a boost duplicating the Sort::Trending formula must be subsumed, got {snap:?}"
    );
    // The complementary like-count boost and skip penalty DO apply.
    assert!(
        snap.iter().any(|(k, _)| k == "like_boost"),
        "a complementary boost the sort formula does not read must still apply, got {snap:?}"
    );
    assert!(
        snap.iter().any(|(k, _)| k == "skip_penalty"),
        "a complementary penalty must still apply under a formula sort, got {snap:?}"
    );
    // The Trending base velocity snapshot entries DO still appear (the sort formula).
    assert!(
        snap.iter().any(|(k, _)| k == "view_velocity"),
        "the Sort::Trending formula's own velocity reads must still appear, got {snap:?}"
    );
}

/// Spec §11.6 regression: shuffle is quality-weighted and per-user/per-minute
/// seeded, not a fixed global hash. A high-quality item (high completion + likes)
/// must out-score a zero-quality item often enough that quality biases the order,
/// and the same `(user, minute)` must reproduce the exact same scores.
#[test]
fn shuffle_is_quality_weighted_and_seed_stable() {
    let ledger = ledger_for(&["view", "like", "completion"]);
    let now = Timestamp::now();
    // Entity 1: high quality (many views, many likes, strong completion).
    for _ in 0..50 {
        ledger
            .record_signal("view", EntityId::new(1), 1.0, now)
            .unwrap();
        ledger
            .record_signal("like", EntityId::new(1), 1.0, now)
            .unwrap();
        ledger
            .record_signal("completion", EntityId::new(1), 1.0, now)
            .unwrap();
    }
    // Entity 2: zero quality (no signals at all) -> quality_weight == 0 -> score 0.

    let executor = ProfileExecutor::new(&ledger).with_shuffle_user(99);
    let profile = profile_with("shuffle_q", |p| p.sort = Some(Sort::Shuffle));
    let candidates = vec![EntityId::new(1), EntityId::new(2)];

    let r1 = executor.score(&candidates, &profile, now).unwrap();
    // The zero-quality item has quality_weight 0, so its raw shuffle score is 0
    // (sorted last; normalized to 0.0). The high-quality item gets a positive
    // draw*weight and normalizes to 1.0 -- quality genuinely biases the order.
    assert_eq!(
        r1[0].entity_id,
        EntityId::new(1),
        "a high-quality item must out-rank a zero-quality one under shuffle"
    );

    // Seed stability: same user, same minute -> byte-identical scores.
    let r2 = executor.score(&candidates, &profile, now).unwrap();
    for (a, b) in r1.iter().zip(r2.iter()) {
        assert_eq!(a.entity_id, b.entity_id);
        assert_eq!(
            a.score.to_bits(),
            b.score.to_bits(),
            "shuffle must be stable for a fixed (user, minute)"
        );
    }

    // Different user -> the seed changes, so the raw draws differ. We assert the
    // permutation is genuinely user-dependent by checking the raw (pre-normalized)
    // draw differs for the high-quality item across two distinct users on the same
    // minute. (Both still rank it first because the other item is zero-quality.)
    let exec_other = ProfileExecutor::new(&ledger).with_shuffle_user(12345);
    let r_other = exec_other.score(&candidates, &profile, now).unwrap();
    // Both runs normalize entity 1 -> 1.0 (only nonzero), so compare is trivially
    // equal post-normalize; the per-user variety is exercised by the seed inputs
    // in `shuffle_random`. Assert at least that scoring still succeeds and ordering
    // holds for the second user.
    assert_eq!(r_other[0].entity_id, EntityId::new(1));
}

mod sort_tests;
