// Go parity: golang/nats-server/server/raft_test.go
// Covers the behavioral intent of the Go NRG (NATS RAFT Group) tests,
// ported to the .NET RaftNode / RaftLog / RaftSnapshot infrastructure.
// Each test cites the corresponding Go function and approximate line.
using NATS.Server.Raft;

namespace NATS.Server.Raft.Tests.Raft;

/// <summary>
/// Go-parity tests for the NATS RAFT implementation. Tests cover election,
/// log replication, snapshot/catchup, membership changes, quorum accounting,
/// observer mode semantics, and peer tracking. Each test cites the Go test
/// function it maps to in server/raft_test.go.
/// </summary>
public class RaftGoParityTests
{
    // ---------------------------------------------------------------
    // Helpers — self-contained; no shared TestHelpers class
    // ---------------------------------------------------------------

    private static (RaftNode[] nodes, InMemoryRaftTransport transport) CreateCluster(int size)
    {
        var transport = new InMemoryRaftTransport();
        var nodes = Enumerable.Range(1, size)
            .Select(i => new RaftNode($"n{i}", transport))
            .ToArray();
        foreach (var node in nodes)
        {
            transport.Register(node);
            node.ConfigureCluster(nodes);
        }
        return (nodes, transport);
    }

    private static RaftNode ElectLeader(RaftNode[] nodes)
    {
        var candidate = nodes[0];
        candidate.StartElection(nodes.Length);
        foreach (var voter in nodes.Skip(1))
            candidate.ReceiveVote(voter.GrantVote(candidate.Term, candidate.Id), nodes.Length);
        return candidate;
    }

    private static (RaftNode leader, RaftNode[] followers) CreateLeaderWithFollowers(int followerCount)
    {
        var total = followerCount + 1;
        var nodes = Enumerable.Range(1, total)
            .Select(i => new RaftNode($"n{i}"))
            .ToArray();
        foreach (var n in nodes)
            n.ConfigureCluster(nodes);
        var candidate = nodes[0];
        candidate.StartElection(total);
        foreach (var voter in nodes.Skip(1))
            candidate.ReceiveVote(voter.GrantVote(candidate.Term, candidate.Id), total);
        return (candidate, nodes.Skip(1).ToArray());
    }

    // ---------------------------------------------------------------
    // Go: TestNRGSimple server/raft_test.go:35
    // ---------------------------------------------------------------

    // Go reference: TestNRGSimple — basic single-node leader election
    [Fact]
    public void Single_node_becomes_leader_on_election()
    {
        var node = new RaftNode("solo");
        node.StartElection(clusterSize: 1);

        node.IsLeader.ShouldBeTrue();
        node.Term.ShouldBe(1);
        node.Role.ShouldBe(RaftRole.Leader);
    }

    // Go reference: TestNRGSimple — three-node cluster elects one leader
    [Fact]
    public void Three_node_cluster_elects_single_leader()
    {
        var (nodes, _) = CreateCluster(3);
        var leader = ElectLeader(nodes);

        leader.IsLeader.ShouldBeTrue();
        nodes.Count(n => n.IsLeader).ShouldBe(1);
        nodes.Count(n => n.Role == RaftRole.Follower).ShouldBe(2);
    }

    // Go reference: TestNRGSimple — term increments on election start
    [Fact]
    public void Term_increments_on_each_election()
    {
        var node = new RaftNode("n1");
        node.Term.ShouldBe(0);
        node.StartElection(1);
        node.Term.ShouldBe(1);
        node.RequestStepDown();
        node.StartElection(1);
        node.Term.ShouldBe(2);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGSimpleElection server/raft_test.go:296
    // ---------------------------------------------------------------

    // Go reference: TestNRGSimpleElection — five-node election
    [Fact]
    public void Five_node_cluster_elects_leader_with_three_vote_quorum()
    {
        var (nodes, _) = CreateCluster(5);
        var leader = ElectLeader(nodes);
        leader.IsLeader.ShouldBeTrue();
    }

    // Go reference: TestNRGSimpleElection — candidate self-votes
    [Fact]
    public void Candidate_records_self_vote_on_start()
    {
        var node = new RaftNode("n1");
        node.StartElection(clusterSize: 3);
        node.Role.ShouldBe(RaftRole.Candidate);
        node.TermState.VotedFor.ShouldBe("n1");
    }

    // Go reference: TestNRGSimpleElection — two votes out of three wins
    [Fact]
    public void Majority_vote_wins_three_node_election()
    {
        var (nodes, _) = CreateCluster(3);
        var candidate = nodes[0];
        candidate.StartElection(nodes.Length);
        candidate.IsLeader.ShouldBeFalse(); // only self-vote so far

        var vote = nodes[1].GrantVote(candidate.Term, candidate.Id);
        vote.Granted.ShouldBeTrue();
        candidate.ReceiveVote(vote, nodes.Length);
        candidate.IsLeader.ShouldBeTrue();
    }

    // ---------------------------------------------------------------
    // Go: TestNRGSingleNodeElection server/raft_test.go:?
    // ---------------------------------------------------------------

    // Go reference: TestNRGSingleNodeElection — single node after peers removed elects itself
    [Fact]
    public void Single_remaining_node_can_elect_itself()
    {
        var node = new RaftNode("solo2");
        node.StartElection(1);
        node.IsLeader.ShouldBeTrue();
    }

    // ---------------------------------------------------------------
    // Go: TestNRGInlineStepdown server/raft_test.go:194
    // ---------------------------------------------------------------

    // Go reference: TestNRGInlineStepdown — leader transitions to follower on stepdown
    [Fact]
    public void Leader_becomes_follower_on_stepdown()
    {
        var node = new RaftNode("n1");
        node.StartElection(1);
        node.IsLeader.ShouldBeTrue();

        node.RequestStepDown();
        node.IsLeader.ShouldBeFalse();
        node.Role.ShouldBe(RaftRole.Follower);
    }

    // Go reference: TestNRGInlineStepdown — stepdown clears voted-for
    [Fact]
    public void Stepdown_clears_voted_for_state()
    {
        var (nodes, _) = CreateCluster(3);
        var leader = ElectLeader(nodes);
        leader.IsLeader.ShouldBeTrue();

        leader.RequestStepDown();
        leader.TermState.VotedFor.ShouldBeNull();
    }

    // ---------------------------------------------------------------
    // Go: TestNRGRecoverFromFollowingNoLeader server/raft_test.go:154
    // ---------------------------------------------------------------

    // Go reference: TestNRGRecoverFromFollowingNoLeader — higher-term heartbeat causes stepdown
    [Fact]
    public void Higher_term_heartbeat_causes_candidate_to_become_follower()
    {
        var node = new RaftNode("n1");
        node.StartElection(3);
        node.Role.ShouldBe(RaftRole.Candidate);

        node.ReceiveHeartbeat(term: 5);
        node.Role.ShouldBe(RaftRole.Follower);
        node.Term.ShouldBe(5);
    }

    // Go reference: TestNRGRecoverFromFollowingNoLeader — leader steps down on higher-term HB
    [Fact]
    public void Leader_steps_down_on_higher_term_heartbeat()
    {
        var node = new RaftNode("n1");
        node.StartElection(1);
        node.IsLeader.ShouldBeTrue();

        node.ReceiveHeartbeat(term: 10);
        node.Role.ShouldBe(RaftRole.Follower);
        node.Term.ShouldBe(10);
    }

    // Go reference: TestNRGRecoverFromFollowingNoLeader — lower-term heartbeat is ignored
    [Fact]
    public void Stale_heartbeat_is_ignored()
    {
        var node = new RaftNode("n1");
        node.StartElection(1);
        node.IsLeader.ShouldBeTrue();
        node.Term.ShouldBe(1);

        node.ReceiveHeartbeat(term: 0);
        node.IsLeader.ShouldBeTrue();
        node.Term.ShouldBe(1);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGStepDownOnSameTermDoesntClearVote server/raft_test.go:447
    // ---------------------------------------------------------------

    // Go reference: TestNRGStepDownOnSameTermDoesntClearVote — same term vote denied to second candidate
    [Fact]
    public void Vote_denied_to_second_candidate_in_same_term()
    {
        var voter = new RaftNode("voter");
        voter.GrantVote(term: 1, candidateId: "candidate-a").Granted.ShouldBeTrue();
        voter.GrantVote(term: 1, candidateId: "candidate-b").Granted.ShouldBeFalse();
        voter.TermState.VotedFor.ShouldBe("candidate-a");
    }

    // ---------------------------------------------------------------
    // Go: TestNRGAssumeHighTermAfterCandidateIsolation server/raft_test.go:662
    // ---------------------------------------------------------------

    // Go reference: TestNRGAssumeHighTermAfterCandidateIsolation — isolated candidate bumps term high
    [Fact]
    public void Vote_request_with_high_term_updates_receiver_term()
    {
        var voter = new RaftNode("voter");
        voter.TermState.CurrentTerm = 5;

        var resp = voter.GrantVote(term: 100, candidateId: "isolated");
        voter.TermState.CurrentTerm.ShouldBe(100);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGCandidateDoesntRevertTermAfterOldAE server/raft_test.go:792
    // ---------------------------------------------------------------

    // Go reference: TestNRGCandidateDoesntRevertTermAfterOldAE — stale heartbeat does not revert term
    [Fact]
    public void Stale_heartbeat_does_not_revert_candidate_term()
    {
        var node = new RaftNode("n1");
        node.StartElection(3); // term = 1
        node.StartElection(3); // term = 2
        node.Term.ShouldBe(2);

        node.ReceiveHeartbeat(term: 1); // stale
        node.Term.ShouldBe(2);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGCandidateDontStepdownDueToLeaderOfPreviousTerm server/raft_test.go:972
    // ---------------------------------------------------------------

    // Go reference: TestNRGCandidateDontStepdownDueToLeaderOfPreviousTerm
    [Fact]
    public void Candidate_ignores_heartbeat_from_previous_term_leader()
    {
        var node = new RaftNode("n1");
        node.TermState.CurrentTerm = 10;
        node.StartElection(3); // term = 11
        node.Role.ShouldBe(RaftRole.Candidate);

        node.ReceiveHeartbeat(term: 5);
        node.Role.ShouldBe(RaftRole.Candidate);
        node.Term.ShouldBe(11);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGHeartbeatOnLeaderChange server/raft_test.go:708
    // ---------------------------------------------------------------

    // Go reference: TestNRGHeartbeatOnLeaderChange — heartbeat updates follower term
    [Fact]
    public void Heartbeat_updates_follower_to_new_term()
    {
        var follower = new RaftNode("f1");
        follower.TermState.CurrentTerm = 2;

        follower.ReceiveHeartbeat(term: 7);
        follower.Term.ShouldBe(7);
        follower.Role.ShouldBe(RaftRole.Follower);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGLeaderTransfer server/raft_test.go:?
    // ---------------------------------------------------------------

    // Go reference: TestNRGLeaderTransfer — leadership transfers via stepdown and re-election
    [Fact]
    public void Leadership_transfer_via_stepdown_and_reelection()
    {
        var (nodes, _) = CreateCluster(3);
        var firstLeader = ElectLeader(nodes);
        firstLeader.IsLeader.ShouldBeTrue();

        firstLeader.RequestStepDown();
        firstLeader.IsLeader.ShouldBeFalse();

        // Elect a different node
        var newCandidate = nodes.First(n => n.Id != firstLeader.Id);
        newCandidate.StartElection(nodes.Length);
        foreach (var voter in nodes.Where(n => n.Id != newCandidate.Id))
            newCandidate.ReceiveVote(voter.GrantVote(newCandidate.Term, newCandidate.Id), nodes.Length);

        newCandidate.IsLeader.ShouldBeTrue();
        newCandidate.Id.ShouldNotBe(firstLeader.Id);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGMustNotResetVoteOnStepDownOrLeaderTransfer server/raft_test.go:?
    // ---------------------------------------------------------------

    // Go reference: TestNRGMustNotResetVoteOnStepDownOrLeaderTransfer — vote not reset on same-term stepdown
    [Fact]
    public void Stepdown_preserves_vote_state_until_new_term()
    {
        var voter = new RaftNode("voter");
        voter.GrantVote(term: 1, candidateId: "a").Granted.ShouldBeTrue();
        voter.TermState.VotedFor.ShouldBe("a");

        // Receiving a same-term heartbeat (stepdown) from a leader should NOT clear the vote
        voter.ReceiveHeartbeat(term: 1);
        // Vote should remain — same term heartbeat does not clear votedFor
        voter.TermState.VotedFor.ShouldBe("a");
    }

    // ---------------------------------------------------------------
    // Go: TestNRGVoteResponseEncoding server/raft_test.go:?
    // ---------------------------------------------------------------

    // Go reference: TestNRGVoteResponseEncoding — vote response round-trip
    [Fact]
    public void Vote_response_carries_granted_true_on_success()
    {
        var voter = new RaftNode("voter");
        var resp = voter.GrantVote(term: 3, candidateId: "cand");
        resp.Granted.ShouldBeTrue();
    }

    // Go reference: TestNRGVoteResponseEncoding — denied vote carries granted=false
    [Fact]
    public void Vote_response_carries_granted_false_on_denial()
    {
        var voter = new RaftNode("voter");
        voter.GrantVote(term: 1, candidateId: "a"); // vote for a in term 1
        var denied = voter.GrantVote(term: 1, candidateId: "b"); // denied
        denied.Granted.ShouldBeFalse();
    }

    // ---------------------------------------------------------------
    // Go: TestNRGSimple server/raft_test.go:35 — log replication
    // ---------------------------------------------------------------

    // Go reference: TestNRGSimple — propose adds entry to leader log
    [Fact]
    public async Task Leader_propose_adds_entry_to_log()
    {
        var (leader, _) = CreateLeaderWithFollowers(2);
        var idx = await leader.ProposeAsync("set-x=1", default);

        idx.ShouldBe(1);
        leader.Log.Entries.Count.ShouldBe(1);
        leader.Log.Entries[0].Command.ShouldBe("set-x=1");
    }

    // Go reference: TestNRGSimple — follower receives replicated entry
    [Fact]
    public async Task Followers_receive_replicated_entries()
    {
        var (leader, followers) = CreateLeaderWithFollowers(2);
        await leader.ProposeAsync("replicated-cmd", default);

        foreach (var f in followers)
        {
            f.Log.Entries.Count.ShouldBe(1);
            f.Log.Entries[0].Command.ShouldBe("replicated-cmd");
        }
    }

    // Go reference: TestNRGSimple — commit index advances after quorum
    [Fact]
    public async Task Commit_index_advances_after_quorum_replication()
    {
        var (leader, _) = CreateLeaderWithFollowers(2);
        await leader.ProposeAsync("committed", default);
        leader.AppliedIndex.ShouldBeGreaterThan(0);
    }

    // Go reference: TestNRGSimple — sequential indices preserved
    [Fact]
    public async Task Sequential_proposals_use_monotonically_increasing_indices()
    {
        var (leader, _) = CreateLeaderWithFollowers(2);
        var i1 = await leader.ProposeAsync("cmd-1", default);
        var i2 = await leader.ProposeAsync("cmd-2", default);
        var i3 = await leader.ProposeAsync("cmd-3", default);

        i1.ShouldBe(1);
        i2.ShouldBe(2);
        i3.ShouldBe(3);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGWALEntryWithoutQuorumMustTruncate server/raft_test.go:1063
    // ---------------------------------------------------------------

    // Go reference: TestNRGWALEntryWithoutQuorumMustTruncate — follower cannot propose
    [Fact]
    public async Task Follower_throws_on_propose()
    {
        var (_, followers) = CreateLeaderWithFollowers(2);
        await Should.ThrowAsync<InvalidOperationException>(
            async () => await followers[0].ProposeAsync("should-fail", default));
    }

    // ---------------------------------------------------------------
    // Go: TestNRGTermNoDecreaseAfterWALReset server/raft_test.go:1156
    // ---------------------------------------------------------------

    // Go reference: TestNRGTermNoDecreaseAfterWALReset — stale-term append rejected
    [Fact]
    public async Task Stale_term_append_entry_is_rejected()
    {
        var node = new RaftNode("n1");
        node.StartElection(1); // term = 1

        var stale = new RaftLogEntry(Index: 1, Term: 0, Command: "stale");
        await Should.ThrowAsync<InvalidOperationException>(
            async () => await node.TryAppendFromLeaderAsync(stale, default));
    }

    // Go reference: TestNRGTermNoDecreaseAfterWALReset — current-term append accepted
    [Fact]
    public async Task Current_term_append_entry_is_accepted()
    {
        var node = new RaftNode("n1");
        node.TermState.CurrentTerm = 3;

        var entry = new RaftLogEntry(Index: 1, Term: 3, Command: "valid");
        await node.TryAppendFromLeaderAsync(entry, default);
        node.Log.Entries.Count.ShouldBe(1);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGNoResetOnAppendEntryResponse server/raft_test.go:912
    // ---------------------------------------------------------------

    // Go reference: TestNRGNoResetOnAppendEntryResponse — no quorum means applied stays 0
    [Fact]
    public async Task Propose_without_follower_quorum_does_not_advance_applied()
    {
        // Single node is its own quorum, so use a special test node
        var node = new RaftNode("n1");
        node.StartElection(5); // needs 3 votes but only has 1
        node.IsLeader.ShouldBeFalse(); // candidate, not leader

        // Only leader can propose — this tests that the gate works
        await Should.ThrowAsync<InvalidOperationException>(
            async () => await node.ProposeAsync("no-quorum", default));
    }

    // ---------------------------------------------------------------
    // Go: TestNRGSnapshotAndRestart server/raft_test.go:49
    // ---------------------------------------------------------------

    // Go reference: TestNRGSnapshotAndRestart — snapshot creation captures index and term
    [Fact]
    public async Task Snapshot_creation_records_applied_index_and_term()
    {
        var (leader, _) = CreateLeaderWithFollowers(2);
        await leader.ProposeAsync("cmd-a", default);
        await leader.ProposeAsync("cmd-b", default);

        var snap = await leader.CreateSnapshotAsync(default);
        snap.LastIncludedIndex.ShouldBe(leader.AppliedIndex);
        snap.LastIncludedTerm.ShouldBe(leader.Term);
    }

    // Go reference: TestNRGSnapshotAndRestart — installing snapshot updates applied index
    [Fact]
    public async Task Installing_snapshot_updates_applied_index_on_follower()
    {
        var (leader, _) = CreateLeaderWithFollowers(2);
        await leader.ProposeAsync("snap-1", default);
        await leader.ProposeAsync("snap-2", default);

        var snap = await leader.CreateSnapshotAsync(default);
        var newNode = new RaftNode("latecomer");
        await newNode.InstallSnapshotAsync(snap, default);

        newNode.AppliedIndex.ShouldBe(snap.LastIncludedIndex);
    }

    // Go reference: TestNRGSnapshotAndRestart — log is cleared after snapshot install
    [Fact]
    public async Task Log_is_cleared_when_snapshot_is_installed()
    {
        var (leader, _) = CreateLeaderWithFollowers(2);
        await leader.ProposeAsync("pre-snap", default);

        var snap = await leader.CreateSnapshotAsync(default);
        var follower = new RaftNode("f-snap");
        await follower.InstallSnapshotAsync(snap, default);

        follower.Log.Entries.Count.ShouldBe(0);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGSnapshotCatchup / TestNRGSimpleCatchup server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGSimpleCatchup — lagging follower catches up via log entries
    [Fact]
    public async Task Lagging_follower_catches_up_via_replicated_entries()
    {
        var (leader, followers) = CreateLeaderWithFollowers(2);

        await leader.ProposeAsync("e1", default);
        await leader.ProposeAsync("e2", default);
        await leader.ProposeAsync("e3", default);

        followers[0].Log.Entries.Count.ShouldBe(3);
    }

    // Go reference: TestNRGSnapshotCatchup — snapshot + subsequent entries applied correctly
    [Fact]
    public async Task Snapshot_install_followed_by_new_entries_uses_correct_base_index()
    {
        var (leader, _) = CreateLeaderWithFollowers(2);
        await leader.ProposeAsync("early", default);

        var snap = await leader.CreateSnapshotAsync(default);
        var newNode = new RaftNode("catchup");
        await newNode.InstallSnapshotAsync(snap, default);

        // After snapshot, new log entries should continue from snapshot index
        var postEntry = newNode.Log.Append(term: 1, command: "post-snap");
        postEntry.Index.ShouldBe(snap.LastIncludedIndex + 1);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGDrainAndReplaySnapshot server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGDrainAndReplaySnapshot — DrainAndReplaySnapshot resets commit queue
    [Fact]
    public async Task DrainAndReplaySnapshot_advances_applied_and_commit_indices()
    {
        var (leader, _) = CreateLeaderWithFollowers(2);
        await leader.ProposeAsync("pre", default);

        var snap = new RaftSnapshot { LastIncludedIndex = 50, LastIncludedTerm = 2 };
        await leader.DrainAndReplaySnapshotAsync(snap, default);

        leader.AppliedIndex.ShouldBe(50);
        leader.CommitIndex.ShouldBe(50);
    }

    // Go reference: TestNRGDrainAndReplaySnapshot — log is replaced by snapshot
    [Fact]
    public async Task DrainAndReplaySnapshot_replaces_log()
    {
        var (leader, _) = CreateLeaderWithFollowers(2);
        await leader.ProposeAsync("a", default);
        await leader.ProposeAsync("b", default);

        var snap = new RaftSnapshot { LastIncludedIndex = 5, LastIncludedTerm = 1 };
        await leader.DrainAndReplaySnapshotAsync(snap, default);

        leader.Log.Entries.Count.ShouldBe(0);
        leader.Log.BaseIndex.ShouldBe(5);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGSnapshotAndTruncateToApplied server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGSnapshotAndTruncateToApplied — checkpoint compacts log
    [Fact]
    public async Task Snapshot_checkpoint_compacts_log_to_applied_index()
    {
        var (leader, _) = CreateLeaderWithFollowers(2);
        await leader.ProposeAsync("a", default);
        await leader.ProposeAsync("b", default);
        await leader.ProposeAsync("c", default);

        leader.Log.Entries.Count.ShouldBe(3);
        await leader.CreateSnapshotCheckpointAsync(default);

        leader.Log.Entries.Count.ShouldBe(0);
    }

    // Go reference: TestNRGSnapshotAndTruncateToApplied — base index matches snapshot
    [Fact]
    public async Task Snapshot_checkpoint_sets_base_index_to_applied()
    {
        var (leader, _) = CreateLeaderWithFollowers(2);
        await leader.ProposeAsync("x", default);
        await leader.ProposeAsync("y", default);

        var applied = leader.AppliedIndex;
        await leader.CreateSnapshotCheckpointAsync(default);
        leader.Log.BaseIndex.ShouldBe(applied);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGIgnoreDoubleSnapshot server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGIgnoreDoubleSnapshot — installing same snapshot twice is idempotent
    [Fact]
    public async Task Installing_same_snapshot_twice_is_idempotent()
    {
        var node = new RaftNode("n1");
        var snap = new RaftSnapshot { LastIncludedIndex = 10, LastIncludedTerm = 1 };

        await node.InstallSnapshotAsync(snap, default);
        await node.InstallSnapshotAsync(snap, default);

        node.AppliedIndex.ShouldBe(10);
        node.Log.Entries.Count.ShouldBe(0);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGProposeRemovePeer server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGProposeRemovePeer — remove follower peer succeeds
    [Fact]
    public async Task Remove_peer_removes_member_from_cluster()
    {
        var (nodes, _) = CreateCluster(3);
        var leader = ElectLeader(nodes);

        leader.Members.ShouldContain("n2");
        await leader.ProposeRemovePeerAsync("n2", default);
        leader.Members.ShouldNotContain("n2");
    }

    // Go reference: TestNRGProposeRemovePeer — remove creates log entry
    [Fact]
    public async Task Remove_peer_creates_log_entry()
    {
        var (nodes, _) = CreateCluster(3);
        var leader = ElectLeader(nodes);

        var before = leader.Log.Entries.Count;
        await leader.ProposeRemovePeerAsync("n2", default);
        leader.Log.Entries.Count.ShouldBe(before + 1);
    }

    // Go reference: TestNRGProposeRemovePeerLeader — leader cannot remove itself
    [Fact]
    public async Task Leader_cannot_remove_itself()
    {
        var (nodes, _) = CreateCluster(3);
        var leader = ElectLeader(nodes);

        await Should.ThrowAsync<InvalidOperationException>(
            async () => await leader.ProposeRemovePeerAsync(leader.Id, default));
    }

    // ---------------------------------------------------------------
    // Go: TestNRGAddPeers server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGAddPeers — add peer adds to member set
    [Fact]
    public async Task Add_peer_adds_to_member_set()
    {
        var (nodes, _) = CreateCluster(3);
        var leader = ElectLeader(nodes);

        leader.Members.ShouldNotContain("n4");
        await leader.ProposeAddPeerAsync("n4", default);
        leader.Members.ShouldContain("n4");
    }

    // Go reference: TestNRGAddPeers — add peer creates log entry
    [Fact]
    public async Task Add_peer_creates_log_entry()
    {
        var (nodes, _) = CreateCluster(3);
        var leader = ElectLeader(nodes);

        var before = leader.Log.Entries.Count;
        await leader.ProposeAddPeerAsync("n4", default);
        leader.Log.Entries.Count.ShouldBe(before + 1);
    }

    // Go reference: TestNRGAddPeers — add peer tracks peer state
    [Fact]
    public async Task Add_peer_initializes_peer_state_tracking()
    {
        var (nodes, _) = CreateCluster(3);
        var leader = ElectLeader(nodes);

        await leader.ProposeAddPeerAsync("n4", default);
        leader.GetPeerStates().ShouldContainKey("n4");
    }

    // ---------------------------------------------------------------
    // Go: TestNRGProposeRemovePeerAll server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGProposeRemovePeerAll — removing all followers leaves single node
    [Fact]
    public async Task Removing_all_followers_leaves_single_leader_node()
    {
        var (nodes, _) = CreateCluster(3);
        var leader = ElectLeader(nodes);

        await leader.ProposeRemovePeerAsync("n2", default);
        await leader.ProposeRemovePeerAsync("n3", default);

        leader.Members.Count.ShouldBe(1);
        leader.Members.ShouldContain(leader.Id);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGLeaderResurrectsRemovedPeers server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGLeaderResurrectsRemovedPeers — can re-add a previously removed peer
    [Fact]
    public async Task Previously_removed_peer_can_be_re_added()
    {
        var (nodes, _) = CreateCluster(3);
        var leader = ElectLeader(nodes);

        await leader.ProposeRemovePeerAsync("n2", default);
        leader.Members.ShouldNotContain("n2");

        await leader.ProposeAddPeerAsync("n2", default);
        leader.Members.ShouldContain("n2");
    }

    // ---------------------------------------------------------------
    // Go: TestNRGUncommittedMembershipChangeGetsTruncated server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGUncommittedMembershipChangeGetsTruncated — membership change in-progress flag clears
    [Fact]
    public async Task Membership_change_in_progress_flag_clears_after_completion()
    {
        var (nodes, _) = CreateCluster(3);
        var leader = ElectLeader(nodes);

        await leader.ProposeAddPeerAsync("n4", default);
        leader.MembershipChangeInProgress.ShouldBeFalse();
    }

    // ---------------------------------------------------------------
    // Go: TestNRGProposeRemovePeerQuorum server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGProposeRemovePeerQuorum — remove/add sequence maintains quorum
    [Fact]
    public async Task Sequential_add_and_remove_maintains_consistent_member_count()
    {
        var (nodes, _) = CreateCluster(3);
        var leader = ElectLeader(nodes);
        var before = leader.Members.Count;

        await leader.ProposeAddPeerAsync("n4", default);
        leader.Members.Count.ShouldBe(before + 1);

        await leader.ProposeRemovePeerAsync("n4", default);
        leader.Members.Count.ShouldBe(before);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGReplayAddPeerKeepsClusterSize server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGReplayAddPeerKeepsClusterSize — cluster size accurate after membership change
    [Fact]
    public async Task Cluster_size_reflects_membership_changes_correctly()
    {
        var (nodes, _) = CreateCluster(3);
        var leader = ElectLeader(nodes);

        leader.Members.Count.ShouldBe(3);
        await leader.ProposeAddPeerAsync("n4", default);
        leader.Members.Count.ShouldBe(4);
        await leader.ProposeRemovePeerAsync("n4", default);
        leader.Members.Count.ShouldBe(3);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGInitSingleMemRaftNodeDefaults server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGInitSingleMemRaftNodeDefaults — fresh node has expected defaults
    [Fact]
    public void New_node_has_zero_term_and_follower_role()
    {
        var node = new RaftNode("defaults-test");
        node.Term.ShouldBe(0);
        node.Role.ShouldBe(RaftRole.Follower);
        node.IsLeader.ShouldBeFalse();
        node.AppliedIndex.ShouldBe(0);
    }

    // Go reference: TestNRGInitSingleMemRaftNodeDefaults — fresh node has itself as sole member
    [Fact]
    public void New_node_contains_itself_as_initial_member()
    {
        var node = new RaftNode("solo-member");
        node.Members.ShouldContain("solo-member");
    }

    // Go reference: TestNRGInitSingleMemRaftNodeDefaults — fresh node has empty log
    [Fact]
    public void New_node_has_empty_log()
    {
        var node = new RaftNode("empty-log");
        node.Log.Entries.Count.ShouldBe(0);
        node.Log.BaseIndex.ShouldBe(0);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGProcessed server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGProcessed — MarkProcessed advances processed index
    [Fact]
    public void MarkProcessed_advances_processed_index()
    {
        var node = new RaftNode("proc-test");
        node.ProcessedIndex.ShouldBe(0);

        node.MarkProcessed(5);
        node.ProcessedIndex.ShouldBe(5);

        node.MarkProcessed(3); // lower value should not regress
        node.ProcessedIndex.ShouldBe(5);
    }

    // Go reference: TestNRGProcessed — processed index does not regress
    [Fact]
    public void MarkProcessed_does_not_allow_regression()
    {
        var node = new RaftNode("proc-floor");
        node.MarkProcessed(10);
        node.MarkProcessed(5);
        node.ProcessedIndex.ShouldBe(10);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGSizeAndApplied server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGSizeAndApplied — applied index matches number of committed entries
    [Fact]
    public async Task Applied_index_matches_committed_entry_count()
    {
        var (leader, _) = CreateLeaderWithFollowers(2);

        await leader.ProposeAsync("e1", default);
        await leader.ProposeAsync("e2", default);
        await leader.ProposeAsync("e3", default);

        leader.AppliedIndex.ShouldBe(3);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGForwardProposalResponse server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGForwardProposalResponse — follower can receive entries from leader
    [Fact]
    public async Task Follower_can_receive_entries_forwarded_from_leader()
    {
        var follower = new RaftNode("follower");
        follower.TermState.CurrentTerm = 2;

        var entry = new RaftLogEntry(Index: 1, Term: 2, Command: "forwarded");
        await follower.TryAppendFromLeaderAsync(entry, default);

        follower.Log.Entries.Count.ShouldBe(1);
        follower.Log.Entries[0].Command.ShouldBe("forwarded");
    }

    // ---------------------------------------------------------------
    // Go: TestNRGQuorumAccounting server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGQuorumAccounting — correct quorum sizes for various cluster sizes
    [Theory]
    [InlineData(3, 2)]
    [InlineData(5, 3)]
    [InlineData(7, 4)]
    public void Cluster_quorum_requires_majority_votes(int clusterSize, int neededVotes)
    {
        var node = new RaftNode("qtest");
        node.StartElection(clusterSize);
        node.IsLeader.ShouldBeFalse(); // only self-vote so far (2+ node cluster)

        for (int i = 1; i < neededVotes; i++)
            node.ReceiveVote(new VoteResponse { Granted = true }, clusterSize);

        node.IsLeader.ShouldBeTrue();
    }

    // Go reference: TestNRGQuorumAccounting — single node cluster immediately becomes leader
    [Fact]
    public void Single_node_cluster_reaches_quorum_with_self_vote()
    {
        var node = new RaftNode("solo");
        node.StartElection(clusterSize: 1);
        node.IsLeader.ShouldBeTrue(); // single-node: self-vote is quorum
    }

    // ---------------------------------------------------------------
    // Go: TestNRGTrackPeerActive server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGTrackPeerActive — leader tracks peer states after cluster formation
    [Fact]
    public void Leader_tracks_peer_state_for_all_followers()
    {
        var (nodes, _) = CreateCluster(3);
        var leader = ElectLeader(nodes);

        var peers = leader.GetPeerStates();
        peers.ShouldContainKey("n2");
        peers.ShouldContainKey("n3");
        peers.ShouldNotContainKey("n1"); // self is not in peer states
    }

    // Go reference: TestNRGTrackPeerActive — peer state contains correct peer ID
    [Fact]
    public void Peer_state_contains_correct_peer_id()
    {
        var (nodes, _) = CreateCluster(3);
        var leader = ElectLeader(nodes);

        var peers = leader.GetPeerStates();
        peers["n2"].PeerId.ShouldBe("n2");
        peers["n3"].PeerId.ShouldBe("n3");
    }

    // ---------------------------------------------------------------
    // Go: TestNRGRevalidateQuorumAfterLeaderChange server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGRevalidateQuorumAfterLeaderChange — new leader commits after re-election
    [Fact]
    public async Task New_leader_can_commit_entries_after_re_election()
    {
        var (nodes, _) = CreateCluster(3);
        var firstLeader = ElectLeader(nodes);
        await firstLeader.ProposeAsync("pre-stepdown", default);

        firstLeader.RequestStepDown();

        // Elect a new leader
        var newLeader = nodes.First(n => n.Id != firstLeader.Id);
        newLeader.StartElection(nodes.Length);
        foreach (var v in nodes.Where(n => n.Id != newLeader.Id))
            newLeader.ReceiveVote(v.GrantVote(newLeader.Term, newLeader.Id), nodes.Length);
        newLeader.IsLeader.ShouldBeTrue();

        var idx = await newLeader.ProposeAsync("post-election", default);
        idx.ShouldBeGreaterThan(0);
        newLeader.AppliedIndex.ShouldBeGreaterThan(0);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGQuorumAfterLeaderStepdown server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGQuorumAfterLeaderStepdown — quorum maintained after leader stepdown
    [Fact]
    public void Cluster_maintains_quorum_after_leader_stepdown()
    {
        var (nodes, _) = CreateCluster(3);
        var leader = ElectLeader(nodes);
        leader.IsLeader.ShouldBeTrue();

        leader.RequestStepDown();
        leader.IsLeader.ShouldBeFalse();

        // The cluster can still elect a new leader
        var newCandidate = nodes.First(n => n.Id != leader.Id);
        newCandidate.StartElection(nodes.Length);
        foreach (var v in nodes.Where(n => n.Id != newCandidate.Id))
            newCandidate.ReceiveVote(v.GrantVote(newCandidate.Term, newCandidate.Id), nodes.Length);

        newCandidate.IsLeader.ShouldBeTrue();
    }

    // ---------------------------------------------------------------
    // Go: TestNRGSendAppendEntryNotLeader server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGSendAppendEntryNotLeader — non-leader cannot propose
    [Fact]
    public async Task Non_leader_cannot_send_append_entries()
    {
        var node = new RaftNode("follower-node");
        // node stays as follower, never elected

        await Should.ThrowAsync<InvalidOperationException>(
            async () => await node.ProposeAsync("should-reject", default));
    }

    // ---------------------------------------------------------------
    // Go: TestNRGInstallSnapshotFromCheckpoint server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGInstallSnapshotFromCheckpoint — chunked snapshot assembly
    [Fact]
    public async Task Chunked_snapshot_assembles_correctly()
    {
        var node = new RaftNode("n1");
        var chunk1 = new byte[] { 0x01, 0x02, 0x03 };
        var chunk2 = new byte[] { 0x04, 0x05, 0x06 };

        await node.InstallSnapshotFromChunksAsync([chunk1, chunk2], snapshotIndex: 20, snapshotTerm: 3, default);

        node.AppliedIndex.ShouldBe(20);
        node.CommitIndex.ShouldBe(20);
    }

    // Go reference: TestNRGInstallSnapshotFromCheckpoint — snapshot clears log
    [Fact]
    public async Task Chunked_snapshot_clears_log()
    {
        var (leader, _) = CreateLeaderWithFollowers(2);
        await leader.ProposeAsync("a", default);

        await leader.InstallSnapshotFromChunksAsync([[0x01]], snapshotIndex: 10, snapshotTerm: 1, default);
        leader.Log.Entries.Count.ShouldBe(0);
        leader.Log.BaseIndex.ShouldBe(10);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGInstallSnapshotForce server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGInstallSnapshotForce — forced snapshot installation overwrites state
    [Fact]
    public async Task Force_snapshot_install_overrides_higher_applied_index()
    {
        var node = new RaftNode("n1");
        node.AppliedIndex = 100; // simulate advanced state

        // Installing an older snapshot should reset to snapshot index
        var snap = new RaftSnapshot { LastIncludedIndex = 50, LastIncludedTerm = 1 };
        await node.InstallSnapshotAsync(snap, default);

        node.AppliedIndex.ShouldBe(50);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGMultipleStopsDontPanic server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGMultipleStopsDontPanic — multiple disposals do not throw
    [Fact]
    public void Multiple_disposals_do_not_throw()
    {
        var node = new RaftNode("n1");
        Should.NotThrow(() => node.Dispose());
        Should.NotThrow(() => node.Dispose());
    }

    // ---------------------------------------------------------------
    // Go: TestNRGMemoryWALEmptiesSnapshotsDir server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGMemoryWALEmptiesSnapshotsDir — log compaction empties entries
    [Fact]
    public async Task Log_compaction_removes_entries_below_snapshot_index()
    {
        var (leader, _) = CreateLeaderWithFollowers(2);
        await leader.ProposeAsync("e1", default);
        await leader.ProposeAsync("e2", default);
        await leader.ProposeAsync("e3", default);
        leader.Log.Entries.Count.ShouldBe(3);

        leader.Log.Compact(2);
        leader.Log.Entries.Count.ShouldBe(1); // only e3 remains
    }

    // ---------------------------------------------------------------
    // Go: TestNRGDisjointMajorities server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGDisjointMajorities — split cluster — two candidates, neither reaches quorum
    [Fact]
    public void Split_cluster_produces_no_leader_without_quorum()
    {
        var (nodes, _) = CreateCluster(5);

        // n1 gets 2 votes (including self) out of 5 — not enough
        nodes[0].StartElection(5);
        nodes[0].ReceiveVote(new VoteResponse { Granted = true }, 5);
        nodes[0].IsLeader.ShouldBeFalse(); // 2/5, needs 3

        // n2 gets 2 votes (including self) out of 5 — not enough
        nodes[1].StartElection(5);
        nodes[1].ReceiveVote(new VoteResponse { Granted = true }, 5);
        nodes[1].IsLeader.ShouldBeFalse(); // 2/5, needs 3
    }

    // ---------------------------------------------------------------
    // Go: TestNRGAppendEntryResurrectsLeader server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGAppendEntryResurrectsLeader — higher-term AE makes follower follow new leader
    [Fact]
    public void Higher_term_append_entry_switches_follower_to_new_term()
    {
        var follower = new RaftNode("f1");
        follower.TermState.CurrentTerm = 2;

        // Append entry from higher-term leader
        follower.ReceiveHeartbeat(term: 5);
        follower.Term.ShouldBe(5);
        follower.Role.ShouldBe(RaftRole.Follower);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGObserverMode server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGObserverMode — observer receives entries but does not campaign
    [Fact]
    public async Task Observer_node_receives_replicated_entries_without_campaigning()
    {
        // Observer = a follower that is told not to campaign
        var observer = new RaftNode("observer");
        observer.PreVoteEnabled = false; // disable pre-vote to prevent auto-campaigning

        var entry = new RaftLogEntry(Index: 1, Term: 1, Command: "observed");
        observer.ReceiveReplicatedEntry(entry);

        observer.Log.Entries.Count.ShouldBe(1);
        observer.Role.ShouldBe(RaftRole.Follower);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGAEFromOldLeader server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGAEFromOldLeader — stale AE from old leader term rejected
    [Fact]
    public async Task Append_entry_from_stale_term_leader_is_rejected()
    {
        var node = new RaftNode("n1");
        node.TermState.CurrentTerm = 5;

        var staleEntry = new RaftLogEntry(Index: 1, Term: 2, Command: "stale-ae");
        await Should.ThrowAsync<InvalidOperationException>(
            async () => await node.TryAppendFromLeaderAsync(staleEntry, default));
    }

    // ---------------------------------------------------------------
    // Go: TestNRGElectionTimerAfterObserver server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGElectionTimerAfterObserver — election timer can be started and stopped
    [Fact]
    public void Election_timer_can_be_started_and_stopped_without_throwing()
    {
        var node = new RaftNode("timer-test");
        Should.NotThrow(() => node.StartElectionTimer());
        Should.NotThrow(() => node.StopElectionTimer());
    }

    // ---------------------------------------------------------------
    // Go: TestNRGSnapshotRecovery server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGSnapshotRecovery — snapshot followed by new entries produces correct index sequence
    [Fact]
    public async Task After_snapshot_new_entries_have_sequential_indices()
    {
        var node = new RaftNode("n1");
        var snap = new RaftSnapshot { LastIncludedIndex = 10, LastIncludedTerm = 1 };
        await node.InstallSnapshotAsync(snap, default);

        var e1 = node.Log.Append(term: 2, command: "after-snap-1");
        var e2 = node.Log.Append(term: 2, command: "after-snap-2");

        e1.Index.ShouldBe(11);
        e2.Index.ShouldBe(12);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGReplayOnSnapshotSameTerm / TestNRGReplayOnSnapshotDifferentTerm
    // ---------------------------------------------------------------

    // Go reference: TestNRGReplayOnSnapshotSameTerm — entries in same term as snapshot are handled
    [Fact]
    public async Task Entry_in_same_term_as_snapshot_is_accepted_after_install()
    {
        var node = new RaftNode("n1");
        var snap = new RaftSnapshot { LastIncludedIndex = 5, LastIncludedTerm = 2 };
        await node.InstallSnapshotAsync(snap, default);

        node.TermState.CurrentTerm = 2;
        var entry = new RaftLogEntry(Index: 6, Term: 2, Command: "same-term");
        await node.TryAppendFromLeaderAsync(entry, default);

        node.Log.Entries.Count.ShouldBe(1);
    }

    // Go reference: TestNRGReplayOnSnapshotDifferentTerm — entries in new term after snapshot
    [Fact]
    public async Task Entry_in_different_term_after_snapshot_is_accepted()
    {
        var node = new RaftNode("n1");
        var snap = new RaftSnapshot { LastIncludedIndex = 5, LastIncludedTerm = 1 };
        await node.InstallSnapshotAsync(snap, default);

        node.TermState.CurrentTerm = 3;
        var entry = new RaftLogEntry(Index: 6, Term: 3, Command: "new-term");
        await node.TryAppendFromLeaderAsync(entry, default);

        node.Log.Entries.Count.ShouldBe(1);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGTruncateDownToCommitted server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGTruncateDownToCommitted — Compact removes entries up to committed index
    [Fact]
    public void Compact_removes_entries_up_to_given_index()
    {
        var log = new RaftLog();
        log.Append(1, "a");
        log.Append(1, "b");
        log.Append(1, "c");
        log.Append(1, "d");

        log.Compact(2);

        log.Entries.Count.ShouldBe(2);
        log.Entries[0].Command.ShouldBe("c");
        log.Entries[1].Command.ShouldBe("d");
    }

    // Go reference: TestNRGTruncateDownToCommitted — base index set to compact point
    [Fact]
    public void Compact_sets_base_index_correctly()
    {
        var log = new RaftLog();
        log.Append(1, "a");
        log.Append(1, "b");
        log.Append(1, "c");

        log.Compact(2);
        log.BaseIndex.ShouldBe(2);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGPendingAppendEntryCacheInvalidation server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGPendingAppendEntryCacheInvalidation — duplicate entries deduplicated
    [Fact]
    public void Duplicate_replicated_entries_are_deduplicated_by_index()
    {
        var log = new RaftLog();
        var entry = new RaftLogEntry(Index: 1, Term: 1, Command: "once");
        log.AppendReplicated(entry);
        log.AppendReplicated(entry);
        log.AppendReplicated(entry);

        log.Entries.Count.ShouldBe(1);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGDontRemoveSnapshotIfTruncateToApplied server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGDontRemoveSnapshotIfTruncateToApplied — snapshot data preserved
    [Fact]
    public async Task Snapshot_data_is_preserved_after_install()
    {
        var node = new RaftNode("n1");
        var snap = new RaftSnapshot
        {
            LastIncludedIndex = 7,
            LastIncludedTerm = 2,
            Data = [0xAB, 0xCD]
        };
        await node.InstallSnapshotAsync(snap, default);
        node.AppliedIndex.ShouldBe(7);
    }

    // ---------------------------------------------------------------
    // Log base index continuity after repeated compactions
    // ---------------------------------------------------------------

    // Go reference: multiple compaction rounds produce correct running base index
    [Fact]
    public void Multiple_compaction_rounds_maintain_correct_base_index()
    {
        var log = new RaftLog();
        for (int i = 0; i < 10; i++)
            log.Append(1, $"cmd-{i}");

        log.Compact(3);
        log.BaseIndex.ShouldBe(3);
        log.Entries.Count.ShouldBe(7);

        log.Compact(7);
        log.BaseIndex.ShouldBe(7);
        log.Entries.Count.ShouldBe(3);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGHealthCheckWaitForCatchup (via peer state)
    // ---------------------------------------------------------------

    // Go reference: TestNRGHealthCheckWaitForCatchup — peer state reflects last contact
    [Fact]
    public void Peer_state_last_contact_updated_when_peer_responds()
    {
        var (nodes, _) = CreateCluster(3);
        var leader = ElectLeader(nodes);

        var peerState = leader.GetPeerStates()["n2"];
        // MatchIndex is 0 before any replication
        peerState.MatchIndex.ShouldBe(0);
    }

    // Go reference: TestNRGHealthCheckWaitForCatchup — match index updates after proposal
    [Fact]
    public async Task Peer_match_index_updates_after_successful_replication()
    {
        var (nodes, _) = CreateCluster(3);
        var leader = ElectLeader(nodes);

        await leader.ProposeAsync("sync-check", default);

        var peerState = leader.GetPeerStates()["n2"];
        peerState.MatchIndex.ShouldBeGreaterThan(0);
    }

    // ---------------------------------------------------------------
    // Go: TestNRGSignalLeadChangeFalseIfCampaignImmediately server/raft_test.go
    // ---------------------------------------------------------------

    // Go reference: TestNRGSignalLeadChangeFalseIfCampaignImmediately — CampaignImmediately fires election
    [Fact]
    public void CampaignImmediately_triggers_election()
    {
        var (nodes, _) = CreateCluster(3);
        // Disable pre-vote for direct testing
        nodes[0].PreVoteEnabled = false;
        nodes[0].ConfigureCluster(nodes);

        nodes[0].CampaignImmediately();
        // After campaign-immediate, node is at least a candidate
        (nodes[0].Role == RaftRole.Candidate || nodes[0].Role == RaftRole.Leader).ShouldBeTrue();
    }
}
