use arrow::array::TimestampNanosecondArray;
use arrow::compute::concat_batches;
use bt_connectors::{
    BarInterval, BinanceProvider, DataProvider, DatabentoProvider, HyperliquidProvider,
    MassiveProvider,
};
use bt_data::{ParquetDataStore, SymbolMapping, WriteOptions};
use bt_etl::ingest_bars_to_store;
use bt_kernel::{AssetClass, BtError, Result, SymbolId, SymbolInfo, TimeRange, Timestamp};
use chrono::{DateTime, Utc};
use pyo3::prelude::*;

use crate::data::PyDataStore;

/// Ingest bars from a data provider into the local Parquet store.
///
/// Usage:
/// ```python
/// store = bt.ingest(
///     provider="databento",
///     symbol="ESH5",
///     symbol_id=1,
///     start="2025-01-01T00:00:00Z",
///     end="2025-01-31T00:00:00Z",
///     interval="1m",
///     dataset="GLBX.MDP3",       # required for databento
///     data_root="data",
///     exchange="CME",
///     asset_class="future",
/// )
/// result = bt.run(strategy, config, store)
/// ```
#[pyfunction]
#[pyo3(signature = (
    provider,
    symbol,
    symbol_id,
    start,
    end,
    interval = "1m".to_string(),
    dataset = None,
    data_root = "data".to_string(),
    metadata_db = "metadata/metadata.sqlite".to_string(),
    exchange = None,
    asset_class = "crypto_spot".to_string(),
))]
#[allow(clippy::too_many_arguments)]
pub fn py_ingest(
    py: Python<'_>,
    provider: &str,
    symbol: &str,
    symbol_id: u32,
    start: &str,
    end: &str,
    interval: String,
    dataset: Option<String>,
    data_root: String,
    metadata_db: String,
    exchange: Option<String>,
    asset_class: String,
) -> PyResult<PyDataStore> {
    let result = py.allow_threads(|| {
        let rt = tokio::runtime::Runtime::new()
            .map_err(|e| BtError::Data(format!("failed to create async runtime: {e}")))?;
        rt.block_on(ingest_bars_impl(
            provider,
            symbol,
            symbol_id,
            start,
            end,
            &interval,
            dataset.as_deref(),
            &data_root,
            &metadata_db,
            exchange.as_deref(),
            &asset_class,
        ))
    });

    match result {
        Ok((dr, mdb)) => PyDataStore::new(dr, mdb, "bars_1m".to_string()),
        Err(e) => Err(super::map_bt_error(e)),
    }
}

#[allow(clippy::too_many_arguments)]
async fn ingest_bars_impl(
    provider_name: &str,
    symbol: &str,
    symbol_id: u32,
    start: &str,
    end: &str,
    interval_str: &str,
    dataset: Option<&str>,
    data_root: &str,
    metadata_db: &str,
    exchange: Option<&str>,
    asset_class: &str,
) -> Result<(String, String)> {
    let provider = build_provider(provider_name, dataset)?;
    let symbol = symbol.to_uppercase();
    let range = parse_time_range(start, end)?;
    let interval = parse_bar_interval(interval_str)?;
    let interval_nanos = interval_to_nanos(&interval)?;
    let ac = parse_asset_class(asset_class)?;
    let exchange_str = exchange.unwrap_or(provider_name).to_uppercase();

    let store = ParquetDataStore::new(data_root, metadata_db)?;
    register_symbol_metadata(&store, symbol_id, &symbol, &exchange_str, ac, range.start)?;

    // Paginated fetch.
    let mut batches = Vec::new();
    let mut cursor_start = range.start;

    loop {
        let window = TimeRange::new(cursor_start, range.end)?;
        let batch = provider
            .fetch_bars(&symbol, window, interval.clone())
            .await?;

        let batch = match batch {
            Some(b) if b.num_rows() > 0 => b,
            _ => break,
        };

        let num_rows = batch.num_rows();

        let timestamps = batch
            .column_by_name("timestamp")
            .ok_or_else(|| BtError::Data("missing timestamp column".to_string()))?
            .as_any()
            .downcast_ref::<TimestampNanosecondArray>()
            .ok_or_else(|| BtError::Data("timestamp column type mismatch".to_string()))?;
        let last_ts = timestamps.value(num_rows - 1);

        batches.push(batch);

        if num_rows < 1000 {
            break;
        }

        cursor_start = Timestamp(last_ts + interval_nanos);
        if cursor_start.0 >= range.end.0 {
            break;
        }

        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
    }

    if batches.is_empty() {
        return Err(BtError::Data(format!("no bars returned for {symbol}")));
    }

    let schema = batches[0].schema();
    let merged = concat_batches(&schema, &batches)?;

    let source_hash = format!(
        "{}:{}:klines:{}:{}:{}",
        provider.name(),
        symbol,
        interval_str,
        range.start.0,
        range.end.0,
    );

    let result = ingest_bars_to_store(
        &store,
        &merged,
        SymbolId(symbol_id),
        WriteOptions {
            source_hash,
            ..WriteOptions::default()
        },
    )?;

    tracing::info!(
        provider = provider.name(),
        symbol = symbol.as_str(),
        bars = result.bars.num_rows(),
        version = result.version.id,
        "ingest complete"
    );

    Ok((data_root.to_string(), metadata_db.to_string()))
}

fn build_provider(name: &str, dataset: Option<&str>) -> Result<Box<dyn DataProvider>> {
    match name {
        "binance" => Ok(Box::new(BinanceProvider::new())),
        "hyperliquid" => Ok(Box::new(HyperliquidProvider::new())),
        "databento" => {
            let ds = dataset.ok_or_else(|| {
                BtError::Data("dataset is required for databento (e.g. 'GLBX.MDP3')".to_string())
            })?;
            Ok(Box::new(DatabentoProvider::from_env(ds)?))
        }
        "massive" => Ok(Box::new(MassiveProvider::from_env()?)),
        other => Err(BtError::Unsupported(format!(
            "unknown provider '{other}'. Available: binance, hyperliquid, databento, massive"
        ))),
    }
}

fn register_symbol_metadata(
    store: &ParquetDataStore,
    symbol_id: u32,
    symbol: &str,
    exchange: &str,
    asset_class: AssetClass,
    valid_from: Timestamp,
) -> Result<()> {
    let (base, quote) = infer_base_quote(symbol);
    let info = SymbolInfo {
        id: SymbolId(symbol_id),
        ticker: symbol.to_string(),
        asset_class,
        exchange: exchange.to_string(),
        base_currency: base,
        quote_currency: quote,
        tick_size: 0.0,
        lot_size: 0.0,
        margin_required: None,
    };

    store.metadata_store().upsert_symbol(
        &info,
        Some(valid_from),
        None,
        serde_json::json!({ "source": "manifoldbt" }),
    )?;

    store.metadata_store().add_symbol_mapping(&SymbolMapping {
        provider: exchange.to_lowercase(),
        raw_symbol: symbol.to_string(),
        symbol_id: info.id,
        valid_from,
        valid_to: None,
    })?;

    Ok(())
}

fn parse_time_range(start: &str, end: &str) -> Result<TimeRange> {
    let s = parse_rfc3339(start)?;
    let e = parse_rfc3339(end)?;
    TimeRange::new(s, e)
}

fn parse_rfc3339(value: &str) -> Result<Timestamp> {
    let dt = DateTime::parse_from_rfc3339(value)
        .map_err(|e| BtError::Data(format!("invalid timestamp '{value}': {e}")))?
        .with_timezone(&Utc);

    let nanos = dt
        .timestamp()
        .checked_mul(1_000_000_000)
        .and_then(|secs| secs.checked_add(i64::from(dt.timestamp_subsec_nanos())))
        .ok_or_else(|| BtError::Data(format!("timestamp overflow for '{value}'")))?;

    Ok(Timestamp(nanos))
}

fn parse_bar_interval(s: &str) -> Result<BarInterval> {
    match s {
        "1s" => Ok(BarInterval::Seconds(1)),
        "1m" => Ok(BarInterval::Minutes(1)),
        "3m" => Ok(BarInterval::Minutes(3)),
        "5m" => Ok(BarInterval::Minutes(5)),
        "15m" => Ok(BarInterval::Minutes(15)),
        "30m" => Ok(BarInterval::Minutes(30)),
        "1h" => Ok(BarInterval::Hours(1)),
        "2h" => Ok(BarInterval::Hours(2)),
        "4h" => Ok(BarInterval::Hours(4)),
        "1d" => Ok(BarInterval::Days(1)),
        _ => Err(BtError::Data(format!(
            "unsupported interval '{s}'. Use: 1s, 1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 1d"
        ))),
    }
}

fn interval_to_nanos(interval: &BarInterval) -> Result<i64> {
    let nanos = match interval {
        BarInterval::Seconds(n) => i64::from(*n) * 1_000_000_000,
        BarInterval::Minutes(n) => i64::from(*n) * 60 * 1_000_000_000,
        BarInterval::Hours(n) => i64::from(*n) * 3600 * 1_000_000_000,
        BarInterval::Days(n) => i64::from(*n) * 86400 * 1_000_000_000,
    };
    Ok(nanos)
}

fn parse_asset_class(s: &str) -> Result<AssetClass> {
    match s {
        "crypto_spot" | "crypto" => Ok(AssetClass::CryptoSpot),
        "crypto_perp" => Ok(AssetClass::CryptoPerpetual),
        "equity" => Ok(AssetClass::Equity),
        "future" | "futures" => Ok(AssetClass::Future),
        "option" | "options" => Ok(AssetClass::EquityOption),
        "forex" | "fx" => Ok(AssetClass::Forex),
        "index" => Ok(AssetClass::Index),
        _ => Err(BtError::Data(format!(
            "unknown asset class '{s}'. Use: crypto_spot, crypto_perp, equity, future, option, forex, index"
        ))),
    }
}

fn infer_base_quote(symbol: &str) -> (String, String) {
    if let Some((base, quote)) = symbol.split_once('/') {
        return (base.to_string(), quote.to_string());
    }

    const QUOTE_SUFFIXES: &[&str] = &["USDT", "USDC", "BUSD", "USD", "BTC", "ETH", "EUR"];
    for suffix in QUOTE_SUFFIXES {
        if symbol.ends_with(suffix) && symbol.len() > suffix.len() {
            let base = &symbol[..symbol.len() - suffix.len()];
            return (base.to_string(), suffix.to_string());
        }
    }

    ("UNKNOWN".to_string(), "UNKNOWN".to_string())
}
