//! Append-only session journal for crash recovery of active sessions.
//!
//! Session lifecycle events (`Start`, `Signal`, `Close`) are written to a
//! separate file (`sessions.log`) with per-write fsync. The file is
//! independent of the signal WAL so that session events do not interfere
//! with the high-throughput batch signal path.
//!
//! On startup, the journal is replayed to detect sessions that were active
//! at the time of a crash (those with a `Start` but no `Close`).

use std::{
    fs::{File, OpenOptions},
    io::Write,
    path::{Path, PathBuf},
};

use super::{
    error::WalError,
    format::{SessionWalEvent, decode_session_events_with_diagnostics, encode_session_event},
};

/// The session journal file name within the WAL directory.
pub const SESSION_JOURNAL_FILENAME: &str = "sessions.log";

/// Result of recovering a session journal, including a corruption signal.
///
/// `recover` stops cleanly at the first malformed record (crash-safe), but a
/// mid-file checksum mismatch means every later session event is silently
/// dropped. `corruption_count` / `corruption_detected` let the recovery path
/// (`Wal::open`) observe that loss instead of silently truncating live
/// sessions — the 3am scenario where a single flipped byte makes sessions
/// vanish with nothing in the logs.
#[derive(Debug, Clone)]
pub struct SessionRecovery {
    /// The valid events recovered before any clean stop or corruption stop.
    pub events: Vec<SessionWalEvent>,
    /// Number of records that failed checksum verification or were malformed
    /// (a clean torn tail is NOT counted).
    pub corruption_count: u64,
    /// Whether recovery stopped because of detected corruption (as opposed to a
    /// clean end-of-file / torn tail).
    pub corruption_detected: bool,
}

/// Append-only session journal.
///
/// Each call to [`append`](Self::append) writes and fsyncs exactly one record,
/// so durability is per-record. The journal is designed for low-frequency writes
/// (session start/close/signal) and prioritises durability over throughput.
///
/// We deliberately write straight to the `File` rather than through a
/// `BufWriter`: every append fsyncs immediately, so a user-space buffer would
/// only ever hold a single record between `write_all` and `sync_data` and adds
/// no batching benefit — it is pure overhead and obscures the durability path.
pub struct SessionJournal {
    file: File,
    path: PathBuf,
}

impl SessionJournal {
    /// Open or create a session journal at the given path.
    ///
    /// # Errors
    ///
    /// Returns `std::io::Error` if the file cannot be opened or created.
    pub fn open(path: &Path) -> Result<Self, std::io::Error> {
        let file = OpenOptions::new().create(true).append(true).open(path)?;
        Ok(Self {
            file,
            path: path.to_path_buf(),
        })
    }

    /// Append a session event to the journal and fsync.
    ///
    /// The record is fully written and durably flushed to stable storage before
    /// this returns. A crash after return guarantees the record is recoverable;
    /// a crash before return leaves at most a torn tail, which
    /// [`recover`](Self::recover) discards cleanly. The flush routes through
    /// [`crate::wal::sync_file_durable`], so on macOS it issues `F_FULLFSYNC`
    /// (defeating the device write cache) rather than a plain `fsync` that could
    /// lose an acknowledged record on power loss.
    ///
    /// # Errors
    ///
    /// Returns [`WalError::Corruption`] if the event cannot be faithfully
    /// encoded (a string field longer than `u16::MAX`; see
    /// [`encode_session_event`]), or [`WalError::Io`] on write or sync failure.
    /// The fallible encode means an oversized field is surfaced to the caller
    /// instead of silently writing a record the decoder cannot read back.
    pub fn append(&mut self, event: &SessionWalEvent) -> Result<(), WalError> {
        let encoded = encode_session_event(event)?;
        self.file.write_all(&encoded)?;
        // Durable flush before returning (F_FULLFSYNC on macOS, fdatasync
        // elsewhere) so an acknowledged session record survives a hard crash.
        crate::wal::sync_file_durable(&self.file)?;
        Ok(())
    }

    /// Recover all session events from an existing journal file.
    ///
    /// Returns an empty [`SessionRecovery`] if the file does not exist. Stops at
    /// the first truncated record (crash-safe, clean torn tail) or the first
    /// checksum-mismatched / malformed record (corruption). The returned
    /// [`SessionRecovery::corruption_detected`] flag distinguishes a clean
    /// end-of-file from a corruption-induced stop, so the caller can surface the
    /// silent truncation rather than dropping later session events unnoticed.
    ///
    /// # Errors
    ///
    /// Returns `std::io::Error` if the file exists but cannot be read.
    pub fn recover(path: &Path) -> Result<SessionRecovery, std::io::Error> {
        if !path.exists() {
            return Ok(SessionRecovery {
                events: Vec::new(),
                corruption_count: 0,
                corruption_detected: false,
            });
        }
        let bytes = std::fs::read(path)?;
        let outcome = decode_session_events_with_diagnostics(&bytes);
        Ok(SessionRecovery {
            events: outcome.events,
            corruption_count: outcome.corruption_count,
            corruption_detected: outcome.corruption_detected,
        })
    }

    /// The path to this journal file.
    #[must_use]
    pub fn path(&self) -> &Path {
        &self.path
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    #[test]
    fn journal_append_and_recover() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(SESSION_JOURNAL_FILENAME);

        // Append events.
        {
            let mut journal = SessionJournal::open(&path).unwrap();
            journal
                .append(&SessionWalEvent::Start {
                    session_id: 1,
                    user_id: 10,
                    started_at_ns: 100,
                    agent_id: "agent-a".to_string(),
                    policy_name: "policy".to_string(),
                })
                .unwrap();
            journal
                .append(&SessionWalEvent::Signal {
                    session_id: 1,
                    entity_id: 42,
                    weight: 1.0,
                    ts_ns: 200,
                    signal_name: "view".to_string(),
                    annotation: None,
                    session_seqno: None,
                    idempotency_key: None,
                })
                .unwrap();
            journal
                .append(&SessionWalEvent::Close { session_id: 1 })
                .unwrap();
        }

        // Recover and verify.
        let recovery = SessionJournal::recover(&path).unwrap();
        assert!(!recovery.corruption_detected);
        let events = recovery.events;
        assert_eq!(events.len(), 3);
        assert!(matches!(
            events[0],
            SessionWalEvent::Start { session_id: 1, .. }
        ));
        assert!(matches!(
            events[1],
            SessionWalEvent::Signal { session_id: 1, .. }
        ));
        assert!(matches!(
            events[2],
            SessionWalEvent::Close { session_id: 1 }
        ));
    }

    #[test]
    fn journal_recover_nonexistent_returns_empty() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("does_not_exist.log");
        let recovery = SessionJournal::recover(&path).unwrap();
        assert!(recovery.events.is_empty());
        assert!(!recovery.corruption_detected);
        assert_eq!(recovery.corruption_count, 0);
    }

    #[test]
    fn journal_survives_truncated_tail() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(SESSION_JOURNAL_FILENAME);

        // Write one complete event.
        {
            let mut journal = SessionJournal::open(&path).unwrap();
            journal
                .append(&SessionWalEvent::Start {
                    session_id: 1,
                    user_id: 10,
                    started_at_ns: 100,
                    agent_id: "agent".to_string(),
                    policy_name: "policy".to_string(),
                })
                .unwrap();
        }

        // Append garbage bytes to simulate a torn write.
        {
            let mut file = OpenOptions::new().append(true).open(&path).unwrap();
            file.write_all(&[0xDE, 0xAD, 0xBE, 0xEF]).unwrap();
        }

        let recovery = SessionJournal::recover(&path).unwrap();
        assert_eq!(
            recovery.events.len(),
            1,
            "only the complete record should be recovered"
        );
        assert!(
            !recovery.corruption_detected,
            "a torn tail is not corruption"
        );
    }

    #[test]
    fn journal_multiple_opens_append() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(SESSION_JOURNAL_FILENAME);

        // First open: write start.
        {
            let mut journal = SessionJournal::open(&path).unwrap();
            journal
                .append(&SessionWalEvent::Start {
                    session_id: 1,
                    user_id: 10,
                    started_at_ns: 100,
                    agent_id: "agent".to_string(),
                    policy_name: "policy".to_string(),
                })
                .unwrap();
        }

        // Second open: write close (appends to existing file).
        {
            let mut journal = SessionJournal::open(&path).unwrap();
            journal
                .append(&SessionWalEvent::Close { session_id: 1 })
                .unwrap();
        }

        let recovery = SessionJournal::recover(&path).unwrap();
        assert_eq!(recovery.events.len(), 2);
        assert!(!recovery.corruption_detected);
    }

    #[test]
    fn journal_recover_flags_corruption() {
        // A mid-file checksum mismatch must be surfaced via corruption_detected,
        // not silently truncate the journal. Write two complete records, then
        // flip a payload byte of the second so its checksum no longer matches.
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(SESSION_JOURNAL_FILENAME);
        {
            let mut journal = SessionJournal::open(&path).unwrap();
            journal
                .append(&SessionWalEvent::Start {
                    session_id: 1,
                    user_id: 10,
                    started_at_ns: 100,
                    agent_id: "agent".to_string(),
                    policy_name: "policy".to_string(),
                })
                .unwrap();
            journal
                .append(&SessionWalEvent::Close { session_id: 2 })
                .unwrap();
        }

        // Corrupt one byte of the second record's framed body so its per-record
        // BLAKE3 checksum no longer matches. The first record is a
        // variable-length Start, so locate the second (Close) record from the
        // end of the file.
        //
        // A v2 session frame is laid out as
        // `[len: u32 LE][marker: 1][version: 1][type: 1][payload][checksum: 8]`,
        // so a Close frame occupies
        // len(4) + marker/version/type(3) + session_id(8) + checksum(8) bytes.
        // The session-format constants that own this layout
        // (`SESSION_LEN_PREFIX_LEN` / `SESSION_FRAME_PREFIX_LEN` /
        // `SESSION_CHECKSUM_LEN`) are module-private to `wal::format::session`,
        // so we mirror them here as named locals rather than re-deriving the
        // offsets with a bare `4 + 3 + 8 + 8`.
        let len_prefix_len: usize = 4; // len: u32 LE
        let frame_header_len: usize = 3; // marker(1) + version(1) + type(1)
        let close_session_id_len: usize = 8; // u64 session_id payload
        let checksum_len: usize = 8; // trailing BLAKE3 prefix
        let mut bytes = std::fs::read(&path).unwrap();
        let close_frame_len =
            len_prefix_len + frame_header_len + close_session_id_len + checksum_len;
        // Flip the marker byte (immediately past the length prefix) of record 2;
        // mangling any covered byte breaks the checksum and surfaces corruption.
        let flip = bytes.len() - close_frame_len + len_prefix_len;
        bytes[flip] ^= 0xFF;
        std::fs::write(&path, &bytes).unwrap();

        let recovery = SessionJournal::recover(&path).unwrap();
        assert_eq!(
            recovery.events.len(),
            1,
            "only the intact first record survives"
        );
        assert!(
            recovery.corruption_detected,
            "mid-file checksum mismatch must be flagged, not silently dropped"
        );
        assert_eq!(recovery.corruption_count, 1);
    }
}
