use super::*;

/// Asks for a title short enough to fit a sidebar row.
const TITLE_INSTRUCTION: &str = "Give this conversation a short title of at most six words. \
Reply with the title alone: no quotes, no trailing period, no explanation.";
const TITLE_MAX_TOKENS: i32 = 48;
const TITLE_MAX_CHARS: usize = 60;

/// A one-shot generation that produces a session title. It runs against the
/// transient KV cache, so it never creates or touches a stored session.
#[cfg(target_os = "macos")]
pub(super) struct TitleRequest {
    session_id: i32,
    /// Title the session had when the request was queued. If it changed since,
    /// somebody renamed it by hand and that newer intent wins.
    expected: String,
    active: ActiveGeneration,
    content: String,
}

#[cfg(target_os = "macos")]
pub(super) enum PendingContinuation {
    None,
    User(String),
    Tool(String),
}

#[cfg(target_os = "macos")]
pub(super) struct CompactionRequest {
    pub(super) active: ActiveGeneration,
    pending: PendingContinuation,
    message_ids: Vec<i32>,
}

#[cfg(target_os = "macos")]
#[derive(Clone, Copy)]
enum ToolCheckStage {
    Initial,
    AfterCompaction,
    BoundedError,
}

#[cfg(target_os = "macos")]
pub(super) struct ToolResultCheck {
    pub(super) active: ActiveGeneration,
    result: String,
    stage: ToolCheckStage,
}

#[derive(Clone, Debug)]
pub(crate) struct ChatMessage {
    pub(super) id: i32,
    pub(super) user: bool,
    pub(super) tool: bool,
    pub(super) system: bool,
    pub(super) compaction: bool,
    pub(super) compaction_tail_start: Option<i32>,
    pub(super) reasoning: Option<String>,
    pub(super) reasoning_complete: bool,
    pub(super) reasoning_open: bool,
    pub(super) content: String,
    pub(super) markdown: Vec<markdown::Item>,
}

impl ChatMessage {
    pub(super) fn append(&mut self, reasoning: bool, chunk: &str) {
        if reasoning {
            self.reasoning.get_or_insert_default().push_str(chunk);
        } else {
            self.reasoning_complete = true;
            self.content.push_str(chunk);
        }
    }

    pub(super) fn refresh_markdown(&mut self) {
        if !self.user && !self.tool {
            let visible = crate::agent::visible_content(&self.content);
            let content = if self.reasoning.is_some() {
                visible.trim_start()
            } else {
                visible
            };
            self.markdown = markdown::parse(content).collect();
        }
    }
}

impl From<StoredMessage> for ChatMessage {
    fn from(message: StoredMessage) -> Self {
        let mut message = Self {
            id: message.id,
            user: message.user,
            tool: message.tool,
            system: message.system,
            compaction: message.compaction,
            compaction_tail_start: message.compaction_tail_start,
            reasoning: message.reasoning,
            reasoning_complete: message.reasoning_complete,
            reasoning_open: false,
            content: message.content,
            markdown: Vec::new(),
        };
        message.refresh_markdown();
        message
    }
}

#[cfg(target_os = "macos")]
fn chat_turn(message: &ChatMessage) -> ChatTurn {
    ChatTurn {
        user: message.user,
        tool: message.tool,
        system: message.system,
        skip_previous_eos: false,
        reasoning: message.reasoning.clone(),
        reasoning_complete: message.reasoning_complete,
        content: message.content.clone(),
    }
}

fn queued_prompt(messages: impl IntoIterator<Item = String>) -> Option<String> {
    let messages = messages.into_iter().collect::<Vec<_>>();
    match messages.as_slice() {
        [] => None,
        [message] => Some(message.clone()),
        _ => Some(
            messages
                .iter()
                .enumerate()
                .map(|(index, message)| format!("Queued user message {}:\n{message}", index + 1))
                .collect::<Vec<_>>()
                .join("\n\n"),
        ),
    }
}

fn compacted_context_start(messages: &[ChatMessage]) -> usize {
    let Some(marker_index) = messages.iter().rposition(|message| message.compaction) else {
        return 0;
    };
    messages[marker_index]
        .compaction_tail_start
        .and_then(|id| {
            messages[..marker_index]
                .iter()
                .position(|message| message.id == id)
        })
        .unwrap_or(marker_index + 1)
}

fn has_chat_after_last_compaction(messages: &[ChatMessage]) -> bool {
    let start = messages
        .iter()
        .rposition(|message| message.compaction)
        .map_or(0, |index| index + 1);
    messages[start..].iter().any(|message| {
        !message.system
            && !message.compaction
            && !message
                .content
                .starts_with(crate::agent::COMPACTION_OBSERVATION_PREFIX)
            && (!message.content.trim().is_empty()
                || message
                    .reasoning
                    .as_deref()
                    .is_some_and(|reasoning| !reasoning.trim().is_empty()))
    })
}

impl App {
    pub(super) fn can_compact_session(&self, session_id: i32) -> bool {
        !self.generating
            && self.selected_session == Some(session_id)
            && has_chat_after_last_compaction(&self.conversation)
    }

    pub(super) fn start_generation(&mut self) {
        if self.selected_project.is_none() {
            return;
        }
        let prompt = self.composer.trim().to_owned();
        if prompt.is_empty() {
            return;
        }
        #[cfg(target_os = "macos")]
        if prompt == "/compact" {
            self.composer.clear();
            if self.generating {
                self.manual_compaction_queued = true;
                self.activity = Some("Compaction queued for the next safe point…".into());
            } else if let Err(error) =
                self.start_compaction(PendingContinuation::None, "manual /compact request")
            {
                self.error = Some(error);
            }
            return;
        }
        if self.generating {
            self.queued_inputs.push_back(prompt);
            self.composer.clear();
            self.activity = Some(format!(
                "{} queued input{}",
                self.queued_inputs.len(),
                if self.queued_inputs.len() == 1 {
                    ""
                } else {
                    "s"
                }
            ));
            return;
        }
        #[cfg(target_os = "macos")]
        self.tool_cards.clear();
        #[cfg(target_os = "macos")]
        if !std::mem::take(&mut self.skip_compaction_once)
            && crate::compaction::should_compact(self.context_used, self.context_limit)
        {
            if let Err(error) = self.start_compaction(
                PendingContinuation::User(prompt),
                "soft limit before user turn",
            ) {
                self.error = Some(error);
            }
            return;
        }
        let model = self.config.model;
        let effective = crate::settings::effective_settings(
            model,
            &self.config.generation,
            &self.config.runtime,
            &models_path(),
        );
        let mut effective = match effective {
            Ok(settings) => settings,
            Err(error) => {
                self.error = Some(error);
                return;
            }
        };
        effective.turn.system_prompt =
            crate::agent::system_prompt(model, &effective.turn.system_prompt);
        effective.turn.system_prompt = crate::compaction::summary_system_prompt(
            &effective.turn.system_prompt,
            self.compaction_summary(),
        );
        let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct;
        #[cfg(target_os = "macos")]
        let opening_turn = self.selected_session.is_none();
        #[cfg(target_os = "macos")]
        let mut injected_system = Vec::new();
        #[cfg(target_os = "macos")]
        if opening_turn {
            injected_system.push(crate::agent::datetime_context());
        }
        #[cfg(target_os = "macos")]
        let reminder_injected = self.system_prompt_reminder_due();
        #[cfg(target_os = "macos")]
        if reminder_injected {
            injected_system.extend(self.system_prompt_reminders(model));
        }
        #[cfg(target_os = "macos")]
        let mut messages = self
            .model_chat_messages()
            .into_iter()
            .map(chat_turn)
            .collect::<Vec<_>>();
        #[cfg(target_os = "macos")]
        messages.extend(injected_system.iter().map(|content| ChatTurn {
            user: false,
            tool: false,
            system: true,
            skip_previous_eos: false,
            reasoning: None,
            reasoning_complete: true,
            content: content.clone(),
        }));
        #[cfg(target_os = "macos")]
        messages.push(ChatTurn {
            user: true,
            tool: false,
            system: false,
            skip_previous_eos: false,
            reasoning: None,
            reasoning_complete: true,
            content: prompt.clone(),
        });

        #[cfg(target_os = "macos")]
        {
            // A draft session only reaches the database once there is a turn to store.
            let session_id = match self.selected_session {
                Some(session_id) => session_id,
                None => {
                    let Some(project_id) = self.selected_project else {
                        return;
                    };
                    match self.persist_session(project_id) {
                        Ok(session_id) => session_id,
                        Err(error) => {
                            self.error = Some(format!("Could not create the session: {error}"));
                            return;
                        }
                    }
                }
            };
            let Some(service) = &self.generation_service else {
                self.error = Some("The model runtime is unavailable.".into());
                return;
            };
            let Some(database) = &mut self.database else {
                return;
            };
            let mut saved = match database.start_chat_turn(
                session_id,
                &prompt,
                &injected_system,
                assistant_reasoning,
            ) {
                Ok(turn) => turn,
                Err(error) => {
                    self.error = Some(format!("Could not save the chat turn: {error}"));
                    return;
                }
            };
            if reminder_injected {
                self.system_prompt_seen_at = self.context_used;
            }
            let idle_timeout =
                Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60);
            self.active_generation = match service.generate(
                effective.engine,
                effective.turn,
                messages,
                CheckpointTarget::Local(session_checkpoint_path(session_id)),
                idle_timeout,
            ) {
                Ok(active) => Some(active),
                Err(error) => {
                    self.generation_service = None;
                    self.error = Some(error);
                    return;
                }
            };
            let mut assistant = ChatMessage::from(saved.pop().unwrap());
            let user = ChatMessage::from(saved.pop().unwrap());
            for message in saved {
                self.conversation.push(ChatMessage::from(message));
            }
            assistant.reasoning_open = assistant_reasoning;
            self.composer.clear();
            self.conversation.push(user);
            self.conversation.push(assistant);
            self.generating = true;
            self.stop_requested = false;
            self.activity = Some("Loading model…".into());
            self.tokens_per_second = None;
            self.error = None;
            if opening_turn {
                // Queued behind the reply, so the answer is never delayed by it.
                self.request_first_title(session_id);
            }
        }
        #[cfg(not(target_os = "macos"))]
        {
            let _ = effective;
            self.error = Some("Local Metal generation requires macOS.".into());
            return;
        }
    }

    fn system_prompt_reminder_due(&self) -> bool {
        crate::agent::prompt_reminder_due(self.context_used, self.system_prompt_seen_at)
    }

    fn system_prompt_reminders(&self, model: ModelChoice) -> Vec<String> {
        let mut reminders = vec![crate::agent::system_prompt_reminder(model)];
        if !self.config.generation.system_prompt.trim().is_empty() {
            reminders.push(self.config.generation.system_prompt.clone());
        }
        reminders
    }

    pub(super) fn poll_generation(&mut self) -> bool {
        #[cfg(target_os = "macos")]
        if self.active_compaction.is_some() {
            return self.poll_compaction();
        }
        #[cfg(target_os = "macos")]
        if self.active_tool_check.is_some() {
            return self.poll_tool_result_check();
        }
        #[cfg(target_os = "macos")]
        if self.active_tools.is_some() {
            while let Some(event) = self
                .active_tools
                .as_ref()
                .and_then(crate::agent::try_tool_event)
            {
                match event {
                    crate::agent::ToolEvent::State {
                        index,
                        state,
                        result,
                    } => {
                        if let Some(card) = self.tool_cards.get_mut(index) {
                            card.state = state;
                            if result.is_some() {
                                card.result = result;
                            }
                        }
                        self.activity = Some(format!("Tool {} · {}", index + 1, state.label()));
                    }
                    crate::agent::ToolEvent::Approval {
                        index,
                        prompt,
                        decision,
                    } => {
                        if let Some(card) = self.tool_cards.get_mut(index) {
                            card.state = crate::agent::ToolLifecycle::AwaitingApproval;
                        }
                        self.pending_tool_approval = Some((prompt, decision));
                        self.activity = Some(format!("Tool {} · Awaiting approval", index + 1));
                    }
                }
            }
        }
        #[cfg(target_os = "macos")]
        if let Some(active) = &self.active_tools {
            match crate::agent::try_tool_result(active) {
                Ok(Some(result)) => {
                    let cancelled = active.cancel.load(Ordering::Relaxed);
                    self.active_tools = None;
                    self.pending_tool_approval = None;
                    if cancelled {
                        self.generating = false;
                        self.activity = Some("Stopped".into());
                        self.start_next_queued();
                        return false;
                    }
                    let continuation = if self.manual_compaction_queued {
                        self.manual_compaction_queued = false;
                        self.start_compaction(
                            PendingContinuation::Tool(result),
                            "queued manual compaction",
                        )
                    } else {
                        self.start_tool_result_check(result, ToolCheckStage::Initial)
                    };
                    if let Err(error) = continuation {
                        self.generating = false;
                        self.activity = Some("Failed".into());
                        self.error = Some(error);
                    }
                    return true;
                }
                Ok(None) => return false,
                Err(error) => {
                    self.active_tools = None;
                    self.generating = false;
                    self.activity = Some("Failed".into());
                    self.error = Some(error);
                    return false;
                }
            }
        }
        #[cfg(target_os = "macos")]
        let Some(active) = &mut self.active_generation else {
            self.generating = false;
            return false;
        };
        #[cfg(target_os = "macos")]
        let mut transcript_changed = false;
        #[cfg(target_os = "macos")]
        let mut context_changed = false;
        #[cfg(target_os = "macos")]
        let mut start_queued = false;
        #[cfg(target_os = "macos")]
        loop {
            match active.events.try_recv() {
                Ok(GenerationEvent::Loading) => {}
                Ok(GenerationEvent::Activity(activity)) => {
                    self.activity = Some(activity.into());
                }
                Ok(GenerationEvent::Compacted(_)) => {
                    self.generating = false;
                    self.error =
                        Some("The model runtime returned an unexpected compaction event.".into());
                }
                Ok(GenerationEvent::Chunk { reasoning, content }) => {
                    if let Some(message) = self.conversation.last_mut()
                        && !message.user
                    {
                        message.append(reasoning, &content);
                        if !reasoning
                            && self.tool_cards.is_empty()
                            && crate::agent::has_tool_markup(&message.content)
                        {
                            self.tool_cards.push(crate::agent::ToolCard::streaming());
                            self.activity = Some("Parsing tool call…".into());
                        }
                        transcript_changed = true;
                    }
                }
                Ok(GenerationEvent::Context {
                    used,
                    limit,
                    tokens_per_second,
                }) => {
                    self.context_used = used;
                    self.context_limit = limit;
                    self.tokens_per_second = tokens_per_second;
                    if !self.stop_requested {
                        self.activity = Some(
                            if tokens_per_second.is_some() {
                                "Generating…"
                            } else {
                                "Reading conversation…"
                            }
                            .into(),
                        );
                    }
                    context_changed = true;
                }
                Ok(GenerationEvent::Finished(result)) => {
                    match result {
                        Ok(_) if self.stop_requested => {
                            self.generating = false;
                            self.activity = Some("Stopped".into());
                            start_queued =
                                !self.queued_inputs.is_empty() || self.manual_compaction_queued;
                        }
                        Ok(_) => {
                            let model = self.config.model;
                            let content = self
                                .conversation
                                .last()
                                .map(|message| message.content.clone())
                                .unwrap_or_default();
                            match crate::agent::parse_tool_calls(model, &content) {
                                Ok((_, calls)) if !calls.is_empty() => {
                                    if let Err(error) = self.start_agent_tools(calls) {
                                        self.generating = false;
                                        self.error = Some(error);
                                    }
                                }
                                Ok(_) => {
                                    self.generating = false;
                                    self.activity = None;
                                    self.tool_cards.clear();
                                    start_queued = !self.queued_inputs.is_empty()
                                        || self.manual_compaction_queued;
                                }
                                Err(error) => {
                                    self.active_tools = Some(crate::agent::error_async(error));
                                }
                            }
                        }
                        Err(_) if self.stop_requested => {
                            self.generating = false;
                            self.activity = Some("Stopped".into());
                        }
                        Err(error) => {
                            self.generating = false;
                            self.activity = Some("Failed".into());
                            self.error = Some(error);
                        }
                    }
                    self.active_generation = None;
                    break;
                }
                Ok(GenerationEvent::Measured(_)) => {
                    self.generating = false;
                    self.activity = Some("Failed".into());
                    self.error = Some(
                        "The model runtime returned an unexpected context measurement.".into(),
                    );
                }
                Err(TryRecvError::Empty) => break,
                Err(TryRecvError::Disconnected) => {
                    self.generating = false;
                    self.active_generation = None;
                    self.error = Some("The model runtime stopped unexpectedly.".into());
                    self.activity = Some("Failed".into());
                    break;
                }
            }
        }
        #[cfg(target_os = "macos")]
        if transcript_changed && let Some(message) = self.conversation.last_mut() {
            message.refresh_markdown();
        }
        #[cfg(target_os = "macos")]
        if transcript_changed
            && let Some(message) = self.conversation.last()
            && let Some(database) = &mut self.database
            && let Err(error) = database.update_message(
                message.id,
                message.reasoning.as_deref(),
                message.reasoning_complete,
                &message.content,
            )
        {
            if let Some(active) = &self.active_generation {
                active.cancel.store(true, Ordering::Relaxed);
            }
            self.error = Some(format!("Could not save generated chat text: {error}"));
        }
        #[cfg(target_os = "macos")]
        if context_changed
            && let Some(session_id) = self.selected_session
            && let Some(database) = &mut self.database
        {
            if let Err(error) = database.update_session_context(
                session_id,
                self.context_used,
                self.context_limit,
                self.tokens_per_second,
            ) {
                self.error = Some(format!("Could not save context usage: {error}"));
            } else if let Some(session) = self
                .projects
                .iter_mut()
                .flat_map(|project| &mut project.sessions)
                .find(|session| session.id == session_id)
            {
                session.context_used = self.context_used as i32;
                session.context_limit = self.context_limit as i32;
                session.last_tokens_per_second = self.tokens_per_second;
            }
        }
        #[cfg(target_os = "macos")]
        if start_queued {
            self.start_next_queued();
        }
        #[cfg(target_os = "macos")]
        return transcript_changed;
        #[cfg(not(target_os = "macos"))]
        false
    }

    fn start_next_queued(&mut self) {
        #[cfg(target_os = "macos")]
        if std::mem::take(&mut self.manual_compaction_queued) {
            if let Err(error) =
                self.start_compaction(PendingContinuation::None, "queued manual compaction")
            {
                self.activity = Some("Failed".into());
                self.error = Some(error);
            }
            return;
        }
        if let Some(prompt) = queued_prompt(self.queued_inputs.drain(..)) {
            self.composer = prompt;
            self.start_generation();
        }
    }

    #[cfg(target_os = "macos")]
    fn start_agent_tools(&mut self, calls: Vec<crate::agent::ToolCall>) -> Result<(), String> {
        let session_id = self
            .selected_session
            .ok_or_else(|| "The active session is unavailable.".to_owned())?;
        if self.agent_tools.as_ref().map(|(id, _)| *id) != Some(session_id) {
            let project_id = self
                .selected_project
                .ok_or_else(|| "The active project is unavailable.".to_owned())?;
            let root = self
                .projects
                .iter()
                .find(|project| project.project.id == project_id)
                .map(|project| PathBuf::from(&project.project.path))
                .ok_or_else(|| "The active project is unavailable.".to_owned())?;
            let tools = crate::agent::Tools::new(&root, self.config.generation.context_tokens)?;
            self.agent_tools = Some((session_id, Arc::new(Mutex::new(tools))));
        }
        let tools = Arc::clone(&self.agent_tools.as_ref().unwrap().1);
        self.tool_cards = calls
            .iter()
            .cloned()
            .map(crate::agent::ToolCard::parsing)
            .collect();
        self.active_tools = Some(crate::agent::execute_async(tools, calls));
        self.activity = Some("Parsing tool calls…".into());
        Ok(())
    }

    #[cfg(target_os = "macos")]
    fn continue_after_tool_result(&mut self, result: &str) -> Result<(), String> {
        let session_id = self
            .selected_session
            .ok_or_else(|| "The active session is unavailable.".to_owned())?;
        self.skip_compaction_once = false;
        let model = self.config.model;
        let mut effective = crate::settings::effective_settings(
            model,
            &self.config.generation,
            &self.config.runtime,
            &models_path(),
        )?;
        effective.turn.system_prompt =
            crate::agent::system_prompt(model, &effective.turn.system_prompt);
        effective.turn.system_prompt = crate::compaction::summary_system_prompt(
            &effective.turn.system_prompt,
            self.compaction_summary(),
        );
        let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct;
        let queued = queued_prompt(self.queued_inputs.drain(..));
        let reminders = if self.system_prompt_reminder_due() {
            self.system_prompt_reminders(model)
        } else {
            Vec::new()
        };
        let mut saved = self
            .database
            .as_mut()
            .ok_or_else(|| "The project database is unavailable.".to_owned())?
            .continue_tool_turn(
                session_id,
                result,
                queued.as_deref(),
                &reminders,
                assistant_reasoning,
            )
            .map_err(|error| format!("Could not save the tool turn: {error}"))?;
        if !reminders.is_empty() {
            self.system_prompt_seen_at = self.context_used;
        }
        let mut assistant = ChatMessage::from(saved.pop().unwrap());
        for message in saved {
            self.conversation.push(ChatMessage::from(message));
        }
        let messages = self
            .model_chat_messages()
            .into_iter()
            .map(chat_turn)
            .collect();
        assistant.reasoning_open = assistant_reasoning;
        self.conversation.push(assistant);
        self.tool_cards.clear();
        let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60);
        self.active_generation = Some(
            self.generation_service
                .as_ref()
                .ok_or_else(|| "The model runtime is unavailable.".to_owned())?
                .generate(
                    effective.engine,
                    effective.turn,
                    messages,
                    CheckpointTarget::Local(session_checkpoint_path(session_id)),
                    idle_timeout,
                )?,
        );
        self.tokens_per_second = None;
        self.activity = Some("Continuing after tools…".into());
        self.stop_requested = false;
        Ok(())
    }

    #[cfg(target_os = "macos")]
    pub(super) fn stop_agent_jobs(&mut self) {
        let Some((_, tools)) = &self.agent_tools else {
            return;
        };
        let Ok(mut tools) = tools.try_lock() else {
            return;
        };
        let failures = tools.stop_all_jobs();
        if !failures.is_empty() {
            self.error = Some(failures.join("\n"));
        }
    }

    #[cfg(target_os = "macos")]
    fn start_tool_result_check(
        &mut self,
        result: String,
        stage: ToolCheckStage,
    ) -> Result<(), String> {
        if matches!(stage, ToolCheckStage::Initial)
            && crate::compaction::should_compact(self.context_used, self.context_limit)
        {
            return self.start_compaction(
                PendingContinuation::Tool(result),
                "soft limit before tool continuation",
            );
        }
        let model = self.config.model;
        let mut effective = crate::settings::effective_settings(
            model,
            &self.config.generation,
            &self.config.runtime,
            &models_path(),
        )?;
        effective.turn.system_prompt =
            crate::agent::system_prompt(model, &effective.turn.system_prompt);
        effective.turn.system_prompt = crate::compaction::summary_system_prompt(
            &effective.turn.system_prompt,
            self.compaction_summary(),
        );
        let mut messages = self
            .model_chat_messages()
            .into_iter()
            .map(chat_turn)
            .collect::<Vec<_>>();
        messages.push(ChatTurn {
            user: false,
            tool: true,
            system: false,
            skip_previous_eos: false,
            reasoning: None,
            reasoning_complete: true,
            content: result.clone(),
        });
        let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60);
        let active = self
            .generation_service
            .as_ref()
            .ok_or_else(|| "The model runtime is unavailable.".to_owned())?
            .measure_context(effective.engine, effective.turn, messages, idle_timeout)?;
        self.active_tool_check = Some(ToolResultCheck {
            active,
            result,
            stage,
        });
        self.activity = Some("Checking tool result context…".into());
        Ok(())
    }

    #[cfg(target_os = "macos")]
    fn poll_tool_result_check(&mut self) -> bool {
        let event = self
            .active_tool_check
            .as_mut()
            .unwrap()
            .active
            .events
            .try_recv();
        match event {
            Ok(GenerationEvent::Loading) => {
                self.activity = Some("Loading model…".into());
                false
            }
            Ok(GenerationEvent::Measured(Ok(projected))) => {
                let check = self.active_tool_check.take().unwrap();
                if self.manual_compaction_queued && matches!(check.stage, ToolCheckStage::Initial) {
                    self.manual_compaction_queued = false;
                    if let Err(error) = self.start_compaction(
                        PendingContinuation::Tool(check.result),
                        "queued manual compaction",
                    ) {
                        self.generating = false;
                        self.activity = Some("Failed".into());
                        self.error = Some(error);
                    }
                    return true;
                }
                let reserve = if matches!(check.stage, ToolCheckStage::BoundedError) {
                    16
                } else {
                    crate::compaction::tool_result_reserve(self.context_limit)
                };
                if crate::compaction::tool_result_fits(projected, self.context_limit, reserve) {
                    if let Err(error) = self.continue_after_tool_result(&check.result) {
                        self.generating = false;
                        self.activity = Some("Failed".into());
                        self.error = Some(error);
                    }
                } else {
                    match check.stage {
                        ToolCheckStage::Initial => {
                            if let Err(error) = self.start_compaction(
                                PendingContinuation::Tool(check.result),
                                "tool result would exceed context",
                            ) {
                                self.generating = false;
                                self.activity = Some("Failed".into());
                                self.error = Some(error);
                            }
                        }
                        ToolCheckStage::AfterCompaction => {
                            let error = crate::compaction::bounded_tool_error(
                                projected,
                                self.context_limit,
                                reserve,
                            );
                            if let Err(error) =
                                self.start_tool_result_check(error, ToolCheckStage::BoundedError)
                            {
                                self.generating = false;
                                self.activity = Some("Failed".into());
                                self.error = Some(error);
                            }
                        }
                        ToolCheckStage::BoundedError => {
                            self.generating = false;
                            self.activity = Some("Failed".into());
                            self.error = Some("context full after compaction".into());
                        }
                    }
                }
                true
            }
            Ok(GenerationEvent::Measured(Err(error))) => {
                self.active_tool_check = None;
                self.generating = false;
                if self.stop_requested {
                    self.activity = Some("Stopped".into());
                } else {
                    self.activity = Some("Failed".into());
                    self.error = Some(error);
                }
                false
            }
            Ok(GenerationEvent::Activity(activity)) => {
                self.activity = Some(activity.into());
                false
            }
            Ok(_) => false,
            Err(TryRecvError::Empty) => false,
            Err(TryRecvError::Disconnected) => {
                self.active_tool_check = None;
                self.generating = false;
                self.activity = Some("Failed".into());
                self.error = Some("The model runtime stopped unexpectedly.".into());
                false
            }
        }
    }

    #[cfg(target_os = "macos")]
    pub(super) fn start_compaction(
        &mut self,
        pending: PendingContinuation,
        reason: &str,
    ) -> Result<(), String> {
        let model = self.config.model;
        let mut effective = crate::settings::effective_settings(
            model,
            &self.config.generation,
            &self.config.runtime,
            &models_path(),
        )?;
        effective.turn.system_prompt =
            crate::agent::system_prompt(model, &effective.turn.system_prompt);
        let rebuild_system_prompt = effective.turn.system_prompt.clone();
        effective.turn.system_prompt = crate::compaction::summary_system_prompt(
            &effective.turn.system_prompt,
            self.compaction_summary(),
        );
        let model_messages = self.model_chat_messages();
        let message_ids = model_messages.iter().map(|message| message.id).collect();
        let messages = model_messages.into_iter().map(chat_turn).collect();
        let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60);
        let active = self
            .generation_service
            .as_ref()
            .ok_or_else(|| "The model runtime is unavailable.".to_owned())?
            .compact(
                effective.engine,
                effective.turn,
                messages,
                reason,
                rebuild_system_prompt,
                session_compaction_checkpoint_path(
                    self.selected_session
                        .ok_or_else(|| "The active session is unavailable.".to_owned())?,
                ),
                idle_timeout,
            )?;
        self.active_compaction = Some(CompactionRequest {
            active,
            pending,
            message_ids,
        });
        self.manual_compaction_queued = false;
        self.generating = true;
        self.activity = Some("Compacting durable task state…".into());
        self.tokens_per_second = None;
        Ok(())
    }

    #[cfg(target_os = "macos")]
    fn poll_compaction(&mut self) -> bool {
        let Some(request) = &mut self.active_compaction else {
            return false;
        };
        loop {
            match request.active.events.try_recv() {
                Ok(GenerationEvent::Loading) => self.activity = Some("Loading model…".into()),
                Ok(GenerationEvent::Activity(activity)) => self.activity = Some(activity.into()),
                Ok(GenerationEvent::Context {
                    used,
                    limit,
                    tokens_per_second,
                }) => {
                    self.context_used = used;
                    self.context_limit = limit;
                    self.tokens_per_second = tokens_per_second;
                }
                Ok(GenerationEvent::Compacted(result)) => {
                    let request = self.active_compaction.take().unwrap();
                    match result {
                        Ok(compacted) => {
                            if let Err(error) =
                                self.apply_compaction(&compacted, &request.message_ids)
                            {
                                let _ = fs::remove_file(&compacted.checkpoint);
                                self.generating = false;
                                self.activity = Some("Failed".into());
                                self.error = Some(error);
                                return false;
                            }
                            self.context_used = compacted.context_tokens;
                            self.system_prompt_seen_at = compacted.context_tokens;
                            self.generating = false;
                            self.activity = None;
                            match request.pending {
                                PendingContinuation::None => self.start_next_queued(),
                                PendingContinuation::User(prompt) => {
                                    self.composer = prompt;
                                    self.skip_compaction_once = true;
                                    self.start_generation();
                                }
                                PendingContinuation::Tool(result) => {
                                    if let Err(error) = self.start_tool_result_check(
                                        result,
                                        ToolCheckStage::AfterCompaction,
                                    ) {
                                        self.generating = false;
                                        self.activity = Some("Failed".into());
                                        self.error = Some(error);
                                    }
                                }
                            }
                            return true;
                        }
                        Err(error) => {
                            self.generating = false;
                            if let PendingContinuation::User(prompt) = request.pending {
                                if self.composer.trim().is_empty() {
                                    self.composer = prompt;
                                } else {
                                    self.queued_inputs.push_front(prompt);
                                }
                            }
                            if self.stop_requested {
                                self.activity = Some("Stopped".into());
                            } else {
                                self.activity = Some("Failed".into());
                                self.error = Some(error);
                            }
                            return false;
                        }
                    }
                }
                Ok(GenerationEvent::Finished(_))
                | Ok(GenerationEvent::Chunk { .. })
                | Ok(GenerationEvent::Measured(_)) => {}
                Err(TryRecvError::Empty) => return false,
                Err(TryRecvError::Disconnected) => {
                    self.active_compaction = None;
                    self.generating = false;
                    self.activity = Some("Failed".into());
                    self.error = Some("The model runtime stopped unexpectedly.".into());
                    return false;
                }
            }
        }
    }

    #[cfg(target_os = "macos")]
    fn apply_compaction(
        &mut self,
        compacted: &crate::engine::CompactionOutput,
        message_ids: &[i32],
    ) -> Result<(), String> {
        let session_id = self
            .selected_session
            .ok_or_else(|| "The active session is unavailable.".to_owned())?;
        let running_jobs = if let Some((tools_session, tools)) = &self.agent_tools
            && *tools_session == session_id
        {
            tools
                .lock()
                .unwrap_or_else(|poisoned| poisoned.into_inner())
                .compaction_observation()
        } else {
            None
        };
        let tail_start = message_ids.get(compacted.tail_start).copied();
        let messages = self
            .database
            .as_mut()
            .ok_or_else(|| "The project database is unavailable.".to_owned())?
            .record_compaction(
                session_id,
                &compacted.summary,
                tail_start,
                running_jobs.as_deref(),
                compacted.context_tokens,
                self.context_limit,
            )
            .map_err(|error| format!("Could not save compacted conversation: {error}"))?;
        self.conversation
            .extend(messages.into_iter().map(ChatMessage::from));
        if let Some(session) = self
            .projects
            .iter_mut()
            .flat_map(|project| &mut project.sessions)
            .find(|session| session.id == session_id)
        {
            session.compacted_summary = Some(compacted.summary.clone());
            session.context_used = compacted.context_tokens as i32;
            session.context_limit = self.context_limit as i32;
            session.last_tokens_per_second = None;
        }
        let final_checkpoint = session_checkpoint_path(session_id);
        if let Err(error) = fs::rename(&compacted.checkpoint, &final_checkpoint) {
            let _ = fs::remove_file(&compacted.checkpoint);
            self.error = Some(format!(
                "The compacted conversation was saved, but its checkpoint could not be promoted: {error}. It will rebuild on next use."
            ));
        }
        Ok(())
    }

    pub(super) fn compaction_summary(&self) -> Option<&str> {
        if let Some(summary) = self
            .conversation
            .iter()
            .rfind(|message| message.compaction)
            .map(|message| message.content.as_str())
        {
            return Some(summary);
        }
        let session_id = self.selected_session?;
        self.projects
            .iter()
            .flat_map(|project| &project.sessions)
            .find(|session| session.id == session_id)
            .and_then(|session| session.compacted_summary.as_deref())
    }

    fn model_chat_messages(&self) -> Vec<&ChatMessage> {
        let start = compacted_context_start(&self.conversation);
        self.conversation[start..]
            .iter()
            .filter(|message| !message.compaction)
            .collect()
    }

    /// Asks the model for a session title without opening a session for it: the
    /// turn runs against the shared transient KV cache and only its text is kept.
    ///
    /// Reports failures to the caller instead of the error banner: the automatic
    /// first-chat titling must stay silent, while the menu action shows them.
    #[cfg(target_os = "macos")]
    fn request_title(&mut self, session_id: i32) -> Result<(), String> {
        if self.active_titling.is_some() {
            return Err("A title is already being generated.".into());
        }
        let model = self.config.model;
        let mut effective = crate::settings::effective_settings(
            model,
            &self.config.generation,
            &self.config.runtime,
            &models_path(),
        )?;
        let database = self
            .database
            .as_mut()
            .ok_or_else(|| "The project database is unavailable.".to_owned())?;
        let stored = database
            .load_messages(session_id)
            .map_err(|error| format!("Could not read the session: {error}"))?;
        // The assistant row is inserted empty when a turn starts; an empty turn
        // is never useful context, and skipping it is what lets the automatic
        // pass summarize the user's opening message on its own.
        let mut messages = stored
            .into_iter()
            .filter(|message| !message.compaction && !message.content.trim().is_empty())
            .map(|message| ChatTurn {
                user: message.user,
                tool: message.tool,
                system: message.system,
                skip_previous_eos: false,
                reasoning: None,
                reasoning_complete: true,
                content: message.content,
            })
            .collect::<Vec<_>>();
        if messages.is_empty() {
            return Err("This session has no messages to summarize yet.".into());
        }
        messages.push(ChatTurn {
            user: true,
            tool: false,
            system: false,
            skip_previous_eos: false,
            reasoning: None,
            reasoning_complete: true,
            content: TITLE_INSTRUCTION.to_owned(),
        });
        effective.turn.max_generated_tokens = TITLE_MAX_TOKENS;
        effective.turn.reasoning_mode = ReasoningMode::Direct;

        let service = self
            .generation_service
            .as_ref()
            .ok_or_else(|| "The model runtime is unavailable.".to_owned())?;
        let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60);
        match service.generate(
            effective.engine,
            effective.turn,
            messages,
            CheckpointTarget::OneShot(transient_cache_path()),
            idle_timeout,
        ) {
            Ok(active) => {
                self.active_titling = Some(TitleRequest {
                    session_id,
                    expected: self.session_title(session_id).unwrap_or_default(),
                    active,
                    content: String::new(),
                });
                Ok(())
            }
            Err(error) => {
                self.generation_service = None;
                Err(error)
            }
        }
    }

    /// The "Retitle with AI" menu action, which reports why it could not run.
    #[cfg(target_os = "macos")]
    pub(super) fn retitle_session(&mut self, session_id: i32) {
        match self.request_title(session_id) {
            Ok(()) => self.error = None,
            Err(error) => self.error = Some(error),
        }
    }

    /// Titles a session from its opening message. Queued behind the reply that
    /// is already running, and skipped without complaint if it cannot start —
    /// the user did not ask for this, so it must never interrupt them.
    #[cfg(target_os = "macos")]
    pub(super) fn request_first_title(&mut self, session_id: i32) {
        let _ = self.request_title(session_id);
    }

    /// Drains the one-shot title generation. Returns true while it is running so
    /// the caller keeps ticking.
    #[cfg(target_os = "macos")]
    pub(super) fn poll_titling(&mut self) -> bool {
        let Some(mut request) = self.active_titling.take() else {
            return false;
        };
        let failure = loop {
            match request.active.events.try_recv() {
                Ok(GenerationEvent::Chunk {
                    reasoning: false,
                    content,
                }) => request.content.push_str(&content),
                Ok(GenerationEvent::Finished(Err(error))) => break Some(error),
                Ok(GenerationEvent::Finished(Ok(_))) => break None,
                Ok(_) => {}
                Err(TryRecvError::Empty) => {
                    self.active_titling = Some(request);
                    return true;
                }
                Err(TryRecvError::Disconnected) => {
                    break Some("The model runtime stopped unexpectedly.".to_owned());
                }
            }
        };
        if let Some(error) = failure {
            self.error = Some(format!("Could not generate a title: {error}"));
        } else if let Some(title) = session_title(&request.content) {
            self.apply_title(&request, &title);
        } else {
            self.error = Some("The model did not return a usable title.".into());
        }
        false
    }

    pub(super) fn session_title(&self, session_id: i32) -> Option<String> {
        self.projects
            .iter()
            .flat_map(|project| &project.sessions)
            .find(|session| session.id == session_id)
            .map(|session| session.title.clone())
    }

    #[cfg(target_os = "macos")]
    fn apply_title(&mut self, request: &TitleRequest, title: &str) {
        let Some(current) = self.session_title(request.session_id) else {
            return;
        };
        if current != request.expected {
            return;
        }
        let Some(database) = &mut self.database else {
            return;
        };
        match database.rename_session(request.session_id, title) {
            Ok(()) => {
                self.error = None;
                self.reload_projects();
            }
            Err(error) => self.error = Some(format!("Could not save the session title: {error}")),
        }
    }
}

/// Reduces a model reply to a single sidebar-sized line, or `None` if nothing
/// usable came back.
pub(super) fn session_title(reply: &str) -> Option<String> {
    let title = reply
        .lines()
        .map(str::trim)
        .find(|line| !line.is_empty())?
        .trim_matches(|character: char| {
            character.is_whitespace() || matches!(character, '"' | '\'' | '`' | '*' | '#' | '.')
        })
        .trim();
    if title.is_empty() {
        return None;
    }
    Some(match title.char_indices().nth(TITLE_MAX_CHARS) {
        Some((index, _)) => format!("{}…", title[..index].trim_end()),
        None => title.to_owned(),
    })
}

#[cfg(test)]
mod tests {
    use super::{
        ChatMessage, compacted_context_start, has_chat_after_last_compaction, queued_prompt,
    };

    #[test]
    fn queued_guidance_is_one_reference_style_user_turn() {
        assert_eq!(queued_prompt(Vec::new()), None);
        assert_eq!(queued_prompt(["one".to_owned()]), Some("one".to_owned()));
        assert_eq!(
            queued_prompt(["one".to_owned(), "two".to_owned()]),
            Some("Queued user message 1:\none\n\nQueued user message 2:\ntwo".to_owned())
        );
    }

    #[test]
    fn last_compaction_selects_its_tail_without_hiding_history() {
        let message = |id: i32, compaction: bool, tail: Option<i32>| ChatMessage {
            id,
            user: !compaction,
            tool: false,
            system: compaction,
            compaction,
            compaction_tail_start: tail,
            reasoning: None,
            reasoning_complete: true,
            reasoning_open: false,
            content: format!("message {id}"),
            markdown: Vec::new(),
        };
        let history = vec![
            message(1, false, None),
            message(2, false, None),
            message(3, true, Some(2)),
            message(4, false, None),
            message(5, true, Some(4)),
            message(6, false, None),
            message(7, true, Some(6)),
            message(8, false, None),
        ];

        assert_eq!(history.len(), 8);
        assert_eq!(compacted_context_start(&history), 5);
        assert_eq!(history[compacted_context_start(&history)].id, 6);
    }

    #[test]
    fn forced_compaction_requires_new_visible_chat() {
        let message =
            |id: i32, user: bool, tool: bool, system: bool, compaction: bool| ChatMessage {
                id,
                user,
                tool,
                system,
                compaction,
                compaction_tail_start: None,
                reasoning: None,
                reasoning_complete: true,
                reasoning_open: false,
                content: format!("message {id}"),
                markdown: Vec::new(),
            };
        let mut history = vec![
            message(1, true, false, false, false),
            message(2, false, false, true, true),
            ChatMessage {
                content: format!(
                    "{} running job",
                    crate::agent::COMPACTION_OBSERVATION_PREFIX
                ),
                ..message(3, false, true, false, false)
            },
            message(4, false, false, true, false),
        ];

        assert!(!has_chat_after_last_compaction(&history));
        history.push(message(5, false, true, false, false));
        assert!(has_chat_after_last_compaction(&history));
        history.push(message(6, false, false, true, true));
        assert!(!has_chat_after_last_compaction(&history));
        history.push(message(7, true, false, false, false));
        assert!(has_chat_after_last_compaction(&history));
    }
}
