mod generation;
mod model_manager;
mod preferences;
mod projects;
mod view;

pub(crate) use view::app_theme;

use generation::ChatMessage;
use model_manager::{ActiveDownload, ModelDownload, ModelOperation};
use preferences::PreferenceDraft;
#[cfg(test)]
use preferences::{parse_optional_gib, parse_streaming_cache};

use crate::config::{Config, EndpointConfig};
use crate::database::{Database, ProjectWithSessions, SessionState, StoredMessage};
#[cfg(target_os = "macos")]
use crate::engine::ChatTurn;
use crate::metrics::{KvCacheReport, Metrics, MetricsSnapshot};
use crate::model::{self, DownloadOutcome, DownloadProgress, ManagedArtifactId, ModelChoice};
#[cfg(target_os = "macos")]
use crate::runtime::{ActiveGeneration, CheckpointTarget, GenerationEvent, GenerationService};
use crate::settings::{
    DiagnosticPreferences, ExecutionPreferences, GIB, GenerationPreferences, KvCachePreferences,
    ReasoningMode, RuntimePreferences, SpeculativePreferences, SsdPreferences, SteeringPreferences,
    StreamingCacheBudget,
};
use iced::widget::{markdown, scrollable, text_input};
use iced::{Size, Subscription, Task, keyboard, mouse, window};
use rfd::AsyncFileDialog;
use std::collections::{HashMap, HashSet, VecDeque};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{self, TryRecvError};
use std::sync::{Arc, Mutex, RwLock};
use std::thread;
use std::time::{Duration, Instant};

const APP_ID: &str = "de.rfc1437.ds4server";
const METRICS_SAMPLE_INTERVAL: Duration = Duration::from_millis(200);
/// Disc scans are cheap but not free; the explorer does not need 5 Hz.
const CACHE_SCAN_INTERVAL: Duration = Duration::from_secs(2);
pub(super) const MIN_SIDEBAR_WIDTH: i32 = 180;
pub(super) const MAX_SIDEBAR_WIDTH: i32 = 520;

pub(crate) struct App {
    main_window: window::Id,
    pub(super) model_manager_window: Option<window::Id>,
    pub(super) pending_model_delete: Option<ManagedArtifactId>,
    #[cfg(target_os = "macos")]
    _native_menu: Option<crate::native_menu::NativeMenu>,
    #[cfg(target_os = "macos")]
    pub(super) native_edit_commands: crate::native_edit::EditCommandQueue,
    database: Option<Database>,
    projects: Vec<ProjectWithSessions>,
    config: Config,
    preference_draft: PreferenceDraft,
    preferences_open: bool,
    preference_error: Option<String>,
    selected_project: Option<i32>,
    selected_session: Option<i32>,
    /// Unsaved sessions, keyed by project. A draft only becomes a `sessions` row
    /// when its first chat turn is stored, so empty ones vanish on restart.
    drafts: HashMap<i32, String>,
    /// Session whose quick-actions menu is open.
    session_menu: Option<i32>,
    /// Session being renamed, with the in-progress title.
    session_rename: Option<(i32, String)>,
    /// Projects whose archived sessions are expanded in the sidebar.
    expanded_archives: HashSet<i32>,
    /// True while the sidebar divider is being dragged.
    sidebar_drag: bool,
    choosing_folder: bool,
    pending_project_path: Option<PathBuf>,
    project_name_input: String,
    model_download: ModelDownload,
    pub(super) composer: String,
    pub(super) queued_inputs: VecDeque<String>,
    pub(super) conversation: Vec<ChatMessage>,
    pub(super) generating: bool,
    pub(super) context_used: u32,
    pub(super) context_limit: u32,
    pub(super) tokens_per_second: Option<f32>,
    pub(super) detail_tab: DetailTab,
    metrics: Arc<Metrics>,
    pub(super) metrics_snapshot: MetricsSnapshot,
    pub(super) metrics_history: VecDeque<MetricsPoint>,
    /// Disc usage of the KV cache directories, rescanned while Stats is open.
    pub(super) kv_cache_report: KvCacheReport,
    last_cache_scan: Instant,
    last_http_requests: u64,
    last_ssd_selected_requests: u64,
    last_ssd_requested_bytes: u64,
    last_ssd_wait_ms: u64,
    #[cfg(target_os = "macos")]
    generation_service: Option<GenerationService>,
    #[cfg(target_os = "macos")]
    active_generation: Option<ActiveGeneration>,
    #[cfg(target_os = "macos")]
    active_compaction: Option<generation::CompactionRequest>,
    #[cfg(target_os = "macos")]
    active_tool_check: Option<generation::ToolResultCheck>,
    #[cfg(target_os = "macos")]
    agent_tools: Option<(i32, Arc<Mutex<crate::agent::Tools>>)>,
    #[cfg(target_os = "macos")]
    active_tools: Option<crate::agent::ActiveTools>,
    #[cfg(target_os = "macos")]
    pub(super) tool_cards: Vec<crate::agent::ToolCard>,
    #[cfg(target_os = "macos")]
    pub(super) pending_tool_approval:
        Option<(crate::agent::ApprovalPrompt, std::sync::mpsc::Sender<bool>)>,
    #[cfg(target_os = "macos")]
    active_titling: Option<generation::TitleRequest>,
    #[cfg(target_os = "macos")]
    runtime_config: Arc<RwLock<Config>>,
    #[cfg(target_os = "macos")]
    _endpoint: Option<crate::server::ServerHandle>,
    error: Option<String>,
    pub(super) activity: Option<String>,
    stop_requested: bool,
    system_prompt_seen_at: u32,
    manual_compaction_queued: bool,
    #[cfg(target_os = "macos")]
    skip_compaction_once: bool,
}

#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(super) enum DetailTab {
    #[default]
    Chat,
    Stats,
}

#[derive(Clone, Copy, Debug, Default)]
pub(super) struct MetricsPoint {
    pub(super) decode_tokens_per_second: f32,
    pub(super) prefill_tokens_per_second: f32,
    pub(super) http_requests_per_second: f32,
    pub(super) kv_read_bytes_per_second: f32,
    pub(super) kv_write_bytes_per_second: f32,
    pub(super) ssd_requests_per_second: f32,
    pub(super) ssd_bytes_per_second: f32,
    pub(super) ssd_wait_ms_per_second: f32,
}

#[derive(Debug, Clone)]
pub(crate) enum Message {
    Noop,
    #[cfg(target_os = "macos")]
    NativeEdit(crate::native_edit::EditCommand),
    OpenPreferences,
    OpenModelManager,
    ModelManagerOpened(window::Id),
    WindowOpened(window::Id),
    WindowClosed(window::Id),
    DismissPanel,
    /// Tab and shift-tab: iced leaves the key to the application, so the fields
    /// of a form are only linked once we move the focus ourselves.
    FocusNext,
    FocusPrevious,
    PreferenceModelChanged(ModelChoice),
    PreferenceLegacyMtpChanged(bool),
    PreferenceDsparkChanged(bool),
    PreferenceTimeoutChanged(String),
    PreferenceEndpointPortChanged(String),
    PreferenceEndpointEnabledChanged(bool),
    PreferenceEndpointCorsChanged(bool),
    PreferenceContextChanged(String),
    PreferenceMaxTokensChanged(String),
    PreferenceSystemPromptChanged(String),
    PreferenceTemperatureChanged(String),
    PreferenceTopPChanged(String),
    PreferenceMinPChanged(String),
    PreferenceSeedChanged(String),
    PreferenceReasoningChanged(ReasoningMode),
    PreferenceCpuThreadsChanged(String),
    PreferencePowerChanged(String),
    PreferencePrefillChunkChanged(String),
    PreferenceQualityChanged(bool),
    PreferenceWarmWeightsChanged(bool),
    PreferenceMtpDraftChanged(String),
    PreferenceMtpMarginChanged(String),
    PreferenceGlmMtpChanged(bool),
    PreferenceGlmMtpTimingChanged(bool),
    PreferenceDsparkConfidenceChanged(String),
    PreferenceDsparkStrictChanged(bool),
    PreferenceSsdChanged(bool),
    PreferenceSsdColdChanged(bool),
    PreferenceSsdCacheChanged(String),
    PreferenceSsdFullLayersChanged(String),
    PreferenceSsdPreloadChanged(String),
    PreferenceSteeringFileChanged(String),
    PreferenceSteeringFfnChanged(String),
    PreferenceSteeringAttnChanged(String),
    PreferenceSimulatedMemoryChanged(String),
    PreferenceExpertProfileChanged(String),
    PreferenceKvBudgetChanged(String),
    PreferenceKvMinTokensChanged(String),
    PreferenceKvColdMaxChanged(String),
    PreferenceKvContinuedIntervalChanged(String),
    ResetPreferences,
    SavePreferences,
    DownloadArtifact(ManagedArtifactId),
    ValidateArtifact(ManagedArtifactId),
    DeleteArtifact(ManagedArtifactId),
    ConfirmDeleteArtifact,
    CancelDeleteArtifact,
    StopModelDownload,
    DownloadProgressTick,
    ComposerChanged(String),
    ToggleReasoning(usize),
    OpenLink(markdown::Url),
    CopyToolText(String),
    OpenToolOutput(PathBuf),
    AllowToolOnce,
    DenyTool,
    SubmitPrompt,
    StopGeneration,
    GenerationTick,
    ChooseProjectFolder,
    ProjectFolderPicked(Option<PathBuf>),
    ProjectNameChanged(String),
    ConfirmProject,
    CancelProject,
    ToggleProject(i32),
    DeleteProject(i32),
    CreateSession(i32),
    DiscardSession(i32),
    SelectSession(i32, i32),
    DeleteSession(i32),
    OpenSessionMenu(i32),
    StartRenameSession(i32),
    SessionTitleChanged(String),
    ConfirmRenameSession,
    RetitleSession(i32),
    CompactSession(i32),
    RebuildSessionContext(i32),
    SetSessionState(i32, SessionState),
    ToggleArchivedSessions(i32),
    ShowChat,
    ShowStats,
    ToggleSidebar,
    StartSidebarDrag,
    DragSidebar(f32),
    EndSidebarDrag,
    MetricsTick,
    DiscardCacheEntry(PathBuf),
    ClearTransientCache,
}

impl App {
    pub(crate) fn load(main_window: window::Id) -> Self {
        let config = match Config::load(&config_path()) {
            Ok(config) => config,
            Err(error) => return Self::failed(error, main_window),
        };
        let path = application_support_path().join("data.sqlite3");
        match Database::open(&path) {
            Ok(mut database) => match database.load_projects() {
                Ok(projects) => {
                    let preference_draft = PreferenceDraft::from_saved(&config);
                    // Before the counters are taken, so they measure what is
                    // actually reachable.
                    sweep_orphan_checkpoints(&kv_cache_path(), &projects);
                    #[cfg(target_os = "macos")]
                    crate::engine::sweep_transient_cache(&transient_cache_path());
                    let context_limit = config.generation.context_tokens.max(0) as u32;
                    // Reopen on the project we left, with a fresh draft chat.
                    let last_project = config
                        .interface
                        .last_project_id
                        .filter(|id| projects.iter().any(|item| item.project.id == *id));
                    let drafts = last_project
                        .map(|id| (id, projects::draft_title(&projects, id)))
                        .into_iter()
                        .collect();
                    let metrics =
                        Arc::new(Metrics::new(&application_support_path().join("kv-cache")));
                    let metrics_snapshot = metrics.snapshot();
                    #[cfg(target_os = "macos")]
                    let (runtime_config, generation_service, endpoint, service_error) =
                        spawn_services(&config, Arc::clone(&metrics));
                    Self {
                        main_window,
                        model_manager_window: None,
                        pending_model_delete: None,
                        #[cfg(target_os = "macos")]
                        _native_menu: None,
                        #[cfg(target_os = "macos")]
                        native_edit_commands: crate::native_edit::command_queue(),
                        database: Some(database),
                        projects,
                        config,
                        preference_draft,
                        preferences_open: false,
                        preference_error: None,
                        selected_project: last_project,
                        selected_session: None,
                        drafts,
                        session_menu: None,
                        session_rename: None,
                        expanded_archives: HashSet::new(),
                        sidebar_drag: false,
                        choosing_folder: false,
                        pending_project_path: None,
                        project_name_input: String::new(),
                        model_download: ModelDownload::Idle,
                        composer: String::new(),
                        queued_inputs: VecDeque::new(),
                        conversation: Vec::new(),
                        generating: false,
                        context_used: 0,
                        context_limit,
                        tokens_per_second: None,
                        detail_tab: DetailTab::Chat,
                        metrics,
                        metrics_snapshot,
                        metrics_history: VecDeque::with_capacity(120),
                        kv_cache_report: KvCacheReport::default(),
                        last_cache_scan: Instant::now() - CACHE_SCAN_INTERVAL,
                        last_http_requests: 0,
                        last_ssd_selected_requests: 0,
                        last_ssd_requested_bytes: 0,
                        last_ssd_wait_ms: 0,
                        #[cfg(target_os = "macos")]
                        generation_service,
                        #[cfg(target_os = "macos")]
                        active_generation: None,
                        #[cfg(target_os = "macos")]
                        active_compaction: None,
                        active_tool_check: None,
                        #[cfg(target_os = "macos")]
                        agent_tools: None,
                        #[cfg(target_os = "macos")]
                        active_tools: None,
                        #[cfg(target_os = "macos")]
                        tool_cards: Vec::new(),
                        #[cfg(target_os = "macos")]
                        pending_tool_approval: None,
                        #[cfg(target_os = "macos")]
                        active_titling: None,
                        #[cfg(target_os = "macos")]
                        runtime_config,
                        #[cfg(target_os = "macos")]
                        _endpoint: endpoint,
                        error: {
                            #[cfg(target_os = "macos")]
                            {
                                service_error
                            }
                            #[cfg(not(target_os = "macos"))]
                            {
                                None
                            }
                        },
                        activity: None,
                        stop_requested: false,
                        system_prompt_seen_at: 0,
                        manual_compaction_queued: false,
                        #[cfg(target_os = "macos")]
                        skip_compaction_once: false,
                    }
                }
                Err(error) => Self::failed(
                    format!("Could not open the project database: {error}"),
                    main_window,
                ),
            },
            Err(error) => Self::failed(
                format!("Could not open the project database: {error}"),
                main_window,
            ),
        }
    }

    fn failed(error: String, main_window: window::Id) -> Self {
        let config = Config::default();
        let preference_draft = PreferenceDraft::from_saved(&config);
        let context_limit = config.generation.context_tokens.max(0) as u32;
        let metrics = Arc::new(Metrics::new(&application_support_path().join("kv-cache")));
        let metrics_snapshot = metrics.snapshot();
        #[cfg(target_os = "macos")]
        let (runtime_config, generation_service, endpoint, service_error) =
            spawn_services(&config, Arc::clone(&metrics));
        #[cfg(target_os = "macos")]
        let startup_error = service_error;
        #[cfg(not(target_os = "macos"))]
        let startup_error = None::<String>;
        Self {
            main_window,
            model_manager_window: None,
            pending_model_delete: None,
            #[cfg(target_os = "macos")]
            _native_menu: None,
            #[cfg(target_os = "macos")]
            native_edit_commands: crate::native_edit::command_queue(),
            database: None,
            projects: Vec::new(),
            config,
            preference_draft,
            preferences_open: false,
            preference_error: None,
            selected_project: None,
            selected_session: None,
            drafts: HashMap::new(),
            session_menu: None,
            session_rename: None,
            expanded_archives: HashSet::new(),
            sidebar_drag: false,
            choosing_folder: false,
            pending_project_path: None,
            project_name_input: String::new(),
            model_download: ModelDownload::Idle,
            composer: String::new(),
            queued_inputs: VecDeque::new(),
            conversation: Vec::new(),
            generating: false,
            context_used: 0,
            context_limit,
            tokens_per_second: None,
            detail_tab: DetailTab::Chat,
            metrics,
            metrics_snapshot,
            metrics_history: VecDeque::with_capacity(120),
            kv_cache_report: KvCacheReport::default(),
            last_cache_scan: Instant::now() - CACHE_SCAN_INTERVAL,
            last_http_requests: 0,
            last_ssd_selected_requests: 0,
            last_ssd_requested_bytes: 0,
            last_ssd_wait_ms: 0,
            #[cfg(target_os = "macos")]
            generation_service,
            #[cfg(target_os = "macos")]
            active_generation: None,
            #[cfg(target_os = "macos")]
            active_compaction: None,
            active_tool_check: None,
            #[cfg(target_os = "macos")]
            agent_tools: None,
            #[cfg(target_os = "macos")]
            active_tools: None,
            #[cfg(target_os = "macos")]
            tool_cards: Vec::new(),
            #[cfg(target_os = "macos")]
            pending_tool_approval: None,
            #[cfg(target_os = "macos")]
            active_titling: None,
            #[cfg(target_os = "macos")]
            runtime_config,
            #[cfg(target_os = "macos")]
            _endpoint: endpoint,
            error: Some(match startup_error {
                Some(service_error) => format!("{error} {service_error}"),
                None => error,
            }),
            activity: None,
            stop_requested: false,
            system_prompt_seen_at: 0,
            manual_compaction_queued: false,
            #[cfg(target_os = "macos")]
            skip_compaction_once: false,
        }
    }

    pub(crate) fn update(&mut self, message: Message) -> Task<Message> {
        match message {
            Message::Noop => {}
            #[cfg(target_os = "macos")]
            Message::NativeEdit(command) => {
                crate::native_edit::queue_command(&self.native_edit_commands, command)
            }
            Message::OpenPreferences => self.open_preferences(),
            Message::OpenModelManager => return self.open_model_manager(),
            Message::ModelManagerOpened(id) => {
                if self.model_manager_window == Some(id) {
                    return window::gain_focus(id);
                }
            }
            Message::WindowOpened(id) => {
                #[cfg(target_os = "macos")]
                if id == self.main_window && self._native_menu.is_none() {
                    match crate::native_menu::install() {
                        Ok(menu) => self._native_menu = Some(menu),
                        Err(error) => {
                            self.error =
                                Some(format!("Could not install the application menu: {error}"))
                        }
                    }
                }
                if id == self.main_window && self.selected_project.is_some() {
                    return focus_composer();
                }
            }
            Message::WindowClosed(id) => {
                if id == self.main_window {
                    return iced::exit();
                }
                if self.model_manager_window == Some(id) {
                    self.model_manager_window = None;
                    self.pending_model_delete = None;
                }
            }
            Message::DismissPanel => {
                if self.session_rename.is_some() || self.session_menu.is_some() {
                    self.session_rename = None;
                    self.session_menu = None;
                } else if self.preferences_open {
                    self.preferences_open = false;
                    self.preference_error = None;
                } else if self.pending_project_path.is_some() {
                    self.pending_project_path = None;
                    self.project_name_input.clear();
                    self.error = None;
                }
            }
            Message::FocusNext => return iced::widget::focus_next().chain(reveal_focused()),
            Message::FocusPrevious => {
                return iced::widget::focus_previous().chain(reveal_focused());
            }
            Message::ShowChat => self.detail_tab = DetailTab::Chat,
            Message::ShowStats => {
                self.detail_tab = DetailTab::Stats;
                self.scan_kv_cache();
            }
            Message::DiscardCacheEntry(path) => self.discard_cache_entry(&path),
            Message::ClearTransientCache => self.clear_transient_cache(),
            // The sidebar starts at the window's left edge, so the cursor's x is
            // the width the user is asking for. Only the final width is stored.
            Message::StartSidebarDrag => self.sidebar_drag = true,
            Message::DragSidebar(x) => {
                if self.sidebar_drag {
                    self.config.interface.sidebar_width =
                        (x.round() as i32).clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH);
                }
            }
            Message::EndSidebarDrag => {
                if self.sidebar_drag {
                    self.sidebar_drag = false;
                    self.store_config();
                }
            }
            Message::ToggleSidebar => {
                self.config.interface.sidebar_collapsed = !self.config.interface.sidebar_collapsed;
                self.store_config();
            }
            Message::MetricsTick => self.sample_metrics(),
            Message::PreferenceModelChanged(model) => {
                self.preference_draft.model = model;
                if !model.supports_dspark() {
                    self.preference_draft.legacy_mtp_enabled = false;
                    self.preference_draft.dspark_enabled = false;
                    self.preference_draft.dspark_confidence_threshold.clear();
                    self.preference_draft.dspark_strict = false;
                }
                if model == ModelChoice::Glm52 {
                    self.preference_draft.power_percent.clear();
                    self.preference_draft.prefill_chunk.clear();
                    self.preference_draft.directional_steering_file.clear();
                    self.preference_draft.directional_steering_ffn.clear();
                    self.preference_draft.directional_steering_attn.clear();
                } else {
                    self.preference_draft.glm_mtp = false;
                    self.preference_draft.glm_mtp_timing = false;
                    self.preference_draft.ssd_full_layers.clear();
                }
                self.preference_error = None;
            }
            Message::PreferenceLegacyMtpChanged(enabled) => {
                self.preference_draft.legacy_mtp_enabled =
                    self.preference_draft.model.supports_dspark() && enabled;
                if self.preference_draft.legacy_mtp_enabled {
                    self.preference_draft.dspark_enabled = false;
                    self.preference_draft.dspark_confidence_threshold.clear();
                    self.preference_draft.dspark_strict = false;
                }
                self.preference_error = None;
            }
            Message::PreferenceDsparkChanged(enabled) => {
                self.preference_draft.dspark_enabled =
                    self.preference_draft.model.supports_dspark() && enabled;
                if !self.preference_draft.dspark_enabled {
                    self.preference_draft.dspark_confidence_threshold.clear();
                    self.preference_draft.dspark_strict = false;
                } else {
                    self.preference_draft.legacy_mtp_enabled = false;
                }
                self.preference_error = None;
            }
            Message::PreferenceTimeoutChanged(value) => {
                self.preference_draft.idle_timeout_minutes = value;
                self.preference_error = None;
            }
            Message::PreferenceEndpointPortChanged(value) => {
                self.preference_draft.endpoint_port = value;
                self.preference_error = None;
            }
            Message::PreferenceEndpointEnabledChanged(enabled) => {
                self.preference_draft.endpoint_enabled = enabled;
                self.preference_error = None;
            }
            Message::PreferenceEndpointCorsChanged(enabled) => {
                self.preference_draft.endpoint_cors = enabled;
                self.preference_error = None;
            }
            Message::PreferenceContextChanged(value) => {
                self.preference_draft.context_tokens = value;
                self.preference_error = None;
            }
            Message::PreferenceMaxTokensChanged(value) => {
                self.preference_draft.max_generated_tokens = value;
                self.preference_error = None;
            }
            Message::PreferenceSystemPromptChanged(value) => {
                self.preference_draft.system_prompt = value;
                self.preference_error = None;
            }
            Message::PreferenceTemperatureChanged(value) => {
                self.preference_draft.temperature = value;
                self.preference_error = None;
            }
            Message::PreferenceTopPChanged(value) => {
                self.preference_draft.top_p = value;
                self.preference_error = None;
            }
            Message::PreferenceMinPChanged(value) => {
                self.preference_draft.min_p = value;
                self.preference_error = None;
            }
            Message::PreferenceSeedChanged(value) => {
                self.preference_draft.seed = value;
                self.preference_error = None;
            }
            Message::PreferenceReasoningChanged(value) => {
                self.preference_draft.reasoning_mode = value;
                self.preference_error = None;
            }
            Message::PreferenceCpuThreadsChanged(value) => {
                self.preference_draft.cpu_threads = value;
                self.preference_error = None;
            }
            Message::PreferencePowerChanged(value) => {
                self.preference_draft.power_percent = value;
                self.preference_error = None;
            }
            Message::PreferencePrefillChunkChanged(value) => {
                self.preference_draft.prefill_chunk = value;
                self.preference_error = None;
            }
            Message::PreferenceQualityChanged(value) => {
                self.preference_draft.quality = value;
                self.preference_error = None;
            }
            Message::PreferenceWarmWeightsChanged(value) => {
                self.preference_draft.warm_weights = value;
                self.preference_error = None;
            }
            Message::PreferenceMtpDraftChanged(value) => {
                self.preference_draft.mtp_draft_tokens = value;
                self.preference_error = None;
            }
            Message::PreferenceMtpMarginChanged(value) => {
                self.preference_draft.mtp_margin = value;
                self.preference_error = None;
            }
            Message::PreferenceGlmMtpChanged(value) => {
                self.preference_draft.glm_mtp =
                    self.preference_draft.model == ModelChoice::Glm52 && value;
                if !self.preference_draft.glm_mtp {
                    self.preference_draft.glm_mtp_timing = false;
                }
                self.preference_error = None;
            }
            Message::PreferenceGlmMtpTimingChanged(value) => {
                self.preference_draft.glm_mtp_timing =
                    self.preference_draft.model == ModelChoice::Glm52 && value;
                if self.preference_draft.glm_mtp_timing {
                    self.preference_draft.glm_mtp = true;
                }
                self.preference_error = None;
            }
            Message::PreferenceDsparkConfidenceChanged(value) => {
                self.preference_draft.dspark_confidence_threshold = value;
                if self.preference_draft.model.supports_dspark()
                    && !self
                        .preference_draft
                        .dspark_confidence_threshold
                        .trim()
                        .is_empty()
                {
                    self.preference_draft.dspark_enabled = true;
                    self.preference_draft.legacy_mtp_enabled = false;
                }
                self.preference_error = None;
            }
            Message::PreferenceDsparkStrictChanged(value) => {
                self.preference_draft.dspark_strict =
                    self.preference_draft.model.supports_dspark() && value;
                if self.preference_draft.dspark_strict {
                    self.preference_draft.dspark_enabled = true;
                    self.preference_draft.legacy_mtp_enabled = false;
                }
                self.preference_error = None;
            }
            Message::PreferenceSsdChanged(value) => {
                self.preference_draft.ssd_streaming = value;
                self.preference_error = None;
            }
            Message::PreferenceSsdColdChanged(value) => {
                self.preference_draft.ssd_streaming_cold = value;
                self.preference_error = None;
            }
            Message::PreferenceSsdCacheChanged(value) => {
                self.preference_draft.ssd_cache = value;
                self.preference_error = None;
            }
            Message::PreferenceSsdFullLayersChanged(value) => {
                if self.preference_draft.model == ModelChoice::Glm52 {
                    self.preference_draft.ssd_full_layers = value;
                }
                self.preference_error = None;
            }
            Message::PreferenceSsdPreloadChanged(value) => {
                self.preference_draft.ssd_preload_experts = value;
                self.preference_error = None;
            }
            Message::PreferenceSteeringFileChanged(value) => {
                if self.preference_draft.model != ModelChoice::Glm52 {
                    self.preference_draft.directional_steering_file = value;
                }
                self.preference_error = None;
            }
            Message::PreferenceSteeringFfnChanged(value) => {
                if self.preference_draft.model != ModelChoice::Glm52 {
                    self.preference_draft.directional_steering_ffn = value;
                }
                self.preference_error = None;
            }
            Message::PreferenceSteeringAttnChanged(value) => {
                if self.preference_draft.model != ModelChoice::Glm52 {
                    self.preference_draft.directional_steering_attn = value;
                }
                self.preference_error = None;
            }
            Message::PreferenceSimulatedMemoryChanged(value) => {
                self.preference_draft.simulated_used_memory_gib = value;
                self.preference_error = None;
            }
            Message::PreferenceExpertProfileChanged(value) => {
                self.preference_draft.expert_profile_path = value;
                self.preference_error = None;
            }
            Message::PreferenceKvBudgetChanged(value) => {
                self.preference_draft.kv_budget_gib = value;
                self.preference_error = None;
            }
            Message::PreferenceKvMinTokensChanged(value) => {
                self.preference_draft.kv_min_tokens = value;
                self.preference_error = None;
            }
            Message::PreferenceKvColdMaxChanged(value) => {
                self.preference_draft.kv_cold_max_tokens = value;
                self.preference_error = None;
            }
            Message::PreferenceKvContinuedIntervalChanged(value) => {
                self.preference_draft.kv_continued_interval_tokens = value;
                self.preference_error = None;
            }
            Message::ResetPreferences => {
                self.preference_draft.reset();
                self.preference_error = None;
            }
            Message::SavePreferences => self.save_preferences(),
            Message::DownloadArtifact(artifact) => {
                self.start_model_operation(artifact, ModelOperation::Download)
            }
            Message::ValidateArtifact(artifact) => {
                self.start_model_operation(artifact, ModelOperation::Validate)
            }
            Message::DeleteArtifact(artifact) => self.pending_model_delete = Some(artifact),
            Message::ConfirmDeleteArtifact => {
                if let Some(artifact) = self.pending_model_delete.take() {
                    match model::delete_managed_artifact(artifact, &models_path()) {
                        Ok(()) => {
                            self.model_download = ModelDownload::Idle;
                            self.error = None;
                        }
                        Err(error) => {
                            self.error = Some(format!("Could not delete {artifact}: {error}"))
                        }
                    }
                }
            }
            Message::CancelDeleteArtifact => self.pending_model_delete = None,
            Message::StopModelDownload => {
                if let ModelDownload::Active(download) = &mut self.model_download {
                    download.stopping = true;
                    download.cancel.store(true, Ordering::Relaxed);
                }
            }
            Message::DownloadProgressTick => self.update_download_progress(),
            Message::ComposerChanged(value) => self.composer = value,
            Message::ToggleReasoning(index) => {
                if let Some(message) = self.conversation.get_mut(index)
                    && message.reasoning.is_some()
                {
                    message.reasoning_open = !message.reasoning_open;
                }
            }
            Message::OpenLink(url) => {
                if matches!(url.scheme(), "http" | "https")
                    && let Err(error) = std::process::Command::new("open").arg(url.as_str()).spawn()
                {
                    self.error = Some(format!("Could not open the link: {error}"));
                }
            }
            Message::CopyToolText(value) => return iced::clipboard::write(value),
            Message::OpenToolOutput(path) => {
                if let Err(error) = std::process::Command::new("open").arg(path).spawn() {
                    self.error = Some(format!("Could not open the tool output: {error}"));
                }
            }
            Message::AllowToolOnce => {
                #[cfg(target_os = "macos")]
                if let Some((_, decision)) = self.pending_tool_approval.take() {
                    let _ = decision.send(true);
                }
            }
            Message::DenyTool =>
            {
                #[cfg(target_os = "macos")]
                if let Some((_, decision)) = self.pending_tool_approval.take() {
                    let _ = decision.send(false);
                }
            }
            Message::SubmitPrompt => {
                self.start_generation();
                return scroll_chat_to_end();
            }
            Message::StopGeneration => {
                self.stop_requested = true;
                self.activity = Some("Stopping…".into());
                #[cfg(target_os = "macos")]
                if let Some(active) = &self.active_generation {
                    active.cancel.store(true, Ordering::Relaxed);
                }
                #[cfg(target_os = "macos")]
                if let Some(active) = &self.active_tools {
                    active.cancel.store(true, Ordering::Relaxed);
                }
                #[cfg(target_os = "macos")]
                if let Some((_, decision)) = self.pending_tool_approval.take() {
                    let _ = decision.send(false);
                }
                #[cfg(target_os = "macos")]
                self.stop_agent_jobs();
                #[cfg(target_os = "macos")]
                if let Some(compaction) = &self.active_compaction {
                    compaction.active.cancel.store(true, Ordering::Relaxed);
                }
                #[cfg(target_os = "macos")]
                if let Some(check) = &self.active_tool_check {
                    check.active.cancel.store(true, Ordering::Relaxed);
                }
            }
            Message::GenerationTick => {
                #[cfg(target_os = "macos")]
                self.poll_titling();
                if self.poll_generation() {
                    return scroll_chat_to_end();
                }
            }
            Message::ChooseProjectFolder => {
                self.choosing_folder = true;
                return Task::perform(
                    async {
                        AsyncFileDialog::new()
                            .set_title("Choose a project folder")
                            .pick_folder()
                            .await
                            .map(|folder| folder.path().to_path_buf())
                    },
                    Message::ProjectFolderPicked,
                );
            }
            Message::ProjectFolderPicked(path) => {
                self.choosing_folder = false;
                if let Some(path) = path {
                    self.prepare_project(path);
                }
            }
            Message::ProjectNameChanged(value) => self.project_name_input = value,
            Message::ConfirmProject => self.create_project(),
            Message::CancelProject => {
                self.pending_project_path = None;
                self.project_name_input.clear();
                self.error = None;
            }
            // Sidebar-only: collapsing a project leaves the open chat alone.
            Message::ToggleProject(project_id) => {
                let Some(item) = self
                    .projects
                    .iter_mut()
                    .find(|item| item.project.id == project_id)
                else {
                    return Task::none();
                };
                let collapsed = !item.project.collapsed;
                item.project.collapsed = collapsed;
                if let Some(database) = &mut self.database
                    && let Err(error) = database.set_project_collapsed(project_id, collapsed)
                {
                    self.error = Some(error);
                }
            }
            Message::DeleteProject(project_id) => {
                if self.generating && self.selected_project == Some(project_id) {
                    self.error =
                        Some("Stop the active generation before deleting its project.".into());
                    return Task::none();
                }
                let checkpoint_ids = self
                    .projects
                    .iter()
                    .find(|item| item.project.id == project_id)
                    .map(|item| {
                        item.sessions
                            .iter()
                            .map(|session| session.id)
                            .collect::<Vec<_>>()
                    })
                    .unwrap_or_default();
                if let Some(database) = &mut self.database {
                    match database.delete_project(project_id) {
                        Ok(()) => {
                            for session_id in checkpoint_ids {
                                let _ = fs::remove_file(session_checkpoint_path(session_id));
                            }
                            self.drafts.remove(&project_id);
                            if self.config.interface.last_project_id == Some(project_id) {
                                self.config.interface.last_project_id = None;
                                let _ = self.config.save(&config_path());
                            }
                            if self.selected_project == Some(project_id) {
                                self.selected_project = None;
                                self.selected_session = None;
                                self.conversation.clear();
                                self.composer.clear();
                                self.queued_inputs.clear();
                                self.system_prompt_seen_at = 0;
                                self.context_used = 0;
                                self.tokens_per_second = None;
                            }
                            self.reload_projects();
                            self.finish_cache_change();
                        }
                        Err(error) => self.error = Some(error),
                    }
                }
            }
            Message::CreateSession(project_id) => {
                self.create_session(project_id);
                if self.draft_selected(project_id) {
                    return focus_composer();
                }
            }
            Message::DiscardSession(project_id) => self.discard_session(project_id),
            Message::OpenSessionMenu(session_id) => {
                self.session_rename = None;
                self.session_menu = if self.session_menu == Some(session_id) {
                    None
                } else {
                    Some(session_id)
                };
            }
            Message::StartRenameSession(session_id) => {
                let title = self
                    .projects
                    .iter()
                    .flat_map(|project| &project.sessions)
                    .find(|session| session.id == session_id)
                    .map(|session| session.title.clone())
                    .unwrap_or_default();
                self.session_menu = None;
                self.session_rename = Some((session_id, title));
            }
            Message::SessionTitleChanged(title) => {
                if let Some((_, draft)) = &mut self.session_rename {
                    *draft = title;
                }
            }
            Message::ConfirmRenameSession => {
                // Keep the dialog open on failure so the edit is not lost.
                let Some((session_id, title)) = self.session_rename.clone() else {
                    return Task::none();
                };
                if let Some(database) = &mut self.database {
                    match database.rename_session(session_id, &title) {
                        Ok(()) => {
                            self.session_rename = None;
                            self.error = None;
                            self.reload_projects();
                        }
                        Err(error) => self.error = Some(error),
                    }
                }
            }
            Message::RetitleSession(session_id) => {
                self.session_menu = None;
                #[cfg(target_os = "macos")]
                self.retitle_session(session_id);
                #[cfg(not(target_os = "macos"))]
                {
                    let _ = session_id;
                    self.error = Some("Local Metal generation requires macOS.".into());
                }
            }
            Message::CompactSession(session_id) => {
                self.session_menu = None;
                if !self.can_compact_session(session_id) {
                    self.error =
                        Some("Open an idle session with new chat before compacting it.".into());
                } else {
                    #[cfg(target_os = "macos")]
                    if let Err(error) = self.start_compaction(
                        generation::PendingContinuation::None,
                        "manual compact action",
                    ) {
                        self.error = Some(error);
                    }
                    #[cfg(not(target_os = "macos"))]
                    {
                        self.error = Some("Local Metal generation requires macOS.".into());
                    }
                }
            }
            Message::RebuildSessionContext(session_id) => {
                self.session_menu = None;
                if self.generating && self.selected_session == Some(session_id) {
                    self.error =
                        Some("Stop the active generation before rebuilding context.".into());
                } else {
                    match fs::remove_file(session_checkpoint_path(session_id)) {
                        Ok(()) => {
                            self.error = None;
                            self.finish_cache_change();
                        }
                        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
                            self.error = None;
                        }
                        Err(error) => {
                            self.error = Some(format!("Could not discard the checkpoint: {error}"));
                        }
                    }
                }
            }
            Message::SetSessionState(session_id, state) => {
                self.session_menu = None;
                if let Some(database) = &mut self.database {
                    match database.set_session_state(session_id, state) {
                        Ok(()) => {
                            self.error = None;
                            self.reload_projects();
                        }
                        Err(error) => self.error = Some(error),
                    }
                }
            }
            Message::ToggleArchivedSessions(project_id) => {
                if !self.expanded_archives.remove(&project_id) {
                    self.expanded_archives.insert(project_id);
                }
            }
            Message::SelectSession(project_id, session_id) => {
                if self.generating && self.selected_session != Some(session_id) {
                    self.error =
                        Some("Stop the active generation before changing sessions.".into());
                    return Task::none();
                }
                if self.selected_session == Some(session_id) {
                    return Task::none();
                }
                #[cfg(target_os = "macos")]
                self.stop_agent_jobs();
                #[cfg(target_os = "macos")]
                self.tool_cards.clear();
                let saved_context = self
                    .projects
                    .iter()
                    .flat_map(|project| &project.sessions)
                    .find(|session| session.id == session_id)
                    .map(|session| {
                        (
                            session.context_used,
                            session.context_limit,
                            session.last_tokens_per_second,
                        )
                    });
                let Some(database) = &mut self.database else {
                    return Task::none();
                };
                match database.load_messages(session_id) {
                    Ok(messages) => {
                        self.conversation = messages.into_iter().map(ChatMessage::from).collect();
                        self.composer.clear();
                        self.remember_project(project_id);
                        self.selected_session = Some(session_id);
                        self.system_prompt_seen_at = 0;
                        self.queued_inputs.clear();
                        let (used, limit, tokens_per_second) = saved_context.unwrap_or_default();
                        self.context_used = used.max(0) as u32;
                        self.context_limit = if limit > 0 {
                            limit as u32
                        } else {
                            self.config.generation.context_tokens.max(0) as u32
                        };
                        self.tokens_per_second = tokens_per_second;
                        self.error = None;
                        return scroll_chat_to_end();
                    }
                    Err(error) => {
                        self.error = Some(format!("Could not load the chat session: {error}"));
                    }
                }
            }
            Message::DeleteSession(session_id) => {
                if self.generating && self.selected_session == Some(session_id) {
                    self.error =
                        Some("Stop the active generation before deleting its session.".into());
                    return Task::none();
                }
                if let Some(database) = &mut self.database {
                    match database.delete_session(session_id) {
                        Ok(()) => {
                            let _ = fs::remove_file(session_checkpoint_path(session_id));
                            self.finish_cache_change();
                            if self.session_menu == Some(session_id) {
                                self.session_menu = None;
                            }
                            if self
                                .session_rename
                                .as_ref()
                                .is_some_and(|(id, _)| *id == session_id)
                            {
                                self.session_rename = None;
                            }
                            if self.selected_session == Some(session_id) {
                                self.selected_session = None;
                                self.conversation.clear();
                                self.composer.clear();
                                self.queued_inputs.clear();
                                self.system_prompt_seen_at = 0;
                                self.context_used = 0;
                                self.tokens_per_second = None;
                            }
                            self.reload_projects();
                        }
                        Err(error) => self.error = Some(error),
                    }
                }
            }
        }
        Task::none()
    }

    pub(crate) fn subscription(&self) -> Subscription<Message> {
        let mut subscriptions = vec![
            keyboard::on_key_press(shortcut),
            // A focused text field takes escape for itself to drop its own
            // focus, so a dialog would never see it through `on_key_press`.
            iced::event::listen_with(|event, _, _| {
                matches!(
                    event,
                    iced::Event::Keyboard(keyboard::Event::KeyPressed {
                        key: keyboard::Key::Named(keyboard::key::Named::Escape),
                        ..
                    })
                )
                .then_some(Message::DismissPanel)
            }),
            window::close_requests().map(Message::WindowClosed),
            window::close_events().map(Message::WindowClosed),
        ];
        subscriptions
            .push(iced::time::every(METRICS_SAMPLE_INTERVAL).map(|_| Message::MetricsTick));
        #[cfg(target_os = "macos")]
        subscriptions.push(iced::time::every(Duration::from_millis(50)).map(|_| {
            match crate::native_menu::next_event() {
                Some(crate::native_menu::NativeMenuEvent::Preferences) => Message::OpenPreferences,
                Some(crate::native_menu::NativeMenuEvent::ModelManager) => {
                    Message::OpenModelManager
                }
                Some(crate::native_menu::NativeMenuEvent::Edit(command)) => {
                    Message::NativeEdit(command)
                }
                None => Message::Noop,
            }
        }));
        if matches!(self.model_download, ModelDownload::Active(_)) {
            subscriptions.push(
                iced::time::every(Duration::from_secs(1)).map(|_| Message::DownloadProgressTick),
            );
        }
        #[cfg(target_os = "macos")]
        let titling = self.active_titling.is_some();
        #[cfg(not(target_os = "macos"))]
        let titling = false;
        if self.generating || titling {
            subscriptions.push(
                iced::time::every(Duration::from_millis(50)).map(|_| Message::GenerationTick),
            );
        }
        // The cursor leaves the thin divider as soon as it moves, so the drag is
        // followed through raw window events instead of the widget.
        if self.sidebar_drag {
            subscriptions.push(iced::event::listen_with(|event, _, _| match event {
                iced::Event::Mouse(mouse::Event::CursorMoved { position }) => {
                    Some(Message::DragSidebar(position.x))
                }
                iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
                    Some(Message::EndSidebarDrag)
                }
                _ => None,
            }));
        }
        Subscription::batch(subscriptions)
    }

    pub(crate) fn title(&self, id: window::Id) -> String {
        if self.model_manager_window == Some(id) {
            "Model Manager — DS4Server".to_owned()
        } else {
            "DS4Server".to_owned()
        }
    }

    fn sample_metrics(&mut self) {
        let prefill_tokens_per_second = self.metrics.take_prefill_sample();
        let (kv_read_bytes, kv_write_bytes) = self.metrics.take_kv_io_sample();
        let snapshot = self.metrics.snapshot();
        let http_requests_per_second = snapshot
            .http_requests
            .saturating_sub(self.last_http_requests) as f32
            / METRICS_SAMPLE_INTERVAL.as_secs_f32();
        let kv_read_bytes_per_second = kv_read_bytes as f32 / METRICS_SAMPLE_INTERVAL.as_secs_f32();
        let kv_write_bytes_per_second =
            kv_write_bytes as f32 / METRICS_SAMPLE_INTERVAL.as_secs_f32();
        let sample_seconds = METRICS_SAMPLE_INTERVAL.as_secs_f32();
        let ssd_requests_per_second = snapshot
            .ssd_selected_requests
            .saturating_sub(self.last_ssd_selected_requests)
            as f32
            / sample_seconds;
        let ssd_bytes_per_second = snapshot
            .ssd_requested_bytes
            .saturating_sub(self.last_ssd_requested_bytes)
            as f32
            / sample_seconds;
        let ssd_wait_ms_per_second =
            snapshot.ssd_wait_ms.saturating_sub(self.last_ssd_wait_ms) as f32 / sample_seconds;
        self.last_http_requests = snapshot.http_requests;
        self.last_ssd_selected_requests = snapshot.ssd_selected_requests;
        self.last_ssd_requested_bytes = snapshot.ssd_requested_bytes;
        self.last_ssd_wait_ms = snapshot.ssd_wait_ms;
        self.metrics_history.push_back(MetricsPoint {
            decode_tokens_per_second: if snapshot.phase == crate::metrics::RuntimePhase::Generating
            {
                snapshot.decode_tokens_per_second
            } else {
                0.0
            },
            prefill_tokens_per_second,
            http_requests_per_second,
            kv_read_bytes_per_second,
            kv_write_bytes_per_second,
            ssd_requests_per_second,
            ssd_bytes_per_second,
            ssd_wait_ms_per_second,
        });
        if self.metrics_history.len() > 120 {
            self.metrics_history.pop_front();
        }
        self.metrics_snapshot = snapshot;
        if self.detail_tab == DetailTab::Stats
            && self.last_cache_scan.elapsed() >= CACHE_SCAN_INTERVAL
        {
            self.scan_kv_cache();
        }
    }

    /// Removes one cache file. Checkpoints are disposable: the next turn on that
    /// conversation prefills again.
    fn discard_cache_entry(&mut self, path: &Path) {
        if let Err(error) = fs::remove_file(path) {
            self.error = Some(format!("Could not delete the checkpoint: {error}"));
            return;
        }
        let _ = fs::remove_file(path.with_extension("meta"));
        self.finish_cache_change();
    }

    /// Empties the transient store only. Session checkpoints belong to their
    /// session and are removed with it.
    fn clear_transient_cache(&mut self) {
        let Ok(files) = fs::read_dir(transient_cache_path()) else {
            return;
        };
        for file in files.flatten() {
            if file.metadata().is_ok_and(|metadata| metadata.is_file())
                && let Err(error) = fs::remove_file(file.path())
            {
                self.error = Some(format!("Could not clear the transient cache: {error}"));
                break;
            }
        }
        self.finish_cache_change();
    }

    /// Writes the configuration file, surfacing a failure in the error banner.
    fn store_config(&mut self) {
        if let Err(error) = self.config.save(&config_path()) {
            self.error = Some(error);
        }
    }

    fn finish_cache_change(&mut self) {
        self.metrics.rescan_cache(&kv_cache_path());
        self.metrics_snapshot = self.metrics.snapshot();
        self.scan_kv_cache();
    }

    fn scan_kv_cache(&mut self) {
        let budget = self.config.runtime.kv_cache.settings().budget_bytes;
        self.kv_cache_report = crate::metrics::kv_cache_report(&kv_cache_path(), budget);
        self.last_cache_scan = Instant::now();
    }
}

#[cfg(target_os = "macos")]
fn spawn_services(
    config: &Config,
    metrics: Arc<Metrics>,
) -> (
    Arc<RwLock<Config>>,
    Option<GenerationService>,
    Option<crate::server::ServerHandle>,
    Option<String>,
) {
    let runtime_config = Arc::new(RwLock::new(config.clone()));
    let generation = match GenerationService::spawn(Arc::clone(&metrics)) {
        Ok(generation) => generation,
        Err(error) => return (runtime_config, None, None, Some(error)),
    };
    let endpoint = config.endpoint.enabled.then(|| {
        crate::server::ServerHandle::spawn(
            generation.clone(),
            Arc::clone(&runtime_config),
            models_path(),
            transient_cache_path(),
            u16::try_from(config.endpoint.port).unwrap_or(4000),
            config.endpoint.cors,
            metrics,
        )
    });
    match endpoint {
        Some(Ok(endpoint)) => (runtime_config, Some(generation), Some(endpoint), None),
        Some(Err(error)) => (runtime_config, Some(generation), None, Some(error)),
        None => (runtime_config, Some(generation), None, None),
    }
}

impl Drop for App {
    fn drop(&mut self) {
        if let ModelDownload::Active(download) = &self.model_download {
            download.cancel.store(true, Ordering::Relaxed);
        }
        #[cfg(target_os = "macos")]
        if let Some(active) = &self.active_generation {
            active.cancel.store(true, Ordering::Relaxed);
        }
        #[cfg(target_os = "macos")]
        if let Some(active) = &self.active_tools {
            active.cancel.store(true, Ordering::Relaxed);
        }
        #[cfg(target_os = "macos")]
        if let Some((_, decision)) = self.pending_tool_approval.take() {
            let _ = decision.send(false);
        }
    }
}

fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option<Message> {
    match key.as_ref() {
        keyboard::Key::Character(",") if modifiers.command() => Some(Message::OpenPreferences),
        keyboard::Key::Character("m") if modifiers.command() && modifiers.shift() => {
            Some(Message::OpenModelManager)
        }
        keyboard::Key::Named(keyboard::key::Named::Tab) => Some(if modifiers.shift() {
            Message::FocusPrevious
        } else {
            Message::FocusNext
        }),
        _ => None,
    }
}

fn application_support_path() -> PathBuf {
    std::env::var_os("HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("."))
        .join("Library")
        .join("Application Support")
        .join(APP_ID)
}

fn models_path() -> PathBuf {
    application_support_path().join("models")
}

/// The settings file, beside the project database.
fn config_path() -> PathBuf {
    application_support_path().join("config.yaml")
}

pub(super) fn chat_scroll_id() -> scrollable::Id {
    scrollable::Id::new("chat-transcript")
}

pub(super) fn composer_id() -> text_input::Id {
    text_input::Id::new("chat-composer")
}

pub(super) fn preferences_scroll_id() -> scrollable::Id {
    scrollable::Id::new("preferences-fields")
}

/// Scrolls the preferences form so the field that just took focus is inside
/// the viewport. Iced moves focus without touching the scroll offset, so a tab
/// past the fold would otherwise send the typing to a field nobody can see.
///
/// The focus callback carries no bounds, so the position is taken from the
/// innermost container around the focused widget — the row that holds the
/// label and its input.
fn reveal_focused() -> Task<Message> {
    use iced::advanced::widget::{Id, Operation, operation};
    use iced::{Rectangle, Vector};

    struct Locate {
        rows: Vec<Rectangle>,
        focused: Option<Rectangle>,
    }

    impl<T> Operation<T> for Locate {
        fn container(
            &mut self,
            _id: Option<&Id>,
            bounds: Rectangle,
            operate_on_children: &mut dyn FnMut(&mut dyn Operation<T>),
        ) {
            self.rows.push(bounds);
            operate_on_children(self);
            self.rows.pop();
        }

        fn focusable(&mut self, state: &mut dyn operation::Focusable, _id: Option<&Id>) {
            if state.is_focused() {
                self.focused = self.rows.last().copied();
            }
        }

        fn finish(&self) -> operation::Outcome<T> {
            self.focused.map_or(operation::Outcome::None, |field| {
                operation::Outcome::Chain(Box::new(Reveal { field }))
            })
        }
    }

    struct Reveal {
        field: Rectangle,
    }

    impl<T> Operation<T> for Reveal {
        fn container(
            &mut self,
            _id: Option<&Id>,
            _bounds: Rectangle,
            operate_on_children: &mut dyn FnMut(&mut dyn Operation<T>),
        ) {
            operate_on_children(self);
        }

        fn scrollable(
            &mut self,
            state: &mut dyn operation::Scrollable,
            id: Option<&Id>,
            bounds: Rectangle,
            content_bounds: Rectangle,
            translation: Vector,
        ) {
            if id != Some(&Id::from(preferences_scroll_id())) {
                return;
            }
            let Some(offset) = reveal_offset(self.field, bounds, translation.y) else {
                return;
            };
            state.scroll_by(
                scrollable::AbsoluteOffset { x: 0.0, y: offset },
                bounds,
                content_bounds,
            );
        }
    }

    iced::advanced::widget::operate(Locate {
        rows: Vec::new(),
        focused: None,
    })
}

/// How far the scroll area has to move for `field` to sit fully inside
/// `viewport`, or `None` when it already does. A field taller than the viewport
/// lines up with its top edge.
///
/// Layout places the content in its own unscrolled coordinates, so `field` only
/// says where it sits once `scrolled` — the offset the area is already at — is
/// taken off it.
fn reveal_offset(field: iced::Rectangle, viewport: iced::Rectangle, scrolled: f32) -> Option<f32> {
    // Keep a row's worth of margin, so a revealed field never sits flush
    // against the edge of the viewport.
    const MARGIN: f32 = 12.0;
    let top = field.y - scrolled;
    let above = top - MARGIN - viewport.y;
    let below = top + field.height + MARGIN - (viewport.y + viewport.height);
    if above < 0.0 {
        Some(above)
    } else if below > 0.0 {
        Some(below)
    } else {
        None
    }
}

fn focus_composer() -> Task<Message> {
    text_input::focus(composer_id())
}

fn scroll_chat_to_end() -> Task<Message> {
    scrollable::snap_to(chat_scroll_id(), scrollable::RelativeOffset::END)
}

/// Deletes session checkpoints whose session is gone. Deleting a session or a
/// project removes its checkpoint directly; this catches the cases that path
/// misses — an interrupted or failed delete, or files left by an older
/// database — so no unreachable checkpoint keeps occupying disc.
fn sweep_orphan_checkpoints(directory: &Path, projects: &[ProjectWithSessions]) -> bool {
    let known = projects
        .iter()
        .flat_map(|item| item.sessions.iter().map(|session| session.id))
        .collect::<HashSet<_>>();
    let Ok(files) = fs::read_dir(directory) else {
        return false;
    };
    let mut removed = false;
    for file in files.flatten() {
        let path = file.path();
        if path
            .extension()
            .is_some_and(|value| value == "compacting" || value == "tmp")
        {
            removed |= fs::remove_file(&path).is_ok();
            continue;
        }
        if path.extension().is_none_or(|value| value != "bin") {
            continue;
        }
        // Only numbered session checkpoints are ours to judge; anything else in
        // the directory is left alone.
        let orphan = path
            .file_stem()
            .and_then(|value| value.to_str())
            .and_then(|value| value.parse::<i32>().ok())
            .is_some_and(|id| !known.contains(&id));
        if orphan {
            removed |= fs::remove_file(&path).is_ok();
        }
    }
    removed
}

fn kv_cache_path() -> PathBuf {
    application_support_path().join("kv-cache")
}

fn session_checkpoint_path(session_id: i32) -> PathBuf {
    kv_cache_path().join(format!("{session_id}.bin"))
}

fn session_compaction_checkpoint_path(session_id: i32) -> PathBuf {
    kv_cache_path().join(format!("{session_id}.compacting"))
}

/// Content-addressed KV cache for turns that belong to no stored session: the
/// HTTP endpoint and the app's own one-shot requests share it.
fn transient_cache_path() -> PathBuf {
    kv_cache_path().join("http")
}

pub(crate) fn app_icon() -> window::Icon {
    let decoder = png::Decoder::new(std::io::Cursor::new(include_bytes!(
        "../assets/app-icon.png"
    )));
    let mut reader = decoder
        .read_info()
        .expect("bundled application icon must be valid PNG");
    let mut rgba = vec![0; reader.output_buffer_size()];
    let info = reader
        .next_frame(&mut rgba)
        .expect("bundled application icon must decode");
    assert_eq!(info.color_type, png::ColorType::Rgba);
    assert_eq!(info.bit_depth, png::BitDepth::Eight);
    rgba.truncate(info.buffer_size());
    window::icon::from_rgba(rgba, info.width, info.height)
        .expect("bundled application icon dimensions must be valid")
}

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

    #[test]
    fn preferences_shortcut_and_dspark_support_are_explicit() {
        let message = shortcut(
            keyboard::Key::Character(",".into()),
            keyboard::Modifiers::COMMAND,
        );
        assert!(matches!(message, Some(Message::OpenPreferences)));
        let message = shortcut(
            keyboard::Key::Character("m".into()),
            keyboard::Modifiers::COMMAND | keyboard::Modifiers::SHIFT,
        );
        assert!(matches!(message, Some(Message::OpenModelManager)));
        let tab = keyboard::Key::Named(keyboard::key::Named::Tab);
        assert!(matches!(
            shortcut(tab.clone(), keyboard::Modifiers::empty()),
            Some(Message::FocusNext)
        ));
        assert!(matches!(
            shortcut(tab, keyboard::Modifiers::SHIFT),
            Some(Message::FocusPrevious)
        ));
        assert!(ModelChoice::DeepSeekV4Flash.supports_dspark());
        assert!(!ModelChoice::DeepSeekV4Pro.supports_dspark());
        assert!(!ModelChoice::Glm52.supports_dspark());
    }

    #[test]
    fn tab_scrolls_a_field_back_into_the_preferences_viewport() {
        let viewport = iced::Rectangle {
            x: 0.0,
            y: 100.0,
            width: 700.0,
            height: 400.0,
        };
        let field = |y| iced::Rectangle {
            x: 0.0,
            y,
            width: 700.0,
            height: 30.0,
        };
        assert_eq!(reveal_offset(field(200.0), viewport, 0.0), None);
        // Above the fold: scroll back by the gap plus the margin.
        assert_eq!(reveal_offset(field(80.0), viewport, 0.0), Some(-32.0));
        // Below it: the bottom edge plus the margin comes into view.
        assert_eq!(reveal_offset(field(490.0), viewport, 0.0), Some(32.0));
        // A field the scroll already brought into view stays put, and one
        // further down moves by the gap alone, not by the whole scroll.
        assert_eq!(reveal_offset(field(690.0), viewport, 500.0), None);
        assert_eq!(reveal_offset(field(990.0), viewport, 500.0), Some(32.0));
    }

    #[test]
    fn ds4_gib_and_streaming_cache_inputs_are_typed() {
        assert_eq!(
            parse_streaming_cache("128").unwrap(),
            Some(StreamingCacheBudget::Experts(128))
        );
        assert_eq!(
            parse_streaming_cache("64gB").unwrap(),
            Some(StreamingCacheBudget::Gib(64))
        );
        assert!(parse_streaming_cache("1.5GB").is_err());
        assert_eq!(parse_optional_gib("Memory", "8GB").unwrap(), Some(8));
    }

    #[test]
    fn one_shot_titles_are_reduced_to_a_single_sidebar_line() {
        use generation::session_title;
        assert_eq!(
            session_title("\"Metal kernel debugging\"").as_deref(),
            Some("Metal kernel debugging")
        );
        assert_eq!(
            session_title("Fixing the tokenizer.\nThat is my answer.").as_deref(),
            Some("Fixing the tokenizer")
        );
        assert_eq!(session_title("   \n  \n").as_deref(), None);
        let long = "word ".repeat(40);
        let title = session_title(&long).expect("a long reply still yields a title");
        assert!(title.chars().count() <= 61, "{title}");
        assert!(title.ends_with('…'));
    }

    #[test]
    fn assistant_stream_keeps_reasoning_separate_from_the_answer() {
        let mut message = ChatMessage {
            id: 1,
            user: false,
            tool: false,
            system: false,
            compaction: false,
            compaction_tail_start: None,
            reasoning: Some(String::new()),
            reasoning_complete: false,
            reasoning_open: true,
            content: String::new(),
            markdown: Vec::new(),
        };
        message.append(true, "working it out");
        message.append(false, "**final answer**");
        message.refresh_markdown();
        assert_eq!(message.reasoning.as_deref(), Some("working it out"));
        assert!(message.reasoning_complete);
        assert_eq!(message.content, "**final answer**");
        assert!(!message.markdown.is_empty());
    }
}
