#![deny(clippy::unwrap_used, clippy::expect_used)]
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
#![allow(clippy::module_name_repetitions)]
//! Asset universe configuration and selection for the trading pipeline.
//!
//! This crate defines which assets are eligible for trading, including
//! their classification, exchange, sector, and minimum volume requirements.
//! It also provides predictability scoring and active-set selection.

pub use ml_core::MLError;

pub mod scorer;
pub mod selector;

use std::path::Path;

use serde::{Deserialize, Serialize};

pub use scorer::{AssetScore, PredictabilityScorer, PredictionResult, ScoringWeights};
pub use selector::{ActiveSetConfig, ActiveSetSelector, TradingCandidate};

/// Classification of a tradable asset.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AssetClass {
    /// Individual equity (stock).
    Equity,
    /// Exchange-traded fund.
    ETF,
    /// Futures contract.
    Future,
}

/// A single asset in the trading universe.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UniverseAsset {
    /// Ticker symbol (e.g. "ES.FUT") -- used for data/training.
    pub symbol: String,
    /// Execution symbol (e.g. "MES.FUT") -- used for order routing.
    /// When `None`, orders are routed to `symbol` directly.
    pub trading_symbol: Option<String>,
    /// MIC exchange code (e.g. "GLBX.MDP3").
    pub exchange: String,
    /// Asset classification.
    pub asset_class: AssetClass,
    /// Optional GICS sector.
    pub sector: Option<String>,
    /// Minimum average daily volume required for eligibility.
    pub min_daily_volume: f64,
    /// Whether this asset is enabled for trading.
    pub enabled: bool,
}

impl UniverseAsset {
    /// The symbol to use for order execution.
    /// Returns `trading_symbol` if set, otherwise `symbol`.
    #[must_use]
    pub fn execution_symbol(&self) -> &str {
        self.trading_symbol.as_deref().unwrap_or(&self.symbol)
    }
}

/// The full set of assets available for trading.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetUniverse {
    /// All configured assets.
    pub assets: Vec<UniverseAsset>,
}

// ---------------------------------------------------------------------------
// TOML config deserialization
// ---------------------------------------------------------------------------

#[derive(Deserialize)]
struct UniverseConfigFile {
    #[allow(dead_code)] // Deserialized for validation; will feed DatasetSpec once download automation lands
    universe: UniverseConfigMeta,
    symbols: Vec<SymbolConfigEntry>,
}

/// Parsed metadata from the `[universe]` section of the TOML config.
/// Fields are currently used only for validation/documentation during parsing;
/// they will feed into `DatasetSpec` construction once download automation lands.
#[derive(Deserialize)]
#[allow(dead_code)]
struct UniverseConfigMeta {
    name: String,
    description: Option<String>,
    date_range_start: Option<String>,
    date_range_end: Option<String>,
    bar_size: Option<String>,
    databento_dataset: Option<String>,
    databento_schema: Option<String>,
}

#[derive(Deserialize)]
struct SymbolConfigEntry {
    symbol: String,
    trading_symbol: Option<String>,
    exchange: String,
    asset_class: String,
    min_daily_volume: Option<f64>,
}

impl AssetUniverse {
    /// Create an empty universe.
    #[must_use]
    pub const fn new() -> Self {
        Self { assets: Vec::new() }
    }

    /// Load a universe from a TOML config file.
    ///
    /// # Errors
    /// Returns `MLError::ConfigError` on I/O or parse failures.
    pub fn from_config(path: &Path) -> Result<Self, MLError> {
        let contents = std::fs::read_to_string(path).map_err(|e| MLError::ConfigError(format!("Failed to read universe config {}: {e}", path.display())))?;

        let config: UniverseConfigFile =
            toml::from_str(&contents).map_err(|e| MLError::ConfigError(format!("Failed to parse universe config {}: {e}", path.display())))?;

        let assets = config
            .symbols
            .into_iter()
            .map(|s| {
                let asset_class = match s.asset_class.as_str() {
                    "Future" | "future" => AssetClass::Future,
                    "Equity" | "equity" => AssetClass::Equity,
                    "ETF" | "etf" => AssetClass::ETF,
                    other => {
                        return Err(MLError::ConfigError(format!("Unknown asset class: {other}")))
                    }
                };
                Ok(UniverseAsset {
                    symbol: s.symbol,
                    trading_symbol: s.trading_symbol,
                    exchange: s.exchange,
                    asset_class,
                    sector: None,
                    min_daily_volume: s.min_daily_volume.unwrap_or(0.0),
                    enabled: true,
                })
            })
            .collect::<Result<Vec<_>, _>>()?;

        Ok(Self { assets })
    }

    /// Return only enabled assets.
    #[must_use]
    pub fn eligible(&self) -> Vec<&UniverseAsset> {
        self.assets.iter().filter(|a| a.enabled).collect()
    }

    /// Count of enabled assets.
    #[must_use]
    pub fn eligible_count(&self) -> usize {
        self.assets.iter().filter(|a| a.enabled).count()
    }

    /// Find an asset by symbol (case-sensitive).
    #[must_use]
    pub fn find(&self, symbol: &str) -> Option<&UniverseAsset> {
        self.assets.iter().find(|a| a.symbol == symbol)
    }

    /// List all asset symbols.
    #[must_use]
    pub fn symbols(&self) -> Vec<&str> {
        self.assets.iter().map(|a| a.symbol.as_str()).collect()
    }

    /// Preset: US large-cap starter universe (7 highly liquid equities/ETFs).
    #[must_use]
    pub fn us_starter() -> Self {
        Self {
            assets: vec![
                UniverseAsset {
                    symbol: "SPY".to_owned(),
                    trading_symbol: None,
                    exchange: "XNYS".to_owned(),
                    asset_class: AssetClass::ETF,
                    sector: None,
                    min_daily_volume: 50_000_000.0,
                    enabled: true,
                },
                UniverseAsset {
                    symbol: "QQQ".to_owned(),
                    trading_symbol: None,
                    exchange: "XNAS".to_owned(),
                    asset_class: AssetClass::ETF,
                    sector: None,
                    min_daily_volume: 30_000_000.0,
                    enabled: true,
                },
                UniverseAsset {
                    symbol: "AAPL".to_owned(),
                    trading_symbol: None,
                    exchange: "XNAS".to_owned(),
                    asset_class: AssetClass::Equity,
                    sector: Some("Technology".to_owned()),
                    min_daily_volume: 50_000_000.0,
                    enabled: true,
                },
                UniverseAsset {
                    symbol: "MSFT".to_owned(),
                    trading_symbol: None,
                    exchange: "XNAS".to_owned(),
                    asset_class: AssetClass::Equity,
                    sector: Some("Technology".to_owned()),
                    min_daily_volume: 20_000_000.0,
                    enabled: true,
                },
                UniverseAsset {
                    symbol: "NVDA".to_owned(),
                    trading_symbol: None,
                    exchange: "XNAS".to_owned(),
                    asset_class: AssetClass::Equity,
                    sector: Some("Technology".to_owned()),
                    min_daily_volume: 30_000_000.0,
                    enabled: true,
                },
                UniverseAsset {
                    symbol: "AMZN".to_owned(),
                    trading_symbol: None,
                    exchange: "XNAS".to_owned(),
                    asset_class: AssetClass::Equity,
                    sector: Some("Consumer".to_owned()),
                    min_daily_volume: 20_000_000.0,
                    enabled: true,
                },
                UniverseAsset {
                    symbol: "IWM".to_owned(),
                    trading_symbol: None,
                    exchange: "XNYS".to_owned(),
                    asset_class: AssetClass::ETF,
                    sector: None,
                    min_daily_volume: 20_000_000.0,
                    enabled: true,
                },
            ],
        }
    }

    /// Preset: CME futures baseline universe (4 assets for limited capital).
    ///
    /// Training is done on full-size contracts (ES, NQ, ZN, 6E).
    /// Execution routes to micro contracts where available (MES, MNQ, M6E).
    #[must_use]
    pub fn futures_baseline() -> Self {
        Self {
            assets: vec![
                UniverseAsset {
                    symbol: "ES.FUT".to_owned(),
                    trading_symbol: Some("MES.FUT".to_owned()),
                    exchange: "GLBX.MDP3".to_owned(),
                    asset_class: AssetClass::Future,
                    sector: None,
                    min_daily_volume: 1_000_000.0,
                    enabled: true,
                },
                UniverseAsset {
                    symbol: "NQ.FUT".to_owned(),
                    trading_symbol: Some("MNQ.FUT".to_owned()),
                    exchange: "GLBX.MDP3".to_owned(),
                    asset_class: AssetClass::Future,
                    sector: None,
                    min_daily_volume: 500_000.0,
                    enabled: true,
                },
                UniverseAsset {
                    symbol: "ZN.FUT".to_owned(),
                    trading_symbol: None,
                    exchange: "GLBX.MDP3".to_owned(),
                    asset_class: AssetClass::Future,
                    sector: None,
                    min_daily_volume: 500_000.0,
                    enabled: true,
                },
                UniverseAsset {
                    symbol: "6E.FUT".to_owned(),
                    trading_symbol: Some("M6E.FUT".to_owned()),
                    exchange: "GLBX.MDP3".to_owned(),
                    asset_class: AssetClass::Future,
                    sector: None,
                    min_daily_volume: 200_000.0,
                    enabled: true,
                },
            ],
        }
    }
}

impl Default for AssetUniverse {
    fn default() -> Self {
        Self::new()
    }
}

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

    #[test]
    fn test_us_starter_universe() {
        let universe = AssetUniverse::us_starter();
        assert_eq!(universe.assets.len(), 7);
        assert_eq!(universe.eligible_count(), 7);
    }

    #[test]
    fn test_eligible_filters_disabled() {
        let mut universe = AssetUniverse::us_starter();
        if let Some(first) = universe.assets.get_mut(0) {
            first.enabled = false;
        }
        assert_eq!(universe.eligible_count(), 6);
    }

    #[test]
    fn test_find_symbol() {
        let universe = AssetUniverse::us_starter();
        let aapl = universe.find("AAPL");
        assert!(aapl.is_some());
        assert_eq!(
            aapl.map(|a| a.exchange.as_str()).unwrap_or_default(),
            "XNAS"
        );
    }

    #[test]
    fn test_find_missing_symbol() {
        let universe = AssetUniverse::us_starter();
        assert!(universe.find("DOESNOTEXIST").is_none());
    }

    #[test]
    fn test_symbols_list() {
        let universe = AssetUniverse::us_starter();
        let symbols = universe.symbols();
        assert_eq!(symbols.len(), 7);
        assert!(symbols.contains(&"SPY"));
        assert!(symbols.contains(&"AAPL"));
    }

    #[test]
    fn test_empty_universe() {
        let universe = AssetUniverse::new();
        assert!(universe.eligible().is_empty());
    }

    #[test]
    fn test_serde_roundtrip() {
        let universe = AssetUniverse::us_starter();
        let json = serde_json::to_string(&universe).unwrap_or_default();
        let restored: AssetUniverse =
            serde_json::from_str(&json).unwrap_or_else(|_| AssetUniverse::new());
        assert_eq!(restored.assets.len(), 7);
    }

    #[test]
    fn test_trading_symbol_mapping() {
        let asset = UniverseAsset {
            symbol: "ES.FUT".to_owned(),
            trading_symbol: Some("MES.FUT".to_owned()),
            exchange: "GLBX.MDP3".to_owned(),
            asset_class: AssetClass::Future,
            sector: None,
            min_daily_volume: 1_000_000.0,
            enabled: true,
        };
        assert_eq!(asset.execution_symbol(), "MES.FUT");
    }

    #[test]
    fn test_trading_symbol_none_falls_back() {
        let asset = UniverseAsset {
            symbol: "ZN.FUT".to_owned(),
            trading_symbol: None,
            exchange: "GLBX.MDP3".to_owned(),
            asset_class: AssetClass::Future,
            sector: None,
            min_daily_volume: 500_000.0,
            enabled: true,
        };
        assert_eq!(asset.execution_symbol(), "ZN.FUT");
    }

    #[test]
    fn test_futures_baseline_universe() {
        let universe = AssetUniverse::futures_baseline();
        assert_eq!(universe.assets.len(), 4);
        assert_eq!(universe.eligible_count(), 4);

        // All are futures
        for asset in &universe.assets {
            assert_eq!(asset.asset_class, AssetClass::Future);
        }
    }

    #[test]
    fn test_futures_baseline_symbols() {
        let universe = AssetUniverse::futures_baseline();
        let symbols = universe.symbols();
        assert!(symbols.contains(&"ES.FUT"));
        assert!(symbols.contains(&"NQ.FUT"));
        assert!(symbols.contains(&"ZN.FUT"));
        assert!(symbols.contains(&"6E.FUT"));
    }

    #[test]
    fn test_futures_baseline_micro_mapping() {
        let universe = AssetUniverse::futures_baseline();

        assert_eq!(
            universe.find("ES.FUT").map(|a| a.execution_symbol()),
            Some("MES.FUT")
        );
        assert_eq!(
            universe.find("NQ.FUT").map(|a| a.execution_symbol()),
            Some("MNQ.FUT")
        );
        // ZN has no micro -- executes as ZN.FUT
        assert_eq!(
            universe.find("ZN.FUT").map(|a| a.execution_symbol()),
            Some("ZN.FUT")
        );
        assert_eq!(
            universe.find("6E.FUT").map(|a| a.execution_symbol()),
            Some("M6E.FUT")
        );
    }

    #[test]
    fn test_futures_baseline_serde_roundtrip() {
        let universe = AssetUniverse::futures_baseline();
        let json = serde_json::to_string(&universe).unwrap_or_default();
        let restored: AssetUniverse =
            serde_json::from_str(&json).unwrap_or_else(|_| AssetUniverse::new());
        assert_eq!(restored.assets.len(), 4);
        // Verify trading_symbol survives roundtrip
        let es = restored.find("ES.FUT");
        assert_eq!(es.map(|a| a.execution_symbol()), Some("MES.FUT"));
    }

    #[test]
    fn test_from_config_toml() {
        let toml_content = r#"
[universe]
name = "test-universe"
description = "Test"

[[symbols]]
symbol = "ES.FUT"
trading_symbol = "MES.FUT"
exchange = "GLBX.MDP3"
asset_class = "Future"
min_daily_volume = 1000000.0

[[symbols]]
symbol = "ZN.FUT"
exchange = "GLBX.MDP3"
asset_class = "Future"
min_daily_volume = 500000.0
"#;
        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tmpdir: {e}"));
        let path = dir.path().join("universe.toml");
        std::fs::write(&path, toml_content).unwrap_or_else(|e| panic!("write: {e}"));

        let universe = AssetUniverse::from_config(&path);
        assert!(universe.is_ok(), "from_config failed: {:?}", universe.err());
        let universe = universe.unwrap_or_else(|_| AssetUniverse::new());
        assert_eq!(universe.assets.len(), 2);
        assert_eq!(
            universe.find("ES.FUT").map(|a| a.execution_symbol()),
            Some("MES.FUT")
        );
        assert_eq!(
            universe.find("ZN.FUT").map(|a| a.trading_symbol.as_deref()),
            Some(None)
        );
    }

    #[test]
    fn test_load_futures_baseline_config() {
        let config_path = std::path::Path::new("../config/universe-futures-baseline.toml");
        if !config_path.exists() {
            // Skip in CI where config may not be at expected relative path
            return;
        }
        let universe = AssetUniverse::from_config(config_path);
        assert!(universe.is_ok(), "Failed to load config: {:?}", universe.err());
        let universe = universe.unwrap_or_else(|_| AssetUniverse::new());
        assert_eq!(universe.assets.len(), 4);
        assert_eq!(
            universe.find("ES.FUT").map(|a| a.execution_symbol()),
            Some("MES.FUT")
        );
    }

    #[test]
    fn test_from_config_missing_file() {
        let result = AssetUniverse::from_config(std::path::Path::new("/nonexistent/path.toml"));
        assert!(result.is_err());
    }

    #[test]
    fn test_from_config_invalid_toml() {
        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tmpdir: {e}"));
        let path = dir.path().join("bad.toml");
        std::fs::write(&path, "not valid toml {{{{").unwrap_or_else(|e| panic!("write: {e}"));
        let result = AssetUniverse::from_config(&path);
        assert!(result.is_err());
    }
}
