use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::{fs, process::Command, time::{SystemTime, UNIX_EPOCH}};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};

use tauri::State;

use crate::config_io::{ensure_dirs, read_openclaw_config, write_json, write_text};
use crate::doctor::{apply_auto_fixes, run_doctor, DoctorReport};
use crate::history::{add_snapshot, list_snapshots, read_snapshot};
use crate::models::resolve_paths;
use crate::ssh::{SshConnectionPool, SshHostConfig, SshExecResult, SftpEntry};
use std::sync::Mutex;

/// Stores remote config baselines keyed by host_id for dirty tracking.
pub struct RemoteConfigBaselines(Mutex<HashMap<String, String>>);

impl RemoteConfigBaselines {
    pub fn new() -> Self {
        Self(Mutex::new(HashMap::new()))
    }
}

use crate::recipe::{
    load_recipes_with_fallback,
    collect_change_paths,
    build_candidate_config_from_template,
    format_diff,
    ApplyResult,
    PreviewResult,
};

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SystemStatus {
    pub healthy: bool,
    pub config_path: String,
    pub openclaw_dir: String,
    pub clawpal_dir: String,
    pub openclaw_version: String,
    pub active_agents: u32,
    pub snapshots: usize,
    pub channels: ChannelSummary,
    pub models: ModelSummary,
    pub memory: MemorySummary,
    pub sessions: SessionSummary,
    pub openclaw_update: OpenclawUpdateCheck,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenclawUpdateCheck {
    pub installed_version: String,
    pub latest_version: Option<String>,
    pub upgrade_available: bool,
    pub channel: Option<String>,
    pub details: Option<String>,
    pub source: String,
    pub checked_at: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelCatalogProviderCache {
    pub cli_version: String,
    pub updated_at: u64,
    pub providers: Vec<ModelCatalogProvider>,
    pub source: String,
    pub error: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenclawCommandOutput {
    pub stdout: String,
    pub stderr: String,
    pub exit_code: i32,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExtractModelProfilesResult {
    pub created: usize,
    pub reused: usize,
    pub skipped_invalid: usize,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExtractModelProfileEntry {
    pub provider: String,
    pub model: String,
    pub source: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenclawUpdateCache {
    pub checked_at: u64,
    pub latest_version: Option<String>,
    pub channel: Option<String>,
    pub details: Option<String>,
    pub source: String,
    pub installed_version: Option<String>,
    pub ttl_seconds: u64,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelSummary {
    pub global_default_model: Option<String>,
    pub agent_overrides: Vec<String>,
    pub channel_overrides: Vec<String>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelSummary {
    pub configured_channels: usize,
    pub channel_model_overrides: usize,
    pub channel_examples: Vec<String>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MemoryFileSummary {
    pub path: String,
    pub size_bytes: u64,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MemorySummary {
    pub file_count: usize,
    pub total_bytes: u64,
    pub files: Vec<MemoryFileSummary>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MemoryFile {
    pub path: String,
    pub relative_path: String,
    pub size_bytes: u64,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentSessionSummary {
    pub agent: String,
    pub session_files: usize,
    pub archive_files: usize,
    pub total_bytes: u64,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionFile {
    pub path: String,
    pub relative_path: String,
    pub agent: String,
    pub kind: String,
    pub size_bytes: u64,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionAnalysis {
    pub agent: String,
    pub session_id: String,
    pub file_path: String,
    pub size_bytes: u64,
    pub message_count: usize,
    pub user_message_count: usize,
    pub assistant_message_count: usize,
    pub last_activity: Option<String>,
    pub age_days: f64,
    pub total_tokens: u64,
    pub model: Option<String>,
    pub category: String,
    pub kind: String,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentSessionAnalysis {
    pub agent: String,
    pub total_files: usize,
    pub total_size_bytes: u64,
    pub empty_count: usize,
    pub low_value_count: usize,
    pub valuable_count: usize,
    pub sessions: Vec<SessionAnalysis>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionSummary {
    pub total_session_files: usize,
    pub total_archive_files: usize,
    pub total_bytes: u64,
    pub by_agent: Vec<AgentSessionSummary>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ModelProfile {
    pub id: String,
    pub name: String,
    pub provider: String,
    pub model: String,
    #[serde(default)]
    pub auth_ref: String,
    #[serde(default)]
    pub api_key: Option<String>,
    pub base_url: Option<String>,
    pub description: Option<String>,
    pub enabled: bool,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ModelCatalogModel {
    pub id: String,
    pub name: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ModelCatalogProvider {
    pub provider: String,
    pub base_url: Option<String>,
    pub models: Vec<ModelCatalogModel>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelNode {
    pub path: String,
    pub channel_type: Option<String>,
    pub mode: Option<String>,
    pub allowlist: Vec<String>,
    pub model: Option<String>,
    pub has_model_field: bool,
    pub display_name: Option<String>,
    pub name_status: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DiscordGuildChannel {
    pub guild_id: String,
    pub guild_name: String,
    pub channel_id: String,
    pub channel_name: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderAuthSuggestion {
    pub auth_ref: Option<String>,
    pub has_key: bool,
    pub source: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelBinding {
    pub scope: String,
    pub scope_id: String,
    pub model_profile_id: Option<String>,
    pub model_value: Option<String>,
    pub path: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HistoryItem {
    pub id: String,
    pub recipe_id: Option<String>,
    pub created_at: String,
    pub source: String,
    pub can_rollback: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub rollback_of: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HistoryPage {
    pub items: Vec<HistoryItem>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FixResult {
    pub ok: bool,
    pub applied: Vec<String>,
    pub remaining_issues: Vec<String>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentOverview {
    pub id: String,
    pub name: Option<String>,
    pub emoji: Option<String>,
    pub model: Option<String>,
    pub channels: Vec<String>,
    pub online: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub workspace: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StatusLight {
    pub healthy: bool,
    pub active_agents: u32,
    pub global_default_model: Option<String>,
}

/// Fast status: reads config + quick TCP probe of gateway port.
#[tauri::command]
pub fn get_status_light() -> Result<StatusLight, String> {
    let paths = resolve_paths();
    let cfg = read_openclaw_config(&paths)?;
    let active_agents = cfg
        .get("agents")
        .and_then(|a| a.get("list"))
        .and_then(|a| a.as_array())
        .map(|a| a.len() as u32)
        .unwrap_or(0);
    let global_default_model = cfg
        .pointer("/agents/defaults/model")
        .and_then(read_model_value)
        .or_else(|| cfg.pointer("/agents/default/model").and_then(read_model_value));

    // Quick gateway health: TCP connect to gateway port
    let gateway_port = cfg.pointer("/gateway/port")
        .and_then(Value::as_u64)
        .unwrap_or(8080) as u16;
    let healthy = std::net::TcpStream::connect_timeout(
        &std::net::SocketAddr::from(([127, 0, 0, 1], gateway_port)),
        std::time::Duration::from_millis(500),
    ).is_ok();

    Ok(StatusLight {
        healthy,
        active_agents,
        global_default_model,
    })
}

/// Returns cached catalog instantly without calling CLI. Returns empty if no cache.
#[tauri::command]
pub fn get_cached_model_catalog() -> Result<Vec<ModelCatalogProvider>, String> {
    let paths = resolve_paths();
    let cache_path = model_catalog_cache_path(&paths);
    if let Some(cached) = read_model_catalog_cache(&cache_path) {
        if cached.error.is_none() && !cached.providers.is_empty() {
            return Ok(cached.providers);
        }
    }
    Ok(Vec::new())
}

/// Refresh catalog from CLI and update cache. Returns the fresh catalog.
#[tauri::command]
pub fn refresh_model_catalog() -> Result<Vec<ModelCatalogProvider>, String> {
    let paths = resolve_paths();
    let cfg = read_openclaw_config(&paths)?;
    load_model_catalog(&paths, &cfg)
}

#[tauri::command]
pub fn get_system_status() -> Result<SystemStatus, String> {
    let paths = resolve_paths();
    ensure_dirs(&paths)?;
    let cfg = read_openclaw_config(&paths)?;
    let active_agents = cfg
        .get("agents")
        .and_then(|a| a.get("list"))
        .and_then(|a| a.as_array())
        .map(|a| a.len() as u32)
        .unwrap_or(0);
    let snapshots = list_snapshots(&paths.metadata_path).unwrap_or_default().items.len();
    let model_summary = collect_model_summary(&cfg);
    let channel_summary = collect_channel_summary(&cfg);
    let memory = collect_memory_overview(&paths.base_dir);
    let sessions = collect_session_overview(&paths.base_dir);
    let openclaw_version = resolve_openclaw_version();
    let openclaw_update = check_openclaw_update_cached(&paths, false).unwrap_or_else(|_| OpenclawUpdateCheck {
        installed_version: openclaw_version.clone(),
        latest_version: None,
        upgrade_available: false,
        channel: None,
        details: Some("update status unavailable".into()),
        source: "unknown".into(),
        checked_at: format_timestamp_from_unix(unix_timestamp_secs()),
    });
    Ok(SystemStatus {
        healthy: true,
        config_path: paths.config_path.to_string_lossy().to_string(),
        openclaw_dir: paths.openclaw_dir.to_string_lossy().to_string(),
        clawpal_dir: paths.clawpal_dir.to_string_lossy().to_string(),
        openclaw_version,
        active_agents,
        snapshots,
        channels: channel_summary,
        models: model_summary,
        memory,
        sessions,
        openclaw_update,
    })
}

#[tauri::command]
pub fn list_model_profiles() -> Result<Vec<ModelProfile>, String> {
    let paths = resolve_paths();
    Ok(load_model_profiles(&paths))
}

#[tauri::command]
pub fn list_model_catalog() -> Result<Vec<ModelCatalogProvider>, String> {
    let paths = resolve_paths();
    let cfg = read_openclaw_config(&paths)?;
    load_model_catalog(&paths, &cfg)
}

#[tauri::command]
pub fn check_openclaw_update() -> Result<OpenclawUpdateCheck, String> {
    let paths = resolve_paths();
    check_openclaw_update_cached(&paths, true)
}

#[tauri::command]
pub fn extract_model_profiles_from_config() -> Result<ExtractModelProfilesResult, String> {
    let paths = resolve_paths();
    let cfg = read_openclaw_config(&paths)?;
    let profiles = load_model_profiles(&paths);
    let bindings = collect_model_bindings(&cfg, &profiles);
    let mut created = 0usize;
    let mut reused = 0usize;
    let mut skipped_invalid = 0usize;
    let mut seen = HashSet::new();

    let mut next_profiles = profiles;
    let mut model_profile_map: HashMap<String, String> = HashMap::new();
    for profile in &next_profiles {
        model_profile_map.insert(normalize_model_ref(&profile_to_model_value(profile)), profile.id.clone());
    }

    for binding in bindings {
        let scope_label = match binding.scope.as_str() {
            "global" => "global".to_string(),
            "agent" => format!("agent:{}", binding.scope_id),
            "channel" => format!("channel:{}", binding.scope_id),
            _ => binding.scope_id,
        };
        let Some(model_ref) = binding.model_value else {
            continue;
        };
        let model_ref = normalize_model_ref(&model_ref);
        if model_ref.trim().is_empty() {
            continue;
        }
        if model_profile_map.contains_key(&model_ref) || seen.contains(&model_ref) {
            reused += 1;
            continue;
        }
        let mut parts = model_ref.splitn(2, '/');
        let provider = parts.next().unwrap_or("").trim();
        let model = parts.next().unwrap_or("").trim();
        if provider.is_empty() || model.is_empty() {
            skipped_invalid += 1;
            continue;
        }
        let auth_ref = resolve_auth_ref_for_provider(&cfg, provider)
            .unwrap_or_else(|| format!("{provider}:default"));
        let base_url = resolve_model_provider_base_url(&cfg, provider);
        let profile = ModelProfile {
            id: uuid::Uuid::new_v4().to_string(),
            name: format!("{scope_label} model profile"),
            provider: provider.to_string(),
            model: model.to_string(),
            auth_ref,
            api_key: None,
            base_url,
            description: Some(format!("Extracted from config ({scope_label})")),
            enabled: true,
        };
        let key = profile_to_model_value(&profile);
        model_profile_map.insert(normalize_model_ref(&key), profile.id.clone());
        next_profiles.push(profile);
        seen.insert(model_ref);
        created += 1;
    }

    if created > 0 {
        save_model_profiles(&paths, &next_profiles)?;
    }

    Ok(ExtractModelProfilesResult {
        created,
        reused,
        skipped_invalid,
    })
}

#[tauri::command]
pub fn upsert_model_profile(mut profile: ModelProfile) -> Result<ModelProfile, String> {
    if profile.provider.trim().is_empty() || profile.model.trim().is_empty() {
        return Err("provider and model are required".into());
    }
    if profile.name.trim().is_empty() {
        profile.name = format!("{}/{}", profile.provider, profile.model);
    }
    let has_api_key = profile.api_key.as_ref().is_some_and(|k| !k.trim().is_empty());
    if profile.auth_ref.trim().is_empty() && !has_api_key {
        // Auto-resolve auth ref from openclaw config or env vars
        let paths_tmp = resolve_paths();
        if let Ok(cfg) = read_openclaw_config(&paths_tmp) {
            if let Some(auth_ref) = resolve_auth_ref_for_provider(&cfg, &profile.provider) {
                profile.auth_ref = auth_ref;
            }
        }
        if profile.auth_ref.trim().is_empty() {
            // Try env var convention
            let provider_upper = profile.provider.trim().to_uppercase().replace('-', "_");
            for suffix in ["_API_KEY", "_KEY", "_TOKEN"] {
                let env_name = format!("{provider_upper}{suffix}");
                if std::env::var(&env_name).map(|v| !v.trim().is_empty()).unwrap_or(false) {
                    profile.auth_ref = env_name;
                    break;
                }
            }
        }
        if profile.auth_ref.trim().is_empty() {
            return Err("API key or auth env var is required".into());
        }
    }
    let paths = resolve_paths();
    let mut profiles = load_model_profiles(&paths);
    if profile.id.trim().is_empty() {
        profile.id = uuid::Uuid::new_v4().to_string();
    }
    let id = profile.id.clone();
    if let Some(existing) = profiles.iter_mut().find(|p| p.id == id) {
        *existing = profile.clone();
    } else {
        profiles.push(profile.clone());
    }
    save_model_profiles(&paths, &profiles)?;
    Ok(profile)
}

#[tauri::command]
pub fn delete_model_profile(profile_id: String) -> Result<bool, String> {
    let paths = resolve_paths();
    let mut profiles = load_model_profiles(&paths);
    let before = profiles.len();
    profiles.retain(|p| p.id != profile_id);
    if profiles.len() == before {
        return Ok(false);
    }
    save_model_profiles(&paths, &profiles)?;
    Ok(true)
}

#[tauri::command]
pub fn resolve_provider_auth(provider: String) -> Result<ProviderAuthSuggestion, String> {
    let provider_trimmed = provider.trim();
    if provider_trimmed.is_empty() {
        return Ok(ProviderAuthSuggestion { auth_ref: None, has_key: false, source: String::new() });
    }
    let paths = resolve_paths();
    let cfg = read_openclaw_config(&paths)?;

    // 1. Check openclaw config auth profiles
    if let Some(auth_ref) = resolve_auth_ref_for_provider(&cfg, provider_trimmed) {
        return Ok(ProviderAuthSuggestion {
            auth_ref: Some(auth_ref),
            has_key: true,
            source: "openclaw auth profile".into(),
        });
    }

    // 2. Check env vars
    let provider_upper = provider_trimmed.to_uppercase().replace('-', "_");
    for suffix in ["_API_KEY", "_KEY", "_TOKEN"] {
        let env_name = format!("{provider_upper}{suffix}");
        if std::env::var(&env_name).map(|v| !v.trim().is_empty()).unwrap_or(false) {
            return Ok(ProviderAuthSuggestion {
                auth_ref: Some(env_name),
                has_key: true,
                source: "environment variable".into(),
            });
        }
    }

    // 3. Check existing model profiles for this provider
    let profiles = load_model_profiles(&paths);
    for p in &profiles {
        if p.provider.eq_ignore_ascii_case(provider_trimmed) {
            let key = resolve_profile_api_key(p, &paths.base_dir);
            if !key.is_empty() {
                let auth_ref = if !p.auth_ref.trim().is_empty() {
                    Some(p.auth_ref.clone())
                } else {
                    None
                };
                return Ok(ProviderAuthSuggestion {
                    auth_ref,
                    has_key: true,
                    source: format!("existing profile {}/{}", p.provider, p.model),
                });
            }
        }
    }

    Ok(ProviderAuthSuggestion { auth_ref: None, has_key: false, source: String::new() })
}

#[tauri::command]
pub fn list_channels() -> Result<Vec<ChannelNode>, String> {
    let paths = resolve_paths();
    let cfg = read_openclaw_config(&paths)?;
    let mut nodes = collect_channel_nodes(&cfg);
    enrich_channel_display_names(&paths, &cfg, &mut nodes)?;
    Ok(nodes)
}

#[tauri::command]
pub fn list_channels_minimal() -> Result<Vec<ChannelNode>, String> {
    let paths = resolve_paths();
    let cfg = read_openclaw_config(&paths)?;
    let nodes = collect_channel_nodes(&cfg);
    Ok(nodes)
}

/// Read Discord guild/channels from persistent cache. Fast, no subprocess.
#[tauri::command]
pub fn list_discord_guild_channels() -> Result<Vec<DiscordGuildChannel>, String> {
    let paths = resolve_paths();
    let cache_file = paths.clawpal_dir.join("discord-guild-channels.json");
    if cache_file.exists() {
        let text = fs::read_to_string(&cache_file).map_err(|e| e.to_string())?;
        let entries: Vec<DiscordGuildChannel> = serde_json::from_str(&text).unwrap_or_default();
        return Ok(entries);
    }
    Ok(Vec::new())
}

/// Resolve Discord guild/channel names via openclaw CLI and persist to cache.
#[tauri::command]
pub async fn refresh_discord_guild_channels() -> Result<Vec<DiscordGuildChannel>, String> {
    tauri::async_runtime::spawn_blocking(move || {
        let paths = resolve_paths();
        ensure_dirs(&paths)?;
        let cfg = read_openclaw_config(&paths)?;

        // Extract bot token for Discord API calls
        let bot_token = cfg
            .pointer("/channels/discord/botToken")
            .or_else(|| cfg.pointer("/channels/discord/token"))
            .and_then(Value::as_str)
            .map(|s| s.to_string());

        let guilds = cfg
            .get("channels")
            .and_then(|c| c.get("discord"))
            .and_then(|d| d.get("guilds"))
            .and_then(Value::as_object);

        let Some(guilds) = guilds else {
            return Ok(Vec::new());
        };

        let mut entries: Vec<DiscordGuildChannel> = Vec::new();
        let mut channel_ids: Vec<String> = Vec::new();
        let mut unresolved_guild_ids: Vec<String> = Vec::new();

        for (guild_id, guild_val) in guilds {
            let guild_name = guild_val
                .get("slug")
                .or_else(|| guild_val.get("name"))
                .and_then(Value::as_str)
                .map(|s| s.trim().to_string())
                .filter(|s| !s.is_empty())
                .unwrap_or_else(|| guild_id.clone());

            if guild_name == *guild_id {
                unresolved_guild_ids.push(guild_id.clone());
            }

            if let Some(channels) = guild_val.get("channels").and_then(Value::as_object) {
                for (channel_id, _channel_val) in channels {
                    channel_ids.push(channel_id.clone());
                    entries.push(DiscordGuildChannel {
                        guild_id: guild_id.clone(),
                        guild_name: guild_name.clone(),
                        channel_id: channel_id.clone(),
                        channel_name: channel_id.clone(),
                    });
                }
            }
        }

        // Resolve channel names via openclaw CLI
        if !channel_ids.is_empty() {
            let mut args = vec![
                "channels", "resolve", "--json",
                "--channel", "discord",
                "--kind", "auto",
            ];
            let id_refs: Vec<&str> = channel_ids.iter().map(String::as_str).collect();
            args.extend_from_slice(&id_refs);

            if let Ok(output) = run_openclaw_raw(&args) {
                if let Some(name_map) = parse_resolve_name_map(&output.stdout) {
                    for entry in &mut entries {
                        if let Some(name) = name_map.get(&entry.channel_id) {
                            entry.channel_name = name.clone();
                        }
                    }
                }
            }
        }

        // Resolve guild names via Discord REST API
        if let Some(token) = &bot_token {
            if !unresolved_guild_ids.is_empty() {
                let mut guild_name_map: std::collections::HashMap<String, String> = std::collections::HashMap::new();
                for gid in &unresolved_guild_ids {
                    if let Ok(name) = fetch_discord_guild_name(token, gid) {
                        guild_name_map.insert(gid.clone(), name);
                    }
                }
                for entry in &mut entries {
                    if let Some(name) = guild_name_map.get(&entry.guild_id) {
                        entry.guild_name = name.clone();
                    }
                }
            }
        }

        // Persist to cache
        let cache_file = paths.clawpal_dir.join("discord-guild-channels.json");
        let json = serde_json::to_string_pretty(&entries).map_err(|e| e.to_string())?;
        write_text(&cache_file, &json)?;

        Ok(entries)
    }).await.map_err(|e| e.to_string())?
}

#[tauri::command]
pub fn update_channel_config(
    path: String,
    channel_type: Option<String>,
    mode: Option<String>,
    allowlist: Vec<String>,
    model: Option<String>,
) -> Result<bool, String> {
    if path.trim().is_empty() {
        return Err("channel path is required".into());
    }
    let paths = resolve_paths();
    let mut cfg = read_openclaw_config(&paths)?;
    let current = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
    set_nested_value(&mut cfg, &format!("{path}.type"), channel_type.map(Value::String))?;
    set_nested_value(&mut cfg, &format!("{path}.mode"), mode.map(Value::String))?;
    let allowlist_values = allowlist
        .into_iter()
        .map(Value::String)
        .collect::<Vec<_>>();
    set_nested_value(&mut cfg, &format!("{path}.allowlist"), Some(Value::Array(allowlist_values)))?;
    set_nested_value(&mut cfg, &format!("{path}.model"), model.map(Value::String))?;
    write_config_with_snapshot(&paths, &current, &cfg, "update-channel")?;
    Ok(true)
}

/// List current channel→agent bindings from config.
#[tauri::command]
pub fn list_bindings() -> Result<Vec<Value>, String> {
    let paths = resolve_paths();
    let cfg = read_openclaw_config(&paths)?;
    let bindings = cfg
        .get("bindings")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    Ok(bindings)
}

/// Assign a Discord channel to an agent (modifies the `bindings` array).
/// Pass `agent_id = None` or empty to remove the binding (revert to default agent).
#[tauri::command]
pub fn assign_channel_agent(
    channel_type: String,
    peer_id: String,
    agent_id: Option<String>,
) -> Result<bool, String> {
    let paths = resolve_paths();
    let mut cfg = read_openclaw_config(&paths)?;
    let current = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;

    let bindings = cfg
        .get_mut("bindings")
        .and_then(Value::as_array_mut);

    let agent_id = agent_id
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty());

    if let Some(arr) = bindings {
        // Remove existing binding for this peer
        arr.retain(|b| {
            let m = b.get("match");
            let ch = m.and_then(|m| m.get("channel")).and_then(Value::as_str);
            let pid = m.and_then(|m| m.pointer("/peer/id")).and_then(Value::as_str);
            !(ch == Some(&channel_type) && pid == Some(&peer_id))
        });

        // Add new binding if agent_id is provided
        if let Some(ref aid) = agent_id {
            arr.push(serde_json::json!({
                "agentId": aid,
                "match": {
                    "channel": channel_type,
                    "peer": {
                        "id": peer_id,
                        "kind": "channel"
                    }
                }
            }));
        }
    } else if let Some(ref aid) = agent_id {
        // No bindings array yet — create one
        cfg.as_object_mut()
            .ok_or("config is not an object")?
            .insert("bindings".into(), serde_json::json!([
                {
                    "agentId": aid,
                    "match": {
                        "channel": channel_type,
                        "peer": {
                            "id": peer_id,
                            "kind": "channel"
                        }
                    }
                }
            ]));
    }

    write_config_with_snapshot(&paths, &current, &cfg, "assign-channel-agent")?;
    Ok(true)
}

#[tauri::command]
pub fn delete_channel_node(path: String) -> Result<bool, String> {
    if path.trim().is_empty() {
        return Err("channel path is required".into());
    }
    let paths = resolve_paths();
    let mut cfg = read_openclaw_config(&paths)?;
    let current = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
    let before = cfg.to_string();
    set_nested_value(&mut cfg, &path, None)?;
    if cfg.to_string() == before {
        return Ok(false);
    }
    write_config_with_snapshot(&paths, &current, &cfg, "delete-channel")?;
    Ok(true)
}

#[tauri::command]
pub fn set_global_model(profile_id: Option<String>) -> Result<bool, String> {
    let paths = resolve_paths();
    let mut cfg = read_openclaw_config(&paths)?;
    let current = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
    let model = resolve_profile_model_value(&paths, profile_id)?;
    set_nested_value(
        &mut cfg,
        "agents.defaults.model",
        model.map(Value::String),
    )?;
    write_config_with_snapshot(&paths, &current, &cfg, "set-global-model")?;
    Ok(true)
}

#[tauri::command]
pub fn set_agent_model(agent_id: String, profile_id: Option<String>) -> Result<bool, String> {
    if agent_id.trim().is_empty() {
        return Err("agent id is required".into());
    }
    let paths = resolve_paths();
    let mut cfg = read_openclaw_config(&paths)?;
    let current = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
    let value = resolve_profile_model_value(&paths, profile_id)?;
    set_agent_model_value(&mut cfg, &agent_id, value)?;
    write_config_with_snapshot(&paths, &current, &cfg, "set-agent-model")?;
    Ok(true)
}

#[tauri::command]
pub fn set_channel_model(path: String, profile_id: Option<String>) -> Result<bool, String> {
    if path.trim().is_empty() {
        return Err("channel path is required".into());
    }
    let paths = resolve_paths();
    let mut cfg = read_openclaw_config(&paths)?;
    let current = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
    let value = resolve_profile_model_value(&paths, profile_id)?;
    set_nested_value(&mut cfg, &format!("{path}.model"), value.map(Value::String))?;
    write_config_with_snapshot(&paths, &current, &cfg, "set-channel-model")?;
    Ok(true)
}

#[tauri::command]
pub fn list_model_bindings() -> Result<Vec<ModelBinding>, String> {
    let paths = resolve_paths();
    let cfg = read_openclaw_config(&paths)?;
    let profiles = load_model_profiles(&paths);
    Ok(collect_model_bindings(&cfg, &profiles))
}

#[tauri::command]
pub fn list_agent_ids() -> Result<Vec<String>, String> {
    let paths = resolve_paths();
    let cfg = read_openclaw_config(&paths)?;
    Ok(collect_agent_ids(&cfg))
}

#[tauri::command]
pub fn list_agents_overview() -> Result<Vec<AgentOverview>, String> {
    let paths = resolve_paths();
    let cfg = read_openclaw_config(&paths)?;
    let mut agents = Vec::new();
    let mut seen_ids = std::collections::HashSet::new();

    let default_workspace = cfg.pointer("/agents/defaults/workspace")
        .and_then(Value::as_str)
        .map(|s| expand_tilde(s));

    if let Some(list) = cfg.pointer("/agents/list").and_then(Value::as_array) {
        let channel_nodes = collect_channel_nodes(&cfg);
        for agent in list {
            let id = agent.get("id").and_then(Value::as_str).unwrap_or("agent").to_string();

            // Deduplicate by ID
            if !seen_ids.insert(id.clone()) {
                continue;
            }

            // Read name/emoji from IDENTITY.md in the agent's workspace
            let workspace = agent.get("workspace").and_then(Value::as_str)
                .map(|s| expand_tilde(s))
                .or_else(|| default_workspace.clone());
            let (name, emoji) = workspace.as_ref()
                .and_then(|ws| parse_identity_md(ws))
                .unwrap_or((None, None));

            let model = agent.get("model").and_then(read_model_value)
                .or_else(|| cfg.pointer("/agents/defaults/model").and_then(read_model_value))
                .or_else(|| cfg.pointer("/agents/default/model").and_then(read_model_value));
            let channels: Vec<String> = channel_nodes.iter()
                .map(|ch| ch.path.clone())
                .collect();
            let has_sessions = paths.base_dir.join("agents").join(&id).join("sessions").exists();
            agents.push(AgentOverview {
                id,
                name,
                emoji,
                model,
                channels,
                online: has_sessions,
                workspace,
            });
        }
    }
    Ok(agents)
}

#[tauri::command]
pub fn create_agent(
    agent_id: String,
    model_profile_id: Option<String>,
    independent: Option<bool>,
) -> Result<AgentOverview, String> {
    let agent_id = agent_id.trim().to_string();
    if agent_id.is_empty() {
        return Err("Agent ID is required".into());
    }
    if !agent_id.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
        return Err("Agent ID may only contain letters, numbers, hyphens, and underscores".into());
    }

    let paths = resolve_paths();
    let mut cfg = read_openclaw_config(&paths)?;
    let current = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;

    let existing_ids = collect_agent_ids(&cfg);
    if existing_ids.iter().any(|id| id.eq_ignore_ascii_case(&agent_id)) {
        return Err(format!("Agent '{}' already exists", agent_id));
    }

    // Resolve model value from profile if provided
    let model_value = if let Some(ref pid) = model_profile_id {
        let pid = pid.trim();
        if pid.is_empty() { None } else {
            Some(resolve_profile_model_value(&paths, Some(pid.to_string()))?)
        }
    } else { None };
    let model_display = model_value.flatten();

    // If independent, create a dedicated workspace directory;
    // otherwise inherit the default workspace so the gateway doesn't auto-create one.
    let workspace = if independent.unwrap_or(false) {
        let ws_dir = paths.base_dir.join("workspaces").join(&agent_id);
        fs::create_dir_all(&ws_dir).map_err(|e| e.to_string())?;
        let ws_path = ws_dir.to_string_lossy().to_string();
        Some(ws_path)
    } else {
        cfg.pointer("/agents/defaults/workspace")
            .or_else(|| cfg.pointer("/agents/default/workspace"))
            .and_then(Value::as_str)
            .map(|s| s.to_string())
    };

    // Build agent entry
    let mut agent_obj = serde_json::Map::new();
    agent_obj.insert("id".into(), Value::String(agent_id.clone()));
    if let Some(ref model_str) = model_display {
        agent_obj.insert("model".into(), Value::String(model_str.clone()));
    }
    if let Some(ref ws) = workspace {
        agent_obj.insert("workspace".into(), Value::String(ws.clone()));
    }

    let agents = cfg
        .as_object_mut()
        .ok_or("config is not an object")?
        .entry("agents")
        .or_insert_with(|| Value::Object(serde_json::Map::new()))
        .as_object_mut()
        .ok_or("agents is not an object")?;
    let list = agents
        .entry("list")
        .or_insert_with(|| Value::Array(Vec::new()))
        .as_array_mut()
        .ok_or("agents.list is not an array")?;
    list.push(Value::Object(agent_obj));

    write_config_with_snapshot(&paths, &current, &cfg, "create-agent")?;
    Ok(AgentOverview {
        id: agent_id,
        name: None,
        emoji: None,
        model: model_display,
        channels: vec![],
        online: false,
        workspace,
    })
}

#[tauri::command]
pub fn delete_agent(agent_id: String) -> Result<bool, String> {
    let agent_id = agent_id.trim().to_string();
    if agent_id.is_empty() {
        return Err("Agent ID is required".into());
    }
    if agent_id == "main" {
        return Err("Cannot delete the main agent".into());
    }

    let paths = resolve_paths();
    let mut cfg = read_openclaw_config(&paths)?;
    let current = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;

    let list = cfg
        .pointer_mut("/agents/list")
        .and_then(Value::as_array_mut)
        .ok_or("agents.list not found")?;

    let before = list.len();
    list.retain(|agent| {
        agent.get("id").and_then(Value::as_str) != Some(&agent_id)
    });

    if list.len() == before {
        return Err(format!("Agent '{}' not found", agent_id));
    }

    // Reset any bindings that reference this agent back to "main" (default)
    // so the channel doesn't lose its binding entry entirely.
    if let Some(bindings) = cfg.pointer_mut("/bindings").and_then(Value::as_array_mut) {
        for b in bindings.iter_mut() {
            if b.get("agentId").and_then(Value::as_str) == Some(&agent_id) {
                if let Some(obj) = b.as_object_mut() {
                    obj.insert("agentId".into(), Value::String("main".into()));
                }
            }
        }
    }

    write_config_with_snapshot(&paths, &current, &cfg, "delete-agent")?;
    Ok(true)
}

#[tauri::command]
pub fn setup_agent_identity(
    agent_id: String,
    name: String,
    emoji: Option<String>,
) -> Result<bool, String> {
    let agent_id = agent_id.trim().to_string();
    let name = name.trim().to_string();
    if agent_id.is_empty() {
        return Err("Agent ID is required".into());
    }
    if name.is_empty() {
        return Err("Name is required".into());
    }

    let paths = resolve_paths();
    let cfg = read_openclaw_config(&paths)?;

    // Find the agent's workspace
    let agents_list = cfg.pointer("/agents/list")
        .and_then(Value::as_array)
        .ok_or("agents.list not found")?;

    let agent = agents_list.iter()
        .find(|a| a.get("id").and_then(Value::as_str) == Some(&agent_id))
        .ok_or_else(|| format!("Agent '{}' not found", agent_id))?;

    let default_workspace = cfg.pointer("/agents/defaults/workspace")
        .or_else(|| cfg.pointer("/agents/default/workspace"))
        .and_then(Value::as_str)
        .map(|s| expand_tilde(s));

    let workspace = agent.get("workspace")
        .and_then(Value::as_str)
        .map(|s| expand_tilde(s))
        .or(default_workspace)
        .ok_or_else(|| format!("Agent '{}' has no workspace configured", agent_id))?;

    // Build IDENTITY.md content
    let mut content = format!("- Name: {}\n", name);
    if let Some(ref e) = emoji {
        let e = e.trim();
        if !e.is_empty() {
            content.push_str(&format!("- Emoji: {}\n", e));
        }
    }

    let ws_path = std::path::Path::new(&workspace);
    fs::create_dir_all(ws_path).map_err(|e| format!("Failed to create workspace dir: {}", e))?;
    let identity_path = ws_path.join("IDENTITY.md");
    fs::write(&identity_path, &content)
        .map_err(|e| format!("Failed to write IDENTITY.md: {}", e))?;

    Ok(true)
}

fn expand_tilde(path: &str) -> String {
    if path.starts_with("~/") {
        if let Some(home) = std::env::var("HOME").ok() {
            return format!("{}{}", home, &path[1..]);
        }
    }
    path.to_string()
}

fn parse_identity_content(content: &str) -> (Option<String>, Option<String>) {
    let mut name = None;
    let mut emoji = None;
    for line in content.lines() {
        let trimmed = line.trim().trim_start_matches('-').trim();
        // Handle both "Name: X" and "**Name:** X"
        let trimmed = trimmed.replace("**", "");
        if let Some(val) = trimmed.strip_prefix("Name:") {
            let val = val.trim();
            if !val.is_empty() {
                name = Some(val.to_string());
            }
        } else if let Some(val) = trimmed.strip_prefix("Emoji:") {
            let val = val.trim();
            if !val.is_empty() {
                emoji = Some(val.to_string());
            }
        }
    }
    (name, emoji)
}

fn parse_identity_md(workspace: &str) -> Option<(Option<String>, Option<String>)> {
    let identity_path = std::path::Path::new(workspace).join("IDENTITY.md");
    let content = fs::read_to_string(&identity_path).ok()?;
    Some(parse_identity_content(&content))
}

#[tauri::command]
pub fn list_memory_files() -> Result<Vec<MemoryFile>, String> {
    let paths = resolve_paths();
    list_memory_files_detailed(&paths.base_dir.join("memory"))
}

#[tauri::command]
pub fn delete_memory_file(path: String) -> Result<bool, String> {
    let paths = resolve_paths();
    let root = paths.base_dir.join("memory");
    let target = resolve_child_path(&root, &path)?;
    if !target.exists() {
        return Ok(false);
    }
    if !target.is_file() {
        return Err("target is not a file".into());
    }
    fs::remove_file(&target).map_err(|e| e.to_string())?;
    Ok(true)
}

#[tauri::command]
pub fn clear_memory() -> Result<usize, String> {
    let paths = resolve_paths();
    let root = paths.base_dir.join("memory");
    if !root.exists() {
        return Ok(0);
    }
    let count = count_files_recursive(&root);
    fs::remove_dir_all(&root).map_err(|e| e.to_string())?;
    fs::create_dir_all(&root).map_err(|e| e.to_string())?;
    Ok(count)
}

#[tauri::command]
pub fn list_session_files() -> Result<Vec<SessionFile>, String> {
    let paths = resolve_paths();
    list_session_files_detailed(&paths.base_dir)
}

#[tauri::command]
pub fn delete_session_file(path: String) -> Result<bool, String> {
    let paths = resolve_paths();
    let target = resolve_child_path(&paths.base_dir, &path)?;
    if !target.exists() {
        return Ok(false);
    }
    if !target.is_file() {
        return Err("target is not a file".into());
    }
    fs::remove_file(&target).map_err(|e| e.to_string())?;
    Ok(true)
}

#[tauri::command]
pub fn clear_all_sessions() -> Result<usize, String> {
    let paths = resolve_paths();
    clear_agent_and_global_sessions(&paths.base_dir.join("agents"), None)
}

#[tauri::command]
pub fn clear_agent_sessions(agent_id: String) -> Result<usize, String> {
    if agent_id.trim().is_empty() {
        return Err("agent id is required".into());
    }
    if agent_id.contains("..") || agent_id.contains('/') || agent_id.contains('\\') {
        return Err("invalid agent id".into());
    }
    let paths = resolve_paths();
    clear_agent_and_global_sessions(&paths.base_dir.join("agents"), Some(agent_id.as_str()))
}

#[tauri::command]
pub async fn analyze_sessions() -> Result<Vec<AgentSessionAnalysis>, String> {
    tauri::async_runtime::spawn_blocking(|| {
        analyze_sessions_sync()
    })
    .await
    .map_err(|e| e.to_string())?
}

fn analyze_sessions_sync() -> Result<Vec<AgentSessionAnalysis>, String> {
    let paths = resolve_paths();
    let agents_root = paths.base_dir.join("agents");
    if !agents_root.exists() {
        return Ok(Vec::new());
    }

    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis() as f64;

    let mut results: Vec<AgentSessionAnalysis> = Vec::new();
    let entries = fs::read_dir(&agents_root).map_err(|e| e.to_string())?;

    for entry in entries.flatten() {
        let entry_path = entry.path();
        if !entry_path.is_dir() {
            continue;
        }
        let agent = entry.file_name().to_string_lossy().to_string();

        // Load sessions.json metadata for this agent
        let sessions_json_path = entry_path.join("sessions").join("sessions.json");
        let sessions_meta: HashMap<String, Value> = if sessions_json_path.exists() {
            let text = fs::read_to_string(&sessions_json_path).unwrap_or_default();
            serde_json::from_str(&text).unwrap_or_default()
        } else {
            HashMap::new()
        };

        // Build sessionId -> metadata lookup
        let mut meta_by_id: HashMap<String, &Value> = HashMap::new();
        for (_key, val) in &sessions_meta {
            if let Some(sid) = val.get("sessionId").and_then(Value::as_str) {
                meta_by_id.insert(sid.to_string(), val);
            }
        }

        let mut agent_sessions: Vec<SessionAnalysis> = Vec::new();

        for (kind_name, dir_name) in [("sessions", "sessions"), ("archive", "sessions_archive")] {
            let dir = entry_path.join(dir_name);
            if !dir.exists() {
                continue;
            }
            let files = match fs::read_dir(&dir) {
                Ok(f) => f,
                Err(_) => continue,
            };
            for file_entry in files.flatten() {
                let file_path = file_entry.path();
                let fname = file_entry.file_name().to_string_lossy().to_string();
                if !fname.ends_with(".jsonl") {
                    continue;
                }

                let metadata = match file_entry.metadata() {
                    Ok(m) => m,
                    Err(_) => continue,
                };
                let size_bytes = metadata.len();

                // Extract session ID from filename (e.g. "abc123.jsonl" or "abc123-topic-456.jsonl")
                let session_id = fname.trim_end_matches(".jsonl").to_string();

                // Parse JSONL to count messages
                let mut message_count = 0usize;
                let mut user_message_count = 0usize;
                let mut assistant_message_count = 0usize;
                let mut last_activity: Option<String> = None;

                if let Ok(file) = fs::File::open(&file_path) {
                    let reader = BufReader::new(file);
                    for line in reader.lines() {
                        let line = match line {
                            Ok(l) => l,
                            Err(_) => continue,
                        };
                        if line.trim().is_empty() {
                            continue;
                        }
                        let obj: Value = match serde_json::from_str(&line) {
                            Ok(v) => v,
                            Err(_) => continue,
                        };
                        if obj.get("type").and_then(Value::as_str) == Some("message") {
                            message_count += 1;
                            if let Some(ts) = obj.get("timestamp").and_then(Value::as_str) {
                                last_activity = Some(ts.to_string());
                            }
                            let role = obj.pointer("/message/role").and_then(Value::as_str);
                            match role {
                                Some("user") => user_message_count += 1,
                                Some("assistant") => assistant_message_count += 1,
                                _ => {}
                            }
                        }
                    }
                }

                // Look up metadata from sessions.json
                // For topic files like "abc-topic-123", try the base session ID "abc"
                let base_id = if session_id.contains("-topic-") {
                    session_id.split("-topic-").next().unwrap_or(&session_id)
                } else {
                    &session_id
                };
                let meta = meta_by_id.get(base_id);

                let total_tokens = meta
                    .and_then(|m| m.get("totalTokens"))
                    .and_then(Value::as_u64)
                    .unwrap_or(0);
                let model = meta
                    .and_then(|m| m.get("model"))
                    .and_then(Value::as_str)
                    .map(|s| s.to_string());
                let updated_at = meta
                    .and_then(|m| m.get("updatedAt"))
                    .and_then(Value::as_f64)
                    .unwrap_or(0.0);

                let age_days = if updated_at > 0.0 {
                    (now - updated_at) / (1000.0 * 60.0 * 60.0 * 24.0)
                } else {
                    // Fall back to file modification time
                    metadata.modified().ok()
                        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
                        .map(|d| (now - d.as_millis() as f64) / (1000.0 * 60.0 * 60.0 * 24.0))
                        .unwrap_or(0.0)
                };

                // Classify
                let category = if size_bytes < 500 || message_count == 0 {
                    "empty"
                } else if user_message_count <= 1 && age_days > 7.0 {
                    "low_value"
                } else {
                    "valuable"
                };

                agent_sessions.push(SessionAnalysis {
                    agent: agent.clone(),
                    session_id,
                    file_path: file_path.to_string_lossy().to_string(),
                    size_bytes,
                    message_count,
                    user_message_count,
                    assistant_message_count,
                    last_activity,
                    age_days,
                    total_tokens,
                    model,
                    category: category.to_string(),
                    kind: kind_name.to_string(),
                });
            }
        }

        // Sort: empty first, then low_value, then valuable; within each by age descending
        agent_sessions.sort_by(|a, b| {
            let cat_order = |c: &str| match c {
                "empty" => 0,
                "low_value" => 1,
                _ => 2,
            };
            cat_order(&a.category).cmp(&cat_order(&b.category))
                .then(b.age_days.partial_cmp(&a.age_days).unwrap_or(std::cmp::Ordering::Equal))
        });

        let total_files = agent_sessions.len();
        let total_size_bytes = agent_sessions.iter().map(|s| s.size_bytes).sum();
        let empty_count = agent_sessions.iter().filter(|s| s.category == "empty").count();
        let low_value_count = agent_sessions.iter().filter(|s| s.category == "low_value").count();
        let valuable_count = agent_sessions.iter().filter(|s| s.category == "valuable").count();

        if total_files > 0 {
            results.push(AgentSessionAnalysis {
                agent,
                total_files,
                total_size_bytes,
                empty_count,
                low_value_count,
                valuable_count,
                sessions: agent_sessions,
            });
        }
    }

    results.sort_by(|a, b| b.total_size_bytes.cmp(&a.total_size_bytes));
    Ok(results)
}

#[tauri::command]
pub async fn delete_sessions_by_ids(agent_id: String, session_ids: Vec<String>) -> Result<usize, String> {
    tauri::async_runtime::spawn_blocking(move || {
        delete_sessions_by_ids_sync(&agent_id, &session_ids)
    })
    .await
    .map_err(|e| e.to_string())?
}

fn delete_sessions_by_ids_sync(agent_id: &str, session_ids: &[String]) -> Result<usize, String> {
    if agent_id.trim().is_empty() {
        return Err("agent id is required".into());
    }
    if agent_id.contains("..") || agent_id.contains('/') || agent_id.contains('\\') {
        return Err("invalid agent id".into());
    }
    let paths = resolve_paths();
    let agent_dir = paths.base_dir.join("agents").join(agent_id);

    let mut deleted = 0usize;

    // Search in both sessions and sessions_archive
    let dirs = ["sessions", "sessions_archive"];

    for sid in session_ids {
        if sid.contains("..") || sid.contains('/') || sid.contains('\\') {
            continue;
        }
        for dir_name in &dirs {
            let dir = agent_dir.join(dir_name);
            if !dir.exists() {
                continue;
            }
            let jsonl_path = dir.join(format!("{}.jsonl", sid));
            if jsonl_path.exists() {
                if fs::remove_file(&jsonl_path).is_ok() {
                    deleted += 1;
                }
            }
            // Also clean up related files (topic files, .lock, .deleted.*)
            if let Ok(entries) = fs::read_dir(&dir) {
                for entry in entries.flatten() {
                    let fname = entry.file_name().to_string_lossy().to_string();
                    if fname.starts_with(sid.as_str()) && fname != format!("{}.jsonl", sid) {
                        let _ = fs::remove_file(entry.path());
                    }
                }
            }
        }
    }

    // Remove entries from sessions.json (in sessions dir)
    let sessions_json_path = agent_dir.join("sessions").join("sessions.json");
    if sessions_json_path.exists() {
        if let Ok(text) = fs::read_to_string(&sessions_json_path) {
            if let Ok(mut data) = serde_json::from_str::<serde_json::Map<String, Value>>(&text) {
                let id_set: HashSet<&str> = session_ids.iter().map(String::as_str).collect();
                data.retain(|_key, val| {
                    let sid = val.get("sessionId").and_then(Value::as_str).unwrap_or("");
                    !id_set.contains(sid)
                });
                let _ = fs::write(&sessions_json_path, serde_json::to_string(&data).unwrap_or_default());
            }
        }
    }

    Ok(deleted)
}

#[tauri::command]
pub async fn preview_session(agent_id: String, session_id: String) -> Result<Vec<Value>, String> {
    tauri::async_runtime::spawn_blocking(move || {
        preview_session_sync(&agent_id, &session_id)
    })
    .await
    .map_err(|e| e.to_string())?
}

fn preview_session_sync(agent_id: &str, session_id: &str) -> Result<Vec<Value>, String> {
    if agent_id.contains("..") || agent_id.contains('/') || agent_id.contains('\\') {
        return Err("invalid agent id".into());
    }
    if session_id.contains("..") || session_id.contains('/') || session_id.contains('\\') {
        return Err("invalid session id".into());
    }
    let paths = resolve_paths();
    let agent_dir = paths.base_dir.join("agents").join(agent_id);
    let jsonl_name = format!("{}.jsonl", session_id);

    // Search in both sessions and sessions_archive
    let file_path = ["sessions", "sessions_archive"]
        .iter()
        .map(|dir| agent_dir.join(dir).join(&jsonl_name))
        .find(|p| p.exists());

    let file_path = match file_path {
        Some(p) => p,
        None => return Ok(Vec::new()),
    };

    let file = fs::File::open(&file_path).map_err(|e| e.to_string())?;
    let reader = BufReader::new(file);
    let mut messages: Vec<Value> = Vec::new();

    for line in reader.lines() {
        let line = match line {
            Ok(l) => l,
            Err(_) => continue,
        };
        if line.trim().is_empty() {
            continue;
        }
        let obj: Value = match serde_json::from_str(&line) {
            Ok(v) => v,
            Err(_) => continue,
        };
        if obj.get("type").and_then(Value::as_str) == Some("message") {
            let role = obj.pointer("/message/role").and_then(Value::as_str).unwrap_or("unknown");
            let content = obj.pointer("/message/content")
                .map(|c| {
                    if let Some(arr) = c.as_array() {
                        arr.iter()
                            .filter_map(|item| item.get("text").and_then(Value::as_str))
                            .collect::<Vec<_>>()
                            .join("\n")
                    } else if let Some(s) = c.as_str() {
                        s.to_string()
                    } else {
                        String::new()
                    }
                })
                .unwrap_or_default();
            messages.push(serde_json::json!({
                "role": role,
                "content": content,
            }));
        }
    }

    Ok(messages)
}

#[tauri::command]
pub fn list_recipes(source: Option<String>) -> Result<Vec<crate::recipe::Recipe>, String> {
    let paths = resolve_paths();
    let default_path = paths.clawpal_dir.join("recipes").join("recipes.json");
    Ok(load_recipes_with_fallback(source, &default_path))
}

#[tauri::command]
pub fn apply_config_patch(
    patch_template: String,
    params: Map<String, Value>,
) -> Result<ApplyResult, String> {
    let paths = resolve_paths();
    ensure_dirs(&paths)?;
    let current = read_openclaw_config(&paths)?;
    let current_text = serde_json::to_string_pretty(&current).map_err(|e| e.to_string())?;
    let snapshot = add_snapshot(
        &paths.history_dir,
        &paths.metadata_path,
        Some("config-patch".into()),
        "apply",
        true,
        &current_text,
        None,
    )?;
    let (candidate, _changes) = build_candidate_config_from_template(&current, &patch_template, &params)?;
    write_json(&paths.config_path, &candidate)?;
    Ok(ApplyResult {
        ok: true,
        snapshot_id: Some(snapshot.id),
        config_path: paths.config_path.to_string_lossy().to_string(),
        backup_path: Some(snapshot.config_path),
        warnings: Vec::new(),
        errors: Vec::new(),
    })
}

#[tauri::command]
pub async fn restart_gateway() -> Result<bool, String> {
    tauri::async_runtime::spawn_blocking(move || {
        run_openclaw_raw(&["gateway", "restart"])?;
        Ok(true)
    }).await.map_err(|e| e.to_string())?
}

#[tauri::command]
pub fn list_history(limit: usize, offset: usize) -> Result<HistoryPage, String> {
    let paths = resolve_paths();
    let index = list_snapshots(&paths.metadata_path)?;
    let items = index
        .items
        .into_iter()
        .skip(offset)
        .take(limit)
        .map(|item| HistoryItem {
            id: item.id,
            recipe_id: item.recipe_id,
            created_at: item.created_at,
            source: item.source,
            can_rollback: item.can_rollback,
            rollback_of: item.rollback_of,
        })
        .collect();
    Ok(HistoryPage { items })
}

#[tauri::command]
pub fn preview_rollback(snapshot_id: String) -> Result<PreviewResult, String> {
    let paths = resolve_paths();
    let index = list_snapshots(&paths.metadata_path)?;
    let target = index
        .items
        .into_iter()
        .find(|s| s.id == snapshot_id)
        .ok_or_else(|| "snapshot not found".to_string())?;
    if !target.can_rollback {
        return Err("snapshot is not rollbackable".to_string());
    }

    let current = read_openclaw_config(&paths)?;
    let target_text = read_snapshot(&target.config_path)?;
    let target_json: Value = json5::from_str(&target_text).unwrap_or(Value::Object(Default::default()));
    let before_text = serde_json::to_string_pretty(&current).unwrap_or_else(|_| "{}".into());
    let after_text = serde_json::to_string_pretty(&target_json).unwrap_or_else(|_| "{}".into());
    Ok(PreviewResult {
        recipe_id: "rollback".into(),
        diff: format_diff(&current, &target_json),
        config_before: before_text,
        config_after: after_text,
        changes: collect_change_paths(&current, &target_json),
        overwrites_existing: true,
        can_rollback: true,
        impact_level: "medium".into(),
        warnings: vec!["Rollback will replace current configuration".into()],
    })
}

#[tauri::command]
pub fn rollback(snapshot_id: String) -> Result<ApplyResult, String> {
    let paths = resolve_paths();
    ensure_dirs(&paths)?;
    let index = list_snapshots(&paths.metadata_path)?;
    let target = index
        .items
        .into_iter()
        .find(|s| s.id == snapshot_id)
        .ok_or_else(|| "snapshot not found".to_string())?;
    if !target.can_rollback {
        return Err("snapshot is not rollbackable".to_string());
    }
    let target_text = read_snapshot(&target.config_path)?;
    let backup = read_openclaw_config(&paths)?;
    let backup_text = serde_json::to_string_pretty(&backup).map_err(|e| e.to_string())?;
    let _ = add_snapshot(
        &paths.history_dir,
        &paths.metadata_path,
        target.recipe_id.clone(),
        "rollback",
        true,
        &backup_text,
        Some(target.id.clone()),
    )?;
    write_text(&paths.config_path, &target_text)?;
    Ok(ApplyResult {
        ok: true,
        snapshot_id: Some(target.id),
        config_path: paths.config_path.to_string_lossy().to_string(),
        backup_path: None,
        warnings: vec!["rolled back".into()],
        errors: Vec::new(),
    })
}

#[tauri::command]
pub fn run_doctor_command() -> Result<DoctorReport, String> {
    let paths = resolve_paths();
    Ok(run_doctor(&paths))
}

#[tauri::command]
pub fn fix_issues(ids: Vec<String>) -> Result<FixResult, String> {
    let paths = resolve_paths();
    let issues = run_doctor(&paths);
    let mut fixable = Vec::new();
    for issue in issues.issues {
        if ids.contains(&issue.id) && issue.auto_fixable {
            fixable.push(issue.id);
        }
    }
    let auto_applied = apply_auto_fixes(&paths, &fixable);
    let mut remaining = Vec::new();
    let mut applied = Vec::new();
    for id in ids {
        if fixable.contains(&id) && auto_applied.iter().any(|x| x == &id) {
            applied.push(id);
        } else {
            remaining.push(id);
        }
    }
    Ok(FixResult {
        ok: true,
        applied,
        remaining_issues: remaining,
    })
}

fn collect_model_summary(cfg: &Value) -> ModelSummary {
    let global_default_model = cfg
        .pointer("/agents/defaults/model")
        .and_then(|value| read_model_value(value))
        .or_else(|| cfg.pointer("/agents/default/model").and_then(|value| read_model_value(value)));

    let mut agent_overrides = Vec::new();
    if let Some(agents) = cfg.pointer("/agents/list").and_then(Value::as_array) {
        for agent in agents {
            if let Some(model_value) = agent.get("model").and_then(read_model_value) {
                let should_emit = global_default_model
                    .as_ref()
                    .map(|global| global != &model_value)
                    .unwrap_or(true);
                if should_emit {
                    let id = agent
                        .get("id")
                        .and_then(Value::as_str)
                        .unwrap_or("agent");
                    agent_overrides.push(format!("{id} => {model_value}"));
                }
            }
        }
    }
    ModelSummary {
        global_default_model,
        agent_overrides,
        channel_overrides: collect_channel_model_overrides(cfg),
    }
}

fn run_external_command_raw(parts: &[&str]) -> Result<OpenclawCommandOutput, String> {
    if parts.is_empty() {
        return Err("no command specified".into());
    }
    let mut command = Command::new(parts[0]);
    if parts.len() > 1 {
        command.args(&parts[1..]);
    }
    let output = command
        .output()
        .map_err(|error| format!("failed to run {}: {error}", parts[0]))?;
    let exit_code = output.status.code().unwrap_or(-1);
    Ok(OpenclawCommandOutput {
        stdout: String::from_utf8_lossy(&output.stdout).trim_end().to_string(),
        stderr: String::from_utf8_lossy(&output.stderr).trim_end().to_string(),
        exit_code,
    })
}

fn run_openclaw_raw(args: &[&str]) -> Result<OpenclawCommandOutput, String> {
    run_openclaw_raw_timeout(args, None)
}

fn run_openclaw_raw_timeout(args: &[&str], timeout_secs: Option<u64>) -> Result<OpenclawCommandOutput, String> {
    let mut child = Command::new("openclaw")
        .args(args)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
        .map_err(|error| format!("failed to run openclaw: {error}"))?;

    if let Some(secs) = timeout_secs {
        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(secs);
        loop {
            match child.try_wait().map_err(|e| e.to_string())? {
                Some(status) => {
                    let mut stdout_buf = Vec::new();
                    let mut stderr_buf = Vec::new();
                    if let Some(mut out) = child.stdout.take() {
                        std::io::Read::read_to_end(&mut out, &mut stdout_buf).ok();
                    }
                    if let Some(mut err) = child.stderr.take() {
                        std::io::Read::read_to_end(&mut err, &mut stderr_buf).ok();
                    }
                    let exit_code = status.code().unwrap_or(-1);
                    let result = OpenclawCommandOutput {
                        stdout: String::from_utf8_lossy(&stdout_buf).trim_end().to_string(),
                        stderr: String::from_utf8_lossy(&stderr_buf).trim_end().to_string(),
                        exit_code,
                    };
                    if exit_code != 0 {
                        let details = if !result.stderr.is_empty() {
                            result.stderr.clone()
                        } else {
                            result.stdout.clone()
                        };
                        return Err(format!("openclaw command failed ({exit_code}): {details}"));
                    }
                    return Ok(result);
                }
                None => {
                    if std::time::Instant::now() >= deadline {
                        let _ = child.kill();
                        return Err(format!(
                            "Command timed out after {secs}s. The gateway may still be restarting in the background."
                        ));
                    }
                    std::thread::sleep(std::time::Duration::from_millis(250));
                }
            }
        }
    } else {
        let output = child
            .wait_with_output()
            .map_err(|error| format!("failed to run openclaw: {error}"))?;
        let exit_code = output.status.code().unwrap_or(-1);
        let result = OpenclawCommandOutput {
            stdout: String::from_utf8_lossy(&output.stdout).trim_end().to_string(),
            stderr: String::from_utf8_lossy(&output.stderr).trim_end().to_string(),
            exit_code,
        };
        if exit_code != 0 {
            let details = if !result.stderr.is_empty() {
                result.stderr.clone()
            } else {
                result.stdout.clone()
            };
            return Err(format!("openclaw command failed ({exit_code}): {details}"));
        }
        Ok(result)
    }
}

/// Strip leading non-JSON lines from CLI output (plugin logs, ANSI codes, etc.)
fn extract_json_from_output(raw: &str) -> Option<&str> {
    let start = raw.find('{').or_else(|| raw.find('['))?;
    Some(&raw[start..])
}

/// Extract the last JSON array from CLI output that may contain ANSI codes and plugin logs.
/// Scans from the end to find the last `]`, then finds its matching `[`.
fn extract_last_json_array(raw: &str) -> Option<&str> {
    let bytes = raw.as_bytes();
    let end = bytes.iter().rposition(|&b| b == b']')?;
    let mut depth = 0;
    for i in (0..=end).rev() {
        match bytes[i] {
            b']' => depth += 1,
            b'[' => {
                depth -= 1;
                if depth == 0 {
                    return Some(&raw[i..=end]);
                }
            }
            _ => {}
        }
    }
    None
}

/// Parse `openclaw channels resolve --json` output into a map of id -> name.
fn parse_resolve_name_map(stdout: &str) -> Option<HashMap<String, String>> {
    let json_str = extract_last_json_array(stdout)?;
    let parsed: Vec<Value> = serde_json::from_str(json_str).ok()?;
    let mut map = HashMap::new();
    for item in parsed {
        let resolved = item.get("resolved").and_then(Value::as_bool).unwrap_or(false);
        if !resolved {
            continue;
        }
        if let (Some(input), Some(name)) = (
            item.get("input").and_then(Value::as_str),
            item.get("name").and_then(Value::as_str),
        ) {
            let name = name.trim().to_string();
            if !name.is_empty() {
                map.insert(input.to_string(), name);
            }
        }
    }
    Some(map)
}

fn extract_version_from_text(input: &str) -> Option<String> {
    let re = regex::Regex::new(r"\d+\.\d+(?:\.\d+){1,3}(?:[-+._a-zA-Z0-9]*)?").ok()?;
    re.find(input).map(|mat| mat.as_str().to_string())
}

fn compare_semver(installed: &str, latest: Option<&str>) -> bool {
    let installed = normalize_semver_components(installed);
    let latest = latest.and_then(normalize_semver_components);
    let (mut installed, mut latest) = match (installed, latest) {
        (Some(installed), Some(latest)) => (installed, latest),
        _ => return false,
    };

    let len = installed.len().max(latest.len());
    while installed.len() < len {
        installed.push(0);
    }
    while latest.len() < len {
        latest.push(0);
    }
    installed < latest
}

fn normalize_semver_components(raw: &str) -> Option<Vec<u32>> {
    let mut parts = Vec::new();
    for bit in raw.split('.') {
        let filtered = bit.trim_start_matches(|c: char| c == 'v' || c == 'V');
        let head = filtered.split(|c: char| !c.is_ascii_digit()).next().unwrap_or("");
        if head.is_empty() {
            continue;
        }
        parts.push(head.parse::<u32>().ok()?);
    }
    if parts.is_empty() {
        return None;
    }
    Some(parts)
}

fn unix_timestamp_secs() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |delta| delta.as_secs())
}

fn format_timestamp_from_unix(timestamp: u64) -> String {
    let Some(utc) = chrono::DateTime::<chrono::Utc>::from_timestamp(timestamp as i64, 0) else {
        return "unknown".into();
    };
    utc.to_rfc3339()
}

fn openclaw_update_cache_path(paths: &crate::models::OpenClawPaths) -> PathBuf {
    paths.clawpal_dir.join("openclaw-update-cache.json")
}

fn read_openclaw_update_cache(
    path: &Path,
) -> Option<OpenclawUpdateCache> {
    let text = fs::read_to_string(path).ok()?;
    serde_json::from_str::<OpenclawUpdateCache>(&text).ok()
}

fn save_openclaw_update_cache(
    path: &Path,
    cache: &OpenclawUpdateCache,
) -> Result<(), String> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|error| error.to_string())?;
    }
    let text = serde_json::to_string_pretty(cache).map_err(|error| error.to_string())?;
    write_text(path, &text)
}

fn read_model_catalog_cache(path: &Path) -> Option<ModelCatalogProviderCache> {
    let text = fs::read_to_string(path).ok()?;
    serde_json::from_str::<ModelCatalogProviderCache>(&text).ok()
}

fn save_model_catalog_cache(
    path: &Path,
    cache: &ModelCatalogProviderCache,
) -> Result<(), String> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|error| error.to_string())?;
    }
    let text = serde_json::to_string_pretty(cache).map_err(|error| error.to_string())?;
    write_text(path, &text)
}

fn model_catalog_cache_path(paths: &crate::models::OpenClawPaths) -> PathBuf {
    paths.clawpal_dir.join("model-catalog-cache.json")
}

fn normalize_model_ref(raw: &str) -> String {
    raw.trim().to_lowercase().replace('\\', "/")
}

fn resolve_openclaw_version() -> String {
    match run_openclaw_raw(&["--version"]) {
        Ok(output) => extract_version_from_text(&output.stdout).unwrap_or_else(|| "unknown".into()),
        Err(_) => "unknown".into(),
    }
}

fn check_openclaw_update_cached(paths: &crate::models::OpenClawPaths, force: bool) -> Result<OpenclawUpdateCheck, String> {
    let cache_path = openclaw_update_cache_path(paths);
    let now = unix_timestamp_secs();
    if !force {
        if let Some(cached) = read_openclaw_update_cache(&cache_path) {
            if now.saturating_sub(cached.checked_at) < cached.ttl_seconds {
                let installed_version = cached.installed_version.unwrap_or_else(resolve_openclaw_version);
                let upgrade_available = compare_semver(&installed_version, cached.latest_version.as_deref());
                return Ok(OpenclawUpdateCheck {
                    installed_version,
                    latest_version: cached.latest_version,
                    upgrade_available,
                    channel: cached.channel,
                    details: cached.details,
                    source: cached.source,
                    checked_at: format_timestamp_from_unix(now),
                });
            }
        }
    }

    let installed_version = resolve_openclaw_version();
    let (latest_version, channel, details, source, upgrade_available) = detect_openclaw_update_cached(&installed_version)
        .unwrap_or((None, None, Some("failed to detect update status".into()), "openclaw-command".into(), false));
    let checked_at = format_timestamp_from_unix(now);
    let cache = OpenclawUpdateCache {
        checked_at: now,
        latest_version: latest_version.clone(),
        channel,
        details: details.clone(),
        source: source.clone(),
        installed_version: Some(installed_version.clone()),
        ttl_seconds: 60 * 60 * 6,
    };
    save_openclaw_update_cache(&cache_path, &cache)?;
    let upgrade = compare_semver(&installed_version, latest_version.as_deref());
    Ok(OpenclawUpdateCheck {
        installed_version,
        latest_version,
        upgrade_available: upgrade || upgrade_available,
        channel: cache.channel,
        details,
        source,
        checked_at,
    })
}

fn detect_openclaw_update_cached(installed_version: &str) -> Option<(Option<String>, Option<String>, Option<String>, String, bool)> {
    let output = run_openclaw_raw(&["update", "status"]).ok()?;
    if let Some((latest_version, channel, details, upgrade_available)) =
        parse_openclaw_update_json(&output.stdout, installed_version)
    {
        return Some((latest_version, Some(channel), Some(details), "openclaw update status --json".into(), upgrade_available));
    }
    let parsed = parse_openclaw_update_text(&output.stdout);
    if let Some((latest_version, channel, details)) = parsed {
        let source = "openclaw update status".into();
        let available = latest_version
            .as_ref()
            .is_some_and(|latest| compare_semver(installed_version, Some(latest)));
        return Some((latest_version, Some(channel), Some(details), source, available));
    }
    let latest_version = query_openclaw_latest_npm().ok().flatten();
    let details = latest_version
        .as_ref()
        .map(|value| format!("npm latest {value}"))
        .unwrap_or_else(|| "update status not available".into());
    let upgrade = latest_version
        .as_ref()
        .is_some_and(|latest| compare_semver(installed_version, Some(latest.as_str())));
    Some((latest_version, None, Some(details), "npm".into(), upgrade))
}

fn parse_openclaw_update_json(raw: &str, installed_version: &str) -> Option<(Option<String>, String, String, bool)> {
    let json_str = extract_json_from_output(raw)?;
    let payload: Value = serde_json::from_str(json_str).ok()?;
    let channel = payload
        .pointer("/channel/value")
        .and_then(Value::as_str)
        .unwrap_or("unknown")
        .to_string();

    let latest_from_update = payload
        .pointer("/update/registry/latestVersion")
        .and_then(Value::as_str)
        .map(|value| value.to_string());
    let latest = payload
        .pointer("/availability/latestVersion")
        .and_then(Value::as_str)
        .map(|value| value.to_string())
        .or(latest_from_update);
    let has_update = payload
        .pointer("/availability/available")
        .and_then(Value::as_bool)
        .unwrap_or(false);

    let details = payload
        .pointer("/availability/latestVersion")
        .and_then(Value::as_str)
        .map(|value| format!("npm latest {value}"))
        .or_else(|| {
            if has_update {
                Some("update available".into())
            } else {
                Some("up to date".into())
            }
        })
        .unwrap_or_else(|| "update status unavailable".into());

    let upgrade_available = if let Some(latest_version) = latest.as_deref() {
        compare_semver(installed_version, Some(latest_version))
    } else {
        has_update
    };

    Some((latest, channel, details, upgrade_available))
}

fn parse_openclaw_update_text(raw: &str) -> Option<(Option<String>, String, String)> {
    let mut channel = String::from("unknown");
    for line in raw.lines() {
        if line.contains("Channel") {
            let right = line.split('│').last().or_else(|| line.split('|').last())?;
            channel = right.trim().to_string();
        }
        if line.to_lowercase().contains("update") && line.contains("npm latest") {
            if let Some(token) = extract_version_from_text(line) {
                return Some((Some(token), channel, line.trim().to_string()));
            }
            return Some((None, channel, line.trim().to_string()));
        }
        if line.to_lowercase().contains("update") && line.contains("unknown") {
            return Some((None, channel, line.trim().to_string()));
        }
    }
    None
}

fn query_openclaw_latest_npm() -> Result<Option<String>, String> {
    // Query npm registry directly via HTTP — no local npm CLI needed
    let client = reqwest::blocking::Client::builder()
        .timeout(std::time::Duration::from_secs(10))
        .build()
        .map_err(|e| format!("HTTP client error: {e}"))?;
    let resp = client
        .get("https://registry.npmjs.org/openclaw/latest")
        .header("Accept", "application/json")
        .send()
        .map_err(|e| format!("npm registry request failed: {e}"))?;
    if !resp.status().is_success() {
        return Ok(None);
    }
    let body: Value = resp.json().map_err(|e| format!("npm registry parse failed: {e}"))?;
    let version = body.get("version").and_then(Value::as_str).map(String::from);
    Ok(version)
}

/// Fetch a Discord guild name via the Discord REST API using a bot token.
fn fetch_discord_guild_name(bot_token: &str, guild_id: &str) -> Result<String, String> {
    let url = format!("https://discord.com/api/v10/guilds/{guild_id}");
    let client = reqwest::blocking::Client::new();
    let resp = client
        .get(&url)
        .header("Authorization", format!("Bot {bot_token}"))
        .send()
        .map_err(|e| format!("Discord API request failed: {e}"))?;
    if !resp.status().is_success() {
        return Err(format!("Discord API returned status {}", resp.status()));
    }
    let body: Value = resp.json().map_err(|e| format!("Failed to parse Discord response: {e}"))?;
    body.get("name")
        .and_then(Value::as_str)
        .map(|s| s.to_string())
        .ok_or_else(|| "No name field in Discord guild response".to_string())
}

fn collect_channel_summary(cfg: &Value) -> ChannelSummary {
    let examples = collect_channel_model_overrides_list(cfg);
    let configured_channels = cfg
        .get("channels")
        .and_then(|v| v.as_object())
        .map(|channels| channels.len())
        .unwrap_or(0);

    ChannelSummary {
        configured_channels,
        channel_model_overrides: examples.len(),
        channel_examples: examples,
    }
}

fn read_model_value(value: &Value) -> Option<String> {
    if let Some(value) = value.as_str() {
        return Some(value.to_string());
    }

    if let Some(model_obj) = value.as_object() {
        if let Some(primary) = model_obj.get("primary").and_then(Value::as_str) {
            return Some(primary.to_string());
        }
        if let Some(name) = model_obj.get("name").and_then(Value::as_str) {
            return Some(name.to_string());
        }
        if let Some(model) = model_obj.get("model").and_then(Value::as_str) {
            return Some(model.to_string());
        }
        if let Some(model) = model_obj.get("default").and_then(Value::as_str) {
            return Some(model.to_string());
        }
        if let Some(v) = model_obj.get("provider").and_then(Value::as_str) {
            if let Some(inner) = model_obj.get("id").and_then(Value::as_str) {
                return Some(format!("{v}/{inner}"));
            }
        }
    }
    None
}

fn collect_channel_model_overrides(cfg: &Value) -> Vec<String> {
    collect_channel_model_overrides_list(cfg)
}

fn collect_channel_model_overrides_list(cfg: &Value) -> Vec<String> {
    let mut out = Vec::new();
    if let Some(channels) = cfg.get("channels").and_then(Value::as_object) {
        for (name, entry) in channels {
            let mut branch = Vec::new();
            collect_channel_paths(name, entry, &mut branch);
            out.extend(branch);
        }
    }
    out
}

fn collect_channel_paths(prefix: &str, node: &Value, out: &mut Vec<String>) {
    if let Some(obj) = node.as_object() {
        if let Some(model) = obj.get("model").and_then(read_model_value) {
            out.push(format!("{prefix} => {model}"));
        }
        for (key, child) in obj {
            if key == "model" {
                continue;
            }
            let next = format!("{prefix}.{key}");
            collect_channel_paths(&next, child, out);
        }
    }
}

fn collect_memory_overview(base_dir: &Path) -> MemorySummary {
    let memory_root = base_dir.join("memory");
    collect_file_inventory(&memory_root, Some(80))
}

fn collect_file_inventory(path: &Path, max_files: Option<usize>) -> MemorySummary {
    let mut queue = VecDeque::new();
    let mut file_count = 0usize;
    let mut total_bytes = 0u64;
    let mut files = Vec::new();

    if !path.exists() {
        return MemorySummary {
            file_count: 0,
            total_bytes: 0,
            files,
        };
    }

    queue.push_back(path.to_path_buf());
    while let Some(current) = queue.pop_front() {
        let entries = match fs::read_dir(&current) {
            Ok(entries) => entries,
            Err(_) => continue,
        };
        for entry in entries.flatten() {
            let entry_path = entry.path();
            if let Ok(metadata) = entry.metadata() {
                if metadata.is_dir() {
                    queue.push_back(entry_path);
                    continue;
                }
                if metadata.is_file() {
                    file_count += 1;
                    total_bytes = total_bytes.saturating_add(metadata.len());
                    if max_files.is_none_or(|limit| files.len() < limit) {
                        files.push(MemoryFileSummary {
                            path: entry_path.to_string_lossy().to_string(),
                            size_bytes: metadata.len(),
                        });
                    }
                }
            }
        }
    }

    files.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes));
    MemorySummary {
        file_count,
        total_bytes,
        files,
    }
}

fn collect_session_overview(base_dir: &Path) -> SessionSummary {
    let agents_dir = base_dir.join("agents");
    let mut by_agent = Vec::new();
    let mut total_session_files = 0usize;
    let mut total_archive_files = 0usize;
    let mut total_bytes = 0u64;

    if !agents_dir.exists() {
        return SessionSummary {
            total_session_files,
            total_archive_files,
            total_bytes,
            by_agent,
        };
    }

    if let Ok(entries) = fs::read_dir(agents_dir) {
        for entry in entries.flatten() {
            let agent_path = entry.path();
            if !agent_path.is_dir() {
                continue;
            }
            let agent = entry.file_name().to_string_lossy().to_string();
            let sessions_dir = agent_path.join("sessions");
            let archive_dir = agent_path.join("sessions_archive");

            let session_info = collect_file_inventory_with_limit(&sessions_dir);
            let archive_info = collect_file_inventory_with_limit(&archive_dir);

            if session_info.files > 0 || archive_info.files > 0 {
                by_agent.push(AgentSessionSummary {
                    agent: agent.clone(),
                    session_files: session_info.files,
                    archive_files: archive_info.files,
                    total_bytes: session_info.total_bytes.saturating_add(archive_info.total_bytes),
                });
            }

            total_session_files = total_session_files.saturating_add(session_info.files);
            total_archive_files = total_archive_files.saturating_add(archive_info.files);
            total_bytes = total_bytes
                .saturating_add(session_info.total_bytes)
                .saturating_add(archive_info.total_bytes);
        }
    }

    by_agent.sort_by(|a, b| b.total_bytes.cmp(&a.total_bytes));
    SessionSummary {
        total_session_files,
        total_archive_files,
        total_bytes,
        by_agent,
    }
}

struct InventorySummary {
    files: usize,
    total_bytes: u64,
}

fn collect_file_inventory_with_limit(path: &Path) -> InventorySummary {
    if !path.exists() {
        return InventorySummary {
            files: 0,
            total_bytes: 0,
        };
    }
    let mut queue = VecDeque::new();
    let mut files = 0usize;
    let mut total_bytes = 0u64;
    queue.push_back(path.to_path_buf());
    while let Some(current) = queue.pop_front() {
        let entries = match fs::read_dir(&current) {
            Ok(entries) => entries,
            Err(_) => continue,
        };
        for entry in entries.flatten() {
            if let Ok(metadata) = entry.metadata() {
                let p = entry.path();
                if metadata.is_dir() {
                    queue.push_back(p);
                } else if metadata.is_file() {
                    files += 1;
                    total_bytes = total_bytes.saturating_add(metadata.len());
                }
            }
        }
    }
    InventorySummary {
        files,
        total_bytes,
    }
}

fn list_memory_files_detailed(memory_root: &Path) -> Result<Vec<MemoryFile>, String> {
    if !memory_root.exists() {
        return Ok(Vec::new());
    }
    let mut queue = VecDeque::new();
    let mut files = Vec::new();
    queue.push_back(memory_root.to_path_buf());
    while let Some(current) = queue.pop_front() {
        let entries = match fs::read_dir(&current) {
            Ok(entries) => entries,
            Err(_) => continue,
        };
        for entry in entries.flatten() {
            let entry_path = entry.path();
            let metadata = match entry.metadata() {
                Ok(meta) => meta,
                Err(_) => continue,
            };
            if metadata.is_dir() {
                queue.push_back(entry_path);
                continue;
            }
            if metadata.is_file() {
                let relative_path = entry_path
                    .strip_prefix(memory_root)
                    .unwrap_or(&entry_path)
                    .to_string_lossy()
                    .to_string();
                files.push(MemoryFile {
                    path: entry_path.to_string_lossy().to_string(),
                    relative_path,
                    size_bytes: metadata.len(),
                });
            }
        }
    }
    files.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes));
    Ok(files)
}

fn list_session_files_detailed(base_dir: &Path) -> Result<Vec<SessionFile>, String> {
    let agents_root = base_dir.join("agents");
    if !agents_root.exists() {
        return Ok(Vec::new());
    }
    let mut out = Vec::new();
    let entries = fs::read_dir(&agents_root).map_err(|e| e.to_string())?;
    for entry in entries.flatten() {
        let entry_path = entry.path();
        if !entry_path.is_dir() {
            continue;
        }
        let agent = entry.file_name().to_string_lossy().to_string();
        let sessions_root = entry_path.join("sessions");
        let archive_root = entry_path.join("sessions_archive");

        collect_session_files_in_scope(&sessions_root, &agent, "sessions", base_dir, &mut out)?;
        collect_session_files_in_scope(&archive_root, &agent, "archive", base_dir, &mut out)?;
    }
    out.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
    Ok(out)
}

fn collect_session_files_in_scope(
    scope_root: &Path,
    agent: &str,
    kind: &str,
    base_dir: &Path,
    out: &mut Vec<SessionFile>,
) -> Result<(), String> {
    if !scope_root.exists() {
        return Ok(());
    }
    let mut queue = VecDeque::new();
    queue.push_back(scope_root.to_path_buf());
    while let Some(current) = queue.pop_front() {
        let entries = match fs::read_dir(&current) {
            Ok(entries) => entries,
            Err(_) => continue,
        };
        for entry in entries.flatten() {
            let entry_path = entry.path();
            let metadata = match entry.metadata() {
                Ok(meta) => meta,
                Err(_) => continue,
            };
            if metadata.is_dir() {
                queue.push_back(entry_path);
                continue;
            }
            if metadata.is_file() {
                let relative_path = entry_path
                    .strip_prefix(base_dir)
                    .unwrap_or(&entry_path)
                    .to_string_lossy()
                    .to_string();
                out.push(SessionFile {
                    path: entry_path.to_string_lossy().to_string(),
                    relative_path,
                    agent: agent.to_string(),
                    kind: kind.to_string(),
                    size_bytes: metadata.len(),
                });
            }
        }
    }
    Ok(())
}

fn resolve_child_path(root: &Path, target: &str) -> Result<PathBuf, String> {
    if target.trim().is_empty() {
        return Err("path is required".into());
    }
    let candidate = if Path::new(target).is_absolute() {
        PathBuf::from(target)
    } else {
        root.join(target)
    };
    let root_abs = if root.exists() {
        fs::canonicalize(root).map_err(|e| e.to_string())?
    } else {
        return Err("root directory not found".into());
    };
    let target_abs = fs::canonicalize(&candidate).map_err(|e| e.to_string())?;
    if !target_abs.starts_with(&root_abs) {
        return Err("path is outside managed directory".into());
    }
    Ok(target_abs)
}

fn count_files_recursive(root: &Path) -> usize {
    if !root.exists() {
        return 0;
    }
    let mut queue = VecDeque::new();
    let mut total = 0usize;
    queue.push_back(root.to_path_buf());
    while let Some(current) = queue.pop_front() {
        let entries = match fs::read_dir(&current) {
            Ok(entries) => entries,
            Err(_) => continue,
        };
        for entry in entries.flatten() {
            if let Ok(metadata) = entry.metadata() {
                let entry_path = entry.path();
                if metadata.is_dir() {
                    queue.push_back(entry_path);
                } else if metadata.is_file() {
                    total = total.saturating_add(1);
                }
            }
        }
    }
    total
}

fn clear_agent_and_global_sessions(agents_root: &Path, agent_id: Option<&str>) -> Result<usize, String> {
    if !agents_root.exists() {
        return Ok(0);
    }
    let mut total = 0usize;
    let mut targets = Vec::new();

    match agent_id {
        Some(agent) => targets.push(agents_root.join(agent)),
        None => {
            for entry in fs::read_dir(agents_root).map_err(|e| e.to_string())? {
                let entry = entry.map_err(|e| e.to_string())?;
                if entry
                    .file_type()
                    .map_err(|e| e.to_string())?
                    .is_dir()
                {
                    targets.push(entry.path());
                }
            }
        }
    }

    for agent_path in targets {
        let sessions = agent_path.join("sessions");
        let archive = agent_path.join("sessions_archive");
        total = total.saturating_add(clear_directory_contents(&sessions)?);
        total = total.saturating_add(clear_directory_contents(&archive)?);
        fs::create_dir_all(&sessions).map_err(|e| e.to_string())?;
        fs::create_dir_all(&archive).map_err(|e| e.to_string())?;
    }
    Ok(total)
}

fn clear_directory_contents(target: &Path) -> Result<usize, String> {
    if !target.exists() {
        return Ok(0);
    }
    let mut total = 0usize;
    let entries = fs::read_dir(target).map_err(|e| e.to_string())?;
    for entry in entries {
        let entry = entry.map_err(|e| e.to_string())?;
        let path = entry.path();
        let metadata = entry
            .metadata()
            .map_err(|e| e.to_string())?;
        if metadata.is_dir() {
            total = total.saturating_add(clear_directory_contents(&path)?);
            fs::remove_dir_all(&path).map_err(|e| e.to_string())?;
            continue;
        }
        if metadata.is_file() || metadata.is_symlink() {
            fs::remove_file(&path).map_err(|e| e.to_string())?;
            total = total.saturating_add(1);
        }
    }
    Ok(total)
}

fn model_profiles_path(paths: &crate::models::OpenClawPaths) -> std::path::PathBuf {
    paths.clawpal_dir.join("model-profiles.json")
}

fn resolve_profile_model_value(
    paths: &crate::models::OpenClawPaths,
    profile_id: Option<String>,
) -> Result<Option<String>, String> {
    let profile_id = profile_id.and_then(|value| {
        let trimmed = value.trim().to_string();
        if trimmed.is_empty() {
            None
        } else {
            Some(trimmed)
        }
    });
    if profile_id.is_none() {
        return Ok(None);
    }
    let target = profile_id.expect("checked");
    let profiles = load_model_profiles(paths);
    for profile in profiles {
        if profile.id == target {
            return Ok(Some(profile_to_model_value(&profile)));
        }
    }
    Err(format!("model profile not found: {target}"))
}

fn profile_to_model_value(profile: &ModelProfile) -> String {
    if profile.model.contains('/') {
        profile.model.clone()
    } else {
        format!("{}/{}", profile.provider, profile.model)
    }
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolvedApiKey {
    pub profile_id: String,
    pub masked_key: String,
}

#[tauri::command]
pub fn resolve_api_keys() -> Result<Vec<ResolvedApiKey>, String> {
    let paths = resolve_paths();
    let profiles = load_model_profiles(&paths);
    let mut out = Vec::new();
    for profile in &profiles {
        let key = resolve_profile_api_key(profile, &paths.base_dir);
        let masked = mask_api_key(&key);
        out.push(ResolvedApiKey {
            profile_id: profile.id.clone(),
            masked_key: masked,
        });
    }
    Ok(out)
}

fn resolve_profile_api_key(profile: &ModelProfile, base_dir: &Path) -> String {
    // 1. Direct api_key field (user entered key directly in ClawPal)
    if let Some(ref key) = profile.api_key {
        let trimmed = key.trim();
        if !trimmed.is_empty() {
            return trimmed.to_string();
        }
    }

    // 2. Try auth_ref as env var name directly (e.g. "OPENAI_API_KEY")
    let auth_ref = profile.auth_ref.trim();
    if !auth_ref.is_empty() {
        if let Ok(val) = std::env::var(auth_ref) {
            if !val.trim().is_empty() {
                return val;
            }
        }
    }

    // 3. Look up auth_ref in agent-level auth-profiles.json files
    //    Keys are stored at: {base_dir}/agents/{agent}/agent/auth-profiles.json
    if !auth_ref.is_empty() {
        if let Some(key) = resolve_key_from_agent_auth_profiles(base_dir, auth_ref) {
            return key;
        }
    }

    // 4. Try common env var naming conventions based on provider
    let provider = profile.provider.trim().to_uppercase().replace('-', "_");
    if !provider.is_empty() {
        for suffix in ["_API_KEY", "_KEY", "_TOKEN"] {
            let env_name = format!("{provider}{suffix}");
            if let Ok(val) = std::env::var(&env_name) {
                if !val.trim().is_empty() {
                    return val;
                }
            }
        }
    }

    String::new()
}

/// Reads agent-level auth-profiles.json to find the actual API key/token.
/// Scans all agents and returns the first match.
fn resolve_key_from_agent_auth_profiles(base_dir: &Path, auth_ref: &str) -> Option<String> {
    let agents_dir = base_dir.join("agents");
    if !agents_dir.exists() {
        return None;
    }
    let entries = fs::read_dir(&agents_dir).ok()?;
    for entry in entries.flatten() {
        let auth_file = entry.path().join("agent").join("auth-profiles.json");
        if !auth_file.exists() {
            continue;
        }
        let text = fs::read_to_string(&auth_file).ok()?;
        let data: Value = serde_json::from_str(&text).ok()?;
        if let Some(profiles) = data.get("profiles").and_then(Value::as_object) {
            if let Some(auth_entry) = profiles.get(auth_ref) {
                if let Some(key) = extract_token_from_auth_entry(auth_entry) {
                    return Some(key);
                }
            }
        }
    }
    None
}

/// Extract the actual key/token from an agent auth-profiles entry.
/// Handles different auth types: token, api_key, oauth.
fn extract_token_from_auth_entry(entry: &Value) -> Option<String> {
    // "token" type → "token" field (e.g. anthropic)
    // "api_key" type → "key" field (e.g. kimi-coding)
    // "oauth" type → "access" field (e.g. minimax-portal, openai-codex)
    for field in ["token", "key", "apiKey", "api_key", "access"] {
        if let Some(val) = entry.get(field).and_then(Value::as_str) {
            let trimmed = val.trim();
            if !trimmed.is_empty() {
                return Some(trimmed.to_string());
            }
        }
    }
    None
}

fn mask_api_key(key: &str) -> String {
    let key = key.trim();
    if key.is_empty() {
        return "not set".to_string();
    }
    if key.len() <= 8 {
        return "***".to_string();
    }
    let prefix = &key[..4.min(key.len())];
    let suffix = &key[key.len().saturating_sub(4)..];
    format!("{prefix}...{suffix}")
}

fn load_model_profiles(paths: &crate::models::OpenClawPaths) -> Vec<ModelProfile> {
    let path = model_profiles_path(paths);
    let text = std::fs::read_to_string(&path).unwrap_or_else(|_| r#"{"profiles":[]}"#.to_string());
    #[derive(serde::Deserialize)]
    struct Storage {
        #[serde(default)]
        profiles: Vec<ModelProfile>,
    }
    let parsed = serde_json::from_str::<Storage>(&text).unwrap_or(Storage {
        profiles: Vec::new(),
    });
    parsed.profiles
}

fn save_model_profiles(paths: &crate::models::OpenClawPaths, profiles: &[ModelProfile]) -> Result<(), String> {
    let path = model_profiles_path(paths);
    #[derive(serde::Serialize)]
    struct Storage<'a> {
        profiles: &'a [ModelProfile],
        #[serde(rename = "version")]
        version: u8,
    }
    let payload = Storage {
        profiles,
        version: 1,
    };
    let text = serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?;
    crate::config_io::write_text(&path, &text)
}

fn write_config_with_snapshot(
    paths: &crate::models::OpenClawPaths,
    current_text: &str,
    next: &Value,
    source: &str,
) -> Result<(), String> {
    let _ = add_snapshot(
        &paths.history_dir,
        &paths.metadata_path,
        Some(source.to_string()),
        source,
        true,
        current_text,
        None,
    )?;
    write_json(&paths.config_path, next)
}

fn set_nested_value(root: &mut Value, path: &str, value: Option<Value>) -> Result<(), String> {
    let path = path.trim().trim_matches('.');
    if path.is_empty() {
        return Err("invalid path".into());
    }
    let mut cur = root;
    let mut parts = path.split('.').peekable();
    while let Some(part) = parts.next() {
        let is_last = parts.peek().is_none();
        let obj = cur
            .as_object_mut()
            .ok_or_else(|| "path must point to object".to_string())?;
        if is_last {
            if let Some(v) = value {
                obj.insert(part.to_string(), v);
            } else {
                obj.remove(part);
            }
            return Ok(());
        }
        let child = obj
            .entry(part.to_string())
            .or_insert_with(|| Value::Object(Default::default()));
        if !child.is_object() {
            *child = Value::Object(Default::default());
        }
        cur = child;
    }
    unreachable!("path should have at least one segment");
}

fn set_agent_model_value(
    root: &mut Value,
    agent_id: &str,
    model: Option<String>,
) -> Result<(), String> {
    if let Some(agents) = root.pointer_mut("/agents").and_then(Value::as_object_mut) {
        if let Some(list) = agents.get_mut("list").and_then(Value::as_array_mut) {
            for agent in list {
                if agent.get("id").and_then(Value::as_str) == Some(agent_id) {
                    if let Some(agent_obj) = agent.as_object_mut() {
                        match model {
                            Some(v) => {
                                // If existing model is an object, update "primary" inside it
                                if let Some(existing) = agent_obj.get_mut("model") {
                                    if let Some(model_obj) = existing.as_object_mut() {
                                        model_obj.insert("primary".into(), Value::String(v));
                                        return Ok(());
                                    }
                                }
                                agent_obj.insert("model".into(), Value::String(v));
                            }
                            None => {
                                agent_obj.remove("model");
                            }
                        }
                    }
                    return Ok(());
                }
            }
        }
    }
    Err(format!("agent not found: {agent_id}"))
}

fn load_model_catalog(
    paths: &crate::models::OpenClawPaths,
    cfg: &Value,
) -> Result<Vec<ModelCatalogProvider>, String> {
    let now = unix_timestamp_secs();
    let cache_path = model_catalog_cache_path(paths);
    let current_version = resolve_openclaw_version();
    let ttl_seconds = 60 * 60 * 12;
    if let Some(cached) = read_model_catalog_cache(&cache_path)
        .filter(|cache| cache.cli_version == current_version)
    {
        if now.saturating_sub(cached.updated_at) < ttl_seconds && cached.error.is_none() {
            return Ok(cached.providers);
        }
        if cached.error.is_none() {
            if let Some(fresh) = extract_model_catalog_from_cli(paths) {
                if !fresh.is_empty() {
                    return Ok(fresh);
                }
            }
            if !cached.providers.is_empty() {
                return Ok(cached.providers);
            }
        }
    }

    if let Some(catalog) = extract_model_catalog_from_cli(paths) {
        if !catalog.is_empty() {
            let cache = ModelCatalogProviderCache {
                cli_version: current_version,
                updated_at: now,
                providers: catalog.clone(),
                source: "openclaw models list --all --json".into(),
                error: None,
            };
            let _ = save_model_catalog_cache(&cache_path, &cache);
            return Ok(catalog);
        }
    }

    let fallback = collect_model_catalog(cfg);
    if let Some(cached) = read_model_catalog_cache(&cache_path) {
        if !cached.providers.is_empty() {
            let catalog = if fallback.is_empty() {
                cached.providers
            } else {
                fallback
            };
            return Ok(catalog);
        }
    }
    Ok(fallback)
}

/// Parse CLI output from `openclaw models list --all --json` into grouped providers.
/// Handles various output formats: flat arrays, {models: [...]}, {items: [...]}, {data: [...]}.
/// Strips prefix junk (plugin log lines) before the JSON.
fn parse_model_catalog_from_cli_output(raw: &str) -> Option<Vec<ModelCatalogProvider>> {
    let json_str = extract_json_from_output(raw)?;
    let response: Value = serde_json::from_str(json_str).ok()?;
    let models: Vec<Value> = response
        .as_array()
        .map(|values| values.to_vec())
        .or_else(|| {
            response
                .get("models")
                .and_then(Value::as_array)
                .map(|values| values.to_vec())
        })
        .or_else(|| {
            response
                .get("items")
                .and_then(Value::as_array)
                .map(|values| values.to_vec())
        })
        .or_else(|| {
            response
                .get("data")
                .and_then(Value::as_array)
                .map(|values| values.to_vec())
        })
        .unwrap_or_default();
    if models.is_empty() {
        return None;
    }
    let mut providers: BTreeMap<String, ModelCatalogProvider> = BTreeMap::new();
    for model in &models {
        let key = model
            .get("key")
            .and_then(Value::as_str)
            .map(str::to_string)
            .or_else(|| {
                let provider = model.get("provider").and_then(Value::as_str)?;
                let model_id = model.get("id").and_then(Value::as_str)?;
                Some(format!("{provider}/{model_id}"))
            });
        let key = match key {
            Some(k) => k,
            None => continue,
        };
        let mut parts = key.splitn(2, '/');
        let provider = match parts.next() {
            Some(p) if !p.trim().is_empty() => p.trim().to_lowercase(),
            _ => continue,
        };
        let id = parts.next().unwrap_or("").trim().to_string();
        if id.is_empty() {
            continue;
        }
        let name = model
            .get("name")
            .and_then(Value::as_str)
            .or_else(|| model.get("model").and_then(Value::as_str))
            .or_else(|| model.get("title").and_then(Value::as_str))
            .map(str::to_string);
        let base_url = model
            .get("baseUrl")
            .or_else(|| model.get("base_url"))
            .or_else(|| model.get("apiBase"))
            .or_else(|| model.get("api_base"))
            .and_then(Value::as_str)
            .map(str::to_string)
            .or_else(|| {
                response
                    .get("providers")
                    .and_then(Value::as_object)
                    .and_then(|providers| providers.get(&provider))
                    .and_then(Value::as_object)
                    .and_then(|provider_cfg| {
                        provider_cfg
                            .get("baseUrl")
                            .or_else(|| provider_cfg.get("base_url"))
                            .or_else(|| provider_cfg.get("apiBase"))
                            .or_else(|| provider_cfg.get("api_base"))
                            .and_then(Value::as_str)
                    })
                    .map(str::to_string)
            });
        let entry = providers.entry(provider.clone()).or_insert(ModelCatalogProvider {
            provider: provider.clone(),
            base_url,
            models: Vec::new(),
        });
        if !entry.models.iter().any(|existing| existing.id == id) {
            entry.models.push(ModelCatalogModel {
                id: id.clone(),
                name: name.clone(),
            });
        }
    }

    if providers.is_empty() {
        return None;
    }

    let mut out: Vec<ModelCatalogProvider> = providers.into_values().collect();
    for provider in &mut out {
        provider.models.sort_by(|a, b| a.id.cmp(&b.id));
    }
    out.sort_by(|a, b| a.provider.cmp(&b.provider));
    Some(out)
}

fn extract_model_catalog_from_cli(
    paths: &crate::models::OpenClawPaths,
) -> Option<Vec<ModelCatalogProvider>> {
    let output = run_openclaw_raw(&["models", "list", "--all", "--json", "--no-color"]).ok()?;
    if output.stdout.trim().is_empty() {
        return None;
    }

    let out = parse_model_catalog_from_cli_output(&output.stdout)?;
    let _ = cache_model_catalog(paths, out.clone());
    Some(out)
}

fn cache_model_catalog(paths: &crate::models::OpenClawPaths, providers: Vec<ModelCatalogProvider>) -> Option<()> {
    let cache_path = model_catalog_cache_path(paths);
    let now = unix_timestamp_secs();
    let cache = ModelCatalogProviderCache {
        cli_version: resolve_openclaw_version(),
        updated_at: now,
        providers,
        source: "openclaw models list --all --json".into(),
        error: None,
    };
    let _ = save_model_catalog_cache(&cache_path, &cache);
    Some(())
}

fn collect_model_catalog(cfg: &Value) -> Vec<ModelCatalogProvider> {
    let mut providers: BTreeMap<String, ModelCatalogProvider> = BTreeMap::new();

    if let Some(configured) = cfg.pointer("/models/providers").and_then(Value::as_object) {
        for (provider_name, provider_cfg) in configured {
            let provider_model_map = extract_catalog_models(provider_cfg).unwrap_or_default();
            let base_url = provider_cfg
                .get("baseUrl")
                .or_else(|| provider_cfg.get("base_url"))
                .and_then(Value::as_str)
                .map(str::to_string);

            providers.entry(provider_name.clone())
                .and_modify(|entry| {
                    if entry.base_url.is_none() {
                        entry.base_url = base_url.clone();
                    }
                    for model in provider_model_map.iter() {
                        if !entry.models.iter().any(|item| item.id == model.id) {
                            entry.models.push(model.clone());
                        }
                    }
                })
                .or_insert(ModelCatalogProvider {
                    provider: provider_name.clone(),
                    base_url,
                    models: provider_model_map,
                });
        }
    }

    if let Some(auth_profiles) = cfg.pointer("/auth/profiles").and_then(Value::as_object) {
        for profile in auth_profiles.values() {
            if let Some(provider_name) = profile
                .get("provider")
                .or_else(|| profile.get("name"))
                .and_then(Value::as_str)
            {
                providers.entry(provider_name.to_string())
                    .or_insert(ModelCatalogProvider {
                        provider: provider_name.to_string(),
                        base_url: None,
                        models: Vec::new(),
                    });
            }
        }
    }

    providers.into_values().collect()
}

fn extract_catalog_models(provider_cfg: &Value) -> Option<Vec<ModelCatalogModel>> {
    let mut model_items: BTreeMap<String, String> = BTreeMap::new();
    let raw_models = provider_cfg.get("models")?.as_array()?;
    for model in raw_models {
        let id = model.as_str().map(str::to_string).or_else(|| {
            model
                .get("id")
                .and_then(Value::as_str)
                .map(str::to_string)
        });
        if let Some(id) = id {
            let name = model
                .get("name")
                .and_then(Value::as_str)
                .map(str::to_string);
            model_items.entry(id).or_insert(name.unwrap_or_default());
        }
    }
    if model_items.is_empty() {
        return None;
    }
    let models = model_items
        .into_iter()
        .map(|(id, name)| ModelCatalogModel {
            id,
            name: (!name.is_empty()).then_some(name),
        })
        .collect();
    Some(models)
}

fn collect_channel_nodes(cfg: &Value) -> Vec<ChannelNode> {
    let mut out = Vec::new();
    if let Some(channels) = cfg.get("channels") {
        walk_channel_nodes("channels", channels, &mut out);
    }
    out.sort_by(|a, b| a.path.cmp(&b.path));
    out
}

fn walk_channel_nodes(prefix: &str, node: &Value, out: &mut Vec<ChannelNode>) {
    let Some(obj) = node.as_object() else {
        return;
    };

    if is_channel_like_node(prefix, obj) {
        let channel_type = resolve_channel_type(prefix, obj);
        let mode = resolve_channel_mode(obj);
        let allowlist = collect_channel_allowlist(obj);
        let has_model_field = obj.contains_key("model");
        let model = obj.get("model").and_then(read_model_value);
        out.push(ChannelNode {
            path: prefix.to_string(),
            channel_type,
            mode,
            allowlist,
            model,
            has_model_field,
            display_name: None,
            name_status: None,
        });
    }

    for (key, child) in obj {
        if key == "allowlist" || key == "model" || key == "mode" {
            continue;
        }
        if let Value::Object(_) = child {
            walk_channel_nodes(&format!("{prefix}.{key}"), child, out);
        }
    }
}

fn enrich_channel_display_names(
    paths: &crate::models::OpenClawPaths,
    cfg: &Value,
    nodes: &mut [ChannelNode],
) -> Result<(), String> {
    let mut grouped: BTreeMap<String, Vec<(usize, String, String)>> = BTreeMap::new();
    let mut local_names: Vec<(usize, String)> = Vec::new();

    for (index, node) in nodes.iter().enumerate() {
        if let Some((plugin, identifier, kind)) = resolve_channel_node_identity(cfg, node) {
            grouped
                .entry(plugin)
                .or_default()
                .push((index, identifier, kind));
        }
        if node.display_name.is_none() {
            if let Some(local_name) = channel_node_local_name(cfg, &node.path) {
                local_names.push((index, local_name));
            }
        }
    }
    for (index, local_name) in local_names {
        if let Some(node) = nodes.get_mut(index) {
            node.display_name = Some(local_name);
            node.name_status = Some("local".into());
        }
    }

    let cache_file = paths.base_dir.join(".clawpal-channel-name-cache.json");
    if nodes.is_empty() {
        if cache_file.exists() {
            let _ = fs::remove_file(&cache_file);
        }
        return Ok(());
    }

    for (plugin, entries) in grouped {
        if entries.is_empty() {
            continue;
        }
        let ids: Vec<String> = entries.iter().map(|(_, identifier, _)| identifier.clone()).collect();
        let kind = &entries[0].2;
        let mut args = vec![
            "channels".to_string(),
            "resolve".to_string(),
            "--json".to_string(),
            "--channel".to_string(),
            plugin.clone(),
            "--kind".to_string(),
            kind.clone(),
        ];
        for entry in &ids {
            args.push(entry.clone());
        }
        let args: Vec<&str> = args.iter().map(String::as_str).collect();
        let output = match run_openclaw_raw(&args) {
            Ok(output) => output,
            Err(_) => {
                for (index, _, _) in entries {
                    nodes[index].name_status = Some("resolve failed".into());
                }
                continue;
            }
        };
        if output.stdout.trim().is_empty() {
            for (index, _, _) in entries {
                nodes[index].name_status = Some("unresolved".into());
            }
            continue;
        }
        let json_str = extract_json_from_output(&output.stdout).unwrap_or("[]");
        let parsed: Vec<Value> = serde_json::from_str(json_str).unwrap_or_default();
        let mut name_map = HashMap::new();
        for item in parsed {
            let input = item.get("input").and_then(Value::as_str).unwrap_or_default().to_string();
            let resolved = item.get("resolved").and_then(Value::as_bool).unwrap_or(false);
            let name = item
                .get("name")
                .and_then(Value::as_str)
                .map(|value| value.trim().to_string())
                .filter(|value| !value.is_empty());
            let note = item.get("note").and_then(Value::as_str).map(|value| value.to_string());
            if !input.is_empty() {
                name_map.insert(input, (resolved, name, note));
            }
        }

        for (index, identifier, _) in entries {
            if let Some((resolved, name, note)) = name_map.get(&identifier) {
                if *resolved {
                    if let Some(name) = name {
                        nodes[index].display_name = Some(name.clone());
                        nodes[index].name_status = Some("resolved".into());
                    } else {
                        nodes[index].name_status = Some("resolved".into());
                    }
                } else if let Some(note) = note {
                    nodes[index].name_status = Some(note.clone());
                } else {
                    nodes[index].name_status = Some("unresolved".into());
                }
            } else {
                nodes[index].name_status = Some("unresolved".into());
            }
        }
    }

    let _ = save_json_cache(&cache_file, nodes);
    Ok(())
}

#[derive(Serialize, Deserialize)]
struct ChannelNameCacheEntry {
    path: String,
    display_name: Option<String>,
    name_status: Option<String>,
}

fn save_json_cache(cache_file: &Path, nodes: &[ChannelNode]) -> Result<(), String> {
    let payload: Vec<ChannelNameCacheEntry> = nodes
        .iter()
        .map(|node| ChannelNameCacheEntry {
            path: node.path.clone(),
            display_name: node.display_name.clone(),
            name_status: node.name_status.clone(),
        })
        .collect();
    write_text(cache_file, &serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?)
}

fn resolve_channel_node_identity(cfg: &Value, node: &ChannelNode) -> Option<(String, String, String)> {
    let parts: Vec<&str> = node.path.split('.').collect();
    if parts.len() < 2 || parts[0] != "channels" {
        return None;
    }
    let plugin = parts[1].to_string();
    let identifier = channel_last_segment(node.path.as_str())?;
    let config_node = channel_lookup_node(cfg, &node.path);
    let kind = if node.channel_type.as_deref() == Some("dm") || node.path.ends_with(".dm") {
        "user".to_string()
    } else if config_node
        .and_then(|value| value.get("users").or(value.get("members")).or_else(|| value.get("peerIds")))
        .is_some()
    {
        "user".to_string()
    } else {
        "group".to_string()
    };
    Some((plugin, identifier, kind))
}

fn channel_last_segment(path: &str) -> Option<String> {
    path.split('.').next_back().map(|value| value.to_string())
}

fn channel_node_local_name(cfg: &Value, path: &str) -> Option<String> {
    channel_lookup_node(cfg, path).and_then(|node| {
        if let Some(slug) = node.get("slug").and_then(Value::as_str) {
            let trimmed = slug.trim();
            if !trimmed.is_empty() {
                return Some(trimmed.to_string());
            }
        }
        if let Some(name) = node.get("name").and_then(Value::as_str) {
            let trimmed = name.trim();
            if !trimmed.is_empty() {
                return Some(trimmed.to_string());
            }
        }
        None
    })
}

fn channel_lookup_node<'a>(cfg: &'a Value, path: &str) -> Option<&'a Value> {
    let mut current = cfg;
    for part in path.split('.') {
        current = current.get(part)?;
    }
    Some(current)
}

fn is_channel_like_node(prefix: &str, obj: &serde_json::Map<String, Value>) -> bool {
    if prefix == "channels" {
        return false;
    }
    if obj.contains_key("model")
        || obj.contains_key("type")
        || obj.contains_key("mode")
        || obj.contains_key("policy")
        || obj.contains_key("allowlist")
        || obj.contains_key("allowFrom")
        || obj.contains_key("groupAllowFrom")
        || obj.contains_key("dmPolicy")
        || obj.contains_key("groupPolicy")
        || obj.contains_key("guilds")
        || obj.contains_key("accounts")
        || obj.contains_key("dm")
        || obj.contains_key("users")
        || obj.contains_key("enabled")
        || obj.contains_key("token")
        || obj.contains_key("botToken")
    {
        return true;
    }
    if prefix.contains(".accounts.") || prefix.contains(".guilds.") || prefix.contains(".channels.") {
        return true;
    }
    if prefix.ends_with(".dm") || prefix.ends_with(".default") {
        return true;
    }
    false
}

fn resolve_channel_type(prefix: &str, obj: &serde_json::Map<String, Value>) -> Option<String> {
    obj.get("type")
        .and_then(Value::as_str)
        .map(str::to_string)
        .or_else(|| {
            if prefix.ends_with(".dm") {
                Some("dm".into())
            } else if prefix.contains(".accounts.") {
                Some("account".into())
            } else if prefix.contains(".channels.") && prefix.contains(".guilds.") {
                Some("channel".into())
            } else if prefix.contains(".guilds.") {
                Some("guild".into())
            } else if obj.contains_key("guilds") {
                Some("platform".into())
            } else if obj.contains_key("accounts") {
                Some("platform".into())
            } else {
                None
            }
        })
}

fn resolve_channel_mode(obj: &serde_json::Map<String, Value>) -> Option<String> {
    let mut modes: Vec<String> = Vec::new();
    if let Some(v) = obj.get("mode").and_then(Value::as_str) {
        modes.push(v.to_string());
    }
    if let Some(v) = obj.get("policy").and_then(Value::as_str) {
        if !modes.iter().any(|m| m == v) {
            modes.push(v.to_string());
        }
    }
    if let Some(v) = obj.get("dmPolicy").and_then(Value::as_str) {
        if !modes.iter().any(|m| m == v) {
            modes.push(v.to_string());
        }
    }
    if let Some(v) = obj.get("groupPolicy").and_then(Value::as_str) {
        if !modes.iter().any(|m| m == v) {
            modes.push(v.to_string());
        }
    }
    if modes.is_empty() {
        None
    } else {
        Some(modes.join(" / "))
    }
}

fn collect_channel_allowlist(obj: &serde_json::Map<String, Value>) -> Vec<String> {
    let mut out: Vec<String> = Vec::new();
    let mut uniq = HashSet::<String>::new();
    for key in ["allowlist", "allowFrom", "groupAllowFrom"] {
        if let Some(values) = obj.get(key).and_then(Value::as_array) {
            for value in values.iter().filter_map(Value::as_str) {
                let next = value.to_string();
                if uniq.insert(next.clone()) {
                    out.push(next);
                }
            }
        }
    }
    if let Some(values) = obj.get("users").and_then(Value::as_array) {
        for value in values.iter().filter_map(Value::as_str) {
            let next = value.to_string();
            if uniq.insert(next.clone()) {
                out.push(next);
            }
        }
    }
    out
}

fn collect_agent_ids(cfg: &Value) -> Vec<String> {
    let mut ids = Vec::new();
    if let Some(agents) = cfg.get("agents").and_then(|v| v.get("list")).and_then(Value::as_array) {
        for agent in agents {
            if let Some(id) = agent.get("id").and_then(Value::as_str) {
                ids.push(id.to_string());
            }
        }
    }
    ids
}

fn collect_model_bindings(cfg: &Value, profiles: &[ModelProfile]) -> Vec<ModelBinding> {
    let mut out = Vec::new();
    let global = cfg
        .pointer("/agents/defaults/model")
        .or_else(|| cfg.pointer("/agents/default/model"))
        .and_then(read_model_value);
    out.push(ModelBinding {
        scope: "global".into(),
        scope_id: "global".into(),
        model_profile_id: find_profile_by_model(profiles, global.as_deref()),
        model_value: global,
        path: Some("agents.defaults.model".into()),
    });

    if let Some(agents) = cfg.get("agents").and_then(|v| v.get("list")).and_then(Value::as_array) {
        for agent in agents {
            let id = agent.get("id").and_then(Value::as_str).unwrap_or("agent");
            let model = agent.get("model").and_then(read_model_value);
            out.push(ModelBinding {
                scope: "agent".into(),
                scope_id: id.to_string(),
                model_profile_id: find_profile_by_model(profiles, model.as_deref()),
                model_value: model,
                path: Some(format!("agents.list.{id}.model")),
            });
        }
    }

    fn walk_channel_binding(prefix: &str, node: &Value, out: &mut Vec<ModelBinding>, profiles: &[ModelProfile]) {
        if let Some(obj) = node.as_object() {
            if let Some(model) = obj.get("model").and_then(read_model_value) {
                out.push(ModelBinding {
                    scope: "channel".into(),
                    scope_id: prefix.to_string(),
                    model_profile_id: find_profile_by_model(profiles, Some(&model)),
                    model_value: Some(model),
                    path: Some(format!("{}.model", prefix)),
                });
            }
            for (k, child) in obj {
                if let Value::Object(_) = child {
                    walk_channel_binding(&format!("{}.{}", prefix, k), child, out, profiles);
                }
            }
        }
    }

    if let Some(channels) = cfg.get("channels") {
        walk_channel_binding("channels", channels, &mut out, profiles);
    }

    out
}

fn find_profile_by_model(profiles: &[ModelProfile], value: Option<&str>) -> Option<String> {
    let value = value?;
    let normalized = normalize_model_ref(value);
    for profile in profiles {
        if normalize_model_ref(&profile_to_model_value(profile)) == normalized
            || normalize_model_ref(&profile.model) == normalized
        {
            return Some(profile.id.clone());
        }
    }
    None
}

fn resolve_auth_ref_for_provider(cfg: &Value, provider: &str) -> Option<String> {
    let provider = provider.trim().to_lowercase();
    if provider.is_empty() {
        return None;
    }
    if let Some(auth_profiles) = cfg.pointer("/auth/profiles").and_then(Value::as_object) {
        let mut fallback = None;
        for (profile_id, profile) in auth_profiles {
            let entry_provider = profile.get("provider").or_else(|| profile.get("name"));
            if let Some(entry_provider) = entry_provider.and_then(Value::as_str) {
                if entry_provider.trim().eq_ignore_ascii_case(&provider) {
                    if profile_id.ends_with(":default") {
                        return Some(profile_id.clone());
                    }
                    if fallback.is_none() {
                        fallback = Some(profile_id.clone());
                    }
                }
            }
        }
        if fallback.is_some() {
            return fallback;
        }
    }
    None
}

#[tauri::command]
pub fn read_raw_config() -> Result<String, String> {
    let paths = resolve_paths();
    let cfg = read_openclaw_config(&paths)?;
    serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())
}

// ---- Config baseline for dirty tracking ----

fn baseline_path(paths: &crate::models::OpenClawPaths) -> std::path::PathBuf {
    paths.clawpal_dir.join("config-baseline.json")
}

#[tauri::command]
pub fn save_config_baseline() -> Result<bool, String> {
    let paths = resolve_paths();
    let cfg = read_openclaw_config(&paths)?;
    let text = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
    let bp = baseline_path(&paths);
    fs::create_dir_all(bp.parent().unwrap()).map_err(|e| e.to_string())?;
    fs::write(&bp, text).map_err(|e| e.to_string())?;
    Ok(true)
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfigDirtyState {
    pub dirty: bool,
    pub baseline: String,
    pub current: String,
}

#[tauri::command]
pub fn check_config_dirty() -> Result<ConfigDirtyState, String> {
    let paths = resolve_paths();
    let cfg = read_openclaw_config(&paths)?;
    let current = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
    let bp = baseline_path(&paths);
    let baseline = if bp.exists() {
        fs::read_to_string(&bp).map_err(|e| e.to_string())?
    } else {
        // No baseline yet — treat current as clean, save it
        fs::create_dir_all(bp.parent().unwrap()).map_err(|e| e.to_string())?;
        fs::write(&bp, &current).map_err(|e| e.to_string())?;
        current.clone()
    };
    let dirty = baseline.trim() != current.trim();
    Ok(ConfigDirtyState { dirty, baseline, current })
}

#[tauri::command]
pub fn discard_config_changes() -> Result<bool, String> {
    let paths = resolve_paths();
    let bp = baseline_path(&paths);
    if !bp.exists() {
        return Err("No baseline config found".into());
    }
    let baseline_text = fs::read_to_string(&bp).map_err(|e| e.to_string())?;
    let baseline: Value = serde_json::from_str(&baseline_text).map_err(|e| e.to_string())?;

    // Save current as snapshot before discarding
    let cfg = read_openclaw_config(&paths)?;
    let current_text = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
    let _ = add_snapshot(
        &paths.history_dir,
        &paths.metadata_path,
        None,
        "discard-changes",
        true,
        &current_text,
        None,
    );

    write_json(&paths.config_path, &baseline)?;
    Ok(true)
}

#[tauri::command]
pub async fn apply_pending_changes() -> Result<bool, String> {
    tauri::async_runtime::spawn_blocking(move || {
        let paths = resolve_paths();
        // Save current config as new baseline
        let cfg = read_openclaw_config(&paths)?;
        let text = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
        let bp = baseline_path(&paths);
        fs::create_dir_all(bp.parent().unwrap()).map_err(|e| e.to_string())?;
        fs::write(&bp, &text).map_err(|e| e.to_string())?;

        // Restart gateway (30s timeout to prevent indefinite hang)
        run_openclaw_raw_timeout(&["gateway", "restart"], Some(30))?;
        Ok(true)
    }).await.map_err(|e| e.to_string())?
}

#[tauri::command]
pub fn resolve_full_api_key(profile_id: String) -> Result<String, String> {
    let paths = resolve_paths();
    let profiles = load_model_profiles(&paths);
    let profile = profiles.iter().find(|p| p.id == profile_id)
        .ok_or_else(|| "Profile not found".to_string())?;
    let key = resolve_profile_api_key(profile, &paths.base_dir);
    if key.is_empty() {
        return Err("No API key configured for this profile".to_string());
    }
    Ok(key)
}

#[tauri::command]
pub fn open_url(url: String) -> Result<(), String> {
    if url.trim().is_empty() {
        return Err("URL is required".into());
    }
    #[cfg(target_os = "macos")]
    {
        Command::new("open").arg(&url).spawn().map_err(|e| e.to_string())?;
    }
    #[cfg(target_os = "linux")]
    {
        Command::new("xdg-open").arg(&url).spawn().map_err(|e| e.to_string())?;
    }
    #[cfg(target_os = "windows")]
    {
        Command::new("cmd").args(["/c", "start", &url]).spawn().map_err(|e| e.to_string())?;
    }
    Ok(())
}

#[tauri::command]
pub async fn chat_via_openclaw(agent_id: String, message: String, session_id: Option<String>) -> Result<Value, String> {
    tauri::async_runtime::spawn_blocking(move || {
        let mut args = vec![
            "agent".to_string(),
            "--local".to_string(),
            "--agent".to_string(),
            agent_id,
            "--message".to_string(),
            message,
            "--json".to_string(),
            "--no-color".to_string(),
        ];
        if let Some(sid) = session_id {
            args.push("--session-id".to_string());
            args.push(sid);
        }

        let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
        let output = run_openclaw_raw(&arg_refs)?;
        let json_str = extract_json_from_output(&output.stdout)
            .ok_or_else(|| format!("No JSON in openclaw output: {}", output.stdout))?;
        serde_json::from_str(json_str)
            .map_err(|e| format!("Parse openclaw response failed: {}", e))
    })
    .await
    .map_err(|e| format!("Task join failed: {}", e))?
}

#[tauri::command]
pub async fn remote_chat_via_openclaw(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
    agent_id: String,
    message: String,
    session_id: Option<String>,
) -> Result<Value, String> {
    let escaped_msg = message.replace('\'', "'\\''");
    let escaped_agent = agent_id.replace('\'', "'\\''");
    let mut cmd = format!(
        "openclaw agent --local --agent '{}' --message '{}' --json --no-color",
        escaped_agent, escaped_msg
    );
    if let Some(sid) = session_id {
        let escaped_sid = sid.replace('\'', "'\\''");
        cmd.push_str(&format!(" --session-id '{}'", escaped_sid));
    }
    let result = pool.exec_login(&host_id, &cmd).await?;
    if result.exit_code != 0 {
        return Err(format!(
            "Remote chat failed (exit {}): {}",
            result.exit_code, result.stderr
        ));
    }
    let json_str = extract_json_from_output(&result.stdout)
        .ok_or_else(|| format!("No JSON in remote openclaw output: {}", result.stdout))?;
    serde_json::from_str(json_str)
        .map_err(|e| format!("Failed to parse remote chat response: {e}"))
}

// ---- Backup / Restore ----

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackupInfo {
    pub name: String,
    pub path: String,
    pub created_at: String,
    pub size_bytes: u64,
}

#[tauri::command]
pub fn backup_before_upgrade() -> Result<BackupInfo, String> {
    let paths = resolve_paths();
    let backups_dir = paths.clawpal_dir.join("backups");
    fs::create_dir_all(&backups_dir).map_err(|e| format!("Failed to create backups dir: {e}"))?;

    let now_secs = unix_timestamp_secs();
    let now_dt = chrono::DateTime::<chrono::Utc>::from_timestamp(now_secs as i64, 0);
    let name = now_dt
        .map(|dt| dt.format("%Y-%m-%d_%H%M%S").to_string())
        .unwrap_or_else(|| format!("{now_secs}"));
    let backup_dir = backups_dir.join(&name);
    fs::create_dir_all(&backup_dir).map_err(|e| format!("Failed to create backup dir: {e}"))?;

    let mut total_bytes = 0u64;

    // Copy config file
    if paths.config_path.exists() {
        let dest = backup_dir.join("openclaw.json");
        fs::copy(&paths.config_path, &dest).map_err(|e| format!("Failed to copy config: {e}"))?;
        total_bytes += fs::metadata(&dest).map(|m| m.len()).unwrap_or(0);
    }

    // Copy directories, excluding sessions and archive
    let skip_dirs: HashSet<&str> = ["sessions", "archive", ".clawpal"].iter().copied().collect();
    copy_dir_recursive(&paths.base_dir, &backup_dir, &skip_dirs, &mut total_bytes)?;

    Ok(BackupInfo {
        name: name.clone(),
        path: backup_dir.to_string_lossy().to_string(),
        created_at: format_timestamp_from_unix(now_secs),
        size_bytes: total_bytes,
    })
}

fn copy_dir_recursive(src: &Path, dst: &Path, skip_dirs: &HashSet<&str>, total: &mut u64) -> Result<(), String> {
    let entries = fs::read_dir(src).map_err(|e| format!("Failed to read dir {}: {e}", src.display()))?;
    for entry in entries {
        let entry = entry.map_err(|e| e.to_string())?;
        let name = entry.file_name();
        let name_str = name.to_string_lossy();

        // Skip the config file (already copied separately) and skip dirs
        if name_str == "openclaw.json" {
            continue;
        }

        let file_type = entry.file_type().map_err(|e| e.to_string())?;
        let dest = dst.join(&name);

        if file_type.is_dir() {
            if skip_dirs.contains(name_str.as_ref()) {
                continue;
            }
            fs::create_dir_all(&dest).map_err(|e| format!("Failed to create dir {}: {e}", dest.display()))?;
            copy_dir_recursive(&entry.path(), &dest, skip_dirs, total)?;
        } else if file_type.is_file() {
            fs::copy(entry.path(), &dest).map_err(|e| format!("Failed to copy {}: {e}", name_str))?;
            *total += fs::metadata(&dest).map(|m| m.len()).unwrap_or(0);
        }
    }
    Ok(())
}

#[tauri::command]
pub fn list_backups() -> Result<Vec<BackupInfo>, String> {
    let paths = resolve_paths();
    let backups_dir = paths.clawpal_dir.join("backups");
    if !backups_dir.exists() {
        return Ok(Vec::new());
    }
    let mut backups = Vec::new();
    let entries = fs::read_dir(&backups_dir).map_err(|e| e.to_string())?;
    for entry in entries {
        let entry = entry.map_err(|e| e.to_string())?;
        if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
            continue;
        }
        let name = entry.file_name().to_string_lossy().to_string();
        let path = entry.path();
        let size = dir_size(&path);
        let created_at = fs::metadata(&path)
            .and_then(|m| m.created())
            .map(|t| {
                let secs = t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
                format_timestamp_from_unix(secs)
            })
            .unwrap_or_else(|_| name.clone());
        backups.push(BackupInfo {
            name,
            path: path.to_string_lossy().to_string(),
            created_at,
            size_bytes: size,
        });
    }
    backups.sort_by(|a, b| b.name.cmp(&a.name));
    Ok(backups)
}

fn dir_size(path: &Path) -> u64 {
    let mut total = 0u64;
    if let Ok(entries) = fs::read_dir(path) {
        for entry in entries.flatten() {
            if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
                total += dir_size(&entry.path());
            } else {
                total += fs::metadata(entry.path()).map(|m| m.len()).unwrap_or(0);
            }
        }
    }
    total
}

#[tauri::command]
pub fn restore_from_backup(backup_name: String) -> Result<String, String> {
    let paths = resolve_paths();
    let backup_dir = paths.clawpal_dir.join("backups").join(&backup_name);
    if !backup_dir.exists() {
        return Err(format!("Backup '{}' not found", backup_name));
    }

    // Restore config file
    let backup_config = backup_dir.join("openclaw.json");
    if backup_config.exists() {
        fs::copy(&backup_config, &paths.config_path)
            .map_err(|e| format!("Failed to restore config: {e}"))?;
    }

    // Restore other directories (agents except sessions/archive, memory, etc.)
    let skip_dirs: HashSet<&str> = ["sessions", "archive", ".clawpal"].iter().copied().collect();
    restore_dir_recursive(&backup_dir, &paths.base_dir, &skip_dirs)?;

    Ok(format!("Restored from backup '{}'", backup_name))
}

fn restore_dir_recursive(src: &Path, dst: &Path, skip_dirs: &HashSet<&str>) -> Result<(), String> {
    let entries = fs::read_dir(src).map_err(|e| format!("Failed to read backup dir: {e}"))?;
    for entry in entries {
        let entry = entry.map_err(|e| e.to_string())?;
        let name = entry.file_name();
        let name_str = name.to_string_lossy();

        if name_str == "openclaw.json" {
            continue; // Already restored separately
        }

        let file_type = entry.file_type().map_err(|e| e.to_string())?;
        let dest = dst.join(&name);

        if file_type.is_dir() {
            if skip_dirs.contains(name_str.as_ref()) {
                continue;
            }
            fs::create_dir_all(&dest).map_err(|e| e.to_string())?;
            restore_dir_recursive(&entry.path(), &dest, skip_dirs)?;
        } else if file_type.is_file() {
            fs::copy(entry.path(), &dest).map_err(|e| format!("Failed to restore {}: {e}", name_str))?;
        }
    }
    Ok(())
}

#[tauri::command]
pub fn delete_backup(backup_name: String) -> Result<bool, String> {
    let paths = resolve_paths();
    let backup_dir = paths.clawpal_dir.join("backups").join(&backup_name);
    if !backup_dir.exists() {
        return Ok(false);
    }
    fs::remove_dir_all(&backup_dir).map_err(|e| format!("Failed to delete backup: {e}"))?;
    Ok(true)
}

// ---- Remote Backup / Restore (via SSH) ----

#[tauri::command]
pub async fn remote_backup_before_upgrade(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
) -> Result<BackupInfo, String> {
    let now_secs = unix_timestamp_secs();
    let now_dt = chrono::DateTime::<chrono::Utc>::from_timestamp(now_secs as i64, 0);
    let name = now_dt
        .map(|dt| dt.format("%Y-%m-%d_%H%M%S").to_string())
        .unwrap_or_else(|| format!("{now_secs}"));

    let cmd = format!(
        concat!(
            "set -e; ",
            "BDIR=\"$HOME/.openclaw/.clawpal/backups/{name}\"; ",
            "mkdir -p \"$BDIR\"; ",
            "cp \"$HOME/.openclaw/openclaw.json\" \"$BDIR/\" 2>/dev/null || true; ",
            "cp -r \"$HOME/.openclaw/agents\" \"$BDIR/\" 2>/dev/null || true; ",
            "cp -r \"$HOME/.openclaw/memory\" \"$BDIR/\" 2>/dev/null || true; ",
            "du -sk \"$BDIR\" 2>/dev/null | awk '{{print $1 * 1024}}' || echo 0"
        ),
        name = name
    );

    let result = pool.exec_login(&host_id, &cmd).await?;
    if result.exit_code != 0 {
        return Err(format!("Remote backup failed (exit {}): {}", result.exit_code, result.stderr));
    }

    let size_bytes: u64 = result.stdout.trim().lines().last()
        .and_then(|l| l.trim().parse().ok())
        .unwrap_or(0);

    Ok(BackupInfo {
        name,
        path: String::new(),
        created_at: format_timestamp_from_unix(now_secs),
        size_bytes,
    })
}

#[tauri::command]
pub async fn remote_list_backups(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
) -> Result<Vec<BackupInfo>, String> {
    // List backup directory names
    let list_result = pool
        .exec_login(&host_id, "ls -1d \"$HOME/.openclaw/.clawpal/backups\"/*/  2>/dev/null || true")
        .await?;

    let dirs: Vec<String> = list_result
        .stdout
        .lines()
        .filter(|l| !l.trim().is_empty())
        .map(|l| l.trim().trim_end_matches('/').to_string())
        .collect();

    if dirs.is_empty() {
        return Ok(Vec::new());
    }

    // Build a single command to get sizes for all backup dirs (du -sk is POSIX portable)
    let du_parts: Vec<String> = dirs
        .iter()
        .map(|d| format!("du -sk '{}' 2>/dev/null || echo '0\t{}'", d, d))
        .collect();
    let du_cmd = du_parts.join("; ");
    let du_result = pool.exec_login(&host_id, &du_cmd).await?;

    let mut size_map = std::collections::HashMap::new();
    for line in du_result.stdout.lines() {
        let parts: Vec<&str> = line.splitn(2, '\t').collect();
        if parts.len() == 2 {
            let size_kb: u64 = parts[0].trim().parse().unwrap_or(0);
            let path = parts[1].trim().trim_end_matches('/');
            size_map.insert(path.to_string(), size_kb * 1024);
        }
    }

    let mut backups: Vec<BackupInfo> = dirs
        .iter()
        .map(|d| {
            let name = d.rsplit('/').next().unwrap_or(d).to_string();
            let size_bytes = size_map.get(d.trim_end_matches('/')).copied().unwrap_or(0);
            BackupInfo {
                name: name.clone(),
                path: String::new(),
                created_at: name.clone(), // Name is the timestamp
                size_bytes,
            }
        })
        .collect();

    backups.sort_by(|a, b| b.name.cmp(&a.name));
    Ok(backups)
}

#[tauri::command]
pub async fn remote_restore_from_backup(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
    backup_name: String,
) -> Result<String, String> {
    let cmd = format!(
        concat!(
            "set -e; ",
            "BDIR=\"$HOME/.openclaw/.clawpal/backups/{name}\"; ",
            "[ -d \"$BDIR\" ] || {{ echo 'Backup not found'; exit 1; }}; ",
            "cp \"$BDIR/openclaw.json\" \"$HOME/.openclaw/openclaw.json\" 2>/dev/null || true; ",
            "[ -d \"$BDIR/agents\" ] && cp -r \"$BDIR/agents\" \"$HOME/.openclaw/\" 2>/dev/null || true; ",
            "[ -d \"$BDIR/memory\" ] && cp -r \"$BDIR/memory\" \"$HOME/.openclaw/\" 2>/dev/null || true; ",
            "echo 'Restored from backup '\"'\"'{name}'\"'\"''"
        ),
        name = backup_name
    );

    let result = pool.exec_login(&host_id, &cmd).await?;
    if result.exit_code != 0 {
        return Err(format!("Remote restore failed: {}", result.stderr));
    }

    Ok(format!("Restored from backup '{}'", backup_name))
}

#[tauri::command]
pub async fn remote_delete_backup(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
    backup_name: String,
) -> Result<bool, String> {
    let cmd = format!(
        "BDIR=\"$HOME/.openclaw/.clawpal/backups/{name}\"; [ -d \"$BDIR\" ] && rm -rf \"$BDIR\" && echo 'deleted' || echo 'not_found'",
        name = backup_name
    );

    let result = pool.exec_login(&host_id, &cmd).await?;
    Ok(result.stdout.trim() == "deleted")
}

fn resolve_model_provider_base_url(cfg: &Value, provider: &str) -> Option<String> {
    let provider = provider.trim();
    if provider.is_empty() {
        return None;
    }
    cfg.pointer("/models/providers")
        .and_then(Value::as_object)
        .and_then(|providers| providers.get(provider))
        .and_then(Value::as_object)
        .and_then(|provider_cfg| {
            provider_cfg
                .get("baseUrl")
                .or_else(|| provider_cfg.get("base_url"))
                .and_then(Value::as_str)
                .map(str::to_string)
                .or_else(|| {
                    provider_cfg
                        .get("apiBase")
                        .or_else(|| provider_cfg.get("api_base"))
                        .and_then(Value::as_str)
                        .map(str::to_string)
                })
        })
}

// ---------------------------------------------------------------------------
// Task 3: Remote instance config CRUD
// ---------------------------------------------------------------------------

fn remote_instances_path() -> PathBuf {
    resolve_paths().clawpal_dir.join("remote-instances.json")
}

fn read_hosts_from_disk() -> Result<Vec<SshHostConfig>, String> {
    let path = remote_instances_path();
    if !path.exists() {
        return Ok(Vec::new());
    }
    let data = fs::read_to_string(&path).map_err(|e| format!("Failed to read remote-instances.json: {e}"))?;
    serde_json::from_str(&data).map_err(|e| format!("Failed to parse remote-instances.json: {e}"))
}

fn write_hosts_to_disk(hosts: &[SshHostConfig]) -> Result<(), String> {
    let path = remote_instances_path();
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|e| format!("Failed to create dir: {e}"))?;
    }
    let json = serde_json::to_string_pretty(hosts).map_err(|e| format!("Failed to serialize hosts: {e}"))?;
    fs::write(&path, json).map_err(|e| format!("Failed to write remote-instances.json: {e}"))
}

#[tauri::command]
pub fn list_ssh_hosts() -> Result<Vec<SshHostConfig>, String> {
    read_hosts_from_disk()
}

#[tauri::command]
pub fn upsert_ssh_host(host: SshHostConfig) -> Result<SshHostConfig, String> {
    let mut hosts = read_hosts_from_disk()?;
    if let Some(existing) = hosts.iter_mut().find(|h| h.id == host.id) {
        *existing = host.clone();
    } else {
        hosts.push(host.clone());
    }
    write_hosts_to_disk(&hosts)?;
    Ok(host)
}

#[tauri::command]
pub fn delete_ssh_host(host_id: String) -> Result<bool, String> {
    let mut hosts = read_hosts_from_disk()?;
    let before = hosts.len();
    hosts.retain(|h| h.id != host_id);
    let removed = hosts.len() < before;
    write_hosts_to_disk(&hosts)?;
    Ok(removed)
}

// ---------------------------------------------------------------------------
// Task 4: SSH connect / disconnect / status
// ---------------------------------------------------------------------------

#[tauri::command]
pub async fn ssh_connect(pool: State<'_, SshConnectionPool>, host_id: String) -> Result<bool, String> {
    // If already connected and handle is alive, reuse
    if pool.is_connected(&host_id).await {
        return Ok(true);
    }
    let hosts = read_hosts_from_disk()?;
    let host = hosts.into_iter().find(|h| h.id == host_id)
        .ok_or_else(|| format!("No SSH host config with id: {host_id}"))?;
    pool.connect(&host).await?;
    Ok(true)
}

#[tauri::command]
pub async fn ssh_disconnect(pool: State<'_, SshConnectionPool>, host_id: String) -> Result<bool, String> {
    pool.disconnect(&host_id).await?;
    Ok(true)
}

#[tauri::command]
pub async fn ssh_status(pool: State<'_, SshConnectionPool>, host_id: String) -> Result<String, String> {
    if pool.is_connected(&host_id).await {
        Ok("connected".to_string())
    } else {
        Ok("disconnected".to_string())
    }
}

// ---------------------------------------------------------------------------
// Task 5: SSH exec and SFTP Tauri commands
// ---------------------------------------------------------------------------

#[tauri::command]
pub async fn ssh_exec(pool: State<'_, SshConnectionPool>, host_id: String, command: String) -> Result<SshExecResult, String> {
    pool.exec(&host_id, &command).await
}

#[tauri::command]
pub async fn sftp_read_file(pool: State<'_, SshConnectionPool>, host_id: String, path: String) -> Result<String, String> {
    pool.sftp_read(&host_id, &path).await
}

#[tauri::command]
pub async fn sftp_write_file(pool: State<'_, SshConnectionPool>, host_id: String, path: String, content: String) -> Result<bool, String> {
    pool.sftp_write(&host_id, &path, &content).await?;
    Ok(true)
}

#[tauri::command]
pub async fn sftp_list_dir(pool: State<'_, SshConnectionPool>, host_id: String, path: String) -> Result<Vec<SftpEntry>, String> {
    pool.sftp_list(&host_id, &path).await
}

#[tauri::command]
pub async fn sftp_remove_file(pool: State<'_, SshConnectionPool>, host_id: String, path: String) -> Result<bool, String> {
    pool.sftp_remove(&host_id, &path).await?;
    Ok(true)
}

// ---------------------------------------------------------------------------
// Task 6: Remote business commands
// ---------------------------------------------------------------------------

#[tauri::command]
pub async fn remote_read_raw_config(pool: State<'_, SshConnectionPool>, host_id: String) -> Result<String, String> {
    pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await
}

#[tauri::command]
pub async fn remote_get_system_status(pool: State<'_, SshConnectionPool>, host_id: String) -> Result<Value, String> {
    // 1. Get openclaw version (use login shell for PATH)
    let version_result = pool.exec_login(&host_id, "openclaw --version").await?;
    let openclaw_version = version_result.stdout.trim().to_string();

    // 2. Read remote config
    let config_raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await;
    let (active_agents, global_default_model, _gateway_port) = match &config_raw {
        Ok(raw) => {
            let cfg: Value = serde_json::from_str(raw).unwrap_or(Value::Null);
            let agents = cfg.pointer("/agents/list")
                .and_then(Value::as_array)
                .map(|a| a.len() as u32)
                .unwrap_or(0);
            let model = cfg.pointer("/agents/defaults/model")
                .and_then(|v| read_model_value(v))
                .or_else(|| cfg.pointer("/agents/default/model").and_then(|v| read_model_value(v)))
                .unwrap_or_default();
            let port = cfg.pointer("/gateway/port")
                .and_then(Value::as_u64)
                .unwrap_or(5337);
            (agents, model, port)
        }
        Err(_) => (0, String::new(), 5337),
    };

    // 3. Check gateway health — pgrep is most reliable via SSH; HTTP as fallback
    let pgrep_result = pool.exec(&host_id, "pgrep -f openclaw-gateway >/dev/null 2>&1").await;
    let healthy = match pgrep_result {
        Ok(r) => r.exit_code == 0,
        Err(_) => false,
    };

    let status = serde_json::json!({
        "healthy": healthy,
        "openclawVersion": openclaw_version,
        "activeAgents": active_agents,
        "globalDefaultModel": global_default_model,
        "configPath": "~/.openclaw/openclaw.json",
        "openclawDir": "~/.openclaw",
    });

    Ok(status)
}

#[tauri::command]
pub async fn remote_check_openclaw_update(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
) -> Result<Value, String> {
    // Get installed version and extract clean semver
    let version_result = pool.exec_login(&host_id, "openclaw --version").await?;
    let installed_version = extract_version_from_text(version_result.stdout.trim())
        .unwrap_or_else(|| version_result.stdout.trim().to_string());

    // Try `openclaw update status --json` first (may not exist on older versions)
    let update_result = pool.exec_login(&host_id, "openclaw update status --json --no-color 2>/dev/null").await;
    if let Ok(r) = update_result {
        if r.exit_code == 0 && !r.stdout.trim().is_empty() {
            if let Some((latest, _channel, _details, upgrade)) =
                parse_openclaw_update_json(&r.stdout, &installed_version)
            {
                return Ok(serde_json::json!({
                    "upgradeAvailable": upgrade,
                    "latestVersion": latest,
                    "installedVersion": installed_version,
                }));
            }
        }
    }

    // Fallback: query npm registry directly from Tauri (no remote CLI dependency)
    let latest_version = query_openclaw_latest_npm().ok().flatten();
    let upgrade = latest_version
        .as_ref()
        .is_some_and(|latest| compare_semver(&installed_version, Some(latest.as_str())));
    Ok(serde_json::json!({
        "upgradeAvailable": upgrade,
        "latestVersion": latest_version,
        "installedVersion": installed_version,
    }))
}

#[tauri::command]
pub async fn remote_list_agents_overview(pool: State<'_, SshConnectionPool>, host_id: String) -> Result<Vec<AgentOverview>, String> {
    let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
    let cfg: Value = serde_json::from_str(&raw).map_err(|e| format!("Failed to parse remote config: {e}"))?;

    // Check if gateway process is running to determine online status
    let gateway_running = pool.exec(&host_id, "pgrep -f openclaw-gateway >/dev/null 2>&1").await
        .map(|r| r.exit_code == 0)
        .unwrap_or(false);

    let mut agents = Vec::new();
    let mut seen_ids = std::collections::HashSet::new();
    let default_model = cfg.pointer("/agents/defaults/model")
        .and_then(read_model_value)
        .or_else(|| cfg.pointer("/agents/default/model").and_then(read_model_value));
    let default_workspace = cfg.pointer("/agents/defaults/workspace")
        .and_then(Value::as_str)
        .map(|s| s.to_string());
    let channel_nodes = collect_channel_nodes(&cfg);

    if let Some(list) = cfg.pointer("/agents/list").and_then(Value::as_array) {
        for agent in list {
            let id = agent.get("id").and_then(Value::as_str).unwrap_or("agent").to_string();
            if !seen_ids.insert(id.clone()) {
                continue;
            }

            let workspace = agent.get("workspace").and_then(Value::as_str)
                .map(|s| s.to_string())
                .or_else(|| default_workspace.clone());

            // Read IDENTITY.md from remote workspace via SFTP
            let (name, emoji) = if let Some(ref ws) = workspace {
                let identity_path = format!("{}/IDENTITY.md", ws.trim_end_matches('/'));
                match pool.sftp_read(&host_id, &identity_path).await {
                    Ok(content) => parse_identity_content(&content),
                    Err(_) => (None, None),
                }
            } else {
                (None, None)
            };

            let model = agent.get("model").and_then(read_model_value)
                .or_else(|| default_model.clone());
            let channels: Vec<String> = channel_nodes.iter()
                .map(|ch| ch.path.clone())
                .collect();
            agents.push(AgentOverview {
                id,
                name,
                emoji,
                model,
                channels,
                online: gateway_running,
                workspace,
            });
        }
    }
    Ok(agents)
}

#[tauri::command]
pub async fn remote_list_channels_minimal(pool: State<'_, SshConnectionPool>, host_id: String) -> Result<Vec<ChannelNode>, String> {
    let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
    let cfg: Value = serde_json::from_str(&raw).map_err(|e| format!("Failed to parse remote config: {e}"))?;
    Ok(collect_channel_nodes(&cfg))
}

#[tauri::command]
pub async fn remote_list_bindings(pool: State<'_, SshConnectionPool>, host_id: String) -> Result<Vec<Value>, String> {
    let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
    let cfg: Value = serde_json::from_str(&raw).map_err(|e| format!("Failed to parse remote config: {e}"))?;
    let bindings = cfg
        .get("bindings")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    Ok(bindings)
}

// ---------------------------------------------------------------------------
// Remote config mutation helpers & commands
// ---------------------------------------------------------------------------

/// Private helper: snapshot current config then write new config on remote.
async fn remote_write_config_with_snapshot(
    pool: &SshConnectionPool,
    host_id: &str,
    current_text: &str,
    next: &Value,
    source: &str,
) -> Result<(), String> {
    // Create snapshot dir
    pool.exec(host_id, "mkdir -p ~/.clawpal/snapshots").await?;
    // Write snapshot (use chrono-free timestamp from SystemTime)
    let ts = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    let snapshot_path = format!("~/.clawpal/snapshots/{ts}-{source}.json");
    pool.sftp_write(host_id, &snapshot_path, current_text).await?;
    // Write new config
    let new_text = serde_json::to_string_pretty(next).map_err(|e| e.to_string())?;
    pool.sftp_write(host_id, "~/.openclaw/openclaw.json", &new_text).await?;
    Ok(())
}

#[tauri::command]
pub async fn remote_restart_gateway(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
) -> Result<bool, String> {
    pool.exec_login(&host_id, "openclaw gateway restart").await?;
    Ok(true)
}

#[tauri::command]
pub async fn remote_save_config_baseline(
    pool: State<'_, SshConnectionPool>,
    baselines: State<'_, RemoteConfigBaselines>,
    host_id: String,
) -> Result<bool, String> {
    let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
    let cfg: Value = serde_json::from_str(&raw)
        .map_err(|e| format!("Failed to parse remote config: {e}"))?;
    let text = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
    baselines.0.lock().unwrap().insert(host_id, text);
    Ok(true)
}

#[tauri::command]
pub async fn remote_check_config_dirty(
    pool: State<'_, SshConnectionPool>,
    baselines: State<'_, RemoteConfigBaselines>,
    host_id: String,
) -> Result<ConfigDirtyState, String> {
    let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
    let cfg: Value = serde_json::from_str(&raw)
        .map_err(|e| format!("Failed to parse remote config: {e}"))?;
    let current = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
    let mut map = baselines.0.lock().unwrap();
    let baseline = match map.get(&host_id) {
        Some(b) => b.clone(),
        None => {
            // No baseline yet — treat current as clean, save it
            map.insert(host_id, current.clone());
            current.clone()
        }
    };
    let dirty = baseline.trim() != current.trim();
    Ok(ConfigDirtyState { dirty, baseline, current })
}

#[tauri::command]
pub async fn remote_discard_config_changes(
    pool: State<'_, SshConnectionPool>,
    baselines: State<'_, RemoteConfigBaselines>,
    host_id: String,
) -> Result<bool, String> {
    let baseline = {
        let map = baselines.0.lock().unwrap();
        map.get(&host_id).cloned()
            .ok_or_else(|| "No baseline config found for this host".to_string())?
    };
    let baseline_val: Value = serde_json::from_str(&baseline)
        .map_err(|e| format!("Failed to parse baseline: {e}"))?;
    // Save current as snapshot before discarding
    let current = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await
        .unwrap_or_default();
    remote_write_config_with_snapshot(&pool, &host_id, &current, &baseline_val, "discard-changes").await?;
    Ok(true)
}

#[tauri::command]
pub async fn remote_apply_pending_changes(
    pool: State<'_, SshConnectionPool>,
    baselines: State<'_, RemoteConfigBaselines>,
    host_id: String,
) -> Result<bool, String> {
    // Save current config as new baseline
    let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
    let cfg: Value = serde_json::from_str(&raw)
        .map_err(|e| format!("Failed to parse remote config: {e}"))?;
    let text = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
    baselines.0.lock().unwrap().insert(host_id.clone(), text);
    // Restart gateway
    pool.exec_login(&host_id, "openclaw gateway restart").await?;
    Ok(true)
}

#[tauri::command]
pub async fn remote_apply_config_patch(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
    patch_template: String,
    params: Map<String, Value>,
) -> Result<ApplyResult, String> {
    let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
    let current: Value =
        serde_json::from_str(&raw).map_err(|e| format!("Failed to parse remote config: {e}"))?;
    let current_text = serde_json::to_string_pretty(&current).map_err(|e| e.to_string())?;
    let (candidate, _changes) =
        build_candidate_config_from_template(&current, &patch_template, &params)?;
    remote_write_config_with_snapshot(&pool, &host_id, &current_text, &candidate, "config-patch")
        .await?;
    Ok(ApplyResult {
        ok: true,
        snapshot_id: None,
        config_path: "~/.openclaw/openclaw.json".to_string(),
        backup_path: None,
        warnings: Vec::new(),
        errors: Vec::new(),
    })
}

#[tauri::command]
pub async fn remote_create_agent(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
    agent_id: String,
    model: Option<String>,
) -> Result<Value, String> {
    let agent_id = agent_id.trim().to_string();
    if agent_id.is_empty() {
        return Err("Agent ID is required".into());
    }
    let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
    let mut cfg: Value =
        serde_json::from_str(&raw).map_err(|e| format!("Failed to parse: {e}"))?;
    let current_text = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;

    let existing = collect_agent_ids(&cfg);
    if existing.iter().any(|id| id.eq_ignore_ascii_case(&agent_id)) {
        return Err(format!("Agent '{}' already exists", agent_id));
    }

    // Resolve model value from profile ID (convert UUID to "provider/model" string)
    let model_value = if let Some(ref profile_id) = model {
        let pid = profile_id.trim();
        if pid.is_empty() {
            None
        } else {
            // Load remote model profiles and find matching one
            let profiles_raw = pool.sftp_read(&host_id, "~/.clawpal/model-profiles.json").await
                .unwrap_or_else(|_| r#"{"profiles":[]}"#.to_string());
            let profiles: Vec<ModelProfile> = serde_json::from_str::<Value>(&profiles_raw)
                .ok()
                .and_then(|v| v.get("profiles")?.as_array().cloned())
                .map(|arr| arr.into_iter().filter_map(|v| serde_json::from_value(v).ok()).collect())
                .unwrap_or_default();
            profiles.iter()
                .find(|p| p.id == pid)
                .map(|p| profile_to_model_value(p))
                .or_else(|| Some(pid.to_string()))
        }
    } else {
        None
    };

    let mut agent_obj = serde_json::Map::new();
    agent_obj.insert("id".into(), Value::String(agent_id.clone()));
    if let Some(ref m) = model_value {
        agent_obj.insert("model".into(), Value::String(m.clone()));
    }

    let agents = cfg
        .as_object_mut()
        .ok_or("config is not an object")?
        .entry("agents")
        .or_insert_with(|| Value::Object(serde_json::Map::new()))
        .as_object_mut()
        .ok_or("agents is not an object")?;
    let list = agents
        .entry("list")
        .or_insert_with(|| Value::Array(Vec::new()))
        .as_array_mut()
        .ok_or("agents.list is not an array")?;
    list.push(Value::Object(agent_obj));

    remote_write_config_with_snapshot(&pool, &host_id, &current_text, &cfg, "create-agent")
        .await?;
    Ok(serde_json::json!({
        "id": agent_id,
        "name": null,
        "emoji": null,
        "model": model_value,
        "channels": [],
        "online": false,
        "workspace": null,
    }))
}

#[tauri::command]
pub async fn remote_delete_agent(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
    agent_id: String,
) -> Result<bool, String> {
    let agent_id = agent_id.trim().to_string();
    if agent_id == "main" {
        return Err("Cannot delete the main agent".into());
    }
    let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
    let mut cfg: Value =
        serde_json::from_str(&raw).map_err(|e| format!("Failed to parse: {e}"))?;
    let current_text = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;

    let list = cfg
        .pointer_mut("/agents/list")
        .and_then(Value::as_array_mut)
        .ok_or("agents.list not found")?;
    let before = list.len();
    list.retain(|a| a.get("id").and_then(Value::as_str) != Some(&agent_id));
    if list.len() == before {
        return Err(format!("Agent '{}' not found", agent_id));
    }

    // Also remove bindings for this agent
    if let Some(bindings) = cfg.get_mut("bindings").and_then(Value::as_array_mut) {
        bindings.retain(|b| b.get("agentId").and_then(Value::as_str) != Some(&agent_id));
    }

    remote_write_config_with_snapshot(&pool, &host_id, &current_text, &cfg, "delete-agent")
        .await?;
    Ok(true)
}

#[tauri::command]
pub async fn remote_assign_channel_agent(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
    channel_type: String,
    peer_id: String,
    agent_id: Option<String>,
) -> Result<bool, String> {
    let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
    let mut cfg: Value =
        serde_json::from_str(&raw).map_err(|e| format!("Failed to parse: {e}"))?;
    let current_text = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;

    let agent_id = agent_id
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty());

    let bindings = cfg.get_mut("bindings").and_then(Value::as_array_mut);
    if let Some(arr) = bindings {
        arr.retain(|b| {
            let m = b.get("match");
            let ch = m.and_then(|m| m.get("channel")).and_then(Value::as_str);
            let pid = m
                .and_then(|m| m.pointer("/peer/id"))
                .and_then(Value::as_str);
            !(ch == Some(&channel_type) && pid == Some(&peer_id))
        });
        if let Some(ref aid) = agent_id {
            arr.push(serde_json::json!({
                "agentId": aid,
                "match": {
                    "channel": channel_type,
                    "peer": { "id": peer_id, "kind": "channel" }
                }
            }));
        }
    } else if let Some(ref aid) = agent_id {
        cfg.as_object_mut().unwrap().insert(
            "bindings".into(),
            serde_json::json!([
                {
                    "agentId": aid,
                    "match": {
                        "channel": channel_type,
                        "peer": { "id": peer_id, "kind": "channel" }
                    }
                }
            ]),
        );
    }

    remote_write_config_with_snapshot(
        &pool,
        &host_id,
        &current_text,
        &cfg,
        "assign-channel-agent",
    )
    .await?;
    Ok(true)
}

#[tauri::command]
pub async fn remote_set_global_model(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
    model_value: Option<String>,
) -> Result<bool, String> {
    let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
    let mut cfg: Value =
        serde_json::from_str(&raw).map_err(|e| format!("Failed to parse: {e}"))?;
    let current_text = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;

    set_nested_value(
        &mut cfg,
        "agents.defaults.model",
        model_value.map(Value::String),
    )?;
    remote_write_config_with_snapshot(&pool, &host_id, &current_text, &cfg, "set-global-model")
        .await?;
    Ok(true)
}

#[tauri::command]
pub async fn remote_set_agent_model(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
    agent_id: String,
    model_value: Option<String>,
) -> Result<bool, String> {
    if agent_id.trim().is_empty() {
        return Err("agent id is required".into());
    }
    let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
    let mut cfg: Value =
        serde_json::from_str(&raw).map_err(|e| format!("Failed to parse: {e}"))?;
    let current_text = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;

    set_agent_model_value(&mut cfg, &agent_id, model_value)?;
    remote_write_config_with_snapshot(&pool, &host_id, &current_text, &cfg, "set-agent-model")
        .await?;
    Ok(true)
}

#[tauri::command]
pub async fn remote_run_doctor(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
) -> Result<Value, String> {
    let result = pool
        .exec_login(
            &host_id,
            "openclaw doctor --json 2>/dev/null || openclaw doctor 2>&1",
        )
        .await?;
    // Try to parse as JSON first
    if let Ok(json) = serde_json::from_str::<Value>(&result.stdout) {
        return Ok(json);
    }
    // Fallback: return raw output as a simple report
    Ok(serde_json::json!({
        "ok": result.exit_code == 0,
        "score": if result.exit_code == 0 { 100 } else { 0 },
        "issues": [],
        "rawOutput": result.stdout,
    }))
}

#[tauri::command]
pub async fn remote_list_history(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
) -> Result<Value, String> {
    // Ensure dir exists
    pool.exec(&host_id, "mkdir -p ~/.clawpal/snapshots").await?;
    let entries = pool.sftp_list(&host_id, "~/.clawpal/snapshots").await?;
    let mut items: Vec<Value> = Vec::new();
    for entry in entries {
        if entry.name.starts_with('.') || entry.is_dir {
            continue;
        }
        // Parse filename: {timestamp}-{source}.json
        let stem = entry.name.trim_end_matches(".json");
        let (ts_str, source) = stem.split_once('-').unwrap_or((stem, "unknown"));
        let created_at = ts_str.parse::<i64>().unwrap_or(0);
        // Convert Unix timestamp to ISO 8601 format for frontend compatibility
        let created_at_iso = chrono::DateTime::from_timestamp(created_at, 0)
            .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
            .unwrap_or_else(|| created_at.to_string());
        items.push(serde_json::json!({
            "id": entry.name,
            "createdAt": created_at_iso,
            "source": source,
            "canRollback": true,
        }));
    }
    // Sort newest first
    items.sort_by(|a, b| {
        let ta = a["createdAt"].as_str().unwrap_or("");
        let tb = b["createdAt"].as_str().unwrap_or("");
        tb.cmp(ta)
    });
    Ok(serde_json::json!({ "items": items }))
}

#[tauri::command]
pub async fn remote_preview_rollback(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
    snapshot_id: String,
) -> Result<PreviewResult, String> {
    let snapshot_path = format!("~/.clawpal/snapshots/{snapshot_id}");
    let snapshot_text = pool.sftp_read(&host_id, &snapshot_path).await?;
    let target: Value = serde_json::from_str(&snapshot_text)
        .map_err(|e| format!("Failed to parse snapshot: {e}"))?;

    let current_text = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
    let current: Value = serde_json::from_str(&current_text)
        .map_err(|e| format!("Failed to parse config: {e}"))?;

    let before = serde_json::to_string_pretty(&current).unwrap_or_else(|_| "{}".into());
    let after = serde_json::to_string_pretty(&target).unwrap_or_else(|_| "{}".into());
    Ok(PreviewResult {
        recipe_id: "rollback".into(),
        diff: format_diff(&current, &target),
        config_before: before,
        config_after: after,
        changes: collect_change_paths(&current, &target),
        overwrites_existing: true,
        can_rollback: true,
        impact_level: "medium".into(),
        warnings: vec!["Rollback will replace current configuration".into()],
    })
}

#[tauri::command]
pub async fn remote_rollback(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
    snapshot_id: String,
) -> Result<ApplyResult, String> {
    let snapshot_path = format!("~/.clawpal/snapshots/{snapshot_id}");
    let target_text = pool.sftp_read(&host_id, &snapshot_path).await?;
    let target: Value = serde_json::from_str(&target_text)
        .map_err(|e| format!("Failed to parse snapshot: {e}"))?;

    let current_text = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
    remote_write_config_with_snapshot(&pool, &host_id, &current_text, &target, "rollback").await?;

    Ok(ApplyResult {
        ok: true,
        snapshot_id: Some(snapshot_id),
        config_path: "~/.openclaw/openclaw.json".into(),
        backup_path: None,
        warnings: vec!["rolled back".into()],
        errors: Vec::new(),
    })
}

#[tauri::command]
pub async fn remote_list_discord_guild_channels(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
) -> Result<Vec<DiscordGuildChannel>, String> {
    let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
    let cfg: Value = serde_json::from_str(&raw).map_err(|e| format!("Failed to parse remote config: {e}"))?;

    // Extract bot token for Discord API calls
    let bot_token = cfg
        .pointer("/channels/discord/botToken")
        .or_else(|| cfg.pointer("/channels/discord/token"))
        .and_then(Value::as_str)
        .map(|s| s.to_string());

    let guilds = cfg
        .get("channels")
        .and_then(|c| c.get("discord"))
        .and_then(|d| d.get("guilds"))
        .and_then(Value::as_object);

    let Some(guilds) = guilds else {
        return Ok(Vec::new());
    };

    let mut entries: Vec<DiscordGuildChannel> = Vec::new();
    let mut channel_ids: Vec<String> = Vec::new();
    let mut unresolved_guild_ids: Vec<String> = Vec::new();

    for (guild_id, guild_val) in guilds {
        let guild_name = guild_val
            .get("slug")
            .or_else(|| guild_val.get("name"))
            .and_then(Value::as_str)
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
            .unwrap_or_else(|| guild_id.clone());

        if guild_name == *guild_id {
            unresolved_guild_ids.push(guild_id.clone());
        }

        if let Some(channels) = guild_val.get("channels").and_then(Value::as_object) {
            for (channel_id, _) in channels {
                channel_ids.push(channel_id.clone());
                entries.push(DiscordGuildChannel {
                    guild_id: guild_id.clone(),
                    guild_name: guild_name.clone(),
                    channel_id: channel_id.clone(),
                    channel_name: channel_id.clone(),
                });
            }
        }
    }

    // Resolve channel names via openclaw CLI on remote
    if !channel_ids.is_empty() {
        let ids_arg = channel_ids.join(" ");
        let cmd = format!("openclaw channels resolve --json --channel discord --kind auto {}", ids_arg);
        if let Ok(r) = pool.exec_login(&host_id, &cmd).await {
            if r.exit_code == 0 && !r.stdout.trim().is_empty() {
                if let Some(name_map) = parse_resolve_name_map(&r.stdout) {
                    for entry in &mut entries {
                        if let Some(name) = name_map.get(&entry.channel_id) {
                            entry.channel_name = name.clone();
                        }
                    }
                }
            }
        }
    }

    // Resolve guild names via Discord REST API (guild names can't be resolved by openclaw CLI)
    // Must use spawn_blocking because reqwest::blocking panics in async context
    if let Some(token) = bot_token {
        if !unresolved_guild_ids.is_empty() {
            let guild_name_map = tokio::task::spawn_blocking(move || {
                let mut map = std::collections::HashMap::new();
                for gid in &unresolved_guild_ids {
                    if let Ok(name) = fetch_discord_guild_name(&token, gid) {
                        map.insert(gid.clone(), name);
                    }
                }
                map
            }).await.unwrap_or_default();
            for entry in &mut entries {
                if let Some(name) = guild_name_map.get(&entry.guild_id) {
                    entry.guild_name = name.clone();
                }
            }
        }
    }

    Ok(entries)
}

#[tauri::command]
pub async fn remote_write_raw_config(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
    content: String,
) -> Result<bool, String> {
    // Validate it's valid JSON
    let next: Value =
        serde_json::from_str(&content).map_err(|e| format!("Invalid JSON: {e}"))?;
    // Read current for snapshot
    let current = pool
        .sftp_read(&host_id, "~/.openclaw/openclaw.json")
        .await
        .unwrap_or_default();
    remote_write_config_with_snapshot(&pool, &host_id, &current, &next, "raw-edit").await?;
    Ok(true)
}

#[tauri::command]
pub async fn remote_analyze_sessions(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
) -> Result<Vec<AgentSessionAnalysis>, String> {
    // Run a shell script via SSH that scans session files and outputs JSON.
    // This is MUCH faster than doing per-file SFTP reads.
    let script = r#"
cd ~/.openclaw/agents 2>/dev/null || { echo '[]'; exit 0; }
now=$(date +%s)
sep=""
echo "["
for agent_dir in */; do
  [ -d "$agent_dir" ] || continue
  agent="${agent_dir%/}"
  # Sanitize agent name for JSON (escape backslash then double-quote)
  safe_agent=$(printf '%s' "$agent" | sed 's/\\/\\\\/g; s/"/\\"/g')
  for kind in sessions sessions_archive; do
    dir="$agent_dir$kind"
    [ -d "$dir" ] || continue
    for f in "$dir"/*.jsonl; do
      [ -f "$f" ] || continue
      fname=$(basename "$f" .jsonl)
      safe_fname=$(printf '%s' "$fname" | sed 's/\\/\\\\/g; s/"/\\"/g')
      size=$(wc -c < "$f" 2>/dev/null | tr -d ' ')
      msgs=$(grep -c '"type":"message"' "$f" 2>/dev/null || true)
      [ -z "$msgs" ] && msgs=0
      user_msgs=$(grep -c '"role":"user"' "$f" 2>/dev/null || true)
      [ -z "$user_msgs" ] && user_msgs=0
      asst_msgs=$(grep -c '"role":"assistant"' "$f" 2>/dev/null || true)
      [ -z "$asst_msgs" ] && asst_msgs=0
      mtime=$(stat -f %m "$f" 2>/dev/null || stat -c %Y "$f" 2>/dev/null || echo 0)
      age_days=$(( (now - mtime) / 86400 ))
      printf '%s{"agent":"%s","sessionId":"%s","sizeBytes":%s,"messageCount":%s,"userMessageCount":%s,"assistantMessageCount":%s,"ageDays":%s,"kind":"%s"}' \
        "$sep" "$safe_agent" "$safe_fname" "$size" "$msgs" "$user_msgs" "$asst_msgs" "$age_days" "$kind"
      sep=","
    done
  done
done
echo "]"
"#;

    let result = pool.exec(&host_id, script).await?;
    if result.exit_code != 0 && result.stdout.trim().is_empty() {
        // No agents directory — return empty
        return Ok(Vec::new());
    }

    // Parse the JSON output
    let raw_sessions: Vec<Value> = serde_json::from_str(result.stdout.trim())
        .map_err(|e| format!("Failed to parse remote session data: {e}\nOutput: {}", &result.stdout[..result.stdout.len().min(500)]))?;

    // Group by agent and classify
    let mut agent_map: std::collections::BTreeMap<String, Vec<SessionAnalysis>> = std::collections::BTreeMap::new();

    for val in &raw_sessions {
        let agent = val.get("agent").and_then(Value::as_str).unwrap_or("unknown").to_string();
        let session_id = val.get("sessionId").and_then(Value::as_str).unwrap_or("").to_string();
        let size_bytes = val.get("sizeBytes").and_then(Value::as_u64).unwrap_or(0);
        let message_count = val.get("messageCount").and_then(Value::as_u64).unwrap_or(0) as usize;
        let user_message_count = val.get("userMessageCount").and_then(Value::as_u64).unwrap_or(0) as usize;
        let assistant_message_count = val.get("assistantMessageCount").and_then(Value::as_u64).unwrap_or(0) as usize;
        let age_days = val.get("ageDays").and_then(Value::as_f64).unwrap_or(0.0);
        let kind = val.get("kind").and_then(Value::as_str).unwrap_or("sessions").to_string();

        let category = if size_bytes < 500 || message_count == 0 {
            "empty"
        } else if user_message_count <= 1 && age_days > 7.0 {
            "low_value"
        } else {
            "valuable"
        };

        agent_map.entry(agent.clone()).or_default().push(SessionAnalysis {
            agent: agent.clone(),
            session_id,
            file_path: String::new(),
            size_bytes,
            message_count,
            user_message_count,
            assistant_message_count,
            last_activity: None,
            age_days,
            total_tokens: 0,
            model: None,
            category: category.to_string(),
            kind,
        });
    }

    let mut results: Vec<AgentSessionAnalysis> = Vec::new();
    for (agent, mut sessions) in agent_map {
        sessions.sort_by(|a, b| {
            let cat_order = |c: &str| match c { "empty" => 0, "low_value" => 1, _ => 2 };
            cat_order(&a.category).cmp(&cat_order(&b.category))
                .then(b.age_days.partial_cmp(&a.age_days).unwrap_or(std::cmp::Ordering::Equal))
        });
        let total_files = sessions.len();
        let total_size_bytes = sessions.iter().map(|s| s.size_bytes).sum();
        let empty_count = sessions.iter().filter(|s| s.category == "empty").count();
        let low_value_count = sessions.iter().filter(|s| s.category == "low_value").count();
        let valuable_count = sessions.iter().filter(|s| s.category == "valuable").count();

        results.push(AgentSessionAnalysis {
            agent,
            total_files,
            total_size_bytes,
            empty_count,
            low_value_count,
            valuable_count,
            sessions,
        });
    }
    Ok(results)
}

#[tauri::command]
pub async fn remote_delete_sessions_by_ids(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
    agent_id: String,
    session_ids: Vec<String>,
) -> Result<usize, String> {
    if agent_id.trim().is_empty() || agent_id.contains("..") || agent_id.contains('/') {
        return Err("invalid agent id".into());
    }

    let mut deleted = 0usize;
    for sid in &session_ids {
        if sid.contains("..") || sid.contains('/') || sid.contains('\\') {
            continue;
        }
        // Delete from both sessions and sessions_archive
        let cmd = format!(
            "rm -f ~/.openclaw/agents/{agent}/sessions/{sid}.jsonl ~/.openclaw/agents/{agent}/sessions/{sid}-topic-*.jsonl ~/.openclaw/agents/{agent}/sessions_archive/{sid}.jsonl ~/.openclaw/agents/{agent}/sessions_archive/{sid}-topic-*.jsonl 2>/dev/null; echo ok",
            agent = agent_id, sid = sid
        );
        if let Ok(r) = pool.exec(&host_id, &cmd).await {
            if r.stdout.trim() == "ok" {
                deleted += 1;
            }
        }
    }

    // Clean up sessions.json
    let sessions_json_path = format!("~/.openclaw/agents/{}/sessions/sessions.json", agent_id);
    if let Ok(content) = pool.sftp_read(&host_id, &sessions_json_path).await {
        if let Ok(mut data) = serde_json::from_str::<serde_json::Map<String, Value>>(&content) {
            let id_set: HashSet<&str> = session_ids.iter().map(String::as_str).collect();
            data.retain(|_key, val| {
                let sid = val.get("sessionId").and_then(Value::as_str).unwrap_or("");
                !id_set.contains(sid)
            });
            let updated = serde_json::to_string(&data).unwrap_or_default();
            let _ = pool.sftp_write(&host_id, &sessions_json_path, &updated).await;
        }
    }

    Ok(deleted)
}

#[tauri::command]
pub async fn remote_list_session_files(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
) -> Result<Vec<SessionFile>, String> {
    let script = r#"
cd ~/.openclaw/agents 2>/dev/null || { echo "[]"; exit 0; }
echo "["
first=1
for agent_dir in */; do
  agent="${agent_dir%/}"
  for kind in sessions sessions_archive; do
    dir="$agent_dir$kind"
    [ -d "$dir" ] || continue
    for f in "$dir"/*.jsonl; do
      [ -f "$f" ] || continue
      size=$(wc -c < "$f" 2>/dev/null | tr -d ' ')
      if [ "$first" = "1" ]; then first=0; else echo ","; fi
      printf '{"agent":"%s","kind":"%s","path":"%s","sizeBytes":%s}' "$agent" "$kind" "$f" "$size"
    done
  done
done
echo "]"
"#;
    let result = pool.exec(&host_id, script).await?;
    let raw: Vec<Value> = serde_json::from_str(result.stdout.trim())
        .unwrap_or_default();

    let mut out = Vec::new();
    for val in &raw {
        let agent = val.get("agent").and_then(Value::as_str).unwrap_or("").to_string();
        let kind = val.get("kind").and_then(Value::as_str).unwrap_or("sessions").to_string();
        let path = val.get("path").and_then(Value::as_str).unwrap_or("").to_string();
        let size_bytes = val.get("sizeBytes").and_then(Value::as_u64).unwrap_or(0);
        out.push(SessionFile {
            relative_path: path.clone(),
            path,
            agent,
            kind,
            size_bytes,
        });
    }
    Ok(out)
}

#[tauri::command]
pub async fn remote_clear_all_sessions(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
) -> Result<usize, String> {
    let script = r#"
count=0
cd ~/.openclaw/agents 2>/dev/null || { echo "0"; exit 0; }
for agent_dir in */; do
  for kind in sessions sessions_archive; do
    dir="$agent_dir$kind"
    [ -d "$dir" ] || continue
    for f in "$dir"/*; do
      [ -f "$f" ] || continue
      rm -f "$f" && count=$((count + 1))
    done
  done
done
echo "$count"
"#;
    let result = pool.exec(&host_id, script).await?;
    let count: usize = result.stdout.trim().parse().unwrap_or(0);
    Ok(count)
}

#[tauri::command]
pub async fn remote_preview_session(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
    agent_id: String,
    session_id: String,
) -> Result<Vec<Value>, String> {
    if agent_id.contains("..") || agent_id.contains('/') || session_id.contains("..") || session_id.contains('/') {
        return Err("invalid id".into());
    }
    let jsonl_name = format!("{}.jsonl", session_id);

    // Try sessions dir first, then archive
    let paths = [
        format!("~/.openclaw/agents/{}/sessions/{}", agent_id, jsonl_name),
        format!("~/.openclaw/agents/{}/sessions_archive/{}", agent_id, jsonl_name),
    ];

    let mut content = String::new();
    for path in &paths {
        if let Ok(c) = pool.sftp_read(&host_id, path).await {
            content = c;
            break;
        }
    }
    if content.is_empty() {
        return Ok(Vec::new());
    }

    let mut messages: Vec<Value> = Vec::new();
    for line in content.lines() {
        if line.trim().is_empty() {
            continue;
        }
        let obj: Value = match serde_json::from_str(line) {
            Ok(v) => v,
            Err(_) => continue,
        };
        if obj.get("type").and_then(Value::as_str) == Some("message") {
            let role = obj.pointer("/message/role").and_then(Value::as_str).unwrap_or("unknown");
            let content_val = obj.pointer("/message/content")
                .map(|c| {
                    if let Some(arr) = c.as_array() {
                        arr.iter()
                            .filter_map(|item| item.get("text").and_then(Value::as_str))
                            .collect::<Vec<_>>()
                            .join("\n")
                    } else if let Some(s) = c.as_str() {
                        s.to_string()
                    } else {
                        String::new()
                    }
                })
                .unwrap_or_default();
            messages.push(serde_json::json!({
                "role": role,
                "content": content_val,
            }));
        }
    }
    Ok(messages)
}

#[tauri::command]
pub async fn remote_list_model_profiles(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
) -> Result<Vec<ModelProfile>, String> {
    let content = pool.sftp_read(&host_id, "~/.clawpal/model-profiles.json").await
        .unwrap_or_else(|_| r#"{"profiles":[]}"#.to_string());
    #[derive(serde::Deserialize)]
    struct Storage {
        #[serde(default)]
        profiles: Vec<ModelProfile>,
    }
    let parsed: Storage = serde_json::from_str(&content).unwrap_or(Storage { profiles: Vec::new() });
    Ok(parsed.profiles)
}

#[tauri::command]
pub async fn remote_upsert_model_profile(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
    mut profile: ModelProfile,
) -> Result<ModelProfile, String> {
    if profile.provider.trim().is_empty() || profile.model.trim().is_empty() {
        return Err("provider and model are required".into());
    }
    if profile.name.trim().is_empty() {
        profile.name = format!("{}/{}", profile.provider, profile.model);
    }

    // Load existing profiles
    let content = pool.sftp_read(&host_id, "~/.clawpal/model-profiles.json").await
        .unwrap_or_else(|_| r#"{"profiles":[]}"#.to_string());
    #[derive(serde::Deserialize, serde::Serialize)]
    struct Storage {
        #[serde(default)]
        profiles: Vec<ModelProfile>,
        #[serde(default = "default_version")]
        version: u8,
    }
    fn default_version() -> u8 { 1 }
    let mut storage: Storage = serde_json::from_str(&content).unwrap_or(Storage { profiles: Vec::new(), version: 1 });

    if profile.id.trim().is_empty() {
        profile.id = uuid::Uuid::new_v4().to_string();
    }
    let id = profile.id.clone();
    if let Some(existing) = storage.profiles.iter_mut().find(|p| p.id == id) {
        // Preserve existing API key if new one is empty
        if profile.api_key.as_ref().map_or(true, |k| k.trim().is_empty()) {
            profile.api_key = existing.api_key.clone();
        }
        *existing = profile.clone();
    } else {
        // New profile: if no API key provided, try to reuse from same-provider profile
        if profile.api_key.as_ref().map_or(true, |k| k.trim().is_empty()) {
            if let Some(donor) = storage.profiles.iter().find(|p| {
                p.provider == profile.provider
                    && p.api_key.as_ref().is_some_and(|k| !k.trim().is_empty())
            }) {
                profile.api_key = donor.api_key.clone();
            }
        }
        storage.profiles.push(profile.clone());
    }

    // Ensure .clawpal dir exists
    let _ = pool.exec(&host_id, "mkdir -p ~/.clawpal").await;
    let text = serde_json::to_string_pretty(&storage).map_err(|e| e.to_string())?;
    pool.sftp_write(&host_id, "~/.clawpal/model-profiles.json", &text).await?;
    Ok(profile)
}

#[tauri::command]
pub async fn remote_delete_model_profile(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
    profile_id: String,
) -> Result<bool, String> {
    let content = pool.sftp_read(&host_id, "~/.clawpal/model-profiles.json").await
        .unwrap_or_else(|_| r#"{"profiles":[]}"#.to_string());
    #[derive(serde::Deserialize, serde::Serialize)]
    struct Storage {
        #[serde(default)]
        profiles: Vec<ModelProfile>,
        #[serde(default = "default_version")]
        version: u8,
    }
    fn default_version() -> u8 { 1 }
    let mut storage: Storage = serde_json::from_str(&content).unwrap_or(Storage { profiles: Vec::new(), version: 1 });
    let before = storage.profiles.len();
    storage.profiles.retain(|p| p.id != profile_id);
    if storage.profiles.len() == before {
        return Ok(false);
    }
    let text = serde_json::to_string_pretty(&storage).map_err(|e| e.to_string())?;
    pool.sftp_write(&host_id, "~/.clawpal/model-profiles.json", &text).await?;
    Ok(true)
}

#[tauri::command]
pub async fn remote_resolve_api_keys(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
) -> Result<Vec<ResolvedApiKey>, String> {
    let content = pool.sftp_read(&host_id, "~/.clawpal/model-profiles.json").await
        .unwrap_or_else(|_| r#"{"profiles":[]}"#.to_string());
    #[derive(serde::Deserialize)]
    struct Storage {
        #[serde(default)]
        profiles: Vec<ModelProfile>,
    }
    let storage: Storage = serde_json::from_str(&content).unwrap_or(Storage { profiles: Vec::new() });
    let mut out = Vec::new();
    for profile in &storage.profiles {
        let masked = if let Some(ref key) = profile.api_key {
            if key.len() > 8 {
                format!("{}...{}", &key[..4], &key[key.len()-4..])
            } else if !key.is_empty() {
                "****".to_string()
            } else if !profile.auth_ref.is_empty() {
                format!("via {}", profile.auth_ref)
            } else {
                "not set".to_string()
            }
        } else if !profile.auth_ref.is_empty() {
            format!("via {}", profile.auth_ref)
        } else {
            "not set".to_string()
        };
        out.push(ResolvedApiKey {
            profile_id: profile.id.clone(),
            masked_key: masked,
        });
    }
    Ok(out)
}

#[tauri::command]
pub async fn remote_extract_model_profiles_from_config(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
) -> Result<ExtractModelProfilesResult, String> {
    let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
    let cfg: Value = serde_json::from_str(&raw).map_err(|e| format!("Failed to parse remote config: {e}"))?;

    let profiles_raw = pool.sftp_read(&host_id, "~/.clawpal/model-profiles.json").await
        .unwrap_or_else(|_| r#"{"profiles":[]}"#.to_string());
    #[derive(serde::Deserialize)]
    struct StorageIn {
        #[serde(default)]
        profiles: Vec<ModelProfile>,
    }
    let existing: StorageIn = serde_json::from_str(&profiles_raw).unwrap_or(StorageIn { profiles: Vec::new() });
    let profiles = existing.profiles;

    let bindings = collect_model_bindings(&cfg, &profiles);
    let mut created = 0usize;
    let mut reused = 0usize;
    let mut skipped_invalid = 0usize;
    let mut seen = HashSet::new();

    let mut next_profiles = profiles;
    let mut model_profile_map: HashMap<String, String> = HashMap::new();
    for profile in &next_profiles {
        model_profile_map.insert(normalize_model_ref(&profile_to_model_value(profile)), profile.id.clone());
    }

    for binding in bindings {
        let scope_label = match binding.scope.as_str() {
            "global" => "global".to_string(),
            "agent" => format!("agent:{}", binding.scope_id),
            "channel" => format!("channel:{}", binding.scope_id),
            _ => binding.scope_id,
        };
        let Some(model_ref) = binding.model_value else { continue };
        let model_ref = normalize_model_ref(&model_ref);
        if model_ref.trim().is_empty() { continue }
        if model_profile_map.contains_key(&model_ref) || seen.contains(&model_ref) {
            reused += 1;
            continue;
        }
        let mut parts = model_ref.splitn(2, '/');
        let provider = parts.next().unwrap_or("").trim();
        let model = parts.next().unwrap_or("").trim();
        if provider.is_empty() || model.is_empty() {
            skipped_invalid += 1;
            continue;
        }
        let auth_ref = resolve_auth_ref_for_provider(&cfg, provider)
            .unwrap_or_else(|| format!("{provider}:default"));
        let base_url = resolve_model_provider_base_url(&cfg, provider);
        let new_profile = ModelProfile {
            id: uuid::Uuid::new_v4().to_string(),
            name: format!("{scope_label} model profile"),
            provider: provider.to_string(),
            model: model.to_string(),
            auth_ref,
            api_key: None,
            base_url,
            description: Some(format!("Extracted from config ({scope_label})")),
            enabled: true,
        };
        let key = profile_to_model_value(&new_profile);
        model_profile_map.insert(normalize_model_ref(&key), new_profile.id.clone());
        next_profiles.push(new_profile);
        seen.insert(model_ref);
        created += 1;
    }

    if created > 0 {
        #[derive(serde::Serialize)]
        struct StorageOut<'a> {
            profiles: &'a [ModelProfile],
            version: u8,
        }
        let payload = StorageOut { profiles: &next_profiles, version: 1 };
        let text = serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?;
        let _ = pool.exec(&host_id, "mkdir -p ~/.clawpal").await;
        pool.sftp_write(&host_id, "~/.clawpal/model-profiles.json", &text).await?;
    }

    Ok(ExtractModelProfilesResult { created, reused, skipped_invalid })
}

#[tauri::command]
pub async fn remote_refresh_model_catalog(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
) -> Result<Vec<ModelCatalogProvider>, String> {
    let result = pool.exec_login(&host_id, "openclaw models list --all --json --no-color").await;
    if let Ok(r) = result {
        if r.exit_code == 0 && !r.stdout.trim().is_empty() {
            if let Some(catalog) = parse_model_catalog_from_cli_output(&r.stdout) {
                return Ok(catalog);
            }
        }
    }

    // Fallback: extract from remote config
    let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?;
    let cfg: Value = serde_json::from_str(&raw).map_err(|e| format!("Failed to parse remote config: {e}"))?;
    Ok(collect_model_catalog(&cfg))
}

#[tauri::command]
pub async fn run_openclaw_upgrade() -> Result<String, String> {
    let output = Command::new("bash")
        .args(["-c", "curl -fsSL https://openclaw.ai/install.sh | bash"])
        .output()
        .map_err(|e| format!("Failed to run upgrade: {e}"))?;
    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
    let combined = if stderr.is_empty() {
        stdout
    } else {
        format!("{stdout}\n{stderr}")
    };
    if output.status.success() {
        Ok(combined)
    } else {
        Err(combined)
    }
}

#[tauri::command]
pub async fn remote_run_openclaw_upgrade(
    pool: State<'_, SshConnectionPool>,
    host_id: String,
) -> Result<String, String> {
    let result = pool
        .exec_login(
            &host_id,
            "curl -fsSL https://openclaw.ai/install.sh | bash",
        )
        .await?;
    let combined = if result.stderr.is_empty() {
        result.stdout.clone()
    } else {
        format!("{}\n{}", result.stdout, result.stderr)
    };
    if result.exit_code == 0 {
        Ok(combined)
    } else {
        Err(combined)
    }
}
