//! WAL diagnostic inspection for crash recovery analysis.
//!
//! Provides dry-run WAL inspection that reads WAL state without writing
//! any changes. Used by `tidalctl recover --verify-only` to report
//! WAL health after a crash.

use std::path::Path;

use super::{
    checkpoint::CheckpointManager, error::WalError, format::decode_session_events_with_diagnostics,
    reader, segment, session_journal::SESSION_JOURNAL_FILENAME,
};

/// Assumed WAL replay throughput, in events per second, used to estimate
/// recovery time in [`diagnose_wal`].
///
/// Drawn from the WAL replay benchmarks (sequential batch decode + BLAKE3
/// verification + in-memory ledger apply on a single thread). It is a coarse
/// planning figure for `tidalctl recover --verify-only`, not a hard SLA: actual
/// throughput varies with batch size, event payload, and storage. Update this
/// alongside the replay benchmarks if the steady-state number moves materially.
const REPLAY_EVENTS_PER_SEC: f64 = 100_000.0;

/// Fixed overhead, in seconds, added to the per-event replay estimate to account
/// for checkpoint restore (reading + applying the last durable signal snapshot)
/// before WAL replay begins. A coarse constant, not a measured-per-run value.
const CHECKPOINT_RESTORE_SECS: f64 = 5.0;

/// Diagnostic report from a dry-run WAL inspection.
#[derive(Debug)]
pub struct WalDiagnosticReport {
    /// Total number of valid signal events found in WAL segments.
    pub total_events: u64,
    /// Number of events that would be replayed (after checkpoint filtering).
    pub replay_events: u64,
    /// Last WAL sequence number seen in any segment (one past last event).
    pub last_wal_seq: u64,
    /// Checkpoint sequence number (from `checkpoint.meta`), or 0 if none.
    pub checkpoint_seq: u64,
    /// Checkpoint timestamp (nanos), or 0 if none.
    pub checkpoint_ts: u64,
    /// Number of WAL segment files.
    pub segment_count: usize,
    /// Total bytes across all segment files.
    pub total_segment_bytes: u64,
    /// Number of corrupted or truncated batches encountered.
    pub inconsistency_count: u64,
    /// Per-segment summary.
    pub segments: Vec<SegmentSummary>,
    /// Estimated recovery time in seconds (based on event count heuristic).
    pub estimated_recovery_secs: f64,
    /// Session-journal (`sessions.log`) health summary.
    pub session_journal: SessionJournalSummary,
}

/// Health summary for the session journal (`sessions.log`).
///
/// The session journal is a separate append-only file from the signal WAL
/// segments, so its corruption is tracked independently. A corrupted session
/// record is a checksum-mismatched (or malformed) frame that halts replay;
/// a clean torn tail is normal after a crash and is NOT counted here.
#[derive(Debug, Default)]
pub struct SessionJournalSummary {
    /// Whether `sessions.log` exists in the WAL directory.
    pub present: bool,
    /// Size of `sessions.log` in bytes (0 if absent).
    pub file_size: u64,
    /// Number of valid session events recovered before any stop point.
    pub event_count: u64,
    /// Number of corrupted (checksum-mismatched / malformed) session records
    /// that halted replay. Excludes clean torn-tail truncations.
    pub corrupt_records: u64,
}

/// Summary of a single WAL segment file.
#[derive(Debug)]
pub struct SegmentSummary {
    /// First sequence number in this segment.
    pub first_seq: u64,
    /// File size in bytes.
    pub file_size: u64,
    /// Number of valid batches.
    pub batch_count: u64,
    /// Number of valid events.
    pub event_count: u64,
    /// Number of corrupted batches.
    pub corrupt_batches: u64,
}

/// Perform a dry-run WAL inspection and produce a diagnostic report.
///
/// Reads WAL segments and the checkpoint file but writes nothing.
/// Safe to run against a live database directory (read-only).
///
/// # Errors
///
/// Returns `WalError::Io` on filesystem failure.
#[allow(clippy::too_many_lines)]
pub fn diagnose_wal(data_dir: &Path) -> Result<WalDiagnosticReport, WalError> {
    let wal_dir = data_dir.join("wal");

    // If the wal directory doesn't exist, return an empty report.
    if !wal_dir.exists() {
        return Ok(WalDiagnosticReport {
            total_events: 0,
            replay_events: 0,
            last_wal_seq: 0,
            checkpoint_seq: 0,
            checkpoint_ts: 0,
            segment_count: 0,
            total_segment_bytes: 0,
            inconsistency_count: 0,
            segments: Vec::new(),
            estimated_recovery_secs: 0.0,
            session_journal: SessionJournalSummary::default(),
        });
    }

    // Read checkpoint (read-only).
    let checkpoint = CheckpointManager::read(&wal_dir)?;
    let (checkpoint_seq, checkpoint_ts) = checkpoint.unwrap_or((0, 0));

    // List and scan segments (read-only).
    let segment_list = segment::list_segments(&wal_dir)?;
    let segment_count = segment_list.len();
    let mut total_events = 0u64;
    let mut replay_events = 0u64;
    let mut last_wal_seq = 0u64;
    let mut inconsistency_count = 0u64;
    let mut total_segment_bytes = 0u64;
    let mut segments = Vec::with_capacity(segment_count);

    for (seg_first_seq, seg_path) in &segment_list {
        let file_size = std::fs::metadata(seg_path).map(|m| m.len()).unwrap_or(0);
        total_segment_bytes += file_size;

        // Scan through the SAME read-only segment scanner recovery uses, so the
        // diagnostic can never drift from the recovery code path on the on-disk
        // batch framing (they previously diverged on the `>=` vs `>` replay
        // predicate). We derive counts from its output rather than re-parsing
        // header bytes here.
        let summary = reader::scan_segment_summary(seg_path)?;

        let mut batch_count = 0u64;
        let mut event_count = 0u64;
        // A torn/corrupt trailing batch stops recovery's scan exactly once per
        // segment; mirror that single count here.
        let mut corrupt_batches = u64::from(summary.tail_corrupt);
        let mut seg_first_seq_seen: Option<u64> = None;

        for (header, events) in &summary.batches {
            batch_count += 1;
            let n = events.len() as u64;
            event_count += n;
            total_events += n;

            if seg_first_seq_seen.is_none() {
                seg_first_seq_seen = Some(header.first_seq);
            }

            for i in 0..events.len() {
                // `first_seq` is read from possibly-damaged on-disk bytes, so the
                // arithmetic is checked. A wrapped `first_seq + i` would silently
                // mis-attribute events in release and panic in debug; on overflow
                // we flag corruption and stop attributing seqs for this batch
                // rather than producing nonsense counts. (`recover` surfaces the
                // same overflow as a hard `Corruption` error; the diagnostic stays
                // tolerant, records it, and moves on to the next batch.)
                let Some(event_seq) = header.first_seq.checked_add(i as u64) else {
                    corrupt_batches += 1;
                    inconsistency_count += 1;
                    break;
                };
                // Only events strictly after the checkpoint are replayed,
                // matching `reader::recover` (strict greater-than) — using
                // `>=` here would over-count the checkpoint event by one.
                if event_seq > checkpoint_seq {
                    replay_events += 1;
                }
                // `last_wal_seq` is "one past the last event"; saturate so a
                // corrupt `event_seq == u64::MAX` cannot wrap to 0.
                let candidate = event_seq.saturating_add(1);
                if candidate > last_wal_seq {
                    last_wal_seq = candidate;
                }
            }
        }

        // A torn/corrupt tail is one inconsistency per segment.
        inconsistency_count += u64::from(summary.tail_corrupt);

        // Use the first_seq from the filename (segment list), falling back
        // to what we actually saw in the data.
        let reported_first_seq = seg_first_seq_seen.unwrap_or(*seg_first_seq);

        segments.push(SegmentSummary {
            first_seq: reported_first_seq,
            file_size,
            batch_count,
            event_count,
            corrupt_batches,
        });
    }

    // Estimate recovery time from the post-checkpoint replay event count at the
    // benchmarked replay throughput, plus a fixed checkpoint-restore overhead.
    #[allow(clippy::cast_precision_loss)]
    let replay_secs = replay_events as f64 / REPLAY_EVENTS_PER_SEC;
    let estimated_recovery_secs = replay_secs + CHECKPOINT_RESTORE_SECS;

    // Scan the session journal (read-only) for corruption alongside the WAL.
    let session_journal = diagnose_session_journal(&wal_dir);
    // Roll session-journal corruption into the overall inconsistency count so a
    // single `--verify-only` glance surfaces it.
    inconsistency_count += session_journal.corrupt_records;

    Ok(WalDiagnosticReport {
        total_events,
        replay_events,
        last_wal_seq,
        checkpoint_seq,
        checkpoint_ts,
        segment_count,
        total_segment_bytes,
        inconsistency_count,
        segments,
        estimated_recovery_secs,
        session_journal,
    })
}

/// Inspect the session journal (`sessions.log`) for corruption (read-only).
///
/// Returns an empty/default summary if the journal is absent or unreadable
/// (non-fatal — the signal WAL diagnostics are still valid). A checksum
/// mismatch or malformed frame is reported as `corrupt_records`; a clean torn
/// tail is not.
fn diagnose_session_journal(wal_dir: &Path) -> SessionJournalSummary {
    let journal_path = wal_dir.join(SESSION_JOURNAL_FILENAME);
    if !journal_path.exists() {
        return SessionJournalSummary::default();
    }
    let file_size = std::fs::metadata(&journal_path)
        .map(|m| m.len())
        .unwrap_or(0);
    let Ok(bytes) = std::fs::read(&journal_path) else {
        // Present but unreadable -- record presence + size, no event scan.
        return SessionJournalSummary {
            present: true,
            file_size,
            event_count: 0,
            corrupt_records: 0,
        };
    };
    let outcome = decode_session_events_with_diagnostics(&bytes);
    let event_count = outcome.events.len() as u64;
    SessionJournalSummary {
        present: true,
        file_size,
        event_count,
        corrupt_records: outcome.corruption_count,
    }
}

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

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

    #[test]
    fn diagnose_empty_dir() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join("wal")).unwrap();

        let report = diagnose_wal(dir.path()).unwrap();
        assert_eq!(report.total_events, 0);
        assert_eq!(report.segment_count, 0);
        assert_eq!(report.checkpoint_seq, 0);
        assert_eq!(report.inconsistency_count, 0);
    }

    #[test]
    fn diagnose_missing_wal_dir() {
        let dir = tempfile::tempdir().unwrap();
        // No wal subdirectory -- should return empty report, not error.
        let report = diagnose_wal(dir.path()).unwrap();
        assert_eq!(report.total_events, 0);
        assert_eq!(report.segment_count, 0);
    }

    #[test]
    fn diagnose_single_segment() {
        let dir = tempfile::tempdir().unwrap();
        let wal_dir = dir.path().join("wal");
        std::fs::create_dir_all(&wal_dir).unwrap();

        let events = vec![sample_event(1), sample_event(2), sample_event(3)];
        let batch = encode_batch(&events, 1, 1000).unwrap();
        let seg_name = segment_filename(ShardId::SINGLE, 1);
        std::fs::write(wal_dir.join(seg_name), &batch).unwrap();

        let report = diagnose_wal(dir.path()).unwrap();
        assert_eq!(report.total_events, 3);
        assert_eq!(report.replay_events, 3); // no checkpoint, all replayed
        assert_eq!(report.segment_count, 1);
        assert_eq!(report.inconsistency_count, 0);
        assert_eq!(report.segments[0].event_count, 3);
        assert_eq!(report.segments[0].batch_count, 1);
    }

    #[test]
    fn diagnose_with_checkpoint() {
        let dir = tempfile::tempdir().unwrap();
        let wal_dir = dir.path().join("wal");
        std::fs::create_dir_all(&wal_dir).unwrap();

        // Write checkpoint at seq=2.
        CheckpointManager::write(&wal_dir, 2, 2000).unwrap();

        // Write events at seq 1, 2, 3 (batch with first_seq=1, 3 events).
        let events = vec![sample_event(1), sample_event(2), sample_event(3)];
        let batch = encode_batch(&events, 1, 1000).unwrap();
        std::fs::write(wal_dir.join(segment_filename(ShardId::SINGLE, 1)), &batch).unwrap();

        let report = diagnose_wal(dir.path()).unwrap();
        assert_eq!(report.total_events, 3);
        // replay_events must match `reader::recover` exactly: only events with
        // event_seq > checkpoint_seq=2 are replayed, i.e. just seq 3 = 1 event.
        // The checkpoint event (seq 2) is already materialized and is NOT
        // replayed; counting it (the old `>=` bug) over-stated recovery work.
        assert_eq!(report.replay_events, 1);
        assert_eq!(report.checkpoint_seq, 2);
        assert_eq!(report.checkpoint_ts, 2000);
    }

    #[test]
    fn diagnose_replay_count_matches_recovery() {
        // The diagnostic replay-event count must equal exactly what
        // `reader::recover` would actually replay; any drift (e.g. the old
        // `>=` vs `>` mismatch) mis-reports recovery work to the operator.
        use crate::wal::reader;

        let dir = tempfile::tempdir().unwrap();
        let wal_dir = dir.path().join("wal");
        std::fs::create_dir_all(&wal_dir).unwrap();

        // Checkpoint at seq=3, events at seq 1..=5 in one batch.
        CheckpointManager::write(&wal_dir, 3, 3000).unwrap();
        let events = vec![
            sample_event(1),
            sample_event(2),
            sample_event(3),
            sample_event(4),
            sample_event(5),
        ];
        let batch = encode_batch(&events, 1, 1000).unwrap();
        std::fs::write(wal_dir.join(segment_filename(ShardId::SINGLE, 1)), &batch).unwrap();

        let recovered = reader::recover(&wal_dir).unwrap();
        let report = diagnose_wal(dir.path()).unwrap();

        assert_eq!(
            report.replay_events as usize,
            recovered.events.len(),
            "diagnostics replay count must match recovery replay count"
        );
        // Concretely: only seq 4 and 5 are > checkpoint_seq=3.
        assert_eq!(report.replay_events, 2);
    }

    #[test]
    fn diagnose_with_corruption() {
        let dir = tempfile::tempdir().unwrap();
        let wal_dir = dir.path().join("wal");
        std::fs::create_dir_all(&wal_dir).unwrap();

        let events = vec![sample_event(1)];
        let mut batch = encode_batch(&events, 1, 1000).unwrap();
        // Append garbage to simulate a torn write.
        batch.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);

        std::fs::write(wal_dir.join(segment_filename(ShardId::SINGLE, 1)), &batch).unwrap();

        let report = diagnose_wal(dir.path()).unwrap();
        assert_eq!(report.total_events, 1); // one valid event before corruption
        assert_eq!(report.inconsistency_count, 1); // one corrupt tail
        assert_eq!(report.segments[0].corrupt_batches, 1);
    }

    #[test]
    fn diagnose_multiple_segments() {
        let dir = tempfile::tempdir().unwrap();
        let wal_dir = dir.path().join("wal");
        std::fs::create_dir_all(&wal_dir).unwrap();

        // Segment 1: events at seq 1..3
        let events1 = vec![sample_event(1), sample_event(2), sample_event(3)];
        let batch1 = encode_batch(&events1, 1, 1000).unwrap();
        std::fs::write(wal_dir.join(segment_filename(ShardId::SINGLE, 1)), &batch1).unwrap();

        // Segment 2: events at seq 4..5
        let events2 = vec![sample_event(4), sample_event(5)];
        let batch2 = encode_batch(&events2, 4, 2000).unwrap();
        std::fs::write(wal_dir.join(segment_filename(ShardId::SINGLE, 4)), &batch2).unwrap();

        let report = diagnose_wal(dir.path()).unwrap();
        assert_eq!(report.total_events, 5);
        assert_eq!(report.replay_events, 5);
        assert_eq!(report.segment_count, 2);
        assert_eq!(report.last_wal_seq, 6); // one past last event (seq 5)
        assert_eq!(report.inconsistency_count, 0);
        assert_eq!(report.segments.len(), 2);
    }

    #[test]
    fn diagnose_estimated_recovery_time() {
        let dir = tempfile::tempdir().unwrap();
        let wal_dir = dir.path().join("wal");
        std::fs::create_dir_all(&wal_dir).unwrap();

        let events = vec![sample_event(1)];
        let batch = encode_batch(&events, 1, 1000).unwrap();
        std::fs::write(wal_dir.join(segment_filename(ShardId::SINGLE, 1)), &batch).unwrap();

        let report = diagnose_wal(dir.path()).unwrap();
        // 1 event / 100_000 + 5.0 = ~5.00001
        assert!(report.estimated_recovery_secs >= 5.0);
        assert!(report.estimated_recovery_secs < 6.0);
    }

    #[test]
    fn diagnose_empty_wal_recovery_time_is_base() {
        let dir = tempfile::tempdir().unwrap();
        // No wal directory at all.
        let report = diagnose_wal(dir.path()).unwrap();
        assert!(report.estimated_recovery_secs.abs() < 1e-12);
    }

    #[test]
    fn diagnose_session_journal_absent() {
        let dir = tempfile::tempdir().unwrap();
        let wal_dir = dir.path().join("wal");
        std::fs::create_dir_all(&wal_dir).unwrap();

        let report = diagnose_wal(dir.path()).unwrap();
        assert!(!report.session_journal.present);
        assert_eq!(report.session_journal.event_count, 0);
        assert_eq!(report.session_journal.corrupt_records, 0);
    }

    #[test]
    fn diagnose_session_journal_intact() {
        use crate::wal::{
            format::{SessionWalEvent, encode_session_event},
            session_journal::SESSION_JOURNAL_FILENAME,
        };

        let dir = tempfile::tempdir().unwrap();
        let wal_dir = dir.path().join("wal");
        std::fs::create_dir_all(&wal_dir).unwrap();

        let mut bytes = Vec::new();
        bytes.extend(
            encode_session_event(&SessionWalEvent::Start {
                session_id: 1,
                user_id: 10,
                started_at_ns: 100,
                agent_id: "agent".to_string(),
                policy_name: "policy".to_string(),
            })
            .unwrap(),
        );
        bytes.extend(encode_session_event(&SessionWalEvent::Close { session_id: 1 }).unwrap());
        std::fs::write(wal_dir.join(SESSION_JOURNAL_FILENAME), &bytes).unwrap();

        let report = diagnose_wal(dir.path()).unwrap();
        assert!(report.session_journal.present);
        assert_eq!(report.session_journal.event_count, 2);
        assert_eq!(report.session_journal.corrupt_records, 0);
        // Intact journal does not inflate the overall inconsistency count.
        assert_eq!(report.inconsistency_count, 0);
    }

    #[test]
    fn diagnose_session_journal_corruption_counted() {
        use crate::wal::{
            format::{SessionWalEvent, encode_session_event},
            session_journal::SESSION_JOURNAL_FILENAME,
        };

        let dir = tempfile::tempdir().unwrap();
        let wal_dir = dir.path().join("wal");
        std::fs::create_dir_all(&wal_dir).unwrap();

        let mut bytes = encode_session_event(&SessionWalEvent::Start {
            session_id: 1,
            user_id: 10,
            started_at_ns: 100,
            agent_id: "agent".to_string(),
            policy_name: "policy".to_string(),
        })
        .unwrap();
        let mut corrupt = encode_session_event(&SessionWalEvent::Close { session_id: 2 }).unwrap();
        // Flip the first payload byte so the per-record BLAKE3 checksum fails.
        //
        // A v2 session frame is laid out as
        // `[len: u32 LE][marker: 1][version: 1][type: 1][payload][checksum: 8]`,
        // so the first payload byte sits past the 4-byte length prefix and the
        // 3-byte marker/version/type header. The session-format constants that
        // own this layout (`SESSION_LEN_PREFIX_LEN` / `SESSION_FRAME_PREFIX_LEN`)
        // are module-private to `wal::format::session`, so we mirror them here as
        // named locals rather than re-deriving the offset with a bare `4 + 3`.
        let len_prefix_len: usize = 4; // len: u32 LE
        let frame_header_len: usize = 3; // marker(1) + version(1) + type(1)
        let flip = len_prefix_len + frame_header_len;
        corrupt[flip] ^= 0xFF;
        bytes.extend(corrupt);
        std::fs::write(wal_dir.join(SESSION_JOURNAL_FILENAME), &bytes).unwrap();

        let report = diagnose_wal(dir.path()).unwrap();
        assert!(report.session_journal.present);
        assert_eq!(report.session_journal.event_count, 1); // only the intact one
        assert_eq!(report.session_journal.corrupt_records, 1);
        // Session-journal corruption rolls into the overall count.
        assert_eq!(report.inconsistency_count, 1);
    }
}
