// Copyright 2015 The etcd Authors
// Copyright 2026 Leo Cheng
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

///|
/// Whether a candidate log described by `(cand_term, cand_index)` is at least as
/// up-to-date as `core`'s (Raft §5.4.1): a higher last term wins, and on a tie
/// the longer log wins.
fn log_up_to_date(core : Node, cand_term : UInt64, cand_index : UInt64) -> Bool {
  let my_term = core.last_log_term()
  cand_term > my_term ||
  (cand_term == my_term && cand_index >= core.last_log_index())
}

///|
/// How an incoming message's term compares with ours. Classifying once, up
/// front, is what lets `step` treat a stale message structurally instead of
/// leaving each handler to remember the check — the omission that was bug B1.
priv enum TermRel {
  Stale
  Aligned
  Ahead
}

///|
fn RaftNode::term_rel(self : RaftNode, msg : Message) -> TermRel {
  let t = msg.term()
  let cur = self.core.current_term()
  if t < cur {
    Stale
  } else if t > cur {
    Ahead
  } else {
    Aligned
  }
}

///|
/// Process one incoming message and return the messages to send in reply. This
/// is the single entry point the transport drives; together with `tick` it is
/// the whole externally-visible behaviour of a server.
///
/// The term is classified first (Raft §5.1). A stale message is never allowed
/// to reach a handler as if it were current: acting on a stale *response* is
/// exactly what let a late `VoteResp` be miscounted into a false majority
/// (double leader) or a late `AppendResp` inflate a follower's progress.
pub fn RaftNode::step(self : RaftNode, msg : Message) -> Array[Message] {
  self.tracer.on_event(ReceiveMessage(msg))
  // Local, routable messages (etcd's Term-0 messages) bypass the term check: a
  // client request carries no term, so it is neither stale nor ahead. The read-
  // index *reply* does carry a term and is dispatched normally below.
  match msg.payload {
    Propose(entries) => return self.step_propose(msg.from, entries)
    ReadIndex(ctx) => return self.step_read_index(msg.from, ctx)
    TransferLeader(target) => return self.step_transfer_leader(msg.from, target)
    ForgetLeader => return self.step_forget_leader()
    _ => ()
  }
  match self.term_rel(msg) {
    Stale => self.on_stale(msg)
    Ahead => {
      // A higher-term vote solicitation is refused while we are leased to a
      // leader — and crucially *without* adopting the challenger's term, so a
      // disruptive candidate cannot force the cluster up a term (§4.2.3). A
      // *forced* vote (a leadership transfer, etcd's campaignTransfer) is exempt:
      // the outgoing leader is deliberately handing off, so the lease must not
      // block it. Pre-vote is handled in its own dispatch (it never bumps term).
      if self.in_lease() && msg.payload is Vote(_) && !msg.force {
        return [
          Message::new(
            self.id,
            msg.from,
            VoteResp({ term: self.core.current_term(), vote_granted: false }),
          ),
        ]
      }
      self.adopt_higher_term(msg)
      self.dispatch(msg)
    }
    Aligned => self.dispatch(msg)
  }
}

///|
/// Handle a message stamped with a term below ours. Responses are dropped, so a
/// reply from a superseded term can never move our state. Requests are still
/// answered — the reply carries our current term, which is how the sender
/// learns it is behind and steps down — and their handlers already reject on
/// the stale term. (etcd's `Step` does the same: ignore stale responses; for
/// stale MsgApp/MsgVote/MsgSnap reveal our term to the sender.)
fn RaftNode::on_stale(self : RaftNode, msg : Message) -> Array[Message] {
  match msg.payload {
    VoteResp(_)
    | PreVoteResp(_)
    | AppendResp(_)
    | HeartbeatResp(_)
    | ReadIndexResp(_)
    | TimeoutNow(_) => []
    PreVote(_) | Vote(_) | Append(_) | Heartbeat(_) | Snapshot(_) =>
      self.dispatch(msg)
    // Local, routable messages are intercepted term-free in `step` and never
    // reach the term classifier, so one arriving here is a routing bug.
    Propose(_) | ReadIndex(_) | TransferLeader(_) | ForgetLeader =>
      abort("local message reached on_stale")
  }
}

///|
/// Adopt a strictly higher term and step down — except for pre-vote traffic,
/// which is hypothetical (a pre-candidate probes under a term it has not
/// adopted) and must neither move our term nor make us yield.
fn RaftNode::adopt_higher_term(self : RaftNode, msg : Message) -> Unit {
  match msg.payload {
    PreVote(_) | PreVoteResp(_) => ()
    _ => {
      self.core.become_follower(msg.term())
      self.in_pre_campaign = false
      // Stepping down abandons any in-flight leadership transfer (etcd's reset →
      // abortLeaderTransfer runs on every transition), so a stale transferee can
      // never linger and block proposals should we lead again.
      self.lead_transferee = None
      self.pending_conf_index = 0
      self.uncommitted_size = 0
      self.leader_id = match msg.payload {
        Append(a) => Some(a.leader_id)
        Heartbeat(a) => Some(a.leader_id)
        Snapshot(a) => Some(a.leader_id)
        _ => None
      }
      self.reset_timeout()
    }
  }
}

///|
/// Route a term-checked message to its handler.
fn RaftNode::dispatch(self : RaftNode, msg : Message) -> Array[Message] {
  match msg.payload {
    PreVote(args) => self.handle_pre_vote(msg.from, args)
    PreVoteResp(reply) => self.handle_pre_vote_resp(msg.from, reply)
    Vote(args) => self.handle_vote(msg.from, args)
    VoteResp(reply) => self.handle_vote_resp(msg.from, reply)
    Append(args) => self.handle_append(msg.from, args)
    AppendResp(reply) => self.handle_append_resp(msg.from, reply)
    Heartbeat(args) => self.handle_heartbeat(msg.from, args)
    HeartbeatResp(reply) => self.handle_heartbeat_resp(msg.from, reply)
    Snapshot(args) => self.handle_snapshot(msg.from, args)
    TimeoutNow(term) => self.handle_timeout_now(term)
    ReadIndexResp(reply) => self.handle_read_index_resp(reply)
    // Local, routable messages are handled term-free in `step` and never reach
    // dispatch, so one arriving here is a routing bug.
    Propose(_) | ReadIndex(_) | TransferLeader(_) | ForgetLeader =>
      abort("local message reached dispatch")
  }
}

///|
/// Step a client proposal (etcd's MsgProp). On a leader the carried entries are
/// appended under the current term and replicated. On a follower the proposal is
/// forwarded to the known leader — preserving the original requester as `from` so
/// a chain of forwards still points back to the origin — unless forwarding is
/// disabled or no leader is known, in which case it is dropped (etcd's
/// ErrProposalDropped). A candidate has no leader to forward to and drops it.
fn RaftNode::step_propose(
  self : RaftNode,
  from : String,
  entries : Array[Entry],
) -> Array[Message] {
  match self.core.role() {
    Leader => {
      // A leader handing off leadership stops accepting proposals so the transfer
      // target can catch up to a fixed log (§3.10).
      if self.lead_transferee is Some(_) {
        return []
      }
      let mut total = 0UL
      for e in entries {
        total = total + e.command.length().to_uint64()
      }
      // Drop the batch if it would push the uncommitted tail over its quota
      // (etcd's ErrProposalDropped), unless the tail is currently empty.
      if !self.increase_uncommitted_size(total) {
        return []
      }
      for e in entries {
        if e.is_conf_change() {
          let is_leave = match ConfChangeV2::decode(e.command) {
            Some(v2) => v2.is_leave()
            None => false
          }
          if self.conf_change_refused(is_leave) {
            // etcd replaces a refused conf change with an empty Normal entry: the
            // log position is still consumed but no membership change is applied.
            self.core.append(self.core.current_term(), b"") |> ignore
          } else {
            self.core.append_conf(self.core.current_term(), e.command) |> ignore
            self.pending_conf_index = self.core.last_log_index()
          }
        } else {
          self.core.append(self.core.current_term(), e.command) |> ignore
        }
      }
      // Self-ack the appended tail before committing, as etcd's leader self-acks
      // its own writes (it sends no MsgApp to itself); the leader's own progress
      // must reflect its stored log for `maybe_commit` to count it.
      self.self_ack()
      self.maybe_commit() |> ignore
      self.bcast_append()
    }
    // A (pre-)candidate has no leader to forward to and drops the proposal
    // (etcd's stepCandidate). PreCandidate is unreachable via `core.role()` —
    // the core stays a follower during pre-vote — but the arm keeps the match
    // exhaustive and mirrors candidate behaviour.
    Candidate | PreCandidate => []
    Follower =>
      match self.leader_id {
        Some(lead) =>
          if self.no_forward {
            []
          } else {
            [Message::new(from, lead, Propose(entries))]
          }
        None => []
      }
  }
}

///|
/// Step a linearizable-read request (etcd's MsgReadIndex). A leader serves it
/// (locally, or by answering a remote requester once its leadership is confirmed
/// for the read). A follower forwards it to the leader, preserving the origin as
/// `from` so the answer routes back to the requester; with no known leader it is
/// dropped.
fn RaftNode::step_read_index(
  self : RaftNode,
  from : String,
  ctx : Bytes,
) -> Array[Message] {
  if self.core.role() == Leader {
    return self.lead_read_index(from, ctx)
  }
  match self.leader_id {
    Some(lead) => [Message::new(from, lead, ReadIndex(ctx))]
    None => []
  }
}

///|
/// A follower records the read index the leader confirmed (etcd appends it to
/// readStates), so a caller waiting on `take_read_states` observes the index the
/// state machine must reach before answering the read.
fn RaftNode::handle_read_index_resp(
  self : RaftNode,
  reply : ReadIndexResp,
) -> Array[Message] {
  self.read_only.ready.push({ index: reply.index, request_ctx: reply.context })
  []
}

///|
/// Step a leadership-transfer request (etcd's MsgTransferLeader). On a leader it
/// begins the handoff to `target`; on a follower it is forwarded to the leader so
/// a transfer may be requested from any server.
fn RaftNode::step_transfer_leader(
  self : RaftNode,
  from : String,
  target : String,
) -> Array[Message] {
  if self.core.role() == Leader {
    return self.transfer_leadership(target)
  }
  match self.leader_id {
    Some(lead) => [Message::new(from, lead, TransferLeader(target))]
    None => []
  }
}

///|
/// Step a forget-leader request (etcd's MsgForgetLeader): a follower drops its
/// recognised leader without moving its term or resetting its election timer, so
/// a follower stuck behind a partition can re-enable (pre)votes at once rather
/// than waiting out a full election timeout. Ignored under lease-based reads,
/// where forgetting the leader would undermine the lease that reads depend on
/// (etcd rejects MsgForgetLeader when ReadOnlyLeaseBased). A candidate or leader,
/// which recognises no current leader anyway, is a no-op.
fn RaftNode::step_forget_leader(self : RaftNode) -> Array[Message] {
  if !self.read_only.safe {
    return []
  }
  if self.core.role() == Follower && self.leader_id is Some(_) {
    self.leader_id = None
  }
  []
}

///|
/// Handle a TimeoutNow from the leader (Raft §3.10, leadership transfer). The
/// target campaigns at once — skipping pre-vote and the usual election-timeout
/// wait — so an orderly handover completes in about one round trip rather than
/// waiting for the old leader to look dead.
fn RaftNode::handle_timeout_now(
  self : RaftNode,
  term : UInt64,
) -> Array[Message] {
  if term < self.core.current_term() || self.core.role() == Leader {
    return []
  }
  // A transfer campaign routes through the same guard as a timeout election
  // (etcd's `hup(campaignTransfer)`): refuse while a committed conf change is
  // unapplied.
  if !self.config.contains(self.id) || self.has_unapplied_conf_changes() {
    return []
  }
  self.reset_timeout()
  self.start_real_campaign(force=true)
}

///|
fn RaftNode::handle_pre_vote(
  self : RaftNode,
  from : String,
  args : RequestVoteArgs,
) -> Array[Message] {
  // etcd's `canVote` (raft.go:1214) governs pre-votes as well as real votes: a
  // node may (pre)vote when it has already voted for this candidate, or when it
  // has neither voted nor recognised a leader this term, or — the pre-vote-only
  // clause — when the solicitation is for a strictly future term. The first two
  // clauses grant a pre-vote at the *same* term, which the earlier port missed by
  // requiring a strictly higher term outright (FINDINGS_LEDGER #23).
  let can_vote = self.core.voted_for == Some(from) ||
    (self.core.voted_for == None && self.leader_id == None) ||
    args.term > self.core.current_term()
  // B3: while we still have contact with a leader (check-quorum lease), refuse
  // to encourage a challenger, so a partitioned node that keeps timing out
  // cannot force needless elections and inflate the term (§4.2.3 disruption).
  let granted = !self.in_lease() &&
    can_vote &&
    log_up_to_date(self.core, args.last_log_term, args.last_log_index)
  let term = if granted { args.term } else { self.core.current_term() }
  [Message::new(self.id, from, PreVoteResp({ term, vote_granted: granted }))]
}

///|
fn RaftNode::handle_pre_vote_resp(
  self : RaftNode,
  from : String,
  reply : RequestVoteReply,
) -> Array[Message] {
  guard self.in_pre_campaign else { return [] }
  // A rejection revealing a higher term ends the pre-campaign (etcd only skips
  // stepping down for a *granted* pre-vote, which carries our own future term).
  if !reply.vote_granted && reply.term > self.core.current_term() {
    self.core.become_follower(reply.term)
    self.in_pre_campaign = false
    return []
  }
  self.record_vote(from, reply.vote_granted)
  match self.config.vote_result(self.votes) {
    @quorum.VoteWon => self.start_real_campaign()
    @quorum.VoteLost => {
      // A majority declined the pre-vote: step back to follower rather than
      // linger in the PreCandidate state (the cluster already has a leader).
      self.become_follower(self.core.current_term())
      []
    }
    @quorum.VotePending => []
  }
}

///|
fn RaftNode::handle_vote(
  self : RaftNode,
  from : String,
  args : RequestVoteArgs,
) -> Array[Message] {
  // B3: reject a real vote too while leased to a leader (§4.2.3).
  if self.in_lease() {
    return [
      Message::new(
        self.id,
        from,
        VoteResp({ term: self.core.current_term(), vote_granted: false }),
      ),
    ]
  }
  let reply = self.core.handle_request_vote(args)
  if reply.vote_granted {
    self.election_elapsed = 0
    self.leader_id = None
  }
  [Message::new(self.id, from, VoteResp(reply))]
}

///|
/// Record a (pre-)vote from `id` (etcd's tracker `RecordVote`): the first vote a
/// node casts in a term is kept, and a later differing response from the same
/// node is ignored. This is what stops a duplicated or reordered vote message
/// from flipping a vote already counted toward the tally.
fn RaftNode::record_vote(self : RaftNode, id : String, granted : Bool) -> Unit {
  if !self.votes.contains(id) {
    self.votes[id] = granted
  }
}

///|
/// Whether we are within a check-quorum lease: check-quorum is on, we currently
/// recognise a leader, and our election timer has not yet expired. During the
/// lease we treat the leader as alive and reject vote solicitations.
fn RaftNode::in_lease(self : RaftNode) -> Bool {
  self.check_quorum &&
  self.leader_id is Some(_) &&
  self.election_elapsed < self.election_timeout
}

///|
fn RaftNode::handle_vote_resp(
  self : RaftNode,
  from : String,
  reply : RequestVoteReply,
) -> Array[Message] {
  guard self.core.role() == Candidate else { return [] }
  self.record_vote(from, reply.vote_granted)
  // Count with the quorum's three-valued result so a candidate that is denied
  // by a majority steps down at once instead of waiting out its timer.
  match self.config.vote_result(self.votes) {
    @quorum.VoteWon => {
      self.become_leader()
      self.bcast_append()
    }
    @quorum.VoteLost => {
      self.core.become_follower(self.core.current_term())
      []
    }
    @quorum.VotePending => []
  }
}

///|
fn RaftNode::handle_append(
  self : RaftNode,
  from : String,
  args : AppendEntriesArgs,
) -> Array[Message] {
  let reply = self.core.handle_append_entries(args)
  // reply.term == args.term means we did not reject for being ahead: the sender
  // is a legitimate current leader, so recognise it and defer our election.
  if reply.term == args.term {
    self.leader_id = Some(args.leader_id)
    self.election_elapsed = 0
    self.in_pre_campaign = false
  }
  // A follower folds committed configuration changes into its own view of the
  // membership as its commit index advances, so every server shares one config.
  self.apply_committed_conf()
  [Message::new(self.id, from, AppendResp(reply))]
}

///|
/// Handle a heartbeat from the current leader (Raft §5.2): recognise the leader,
/// defer our election, advance our commit index toward the leader's, and reply.
fn RaftNode::handle_heartbeat(
  self : RaftNode,
  from : String,
  args : HeartbeatArgs,
) -> Array[Message] {
  if args.term >= self.core.current_term() {
    self.leader_id = Some(args.leader_id)
    self.election_elapsed = 0
    self.in_pre_campaign = false
    self.core.role = Follower
    // Commit only up to what we actually hold; the leader already caps `commit`.
    let upto = if args.commit < self.core.last_log_index() {
      args.commit
    } else {
      self.core.last_log_index()
    }
    self.core.advance_commit(upto)
    self.apply_committed_conf()
  }
  [
    Message::new(
      self.id,
      from,
      HeartbeatResp({ term: self.core.current_term(), context: args.context }),
    ),
  ]
}

///|
/// Handle a heartbeat acknowledgement (Raft §5.2). It is pure liveness — no log
/// index — so it marks the follower active (for check-quorum and lease reads)
/// and resumes replication: a follower still behind is sent the entries it
/// lacks, or a content-free probe when its in-flight window is full.
fn RaftNode::handle_heartbeat_resp(
  self : RaftNode,
  from : String,
  reply : HeartbeatReply,
) -> Array[Message] {
  guard self.core.role() == Leader else { return [] }
  let out : Array[Message] = []
  // ReadOnlySafe: an ack echoing a read-index context counts toward confirming
  // the leader still commands a quorum for that read (§6.4). A confirmed read is
  // delivered to its originator — recorded locally, or answered to the follower
  // that forwarded it with a ReadIndexResp.
  if !reply.context.is_empty() {
    self.read_only.recv_ack(from, reply.context)
    for req in self.read_only.maybe_advance(self.config) {
      for m in self.deliver_read(req) {
        out.push(m)
      }
    }
  }
  match self.progress.get(from) {
    None => out
    Some(p) => {
      p.mark_active()
      // A heartbeat response clears the flow throttle (etcd), so one more
      // message goes out to a follower that is still behind.
      p.unpause()
      // The follower was just marked active, so `send_to` yields a message (it
      // withholds only from an inactive follower behind the snapshot).
      if p.match_index < self.core.last_log_index() && p.state != Snapshot {
        if self.send_to(from) is Some(m) {
          out.push(m)
        }
      }
      out
    }
  }
}

///|
/// Report the outcome of a snapshot the leader shipped (etcd's ReportSnapshot /
/// MsgSnapStatus). A failure discards the pending snapshot; either way the
/// follower leaves the Snapshot state and is probed, paused until its next
/// AppendEntries response confirms where it now stands.
pub fn RaftNode::report_snapshot(
  self : RaftNode,
  id : String,
  reject : Bool,
) -> Unit {
  guard self.core.role() == Leader else { return }
  if self.progress.get(id) is Some(p) && p.state == Snapshot {
    if reject {
      p.pending_snapshot = 0
    }
    p.become_probe()
    p.msg_app_flow_paused = true
  }
}

///|
/// Report that a follower is unreachable (etcd's ReportUnreachable). A streaming
/// follower drops back to probing so the leader stops optimistically advancing
/// into messages that are being lost.
pub fn RaftNode::report_unreachable(self : RaftNode, id : String) -> Unit {
  guard self.core.role() == Leader else { return }
  if self.progress.get(id) is Some(p) && p.state == Replicate {
    p.become_probe()
  }
}

///|
fn RaftNode::handle_append_resp(
  self : RaftNode,
  from : String,
  reply : AppendEntriesReply,
) -> Array[Message] {
  if self.core.role() != Leader {
    return []
  }
  // No higher-term guard here: `step` classifies the term first and
  // `adopt_higher_term` steps a leader down before dispatch, so this handler
  // only ever runs at the aligned term (a stale reply is dropped in `on_stale`).
  match self.progress.get(from) {
    None => []
    Some(p) => {
      p.mark_active()
      if reply.success {
        p.maybe_update(reply.match_index) |> ignore
        // A first ack promotes a prober to streaming; a streaming follower just
        // releases the acknowledged slots of its in-flight window.
        match p.state {
          Probe => p.become_replicate()
          Replicate => p.free_le(reply.match_index)
          // A snapshotting follower whose ack reconnects it to the leader's log
          // (its match is at or past the snapshot baseline) resumes streaming,
          // aborting the pending snapshot (etcd's StateSnapshot recovery).
          Snapshot =>
            if p.match_index >= self.core.snapshot_index {
              p.become_probe()
              p.become_replicate()
            }
        }
        let out : Array[Message] = []
        // A leadership-transfer target that has now caught up to the leader's
        // last index is told to time out and campaign at once (§3.10).
        if self.lead_transferee == Some(from) &&
          p.match_index == self.core.last_log_index() {
          out.push(
            Message::new(self.id, from, TimeoutNow(self.core.current_term())),
          )
        }
        if self.maybe_commit() {
          // The commit index advanced into the current term, so it is now safe
          // to answer any read-index requests that were waiting for it (etcd
          // releases pending reads here before broadcasting).
          for m in self.release_pending_read_index() {
            out.push(m)
          }
          // A new commit: tell every follower, which also carries entries to
          // any that are behind.
          for m in self.bcast_append() {
            out.push(m)
          }
          //
        } else if (
            p.match_index < self.core.last_log_index() ||
            p.can_bump_commit(self.core.commit_index)
          ) &&
          !p.is_paused() {
          // No new commit from this ack, but this follower is still behind — on
          // entries (match < last) or merely on the commit index it has been told
          // (etcd's `CanBumpCommit`, which skips a redundant commit-only MsgApp
          // when the follower already knows the latest in-flight commit). Push it
          // the next append now rather than waiting for a client proposal. The
          // follower was just marked active, so `send_to` yields a message.
          if self.send_to(from) is Some(m) {
            out.push(m)
          }
        }
        out
      } else {
        // Back off. The follower's hint is refined against the leader's own log
        // (two-sided findConflictByTerm): if it reported the term at its
        // conflict point, jump to the last leader index whose term does not
        // exceed it, skipping a whole run of superseded entries in one retry.
        // `rejected` is the probe point the follower echoed back (etcd's
        // `m.GetIndex()`, raft.go:1512), not a value re-derived from `next_index`:
        // feeding the carried index lets `maybe_decr_to`'s staleness guard
        // (`next_index - 1 != rejected`) discard a reordered reject for an entry
        // no longer in flight instead of driving a spurious back-off.
        let rejected = reply.reject_index
        let next_probe = if reply.conflict_term > 0 {
          let (idx, _) = self.core.find_conflict_by_term(
            reply.conflict_index,
            reply.conflict_term,
          )
          idx
        } else {
          reply.conflict_index
        }
        if p.maybe_decr_to(rejected, next_probe) {
          if p.state == Replicate {
            p.become_probe()
          }
          // The follower was just marked active, so `send_to` yields the retry.
          let out : Array[Message] = []
          if self.send_to(from) is Some(m) {
            out.push(m)
          }
          out
        } else {
          []
        }
      }
    }
  }
}

///|

///|
/// Whether `id` appears in none of a `ConfState`'s membership sets (voters,
/// learners, or the outgoing voters of a joint config). etcd checks these three;
/// `learners_next` need not be checked because such a peer is also in
/// `voters_outgoing` (raft.go:1888).
fn not_in_conf_state(cs : ConfState, id : String) -> Bool {
  !cs.voters.contains(id) &&
  !cs.learners.contains(id) &&
  !cs.voters_outgoing.contains(id)
}

///|
fn RaftNode::handle_snapshot(
  self : RaftNode,
  from : String,
  args : InstallSnapshotArgs,
) -> Array[Message] {
  // Defense-in-depth (etcd's `restore`, raft.go:1901): refuse a snapshot whose
  // recorded configuration does not list us at all — installing it would drop us
  // from every membership set and leave the progress tracker assuming we are
  // present. This should never happen for a correct leader, so it is only a
  // guard: the term/leader were already adopted in `step` before dispatch. A
  // refusal answers like any failed restore — an `AppendResp` at our commit
  // index (etcd's `handleSnapshot` false branch, raft.go:1852).
  if !args.conf_state.is_empty() && not_in_conf_state(args.conf_state, self.id) {
    return [self.snapshot_ack(from, self.core.commit_index)]
  }
  let before = self.core.snapshot_index
  let reply = self.core.handle_install_snapshot(args)
  if reply.term == args.term {
    self.leader_id = Some(args.leader_id)
    self.election_elapsed = 0
    self.in_pre_campaign = false
  }
  // If the snapshot was actually installed (its baseline advanced ours) and it
  // records a configuration, rebuild the live membership from it (§7) — so a
  // follower does not silently lose its voter/learner sets on restore.
  let installed = self.core.snapshot_index > before
  if installed && !args.conf_state.is_empty() {
    self.restore_conf_state(args.conf_state)
  }
  // Answer with an `AppendResp`, as etcd's `handleSnapshot` answers `MsgSnap`
  // with `MsgAppResp` (raft.go:1848/1852): the follower's last index once the
  // snapshot took, or its commit index when the snapshot was stale and left the
  // log untouched. The leader's ordinary append-response handler then folds this
  // into the follower's progress, aborting the pending snapshot on success.
  let index = if installed {
    self.core.last_log_index()
  } else {
    self.core.commit_index
  }
  [self.snapshot_ack(from, index)]
}

///|
/// The follower's answer to an InstallSnapshot: a non-rejecting `AppendResp` at
/// `index`, the shape etcd gives `handleSnapshot`'s `MsgAppResp` reply. Carrying
/// the real index (rather than a bare term) lets the leader learn where the
/// follower now stands and resume replication through the shared append path.
fn RaftNode::snapshot_ack(
  self : RaftNode,
  from : String,
  index : UInt64,
) -> Message {
  Message::new(
    self.id,
    from,
    AppendResp({
      term: self.core.current_term(),
      success: true,
      match_index: index,
      conflict_index: 0,
      conflict_term: 0,
      reject_index: 0,
    }),
  )
}