use std::time::Duration;

use super::*;
use crate::ranking::builtins::register_builtins;
use crate::ranking::profile::{CandidateStrategy, DiversitySpec, Gate, SignalAgg, Sort};
use crate::ranking::registry::ProfileRegistry;
use crate::schema::{DecaySpec, EntityKind, SchemaBuilder, Window};
use crate::signals::{NoopWalWriter, SignalLedger};

fn test_ledger() -> SignalLedger {
    let mut builder = SchemaBuilder::new();
    for sig in &["view", "share", "like"] {
        let _ = builder
            .signal(
                sig,
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(7 * 24 * 3600),
                },
            )
            .windows(&[Window::OneHour, Window::TwentyFourHours, Window::SevenDays])
            .velocity(true)
            .add();
    }
    let schema = builder.build().unwrap();
    let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));

    let base_ns = 1_708_000_000_000_000_000u64;
    for i in 0u64..5 {
        let entity_id = EntityId::new(i + 1);
        let ts = Timestamp::from_nanos(base_ns - i * 3_600_000_000_000);
        ledger
            .record_signal("view", entity_id, (5 - i) as f64, ts)
            .unwrap();
        ledger
            .record_signal("share", entity_id, (i % 3) as f64, ts)
            .unwrap();
        ledger
            .record_signal("like", entity_id, (i % 2) as f64, ts)
            .unwrap();
    }
    ledger
}

#[test]
fn score_empty_candidates_returns_empty() {
    let ledger = test_ledger();
    let executor = ProfileExecutor::new(&ledger);
    let profile = RankingProfile {
        name: "test".into(),
        version: 1,
        candidate_strategy: CandidateStrategy::Scan {
            sort_field: "created_at".into(),
        },
        boosts: vec![],
        decay: None,
        gates: vec![],
        penalties: vec![],
        excludes: vec![],
        diversity: DiversitySpec::default(),
        exploration: 0.0,
        sort: Some(Sort::Trending),
        is_builtin: false,
    };
    let result = executor.score(
        &[],
        &profile,
        Timestamp::from_nanos(1_708_000_000_000_000_000),
    );
    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);
    // 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);
    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, which normalizes to 1.0.
    let profile = RankingProfile {
        name: "test_equal".into(),
        version: 1,
        candidate_strategy: CandidateStrategy::Scan {
            sort_field: "created_at".into(),
        },
        boosts: vec![],
        decay: None,
        gates: vec![],
        penalties: vec![],
        excludes: vec![],
        diversity: DiversitySpec::default(),
        exploration: 0.0,
        sort: None,
        is_builtin: false,
    };
    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);
    assert_eq!(result.len(), 3);
    for c in &result {
        assert_eq!(c.score, 1.0);
    }
}

#[test]
fn score_gate_filters_candidates() {
    let ledger = test_ledger();
    let executor = ProfileExecutor::new(&ledger);
    let profile = RankingProfile {
        name: "test_gated".into(),
        version: 1,
        candidate_strategy: CandidateStrategy::Scan {
            sort_field: "created_at".into(),
        },
        boosts: vec![],
        decay: None,
        gates: vec![Gate {
            signal: "view".into(),
            agg: SignalAgg::Value,
            window: Window::AllTime,
            // Threshold higher than any recorded count -- all filtered.
            min_threshold: f64::MAX,
        }],
        penalties: vec![],
        excludes: vec![],
        diversity: DiversitySpec::default(),
        exploration: 0.0,
        sort: Some(Sort::Trending),
        is_builtin: false,
    };
    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);
    assert!(result.is_empty());
}

#[test]
fn score_gate_on_missing_signal_filters_all() {
    // A gate on a non-existent signal should silently return 0.0,
    // causing all candidates to fail the gate threshold.
    let mut builder = SchemaBuilder::new();
    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));
    let executor = ProfileExecutor::new(&ledger);

    let candidates: Vec<EntityId> = (1..=5).map(EntityId::new).collect();
    let profile = RankingProfile {
        name: "test_gate".into(),
        version: 1,
        candidate_strategy: CandidateStrategy::Scan {
            sort_field: "created_at".into(),
        },
        boosts: vec![],
        decay: None,
        gates: vec![Gate {
            signal: "nonexistent_signal".into(),
            agg: SignalAgg::Value,
            window: Window::AllTime,
            min_threshold: 1.0, // non-existent signal returns 0.0 < 1.0
        }],
        penalties: vec![],
        excludes: vec![],
        diversity: DiversitySpec::default(),
        exploration: 0.0,
        sort: Some(Sort::New),
        is_builtin: false,
    };
    let now = Timestamp::from_nanos(1_708_000_000_000_000_000u64);
    let result = executor.score(&candidates, &profile, now);
    assert!(
        result.is_empty(),
        "all candidates should be filtered by gate on missing signal"
    );
}

#[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 = RankingProfile {
        name: "test_nan".into(),
        version: 1,
        candidate_strategy: CandidateStrategy::Scan {
            sort_field: "created_at".into(),
        },
        boosts: vec![],
        decay: None,
        gates: vec![],
        penalties: vec![],
        excludes: vec![],
        diversity: DiversitySpec::default(),
        exploration: 0.0,
        sort: Some(Sort::Trending),
        is_builtin: false,
    };
    let result = executor.score(&candidates, &profile, now);
    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);
    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 = RankingProfile {
        name: "test_personalized".into(),
        version: 1,
        candidate_strategy: CandidateStrategy::Scan {
            sort_field: "created_at".into(),
        },
        boosts: vec![],
        decay: None,
        gates: vec![],
        penalties: vec![],
        excludes: vec![],
        diversity: DiversitySpec::default(),
        exploration: 0.0,
        sort: None,
        is_builtin: false,
    };

    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,
    };

    let result =
        executor.score_personalized(&candidates, &profile, now, None, &user_ctx, &HashMap::new());
    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_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 = RankingProfile {
        name: "test_no_boost".into(),
        version: 1,
        candidate_strategy: CandidateStrategy::Scan {
            sort_field: "created_at".into(),
        },
        boosts: vec![],
        decay: None,
        gates: vec![],
        penalties: vec![],
        excludes: vec![],
        diversity: DiversitySpec::default(),
        exploration: 0.0,
        sort: Some(Sort::New),
        is_builtin: false,
    };

    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(),
    };

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

    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
        );
    }
}

mod sort_tests;
