//! CRDT snapshot compaction with cross-node coordination.
//!
//! This module implements full CRDT state snapshots for compacting the op journal.
//! When the op log grows beyond a configurable threshold (default: 10,000 ops) or
//! on a scheduled tick (default: weekly), the leader node generates a snapshot of
//! the current CRDT state and coordinates with all alive peers to discard ops that
//! precede the snapshot.
//!
//! # Attribution preservation
//!
//! Compaction preserves the full attribution chain via an "op manifest" — a
//! compact record of `(author, story_id, signature, seq)` tuples for every op
//! in the compacted range.  This allows incident-response forensics to
//! reconstruct who did what to which story, even after the op payloads are
//! discarded.
//!
//! # Wire messages
//!
//! - `{"type": "snapshot", "at_seq": <seq>, "state": <serialized CRDT state>}`
//! - `{"type": "snapshot_ack", "at_seq": <seq>}`
//!
//! # Leader selection
//!
//! The alive peer with the lowest `hash(node_id)` generates the snapshot,
//! mirroring the claim-priority shape from story 634.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::OnceLock;

use crate::crdt_state;

// ── Configuration ──────────────────────────────────────────────────────

/// Default threshold: snapshot fires when ALL_OPS.len() exceeds this.
pub const DEFAULT_SNAPSHOT_THRESHOLD: usize = 10_000;

/// Default scheduled interval in seconds (1 week).
pub const DEFAULT_SNAPSHOT_INTERVAL_SECS: u64 = 7 * 24 * 3600;

/// Configurable snapshot threshold (set at startup, defaults to [`DEFAULT_SNAPSHOT_THRESHOLD`]).
static SNAPSHOT_THRESHOLD: OnceLock<usize> = OnceLock::new();

/// Return the current snapshot threshold.
pub fn snapshot_threshold() -> usize {
    SNAPSHOT_THRESHOLD
        .get()
        .copied()
        .unwrap_or(DEFAULT_SNAPSHOT_THRESHOLD)
}

/// Set the snapshot threshold (must be called before first trigger check).
pub fn set_snapshot_threshold(threshold: usize) {
    let _ = SNAPSHOT_THRESHOLD.set(threshold);
}

// ── Wire message types ─────────────────────────────────────────────────

/// A single entry in the attribution manifest — preserves forensic metadata
/// for each op that existed before the snapshot point.
///
/// This allows reconstructing who did what to which story, even after the
/// op payloads (state values) are discarded during compaction.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct OpManifestEntry {
    /// Hex-encoded Ed25519 public key of the op's author.
    pub author: String,
    /// The story_id this op pertains to (extracted from the op's path/content).
    pub story_id: String,
    /// Hex-encoded Ed25519 signature proving the author created this op.
    pub sig: String,
    /// Lamport sequence number of the op.
    pub seq: u64,
}

/// A CRDT state snapshot used for compaction.
///
/// Contains the serialized CRDT state at a specific sequence point, plus an
/// attribution manifest that preserves the forensic chain for all ops up to
/// that point.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Snapshot {
    /// The sequence number at which this snapshot was taken.
    /// All ops with `seq < at_seq` can be discarded after compaction.
    pub at_seq: u64,
    /// The serialized CRDT state (all ops as JSON strings).
    pub state: Vec<String>,
    /// Attribution manifest: one entry per op in the compacted range.
    /// Preserves author, story_id, signature, and seq for forensics.
    pub op_manifest: Vec<OpManifestEntry>,
}

/// Acknowledgement that a peer has received and applied a snapshot.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct SnapshotAck {
    /// The sequence number being acknowledged — must match the snapshot's `at_seq`.
    pub at_seq: u64,
}

/// Wire messages for the snapshot protocol, exchanged as text frames during
/// the CRDT sync streaming phase.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SnapshotMessage {
    /// Leader broadcasts the snapshot to all peers.
    Snapshot(Snapshot),
    /// Peer acknowledges receipt and application of the snapshot.
    SnapshotAck(SnapshotAck),
}

// ── Snapshot state ─────────────────────────────────────────────────────

/// In-memory snapshot state for this node.
struct SnapshotState {
    /// The most recent completed snapshot (if any).
    latest_snapshot: Option<Snapshot>,
    /// Pending acks for an in-progress snapshot coordination.
    /// Maps node_id → whether they've acked.
    pending_acks: HashMap<String, bool>,
    /// The at_seq of the in-progress snapshot (if any).
    pending_at_seq: Option<u64>,
}

#[cfg(not(test))]
static SNAPSHOT_STATE: OnceLock<Mutex<SnapshotState>> = OnceLock::new();

#[cfg(test)]
thread_local! {
    /// Per-thread snapshot state for test isolation.  Each test thread gets its
    /// own SnapshotState so parallel tests do not leak into each other's
    /// snapshot/coordination history.
    static SNAPSHOT_STATE_TL: OnceLock<Mutex<SnapshotState>> = const { OnceLock::new() };
}

/// Read access to the snapshot state.
///
/// In production this returns the global `SNAPSHOT_STATE`.  In `cfg(test)`
/// each thread sees its own thread-local state, so parallel tests do not
/// share `SnapshotState`.
fn snapshot_state() -> Option<&'static Mutex<SnapshotState>> {
    #[cfg(not(test))]
    {
        SNAPSHOT_STATE.get()
    }
    #[cfg(test)]
    {
        let ptr = SNAPSHOT_STATE_TL.with(|lock| lock as *const OnceLock<Mutex<SnapshotState>>);
        // SAFETY: the thread-local lives as long as the thread.  We only need
        // 'static for the return type; consumers never outlive the spawning
        // test thread.
        unsafe { &*ptr }.get()
    }
}

/// Initialise snapshot state.  Safe to call multiple times.
///
/// In production: idempotent via `OnceLock`.  In `cfg(test)`: initialises the
/// per-thread state on first call; subsequent calls on the same thread are
/// no-ops.
pub fn init() {
    let value = || {
        Mutex::new(SnapshotState {
            latest_snapshot: None,
            pending_acks: HashMap::new(),
            pending_at_seq: None,
        })
    };
    #[cfg(not(test))]
    {
        let _ = SNAPSHOT_STATE.set(value());
    }
    #[cfg(test)]
    {
        SNAPSHOT_STATE_TL.with(|lock| {
            let _ = lock.set(value());
        });
    }
}

/// Return the most recent completed snapshot, if any.
pub fn latest_snapshot() -> Option<Snapshot> {
    snapshot_state()?.lock().ok()?.latest_snapshot.clone()
}

// ── Leader selection ───────────────────────────────────────────────────

/// Compute the snapshot-leader priority hash for a node.
///
/// Uses the same SHA-256 approach as story 634's claim priority, but hashes
/// only the `node_id` (not a story_id) since snapshot leadership is global.
fn leader_hash(node_id: &str) -> u64 {
    use sha2::{Digest, Sha256};
    let mut hasher = Sha256::new();
    hasher.update(node_id.as_bytes());
    let digest = hasher.finalize();
    u64::from_be_bytes(digest[..8].try_into().expect("sha256 is 32 bytes"))
}

/// Determine whether this node is the snapshot leader among alive peers.
///
/// The alive peer with the **lowest** `hash(node_id)` is the leader.
/// Returns `true` if `self_node_id` has a strictly lower hash than all
/// other alive peers.  When there are no other alive peers (single-node
/// cluster), the result is always `true`.
pub fn is_snapshot_leader(self_node_id: &str, alive_peer_node_ids: &[String]) -> bool {
    let my_hash = leader_hash(self_node_id);
    for peer_id in alive_peer_node_ids {
        if peer_id == self_node_id {
            continue;
        }
        if leader_hash(peer_id) <= my_hash {
            return false;
        }
    }
    true
}

// ── Trigger logic ──────────────────────────────────────────────────────

/// Check whether a snapshot should be triggered based on the current op count.
///
/// Returns `true` when `ALL_OPS.len() > threshold` (configurable, default 10,000).
pub fn should_trigger_by_count() -> bool {
    let count = crdt_state::all_ops_json().map(|ops| ops.len()).unwrap_or(0);
    count > snapshot_threshold()
}

// ── Snapshot generation ────────────────────────────────────────────────

/// Generate a snapshot of the current CRDT state.
///
/// The snapshot contains:
/// 1. All ops as serialized JSON (the full CRDT state).
/// 2. An attribution manifest preserving author, story_id, sig, and seq
///    for every op — ensuring forensic traceability survives compaction.
///
/// The `at_seq` is the highest sequence number across all ops.
pub fn generate_snapshot() -> Option<Snapshot> {
    let all_ops = crdt_state::all_ops_json()?;
    if all_ops.is_empty() {
        return None;
    }

    let mut max_seq: u64 = 0;
    let mut manifest = Vec::with_capacity(all_ops.len());

    for op_json in &all_ops {
        if let Ok(signed_op) = serde_json::from_str::<bft_json_crdt::json_crdt::SignedOp>(op_json) {
            let seq = signed_op.inner.seq;
            if seq > max_seq {
                max_seq = seq;
            }

            // Extract story_id from the op's content if available.
            let story_id = extract_story_id_from_op(&signed_op);

            manifest.push(OpManifestEntry {
                author: crdt_state::hex::encode(&signed_op.author()),
                story_id,
                sig: crdt_state::hex::encode(&signed_op.signed_digest),
                seq,
            });
        }
    }

    Some(Snapshot {
        at_seq: max_seq,
        state: all_ops,
        op_manifest: manifest,
    })
}

/// Extract the story_id from a SignedOp's content, if it contains one.
///
/// Ops that target pipeline items contain a JSON object with a `story_id`
/// field.  For ops that don't (e.g. node presence), returns an empty string.
pub(crate) fn extract_story_id_from_op(op: &bft_json_crdt::json_crdt::SignedOp) -> String {
    // Try to extract from the op's path — the second segment often contains
    // the list index which maps to a story.  However, the most reliable way
    // is to look at the content if it's an insert op.
    if let Some(bft_json_crdt::json_crdt::JsonValue::Object(map)) = &op.inner.content
        && let Some(bft_json_crdt::json_crdt::JsonValue::String(sid)) = map.get("story_id")
    {
        return sid.clone();
    }
    // For field-update ops (LWW set), the path tells us which item, but not
    // the story_id directly.  Return empty — the manifest still preserves
    // author + sig + seq for forensic correlation.
    String::new()
}

// ── Coordination ──────────────────────────────────────────────────────

/// Begin snapshot coordination as the leader.
///
/// Records which alive peers need to ack and stores the pending snapshot.
/// Returns the snapshot to broadcast to peers.
pub fn begin_coordination(
    snapshot: Snapshot,
    alive_peer_node_ids: &[String],
    self_node_id: &str,
) -> Option<Snapshot> {
    let state = snapshot_state()?;
    let mut s = state.lock().ok()?;

    let mut pending = HashMap::new();
    for peer_id in alive_peer_node_ids {
        if peer_id != self_node_id {
            pending.insert(peer_id.clone(), false);
        }
    }

    s.pending_at_seq = Some(snapshot.at_seq);
    s.pending_acks = pending;

    Some(snapshot)
}

/// Record a snapshot ack from a peer.
///
/// Returns `true` if quorum has been reached (all alive peers have acked).
pub fn record_ack(node_id: &str, at_seq: u64) -> bool {
    let Some(state) = snapshot_state() else {
        return false;
    };
    let Ok(mut s) = state.lock() else {
        return false;
    };

    // Verify the ack matches the pending snapshot.
    if s.pending_at_seq != Some(at_seq) {
        return false;
    }

    if let Some(acked) = s.pending_acks.get_mut(node_id) {
        *acked = true;
    }

    // Check if all peers have acked (quorum = all alive peers).
    s.pending_acks.values().all(|&v| v)
}

/// Abort the current pending snapshot coordination.
///
/// Called when a peer goes offline mid-coordination or quorum times out.
pub fn abort_coordination() {
    if let Some(state) = snapshot_state()
        && let Ok(mut s) = state.lock()
    {
        s.pending_at_seq = None;
        s.pending_acks.clear();
    }
}

/// Check whether there is a pending snapshot coordination in progress.
pub fn has_pending_coordination() -> bool {
    snapshot_state()
        .and_then(|s| s.lock().ok())
        .map(|s| s.pending_at_seq.is_some())
        .unwrap_or(false)
}

/// Return the list of peers that have NOT yet acked the pending snapshot.
pub fn unacked_peers() -> Vec<String> {
    snapshot_state()
        .and_then(|s| s.lock().ok())
        .map(|s| {
            s.pending_acks
                .iter()
                .filter(|&(_, &acked)| !acked)
                .map(|(id, _)| id.clone())
                .collect()
        })
        .unwrap_or_default()
}

// ── Compaction ─────────────────────────────────────────────────────────

/// Apply compaction: replace the op journal with the snapshot state.
///
/// After successful quorum, the leader (and each peer upon receiving the
/// snapshot) replaces its `ALL_OPS` with the snapshot's state and resets
/// the vector clock accordingly.
///
/// The snapshot's `op_manifest` is preserved in the snapshot state for
/// forensic queries.
///
/// Returns `true` if compaction was applied successfully.
pub fn apply_compaction(snapshot: Snapshot) -> bool {
    // Store the snapshot as the latest for future new-node onboarding.
    if let Some(state) = snapshot_state()
        && let Ok(mut s) = state.lock()
    {
        s.latest_snapshot = Some(snapshot.clone());
        s.pending_at_seq = None;
        s.pending_acks.clear();
    }

    // Replace ALL_OPS with the snapshot state (the compacted ops).
    // In a real compaction, we'd keep only ops with seq >= at_seq, but the
    // snapshot already contains the minimal set needed to reconstruct state.
    //
    // For this implementation, the snapshot state IS the full state — peers
    // discard their old journal and replace it with the snapshot's ops.
    // The op_manifest preserves attribution for the discarded ops.
    if let Some(all_ops) = crdt_state::all_ops_lock()
        && let Ok(mut v) = all_ops.lock()
    {
        // Calculate ops to prune: those with seq < at_seq
        let mut kept_ops = Vec::new();
        let mut pruned_count = 0usize;
        for op_json in v.iter() {
            if let Ok(signed_op) =
                serde_json::from_str::<bft_json_crdt::json_crdt::SignedOp>(op_json)
            {
                if signed_op.inner.seq >= snapshot.at_seq {
                    kept_ops.push(op_json.clone());
                } else {
                    pruned_count += 1;
                }
            } else {
                // Keep unparseable ops to avoid data loss.
                kept_ops.push(op_json.clone());
            }
        }

        *v = kept_ops;

        // Rebuild vector clock from remaining ops.
        if let Some(vc) = crdt_state::vector_clock_lock()
            && let Ok(mut clock) = vc.lock()
        {
            clock.clear();
            for op_json in v.iter() {
                if let Ok(signed_op) =
                    serde_json::from_str::<bft_json_crdt::json_crdt::SignedOp>(op_json)
                {
                    let author_hex = crdt_state::hex::encode(&signed_op.author());
                    *clock.entry(author_hex).or_insert(0) += 1;
                }
            }
        }

        crate::slog!(
            "[crdt-snapshot] Compaction applied: pruned {pruned_count} ops, kept {} ops",
            v.len()
        );
        return true;
    }

    false
}

/// Retrieve the op manifest from the latest snapshot for forensic queries.
///
/// Returns `None` if no snapshot has been taken yet.
pub fn latest_op_manifest() -> Option<Vec<OpManifestEntry>> {
    latest_snapshot().map(|s| s.op_manifest)
}

/// Query the op manifest for a specific story's attribution chain.
///
/// Returns all manifest entries where `story_id` matches the query.
pub fn query_attribution(story_id: &str) -> Vec<OpManifestEntry> {
    latest_op_manifest()
        .unwrap_or_default()
        .into_iter()
        .filter(|entry| entry.story_id == story_id)
        .collect()
}

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

#[cfg(test)]
mod tests;
