//! Active-set selector -- picks the top-N assets for ensemble prediction.
//!
//! This is the final tier of the selection funnel.  After assets are scored
//! by [`super::scorer::PredictabilityScorer`], the `ActiveSetSelector`
//! filters by a minimum composite score threshold and retains only the top
//! `max_active` candidates.  Only assets in the active set receive full
//! ensemble predictions each tick, keeping compute costs bounded.

use std::cmp::Ordering;
use std::fmt;
use std::time::{Duration, Instant};

use super::scorer::AssetScore;

// ---------------------------------------------------------------------------
// Data types
// ---------------------------------------------------------------------------

/// A scored asset that has been promoted into the active trading set.
#[derive(Debug, Clone)]
pub struct TradingCandidate {
    /// The underlying composite score.
    pub score: AssetScore,
    /// When this candidate was selected into the active set.
    pub selected_at: Instant,
}

/// Configuration for the active-set selector.
#[derive(Debug, Clone)]
pub struct ActiveSetConfig {
    /// Maximum number of assets in the active set.
    pub max_active: usize,
    /// Minimum composite score required for consideration.
    pub min_score_threshold: f64,
    /// How often the active set should be re-evaluated.
    pub rebalance_interval: Duration,
}

impl Default for ActiveSetConfig {
    fn default() -> Self {
        Self {
            max_active: 5,
            min_score_threshold: 0.40,
            rebalance_interval: Duration::from_secs(24 * 60 * 60),
        }
    }
}

// ---------------------------------------------------------------------------
// Selector
// ---------------------------------------------------------------------------

/// Maintains the active set of assets eligible for ensemble predictions.
///
/// Call [`select`](ActiveSetSelector::select) with fresh scores whenever the
/// selector [`needs_rebalance`](ActiveSetSelector::needs_rebalance).
pub struct ActiveSetSelector {
    config: ActiveSetConfig,
    active_set: Vec<TradingCandidate>,
    last_rebalance: Option<Instant>,
}

impl fmt::Debug for ActiveSetSelector {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("ActiveSetSelector")
            .field("config", &self.config)
            .field("active_count", &self.active_set.len())
            .field("has_rebalanced", &self.last_rebalance.is_some())
            .finish()
    }
}

impl ActiveSetSelector {
    /// Create a new selector with the given configuration.
    #[must_use]
    pub const fn new(config: ActiveSetConfig) -> Self {
        Self {
            config,
            active_set: Vec::new(),
            last_rebalance: None,
        }
    }

    /// Return a reference to the current active set.
    #[must_use]
    pub fn active_set(&self) -> &[TradingCandidate] {
        &self.active_set
    }

    /// Return the symbols of all currently active assets.
    #[must_use]
    pub fn active_symbols(&self) -> Vec<String> {
        self.active_set
            .iter()
            .map(|c| c.score.symbol.clone())
            .collect()
    }

    /// Number of assets currently in the active set.
    #[must_use]
    pub fn active_count(&self) -> usize {
        self.active_set.len()
    }

    /// Whether the selector has never run or the rebalance interval has
    /// elapsed since the last selection.
    #[must_use]
    pub fn needs_rebalance(&self) -> bool {
        match self.last_rebalance {
            None => true,
            Some(last) => last.elapsed() >= self.config.rebalance_interval,
        }
    }

    /// Run the selection funnel on fresh scores.
    ///
    /// 1. Filter by `min_score_threshold`
    /// 2. Sort descending by composite score
    /// 3. Take top `max_active`
    /// 4. Store as the new active set
    ///
    /// Returns the newly selected candidates (same as [`active_set()`]).
    pub fn select(&mut self, scores: &[AssetScore]) -> Vec<TradingCandidate> {
        let now = Instant::now();

        // 1. Filter by threshold
        let mut eligible: Vec<&AssetScore> = scores
            .iter()
            .filter(|s| s.composite >= self.config.min_score_threshold)
            .collect();

        // 2. Sort descending by composite
        eligible.sort_by(|a, b| {
            b.composite
                .partial_cmp(&a.composite)
                .unwrap_or(Ordering::Equal)
        });

        // 3. Take top max_active
        let selected: Vec<TradingCandidate> = eligible
            .iter()
            .take(self.config.max_active)
            .map(|s| TradingCandidate {
                score: (*s).clone(),
                selected_at: now,
            })
            .collect();

        // 4. Store and update timestamp
        self.active_set = selected.clone();
        self.last_rebalance = Some(now);

        selected
    }

    /// Check whether a symbol is currently in the active set.
    #[must_use]
    pub fn is_active(&self, symbol: &str) -> bool {
        self.active_set.iter().any(|c| c.score.symbol == symbol)
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
#[allow(clippy::str_to_string, clippy::get_first)]
mod tests {
    use super::*;

    /// Helper to build an `AssetScore` with a given symbol and composite
    /// score.  Other fields are set to the composite value for simplicity.
    fn make_score(symbol: &str, composite: f64) -> AssetScore {
        AssetScore {
            symbol: symbol.to_string(),
            predictability: composite,
            liquidity: composite,
            regime_fit: composite,
            composite,
        }
    }

    #[test]
    fn test_select_top_n() {
        let config = ActiveSetConfig {
            max_active: 3,
            ..Default::default()
        };
        let mut selector = ActiveSetSelector::new(config);

        let scores = vec![
            make_score("A", 0.90),
            make_score("B", 0.80),
            make_score("C", 0.70),
            make_score("D", 0.60),
            make_score("E", 0.50),
        ];

        let selected = selector.select(&scores);
        assert_eq!(selected.len(), 3);
        assert_eq!(
            selected.get(0).map(|c| c.score.symbol.as_str()),
            Some("A")
        );
        assert_eq!(
            selected.get(1).map(|c| c.score.symbol.as_str()),
            Some("B")
        );
        assert_eq!(
            selected.get(2).map(|c| c.score.symbol.as_str()),
            Some("C")
        );
    }

    #[test]
    fn test_min_score_filters() {
        let config = ActiveSetConfig {
            min_score_threshold: 0.5,
            ..Default::default()
        };
        let mut selector = ActiveSetSelector::new(config);

        let scores = vec![
            make_score("HIGH", 0.80),
            make_score("MID", 0.50),
            make_score("LOW", 0.30),
        ];

        let selected = selector.select(&scores);
        // Only HIGH (0.80) and MID (0.50) pass the threshold
        assert_eq!(selected.len(), 2);
        assert!(selector.is_active("HIGH"));
        assert!(selector.is_active("MID"));
        assert!(!selector.is_active("LOW"));
    }

    #[test]
    fn test_empty_scores() {
        let mut selector = ActiveSetSelector::new(ActiveSetConfig::default());
        let selected = selector.select(&[]);
        assert!(selected.is_empty());
        assert_eq!(selector.active_count(), 0);
    }

    #[test]
    fn test_is_active() {
        let config = ActiveSetConfig {
            max_active: 2,
            min_score_threshold: 0.0,
            ..Default::default()
        };
        let mut selector = ActiveSetSelector::new(config);

        let scores = vec![
            make_score("AAPL", 0.90),
            make_score("MSFT", 0.80),
            make_score("GOOG", 0.70),
        ];

        selector.select(&scores);
        assert!(selector.is_active("AAPL"));
        assert!(selector.is_active("MSFT"));
        assert!(!selector.is_active("GOOG"));
    }

    #[test]
    fn test_needs_rebalance_initially() {
        let selector = ActiveSetSelector::new(ActiveSetConfig::default());
        assert!(selector.needs_rebalance());
    }

    #[test]
    fn test_rebalance_resets_timer() {
        let mut selector = ActiveSetSelector::new(ActiveSetConfig::default());
        assert!(selector.needs_rebalance());

        selector.select(&[make_score("X", 0.80)]);

        // Right after select the timer was just set, so interval hasn't elapsed
        assert!(!selector.needs_rebalance());
    }

    #[test]
    fn test_active_symbols() {
        let config = ActiveSetConfig {
            max_active: 3,
            min_score_threshold: 0.0,
            ..Default::default()
        };
        let mut selector = ActiveSetSelector::new(config);

        let scores = vec![
            make_score("AAPL", 0.90),
            make_score("MSFT", 0.80),
            make_score("NVDA", 0.70),
        ];

        selector.select(&scores);
        let symbols = selector.active_symbols();
        assert_eq!(symbols.len(), 3);
        assert!(symbols.contains(&"AAPL".to_owned()));
        assert!(symbols.contains(&"MSFT".to_owned()));
        assert!(symbols.contains(&"NVDA".to_owned()));
    }
}
