//! Session state persistence — save on quit, restore on launch.
//!
//! Enables the TUI to resume where the user left off: current screen,
//! navigation history, filter state, scroll positions.
//!
//! ## File format
//!
//! `session.json` is a versioned JSON blob with a CRC32 checksum appended
//! as the last 8 hex characters. Writes are atomic (tmp → fsync → rename).
//! Corrupt files are quarantined, not deleted.

use std::fs;
use std::io::Write;
use std::path::Path;

use serde::{Deserialize, Serialize};

/// Maximum session file size (1 MB). Files larger than this are rejected.
const MAX_SESSION_SIZE: u64 = 1_024 * 1_024;

/// Current session format version. Bump when the schema changes.
const SESSION_VERSION: u32 = 1;

// ---------------------------------------------------------------------------
// Persisted screen (decoupled from message::Screen)
// ---------------------------------------------------------------------------

/// Lightweight screen identifier for serialization.
///
/// Decoupled from `message::Screen` so session persistence doesn't require
/// `Serialize`/`Deserialize` on core types.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind")]
pub enum PersistedScreen {
    Dashboard,
    IssueList,
    IssueDetail { project_id: i64, iid: i64 },
    MrList,
    MrDetail { project_id: i64, iid: i64 },
    Search,
    Timeline,
    Who,
    Trace,
    FileHistory,
    Sync,
    Stats,
    Doctor,
}

// ---------------------------------------------------------------------------
// Session state
// ---------------------------------------------------------------------------

/// Versioned session state persisted to disk.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionState {
    /// Format version for migration.
    pub version: u32,
    /// Screen to restore on launch.
    pub current_screen: PersistedScreen,
    /// Navigation history (back stack).
    pub nav_history: Vec<PersistedScreen>,
    /// Per-screen filter text (screen name -> filter string).
    pub filters: Vec<(String, String)>,
    /// Per-screen scroll offset (screen name -> offset).
    pub scroll_offsets: Vec<(String, u16)>,
    /// Global scope project path filter (if set).
    pub global_scope: Option<String>,
}

impl Default for SessionState {
    fn default() -> Self {
        Self {
            version: SESSION_VERSION,
            current_screen: PersistedScreen::Dashboard,
            nav_history: Vec::new(),
            filters: Vec::new(),
            scroll_offsets: Vec::new(),
            global_scope: None,
        }
    }
}

// ---------------------------------------------------------------------------
// Save / Load
// ---------------------------------------------------------------------------

/// Save session state atomically.
///
/// Writes to a temp file, fsyncs, appends CRC32 checksum, then renames
/// over the target path. This prevents partial writes on crash.
pub fn save_session(state: &SessionState, path: &Path) -> Result<(), SessionError> {
    // Ensure parent directory exists.
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|e| SessionError::Io(e.to_string()))?;
    }

    let json =
        serde_json::to_string_pretty(state).map_err(|e| SessionError::Serialize(e.to_string()))?;

    // Check size before writing.
    if json.len() as u64 > MAX_SESSION_SIZE {
        return Err(SessionError::TooLarge {
            size: json.len() as u64,
            max: MAX_SESSION_SIZE,
        });
    }

    // Compute CRC32 over the JSON payload.
    let checksum = crc32fast::hash(json.as_bytes());
    let payload = format!("{json}\n{checksum:08x}");

    // Write to temp file, fsync, rename.
    let tmp_path = path.with_extension("tmp");
    let mut file = fs::File::create(&tmp_path).map_err(|e| SessionError::Io(e.to_string()))?;
    file.write_all(payload.as_bytes())
        .map_err(|e| SessionError::Io(e.to_string()))?;
    file.sync_all()
        .map_err(|e| SessionError::Io(e.to_string()))?;
    drop(file);

    fs::rename(&tmp_path, path).map_err(|e| SessionError::Io(e.to_string()))?;

    Ok(())
}

/// Load session state from disk.
///
/// Validates CRC32 checksum. On corruption, quarantines the file and
/// returns `SessionError::Corrupt`.
pub fn load_session(path: &Path) -> Result<SessionState, SessionError> {
    if !path.exists() {
        return Err(SessionError::NotFound);
    }

    // Check file size before reading.
    let metadata = fs::metadata(path).map_err(|e| SessionError::Io(e.to_string()))?;
    if metadata.len() > MAX_SESSION_SIZE {
        quarantine(path)?;
        return Err(SessionError::TooLarge {
            size: metadata.len(),
            max: MAX_SESSION_SIZE,
        });
    }

    let raw = fs::read_to_string(path).map_err(|e| SessionError::Io(e.to_string()))?;

    // Split: everything before the last newline is JSON, after is the checksum.
    let (json, checksum_hex) = raw
        .rsplit_once('\n')
        .ok_or_else(|| SessionError::Corrupt("no checksum separator".into()))?;

    // Validate checksum.
    let expected = u32::from_str_radix(checksum_hex.trim(), 16)
        .map_err(|_| SessionError::Corrupt("invalid checksum hex".into()))?;
    let actual = crc32fast::hash(json.as_bytes());
    if actual != expected {
        quarantine(path)?;
        return Err(SessionError::Corrupt(format!(
            "CRC32 mismatch: expected {expected:08x}, got {actual:08x}"
        )));
    }

    // Deserialize.
    let state: SessionState = serde_json::from_str(json)
        .map_err(|e| SessionError::Corrupt(format!("JSON parse error: {e}")))?;

    // Version check — future-proof: reject newer versions, accept current.
    if state.version > SESSION_VERSION {
        return Err(SessionError::Corrupt(format!(
            "session version {} is newer than supported ({})",
            state.version, SESSION_VERSION
        )));
    }

    Ok(state)
}

/// Move a corrupt session file to `.quarantine/` instead of deleting it.
fn quarantine(path: &Path) -> Result<(), SessionError> {
    let quarantine_dir = path.parent().unwrap_or(Path::new(".")).join(".quarantine");
    fs::create_dir_all(&quarantine_dir).map_err(|e| SessionError::Io(e.to_string()))?;

    let filename = path
        .file_name()
        .unwrap_or_default()
        .to_string_lossy()
        .to_string();
    let ts = chrono::Utc::now().format("%Y%m%d_%H%M%S");
    let quarantine_path = quarantine_dir.join(format!("{filename}.{ts}"));

    fs::rename(path, &quarantine_path).map_err(|e| SessionError::Io(e.to_string()))?;

    Ok(())
}

// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------

/// Session persistence errors.
#[derive(Debug, Clone, PartialEq)]
pub enum SessionError {
    /// Session file not found (first launch).
    NotFound,
    /// File is corrupt (bad checksum, invalid JSON, etc.).
    Corrupt(String),
    /// File exceeds size limit.
    TooLarge { size: u64, max: u64 },
    /// I/O error.
    Io(String),
    /// Serialization error.
    Serialize(String),
}

impl std::fmt::Display for SessionError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::NotFound => write!(f, "session file not found"),
            Self::Corrupt(msg) => write!(f, "corrupt session: {msg}"),
            Self::TooLarge { size, max } => {
                write!(f, "session file too large ({size} bytes, max {max})")
            }
            Self::Io(msg) => write!(f, "session I/O error: {msg}"),
            Self::Serialize(msg) => write!(f, "session serialization error: {msg}"),
        }
    }
}

impl std::error::Error for SessionError {}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    fn sample_state() -> SessionState {
        SessionState {
            version: SESSION_VERSION,
            current_screen: PersistedScreen::IssueList,
            nav_history: vec![PersistedScreen::Dashboard],
            filters: vec![("IssueList".into(), "bug".into())],
            scroll_offsets: vec![("IssueList".into(), 5)],
            global_scope: Some("group/project".into()),
        }
    }

    #[test]
    fn test_session_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("session.json");

        let state = sample_state();
        save_session(&state, &path).unwrap();

        let loaded = load_session(&path).unwrap();
        assert_eq!(state, loaded);
    }

    #[test]
    fn test_session_default_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("session.json");

        let state = SessionState::default();
        save_session(&state, &path).unwrap();

        let loaded = load_session(&path).unwrap();
        assert_eq!(state, loaded);
    }

    #[test]
    fn test_session_not_found() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("nonexistent.json");

        let result = load_session(&path);
        assert_eq!(result.unwrap_err(), SessionError::NotFound);
    }

    #[test]
    fn test_session_corruption_detected() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("session.json");

        let state = sample_state();
        save_session(&state, &path).unwrap();

        // Tamper with the file — modify a byte in the JSON section.
        let raw = fs::read_to_string(&path).unwrap();
        let tampered = raw.replacen("IssueList", "MrList___", 1);
        fs::write(&path, tampered).unwrap();

        let result = load_session(&path);
        assert!(matches!(result, Err(SessionError::Corrupt(_))));
    }

    #[test]
    fn test_session_corruption_quarantines_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("session.json");

        let state = sample_state();
        save_session(&state, &path).unwrap();

        // Tamper with the checksum line.
        let raw = fs::read_to_string(&path).unwrap();
        let tampered = format!("{}\ndeadbeef", raw.rsplit_once('\n').unwrap().0);
        fs::write(&path, tampered).unwrap();

        let _ = load_session(&path);

        // Original file should be gone.
        assert!(!path.exists());

        // Quarantine directory should contain the file.
        let quarantine_dir = dir.path().join(".quarantine");
        assert!(quarantine_dir.exists());
        let entries: Vec<_> = fs::read_dir(&quarantine_dir).unwrap().collect();
        assert_eq!(entries.len(), 1);
    }

    #[test]
    fn test_session_creates_parent_directory() {
        let dir = tempfile::tempdir().unwrap();
        let nested = dir.path().join("a").join("b").join("session.json");

        let state = SessionState::default();
        save_session(&state, &nested).unwrap();
        assert!(nested.exists());
    }

    #[test]
    fn test_session_persisted_screen_variants() {
        let screens = vec![
            PersistedScreen::Dashboard,
            PersistedScreen::IssueList,
            PersistedScreen::IssueDetail {
                project_id: 1,
                iid: 42,
            },
            PersistedScreen::MrList,
            PersistedScreen::MrDetail {
                project_id: 2,
                iid: 99,
            },
            PersistedScreen::Search,
            PersistedScreen::Timeline,
            PersistedScreen::Who,
            PersistedScreen::Trace,
            PersistedScreen::FileHistory,
            PersistedScreen::Sync,
            PersistedScreen::Stats,
            PersistedScreen::Doctor,
        ];

        for screen in screens {
            let state = SessionState {
                current_screen: screen.clone(),
                ..SessionState::default()
            };
            let dir = tempfile::tempdir().unwrap();
            let path = dir.path().join("session.json");
            save_session(&state, &path).unwrap();
            let loaded = load_session(&path).unwrap();
            assert_eq!(state.current_screen, loaded.current_screen);
        }
    }

    #[test]
    fn test_session_max_size_enforced() {
        let state = SessionState {
            filters: (0..100_000)
                .map(|i| (format!("key_{i}"), "x".repeat(100)))
                .collect(),
            ..SessionState::default()
        };

        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("session.json");

        let result = save_session(&state, &path);
        assert!(matches!(result, Err(SessionError::TooLarge { .. })));
    }

    #[test]
    fn test_session_atomic_write_no_partial() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("session.json");
        let tmp_path = path.with_extension("tmp");

        let state = sample_state();
        save_session(&state, &path).unwrap();

        // After save, no tmp file should remain.
        assert!(!tmp_path.exists());
        assert!(path.exists());
    }
}
