use std::collections::HashMap;
use std::io::Write as _;
use std::path::PathBuf;
use std::sync::Arc;

use crab_core::message::{ContentBlock, Message, Role};
use serde::{Deserialize, Serialize};

/// On-disk session transcript format.
#[derive(Debug, Serialize, Deserialize)]
struct SessionFile {
    session_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    name: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    working_dir: Option<String>,
    messages: Vec<Message>,
    /// Session-level "always allow" tool grants — preserved across save and
    /// resume so the user is not re-prompted for tools they already granted.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    grants: Vec<String>,
}

/// Metadata for a saved session (returned by list operations).
#[derive(Debug, Clone)]
pub struct SessionMetadata {
    pub session_id: String,
    pub name: Option<String>,
    pub working_dir: Option<String>,
    pub message_count: usize,
    pub modified: Option<std::time::SystemTime>,
    /// Short preview — trimmed text of the first user message (or the
    /// first text block) in the session. Populated lazily by
    /// [`list_sessions`] so the welcome panel can show something more
    /// informative than session IDs. Capped at [`SUMMARY_MAX_CHARS`].
    pub summary: Option<String>,
}

/// Maximum character count for [`SessionMetadata::summary`]. Anything
/// longer is truncated with a trailing ellipsis.
pub const SUMMARY_MAX_CHARS: usize = 80;

/// Build a short preview from a conversation's messages.
///
/// Uses the first user text block (falling back to any text block) as the
/// preview. Collapses whitespace and truncates to [`SUMMARY_MAX_CHARS`].
/// Returns `None` when no textual content is present.
fn extract_summary(messages: &[Message]) -> Option<String> {
    let first_user_text = messages
        .iter()
        .find(|m| matches!(m.role, Role::User))
        .and_then(|m| {
            m.content.iter().find_map(|block| match block {
                ContentBlock::Text { text } => Some(text.as_str()),
                _ => None,
            })
        });

    let raw = match first_user_text {
        Some(text) => text,
        None => messages.iter().find_map(|m| {
            m.content.iter().find_map(|block| match block {
                ContentBlock::Text { text } => Some(text.as_str()),
                _ => None,
            })
        })?,
    };

    let collapsed: String = raw.split_whitespace().collect::<Vec<_>>().join(" ");
    if collapsed.is_empty() {
        return None;
    }
    if collapsed.chars().count() <= SUMMARY_MAX_CHARS {
        Some(collapsed)
    } else {
        let mut out: String = collapsed
            .chars()
            .take(SUMMARY_MAX_CHARS.saturating_sub(1))
            .collect();
        out.push('…');
        Some(out)
    }
}

/// A search hit within a session.
#[derive(Debug, Clone)]
pub struct SearchResult {
    /// The session ID containing the match.
    pub session_id: String,
    /// Zero-based index of the matching message.
    pub message_index: usize,
    /// Role of the matching message.
    pub role: Role,
    /// The matched text snippet (first matching content block text).
    pub snippet: String,
}

/// Statistics for a single session.
#[derive(Debug, Clone, Default, Serialize)]
pub struct SessionStats {
    /// Total number of messages.
    pub message_count: usize,
    /// Number of user messages.
    pub user_messages: usize,
    /// Number of assistant messages.
    pub assistant_messages: usize,
    /// Number of tool use invocations.
    pub tool_use_count: usize,
    /// Number of tool results.
    pub tool_result_count: usize,
    /// Number of error tool results.
    pub tool_error_count: usize,
    /// Per-tool invocation counts (`tool_name` -> count).
    pub tool_calls_by_name: HashMap<String, usize>,
    /// Estimated total tokens (rough heuristic).
    pub estimated_tokens: u64,
}

/// Export format for sessions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportFormat {
    /// Pretty-printed JSON (same as on-disk format).
    Json,
    /// Human-readable Markdown transcript.
    Markdown,
}

/// Callback for auto-persisting messages during the query loop.
///
/// Implementors should write the message to durable storage (e.g. JSONL
/// append) so that crash recovery can reconstruct the conversation.
pub trait SessionPersister: Send + Sync {
    fn persist_message(&self, msg: &Message);
}

/// Persists and recovers session transcripts from disk.
///
/// Each session is stored as `{base_dir}/{session_id}.json`.
/// JSONL transcripts (one message per line) are stored alongside as
/// `{base_dir}/{session_id}.jsonl` for crash-resilient per-turn saving.
pub struct SessionHistory {
    pub base_dir: PathBuf,
}

impl SessionHistory {
    pub fn new(base_dir: PathBuf) -> Self {
        Self { base_dir }
    }

    /// Ensure the base directory exists.
    fn ensure_dir(&self) -> crab_core::Result<()> {
        std::fs::create_dir_all(&self.base_dir)?;
        Ok(())
    }

    /// Path to a session file.
    fn session_path(&self, session_id: &str) -> PathBuf {
        self.base_dir.join(format!("{session_id}.json"))
    }

    /// Save a session transcript to disk.
    ///
    /// Preserves any session-level grants already on disk: the auto-save
    /// path doesn't have grants in scope, so reading them back avoids
    /// silently wiping a user's "always allow" set on every turn.
    pub fn save(&self, session_id: &str, messages: &[Message]) -> crab_core::Result<()> {
        let existing_grants = self.load_grants(session_id).unwrap_or_default();
        self.save_with_metadata(session_id, None, None, messages, &existing_grants)
    }

    /// Save a session with optional name and working directory metadata.
    pub fn save_with_metadata(
        &self,
        session_id: &str,
        name: Option<&str>,
        working_dir: Option<&str>,
        messages: &[Message],
        grants: &[String],
    ) -> crab_core::Result<()> {
        self.ensure_dir()?;
        let file = SessionFile {
            session_id: session_id.to_string(),
            name: name.map(std::string::ToString::to_string),
            working_dir: working_dir.map(std::string::ToString::to_string),
            messages: messages.to_vec(),
            grants: grants.to_vec(),
        };
        let json = serde_json::to_string_pretty(&file)
            .map_err(|e| crab_core::Error::Other(format!("serialize session: {e}")))?;
        atomic_write(&self.session_path(session_id), json.as_bytes())?;
        Ok(())
    }

    /// Read and parse a session file. A corrupt file is quarantined and treated
    /// as missing (`Ok(None)`) rather than a hard error, so one bad file cannot
    /// poison every future load/list/resume.
    fn read_session_file(&self, session_id: &str) -> crab_core::Result<Option<SessionFile>> {
        let path = self.session_path(session_id);
        if !path.exists() {
            return Ok(None);
        }
        let data = std::fs::read_to_string(&path)?;
        match serde_json::from_str::<SessionFile>(&data) {
            Ok(file) => Ok(Some(file)),
            Err(e) => {
                quarantine_corrupt(&path, &e);
                Ok(None)
            }
        }
    }

    /// Save messages plus session-level grants in one call.
    ///
    /// This is the path used by interactive frontends (TUI / IDE) that
    /// track user-granted "always allow" decisions for the duration of a
    /// session and want them restored on resume.
    pub fn save_with_grants(
        &self,
        session_id: &str,
        messages: &[Message],
        grants: &[String],
    ) -> crab_core::Result<()> {
        self.save_with_metadata(session_id, None, None, messages, grants)
    }

    /// Load a session transcript from disk. Returns `None` if the file doesn't
    /// exist or is corrupt (corrupt files are quarantined, not surfaced as
    /// errors, so resume can fall back to a fresh conversation).
    pub fn load(&self, session_id: &str) -> crab_core::Result<Option<Vec<Message>>> {
        Ok(self.read_session_file(session_id)?.map(|f| f.messages))
    }

    /// Load both messages and session-level grants from disk.
    ///
    /// Returns `None` if the session file doesn't exist; otherwise returns
    /// `Some((messages, grants))`. Grants default to an empty `Vec` when
    /// the on-disk file predates the field.
    pub fn load_with_grants(
        &self,
        session_id: &str,
    ) -> crab_core::Result<Option<(Vec<Message>, Vec<String>)>> {
        Ok(self
            .read_session_file(session_id)?
            .map(|f| (f.messages, f.grants)))
    }

    /// Load only the session-level grants. Returns an empty `Vec` when the
    /// session file is missing, corrupt, or the grants field is absent.
    pub fn load_grants(&self, session_id: &str) -> crab_core::Result<Vec<String>> {
        Ok(self
            .read_session_file(session_id)?
            .map(|f| f.grants)
            .unwrap_or_default())
    }

    /// List all saved session IDs (sorted by name).
    pub fn list_sessions(&self) -> crab_core::Result<Vec<String>> {
        if !self.base_dir.exists() {
            return Ok(Vec::new());
        }
        let mut sessions = Vec::new();
        for entry in std::fs::read_dir(&self.base_dir)? {
            let entry = entry?;
            let name = entry.file_name();
            let name = name.to_string_lossy();
            if let Some(id) = name.strip_suffix(".json") {
                sessions.push(id.to_string());
            }
        }
        sessions.sort();
        Ok(sessions)
    }

    /// List all sessions with metadata (name, `working_dir`, message count, mtime).
    /// Sorted by modification time (newest first).
    pub fn list_sessions_with_metadata(&self) -> crab_core::Result<Vec<SessionMetadata>> {
        if !self.base_dir.exists() {
            return Ok(Vec::new());
        }
        let mut results = Vec::new();
        for entry in std::fs::read_dir(&self.base_dir)? {
            let entry = entry?;
            let name = entry.file_name();
            let name = name.to_string_lossy();
            if let Some(id) = name.strip_suffix(".json") {
                let modified = entry.metadata().ok().and_then(|m| m.modified().ok());
                // Read the file to extract metadata
                let (session_name, working_dir, message_count, summary) =
                    std::fs::read_to_string(entry.path())
                        .ok()
                        .and_then(|data| serde_json::from_str::<SessionFile>(&data).ok())
                        .map_or((None, None, 0, None), |file| {
                            let summary = extract_summary(&file.messages);
                            (file.name, file.working_dir, file.messages.len(), summary)
                        });
                results.push(SessionMetadata {
                    session_id: id.to_string(),
                    name: session_name,
                    working_dir,
                    message_count,
                    modified,
                    summary,
                });
            }
        }
        // Sort by mtime, newest first
        results.sort_by_key(|r| std::cmp::Reverse(r.modified));
        Ok(results)
    }

    /// Delete a session file.
    pub fn delete(&self, session_id: &str) -> crab_core::Result<()> {
        let path = self.session_path(session_id);
        if path.exists() {
            std::fs::remove_file(&path)?;
        }
        Ok(())
    }

    /// Read the persisted name for a session, if any.
    pub fn load_name(&self, session_id: &str) -> Option<String> {
        self.read_session_file(session_id).ok().flatten()?.name
    }

    /// Path to a session's cost sidecar (`{base_dir}/{session_id}.cost.json`).
    fn cost_path(&self, session_id: &str) -> PathBuf {
        self.base_dir.join(format!("{session_id}.cost.json"))
    }

    /// Persist a session's accumulated cost summary alongside its transcript so
    /// resume can restore the running cost/token display instead of zeroing it.
    pub fn save_cost(
        &self,
        session_id: &str,
        cost: &crate::cost::CostSummary,
    ) -> crab_core::Result<()> {
        self.ensure_dir()?;
        crate::cost::save_cost_summary(&self.cost_path(session_id), cost)
    }

    /// Load a session's persisted cost summary, if present.
    #[must_use]
    pub fn load_cost(&self, session_id: &str) -> Option<crate::cost::CostSummary> {
        crate::cost::load_cost_summary(&self.cost_path(session_id))
            .ok()
            .flatten()
    }

    /// Delete session files (`.json` / `.jsonl` and quarantined `.corrupt-*`)
    /// whose modification time is older than `days`, bounding the growth of the
    /// sessions directory. `days == 0` is a no-op (retention disabled). Returns
    /// the number of files removed.
    pub fn cleanup_older_than(&self, days: u32) -> crab_core::Result<u32> {
        if days == 0 || !self.base_dir.exists() {
            return Ok(0);
        }
        let Some(cutoff) = std::time::SystemTime::now()
            .checked_sub(std::time::Duration::from_secs(u64::from(days) * 86_400))
        else {
            return Ok(0);
        };

        let mut removed = 0u32;
        for entry in std::fs::read_dir(&self.base_dir)?.flatten() {
            let path = entry.path();
            if !path.is_file() {
                continue;
            }
            if let Ok(meta) = entry.metadata()
                && let Ok(modified) = meta.modified()
                && modified < cutoff
                && std::fs::remove_file(&path).is_ok()
            {
                removed += 1;
            }
        }
        Ok(removed)
    }

    /// Find the most recent session to resume for `--continue`.
    ///
    /// Prefers the newest session whose recorded working directory matches
    /// `dir` (so `--continue` stays within the current project). Falls back to
    /// the globally newest session when none records a matching directory —
    /// preserving behavior for legacy sessions that predate working-dir
    /// tracking.
    pub fn find_latest_for_dir(&self, dir: &std::path::Path) -> Option<String> {
        // Sorted newest-first by list_sessions_with_metadata.
        let metas = self.list_sessions_with_metadata().ok()?;
        let canon_dir = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());

        let dir_match = metas.iter().find(|m| {
            m.working_dir.as_ref().is_some_and(|wd| {
                let canon_wd =
                    std::fs::canonicalize(wd).unwrap_or_else(|_| std::path::PathBuf::from(wd));
                canon_wd == canon_dir
            })
        });

        dir_match
            .or_else(|| metas.first())
            .map(|m| m.session_id.clone())
    }

    // ── Search ─────────────────────────────────────────────────────

    /// Search a single session's messages for a keyword (case-insensitive).
    /// Returns matching results with message index and snippet.
    pub fn search_session(
        &self,
        session_id: &str,
        query: &str,
    ) -> crab_core::Result<Vec<SearchResult>> {
        let Some(messages) = self.load(session_id)? else {
            return Ok(Vec::new());
        };
        Ok(find_matches(session_id, &messages, query))
    }

    /// Search all sessions for a keyword (case-insensitive).
    /// Returns matches across all sessions, ordered by session ID.
    pub fn search_all(&self, query: &str) -> crab_core::Result<Vec<SearchResult>> {
        let session_ids = self.list_sessions()?;
        let mut all_results = Vec::new();
        for sid in &session_ids {
            let results = self.search_session(sid, query)?;
            all_results.extend(results);
        }
        Ok(all_results)
    }

    // ── Export ──────────────────────────────────────────────────────

    /// Export a session in the given format.
    /// Returns `None` if the session doesn't exist.
    pub fn export(
        &self,
        session_id: &str,
        format: ExportFormat,
    ) -> crab_core::Result<Option<String>> {
        let Some(messages) = self.load(session_id)? else {
            return Ok(None);
        };
        let output = match format {
            ExportFormat::Json => {
                let file = SessionFile {
                    session_id: session_id.to_string(),
                    name: None,
                    working_dir: None,
                    messages,
                    grants: Vec::new(),
                };
                serde_json::to_string_pretty(&file)
                    .map_err(|e| crab_core::Error::Other(format!("export json: {e}")))?
            }
            ExportFormat::Markdown => export_markdown(session_id, &messages),
        };
        Ok(Some(output))
    }

    // ── Statistics ──────────────────────────────────────────────────

    /// Compute statistics for a single session.
    /// Returns `None` if the session doesn't exist.
    pub fn stats(&self, session_id: &str) -> crab_core::Result<Option<SessionStats>> {
        let Some(messages) = self.load(session_id)? else {
            return Ok(None);
        };
        Ok(Some(compute_stats(&messages)))
    }

    // ── JSONL per-turn persistence ─────────────────────────────────

    fn jsonl_path(&self, session_id: &str) -> PathBuf {
        self.base_dir.join(format!("{session_id}.jsonl"))
    }

    /// Append a single message as one JSONL line (crash-resilient).
    pub fn append_jsonl(&self, session_id: &str, msg: &Message) -> crab_core::Result<()> {
        self.ensure_dir()?;
        let mut line = serde_json::to_string(msg)
            .map_err(|e| crab_core::Error::Other(format!("serialize message: {e}")))?;
        line.push('\n');

        let mut file = std::fs::OpenOptions::new()
            .create(true)
            .append(true)
            .open(self.jsonl_path(session_id))?;
        file.write_all(line.as_bytes())?;
        Ok(())
    }

    /// Load messages from a JSONL transcript. Returns `None` if the file
    /// doesn't exist. Silently skips malformed lines (partial writes from
    /// crashes).
    pub fn load_jsonl(&self, session_id: &str) -> crab_core::Result<Option<Vec<Message>>> {
        let path = self.jsonl_path(session_id);
        if !path.exists() {
            return Ok(None);
        }
        let data = std::fs::read_to_string(&path)?;
        let messages: Vec<Message> = data
            .lines()
            .filter(|l| !l.is_empty())
            .filter_map(|l| serde_json::from_str(l).ok())
            .collect();
        Ok(Some(messages))
    }

    /// Clear the `.jsonl` crash log after its messages have been folded into
    /// the canonical `.json` snapshot, so it only ever holds messages produced
    /// since the last snapshot.
    pub fn truncate_jsonl(&self, session_id: &str) -> crab_core::Result<()> {
        let path = self.jsonl_path(session_id);
        if path.exists() {
            std::fs::remove_file(&path)?;
        }
        Ok(())
    }

    /// Load a session for resume: the canonical `.json` snapshot plus any
    /// messages still in the `.jsonl` crash log.
    ///
    /// The `.jsonl` is truncated whenever a snapshot is written, so it only
    /// contains post-snapshot messages — i.e. an in-flight turn that crashed
    /// before the next snapshot. Returns `None` only when neither file exists.
    pub fn load_for_resume(
        &self,
        session_id: &str,
    ) -> crab_core::Result<Option<(Vec<Message>, Vec<String>)>> {
        let snapshot = self.load_with_grants(session_id)?;
        let tail = self.load_jsonl(session_id)?.unwrap_or_default();

        match (snapshot, tail.is_empty()) {
            (Some((mut messages, grants)), false) => {
                messages.extend(tail);
                Ok(Some((messages, grants)))
            }
            (Some(pair), true) => Ok(Some(pair)),
            // No snapshot but a crash log exists (crashed before the first
            // snapshot): recover the turn from the log alone.
            (None, false) => Ok(Some((tail, Vec::new()))),
            (None, true) => Ok(None),
        }
    }

    /// Create a [`SessionPersister`] bound to a specific session ID.
    pub fn persister(self: &Arc<Self>, session_id: String) -> BoundSessionPersister {
        BoundSessionPersister {
            history: Arc::clone(self),
            session_id,
        }
    }
}

/// A [`SessionPersister`] bound to one session, suitable for passing into
/// the query loop.
pub struct BoundSessionPersister {
    history: Arc<SessionHistory>,
    session_id: String,
}

impl SessionPersister for BoundSessionPersister {
    fn persist_message(&self, msg: &Message) {
        if let Err(e) = self.history.append_jsonl(&self.session_id, msg) {
            tracing::error!(error = %e, session_id = %self.session_id, "failed to persist message to JSONL");
        }
    }
}

// ── Helper functions ───────────────────────────────────────────────────

/// Atomically write `bytes` to `path` via a sibling temp file + rename, so a
/// crash mid-write cannot truncate or corrupt an existing session file.
fn atomic_write(path: &std::path::Path, bytes: &[u8]) -> crab_core::Result<()> {
    let mut tmp = path.as_os_str().to_owned();
    tmp.push(".tmp");
    let tmp = PathBuf::from(tmp);

    {
        let mut f = std::fs::File::create(&tmp)?;
        f.write_all(bytes)?;
        f.flush()?;
        // Best-effort durability before the rename; not fatal if unsupported.
        let _ = f.sync_all();
    }

    if let Err(e) = std::fs::rename(&tmp, path) {
        let _ = std::fs::remove_file(&tmp);
        return Err(e.into());
    }
    Ok(())
}

/// Move a corrupt session file aside (`{path}.corrupt-{secs}`) so it stops
/// poisoning every future list/load, logging the reason.
fn quarantine_corrupt(path: &std::path::Path, err: &dyn std::fmt::Display) {
    let secs = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_or(0, |d| d.as_secs());
    let mut quarantined = path.as_os_str().to_owned();
    quarantined.push(format!(".corrupt-{secs}"));
    if std::fs::rename(path, PathBuf::from(quarantined)).is_ok() {
        tracing::warn!(
            path = %path.display(),
            error = %err,
            "quarantined corrupt session file"
        );
    } else {
        tracing::error!(
            path = %path.display(),
            error = %err,
            "failed to read corrupt session file"
        );
    }
}

/// Extract searchable text from a content block.
fn block_text(block: &ContentBlock) -> Option<&str> {
    match block {
        ContentBlock::Text { text } => Some(text),
        ContentBlock::ToolResult { content, .. } => Some(content),
        ContentBlock::ToolUse { name, .. } => Some(name),
        ContentBlock::Thinking { thinking } => Some(thinking),
        ContentBlock::Image { .. } => None,
    }
}

/// Find messages matching `query` (case-insensitive) within a message list.
fn find_matches(session_id: &str, messages: &[Message], query: &str) -> Vec<SearchResult> {
    let query_lower = query.to_lowercase();
    let mut results = Vec::new();

    for (idx, msg) in messages.iter().enumerate() {
        for block in &msg.content {
            if let Some(text) = block_text(block)
                && text.to_lowercase().contains(&query_lower)
            {
                // Take a snippet: first 120 chars of the matching text
                let snippet = if text.len() > 120 {
                    format!("{}...", &text[..120])
                } else {
                    text.to_string()
                };
                results.push(SearchResult {
                    session_id: session_id.to_string(),
                    message_index: idx,
                    role: msg.role,
                    snippet,
                });
                break; // one hit per message is enough
            }
        }
    }

    results
}

/// Render messages as a Markdown transcript.
fn export_markdown(session_id: &str, messages: &[Message]) -> String {
    use std::fmt::Write;

    let mut md = String::new();
    let _ = writeln!(md, "# Session: {session_id}\n");

    for (i, msg) in messages.iter().enumerate() {
        let role_label = match msg.role {
            Role::User => "User",
            Role::Assistant => "Assistant",
            Role::System => "System",
        };
        let _ = writeln!(md, "## [{i}] {role_label}\n");

        for block in &msg.content {
            match block {
                ContentBlock::Text { text } => {
                    let _ = writeln!(md, "{text}\n");
                }
                ContentBlock::ToolUse { id, name, input } => {
                    let _ = writeln!(md, "**Tool Use:** `{name}` (id: `{id}`)\n");
                    let _ = writeln!(
                        md,
                        "```json\n{}\n```\n",
                        serde_json::to_string_pretty(input).unwrap_or_default()
                    );
                }
                ContentBlock::ToolResult {
                    tool_use_id,
                    content,
                    is_error,
                } => {
                    let label = if *is_error {
                        "Tool Error"
                    } else {
                        "Tool Result"
                    };
                    let _ = writeln!(md, "**{label}** (id: `{tool_use_id}`)\n");
                    let _ = writeln!(md, "```\n{content}\n```\n");
                }
                ContentBlock::Thinking { thinking } => {
                    let _ = writeln!(md, "*[thinking]* {thinking}\n");
                }
                ContentBlock::Image { .. } => {
                    let _ = writeln!(md, "*[image]*\n");
                }
            }
        }
    }

    md
}

/// Compute statistics from a message list.
fn compute_stats(messages: &[Message]) -> SessionStats {
    let mut stats = SessionStats {
        message_count: messages.len(),
        ..SessionStats::default()
    };

    for msg in messages {
        match msg.role {
            Role::User => stats.user_messages += 1,
            Role::Assistant => stats.assistant_messages += 1,
            Role::System => {}
        }
        stats.estimated_tokens += msg.estimated_tokens();

        for block in &msg.content {
            match block {
                ContentBlock::ToolUse { name, .. } => {
                    stats.tool_use_count += 1;
                    *stats.tool_calls_by_name.entry(name.clone()).or_insert(0) += 1;
                }
                ContentBlock::ToolResult { is_error, .. } => {
                    stats.tool_result_count += 1;
                    if *is_error {
                        stats.tool_error_count += 1;
                    }
                }
                _ => {}
            }
        }
    }

    stats
}

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

    #[test]
    fn save_and_load_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());

        let messages = vec![Message::user("Hello"), Message::assistant("Hi there!")];
        history.save("test-session", &messages).unwrap();

        let loaded = history.load("test-session").unwrap().unwrap();
        assert_eq!(loaded.len(), 2);
        assert_eq!(loaded[0].text(), "Hello");
        assert_eq!(loaded[1].text(), "Hi there!");
    }

    #[test]
    fn load_nonexistent_returns_none() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        assert!(history.load("nope").unwrap().is_none());
    }

    #[test]
    fn list_sessions_empty() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().join("sessions"));
        assert!(history.list_sessions().unwrap().is_empty());
    }

    #[test]
    fn list_sessions_returns_ids() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());

        history.save("session-b", &[Message::user("b")]).unwrap();
        history.save("session-a", &[Message::user("a")]).unwrap();

        let sessions = history.list_sessions().unwrap();
        assert_eq!(sessions, vec!["session-a", "session-b"]);
    }

    #[test]
    fn extract_summary_empty_is_none() {
        assert!(extract_summary(&[]).is_none());
    }

    #[test]
    fn extract_summary_picks_first_user_message() {
        let msgs = vec![
            Message::assistant("ignore me"),
            Message::user("what's the weather"),
            Message::user("and the news"),
        ];
        let s = extract_summary(&msgs).unwrap();
        assert_eq!(s, "what's the weather");
    }

    #[test]
    fn extract_summary_collapses_whitespace() {
        let msgs = vec![Message::user("line1\n\n  line2\t\ttab")];
        let s = extract_summary(&msgs).unwrap();
        assert_eq!(s, "line1 line2 tab");
    }

    #[test]
    fn extract_summary_truncates_long_text() {
        let long = "x".repeat(200);
        let msgs = vec![Message::user(&long)];
        let s = extract_summary(&msgs).unwrap();
        assert_eq!(s.chars().count(), SUMMARY_MAX_CHARS);
        assert!(s.ends_with('…'));
    }

    #[test]
    fn extract_summary_falls_back_to_non_user_text() {
        let msgs = vec![Message::assistant("only assistant text here")];
        assert_eq!(extract_summary(&msgs).unwrap(), "only assistant text here");
    }

    #[test]
    fn list_sessions_with_metadata_populates_summary() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        history
            .save("s1", &[Message::user("hello world from session one")])
            .unwrap();
        let metas = history.list_sessions_with_metadata().unwrap();
        assert_eq!(metas.len(), 1);
        assert_eq!(
            metas[0].summary.as_deref(),
            Some("hello world from session one")
        );
    }

    #[test]
    fn delete_session() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());

        history.save("to-delete", &[Message::user("x")]).unwrap();
        assert!(history.load("to-delete").unwrap().is_some());

        history.delete("to-delete").unwrap();
        assert!(history.load("to-delete").unwrap().is_none());
    }

    #[test]
    fn delete_nonexistent_is_ok() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        history.delete("nope").unwrap(); // should not error
    }

    #[test]
    fn save_overwrites_existing_session() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());

        history.save("sess", &[Message::user("original")]).unwrap();
        history
            .save(
                "sess",
                &[Message::user("updated"), Message::assistant("ok")],
            )
            .unwrap();

        let loaded = history.load("sess").unwrap().unwrap();
        assert_eq!(loaded.len(), 2);
        assert_eq!(loaded[0].text(), "updated");
    }

    #[test]
    fn save_empty_messages() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        history.save("empty-session", &[]).unwrap();
        let loaded = history.load("empty-session").unwrap().unwrap();
        assert!(loaded.is_empty());
    }

    #[test]
    fn list_sessions_ignores_non_json_files() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());

        history
            .save("valid-session", &[Message::user("hi")])
            .unwrap();
        // Create non-json file
        std::fs::write(dir.path().join("notes.txt"), "not a session").unwrap();

        let sessions = history.list_sessions().unwrap();
        assert_eq!(sessions, vec!["valid-session"]);
    }

    #[test]
    fn list_sessions_nonexistent_dir_returns_empty() {
        let history = SessionHistory::new(std::path::PathBuf::from("/nonexistent/sessions"));
        let sessions = history.list_sessions().unwrap();
        assert!(sessions.is_empty());
    }

    #[test]
    fn save_creates_directory_if_missing() {
        let dir = tempfile::tempdir().unwrap();
        let nested = dir.path().join("deep").join("sessions");
        let history = SessionHistory::new(nested.clone());

        history.save("sess", &[Message::user("test")]).unwrap();
        assert!(nested.join("sess.json").exists());
    }

    #[test]
    fn load_corrupt_json_recovers_and_quarantines() {
        // A corrupt file must not be a hard error: it is quarantined aside and
        // load returns Ok(None) so resume can fall back to a fresh session.
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("bad.json"), "not valid json").unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());

        let result = history.load("bad").unwrap();
        assert!(result.is_none(), "corrupt load should recover to None");
        // The original file is moved aside, not left in place.
        assert!(!dir.path().join("bad.json").exists());
        let quarantined = std::fs::read_dir(dir.path())
            .unwrap()
            .filter_map(Result::ok)
            .any(|e| {
                e.file_name()
                    .to_string_lossy()
                    .starts_with("bad.json.corrupt-")
            });
        assert!(quarantined, "corrupt file should be quarantined aside");
    }

    #[test]
    fn save_is_atomic_no_tmp_left_behind() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        history.save("s1", &[Message::user("hi")]).unwrap();
        // The temp file used for the atomic rename must not linger.
        assert!(!dir.path().join("s1.json.tmp").exists());
        assert!(dir.path().join("s1.json").exists());
    }

    #[test]
    fn multiple_sessions_sorted_by_id() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());

        history.save("z-session", &[Message::user("z")]).unwrap();
        history.save("a-session", &[Message::user("a")]).unwrap();
        history.save("m-session", &[Message::user("m")]).unwrap();

        let sessions = history.list_sessions().unwrap();
        assert_eq!(sessions, vec!["a-session", "m-session", "z-session"]);
    }

    // ── Search tests ───────────────────────────────────────────────

    #[test]
    fn search_session_finds_keyword() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        history
            .save(
                "s1",
                &[
                    Message::user("fix the authentication bug"),
                    Message::assistant("I'll look into the auth module"),
                ],
            )
            .unwrap();

        let results = history.search_session("s1", "auth").unwrap();
        assert_eq!(results.len(), 2);
        assert_eq!(results[0].message_index, 0);
        assert_eq!(results[0].role, Role::User);
        assert_eq!(results[1].message_index, 1);
        assert_eq!(results[1].role, Role::Assistant);
    }

    #[test]
    fn search_session_case_insensitive() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        history.save("s1", &[Message::user("Hello World")]).unwrap();

        let results = history.search_session("s1", "hello world").unwrap();
        assert_eq!(results.len(), 1);
    }

    #[test]
    fn search_session_no_match() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        history
            .save("s1", &[Message::user("something else")])
            .unwrap();

        let results = history.search_session("s1", "nonexistent").unwrap();
        assert!(results.is_empty());
    }

    #[test]
    fn search_session_nonexistent() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        let results = history.search_session("nope", "test").unwrap();
        assert!(results.is_empty());
    }

    #[test]
    fn search_all_across_sessions() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        history
            .save("s1", &[Message::user("deploy to production")])
            .unwrap();
        history
            .save("s2", &[Message::user("no match here")])
            .unwrap();
        history
            .save("s3", &[Message::assistant("deploying now")])
            .unwrap();

        let results = history.search_all("deploy").unwrap();
        assert_eq!(results.len(), 2);
        assert_eq!(results[0].session_id, "s1");
        assert_eq!(results[1].session_id, "s3");
    }

    #[test]
    fn search_matches_tool_use_name() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        let msg = Message::new(
            Role::Assistant,
            vec![ContentBlock::tool_use(
                "t1",
                "bash",
                serde_json::json!({"command": "ls"}),
            )],
        );
        history.save("s1", &[msg]).unwrap();

        let results = history.search_session("s1", "bash").unwrap();
        assert_eq!(results.len(), 1);
    }

    #[test]
    fn search_matches_tool_result_content() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        let msg = Message::tool_result("t1", "file not found: main.rs", true);
        history.save("s1", &[msg]).unwrap();

        let results = history.search_session("s1", "main.rs").unwrap();
        assert_eq!(results.len(), 1);
    }

    #[test]
    fn search_snippet_truncates_long_text() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        let long_text = "keyword ".repeat(50); // ~400 chars
        history.save("s1", &[Message::user(&long_text)]).unwrap();

        let results = history.search_session("s1", "keyword").unwrap();
        assert_eq!(results.len(), 1);
        assert!(results[0].snippet.ends_with("..."));
        assert!(results[0].snippet.len() <= 124); // 120 + "..."
    }

    // ── Export tests ───────────────────────────────────────────────

    #[test]
    fn export_json_roundtrips() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        let msgs = vec![Message::user("Hello"), Message::assistant("Hi")];
        history.save("s1", &msgs).unwrap();

        let json_str = history.export("s1", ExportFormat::Json).unwrap().unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
        assert_eq!(parsed["session_id"], "s1");
        assert_eq!(parsed["messages"].as_array().unwrap().len(), 2);
    }

    #[test]
    fn export_markdown_has_headers() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        let msgs = vec![
            Message::user("What files exist?"),
            Message::new(
                Role::Assistant,
                vec![
                    ContentBlock::text("Let me check."),
                    ContentBlock::tool_use("t1", "bash", serde_json::json!({"command": "ls"})),
                ],
            ),
            Message::tool_result("t1", "main.rs\nlib.rs", false),
        ];
        history.save("s1", &msgs).unwrap();

        let md = history
            .export("s1", ExportFormat::Markdown)
            .unwrap()
            .unwrap();
        assert!(md.contains("# Session: s1"));
        assert!(md.contains("## [0] User"));
        assert!(md.contains("## [1] Assistant"));
        assert!(md.contains("**Tool Use:** `bash`"));
        assert!(md.contains("**Tool Result**"));
        assert!(md.contains("main.rs"));
    }

    #[test]
    fn export_markdown_tool_error() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        let msgs = vec![Message::tool_result("t1", "command failed", true)];
        history.save("s1", &msgs).unwrap();

        let md = history
            .export("s1", ExportFormat::Markdown)
            .unwrap()
            .unwrap();
        assert!(md.contains("**Tool Error**"));
    }

    #[test]
    fn export_nonexistent_returns_none() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        assert!(
            history
                .export("nope", ExportFormat::Json)
                .unwrap()
                .is_none()
        );
    }

    #[test]
    fn export_empty_session() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        history.save("empty", &[]).unwrap();

        let json = history
            .export("empty", ExportFormat::Json)
            .unwrap()
            .unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
        assert!(parsed["messages"].as_array().unwrap().is_empty());

        let md = history
            .export("empty", ExportFormat::Markdown)
            .unwrap()
            .unwrap();
        assert!(md.contains("# Session: empty"));
    }

    #[test]
    fn export_markdown_image_block() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        let msgs = vec![Message::new(
            Role::User,
            vec![ContentBlock::Image {
                source: crab_core::message::ImageSource::base64("image/png", "data"),
            }],
        )];
        history.save("s1", &msgs).unwrap();

        let md = history
            .export("s1", ExportFormat::Markdown)
            .unwrap()
            .unwrap();
        assert!(md.contains("*[image]*"));
    }

    // ── Stats tests ────────────────────────────────────────────────

    #[test]
    fn stats_basic_counts() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        let msgs = vec![
            Message::user("question"),
            Message::assistant("answer"),
            Message::user("follow up"),
            Message::assistant("more info"),
        ];
        history.save("s1", &msgs).unwrap();

        let stats = history.stats("s1").unwrap().unwrap();
        assert_eq!(stats.message_count, 4);
        assert_eq!(stats.user_messages, 2);
        assert_eq!(stats.assistant_messages, 2);
        assert_eq!(stats.tool_use_count, 0);
        assert_eq!(stats.tool_result_count, 0);
    }

    #[test]
    fn stats_tool_counts() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        let msgs = vec![
            Message::user("do something"),
            Message::new(
                Role::Assistant,
                vec![
                    ContentBlock::tool_use("t1", "bash", serde_json::json!({})),
                    ContentBlock::tool_use("t2", "read_file", serde_json::json!({})),
                ],
            ),
            Message::tool_result("t1", "ok", false),
            Message::tool_result("t2", "error!", true),
            Message::new(
                Role::Assistant,
                vec![ContentBlock::tool_use("t3", "bash", serde_json::json!({}))],
            ),
        ];
        history.save("s1", &msgs).unwrap();

        let stats = history.stats("s1").unwrap().unwrap();
        assert_eq!(stats.tool_use_count, 3);
        assert_eq!(stats.tool_result_count, 2);
        assert_eq!(stats.tool_error_count, 1);
        assert_eq!(stats.tool_calls_by_name.get("bash"), Some(&2));
        assert_eq!(stats.tool_calls_by_name.get("read_file"), Some(&1));
    }

    #[test]
    fn stats_estimated_tokens_positive() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        history
            .save(
                "s1",
                &[
                    Message::user("Hello world this is a test"),
                    Message::assistant("Thanks for the message"),
                ],
            )
            .unwrap();

        let stats = history.stats("s1").unwrap().unwrap();
        assert!(stats.estimated_tokens > 0);
    }

    #[test]
    fn stats_nonexistent_returns_none() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        assert!(history.stats("nope").unwrap().is_none());
    }

    #[test]
    fn stats_empty_session() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        history.save("empty", &[]).unwrap();

        let stats = history.stats("empty").unwrap().unwrap();
        assert_eq!(stats.message_count, 0);
        assert_eq!(stats.user_messages, 0);
        assert_eq!(stats.tool_use_count, 0);
        assert_eq!(stats.estimated_tokens, 0);
    }

    #[test]
    fn stats_serializes_to_json() {
        let stats = SessionStats {
            message_count: 10,
            user_messages: 5,
            assistant_messages: 5,
            tool_use_count: 3,
            tool_result_count: 3,
            tool_error_count: 1,
            tool_calls_by_name: HashMap::from([("bash".into(), 2), ("read_file".into(), 1)]),
            estimated_tokens: 500,
        };
        let json = serde_json::to_string(&stats).unwrap();
        assert!(json.contains("\"message_count\":10"));
        assert!(json.contains("\"tool_error_count\":1"));
    }

    // ── Helper function tests ──────────────────────────────────────

    #[test]
    fn compute_stats_system_messages_not_counted_as_user_or_assistant() {
        let msgs = vec![
            Message::system("system prompt"),
            Message::user("hi"),
            Message::assistant("hello"),
        ];
        let stats = compute_stats(&msgs);
        assert_eq!(stats.message_count, 3);
        assert_eq!(stats.user_messages, 1);
        assert_eq!(stats.assistant_messages, 1);
    }

    #[test]
    fn find_matches_empty_messages() {
        let results = find_matches("s1", &[], "test");
        assert!(results.is_empty());
    }

    #[test]
    fn export_markdown_system_message() {
        let md = export_markdown("s1", &[Message::system("Be helpful")]);
        assert!(md.contains("## [0] System"));
        assert!(md.contains("Be helpful"));
    }

    // ── find_latest_for_dir tests ─────────────────────────────────

    #[test]
    fn find_latest_for_dir_empty_returns_none() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().join("sessions"));
        assert!(
            history
                .find_latest_for_dir(std::path::Path::new("/tmp"))
                .is_none()
        );
    }

    #[test]
    fn find_latest_for_dir_returns_most_recent() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());

        history
            .save("session-old", &[Message::user("old")])
            .unwrap();
        // Small delay to ensure different mtime
        std::thread::sleep(std::time::Duration::from_millis(50));
        history
            .save("session-new", &[Message::user("new")])
            .unwrap();

        // Neither records a working_dir → falls back to the global newest.
        let latest = history
            .find_latest_for_dir(std::path::Path::new("/tmp"))
            .unwrap();
        assert_eq!(latest, "session-new");
    }

    #[test]
    fn find_latest_for_dir_prefers_matching_working_dir() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        let proj_a = dir.path().join("a");
        let proj_b = dir.path().join("b");
        std::fs::create_dir_all(&proj_a).unwrap();
        std::fs::create_dir_all(&proj_b).unwrap();

        // Newest session is in project B; an older one is in project A.
        history
            .save_with_metadata(
                "in-a",
                None,
                Some(proj_a.to_str().unwrap()),
                &[Message::user("a")],
                &[],
            )
            .unwrap();
        std::thread::sleep(std::time::Duration::from_millis(50));
        history
            .save_with_metadata(
                "in-b",
                None,
                Some(proj_b.to_str().unwrap()),
                &[Message::user("b")],
                &[],
            )
            .unwrap();

        // --continue from project A resumes the A session, not the newer B one.
        assert_eq!(history.find_latest_for_dir(&proj_a).unwrap(), "in-a");
        assert_eq!(history.find_latest_for_dir(&proj_b).unwrap(), "in-b");
        // An unrelated dir falls back to the global newest.
        let other = dir.path().join("c");
        std::fs::create_dir_all(&other).unwrap();
        assert_eq!(history.find_latest_for_dir(&other).unwrap(), "in-b");
    }

    // ── Session metadata tests ────────────────────────────────────

    #[test]
    fn save_with_metadata_stores_name() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());

        history
            .save_with_metadata(
                "s1",
                Some("my feature"),
                Some("/tmp/project"),
                &[Message::user("hi")],
                &[],
            )
            .unwrap();

        // Load raw file to verify name is stored
        let data = std::fs::read_to_string(dir.path().join("s1.json")).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&data).unwrap();
        assert_eq!(parsed["name"], "my feature");
        assert_eq!(parsed["working_dir"], "/tmp/project");
    }

    #[test]
    fn save_with_metadata_none_name() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());

        history
            .save_with_metadata("s1", None, None, &[Message::user("hi")], &[])
            .unwrap();

        let data = std::fs::read_to_string(dir.path().join("s1.json")).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&data).unwrap();
        assert!(parsed.get("name").is_none());
    }

    #[test]
    fn list_sessions_with_metadata_returns_names() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());

        history
            .save_with_metadata("s1", Some("bug fix"), None, &[Message::user("a")], &[])
            .unwrap();
        history
            .save("s2", &[Message::user("b"), Message::assistant("c")])
            .unwrap();

        let metas = history.list_sessions_with_metadata().unwrap();
        assert_eq!(metas.len(), 2);

        let s1 = metas.iter().find(|m| m.session_id == "s1").unwrap();
        assert_eq!(s1.name.as_deref(), Some("bug fix"));
        assert_eq!(s1.message_count, 1);

        let s2 = metas.iter().find(|m| m.session_id == "s2").unwrap();
        assert!(s2.name.is_none());
        assert_eq!(s2.message_count, 2);
    }

    #[test]
    fn list_sessions_with_metadata_empty_dir() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().join("sessions"));
        assert!(history.list_sessions_with_metadata().unwrap().is_empty());
    }

    #[test]
    fn save_with_metadata_backward_compatible_load() {
        // Sessions saved with metadata can still be loaded by plain load()
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());

        history
            .save_with_metadata(
                "s1",
                Some("named"),
                Some("/proj"),
                &[Message::user("test")],
                &[],
            )
            .unwrap();

        let msgs = history.load("s1").unwrap().unwrap();
        assert_eq!(msgs.len(), 1);
        assert_eq!(msgs[0].text(), "test");
    }

    #[test]
    fn load_legacy_session_without_name_field() {
        // Sessions saved without name/working_dir (old format) should still load
        let dir = tempfile::tempdir().unwrap();
        let legacy_json = r#"{"session_id":"legacy","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}"#;
        std::fs::write(dir.path().join("legacy.json"), legacy_json).unwrap();

        let history = SessionHistory::new(dir.path().to_path_buf());
        let msgs = history.load("legacy").unwrap().unwrap();
        assert_eq!(msgs.len(), 1);

        let metas = history.list_sessions_with_metadata().unwrap();
        assert_eq!(metas.len(), 1);
        assert!(metas[0].name.is_none());
    }

    // ── Grants persistence tests ───────────────────────────────────

    #[test]
    fn save_with_grants_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());

        let grants = vec!["bash".to_string(), "edit".to_string()];
        history
            .save_with_grants("s1", &[Message::user("hi")], &grants)
            .unwrap();

        let (msgs, loaded_grants) = history.load_with_grants("s1").unwrap().unwrap();
        assert_eq!(msgs.len(), 1);
        assert_eq!(loaded_grants, grants);
    }

    #[test]
    fn load_grants_returns_empty_for_missing_session() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        assert!(history.load_grants("nope").unwrap().is_empty());
    }

    #[test]
    fn load_grants_returns_empty_for_legacy_file_without_grants_field() {
        let dir = tempfile::tempdir().unwrap();
        let legacy_json = r#"{"session_id":"legacy","messages":[]}"#;
        std::fs::write(dir.path().join("legacy.json"), legacy_json).unwrap();

        let history = SessionHistory::new(dir.path().to_path_buf());
        assert!(history.load_grants("legacy").unwrap().is_empty());

        // load_with_grants should also succeed and return empty grants.
        let (msgs, grants) = history.load_with_grants("legacy").unwrap().unwrap();
        assert!(msgs.is_empty());
        assert!(grants.is_empty());
    }

    #[test]
    fn save_preserves_existing_grants_when_no_grants_supplied() {
        // Auto-save (which doesn't have grants in scope) must not wipe a
        // previously persisted grants set — otherwise every turn would
        // silently revoke the user's "always allow" decisions.
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());

        let initial = vec!["bash".to_string()];
        history
            .save_with_grants("s1", &[Message::user("first")], &initial)
            .unwrap();

        // Plain save: must not clobber the on-disk grants.
        history
            .save("s1", &[Message::user("first"), Message::assistant("ack")])
            .unwrap();

        let grants = history.load_grants("s1").unwrap();
        assert_eq!(grants, initial);
    }

    #[test]
    fn empty_grants_skipped_in_serialized_output() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());

        history.save("s1", &[Message::user("hi")]).unwrap();

        let data = std::fs::read_to_string(dir.path().join("s1.json")).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&data).unwrap();
        assert!(
            parsed.get("grants").is_none(),
            "empty grants should be omitted from disk; got: {data}"
        );
    }

    // ── JSONL tests ───────────────────────────────────────────────

    #[test]
    fn jsonl_append_and_load_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());

        history.append_jsonl("s1", &Message::user("hello")).unwrap();
        history
            .append_jsonl("s1", &Message::assistant("hi"))
            .unwrap();

        let msgs = history.load_jsonl("s1").unwrap().unwrap();
        assert_eq!(msgs.len(), 2);
        assert_eq!(msgs[0].text(), "hello");
        assert_eq!(msgs[1].text(), "hi");
    }

    #[test]
    fn jsonl_load_nonexistent_returns_none() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        assert!(history.load_jsonl("nope").unwrap().is_none());
    }

    #[test]
    fn load_for_resume_appends_crash_log_tail() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        // Snapshot holds one completed turn.
        history
            .save("s1", &[Message::user("turn1"), Message::assistant("ok")])
            .unwrap();
        // A crash left an in-flight turn in the jsonl log.
        history.append_jsonl("s1", &Message::user("turn2")).unwrap();

        let (messages, _grants) = history.load_for_resume("s1").unwrap().unwrap();
        assert_eq!(messages.len(), 3);
        assert_eq!(messages[2].text(), "turn2");
    }

    #[test]
    fn load_for_resume_snapshot_only_when_log_empty() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        history.save("s1", &[Message::user("only")]).unwrap();
        let (messages, _) = history.load_for_resume("s1").unwrap().unwrap();
        assert_eq!(messages.len(), 1);
    }

    #[test]
    fn truncate_jsonl_clears_crash_log() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        history.append_jsonl("s1", &Message::user("x")).unwrap();
        history.truncate_jsonl("s1").unwrap();
        assert!(history.load_jsonl("s1").unwrap().is_none());
    }

    #[test]
    fn save_and_load_cost_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        let cost = crate::cost::CostSummary {
            input_tokens: 100,
            output_tokens: 50,
            cache_read_tokens: 10,
            cache_creation_tokens: 5,
            total_cost_usd: 0.0123,
            api_calls: 3,
        };
        history.save_cost("s1", &cost).unwrap();
        let loaded = history.load_cost("s1").expect("cost should load");
        assert_eq!(loaded.input_tokens, 100);
        assert_eq!(loaded.api_calls, 3);
    }

    #[test]
    fn load_cost_missing_is_none() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        assert!(history.load_cost("nope").is_none());
    }

    #[test]
    fn cleanup_older_than_removes_stale_and_keeps_recent() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        history.save("recent", &[Message::user("new")]).unwrap();

        // Backdate one session file well past the retention window.
        let old_path = dir.path().join("old.json");
        std::fs::write(&old_path, r#"{"session_id":"old","messages":[]}"#).unwrap();
        let stale = std::time::SystemTime::now() - std::time::Duration::from_secs(40 * 86_400);
        let f = std::fs::OpenOptions::new()
            .write(true)
            .open(&old_path)
            .unwrap();
        f.set_modified(stale).unwrap();
        drop(f);

        let removed = history.cleanup_older_than(30).unwrap();
        assert_eq!(removed, 1);
        assert!(!old_path.exists());
        assert!(dir.path().join("recent.json").exists());
    }

    #[test]
    fn cleanup_older_than_zero_is_disabled() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());
        history.save("s1", &[Message::user("hi")]).unwrap();
        assert_eq!(history.cleanup_older_than(0).unwrap(), 0);
        assert!(dir.path().join("s1.json").exists());
    }

    #[test]
    fn jsonl_skips_malformed_lines() {
        let dir = tempfile::tempdir().unwrap();
        let history = SessionHistory::new(dir.path().to_path_buf());

        history.append_jsonl("s1", &Message::user("good")).unwrap();
        // Simulate a partial write (crash mid-line)
        let path = dir.path().join("s1.jsonl");
        std::fs::OpenOptions::new()
            .append(true)
            .open(&path)
            .unwrap()
            .write_all(b"{\"truncated\n")
            .unwrap();
        history
            .append_jsonl("s1", &Message::assistant("also good"))
            .unwrap();

        let msgs = history.load_jsonl("s1").unwrap().unwrap();
        assert_eq!(msgs.len(), 2);
        assert_eq!(msgs[0].text(), "good");
        assert_eq!(msgs[1].text(), "also good");
    }

    #[test]
    fn jsonl_persister_via_trait() {
        let dir = tempfile::tempdir().unwrap();
        let history = Arc::new(SessionHistory::new(dir.path().to_path_buf()));
        let persister = history.persister("s1".into());

        persister.persist_message(&Message::user("from trait"));
        persister.persist_message(&Message::assistant("reply"));

        let msgs = history.load_jsonl("s1").unwrap().unwrap();
        assert_eq!(msgs.len(), 2);
    }
}
