//! Simulated multi-region cluster for M8 UAT testing.
//!
//! Creates a set of ephemeral [`TidalDb`] instances (one per region) and
//! replicates signals from the leader to followers via a shared relay log.
//!
//! # Architecture
//!
//! Each node is a standard `TidalDb::builder().ephemeral().with_schema(...)`.
//! The "leader" is the node that accepts writes. The "followers" receive
//! replicated signals when [`SimulatedCluster::await_convergence`] is called.
//!
//! Replication is **signal-replay**: the leader records each signal in a
//! shared relay log, and `await_convergence` replays pending events into
//! each non-partitioned follower's `TidalDb`. This is fully synchronous
//! and deterministic -- no background threads, no race conditions.
//!
//! Partition injection marks a region as isolated. Isolated followers are
//! skipped during convergence and do not receive new events until the
//! partition is healed.

use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant};

use crate::db::TidalDb;
use crate::replication::shard::RegionId;
use crate::schema::{EntityId, Schema, Timestamp};

/// A signal event captured in the relay log for replication.
#[derive(Debug, Clone)]
pub struct RelayEvent {
    /// The signal type name (e.g. "view", "like").
    pub signal_type: String,
    /// The entity this signal targets.
    pub entity_id: EntityId,
    /// Signal weight.
    pub weight: f64,
    /// Timestamp of the signal.
    pub timestamp: Timestamp,
    /// Monotonically increasing sequence number (0-indexed).
    pub seqno: u64,
}

/// A simulated node in the cluster.
pub struct SimulatedNode {
    /// The region this node belongs to.
    pub region_id: RegionId,
    /// The `TidalDb` instance for this node.
    pub db: TidalDb,
}

/// Configuration for building a [`SimulatedCluster`].
pub struct ClusterConfig {
    /// Region IDs to create.
    pub regions: Vec<RegionId>,
    /// Which region hosts the leader node.
    pub leader_region: RegionId,
    /// Schema shared by all nodes.
    pub schema: Schema,
}

/// A simulated multi-region tidalDB cluster.
///
/// All communication happens via in-memory relay log. No actual network,
/// no actual disk I/O (ephemeral mode). Designed for deterministic,
/// repeatable integration tests.
pub struct SimulatedCluster {
    /// Current leader region ID. Mutable via `promote_leader`.
    leader_region: Mutex<RegionId>,
    /// All nodes indexed by region.
    nodes: HashMap<RegionId, SimulatedNode>,
    /// Shared relay log: append-only sequence of signal events.
    relay_log: Arc<Mutex<Vec<RelayEvent>>>,
    /// Per-follower: how many events from `relay_log` have been applied to
    /// this follower's `TidalDb`. Monotonically increasing.
    db_applied: HashMap<RegionId, AtomicU64>,
    /// Set of regions currently isolated (partition injected).
    partitioned_regions: Arc<RwLock<HashSet<RegionId>>>,
    /// Schema used by all nodes (kept for reference).
    #[allow(dead_code)]
    schema: Schema,
}

impl SimulatedCluster {
    /// Build a cluster from the given configuration.
    ///
    /// All nodes are created immediately in ephemeral mode.
    ///
    /// # Panics
    ///
    /// Panics if any `TidalDb` fails to open (e.g. invalid schema).
    #[must_use]
    pub fn build(config: ClusterConfig) -> Self {
        let mut nodes = HashMap::new();
        let mut db_applied = HashMap::new();

        for &region in &config.regions {
            let db = TidalDb::builder()
                .ephemeral()
                .with_schema(config.schema.clone())
                .open()
                .expect("ephemeral TidalDb with valid schema must open");
            nodes.insert(
                region,
                SimulatedNode {
                    region_id: region,
                    db,
                },
            );
            db_applied.insert(region, AtomicU64::new(0));
        }

        Self {
            leader_region: Mutex::new(config.leader_region),
            nodes,
            relay_log: Arc::new(Mutex::new(Vec::new())),
            db_applied,
            partitioned_regions: Arc::new(RwLock::new(HashSet::new())),
            schema: config.schema,
        }
    }

    /// The current leader region ID.
    #[must_use]
    pub fn leader_region(&self) -> RegionId {
        *self
            .leader_region
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
    }

    /// Reference to the leader node.
    ///
    /// # Panics
    ///
    /// Panics if the leader region has no node.
    #[must_use]
    pub fn leader(&self) -> &SimulatedNode {
        let region = self.leader_region();
        self.nodes
            .get(&region)
            .expect("leader region must have a node")
    }

    /// Reference to a node by region.
    ///
    /// # Panics
    ///
    /// Panics if no node exists for the region.
    #[must_use]
    pub fn node(&self, region: RegionId) -> &SimulatedNode {
        self.nodes
            .get(&region)
            .unwrap_or_else(|| panic!("no node for region {region}"))
    }

    /// Write a signal to the cluster leader and append to the relay log.
    ///
    /// The signal is immediately applied to the leader's `TidalDb` and
    /// recorded in the relay log for later replication to followers.
    ///
    /// # Panics
    ///
    /// Panics if the signal write on the leader fails.
    pub fn write_signal(&self, signal_type: &str, entity_id: EntityId, weight: f64) {
        let ts = Timestamp::now();
        let leader_region = self.leader_region();
        self.nodes
            .get(&leader_region)
            .expect("leader node exists")
            .db
            .signal(signal_type, entity_id, weight, ts)
            .expect("signal write on leader must succeed");

        let mut log = self
            .relay_log
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        let seqno = log.len() as u64;
        log.push(RelayEvent {
            signal_type: signal_type.to_string(),
            entity_id,
            weight,
            timestamp: ts,
            seqno,
        });
    }

    /// Write a signal directly to a specific region's node (bypassing leader).
    ///
    /// Used to simulate partitioned writes during split-brain scenarios.
    ///
    /// # Panics
    ///
    /// Panics if the signal write fails.
    pub fn write_signal_to_region(
        &self,
        region: RegionId,
        signal_type: &str,
        entity_id: EntityId,
        weight: f64,
    ) {
        let ts = Timestamp::now();
        self.nodes
            .get(&region)
            .unwrap_or_else(|| panic!("no node for region {region}"))
            .db
            .signal(signal_type, entity_id, weight, ts)
            .expect("signal write must succeed");
    }

    /// Wait for all non-partitioned followers to receive and apply all
    /// pending relay log events.
    ///
    /// This is synchronous: it replays events into each follower's `TidalDb`
    /// directly. The `timeout` guards against programming errors, not actual
    /// latency (in-process replay is instant).
    ///
    /// # Panics
    ///
    /// Panics if convergence is not reached within `timeout` (should never
    /// happen for in-process relay, but defends against infinite loops).
    pub fn await_convergence(&self, timeout: Duration) {
        let deadline = Instant::now() + timeout;
        let leader_region = self.leader_region();

        // In-process replay: apply all pending events to each non-partitioned
        // follower. We loop because a partition might be healed mid-wait.
        loop {
            assert!(
                Instant::now() <= deadline,
                "convergence timeout: cluster did not converge within {timeout:?}"
            );

            let partitioned = self
                .partitioned_regions
                .read()
                .unwrap_or_else(std::sync::PoisonError::into_inner)
                .clone();

            let log = self
                .relay_log
                .lock()
                .unwrap_or_else(std::sync::PoisonError::into_inner)
                .clone();
            let log_len = log.len() as u64;

            let mut all_converged = true;
            for (&region, node) in &self.nodes {
                if region == leader_region || partitioned.contains(&region) {
                    continue;
                }

                let applied = self
                    .db_applied
                    .get(&region)
                    .expect("db_applied entry for every region");
                let current = applied.load(Ordering::Acquire);

                if current < log_len {
                    // Replay events [current..log_len) into this follower.
                    for event in &log[current as usize..log_len as usize] {
                        node.db
                            .signal(
                                &event.signal_type,
                                event.entity_id,
                                event.weight,
                                event.timestamp,
                            )
                            .expect("follower signal replay must succeed");
                    }
                    applied.store(log_len, Ordering::Release);
                }

                if applied.load(Ordering::Acquire) < log_len {
                    all_converged = false;
                }
            }

            if all_converged {
                return;
            }
            std::thread::sleep(Duration::from_millis(1));
        }
    }

    /// Read decay score from a specific region's node.
    #[must_use]
    pub fn read_decay_score(
        &self,
        region: RegionId,
        entity_id: EntityId,
        signal_type: &str,
    ) -> Option<f64> {
        self.nodes.get(&region).and_then(|n| {
            n.db.read_decay_score(entity_id, signal_type, 0)
                .ok()
                .flatten()
        })
    }

    /// Current length of the relay log (total events written by the leader).
    #[must_use]
    pub fn relay_log_len(&self) -> u64 {
        self.relay_log
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .len() as u64
    }

    /// How many events have been applied to a specific region's follower DB.
    #[must_use]
    pub fn applied_count(&self, region: RegionId) -> u64 {
        self.db_applied
            .get(&region)
            .map_or(0, |a| a.load(Ordering::Acquire))
    }

    /// Access the shared partitioned regions set (for fault injection).
    #[must_use]
    pub const fn partitioned_regions(&self) -> &Arc<RwLock<HashSet<RegionId>>> {
        &self.partitioned_regions
    }

    /// Promote a follower to leader.
    ///
    /// The old leader stops receiving writes via `write_signal` (it is
    /// no longer returned by `leader()`). The new leader can now accept
    /// writes. Data already on each node is preserved.
    ///
    /// # Panics
    ///
    /// Panics if `new_leader` does not correspond to an existing node.
    pub fn promote_leader(&self, new_leader: RegionId) {
        assert!(
            self.nodes.contains_key(&new_leader),
            "cannot promote non-existent region {new_leader}"
        );
        let mut leader = self
            .leader_region
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        *leader = new_leader;
    }

    /// All region IDs in the cluster.
    #[must_use]
    pub fn regions(&self) -> Vec<RegionId> {
        self.nodes.keys().copied().collect()
    }
}
