// Issue #161: PyO3 bindings for StreamManager (managed streaming with rate limiting)

use super::*;
use opendeviationbar_providers::binance::AdaptiveRateLimiter;
use opendeviationbar_streaming::FormingBarWatches;
use std::sync::Arc;

/// Separate pyclass for forming bar watch receivers (Issue #214).
///
/// Extracted from PyStreamManager so that `get_forming_bars(&self)` on this object
/// doesn't conflict with `next_bar(&mut self)` on the manager. PyO3's runtime borrow
/// checker operates at the PyCell level — fields within the same struct share one lock.
/// By making this a separate Python object, each has its own independent borrow state.
#[pyclass(name = "FormingBarWatcher")]
pub struct PyFormingBarWatcher {
    watches: Option<FormingBarWatches>,
}

#[pymethods]
impl PyFormingBarWatcher {
    /// Get current forming (incomplete) bar snapshots for all active pairs.
    ///
    /// Returns a list of dicts, one per (symbol, threshold) pair that has an
    /// in-progress bar. Each dict contains the bar fields plus `_symbol`,
    /// `_threshold`, and `is_forming=True` metadata.
    ///
    /// Safe to call from a daemon thread at 1Hz — reads from tokio::sync::watch
    /// receivers with zero contention against the StreamManager.
    pub fn get_forming_bars(&self, py: Python) -> PyResult<PyObject> {
        let Some(watches) = &self.watches else {
            eprintln!("[WARN] get_forming_bars called but watches are None (already taken?)");
            let list = pyo3::types::PyList::empty(py);
            return Ok(list.into_any().unbind());
        };

        let list = pyo3::types::PyList::empty(py);
        for ((sym, threshold), rx) in watches {
            let snapshot = rx.borrow();
            if let Some(ref forming) = *snapshot {
                let dict = helpers::opendeviationbar_to_dict(py, &forming.bar)?;
                let py_dict: &Bound<'_, PyDict> = dict.cast_bound(py)?;
                py_dict.set_item(pyo3::types::PyString::intern(py, "_symbol"), &**sym)?;
                py_dict.set_item(pyo3::types::PyString::intern(py, "_threshold"), *threshold)?;
                py_dict.set_item(pyo3::types::PyString::intern(py, "is_forming"), true)?;
                py_dict.set_item(
                    pyo3::types::PyString::intern(py, "_last_trade_timestamp_us"),
                    forming.last_trade_timestamp_us,
                )?;
                list.append(dict)?;
            }
        }
        Ok(list.into_any().unbind())
    }
}

/// Managed streaming engine with adaptive rate limiting and trade-ID continuity tracking.
///
/// Drop-in replacement for LiveBarEngine with additional orchestration:
/// - Proactive REST API rate limiting (Issue #162)
/// - Trade-ID continuity tracking seeded from ClickHouse
/// - Combined metrics (engine + stream manager + rate limiter)
/// - Forming bar snapshots via watch channels (Issue #214)
///
/// Example:
///     >>> manager = StreamManager(["BTCUSDT"], [250, 500])
///     >>> manager.seed_trade_id("BTCUSDT", 250, 1000000)  # optional: seed from ClickHouse
///     >>> manager.start()
///     >>> bar = manager.next_bar(timeout_ms=5000)
///     >>> watcher = manager.take_forming_bar_watcher()  # Issue #214
///     >>> forming = watcher.get_forming_bars()
#[pyclass(name = "StreamManager")]
pub struct PyStreamManager {
    manager: Option<opendeviationbar_streaming::StreamManager>,
    runtime: Option<tokio::runtime::Runtime>,
    started: bool,
    rate_limiter: Arc<AdaptiveRateLimiter>,
    /// Issue #214: Forming bar watches, extracted before start() and returned via
    /// take_forming_bar_watcher() as a separate PyFormingBarWatcher object.
    forming_bar_watches: Option<FormingBarWatches>,
    /// Issue #257: Gap event receiver, extracted before start() and returned via
    /// take_gap_event_watcher() as a separate PyGapEventWatcher object.
    gap_event_rx: Option<tokio::sync::mpsc::Receiver<opendeviationbar_streaming::GapEvent>>,
}

#[pymethods]
impl PyStreamManager {
    /// Create a new StreamManager.
    ///
    /// Args:
    ///     symbols: List of trading pairs (e.g., ["BTCUSDT", "ETHUSDT"])
    ///     thresholds: List of thresholds in decimal basis points (e.g., [250, 500])
    ///     include_microstructure: Whether to compute all 58 columns (default: True)
    ///     max_weight_per_minute: Binance rate limit budget (default: 6000, official IP limit)
    ///     safety_margin: Fraction of budget to reserve (default: 0.20)
    #[new]
    #[pyo3(signature = (symbols, thresholds, include_microstructure=true, compute_tier2=true, compute_tier3=false, compute_hurst=None, compute_permutation_entropy=None, max_weight_per_minute=6000, safety_margin=0.20, shadow_validation=false, shadow_interval_secs=5, ws_mode="combined", symbol_modes=None))]
    pub fn new(
        symbols: Vec<String>,
        thresholds: Vec<u32>,
        include_microstructure: bool,
        compute_tier2: bool,
        compute_tier3: bool,
        compute_hurst: Option<bool>,
        compute_permutation_entropy: Option<bool>,
        max_weight_per_minute: u32,
        safety_margin: f32,
        shadow_validation: bool,
        shadow_interval_secs: u64,
        ws_mode: &str,
        symbol_modes: Option<std::collections::HashMap<String, String>>,
    ) -> PyResult<Self> {
        if symbols.is_empty() {
            return Err(PyValueError::new_err("symbols must not be empty"));
        }
        if thresholds.is_empty() {
            return Err(PyValueError::new_err("thresholds must not be empty"));
        }

        let mut engine_config =
            opendeviationbar_streaming::LiveEngineConfig::new(symbols, thresholds);
        engine_config.include_microstructure = include_microstructure;
        engine_config.compute_tier2 = compute_tier2;
        engine_config.compute_tier3 = compute_tier3;
        engine_config.compute_hurst = compute_hurst;
        engine_config.compute_permutation_entropy = compute_permutation_entropy;
        engine_config.ws_mode = match ws_mode {
            "per_symbol" => opendeviationbar_streaming::WsMode::PerSymbol,
            _ => opendeviationbar_streaming::WsMode::Combined,
        };

        // Phase 16: Convert Python symbol_modes dict to Rust HashMap<String, OuroborosMode>
        if let Some(modes) = symbol_modes {
            for (sym, mode_str) in modes {
                let mode = match mode_str.as_str() {
                    "aion" => opendeviationbar_streaming::OuroborosMode::Aion,
                    _ => opendeviationbar_streaming::OuroborosMode::Day,
                };
                engine_config.symbol_modes.insert(sym, mode);
            }
        }

        let rate_limiter = Arc::new(AdaptiveRateLimiter::new(
            max_weight_per_minute,
            safety_margin,
        ));

        let mut sm_config = opendeviationbar_streaming::StreamManagerConfig::new(
            engine_config,
            rate_limiter.clone(),
        );
        sm_config.shadow_validation_enabled = shadow_validation;
        sm_config.shadow_interval_secs = shadow_interval_secs;

        let mut manager = opendeviationbar_streaming::StreamManager::new(sm_config);

        // Issue #214: Extract forming bar watches before start() to store separately.
        // This avoids &mut self borrow conflict between next_bar() and get_forming_bars().
        let forming_bar_watches = manager.take_forming_bar_watches();

        // Issue #257: Extract gap event receiver before start().
        let gap_event_rx = manager.take_gap_event_receiver();

        Ok(Self {
            manager: Some(manager),
            runtime: None,
            started: false,
            rate_limiter,
            forming_bar_watches,
            gap_event_rx,
        })
    }

    /// Seed trade-ID continuity tracker for a (symbol, threshold) pair.
    ///
    /// Call before start() to seed from ClickHouse's last_agg_trade_id.
    /// The first WS trade will trigger gap detection if its ID doesn't
    /// follow the seeded value contiguously.
    pub fn seed_trade_id(
        &mut self,
        symbol: &str,
        threshold: u32,
        last_agg_trade_id: i64,
    ) -> PyResult<()> {
        if self.started {
            return Err(PyRuntimeError::new_err(
                "Cannot seed trade ID after start()",
            ));
        }
        if let Some(ref mut manager) = self.manager {
            manager.seed_trade_id(symbol, threshold, last_agg_trade_id);
        }
        Ok(())
    }

    /// Inject a processor checkpoint before start().
    pub fn set_checkpoint(
        &mut self,
        py: Python,
        symbol: &str,
        threshold: u32,
        checkpoint: &Bound<'_, PyDict>,
    ) -> PyResult<()> {
        if self.started {
            return Err(PyRuntimeError::new_err(
                "Cannot set checkpoint after start()",
            ));
        }

        let cp = helpers::dict_to_checkpoint(py, checkpoint)?;

        if let Some(ref mut manager) = self.manager {
            manager.set_initial_checkpoint(symbol, threshold, cp);
        }
        Ok(())
    }

    /// Start WebSocket connections only — buffer trades without processing (#286).
    ///
    /// Call BEFORE loading checkpoints or seeding. Trades buffer in channels.
    /// Then call start() to begin processing with zero-gap transition.
    pub fn start_ws(&mut self, py: Python) -> PyResult<()> {
        if self.started {
            return Ok(());
        }

        // Create runtime if not yet exists
        if self.runtime.is_none() {
            let runtime = tokio::runtime::Builder::new_multi_thread()
                .worker_threads(2)
                .enable_all()
                .build()
                .map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {e}")))?;
            self.runtime = Some(runtime);
        }

        let _guard = self.runtime.as_ref().unwrap().enter();

        if let Some(ref mut manager) = self.manager {
            manager.start_ws();
        }

        // Brief GIL release for WS handshake
        py.detach(|| std::thread::sleep(std::time::Duration::from_millis(100)));

        Ok(())
    }

    /// Fill historical gap from REST before starting WS processing (#286).
    ///
    /// Creates processors, feeds REST trades through them, preserves state.
    /// Call sequence: start_ws() → fill_from_rest() → start()
    /// The SAME processors transition seamlessly to WS mode in start().
    pub fn fill_from_rest(&mut self, py: Python) -> PyResult<u64> {
        if self.started {
            return Ok(0);
        }

        // Create runtime if not yet exists
        if self.runtime.is_none() {
            let runtime = tokio::runtime::Builder::new_multi_thread()
                .worker_threads(2)
                .enable_all()
                .build()
                .map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {e}")))?;
            self.runtime = Some(runtime);
        }

        let runtime = self.runtime.as_ref().unwrap();
        let _guard = runtime.enter();

        let bars = if let Some(ref mut manager) = self.manager {
            py.detach(|| runtime.block_on(manager.fill_from_rest()))
                .map_err(|e| PyRuntimeError::new_err(format!("fill_from_rest failed: {e}")))?
        } else {
            0
        };

        Ok(bars)
    }

    /// Start all WebSocket connections and processing loops.
    ///
    /// If start_ws() was called first, reuses buffered WS channels (zero-gap).
    /// If fill_from_rest() was called, reuses pre-created processors with state.
    pub fn start(&mut self, py: Python) -> PyResult<()> {
        if self.started {
            return Ok(());
        }

        // Create runtime if start_ws() already created one
        if self.runtime.is_none() {
            let runtime = tokio::runtime::Builder::new_multi_thread()
                .worker_threads(2)
                .enable_all()
                .build()
                .map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {e}")))?;
            self.runtime = Some(runtime);
        }

        let _guard = self.runtime.as_ref().unwrap().enter();

        if let Some(ref mut manager) = self.manager {
            manager
                .start()
                .map_err(|e| PyValueError::new_err(format!("Failed to start: {e}")))?;
        }

        self.started = true;

        // Brief GIL release to let WS connections initialize
        py.detach(|| {
            std::thread::sleep(std::time::Duration::from_millis(100));
        });

        Ok(())
    }

    /// Get the next completed bar. Blocks until available or timeout.
    ///
    /// Returns a dict with all columns plus `_symbol` and `_threshold` metadata.
    #[pyo3(signature = (timeout_ms=5000))]
    pub fn next_bar(&mut self, py: Python, timeout_ms: u64) -> PyResult<Option<PyObject>> {
        let runtime = self
            .runtime
            .as_ref()
            .ok_or_else(|| PyRuntimeError::new_err("Not started. Call start() first."))?;
        let manager = self
            .manager
            .as_mut()
            .ok_or_else(|| PyRuntimeError::new_err("Manager not available"))?;

        let completed = py.detach(|| {
            runtime.block_on(manager.next_bar(tokio::time::Duration::from_millis(timeout_ms)))
        });

        match completed {
            Some(bar) => {
                let dict = helpers::opendeviationbar_to_dict(py, &bar.bar)?;
                let py_dict: &Bound<'_, PyDict> = dict.cast_bound(py)?;
                // Issue #96 Memory v2: intern per-bar metadata keys (deref Arc<str>)
                py_dict.set_item(pyo3::types::PyString::intern(py, "_symbol"), &*bar.symbol)?;
                py_dict.set_item(
                    pyo3::types::PyString::intern(py, "_threshold"),
                    bar.threshold_decimal_bps,
                )?;
                Ok(Some(dict))
            }
            None => Ok(None),
        }
    }

    /// Extract forming bar watches into a separate FormingBarWatcher object (Issue #214).
    ///
    /// Returns a FormingBarWatcher that can safely call get_forming_bars() from a
    /// daemon thread without conflicting with next_bar() on this StreamManager.
    /// Can only be called once — subsequent calls return None.
    ///
    /// MUST be called before start() or immediately after construction.
    pub fn take_forming_bar_watcher(&mut self) -> Option<PyFormingBarWatcher> {
        self.forming_bar_watches
            .take()
            .map(|w| PyFormingBarWatcher { watches: Some(w) })
    }

    /// Extract gap event receiver into a separate GapEventWatcher object (Issue #257).
    ///
    /// Returns a GapEventWatcher that can safely call next_event() from a
    /// daemon thread without conflicting with next_bar() on this StreamManager.
    /// Can only be called once — subsequent calls return None.
    ///
    /// MUST be called before start() or immediately after construction.
    pub fn take_gap_event_watcher(&mut self) -> Option<gap_bindings::PyGapEventWatcher> {
        let rx = self.gap_event_rx.take()?;
        let handle = self.runtime.as_ref().map(|rt| rt.handle().clone());
        Some(gap_bindings::PyGapEventWatcher::new(rx, handle))
    }

    /// Submit an on-demand gap-fill request (Issue #257).
    ///
    /// Sends a fill command to the symbol's engine task and blocks until complete.
    /// Uses `&self` — safe to call concurrently with `next_bar()`.
    ///
    /// Args:
    ///     symbol: Trading pair (e.g., "BTCUSDT")
    ///     from_tid: First missing trade ID (inclusive)
    ///     to_tid: First trade ID that IS present (exclusive)
    ///
    /// Returns:
    ///     GapFillResult with trades_fetched, bars_produced, duration_ms, partial.
    ///     None if symbol is unknown or engine not started.
    #[pyo3(signature = (symbol, from_tid, to_tid))]
    pub fn fill_gap(
        &self,
        py: Python,
        symbol: &str,
        from_tid: i64,
        to_tid: i64,
    ) -> PyResult<Option<gap_bindings::PyGapFillResult>> {
        let runtime = self
            .runtime
            .as_ref()
            .ok_or_else(|| PyRuntimeError::new_err("Not started. Call start() first."))?;
        let manager = self
            .manager
            .as_ref()
            .ok_or_else(|| PyRuntimeError::new_err("Manager not available"))?;

        let symbol_upper = symbol.to_uppercase();
        let result =
            py.detach(|| runtime.block_on(manager.fill_gap(&symbol_upper, from_tid, to_tid)));

        Ok(result.map(|r| gap_bindings::PyGapFillResult {
            trades_fetched: r.trades_fetched,
            bars_produced: r.bars_produced,
            duration_ms: r.duration_ms,
            partial: r.partial,
        }))
    }

    /// Get current forming bars (DEPRECATED — use take_forming_bar_watcher() instead).
    ///
    /// This method exists for backward compatibility but will raise RuntimeError
    /// if called concurrently with next_bar() due to PyO3 borrow semantics.
    /// Prefer take_forming_bar_watcher() which returns a separate object free
    /// from borrow conflicts.
    pub fn get_forming_bars(&self, py: Python) -> PyResult<PyObject> {
        let Some(watches) = &self.forming_bar_watches else {
            let list = pyo3::types::PyList::empty(py);
            return Ok(list.into_any().unbind());
        };

        let list = pyo3::types::PyList::empty(py);
        for ((sym, threshold), rx) in watches {
            let snapshot = rx.borrow();
            if let Some(ref forming) = *snapshot {
                let dict = helpers::opendeviationbar_to_dict(py, &forming.bar)?;
                let py_dict: &Bound<'_, PyDict> = dict.cast_bound(py)?;
                py_dict.set_item(pyo3::types::PyString::intern(py, "_symbol"), &**sym)?;
                py_dict.set_item(pyo3::types::PyString::intern(py, "_threshold"), *threshold)?;
                py_dict.set_item(pyo3::types::PyString::intern(py, "is_forming"), true)?;
                py_dict.set_item(
                    pyo3::types::PyString::intern(py, "_last_trade_timestamp_us"),
                    forming.last_trade_timestamp_us,
                )?;
                list.append(dict)?;
            }
        }
        Ok(list.into_any().unbind())
    }

    /// Drain all completed bars from the ring buffer synchronously (#296).
    ///
    /// Returns a list of bar dicts in FIFO order. Purely synchronous — does
    /// NOT use block_on or async polling. This bypasses the next_bar() async
    /// polling loop which can fail when called before engine.start().
    ///
    /// Use this instead of polling next_bar() to drain REST bars after
    /// fill_from_rest() and before start().
    pub fn drain_bars(&self, py: Python) -> PyResult<PyObject> {
        let manager = self
            .manager
            .as_ref()
            .ok_or_else(|| PyRuntimeError::new_err("Manager not available"))?;

        let bars = manager.drain_bars();

        let list = pyo3::types::PyList::empty(py);
        for bar in &bars {
            let dict = helpers::opendeviationbar_to_dict(py, &bar.bar)?;
            let py_dict: &Bound<'_, PyDict> = dict.cast_bound(py)?;
            py_dict.set_item(pyo3::types::PyString::intern(py, "_symbol"), &*bar.symbol)?;
            py_dict.set_item(
                pyo3::types::PyString::intern(py, "_threshold"),
                bar.threshold_decimal_bps,
            )?;
            list.append(dict)?;
        }
        Ok(list.into_any().unbind())
    }

    /// Drain all completed bars as an Arrow RecordBatch (#313, v7.2).
    ///
    /// Returns bars in FIFO order as a single RecordBatch with all bar fields
    /// plus `_symbol` (Utf8) and `_threshold_decimal_bps` (UInt32) metadata columns.
    /// Zero-copy convertible to Polars DataFrame for efficient grouping.
    ///
    /// Purely synchronous — no async, no block_on. Safe to call before start().
    /// GIL is released during the Rust drain + Arrow conversion.
    #[cfg(feature = "arrow-export")]
    pub fn drain_bars_arrow(&self, py: Python) -> PyResult<PyRecordBatch> {
        let manager = self
            .manager
            .as_ref()
            .ok_or_else(|| PyRuntimeError::new_err("Manager not available"))?;

        // Release GIL during Rust drain + Arrow conversion
        let batch = py.detach(|| manager.drain_bars_arrow());

        Ok(PyRecordBatch::new(batch))
    }

    /// Graceful stop (signals all tasks to wind down).
    pub fn stop(&mut self) -> PyResult<()> {
        if let Some(ref mut manager) = self.manager {
            manager.stop();
        }
        self.started = false;
        Ok(())
    }

    /// Collect processor checkpoints for state recovery.
    #[pyo3(signature = (timeout_ms=5000))]
    pub fn collect_checkpoints(&mut self, py: Python, timeout_ms: u64) -> PyResult<PyObject> {
        let runtime = self
            .runtime
            .as_ref()
            .ok_or_else(|| PyRuntimeError::new_err("Not started"))?;
        let manager = self
            .manager
            .as_mut()
            .ok_or_else(|| PyRuntimeError::new_err("Manager not available"))?;

        let checkpoints = py.detach(|| {
            runtime.block_on(
                manager.collect_checkpoints(tokio::time::Duration::from_millis(timeout_ms)),
            )
        });

        let dict = PyDict::new(py);
        for (key, cp) in checkpoints {
            dict.set_item(&key, helpers::checkpoint_to_dict(py, &cp)?)?;
        }
        Ok(dict.into_any().unbind())
    }

    /// Fully release engine resources.
    pub fn shutdown(&mut self) -> PyResult<()> {
        if let Some(ref mut manager) = self.manager {
            manager.stop();
        }
        self.started = false;
        if let Some(runtime) = self.runtime.take() {
            runtime.shutdown_background();
        }
        Ok(())
    }

    /// Get combined metrics (engine + stream manager + rate limiter).
    pub fn get_metrics(&self, py: Python) -> PyResult<PyObject> {
        let manager = self
            .manager
            .as_ref()
            .ok_or_else(|| PyRuntimeError::new_err("Manager not available"))?;

        let combined = manager.get_combined_metrics();
        let dict = PyDict::new(py);
        for (key, value) in &combined {
            dict.set_item(key, value)?;
        }
        dict.set_item(
            "rate_limiter_utilization",
            manager.rate_limiter_utilization(),
        )?;
        Ok(dict.into_any().unbind())
    }

    /// Whether the engine has been started.
    #[getter]
    pub const fn is_started(&self) -> bool {
        self.started
    }

    fn __repr__(&self) -> String {
        format!(
            "StreamManager(started={}, rate_limiter_remaining={})",
            self.started,
            self.rate_limiter.remaining()
        )
    }
}

impl Drop for PyStreamManager {
    fn drop(&mut self) {
        if let Some(ref mut manager) = self.manager {
            manager.stop();
        }
        if let Some(runtime) = self.runtime.take() {
            runtime.shutdown_background();
        }
    }
}
