//! 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};
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};

use super::format::{SessionWalEvent, decode_session_events, encode_session_event};

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

/// Append-only session journal.
///
/// Each call to [`append`](Self::append) writes and fsyncs one record.
/// The journal is designed for low-frequency writes (session start/close/signal)
/// and prioritises durability over throughput.
pub struct SessionJournal {
    writer: BufWriter<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 {
            writer: BufWriter::new(file),
            path: path.to_path_buf(),
        })
    }

    /// Append a session event to the journal and fsync.
    ///
    /// # Errors
    ///
    /// Returns `std::io::Error` on write or sync failure.
    pub fn append(&mut self, event: &SessionWalEvent) -> Result<(), std::io::Error> {
        let encoded = encode_session_event(event);
        self.writer.write_all(&encoded)?;
        self.writer.flush()?;
        // fsync the underlying file descriptor for durability.
        self.writer.get_ref().sync_data()?;
        Ok(())
    }

    /// Recover all session events from an existing journal file.
    ///
    /// Returns an empty vec if the file does not exist. Stops at the first
    /// truncated record (crash-safe).
    ///
    /// # Errors
    ///
    /// Returns `std::io::Error` if the file exists but cannot be read.
    pub fn recover(path: &Path) -> Result<Vec<SessionWalEvent>, std::io::Error> {
        if !path.exists() {
            return Ok(Vec::new());
        }
        let bytes = std::fs::read(path)?;
        Ok(decode_session_events(&bytes))
    }

    /// 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 events = SessionJournal::recover(&path).unwrap();
        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 events = SessionJournal::recover(&path).unwrap();
        assert!(events.is_empty());
    }

    #[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 events = SessionJournal::recover(&path).unwrap();
        assert_eq!(
            events.len(),
            1,
            "only the complete record should be recovered"
        );
    }

    #[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 events = SessionJournal::recover(&path).unwrap();
        assert_eq!(events.len(), 2);
    }
}
