use serde::Serialize;

use crate::agent::session::SessionEntry;
use yoagent::types::AgentMessage;

// ── CompactionSettings ─────────────────────────────────────────────

/// Per-session config for compaction behaviour.
#[derive(Debug, Clone)]
pub struct CompactionSettings {
    pub enabled: bool,
    /// Tokens to reserve for system prompt, tool defs, and the response.
    pub reserve_tokens: u64,
    /// Number of most-recent tokens to always keep (never summarised).
    pub keep_recent_tokens: u64,
}

impl Default for CompactionSettings {
    fn default() -> Self {
        Self {
            enabled: true,
            reserve_tokens: 16_384,
            keep_recent_tokens: 20_000,
        }
    }
}

// ── Compaction reason ──────────────────────────────────────────────

/// Why compaction was triggered.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CompactionReason {
    /// User manually triggered `/compact`.
    Manual,
    /// Context usage exceeded the configured threshold.
    Threshold,
    /// Provider returned a context overflow error.
    Overflow,
}

impl std::fmt::Display for CompactionReason {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            CompactionReason::Manual => write!(f, "manual"),
            CompactionReason::Threshold => write!(f, "threshold"),
            CompactionReason::Overflow => write!(f, "overflow"),
        }
    }
}

// ── Result types ───────────────────────────────────────────────────

/// Result of prepare_compaction — what to summarise and what to keep.
#[derive(Debug, Clone)]
pub struct CompactionPreparation {
    /// ID of the first entry to keep (everything before is summarised).
    pub first_kept_entry_id: String,
    /// Messages to summarise (will be replaced by a compaction entry).
    pub messages_to_summarize: Vec<AgentMessage>,
    /// Turn-prefix messages when splitting a single turn.
    pub turn_prefix_messages: Vec<AgentMessage>,
    /// Whether the cut point split a turn in half.
    pub is_split_turn: bool,
    /// Estimated total tokens before compaction.
    pub tokens_before: u64,
    /// Previous compaction summary (for incremental update).
    pub previous_summary: Option<String>,
}

/// Result of compact() — ready to append to the session.
#[derive(Debug, Clone, Serialize)]
pub struct CompactionResult {
    pub summary: String,
    pub first_kept_entry_id: String,
    pub tokens_before: u64,
    /// Estimated context tokens immediately after compaction is applied.
    pub estimated_tokens_after: u64,
    /// File operation details (readFiles, modifiedFiles).
    pub details: Option<serde_json::Value>,
}

// ── Default context windows ────────────────────────────────────────

/// Known model context windows (in tokens).
/// Falls back to 200_000 for unknown models.
const MODEL_CONTEXT_WINDOWS: &[(&str, u64)] = &[
    ("deepseek", 1_000_000),
    ("claude", 200_000),
    ("gpt-4", 128_000),
    ("gpt-4o", 128_000),
    ("gemini", 1_048_576),
    ("sonnet", 200_000),
    ("haiku", 200_000),
];

/// Look up the context window for a model name.
pub fn get_model_context_window(model: &str) -> u64 {
    let lower = model.to_lowercase();
    for (prefix, window) in MODEL_CONTEXT_WINDOWS {
        if lower.starts_with(prefix) {
            return *window;
        }
    }
    200_000
}

// ── Token estimation ───────────────────────────────────────────────

/// Estimate token count for a single message (chars/4 heuristic, conservative).
pub fn estimate_tokens(message: &AgentMessage) -> u64 {
    use yoagent::types::Content;

    let text = crate::agent::types::message_text(message);
    let mut chars: usize = text.len();

    if let AgentMessage::Llm(yoagent::types::Message::Assistant { content, .. }) = message {
        // Account for thinking blocks and images in assistant messages (pi-compatible).
        // text.len() is already counted via message_text above, so only add extra non-text content.
        for c in content {
            match c {
                Content::Text { .. } => {
                    // Already counted in message_text
                }
                Content::Thinking { thinking, .. } => {
                    chars += thinking.len();
                }
                Content::ToolCall {
                    name, arguments, ..
                } => {
                    chars += name.len();
                    chars += serde_json::to_string(arguments).unwrap_or_default().len();
                }
                Content::Image { .. } => {
                    // Pi estimates 4800 chars per image
                    chars += 4800;
                }
            }
        }
    } else if let AgentMessage::Llm(yoagent::types::Message::User { content: c, .. }) = message {
        // Account for images in user messages (pi-compatible)
        for c in c {
            if matches!(c, Content::Image { .. }) {
                chars += 4800;
            }
        }
    }

    (chars as u64).div_ceil(4)
}

/// Estimate context tokens for a slice of messages.
/// Uses recorded usage from the last non-aborted assistant message as the baseline,
/// then adds estimated tokens for any messages after it.
pub fn estimate_context_tokens(messages: &[AgentMessage]) -> u64 {
    let mut last_usage_index = None;
    for (i, msg) in messages.iter().enumerate().rev() {
        if let Some(usage) = crate::agent::types::message_usage(msg) {
            // Skip usage records that are all zeros (e.g. from test helpers)
            if usage.input > 0 || usage.output > 0 || usage.cache_read > 0 {
                last_usage_index = Some(i);
                break;
            }
        }
    }

    if let Some(idx) = last_usage_index {
        if let Some(usage) = crate::agent::types::message_usage(&messages[idx]) {
            let usage_tokens = usage.input + usage.output + usage.cache_read;
            let mut trailing = 0u64;
            for msg in &messages[idx + 1..] {
                trailing += estimate_tokens(msg);
            }
            usage_tokens + trailing
        } else {
            messages.iter().map(estimate_tokens).sum()
        }
    } else {
        messages.iter().map(estimate_tokens).sum()
    }
}

// ── shouldCompact ──────────────────────────────────────────────────

/// Determine whether compaction should trigger.
pub fn should_compact(
    context_tokens: u64,
    context_window: u64,
    settings: &CompactionSettings,
) -> bool {
    if !settings.enabled {
        return false;
    }
    context_tokens > context_window.saturating_sub(settings.reserve_tokens)
}

// ── Cut-point detection ────────────────────────────────────────────

/// Find valid cut-point indices: user and assistant messages (never tool results).
fn find_valid_cut_points(entries: &[SessionEntry], start: usize, end: usize) -> Vec<usize> {
    let mut points = Vec::new();
    for (i, entry) in entries.iter().enumerate().take(end).skip(start) {
        match entry {
            SessionEntry::Message(m) => {
                if crate::agent::types::message_is_user(&m.message)
                    || crate::agent::types::message_is_assistant(&m.message)
                {
                    points.push(i);
                }
            }
            // Pi-compatible: branch_summary and custom_message are valid cut points
            SessionEntry::BranchSummary(_) | SessionEntry::CustomMessage(_) => {
                points.push(i);
            }
            SessionEntry::ThinkingLevelChange(_)
            | SessionEntry::ModelChange(_)
            | SessionEntry::ActiveToolsChange(_)
            | SessionEntry::Custom(_)
            | SessionEntry::Label(_)
            | SessionEntry::SessionInfo(_)
            | SessionEntry::Compaction(_)
            | SessionEntry::Leaf(_) => {}
        }
    }
    points
}

/// Find the user message that starts the turn containing `entry_index`.
fn find_turn_start_index(
    entries: &[SessionEntry],
    entry_index: usize,
    start: usize,
) -> Option<usize> {
    for i in (start..=entry_index).rev() {
        match &entries[i] {
            SessionEntry::Message(m) if crate::agent::types::message_is_user(&m.message) => {
                return Some(i);
            }
            // Pi-compatible: branch_summary and custom_message start a turn
            SessionEntry::BranchSummary(_) | SessionEntry::CustomMessage(_) => return Some(i),
            _ => {}
        }
    }
    None
}

/// Result of finding the cut point.
struct CutPointResult {
    first_kept_entry_index: usize,
    turn_start_index: Option<usize>,
    is_split_turn: bool,
}

/// Walk backwards from the end, accumulating estimated token sizes,
/// and find where to cut.
fn find_cut_point(
    entries: &[SessionEntry],
    start: usize,
    end: usize,
    keep_recent_tokens: u64,
) -> CutPointResult {
    let cut_points = find_valid_cut_points(entries, start, end);

    if cut_points.is_empty() {
        return CutPointResult {
            first_kept_entry_index: start,
            turn_start_index: None,
            is_split_turn: false,
        };
    }

    let mut accumulated = 0u64;
    let mut cut_index = cut_points[0];

    for i in (start..end).rev() {
        let tokens = match &entries[i] {
            SessionEntry::Message(m) => estimate_tokens(&m.message),
            _ => continue,
        };
        accumulated += tokens;

        if accumulated >= keep_recent_tokens {
            // Find the closest valid cut point at or after this entry
            for &cp in &cut_points {
                if cp >= i {
                    cut_index = cp;
                    break;
                }
            }
            break;
        }
    }

    // Walk backward past non-message entries (label, info, etc.)
    while cut_index > start {
        match &entries[cut_index - 1] {
            SessionEntry::Message(_) | SessionEntry::Compaction(_) => break,
            _ => cut_index -= 1,
        }
    }

    let cut_entry = &entries[cut_index];
    let is_user_msg = matches!(cut_entry, SessionEntry::Message(m) if crate::agent::types::message_is_user(&m.message));
    let turn_start = if is_user_msg {
        None
    } else {
        find_turn_start_index(entries, cut_index, start)
    };

    CutPointResult {
        first_kept_entry_index: cut_index,
        turn_start_index: turn_start,
        is_split_turn: !is_user_msg && turn_start.is_some(),
    }
}

// ── prepareCompaction ──────────────────────────────────────────────

/// Analyse the session branch and determine what should be compacted.
///
/// Returns `None` when the last entry is already a compaction (nothing new to do).
pub fn prepare_compaction(
    entries: &[SessionEntry],
    settings: &CompactionSettings,
) -> Option<CompactionPreparation> {
    // Don't compact if no entries
    if entries.is_empty() {
        return None;
    }
    // Don't compact if the last entry is already a compaction
    if let Some(SessionEntry::Compaction(_)) = entries.last() {
        return None;
    }

    // Find previous compaction boundary
    let mut prev_compaction_idx = None;
    for (i, entry) in entries.iter().enumerate().rev() {
        if matches!(entry, SessionEntry::Compaction(_)) {
            prev_compaction_idx = Some(i);
            break;
        }
    }

    let mut previous_summary: Option<String> = None;
    let boundary_start = if let Some(ci) = prev_compaction_idx {
        if let SessionEntry::Compaction(c) = &entries[ci] {
            previous_summary = Some(c.summary.clone());
            // Find where the previous compaction's kept region starts
            let kept_idx = entries.iter().position(|e| e.id() == c.first_kept_entry_id);
            kept_idx.unwrap_or(ci + 1)
        } else {
            0
        }
    } else {
        0
    };

    let boundary_end = entries.len();
    let context_msgs: Vec<AgentMessage> = entries
        .iter()
        .filter_map(|e| match e {
            SessionEntry::Message(m) => Some(m.message.clone()),
            SessionEntry::BranchSummary(s) => Some(crate::agent::types::assistant_message(
                format!("[Branch: from {}] {}", s.from_id, s.summary),
            )),
            SessionEntry::CustomMessage(c) => {
                Some(crate::agent::types::assistant_message(format!(
                    "[{}] {}",
                    c.custom_type,
                    serde_json::to_string(&c.content).unwrap_or_default()
                )))
            }
            _ => None,
        })
        .collect();

    let tokens_before = estimate_context_tokens(&context_msgs);

    let cut = find_cut_point(
        entries,
        boundary_start,
        boundary_end,
        settings.keep_recent_tokens,
    );

    let first_kept = &entries[cut.first_kept_entry_index];
    let first_kept_entry_id = first_kept.id().to_string();

    let history_end = if cut.is_split_turn {
        cut.turn_start_index.unwrap_or(cut.first_kept_entry_index)
    } else {
        cut.first_kept_entry_index
    };

    // Collect messages to summarise
    let messages_to_summarize: Vec<AgentMessage> = entries[boundary_start..history_end]
        .iter()
        .filter_map(|e| match e {
            SessionEntry::Message(m) => Some(m.message.clone()),
            _ => None,
        })
        .collect();

    // Turn prefix messages (when splitting a turn)
    let turn_prefix_messages: Vec<AgentMessage> = if cut.is_split_turn {
        entries[cut.turn_start_index.unwrap_or(0)..cut.first_kept_entry_index]
            .iter()
            .filter_map(|e| match e {
                SessionEntry::Message(m) => Some(m.message.clone()),
                _ => None,
            })
            .collect()
    } else {
        vec![]
    };

    if messages_to_summarize.is_empty() && turn_prefix_messages.is_empty() {
        return None;
    }

    Some(CompactionPreparation {
        first_kept_entry_id,
        messages_to_summarize,
        turn_prefix_messages,
        is_split_turn: cut.is_split_turn,
        tokens_before,
        previous_summary,
    })
}

// ── Summarization prompts ──────────────────────────────────────────

const SUMMARIZATION_SYSTEM_PROMPT: &str = "You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.\n\nDo NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.";

const SUMMARIZATION_PROMPT: &str = "The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.\n\nUse this EXACT format:\n\n## Goal\n[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]\n\n## Constraints & Preferences\n- [Any constraints, preferences, or requirements mentioned by user]\n- [Or \"(none)\" if none were mentioned]\n\n## Progress\n### Done\n- [x] [Completed tasks/changes]\n\n### In Progress\n- [ ] [Current work]\n\n### Blocked\n- [Issues preventing progress, if any]\n\n## Key Decisions\n- **[Decision]**: [Brief rationale]\n\n## Next Steps\n1. [Ordered list of what should happen next]\n\n## Critical Context\n- [Any data, examples, or references needed to continue]\n- [Or \"(none)\" if not applicable]\n\nKeep each section concise. Preserve exact file paths, function names, and error messages.";

const UPDATE_SUMMARIZATION_PROMPT: &str = "The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.\n\nUpdate the existing structured summary with new information. RULES:\n- PRESERVE all existing information from the previous summary\n- ADD new progress, decisions, and context from the new messages\n- UPDATE the Progress section: move items from \"In Progress\" to \"Done\" when completed\n- UPDATE \"Next Steps\" based on what was accomplished\n- PRESERVE exact file paths, function names, and error messages\n- If something is no longer relevant, you may remove it\n\nUse this EXACT format:\n\n## Goal\n[Preserve existing goals, add new ones if the task expanded]\n\n## Constraints & Preferences\n- [Preserve existing, add new ones discovered]\n\n## Progress\n### Done\n- [x] [Include previously done items AND newly completed items]\n\n### In Progress\n- [ ] [Current work - update based on progress]\n\n### Blocked\n- [Current blockers - remove if resolved]\n\n## Key Decisions\n- **[Decision]**: [Brief rationale] (preserve all previous, add new)\n\n## Next Steps\n1. [Update based on current state]\n\n## Critical Context\n- [Preserve important context, add new if needed]\n\nKeep each section concise. Preserve exact file paths, function names, and error messages.";

const TURN_PREFIX_SUMMARIZATION_PROMPT: &str = r#"This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.

Summarize the prefix to provide context for the retained suffix:

## Original Request
[What did the user ask for?]

## Early Progress
- [Key decisions and work done]

## Context for Suffix
- [Information needed to understand the kept suffix]"#;

// ── File operation extraction ──────────────────────────────────────

/// File operations accumulator, matching pi's FileOperations / createFileOps.
pub struct FileOps {
    pub read: std::collections::HashSet<String>,
    pub written: std::collections::HashSet<String>,
    pub edited: std::collections::HashSet<String>,
}

impl FileOps {
    pub fn new() -> Self {
        Self {
            read: std::collections::HashSet::new(),
            written: std::collections::HashSet::new(),
            edited: std::collections::HashSet::new(),
        }
    }

    /// Extract file ops from a single assistant message (pi-compatible).
    pub fn extract_from_message(&mut self, msg: &AgentMessage) {
        if let AgentMessage::Llm(yoagent::types::Message::Assistant { content, .. }) = msg {
            let tcs = crate::agent::types::content_tool_calls(content);
            for (_, name, args) in &tcs {
                // Pi only checks `path` field (not `file_path`)
                let path = args
                    .get("path")
                    .and_then(|v| v.as_str())
                    .map(|s| s.to_string());
                let Some(p) = path else { continue };
                match name.as_str() {
                    "read" => {
                        self.read.insert(p);
                    }
                    "write" => {
                        self.written.insert(p);
                    }
                    "edit" => {
                        self.edited.insert(p);
                    }
                    _ => {}
                }
            }
        }
    }

    /// Compute sorted read-only and modified file lists (pi-compatible).
    pub fn compute_lists(&self) -> (Vec<String>, Vec<String>) {
        let modified: std::collections::HashSet<String> =
            self.edited.union(&self.written).cloned().collect();
        let mut read_only: Vec<String> = self.read.difference(&modified).cloned().collect();
        read_only.sort();
        let mut modified_sorted: Vec<String> = modified.into_iter().collect();
        modified_sorted.sort();
        (read_only, modified_sorted)
    }

    /// Serialize to JSON for compaction details (pi-compatible).
    pub fn to_json_value(&self) -> Option<serde_json::Value> {
        let (read_files, modified_files) = self.compute_lists();
        if read_files.is_empty() && modified_files.is_empty() {
            return None;
        }
        Some(serde_json::json!({
            "readFiles": read_files,
            "modifiedFiles": modified_files,
        }))
    }
}

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

/// Extract file operations from a list of messages (for compaction details).
fn extract_file_ops(messages: &[AgentMessage]) -> Option<serde_json::Value> {
    let mut ops = FileOps::new();
    for msg in messages {
        ops.extract_from_message(msg);
    }
    ops.to_json_value()
}

// ── compact ────────────────────────────────────────────────────────

/// Execute compaction: send messages to the provider for summarisation
/// and return the result ready to append to the session.
///
/// `model_config` should be the session's current model configuration.
/// `thinking_level` controls whether the summarization uses reasoning mode.
pub async fn compact(
    preparation: &CompactionPreparation,
    api_key: &str,
    model: &str,
    system_prompt_override: Option<&str>,
    thinking_level: yoagent::types::ThinkingLevel,
    model_config: Option<yoagent::provider::model::ModelConfig>,
) -> Result<CompactionResult, String> {
    // Serialize messages to summarise into a single text block
    let mut conversation_text = String::new();
    for msg in &preparation.messages_to_summarize {
        conversation_text.push_str(&format_message_for_summary(msg));
        conversation_text.push('\n');
    }

    // Build the summarisation prompt
    let system = system_prompt_override.unwrap_or(SUMMARIZATION_SYSTEM_PROMPT);
    let mut prompt = String::new();
    if !conversation_text.is_empty() {
        prompt.push_str("<conversation>\n");
        prompt.push_str(&conversation_text);
        prompt.push_str("\n</conversation>\n\n");
    }

    // Add previous summary if available (incremental update)
    if let Some(ref prev) = preparation.previous_summary {
        prompt.push_str(&format!(
            "<previous-summary>\n{}\n</previous-summary>\n\n",
            prev
        ));
    }

    if preparation.is_split_turn && !preparation.turn_prefix_messages.is_empty() {
        // Two-part summary: history + turn prefix
        let mut history_text = String::new();
        for msg in &preparation.turn_prefix_messages {
            history_text.push_str(&format_message_for_summary(msg));
            history_text.push('\n');
        }
        let turn_prompt = format!(
            "{}\n\n<turn-prefix>\n{}\n</turn-prefix>\n\n{}",
            prompt, history_text, TURN_PREFIX_SUMMARIZATION_PROMPT
        );
        prompt = turn_prompt;
    } else if preparation.previous_summary.is_some() {
        prompt.push_str(UPDATE_SUMMARIZATION_PROMPT);
    } else {
        prompt.push_str(SUMMARIZATION_PROMPT);
    }

    // Create a summarisation message
    let summary_msg = crate::agent::types::user_message(&prompt);

    // Get summary from provider via yoagent
    let summary_text = summarize_text(
        api_key,
        model,
        system,
        &[summary_msg],
        thinking_level,
        model_config,
    )
    .await?;

    // Extract file operations from messages being summarised
    let mut all_messages = preparation.messages_to_summarize.clone();
    all_messages.extend(preparation.turn_prefix_messages.clone());
    let details = extract_file_ops(&all_messages);

    // Estimate tokens after compaction:
    //   summary text + kept messages (estimated via heuristic)
    let summary_msg_est = (summary_text.len() as u64).div_ceil(4);
    let kept_tokens = preparation
        .tokens_before
        .saturating_sub(
            preparation
                .messages_to_summarize
                .iter()
                .map(estimate_tokens)
                .sum::<u64>(),
        )
        .saturating_sub(
            preparation
                .turn_prefix_messages
                .iter()
                .map(estimate_tokens)
                .sum::<u64>(),
        );
    let estimated_tokens_after = summary_msg_est + kept_tokens;

    // Build the result
    Ok(CompactionResult {
        summary: summary_text,
        first_kept_entry_id: preparation.first_kept_entry_id.clone(),
        tokens_before: preparation.tokens_before,
        estimated_tokens_after,
        details,
    })
}

/// Call the provider for a simple text completion (no tools, no streaming).
///
/// Format a message for inclusion in the summarisation prompt.
fn format_message_for_summary(msg: &AgentMessage) -> String {
    let role_label = if crate::agent::types::message_is_user(msg) {
        "User"
    } else if crate::agent::types::message_is_assistant(msg) {
        "Assistant"
    } else {
        "Tool Result"
    };
    let content = crate::agent::types::message_text(msg);
    let mut result = format!("<{}>\n", role_label);
    result.push_str(&content);

    // Include tool calls for assistant messages
    if crate::agent::types::message_tool_call_count(msg) > 0
        && let AgentMessage::Llm(yoagent::types::Message::Assistant { content: c, .. }) = msg
    {
        let tcs = crate::agent::types::content_tool_calls(c);
        if !tcs.is_empty() {
            result.push_str("\n\nTool calls:\n");
            for (_, name, args) in &tcs {
                result.push_str(&format!(
                    "  - {}: {}\n",
                    name,
                    serde_json::to_string(args).unwrap_or_default()
                ));
            }
        }
    }
    result.push_str(&format!("\n</{}>", role_label));
    result
}

// ── Tests ──────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::agent::session::{CompactionEntry, MessageEntry};
    use crate::agent::types::{assistant_message, tool_result_message, user_message};
    use yoagent::types::{AgentMessage, Content, Message};

    // ── get_model_context_window tests ──────────────────────────────

    #[test]
    fn test_context_window_known_model() {
        assert_eq!(get_model_context_window("deepseek-v4-flash"), 1_000_000);
        assert_eq!(get_model_context_window("claude-sonnet-4"), 200_000);
        assert_eq!(get_model_context_window("gpt-4o"), 128_000);
        assert_eq!(get_model_context_window("gemini-2.0-flash"), 1_048_576);
    }

    #[test]
    fn test_context_window_unknown_model_falls_back() {
        assert_eq!(get_model_context_window("unknown-model-42"), 200_000);
    }

    #[test]
    fn test_context_window_case_insensitive() {
        assert_eq!(get_model_context_window("DeepSeek-V4"), 1_000_000);
        assert_eq!(get_model_context_window("CLAUDE-OPUS"), 200_000);
    }

    // ── estimate_tokens tests ───────────────────────────────────────

    #[test]
    fn test_estimate_tokens_empty_message() {
        let msg = user_message("");
        assert_eq!(estimate_tokens(&msg), 0);
    }

    #[test]
    fn test_estimate_tokens_short_message() {
        let msg = user_message("hello");
        // 5 chars / 4 = 2 (div_ceil)
        assert_eq!(estimate_tokens(&msg), 2);
    }

    #[test]
    fn test_estimate_tokens_long_message() {
        let text = "a".repeat(100);
        let msg = user_message(&text);
        // 100 / 4 = 25
        assert_eq!(estimate_tokens(&msg), 25);
    }

    #[test]
    fn test_estimate_tokens_tool_call_includes_arguments() {
        let content = vec![
            Content::Text {
                text: "checking".into(),
            },
            Content::ToolCall {
                id: "call1".into(),
                name: "read".into(),
                arguments: serde_json::json!({"path": "/tmp/file.txt"}),
                provider_metadata: None,
            },
        ];
        let msg = AgentMessage::Llm(Message::Assistant {
            content,
            stop_reason: yoagent::types::StopReason::Stop,
            model: String::new(),
            provider: String::new(),
            usage: yoagent::types::Usage::default(),
            timestamp: 0,
            error_message: None,
        });
        let tokens = estimate_tokens(&msg);
        // text "checking" (8) + name "read" (4) + args json length >= 17
        assert!(tokens >= 8, "tokens={}", tokens);
    }

    // ── estimate_context_tokens tests ───────────────────────────────

    #[test]
    fn test_estimate_context_tokens_empty() {
        assert_eq!(estimate_context_tokens(&[]), 0);
    }

    #[test]
    fn test_estimate_context_tokens_no_usage_uses_heuristic() {
        let msgs = vec![user_message("hello"), assistant_message("world")];
        let tokens = estimate_context_tokens(&msgs);
        // 5/4 + 5/4 = 2 + 2 = 4
        assert_eq!(tokens, 4);
    }

    #[test]
    fn test_estimate_context_tokens_with_usage_baseline() {
        let msg_with_usage = AgentMessage::Llm(Message::Assistant {
            content: vec![Content::Text {
                text: "response".into(),
            }],
            stop_reason: yoagent::types::StopReason::Stop,
            model: String::new(),
            provider: String::new(),
            usage: yoagent::types::Usage {
                input: 100,
                output: 50,
                cache_read: 20,
                cache_write: 0,
                total_tokens: 0,
            },
            timestamp: 0,
            error_message: None,
        });
        let msgs = vec![
            user_message("hello"),
            msg_with_usage,
            user_message("follow-up"),
        ];
        let tokens = estimate_context_tokens(&msgs);
        // usage: 100 + 50 + 20 = 170 + trailing "follow-up" (9/4=3) = 173
        assert_eq!(tokens, 173);
    }

    // ── should_compact tests ────────────────────────────────────────

    #[test]
    fn test_should_compact_disabled() {
        let settings = CompactionSettings {
            enabled: false,
            reserve_tokens: 16_384,
            keep_recent_tokens: 20_000,
        };
        assert!(!should_compact(999_999, 1_000_000, &settings));
    }

    #[test]
    fn test_should_compact_under_threshold() {
        let settings = CompactionSettings::default();
        assert!(!should_compact(100_000, 200_000, &settings));
    }

    #[test]
    fn test_should_compact_at_threshold() {
        let settings = CompactionSettings {
            reserve_tokens: 10_000,
            keep_recent_tokens: 20_000,
            ..Default::default()
        };
        // context_tokens > context_window - reserve = 190_000
        assert!(should_compact(190_001, 200_000, &settings));
        assert!(!should_compact(190_000, 200_000, &settings));
    }

    #[test]
    fn test_should_compact_exact_boundary() {
        let settings = CompactionSettings {
            enabled: true,
            reserve_tokens: 0,
            keep_recent_tokens: 0,
        };
        assert!(!should_compact(200_000, 200_000, &settings));
        assert!(should_compact(200_001, 200_000, &settings));
    }

    // ── find_valid_cut_points (via prepare_compaction) ──────────────

    /// Build a minimal session entry list for compaction testing.
    fn make_msg_entry(content: &str) -> SessionEntry {
        SessionEntry::Message(MessageEntry {
            id: uuid::Uuid::new_v4().to_string(),
            parent_id: None,
            timestamp: String::new(),
            message: user_message(content),
            cost: 0.0,
        })
    }

    fn make_asst_entry(content: &str) -> SessionEntry {
        SessionEntry::Message(MessageEntry {
            id: uuid::Uuid::new_v4().to_string(),
            parent_id: None,
            timestamp: String::new(),
            message: assistant_message(content),
            cost: 0.0,
        })
    }

    fn make_compaction_entry(first_kept_id: &str) -> SessionEntry {
        SessionEntry::Compaction(CompactionEntry {
            id: uuid::Uuid::new_v4().to_string(),
            parent_id: None,
            timestamp: String::new(),
            summary: "previous summary".into(),
            first_kept_entry_id: first_kept_id.to_string(),
            tokens_before: 1000,
            details: None,
            from_hook: None,
        })
    }

    #[test]
    fn test_prepare_compaction_empty_entries() {
        let settings = CompactionSettings::default();
        assert!(prepare_compaction(&[], &settings).is_none());
    }

    #[test]
    fn test_prepare_compaction_last_entry_is_compaction() {
        let entries = vec![make_msg_entry("hello"), make_compaction_entry("some-id")];
        let settings = CompactionSettings::default();
        assert!(prepare_compaction(&entries, &settings).is_none());
    }

    #[test]
    fn test_prepare_compaction_returns_preparation() {
        // Create enough entries that keep_recent_tokens forces a cut
        let mut entries: Vec<SessionEntry> = (0..10)
            .map(|i| {
                make_msg_entry(&format!(
                    "message {} with enough text to accumulate tokens",
                    i
                ))
            })
            .collect();
        // Add some assistant messages too
        for i in 0..5 {
            entries.push(make_asst_entry(&format!("response {} with enough text", i)));
        }

        let settings = CompactionSettings {
            enabled: true,
            reserve_tokens: 100_000,
            keep_recent_tokens: 2, // very small, will cut early
        };
        let result = prepare_compaction(&entries, &settings);
        assert!(result.is_some(), "should return preparation");
        let prep = result.unwrap();
        assert!(!prep.messages_to_summarize.is_empty());
        assert!(!prep.first_kept_entry_id.is_empty());
        assert!(prep.tokens_before > 0);
    }

    #[test]
    fn test_prepare_compaction_with_previous_compaction() {
        let mut entries: Vec<SessionEntry> = vec![make_msg_entry("old message")];

        // First compaction entry
        let first_id = entries[0].id().to_string();
        entries.push(make_compaction_entry(&first_id));

        // New messages after compaction
        entries.push(make_msg_entry("new message"));
        entries.push(make_asst_entry("new response"));

        let settings = CompactionSettings {
            enabled: true,
            reserve_tokens: 100_000,
            keep_recent_tokens: 1,
        };
        let result = prepare_compaction(&entries, &settings);
        assert!(result.is_some(), "should compact new messages");
        let prep = result.unwrap();
        assert!(prep.previous_summary.is_some());
        assert_eq!(prep.previous_summary.as_deref(), Some("previous summary"));
    }

    // ── extract_file_ops tests ──────────────────────────────────────

    fn make_asst_with_tool_call(name: &str, path: &str) -> AgentMessage {
        AgentMessage::Llm(Message::Assistant {
            content: vec![
                Content::Text {
                    text: "using tool".into(),
                },
                Content::ToolCall {
                    id: "call-1".into(),
                    name: name.into(),
                    arguments: serde_json::json!({"path": path}),
                    provider_metadata: None,
                },
            ],
            stop_reason: yoagent::types::StopReason::ToolUse,
            model: String::new(),
            provider: String::new(),
            usage: yoagent::types::Usage::default(),
            timestamp: 0,
            error_message: None,
        })
    }

    #[test]
    fn test_extract_file_ops_empty() {
        assert!(extract_file_ops(&[]).is_none());
    }

    #[test]
    fn test_extract_file_ops_no_tools() {
        let msgs = vec![user_message("hello"), assistant_message("hi")];
        assert!(extract_file_ops(&msgs).is_none());
    }

    #[test]
    fn test_extract_file_ops_read_and_write() {
        let msgs = vec![
            make_asst_with_tool_call("read", "/tmp/a.txt"),
            make_asst_with_tool_call("read", "/tmp/b.txt"),
            make_asst_with_tool_call("write", "/tmp/a.txt"),
        ];
        let result = extract_file_ops(&msgs).unwrap();
        let obj = result.as_object().unwrap();
        let read: Vec<String> = serde_json::from_value(obj["readFiles"].clone()).unwrap();
        let modified: Vec<String> = serde_json::from_value(obj["modifiedFiles"].clone()).unwrap();
        // a.txt is both read and modified -> goes only in modified
        assert_eq!(read, vec!["/tmp/b.txt".to_string()]);
        assert_eq!(modified, vec!["/tmp/a.txt".to_string()]);
    }

    #[test]
    fn test_extract_file_ops_deduplicates() {
        let msgs = vec![
            make_asst_with_tool_call("read", "/tmp/x.txt"),
            make_asst_with_tool_call("read", "/tmp/x.txt"),
        ];
        let result = extract_file_ops(&msgs).unwrap();
        let obj = result.as_object().unwrap();
        let read: Vec<String> = serde_json::from_value(obj["readFiles"].clone()).unwrap();
        assert_eq!(read.len(), 1);
    }

    // ── format_message_for_summary tests ────────────────────────────

    #[test]
    fn test_format_user_message() {
        let msg = user_message("hello world");
        let formatted = format_message_for_summary(&msg);
        assert!(formatted.contains("<User>"));
        assert!(formatted.contains("hello world"));
        assert!(formatted.contains("</User>"));
    }

    #[test]
    fn test_format_assistant_message_with_tool_calls() {
        let msg = make_asst_with_tool_call("edit", "/tmp/f.py");
        let formatted = format_message_for_summary(&msg);
        assert!(formatted.contains("<Assistant>"));
        assert!(formatted.contains("using tool"));
        assert!(formatted.contains("Tool calls"));
        assert!(formatted.contains("edit"));
    }

    #[test]
    fn test_format_tool_result_message() {
        let msg = tool_result_message("call-1", "bash", "command output", false);
        let formatted = format_message_for_summary(&msg);
        assert!(formatted.contains("Tool Result"));
        assert!(formatted.contains("command output"));
    }
}

// ── Summarization helper (shared with branch_summary) ──

/// Call yoagent's provider for a simple text completion (no tools, no streaming).
///
/// Uses the provided `model_config` (base URL, compat flags, etc.) and `thinking_level`
/// instead of hardcoded values. When `model_config` is None, falls back to the default
/// OpenCode Go endpoint for backward compatibility.
pub async fn summarize_text(
    api_key: &str,
    model: &str,
    system_prompt: &str,
    messages: &[AgentMessage],
    thinking_level: yoagent::types::ThinkingLevel,
    model_config: Option<yoagent::provider::model::ModelConfig>,
) -> Result<String, String> {
    use yoagent::provider::StreamProvider;
    use yoagent::provider::traits::StreamConfig;

    let yoagent_messages: Vec<yoagent::types::Message> = messages
        .iter()
        .filter_map(|m| match m {
            AgentMessage::Llm(msg) => Some(msg.clone()),
            AgentMessage::Extension(_) => None,
        })
        .collect();

    // Use provided model config, or fall back to hardcoded OpenCode Go for backward compat
    let model_config = model_config.unwrap_or_else(|| crate::agent::base_model_config(model));

    let retry_config = yoagent::RetryConfig::default();

    for attempt in 0..=retry_config.max_retries {
        let config = StreamConfig {
            model: model.to_string(),
            system_prompt: system_prompt.to_string(),
            messages: yoagent_messages.clone(),
            tools: vec![],
            thinking_level,
            api_key: api_key.to_string(),
            max_tokens: Some(2048),
            temperature: Some(0.3),
            model_config: Some(model_config.clone()),
            cache_config: yoagent::types::CacheConfig::default(),
        };

        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
        let cancel = tokio_util::sync::CancellationToken::new();

        tokio::spawn(async move {
            let _ = yoagent::provider::OpenAiCompatProvider
                .stream(config, tx, cancel)
                .await;
        });

        let mut text = String::new();
        let mut last_error: Option<String> = None;

        while let Some(event) = rx.recv().await {
            match event {
                yoagent::provider::traits::StreamEvent::TextDelta { delta, .. } => {
                    text.push_str(&delta);
                }
                yoagent::provider::traits::StreamEvent::Done { message } => {
                    if let yoagent::types::Message::Assistant { content, .. } = &message {
                        for c in content {
                            if let yoagent::types::Content::Text { text: t } = c
                                && text.is_empty()
                            {
                                text = t.clone();
                            }
                        }
                    }
                    break;
                }
                yoagent::provider::traits::StreamEvent::Error { .. } => {
                    last_error = Some("Provider returned error".to_string());
                    break;
                }
                _ => {}
            }
        }

        if let Some(err) = last_error {
            if attempt < retry_config.max_retries {
                let delay = retry_config.delay_for_attempt(attempt + 1);
                tokio::time::sleep(delay).await;
                continue;
            }
            return Err(err);
        }
        return Ok(text);
    }

    unreachable!()
}
