use crate::config::PySchedulerConfig;
use crate::node::PyNodeInfo;
use horus::core::{NodeInfo as CoreNodeInfo, TopicMetadata};
use horus_core::core::Miss;
use horus_core::core::Node as CoreNode;
use horus_core::core::NodeMetrics;
use horus_core::error::HorusError;
use horus_core::scheduling::{
    ExecutionClass, FailurePolicy, NodeRegistration, Scheduler as CoreScheduler,
};
use pyo3::exceptions::PyRuntimeError;
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyList};
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;

// ─── PyNodeAdapter ───────────────────────────────────────────────────────────
// Implements horus_core::Node by bridging to Python objects via GIL acquisition.
//
// # GIL / Rust-lock Safety
//
// The GIL and Rust Mutexes interact through three invariants that MUST be
// maintained to avoid deadlock:
//
// ## Invariant 1 — No Rust lock is held when Python::attach is entered
//
//   `init()`, `tick()`, and `shutdown()` each call `Python::attach`.
//   Before that call, every Rust MutexGuard must already be dropped.
//   `node_context` is only locked for brief, non-blocking operations
//   (`start_tick`, `record_tick`) that complete before Python is invoked.
//
// ## Invariant 2 — The GIL is released before long scheduler operations
//
//   `with_inner_run` calls `py.detach(...)` to release the GIL while
//   `CoreScheduler::run()` executes.  The `self.inner` Mutex is unlocked
//   (taken via `guard.take()`) *before* `detach` is called.
//   Consequently, when Python callbacks re-acquire the GIL inside `tick()`,
//   no PyScheduler-level Mutex is still held.
//
// ## Invariant 3 — Python callbacks MUST NOT re-enter the scheduler
//
//   A Python callback that calls `scheduler.run()` / `scheduler.add()` will
//   find `self.inner == None` and receive `RuntimeError("Scheduler already
//   running")` — an error, not a deadlock.  However, users must never block
//   inside a Python callback waiting for the scheduler to become free;
//   doing so would deadlock the scheduler thread.
//
// ## Summary table
//
//   | Lock         | Held when attach entered?   | Held when Python CB runs? |
//   |------------- |-----------------------------|---------------------------|
//   | GIL          | Yes (that's the whole point)| Yes                       |
//   | node_context | No — released before call   | No                        |
//   | self.inner   | No — taken out before run   | No                        |

struct PyNodeAdapter {
    py_object: Py<PyAny>,
    node_name: String,
    node_context: Arc<Mutex<CoreNodeInfo>>,
    cached_info: Option<Py<PyNodeInfo>>,
    stop_requested: Arc<AtomicBool>,
    scheduler_running: Arc<AtomicBool>,
    publishers_list: Vec<TopicMetadata>,
    subscribers_list: Vec<TopicMetadata>,
}

impl CoreNode for PyNodeAdapter {
    fn name(&self) -> &str {
        &self.node_name
    }

    fn init(&mut self) -> horus_core::error::HorusResult<()> {
        Python::attach(|py| {
            let py_info = Py::new(
                py,
                PyNodeInfo {
                    inner: self.node_context.clone(),
                    scheduler_running: Some(self.scheduler_running.clone()),
                },
            )
            .map_err(|e| HorusError::node(&self.node_name, e.to_string()))?;

            self.cached_info = Some(py_info.clone_ref(py));

            let result = self
                .py_object
                .call_method1(py, "init", (py_info,))
                .or_else(|_| self.py_object.call_method0(py, "init"));

            match result {
                Ok(_) => Ok(()),
                Err(e) => {
                    if e.is_instance_of::<pyo3::exceptions::PyKeyboardInterrupt>(py) {
                        self.stop_requested.store(true, Ordering::Relaxed);
                        Ok(())
                    } else {
                        Err(HorusError::node(
                            &self.node_name,
                            format!("init failed: {}", e),
                        ))
                    }
                }
            }
        })
    }

    fn tick(&mut self) {
        if self.stop_requested.load(Ordering::Relaxed) {
            return;
        }

        Python::attach(|py| {
            // GIL Safety: `node_context` is locked only for the duration of
            // `start_tick()`.  The MutexGuard is dropped at the closing `}` of
            // this if-let block — BEFORE any Python code is invoked.  This
            // ensures no Rust lock is held when the Python callback runs.
            if let Ok(mut ctx) = self.node_context.lock() {
                ctx.start_tick();
            } // ← node_context MutexGuard released here

            let py_info = if let Some(ref cached) = self.cached_info {
                cached.clone_ref(py)
            } else {
                match Py::new(
                    py,
                    PyNodeInfo {
                        inner: self.node_context.clone(),
                        scheduler_running: Some(self.scheduler_running.clone()),
                    },
                ) {
                    Ok(info) => {
                        self.cached_info = Some(info.clone_ref(py));
                        info
                    }
                    Err(_) => return,
                }
            };

            // Python callback runs here.  No Rust Mutex is held at this point
            // (see module-level GIL/lock safety comment).  If the Python code
            // calls back into Rust (e.g., topic.send()), that is safe because
            // node_context and self.inner are unlocked.
            let result = self
                .py_object
                .call_method1(py, "tick", (py_info,))
                .or_else(|_| self.py_object.call_method0(py, "tick"));

            match result {
                Ok(_) => {
                    // node_context is again briefly locked only for record_tick()
                    // and released before the next Python interaction.
                    if let Ok(mut ctx) = self.node_context.lock() {
                        ctx.record_tick();
                    } // ← node_context MutexGuard released here
                }
                Err(e) => {
                    if e.is_instance_of::<pyo3::exceptions::PyKeyboardInterrupt>(py) {
                        self.stop_requested.store(true, Ordering::Relaxed);
                        // Also stop the scheduler via the running flag
                        self.scheduler_running.store(false, Ordering::SeqCst);
                    } else {
                        // Intentional panic: horus_core wraps tick() in catch_unwind
                        // and routes panics through the node's FailurePolicy (restart,
                        // escalate, ignore).  Returning an error here would bypass
                        // the fault-tolerance system.
                        panic!("Python node '{}' tick failed: {}", &self.node_name, e);
                    }
                }
            }
        });
    }

    fn shutdown(&mut self) -> horus_core::error::HorusResult<()> {
        Python::attach(|py| {
            let py_info = if let Some(ref cached) = self.cached_info {
                cached.clone_ref(py)
            } else {
                Py::new(
                    py,
                    PyNodeInfo {
                        inner: self.node_context.clone(),
                        scheduler_running: Some(self.scheduler_running.clone()),
                    },
                )
                .map_err(|e| HorusError::node(&self.node_name, e.to_string()))?
            };

            let result = self
                .py_object
                .call_method1(py, "shutdown", (py_info,))
                .or_else(|_| self.py_object.call_method0(py, "shutdown"));

            match result {
                Ok(_) => Ok(()),
                Err(e) => {
                    if e.is_instance_of::<pyo3::exceptions::PyKeyboardInterrupt>(py) {
                        self.stop_requested.store(true, Ordering::Relaxed);
                        Ok(())
                    } else {
                        Err(HorusError::node(
                            &self.node_name,
                            format!("shutdown failed: {}", e),
                        ))
                    }
                }
            }
        })
    }

    fn publishers(&self) -> Vec<TopicMetadata> {
        self.publishers_list.clone()
    }

    fn subscribers(&self) -> Vec<TopicMetadata> {
        self.subscribers_list.clone()
    }
}

// ─── PyMiss ─────────────────────────────────────────────────────────────────

/// Deadline miss policy for a node.
///
/// Set via `.on_miss()` on the node builder.
///
/// Example:
///     scheduler.node(motor).order(0).rate_hz(1000).on_miss(Miss.SAFE_MODE).done()
#[pyclass(name = "Miss", module = "horus._horus")]
#[derive(Debug, Clone, Copy)]
pub struct PyMiss;

#[pymethods]
impl PyMiss {
    /// Log warning and continue normally.
    #[classattr]
    const WARN: &'static str = "warn";

    /// Skip this tick, resume next cycle.
    #[classattr]
    const SKIP: &'static str = "skip";

    /// Enter safe mode — calls `enter_safe_state()` on the node.
    #[classattr]
    const SAFE_MODE: &'static str = "safe_mode";

    /// Stop the entire scheduler (last resort).
    #[classattr]
    const STOP: &'static str = "stop";
}

/// Parse a miss policy string into a `Miss` enum.
fn parse_miss_policy(name: &str) -> PyResult<Miss> {
    match name {
        "warn" => Ok(Miss::Warn),
        "skip" => Ok(Miss::Skip),
        "safe_mode" => Ok(Miss::SafeMode),
        "stop" => Ok(Miss::Stop),
        _ => Err(PyRuntimeError::new_err(format!(
            "Unknown miss policy '{}'. Use Miss.WARN, Miss.SKIP, Miss.SAFE_MODE, or Miss.STOP",
            name
        ))),
    }
}

// ─── PyNodeBuilder ───────────────────────────────────────────────────────────

/// Fluent builder for adding nodes to the scheduler.
///
/// Example:
///     scheduler.add(my_node).order(0).rate_hz(100.0).budget(500).build()
#[pyclass(name = "NodeBuilder", module = "horus._horus")]
pub struct PyNodeBuilder {
    scheduler: Py<PyScheduler>,
    node: Py<PyAny>,
    order: u32,
    rate_hz: Option<f64>,
    rt: bool,
    deadline_ms: Option<f64>,
    budget_us: Option<u64>,
    failure_policy: Option<FailurePolicy>,
    miss_policy: Option<String>,
    execution_class: Option<ExecutionClass>,
}

#[pymethods]
impl PyNodeBuilder {
    /// Set execution order (lower = earlier in tick sequence).
    fn order(mut slf: PyRefMut<'_, Self>, order: u32) -> PyRefMut<'_, Self> {
        slf.order = order;
        slf
    }

    /// Set node-specific tick rate in Hz.
    fn rate_hz(mut slf: PyRefMut<'_, Self>, rate: f64) -> PyRefMut<'_, Self> {
        slf.rate_hz = Some(rate);
        slf
    }

    /// Set soft deadline in milliseconds.
    fn deadline(mut slf: PyRefMut<'_, Self>, ms: f64) -> PyRefMut<'_, Self> {
        slf.deadline_ms = Some(ms);
        slf.rt = true;
        slf
    }

    /// Set tick budget in microseconds.
    fn budget(mut slf: PyRefMut<'_, Self>, us: u64) -> PyRefMut<'_, Self> {
        slf.budget_us = Some(us);
        slf.rt = true;
        slf
    }

    /// Set deadline miss policy: Miss.WARN, Miss.SKIP, Miss.SAFE_MODE, Miss.STOP.
    fn on_miss(mut slf: PyRefMut<'_, Self>, policy: String) -> PyRefMut<'_, Self> {
        slf.miss_policy = Some(policy);
        slf
    }

    /// Set failure policy: "fatal", "restart", "skip", "ignore".
    ///
    /// For "restart" and "skip", optional keyword arguments control behavior:
    ///   - restart: max_retries (default 5), backoff_ms (default 100)
    ///   - skip: max_failures (default 5), cooldown_ms (default 30000)
    ///
    /// Examples:
    ///     .failure_policy("restart")
    ///     .failure_policy("restart", max_retries=10, backoff_ms=200)
    ///     .failure_policy("skip", max_failures=3, cooldown_ms=5000)
    #[pyo3(signature = (name, max_retries=None, backoff_ms=None, max_failures=None, cooldown_ms=None))]
    fn failure_policy(
        mut slf: PyRefMut<'_, Self>,
        name: String,
        max_retries: Option<u32>,
        backoff_ms: Option<u64>,
        max_failures: Option<u32>,
        cooldown_ms: Option<u64>,
    ) -> PyResult<PyRefMut<'_, Self>> {
        let policy = match name.as_str() {
            "fatal" => FailurePolicy::Fatal,
            "restart" => {
                FailurePolicy::restart(max_retries.unwrap_or(5), backoff_ms.unwrap_or(100))
            }
            "skip" => FailurePolicy::skip(max_failures.unwrap_or(5), cooldown_ms.unwrap_or(30_000)),
            "ignore" => FailurePolicy::Ignore,
            _ => {
                return Err(PyRuntimeError::new_err(format!(
                    "Unknown failure policy '{}'. Use 'fatal', 'restart', 'skip', or 'ignore'",
                    name
                )));
            }
        };
        slf.failure_policy = Some(policy);
        Ok(slf)
    }

    /// Set event-driven execution: node ticks when `topic` receives data.
    ///
    /// Example:
    ///     scheduler.node(detector).on("lidar_scan").done()
    fn on(mut slf: PyRefMut<'_, Self>, topic: String) -> PyRefMut<'_, Self> {
        slf.execution_class = Some(ExecutionClass::Event(topic));
        slf
    }

    /// Set compute execution class (CPU-bound work on thread pool).
    fn compute(mut slf: PyRefMut<'_, Self>) -> PyRefMut<'_, Self> {
        slf.execution_class = Some(ExecutionClass::Compute);
        slf
    }

    /// Set async I/O execution class (I/O-bound work on tokio runtime).
    fn async_io(mut slf: PyRefMut<'_, Self>) -> PyRefMut<'_, Self> {
        slf.execution_class = Some(ExecutionClass::AsyncIo);
        slf
    }

    /// Finalize and add the node to the scheduler.
    fn done(slf: PyRefMut<'_, Self>, py: Python) -> PyResult<Py<PyScheduler>> {
        let scheduler = slf.scheduler.clone_ref(py);
        let node = slf.node.clone_ref(py);
        let order = slf.order;
        let rate_hz = slf.rate_hz;
        let rt = slf.rt;
        let deadline_ms = slf.deadline_ms;
        let budget_us = slf.budget_us;
        let failure_policy = slf.failure_policy.clone();
        let miss_policy = slf.miss_policy.clone();
        let execution_class = slf.execution_class.clone();
        drop(slf);

        {
            let sched = scheduler.borrow(py);
            sched.add_node_internal(
                py,
                node,
                order,
                rate_hz,
                rt,
                deadline_ms,
                budget_us,
                failure_policy,
                miss_policy,
                execution_class,
            )?;
        }

        Ok(scheduler)
    }
}

// ─── PyScheduler ─────────────────────────────────────────────────────────────

/// Python wrapper for HORUS Scheduler wrapping horus_core::Scheduler.
///
/// All scheduling logic (rate control, fault tolerance, watchdog, deadline,
/// parallel execution, record/replay, telemetry, etc.) is handled by horus_core.
/// Python API is preserved with zero breaking changes.
#[pyclass(name = "Scheduler", module = "horus._horus")]
pub struct PyScheduler {
    inner: Mutex<Option<CoreScheduler>>,
    tick_rate_hz: f64,
    scheduler_running: Arc<AtomicBool>,
    stop_requested: Arc<AtomicBool>,
    removed_nodes: Mutex<HashSet<String>>,
}

impl PyScheduler {
    fn wrap_core(core_sched: CoreScheduler, tick_rate: f64) -> PyResult<Self> {
        let running_flag = core_sched.running_flag();
        Ok(PyScheduler {
            inner: Mutex::new(Some(core_sched)),
            tick_rate_hz: tick_rate,
            scheduler_running: running_flag,
            stop_requested: Arc::new(AtomicBool::new(false)),
            removed_nodes: Mutex::new(HashSet::new()),
        })
    }

    #[allow(clippy::too_many_arguments)] // Internal helper mirrors PyO3 add() kwargs; no meaningful grouping
    fn add_node_internal(
        &self,
        py: Python,
        node: Py<PyAny>,
        order: u32,
        rate_hz: Option<f64>,
        rt: bool,
        deadline_ms: Option<f64>,
        budget_us: Option<u64>,
        failure_policy: Option<FailurePolicy>,
        miss_policy: Option<String>,
        execution_class: Option<ExecutionClass>,
    ) -> PyResult<()> {
        let name: String = node.getattr(py, "name")?.extract(py)?;

        let publishers: Vec<TopicMetadata> = node
            .getattr(py, "pub_topics")
            .and_then(|attr| attr.extract::<Vec<String>>(py))
            .unwrap_or_default()
            .into_iter()
            .map(|t| TopicMetadata {
                topic_name: t,
                type_name: "dynamic".into(),
            })
            .collect();

        let subscribers: Vec<TopicMetadata> = node
            .getattr(py, "sub_topics")
            .and_then(|attr| attr.extract::<Vec<String>>(py))
            .unwrap_or_default()
            .into_iter()
            .map(|t| TopicMetadata {
                topic_name: t,
                type_name: "dynamic".into(),
            })
            .collect();

        // Rate precedence: explicit param > node.rate attribute > None (scheduler default)
        let node_rate = rate_hz.or_else(|| {
            node.getattr(py, "rate")
                .and_then(|attr| attr.extract::<f64>(py))
                .ok()
        });

        let adapter = PyNodeAdapter {
            py_object: node,
            node_name: name.clone(),
            node_context: Arc::new(Mutex::new(CoreNodeInfo::new(name.clone()))),
            cached_info: None,
            stop_requested: self.stop_requested.clone(),
            scheduler_running: self.scheduler_running.clone(),
            publishers_list: publishers,
            subscribers_list: subscribers,
        };

        let mut config = NodeRegistration::new(Box::new(adapter)).order(order);
        if let Some(rate) = node_rate {
            config = config.rate_hz(rate);
        }
        if let Some(ms) = deadline_ms {
            config = config.deadline(std::time::Duration::from_millis(ms as u64));
        }
        if let Some(us) = budget_us {
            config = config.budget(std::time::Duration::from_micros(us));
        }
        if let Some(ref miss_name) = miss_policy {
            config = config.on_miss(parse_miss_policy(miss_name)?);
        }
        if let Some(policy) = failure_policy {
            config = config.failure_policy(policy);
        }
        if let Some(exec_class) = execution_class {
            match exec_class {
                ExecutionClass::Event(ref topic) => {
                    config = config.on(topic);
                }
                ExecutionClass::Compute => {
                    config = config.compute();
                }
                ExecutionClass::AsyncIo => {
                    config = config.async_io();
                }
                ExecutionClass::Rt => {
                    // RT is set implicitly via deadline/budget
                }
                ExecutionClass::BestEffort => {} // default, no-op
            }
        }

        let mut guard = self
            .inner
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
        let inner = guard.as_mut().ok_or_else(|| {
            PyRuntimeError::new_err("Cannot add nodes while scheduler is running")
        })?;

        config
            .validate()
            .map_err(|e| PyRuntimeError::new_err(format!("Invalid node configuration: {}", e)))?;
        inner.add_configured(config);

        tracing::info!(
            node = %name,
            order,
            rate = %node_rate.map_or("scheduler".to_string(), |r| format!("{}Hz", r)),
            rt,
            "Added node",
        );

        Ok(())
    }

    /// Build a Python dict from a NodeMetrics.
    fn metric_to_dict(py: Python, metric: &NodeMetrics, tick_rate: f64) -> PyResult<Py<PyAny>> {
        let dict = PyDict::new(py);
        dict.set_item("name", &metric.name)?;
        dict.set_item("order", metric.order)?;
        dict.set_item("rate_hz", tick_rate)?;
        dict.set_item("total_ticks", metric.total_ticks)?;
        dict.set_item("successful_ticks", metric.successful_ticks)?;
        dict.set_item("failed_ticks", metric.failed_ticks)?;
        dict.set_item("errors_count", metric.errors_count)?;
        dict.set_item("avg_tick_duration_ms", metric.avg_tick_duration_ms)?;
        dict.set_item("min_tick_duration_ms", metric.min_tick_duration_ms)?;
        dict.set_item("max_tick_duration_ms", metric.max_tick_duration_ms)?;
        dict.set_item("last_tick_duration_ms", metric.last_tick_duration_ms)?;
        dict.set_item("uptime_seconds", metric.uptime_seconds)?;
        dict.set_item("messages_sent", metric.messages_sent)?;
        dict.set_item("messages_received", metric.messages_received)?;

        Ok(dict.into())
    }

    /// Helper: take the inner scheduler, run a closure, put it back.
    fn with_inner_run<F>(&self, py: Python, f: F) -> PyResult<()>
    where
        F: FnOnce(&mut CoreScheduler) -> horus_core::error::HorusResult<()> + Send,
        // CoreScheduler must be Send for py.detach
    {
        let mut inner = {
            let mut guard = self
                .inner
                .lock()
                .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
            guard.take().ok_or_else(|| {
                PyRuntimeError::new_err("Scheduler already running or not initialized")
            })?
        };

        self.stop_requested.store(false, Ordering::Relaxed);

        let result = py.detach(move || {
            let r = f(&mut inner);
            (inner, r)
        });

        let (returned_inner, run_result) = result;

        {
            let mut guard = self
                .inner
                .lock()
                .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
            *guard = Some(returned_inner);
        }

        run_result.map_err(|e| PyRuntimeError::new_err(e.to_string()))
    }
}

#[pymethods]
impl PyScheduler {
    #[new]
    #[pyo3(signature = (config=None))]
    pub fn new(config: Option<PySchedulerConfig>) -> PyResult<Self> {
        let core_config = config
            .as_ref()
            .map(|c| c.to_core_config())
            .unwrap_or_default();

        let tick_rate = config.as_ref().map_or(100.0, |c| c.tick_rate);

        let mut core_sched = CoreScheduler::new().with_name("PythonScheduler");
        core_sched.apply_config(core_config);
        Self::wrap_core(core_sched, tick_rate)
    }

    /// Start building a node configuration (fluent API).
    fn node(slf: Py<Self>, _py: Python, node: Py<PyAny>) -> PyResult<PyNodeBuilder> {
        Ok(PyNodeBuilder {
            scheduler: slf,
            node,
            order: 100,
            rate_hz: None,
            rt: false,
            deadline_ms: None,
            budget_us: None,
            failure_policy: None,
            miss_policy: None,
            execution_class: None,
        })
    }

    /// Add a node to the scheduler.
    #[allow(clippy::too_many_arguments)] // PyO3 kwargs match Python __init__ convention
    #[pyo3(signature = (node, order=100, rate_hz=None, rt=false, deadline=None, budget=None, failure_policy=None, on_miss=None))]
    fn add(
        &self,
        py: Python,
        node: Py<PyAny>,
        order: u32,
        rate_hz: Option<f64>,
        rt: bool,
        deadline: Option<f64>,
        budget: Option<u64>,
        failure_policy: Option<String>,
        on_miss: Option<String>,
    ) -> PyResult<()> {
        let parsed_policy = match failure_policy.as_deref() {
            Some("fatal") => Some(FailurePolicy::Fatal),
            Some("restart") => Some(FailurePolicy::restart(5, 100)),
            Some("skip") => Some(FailurePolicy::skip(5, 30_000)),
            Some("ignore") => Some(FailurePolicy::Ignore),
            Some(other) => {
                return Err(PyRuntimeError::new_err(format!(
                    "Unknown failure policy '{}'. Use 'fatal', 'restart', 'skip', or 'ignore'",
                    other
                )));
            }
            None => None,
        };
        self.add_node_internal(
            py,
            node,
            order,
            rate_hz,
            rt,
            deadline,
            budget,
            parsed_policy,
            on_miss,
            None,
        )
    }

    /// Set per-node rate control
    fn set_node_rate(&self, node_name: String, rate_hz: f64) -> PyResult<()> {
        if rate_hz <= 0.0 || rate_hz > 10000.0 {
            return Err(PyRuntimeError::new_err(
                "Rate must be between 0 and 10000 Hz",
            ));
        }

        let mut guard = self
            .inner
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
        let inner = guard
            .as_mut()
            .ok_or_else(|| PyRuntimeError::new_err("Cannot modify while scheduler is running"))?;

        inner.set_node_rate(&node_name, rate_hz);
        tracing::info!(node = %node_name, rate_hz, "Set node rate");
        Ok(())
    }

    /// Run the scheduler indefinitely (until stop() or Ctrl+C).
    fn run(&self, py: Python) -> PyResult<()> {
        self.with_inner_run(py, |sched| sched.run())
    }

    /// Run the scheduler for a specified duration (in seconds).
    fn run_for(&self, py: Python, duration_seconds: f64) -> PyResult<()> {
        if duration_seconds <= 0.0 {
            return Err(PyRuntimeError::new_err("Duration must be positive"));
        }
        let duration = Duration::from_secs_f64(duration_seconds);
        self.with_inner_run(py, move |sched| sched.run_for(duration))
    }

    /// Run specific nodes by name (continuously until stop()).
    fn tick(&self, py: Python, node_names: Vec<String>) -> PyResult<()> {
        self.with_inner_run(py, move |sched| {
            let refs: Vec<&str> = node_names.iter().map(|s| s.as_str()).collect();
            sched.tick(&refs)
        })
    }

    /// Run specific nodes for a specified duration (in seconds).
    fn tick_for(&self, py: Python, node_names: Vec<String>, duration_seconds: f64) -> PyResult<()> {
        if duration_seconds <= 0.0 {
            return Err(PyRuntimeError::new_err("Duration must be positive"));
        }
        let duration = Duration::from_secs_f64(duration_seconds);
        self.with_inner_run(py, move |sched| {
            let refs: Vec<&str> = node_names.iter().map(|s| s.as_str()).collect();
            sched.tick_for(&refs, duration)
        })
    }

    /// Stop the scheduler.
    fn stop(&self) -> PyResult<()> {
        self.stop_requested.store(true, Ordering::Relaxed);
        self.scheduler_running.store(false, Ordering::SeqCst);
        Ok(())
    }

    /// Check if the scheduler is running.
    fn is_running(&self) -> PyResult<bool> {
        Ok(self.scheduler_running.load(Ordering::SeqCst))
    }

    /// Get node statistics.
    fn get_node_stats(&self, py: Python, node_name: String) -> PyResult<Py<PyAny>> {
        let guard = self
            .inner
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
        let inner = guard.as_ref().ok_or_else(|| {
            PyRuntimeError::new_err("Stats unavailable while scheduler is running")
        })?;

        for metric in inner.metrics() {
            if metric.name == node_name {
                return Self::metric_to_dict(py, &metric, self.tick_rate_hz);
            }
        }

        Err(crate::errors::HorusNotFoundError::new_err(format!(
            "Node '{}' not found",
            node_name
        )))
    }

    /// Remove a node from the scheduler.
    /// Note: horus_core doesn't support runtime removal; node is excluded from stats.
    fn remove_node(&self, name: String) -> PyResult<bool> {
        let mut removed = self
            .removed_nodes
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
        Ok(removed.insert(name))
    }

    /// Get list of all nodes with basic information.
    fn get_all_nodes(&self, py: Python) -> PyResult<Vec<Py<PyAny>>> {
        let guard = self
            .inner
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
        let inner = guard.as_ref().ok_or_else(|| {
            PyRuntimeError::new_err("Node list unavailable while scheduler is running")
        })?;
        let removed = self
            .removed_nodes
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;

        let mut result = Vec::new();
        for metric in inner.metrics() {
            if removed.contains(&metric.name) {
                continue;
            }
            result.push(Self::metric_to_dict(py, &metric, self.tick_rate_hz)?);
        }

        Ok(result)
    }

    /// Get count of all registered nodes.
    fn get_node_count(&self) -> PyResult<usize> {
        let guard = self
            .inner
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
        match guard.as_ref() {
            Some(sched) => {
                let removed = self
                    .removed_nodes
                    .lock()
                    .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
                Ok(sched.node_list().len() - removed.len())
            }
            None => Ok(0),
        }
    }

    /// Check if a node exists by name.
    fn has_node(&self, name: String) -> PyResult<bool> {
        let guard = self
            .inner
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
        match guard.as_ref() {
            Some(sched) => {
                let removed = self
                    .removed_nodes
                    .lock()
                    .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
                Ok(sched.node_list().contains(&name) && !removed.contains(&name))
            }
            None => Ok(false),
        }
    }

    /// Get list of node names.
    fn get_node_names(&self) -> PyResult<Vec<String>> {
        let guard = self
            .inner
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
        match guard.as_ref() {
            Some(sched) => {
                let removed = self
                    .removed_nodes
                    .lock()
                    .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
                Ok(sched
                    .node_list()
                    .into_iter()
                    .filter(|n| !removed.contains(n))
                    .collect())
            }
            None => Ok(Vec::new()),
        }
    }

    /// Get node order (execution priority).
    fn get_node_info(&self, name: String) -> PyResult<Option<u32>> {
        let guard = self
            .inner
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
        match guard.as_ref() {
            Some(sched) => {
                for metric in sched.metrics() {
                    if metric.name == name {
                        return Ok(Some(metric.order));
                    }
                }
                Ok(None)
            }
            None => Ok(None),
        }
    }

    // ========================================================================
    // Status & Diagnostics
    // ========================================================================

    /// Get scheduler status string.
    fn status(&self) -> PyResult<String> {
        let guard = self
            .inner
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
        match guard.as_ref() {
            Some(sched) => Ok(sched.status()),
            None => Ok("running".to_string()),
        }
    }

    /// Get runtime capabilities as a dict.
    fn capabilities(&self, py: Python) -> PyResult<Option<Py<PyAny>>> {
        let guard = self
            .inner
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
        let inner = match guard.as_ref() {
            Some(s) => s,
            None => return Ok(None),
        };
        match inner.capabilities() {
            Some(caps) => {
                let dict = PyDict::new(py);
                dict.set_item("preempt_rt", caps.preempt_rt)?;
                dict.set_item("rt_priority_available", caps.rt_priority_available)?;
                dict.set_item("max_rt_priority", caps.max_rt_priority)?;
                dict.set_item("min_rt_priority", caps.min_rt_priority)?;
                Ok(Some(dict.into()))
            }
            None => Ok(None),
        }
    }

    /// Check if full real-time capabilities are available.
    fn has_full_rt(&self) -> PyResult<bool> {
        let guard = self
            .inner
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
        match guard.as_ref() {
            Some(sched) => Ok(sched.has_full_rt()),
            None => Ok(false),
        }
    }

    /// Get RT degradations as a list of dicts.
    fn degradations(&self, py: Python) -> PyResult<Py<PyAny>> {
        let guard = self
            .inner
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
        let inner = match guard.as_ref() {
            Some(s) => s,
            None => return Ok(PyList::empty(py).into()),
        };
        let result = PyList::empty(py);
        for deg in inner.degradations() {
            let dict = PyDict::new(py);
            dict.set_item("feature", format!("{:?}", deg.feature))?;
            dict.set_item("reason", &deg.reason)?;
            dict.set_item("severity", format!("{:?}", deg.severity))?;
            result.append(dict)?;
        }
        Ok(result.into())
    }

    /// Get the current tick count.
    fn current_tick(&self) -> PyResult<u64> {
        let guard = self
            .inner
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
        match guard.as_ref() {
            Some(sched) => Ok(sched.current_tick()),
            None => Ok(0),
        }
    }

    /// Get the scheduler name.
    fn scheduler_name(&self) -> PyResult<String> {
        let guard = self
            .inner
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
        match guard.as_ref() {
            Some(sched) => Ok(sched.scheduler_name().to_string()),
            None => Ok("PythonScheduler".to_string()),
        }
    }

    // ========================================================================
    // Safety Stats
    // ========================================================================

    /// Get safety statistics as a dict.
    fn safety_stats(&self, py: Python) -> PyResult<Option<Py<PyAny>>> {
        let guard = self
            .inner
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
        let inner = match guard.as_ref() {
            Some(s) => s,
            None => return Ok(None),
        };
        match inner.safety_stats() {
            Some(stats) => {
                let dict = PyDict::new(py);
                dict.set_item("state", format!("{:?}", stats.state))?;
                dict.set_item("budget_overruns", stats.budget_overruns)?;
                dict.set_item("deadline_misses", stats.deadline_misses)?;
                dict.set_item("watchdog_expirations", stats.watchdog_expirations)?;
                dict.set_item("safe_mode_activations", stats.safe_mode_activations)?;
                Ok(Some(dict.into()))
            }
            None => Ok(None),
        }
    }

    // ========================================================================
    // Recording
    // ========================================================================

    /// Check if scheduler is currently recording.
    fn is_recording(&self) -> PyResult<bool> {
        let guard = self
            .inner
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
        match guard.as_ref() {
            Some(sched) => Ok(sched.is_recording()),
            None => Ok(false),
        }
    }

    /// Check if scheduler is currently replaying.
    fn is_replaying(&self) -> PyResult<bool> {
        let guard = self
            .inner
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
        match guard.as_ref() {
            Some(sched) => Ok(sched.is_replaying()),
            None => Ok(false),
        }
    }

    /// Stop recording and return saved file paths.
    fn stop_recording(&self) -> PyResult<Vec<String>> {
        let mut guard = self
            .inner
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
        let inner = guard.as_mut().ok_or_else(|| {
            PyRuntimeError::new_err("Cannot stop recording while scheduler is running")
        })?;
        let paths = inner
            .stop_recording()
            .map_err(|e| PyRuntimeError::new_err(e.to_string()))?;
        Ok(paths.into_iter().map(|p| p.display().to_string()).collect())
    }

    /// List available recordings.
    #[staticmethod]
    fn list_recordings() -> PyResult<Vec<String>> {
        CoreScheduler::list_recordings().map_err(|e| PyRuntimeError::new_err(e.to_string()))
    }

    // ========================================================================
    // Node Control
    // ========================================================================

    /// Set tick budget for a node in microseconds.
    fn set_tick_budget(&self, node_name: String, us: u64) -> PyResult<()> {
        let mut guard = self
            .inner
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
        let inner = guard.as_mut().ok_or_else(|| {
            PyRuntimeError::new_err("Cannot set tick budget while scheduler is running")
        })?;
        inner
            .set_tick_budget(&node_name, Duration::from_micros(us))
            .map_err(|e| PyRuntimeError::new_err(e.to_string()))?;
        Ok(())
    }

    /// Add a critical node with watchdog timeout in milliseconds.
    fn add_critical_node(&self, node_name: String, timeout_ms: u64) -> PyResult<()> {
        let mut guard = self
            .inner
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
        let inner = guard.as_mut().ok_or_else(|| {
            PyRuntimeError::new_err("Cannot add critical node while scheduler is running")
        })?;
        inner
            .add_critical_node(&node_name, Duration::from_millis(timeout_ms))
            .map_err(|e| PyRuntimeError::new_err(e.to_string()))?;
        Ok(())
    }

    /// Delete a recording by session name.
    #[staticmethod]
    fn delete_recording(session_name: String) -> PyResult<()> {
        CoreScheduler::delete_recording(&session_name)
            .map_err(|e| PyRuntimeError::new_err(e.to_string()))
    }

    fn __repr__(&self) -> PyResult<String> {
        let guard = self
            .inner
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))?;
        let count = guard.as_ref().map(|s| s.node_list().len()).unwrap_or(0);
        Ok(format!(
            "Scheduler(nodes={}, tick_rate={}Hz)",
            count, self.tick_rate_hz
        ))
    }

    /// Pickle support: Get state for serialization.
    fn __getstate__(&self, py: Python) -> PyResult<Py<PyAny>> {
        let state = PyDict::new(py);
        state.set_item("tick_rate_hz", self.tick_rate_hz)?;
        Ok(state.into())
    }

    /// Pickle support: Restore state from deserialization.
    fn __setstate__(&mut self, state: &Bound<'_, PyDict>) -> PyResult<()> {
        let tick_rate_hz: f64 = state
            .get_item("tick_rate_hz")?
            .ok_or_else(|| PyRuntimeError::new_err("Missing 'tick_rate_hz' in pickled state"))?
            .extract()?;

        self.tick_rate_hz = tick_rate_hz;

        let core_sched = CoreScheduler::new().with_name("PythonScheduler");
        self.scheduler_running = core_sched.running_flag();
        *self
            .inner
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))? = Some(core_sched);
        *self
            .removed_nodes
            .lock()
            .map_err(|_| PyRuntimeError::new_err("Internal lock poisoned"))? = HashSet::new();

        Ok(())
    }
}

// ─── Lock-ordering safety tests ─────────────────────────────────────────────
#[cfg(test)]
mod tests {
    use super::*;
    use horus_core::core::NodeInfo as CoreNodeInfo;
    use std::sync::atomic::AtomicBool;
    use std::sync::{Arc, Mutex};

    /// Verify that `node_context` is NOT locked when a Python callback is
    /// about to be invoked inside `tick()`.
    ///
    /// We simulate this by cloning the Arc, and — inside a mock "callback" —
    /// using `try_lock()` to assert the mutex is free.  If the lock were held
    /// across the callback site, `try_lock` would return `Err(WouldBlock)`.
    ///
    /// This test does not require PyO3 / a live Python interpreter.
    #[test]
    fn node_context_not_locked_at_callback_site() {
        let node_context = Arc::new(Mutex::new(CoreNodeInfo::new("test_node".to_string())));
        let observer = node_context.clone();

        // Simulate the sequence inside PyNodeAdapter::tick():
        // 1. Acquire and release lock for start_tick
        {
            let mut ctx = node_context.lock().unwrap();
            ctx.start_tick();
        } // ← lock released

        // 2. "Python callback" runs — must be able to acquire node_context
        let try_result = observer.try_lock();
        assert!(
            try_result.is_ok(),
            "node_context was still locked at Python callback site — \
             GIL deadlock would occur if Python re-entered Rust here"
        );
        drop(try_result);

        // 3. Acquire and release lock for record_tick
        {
            let mut ctx = node_context.lock().unwrap();
            ctx.record_tick();
        }
    }

    /// Verify that `scheduler_running` can be observed from a second thread
    /// while a node callback is running (simulates topic.send() pattern).
    #[test]
    fn scheduler_running_flag_readable_during_callback() {
        let running = Arc::new(AtomicBool::new(true));
        let observer = running.clone();

        // Simulate: scheduler is running
        running.store(true, Ordering::SeqCst);

        // Another thread (e.g., Python callback invoking topic.send()) can
        // read the flag without blocking.
        let handle = std::thread::spawn(move || observer.load(Ordering::SeqCst));

        assert!(
            handle.join().unwrap(),
            "Running flag should be observable from callback thread"
        );
    }

    /// Verify tick metrics accumulate correctly across multiple ticks.
    #[test]
    fn node_context_tick_metrics_accumulate() {
        let ctx = Arc::new(Mutex::new(CoreNodeInfo::new("metrics_test".to_string())));

        for _ in 0..5 {
            let mut guard = ctx.lock().unwrap();
            guard.start_tick();
            guard.record_tick();
        }

        let guard = ctx.lock().unwrap();
        assert_eq!(guard.metrics().total_ticks, 5);
        assert_eq!(guard.metrics().successful_ticks, 5);
    }

    /// Verify that failed ticks are tracked separately.
    #[test]
    fn node_context_failed_ticks_tracked() {
        let ctx = Arc::new(Mutex::new(CoreNodeInfo::new("fail_test".to_string())));

        // 3 successful ticks
        for _ in 0..3 {
            let mut guard = ctx.lock().unwrap();
            guard.start_tick();
            guard.record_tick();
        }

        // 2 failed ticks
        for _ in 0..2 {
            let mut guard = ctx.lock().unwrap();
            guard.start_tick();
            guard.record_tick_failure("test error".to_string());
        }

        let guard = ctx.lock().unwrap();
        assert_eq!(guard.metrics().total_ticks, 5);
        assert_eq!(guard.metrics().successful_ticks, 3);
        assert_eq!(guard.metrics().failed_ticks, 2);
    }

    /// Concurrent access to node_context from multiple threads.
    #[test]
    fn node_context_concurrent_access() {
        let ctx = Arc::new(Mutex::new(CoreNodeInfo::new("concurrent".to_string())));

        let handles: Vec<_> = (0..4)
            .map(|_| {
                let ctx = ctx.clone();
                std::thread::spawn(move || {
                    for _ in 0..10 {
                        let mut guard = ctx.lock().unwrap();
                        guard.start_tick();
                        guard.record_tick();
                    }
                })
            })
            .collect();

        for h in handles {
            h.join().unwrap();
        }

        let guard = ctx.lock().unwrap();
        assert_eq!(guard.metrics().total_ticks, 40);
    }

    /// Verify stop_requested flag propagates across threads.
    #[test]
    fn stop_requested_flag_propagates() {
        let stop = Arc::new(AtomicBool::new(false));
        let observer = stop.clone();

        assert!(!stop.load(Ordering::SeqCst));

        // Simulate request_stop from Python
        stop.store(true, Ordering::SeqCst);

        let saw_stop = std::thread::spawn(move || observer.load(Ordering::SeqCst))
            .join()
            .unwrap();
        assert!(saw_stop, "Stop flag should propagate to other threads");
    }
}
