namespace NATS.Server.Raft;

public sealed class RaftNode : IDisposable
{
    public const string NoLeader = "";
    public const string NoVote = "";

    public static readonly TimeSpan MinCampaignTimeoutDefault = TimeSpan.FromMilliseconds(100);
    public static readonly TimeSpan MaxCampaignTimeoutDefault = TimeSpan.FromMilliseconds(800);
    public static readonly TimeSpan HbIntervalDefault = TimeSpan.FromSeconds(1);
    public static readonly TimeSpan LostQuorumIntervalDefault = TimeSpan.FromSeconds(10);
    public static readonly TimeSpan ObserverModeIntervalDefault = TimeSpan.FromHours(48);
    public static readonly TimeSpan PeerRemoveTimeoutDefault = TimeSpan.FromMinutes(5);

    private int _votesReceived;
    private readonly List<RaftNode> _cluster = [];
    private readonly RaftReplicator _replicator = new();
    private readonly RaftSnapshotStore _snapshotStore = new();
    private readonly IRaftTransport? _transport;
    private readonly string? _persistDirectory;
    private readonly DateTime _createdUtc;
    private readonly HashSet<string> _members = new(StringComparer.Ordinal);
    private int _clusterSize;
    private bool _observerMode;
    private string _groupLeader = NoLeader;
    private DateTime? _leaderSinceUtc;
    private bool _hadPreviousLeader;
    private bool _isDeleted;
    private readonly TaskCompletionSource _stopSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);

    // B2: Election timer fields
    // Go reference: raft.go:1400-1450 (resetElectionTimeout), raft.go:1500-1550 (campaign logic)
    private Timer? _electionTimer;
    private CancellationTokenSource? _electionTimerCts;

    // B3: Peer state tracking
    // Go reference: raft.go peer tracking (nextIndex, matchIndex, last contact)
    private readonly Dictionary<string, RaftPeerState> _peerStates = new(StringComparer.Ordinal);

    // B4: In-flight membership change tracking — only one at a time is permitted.
    // Go reference: raft.go:961-1019 (proposeAddPeer / proposeRemovePeer, single-change invariant)
    private long _membershipChangeIndex;

    // Leadership transfer: when true, the leader is handing off to a target and
    // must reject new proposals until the transfer completes or times out.
    // Go reference: raft.go stepDown / sendTimeoutNow (leader blocks proposals during transfer)
    private int _transferInProgress; // 0 = false, 1 = true (Interlocked-compatible)

    // Joint consensus (two-phase membership change) per Raft paper Section 4.
    // During the joint phase both the old config (Cold) and new config (Cnew) are stored.
    // A quorum decision requires majority from BOTH configurations simultaneously.
    // Go reference: raft.go joint consensus / two-phase membership transitions.
    private HashSet<string>? _jointOldMembers;
    private HashSet<string>? _jointNewMembers;

    // Pre-vote: Go NATS server does not implement pre-vote (RFC 5849 §9.6). Skipped for parity.

    public string Id { get; }
    public string GroupName { get; }
    public DateTime CreatedUtc => _createdUtc;
    public int Term => TermState.CurrentTerm;
    public bool IsLeader => Role == RaftRole.Leader;
    public DateTime? LeaderSince => _leaderSinceUtc;
    public string GroupLeader => _groupLeader;
    public bool Leaderless => string.IsNullOrEmpty(_groupLeader);
    public bool HadPreviousLeader => _hadPreviousLeader;
    public RaftRole Role { get; private set; } = RaftRole.Follower;
    public bool IsObserver => _observerMode;
    public bool IsDeleted => _isDeleted;
    public IReadOnlyCollection<string> Members => _members;
    public RaftTermState TermState { get; } = new();
    public long AppliedIndex { get; set; }
    public RaftLog Log { get; private set; } = new();

    // B1: Commit tracking
    // Go reference: raft.go:150-160 (applied/processed fields), raft.go:2100-2150 (ApplyQ)
    public long CommitIndex { get; private set; }
    public long ProcessedIndex { get; private set; }
    public CommitQueue<RaftLogEntry> CommitQueue { get; } = new();

    // B2: Election timeout configuration (milliseconds)
    public int ElectionTimeoutMinMs { get; set; } = 150;
    public int ElectionTimeoutMaxMs { get; set; } = 300;

    // B6: Pre-vote protocol
    // Go reference: raft.go:1600-1700 (pre-vote logic)
    // When enabled, a node first conducts a pre-vote round before starting a real election.
    // This prevents partitioned nodes from disrupting the cluster by incrementing terms.
    public bool PreVoteEnabled { get; set; } = true;

    // B4: True while a membership change log entry is pending quorum.
    // Go reference: raft.go:961-1019 single-change invariant.
    public bool MembershipChangeInProgress => Interlocked.Read(ref _membershipChangeIndex) > 0;

    /// <summary>
    /// True while a leadership transfer is in progress.
    /// During a transfer the leader blocks new proposals.
    /// Go reference: raft.go stepDown / sendTimeoutNow.
    /// </summary>
    public bool TransferInProgress => Interlocked.CompareExchange(ref _transferInProgress, 0, 0) == 1;

    /// <summary>
    /// True when this node is in the joint consensus phase (transitioning between
    /// two membership configurations).
    /// Go reference: raft.go joint consensus / two-phase membership transitions.
    /// </summary>
    public bool InJointConsensus => _jointNewMembers != null;

    /// <summary>
    /// The new (Cnew) member set stored during a joint configuration transition,
    /// or null when not in joint consensus. Exposed for testing.
    /// </summary>
    public IReadOnlyCollection<string>? JointNewMembers => _jointNewMembers;

    /// <summary>
    /// Configurable log compaction policy. When set, <see cref="CompactLogAsync"/> uses
    /// <see cref="CompactionOptions.ComputeCompactionIndex"/> to determine the cutoff index
    /// rather than always compacting to <see cref="AppliedIndex"/>.
    /// Go reference: raft.go compactLog thresholds.
    /// </summary>
    public CompactionOptions? CompactionOptions { get; set; }

    // B8: Injectable Random for deterministic testing of election jitter
    // Go reference: raft.go resetElectionTimeout (uses rand.Int63n for jitter)
    private Random _random;

    public RaftNode(string id, IRaftTransport? transport = null, string? persistDirectory = null,
        CompactionOptions? compactionOptions = null, Random? random = null, string? group = null)
    {
        Id = id;
        GroupName = string.IsNullOrWhiteSpace(group) ? id : group;
        _createdUtc = DateTime.UtcNow;
        _transport = transport;
        _persistDirectory = persistDirectory;
        _members.Add(id);
        _clusterSize = 1;
        CompactionOptions = compactionOptions;
        _random = random ?? Random.Shared;
    }

    public void ConfigureCluster(IEnumerable<RaftNode> peers)
    {
        var configuredPeers = peers as ICollection<RaftNode> ?? peers.ToList();
        _cluster.Clear();
        _cluster.AddRange(configuredPeers);
        _members.Clear();
        _peerStates.Clear();
        foreach (var peer in configuredPeers)
        {
            _members.Add(peer.Id);
            // B3: Initialize peer state for all peers except self
            if (!string.Equals(peer.Id, Id, StringComparison.Ordinal))
            {
                _peerStates[peer.Id] = new RaftPeerState
                {
                    PeerId = peer.Id,
                    Current = true,
                };
                _peerStates[peer.Id].RecalculateLag();
            }
        }

        _clusterSize = Math.Max(configuredPeers.Count, 1);
    }

    public void AddMember(string memberId) => _members.Add(memberId);

    public void RemoveMember(string memberId) => _members.Remove(memberId);

    public void StartElection(int clusterSize)
    {
        _groupLeader = NoLeader;
        _leaderSinceUtc = null;
        Role = RaftRole.Candidate;
        TermState.CurrentTerm++;
        TermState.VotedFor = Id;
        _votesReceived = 1;
        TryBecomeLeader(clusterSize);
    }

    public VoteResponse GrantVote(int term, string candidateId = "")
    {
        if (term < TermState.CurrentTerm)
            return new VoteResponse { Granted = false };

        if (term > TermState.CurrentTerm)
        {
            TermState.CurrentTerm = term;
            TermState.VotedFor = null;
        }

        if (!string.IsNullOrEmpty(TermState.VotedFor)
            && !string.Equals(TermState.VotedFor, candidateId, StringComparison.Ordinal))
        {
            return new VoteResponse { Granted = false };
        }

        TermState.VotedFor = candidateId;
        return new VoteResponse { Granted = true };
    }

    public void ReceiveHeartbeat(int term, string? fromPeerId = null)
    {
        if (term < TermState.CurrentTerm)
            return;

        TermState.CurrentTerm = term;
        Role = RaftRole.Follower;
        if (!string.IsNullOrEmpty(fromPeerId))
        {
            _groupLeader = fromPeerId;
            _hadPreviousLeader = true;
            _leaderSinceUtc = null;
        }

        // B2: Reset election timer on valid heartbeat
        ResetElectionTimeout();

        // B3: Update peer contact time
        if (fromPeerId != null && _peerStates.TryGetValue(fromPeerId, out var peerState))
        {
            peerState.LastContact = DateTime.UtcNow;
            peerState.RefreshCurrent(TimeSpan.FromMilliseconds(ElectionTimeoutMaxMs));
        }
    }

    public void ReceiveVote(VoteResponse response, int clusterSize = 3)
    {
        if (!response.Granted)
            return;

        _votesReceived++;
        TryBecomeLeader(clusterSize);
    }

    /// <summary>
    /// Returns true when this leader has heard from enough peers within the quorum
    /// window (2 × ElectionTimeoutMaxMs) to be confident it still holds a majority.
    ///
    /// Quorum window = 2 × ElectionTimeoutMaxMs. Peers whose <see cref="RaftPeerState.LastContact"/>
    /// is within that window are counted as current. Self counts as one voter.
    /// Returns false for non-leaders immediately (a follower never has proposer quorum).
    ///
    /// Go reference: raft.go checkQuorum / stepDown (leader steps down when it cannot
    /// confirm quorum with a majority of peers within an election-timeout period).
    /// </summary>
    public bool HasQuorum()
    {
        if (Role != RaftRole.Leader)
            return false;

        var totalMembers = _members.Count;

        // Single-node cluster: self is always majority.
        if (totalMembers <= 1)
            return true;

        var window = TimeSpan.FromMilliseconds(ElectionTimeoutMaxMs * 2);
        var currentPeers = _peerStates.Values.Count(p => DateTime.UtcNow - p.LastContact < window);

        var majority = (totalMembers / 2) + 1;
        return currentPeers + 1 >= majority; // +1 for self
    }

    /// <summary>
    /// Performs a linearizable read by confirming quorum before returning the current
    /// <see cref="CommitIndex"/>. The caller must wait until <see cref="AppliedIndex"/>
    /// reaches the returned index before serving the read to ensure the response reflects
    /// all committed state.
    ///
    /// Safety property: a partitioned leader that can no longer reach a majority of peers
    /// will fail to obtain quorum confirmation and will throw <see cref="TimeoutException"/>,
    /// preventing stale reads. This avoids log growth — no log entry is appended for reads.
    ///
    /// Algorithm:
    ///   1. Verify this node is the current leader.
    ///   2. Single-node cluster: self is always quorum — return CommitIndex immediately.
    ///   3. Send heartbeat RPCs to all peers. Each ack updates the peer's LastContact.
    ///   4. After the heartbeat round, call HasQuorum(). If quorum confirmed, return CommitIndex.
    ///   5. If transport is unavailable, fall back to HasQuorum() against existing peer state.
    ///   6. Timeout after ElectionTimeoutMaxMs if quorum cannot be confirmed.
    ///
    /// Go reference: raft.go — read-index optimisation (send AppendEntries with no payload to
    /// verify quorum before responding to a linearizable client read, without appending a log
    /// entry and therefore without growing the log).
    /// </summary>
    /// <param name="ct">Cancellation token.</param>
    /// <returns>
    /// The current <see cref="CommitIndex"/> at the moment quorum was confirmed.
    /// </returns>
    /// <exception cref="InvalidOperationException">
    /// Thrown when this node is not the leader.
    /// </exception>
    /// <exception cref="TimeoutException">
    /// Thrown when quorum cannot be confirmed within <see cref="ElectionTimeoutMaxMs"/> milliseconds.
    /// </exception>
    public async ValueTask<long> ReadIndexAsync(CancellationToken ct)
    {
        if (Role != RaftRole.Leader)
            throw new InvalidOperationException("Only the leader can serve linearizable reads via ReadIndex.");

        // Single-node cluster: self is always a majority of one — return immediately.
        if (_members.Count <= 1)
            return CommitIndex;

        // Capture the commit index at the start of the quorum confirmation round.
        // Even if new proposals arrive and advance CommitIndex while we wait, we return
        // the index we observed at the beginning, which is safe: the caller waits for
        // AppliedIndex >= returnedIndex, and all entries up to that point are committed.
        var readIndex = CommitIndex;

        if (_transport != null)
        {
            // Send heartbeat to all peers; each ack updates that peer's LastContact so
            // HasQuorum() can evaluate freshness after the round completes.
            var followerIds = _peerStates.Keys.ToArray();
            await _transport.SendHeartbeatAsync(
                Id,
                followerIds,
                TermState.CurrentTerm,
                ackPeerId =>
                {
                    if (_peerStates.TryGetValue(ackPeerId, out var state))
                    {
                        state.LastContact = DateTime.UtcNow;
                        state.RefreshCurrent(TimeSpan.FromMilliseconds(ElectionTimeoutMaxMs));
                    }
                },
                ct);
        }

        // After the heartbeat round, check whether a quorum of peers acknowledged.
        if (HasQuorum())
            return readIndex;

        // If quorum is still not confirmed (e.g., no transport configured and peers are
        // stale), poll briefly with a timeout so callers get a fast failure rather than
        // hanging indefinitely.
        var deadlineMs = ElectionTimeoutMaxMs;
        var pollIntervalMs = Math.Max(1, ElectionTimeoutMinMs / 10);
        var elapsed = 0;

        while (elapsed < deadlineMs)
        {
            await Task.Delay(pollIntervalMs, ct);
            elapsed += pollIntervalMs;

            if (HasQuorum())
                return readIndex;
        }

        throw new TimeoutException(
            $"ReadIndex: quorum could not be confirmed within {deadlineMs} ms. " +
            "The leader may be partitioned.");
    }

    public async ValueTask<long> ProposeAsync(string command, CancellationToken ct)
    {
        if (Role != RaftRole.Leader)
            throw new InvalidOperationException("Only leader can propose entries.");

        // Block proposals while a leadership transfer is in progress.
        // Go reference: raft.go — leader rejects new entries during stepDown / sendTimeoutNow.
        if (Interlocked.CompareExchange(ref _transferInProgress, 0, 0) == 1)
            throw new InvalidOperationException("Leadership transfer in progress; proposals are blocked.");

        // Quorum guard: refuse to append entries when the leader cannot confirm that a
        // majority of peers are still reachable. This prevents a partitioned leader from
        // diverging the log while isolated from the rest of the cluster.
        // Go reference: raft.go checkQuorum — leader steps down (and therefore blocks
        // proposals) when it has not heard from a quorum within the election timeout.
        if (!HasQuorum())
            throw new InvalidOperationException("Cannot propose: leader has no quorum (majority of peers unreachable).");

        var entry = Log.Append(TermState.CurrentTerm, command);
        var followers = _cluster.Where(n => n.Id != Id).ToList();
        var results = await _replicator.ReplicateAsync(Id, entry, followers, _transport, ct);
        var acknowledgements = results.Count(r => r.Success);

        var quorum = (_cluster.Count / 2) + 1;
        if (acknowledgements + 1 >= quorum)
        {
            AppliedIndex = entry.Index;
            foreach (var node in _cluster)
                node.AppliedIndex = Math.Max(node.AppliedIndex, entry.Index);

            // B1: Update commit index and enqueue for state machine application
            CommitIndex = entry.Index;
            await CommitQueue.EnqueueAsync(entry, ct);

            // B3: Update peer match/next indices for successful replications
            foreach (var result in results.Where(r => r.Success))
            {
                if (_peerStates.TryGetValue(result.FollowerId, out var peerState))
                {
                    peerState.MatchIndex = Math.Max(peerState.MatchIndex, entry.Index);
                    peerState.NextIndex = entry.Index + 1;
                    peerState.LastContact = DateTime.UtcNow;
                    peerState.RecalculateLag();
                    peerState.RefreshCurrent(TimeSpan.FromMilliseconds(ElectionTimeoutMaxMs));
                }
            }

            foreach (var node in _cluster.Where(n => n._persistDirectory != null))
                await node.PersistAsync(ct);
        }

        if (_persistDirectory != null)
            await PersistAsync(ct);

        return entry.Index;
    }

    /// <summary>
    /// Proposes a batch of commands in order and returns their resulting indexes.
    /// Go reference: raft.go ProposeMulti.
    /// </summary>
    public async ValueTask<IReadOnlyList<long>> ProposeMultiAsync(IEnumerable<string> commands, CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(commands);

        var indexes = new List<long>();
        foreach (var command in commands)
        {
            ct.ThrowIfCancellationRequested();
            indexes.Add(await ProposeAsync(command, ct));
        }

        return indexes;
    }

    public (long Entries, long Bytes) Applied(long index)
    {
        MarkProcessed(index);
        var entries = Math.Max(0, index - Log.BaseIndex);
        var bytes = Log.Entries
            .Where(e => e.Index <= index)
            .Sum(e => (long)System.Text.Encoding.UTF8.GetByteCount(e.Command ?? string.Empty));
        return (entries, bytes);
    }

    // B4: Membership change proposals
    // Go reference: raft.go:961-1019 (proposeAddPeer, proposeRemovePeer)

    /// <summary>
    /// Proposes adding a new peer to the cluster as a RAFT log entry.
    /// Only the leader may propose; only one membership change may be in flight at a time.
    /// After the entry reaches quorum the peer is added to _members.
    /// Go reference: raft.go:961-990 (proposeAddPeer).
    /// </summary>
    public async ValueTask<long> ProposeAddPeerAsync(string peerId, CancellationToken ct)
    {
        if (Role != RaftRole.Leader)
            throw new InvalidOperationException("Only the leader can propose membership changes.");

        if (Interlocked.Read(ref _membershipChangeIndex) > 0)
            throw new InvalidOperationException("A membership change is already in progress.");

        var command = $"+peer:{peerId}";
        var entry = Log.Append(TermState.CurrentTerm, command);
        Interlocked.Exchange(ref _membershipChangeIndex, entry.Index);

        var followers = _cluster.Where(n => n.Id != Id).ToList();
        var results = await _replicator.ReplicateAsync(Id, entry, followers, _transport, ct);
        var acknowledgements = results.Count(r => r.Success);
        var quorum = (_cluster.Count / 2) + 1;

        if (acknowledgements + 1 >= quorum)
        {
            CommitIndex = entry.Index;
            AppliedIndex = entry.Index;
            await CommitQueue.EnqueueAsync(entry, ct);

            // Apply the membership change: add the peer and track its state
            _members.Add(peerId);
            if (!string.Equals(peerId, Id, StringComparison.Ordinal)
                && !_peerStates.ContainsKey(peerId))
            {
                _peerStates[peerId] = new RaftPeerState
                {
                    PeerId = peerId,
                    Current = true,
                };
                _peerStates[peerId].RecalculateLag();
            }
        }

        // Clear the in-flight tracking regardless of quorum outcome
        Interlocked.Exchange(ref _membershipChangeIndex, 0);
        return entry.Index;
    }

    /// <summary>
    /// Proposes removing a peer from the cluster as a RAFT log entry.
    /// Refuses to remove the last remaining member.
    /// Only the leader may propose; only one membership change may be in flight at a time.
    /// Go reference: raft.go:992-1019 (proposeRemovePeer).
    /// </summary>
    public async ValueTask<long> ProposeRemovePeerAsync(string peerId, CancellationToken ct)
    {
        if (Role != RaftRole.Leader)
            throw new InvalidOperationException("Only the leader can propose membership changes.");

        if (Interlocked.Read(ref _membershipChangeIndex) > 0)
            throw new InvalidOperationException("A membership change is already in progress.");

        if (string.Equals(peerId, Id, StringComparison.Ordinal))
            throw new InvalidOperationException("Leader cannot remove itself. Step down first.");

        if (_members.Count <= 1)
            throw new InvalidOperationException("Cannot remove the last member from the cluster.");

        var command = $"-peer:{peerId}";
        var entry = Log.Append(TermState.CurrentTerm, command);
        Interlocked.Exchange(ref _membershipChangeIndex, entry.Index);

        var followers = _cluster.Where(n => n.Id != Id).ToList();
        var results = await _replicator.ReplicateAsync(Id, entry, followers, _transport, ct);
        var acknowledgements = results.Count(r => r.Success);
        var quorum = (_cluster.Count / 2) + 1;

        if (acknowledgements + 1 >= quorum)
        {
            CommitIndex = entry.Index;
            AppliedIndex = entry.Index;
            await CommitQueue.EnqueueAsync(entry, ct);

            // Apply the membership change: remove the peer and its state
            _members.Remove(peerId);
            _peerStates.Remove(peerId);
        }

        // Clear the in-flight tracking regardless of quorum outcome
        Interlocked.Exchange(ref _membershipChangeIndex, 0);
        return entry.Index;
    }

    // Joint consensus (Raft paper Section 4) — two-phase membership transitions.
    // Go reference: raft.go joint consensus / two-phase membership transitions.

    /// <summary>
    /// Enters the joint consensus phase with the given old and new configurations.
    /// During this phase quorum decisions require majority from BOTH configurations.
    /// The active member set is set to the union of Cold and Cnew so that entries
    /// are replicated to all nodes that participate in either configuration.
    /// Go reference: raft.go Section 4 (joint consensus).
    /// </summary>
    public void BeginJointConsensus(IReadOnlyCollection<string> cold, IReadOnlyCollection<string> cnew)
    {
        _jointOldMembers = new HashSet<string>(cold, StringComparer.Ordinal);
        _jointNewMembers = new HashSet<string>(cnew, StringComparer.Ordinal);
        // The active member set is the union of both configs
        foreach (var member in cnew)
            _members.Add(member);
    }

    /// <summary>
    /// Commits the joint configuration by finalizing Cnew as the active member set.
    /// Clears both Cold and Cnew, leaving only the new configuration.
    /// Call this once the Cnew log entry has reached quorum in both configs.
    /// Go reference: raft.go joint consensus commit.
    /// </summary>
    public void CommitJointConsensus()
    {
        if (_jointNewMembers == null)
            return;

        _members.Clear();
        foreach (var m in _jointNewMembers)
            _members.Add(m);

        _jointOldMembers = null;
        _jointNewMembers = null;
    }

    /// <summary>
    /// During joint consensus, checks whether a set of acknowledging voters satisfies
    /// a majority in BOTH the old configuration (Cold) and the new configuration (Cnew).
    /// Returns false when not in joint consensus.
    /// Go reference: raft.go Section 4 — joint config quorum calculation.
    /// </summary>
    public bool CalculateJointQuorum(
        IReadOnlyCollection<string> coldVoters,
        IReadOnlyCollection<string> cnewVoters)
    {
        if (_jointOldMembers == null || _jointNewMembers == null)
            return false;

        var oldQuorum = (_jointOldMembers.Count / 2) + 1;
        var newQuorum = (_jointNewMembers.Count / 2) + 1;
        return coldVoters.Count >= oldQuorum && cnewVoters.Count >= newQuorum;
    }

    // B5: Snapshot checkpoints and log compaction
    // Go reference: raft.go CreateSnapshotCheckpoint, DrainAndReplaySnapshot

    /// <summary>
    /// Creates a snapshot at the current applied index and compacts the log up to that point.
    /// This combines snapshot creation with log truncation so that snapshotted entries
    /// do not need to be replayed on restart.
    /// Go reference: raft.go CreateSnapshotCheckpoint.
    /// </summary>
    public async Task<RaftSnapshot> CreateSnapshotCheckpointAsync(CancellationToken ct)
    {
        var snapshot = new RaftSnapshot
        {
            LastIncludedIndex = AppliedIndex,
            LastIncludedTerm = Term,
        };
        await _snapshotStore.SaveAsync(snapshot, ct);
        Log.Compact(snapshot.LastIncludedIndex);
        return snapshot;
    }

    /// <summary>
    /// Drains the commit queue, installs the given snapshot, and updates the commit index.
    /// Used when a leader sends a snapshot to a lagging follower: the follower pauses its
    /// apply pipeline, discards pending entries, then fast-forwards to the snapshot state.
    /// Go reference: raft.go DrainAndReplaySnapshot.
    /// </summary>
    public async Task DrainAndReplaySnapshotAsync(RaftSnapshot snapshot, CancellationToken ct)
    {
        // Drain any pending commit-queue entries that are now superseded by the snapshot
        while (CommitQueue.TryDequeue(out _))
        {
            // discard — snapshot covers these
        }

        // Install the snapshot: replaces the log and advances applied state
        Log.ReplaceWithSnapshot(snapshot);
        AppliedIndex = snapshot.LastIncludedIndex;
        CommitIndex = snapshot.LastIncludedIndex;
        await _snapshotStore.SaveAsync(snapshot, ct);
    }

    /// <summary>
    /// Compacts the log according to the provided (or configured) compaction options.
    ///
    /// When <paramref name="options"/> is supplied it takes precedence over <see cref="CompactionOptions"/>.
    /// When neither is provided the method falls back to compacting up to <see cref="AppliedIndex"/>,
    /// which preserves the original behavior.
    ///
    /// The compaction index is always capped at <see cref="AppliedIndex"/> to guarantee that no
    /// committed-but-not-yet-applied entries are discarded.
    ///
    /// Go reference: raft.go WAL compact / compactLog threshold checks.
    /// </summary>
    /// <param name="ct">Cancellation token (reserved for future async I/O).</param>
    /// <param name="options">
    /// Optional policy override. When null, uses <see cref="CompactionOptions"/> if set,
    /// otherwise defaults to compacting everything up to <see cref="AppliedIndex"/>.
    /// </param>
    public Task CompactLogAsync(CancellationToken ct, CompactionOptions? options = null)
    {
        _ = ct;

        var effectiveOptions = options ?? CompactionOptions;

        long compactUpTo;
        if (effectiveOptions != null)
        {
            compactUpTo = effectiveOptions.ComputeCompactionIndex(Log, AppliedIndex);
            if (compactUpTo < 0)
                return Task.CompletedTask; // policy says: nothing to do
        }
        else
        {
            // Default behavior: compact everything up to the applied index.
            compactUpTo = AppliedIndex;
        }

        if (compactUpTo > 0)
            Log.Compact(compactUpTo);

        return Task.CompletedTask;
    }

    /// <summary>
    /// Installs a snapshot assembled from streaming chunks, with optional CRC32 integrity validation.
    ///
    /// After all chunks are accumulated and reassembled into a contiguous byte array, the CRC32
    /// (IEEE 802.3) checksum of the assembled data is computed and compared against
    /// <paramref name="expectedCrc32"/> when provided. A mismatch indicates data corruption during
    /// transfer and causes an <see cref="InvalidDataException"/> to be thrown before any state is
    /// mutated, so the follower can request a retransmit rather than applying corrupt state.
    ///
    /// Go reference: raft.go:3500-3700 (installSnapshot with chunked transfer and CRC validation).
    /// </summary>
    /// <param name="chunks">Ordered sequence of byte arrays that form the snapshot when concatenated.</param>
    /// <param name="snapshotIndex">The last log index covered by this snapshot.</param>
    /// <param name="snapshotTerm">The term of the last log entry covered by this snapshot.</param>
    /// <param name="expectedCrc32">
    /// Optional CRC32 checksum of the full assembled snapshot data. When non-null the assembled
    /// data is validated before installation; a mismatch throws <see cref="InvalidDataException"/>.
    /// </param>
    /// <param name="ct">Cancellation token.</param>
    /// <exception cref="InvalidDataException">
    /// Thrown when <paramref name="expectedCrc32"/> is provided and the assembled data does not
    /// match the expected checksum.
    /// </exception>
    public async Task InstallSnapshotFromChunksAsync(
        IEnumerable<byte[]> chunks,
        long snapshotIndex,
        int snapshotTerm,
        CancellationToken ct,
        uint? expectedCrc32 = null)
    {
        var checkpoint = new RaftSnapshotCheckpoint
        {
            SnapshotIndex = snapshotIndex,
            SnapshotTerm = snapshotTerm,
        };

        foreach (var chunk in chunks)
            checkpoint.AddChunk(chunk);

        var data = checkpoint.Assemble();

        // CRC32 validation: compute over assembled data and compare to the sender's checksum.
        // Go reference: raft.go installSnapshot CRC check before applying snapshot state.
        if (expectedCrc32.HasValue)
        {
            var crc = new System.IO.Hashing.Crc32();
            crc.Append(data);
            var actualCrc32 = crc.GetCurrentHashAsUInt32();
            if (actualCrc32 != expectedCrc32.Value)
                throw new InvalidDataException(
                    $"Snapshot CRC32 mismatch: expected 0x{expectedCrc32.Value:X8}, got 0x{actualCrc32:X8}.");
        }

        var snapshot = new RaftSnapshot
        {
            LastIncludedIndex = snapshotIndex,
            LastIncludedTerm = snapshotTerm,
            Data = data,
        };

        Log.ReplaceWithSnapshot(snapshot);
        AppliedIndex = snapshotIndex;
        CommitIndex = snapshotIndex;
        await _snapshotStore.SaveAsync(snapshot, ct);
    }

    /// <summary>
    /// Marks the given index as processed by the state machine.
    /// Go reference: raft.go applied/processed tracking.
    /// </summary>
    public void MarkProcessed(long index)
    {
        if (index > ProcessedIndex)
            ProcessedIndex = index;
    }

    public void ReceiveReplicatedEntry(RaftLogEntry entry)
    {
        Log.AppendReplicated(entry);
    }

    public Task TryAppendFromLeaderAsync(RaftLogEntry entry, CancellationToken ct)
    {
        _ = ct;
        if (entry.Term < TermState.CurrentTerm)
            throw new InvalidOperationException("stale term append rejected");

        // B2: Reset election timer when receiving append from leader
        ResetElectionTimeout();

        ReceiveReplicatedEntry(entry);
        return Task.CompletedTask;
    }

    public async Task<RaftSnapshot> CreateSnapshotAsync(CancellationToken ct)
    {
        var snapshot = new RaftSnapshot
        {
            LastIncludedIndex = AppliedIndex,
            LastIncludedTerm = Term,
        };
        await _snapshotStore.SaveAsync(snapshot, ct);
        return snapshot;
    }

    public Task InstallSnapshotAsync(RaftSnapshot snapshot, CancellationToken ct)
    {
        Log.ReplaceWithSnapshot(snapshot);
        AppliedIndex = snapshot.LastIncludedIndex;
        return _snapshotStore.SaveAsync(snapshot, ct);
    }

    public void RequestStepDown()
    {
        Role = RaftRole.Follower;
        _votesReceived = 0;
        TermState.VotedFor = null;
        _groupLeader = NoLeader;
        _leaderSinceUtc = null;
    }

    public (long Index, long Commit, long Applied) Progress()
    {
        var index = Log.Entries.Count > 0 ? Log.Entries[^1].Index : Log.BaseIndex;
        return (index, CommitIndex, AppliedIndex);
    }

    public (long Entries, long Bytes) Size()
    {
        var entries = (long)Log.Entries.Count;
        var bytes = Log.Entries.Sum(e => (long)System.Text.Encoding.UTF8.GetByteCount(e.Command ?? string.Empty));
        return (entries, bytes);
    }

    public int ClusterSize()
        => _clusterSize > 0 ? _clusterSize : Math.Max(_members.Count, 1);

    public bool AdjustBootClusterSize(int clusterSize)
    {
        if (!Leaderless || HadPreviousLeader)
            return false;

        _clusterSize = Math.Max(2, clusterSize);
        return true;
    }

    public bool AdjustClusterSize(int clusterSize)
    {
        if (!IsLeader)
            return false;

        _clusterSize = Math.Max(2, clusterSize);
        return true;
    }

    public void SetObserver(bool enabled)
        => _observerMode = enabled;

    // B2: Election timer management
    // Go reference: raft.go:1400-1450 (resetElectionTimeout)

    /// <summary>
    /// Returns a randomized election timeout in the range [ElectionTimeoutMinMs, ElectionTimeoutMaxMs).
    /// Each call produces an independent random value to prevent synchronized elections after
    /// network partitions (split-vote avoidance).
    ///
    /// IMPORTANT: Callers must use <see cref="TimeSpan.TotalMilliseconds"/> (not <c>.Milliseconds</c>)
    /// when converting the result to an integer for timer APIs. <c>.Milliseconds</c> is the 0–999
    /// component only; <c>.TotalMilliseconds</c> gives the full value.
    ///
    /// Go reference: raft.go resetElectionTimeout — uses rand.Int63n to jitter election timeout.
    /// </summary>
    public TimeSpan RandomizedElectionTimeout()
    {
        var ms = ElectionTimeoutMinMs + _random.Next(0, ElectionTimeoutMaxMs - ElectionTimeoutMinMs);
        return TimeSpan.FromMilliseconds(ms);
    }

    public TimeSpan RandomizedCampaignTimeout()
    {
        var min = (int)MinCampaignTimeoutDefault.TotalMilliseconds;
        var max = (int)MaxCampaignTimeoutDefault.TotalMilliseconds;
        var ms = min + _random.Next(0, Math.Max(1, max - min));
        return TimeSpan.FromMilliseconds(ms);
    }

    /// <summary>
    /// Resets the election timeout timer with a new randomized interval.
    /// Called on heartbeat receipt and append entries from leader.
    /// </summary>
    public void ResetElectionTimeout()
    {
        var timeout = (int)RandomizedElectionTimeout().TotalMilliseconds;
        _electionTimer?.Change(timeout, Timeout.Infinite);
    }

    /// <summary>
    /// Starts the background election timer. When it fires and this node is a Follower,
    /// an election campaign is triggered automatically.
    /// Go reference: raft.go:1500-1550 (campaign logic).
    /// </summary>
    public void StartElectionTimer(CancellationToken ct = default)
    {
        _electionTimerCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
        var timeout = (int)RandomizedElectionTimeout().TotalMilliseconds;
        _electionTimer = new Timer(ElectionTimerCallback, null, timeout, Timeout.Infinite);
    }

    /// <summary>
    /// Stops and disposes the election timer.
    /// </summary>
    public void StopElectionTimer()
    {
        _electionTimer?.Dispose();
        _electionTimer = null;
        _electionTimerCts?.Cancel();
        _electionTimerCts?.Dispose();
        _electionTimerCts = null;
    }

    /// <summary>
    /// Bypasses the election timer and immediately starts an election campaign.
    /// Useful for testing.
    /// </summary>
    public void CampaignImmediately()
    {
        var clusterSize = _cluster.Count > 0 ? _cluster.Count : _members.Count;
        StartElection(clusterSize);
    }

    // Leadership transfer
    // Go reference: raft.go sendTimeoutNow / processTimeoutNow

    /// <summary>
    /// Transfers leadership to the target peer by sending a TimeoutNow RPC.
    /// Must only be called on the current leader. Blocks new proposals during
    /// the transfer window (up to 2× the election timeout).
    ///
    /// Returns <c>true</c> when the target becomes leader within the deadline,
    /// or <c>false</c> on timeout.
    ///
    /// Go reference: raft.go stepDown (leadership transfer variant) / sendTimeoutNow.
    /// </summary>
    public async Task<bool> TransferLeadershipAsync(string targetId, CancellationToken ct)
    {
        if (Role != RaftRole.Leader)
            throw new InvalidOperationException("Only the leader can transfer leadership.");

        if (_transport == null)
            throw new InvalidOperationException("No transport configured; cannot send TimeoutNow RPC.");

        // Set the transfer flag so ProposeAsync is blocked.
        Interlocked.Exchange(ref _transferInProgress, 1);
        try
        {
            await _transport.SendTimeoutNowAsync(Id, targetId, (ulong)TermState.CurrentTerm, ct);

            // Wait for the target to become leader (poll the in-process cluster).
            // Deadline = 2× election timeout maximum.
            var deadlineMs = ElectionTimeoutMaxMs * 2;
            var pollIntervalMs = Math.Max(1, ElectionTimeoutMinMs / 10);
            var elapsed = 0;

            while (elapsed < deadlineMs)
            {
                var target = _cluster.FirstOrDefault(
                    n => string.Equals(n.Id, targetId, StringComparison.Ordinal));

                if (target != null && target.IsLeader)
                    return true;

                await Task.Delay(pollIntervalMs, ct);
                elapsed += pollIntervalMs;
            }

            return false;
        }
        finally
        {
            Interlocked.Exchange(ref _transferInProgress, 0);
        }
    }

    /// <summary>
    /// Handles a received TimeoutNow RPC: immediately starts an election,
    /// bypassing both the election timer and the pre-vote round.
    /// Called on the target node when the current leader sends a TimeoutNow message.
    ///
    /// Go reference: raft.go processTimeoutNow — triggers immediate campaign.
    /// </summary>
    public void ReceiveTimeoutNow(ulong term)
    {
        // Accept the sender's term if it's higher.
        if ((int)term > TermState.CurrentTerm)
            TermState.CurrentTerm = (int)term;

        // Immediately campaign, skipping pre-vote (the leader has already decided
        // this node should win, so pre-vote would be redundant and wasteful).
        var clusterSize = _cluster.Count > 0 ? _cluster.Count : _members.Count;
        StartElection(clusterSize);
    }

    private void ElectionTimerCallback(object? state)
    {
        if (_electionTimerCts?.IsCancellationRequested == true)
            return;

        if (Role == RaftRole.Follower)
        {
            // B6: Use pre-vote when enabled to avoid disrupting the cluster
            CampaignWithPreVote();
        }
        else
        {
            // Re-arm the timer for non-follower states so it can fire again
            // if the node transitions back to follower.
            ResetElectionTimeout();
        }
    }

    // B3: Peer state accessors

    /// <summary>
    /// Returns a read-only view of all tracked peer states.
    /// </summary>
    public IReadOnlyDictionary<string, RaftPeerState> GetPeerStates()
        => _peerStates;

    /// <summary>
    /// Checks if this node's log is current (within one election timeout of the leader).
    /// Go reference: raft.go isCurrent check.
    /// </summary>
    public bool IsCurrent(TimeSpan electionTimeout)
    {
        // A leader is always current
        if (Role == RaftRole.Leader)
            return true;

        // Check if any peer (which could be the leader) has contacted us recently
        return _peerStates.Values.Any(p => p.IsCurrent(electionTimeout));
    }

    /// <summary>
    /// Overall health check: node is active and peers are responsive.
    /// </summary>
    public bool IsHealthy(TimeSpan healthThreshold)
    {
        if (Role == RaftRole.Leader)
        {
            // Leader is healthy if a majority of peers are responsive
            var healthyPeers = _peerStates.Values.Count(p => p.IsHealthy(healthThreshold));
            var quorum = (_peerStates.Count + 1) / 2; // +1 for self
            return healthyPeers >= quorum;
        }

        // Follower/candidate: healthy if at least one peer (the leader) is responsive
        return _peerStates.Values.Any(p => p.IsHealthy(healthThreshold));
    }

    // B6: Pre-vote protocol implementation
    // Go reference: raft.go:1600-1700 (pre-vote logic)

    /// <summary>
    /// Evaluates a pre-vote request from a candidate. Grants the pre-vote if the
    /// candidate's log is at least as up-to-date as this node's log and the candidate's
    /// term is at least as high as the current term.
    /// Pre-votes do NOT change any persistent state (no term increment, no votedFor change).
    /// Go reference: raft.go:1600-1700 (pre-vote logic).
    /// </summary>
    public bool RequestPreVote(ulong term, ulong lastTerm, ulong lastIndex, string candidateId)
    {
        _ = candidateId; // used for logging in production; not needed for correctness

        // Deny if candidate's term is behind ours
        if ((int)term < TermState.CurrentTerm)
            return false;

        // Check if candidate's log is at least as up-to-date as ours
        var ourLastTerm = Log.Entries.Count > 0 ? (ulong)Log.Entries[^1].Term : 0UL;
        var ourLastIndex = Log.Entries.Count > 0 ? (ulong)Log.Entries[^1].Index : 0UL;

        // Candidate's log is at least as up-to-date if:
        //   (1) candidate's last term > our last term, OR
        //   (2) candidate's last term == our last term AND candidate's last index >= our last index
        if (lastTerm > ourLastTerm)
            return true;

        if (lastTerm == ourLastTerm && lastIndex >= ourLastIndex)
            return true;

        return false;
    }

    /// <summary>
    /// Conducts a pre-vote round among cluster peers without incrementing the term.
    /// Returns true if a majority of peers granted the pre-vote, meaning this node
    /// should proceed to a real election.
    /// Go reference: raft.go:1600-1700 (pre-vote logic).
    /// </summary>
    public bool StartPreVote()
    {
        var clusterSize = _cluster.Count > 0 ? _cluster.Count : _members.Count;
        var preVotesGranted = 1; // vote for self

        var ourLastTerm = Log.Entries.Count > 0 ? (ulong)Log.Entries[^1].Term : 0UL;
        var ourLastIndex = Log.Entries.Count > 0 ? (ulong)Log.Entries[^1].Index : 0UL;

        // Send pre-vote requests to all peers (without incrementing our term)
        foreach (var peer in _cluster.Where(n => !string.Equals(n.Id, Id, StringComparison.Ordinal)))
        {
            if (peer.RequestPreVote((ulong)TermState.CurrentTerm, ourLastTerm, ourLastIndex, Id))
                preVotesGranted++;
        }

        var quorum = (clusterSize / 2) + 1;
        return preVotesGranted >= quorum;
    }

    /// <summary>
    /// Starts an election campaign, optionally preceded by a pre-vote round.
    /// When PreVoteEnabled is true, the node first conducts a pre-vote round.
    /// If the pre-vote fails, the node stays as a follower without incrementing its term.
    /// Go reference: raft.go:1600-1700 (pre-vote), raft.go:1500-1550 (campaign).
    /// </summary>
    public void CampaignWithPreVote()
    {
        var clusterSize = _cluster.Count > 0 ? _cluster.Count : _members.Count;

        if (PreVoteEnabled && _cluster.Count > 0)
        {
            // Pre-vote round: test if we would win without incrementing term
            if (!StartPreVote())
                return; // Pre-vote failed, stay as follower — don't disrupt cluster
        }

        // Pre-vote succeeded (or disabled), proceed to real election
        StartElection(clusterSize);
    }

    private void TryBecomeLeader(int clusterSize)
    {
        var quorum = (clusterSize / 2) + 1;
        if (_votesReceived >= quorum)
        {
            Role = RaftRole.Leader;
            _groupLeader = Id;
            _leaderSinceUtc = DateTime.UtcNow;
            _hadPreviousLeader = true;
        }
    }

    public void Stop()
    {
        Role = RaftRole.Follower;
        _groupLeader = NoLeader;
        _leaderSinceUtc = null;
        StopElectionTimer();
        _stopSignal.TrySetResult();
    }

    public void WaitForStop()
    {
        _stopSignal.Task.GetAwaiter().GetResult();
    }

    public void Delete()
    {
        Stop();
        _isDeleted = true;

        if (string.IsNullOrWhiteSpace(_persistDirectory))
            return;

        if (Directory.Exists(_persistDirectory))
            Directory.Delete(_persistDirectory, recursive: true);
    }

    public async Task PersistAsync(CancellationToken ct)
    {
        var dir = _persistDirectory ?? Path.Combine(Path.GetTempPath(), "natsdotnet-raft", Id);
        Directory.CreateDirectory(dir);
        await Log.PersistAsync(Path.Combine(dir, "log.json"), ct);
        await File.WriteAllTextAsync(Path.Combine(dir, "applied.txt"), AppliedIndex.ToString(), ct);

        // Persist term and VotedFor together in meta.json for atomic durable state.
        // Go reference: raft.go storeMeta / writeTermVote (term + votedFor written atomically)
        var meta = new RaftMetaState
        {
            CurrentTerm = TermState.CurrentTerm,
            VotedFor = TermState.VotedFor,
        };
        await File.WriteAllTextAsync(
            Path.Combine(dir, "meta.json"),
            System.Text.Json.JsonSerializer.Serialize(meta),
            ct);
    }

    public async Task LoadPersistedStateAsync(CancellationToken ct)
    {
        var dir = _persistDirectory ?? Path.Combine(Path.GetTempPath(), "natsdotnet-raft", Id);
        Log = await RaftLog.LoadAsync(Path.Combine(dir, "log.json"), ct);

        // Load from meta.json first (includes VotedFor); fall back to legacy term.txt
        var metaPath = Path.Combine(dir, "meta.json");
        if (File.Exists(metaPath))
        {
            var json = await File.ReadAllTextAsync(metaPath, ct);
            var meta = System.Text.Json.JsonSerializer.Deserialize<RaftMetaState>(json);
            if (meta is not null)
            {
                TermState.CurrentTerm = meta.CurrentTerm;
                TermState.VotedFor = meta.VotedFor;
            }
        }
        else
        {
            // Legacy: term.txt only (no VotedFor)
            var termPath = Path.Combine(dir, "term.txt");
            if (File.Exists(termPath) && int.TryParse(await File.ReadAllTextAsync(termPath, ct), out var term))
                TermState.CurrentTerm = term;
        }

        var appliedPath = Path.Combine(dir, "applied.txt");
        if (File.Exists(appliedPath) && long.TryParse(await File.ReadAllTextAsync(appliedPath, ct), out var applied))
            AppliedIndex = applied;
        else if (Log.Entries.Count > 0)
            AppliedIndex = Log.Entries[^1].Index;
    }

    /// <summary>Durable term + vote metadata written alongside the log.</summary>
    private sealed class RaftMetaState
    {
        public int CurrentTerm { get; set; }
        public string? VotedFor { get; set; }
    }

    public void Dispose()
    {
        Stop();
    }
}
