use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;

use a3s_boot::{BootError, Result as BootResult};
use a3s_code_core::{AgentSession, ContentBlock, Message};
use serde_json::{json, Value};
use tokio::process::Command;
use tokio::time::{timeout, Duration};

use super::controls::{
    compose_controlled_prompt, controls_json, effort_levels_json, normalize_effort, normalize_goal,
};
use super::sleep::{parse_sleep_report, sleep_directive, sleep_today, store_sleep_memories};
use crate::api::code_web::dto::{
    ChatRequest, ChatResponse, CreateSessionRequest, ForkSessionRequest, KernelSessionResponse,
    SessionListResponse, SessionResponse, ShellSessionRequest, SleepSessionRequest, UsageResponse,
};
use crate::api::code_web::session_runtime::{
    activate_session_runtime, code_web_context_limit_for_model, code_web_os_status,
    code_web_session_options, effective_session_model,
};
use crate::api::code_web::state::{
    CodeWebSessionContext, CodeWebSessionControls, CodeWebSessionSettings, CodeWebState,
};

pub(in crate::api::code_web) struct KernelService {
    state: Arc<CodeWebState>,
}

const SHELL_COMMAND_TIMEOUT: Duration = Duration::from_secs(30 * 60);
const SHELL_OUTPUT_MAX_CHARS: usize = 128_000;

impl KernelService {
    pub(in crate::api::code_web) fn new(state: Arc<CodeWebState>) -> Self {
        Self { state }
    }

    pub(in crate::api::code_web) async fn create_session(
        &self,
        request: CreateSessionRequest,
    ) -> BootResult<SessionResponse> {
        let session = self.create_or_get_session(None, request).await?;
        let settings = self.session_settings_snapshot(session.session_id()).await;
        Ok(SessionResponse::from_session(
            &session,
            self.session_response_model(session.session_id()).await,
            settings.follow_default_model,
            settings.permission_mode,
            None,
            None,
        ))
    }

    pub(in crate::api::code_web) async fn create_kernel_session(
        &self,
        request: CreateSessionRequest,
    ) -> BootResult<KernelSessionResponse> {
        let title = request.title.clone();
        let agent_id = request.agent_id.clone();
        let session = self.create_or_get_session(None, request).await?;
        let settings = self.session_settings_snapshot(session.session_id()).await;
        Ok(KernelSessionResponse {
            success: true,
            session: SessionResponse::from_session(
                &session,
                self.session_response_model(session.session_id()).await,
                settings.follow_default_model,
                settings.permission_mode,
                title,
                agent_id,
            ),
        })
    }

    pub(in crate::api::code_web) async fn list_agents(&self) -> Vec<serde_json::Value> {
        vec![json!({
            "id": "default",
            "name": "书小安",
            "description": "A3S Code local assistant",
            "tags": ["local", "a3s-code"],
        })]
    }

    pub(in crate::api::code_web) async fn list_sessions(&self) -> BootResult<SessionListResponse> {
        let session_ids: Vec<String> = self
            .state
            .sessions
            .lock()
            .await
            .values()
            .map(|session| session.session_id().to_string())
            .collect();
        let mut sessions = Vec::new();
        for session_id in session_ids {
            let session = self.kernel_session(&session_id).await?;
            let settings = self.session_settings_snapshot(&session_id).await;
            sessions.push(SessionResponse::from_session(
                session.as_ref(),
                self.session_response_model(&session_id).await,
                settings.follow_default_model,
                settings.permission_mode,
                None,
                None,
            ));
        }
        Ok(SessionListResponse {
            total: sessions.len(),
            items: sessions,
        })
    }

    pub(in crate::api::code_web) async fn get_session(
        &self,
        session_id: &str,
    ) -> BootResult<SessionResponse> {
        let session = self.kernel_session(session_id).await?;
        let settings = self.session_settings_snapshot(session_id).await;
        Ok(SessionResponse::from_session(
            session.as_ref(),
            self.session_response_model(session_id).await,
            settings.follow_default_model,
            settings.permission_mode,
            None,
            None,
        ))
    }

    pub(in crate::api::code_web) async fn update_session(
        &self,
        session_id: &str,
        patch: serde_json::Value,
    ) -> BootResult<SessionResponse> {
        self.apply_session_update(session_id, patch).await?;
        self.get_session(session_id).await
    }

    pub(in crate::api::code_web) async fn delete_session(
        &self,
        session_id: &str,
    ) -> BootResult<()> {
        let removed = self.state.sessions.lock().await.remove(session_id);
        self.state.messages.lock().await.remove(session_id);
        self.state.session_controls.lock().await.remove(session_id);
        self.state.session_contexts.lock().await.remove(session_id);
        self.state.session_settings.lock().await.remove(session_id);
        if removed.is_none() {
            return Err(BootError::NotFound(format!(
                "session `{session_id}` was not found"
            )));
        }
        Ok(())
    }

    pub(in crate::api::code_web) async fn session_messages(
        &self,
        session_id: &str,
    ) -> BootResult<serde_json::Value> {
        self.kernel_session(session_id).await?;
        let messages = self
            .state
            .messages
            .lock()
            .await
            .get(session_id)
            .cloned()
            .unwrap_or_default();
        Ok(json!({
            "items": messages,
            "total": messages.len(),
            "page": 1,
            "limit": messages.len().max(1),
        }))
    }

    pub(in crate::api::code_web) async fn session_output(
        &self,
        session_id: &str,
    ) -> BootResult<serde_json::Value> {
        self.kernel_session(session_id).await?;
        let messages = self
            .state
            .messages
            .lock()
            .await
            .get(session_id)
            .cloned()
            .unwrap_or_default();
        Ok(session_output_json(session_id, &messages))
    }

    pub(in crate::api::code_web) async fn run_shell_command(
        &self,
        session_id: &str,
        request: ShellSessionRequest,
    ) -> BootResult<serde_json::Value> {
        let session = self.kernel_session(session_id).await?;
        let command = request.command.trim();
        if command.is_empty() {
            return Err(BootError::BadRequest("command is required".to_string()));
        }
        if command.chars().count() > 20_000 {
            return Err(BootError::BadRequest("command is too long".to_string()));
        }

        let cwd = session.workspace().to_path_buf();
        let started_at = chrono::Utc::now();
        let timer = Instant::now();
        let output = timeout(SHELL_COMMAND_TIMEOUT, async {
            let mut process = Command::new("sh");
            process
                .arg("-c")
                .arg(command)
                .current_dir(&cwd)
                .kill_on_drop(true);
            process.output().await
        })
        .await;
        let completed_at = chrono::Utc::now();
        let duration_ms = timer.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;

        let (stdout, stderr, exit_code, timed_out, spawn_error) = match output {
            Ok(Ok(output)) => (
                shell_text(&output.stdout),
                shell_text(&output.stderr),
                output.status.code(),
                false,
                None,
            ),
            Ok(Err(error)) => (
                String::new(),
                format!("failed to run: {error}"),
                None,
                false,
                Some(error.to_string()),
            ),
            Err(_) => (
                String::new(),
                format!(
                    "command timed out after {} seconds",
                    SHELL_COMMAND_TIMEOUT.as_secs()
                ),
                None,
                true,
                None,
            ),
        };
        let mut combined = String::new();
        combined.push_str(&stdout);
        combined.push_str(&stderr);
        let output_text = if combined.trim().is_empty() {
            match exit_code {
                Some(code) => format!("(exit {code})"),
                None => stderr.clone(),
            }
        } else {
            truncate_chars(&combined, SHELL_OUTPUT_MAX_CHARS)
        };
        let is_error = timed_out || spawn_error.is_some() || exit_code.is_none_or(|code| code != 0);
        let record = shell_output_record(ShellOutputRecordInput {
            session_id,
            command,
            cwd: &cwd.display().to_string(),
            stdout: &truncate_chars(&stdout, SHELL_OUTPUT_MAX_CHARS),
            stderr: &truncate_chars(&stderr, SHELL_OUTPUT_MAX_CHARS),
            output: &output_text,
            exit_code,
            is_error,
            timed_out,
            duration_ms,
            started_at: &started_at.to_rfc3339(),
            completed_at: &completed_at.to_rfc3339(),
        });
        self.append_shell_output_message(session_id, &record).await;

        Ok(json!({
            "sessionId": session_id,
            "command": command,
            "cwd": cwd.display().to_string(),
            "stdout": truncate_chars(&stdout, SHELL_OUTPUT_MAX_CHARS),
            "stderr": truncate_chars(&stderr, SHELL_OUTPUT_MAX_CHARS),
            "output": output_text,
            "exitCode": exit_code,
            "success": !is_error,
            "isError": is_error,
            "timedOut": timed_out,
            "durationMs": duration_ms,
            "startedAt": started_at.to_rfc3339(),
            "completedAt": completed_at.to_rfc3339(),
            "record": record,
        }))
    }

    pub(in crate::api::code_web) async fn clear_session_messages(
        &self,
        session_id: &str,
    ) -> BootResult<serde_json::Value> {
        let old_session = self.kernel_session(session_id).await?;
        let workspace = old_session.workspace().to_path_buf();
        old_session.close().await;

        let controls = self.session_controls_snapshot(session_id).await;
        let new_session = self
            .create_agent_session(
                &workspace,
                Some(session_id),
                self.session_response_model(session_id).await,
                &controls.effort,
            )
            .await?;
        self.state
            .sessions
            .lock()
            .await
            .insert(session_id.to_string(), new_session);
        self.state
            .messages
            .lock()
            .await
            .insert(session_id.to_string(), Vec::new());
        self.state
            .session_contexts
            .lock()
            .await
            .insert(session_id.to_string(), CodeWebSessionContext::default());
        Ok(json!({
            "sessionId": session_id,
            "cleared": true,
            "items": [],
            "total": 0,
        }))
    }

    pub(in crate::api::code_web) async fn session_status(
        &self,
        session_id: &str,
    ) -> BootResult<serde_json::Value> {
        let session = self.kernel_session(session_id).await?;
        let settings = self.session_settings_snapshot(session_id).await;
        let os_status = code_web_os_status(self.state.as_ref()).await?;
        let runtime_connected = os_status
            .get("runtimeToolActive")
            .and_then(Value::as_bool)
            .unwrap_or(false);
        Ok(json!({
            "sessionId": session.session_id(),
            "status": "idle",
            "cwd": session.workspace().display().to_string(),
            "model": self.effective_model(&settings),
            "followDefaultModel": settings.follow_default_model,
            "permissionMode": settings.permission_mode,
            "planningMode": settings.planning_mode,
            "goalTracking": settings.goal_tracking,
            "mcpServers": [],
            "runtime": {
                "connected": runtime_connected,
                "transport": "rest",
                "os": os_status,
            },
            "commands": ["clear", "compact", "cost", "help", "history", "mcp", "model", "tools"],
        }))
    }

    pub(in crate::api::code_web) async fn effort_levels(&self) -> BootResult<serde_json::Value> {
        Ok(json!({
            "items": effort_levels_json(),
        }))
    }

    pub(in crate::api::code_web) async fn session_controls(
        &self,
        session_id: &str,
    ) -> BootResult<serde_json::Value> {
        self.kernel_session(session_id).await?;
        let controls = self.session_controls_snapshot(session_id).await;
        let settings = self.session_settings_snapshot(session_id).await;
        let model = self.effective_model(&settings);
        let context_limit = code_web_context_limit_for_model(self.state.as_ref(), model.as_deref());
        Ok(controls_json(session_id, &controls, Some(context_limit)))
    }

    pub(in crate::api::code_web) async fn update_session_controls(
        &self,
        session_id: &str,
        request: serde_json::Value,
    ) -> BootResult<serde_json::Value> {
        self.kernel_session(session_id).await?;
        let (controls_snapshot, effort_changed) = {
            let mut controls_by_session = self.state.session_controls.lock().await;
            let controls = controls_by_session
                .entry(session_id.to_string())
                .or_default();
            let original_effort = controls.effort.clone();

            if let Some(effort_value) = request.get("effort") {
                let effort = effort_value
                    .as_str()
                    .ok_or_else(|| BootError::BadRequest("effort must be a string".to_string()))?;
                let profile = normalize_effort(effort).ok_or_else(|| {
                    BootError::BadRequest(format!("unsupported effort level `{effort}`"))
                })?;
                controls.effort = profile.id.to_string();
            }

            if let Some(goal_value) = request.get("goal") {
                match goal_value {
                    Value::Null => controls.goal = None,
                    Value::String(goal) => controls.goal = normalize_goal(goal),
                    _ => {
                        return Err(BootError::BadRequest(
                            "goal must be a string or null".to_string(),
                        ));
                    }
                }
            }

            (controls.clone(), controls.effort != original_effort)
        };

        if effort_changed {
            let settings = self.session_settings_snapshot(session_id).await;
            self.rebuild_session_with_settings(session_id, &settings)
                .await?;
        }

        let settings = self.session_settings_snapshot(session_id).await;
        let model = self.effective_model(&settings);
        let context_limit = code_web_context_limit_for_model(self.state.as_ref(), model.as_deref());
        Ok(controls_json(
            session_id,
            &controls_snapshot,
            Some(context_limit),
        ))
    }

    pub(in crate::api::code_web) async fn compact_session(
        &self,
        session_id: &str,
    ) -> BootResult<serde_json::Value> {
        let session = self.kernel_session(session_id).await?;
        let history = session.history();
        if history.is_empty() {
            return Err(BootError::BadRequest("nothing to compact yet".to_string()));
        }

        let workspace = session.workspace().to_path_buf();
        let previous_summary = self
            .session_context_snapshot(session_id)
            .await
            .compact_summary;
        let prompt = compact_prompt(previous_summary.as_deref());
        let result = session
            .send(&prompt, Some(&history))
            .await
            .map_err(|error| BootError::Internal(error.to_string()))?;
        let summary = result.text.trim().to_string();
        if summary.is_empty() {
            return Err(BootError::Internal(
                "compaction failed with an empty summary".to_string(),
            ));
        }

        session.close().await;
        let controls = self.session_controls_snapshot(session_id).await;
        let new_session = self
            .create_agent_session(
                &workspace,
                Some(session_id),
                self.session_response_model(session_id).await,
                &controls.effort,
            )
            .await?;
        self.state
            .sessions
            .lock()
            .await
            .insert(session_id.to_string(), new_session);
        self.state.messages.lock().await.insert(
            session_id.to_string(),
            vec![json!({
                "id": format!("{}-compact", chrono::Utc::now().timestamp_millis()),
                "sessionId": session_id,
                "role": "system",
                "content": format!("Context compacted. Future turns will continue from this summary:\n\n{summary}"),
                "createdAt": chrono::Utc::now().to_rfc3339(),
            })],
        );
        self.state.session_contexts.lock().await.insert(
            session_id.to_string(),
            CodeWebSessionContext {
                compact_summary: Some(summary.clone()),
            },
        );

        Ok(json!({
            "sessionId": session_id,
            "compacted": true,
            "summary": summary,
            "historyMessages": history.len(),
            "completedAt": chrono::Utc::now().to_rfc3339(),
        }))
    }

    pub(in crate::api::code_web) async fn sleep_session(
        &self,
        session_id: &str,
        request: SleepSessionRequest,
    ) -> BootResult<serde_json::Value> {
        let session = self.kernel_session(session_id).await?;
        let focus = request
            .focus
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .unwrap_or("")
            .to_string();
        let today = sleep_today();
        let directive = sleep_directive(&focus, false, &today);
        let result = session
            .send(&directive, None)
            .await
            .map_err(|error| BootError::Internal(error.to_string()))?;
        let assistant_text = result.text;
        let usage = UsageResponse::from_usage(result.usage);
        let tool_calls_count = result.tool_calls_count;
        let (report_captured, saved_memories) = match parse_sleep_report(&assistant_text) {
            Some(memories) => (
                true,
                store_sleep_memories(session.as_ref(), memories, &today).await?,
            ),
            None => (false, Vec::new()),
        };

        let display = if focus.is_empty() {
            "Sleep consolidation".to_string()
        } else {
            format!("Sleep consolidation focus: {focus}")
        };
        let summary = if !report_captured {
            "Sleep consolidation finished without a parseable memory report.".to_string()
        } else if saved_memories.is_empty() {
            "Sleep consolidation finished with no durable memories to save.".to_string()
        } else {
            format!(
                "Sleep consolidation saved {} durable memories.",
                saved_memories.len()
            )
        };
        self.append_message(session_id, "user", &display, None)
            .await;
        self.append_message(session_id, "system", &summary, None)
            .await;

        Ok(json!({
            "sessionId": session_id,
            "focus": focus,
            "date": today,
            "reportCaptured": report_captured,
            "savedCount": saved_memories.len(),
            "memories": saved_memories,
            "assistantText": assistant_text,
            "usage": usage,
            "toolCallsCount": tool_calls_count,
            "completedAt": chrono::Utc::now().to_rfc3339(),
        }))
    }

    pub(in crate::api::code_web) async fn fork_session(
        &self,
        session_id: &str,
        request: ForkSessionRequest,
    ) -> BootResult<serde_json::Value> {
        let source_session = self.kernel_session(session_id).await?;
        let focus = request
            .focus
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .unwrap_or("")
            .to_string();
        let source_history = source_session.history();
        let source_messages = self
            .state
            .messages
            .lock()
            .await
            .get(session_id)
            .cloned()
            .unwrap_or_default();
        if source_history.is_empty() && source_messages.is_empty() {
            return Err(BootError::BadRequest(
                "nothing to fork yet - start a conversation first".to_string(),
            ));
        }

        let workspace = source_session.workspace().to_path_buf();
        let source_settings = self.session_settings_snapshot(session_id).await;
        let model = self.effective_model(&source_settings);
        let target_settings = source_settings.clone();
        let source_controls = self.session_controls_snapshot(session_id).await;
        let target_session = self
            .create_agent_session(&workspace, None, model.clone(), &source_controls.effort)
            .await?;
        let target_session_id = target_session.session_id().to_string();
        let source_context = self.session_context_snapshot(session_id).await;
        let target_context = CodeWebSessionContext {
            compact_summary: build_fork_context(
                session_id,
                &focus,
                source_context.compact_summary.as_deref(),
                &source_messages,
                &source_history,
            ),
        };
        let target_messages =
            fork_messages(session_id, &target_session_id, &source_messages, &focus);

        self.state
            .sessions
            .lock()
            .await
            .insert(target_session_id.clone(), Arc::clone(&target_session));
        self.state
            .messages
            .lock()
            .await
            .insert(target_session_id.clone(), target_messages.clone());
        self.state
            .session_controls
            .lock()
            .await
            .insert(target_session_id.clone(), source_controls);
        self.state
            .session_contexts
            .lock()
            .await
            .insert(target_session_id.clone(), target_context);
        self.state
            .session_settings
            .lock()
            .await
            .insert(target_session_id.clone(), target_settings.clone());

        let title = fork_title(&focus);
        Ok(json!({
            "sourceSessionId": session_id,
            "sessionId": target_session_id,
            "focus": focus,
            "workspace": workspace.display().to_string(),
            "model": model,
            "title": title,
            "copiedMessages": source_messages.len(),
            "messages": target_messages,
            "session": SessionResponse::from_session(
                target_session.as_ref(),
                model.clone(),
                target_settings.follow_default_model,
                target_settings.permission_mode,
                Some(title),
                None,
            ),
            "createdAt": chrono::Utc::now().to_rfc3339(),
        }))
    }

    pub(in crate::api::code_web) async fn run_session_message(
        &self,
        session_id: &str,
        request: serde_json::Value,
    ) -> BootResult<serde_json::Value> {
        let content = request
            .get("content")
            .or_else(|| request.get("message"))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .ok_or_else(|| BootError::BadRequest("content is required".to_string()))?;
        let session = self.kernel_session(session_id).await?;
        let prompt = self.compose_session_prompt(session_id, content).await;
        self.append_message(session_id, "user", content, None).await;
        let result = session
            .send(&prompt, None)
            .await
            .map_err(|error| BootError::Internal(error.to_string()))?;
        self.append_message(
            session_id,
            "assistant",
            &result.text,
            self.session_response_model(session_id).await,
        )
        .await;
        Ok(json!({
            "sessionId": session_id,
            "accepted": true,
            "events": [],
            "completedAt": chrono::Utc::now().to_rfc3339(),
        }))
    }

    pub(in crate::api::code_web) async fn chat(
        &self,
        request: ChatRequest,
    ) -> BootResult<ChatResponse> {
        let message = request.message.trim().to_string();
        if message.is_empty() {
            return Err(BootError::BadRequest("message cannot be empty".to_string()));
        }

        let session_request = CreateSessionRequest {
            workspace: request.workspace,
            cwd: None,
            model: request.model,
            follow_default_model: None,
            permission_mode: None,
            planning_mode: None,
            goal_tracking: None,
            title: None,
            agent_id: None,
        };
        let session = self
            .create_or_get_session(request.session_id, session_request)
            .await?;
        let session_id = session.session_id().to_string();
        let prompt = self.compose_session_prompt(&session_id, &message).await;
        self.append_message(&session_id, "user", &message, None)
            .await;
        let result = session
            .send(&prompt, None)
            .await
            .map_err(|e| BootError::Internal(e.to_string()))?;
        self.append_message(
            &session_id,
            "assistant",
            &result.text,
            self.session_response_model(&session_id).await,
        )
        .await;

        Ok(ChatResponse {
            session_id,
            workspace: session.workspace().display().to_string(),
            model: self.session_response_model(session.session_id()).await,
            text: result.text,
            usage: UsageResponse::from_usage(result.usage),
            tool_calls_count: result.tool_calls_count,
        })
    }

    async fn create_or_get_session(
        &self,
        requested_id: Option<String>,
        request: CreateSessionRequest,
    ) -> BootResult<Arc<AgentSession>> {
        if let Some(id) = requested_id
            .as_deref()
            .map(str::trim)
            .filter(|id| !id.is_empty())
        {
            let existing_session = self.state.sessions.lock().await.get(id).cloned();
            if let Some(session) = existing_session {
                self.state
                    .session_controls
                    .lock()
                    .await
                    .entry(id.to_string())
                    .or_default();
                self.state
                    .session_contexts
                    .lock()
                    .await
                    .entry(id.to_string())
                    .or_default();
                self.state
                    .session_settings
                    .lock()
                    .await
                    .entry(id.to_string())
                    .or_default();
                return Ok(session);
            }
        }

        let workspace = request
            .workspace
            .as_deref()
            .or(request.cwd.as_deref())
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(PathBuf::from)
            .unwrap_or_else(|| self.state.default_workspace.clone());
        let settings = self.settings_from_create_request(&request);
        let requested_session_id = requested_id
            .as_deref()
            .map(str::trim)
            .filter(|id| !id.is_empty());
        let default_controls = CodeWebSessionControls::default();

        let session = self
            .create_agent_session(
                &workspace,
                requested_session_id,
                self.effective_model(&settings),
                &default_controls.effort,
            )
            .await?;
        self.state
            .sessions
            .lock()
            .await
            .insert(session.session_id().to_string(), Arc::clone(&session));
        self.state
            .messages
            .lock()
            .await
            .entry(session.session_id().to_string())
            .or_default();
        self.state
            .session_controls
            .lock()
            .await
            .entry(session.session_id().to_string())
            .or_insert(default_controls);
        self.state
            .session_contexts
            .lock()
            .await
            .entry(session.session_id().to_string())
            .or_default();
        self.state
            .session_settings
            .lock()
            .await
            .entry(session.session_id().to_string())
            .or_insert(settings);
        Ok(session)
    }

    async fn kernel_session(&self, session_id: &str) -> BootResult<Arc<AgentSession>> {
        self.state
            .sessions
            .lock()
            .await
            .get(session_id)
            .cloned()
            .ok_or_else(|| BootError::NotFound(format!("session `{session_id}` was not found")))
    }

    async fn session_controls_snapshot(&self, session_id: &str) -> CodeWebSessionControls {
        self.state
            .session_controls
            .lock()
            .await
            .entry(session_id.to_string())
            .or_default()
            .clone()
    }

    async fn session_context_snapshot(&self, session_id: &str) -> CodeWebSessionContext {
        self.state
            .session_contexts
            .lock()
            .await
            .entry(session_id.to_string())
            .or_default()
            .clone()
    }

    async fn session_settings_snapshot(&self, session_id: &str) -> CodeWebSessionSettings {
        self.state
            .session_settings
            .lock()
            .await
            .entry(session_id.to_string())
            .or_default()
            .clone()
    }

    fn settings_from_create_request(
        &self,
        request: &CreateSessionRequest,
    ) -> CodeWebSessionSettings {
        let follow_default_model = request.follow_default_model.unwrap_or_else(|| {
            request
                .model
                .as_deref()
                .map(str::trim)
                .unwrap_or("")
                .is_empty()
        });
        let model = if follow_default_model {
            None
        } else {
            request
                .model
                .as_deref()
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .map(ToOwned::to_owned)
                .or_else(|| self.state.current_default_model())
        };
        CodeWebSessionSettings {
            model,
            follow_default_model,
            permission_mode: request
                .permission_mode
                .as_deref()
                .and_then(normalize_permission_mode)
                .unwrap_or_else(|| "auto".to_string()),
            planning_mode: request
                .planning_mode
                .as_deref()
                .and_then(normalize_planning_mode),
            goal_tracking: request.goal_tracking,
        }
    }

    fn effective_model(&self, settings: &CodeWebSessionSettings) -> Option<String> {
        effective_session_model(self.state.as_ref(), settings)
    }

    async fn create_agent_session(
        &self,
        workspace: &Path,
        session_id: Option<&str>,
        model: Option<String>,
        effort: &str,
    ) -> BootResult<Arc<AgentSession>> {
        let (options, runtime) =
            code_web_session_options(self.state.as_ref(), workspace, session_id, model, effort)
                .await;
        let session = Arc::new(
            self.state
                .agent
                .session(workspace.display().to_string(), Some(options))
                .map_err(|error| BootError::Internal(error.to_string()))?,
        );
        activate_session_runtime(session.as_ref(), &runtime);
        Ok(session)
    }

    async fn session_response_model(&self, session_id: &str) -> Option<String> {
        let settings = self.session_settings_snapshot(session_id).await;
        self.effective_model(&settings)
    }

    async fn apply_session_update(
        &self,
        session_id: &str,
        patch: serde_json::Value,
    ) -> BootResult<()> {
        self.kernel_session(session_id).await?;
        let default_model = self.state.current_default_model();
        let (settings, model_changed) = {
            let mut settings_by_session = self.state.session_settings.lock().await;
            let settings = settings_by_session
                .entry(session_id.to_string())
                .or_default();
            let model_changed = apply_settings_patch(settings, &patch, default_model)?;
            (settings.clone(), model_changed)
        };

        if model_changed {
            self.rebuild_session_with_settings(session_id, &settings)
                .await?;
        }
        Ok(())
    }

    async fn rebuild_session_with_settings(
        &self,
        session_id: &str,
        settings: &CodeWebSessionSettings,
    ) -> BootResult<()> {
        let old_session = self.kernel_session(session_id).await?;
        let workspace = old_session.workspace().to_path_buf();
        let history = old_session.history();
        let messages = self
            .state
            .messages
            .lock()
            .await
            .get(session_id)
            .cloned()
            .unwrap_or_default();
        let previous_context = self.session_context_snapshot(session_id).await;
        if let Some(carried_context) = build_reconfigure_context(
            previous_context.compact_summary.as_deref(),
            &messages,
            &history,
        ) {
            self.state.session_contexts.lock().await.insert(
                session_id.to_string(),
                CodeWebSessionContext {
                    compact_summary: Some(carried_context),
                },
            );
        }

        old_session.close().await;
        let controls = self.session_controls_snapshot(session_id).await;
        let new_session = self
            .create_agent_session(
                &workspace,
                Some(session_id),
                self.effective_model(settings),
                &controls.effort,
            )
            .await?;
        self.state
            .sessions
            .lock()
            .await
            .insert(session_id.to_string(), new_session);
        Ok(())
    }

    async fn compose_session_prompt(&self, session_id: &str, user_prompt: &str) -> String {
        if user_prompt.trim_start().starts_with('/') {
            return user_prompt.to_string();
        }

        let controls = self.session_controls_snapshot(session_id).await;
        let context = self.session_context_snapshot(session_id).await;
        let controlled_prompt = compose_controlled_prompt(&controls, user_prompt);
        match context
            .compact_summary
            .as_deref()
            .map(str::trim)
            .filter(|summary| !summary.is_empty())
        {
            Some(summary) => {
                format!("# Earlier conversation (compacted)\n\n{summary}\n\n{controlled_prompt}")
            }
            None => controlled_prompt,
        }
    }

    async fn append_message(
        &self,
        session_id: &str,
        role: &str,
        content: &str,
        model: Option<String>,
    ) {
        let id = format!("{}-{}", chrono::Utc::now().timestamp_millis(), role);
        let mut message = json!({
            "id": id,
            "sessionId": session_id,
            "role": role,
            "content": content,
            "createdAt": chrono::Utc::now().to_rfc3339(),
        });
        if let Some(model) = model {
            message["model"] = Value::String(model);
        }
        self.state
            .messages
            .lock()
            .await
            .entry(session_id.to_string())
            .or_default()
            .push(message);
    }

    async fn append_shell_output_message(&self, session_id: &str, record: &Value) {
        let id = format!("{}-shell", chrono::Utc::now().timestamp_millis());
        let command = record
            .get("input")
            .and_then(Value::as_str)
            .unwrap_or_default();
        let output = record
            .get("output")
            .and_then(Value::as_str)
            .unwrap_or_default();
        let tool_use_id = record
            .get("toolUseId")
            .and_then(Value::as_str)
            .unwrap_or(&id);
        let message = json!({
            "id": id,
            "sessionId": session_id,
            "role": "assistant",
            "content": format!("Shell command finished: {command}"),
            "createdAt": chrono::Utc::now().to_rfc3339(),
            "source": "command:!",
            "contentBlocks": [
                {
                    "type": "tool_use",
                    "id": tool_use_id,
                    "name": "shell_command",
                    "input": {
                        "command": command,
                        "cwd": record.get("cwd").cloned().unwrap_or(Value::Null),
                    }
                },
                {
                    "type": "tool_result",
                    "toolUseId": tool_use_id,
                    "content": output,
                    "isError": record.get("isError").cloned().unwrap_or(Value::Bool(false)),
                    "exitCode": record.get("exitCode").cloned().unwrap_or(Value::Null),
                    "durationMs": record.get("durationMs").cloned().unwrap_or(Value::Null),
                }
            ],
        });
        self.state
            .messages
            .lock()
            .await
            .entry(session_id.to_string())
            .or_default()
            .push(message);
    }
}

fn compact_prompt(previous_summary: Option<&str>) -> String {
    match previous_summary
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        Some(previous_summary) => format!(
            "An earlier part of this conversation was already condensed into this \
             summary:\n\n{previous_summary}\n\nProduce a SINGLE updated summary that \
             fully incorporates the summary above AND the conversation history below, \
             so a fresh session can continue seamlessly: the goal, key decisions, \
             files/commands touched, current state, and the immediate next steps. Be \
             thorough but compact."
        ),
        None => "Summarize this conversation so a fresh session can continue seamlessly: \
             the goal, key decisions, files/commands touched, current state, and the \
             immediate next steps. Be thorough but compact."
            .to_string(),
    }
}

fn apply_settings_patch(
    settings: &mut CodeWebSessionSettings,
    patch: &Value,
    default_model: Option<String>,
) -> BootResult<bool> {
    let Some(patch) = patch.as_object() else {
        return Err(BootError::BadRequest(
            "session update body must be an object".to_string(),
        ));
    };
    let previous_model = settings.model.clone();
    let previous_follow_default_model = settings.follow_default_model;

    let follow_default_patch = match patch.get("followDefaultModel") {
        Some(Value::Bool(value)) => Some(*value),
        Some(Value::Null) | None => None,
        Some(_) => {
            return Err(BootError::BadRequest(
                "followDefaultModel must be a boolean".to_string(),
            ));
        }
    };

    if let Some(model_value) = patch.get("model") {
        match model_value {
            Value::String(model) => {
                let model = model.trim();
                if model.is_empty() {
                    settings.model = None;
                    settings.follow_default_model = follow_default_patch.unwrap_or(true);
                } else {
                    settings.model = Some(model.to_string());
                    settings.follow_default_model = follow_default_patch.unwrap_or(false);
                }
            }
            Value::Null => {
                settings.model = None;
                settings.follow_default_model = follow_default_patch.unwrap_or(true);
            }
            _ => return Err(BootError::BadRequest("model must be a string".to_string())),
        }
    } else if let Some(follow_default_model) = follow_default_patch {
        settings.follow_default_model = follow_default_model;
        if follow_default_model {
            settings.model = None;
        } else if settings.model.is_none() {
            settings.model = default_model;
        }
    }

    if let Some(permission_mode_value) = patch.get("permissionMode") {
        let permission_mode = permission_mode_value
            .as_str()
            .ok_or_else(|| BootError::BadRequest("permissionMode must be a string".to_string()))?;
        settings.permission_mode = normalize_permission_mode(permission_mode).ok_or_else(|| {
            BootError::BadRequest(format!("unsupported permissionMode `{permission_mode}`"))
        })?;
    }

    if let Some(planning_mode_value) = patch.get("planningMode") {
        match planning_mode_value {
            Value::String(planning_mode) => {
                settings.planning_mode =
                    Some(normalize_planning_mode(planning_mode).ok_or_else(|| {
                        BootError::BadRequest(format!("unsupported planningMode `{planning_mode}`"))
                    })?);
            }
            Value::Null => settings.planning_mode = None,
            _ => {
                return Err(BootError::BadRequest(
                    "planningMode must be a string or null".to_string(),
                ));
            }
        }
    }

    if let Some(goal_tracking_value) = patch.get("goalTracking") {
        match goal_tracking_value {
            Value::Bool(goal_tracking) => settings.goal_tracking = Some(*goal_tracking),
            Value::Null => settings.goal_tracking = None,
            _ => {
                return Err(BootError::BadRequest(
                    "goalTracking must be a boolean or null".to_string(),
                ));
            }
        }
    }

    Ok(previous_model != settings.model
        || previous_follow_default_model != settings.follow_default_model)
}

fn normalize_permission_mode(value: &str) -> Option<String> {
    match value.trim().to_ascii_lowercase().as_str() {
        "default" | "plan" | "auto" => Some(value.trim().to_ascii_lowercase()),
        _ => None,
    }
}

fn normalize_planning_mode(value: &str) -> Option<String> {
    match value.trim().to_ascii_lowercase().as_str() {
        "auto" | "enabled" | "disabled" => Some(value.trim().to_ascii_lowercase()),
        _ => None,
    }
}

fn build_reconfigure_context(
    previous_summary: Option<&str>,
    messages: &[serde_json::Value],
    history: &[Message],
) -> Option<String> {
    let mut parts = Vec::new();
    if let Some(summary) = previous_summary
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        parts.push(format!("Existing compacted summary:\n{summary}"));
    }
    let visible_transcript = fork_transcript_from_messages(messages);
    if !visible_transcript.is_empty() {
        parts.push(format!(
            "Visible conversation transcript:\n{visible_transcript}"
        ));
    } else {
        let history_transcript = fork_transcript_from_history(history);
        if !history_transcript.is_empty() {
            parts.push(format!("Core conversation history:\n{history_transcript}"));
        }
    }
    if parts.is_empty() {
        None
    } else {
        Some(truncate_chars(
            &format!(
                "This session was reconfigured. Continue with this carried context:\n\n{}",
                parts.join("\n\n")
            ),
            12_000,
        ))
    }
}

fn fork_title(focus: &str) -> String {
    let focus = focus.trim();
    if focus.is_empty() {
        "Forked session".to_string()
    } else {
        format!("Fork: {}", truncate_chars(focus, 48))
    }
}

fn fork_messages(
    source_session_id: &str,
    target_session_id: &str,
    source_messages: &[serde_json::Value],
    focus: &str,
) -> Vec<serde_json::Value> {
    let now = chrono::Utc::now();
    let mut messages: Vec<serde_json::Value> = source_messages
        .iter()
        .enumerate()
        .map(|(index, message)| {
            let mut cloned = message.clone();
            if let Value::Object(map) = &mut cloned {
                map.insert(
                    "id".to_string(),
                    Value::String(format!("{}-fork-copy-{index}", now.timestamp_millis())),
                );
                map.insert(
                    "sessionId".to_string(),
                    Value::String(target_session_id.to_string()),
                );
            }
            cloned
        })
        .collect();
    let focus_line = focus
        .trim()
        .is_empty()
        .then(String::new)
        .unwrap_or_else(|| format!("\nFocus: {}", focus.trim()));
    messages.push(json!({
        "id": format!("{}-fork", now.timestamp_millis()),
        "sessionId": target_session_id,
        "role": "system",
        "content": format!("Forked from session `{source_session_id}`.{focus_line}"),
        "createdAt": now.to_rfc3339(),
    }));
    messages
}

fn build_fork_context(
    source_session_id: &str,
    focus: &str,
    previous_summary: Option<&str>,
    source_messages: &[serde_json::Value],
    source_history: &[Message],
) -> Option<String> {
    let mut parts = Vec::new();
    if let Some(summary) = previous_summary
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        parts.push(format!("Existing compacted summary:\n{summary}"));
    }
    let visible_transcript = fork_transcript_from_messages(source_messages);
    if !visible_transcript.is_empty() {
        parts.push(format!(
            "Visible conversation transcript:\n{visible_transcript}"
        ));
    } else {
        let history_transcript = fork_transcript_from_history(source_history);
        if !history_transcript.is_empty() {
            parts.push(format!("Core conversation history:\n{history_transcript}"));
        }
    }
    if parts.is_empty() {
        return None;
    }
    let focus_line = focus
        .trim()
        .is_empty()
        .then(String::new)
        .unwrap_or_else(|| format!("\nFork focus: {}", focus.trim()));
    Some(truncate_chars(
        &format!(
            "This session was forked from `{source_session_id}`.{focus_line}\n\n{}",
            parts.join("\n\n")
        ),
        12_000,
    ))
}

fn fork_transcript_from_messages(messages: &[serde_json::Value]) -> String {
    let mut lines = Vec::new();
    for message in messages.iter().rev().take(24).rev() {
        let role = message
            .get("role")
            .and_then(Value::as_str)
            .unwrap_or("message");
        let Some(content) = message.get("content").and_then(Value::as_str) else {
            continue;
        };
        let content = content.trim();
        if content.is_empty() {
            continue;
        }
        lines.push(format!("{role}: {}", truncate_chars(content, 1200)));
    }
    truncate_chars(&lines.join("\n\n"), 10_000)
}

fn fork_transcript_from_history(history: &[Message]) -> String {
    let mut lines = Vec::new();
    for message in history.iter().rev().take(24).rev() {
        let content = message_text(message);
        if content.trim().is_empty() {
            continue;
        }
        lines.push(format!(
            "{}: {}",
            message.role,
            truncate_chars(content.trim(), 1200)
        ));
    }
    truncate_chars(&lines.join("\n\n"), 10_000)
}

fn message_text(message: &Message) -> String {
    message
        .content
        .iter()
        .filter_map(|block| match block {
            ContentBlock::Text { text } => Some(text.as_str()),
            ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
            ContentBlock::ToolResult { .. } | ContentBlock::Image { .. } => None,
        })
        .collect::<Vec<_>>()
        .join("\n")
}

#[derive(Debug, Clone)]
struct PendingToolUse {
    tool_name: String,
    input: String,
    created_at: Option<String>,
    source_message_id: String,
}

struct ShellOutputRecordInput<'a> {
    session_id: &'a str,
    command: &'a str,
    cwd: &'a str,
    stdout: &'a str,
    stderr: &'a str,
    output: &'a str,
    exit_code: Option<i32>,
    is_error: bool,
    timed_out: bool,
    duration_ms: u64,
    started_at: &'a str,
    completed_at: &'a str,
}

fn shell_text(bytes: &[u8]) -> String {
    String::from_utf8_lossy(bytes).into_owned()
}

fn shell_output_record(input: ShellOutputRecordInput<'_>) -> Value {
    let tool_use_id = format!(
        "shell-{}",
        input
            .started_at
            .chars()
            .filter(|ch| ch.is_ascii_alphanumeric())
            .collect::<String>()
    );
    json!({
        "id": tool_use_id,
        "index": 0,
        "toolUseId": tool_use_id,
        "toolName": "shell_command",
        "input": input.command,
        "output": input.output,
        "stdout": input.stdout,
        "stderr": input.stderr,
        "cwd": input.cwd,
        "exitCode": input.exit_code,
        "success": !input.is_error,
        "isError": input.is_error,
        "timedOut": input.timed_out,
        "durationMs": input.duration_ms,
        "createdAt": input.started_at,
        "completedAt": input.completed_at,
        "sourceMessageId": Value::Null,
        "resultMessageId": Value::Null,
        "sessionId": input.session_id,
    })
}

fn session_output_json(session_id: &str, messages: &[Value]) -> Value {
    let items = tool_output_records_from_messages(messages);
    let total = items.len();
    json!({
        "sessionId": session_id,
        "items": items,
        "total": total,
        "format": "structured-tool-log",
    })
}

fn tool_output_records_from_messages(messages: &[Value]) -> Vec<Value> {
    let mut records = Vec::new();
    let mut pending: HashMap<String, PendingToolUse> = HashMap::new();
    let mut last_tool_use_id: Option<String> = None;

    for (message_index, message) in messages.iter().enumerate() {
        let source_message_id = string_field(message, &["id", "messageId", "message_id"])
            .unwrap_or_else(|| format!("message-{message_index}"));
        let created_at = string_field(
            message,
            &["createdAt", "created_at", "timestamp", "created"],
        );

        for (block_index, block) in message_blocks(message).into_iter().enumerate() {
            let block_type = string_field(block, &["type"]).unwrap_or_default();
            match block_type.as_str() {
                "tool_use" | "toolUse" | "tool-call-input" => {
                    let tool_use_id = string_field(
                        block,
                        &[
                            "id",
                            "toolUseId",
                            "tool_use_id",
                            "toolCallId",
                            "tool_call_id",
                        ],
                    )
                    .unwrap_or_else(|| format!("tool-{message_index}-{block_index}"));
                    let tool_name = string_field(
                        block,
                        &["name", "toolName", "tool_name", "tool", "function"],
                    )
                    .unwrap_or_else(|| "tool".to_string());
                    let input = first_field(
                        block,
                        &["input", "toolInput", "tool_input", "args", "arguments"],
                    )
                    .map(stringify_json_value)
                    .unwrap_or_default();
                    pending.insert(
                        tool_use_id.clone(),
                        PendingToolUse {
                            tool_name,
                            input,
                            created_at: created_at.clone(),
                            source_message_id: source_message_id.clone(),
                        },
                    );
                    last_tool_use_id = Some(tool_use_id);
                }
                "tool_result" | "toolResult" | "tool-call-output" => {
                    let tool_use_id = string_field(
                        block,
                        &[
                            "toolUseId",
                            "tool_use_id",
                            "toolCallId",
                            "tool_call_id",
                            "id",
                        ],
                    )
                    .or_else(|| last_tool_use_id.clone())
                    .unwrap_or_else(|| format!("tool-result-{message_index}-{block_index}"));
                    let pending_use = pending.remove(&tool_use_id);
                    let tool_name = pending_use
                        .as_ref()
                        .map(|tool_use| tool_use.tool_name.clone())
                        .or_else(|| {
                            string_field(
                                block,
                                &["name", "toolName", "tool_name", "tool", "function"],
                            )
                        })
                        .unwrap_or_else(|| "result".to_string());
                    let input = pending_use
                        .as_ref()
                        .map(|tool_use| tool_use.input.clone())
                        .or_else(|| {
                            first_field(
                                block,
                                &["input", "toolInput", "tool_input", "args", "arguments"],
                            )
                            .map(stringify_json_value)
                        })
                        .unwrap_or_default();
                    records.push(json!({
                        "id": tool_use_id,
                        "index": records.len(),
                        "toolUseId": tool_use_id,
                        "toolName": tool_name,
                        "input": input,
                        "output": first_field(
                            block,
                            &["content", "output", "toolOutput", "tool_output", "result"],
                        )
                        .map(stringify_tool_output)
                        .unwrap_or_default(),
                        "isError": bool_field(block, &["isError", "is_error", "error"]).unwrap_or(false),
                        "exitCode": first_field(block, &["exitCode", "exit_code", "status"]).cloned().unwrap_or(Value::Null),
                        "before": first_field(block, &["before"]).cloned().unwrap_or(Value::Null),
                        "after": first_field(block, &["after"]).cloned().unwrap_or(Value::Null),
                        "filePath": first_field(block, &["filePath", "file_path", "path"]).cloned().unwrap_or(Value::Null),
                        "durationMs": first_field(block, &["durationMs", "duration_ms", "elapsedMs", "elapsed_ms"]).cloned().unwrap_or(Value::Null),
                        "createdAt": pending_use
                            .as_ref()
                            .and_then(|tool_use| tool_use.created_at.clone())
                            .or_else(|| created_at.clone()),
                        "sourceMessageId": pending_use
                            .as_ref()
                            .map(|tool_use| tool_use.source_message_id.clone())
                            .unwrap_or_else(|| source_message_id.clone()),
                        "resultMessageId": source_message_id.clone(),
                    }));
                }
                "tool_call" | "tool" | "completed_tool" | "completed_tool_call" => {
                    if first_field(
                        block,
                        &["output", "content", "toolOutput", "tool_output", "result"],
                    )
                    .is_none()
                    {
                        continue;
                    }
                    let tool_use_id = string_field(
                        block,
                        &[
                            "toolUseId",
                            "tool_use_id",
                            "toolCallId",
                            "tool_call_id",
                            "id",
                        ],
                    )
                    .unwrap_or_else(|| format!("tool-{message_index}-{block_index}"));
                    records.push(json!({
                        "id": tool_use_id,
                        "index": records.len(),
                        "toolUseId": tool_use_id,
                        "toolName": string_field(block, &["toolName", "tool_name", "name", "tool"])
                            .unwrap_or_else(|| "tool".to_string()),
                        "input": first_field(
                            block,
                            &["input", "toolInput", "tool_input", "args", "arguments"],
                        )
                        .map(stringify_json_value)
                        .unwrap_or_default(),
                        "output": first_field(
                            block,
                            &["output", "content", "toolOutput", "tool_output", "result"],
                        )
                        .map(stringify_tool_output)
                        .unwrap_or_default(),
                        "isError": bool_field(block, &["isError", "is_error", "error"]).unwrap_or(false),
                        "exitCode": first_field(block, &["exitCode", "exit_code", "status"]).cloned().unwrap_or(Value::Null),
                        "before": first_field(block, &["before"]).cloned().unwrap_or(Value::Null),
                        "after": first_field(block, &["after"]).cloned().unwrap_or(Value::Null),
                        "filePath": first_field(block, &["filePath", "file_path", "path"]).cloned().unwrap_or(Value::Null),
                        "durationMs": first_field(block, &["durationMs", "duration_ms", "elapsedMs", "elapsed_ms"]).cloned().unwrap_or(Value::Null),
                        "createdAt": created_at.clone(),
                        "sourceMessageId": source_message_id.clone(),
                        "resultMessageId": source_message_id,
                    }));
                }
                _ => {}
            }
        }
    }

    records
}

fn message_blocks(message: &Value) -> Vec<&Value> {
    if let Some(blocks) = first_field(message, &["contentBlocks", "content_blocks", "blocks"])
        .and_then(Value::as_array)
    {
        return blocks.iter().collect();
    }

    if let Some(blocks) = message.get("content").and_then(Value::as_array) {
        return blocks.iter().collect();
    }

    if let Some(nested_message) = message.get("message") {
        if let Some(blocks) = first_field(
            nested_message,
            &["contentBlocks", "content_blocks", "blocks"],
        )
        .and_then(Value::as_array)
        {
            return blocks.iter().collect();
        }
        if let Some(blocks) = nested_message.get("content").and_then(Value::as_array) {
            return blocks.iter().collect();
        }
    }

    match message.get("type").and_then(Value::as_str) {
        Some("tool_use" | "toolUse" | "tool_result" | "toolResult" | "tool_call" | "tool") => {
            vec![message]
        }
        _ => Vec::new(),
    }
}

fn first_field<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a Value> {
    let map = value.as_object()?;
    keys.iter()
        .filter_map(|key| map.get(*key))
        .find(|field| !field.is_null())
}

fn string_field(value: &Value, keys: &[&str]) -> Option<String> {
    first_field(value, keys).and_then(json_scalar_to_string)
}

fn bool_field(value: &Value, keys: &[&str]) -> Option<bool> {
    match first_field(value, keys)? {
        Value::Bool(value) => Some(*value),
        Value::String(value) => match value.trim().to_ascii_lowercase().as_str() {
            "true" | "yes" | "1" => Some(true),
            "false" | "no" | "0" => Some(false),
            _ => None,
        },
        _ => None,
    }
}

fn json_scalar_to_string(value: &Value) -> Option<String> {
    match value {
        Value::String(value) => {
            let value = value.trim();
            (!value.is_empty()).then(|| value.to_string())
        }
        Value::Number(value) => Some(value.to_string()),
        Value::Bool(value) => Some(value.to_string()),
        _ => None,
    }
}

fn stringify_json_value(value: &Value) -> String {
    match value {
        Value::Null => String::new(),
        Value::String(value) => value.clone(),
        _ => serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()),
    }
}

fn stringify_tool_output(value: &Value) -> String {
    match value {
        Value::Array(items) => {
            let text = items
                .iter()
                .filter_map(|item| {
                    if let Value::String(value) = item {
                        return Some(value.as_str());
                    }
                    item.get("text")
                        .or_else(|| item.get("content"))
                        .or_else(|| item.get("message"))
                        .and_then(Value::as_str)
                })
                .collect::<Vec<_>>()
                .join("\n");
            if text.trim().is_empty() {
                stringify_json_value(value)
            } else {
                text
            }
        }
        _ => stringify_json_value(value),
    }
}

fn truncate_chars(value: &str, max_chars: usize) -> String {
    if value.chars().count() <= max_chars {
        return value.to_string();
    }
    let mut truncated = value.chars().take(max_chars).collect::<String>();
    truncated.push_str("...");
    truncated
}

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

    #[test]
    fn fork_messages_rekeys_copied_messages_and_marks_the_branch() {
        let source_messages = vec![json!({
            "id": "old",
            "sessionId": "source",
            "role": "user",
            "content": "continue the UI parity work",
        })];
        let messages = fork_messages("source", "target", &source_messages, "sleep UI");
        assert_eq!(messages.len(), 2);
        assert_eq!(messages[0]["sessionId"], "target");
        assert_ne!(messages[0]["id"], "old");
        assert_eq!(messages[1]["role"], "system");
        assert!(messages[1]["content"]
            .as_str()
            .unwrap()
            .contains("Focus: sleep UI"));
    }

    #[test]
    fn fork_context_prefers_visible_messages_and_keeps_focus() {
        let source_messages = vec![json!({
            "role": "assistant",
            "content": "The toolbar now uses GUI actions for sleep.",
        })];
        let history = vec![Message::user("history fallback")];
        let context = build_fork_context(
            "source",
            "finish fork",
            Some("Earlier compact summary"),
            &source_messages,
            &history,
        )
        .expect("fork context");
        assert!(context.contains("Fork focus: finish fork"));
        assert!(context.contains("Earlier compact summary"));
        assert!(context.contains("The toolbar now uses GUI actions for sleep."));
        assert!(!context.contains("history fallback"));
    }

    #[test]
    fn settings_patch_pins_model_and_execution_mode() {
        let mut settings = CodeWebSessionSettings::default();
        let changed = apply_settings_patch(
            &mut settings,
            &json!({
                "model": "openai/gpt-5.5",
                "followDefaultModel": false,
                "permissionMode": "plan",
                "planningMode": "enabled",
                "goalTracking": true,
            }),
            Some("openai/default".to_string()),
        )
        .expect("settings patch");
        assert!(changed);
        assert_eq!(settings.model.as_deref(), Some("openai/gpt-5.5"));
        assert!(!settings.follow_default_model);
        assert_eq!(settings.permission_mode, "plan");
        assert_eq!(settings.planning_mode.as_deref(), Some("enabled"));
        assert_eq!(settings.goal_tracking, Some(true));
    }

    #[test]
    fn settings_patch_returns_to_default_model() {
        let mut settings = CodeWebSessionSettings {
            model: Some("openai/gpt-5.5".to_string()),
            follow_default_model: false,
            ..CodeWebSessionSettings::default()
        };
        let changed = apply_settings_patch(
            &mut settings,
            &json!({ "followDefaultModel": true }),
            Some("openai/default".to_string()),
        )
        .expect("settings patch");
        assert!(changed);
        assert!(settings.model.is_none());
        assert!(settings.follow_default_model);
    }

    #[test]
    fn settings_patch_rejects_unknown_execution_mode() {
        let mut settings = CodeWebSessionSettings::default();
        let error =
            apply_settings_patch(&mut settings, &json!({ "permissionMode": "danger" }), None)
                .expect_err("unsupported permission mode should fail");
        assert!(error.to_string().contains("unsupported permissionMode"));
    }

    #[test]
    fn session_output_pairs_tool_use_and_result_blocks() {
        let messages = vec![json!({
            "id": "assistant-1",
            "role": "assistant",
            "createdAt": "2026-07-07T00:00:00Z",
            "contentBlocks": [
                {
                    "type": "tool_use",
                    "id": "call-1",
                    "name": "shell_command",
                    "input": { "command": "just web" }
                },
                {
                    "type": "tool_result",
                    "toolUseId": "call-1",
                    "content": "server started",
                    "isError": false,
                    "exitCode": 0,
                    "durationMs": 1200
                }
            ]
        })];

        let output = session_output_json("session-1", &messages);
        assert_eq!(output["sessionId"], "session-1");
        assert_eq!(output["total"], 1);
        assert_eq!(output["items"][0]["toolUseId"], "call-1");
        assert_eq!(output["items"][0]["toolName"], "shell_command");
        assert_eq!(
            output["items"][0]["input"],
            "{\n  \"command\": \"just web\"\n}"
        );
        assert_eq!(output["items"][0]["output"], "server started");
        assert_eq!(output["items"][0]["exitCode"], 0);
        assert_eq!(output["items"][0]["durationMs"], 1200);
        assert_eq!(output["items"][0]["sourceMessageId"], "assistant-1");
    }

    #[test]
    fn session_output_reads_legacy_content_array_aliases() {
        let messages = vec![json!({
            "id": "assistant-2",
            "role": "assistant",
            "content": [
                {
                    "type": "tool_use",
                    "tool_use_id": "call-2",
                    "tool_name": "read_file",
                    "tool_input": { "path": "README.md" }
                },
                {
                    "type": "tool_result",
                    "tool_use_id": "call-2",
                    "tool_output": [
                        { "type": "text", "text": "# A3S" }
                    ],
                    "is_error": "true",
                    "file_path": "README.md"
                }
            ]
        })];

        let output = session_output_json("session-2", &messages);
        assert_eq!(output["total"], 1);
        assert_eq!(output["items"][0]["toolName"], "read_file");
        assert_eq!(output["items"][0]["output"], "# A3S");
        assert_eq!(output["items"][0]["isError"], true);
        assert_eq!(output["items"][0]["filePath"], "README.md");
    }

    #[test]
    fn session_output_keeps_result_without_matching_use_visible() {
        let messages = vec![json!({
            "id": "assistant-3",
            "role": "assistant",
            "content_blocks": [
                {
                    "type": "tool_result",
                    "toolCallId": "orphan-result",
                    "name": "result",
                    "result": { "ok": true }
                }
            ]
        })];

        let output = session_output_json("session-3", &messages);
        assert_eq!(output["total"], 1);
        assert_eq!(output["items"][0]["toolUseId"], "orphan-result");
        assert_eq!(output["items"][0]["toolName"], "result");
        assert_eq!(output["items"][0]["output"], "{\n  \"ok\": true\n}");
    }

    #[test]
    fn shell_output_record_is_visible_to_output_page() {
        let record = shell_output_record(ShellOutputRecordInput {
            session_id: "session-shell",
            command: "printf hello",
            cwd: "/workspace",
            stdout: "hello",
            stderr: "",
            output: "hello",
            exit_code: Some(0),
            is_error: false,
            timed_out: false,
            duration_ms: 25,
            started_at: "2026-07-07T00:00:00Z",
            completed_at: "2026-07-07T00:00:00Z",
        });
        let messages = vec![json!({
            "id": "shell-message",
            "role": "assistant",
            "contentBlocks": [
                {
                    "type": "tool_use",
                    "id": record["toolUseId"],
                    "name": "shell_command",
                    "input": {
                        "command": record["input"],
                        "cwd": record["cwd"],
                    }
                },
                {
                    "type": "tool_result",
                    "toolUseId": record["toolUseId"],
                    "content": record["output"],
                    "isError": record["isError"],
                    "exitCode": record["exitCode"],
                    "durationMs": record["durationMs"],
                }
            ]
        })];

        let output = session_output_json("session-shell", &messages);
        assert_eq!(output["total"], 1);
        assert_eq!(output["items"][0]["toolName"], "shell_command");
        assert_eq!(
            output["items"][0]["input"],
            "{\n  \"command\": \"printf hello\",\n  \"cwd\": \"/workspace\"\n}"
        );
        assert_eq!(output["items"][0]["output"], "hello");
        assert_eq!(output["items"][0]["exitCode"], 0);
    }
}
