//! Python bindings for OCG (OpenCypher Graph) using PyO3
//!
//! This module provides Python-friendly wrappers around the core Rust functionality.

use pyo3::prelude::*;
use pyo3::types::{PyDate, PyDateTime, PyDelta, PyDict, PyList, PyTime, PyTzInfo};
use pyo3::exceptions::{PyRuntimeError, PyValueError};
use std::collections::HashMap;

// pyo3 0.28 compatibility alias
type PyObject = Py<PyAny>;

use crate::{GraphEdge, GraphNode, PropertyGraph, execute_with_params, CypherValue, PropertyValue};
use crate::graph::backends::PetgraphAlgorithms;
use crate::graph::backends::petgraph_algorithms::CommunityInfo;
use crate::graph::backends::{NetworKitRustBackend, RustworkxCoreBackend, GraphrsBackend};
use crate::graph::serialization::{SerializedEdge, dump_to_file, load_dump};
#[cfg(feature = "persistence")]
use crate::persistence::wal::{StandaloneWal, StandaloneWalConfig, StandaloneWalSyncMode, WalOp};

/// Convert Rust CypherValue to Python object
fn cypher_value_to_py(py: Python, value: &CypherValue) -> PyResult<PyObject> {
    match value {
        CypherValue::Null => Ok(py.None()),
        CypherValue::Boolean(b) => Ok(b.into_pyobject(py).unwrap().to_owned().into_any().unbind()),
        CypherValue::Integer(i) => Ok(i.into_pyobject(py).unwrap().to_owned().into_any().unbind()),
        CypherValue::Float(f) => Ok(f.into_pyobject(py).unwrap().to_owned().into_any().unbind()),
        CypherValue::String(s) => Ok(s.into_pyobject(py).unwrap().to_owned().into_any().unbind()),
        CypherValue::List(list) => {
            let py_list = PyList::empty(py);
            for item in list {
                py_list.append(cypher_value_to_py(py, item)?)?;
            }
            Ok(py_list.into_pyobject(py).unwrap().into_any().unbind())
        }
        CypherValue::Map(map) => {
            let py_dict = PyDict::new(py);
            for (key, value) in map {
                py_dict.set_item(key, cypher_value_to_py(py, value)?)?;
            }
            Ok(py_dict.into_pyobject(py).unwrap().into_any().unbind())
        }
        CypherValue::Node(node) => {
            let py_dict = PyDict::new(py);
            py_dict.set_item("id", node.id)?;
            py_dict.set_item("labels", node.labels.iter().collect::<Vec<_>>())?;

            let props_dict = PyDict::new(py);
            for (key, value) in &node.properties {
                props_dict.set_item(key, cypher_value_to_py(py, value)?)?;
            }
            py_dict.set_item("properties", props_dict)?;
            Ok(py_dict.into_pyobject(py).unwrap().into_any().unbind())
        }
        CypherValue::Relationship(rel) => {
            let py_dict = PyDict::new(py);
            py_dict.set_item("id", rel.id)?;
            py_dict.set_item("start_node_id", rel.start_node_id)?;
            py_dict.set_item("end_node_id", rel.end_node_id)?;
            py_dict.set_item("type", &rel.rel_type)?;

            let props_dict = PyDict::new(py);
            for (key, value) in &rel.properties {
                props_dict.set_item(key, cypher_value_to_py(py, value)?)?;
            }
            py_dict.set_item("properties", props_dict)?;
            Ok(py_dict.into_pyobject(py).unwrap().into_any().unbind())
        }
        CypherValue::Path(path) => {
            let py_dict = PyDict::new(py);

            let nodes_list = PyList::empty(py);
            for node in &path.nodes {
                nodes_list.append(cypher_value_to_py(py, &CypherValue::Node(node.clone()))?)?;
            }
            py_dict.set_item("nodes", nodes_list)?;

            let rels_list = PyList::empty(py);
            for rel in &path.relationships {
                rels_list.append(cypher_value_to_py(py, &CypherValue::Relationship(rel.clone()))?)?;
            }
            py_dict.set_item("relationships", rels_list)?;

            Ok(py_dict.into_pyobject(py).unwrap().into_any().unbind())
        }
        // Temporal types - convert to proper Python datetime objects
        CypherValue::Date(d) => {
            let py_date = PyDate::new(py, d.year(), d.month() as u8, d.day() as u8)?;
            Ok(py_date.into_any().unbind())
        }
        CypherValue::LocalTime(lt) => {
            let nanos = lt.nanosecond();
            let microsecond = nanos / 1_000;
            let py_time = PyTime::new(py, lt.hour() as u8, lt.minute() as u8, lt.second() as u8, microsecond, None)?;
            Ok(py_time.into_any().unbind())
        }
        CypherValue::LocalDateTime(ldt) => {
            let nanos = ldt.nanosecond();
            let microsecond = nanos / 1_000;
            let py_dt = PyDateTime::new(
                py,
                ldt.year(),
                ldt.month() as u8,
                ldt.day() as u8,
                ldt.hour() as u8,
                ldt.minute() as u8,
                ldt.second() as u8,
                microsecond,
                None,
            )?;
            Ok(py_dt.into_any().unbind())
        }
        CypherValue::Time(t) => {
            // Time with timezone offset - create with tzinfo
            let offset_secs = t.offset.local_minus_utc();
            let offset_delta = PyDelta::new(py, 0, offset_secs, 0, true)?;
            let tzinfo = PyTzInfo::fixed_offset(py, offset_delta)?;
            let nanos = t.nanosecond();
            let microsecond = nanos / 1_000;
            let py_time = PyTime::new(py, t.hour() as u8, t.minute() as u8, t.second() as u8, microsecond, Some(&tzinfo))?;
            Ok(py_time.into_any().unbind())
        }
        CypherValue::DateTime(dt) => {
            // DateTime with timezone - create with tzinfo from offset
            let offset_secs = dt.datetime.offset().local_minus_utc();
            let offset_delta = PyDelta::new(py, 0, offset_secs, 0, true)?;
            let tzinfo = PyTzInfo::fixed_offset(py, offset_delta)?;
            let nanos = dt.nanosecond();
            let microsecond = nanos / 1_000;
            let py_dt = PyDateTime::new(
                py,
                dt.year(),
                dt.month() as u8,
                dt.day() as u8,
                dt.hour() as u8,
                dt.minute() as u8,
                dt.second() as u8,
                microsecond,
                Some(&tzinfo),
            )?;
            Ok(py_dt.into_any().unbind())
        }
        CypherValue::Duration(dur) => {
            // timedelta cannot represent months (variable length), so return as dict
            // when months == 0, we can use a timedelta; otherwise use a dict
            if dur.months == 0 {
                // Convert to timedelta: days + seconds + microseconds
                let total_secs = dur.seconds;
                let days = dur.days + total_secs / 86400;
                let remaining_secs = (total_secs % 86400) as i32;
                let microseconds = dur.nanos / 1_000;
                let py_delta = PyDelta::new(py, days as i32, remaining_secs, microseconds, true)?;
                Ok(py_delta.into_any().unbind())
            } else {
                // Has months component — return as dict
                let py_dict = PyDict::new(py);
                py_dict.set_item("months", dur.months)?;
                py_dict.set_item("days", dur.days)?;
                py_dict.set_item("seconds", dur.seconds)?;
                py_dict.set_item("nanoseconds", dur.nanos)?;
                Ok(py_dict.into_pyobject(py).unwrap().into_any().unbind())
            }
        }
        CypherValue::ExtendedDate(ed) => Ok(format!("{}", ed).into_pyobject(py).unwrap().into_any().unbind()),
        CypherValue::ExtendedLocalDateTime(eldt) => Ok(format!("{}", eldt).into_pyobject(py).unwrap().into_any().unbind()),
    }
}


/// Convert Python object to CypherValue
#[allow(deprecated)]
fn py_to_cypher_value(obj: &Bound<'_, PyAny>) -> PyResult<CypherValue> {
    if obj.is_none() {
        Ok(CypherValue::Null)
    } else if let Ok(b) = obj.extract::<bool>() {
        Ok(CypherValue::Boolean(b))
    } else if let Ok(i) = obj.extract::<i64>() {
        Ok(CypherValue::Integer(i))
    } else if let Ok(f) = obj.extract::<f64>() {
        Ok(CypherValue::Float(f))
    } else if let Ok(s) = obj.extract::<String>() {
        Ok(CypherValue::String(s))
    } else if let Ok(list) = obj.downcast::<PyList>() {
        let mut vec = Vec::new();
        for item in list.iter() {
            vec.push(py_to_cypher_value(&item)?);
        }
        Ok(CypherValue::List(vec))
    } else if let Ok(dict) = obj.downcast::<PyDict>() {
        let mut map = indexmap::IndexMap::new();
        for (key, value) in dict.iter() {
            let key_str = key.extract::<String>()?;
            map.insert(key_str, py_to_cypher_value(&value)?);
        }
        Ok(CypherValue::Map(map))
    } else {
        Err(PyValueError::new_err(format!(
            "Unsupported Python type for Cypher value: {:?}",
            obj.get_type()
        )))
    }
}

/// Convert CypherValue to PropertyValue (for bulk loader)
fn cypher_to_property_value(value: &CypherValue) -> PyResult<PropertyValue> {
    match value {
        CypherValue::Null => Ok(PropertyValue::Null),
        CypherValue::Boolean(b) => Ok(PropertyValue::Bool(*b)),
        CypherValue::Integer(i) => Ok(PropertyValue::Integer(*i)),
        CypherValue::Float(f) => Ok(PropertyValue::Float(*f)),
        CypherValue::String(s) => Ok(PropertyValue::String(s.clone())),
        CypherValue::List(list) => {
            let mut prop_list = Vec::new();
            for item in list {
                prop_list.push(cypher_to_property_value(item)?);
            }
            Ok(PropertyValue::List(prop_list))
        }
        CypherValue::Map(map) => {
            let mut prop_map = indexmap::IndexMap::new();
            for (key, val) in map {
                prop_map.insert(key.clone(), cypher_to_property_value(val)?);
            }
            Ok(PropertyValue::Map(prop_map))
        }
        CypherValue::Date(d) => Ok(PropertyValue::Date(d.clone())),
        CypherValue::Time(t) => Ok(PropertyValue::Time(t.clone())),
        CypherValue::LocalTime(lt) => Ok(PropertyValue::LocalTime(lt.clone())),
        CypherValue::DateTime(dt) => Ok(PropertyValue::DateTime(dt.clone())),
        CypherValue::LocalDateTime(ldt) => Ok(PropertyValue::LocalDateTime(ldt.clone())),
        CypherValue::Duration(dur) => Ok(PropertyValue::Duration(dur.clone())),
        CypherValue::ExtendedDate(ed) => {
            // Convert ExtendedDateValue to NxDate::Extended
            use crate::result::{NxDate, ExtendedDate};
            Ok(PropertyValue::ExtendedDate(NxDate::Extended(
                ExtendedDate::from_ymd(ed.year, ed.month as u32, ed.day as u32)
                    .ok_or_else(|| PyValueError::new_err("Invalid extended date"))?
            )))
        }
        CypherValue::ExtendedLocalDateTime(eldt) => {
            // Convert ExtendedLocalDateTimeValue to NxLocalDateTime::Extended
            use crate::result::{NxLocalDateTime, ExtendedLocalDateTime};
            Ok(PropertyValue::ExtendedLocalDateTime(NxLocalDateTime::Extended(
                ExtendedLocalDateTime::from_ymdhms(
                    eldt.date.year, eldt.date.month as u32, eldt.date.day as u32,
                    eldt.hour as u32, eldt.minute as u32, eldt.second as u32
                ).ok_or_else(|| PyValueError::new_err("Invalid extended local datetime"))?
            )))
        }
        // Node, Relationship, Path cannot be used as property values
        CypherValue::Node(_) | CypherValue::Relationship(_) | CypherValue::Path(_) => {
            Err(PyValueError::new_err(
                "Nodes, relationships, and paths cannot be used as property values"
            ))
        }
    }
}

/// Python wrapper for PropertyGraph.
///
/// Thread-safety is provided by PyO3's GIL: `&mut self` methods acquire
/// an exclusive borrow, so concurrent Python threads are serialized
/// automatically.
#[pyclass(name = "Graph")]
pub struct PyGraph {
    graph: PropertyGraph,
    #[cfg(feature = "metrics")]
    metrics: crate::telemetry::GraphMetrics,
    #[cfg(feature = "metrics")]
    on_execute_cb: Option<Py<pyo3::PyAny>>,
    #[cfg(feature = "persistence")]
    wal: Option<StandaloneWal>,
}


// ─── Python type conversion helpers for algorithm results ─────────────────

fn hashmap_u64_f64_to_py(py: Python, map: HashMap<u64, f64>) -> PyObject {
    let d = PyDict::new(py);
    for (k, v) in map { d.set_item(k, v).unwrap(); }
    d.into_pyobject(py).unwrap().into_any().unbind()
}

fn hashmap_u64_usize_to_py(py: Python, map: HashMap<u64, usize>) -> PyObject {
    let d = PyDict::new(py);
    for (k, v) in map { d.set_item(k, v).unwrap(); }
    d.into_pyobject(py).unwrap().into_any().unbind()
}

fn vec_u64_to_py(py: Python, v: Vec<u64>) -> PyObject {
    v.into_pyobject(py).unwrap().into_any().unbind()
}

fn vec_vecs_to_py(py: Python, vv: Vec<Vec<u64>>) -> PyObject {
    let list = PyList::empty(py);
    for inner in vv { list.append(inner).unwrap(); }
    list.into_pyobject(py).unwrap().into_any().unbind()
}

fn edge_triples_to_py(py: Python, edges: Vec<(u64, u64, f64)>) -> PyObject {
    let list = PyList::empty(py);
    for (a, b, w) in edges { list.append((a, b, w)).unwrap(); }
    list.into_pyobject(py).unwrap().into_any().unbind()
}

fn edge_pairs_to_py(py: Python, edges: impl IntoIterator<Item = (u64, u64)>) -> PyObject {
    let list = PyList::empty(py);
    for (a, b) in edges { list.append((a, b)).unwrap(); }
    list.into_pyobject(py).unwrap().into_any().unbind()
}

fn flow_map_to_py(py: Python, map: HashMap<(u64, u64), f64>) -> PyObject {
    let d = PyDict::new(py);
    for ((a, b), f) in map { d.set_item((a, b), f).unwrap(); }
    d.into_pyobject(py).unwrap().into_any().unbind()
}

fn community_info_to_py(py: Python, info: CommunityInfo) -> PyObject {
    let d = PyDict::new(py);
    let num = info.num_communities();
    d.set_item("node_to_community", hashmap_u64_usize_to_py(py, info.node_to_community)).unwrap();
    d.set_item("communities", vec_vecs_to_py(py, info.communities)).unwrap();
    d.set_item("modularity", info.modularity).unwrap();
    d.set_item("num_communities", num).unwrap();
    d.into_pyobject(py).unwrap().into_any().unbind()
}

fn apsp_to_py(py: Python, map: HashMap<u64, HashMap<u64, f64>>) -> PyObject {
    let outer = PyDict::new(py);
    for (src, dists) in map {
        outer.set_item(src, hashmap_u64_f64_to_py(py, dists)).unwrap();
    }
    outer.into_pyobject(py).unwrap().into_any().unbind()
}

// ─── Algorithm methods (centrality, paths, communities, flow, coloring, matching) ──


#[pymethods]
impl PyGraph {
    /// Create a new empty graph
    #[new]
    fn new() -> Self {
        PyGraph {
            graph: PropertyGraph::new(),
            #[cfg(feature = "metrics")]
            metrics: crate::telemetry::GraphMetrics::new(),
            #[cfg(feature = "metrics")]
            on_execute_cb: None,
            #[cfg(feature = "persistence")]
            wal: None,
        }
    }

    /// Open (or create) a persistent graph backed by WAL + Parquet snapshots.
    ///
    /// If `dir` already contains a `snapshot.json` file it will be loaded first,
    /// then any entries in `wal.ndjson` are replayed on top.
    ///
    /// Args:
    ///     dir: Directory path for persistence files (WAL + snapshot).
    ///
    /// Returns:
    ///     A Graph instance restored from disk (or empty if no prior state).
    #[staticmethod]
    #[cfg(feature = "persistence")]
    fn open(dir: &str) -> PyResult<PyGraph> {
        use std::path::PathBuf;

        let dir_path = PathBuf::from(dir);
        std::fs::create_dir_all(&dir_path)
            .map_err(|e| PyRuntimeError::new_err(format!("Failed to create dir {}: {}", dir, e)))?;

        let mut graph = PropertyGraph::new();

        // Restore from JSON snapshot if present
        let snapshot_path = dir_path.join("snapshot.json");
        if snapshot_path.exists() {
            let dump = load_dump(snapshot_path.to_str().unwrap())
                .map_err(|e| PyRuntimeError::new_err(format!("Failed to load snapshot: {}", e)))?;
            let mut nodes = dump.nodes;
            nodes.sort_by_key(|n| n.id);
            for node in nodes {
                graph.add_existing_node(crate::graph::GraphNode {
                    id: node.id,
                    labels: node.labels.into_iter().collect(),
                    properties: node.properties,
                });
            }
            for se in dump.edges {
                graph.create_relationship_with_id(
                    se.edge.id,
                    se.start_id,
                    se.end_id,
                    se.edge.rel_type,
                    se.edge.properties,
                ).map_err(|e| PyRuntimeError::new_err(format!("Failed to restore edge: {}", e)))?;
            }
        }

        // Open WAL and replay any entries on top of snapshot
        let cfg = StandaloneWalConfig {
            enabled: true,
            dir: dir.to_string(),
            sync_mode: StandaloneWalSyncMode::Async,
            max_entries_before_checkpoint: 10_000,
        };
        let wal = StandaloneWal::open(&cfg)
            .map_err(|e| PyRuntimeError::new_err(format!("Failed to open WAL: {}", e)))?;
        let replayed = wal.replay(&mut graph)
            .map_err(|e| PyRuntimeError::new_err(format!("Failed to replay WAL: {}", e)))?;

        tracing::info!(snapshot = %snapshot_path.exists(), replayed, "PyGraph opened from {}", dir);

        Ok(PyGraph {
            graph,
            #[cfg(feature = "metrics")]
            metrics: crate::telemetry::GraphMetrics::new(),
            #[cfg(feature = "metrics")]
            on_execute_cb: None,
            wal: Some(wal),
        })
    }

    /// Write a Parquet/JSON snapshot and truncate the WAL.
    ///
    /// After this call the WAL will be empty and the snapshot will represent
    /// the full current state of the graph.
    ///
    /// Only available when the graph was opened with `Graph.open()`.
    ///
    /// Returns:
    ///     Number of nodes and edges in the snapshot.
    #[cfg(feature = "persistence")]
    fn checkpoint(&mut self) -> PyResult<(usize, usize)> {
        let wal = self.wal.as_mut()
            .ok_or_else(|| PyRuntimeError::new_err("Graph was not opened with Graph.open(); no WAL attached"))?;

        // Determine the WAL dir from the log path (parent directory)
        let wal_dir = wal.log_path().parent()
            .map(|p| p.to_path_buf())
            .ok_or_else(|| PyRuntimeError::new_err("Cannot determine WAL directory"))?;

        // Save JSON snapshot
        let nodes: Vec<_> = self.graph.all_nodes().map(|n| n.clone()).collect();
        let edges: Vec<SerializedEdge> = self.graph.all_edges().map(|e| SerializedEdge {
            start_id: self.graph.get_edge_endpoints(e.id).map(|(s, _)| s).unwrap_or(0),
            end_id: self.graph.get_edge_endpoints(e.id).map(|(_, d)| d).unwrap_or(0),
            edge: e.clone(),
        }).collect();

        let snapshot_path = wal_dir.join("snapshot.json");
        dump_to_file("PyGraph", nodes, edges, snapshot_path.to_str().unwrap())
            .map_err(|e| PyRuntimeError::new_err(format!("Failed to save snapshot: {}", e)))?;

        let nc = self.graph.node_count();
        let ec = self.graph.edge_count();

        wal.checkpoint()
            .map_err(|e| PyRuntimeError::new_err(format!("Failed to checkpoint WAL: {}", e)))?;

        Ok((nc, ec))
    }

    /// Execute an openCypher query
    ///
    /// Args:
    ///     query: openCypher query string
    ///     params: Optional dictionary of query parameters
    ///
    /// Returns:
    ///     List of result rows (dictionaries)
    ///
    /// Example:
    ///     >>> graph = Graph()
    ///     >>> graph.execute("CREATE (n:Person {name: 'Alice'})")
    ///     >>> result = graph.execute("MATCH (n:Person) RETURN n.name AS name")
    ///     >>> print(result[0]['name'])
    ///     Alice
    #[pyo3(signature = (query, params=None))]
    fn execute(
        &mut self,
        py: Python,
        query: &str,
        params: Option<&Bound<'_, PyDict>>,
    ) -> PyResult<Vec<PyObject>> {
        // Convert Python params to Rust HashMap
        let rust_params = if let Some(py_params) = params {
            let mut map = HashMap::new();
            for (key, value) in py_params.iter() {
                let key_str = key.extract::<String>()?;
                let cypher_value = py_to_cypher_value(&value)?;
                map.insert(key_str, cypher_value);
            }
            map
        } else {
            HashMap::new()
        };

        // Execute the query
        #[cfg(feature = "metrics")]
        let t0 = std::time::Instant::now();

        let result = execute_with_params(&mut self.graph, query, rust_params)
            .map_err(|e| {
                #[cfg(feature = "metrics")]
                self.metrics.record_error();
                PyRuntimeError::new_err(format!("Query execution error: {}", e))
            })?;

        #[cfg(feature = "metrics")]
        {
            let elapsed_ns = t0.elapsed().as_nanos() as u64;
            self.metrics.record_execute(
                elapsed_ns,
                result.stats.nodes_created as u64,
                result.stats.relationships_created as u64,
                result.stats.nodes_deleted as u64,
                result.stats.relationships_deleted as u64,
            );
            if let Some(ref cb) = self.on_execute_cb {
                let _ = cb.call1(py, (query, elapsed_ns, result.row_count()));
            }
        }

        // Append mutations to WAL (if open)
        #[cfg(feature = "persistence")]
        if let Some(ref mut wal) = self.wal {
            // Log created nodes (look up from graph by ID)
            for &nid in &result.stats.nodes_created_ids {
                if let Some(node) = self.graph.get_node(nid) {
                    let _ = wal.append(WalOp::CreateNode {
                        id: node.id,
                        labels: node.labels.iter().cloned().collect(),
                        properties: node.properties.clone(),
                    });
                }
            }
            // Log created relationships (look up from graph by ID)
            for &eid in &result.stats.relationships_created_ids {
                if let Some(edge) = self.graph.get_edge(eid) {
                    if let Some((src, dst)) = self.graph.get_edge_endpoints(eid) {
                        let _ = wal.append(WalOp::CreateRelationship {
                            id: eid,
                            src,
                            dst,
                            rel_type: edge.rel_type.clone(),
                            properties: edge.properties.clone(),
                        });
                    }
                }
            }
            // Log deleted nodes
            for &nid in &result.stats.nodes_deleted_ids {
                let _ = wal.append(WalOp::DeleteNode { id: nid });
            }
            // Log deleted relationships
            for &eid in &result.stats.relationships_deleted_ids {
                let _ = wal.append(WalOp::DeleteRelationship { id: eid });
            }
            // Log set node properties (read new values from graph post-execution)
            for &(nid, ref key) in &result.stats.unique_node_properties {
                if let Some(node) = self.graph.get_node(nid) {
                    if let Some(val) = node.properties.get(key) {
                        let _ = wal.append(WalOp::SetNodeProperty {
                            id: nid,
                            key: key.clone(),
                            value: val.clone(),
                        });
                    } else {
                        // Property was removed during the same query
                        let _ = wal.append(WalOp::RemoveNodeProperty { id: nid, key: key.clone() });
                    }
                }
            }
            // Log set relationship properties
            for &(eid, ref key) in &result.stats.unique_rel_properties {
                if let Some(edge) = self.graph.get_edge(eid) {
                    if let Some(val) = edge.properties.get(key) {
                        let _ = wal.append(WalOp::SetEdgeProperty {
                            id: eid,
                            key: key.clone(),
                            value: val.clone(),
                        });
                    } else {
                        let _ = wal.append(WalOp::RemoveEdgeProperty { id: eid, key: key.clone() });
                    }
                }
            }
            // Log label additions
            for &(nid, ref label) in &result.stats.labels_added_ids {
                let _ = wal.append(WalOp::AddLabel { id: nid, label: label.clone() });
            }
            // Log label removals
            for &(nid, ref label) in &result.stats.labels_removed_ids {
                let _ = wal.append(WalOp::RemoveLabel { id: nid, label: label.clone() });
            }
            // Auto-checkpoint if threshold exceeded
            if wal.needs_checkpoint() {
                let wal_dir = wal.log_path().parent().map(|p| p.to_path_buf());
                if let Some(wal_dir) = wal_dir {
                    let nodes: Vec<_> = self.graph.all_nodes().map(|n| n.clone()).collect();
                    let edges: Vec<SerializedEdge> = self.graph.all_edges().map(|e| SerializedEdge {
                        start_id: self.graph.get_edge_endpoints(e.id).map(|(s, _)| s).unwrap_or(0),
                        end_id: self.graph.get_edge_endpoints(e.id).map(|(_, d)| d).unwrap_or(0),
                        edge: e.clone(),
                    }).collect();
                    let snapshot_path = wal_dir.join("snapshot.json");
                    if dump_to_file("PyGraph", nodes, edges, snapshot_path.to_str().unwrap_or("")).is_ok() {
                        let _ = wal.checkpoint();
                    }
                }
            }
        }

        // Convert result rows to Python dictionaries
        let mut py_rows = Vec::new();
        for i in 0..result.row_count() {
            let record = &result.rows[i];
            let row_dict = PyDict::new(py);
            for (_col_idx, col_name) in result.columns.iter().enumerate() {
                if let Some(value) = record.get(col_name) {
                    row_dict.set_item(col_name, cypher_value_to_py(py, value)?)?;
                }
            }
            py_rows.push(row_dict.into_pyobject(py).unwrap().into_any().unbind());
        }

        Ok(py_rows)
    }

    #[pyo3(name = "metrics")]
    fn metrics_dict(&self, py: Python) -> PyResult<Py<pyo3::PyAny>> {
        let d = pyo3::types::PyDict::new(py);
        #[cfg(feature = "metrics")]
        for (k, v) in self.metrics.snapshot() {
            d.set_item(k, v)?;
        }
        Ok(d.into_pyobject(py).unwrap().into_any().unbind())
    }

    fn on_execute(&mut self, _callback: Option<Py<pyo3::PyAny>>) {
        #[cfg(feature = "metrics")]
        { self.on_execute_cb = _callback; }
    }

    /// Get the number of nodes in the graph
    fn node_count(&self) -> usize {
        self.graph.node_count()
    }

    /// Get the number of relationships in the graph
    fn edge_count(&self) -> usize {
        self.graph.edge_count()
    }

    /// String representation
    fn __repr__(&self) -> String {
        format!(
            "Graph(nodes={}, relationships={})",
            self.graph.node_count(),
            self.graph.edge_count()
        )
    }

    // ===== Bulk Loader API =====

    /// Create a single node directly (bypass Cypher parser)
    ///
    /// Args:
    ///     labels: List of label strings (e.g., ["Person", "Employee"])
    ///     properties: Optional dictionary of property name → value
    ///
    /// Returns:
    ///     Node ID (integer)
    ///
    /// Example:
    ///     >>> graph = Graph()
    ///     >>> node_id = graph.create_node(["Person"], {"name": "Alice", "age": 30})
    ///     >>> print(node_id)
    ///     1
    #[pyo3(signature = (labels, properties=None))]
    fn create_node(
        &mut self,
        labels: Vec<String>,
        properties: Option<&Bound<'_, PyDict>>,
    ) -> PyResult<u64> {
        // Convert Python properties dict to Rust IndexMap
        let props = if let Some(py_props) = properties {
            let mut map = indexmap::IndexMap::new();
            for (key, value) in py_props.iter() {
                let key_str = key.extract::<String>()?;
                let cypher_value = py_to_cypher_value(&value)?;
                // Convert CypherValue to PropertyValue
                let prop_value = cypher_to_property_value(&cypher_value)?;
                map.insert(key_str, prop_value);
            }
            map
        } else {
            indexmap::IndexMap::new()
        };

        // Create the node
        let node = self.graph.create_node(labels, props);
        Ok(node.id)
    }

    /// Create a single relationship directly (bypass Cypher parser)
    ///
    /// Args:
    ///     from_node: Source node ID
    ///     to_node: Target node ID
    ///     rel_type: Relationship type string
    ///     properties: Optional dictionary of property name → value
    ///
    /// Returns:
    ///     Edge ID (integer)
    ///
    /// Example:
    ///     >>> edge_id = graph.create_relationship(1, 2, "KNOWS", {"since": 2020})
    ///     >>> print(edge_id)
    ///     1
    #[pyo3(signature = (from_node, to_node, rel_type, properties=None))]
    fn create_relationship(
        &mut self,
        from_node: u64,
        to_node: u64,
        rel_type: String,
        properties: Option<&Bound<'_, PyDict>>,
    ) -> PyResult<u64> {
        // Convert Python properties dict to Rust IndexMap
        let props = if let Some(py_props) = properties {
            let mut map = indexmap::IndexMap::new();
            for (key, value) in py_props.iter() {
                let key_str = key.extract::<String>()?;
                let cypher_value = py_to_cypher_value(&value)?;
                // Convert CypherValue to PropertyValue
                let prop_value = cypher_to_property_value(&cypher_value)?;
                map.insert(key_str, prop_value);
            }
            map
        } else {
            indexmap::IndexMap::new()
        };

        // Create the relationship
        let edge = self.graph
            .create_relationship(from_node, to_node, rel_type, props)
            .map_err(|e| PyRuntimeError::new_err(format!("Failed to create relationship: {}", e)))?;

        Ok(edge.id)
    }

    /// Bulk create multiple nodes (optimized batch operation)
    ///
    /// Args:
    ///     nodes: List of (labels, properties) tuples
    ///
    /// Returns:
    ///     List of node IDs
    ///
    /// Example:
    ///     >>> nodes = [
    ///     ...     (["Person"], {"name": "Alice", "age": 30}),
    ///     ...     (["Person"], {"name": "Bob", "age": 25}),
    ///     ...     (["Person"], {"name": "Charlie", "age": 35}),
    ///     ... ]
    ///     >>> node_ids = graph.bulk_create_nodes(nodes)
    ///     >>> print(node_ids)
    ///     [1, 2, 3]
    fn bulk_create_nodes(
        &mut self,
        nodes: Vec<(Vec<String>, Bound<'_, PyDict>)>,
    ) -> PyResult<Vec<u64>> {
        let mut node_ids = Vec::with_capacity(nodes.len());

        for (labels, properties) in nodes {
            // Convert properties
            let mut props = indexmap::IndexMap::new();
            for (key, value) in properties.iter() {
                let key_str = key.extract::<String>()?;
                let cypher_value = py_to_cypher_value(&value)?;
                let prop_value = cypher_to_property_value(&cypher_value)?;
                props.insert(key_str, prop_value);
            }

            // Create node
            let node = self.graph.create_node(labels, props);
            node_ids.push(node.id);
        }

        Ok(node_ids)
    }

    /// Bulk create multiple relationships (optimized batch operation)
    ///
    /// Args:
    ///     relationships: List of (from_node, to_node, rel_type, properties) tuples
    ///
    /// Returns:
    ///     List of edge IDs
    ///
    /// Example:
    ///     >>> rels = [
    ///     ...     (1, 2, "KNOWS", {"since": 2020}),
    ///     ...     (2, 3, "WORKS_WITH", {"department": "Engineering"}),
    ///     ... ]
    ///     >>> edge_ids = graph.bulk_create_relationships(rels)
    ///     >>> print(edge_ids)
    ///     [1, 2]
    fn bulk_create_relationships(
        &mut self,
        relationships: Vec<(u64, u64, String, Bound<'_, PyDict>)>,
    ) -> PyResult<Vec<u64>> {
        let mut edge_ids = Vec::with_capacity(relationships.len());

        for (from_node, to_node, rel_type, properties) in relationships {
            // Convert properties
            let mut props = indexmap::IndexMap::new();
            for (key, value) in properties.iter() {
                let key_str = key.extract::<String>()?;
                let cypher_value = py_to_cypher_value(&value)?;
                let prop_value = cypher_to_property_value(&cypher_value)?;
                props.insert(key_str, prop_value);
            }

            // Create relationship
            let edge = self.graph
                .create_relationship(from_node, to_node, rel_type, props)
                .map_err(|e| PyRuntimeError::new_err(format!("Failed to create relationship: {}", e)))?;

            edge_ids.push(edge.id);
        }

        Ok(edge_ids)
    }

    // ── Centrality ────────────────────────────────────────────────────────

    /// Degree centrality for every node.
    ///
    /// Returns: dict mapping node_id → score (float)
    fn degree_centrality(&self, py: Python) -> PyObject {
        hashmap_u64_f64_to_py(py, self.graph.degree_centrality())
    }

    /// Betweenness centrality (Brandes 2001).
    ///
    /// Args:
    ///     normalized: Divide by (n-1)(n-2) to normalise to [0, 1].  Default True.
    ///
    /// Returns: dict mapping node_id → score (float)
    #[pyo3(signature = (normalized=true))]
    fn betweenness_centrality(&self, py: Python, normalized: bool) -> PyObject {
        hashmap_u64_f64_to_py(py, self.graph.betweenness_centrality(normalized))
    }

    /// Closeness centrality (average inverse distance).
    ///
    /// Args:
    ///     normalized: Apply normalization.  Default True.
    ///
    /// Returns: dict mapping node_id → score (float)
    #[pyo3(signature = (normalized=true))]
    fn closeness_centrality(&self, py: Python, normalized: bool) -> PyObject {
        hashmap_u64_f64_to_py(py, self.graph.closeness_centrality(normalized))
    }

    /// PageRank importance score.
    ///
    /// Args:
    ///     damping:  Damping factor (default 0.85).
    ///     max_iter: Maximum iterations (default 100).
    ///
    /// Returns: dict mapping node_id → score (float)
    #[pyo3(signature = (damping=0.85, max_iter=100))]
    fn pagerank(&self, py: Python, damping: f64, max_iter: usize) -> PyObject {
        hashmap_u64_f64_to_py(py, self.graph.pagerank(damping, max_iter))
    }

    // ── Pathfinding ───────────────────────────────────────────────────────

    /// Breadth-first shortest path (unweighted).
    ///
    /// Returns: list of node_ids from source to target, or None if unreachable.
    fn bfs_path(&self, py: Python, source: u64, target: u64) -> PyObject {
        match self.graph.bfs_path(source, target) {
            Some(path) => vec_u64_to_py(py, path),
            None => py.None(),
        }
    }

    /// Dijkstra weighted shortest path.
    ///
    /// Returns: (cost: float, path: list[int]) or None if unreachable.
    fn dijkstra_path(&self, py: Python, source: u64, target: u64) -> PyObject {
        match self.graph.dijkstra_path(source, target) {
            Some((cost, path)) => (cost, path).into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        }
    }

    /// A* shortest path with optional Python heuristic callable.
    ///
    /// Args:
    ///     source:    Source node id.
    ///     target:    Target node id.
    ///     heuristic: Optional callable(node_id: int) -> float estimating
    ///                remaining distance.  Defaults to zero (same as Dijkstra).
    ///
    /// Returns: (cost: float, path: list[int]) or None if unreachable.
    #[pyo3(signature = (source, target, heuristic=None))]
    fn astar_path(
        &self,
        py: Python,
        source: u64,
        target: u64,
        heuristic: Option<PyObject>,
    ) -> PyResult<PyObject> {
        let result = if let Some(h_fn) = heuristic {
            self.graph.astar_path(source, target, |node_id| {
                h_fn.call1(py, (node_id,))
                    .and_then(|v| v.extract::<f64>(py))
                    .unwrap_or(0.0)
            })
        } else {
            self.graph.astar_path(source, target, |_| 0.0)
        };
        Ok(match result {
            Some((cost, path)) => (cost, path).into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        })
    }

    /// Bellman-Ford single-source distances (handles negative weights).
    ///
    /// Returns: dict mapping node_id → distance, or None if negative cycle detected.
    fn bellman_ford_distances(&self, py: Python, source: u64) -> PyObject {
        match self.graph.bellman_ford_distances(source) {
            Some(d) => hashmap_u64_f64_to_py(py, d),
            None => py.None(),
        }
    }

    /// Floyd-Warshall all-pairs shortest distances.
    ///
    /// Returns: dict mapping (src_id, tgt_id) → distance (float).
    /// Note: O(n³) — use on small graphs only.
    fn floyd_warshall_distances(&self, py: Python) -> PyObject {
        let result = self.graph.floyd_warshall_distances();
        let d = PyDict::new(py);
        for ((a, b), dist) in result { d.set_item((a, b), dist).unwrap(); }
        d.into_pyobject(py).unwrap().into_any().unbind()
    }

    /// All-pairs shortest distances via Dijkstra from each node.
    ///
    /// Returns: dict mapping src_id → {tgt_id → distance}.
    fn all_pairs_distances(&self, py: Python) -> PyObject {
        apsp_to_py(py, self.graph.all_pairs_distances())
    }

    // ── Spanning Trees ────────────────────────────────────────────────────

    /// Minimum spanning tree (Kruskal).
    ///
    /// Returns: list of (src_id, tgt_id, weight) triples.
    fn minimum_spanning_tree(&self, py: Python) -> PyObject {
        edge_triples_to_py(py, self.graph.minimum_spanning_tree())
    }

    /// Maximum spanning tree (Kruskal, inverted weights).
    ///
    /// Returns: list of (src_id, tgt_id, weight) triples.
    fn maximum_spanning_tree(&self, py: Python) -> PyObject {
        edge_triples_to_py(py, self.graph.maximum_spanning_tree())
    }

    // ── DAG Algorithms ────────────────────────────────────────────────────

    /// Topological sort of a DAG.
    ///
    /// Returns: ordered list of node_ids, or raises RuntimeError listing cycle nodes.
    fn topological_sort(&self, py: Python) -> PyResult<PyObject> {
        match self.graph.topological_sort() {
            Ok(order) => Ok(vec_u64_to_py(py, order)),
            Err(cycle) => Err(PyRuntimeError::new_err(format!(
                "Graph has cycles involving nodes: {:?}", cycle
            ))),
        }
    }

    /// Return True if the graph has no directed cycles.
    fn is_dag(&self) -> bool {
        self.graph.is_dag()
    }

    /// Find all directed cycles.
    ///
    /// Returns: list of lists, each inner list is a cycle of node_ids.
    fn find_cycles(&self, py: Python) -> PyObject {
        vec_vecs_to_py(py, self.graph.find_cycles())
    }

    /// Longest path in a DAG (unweighted — counts edges).
    ///
    /// Returns: (length: int, path: list[int]) or None if graph has cycles.
    fn dag_longest_path(&self, py: Python) -> PyObject {
        match self.graph.dag_longest_path() {
            Some((len, path)) => (len, path).into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        }
    }

    /// Longest path in a DAG using edge weights.
    ///
    /// Returns: (cost: float, path: list[int]) or None if graph has cycles.
    fn dag_longest_path_weighted(&self, py: Python) -> PyObject {
        match self.graph.dag_longest_path_weighted() {
            Some((cost, path)) => (cost, path).into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        }
    }

    /// Transitive closure — all (src, tgt) pairs where tgt is reachable from src.
    ///
    /// Returns: list of (src_id, tgt_id) tuples.
    /// Note: O(n² + nm) — use on small graphs only.
    fn transitive_closure(&self, py: Python) -> PyObject {
        edge_pairs_to_py(py, self.graph.transitive_closure())
    }

    // ── Network Flow ──────────────────────────────────────────────────────

    /// Edmonds-Karp maximum flow.
    ///
    /// Returns: (max_flow: float, edge_flows: dict[(src, tgt) → flow])
    ///          or None if source/sink not in graph.
    fn max_flow(&self, py: Python, source: u64, sink: u64) -> PyObject {
        match self.graph.max_flow(source, sink) {
            Some((mf, flows)) => (mf, flow_map_to_py(py, flows)).into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        }
    }

    /// Minimum cut capacity between source and sink.
    ///
    /// Returns: float, or None if source/sink not in graph.
    fn min_cut_capacity(&self, py: Python, source: u64, sink: u64) -> PyObject {
        match self.graph.min_cut_capacity(source, sink) {
            Some(c) => c.into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        }
    }

    // ── Graph Coloring ────────────────────────────────────────────────────

    /// Greedy vertex coloring.
    ///
    /// Returns: dict mapping node_id → color (int, 0-indexed).
    fn node_coloring(&self, py: Python) -> PyObject {
        hashmap_u64_usize_to_py(py, self.graph.node_coloring())
    }

    /// Greedy edge coloring.
    ///
    /// Returns: dict mapping (src_id, tgt_id) → color (int, 0-indexed).
    fn edge_coloring(&self, py: Python) -> PyObject {
        let d = PyDict::new(py);
        for ((a, b), c) in self.graph.edge_coloring() { d.set_item((a, b), c).unwrap(); }
        d.into_pyobject(py).unwrap().into_any().unbind()
    }

    /// Approximate chromatic number (minimum colors needed to color all nodes).
    ///
    /// Returns: int
    fn chromatic_number(&self) -> usize {
        self.graph.chromatic_number()
    }

    // ── Matching ──────────────────────────────────────────────────────────

    /// Maximum weight matching (greedy).
    ///
    /// Returns: list of matched (node_id, node_id) pairs.
    fn max_weight_matching(&self, py: Python) -> PyObject {
        edge_pairs_to_py(py, self.graph.max_weight_matching_edges())
    }

    /// Maximum cardinality matching (most edges possible).
    ///
    /// Returns: list of matched (node_id, node_id) pairs.
    fn max_cardinality_matching(&self, py: Python) -> PyObject {
        edge_pairs_to_py(py, self.graph.max_cardinality_matching_edges())
    }

    // ── Community Detection ───────────────────────────────────────────────

    /// Louvain community detection.
    ///
    /// Args:
    ///     resolution: Resolution parameter (default None → 1.0).
    ///                 Higher values produce more, smaller communities.
    ///
    /// Returns: dict with keys:
    ///     "node_to_community": {node_id: community_id}
    ///     "communities":       [[node_ids], ...]
    ///     "modularity":        float  (quality score, higher is better)
    #[pyo3(signature = (resolution=None))]
    fn louvain_communities(&self, py: Python, resolution: Option<f64>) -> PyObject {
        community_info_to_py(py, self.graph.louvain_communities(resolution))
    }

    /// Label propagation community detection.
    ///
    /// Returns: same dict structure as louvain_communities().
    fn label_propagation_communities(&self, py: Python) -> PyObject {
        community_info_to_py(py, self.graph.label_propagation_communities())
    }

    /// Girvan-Newman community detection (edge betweenness removal).
    ///
    /// Args:
    ///     k: Number of communities to produce.
    ///
    /// Returns: same dict structure as louvain_communities().
    fn girvan_newman_communities(&self, py: Python, k: usize) -> PyObject {
        community_info_to_py(py, self.graph.girvan_newman_communities(k))
    }

    // ── Components ────────────────────────────────────────────────────────

    /// Find weakly connected components (treats directed edges as undirected).
    ///
    /// Returns: list of lists, each inner list is a component's node_ids.
    fn connected_components(&self, py: Python) -> PyObject {
        vec_vecs_to_py(py, self.graph.connected_components_list())
    }

    /// Find strongly connected components (Tarjan's algorithm).
    ///
    /// Returns: list of lists, each inner list is an SCC's node_ids.
    fn strongly_connected_components(&self, py: Python) -> PyObject {
        vec_vecs_to_py(py, self.graph.strongly_connected_components_list())
    }

    // ── Serialization ─────────────────────────────────────────────────────

    /// Save the graph to a JSON file using the GraphDump format.
    ///
    /// Args:
    ///     path: File path to write to (created or overwritten).
    fn save(&self, path: &str) -> PyResult<()> {
        let nodes: Vec<GraphNode> = self.graph.all_nodes().cloned().collect();
        let edges: Vec<SerializedEdge> = self
            .graph
            .all_edges()
            .map(|edge| {
                let (start_id, end_id) = self.graph.get_edge_endpoints(edge.id).unwrap_or((0, 0));
                SerializedEdge {
                    edge: edge.clone(),
                    start_id,
                    end_id,
                }
            })
            .collect();
        dump_to_file("PropertyGraph", nodes, edges, path)
            .map_err(|e| PyRuntimeError::new_err(format!("Failed to save graph: {}", e)))
    }

    /// Load a graph from a JSON file previously saved with `save()`.
    ///
    /// Args:
    ///     path: File path to read from.
    ///
    /// Returns:
    ///     A new Graph instance with the loaded data.
    #[staticmethod]
    fn load(path: &str) -> PyResult<PyGraph> {
        let dump = load_dump(path)
            .map_err(|e| PyRuntimeError::new_err(format!("Failed to load graph: {}", e)))?;
        let mut graph = PropertyGraph::new();
        // Sort nodes by ID to ensure consistent ordering when recreating.
        let mut nodes = dump.nodes;
        nodes.sort_by_key(|n| n.id);
        for node in nodes {
            graph.create_node(node.labels.iter().cloned(), node.properties.clone());
        }
        for se in dump.edges {
            graph
                .create_relationship(se.start_id, se.end_id, se.edge.rel_type.clone(), se.edge.properties.clone())
                .map_err(|e| PyRuntimeError::new_err(format!("Failed to restore relationship: {}", e)))?;
        }
        Ok(PyGraph {
            graph,
            #[cfg(feature = "metrics")]
            metrics: crate::telemetry::GraphMetrics::new(),
            #[cfg(feature = "metrics")]
            on_execute_cb: None,
            #[cfg(feature = "persistence")]
            wal: None,
        })
    }
}

/// Execute an openCypher query on a new graph (convenience function)
///
/// Args:
///     query: openCypher query string
///     params: Optional dictionary of query parameters
///
/// Returns:
///     List of result rows (dictionaries)
///
/// Example:
///     >>> from ocg import Graph, execute
///     >>> result = execute("RETURN 1 AS num")
///     >>> print(result[0]['num'])
///     1
#[pyfunction]
#[pyo3(signature = (query, params=None))]
fn execute(
    py: Python,
    query: &str,
    params: Option<&Bound<'_, PyDict>>,
) -> PyResult<Vec<PyObject>> {
    let mut graph = PyGraph::new();
    graph.execute(py, query, params)
}

// ─────────────────────────────────────────────────────────────────────────────
// PyNetworKitGraph

/// Python wrapper for the NetworKit-based backend.
///
/// This backend uses a pure-Rust NetworKit-inspired adjacency list graph with
/// native algorithm implementations operating directly on internal node IDs.
#[pyclass(name = "NetworKitGraph")]
pub struct PyNetworKitGraph {
    graph: NetworKitRustBackend,
    #[cfg(feature = "metrics")]
    metrics: crate::telemetry::GraphMetrics,
    #[cfg(feature = "metrics")]
    on_execute_cb: Option<Py<pyo3::PyAny>>,
}

#[pymethods]
impl PyNetworKitGraph {
    #[new]
    fn new() -> Self {
        PyNetworKitGraph {
            graph: NetworKitRustBackend::new(),
            #[cfg(feature = "metrics")]
            metrics: crate::telemetry::GraphMetrics::new(),
            #[cfg(feature = "metrics")]
            on_execute_cb: None,
        }
    }

    fn node_count(&self) -> usize {
        use crate::graph::GraphBackend;
        self.graph.node_count()
    }

    fn edge_count(&self) -> usize {
        use crate::graph::GraphBackend;
        self.graph.edge_count()
    }

    fn __repr__(&self) -> String {
        use crate::graph::GraphBackend;
        format!(
            "NetworKitGraph(nodes={}, relationships={})",
            self.graph.node_count(),
            self.graph.edge_count()
        )
    }

    #[pyo3(signature = (query, params=None))]
    fn execute(
        &mut self,
        py: Python,
        query: &str,
        params: Option<&Bound<'_, PyDict>>,
    ) -> PyResult<Vec<PyObject>> {
        let rust_params = if let Some(py_params) = params {
            let mut map = HashMap::new();
            for (key, value) in py_params.iter() {
                let key_str = key.extract::<String>()?;
                let cypher_value = py_to_cypher_value(&value)?;
                map.insert(key_str, cypher_value);
            }
            map
        } else {
            HashMap::new()
        };

        #[cfg(feature = "metrics")]
        let t0 = std::time::Instant::now();

        let result = execute_with_params(&mut self.graph, query, rust_params)
            .map_err(|e| {
                #[cfg(feature = "metrics")]
                self.metrics.record_error();
                PyRuntimeError::new_err(format!("Query execution error: {}", e))
            })?;

        #[cfg(feature = "metrics")]
        {
            let elapsed_ns = t0.elapsed().as_nanos() as u64;
            self.metrics.record_execute(
                elapsed_ns,
                result.stats.nodes_created as u64,
                result.stats.relationships_created as u64,
                result.stats.nodes_deleted as u64,
                result.stats.relationships_deleted as u64,
            );
            if let Some(ref cb) = self.on_execute_cb {
                let _ = cb.call1(py, (query, elapsed_ns, result.row_count()));
            }
        }

        let mut py_rows = Vec::new();
        for i in 0..result.row_count() {
            let record = &result.rows[i];
            let row_dict = PyDict::new(py);
            for col_name in result.columns.iter() {
                if let Some(value) = record.get(col_name) {
                    row_dict.set_item(col_name, cypher_value_to_py(py, value)?)?;
                }
            }
            py_rows.push(row_dict.into_pyobject(py).unwrap().into_any().unbind());
        }
        Ok(py_rows)
    }

    #[pyo3(name = "metrics")]
    fn metrics_dict(&self, py: Python) -> PyResult<Py<pyo3::PyAny>> {
        let d = pyo3::types::PyDict::new(py);
        #[cfg(feature = "metrics")]
        for (k, v) in self.metrics.snapshot() {
            d.set_item(k, v)?;
        }
        Ok(d.into_pyobject(py).unwrap().into_any().unbind())
    }

    fn on_execute(&mut self, _callback: Option<Py<pyo3::PyAny>>) {
        #[cfg(feature = "metrics")]
        { self.on_execute_cb = _callback; }
    }

    #[pyo3(signature = (labels, properties=None))]
    fn create_node(
        &mut self,
        labels: Vec<String>,
        properties: Option<&Bound<'_, PyDict>>,
    ) -> PyResult<u64> {
        use crate::graph::GraphBackend;
        let props = if let Some(py_props) = properties {
            let mut map = indexmap::IndexMap::new();
            for (key, value) in py_props.iter() {
                let key_str = key.extract::<String>()?;
                let cypher_value = py_to_cypher_value(&value)?;
                let prop_value = cypher_to_property_value(&cypher_value)?;
                map.insert(key_str, prop_value);
            }
            map
        } else {
            indexmap::IndexMap::new()
        };
        Ok(self.graph.create_node(labels, props))
    }

    #[pyo3(signature = (from_node, to_node, rel_type, properties=None))]
    fn create_relationship(
        &mut self,
        from_node: u64,
        to_node: u64,
        rel_type: String,
        properties: Option<&Bound<'_, PyDict>>,
    ) -> PyResult<u64> {
        use crate::graph::GraphBackend;
        let props = if let Some(py_props) = properties {
            let mut map = indexmap::IndexMap::new();
            for (key, value) in py_props.iter() {
                let key_str = key.extract::<String>()?;
                let cypher_value = py_to_cypher_value(&value)?;
                let prop_value = cypher_to_property_value(&cypher_value)?;
                map.insert(key_str, prop_value);
            }
            map
        } else {
            indexmap::IndexMap::new()
        };
        self.graph
            .create_relationship(from_node, to_node, rel_type, props)
            .map_err(|e| PyRuntimeError::new_err(format!("Failed to create relationship: {}", e)))
    }

    // ── Centrality ────────────────────────────────────────────────────────

    fn degree_centrality(&self, py: Python) -> PyObject {
        hashmap_u64_f64_to_py(py, self.graph.degree_centrality())
    }

    #[pyo3(signature = (normalized=true))]
    fn betweenness_centrality(&self, py: Python, normalized: bool) -> PyObject {
        hashmap_u64_f64_to_py(py, self.graph.betweenness_centrality(normalized))
    }

    #[pyo3(signature = (normalized=true))]
    fn closeness_centrality(&self, py: Python, normalized: bool) -> PyObject {
        hashmap_u64_f64_to_py(py, self.graph.closeness_centrality(normalized))
    }

    #[pyo3(signature = (damping=0.85, max_iter=100))]
    fn pagerank(&self, py: Python, damping: f64, max_iter: usize) -> PyObject {
        hashmap_u64_f64_to_py(py, self.graph.pagerank(damping, max_iter))
    }

    // ── Pathfinding ───────────────────────────────────────────────────────

    fn bfs_path(&self, py: Python, source: u64, target: u64) -> PyObject {
        match self.graph.bfs_path(source, target) {
            Some(path) => vec_u64_to_py(py, path),
            None => py.None(),
        }
    }

    fn dijkstra_path(&self, py: Python, source: u64, target: u64) -> PyObject {
        match self.graph.dijkstra_path(source, target) {
            Some((cost, path)) => (cost, path).into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        }
    }

    #[pyo3(signature = (source, target, heuristic=None))]
    fn astar_path(
        &self,
        py: Python,
        source: u64,
        target: u64,
        heuristic: Option<PyObject>,
    ) -> PyResult<PyObject> {
        let result = if let Some(h_fn) = heuristic {
            self.graph.astar_path(source, target, |node_id| {
                h_fn.call1(py, (node_id,))
                    .and_then(|v| v.extract::<f64>(py))
                    .unwrap_or(0.0)
            })
        } else {
            self.graph.astar_path(source, target, |_| 0.0)
        };
        Ok(match result {
            Some((cost, path)) => (cost, path).into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        })
    }

    fn bellman_ford_distances(&self, py: Python, source: u64) -> PyObject {
        match self.graph.bellman_ford_distances(source) {
            Some(d) => hashmap_u64_f64_to_py(py, d),
            None => py.None(),
        }
    }

    fn floyd_warshall_distances(&self, py: Python) -> PyObject {
        let result = self.graph.floyd_warshall_distances();
        let d = PyDict::new(py);
        for ((a, b), dist) in result { d.set_item((a, b), dist).unwrap(); }
        d.into_pyobject(py).unwrap().into_any().unbind()
    }

    fn all_pairs_distances(&self, py: Python) -> PyObject {
        apsp_to_py(py, self.graph.all_pairs_distances())
    }

    // ── Spanning Trees ────────────────────────────────────────────────────

    fn minimum_spanning_tree(&self, py: Python) -> PyObject {
        edge_triples_to_py(py, self.graph.minimum_spanning_tree())
    }

    fn maximum_spanning_tree(&self, py: Python) -> PyObject {
        edge_triples_to_py(py, self.graph.maximum_spanning_tree())
    }

    // ── DAG Algorithms ────────────────────────────────────────────────────

    fn topological_sort(&self, py: Python) -> PyResult<PyObject> {
        match self.graph.topological_sort() {
            Ok(order) => Ok(vec_u64_to_py(py, order)),
            Err(cycle) => Err(PyRuntimeError::new_err(format!(
                "Graph has cycles involving nodes: {:?}", cycle
            ))),
        }
    }

    fn is_dag(&self) -> bool {
        self.graph.is_dag()
    }

    fn find_cycles(&self, py: Python) -> PyObject {
        vec_vecs_to_py(py, self.graph.find_cycles())
    }

    fn dag_longest_path(&self, py: Python) -> PyObject {
        match self.graph.dag_longest_path() {
            Some((len, path)) => (len, path).into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        }
    }

    fn dag_longest_path_weighted(&self, py: Python) -> PyObject {
        match self.graph.dag_longest_path_weighted() {
            Some((cost, path)) => (cost, path).into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        }
    }

    fn transitive_closure(&self, py: Python) -> PyObject {
        edge_pairs_to_py(py, self.graph.transitive_closure())
    }

    // ── Network Flow ──────────────────────────────────────────────────────

    fn max_flow(&self, py: Python, source: u64, sink: u64) -> PyObject {
        match self.graph.max_flow(source, sink) {
            Some((mf, flows)) => (mf, flow_map_to_py(py, flows)).into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        }
    }

    fn min_cut_capacity(&self, py: Python, source: u64, sink: u64) -> PyObject {
        match self.graph.min_cut_capacity(source, sink) {
            Some(c) => c.into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        }
    }

    // ── Graph Coloring ────────────────────────────────────────────────────

    fn node_coloring(&self, py: Python) -> PyObject {
        hashmap_u64_usize_to_py(py, self.graph.node_coloring())
    }

    fn edge_coloring(&self, py: Python) -> PyObject {
        let d = PyDict::new(py);
        for ((a, b), c) in self.graph.edge_coloring() { d.set_item((a, b), c).unwrap(); }
        d.into_pyobject(py).unwrap().into_any().unbind()
    }

    fn chromatic_number(&self) -> usize {
        self.graph.chromatic_number()
    }

    // ── Matching ──────────────────────────────────────────────────────────

    fn max_weight_matching(&self, py: Python) -> PyObject {
        edge_pairs_to_py(py, self.graph.max_weight_matching_edges())
    }

    fn max_cardinality_matching(&self, py: Python) -> PyObject {
        edge_pairs_to_py(py, self.graph.max_cardinality_matching_edges())
    }

    // ── Community Detection ───────────────────────────────────────────────

    #[pyo3(signature = (resolution=None))]
    fn louvain_communities(&self, py: Python, resolution: Option<f64>) -> PyObject {
        community_info_to_py(py, self.graph.louvain_communities(resolution))
    }

    fn label_propagation_communities(&self, py: Python) -> PyObject {
        community_info_to_py(py, self.graph.label_propagation_communities())
    }

    fn girvan_newman_communities(&self, py: Python, k: usize) -> PyObject {
        community_info_to_py(py, self.graph.girvan_newman_communities(k))
    }

    // ── Components ────────────────────────────────────────────────────────

    fn connected_components(&self, py: Python) -> PyObject {
        vec_vecs_to_py(py, self.graph.connected_components_list())
    }

    fn strongly_connected_components(&self, py: Python) -> PyObject {
        vec_vecs_to_py(py, self.graph.strongly_connected_components_list())
    }

    // ── Serialization ─────────────────────────────────────────────────────

    /// Save the graph to a JSON file using the GraphDump format.
    ///
    /// Args:
    ///     path: File path to write to (created or overwritten).
    fn save(&self, path: &str) -> PyResult<()> {
        use crate::graph::{GraphBackend, NodeLike, EdgeLike};
        let nodes: Vec<GraphNode> = self
            .graph
            .all_nodes()
            .map(|n| {
                let mut node = GraphNode::new(n.id());
                node.labels = n.labels().clone();
                node.properties = n.properties().clone();
                node
            })
            .collect();
        let edges: Vec<SerializedEdge> = self
            .graph
            .all_edges()
            .map(|e| {
                let edge_id = e.id();
                let (start_id, end_id) = self.graph.get_edge_endpoints(edge_id).unwrap_or((0, 0));
                let mut edge = GraphEdge::new(edge_id, e.rel_type().to_string());
                edge.properties = e.properties().clone();
                SerializedEdge { edge, start_id, end_id }
            })
            .collect();
        dump_to_file("NetworKitRust", nodes, edges, path)
            .map_err(|e| PyRuntimeError::new_err(format!("Failed to save graph: {}", e)))
    }

    /// Load a graph from a JSON file previously saved with `save()`.
    ///
    /// Args:
    ///     path: File path to read from.
    ///
    /// Returns:
    ///     A new NetworKitGraph instance with the loaded data.
    #[staticmethod]
    fn load(path: &str) -> PyResult<PyNetworKitGraph> {
        use crate::graph::GraphBackend;
        let dump = load_dump(path)
            .map_err(|e| PyRuntimeError::new_err(format!("Failed to load graph: {}", e)))?;
        let mut graph = NetworKitRustBackend::new();
        let mut nodes = dump.nodes;
        nodes.sort_by_key(|n| n.id);
        for node in nodes {
            graph.create_node(node.labels.into_iter(), node.properties.clone());
        }
        for se in dump.edges {
            graph
                .create_relationship(se.start_id, se.end_id, se.edge.rel_type.clone(), se.edge.properties.clone())
                .map_err(|e| PyRuntimeError::new_err(format!("Failed to restore relationship: {}", e)))?;
        }
        Ok(PyNetworKitGraph {
            graph,
            #[cfg(feature = "metrics")]
            metrics: crate::telemetry::GraphMetrics::new(),
            #[cfg(feature = "metrics")]
            on_execute_cb: None,
        })
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// PyRustworkxGraph

/// Python wrapper for the RustworkxCore backend.
///
/// This backend uses a petgraph StableDiGraph with IBM Qiskit rustworkx-core
/// algorithms, all ported to operate on the NetworKit graph via `PetgraphAlgorithms`.
#[pyclass(name = "RustworkxGraph")]
pub struct PyRustworkxGraph {
    graph: RustworkxCoreBackend,
    #[cfg(feature = "metrics")]
    metrics: crate::telemetry::GraphMetrics,
    #[cfg(feature = "metrics")]
    on_execute_cb: Option<Py<pyo3::PyAny>>,
}

#[pymethods]
impl PyRustworkxGraph {
    #[new]
    fn new() -> Self {
        PyRustworkxGraph {
            graph: RustworkxCoreBackend::new(),
            #[cfg(feature = "metrics")]
            metrics: crate::telemetry::GraphMetrics::new(),
            #[cfg(feature = "metrics")]
            on_execute_cb: None,
        }
    }

    fn node_count(&self) -> usize {
        self.graph.node_count()
    }

    fn edge_count(&self) -> usize {
        self.graph.edge_count()
    }

    fn __repr__(&self) -> String {
        format!(
            "RustworkxGraph(nodes={}, relationships={})",
            self.graph.node_count(),
            self.graph.edge_count()
        )
    }

    #[pyo3(signature = (query, params=None))]
    fn execute(
        &mut self,
        py: Python,
        query: &str,
        params: Option<&Bound<'_, PyDict>>,
    ) -> PyResult<Vec<PyObject>> {
        let rust_params = if let Some(py_params) = params {
            let mut map = HashMap::new();
            for (key, value) in py_params.iter() {
                let key_str = key.extract::<String>()?;
                let cypher_value = py_to_cypher_value(&value)?;
                map.insert(key_str, cypher_value);
            }
            map
        } else {
            HashMap::new()
        };

        #[cfg(feature = "metrics")]
        let t0 = std::time::Instant::now();

        let result = execute_with_params(&mut self.graph, query, rust_params)
            .map_err(|e| {
                #[cfg(feature = "metrics")]
                self.metrics.record_error();
                PyRuntimeError::new_err(format!("Query execution error: {}", e))
            })?;

        #[cfg(feature = "metrics")]
        {
            let elapsed_ns = t0.elapsed().as_nanos() as u64;
            self.metrics.record_execute(
                elapsed_ns,
                result.stats.nodes_created as u64,
                result.stats.relationships_created as u64,
                result.stats.nodes_deleted as u64,
                result.stats.relationships_deleted as u64,
            );
            if let Some(ref cb) = self.on_execute_cb {
                let _ = cb.call1(py, (query, elapsed_ns, result.row_count()));
            }
        }

        let mut py_rows = Vec::new();
        for i in 0..result.row_count() {
            let record = &result.rows[i];
            let row_dict = PyDict::new(py);
            for col_name in result.columns.iter() {
                if let Some(value) = record.get(col_name) {
                    row_dict.set_item(col_name, cypher_value_to_py(py, value)?)?;
                }
            }
            py_rows.push(row_dict.into_pyobject(py).unwrap().into_any().unbind());
        }
        Ok(py_rows)
    }

    #[pyo3(name = "metrics")]
    fn metrics_dict(&self, py: Python) -> PyResult<Py<pyo3::PyAny>> {
        let d = pyo3::types::PyDict::new(py);
        #[cfg(feature = "metrics")]
        for (k, v) in self.metrics.snapshot() {
            d.set_item(k, v)?;
        }
        Ok(d.into_pyobject(py).unwrap().into_any().unbind())
    }

    fn on_execute(&mut self, _callback: Option<Py<pyo3::PyAny>>) {
        #[cfg(feature = "metrics")]
        { self.on_execute_cb = _callback; }
    }

    #[pyo3(signature = (labels, properties=None))]
    fn create_node(
        &mut self,
        labels: Vec<String>,
        properties: Option<&Bound<'_, PyDict>>,
    ) -> PyResult<u64> {
        let props = if let Some(py_props) = properties {
            let mut map = indexmap::IndexMap::new();
            for (key, value) in py_props.iter() {
                let key_str = key.extract::<String>()?;
                let cypher_value = py_to_cypher_value(&value)?;
                let prop_value = cypher_to_property_value(&cypher_value)?;
                map.insert(key_str, prop_value);
            }
            map
        } else {
            indexmap::IndexMap::new()
        };
        let node = self.graph.create_node(labels, props);
        Ok(node.id)
    }

    #[pyo3(signature = (from_node, to_node, rel_type, properties=None))]
    fn create_relationship(
        &mut self,
        from_node: u64,
        to_node: u64,
        rel_type: String,
        properties: Option<&Bound<'_, PyDict>>,
    ) -> PyResult<u64> {
        let props = if let Some(py_props) = properties {
            let mut map = indexmap::IndexMap::new();
            for (key, value) in py_props.iter() {
                let key_str = key.extract::<String>()?;
                let cypher_value = py_to_cypher_value(&value)?;
                let prop_value = cypher_to_property_value(&cypher_value)?;
                map.insert(key_str, prop_value);
            }
            map
        } else {
            indexmap::IndexMap::new()
        };
        let edge = self.graph
            .create_relationship(from_node, to_node, rel_type, props)
            .map_err(|e| PyRuntimeError::new_err(format!("Failed to create relationship: {}", e)))?;
        Ok(edge.id)
    }

    // ── Centrality ────────────────────────────────────────────────────────

    fn degree_centrality(&self, py: Python) -> PyObject {
        hashmap_u64_f64_to_py(py, self.graph.degree_centrality())
    }

    #[pyo3(signature = (normalized=true))]
    fn betweenness_centrality(&self, py: Python, normalized: bool) -> PyObject {
        hashmap_u64_f64_to_py(py, self.graph.betweenness_centrality(normalized))
    }

    #[pyo3(signature = (normalized=true))]
    fn closeness_centrality(&self, py: Python, normalized: bool) -> PyObject {
        hashmap_u64_f64_to_py(py, self.graph.closeness_centrality(normalized))
    }

    #[pyo3(signature = (damping=0.85, max_iter=100))]
    fn pagerank(&self, py: Python, damping: f64, max_iter: usize) -> PyObject {
        hashmap_u64_f64_to_py(py, self.graph.pagerank(damping, max_iter))
    }

    // ── Pathfinding ───────────────────────────────────────────────────────

    fn bfs_path(&self, py: Python, source: u64, target: u64) -> PyObject {
        match self.graph.bfs_path(source, target) {
            Some(path) => vec_u64_to_py(py, path),
            None => py.None(),
        }
    }

    fn dijkstra_path(&self, py: Python, source: u64, target: u64) -> PyObject {
        match self.graph.dijkstra_path(source, target) {
            Some((cost, path)) => (cost, path).into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        }
    }

    #[pyo3(signature = (source, target, heuristic=None))]
    fn astar_path(
        &self,
        py: Python,
        source: u64,
        target: u64,
        heuristic: Option<PyObject>,
    ) -> PyResult<PyObject> {
        let result = if let Some(h_fn) = heuristic {
            self.graph.astar_path(source, target, |node_id| {
                h_fn.call1(py, (node_id,))
                    .and_then(|v| v.extract::<f64>(py))
                    .unwrap_or(0.0)
            })
        } else {
            self.graph.astar_path(source, target, |_| 0.0)
        };
        Ok(match result {
            Some((cost, path)) => (cost, path).into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        })
    }

    fn bellman_ford_distances(&self, py: Python, source: u64) -> PyObject {
        match self.graph.bellman_ford_distances(source) {
            Some(d) => hashmap_u64_f64_to_py(py, d),
            None => py.None(),
        }
    }

    fn floyd_warshall_distances(&self, py: Python) -> PyObject {
        let result = self.graph.floyd_warshall_distances();
        let d = PyDict::new(py);
        for ((a, b), dist) in result { d.set_item((a, b), dist).unwrap(); }
        d.into_pyobject(py).unwrap().into_any().unbind()
    }

    fn all_pairs_distances(&self, py: Python) -> PyObject {
        apsp_to_py(py, self.graph.all_pairs_distances())
    }

    // ── Spanning Trees ────────────────────────────────────────────────────

    fn minimum_spanning_tree(&self, py: Python) -> PyObject {
        edge_triples_to_py(py, self.graph.minimum_spanning_tree())
    }

    fn maximum_spanning_tree(&self, py: Python) -> PyObject {
        edge_triples_to_py(py, self.graph.maximum_spanning_tree())
    }

    // ── DAG Algorithms ────────────────────────────────────────────────────

    fn topological_sort(&self, py: Python) -> PyResult<PyObject> {
        match self.graph.topological_sort() {
            Ok(order) => Ok(vec_u64_to_py(py, order)),
            Err(cycle) => Err(PyRuntimeError::new_err(format!(
                "Graph has cycles involving nodes: {:?}", cycle
            ))),
        }
    }

    fn is_dag(&self) -> bool {
        self.graph.is_dag()
    }

    fn find_cycles(&self, py: Python) -> PyObject {
        vec_vecs_to_py(py, self.graph.find_cycles())
    }

    fn dag_longest_path(&self, py: Python) -> PyObject {
        match self.graph.dag_longest_path() {
            Some((len, path)) => (len, path).into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        }
    }

    fn dag_longest_path_weighted(&self, py: Python) -> PyObject {
        match self.graph.dag_longest_path_weighted() {
            Some((cost, path)) => (cost, path).into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        }
    }

    fn transitive_closure(&self, py: Python) -> PyObject {
        edge_pairs_to_py(py, self.graph.transitive_closure())
    }

    // ── Network Flow ──────────────────────────────────────────────────────

    fn max_flow(&self, py: Python, source: u64, sink: u64) -> PyObject {
        match self.graph.max_flow(source, sink) {
            Some((mf, flows)) => (mf, flow_map_to_py(py, flows)).into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        }
    }

    fn min_cut_capacity(&self, py: Python, source: u64, sink: u64) -> PyObject {
        match self.graph.min_cut_capacity(source, sink) {
            Some(c) => c.into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        }
    }

    // ── Graph Coloring ────────────────────────────────────────────────────

    fn node_coloring(&self, py: Python) -> PyObject {
        hashmap_u64_usize_to_py(py, self.graph.node_coloring())
    }

    fn edge_coloring(&self, py: Python) -> PyObject {
        let d = PyDict::new(py);
        for ((a, b), c) in self.graph.edge_coloring() { d.set_item((a, b), c).unwrap(); }
        d.into_pyobject(py).unwrap().into_any().unbind()
    }

    fn chromatic_number(&self) -> usize {
        self.graph.chromatic_number()
    }

    // ── Matching ──────────────────────────────────────────────────────────

    fn max_weight_matching(&self, py: Python) -> PyObject {
        edge_pairs_to_py(py, self.graph.max_weight_matching_edges())
    }

    fn max_cardinality_matching(&self, py: Python) -> PyObject {
        edge_pairs_to_py(py, self.graph.max_cardinality_matching_edges())
    }

    // ── Community Detection ───────────────────────────────────────────────

    #[pyo3(signature = (resolution=None))]
    fn louvain_communities(&self, py: Python, resolution: Option<f64>) -> PyObject {
        community_info_to_py(py, self.graph.louvain_communities(resolution))
    }

    fn label_propagation_communities(&self, py: Python) -> PyObject {
        community_info_to_py(py, self.graph.label_propagation_communities())
    }

    fn girvan_newman_communities(&self, py: Python, k: usize) -> PyObject {
        community_info_to_py(py, self.graph.girvan_newman_communities(k))
    }

    // ── Components ────────────────────────────────────────────────────────

    fn connected_components(&self, py: Python) -> PyObject {
        vec_vecs_to_py(py, self.graph.connected_components_list())
    }

    fn strongly_connected_components(&self, py: Python) -> PyObject {
        vec_vecs_to_py(py, self.graph.strongly_connected_components_list())
    }

    // ── Serialization ─────────────────────────────────────────────────────

    /// Save the graph to a JSON file using the GraphDump format.
    ///
    /// Args:
    ///     path: File path to write to (created or overwritten).
    fn save(&self, path: &str) -> PyResult<()> {
        let nodes: Vec<GraphNode> = self.graph.all_nodes().cloned().collect();
        let edges: Vec<SerializedEdge> = self
            .graph
            .all_edges()
            .map(|edge| {
                let (start_id, end_id) = self.graph.get_edge_endpoints(edge.id).unwrap_or((0, 0));
                SerializedEdge {
                    edge: edge.clone(),
                    start_id,
                    end_id,
                }
            })
            .collect();
        dump_to_file("RustworkxCore", nodes, edges, path)
            .map_err(|e| PyRuntimeError::new_err(format!("Failed to save graph: {}", e)))
    }

    /// Load a graph from a JSON file previously saved with `save()`.
    ///
    /// Args:
    ///     path: File path to read from.
    ///
    /// Returns:
    ///     A new RustworkxGraph instance with the loaded data.
    #[staticmethod]
    fn load(path: &str) -> PyResult<PyRustworkxGraph> {
        let dump = load_dump(path)
            .map_err(|e| PyRuntimeError::new_err(format!("Failed to load graph: {}", e)))?;
        let mut graph = RustworkxCoreBackend::new();
        let mut nodes = dump.nodes;
        nodes.sort_by_key(|n| n.id);
        for node in nodes {
            graph.create_node(node.labels.into_iter(), node.properties.clone());
        }
        for se in dump.edges {
            graph
                .create_relationship(se.start_id, se.end_id, se.edge.rel_type.clone(), se.edge.properties.clone())
                .map_err(|e| PyRuntimeError::new_err(format!("Failed to restore relationship: {}", e)))?;
        }
        Ok(PyRustworkxGraph {
            graph,
            #[cfg(feature = "metrics")]
            metrics: crate::telemetry::GraphMetrics::new(),
            #[cfg(feature = "metrics")]
            on_execute_cb: None,
        })
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// PyGraphrsGraph

/// Python wrapper for the Graphrs backend.
///
/// This backend uses a petgraph StableDiGraph with algorithms from the graphrs
/// community detection library, plus the full PetgraphAlgorithms suite.
#[pyclass(name = "GraphrsGraph")]
pub struct PyGraphrsGraph {
    graph: GraphrsBackend,
    #[cfg(feature = "metrics")]
    metrics: crate::telemetry::GraphMetrics,
    #[cfg(feature = "metrics")]
    on_execute_cb: Option<Py<pyo3::PyAny>>,
}

#[pymethods]
impl PyGraphrsGraph {
    #[new]
    fn new() -> Self {
        PyGraphrsGraph {
            graph: GraphrsBackend::new(),
            #[cfg(feature = "metrics")]
            metrics: crate::telemetry::GraphMetrics::new(),
            #[cfg(feature = "metrics")]
            on_execute_cb: None,
        }
    }

    fn node_count(&self) -> usize {
        self.graph.node_count()
    }

    fn edge_count(&self) -> usize {
        self.graph.edge_count()
    }

    fn __repr__(&self) -> String {
        format!(
            "GraphrsGraph(nodes={}, relationships={})",
            self.graph.node_count(),
            self.graph.edge_count()
        )
    }

    #[pyo3(signature = (query, params=None))]
    fn execute(
        &mut self,
        py: Python,
        query: &str,
        params: Option<&Bound<'_, PyDict>>,
    ) -> PyResult<Vec<PyObject>> {
        let rust_params = if let Some(py_params) = params {
            let mut map = HashMap::new();
            for (key, value) in py_params.iter() {
                let key_str = key.extract::<String>()?;
                let cypher_value = py_to_cypher_value(&value)?;
                map.insert(key_str, cypher_value);
            }
            map
        } else {
            HashMap::new()
        };

        #[cfg(feature = "metrics")]
        let t0 = std::time::Instant::now();

        let result = execute_with_params(&mut self.graph, query, rust_params)
            .map_err(|e| {
                #[cfg(feature = "metrics")]
                self.metrics.record_error();
                PyRuntimeError::new_err(format!("Query execution error: {}", e))
            })?;

        #[cfg(feature = "metrics")]
        {
            let elapsed_ns = t0.elapsed().as_nanos() as u64;
            self.metrics.record_execute(
                elapsed_ns,
                result.stats.nodes_created as u64,
                result.stats.relationships_created as u64,
                result.stats.nodes_deleted as u64,
                result.stats.relationships_deleted as u64,
            );
            if let Some(ref cb) = self.on_execute_cb {
                let _ = cb.call1(py, (query, elapsed_ns, result.row_count()));
            }
        }

        let mut py_rows = Vec::new();
        for i in 0..result.row_count() {
            let record = &result.rows[i];
            let row_dict = PyDict::new(py);
            for col_name in result.columns.iter() {
                if let Some(value) = record.get(col_name) {
                    row_dict.set_item(col_name, cypher_value_to_py(py, value)?)?;
                }
            }
            py_rows.push(row_dict.into_pyobject(py).unwrap().into_any().unbind());
        }
        Ok(py_rows)
    }

    #[pyo3(name = "metrics")]
    fn metrics_dict(&self, py: Python) -> PyResult<Py<pyo3::PyAny>> {
        let d = pyo3::types::PyDict::new(py);
        #[cfg(feature = "metrics")]
        for (k, v) in self.metrics.snapshot() {
            d.set_item(k, v)?;
        }
        Ok(d.into_pyobject(py).unwrap().into_any().unbind())
    }

    fn on_execute(&mut self, _callback: Option<Py<pyo3::PyAny>>) {
        #[cfg(feature = "metrics")]
        { self.on_execute_cb = _callback; }
    }

    #[pyo3(signature = (labels, properties=None))]
    fn create_node(
        &mut self,
        labels: Vec<String>,
        properties: Option<&Bound<'_, PyDict>>,
    ) -> PyResult<u64> {
        let props = if let Some(py_props) = properties {
            let mut map = indexmap::IndexMap::new();
            for (key, value) in py_props.iter() {
                let key_str = key.extract::<String>()?;
                let cypher_value = py_to_cypher_value(&value)?;
                let prop_value = cypher_to_property_value(&cypher_value)?;
                map.insert(key_str, prop_value);
            }
            map
        } else {
            indexmap::IndexMap::new()
        };
        let node = self.graph.create_node(labels, props);
        Ok(node.id)
    }

    #[pyo3(signature = (from_node, to_node, rel_type, properties=None))]
    fn create_relationship(
        &mut self,
        from_node: u64,
        to_node: u64,
        rel_type: String,
        properties: Option<&Bound<'_, PyDict>>,
    ) -> PyResult<u64> {
        let props = if let Some(py_props) = properties {
            let mut map = indexmap::IndexMap::new();
            for (key, value) in py_props.iter() {
                let key_str = key.extract::<String>()?;
                let cypher_value = py_to_cypher_value(&value)?;
                let prop_value = cypher_to_property_value(&cypher_value)?;
                map.insert(key_str, prop_value);
            }
            map
        } else {
            indexmap::IndexMap::new()
        };
        let edge = self.graph
            .create_relationship(from_node, to_node, rel_type, props)
            .map_err(|e| PyRuntimeError::new_err(format!("Failed to create relationship: {}", e)))?;
        Ok(edge.id)
    }

    // ── Centrality ────────────────────────────────────────────────────────

    fn degree_centrality(&self, py: Python) -> PyObject {
        hashmap_u64_f64_to_py(py, self.graph.degree_centrality())
    }

    #[pyo3(signature = (normalized=true))]
    fn betweenness_centrality(&self, py: Python, normalized: bool) -> PyObject {
        hashmap_u64_f64_to_py(py, self.graph.betweenness_centrality(normalized))
    }

    #[pyo3(signature = (normalized=true))]
    fn closeness_centrality(&self, py: Python, normalized: bool) -> PyObject {
        hashmap_u64_f64_to_py(py, self.graph.closeness_centrality(normalized))
    }

    #[pyo3(signature = (damping=0.85, max_iter=100))]
    fn pagerank(&self, py: Python, damping: f64, max_iter: usize) -> PyObject {
        hashmap_u64_f64_to_py(py, self.graph.pagerank(damping, max_iter))
    }

    // ── Pathfinding ───────────────────────────────────────────────────────

    fn bfs_path(&self, py: Python, source: u64, target: u64) -> PyObject {
        match self.graph.bfs_path(source, target) {
            Some(path) => vec_u64_to_py(py, path),
            None => py.None(),
        }
    }

    fn dijkstra_path(&self, py: Python, source: u64, target: u64) -> PyObject {
        match self.graph.dijkstra_path(source, target) {
            Some((cost, path)) => (cost, path).into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        }
    }

    #[pyo3(signature = (source, target, heuristic=None))]
    fn astar_path(
        &self,
        py: Python,
        source: u64,
        target: u64,
        heuristic: Option<PyObject>,
    ) -> PyResult<PyObject> {
        let result = if let Some(h_fn) = heuristic {
            self.graph.astar_path(source, target, |node_id| {
                h_fn.call1(py, (node_id,))
                    .and_then(|v| v.extract::<f64>(py))
                    .unwrap_or(0.0)
            })
        } else {
            self.graph.astar_path(source, target, |_| 0.0)
        };
        Ok(match result {
            Some((cost, path)) => (cost, path).into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        })
    }

    fn bellman_ford_distances(&self, py: Python, source: u64) -> PyObject {
        match self.graph.bellman_ford_distances(source) {
            Some(d) => hashmap_u64_f64_to_py(py, d),
            None => py.None(),
        }
    }

    fn floyd_warshall_distances(&self, py: Python) -> PyObject {
        let result = self.graph.floyd_warshall_distances();
        let d = PyDict::new(py);
        for ((a, b), dist) in result { d.set_item((a, b), dist).unwrap(); }
        d.into_pyobject(py).unwrap().into_any().unbind()
    }

    fn all_pairs_distances(&self, py: Python) -> PyObject {
        apsp_to_py(py, self.graph.all_pairs_distances())
    }

    // ── Spanning Trees ────────────────────────────────────────────────────

    fn minimum_spanning_tree(&self, py: Python) -> PyObject {
        edge_triples_to_py(py, self.graph.minimum_spanning_tree())
    }

    fn maximum_spanning_tree(&self, py: Python) -> PyObject {
        edge_triples_to_py(py, self.graph.maximum_spanning_tree())
    }

    // ── DAG Algorithms ────────────────────────────────────────────────────

    fn topological_sort(&self, py: Python) -> PyResult<PyObject> {
        match self.graph.topological_sort() {
            Ok(order) => Ok(vec_u64_to_py(py, order)),
            Err(cycle) => Err(PyRuntimeError::new_err(format!(
                "Graph has cycles involving nodes: {:?}", cycle
            ))),
        }
    }

    fn is_dag(&self) -> bool {
        self.graph.is_dag()
    }

    fn find_cycles(&self, py: Python) -> PyObject {
        vec_vecs_to_py(py, self.graph.find_cycles())
    }

    fn dag_longest_path(&self, py: Python) -> PyObject {
        match self.graph.dag_longest_path() {
            Some((len, path)) => (len, path).into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        }
    }

    fn dag_longest_path_weighted(&self, py: Python) -> PyObject {
        match self.graph.dag_longest_path_weighted() {
            Some((cost, path)) => (cost, path).into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        }
    }

    fn transitive_closure(&self, py: Python) -> PyObject {
        edge_pairs_to_py(py, self.graph.transitive_closure())
    }

    // ── Network Flow ──────────────────────────────────────────────────────

    fn max_flow(&self, py: Python, source: u64, sink: u64) -> PyObject {
        match self.graph.max_flow(source, sink) {
            Some((mf, flows)) => (mf, flow_map_to_py(py, flows)).into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        }
    }

    fn min_cut_capacity(&self, py: Python, source: u64, sink: u64) -> PyObject {
        match self.graph.min_cut_capacity(source, sink) {
            Some(c) => c.into_pyobject(py).unwrap().into_any().unbind(),
            None => py.None(),
        }
    }

    // ── Graph Coloring ────────────────────────────────────────────────────

    fn node_coloring(&self, py: Python) -> PyObject {
        hashmap_u64_usize_to_py(py, self.graph.node_coloring())
    }

    fn edge_coloring(&self, py: Python) -> PyObject {
        let d = PyDict::new(py);
        for ((a, b), c) in self.graph.edge_coloring() { d.set_item((a, b), c).unwrap(); }
        d.into_pyobject(py).unwrap().into_any().unbind()
    }

    fn chromatic_number(&self) -> usize {
        self.graph.chromatic_number()
    }

    // ── Matching ──────────────────────────────────────────────────────────

    fn max_weight_matching(&self, py: Python) -> PyObject {
        edge_pairs_to_py(py, self.graph.max_weight_matching_edges())
    }

    fn max_cardinality_matching(&self, py: Python) -> PyObject {
        edge_pairs_to_py(py, self.graph.max_cardinality_matching_edges())
    }

    // ── Community Detection ───────────────────────────────────────────────

    #[pyo3(signature = (resolution=None))]
    fn louvain_communities(&self, py: Python, resolution: Option<f64>) -> PyObject {
        community_info_to_py(py, self.graph.louvain_communities(resolution))
    }

    fn label_propagation_communities(&self, py: Python) -> PyObject {
        community_info_to_py(py, self.graph.label_propagation_communities())
    }

    fn girvan_newman_communities(&self, py: Python, k: usize) -> PyObject {
        community_info_to_py(py, self.graph.girvan_newman_communities(k))
    }

    // ── Components ────────────────────────────────────────────────────────

    fn connected_components(&self, py: Python) -> PyObject {
        vec_vecs_to_py(py, self.graph.connected_components_list())
    }

    fn strongly_connected_components(&self, py: Python) -> PyObject {
        vec_vecs_to_py(py, self.graph.strongly_connected_components_list())
    }

    // ── Serialization ─────────────────────────────────────────────────────

    /// Save the graph to a JSON file using the GraphDump format.
    ///
    /// Args:
    ///     path: File path to write to (created or overwritten).
    fn save(&self, path: &str) -> PyResult<()> {
        let nodes: Vec<GraphNode> = self.graph.all_nodes().cloned().collect();
        let edges: Vec<SerializedEdge> = self
            .graph
            .all_edges()
            .map(|edge| {
                let (start_id, end_id) = self.graph.get_edge_endpoints(edge.id).unwrap_or((0, 0));
                SerializedEdge {
                    edge: edge.clone(),
                    start_id,
                    end_id,
                }
            })
            .collect();
        dump_to_file("Graphrs", nodes, edges, path)
            .map_err(|e| PyRuntimeError::new_err(format!("Failed to save graph: {}", e)))
    }

    /// Load a graph from a JSON file previously saved with `save()`.
    ///
    /// Args:
    ///     path: File path to read from.
    ///
    /// Returns:
    ///     A new GraphrsGraph instance with the loaded data.
    #[staticmethod]
    fn load(path: &str) -> PyResult<PyGraphrsGraph> {
        let dump = load_dump(path)
            .map_err(|e| PyRuntimeError::new_err(format!("Failed to load graph: {}", e)))?;
        let mut graph = GraphrsBackend::new();
        let mut nodes = dump.nodes;
        nodes.sort_by_key(|n| n.id);
        for node in nodes {
            graph.create_node(node.labels.into_iter(), node.properties.clone());
        }
        for se in dump.edges {
            graph
                .create_relationship(se.start_id, se.end_id, se.edge.rel_type.clone(), se.edge.properties.clone())
                .map_err(|e| PyRuntimeError::new_err(format!("Failed to restore relationship: {}", e)))?;
        }
        Ok(PyGraphrsGraph {
            graph,
            #[cfg(feature = "metrics")]
            metrics: crate::telemetry::GraphMetrics::new(),
            #[cfg(feature = "metrics")]
            on_execute_cb: None,
        })
    }
}

/// Python module initialization
#[pymodule]
fn _ocg(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add("__version__", env!("CARGO_PKG_VERSION"))?;
    m.add_class::<PyGraph>()?;
    m.add_class::<PyNetworKitGraph>()?;
    m.add_class::<PyRustworkxGraph>()?;
    m.add_class::<PyGraphrsGraph>()?;
    m.add_function(wrap_pyfunction!(execute, m)?)?;
    Ok(())
}
