//! AI-powered conversation compaction
//!
//! This module implements the AI compaction layer of the 3-layer compaction system,
//! which generates summaries when context threshold is reached.

use crate::agent::utils::render_prompt_for_llm;
use crate::model::{AgentMessage, MessagePart};
use crate::session::pruning::{SimpleTokenEstimator, TokenEstimator};
use anyhow::Result;
use futures_util::StreamExt;
use querymt::chat::{ChatRole, StreamChunk};
use std::sync::Arc;
use std::time::Duration;

/// Default compaction prompt used to generate conversation summaries
pub const COMPACTION_PROMPT: &str = r#"Provide a detailed prompt for continuing our conversation above. Focus on:
- What was done
- What is currently being worked on
- Which files are being modified
- What needs to be done next
- Key user requests, constraints, or preferences that should persist

Be comprehensive but concise. This summary will replace the conversation history."#;

/// Configuration for retry behavior
#[derive(Debug, Clone)]
pub struct RetryConfig {
    /// Maximum retry attempts
    pub max_retries: usize,
    /// Initial backoff delay in milliseconds
    pub initial_backoff_ms: u64,
    /// Multiplier for exponential backoff
    pub backoff_multiplier: f64,
}

impl Default for RetryConfig {
    fn default() -> Self {
        Self {
            max_retries: 3,
            initial_backoff_ms: 1000,
            backoff_multiplier: 2.0,
        }
    }
}

/// Result of a compaction operation
#[derive(Debug, Clone)]
pub struct CompactionResult {
    /// The generated summary
    pub summary: String,
    /// Original token count before compaction
    pub original_token_count: usize,
    /// Estimated token count of the summary
    pub summary_token_count: usize,
}

/// Service for generating AI-powered conversation summaries
#[derive(Clone)]
pub struct SessionCompaction {
    estimator: Arc<dyn TokenEstimator>,
}

impl Default for SessionCompaction {
    fn default() -> Self {
        Self::new()
    }
}

impl SessionCompaction {
    /// Create a new SessionCompaction with default token estimator
    pub fn new() -> Self {
        Self {
            estimator: Arc::new(SimpleTokenEstimator),
        }
    }

    /// Create a new SessionCompaction with custom token estimator
    pub fn with_estimator(estimator: Arc<dyn TokenEstimator>) -> Self {
        Self { estimator }
    }

    /// Process messages and generate a compaction summary
    ///
    /// This method:
    /// 1. Builds a prompt with conversation history
    /// 2. Calls the LLM provider with retry logic
    /// 3. Returns the compaction result
    ///
    /// # Arguments
    ///
    /// * `messages` - The conversation history to summarize
    /// * `provider` - The LLM provider to use for summary generation
    /// * `retry_config` - Configuration for retry behavior
    pub async fn process(
        &self,
        messages: &[AgentMessage],
        provider: Arc<dyn querymt::chat::ChatProvider>,
        model: &str,
        retry_config: &RetryConfig,
        max_prompt_bytes: Option<usize>,
    ) -> Result<CompactionResult> {
        // Estimate original token count
        let original_token_count = self.estimate_messages_tokens(messages, max_prompt_bytes);

        // Build the compaction request
        let chat_messages = self.build_compaction_messages(messages, max_prompt_bytes);

        // Call LLM with retry logic
        let summary = self
            .call_with_retry(&chat_messages, provider, model, retry_config)
            .await?;

        let summary_token_count = self.estimator.estimate(&summary);

        Ok(CompactionResult {
            summary,
            original_token_count,
            summary_token_count,
        })
    }

    /// Build chat messages for compaction request
    fn build_compaction_messages(
        &self,
        messages: &[AgentMessage],
        max_prompt_bytes: Option<usize>,
    ) -> Vec<querymt::chat::ChatMessage> {
        let mut chat_messages: Vec<querymt::chat::ChatMessage> = messages
            .iter()
            .map(|m| m.to_chat_message_with_max_prompt_bytes(max_prompt_bytes))
            .collect();

        // Add the compaction prompt as a user message
        chat_messages.push(querymt::chat::ChatMessage {
            role: ChatRole::User,
            content: vec![querymt::chat::Content::text(COMPACTION_PROMPT)],
            cache: None,
        });

        chat_messages
    }

    /// Call LLM with exponential backoff retry.
    ///
    /// Prefers streaming when the provider supports it.  The streaming path
    /// uses per-chunk timeouts (typically 60 s) instead of a single total
    /// request-response timeout, keeping the connection alive while the model
    /// generates the compaction summary.  This avoids the 10 s libp2p default
    /// timeout that caused compaction failures on remote mesh nodes.
    ///
    /// Mesh timeout errors (remote still processing) are treated as
    /// **non-retriable** to prevent queuing duplicate inference work on the
    /// remote GPU.
    async fn call_with_retry(
        &self,
        messages: &[querymt::chat::ChatMessage],
        provider: Arc<dyn querymt::chat::ChatProvider>,
        _model: &str,
        retry_config: &RetryConfig,
    ) -> Result<String> {
        let mut last_error = None;
        let mut backoff_ms = retry_config.initial_backoff_ms;

        for attempt in 0..=retry_config.max_retries {
            if attempt > 0 {
                log::debug!(
                    "Compaction retry attempt {} after {}ms",
                    attempt,
                    backoff_ms
                );
                tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
                backoff_ms = (backoff_ms as f64 * retry_config.backoff_multiplier) as u64;
            }

            // Prefer streaming to avoid mesh request-response timeout on long
            // inference.  The streaming path uses per-chunk timeouts instead of
            // a single total-request timeout, keeping the connection alive
            // while the model generates.
            let result = if provider.supports_streaming() {
                Self::call_streaming(messages, &provider).await
            } else {
                provider
                    .chat(messages)
                    .await
                    .map(|r| r.text().unwrap_or_default())
            };

            match result {
                Ok(text) => return Ok(text),
                Err(e) => {
                    log::warn!(
                        "Compaction LLM call failed (attempt {}): {}",
                        attempt + 1,
                        e
                    );
                    // Mesh timeouts mean the remote is still processing.
                    // Retrying would just queue more work on an already-busy
                    // remote GPU — abort immediately.
                    if is_mesh_timeout_error(&e) {
                        log::warn!(
                            "Compaction error is a mesh timeout (remote node likely still \
                             processing). Aborting retries to avoid queuing duplicate work."
                        );
                        return Err(anyhow::anyhow!(
                            "Compaction failed: mesh timeout (remote still processing): {}",
                            e
                        ));
                    }
                    last_error = Some(e);
                }
            }
        }

        Err(anyhow::anyhow!(
            "Compaction failed after {} retries: {:?}",
            retry_config.max_retries,
            last_error
        ))
    }

    /// Streaming compaction call — collects text chunks into a single string.
    async fn call_streaming(
        messages: &[querymt::chat::ChatMessage],
        provider: &Arc<dyn querymt::chat::ChatProvider>,
    ) -> std::result::Result<String, querymt::error::LLMError> {
        let mut stream = provider.chat_stream(messages).await?;
        let mut text = String::new();
        while let Some(chunk) = stream.next().await {
            match chunk? {
                StreamChunk::Text(delta) => text.push_str(&delta),
                StreamChunk::Done { .. } => break,
                _ => {} // ignore Thinking, ToolUseStart, etc. for compaction
            }
        }
        Ok(text)
    }

    /// Estimate token count for a list of messages
    pub(crate) fn estimate_messages_tokens(
        &self,
        messages: &[AgentMessage],
        max_prompt_bytes: Option<usize>,
    ) -> usize {
        messages
            .iter()
            .map(|m| {
                m.parts
                    .iter()
                    .map(|p| match p {
                        MessagePart::Text { content } => self.estimator.estimate(content),
                        MessagePart::Prompt { blocks } => self
                            .estimator
                            .estimate(&render_prompt_for_llm(blocks, max_prompt_bytes)),
                        MessagePart::ToolResult { content, .. } => {
                            let text: String = content
                                .iter()
                                .filter_map(|b| b.as_text())
                                .collect::<Vec<_>>()
                                .join("\n");
                            self.estimator.estimate(&text)
                        }
                        MessagePart::Reasoning { content, .. } => self.estimator.estimate(content),
                        MessagePart::Compaction { summary, .. } => self.estimator.estimate(summary),
                        _ => 0,
                    })
                    .sum::<usize>()
            })
            .sum()
    }

    /// Create a compaction message pair to be stored in history.
    ///
    /// Returns a (user_request, assistant_summary) tuple that forms a natural
    /// user→assistant exchange after context compaction. The user message asks
    /// "Summarize our conversation so far." and the assistant message contains
    /// the actual summary.
    pub fn create_compaction_messages(
        session_id: &str,
        summary: &str,
        original_token_count: usize,
    ) -> (AgentMessage, AgentMessage) {
        let request_id = uuid::Uuid::now_v7().to_string();
        let summary_id = uuid::Uuid::now_v7().to_string();
        let now = time::OffsetDateTime::now_utc().unix_timestamp();

        let request_msg = AgentMessage {
            id: request_id.clone(),
            session_id: session_id.to_string(),
            role: ChatRole::User,
            parts: vec![MessagePart::CompactionRequest {
                original_token_count,
            }],
            created_at: now,
            parent_message_id: None,
            source_provider: None,
            source_model: None,
        };

        let summary_msg = AgentMessage {
            id: summary_id,
            session_id: session_id.to_string(),
            role: ChatRole::Assistant,
            parts: vec![MessagePart::Compaction {
                summary: summary.to_string(),
                original_token_count,
            }],
            created_at: now,
            parent_message_id: Some(request_id),
            source_provider: None,
            source_model: None,
        };

        (request_msg, summary_msg)
    }
}

/// Check if an LLM error indicates a mesh timeout (remote node still processing).
///
/// These errors should **not** be retried because the remote GPU is still busy
/// with the previous request.  Retrying just queues additional inference work,
/// wasting compute and potentially causing cascading timeouts.
///
/// Detection matches on the typed `LLMError::Transport { kind: Timeout, .. }`
/// variant which is produced by `MeshChatProvider::chat_with_tools` when
/// kameo's `RemoteSendError::Timeout` fires, or when a mesh stream's
/// reconnect grace period expires.
fn is_mesh_timeout_error(e: &querymt::error::LLMError) -> bool {
    matches!(
        e,
        querymt::error::LLMError::Transport {
            kind: querymt::error::TransportErrorKind::Timeout,
            ..
        }
    )
}

/// Filter messages to return only the "effective" history after the last compaction.
///
/// This function finds the most recent compaction message and returns only
/// messages from that point forward (including the compaction itself).
///
/// # Arguments
///
/// * `messages` - The full conversation history
///
/// # Returns
///
/// A subset of messages starting from (and including) the last compaction,
/// or all messages if no compaction exists.
pub fn filter_to_effective_history(messages: Vec<AgentMessage>) -> Vec<AgentMessage> {
    // Find the index of the last compaction request (user message).
    // This anchors on the user side of the compaction pair so that the
    // effective history starts with the user→assistant compaction exchange.
    let last_compaction_idx = messages.iter().rposition(|m| {
        m.parts
            .iter()
            .any(|p| matches!(p, MessagePart::CompactionRequest { .. }))
    });

    let filtered: Vec<AgentMessage> = match last_compaction_idx {
        Some(idx) => messages.into_iter().skip(idx).collect(),
        None => messages, // No compaction found, return all messages
    };

    // Filter out messages that only contain snapshot metadata parts
    // These are for undo/redo tracking and should not be sent to the LLM
    // Keeping them creates empty messages that break tool_use -> tool_result sequencing
    let without_snapshots = filtered
        .into_iter()
        .filter(|m| {
            m.parts.iter().any(|p| {
                !matches!(
                    p,
                    MessagePart::TurnSnapshotStart { .. } | MessagePart::TurnSnapshotPatch { .. }
                )
            })
        })
        .collect::<Vec<_>>();

    // Safety net: merge consecutive User messages that each contain a
    // tool_result part.  Legacy sessions stored one message per tool result
    // which violates the LLM API contract that all tool_results for a batch
    // of tool_use calls appear in a single user message.
    merge_consecutive_tool_result_messages(without_snapshots)
}

/// Merge runs of consecutive `User` messages where **every** message in the
/// run contains at least one `ToolResult` part.  The parts from subsequent
/// messages are folded into the first message of the run.
///
/// This repairs data written by older versions of `store_all_tool_results`
/// which created one message per tool result instead of batching them.
pub fn merge_consecutive_tool_result_messages(messages: Vec<AgentMessage>) -> Vec<AgentMessage> {
    if messages.len() < 2 {
        return messages;
    }

    fn is_user_tool_result(m: &AgentMessage) -> bool {
        m.role == ChatRole::User
            && m.parts
                .iter()
                .any(|p| matches!(p, MessagePart::ToolResult { .. }))
    }

    let mut result: Vec<AgentMessage> = Vec::with_capacity(messages.len());

    for msg in messages {
        let dominated_by_tool_result = is_user_tool_result(&msg);
        let should_merge =
            dominated_by_tool_result && result.last().is_some_and(is_user_tool_result);

        if should_merge {
            // Safe: we just checked last() is Some.
            let prev = result.last_mut().unwrap();
            prev.parts.extend(msg.parts);
        } else {
            result.push(msg);
        }
    }

    result
}

/// Check if messages contain a compaction pair
pub fn has_compaction(messages: &[AgentMessage]) -> bool {
    messages.iter().any(|m| {
        m.parts.iter().any(|p| {
            matches!(
                p,
                MessagePart::CompactionRequest { .. } | MessagePart::Compaction { .. }
            )
        })
    })
}

/// Get the most recent compaction summary if it exists
pub fn get_last_compaction(messages: &[AgentMessage]) -> Option<&MessagePart> {
    for message in messages.iter().rev() {
        for part in &message.parts {
            if matches!(part, MessagePart::Compaction { .. }) {
                return Some(part);
            }
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_utils::mocks::MockCompactionProvider;
    use querymt::error::LLMError;

    // ========================================================================
    // Test Fixtures
    // ========================================================================

    /// Fixture for building test messages
    struct MessageFixture;

    impl MessageFixture {
        fn text_message(id: &str, session_id: &str, role: ChatRole, text: &str) -> AgentMessage {
            AgentMessage {
                id: id.to_string(),
                session_id: session_id.to_string(),
                role,
                parts: vec![MessagePart::Text {
                    content: text.to_string(),
                }],
                created_at: 0,
                parent_message_id: None,
                source_provider: None,
                source_model: None,
            }
        }

        fn assistant_message(id: &str, session_id: &str, text: &str) -> AgentMessage {
            Self::text_message(id, session_id, ChatRole::Assistant, text)
        }

        fn user_message(id: &str, session_id: &str, text: &str) -> AgentMessage {
            Self::text_message(id, session_id, ChatRole::User, text)
        }

        fn tool_result_message(
            id: &str,
            session_id: &str,
            call_id: &str,
            content: &str,
        ) -> AgentMessage {
            AgentMessage {
                id: id.to_string(),
                session_id: session_id.to_string(),
                role: ChatRole::Assistant,
                parts: vec![MessagePart::ToolResult {
                    call_id: call_id.to_string(),
                    content: vec![querymt::chat::Content::text(content)],
                    is_error: false,
                    tool_name: Some("test_tool".to_string()),
                    tool_arguments: None,
                    compacted_at: None,
                }],
                created_at: 0,
                parent_message_id: None,
                source_provider: None,
                source_model: None,
            }
        }

        fn compaction_request_message(id: &str, session_id: &str) -> AgentMessage {
            AgentMessage {
                id: id.to_string(),
                session_id: session_id.to_string(),
                role: ChatRole::User,
                parts: vec![MessagePart::CompactionRequest {
                    original_token_count: 1000,
                }],
                created_at: 0,
                parent_message_id: None,
                source_provider: None,
                source_model: None,
            }
        }

        fn compaction_message(id: &str, session_id: &str, summary: &str) -> AgentMessage {
            AgentMessage {
                id: id.to_string(),
                session_id: session_id.to_string(),
                role: ChatRole::Assistant,
                parts: vec![MessagePart::Compaction {
                    summary: summary.to_string(),
                    original_token_count: 1000,
                }],
                created_at: 0,
                parent_message_id: None,
                source_provider: None,
                source_model: None,
            }
        }

        fn reasoning_message(id: &str, session_id: &str, reasoning: &str) -> AgentMessage {
            AgentMessage {
                id: id.to_string(),
                session_id: session_id.to_string(),
                role: ChatRole::Assistant,
                parts: vec![MessagePart::Reasoning {
                    content: reasoning.to_string(),
                    signature: None,
                    time_ms: Some(100),
                }],
                created_at: 0,
                parent_message_id: None,
                source_provider: None,
                source_model: None,
            }
        }

        /// Create a simple conversation with user/assistant exchanges
        fn simple_conversation(session_id: &str) -> Vec<AgentMessage> {
            vec![
                Self::user_message("1", session_id, "Hello"),
                Self::assistant_message("2", session_id, "Hi there"),
                Self::user_message("3", session_id, "How are you?"),
                Self::assistant_message("4", session_id, "I'm doing great!"),
            ]
        }

        /// Create a conversation with tool usage
        fn conversation_with_tools(session_id: &str) -> Vec<AgentMessage> {
            vec![
                Self::user_message("1", session_id, "List the files"),
                Self::tool_result_message("2", session_id, "call1", "file1.txt\nfile2.txt"),
                Self::user_message("3", session_id, "Read file1.txt"),
                Self::tool_result_message("4", session_id, "call2", "File contents here"),
            ]
        }

        /// Create a long conversation (for token counting tests)
        fn long_conversation(session_id: &str) -> Vec<AgentMessage> {
            let long_text = "a".repeat(1000);
            vec![
                Self::user_message("1", session_id, &long_text),
                Self::tool_result_message("2", session_id, "call1", &long_text),
                Self::user_message("3", session_id, &long_text),
                Self::tool_result_message("4", session_id, "call2", &long_text),
            ]
        }
    }

    /// Fixture for SessionCompaction service
    struct CompactionFixture {
        service: SessionCompaction,
    }

    impl CompactionFixture {
        fn new() -> Self {
            Self {
                service: SessionCompaction::new(),
            }
        }

        async fn process_with_summary(
            &self,
            messages: &[AgentMessage],
            summary_response: &str,
        ) -> Result<CompactionResult> {
            let mock = MockCompactionProvider::with_summary(summary_response);
            self.service
                .process(
                    messages,
                    Arc::new(mock),
                    "test-model",
                    &RetryConfig::default(),
                    None,
                )
                .await
        }
    }

    // ========================================================================
    // Helper Functions (backwards compatibility)
    // ========================================================================

    fn make_text_message(id: &str, session_id: &str, role: ChatRole, text: &str) -> AgentMessage {
        MessageFixture::text_message(id, session_id, role, text)
    }

    fn make_compaction_request_message(id: &str, session_id: &str) -> AgentMessage {
        MessageFixture::compaction_request_message(id, session_id)
    }

    fn make_compaction_message(id: &str, session_id: &str, summary: &str) -> AgentMessage {
        MessageFixture::compaction_message(id, session_id, summary)
    }

    #[test]
    fn test_filter_returns_all_if_no_compaction() {
        let messages = vec![
            make_text_message("1", "s1", ChatRole::User, "Hello"),
            make_text_message("2", "s1", ChatRole::Assistant, "Hi there"),
            make_text_message("3", "s1", ChatRole::User, "How are you?"),
        ];

        let filtered = filter_to_effective_history(messages.clone());
        assert_eq!(filtered.len(), 3);
        assert_eq!(filtered[0].id, "1");
    }

    #[test]
    fn test_filter_returns_after_compaction() {
        let messages = vec![
            make_text_message("1", "s1", ChatRole::User, "Old message"),
            make_text_message("2", "s1", ChatRole::Assistant, "Old response"),
            make_compaction_request_message("3", "s1"),
            make_compaction_message("4", "s1", "Summary of previous conversation"),
            make_text_message("5", "s1", ChatRole::User, "New message"),
            make_text_message("6", "s1", ChatRole::Assistant, "New response"),
        ];

        let filtered = filter_to_effective_history(messages);
        assert_eq!(filtered.len(), 4);
        assert_eq!(filtered[0].id, "3"); // CompactionRequest (user)
        assert_eq!(filtered[1].id, "4"); // Compaction (assistant)
        assert_eq!(filtered[2].id, "5");
        assert_eq!(filtered[3].id, "6");
    }

    #[test]
    fn test_filter_uses_last_compaction() {
        let messages = vec![
            make_text_message("1", "s1", ChatRole::User, "Very old"),
            make_compaction_request_message("2", "s1"),
            make_compaction_message("3", "s1", "First summary"),
            make_text_message("4", "s1", ChatRole::User, "Medium old"),
            make_compaction_request_message("5", "s1"),
            make_compaction_message("6", "s1", "Second summary"),
            make_text_message("7", "s1", ChatRole::User, "Recent"),
        ];

        let filtered = filter_to_effective_history(messages);
        assert_eq!(filtered.len(), 3);
        assert_eq!(filtered[0].id, "5"); // Second CompactionRequest
        assert_eq!(filtered[1].id, "6"); // Second Compaction
        assert_eq!(filtered[2].id, "7");
    }

    #[test]
    fn test_has_compaction() {
        let messages_with = vec![
            make_text_message("1", "s1", ChatRole::User, "Hello"),
            make_compaction_message("2", "s1", "Summary"),
        ];

        let messages_without = vec![
            make_text_message("1", "s1", ChatRole::User, "Hello"),
            make_text_message("2", "s1", ChatRole::Assistant, "Hi"),
        ];

        assert!(has_compaction(&messages_with));
        assert!(!has_compaction(&messages_without));
    }

    #[test]
    fn test_get_last_compaction() {
        let messages = vec![
            make_compaction_message("1", "s1", "First summary"),
            make_text_message("2", "s1", ChatRole::User, "Hello"),
            make_compaction_message("3", "s1", "Second summary"),
        ];

        let last = get_last_compaction(&messages);
        assert!(last.is_some());
        if let Some(MessagePart::Compaction { summary, .. }) = last {
            assert_eq!(summary, "Second summary");
        } else {
            panic!("Expected Compaction part");
        }
    }

    #[test]
    fn test_create_compaction_messages() {
        let (req, sum) =
            SessionCompaction::create_compaction_messages("session1", "Test summary", 5000);

        // Request message (user side)
        assert_eq!(req.session_id, "session1");
        assert_eq!(req.role, ChatRole::User);
        assert_eq!(req.parts.len(), 1);
        assert!(matches!(
            req.parts[0],
            MessagePart::CompactionRequest {
                original_token_count: 5000
            }
        ));

        // Summary message (assistant side)
        assert_eq!(sum.session_id, "session1");
        assert_eq!(sum.role, ChatRole::Assistant);
        assert_eq!(sum.parts.len(), 1);
        assert_eq!(sum.parent_message_id.as_deref(), Some(req.id.as_str()));

        if let MessagePart::Compaction {
            summary,
            original_token_count,
        } = &sum.parts[0]
        {
            assert_eq!(summary, "Test summary");
            assert_eq!(*original_token_count, 5000);
        } else {
            panic!("Expected Compaction part");
        }
    }

    #[test]
    fn test_retry_config_default() {
        let config = RetryConfig::default();
        assert_eq!(config.max_retries, 3);
        assert_eq!(config.initial_backoff_ms, 1000);
        assert!((config.backoff_multiplier - 2.0).abs() < f64::EPSILON);
    }

    // ========================================================================
    // New Unit Tests Using Fixtures
    // ========================================================================

    #[test]
    fn test_message_fixture_creates_text_messages() {
        let msg = MessageFixture::user_message("1", "sess1", "test");
        assert_eq!(msg.id, "1");
        assert_eq!(msg.session_id, "sess1");
        assert_eq!(msg.role, ChatRole::User);
        assert_eq!(msg.parts.len(), 1);
    }

    #[test]
    fn test_message_fixture_simple_conversation() {
        let conv = MessageFixture::simple_conversation("s1");
        assert_eq!(conv.len(), 4);
        assert_eq!(conv[0].role, ChatRole::User);
        assert_eq!(conv[1].role, ChatRole::Assistant);
    }

    #[test]
    fn test_estimate_messages_tokens_text_parts() {
        let fixture = CompactionFixture::new();
        let messages = MessageFixture::simple_conversation("s1");

        let tokens = fixture.service.estimate_messages_tokens(&messages, None);
        assert!(tokens > 0, "Should count tokens for text messages");
    }

    #[test]
    fn test_estimate_messages_tokens_tool_results() {
        let fixture = CompactionFixture::new();
        let messages = MessageFixture::conversation_with_tools("s1");

        let tokens = fixture.service.estimate_messages_tokens(&messages, None);
        assert!(tokens > 0, "Should count tokens for tool results");
    }

    #[test]
    fn test_estimate_messages_tokens_mixed_parts() {
        let fixture = CompactionFixture::new();
        let messages = vec![
            MessageFixture::user_message("1", "s1", "User request"),
            MessageFixture::reasoning_message("2", "s1", "Let me think about this"),
            MessageFixture::tool_result_message("3", "s1", "c1", "Tool output"),
            MessageFixture::assistant_message("4", "s1", "Final response"),
        ];

        let tokens = fixture.service.estimate_messages_tokens(&messages, None);
        assert!(tokens > 0, "Should count tokens for all part types");
    }

    #[test]
    fn test_estimate_messages_tokens_empty() {
        let fixture = CompactionFixture::new();
        let messages: Vec<AgentMessage> = vec![];

        let tokens = fixture.service.estimate_messages_tokens(&messages, None);
        assert_eq!(tokens, 0, "Empty messages should have 0 tokens");
    }

    #[test]
    fn test_estimate_messages_tokens_long_conversation() {
        let fixture = CompactionFixture::new();
        let messages = MessageFixture::long_conversation("s1");

        let tokens = fixture.service.estimate_messages_tokens(&messages, None);
        assert!(
            tokens > 500,
            "Long conversation should have significant token count"
        );
    }

    #[test]
    fn test_build_compaction_messages_appends_prompt() {
        let fixture = CompactionFixture::new();
        let messages = MessageFixture::simple_conversation("s1");

        let chat_messages = fixture.service.build_compaction_messages(&messages, None);

        // Should have all original messages + compaction prompt
        assert_eq!(
            chat_messages.len(),
            messages.len() + 1,
            "Should append compaction prompt"
        );

        // Last message should be the compaction prompt
        assert_eq!(chat_messages.last().unwrap().role, ChatRole::User);
        assert!(
            chat_messages
                .last()
                .unwrap()
                .text()
                .contains("conversation")
        );
    }

    #[test]
    fn test_build_compaction_messages_preserves_roles() {
        let fixture = CompactionFixture::new();
        let messages = MessageFixture::simple_conversation("s1");

        let chat_messages = fixture.service.build_compaction_messages(&messages, None);

        // Check that roles are preserved (excluding the appended prompt)
        assert_eq!(chat_messages[0].role, ChatRole::User);
        assert_eq!(chat_messages[1].role, ChatRole::Assistant);
        assert_eq!(chat_messages[2].role, ChatRole::User);
        assert_eq!(chat_messages[3].role, ChatRole::Assistant);
    }

    #[tokio::test]
    async fn test_process_success_simple() {
        let fixture = CompactionFixture::new();
        let messages = MessageFixture::simple_conversation("s1");

        let result = fixture
            .process_with_summary(&messages, "This is a summary")
            .await
            .expect("process should succeed");

        assert_eq!(result.summary, "This is a summary");
        assert!(result.original_token_count > 0);
        assert!(result.summary_token_count > 0);
    }

    #[tokio::test]
    async fn test_process_success_with_tools() {
        let fixture = CompactionFixture::new();
        let messages = MessageFixture::conversation_with_tools("s1");

        let result = fixture
            .process_with_summary(&messages, "Tool execution summary")
            .await
            .expect("process should succeed");

        assert!(!result.summary.is_empty());
        assert!(result.original_token_count > 0);
        assert!(result.summary_token_count > 0);
    }

    #[tokio::test]
    async fn test_process_returns_correct_token_counts() {
        let fixture = CompactionFixture::new();
        let messages = MessageFixture::long_conversation("s1");

        let original_tokens = fixture.service.estimate_messages_tokens(&messages, None);
        let result = fixture
            .process_with_summary(&messages, "short")
            .await
            .expect("process should succeed");

        assert_eq!(result.original_token_count, original_tokens);
        // Summary "short" should have fewer tokens than original
        assert!(result.summary_token_count <= original_tokens);
    }

    #[tokio::test]
    async fn test_process_with_empty_messages() {
        let fixture = CompactionFixture::new();
        let messages: Vec<AgentMessage> = vec![];

        let result = fixture
            .process_with_summary(&messages, "empty summary")
            .await
            .expect("process should succeed with empty messages");

        assert_eq!(result.original_token_count, 0);
        assert!(!result.summary.is_empty());
    }

    #[tokio::test]
    async fn test_process_retries_on_llm_error() {
        let service = SessionCompaction::new();
        let messages = MessageFixture::simple_conversation("s1");

        // Create a mock that fails twice then succeeds
        let mock = MockCompactionProvider::new(vec![
            Err(LLMError::GenericError("timeout".to_string())),
            Err(LLMError::GenericError("timeout".to_string())),
            Ok("recovered summary".to_string()),
        ]);
        let mock_arc = Arc::new(mock);

        let result = service
            .process(
                &messages,
                mock_arc.clone(),
                "model",
                &RetryConfig::default(),
                None,
            )
            .await
            .expect("should succeed after retries");

        assert_eq!(result.summary, "recovered summary");
        assert_eq!(mock_arc.call_count(), 3, "Should have retried twice");
    }

    #[tokio::test]
    async fn test_process_fails_after_max_retries() {
        let service = SessionCompaction::new();
        let messages = MessageFixture::simple_conversation("s1");

        // Create a mock that always fails
        let mock = MockCompactionProvider::new(vec![
            Err(LLMError::GenericError("error1".to_string())),
            Err(LLMError::GenericError("error2".to_string())),
            Err(LLMError::GenericError("error3".to_string())),
            Err(LLMError::GenericError("error4".to_string())),
        ]);
        let mock_arc = Arc::new(mock);

        let result = service
            .process(
                &messages,
                mock_arc.clone(),
                "model",
                &RetryConfig::default(),
                None,
            )
            .await;

        assert!(result.is_err(), "should fail after max retries");
        // Default retry config has max_retries=3, so 4 calls (initial + 3 retries)
        assert_eq!(mock_arc.call_count(), 4);
    }

    #[tokio::test]
    async fn test_process_with_custom_retry_config() {
        let service = SessionCompaction::new();
        let messages = MessageFixture::simple_conversation("s1");

        let custom_config = RetryConfig {
            max_retries: 1,
            initial_backoff_ms: 10,
            backoff_multiplier: 1.0,
        };

        let mock = MockCompactionProvider::new(vec![
            Err(LLMError::GenericError("error1".to_string())),
            Err(LLMError::GenericError("error2".to_string())),
        ]);
        let mock_arc = Arc::new(mock);

        let result = service
            .process(&messages, mock_arc.clone(), "model", &custom_config, None)
            .await;

        assert!(result.is_err());
        // With max_retries=1, should have 2 calls (initial + 1 retry)
        assert_eq!(mock_arc.call_count(), 2);
    }

    #[test]
    fn test_create_compaction_messages_structure() {
        let (req, sum) =
            SessionCompaction::create_compaction_messages("session1", "Test summary", 5000);

        assert_eq!(req.session_id, "session1");
        assert_eq!(req.role, ChatRole::User);
        assert_eq!(req.parts.len(), 1);
        assert!(!req.id.is_empty());
        assert!(req.created_at > 0);

        assert_eq!(sum.session_id, "session1");
        assert_eq!(sum.role, ChatRole::Assistant);
        assert_eq!(sum.parts.len(), 1);
        assert!(!sum.id.is_empty());
        assert!(sum.created_at > 0);
        assert_eq!(sum.parent_message_id.as_deref(), Some(req.id.as_str()));
    }

    #[test]
    fn test_create_compaction_messages_with_different_summaries() {
        let (_, sum1) = SessionCompaction::create_compaction_messages("s1", "Summary 1", 1000);
        let (_, sum2) = SessionCompaction::create_compaction_messages("s1", "Summary 2", 2000);

        assert_ne!(
            sum1.id, sum2.id,
            "Different messages should have different IDs"
        );

        if let MessagePart::Compaction {
            summary: s1,
            original_token_count: t1,
        } = &sum1.parts[0]
            && let MessagePart::Compaction {
                summary: s2,
                original_token_count: t2,
            } = &sum2.parts[0]
        {
            assert_ne!(s1, s2);
            assert_ne!(t1, t2);
        }
    }

    #[tokio::test]
    async fn test_process_message_order_preserved() {
        let fixture = CompactionFixture::new();
        let messages = MessageFixture::simple_conversation("s1");

        let chat_messages = fixture.service.build_compaction_messages(&messages, None);

        // Verify first message is the user message from conversation
        if let MessagePart::Text { content } = &messages[0].parts[0] {
            assert!(chat_messages[0].text().contains(content));
        }
    }

    #[test]
    fn test_compaction_message_to_chat_has_no_trailing_whitespace() {
        let (_, sum) = SessionCompaction::create_compaction_messages("s1", "Test summary", 5000);
        let chat = sum.to_chat_message();

        let text = chat.text();
        assert!(
            !text.ends_with(char::is_whitespace),
            "Compaction chat message must not end with whitespace, got: {:?}",
            &text[text.len().saturating_sub(30)..]
        );
    }

    #[test]
    fn test_filter_to_effective_history_only_returns_post_compaction() {
        // Simulate a second compaction scenario: old messages, compaction pair, new messages
        let messages = vec![
            MessageFixture::user_message("1", "s1", "Very old message"),
            MessageFixture::assistant_message("2", "s1", "Very old response"),
            MessageFixture::user_message("3", "s1", "Another old message"),
            MessageFixture::assistant_message("4", "s1", "Another old response"),
            MessageFixture::compaction_request_message("5", "s1"),
            MessageFixture::compaction_message("6", "s1", "Summary of old messages"),
            MessageFixture::user_message("7", "s1", "New message after compaction"),
            MessageFixture::assistant_message("8", "s1", "New response"),
            MessageFixture::user_message("9", "s1", "Latest message"),
        ];

        let filtered = filter_to_effective_history(messages);

        // Should contain compaction pair + everything after
        assert_eq!(filtered.len(), 5);
        assert_eq!(filtered[0].id, "5"); // CompactionRequest (user)
        assert_eq!(filtered[1].id, "6"); // Compaction (assistant)
        assert_eq!(filtered[2].id, "7");
        assert_eq!(filtered[3].id, "8");
        assert_eq!(filtered[4].id, "9");

        // Verify the pair starts with user→assistant
        assert_eq!(filtered[0].role, ChatRole::User);
        assert!(
            filtered[0]
                .parts
                .iter()
                .any(|p| matches!(p, MessagePart::CompactionRequest { .. }))
        );
        assert_eq!(filtered[1].role, ChatRole::Assistant);
        assert!(
            filtered[1]
                .parts
                .iter()
                .any(|p| matches!(p, MessagePart::Compaction { .. }))
        );
    }

    // ========================================================================
    // is_mesh_timeout_error tests
    // ========================================================================

    #[test]
    fn test_mesh_timeout_network_timeout() {
        let err = LLMError::Transport {
            kind: querymt::error::TransportErrorKind::Timeout,
            message: "network timeout".to_string(),
        };
        assert!(
            is_mesh_timeout_error(&err),
            "network timeout should be detected as mesh timeout"
        );
    }

    #[test]
    fn test_mesh_timeout_eof_error() {
        let err = LLMError::Transport {
            kind: querymt::error::TransportErrorKind::Timeout,
            message: "Eof { name: \"enum\", expect: Small(1) }".to_string(),
        };
        assert!(
            is_mesh_timeout_error(&err),
            "Eof decode error should be detected as mesh timeout"
        );
    }

    #[test]
    fn test_mesh_timeout_dial_failure_is_not_timeout() {
        let err = LLMError::Transport {
            kind: querymt::error::TransportErrorKind::ConnectionRefused,
            message: "dial failure".to_string(),
        };
        assert!(
            !is_mesh_timeout_error(&err),
            "dial failure should NOT be classified as mesh timeout"
        );
    }

    #[test]
    fn test_mesh_timeout_generic_error_is_not_timeout() {
        let err = LLMError::GenericError("some other error".to_string());
        assert!(
            !is_mesh_timeout_error(&err),
            "generic errors should NOT be classified as mesh timeout"
        );
    }

    #[test]
    fn test_mesh_timeout_connection_closed_is_not_timeout() {
        let err = LLMError::Transport {
            kind: querymt::error::TransportErrorKind::ConnectionClosed,
            message: "connection closed".to_string(),
        };
        assert!(
            !is_mesh_timeout_error(&err),
            "connection closed should NOT be classified as mesh timeout"
        );
    }

    #[tokio::test]
    async fn test_process_aborts_on_mesh_timeout() {
        let service = SessionCompaction::new();
        let messages = MessageFixture::simple_conversation("s1");

        // Simulate a mesh timeout on the first attempt — should NOT retry.
        let mock = MockCompactionProvider::new(vec![
            Err(LLMError::Transport {
                kind: querymt::error::TransportErrorKind::Timeout,
                message: "network timeout".to_string(),
            }),
            // This second response should never be reached.
            Ok("should not be used".to_string()),
        ]);
        let mock_arc = Arc::new(mock);

        let result = service
            .process(
                &messages,
                mock_arc.clone(),
                "model",
                &RetryConfig::default(),
                None,
            )
            .await;

        assert!(result.is_err(), "should fail on mesh timeout");
        assert_eq!(
            mock_arc.call_count(),
            1,
            "should NOT retry after mesh timeout — only 1 call expected"
        );
        let err_msg = result.unwrap_err().to_string();
        assert!(
            err_msg.contains("mesh timeout"),
            "error should mention mesh timeout, got: {}",
            err_msg
        );
    }

    // ========================================================================
    // merge_consecutive_tool_result_messages tests
    // ========================================================================

    /// Helper: a User message with a single tool_result part.
    fn make_user_tool_result(id: &str, session_id: &str, call_id: &str) -> AgentMessage {
        AgentMessage {
            id: id.to_string(),
            session_id: session_id.to_string(),
            role: ChatRole::User,
            parts: vec![MessagePart::ToolResult {
                call_id: call_id.to_string(),
                content: vec![querymt::chat::Content::text("output")],
                is_error: false,
                tool_name: Some("tool".to_string()),
                tool_arguments: None,
                compacted_at: None,
            }],
            created_at: 0,
            parent_message_id: None,
            source_provider: None,
            source_model: None,
        }
    }

    /// Helper: an Assistant message with tool_use parts.
    fn make_assistant_tool_use(id: &str, session_id: &str) -> AgentMessage {
        AgentMessage {
            id: id.to_string(),
            session_id: session_id.to_string(),
            role: ChatRole::Assistant,
            parts: vec![MessagePart::ToolUse(querymt::ToolCall {
                id: "tu-1".to_string(),
                call_type: "function".to_string(),
                function: querymt::FunctionCall {
                    name: "tool".to_string(),
                    arguments: "{}".to_string(),
                },
            })],
            created_at: 0,
            parent_message_id: None,
            source_provider: None,
            source_model: None,
        }
    }

    #[test]
    fn test_merge_consecutive_tool_result_messages_combines_split_results() {
        // Simulates legacy DB layout: assistant with 3 tool_use, followed by
        // 3 separate User messages each with 1 tool_result.
        let messages = vec![
            MessageFixture::user_message("1", "s1", "do stuff"),
            make_assistant_tool_use("2", "s1"),
            make_user_tool_result("3", "s1", "call-a"),
            make_user_tool_result("4", "s1", "call-b"),
            make_user_tool_result("5", "s1", "call-c"),
            MessageFixture::assistant_message("6", "s1", "done"),
        ];

        let merged = merge_consecutive_tool_result_messages(messages);

        assert_eq!(merged.len(), 4, "3 tool_result msgs should merge into 1");
        // The merged message should keep the first message's id.
        assert_eq!(merged[2].id, "3");
        assert_eq!(merged[2].role, ChatRole::User);
        // All 3 tool_result parts should be in the merged message.
        let tr_count = merged[2]
            .parts
            .iter()
            .filter(|p| matches!(p, MessagePart::ToolResult { .. }))
            .count();
        assert_eq!(tr_count, 3, "merged message should have 3 ToolResult parts");
    }

    #[test]
    fn test_merge_consecutive_leaves_single_results_alone() {
        // Single tool_result User messages should not be affected.
        let messages = vec![
            MessageFixture::user_message("1", "s1", "hi"),
            make_assistant_tool_use("2", "s1"),
            make_user_tool_result("3", "s1", "call-a"),
            MessageFixture::assistant_message("4", "s1", "ok"),
        ];

        let merged = merge_consecutive_tool_result_messages(messages.clone());
        assert_eq!(merged.len(), 4);
        assert_eq!(merged[2].parts.len(), 1);
    }

    #[test]
    fn test_merge_consecutive_does_not_merge_non_tool_result_user_messages() {
        // Two consecutive User messages where the second is plain text (not a
        // tool result) should not be merged.
        let messages = vec![
            make_assistant_tool_use("1", "s1"),
            make_user_tool_result("2", "s1", "call-a"),
            MessageFixture::user_message("3", "s1", "follow up"),
            MessageFixture::assistant_message("4", "s1", "ok"),
        ];

        let merged = merge_consecutive_tool_result_messages(messages);
        // No merging should happen — 2 and 3 stay separate.
        assert_eq!(merged.len(), 4);
    }

    #[test]
    fn test_filter_to_effective_history_merges_split_tool_results() {
        // End-to-end: filter_to_effective_history should also merge split
        // tool_result messages (safety net for existing DB data).
        let messages = vec![
            MessageFixture::user_message("1", "s1", "do stuff"),
            make_assistant_tool_use("2", "s1"),
            make_user_tool_result("3", "s1", "call-a"),
            make_user_tool_result("4", "s1", "call-b"),
            MessageFixture::assistant_message("5", "s1", "done"),
        ];

        let filtered = filter_to_effective_history(messages);
        assert_eq!(
            filtered.len(),
            4,
            "2 split tool_result msgs should be merged into 1"
        );
    }
}
