//! autoDream gating and execution logic.
//!
//! Determines whether a dream consolidation should fire based on
//! config gates, time elapsed, and session count. Provides prompt
//! construction, response processing, and lock lifecycle management
//! for the dream consolidation pass.

use std::path::Path;
use std::time::SystemTime;

use super::dream_lock::DreamLock;
use kigi_config_types::MemoryDreamConfig;

/// Result of the dream gate check.
#[derive(Debug, PartialEq, Eq)]
pub enum DreamGate {
    /// All gates passed — dream should fire.
    Open {
        /// Sessions to consolidate (file stems for the prompt).
        sessions: Vec<String>,
    },
    /// Dream is disabled by config.
    Disabled,
    /// Not enough time has passed since last consolidation.
    TooSoon { hours_since: u64 },
    /// Not enough sessions since last consolidation.
    TooFewSessions { count: usize, required: u64 },
    /// Lock acquisition or I/O failed.
    Error(String),
}

/// Check all dream gates (cheapest first).
///
/// Gate order:
/// 1. Config: `dream.enabled` must be true
/// 2. Time: hours since last consolidation >= `dream.min_hours`
/// 3. Sessions: session count since last consolidation >= `dream.min_sessions`
///
/// Does NOT acquire the lock — callers should acquire after confirming `Open`.
pub fn check_dream_gates(
    config: &MemoryDreamConfig,
    lock: &DreamLock,
    sessions_dir: &Path,
    current_session_sid8: Option<&str>,
) -> DreamGate {
    if !config.enabled {
        return DreamGate::Disabled;
    }

    // Time gate
    let last_at = match lock.last_consolidated_at() {
        Ok(Some(t)) => t,
        Ok(None) => SystemTime::UNIX_EPOCH,
        Err(e) => return DreamGate::Error(e.to_string()),
    };
    let elapsed = SystemTime::now()
        .duration_since(last_at)
        .unwrap_or_default();
    let hours_since = elapsed.as_secs() / 3600;
    if hours_since < config.min_hours {
        return DreamGate::TooSoon { hours_since };
    }

    // Session gate
    let sessions =
        match super::dream_lock::sessions_since(sessions_dir, last_at, current_session_sid8) {
            Ok(s) => s,
            Err(e) => return DreamGate::Error(e.to_string()),
        };
    if (sessions.len() as u64) < config.min_sessions {
        return DreamGate::TooFewSessions {
            count: sessions.len(),
            required: config.min_sessions,
        };
    }

    DreamGate::Open { sessions }
}

// ---------------------------------------------------------------------------
// Dream prompt, response processing, and execution
// ---------------------------------------------------------------------------

use super::text_utils::{has_markdown_headers, is_no_reply};

const LOG: &str = "xai_memory";

pub const DREAM_SYSTEM_PROMPT: &str = "\
You are performing a dream \u{2014} a reflective pass over memory files. \
Synthesize recent session logs into durable, well-organized memories \
so future sessions orient quickly.

You will receive the contents of recent session logs. \
You may also receive an existing memory document \u{2014} merge it with new sessions \
rather than discarding prior knowledge. Your job:

1. **Merge** related information into coherent topic summaries
2. **Resolve** contradictions \u{2014} if a recent session disproves an older fact, keep only the current truth
3. **Convert** relative dates (\"yesterday\", \"last week\") to absolute dates
4. **Discard** ephemeral details:
   - Greetings, meta-commentary, tool output noise
   - Message counts and tool-usage statistics
   - 'Current state' and 'Next steps' sections
   - User preferences already in global memory (OS, shell, paths)
   - Session metadata (dates, message counts)
5. **Preserve** decisions, rationale, architecture, preferences, and problem/solution pairs

Respond with a single markdown document. Use ## headers to separate topics. \
Each topic should be self-contained and useful to a future session that knows \
nothing about the current conversation.

If the session logs contain nothing worth persisting, respond with NO_REPLY.";

/// Outcome of a dream consolidation attempt.
#[derive(Debug)]
pub struct DreamResult {
    pub status: DreamStatus,
    /// Number of gate-eligible sessions (total sessions passed to the dream,
    /// including those beyond the 32K input cap that were not actually read).
    pub sessions_eligible: usize,
    /// File stems that were actually deleted after a successful consolidation.
    /// Empty for non-`Completed` statuses. The caller uses this to remove
    /// the corresponding chunks from the search index — only stems that were
    /// truly removed from disk should have their index entries purged.
    pub cleaned_stems: Vec<String>,
}

#[derive(Debug, PartialEq, Eq)]
pub enum DreamStatus {
    /// Gates didn't pass \u{2014} no dream attempted.
    Skipped(String),
    /// Dream ran and produced consolidated output.
    Completed { chars_written: usize },
    /// Dream ran but model returned nothing useful.
    NothingToConsolidate,
    /// Dream failed.
    Failed(String),
}

const MAX_DREAM_INPUT_CHARS: usize = 32_000;

/// Output of [`build_dream_user_message`]: the prompt text and the stems
/// that were actually read (within the size cap).
#[derive(Debug)]
pub struct DreamMessage {
    /// The concatenated session content for the model prompt.
    pub content: String,
    /// File stems that were successfully read and included. Only these
    /// sessions should be cleaned up after a successful consolidation —
    /// stems beyond the [`MAX_DREAM_INPUT_CHARS`] cap are deliberately
    /// excluded so their content is preserved for a future dream pass.
    pub processed_stems: Vec<String>,
}

/// Returns `true` if the content is scaffold boilerplate that should not be
/// fed to the dream model as existing memory context.
///
/// A file is scaffold only if it is short (< 500 bytes trimmed) AND contains
/// a scaffold marker. Files with substantial content are never scaffold, even
/// if they contain leftover marker strings from the initial template.
pub(crate) fn is_scaffold_template(content: &str) -> bool {
    const SCAFFOLD_MAX_LEN: usize = 500;
    const MARKERS: &[&str] = &[
        "Auto-populated by dream consolidation",
        "Add project-specific knowledge here",
        "Add any cross-project preferences here",
    ];
    let trimmed = content.trim();
    trimmed.len() < SCAFFOLD_MAX_LEN && MARKERS.iter().any(|marker| trimmed.contains(marker))
}

/// Build the user message for the dream model call from session log contents.
///
/// When `existing_memory` is provided and is not scaffold boilerplate,
/// it is prepended before session logs so the model can merge prior
/// knowledge with new sessions.
///
/// Reads each session file and concatenates their contents with separators.
/// Stops adding sessions once total size exceeds [`MAX_DREAM_INPUT_CHARS`].
/// Returns `None` if no session files could be read.
pub fn build_dream_user_message(
    sessions_dir: &Path,
    stems: &[String],
    existing_memory: Option<&str>,
) -> Option<DreamMessage> {
    let existing_len = existing_memory.map_or(0, str::len);
    let mut buf = String::with_capacity(existing_len + stems.len().min(10) * 2000);

    if let Some(mem) = existing_memory {
        let trimmed = mem.trim();
        if !trimmed.is_empty() && !is_scaffold_template(trimmed) {
            buf.push_str("--- Existing Memory (merge with new sessions) ---\n\n");
            let cap = MAX_DREAM_INPUT_CHARS / 2;
            if trimmed.len() <= cap {
                buf.push_str(trimmed);
            } else {
                let mut end = cap;
                while end > 0 && !trimmed.is_char_boundary(end) {
                    end -= 1;
                }
                buf.push_str(&trimmed[..end]);
                tracing::warn!(
                    target: LOG,
                    original = trimmed.len(),
                    cap,
                    "DREAM_BUILD_MESSAGE: existing memory truncated"
                );
            }
        }
    }

    let mut processed_stems = Vec::with_capacity(stems.len());
    for stem in stems {
        let path = sessions_dir.join(format!("{stem}.md"));
        if let Ok(content) = std::fs::read_to_string(&path)
            && !content.trim().is_empty()
        {
            if !buf.is_empty() {
                buf.push_str("\n\n");
            }
            buf.push_str("--- Session: ");
            buf.push_str(stem);
            buf.push_str(" ---\n\n");
            buf.push_str(&content);
            processed_stems.push(stem.clone());

            if buf.len() >= MAX_DREAM_INPUT_CHARS {
                tracing::warn!(
                    target: LOG,
                    sessions_read = processed_stems.len(),
                    total = stems.len(),
                    chars = buf.len(),
                    "DREAM_BUILD_MESSAGE: truncated input at {MAX_DREAM_INPUT_CHARS} chars"
                );
                break;
            }
        }
    }
    if processed_stems.is_empty() {
        tracing::debug!(target: LOG, "DREAM_BUILD_MESSAGE: no readable session files");
        return None;
    }
    tracing::info!(
        target: LOG,
        sessions_read = processed_stems.len(),
        total = stems.len(),
        chars = buf.len(),
        "DREAM_BUILD_MESSAGE: built user message"
    );
    Some(DreamMessage {
        content: buf,
        processed_stems,
    })
}

/// Output cap for dream responses. Hardcoded for v1; can be moved to
/// `MemoryDreamConfig` if operational tuning is needed.
const MAX_DREAM_CHARS: usize = 16_000;

/// Process the dream model's response.
///
/// Returns the processed content ready for writing, or `None` if:
/// - Response is empty/whitespace
/// - Response matches the `NO_REPLY` pattern
/// - Response lacks markdown heading structure
///
/// Dream output that passes the quality check below contains markdown
/// headers (`# ` or `## `). Since `write_long_term` writes content
/// directly without normalization, dream's markdown structure is
/// preserved as-is.
///
/// Truncates content exceeding [`MAX_DREAM_CHARS`].
pub fn process_dream_response(response: &str) -> Option<String> {
    let trimmed = response.trim();
    if trimmed.is_empty() {
        tracing::info!(target: LOG, "DREAM_RESPONSE: empty");
        return None;
    }

    if is_no_reply(trimmed) {
        tracing::info!(target: LOG, "DREAM_RESPONSE: NO_REPLY");
        return None;
    }

    if !has_markdown_headers(trimmed) {
        tracing::info!(
            target: LOG,
            len = trimmed.len(),
            "DREAM_RESPONSE: no markdown headers, rejected"
        );
        return None;
    }

    let char_count = trimmed.chars().count();
    let (content, accepted_chars) = if char_count > MAX_DREAM_CHARS {
        tracing::warn!(
            target: LOG,
            original = char_count,
            limit = MAX_DREAM_CHARS,
            "DREAM_RESPONSE: truncated"
        );
        (
            trimmed.chars().take(MAX_DREAM_CHARS).collect(),
            MAX_DREAM_CHARS,
        )
    } else {
        (trimmed.to_string(), char_count)
    };

    tracing::info!(
        target: LOG,
        chars = accepted_chars,
        "DREAM_RESPONSE: accepted"
    );
    Some(content)
}

/// Minimum age (in seconds) a session file must have before cleanup will
/// delete it. Protects against removing files that a concurrent session
/// may still be actively appending to.
const CLEANUP_RECENCY_GUARD_SECS: u64 = 300; // 5 minutes

/// Delete session log files whose stems were processed during dream.
///
/// Returns the stems that were actually removed from disk. The caller
/// uses this list to purge the corresponding search-index entries —
/// stems skipped by the recency guard or that failed to delete are
/// excluded so their index chunks remain intact.
///
/// Logs warnings for individual deletion failures but never propagates
/// errors \u{2014} the consolidation has already succeeded at this point.
///
/// Files modified within the last [`CLEANUP_RECENCY_GUARD_SECS`] are
/// skipped to avoid deleting logs that a concurrent session is still
/// actively writing to.
fn clean_processed_sessions(sessions_dir: &Path, stems: &[String]) -> Vec<String> {
    let mut cleaned = Vec::new();
    let now = SystemTime::now();
    for stem in stems {
        let path = sessions_dir.join(format!("{stem}.md"));

        // Recency guard: skip files modified very recently — a
        // concurrent session may be actively appending.
        if let Ok(meta) = std::fs::metadata(&path)
            && let Ok(mtime) = meta.modified()
            && now.duration_since(mtime).unwrap_or_default().as_secs() < CLEANUP_RECENCY_GUARD_SECS
        {
            tracing::debug!(
                target: LOG,
                path = %path.display(),
                "DREAM_CLEANUP: skipping recently-modified session file"
            );
            continue;
        }

        match std::fs::remove_file(&path) {
            Ok(()) => cleaned.push(stem.clone()),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                // Already gone \u{2014} not an error, but don't count as removed.
            }
            Err(e) => {
                tracing::warn!(
                    target: LOG,
                    path = %path.display(),
                    error = %e,
                    "DREAM_CLEANUP: failed to delete session file"
                );
            }
        }
    }
    if !cleaned.is_empty() {
        tracing::info!(
            target: LOG,
            cleaned = cleaned.len(),
            total = stems.len(),
            "DREAM_CLEANUP: removed processed session files"
        );
    }
    cleaned
}

/// Execute the dream lifecycle around a provided model response.
///
/// This is the "pure logic" half of dream execution \u{2014} the session actor
/// handles the actual model call and passes the response here.
///
/// Steps:
/// 1. Acquire lock
/// 2. Process response
/// 3. Overwrite workspace MEMORY.md
/// 4. Clean up processed session files (on success only)
/// 5. On success: leave lock mtime (records consolidation)
/// 6. On failure: rollback lock
pub fn execute_dream(
    lock: &DreamLock,
    storage: &super::storage::MemoryStorage,
    response: &str,
    sessions_eligible: usize,
    stale_lock_secs: u64,
    sessions_dir: &Path,
    processed_stems: &[String],
) -> DreamResult {
    let prior = match lock.try_acquire(stale_lock_secs) {
        Ok(Some(prior)) => {
            tracing::info!(target: LOG, "DREAM_EXECUTE: lock acquired");
            prior
        }
        Ok(None) => {
            tracing::info!(target: LOG, "DREAM_EXECUTE: lock held by another process, skipping");
            return DreamResult {
                status: DreamStatus::Skipped("lock held by another process".into()),
                sessions_eligible: 0,
                cleaned_stems: Vec::new(),
            };
        }
        Err(e) => {
            tracing::warn!(target: LOG, error = %e, "DREAM_EXECUTE: lock acquire failed");
            return DreamResult {
                status: DreamStatus::Failed(format!("lock acquire failed: {e}")),
                sessions_eligible: 0,
                cleaned_stems: Vec::new(),
            };
        }
    };

    let content = match process_dream_response(response) {
        Some(c) => c,
        None => {
            tracing::info!(target: LOG, sessions_eligible, "DREAM_EXECUTE: nothing to consolidate");
            return DreamResult {
                status: DreamStatus::NothingToConsolidate,
                sessions_eligible,
                cleaned_stems: Vec::new(),
            };
        }
    };

    let chars_written = content.chars().count();
    if let Err(e) = storage.write_long_term(super::storage::MemoryScope::Workspace, &content) {
        let _ = lock.rollback(prior);
        tracing::warn!(target: LOG, error = %e, "DREAM_EXECUTE: write failed, lock rolled back");
        return DreamResult {
            status: DreamStatus::Failed(format!("failed to write MEMORY.md: {e}")),
            sessions_eligible,
            cleaned_stems: Vec::new(),
        };
    }

    // Consolidation succeeded — clean up the session files that were read.
    let cleaned_stems = clean_processed_sessions(sessions_dir, processed_stems);

    tracing::info!(
        target: LOG,
        chars_written,
        sessions_eligible,
        sessions_cleaned = cleaned_stems.len(),
        "DREAM_EXECUTE: completed"
    );
    DreamResult {
        status: DreamStatus::Completed { chars_written },
        sessions_eligible,
        cleaned_stems,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use filetime::FileTime;
    use std::fs;
    use std::time::Duration;
    use tempfile::TempDir;

    // Mirrors `dream_lock::tests::write_session` — kept local to avoid
    // cross-module test coupling for a trivial 5-line helper.
    fn write_session(dir: &Path, name: &str, age_secs: u64) {
        fs::create_dir_all(dir).unwrap();
        let path = dir.join(format!("{name}.md"));
        fs::write(&path, "test").unwrap();
        let t = SystemTime::now() - Duration::from_secs(age_secs);
        filetime::set_file_mtime(&path, FileTime::from_system_time(t)).unwrap();
    }

    fn enabled_config() -> MemoryDreamConfig {
        MemoryDreamConfig {
            enabled: true,
            min_hours: 24,
            min_sessions: 5,
            stale_lock_secs: 3600,
            check_interval_secs: None,
        }
    }

    fn set_consolidation_age(dir: &Path, age_secs: u64) {
        let lock_path = dir.join(".dream-lock");
        fs::write(&lock_path, "").unwrap();
        let t = SystemTime::now() - Duration::from_secs(age_secs);
        filetime::set_file_mtime(&lock_path, FileTime::from_system_time(t)).unwrap();
    }

    #[test]
    fn disabled_config_returns_disabled() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());
        let sessions = dir.path().join("sessions");

        let config = MemoryDreamConfig {
            enabled: false,
            ..enabled_config()
        };
        assert_eq!(
            check_dream_gates(&config, &lock, &sessions, None),
            DreamGate::Disabled
        );
    }

    #[test]
    fn too_soon_when_recently_consolidated() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());

        lock.record_consolidation().unwrap();

        let sessions = dir.path().join("sessions");
        let config = enabled_config();
        let result = check_dream_gates(&config, &lock, &sessions, None);
        match result {
            DreamGate::TooSoon { hours_since } => {
                assert!(hours_since < config.min_hours);
            }
            other => panic!("expected TooSoon, got {other:?}"),
        }
    }

    #[test]
    fn too_few_sessions_when_under_threshold() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());
        let sessions = dir.path().join("sessions");

        set_consolidation_age(dir.path(), 48 * 3600);

        for i in 0..3 {
            write_session(&sessions, &format!("2026-01-0{i}-proj-aaa{i:05}"), 100);
        }

        let config = enabled_config();
        let result = check_dream_gates(&config, &lock, &sessions, None);
        assert_eq!(
            result,
            DreamGate::TooFewSessions {
                count: 3,
                required: 5,
            }
        );
    }

    #[test]
    fn all_gates_pass_returns_open_with_sessions() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());
        let sessions = dir.path().join("sessions");

        set_consolidation_age(dir.path(), 48 * 3600);

        for i in 0..6 {
            write_session(&sessions, &format!("2026-01-{i:02}-proj-aaa{i:05}"), 100);
        }

        let config = enabled_config();
        let result = check_dream_gates(&config, &lock, &sessions, None);
        match result {
            DreamGate::Open { sessions } => {
                assert_eq!(sessions.len(), 6);
                assert!(sessions.windows(2).all(|w| w[0] <= w[1]));
            }
            other => panic!("expected Open, got {other:?}"),
        }
    }

    #[test]
    fn no_prior_consolidation_treats_as_epoch() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());
        let sessions = dir.path().join("sessions");

        for i in 0..5 {
            write_session(&sessions, &format!("2026-01-0{i}-proj-aaa{i:05}"), 100);
        }

        let config = enabled_config();
        let result = check_dream_gates(&config, &lock, &sessions, None);
        match result {
            DreamGate::Open { sessions } => assert_eq!(sessions.len(), 5),
            other => panic!("expected Open, got {other:?}"),
        }
    }

    #[test]
    fn nonexistent_sessions_dir_returns_too_few() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());
        let sessions = dir.path().join("nonexistent");

        let config = enabled_config();
        let result = check_dream_gates(&config, &lock, &sessions, None);
        assert_eq!(
            result,
            DreamGate::TooFewSessions {
                count: 0,
                required: 5,
            }
        );
    }

    #[test]
    fn current_session_excluded_from_count() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());
        let sessions = dir.path().join("sessions");

        set_consolidation_age(dir.path(), 48 * 3600);

        for i in 0..4 {
            write_session(&sessions, &format!("2026-01-0{i}-proj-aaa{i:05}"), 100);
        }
        write_session(&sessions, "2026-01-05-proj-current1", 100);

        let config = enabled_config();
        let result = check_dream_gates(&config, &lock, &sessions, Some("current1"));
        assert_eq!(
            result,
            DreamGate::TooFewSessions {
                count: 4,
                required: 5,
            }
        );
    }

    #[test]
    fn min_hours_boundary_exact_is_too_soon() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());
        let sessions = dir.path().join("sessions");

        let config = MemoryDreamConfig {
            min_hours: 24,
            ..enabled_config()
        };
        set_consolidation_age(dir.path(), 23 * 3600 + 59 * 60);

        let result = check_dream_gates(&config, &lock, &sessions, None);
        match result {
            DreamGate::TooSoon { hours_since } => assert_eq!(hours_since, 23),
            other => panic!("expected TooSoon, got {other:?}"),
        }
    }

    #[test]
    fn min_sessions_boundary_exact_passes() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());
        let sessions = dir.path().join("sessions");

        let config = MemoryDreamConfig {
            min_sessions: 3,
            ..enabled_config()
        };
        for i in 0..3 {
            write_session(&sessions, &format!("2026-01-0{i}-proj-aaa{i:05}"), 100);
        }

        let result = check_dream_gates(&config, &lock, &sessions, None);
        match result {
            DreamGate::Open { sessions } => assert_eq!(sessions.len(), 3),
            other => panic!("expected Open, got {other:?}"),
        }
    }

    #[test]
    fn min_sessions_boundary_one_below_fails() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());
        let sessions = dir.path().join("sessions");

        let config = MemoryDreamConfig {
            min_sessions: 3,
            ..enabled_config()
        };
        for i in 0..2 {
            write_session(&sessions, &format!("2026-01-0{i}-proj-aaa{i:05}"), 100);
        }

        let result = check_dream_gates(&config, &lock, &sessions, None);
        assert_eq!(
            result,
            DreamGate::TooFewSessions {
                count: 2,
                required: 3,
            }
        );
    }

    // -------------------------------------------------------------------
    // build_dream_user_message tests
    // -------------------------------------------------------------------

    fn write_session_content(dir: &Path, name: &str, content: &str) {
        fs::create_dir_all(dir).unwrap();
        fs::write(dir.join(format!("{name}.md")), content).unwrap();
    }

    /// Write a session file and back-date its mtime so it passes the
    /// recency guard in `clean_processed_sessions`.
    fn write_old_session_content(dir: &Path, name: &str, content: &str) {
        write_session_content(dir, name, content);
        let old = SystemTime::now() - Duration::from_secs(CLEANUP_RECENCY_GUARD_SECS + 60);
        let path = dir.join(format!("{name}.md"));
        filetime::set_file_mtime(&path, FileTime::from_system_time(old)).unwrap();
    }

    #[test]
    fn build_message_with_valid_sessions() {
        let dir = TempDir::new().unwrap();
        let sessions = dir.path().join("sessions");
        write_session_content(&sessions, "sess-a", "Decision: use Rust");
        write_session_content(&sessions, "sess-b", "Architecture: event-driven");

        let stems = vec!["sess-a".to_string(), "sess-b".to_string()];
        let msg = build_dream_user_message(&sessions, &stems, None).unwrap();

        assert!(msg.content.contains("--- Session: sess-a ---"));
        assert!(msg.content.contains("Decision: use Rust"));
        assert!(msg.content.contains("--- Session: sess-b ---"));
        assert!(msg.content.contains("Architecture: event-driven"));
        assert_eq!(msg.processed_stems, vec!["sess-a", "sess-b"]);
    }

    #[test]
    fn build_message_skips_empty_sessions() {
        let dir = TempDir::new().unwrap();
        let sessions = dir.path().join("sessions");
        write_session_content(&sessions, "real", "Some content");
        write_session_content(&sessions, "empty", "   ");

        let stems = vec!["real".to_string(), "empty".to_string()];
        let msg = build_dream_user_message(&sessions, &stems, None).unwrap();

        assert!(msg.content.contains("--- Session: real ---"));
        assert!(!msg.content.contains("--- Session: empty ---"));
        assert_eq!(msg.processed_stems, vec!["real"]);
    }

    #[test]
    fn build_message_returns_none_for_no_readable_files() {
        let dir = TempDir::new().unwrap();
        let sessions = dir.path().join("sessions");
        fs::create_dir_all(&sessions).unwrap();

        let stems = vec!["missing-a".to_string(), "missing-b".to_string()];
        assert!(build_dream_user_message(&sessions, &stems, None).is_none());
    }

    #[test]
    fn build_message_mixed_readable_and_missing() {
        let dir = TempDir::new().unwrap();
        let sessions = dir.path().join("sessions");
        write_session_content(&sessions, "exists", "Important info");

        let stems = vec!["exists".to_string(), "ghost".to_string()];
        let msg = build_dream_user_message(&sessions, &stems, None).unwrap();

        assert!(msg.content.contains("Important info"));
        assert!(!msg.content.contains("ghost"));
        assert_eq!(msg.processed_stems, vec!["exists"]);
    }

    // -------------------------------------------------------------------
    // process_dream_response tests
    // -------------------------------------------------------------------

    #[test]
    fn process_empty_response_returns_none() {
        assert!(process_dream_response("").is_none());
        assert!(process_dream_response("   ").is_none());
        assert!(process_dream_response("\n\n").is_none());
    }

    #[test]
    fn process_no_reply_returns_none() {
        assert!(process_dream_response("NO_REPLY").is_none());
        assert!(process_dream_response("no reply").is_none());
        assert!(process_dream_response("No-Reply").is_none());
        assert!(process_dream_response("  NO_REPLY  ").is_none());
    }

    #[test]
    fn process_valid_markdown_returns_content() {
        let input = "## Topic A\n\nSome insight.\n\n## Topic B\n\nAnother insight.";
        let result = process_dream_response(input).unwrap();
        assert_eq!(result, input);
    }

    #[test]
    fn process_h1_header_accepted() {
        let input = "# Top Level\n\nSome content.";
        assert_eq!(process_dream_response(input).unwrap(), input);
    }

    #[test]
    fn process_no_headers_returns_none() {
        assert!(process_dream_response("Just plain text without headers.").is_none());
    }

    #[test]
    fn process_truncates_long_content() {
        let header = "## Long\n\n";
        let body = "x".repeat(MAX_DREAM_CHARS + 1000);
        let input = format!("{header}{body}");

        let result = process_dream_response(&input).unwrap();
        assert_eq!(result.chars().count(), MAX_DREAM_CHARS);
        assert!(result.starts_with("## Long"));
    }

    #[test]
    fn process_exactly_at_limit_not_truncated() {
        let header = "## H\n\n";
        let remaining = MAX_DREAM_CHARS - header.len();
        let body = "a".repeat(remaining);
        let input = format!("{header}{body}");

        let result = process_dream_response(&input).unwrap();
        assert_eq!(result.chars().count(), MAX_DREAM_CHARS);
        assert_eq!(result, input);
    }

    // -------------------------------------------------------------------
    // execute_dream tests
    // -------------------------------------------------------------------

    use super::super::storage::MemoryStorage;
    use std::path::PathBuf;

    fn test_storage(tmp: &TempDir) -> (MemoryStorage, PathBuf) {
        let global = tmp.path().join("memory");
        let workspace = global.join("test-ws");
        let storage = MemoryStorage::with_paths(global, workspace.clone());
        (storage, workspace)
    }

    /// Helper: path for an empty sessions directory (no cleanup expected).
    fn empty_sessions_dir(tmp: &TempDir) -> PathBuf {
        let p = tmp.path().join("empty-sessions");
        fs::create_dir_all(&p).unwrap();
        p
    }

    #[test]
    fn execute_dream_valid_response_writes_memory() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());
        let (storage, ws) = test_storage(&dir);
        let sdir = empty_sessions_dir(&dir);

        let response = "## Decisions\n\nWe chose Rust.\n\n## Architecture\n\nEvent-driven.";
        let result = execute_dream(&lock, &storage, response, 5, 300, &sdir, &[]);

        assert!(
            matches!(result.status, DreamStatus::Completed { chars_written } if chars_written == response.chars().count())
        );
        assert_eq!(result.sessions_eligible, 5);
        assert_eq!(result.cleaned_stems.len(), 0);

        let memory = fs::read_to_string(ws.join("MEMORY.md")).unwrap();
        assert!(memory.contains("We chose Rust."));
        assert!(memory.contains("Event-driven."));
    }

    #[test]
    fn execute_dream_empty_response_nothing_to_consolidate() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());
        let (storage, _) = test_storage(&dir);
        let sdir = empty_sessions_dir(&dir);

        let result = execute_dream(&lock, &storage, "", 3, 300, &sdir, &[]);
        assert_eq!(result.status, DreamStatus::NothingToConsolidate);
        assert_eq!(result.sessions_eligible, 3);
        assert_eq!(result.cleaned_stems.len(), 0);
    }

    #[test]
    fn execute_dream_no_reply_nothing_to_consolidate() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());
        let (storage, _) = test_storage(&dir);
        let sdir = empty_sessions_dir(&dir);

        let result = execute_dream(&lock, &storage, "NO_REPLY", 2, 300, &sdir, &[]);
        assert_eq!(result.status, DreamStatus::NothingToConsolidate);
        assert_eq!(result.cleaned_stems.len(), 0);
    }

    #[test]
    fn execute_dream_lock_held_returns_skipped() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());
        let (storage, _) = test_storage(&dir);
        let sdir = empty_sessions_dir(&dir);

        // Acquire the lock (our own PID, non-stale)
        lock.try_acquire(300).unwrap().unwrap();

        let result = execute_dream(&lock, &storage, "## Topic\n\nContent", 5, 300, &sdir, &[]);
        match &result.status {
            DreamStatus::Skipped(reason) => {
                assert!(reason.contains("lock"), "reason: {reason}");
            }
            other => panic!("expected Skipped, got {other:?}"),
        }
        assert_eq!(result.sessions_eligible, 0);
        assert_eq!(result.cleaned_stems.len(), 0);
    }

    #[test]
    fn execute_dream_write_failure_rolls_back_lock() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());
        let sdir = empty_sessions_dir(&dir);

        // Point workspace dir at a path under a regular file, so
        // create_dir_all inside append_to_memory will fail (works
        // even when running as root, unlike chmod-based approaches).
        let blocker = dir.path().join("memory").join("blocker");
        fs::create_dir_all(blocker.parent().unwrap()).unwrap();
        fs::write(&blocker, "I am a file").unwrap();
        let workspace = blocker.join("impossible-subdir");

        let storage = MemoryStorage::with_paths(dir.path().join("memory"), workspace);

        let result = execute_dream(&lock, &storage, "## Topic\n\nContent", 3, 300, &sdir, &[]);

        match &result.status {
            DreamStatus::Failed(reason) => {
                assert!(reason.contains("MEMORY.md"), "reason: {reason}");
            }
            other => panic!("expected Failed, got {other:?}"),
        }
        assert_eq!(result.cleaned_stems.len(), 0);

        // Verify rollback: lock file should be gone (prior was None).
        let lock_path = dir.path().join(".dream-lock");
        assert!(
            !lock_path.exists(),
            "lock file should be deleted after rollback with no prior"
        );
    }

    #[test]
    fn execute_dream_overwrites_existing_memory() {
        let dir = TempDir::new().unwrap();
        let (storage, ws) = test_storage(&dir);
        let sdir = empty_sessions_dir(&dir);

        // Pre-populate MEMORY.md
        fs::create_dir_all(&ws).unwrap();
        fs::write(ws.join("MEMORY.md"), "## Existing\n\nOld content.").unwrap();

        let lock = DreamLock::new(dir.path());
        let response = "## New Topic\n\nFresh insight.";
        let result = execute_dream(&lock, &storage, response, 2, 300, &sdir, &[]);

        assert!(matches!(result.status, DreamStatus::Completed { .. }));

        let memory = fs::read_to_string(ws.join("MEMORY.md")).unwrap();
        // write_long_term overwrites: old content is replaced
        assert!(!memory.contains("Old content."));
        assert_eq!(memory.trim(), response);
    }

    // -------------------------------------------------------------------
    // DREAM_SYSTEM_PROMPT sanity checks
    // -------------------------------------------------------------------

    #[test]
    fn dream_system_prompt_has_required_content() {
        assert!(DREAM_SYSTEM_PROMPT.contains("dream"));
        assert!(DREAM_SYSTEM_PROMPT.contains("NO_REPLY"));
        assert!(DREAM_SYSTEM_PROMPT.contains("## headers"));
        // Core instruction verbs that define dream behavior
        assert!(DREAM_SYSTEM_PROMPT.contains("Merge"));
        assert!(DREAM_SYSTEM_PROMPT.contains("Resolve"));
        assert!(DREAM_SYSTEM_PROMPT.contains("Discard"));
        assert!(DREAM_SYSTEM_PROMPT.contains("Preserve"));
        assert!(DREAM_SYSTEM_PROMPT.contains("Convert"));
    }

    #[test]
    fn build_message_respects_input_size_cap() {
        let dir = TempDir::new().unwrap();
        let sessions = dir.path().join("sessions");
        let big_content = "x".repeat(MAX_DREAM_INPUT_CHARS);
        write_session_content(&sessions, "big", &big_content);
        write_session_content(&sessions, "small", "should not appear");

        let stems = vec!["big".to_string(), "small".to_string()];
        let msg = build_dream_user_message(&sessions, &stems, None).unwrap();

        assert!(msg.content.len() >= MAX_DREAM_INPUT_CHARS);
        assert!(
            !msg.content.contains("should not appear"),
            "second session should be skipped after cap"
        );
        // Only the first stem should be in processed_stems — the second
        // was beyond the 32K cap and must NOT be cleaned up.
        assert_eq!(msg.processed_stems, vec!["big"]);
    }

    #[test]
    fn normalize_memory_content_preserves_headers() {
        let input = "## Topic A\n\nContent A.\n\n## Topic B\n\nContent B.";
        let normalized = super::super::storage::normalize_memory_content(input);
        assert_eq!(normalized, input);
    }

    // -------------------------------------------------------------------
    // Session cleanup tests
    // -------------------------------------------------------------------

    #[test]
    fn cleanup_deletes_processed_sessions_on_completed() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());
        let (storage, _ws) = test_storage(&dir);

        // Create session files that will be "processed" (old mtime to
        // pass the recency guard).
        let sessions = dir.path().join("sessions");
        write_old_session_content(&sessions, "sess-a", "Content A");
        write_old_session_content(&sessions, "sess-b", "Content B");

        let processed = vec!["sess-a".to_string(), "sess-b".to_string()];
        let response = "## Consolidated\n\nMerged content.";
        let result = execute_dream(&lock, &storage, response, 2, 300, &sessions, &processed);

        assert!(matches!(result.status, DreamStatus::Completed { .. }));
        assert_eq!(result.cleaned_stems.len(), 2);

        // Both files should be gone
        assert!(!sessions.join("sess-a.md").exists());
        assert!(!sessions.join("sess-b.md").exists());
    }

    #[test]
    fn cleanup_preserves_unprocessed_sessions() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());
        let (storage, _ws) = test_storage(&dir);

        // Create session files — only some will be "processed"
        let sessions = dir.path().join("sessions");
        write_old_session_content(&sessions, "processed", "Content");
        write_old_session_content(&sessions, "unprocessed", "Kept content");

        // Only mark "processed" as having been read
        let processed = vec!["processed".to_string()];
        let response = "## Consolidated\n\nMerged.";
        let result = execute_dream(&lock, &storage, response, 1, 300, &sessions, &processed);

        assert!(matches!(result.status, DreamStatus::Completed { .. }));
        assert_eq!(result.cleaned_stems.len(), 1);

        // Processed file should be gone, unprocessed should remain
        assert!(!sessions.join("processed.md").exists());
        assert!(sessions.join("unprocessed.md").exists());
        assert_eq!(
            fs::read_to_string(sessions.join("unprocessed.md")).unwrap(),
            "Kept content"
        );
    }

    #[test]
    fn cleanup_failure_does_not_affect_completed_status() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());
        let (storage, _ws) = test_storage(&dir);

        let sessions = dir.path().join("sessions");
        fs::create_dir_all(&sessions).unwrap();

        // Create a directory where a .md file is expected — remove_file
        // on a directory fails even as root, giving us a guaranteed
        // cleanup failure without relying on chmod. Back-date the dir's
        // mtime so it passes the recency guard.
        let bad_path = sessions.join("bad-stem.md");
        fs::create_dir_all(&bad_path).unwrap();
        let old = SystemTime::now() - Duration::from_secs(CLEANUP_RECENCY_GUARD_SECS + 60);
        filetime::set_file_mtime(&bad_path, FileTime::from_system_time(old)).unwrap();

        // Also create a normal file that CAN be cleaned
        write_old_session_content(&sessions, "good-stem", "Content");

        let processed = vec!["bad-stem".to_string(), "good-stem".to_string()];
        let response = "## Consolidated\n\nMerged.";
        let result = execute_dream(&lock, &storage, response, 2, 300, &sessions, &processed);

        // Status must still be Completed despite the cleanup failure
        assert!(matches!(result.status, DreamStatus::Completed { .. }));
        // Only the good-stem file was cleaned; bad-stem failed
        assert_eq!(result.cleaned_stems.len(), 1);
        // The directory-as-file still exists (cleanup failure)
        assert!(sessions.join("bad-stem.md").exists());
        // The normal file was successfully removed
        assert!(!sessions.join("good-stem.md").exists());
    }

    #[test]
    fn cleanup_skips_recently_modified_files() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());
        let (storage, _ws) = test_storage(&dir);

        let sessions = dir.path().join("sessions");
        // This file has the current mtime (within recency guard window)
        write_session_content(&sessions, "recent", "Still being written");
        // This file is old enough to be cleaned
        write_old_session_content(&sessions, "old", "Done");

        let processed = vec!["recent".to_string(), "old".to_string()];
        let response = "## Consolidated\n\nMerged.";
        let result = execute_dream(&lock, &storage, response, 2, 300, &sessions, &processed);

        assert!(matches!(result.status, DreamStatus::Completed { .. }));
        // Only the old file should be cleaned; recent should be skipped.
        // Crucially, cleaned_stems must contain ONLY "old" — if the
        // caller used this list for index cleanup, "recent" must NOT
        // have its index chunks removed since its file is still on disk.
        assert_eq!(result.cleaned_stems, vec!["old"]);
        assert!(
            sessions.join("recent.md").exists(),
            "recently-modified file must be preserved"
        );
        assert!(!sessions.join("old.md").exists());
    }

    #[test]
    fn no_cleanup_on_nothing_to_consolidate() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());
        let (storage, _ws) = test_storage(&dir);

        let sessions = dir.path().join("sessions");
        write_session_content(&sessions, "kept", "Content");

        let processed = vec!["kept".to_string()];
        // NO_REPLY → NothingToConsolidate — should NOT clean up
        let result = execute_dream(&lock, &storage, "NO_REPLY", 1, 300, &sessions, &processed);

        assert_eq!(result.status, DreamStatus::NothingToConsolidate);
        assert_eq!(result.cleaned_stems.len(), 0);
        assert!(
            sessions.join("kept.md").exists(),
            "session file must be preserved on NothingToConsolidate"
        );
    }

    #[test]
    fn no_cleanup_on_failed_status() {
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());

        // Force a write failure with the blocker trick
        let blocker = dir.path().join("memory").join("blocker");
        fs::create_dir_all(blocker.parent().unwrap()).unwrap();
        fs::write(&blocker, "I am a file").unwrap();
        let workspace = blocker.join("impossible-subdir");
        let storage = MemoryStorage::with_paths(dir.path().join("memory"), workspace);

        let sessions = dir.path().join("sessions");
        write_session_content(&sessions, "kept", "Content");

        let processed = vec!["kept".to_string()];
        let result = execute_dream(
            &lock,
            &storage,
            "## Topic\n\nContent",
            1,
            300,
            &sessions,
            &processed,
        );

        assert!(matches!(result.status, DreamStatus::Failed(_)));
        assert_eq!(result.cleaned_stems.len(), 0);
        assert!(
            sessions.join("kept.md").exists(),
            "session file must be preserved on Failed"
        );
    }

    #[test]
    fn build_message_processed_stems_excludes_capped_sessions() {
        let dir = TempDir::new().unwrap();
        let sessions = dir.path().join("sessions");

        // First session fills the buffer past the cap
        let big_content = "x".repeat(MAX_DREAM_INPUT_CHARS);
        write_session_content(&sessions, "first", &big_content);
        // Second session should NOT be processed (over cap)
        write_session_content(&sessions, "second", "small content");
        // Third session should NOT be processed either
        write_session_content(&sessions, "third", "more content");

        let stems = vec![
            "first".to_string(),
            "second".to_string(),
            "third".to_string(),
        ];
        let msg = build_dream_user_message(&sessions, &stems, None).unwrap();

        assert_eq!(
            msg.processed_stems,
            vec!["first"],
            "only the first session should be in processed_stems"
        );
        assert_eq!(msg.processed_stems.len(), 1);
    }

    #[test]
    fn end_to_end_cap_boundary_cleanup() {
        // Integration test: build_dream_user_message hits the 32K cap
        // partway through the stems list, then execute_dream cleans up
        // only the processed files while preserving unprocessed ones.
        let dir = TempDir::new().unwrap();
        let lock = DreamLock::new(dir.path());
        let (storage, _ws) = test_storage(&dir);

        let sessions = dir.path().join("sessions");

        // Create 5 session files. The first 2 will fill past the cap;
        // sessions 3-5 should survive cleanup.
        let half_cap = MAX_DREAM_INPUT_CHARS / 2 + 500; // slightly over half
        write_old_session_content(&sessions, "aaa-first", &"a".repeat(half_cap));
        write_old_session_content(&sessions, "bbb-second", &"b".repeat(half_cap));
        write_old_session_content(&sessions, "ccc-third", "small content 3");
        write_old_session_content(&sessions, "ddd-fourth", "small content 4");
        write_old_session_content(&sessions, "eee-fifth", "small content 5");

        let all_stems: Vec<String> = vec![
            "aaa-first",
            "bbb-second",
            "ccc-third",
            "ddd-fourth",
            "eee-fifth",
        ]
        .into_iter()
        .map(String::from)
        .collect();

        // Phase 1: build_dream_user_message should cap after the first 2
        let dream_msg = build_dream_user_message(&sessions, &all_stems, None).unwrap();
        assert_eq!(
            dream_msg.processed_stems.len(),
            2,
            "only first 2 sessions should fit within 32K cap"
        );
        assert_eq!(dream_msg.processed_stems[0], "aaa-first");
        assert_eq!(dream_msg.processed_stems[1], "bbb-second");

        // Phase 2: execute_dream with a valid response should clean up
        // only the processed stems.
        let response = "## Consolidated\n\nMerged from 2 sessions.";
        let result = execute_dream(
            &lock,
            &storage,
            response,
            all_stems.len(),
            300,
            &sessions,
            &dream_msg.processed_stems,
        );

        assert!(matches!(result.status, DreamStatus::Completed { .. }));
        assert_eq!(result.cleaned_stems.len(), 2);

        // Processed files deleted
        assert!(!sessions.join("aaa-first.md").exists());
        assert!(!sessions.join("bbb-second.md").exists());

        // Unprocessed files preserved
        assert!(sessions.join("ccc-third.md").exists());
        assert!(sessions.join("ddd-fourth.md").exists());
        assert!(sessions.join("eee-fifth.md").exists());
    }

    #[test]
    fn clean_processed_sessions_handles_already_missing_files() {
        let dir = TempDir::new().unwrap();
        let sessions = dir.path().join("sessions");
        fs::create_dir_all(&sessions).unwrap();

        // Only create one of two stems (old mtime to pass recency guard)
        write_old_session_content(&sessions, "exists", "Content");

        // Pass both — "missing" doesn't exist, should not error or count
        let cleaned =
            clean_processed_sessions(&sessions, &["exists".to_string(), "missing".to_string()]);

        assert_eq!(
            cleaned.len(),
            1,
            "only the existing file should count as cleaned"
        );
        assert_eq!(cleaned[0], "exists");
        assert!(!sessions.join("exists.md").exists());
    }

    // -------------------------------------------------------------------
    // is_scaffold_template tests
    // -------------------------------------------------------------------

    #[test]
    fn scaffold_detects_old_workspace_template() {
        assert!(is_scaffold_template(
            "# Project Memory\n\n## Project Context\n\n<!-- Add project-specific knowledge here -->\n",
        ));
    }

    #[test]
    fn scaffold_detects_new_workspace_template() {
        assert!(is_scaffold_template(
            "# Project Memory — /test\n\n> Auto-populated by dream consolidation. Edit freely.\n",
        ));
    }

    #[test]
    fn scaffold_detects_global_template() {
        assert!(is_scaffold_template(
            "# Global Memory\n\n## Preferences\n\n<!-- Add any cross-project preferences here -->\n",
        ));
    }

    #[test]
    fn scaffold_rejects_real_content() {
        assert!(!is_scaffold_template(
            "## Decisions\n\nWe chose Rust.\n\n## Architecture\n\nEvent-driven.",
        ));
    }

    #[test]
    fn scaffold_boundary_499_bytes_is_scaffold() {
        let marker = "Add project-specific knowledge here";
        let pad_len = 499 - marker.len();
        let content = format!("{}{}", "x".repeat(pad_len), marker);
        assert_eq!(content.trim().len(), 499);
        assert!(
            is_scaffold_template(&content),
            "499-byte content with marker must be classified as scaffold"
        );
    }

    #[test]
    fn scaffold_boundary_500_bytes_is_not_scaffold() {
        let marker = "Add project-specific knowledge here";
        let pad_len = 500 - marker.len();
        let content = format!("{}{}", "x".repeat(pad_len), marker);
        assert_eq!(content.trim().len(), 500);
        assert!(
            !is_scaffold_template(&content),
            "500-byte content with marker must NOT be classified as scaffold"
        );
    }

    #[test]
    fn scaffold_rejects_large_content_with_leftover_marker() {
        let real_content = "x".repeat(600);
        let content_with_marker = format!(
            "# Project Memory\n\n<!-- Add project-specific knowledge here -->\n\n## Decisions\n\n{}",
            real_content
        );
        assert!(
            !is_scaffold_template(&content_with_marker),
            "large file with leftover scaffold marker must NOT be classified as scaffold"
        );
    }

    #[test]
    fn scaffold_rejects_large_workspace_template_with_real_content() {
        let real_content = "a ".repeat(300);
        let content = format!(
            "# Project Memory — /test\n\n> Auto-populated by dream consolidation. Edit freely.\n\n## Architecture\n\n{}",
            real_content
        );
        assert!(
            !is_scaffold_template(&content),
            "workspace template with substantial appended content must not be scaffold"
        );
    }

    // -------------------------------------------------------------------
    // build_dream_user_message with existing memory tests
    // -------------------------------------------------------------------

    #[test]
    fn build_message_prepends_existing_memory() {
        let dir = TempDir::new().unwrap();
        let sessions = dir.path().join("sessions");
        write_session_content(&sessions, "sess-a", "Decision: use Rust");

        let stems = vec!["sess-a".to_string()];
        let existing = "## Prior\n\nOld knowledge.";
        let msg = build_dream_user_message(&sessions, &stems, Some(existing)).unwrap();

        assert!(msg.content.starts_with("--- Existing Memory"));
        assert!(msg.content.contains("Old knowledge."));
        assert!(msg.content.contains("Decision: use Rust"));
        let mem_pos = msg.content.find("Old knowledge.").unwrap();
        let sess_pos = msg.content.find("Decision: use Rust").unwrap();
        assert!(mem_pos < sess_pos, "existing memory must precede sessions");
    }

    #[test]
    fn build_message_skips_scaffold_existing_memory() {
        let dir = TempDir::new().unwrap();
        let sessions = dir.path().join("sessions");
        write_session_content(&sessions, "sess-a", "Decision: use Rust");

        let stems = vec!["sess-a".to_string()];
        let scaffold = "# Project Memory\n\n<!-- Add project-specific knowledge here -->\n";
        let msg = build_dream_user_message(&sessions, &stems, Some(scaffold)).unwrap();

        assert!(!msg.content.contains("Existing Memory"));
        assert!(msg.content.contains("Decision: use Rust"));
    }

    #[test]
    fn build_message_includes_large_memory_with_scaffold_marker() {
        let dir = TempDir::new().unwrap();
        let sessions = dir.path().join("sessions");
        write_session_content(&sessions, "sess-a", "Decision: use Rust");

        let stems = vec!["sess-a".to_string()];
        let real_content = "x".repeat(600);
        let memory = format!(
            "# Project Memory\n\n<!-- Add project-specific knowledge here -->\n\n## Architecture\n\n{}",
            real_content
        );
        let msg = build_dream_user_message(&sessions, &stems, Some(&memory)).unwrap();

        assert!(
            msg.content.contains("Existing Memory"),
            "large memory with leftover scaffold marker must be included"
        );
        assert!(msg.content.contains(&real_content));
    }

    #[test]
    fn build_message_returns_none_with_existing_memory_but_no_sessions() {
        let dir = TempDir::new().unwrap();
        let sessions = dir.path().join("sessions");
        fs::create_dir_all(&sessions).unwrap();

        let stems = vec!["missing".to_string()];
        let existing = "## Prior\n\nOld knowledge.";
        assert!(
            build_dream_user_message(&sessions, &stems, Some(existing)).is_none(),
            "should return None when no sessions are readable, even with existing memory"
        );
    }
}
