//! Replication, multi-tenancy, and control-plane methods on [`TidalDb`].

use std::sync::Arc;

use super::TidalDb;

impl TidalDb {
    /// Access the replication state (for lag gauges and tests).
    #[must_use]
    #[allow(clippy::missing_const_for_fn)] // Arc field prevents const in practice
    pub fn replication_state(&self) -> &Arc<crate::replication::state::ReplicationState> {
        &self.replication_state
    }

    /// Start the segment receiver for a follower node.
    ///
    /// The receiver thread blocks on the transport's `recv_segment()` and
    /// applies each received WAL segment to the local signal ledger.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` if no ledger is wired (ephemeral without schema).
    pub fn start_replication<T: crate::replication::Transport + ?Sized>(
        &self,
        transport: std::sync::Arc<T>,
    ) -> crate::Result<()> {
        let ledger = self.ledger()?.clone();
        let state = Arc::clone(&self.replication_state);
        // Feed the control-plane lag gauge so `tidaldb_replication_lag_seqno`
        // reflects real follower backpressure (leader HWM − applied) instead of
        // staying pinned at 0 (obs-REPL-1). The gauge shares this node's
        // `replication_state`, so applied advances stay consistent.
        let lag_gauge = Some(self.control_plane.lag_gauge());
        let handle =
            crate::replication::receiver::spawn_receiver(transport, ledger, state, lag_gauge);
        if let Ok(mut guard) = self.receiver_handle.lock() {
            *guard = Some(handle);
        }
        Ok(())
    }

    /// Test-only: durably checkpoint the follower's replicated state into a
    /// crash-consistent on-disk snapshot.
    ///
    /// Writes, atomically-consistent with each other:
    /// 1. the signal-ledger aggregate (fjall checkpoint at the current WAL seq),
    /// 2. the WAL checkpoint marker at the SAME seq (so a subsequent crash replays
    ///    only post-checkpoint follower-WAL events — no double-counting of events
    ///    already folded into the aggregate), and
    /// 3. the replication high-water-mark ([`ReplicationState`] checkpoint).
    ///
    /// This lands the exact on-disk state a clean checkpoint cycle would, without
    /// the 30s wait, so a crash-recovery test can then simulate a hard crash (no
    /// graceful shutdown) and prove the follower restores its replicated state and
    /// applied-seqno boundary instead of resetting to a fresh node (BLOCKER 6).
    ///
    /// No-op in ephemeral mode (no durable storage / no WAL). Returns the same
    /// errors the periodic and shutdown checkpoints would.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Storage`/`TidalError::Durability` if any of the
    /// signal-ledger, WAL-marker, or replication-state checkpoints fail.
    ///
    /// [`ReplicationState`]: crate::replication::state::ReplicationState
    #[cfg(any(test, feature = "test-utils"))]
    pub fn force_replication_checkpoint(&self) -> crate::Result<()> {
        let (Some(ledger), Some(storage)) = (&self.ledger, &self.storage) else {
            return Ok(());
        };
        let seq = self.last_wal_seq.load(std::sync::atomic::Ordering::Acquire);
        let meta = crate::signals::checkpoint::CheckpointMeta {
            checkpoint_time_ns: crate::schema::Timestamp::now().as_nanos(),
            wal_sequence: seq,
            payload_hash: [0u8; 32],
        };
        ledger.checkpoint(storage.items_engine(), meta)?;
        crate::db::state_rebuild::checkpoint_replication_state(
            storage.items_engine(),
            self.replication_state.as_ref(),
        )?;
        // Advance the WAL checkpoint marker so reopen replays only follower-WAL
        // events strictly after `seq` — keeping the WAL marker consistent with the
        // fjall ledger checkpoint at the same seq (the same invariant shutdown
        // maintains), so the recovered aggregate is not double-counted. The
        // marker write is delegated out of the lock scope below to keep the WAL
        // mutex held for only the borrow.
        if seq > 0 {
            let marker_result = {
                let guard = self
                    .wal
                    .lock()
                    .map_err(|_| crate::TidalError::internal("wal_access", "WAL mutex poisoned"))?;
                guard.as_ref().map(|wal| wal.checkpoint(seq))
            };
            if let Some(Err(e)) = marker_result {
                return Err(crate::TidalError::Durability(
                    crate::schema::DurabilityError {
                        message: format!("force_replication_checkpoint WAL marker failed: {e}"),
                    },
                ));
            }
        }
        Ok(())
    }

    /// Snapshot this node's live CRDT state for reconciliation after a partition.
    ///
    /// Walks the signal ledger and the hard-negative index into a
    /// [`StateSnapshot`](crate::replication::reconcile::StateSnapshot):
    ///
    /// - **Signals:** every `(entity, signal_type)` entry becomes a per-node
    ///   [`CrdtSignalState`](crate::replication::crdt::CrdtSignalState) attributed
    ///   to this node's shard, carrying its decay score, `last_update_ns`, and —
    ///   crucially — its live `AllTime` windowed count as the per-node bucket
    ///   count, so the windowed total round-trips through merge instead of
    ///   collapsing to 0.
    /// - **Hard negatives:** every in-memory `(user, item)` pair becomes an
    ///   `LWWRegister<HardNegAction::Hide>` stamped at the current HLC. The index
    ///   stores only resolved membership, so "currently hidden as of now" is the
    ///   honest local assertion; the reconciliation engine's HLC LWW then resolves
    ///   it against any remote hide/unhide.
    ///
    /// Pair this with [`reconcile_with`](Self::reconcile_with) on the *other*
    /// node's snapshot (exchanged over the control plane after a partition heals)
    /// to deterministically merge diverged state. Producing a snapshot does not
    /// mutate any state.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` if this node was opened without a schema
    /// (no signal ledger to snapshot).
    pub fn take_crdt_snapshot(
        &self,
    ) -> crate::Result<crate::replication::reconcile::StateSnapshot> {
        use crate::replication::{
            crdt::{Hlc, LWWRegister},
            reconcile::{HardNegAction, StateSnapshot},
        };

        let ledger = self.ledger()?;
        let local_shard = self.config.cluster.shard_id;

        let mut snapshot = StateSnapshot::new();

        // Signal contributions (stored decay accumulator + windowed bucket count
        // per entry). The contribution carries each entry's stored score paired
        // with its own last_update_ns; decay is applied once at reconcile time.
        for (entity_id, type_id, state) in ledger.crdt_contributions(local_shard) {
            snapshot.add_signal_state(entity_id, type_id, state);
        }

        // Hard-negative membership → per-pair Hide registers stamped at now().
        // One HLC for this node attributes every register to this shard with a
        // strictly-monotonic timestamp, so the reconcile engine can LWW-order
        // them against remote hide/unhide decisions.
        let hlc = Hlc::for_shard(local_shard);
        for (user_id, item_id) in self.hard_negatives.all_pairs() {
            let mut reg = LWWRegister::empty();
            reg.write(HardNegAction::Hide, hlc.now());
            snapshot.add_hardneg_register(
                crate::schema::EntityId::new(user_id),
                crate::schema::EntityId::new(u64::from(item_id)),
                reg,
            );
        }

        Ok(snapshot)
    }

    /// Reconcile this node's live state against a remote node's CRDT snapshot
    /// after a partition heals.
    ///
    /// Builds a [`ReconciliationEngine`](crate::replication::reconcile::ReconciliationEngine)
    /// bound to this node's signal ledger and hard-negative index, takes this
    /// node's current [`take_crdt_snapshot`](Self::take_crdt_snapshot) as the
    /// local side, computes the deterministic
    /// [`MergePlan`](crate::replication::reconcile::MergePlan) against
    /// `remote_snapshot`, and applies it:
    ///
    /// - signal states are CRDT-merged per `(entity, signal_type)` (per-node
    ///   contributions preserved, windowed counts summed) and folded into both
    ///   the hot and warm tiers via `apply_crdt_state`;
    /// - hard negatives are LWW-resolved per `(user, item)` by HLC timestamp and
    ///   committed (Hide → add, Unhide → remove).
    ///
    /// Applying is idempotent: re-running with the same `remote_snapshot`
    /// produces identical state, so this is safe under at-least-once delivery of
    /// the snapshot exchange. Returns the number of operations applied (signal
    /// merges + hard-negative resolutions) for observability.
    ///
    /// This is the production entry point that heals a partition — without it the
    /// CRDT subsystem never runs outside tests. Wire it into the anti-entropy /
    /// reconnection flow by exchanging snapshots between the two sides and calling
    /// this on each. (No standing reconnection hook exists yet; the control plane
    /// tracks health only.)
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` if this node has no schema/ledger, or if a
    /// signal type in the plan is unknown to this ledger, or a hard-negative item
    /// ID exceeds the `u32` bitmap range.
    pub fn reconcile_with(
        &self,
        remote_snapshot: &crate::replication::reconcile::StateSnapshot,
    ) -> crate::Result<usize> {
        use crate::replication::reconcile::ReconciliationEngine;

        self.require_writeable("reconcile_with")?;

        let ledger = Arc::clone(self.ledger()?);
        let hard_neg = Arc::clone(&self.hard_negatives);
        let local_snapshot = self.take_crdt_snapshot()?;

        let engine = ReconciliationEngine::new(ledger, hard_neg);
        let plan = engine.plan(&local_snapshot, remote_snapshot);
        let op_count = plan.operation_count();
        engine.apply(&plan)?;

        tracing::info!(
            ops = op_count,
            signal_merges = plan.signal_merges.len(),
            hardneg_resolutions = plan.hardneg_resolutions.len(),
            "reconcile_with: applied CRDT merge plan after partition heal"
        );

        Ok(op_count)
    }

    /// Restore the tenant router's migration routing state (dual-write pairs +
    /// shard pins) from its durable checkpoint.
    ///
    /// Idempotent and safe to call once at open: on first boot / no checkpoint /
    /// ephemeral mode it restores nothing (every tenant uses jump-hash routing).
    /// Without it, a crash mid-migration silently reverts a finalized tenant to
    /// jump-hash routing (Accuracy-W). A read error is non-fatal: the node starts
    /// with empty routing and logs the failure.
    ///
    /// **Open-time wiring (db/mod.rs, out of scope):** call this once in
    /// `from_parts` immediately after `build_control_plane`, e.g.
    /// `db.restore_migration_routing();` after the `TidalDb` value is built — or,
    /// before the value exists, `crate::replication::tenant::restore_migration_routing(sb.items_engine(), &tenant_router)`.
    pub(crate) fn restore_migration_routing(&self) {
        let Ok(storage) = self.storage() else {
            return; // ephemeral / no schema: nothing durable to restore.
        };
        if let Err(e) = crate::replication::tenant::restore_migration_routing(
            storage.items_engine(),
            &self.tenant_router,
        ) {
            tracing::warn!(
                error = %e,
                "migration routing checkpoint read failed; tenant routing starts empty \
                 (tenants fall back to jump-hash routing)"
            );
        }
    }

    /// Returns the shared `TenantRouter` for multi-tenant routing.
    #[must_use]
    #[allow(clippy::missing_const_for_fn)]
    pub fn tenant_router(&self) -> Arc<crate::replication::TenantRouter> {
        Arc::clone(&self.tenant_router)
    }

    /// Returns a reference to the embedded control plane.
    #[must_use]
    #[allow(clippy::missing_const_for_fn)]
    pub fn control_plane(&self) -> &Arc<crate::replication::ControlPlane> {
        &self.control_plane
    }

    /// Create a `RollingUpgradeCoordinator` backed by this instance's control plane.
    #[must_use]
    pub fn rolling_upgrade_coordinator(&self) -> crate::replication::RollingUpgradeCoordinator {
        crate::replication::RollingUpgradeCoordinator::new(Arc::clone(&self.control_plane))
    }

    /// Write a signal on behalf of a tenant, enforcing per-tenant rate limits.
    ///
    /// Dispatches to each assigned shard. On single-node deployments all
    /// assignments resolve to the local shard, so behavior is identical to
    /// calling `signal()` directly.
    ///
    /// # On-node tenant isolation is LOGICAL, not physical
    ///
    /// `tenant_id` drives the per-tenant rate limiter and the shard routing
    /// only. The local write below goes to the single shared signal
    /// ledger/entity store with **no** tenant namespace in the key, so on a
    /// single node hosting multiple tenants every tenant's signals co-mingle in
    /// one keyspace/WAL. Physical per-tenant keyspace/WAL isolation and
    /// enforcement of `TenantConfig::max_storage_bytes`/`max_entities` are a
    /// separate, not-yet-wired task (`docs/specs/14-scale-architecture.md`).
    ///
    /// # Remote-shard dispatch is NOT yet wired (fully fails closed)
    ///
    /// During a dual-write migration the tenant router resolves two assignments
    /// (source and target shard). Cross-node dispatch of the remote half over
    /// the replication transport is **not yet implemented** (tracked as the
    /// "Task 5" shipper-integrated remote dispatch in `docs/specs/14`, per
    /// `CODING_GUIDELINES.md` §12 "no TODO without a tracking note"). Rather than
    /// silently dropping the remote write — which would lose signals during a
    /// migration with no error visible to the caller — this method **fails
    /// closed BEFORE any write happens**: it checks every resolved assignment
    /// first, and if *any* targets a non-local shard it returns
    /// `TidalError::Internal` with zero mutations. This makes the operation
    /// atomic from the caller's perspective — a retry after the error cannot
    /// double-count the local half, because the local half is never written when
    /// the whole op cannot succeed (W8).
    ///
    /// # Errors
    ///
    /// - `TidalError::QuotaExceeded` — tenant rate limit exceeded.
    /// - `TidalError::Internal` — no eligible shards for the tenant, OR an
    ///   assignment targets a non-local shard while cross-node dispatch is
    ///   unwired (see above). In the latter case NO signal was written.
    /// - Any error from the underlying `signal()` call.
    pub fn signal_for_tenant(
        &self,
        tenant_id: crate::replication::TenantId,
        signal_type: &str,
        entity_id: crate::schema::EntityId,
        weight: f64,
        timestamp: crate::schema::Timestamp,
    ) -> crate::Result<()> {
        self.require_writeable("signal_for_tenant")?;
        if let Some(limiter) = self.tenant_router.rate_limiter_for(tenant_id) {
            limiter.try_acquire()?;
        }
        // Resolve all shard targets (1 in normal mode; 2 during dual-write migration).
        let assignments = self.tenant_router.write_assignments(tenant_id, entity_id)?;
        let local_shard = self.config.cluster.shard_id;

        // Fail CLOSED *before* any write (W8): scan all assignments first. If any
        // targets a non-local shard, the cross-node dispatch needed to satisfy
        // the dual-write is unwired (Task 5, docs/specs/14), so the whole op
        // cannot succeed. Returning here — before the local write — makes the
        // operation atomic: a caller that retries the returned Err cannot
        // double-count the local signal (signal()/record_signal accumulates and
        // is not idempotent). A previous version wrote the local half first and
        // only then errored, so every retry inflated the local weight.
        if let Some(remote) = assignments.iter().find(|a| a.shard_id != local_shard) {
            tracing::warn!(
                tenant_id = tenant_id.0,
                shard_id = remote.shard_id.0,
                entity_id = entity_id.as_u64(),
                signal_type,
                "signal_for_tenant: remote-shard dispatch is not wired (Task 5); \
                 failing closed BEFORE any write to avoid silently dropping the \
                 dual-write or double-counting the local half on retry"
            );
            return Err(crate::TidalError::internal(
                "signal_for_tenant",
                format!(
                    "remote-shard dispatch not yet wired: assignment targets shard {} \
                     but this node is shard {}; cross-node dual-write requires the \
                     transport-integrated shipper (Task 5, docs/specs/14)",
                    remote.shard_id.0, local_shard.0
                ),
            ));
        }

        // Every assignment is local: apply the local write(s). On a single node
        // all assignments resolve to the same local shard, so the write happens
        // once per assignment (normally exactly one).
        for _assignment in &assignments {
            self.signal(signal_type, entity_id, weight, timestamp)?;
        }
        Ok(())
    }
}
