//! Write-Ahead Log (WAL) for durable partition persistence.
//!
//! Enabled by WalConfig. When enabled, every mutation is appended to an
//! NDJSON log file before being applied to the in-memory graph. On pod
//! restart, the WAL is replayed on top of the last Parquet snapshot,
//! giving zero-data-loss recovery down to the operation granularity.

#[cfg(feature = "distributed")]
use std::fs::{File, OpenOptions};
#[cfg(feature = "distributed")]
use std::io::{BufRead, BufReader, Write};
#[cfg(feature = "distributed")]
use std::path::PathBuf;

#[cfg(feature = "distributed")]
use crate::distributed::graph::PartitionedGraphBackend;
#[cfg(feature = "distributed")]
use crate::error::{CypherError, CypherResult, ExecutionError};
#[cfg(feature = "distributed")]
use crate::graph::GraphBackend;

/// Helper macro to convert error messages to CypherError
#[cfg(feature = "distributed")]
macro_rules! cypher_err {
    ($msg:expr) => {
        CypherError::Execution(ExecutionError::Internal($msg.to_string()))
    };
}

/// WAL sync mode: flush to disk on every write (Fsync) or accept OS buffering (Async).
#[cfg(feature = "distributed")]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum WalSyncMode {
    /// Call fsync(2) after every append — safest, slowest.
    Fsync,
    /// Rely on OS page-cache flush — fastest, may lose the last few entries on crash.
    Async,
}

/// Configuration for the Write-Ahead Log.
#[cfg(feature = "distributed")]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct WalConfig {
    /// Enable WAL. When false, all WAL methods become no-ops.
    pub enabled: bool,
    /// Explicit path to the WAL log file. Defaults to `{storage_path}/wal/wal.log`.
    pub path: Option<String>,
    /// Sync strategy after each append.
    pub sync_mode: WalSyncMode,
    /// Number of entries after which `needs_checkpoint()` returns true.
    pub max_entries_before_checkpoint: u64,
}

#[cfg(feature = "distributed")]
impl Default for WalConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            path: None,
            sync_mode: WalSyncMode::Fsync,
            max_entries_before_checkpoint: 10_000,
        }
    }
}

#[cfg(feature = "distributed")]
impl WalConfig {
    /// Build a `WalConfig` from environment variables.
    ///
    /// | Variable                    | Default  | Description                          |
    /// |-----------------------------|----------|--------------------------------------|
    /// | `WAL_ENABLED`               | `true`   | Enable WAL journaling                |
    /// | `WAL_DIR`                   | (none)   | Explicit WAL directory path          |
    /// | `WAL_SYNC_MODE`             | `fsync`  | `fsync` or `async`                   |
    /// | `WAL_CHECKPOINT_THRESHOLD`  | `10000`  | Entries before auto-checkpoint       |
    pub fn from_env() -> Self {
        let enabled = std::env::var("WAL_ENABLED")
            .map(|v| !matches!(v.to_lowercase().as_str(), "false" | "0" | "no"))
            .unwrap_or(true);

        let path = std::env::var("WAL_DIR").ok();

        let sync_mode = std::env::var("WAL_SYNC_MODE")
            .map(|v| match v.to_lowercase().as_str() {
                "async" => WalSyncMode::Async,
                _ => WalSyncMode::Fsync,
            })
            .unwrap_or(WalSyncMode::Fsync);

        let max_entries_before_checkpoint = std::env::var("WAL_CHECKPOINT_THRESHOLD")
            .ok()
            .and_then(|s| s.parse().ok())
            .unwrap_or(10_000);

        Self {
            enabled,
            path,
            sync_mode,
            max_entries_before_checkpoint,
        }
    }
}

/// All graph-mutating operations that can be logged.
#[cfg(feature = "distributed")]
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(tag = "op", content = "data")]
pub enum WalOperation {
    CreateNode {
        id: u64,
        labels: Vec<String>,
        properties: indexmap::IndexMap<String, crate::graph::PropertyValue>,
    },
    CreateRelationship {
        id: u64,
        src: u64,
        dst: u64,
        rel_type: String,
        properties: indexmap::IndexMap<String, crate::graph::PropertyValue>,
    },
    DeleteNode {
        id: u64,
    },
    DeleteRelationship {
        id: u64,
    },
    SetNodeProperty {
        id: u64,
        key: String,
        value: crate::graph::PropertyValue,
    },
    SetEdgeProperty {
        id: u64,
        key: String,
        value: crate::graph::PropertyValue,
    },
    RemoveNodeProperty {
        id: u64,
        key: String,
    },
    RemoveEdgeProperty {
        id: u64,
        key: String,
    },
    RemoveLabel {
        id: u64,
        label: String,
    },
    AddLabel {
        id: u64,
        label: String,
    },
    AddCrossPartitionEdge {
        edge_id: u64,
        source_node_id: u64,
        target_node_id: u64,
        source_partition: u32,
        target_partition: u32,
        rel_type: String,
    },
    RemoveCrossPartitionEdge {
        edge_id: u64,
    },
}

/// A single WAL entry (one line in the NDJSON file).
#[cfg(feature = "distributed")]
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct WalEntry {
    /// Log Sequence Number — monotonically increasing, 1-based.
    pub lsn: u64,
    /// RFC3339 timestamp with millisecond precision.
    pub ts: String,
    /// Whether the entry was fully committed (always true on write).
    pub committed: bool,
    /// The graph operation.
    #[serde(flatten)]
    pub op: WalOperation,
}

/// Write-Ahead Log handle.
///
/// All operations are synchronous (backed by `std::fs::File`).
/// Async callers should run mutations in a `spawn_blocking` task if needed.
#[cfg(feature = "distributed")]
pub struct WriteAheadLog {
    config: WalConfig,
    log_path: PathBuf,
    file: Option<File>,
    next_lsn: u64,
    entries_since_checkpoint: u64,
}

#[cfg(feature = "distributed")]
impl WriteAheadLog {
    /// Open (or create) the WAL at `config.path` or the default path under `storage_path`.
    ///
    /// Counts existing lines in the file to initialise `next_lsn`.
    pub fn open(config: &WalConfig, storage_path: &str) -> CypherResult<Self> {
        let log_path = match &config.path {
            Some(p) => PathBuf::from(p),
            None => {
                let mut p = PathBuf::from(storage_path);
                p.push("wal");
                p.push("wal.log");
                p
            }
        };

        // Create parent directory if it does not exist.
        if let Some(parent) = log_path.parent() {
            std::fs::create_dir_all(parent)
                .map_err(|e| cypher_err!(format!("WAL: failed to create dir {}: {}", parent.display(), e)))?;
        }

        // Count existing entries to restore next_lsn.
        let existing_entries = if log_path.exists() {
            let f = File::open(&log_path)
                .map_err(|e| cypher_err!(format!("WAL: failed to open for counting: {}", e)))?;
            BufReader::new(f).lines().filter(|l| l.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false)).count() as u64
        } else {
            0
        };

        let file = OpenOptions::new()
            .create(true)
            .append(true)
            .open(&log_path)
            .map_err(|e| cypher_err!(format!("WAL: failed to open {}: {}", log_path.display(), e)))?;

        Ok(Self {
            config: config.clone(),
            log_path,
            file: Some(file),
            next_lsn: existing_entries + 1,
            entries_since_checkpoint: existing_entries,
        })
    }

    /// Append a WAL operation. Returns the LSN assigned to the entry.
    ///
    /// Fsyncs the file if `sync_mode == Fsync`.
    pub fn append(&mut self, op: WalOperation) -> CypherResult<u64> {
        let lsn = self.next_lsn;
        let ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();

        let entry = WalEntry {
            lsn,
            ts,
            committed: true,
            op,
        };

        let mut line = serde_json::to_string(&entry)
            .map_err(|e| cypher_err!(format!("WAL: failed to serialize entry: {}", e)))?;
        line.push('\n');

        let file = self.file.as_mut().ok_or_else(|| cypher_err!("WAL: file handle not open"))?;

        file.write_all(line.as_bytes())
            .map_err(|e| cypher_err!(format!("WAL: write failed: {}", e)))?;

        if matches!(self.config.sync_mode, WalSyncMode::Fsync) {
            file.sync_data()
                .map_err(|e| cypher_err!(format!("WAL: fsync failed: {}", e)))?;
        }

        self.next_lsn += 1;
        self.entries_since_checkpoint += 1;

        Ok(lsn)
    }

    /// Truncate the WAL file to zero bytes and reset counters.
    ///
    /// Call this after a successful Parquet snapshot save.
    pub fn checkpoint(&mut self) -> CypherResult<()> {
        // Close the current file handle, truncate, then reopen for appending.
        self.file = None;

        // Overwrite the file with empty content.
        File::create(&self.log_path)
            .map_err(|e| cypher_err!(format!("WAL: checkpoint truncate failed: {}", e)))?;

        let file = OpenOptions::new()
            .create(true)
            .append(true)
            .open(&self.log_path)
            .map_err(|e| cypher_err!(format!("WAL: failed to reopen after checkpoint: {}", e)))?;

        self.file = Some(file);
        self.next_lsn = 1;
        self.entries_since_checkpoint = 0;

        tracing::info!(log_path = %self.log_path.display(), "WAL checkpoint: log truncated");
        Ok(())
    }

    /// Replay all entries in the WAL on top of `backend`.
    ///
    /// Returns the number of entries replayed.
    /// Skips empty lines; returns an error on malformed JSON.
    pub fn replay(&self, backend: &mut PartitionedGraphBackend) -> CypherResult<usize> {
        if !self.log_path.exists() {
            tracing::info!(log_path = %self.log_path.display(), "WAL: no log file found; nothing to replay");
            return Ok(0);
        }

        let f = File::open(&self.log_path)
            .map_err(|e| cypher_err!(format!("WAL: failed to open for replay: {}", e)))?;

        let reader = BufReader::new(f);
        let mut count = 0usize;

        for (line_no, line_result) in reader.lines().enumerate() {
            let line = line_result
                .map_err(|e| cypher_err!(format!("WAL: I/O error reading line {}: {}", line_no + 1, e)))?;

            let trimmed = line.trim();
            if trimmed.is_empty() {
                continue;
            }

            let entry: WalEntry = serde_json::from_str(trimmed)
                .map_err(|e| cypher_err!(format!("WAL: malformed entry at line {}: {} — {:?}", line_no + 1, e, trimmed)))?;

            Self::apply_entry(backend, entry.op)?;
            count += 1;
        }

        Ok(count)
    }

    /// Number of entries written since the last checkpoint.
    pub fn entries_since_checkpoint(&self) -> u64 {
        self.entries_since_checkpoint
    }

    /// Returns true when the log has grown past `max_entries_before_checkpoint`.
    pub fn needs_checkpoint(&self) -> bool {
        self.entries_since_checkpoint >= self.config.max_entries_before_checkpoint
    }

    // ─── Internal helpers ───────────────────────────────────────────────────

    fn apply_entry(
        backend: &mut PartitionedGraphBackend,
        op: WalOperation,
    ) -> CypherResult<()> {
        match op {
            WalOperation::CreateNode { id, labels, properties } => {
                // Replay with the exact same ID that was stored in the WAL.
                backend.replay_create_node(id, labels, properties);
            }
            WalOperation::CreateRelationship { id, src, dst, rel_type, properties } => {
                // Replay with the exact edge ID from the WAL.
                backend.replay_create_relationship(id, src, dst, rel_type, properties)
                    .map_err(|e| cypher_err!(format!("WAL replay CreateRelationship: {}", e)))?;
            }
            WalOperation::DeleteNode { id } => {
                // Best-effort: ignore node-not-found (already gone on idempotent replay).
                let _ = backend.delete_node(id);
            }
            WalOperation::DeleteRelationship { id } => {
                let _ = backend.delete_relationship(id);
            }
            WalOperation::SetNodeProperty { id, key, value } => {
                backend.set_node_property(id, key, value)
                    .map_err(|e| cypher_err!(format!("WAL replay SetNodeProperty: {}", e)))?;
            }
            WalOperation::SetEdgeProperty { id, key, value } => {
                backend.set_edge_property(id, key, value)
                    .map_err(|e| cypher_err!(format!("WAL replay SetEdgeProperty: {}", e)))?;
            }
            WalOperation::RemoveNodeProperty { id, key } => {
                let _ = backend.remove_node_property(id, &key);
            }
            WalOperation::RemoveEdgeProperty { id, key } => {
                let _ = backend.remove_edge_property(id, &key);
            }
            WalOperation::RemoveLabel { id, label } => {
                let _ = backend.remove_label(id, &label);
            }
            WalOperation::AddLabel { id, label } => {
                backend.add_label(id, label)
                    .map_err(|e| cypher_err!(format!("WAL replay AddLabel: {}", e)))?;
            }
            WalOperation::AddCrossPartitionEdge {
                edge_id, source_node_id, target_node_id,
                source_partition, target_partition, rel_type,
            } => {
                backend.replay_add_cross_partition_edge(
                    edge_id, source_node_id, target_node_id,
                    source_partition, target_partition, rel_type,
                );
            }
            WalOperation::RemoveCrossPartitionEdge { edge_id } => {
                backend.replay_remove_cross_partition_edge(edge_id);
            }
        }
        Ok(())
    }
}

// ─── Tests ──────────────────────────────────────────────────────────────────

#[cfg(all(test, feature = "distributed"))]
mod tests {
    use super::*;
    use crate::distributed::graph::PartitionedGraphBackend;
    use crate::distributed::partition::PartitionMap;
    use crate::graph::GraphBackend;
    use indexmap::IndexMap;
    use std::sync::{Arc, RwLock};
    use tempfile::TempDir;

    fn make_config(dir: &std::path::Path) -> WalConfig {
        WalConfig {
            enabled: true,
            path: Some(dir.join("wal.log").to_string_lossy().into_owned()),
            sync_mode: WalSyncMode::Async,
            max_entries_before_checkpoint: 10_000,
        }
    }

    fn make_backend(partition_id: u32) -> PartitionedGraphBackend {
        let pm = Arc::new(RwLock::new(PartitionMap::new(1)));
        PartitionedGraphBackend::new(partition_id, pm)
    }

    /// Round-trip: write CreateNode + CreateRelationship, replay into fresh backend.
    #[test]
    fn test_wal_create_and_replay() {
        let tmp = TempDir::new().unwrap();
        let cfg = make_config(tmp.path());

        let mut wal = WriteAheadLog::open(&cfg, tmp.path().to_str().unwrap()).unwrap();

        // Populate a source backend.
        let mut src = make_backend(0);
        let n1 = src.create_node(vec!["Person"], {
            let mut p = IndexMap::new();
            p.insert("name".to_string(), crate::graph::PropertyValue::String("Alice".to_string()));
            p
        });
        let n2 = src.create_node(vec!["Person"], IndexMap::new());
        let _e1 = src.create_relationship(n1, n2, "KNOWS", IndexMap::new()).unwrap();

        // Log the same operations to WAL.
        wal.append(WalOperation::CreateNode {
            id: n1,
            labels: vec!["Person".to_string()],
            properties: {
                let mut p = IndexMap::new();
                p.insert("name".to_string(), crate::graph::PropertyValue::String("Alice".to_string()));
                p
            },
        }).unwrap();
        wal.append(WalOperation::CreateNode {
            id: n2,
            labels: vec!["Person".to_string()],
            properties: IndexMap::new(),
        }).unwrap();
        wal.append(WalOperation::CreateRelationship {
            id: _e1,
            src: n1,
            dst: n2,
            rel_type: "KNOWS".to_string(),
            properties: IndexMap::new(),
        }).unwrap();

        assert_eq!(wal.entries_since_checkpoint(), 3);

        // Replay into a fresh backend.
        let mut dst = make_backend(0);
        let replayed = wal.replay(&mut dst).unwrap();

        assert_eq!(replayed, 3);
        assert_eq!(dst.node_count(), 2);
        assert_eq!(dst.edge_count(), 1);
    }

    /// Checkpoint truncates the log and resets the counter.
    #[test]
    fn test_wal_checkpoint() {
        let tmp = TempDir::new().unwrap();
        let cfg = make_config(tmp.path());
        let mut wal = WriteAheadLog::open(&cfg, tmp.path().to_str().unwrap()).unwrap();

        wal.append(WalOperation::DeleteNode { id: 1 }).unwrap();
        wal.append(WalOperation::DeleteNode { id: 2 }).unwrap();
        assert_eq!(wal.entries_since_checkpoint(), 2);

        wal.checkpoint().unwrap();
        assert_eq!(wal.entries_since_checkpoint(), 0);

        // After checkpoint the file should be empty (0 entries replayed).
        let mut backend = make_backend(0);
        let replayed = wal.replay(&mut backend).unwrap();
        assert_eq!(replayed, 0);
    }

    /// needs_checkpoint respects max_entries_before_checkpoint.
    #[test]
    fn test_wal_needs_checkpoint() {
        let tmp = TempDir::new().unwrap();
        let cfg = WalConfig {
            enabled: true,
            path: Some(tmp.path().join("wal.log").to_string_lossy().into_owned()),
            sync_mode: WalSyncMode::Async,
            max_entries_before_checkpoint: 2,
        };
        let mut wal = WriteAheadLog::open(&cfg, tmp.path().to_str().unwrap()).unwrap();

        assert!(!wal.needs_checkpoint());
        wal.append(WalOperation::DeleteNode { id: 1 }).unwrap();
        assert!(!wal.needs_checkpoint());
        wal.append(WalOperation::DeleteNode { id: 2 }).unwrap();
        assert!(wal.needs_checkpoint());
    }
}
