use std::{
    fs::{self, File},
    io::Read,
    path::Path,
};

use super::{
    checkpoint::CheckpointManager,
    error::WalError,
    format::{self, BatchHeader, EventRecord, HEADER_SIZE, MAGIC},
    segment::list_segments,
};

/// Result of WAL recovery: replayed events and the next usable sequence number.
pub struct RecoveryResult {
    /// Events replayed from the WAL since the last checkpoint.
    pub events: Vec<EventRecord>,
    /// The next sequence number to assign (one past the last valid event).
    pub next_seq: u64,
}

/// Recover the WAL from disk.
///
/// Reads the checkpoint (if any), then scans all segment files from the
/// checkpoint position forward. Validates each batch with two-phase
/// checking (magic + bounds, then BLAKE3).
///
/// # Corruption is only repairable in the FINAL segment
///
/// A torn or corrupt *tail* is the normal signature of a crash mid-append, and
/// it can only legitimately occur in the segment a writer was actively
/// appending to — i.e. the last (highest-`first_seq`) segment. Recovery
/// truncates that torn tail and replays the rest, exactly as before.
///
/// A tail-corrupt **non-final** segment is a different beast entirely: that
/// segment was already sealed and synced (it rotated when a higher segment was
/// created), so a missing/garbled trailing batch there is silent bit-rot, not a
/// crash artifact. Blindly truncating it and replaying the later segments would
/// produce a state with a contiguous run of *missing* sequences — silent data
/// loss in the durability-critical path, returned as a clean `Ok`. So recovery
/// surfaces it as [`WalError::Corruption`] instead, mirroring the
/// sequence-overflow handling and the session-journal corruption signal. It
/// also enforces cross-segment sequence continuity: segment N+1's first batch
/// must begin exactly where segment N ended, and a gap is likewise
/// `Corruption`. An operator then learns at `open()` that bytes were lost, rather
/// than getting a silently-truncated database.
///
/// Returns the replayed events and the next sequence number to assign.
///
/// # Errors
///
/// Returns `WalError::Io` on filesystem failure, or `WalError::Corruption`
/// if a non-final segment has a corrupt tail, if there is a gap in the
/// cross-segment sequence space, or if a forged `first_seq` overflows `u64`.
pub fn recover(dir: &Path) -> Result<RecoveryResult, WalError> {
    // WAL open is a writable path: sweep any checkpoint temp orphaned by a crash
    // between a prior write and its rename. `CheckpointManager::read` is kept
    // side-effect-free (so read-only inspection and lock-free inspectors stay
    // safe), so the open-time cleanup lives here. Best-effort: a stale temp is
    // harmless (it is never read), so a sweep failure must not block recovery.
    if let Err(e) = CheckpointManager::sweep_stale_temp(dir) {
        tracing::warn!(
            error = %e,
            "failed to sweep stale checkpoint temp during WAL recovery (continuing)"
        );
    }
    let checkpoint = CheckpointManager::read(dir)?;
    let checkpoint_seq = checkpoint.map_or(0, |(seq, _)| seq);

    let segments = list_segments(dir)?;
    if segments.is_empty() {
        return Ok(RecoveryResult {
            events: Vec::new(),
            // New events must have seq > checkpoint_seq so WAL replay (which uses
            // strict greater-than) never skips them. Start one past the checkpoint.
            // max(1) ensures seq=0 (the dedup sentinel) is never assigned.
            next_seq: checkpoint_seq.saturating_add(1).max(1),
        });
    }

    let mut all_events = Vec::new();
    // Same invariant for the loop: new events must have seq > checkpoint_seq.
    // If all segment events are at or below checkpoint_seq, this floor ensures
    // the writer still starts past the checkpoint boundary.
    let mut next_seq = checkpoint_seq.saturating_add(1).max(1);

    // We scan every segment forward rather than skipping any by filename:
    // a segment's filename `first_seq` is only its *lowest* sequence, and it may
    // hold events past the checkpoint, so the post-checkpoint filter is applied
    // per event below (`event_seq > checkpoint_seq`), not per segment.
    //
    // Cross-segment continuity: each non-first segment's first batch must begin
    // exactly where the previous segment's last event ended. `expected_first_seq`
    // tracks that boundary; a mismatch is a gap in the durable sequence space and
    // is surfaced as `Corruption` rather than silently replayed past.
    let last_idx = segments.len() - 1;
    let mut expected_first_seq: Option<u64> = None;
    for (idx, (_seg_first_seq, seg_path)) in segments.iter().enumerate() {
        let is_final = idx == last_idx;

        // Scan WITHOUT mutating first, so we can decide truncate-vs-error from
        // the tail-corruption flag before touching the file. Only the final
        // segment's torn tail is a legitimate crash artifact we may repair.
        let summary = scan_segment_summary(seg_path)?;
        if summary.tail_corrupt {
            if is_final {
                // Crash mid-append on the live segment: repair the torn tail in
                // place (single-threaded recovery owns the dir here) and keep
                // the valid batches we already scanned.
                recover_segment(seg_path)?;
            } else {
                // A sealed, already-synced segment with a corrupt tail is silent
                // bit-rot, NOT a crash artifact. Truncating it would drop a
                // contiguous run of sequences and leave a gap; surface it loudly.
                return Err(WalError::Corruption {
                    message: format!(
                        "non-final WAL segment {} has a corrupt/truncated tail; \
                         truncating it would silently drop sequences covered by \
                         later segments — refusing to recover past the gap",
                        seg_path.display()
                    ),
                });
            }
        }

        for (header, events) in summary.batches {
            // Enforce cross-segment continuity at the FIRST batch of each
            // segment after the first: it must begin where the previous segment
            // ended. A gap means sequences were lost between segments.
            if let Some(expected) = expected_first_seq.take()
                && header.first_seq != expected
            {
                return Err(WalError::Corruption {
                    message: format!(
                        "WAL sequence gap at segment {}: expected first_seq={expected}, \
                         found {} — a contiguous run of sequences is missing",
                        seg_path.display(),
                        header.first_seq
                    ),
                });
            }

            for (i, event) in events.into_iter().enumerate() {
                // `header.first_seq` is read from on-disk segment bytes, which
                // may be corruption-controlled. Adding the in-batch index with
                // checked arithmetic prevents a silent wrap (release) or panic
                // (debug) on a forged `first_seq` near `u64::MAX`. Overflow here
                // means the sequence space is corrupt in a way truncation cannot
                // repair, so we surface it as `Corruption` rather than guess.
                let event_seq =
                    header
                        .first_seq
                        .checked_add(i as u64)
                        .ok_or_else(|| WalError::Corruption {
                            message: format!(
                                "sequence overflow: first_seq={} + index={} exceeds u64::MAX",
                                header.first_seq, i
                            ),
                        })?;
                // Only replay events strictly after the checkpoint boundary.
                // checkpoint_seq is the last sequence that was materialized
                // to durable storage — it must not be replayed again.
                if event_seq > checkpoint_seq {
                    all_events.push(event);
                }
                // Track the highest sequence number seen. Saturate so a corrupt
                // `event_seq == u64::MAX` cannot wrap the next-seq floor to 0.
                let candidate = event_seq.saturating_add(1);
                if candidate > next_seq {
                    next_seq = candidate;
                }
                // The next event/segment must continue from one past this one.
                expected_first_seq = Some(candidate);
            }
        }
    }

    Ok(RecoveryResult {
        events: all_events,
        next_seq,
    })
}

/// Scan all WAL segment files and return every event record.
///
/// Unlike [`recover`], this reads ALL events regardless of the checkpoint
/// boundary. Used for RLHF export and offline analysis.
///
/// This is a strictly read-only path: it uses [`scan_segment_readonly`] and
/// never truncates a segment, so it is safe to call concurrently with the live
/// writer thread. A torn tail in the active segment is simply ignored (its
/// records are not returned), never repaired here — torn-tail repair only ever
/// happens during single-threaded [`recover`] at open time.
///
/// # Errors
///
/// Returns `WalError::Io` if a segment file cannot be opened.
pub fn read_all_events(dir: &Path) -> Result<Vec<EventRecord>, WalError> {
    let segments = list_segments(dir)?;
    let mut all_events = Vec::new();
    for (_seq, path) in &segments {
        for (_header, events) in scan_segment_readonly(path)? {
            all_events.extend(events);
        }
    }
    Ok(all_events)
}

/// Decoded contents of a single segment scan.
///
/// `last_valid_offset` is the byte offset one past the last fully decoded
/// batch; `total_len` is the file's size on disk. When `last_valid_offset <
/// total_len` the segment has a corrupted or torn tail that recovery (and only
/// recovery) is allowed to truncate away.
struct SegmentScan {
    batches: Vec<(BatchHeader, Vec<EventRecord>)>,
    last_valid_offset: usize,
    total_len: usize,
}

/// Decode a single segment file WITHOUT mutating it.
///
/// Reads the file, validates each batch with two-phase checking (magic +
/// bounds, then BLAKE3), and returns every fully valid batch plus the offset of
/// the first byte past them. A corrupted or truncated tail stops the scan but
/// is left untouched on disk — this function has no side effects and is safe to
/// call from read-only paths (export, diagnostics) and concurrently with the
/// live writer thread.
fn scan_segment_inner(path: &Path) -> Result<SegmentScan, WalError> {
    let mut file = File::open(path)?;
    let file_len = file.metadata()?.len();

    let mut data = Vec::new();
    file.read_to_end(&mut data)?;
    drop(file);

    let mut batches = Vec::new();
    let mut offset: usize = 0;
    let mut last_valid_offset: usize = 0;

    while offset < data.len() {
        // Phase 1: Can we read a header?
        if data.len() - offset < HEADER_SIZE {
            tracing::warn!(
                path = %path.display(),
                offset,
                remaining = data.len() - offset,
                "truncated header at end of segment"
            );
            break;
        }

        // Phase 1: Check magic bytes
        if data[offset..offset + 4] != MAGIC {
            tracing::warn!(
                path = %path.display(),
                offset,
                "invalid magic bytes, stopping scan"
            );
            break;
        }

        // Phase 1: Read payload_len to check bounds. Use the format module's
        // owned header accessor so this scanner never re-hardcodes the field
        // offset and can never drift from the on-disk header layout.
        let payload_len = format::payload_len_from_header(&data[offset..])? as usize;

        let batch_end = offset + HEADER_SIZE + payload_len;
        if batch_end > data.len() {
            tracing::warn!(
                path = %path.display(),
                offset,
                payload_len,
                file_len,
                "truncated payload, stopping scan"
            );
            break;
        }

        // Phase 2: Full decode with BLAKE3 verification
        match format::decode_batch(&data[offset..batch_end]) {
            Ok((header, events)) => {
                batches.push((header, events));
                last_valid_offset = batch_end;
                offset = batch_end;
            }
            Err(e) => {
                tracing::warn!(
                    path = %path.display(),
                    offset,
                    error = %e,
                    "batch decode failed, stopping scan"
                );
                break;
            }
        }
    }

    Ok(SegmentScan {
        batches,
        last_valid_offset,
        total_len: data.len(),
    })
}

/// Scan a single segment file read-only, returning all valid batches.
///
/// Decodes the segment with the same two-phase validation as recovery but never
/// mutates the file: a corrupted or torn tail is ignored (its records are not
/// returned) and left on disk untouched. Use this from any read path (RLHF
/// export, offline analysis, diagnostics) and from contexts that may run
/// concurrently with the live writer thread.
pub(crate) fn scan_segment_readonly(
    path: &Path,
) -> Result<Vec<(BatchHeader, Vec<EventRecord>)>, WalError> {
    Ok(scan_segment_inner(path)?.batches)
}

/// Read-only scan of one segment, plus whether its tail was corrupt/torn.
///
/// This is the diagnostic-facing view of [`scan_segment_inner`]: it carries the
/// same fully-validated batches that [`recover`] would replay, plus a
/// `tail_corrupt` flag set when the scan stopped before the end of the file (a
/// torn or corrupt trailing batch). Diagnostics derive their per-segment counts
/// from this single shared scan rather than re-implementing the
/// header-bounds-then-decode loop, so the two can never drift on the on-disk
/// batch framing (they previously diverged on the `>=` vs `>` replay predicate).
pub(crate) struct SegmentScanSummary {
    /// Every fully valid batch, in file order — identical to what [`recover`]
    /// would replay (modulo the checkpoint filter the caller applies).
    pub batches: Vec<(BatchHeader, Vec<EventRecord>)>,
    /// `true` when the scan stopped on a corrupt/truncated trailing batch
    /// (i.e. valid bytes ended before the file did). The diagnostic counts this
    /// as exactly one corrupt batch, matching the single break-on-corruption the
    /// recovery scan performs per segment.
    pub tail_corrupt: bool,
}

/// Scan a single segment read-only and summarize it for diagnostics.
///
/// Thin adapter over [`scan_segment_inner`] that exposes the batches and the
/// tail-corruption flag without re-parsing bytes. Used by `diagnose_wal` so the
/// diagnostic counts come from the exact code path recovery uses.
pub(crate) fn scan_segment_summary(path: &Path) -> Result<SegmentScanSummary, WalError> {
    let scan = scan_segment_inner(path)?;
    Ok(SegmentScanSummary {
        tail_corrupt: scan.last_valid_offset < scan.total_len,
        batches: scan.batches,
    })
}

/// Scan a single segment file and repair a torn tail.
///
/// Returns all valid batches and, on encountering a corrupted or truncated
/// batch, truncates the file to the last valid offset and `sync_all`s it. This
/// handles torn writes from crashes and so MUST only ever be called from the
/// single-threaded [`recover`] path at open time, before the writer thread is
/// spawned — never from a read path or while the writer is running.
pub(crate) fn recover_segment(
    path: &Path,
) -> Result<Vec<(BatchHeader, Vec<EventRecord>)>, WalError> {
    let scan = scan_segment_inner(path)?;

    // Truncate the file to remove any corrupted tail.
    if scan.last_valid_offset < scan.total_len {
        tracing::warn!(
            path = %path.display(),
            valid_bytes = scan.last_valid_offset,
            total_bytes = scan.total_len,
            "truncating corrupted tail"
        );
        let file = fs::OpenOptions::new().write(true).open(path)?;
        file.set_len(scan.last_valid_offset as u64)?;
        file.sync_all()?;
    }

    Ok(scan.batches)
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::{
        super::format::{EventRecord, encode_batch},
        *,
    };
    use crate::replication::ShardId;

    fn sample_event(id: u64, ts: u64) -> EventRecord {
        EventRecord::signal(id, 1, 1.0, ts)
    }

    #[test]
    fn recover_empty_dir() {
        let dir = tempfile::tempdir().expect("tempdir creation should succeed");
        let result = recover(dir.path()).expect("recover should succeed");
        assert!(result.events.is_empty());
        // No checkpoint, no segments: next_seq starts at 1 (seq=0 is the dedup sentinel).
        assert_eq!(result.next_seq, 1);
    }

    #[test]
    fn recover_single_batch() {
        let dir = tempfile::tempdir().expect("tempdir creation should succeed");
        let events = vec![sample_event(1, 1000), sample_event(2, 2000)];
        let batch_bytes = encode_batch(&events, 1, 1000).expect("encode should succeed");

        let seg_name = super::super::segment::segment_filename(ShardId::SINGLE, 1);
        fs::write(dir.path().join(seg_name), &batch_bytes).expect("write should succeed");

        let result = recover(dir.path()).expect("recover should succeed");
        assert_eq!(result.events.len(), 2);
        assert_eq!(result.next_seq, 3); // first_seq=1, 2 events -> next=3
    }

    #[test]
    fn recover_multiple_batches() {
        let dir = tempfile::tempdir().expect("tempdir creation should succeed");

        let events1 = vec![sample_event(1, 1000)];
        let events2 = vec![sample_event(2, 2000)];

        let batch1 = encode_batch(&events1, 1, 1000).expect("encode should succeed");
        let batch2 = encode_batch(&events2, 2, 2000).expect("encode should succeed");

        let mut segment_data = batch1;
        segment_data.extend_from_slice(&batch2);

        let seg_name = super::super::segment::segment_filename(ShardId::SINGLE, 1);
        fs::write(dir.path().join(seg_name), &segment_data).expect("write should succeed");

        let result = recover(dir.path()).expect("recover should succeed");
        assert_eq!(result.events.len(), 2);
        assert_eq!(result.next_seq, 3);
    }

    #[test]
    fn recover_truncated_tail() {
        let dir = tempfile::tempdir().expect("tempdir creation should succeed");

        let events = vec![sample_event(1, 1000)];
        let batch_bytes = encode_batch(&events, 1, 1000).expect("encode should succeed");

        // Write a valid batch followed by garbage (simulating a torn write)
        let mut data = batch_bytes.clone();
        data.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11]);

        let seg_name = super::super::segment::segment_filename(ShardId::SINGLE, 1);
        let seg_path = dir.path().join(&seg_name);
        fs::write(&seg_path, &data).expect("write should succeed");

        let result = recover(dir.path()).expect("recover should succeed");
        assert_eq!(result.events.len(), 1);
        assert_eq!(result.next_seq, 2);

        // Verify file was truncated to valid length
        let file_len = fs::metadata(&seg_path)
            .expect("metadata should succeed")
            .len();
        assert_eq!(file_len, batch_bytes.len() as u64);
    }

    #[test]
    fn recover_with_checkpoint_skips_old_events() {
        let dir = tempfile::tempdir().expect("tempdir creation should succeed");

        // Write a checkpoint at seq=4 (events through seq=4 are materialized; 5 and 6 need replay)
        CheckpointManager::write(dir.path(), 4, 4000).expect("write should succeed");

        // Batch 1: events at seq 1..=3
        let events1: Vec<EventRecord> = (1..=3).map(|i| sample_event(i, i * 1000)).collect();
        let batch1 = encode_batch(&events1, 1, 1000).expect("encode should succeed");

        // Batch 2: events at seq 4..=6
        let events2: Vec<EventRecord> = (4..=6).map(|i| sample_event(i, i * 1000)).collect();
        let batch2 = encode_batch(&events2, 4, 4000).expect("encode should succeed");

        let mut segment_data = batch1;
        segment_data.extend_from_slice(&batch2);

        let seg_name = super::super::segment::segment_filename(ShardId::SINGLE, 1);
        fs::write(dir.path().join(seg_name), &segment_data).expect("write should succeed");

        let result = recover(dir.path()).expect("recover should succeed");
        // Events at seq 5 and 6 should be replayed (> checkpoint_seq=4)
        assert_eq!(result.events.len(), 2);
        assert_eq!(result.events[0].entity_id, 5);
        assert_eq!(result.events[1].entity_id, 6);
        assert_eq!(result.next_seq, 7);
    }

    #[test]
    fn recover_sequence_overflow_is_corruption_not_panic() {
        // A corruption-controlled `first_seq` near u64::MAX, combined with an
        // in-batch index, must not silently wrap (release) or panic (debug).
        // The reader surfaces a clean `Corruption` error instead.
        let dir = tempfile::tempdir().expect("tempdir creation should succeed");

        // first_seq = u64::MAX with 2 events: index 1 overflows u64.
        let events = vec![sample_event(1, 1000), sample_event(2, 2000)];
        let batch_bytes = encode_batch(&events, u64::MAX, 1000).expect("encode should succeed");

        let seg_name = super::super::segment::segment_filename(ShardId::SINGLE, 1);
        fs::write(dir.path().join(seg_name), &batch_bytes).expect("write should succeed");

        let result = recover(dir.path());
        match result {
            Err(WalError::Corruption { message }) => {
                assert!(
                    message.contains("sequence overflow"),
                    "unexpected corruption message: {message}"
                );
            }
            _ => panic!("expected Corruption on sequence overflow, got a non-Corruption result"),
        }
    }

    #[test]
    fn recover_corrupted_batch_in_middle() {
        let dir = tempfile::tempdir().expect("tempdir creation should succeed");

        let events1 = vec![sample_event(1, 1000)];
        let events2 = vec![sample_event(2, 2000)];

        let batch1 = encode_batch(&events1, 1, 1000).expect("encode should succeed");
        let mut batch2 = encode_batch(&events2, 2, 2000).expect("encode should succeed");
        // Corrupt batch2's payload
        batch2[HEADER_SIZE + 5] ^= 0xFF;

        let mut segment_data = batch1;
        segment_data.extend_from_slice(&batch2);

        let seg_name = super::super::segment::segment_filename(ShardId::SINGLE, 1);
        fs::write(dir.path().join(seg_name), &segment_data).expect("write should succeed");

        let result = recover(dir.path()).expect("recover should succeed");
        // Only batch1 is valid
        assert_eq!(result.events.len(), 1);
        assert_eq!(result.next_seq, 2);
    }

    #[test]
    fn recover_empty_segment_file() {
        let dir = tempfile::tempdir().expect("tempdir creation should succeed");
        let seg_name = super::super::segment::segment_filename(ShardId::SINGLE, 1);
        fs::write(dir.path().join(seg_name), []).expect("write should succeed");

        let result = recover(dir.path()).expect("recover should succeed");
        assert!(result.events.is_empty());
        // No checkpoint, empty segment: next_seq starts at 1 (seq=0 is the dedup sentinel).
        assert_eq!(result.next_seq, 1);
    }

    #[test]
    fn scan_segment_readonly_does_not_truncate_torn_tail() {
        let dir = tempfile::tempdir().expect("tempdir creation should succeed");

        let events = vec![sample_event(1, 1000)];
        let batch_bytes = encode_batch(&events, 1, 1000).expect("encode should succeed");

        // Valid batch followed by garbage (a torn write).
        let mut data = batch_bytes;
        data.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11]);

        let seg_name = super::super::segment::segment_filename(ShardId::SINGLE, 1);
        let seg_path = dir.path().join(&seg_name);
        fs::write(&seg_path, &data).expect("write should succeed");

        // Read-only scan returns only the valid batch but MUST NOT touch the file.
        let batches = scan_segment_readonly(&seg_path).expect("read-only scan should succeed");
        assert_eq!(batches.len(), 1);
        assert_eq!(batches[0].1.len(), 1);

        let file_len_after = fs::metadata(&seg_path)
            .expect("metadata should succeed")
            .len();
        assert_eq!(
            file_len_after,
            data.len() as u64,
            "read-only scan must leave the torn tail intact on disk"
        );
    }

    #[test]
    fn recover_segment_truncates_torn_tail() {
        let dir = tempfile::tempdir().expect("tempdir creation should succeed");

        let events = vec![sample_event(1, 1000)];
        let batch_bytes = encode_batch(&events, 1, 1000).expect("encode should succeed");

        // Valid batch followed by garbage (a torn write).
        let mut data = batch_bytes.clone();
        data.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11]);

        let seg_name = super::super::segment::segment_filename(ShardId::SINGLE, 1);
        let seg_path = dir.path().join(&seg_name);
        fs::write(&seg_path, &data).expect("write should succeed");

        let batches = recover_segment(&seg_path).expect("recover_segment should succeed");
        assert_eq!(batches.len(), 1);

        // Recovery repairs the torn tail in place.
        let file_len_after = fs::metadata(&seg_path)
            .expect("metadata should succeed")
            .len();
        assert_eq!(
            file_len_after,
            batch_bytes.len() as u64,
            "recover_segment must truncate the torn tail to the last valid offset"
        );
    }

    #[test]
    fn recover_multiple_segments() {
        let dir = tempfile::tempdir().expect("tempdir creation should succeed");

        let events1 = vec![sample_event(1, 1000)];
        let events2 = vec![sample_event(2, 2000)];

        let batch1 = encode_batch(&events1, 1, 1000).expect("encode should succeed");
        let batch2 = encode_batch(&events2, 2, 2000).expect("encode should succeed");

        let seg1 = super::super::segment::segment_filename(ShardId::SINGLE, 1);
        let seg2 = super::super::segment::segment_filename(ShardId::SINGLE, 2);
        fs::write(dir.path().join(seg1), &batch1).expect("write should succeed");
        fs::write(dir.path().join(seg2), &batch2).expect("write should succeed");

        let result = recover(dir.path()).expect("recover should succeed");
        assert_eq!(result.events.len(), 2);
        assert_eq!(result.next_seq, 3);
    }

    #[test]
    fn recover_corrupt_tail_in_non_final_segment_is_corruption_not_silent_loss() {
        // C17 regression: a SEALED (non-final) segment with a corrupt/truncated
        // tail is silent bit-rot, not a crash artifact. Truncating it and
        // replaying the later segment would drop a contiguous run of sequences
        // and return a clean `Ok` — exactly the silent data loss this guards
        // against. recover() must surface it as `Corruption` instead, AND must
        // NOT truncate the sealed segment on disk.
        let dir = tempfile::tempdir().expect("tempdir creation should succeed");

        // Segment 1 (will be NON-final): a valid batch + garbage tail.
        let events1 = vec![sample_event(1, 1000)];
        let batch1 = encode_batch(&events1, 1, 1000).expect("encode should succeed");
        let mut seg1_data = batch1;
        seg1_data.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11]);
        let seg1 = super::super::segment::segment_filename(ShardId::SINGLE, 1);
        let seg1_path = dir.path().join(&seg1);
        fs::write(&seg1_path, &seg1_data).expect("write should succeed");

        // Segment 2 (final): a clean, valid batch at a higher first_seq.
        let events2 = vec![sample_event(2, 2000)];
        let batch2 = encode_batch(&events2, 2, 2000).expect("encode should succeed");
        let seg2 = super::super::segment::segment_filename(ShardId::SINGLE, 2);
        fs::write(dir.path().join(seg2), &batch2).expect("write should succeed");

        let result = recover(dir.path());
        match result {
            Err(WalError::Corruption { message }) => {
                assert!(
                    message.contains("non-final") || message.contains("corrupt"),
                    "unexpected corruption message: {message}"
                );
            }
            other => panic!(
                "expected Corruption for a tail-corrupt non-final segment, got: {:?}",
                other.map(|r| r.events.len())
            ),
        }

        // The sealed non-final segment must be left intact on disk — recovery
        // refused, so it must not have silently truncated the bit-rotted bytes.
        let seg1_len_after = fs::metadata(&seg1_path)
            .expect("metadata should succeed")
            .len();
        assert_eq!(
            seg1_len_after,
            seg1_data.len() as u64,
            "a non-final corrupt segment must NOT be truncated by recover()"
        );
    }

    #[test]
    fn recover_cross_segment_sequence_gap_is_corruption() {
        // Two intact segments, but segment 2 begins at a first_seq that leaves a
        // hole after segment 1's last event. A contiguous run of sequences is
        // missing between them; recover() must surface a gap rather than replay
        // past it as if nothing were lost.
        let dir = tempfile::tempdir().expect("tempdir creation should succeed");

        // Segment 1: one event at seq 1 (so it ends expecting next_seq=2).
        let batch1 = encode_batch(&[sample_event(1, 1000)], 1, 1000).expect("encode");
        let seg1 = super::super::segment::segment_filename(ShardId::SINGLE, 1);
        fs::write(dir.path().join(seg1), &batch1).expect("write should succeed");

        // Segment 2: starts at seq 5, skipping seqs 2,3,4 entirely.
        let batch2 = encode_batch(&[sample_event(5, 5000)], 5, 5000).expect("encode");
        let seg2 = super::super::segment::segment_filename(ShardId::SINGLE, 5);
        fs::write(dir.path().join(seg2), &batch2).expect("write should succeed");

        match recover(dir.path()) {
            Err(WalError::Corruption { message }) => {
                assert!(
                    message.contains("gap") || message.contains("missing"),
                    "unexpected corruption message: {message}"
                );
            }
            other => panic!(
                "expected Corruption for a cross-segment sequence gap, got: {:?}",
                other.map(|r| r.events.len())
            ),
        }
    }

    #[test]
    fn recover_two_contiguous_segments_with_torn_final_tail_succeeds() {
        // The complement of the C17 case: when the torn tail is in the FINAL
        // (live) segment it IS a legitimate crash artifact, so recovery repairs
        // it and replays the contiguous prefix. This keeps the legitimate
        // crash-mid-append path working after the non-final guard was added.
        let dir = tempfile::tempdir().expect("tempdir creation should succeed");

        let batch1 = encode_batch(&[sample_event(1, 1000)], 1, 1000).expect("encode");
        let seg1 = super::super::segment::segment_filename(ShardId::SINGLE, 1);
        fs::write(dir.path().join(seg1), &batch1).expect("write should succeed");

        // Final segment: valid batch at seq 2 + torn tail.
        let batch2 = encode_batch(&[sample_event(2, 2000)], 2, 2000).expect("encode");
        let mut seg2_data = batch2.clone();
        seg2_data.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);
        let seg2 = super::super::segment::segment_filename(ShardId::SINGLE, 2);
        let seg2_path = dir.path().join(&seg2);
        fs::write(&seg2_path, &seg2_data).expect("write should succeed");

        let result = recover(dir.path()).expect("recover should succeed");
        assert_eq!(result.events.len(), 2);
        assert_eq!(result.next_seq, 3);

        // The final segment's torn tail was repaired in place.
        let seg2_len_after = fs::metadata(&seg2_path)
            .expect("metadata should succeed")
            .len();
        assert_eq!(seg2_len_after, batch2.len() as u64);
    }
}
