use crate::call_capture;
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use futures_util::StreamExt;
use minisign_verify::{PublicKey, Signature};
use minutes_core::capture::{
    should_bypass_preflight_block_for_native_call_capture, RecordingIntent,
};
use minutes_core::config::{ConsentMode, VALID_LIVE_TRANSCRIPT_BACKENDS, VALID_PARAKEET_MODELS};
use minutes_core::markdown::ConsentBasis;
use minutes_core::{CaptureMode, Config, ContentType};
use reqwest::header::{ACCEPT, CONTENT_LENGTH};
use std::cmp::Reverse;
use std::collections::hash_map::DefaultHasher;
use std::collections::HashMap;
use std::fs::OpenOptions;
use std::hash::{Hash, Hasher};
use std::io::{self, Write};
use std::path::Path;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime};
use tauri::{Emitter, Manager};
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
use tauri_plugin_notification::NotificationExt;
use tauri_plugin_shell::ShellExt;

pub struct AppState {
    pub recording: Arc<AtomicBool>,
    pub starting: Arc<AtomicBool>,
    pub stop_flag: Arc<AtomicBool>,
    pub processing: Arc<AtomicBool>,
    pub processing_stage: Arc<Mutex<Option<String>>>,
    pub latest_output: Arc<Mutex<Option<OutputNotice>>>,
    pub activation_progress: Arc<Mutex<ActivationProgress>>,
    pub call_capture_health: Arc<Mutex<Option<crate::call_capture::CallSourceHealth>>>,
    pub completion_notifications_enabled: Arc<AtomicBool>,
    pub screen_share_hidden: Arc<AtomicBool>,
    pub global_hotkey_enabled: Arc<AtomicBool>,
    pub global_hotkey_shortcut: Arc<Mutex<String>>,
    pub hotkey_runtime: Arc<Mutex<HotkeyRuntime>>,
    pub discard_short_hotkey_capture: Arc<AtomicBool>,
    pub pty_manager: Arc<Mutex<crate::pty::PtyManager>>,
    pub dictation_active: Arc<AtomicBool>,
    pub dictation_stop_flag: Arc<AtomicBool>,
    pub dictation_focus_guard: Arc<Mutex<Option<DictationFocusGuard>>>,
    pub pending_dictation_target: Arc<Mutex<Option<PendingDictationTarget>>>,
    pub dictation_release_started_at: Arc<Mutex<Option<Instant>>>,
    pub dictation_shortcut_enabled: Arc<AtomicBool>,
    pub dictation_shortcut: Arc<Mutex<String>>,
    pub live_transcript_active: Arc<AtomicBool>,
    pub live_transcript_stop_flag: Arc<AtomicBool>,
    pub live_shortcut_enabled: Arc<AtomicBool>,
    pub live_shortcut: Arc<Mutex<String>>,
    pub pending_update: Arc<Mutex<Option<PendingUpdate>>>,
    pub update_install_running: Arc<AtomicBool>,
    pub update_install_cancel: Arc<AtomicBool>,
    pub update_install_state: Arc<Mutex<UpdateUiState>>,
    /// Whether the palette global shortcut is currently registered.
    pub palette_shortcut_enabled: Arc<AtomicBool>,
    /// The shortcut string registered for the palette (e.g. "CmdOrCtrl+Shift+K").
    pub palette_shortcut: Arc<Mutex<String>>,
    /// Explicit lifecycle state for the palette overlay window. Tracked as a
    /// four-state machine (Closed/Opening/Open/Closing) rather than a boolean
    /// so fast `⌘⇧K` mashing during the close path doesn't eat keypresses.
    /// See PLAN.md.command-palette-slice-2 D3.
    pub palette_lifecycle: Arc<Mutex<PaletteLifecycle>>,
    /// Set when a hotkey press lands in the `Closing` state. The close path
    /// drains this flag on completion and re-opens the palette if it was set.
    pub palette_reopen_pending: Arc<AtomicBool>,
    /// Staged payloads for meeting-prompt overlays, keyed by an opaque token
    /// passed via URL query (`?t=<token>`). Each overlay consumes exactly one
    /// entry on load. Keyed rather than single-slot to avoid a race when a
    /// second prompt fires before the first overlay's JS has consumed its
    /// payload — see `show_meeting_prompt` in main.rs.
    pub pending_meeting_prompts: Arc<Mutex<HashMap<u64, MeetingPromptData>>>,
    /// `true` iff the currently-active recording was started by a user click
    /// on the call detection banner. Scopes `stop_when_call_ends` so manual
    /// `cmd_start_recording` sessions are never auto-stopped.
    pub recording_started_by_call_detect: Arc<AtomicBool>,
    /// Set by the frontend's "Keep recording" button during an auto-stop
    /// countdown. Read by the countdown thread in `call_detect.rs` to bail
    /// out before calling stop.
    pub call_end_countdown_cancel: Arc<AtomicBool>,
    /// `true` while a call-end auto-stop countdown is running. Keeps repeat
    /// call-end transitions from spawning parallel countdown threads.
    pub call_end_countdown_active: Arc<AtomicBool>,
    /// Terminal reason for the most recent countdown lifecycle. Kept separate
    /// from the cancel bit so the detector can tell an explicit user cancel
    /// from an internal reset or teardown path.
    pub call_end_countdown_terminal_state: Arc<AtomicU8>,
}

#[derive(Debug, Clone)]
pub struct DictationFocusGuard {
    target_context: Option<crate::text_insertion::ActiveTargetContext>,
    main_window_hidden: bool,
}

#[derive(Debug, Clone)]
pub struct PendingDictationTarget {
    captured_at: Instant,
    target_context: Option<crate::text_insertion::ActiveTargetContext>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PermissionRestartStatus {
    Allowed,
    Blocked,
    ConfirmationRequired,
}

#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionRestartSafety {
    pub status: PermissionRestartStatus,
    pub can_restart: bool,
    pub requires_confirmation: bool,
    pub blockers: Vec<String>,
    pub confirmations: Vec<String>,
    pub detail: String,
}

#[derive(Debug, Clone, Default)]
struct PermissionRestartSnapshot {
    recording: bool,
    recording_starting: bool,
    processing: bool,
    live_transcript: bool,
    dictation: bool,
    update_installing: bool,
    call_capture: bool,
    assistant_session: Option<String>,
}

fn dictation_focus_debug(
    event: &str,
    target_context: Option<&crate::text_insertion::ActiveTargetContext>,
    main_window_hidden: Option<bool>,
    note: Option<&str>,
) {
    let current_frontmost = if dictation_focus_frontmost_debug_enabled() {
        crate::text_insertion::capture_active_target_context()
    } else {
        None
    };
    let payload = serde_json::json!({
        "ts": chrono::Utc::now().to_rfc3339(),
        "event": event,
        "target": target_context.map(|context| serde_json::json!({
            "appName": context.app_name.as_deref(),
            "bundleId": context.bundle_id.as_deref(),
            "platform": context.platform.as_str(),
        })),
        "currentFrontmost": current_frontmost.map(|context| serde_json::json!({
            "appName": context.app_name.as_deref(),
            "bundleId": context.bundle_id.as_deref(),
            "platform": context.platform,
        })),
        "mainWindowHidden": main_window_hidden,
        "note": note,
    });

    let path = Config::minutes_dir().join("dictation-focus-debug.jsonl");
    if let Some(parent) = path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    if let Ok(mut file) = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)
    {
        let _ = writeln!(file, "{payload}");
    }
}

fn dictation_focus_frontmost_debug_enabled() -> bool {
    std::env::var("MINUTES_DICTATION_FOCUS_FRONTMOST")
        .ok()
        .is_some_and(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
        || Config::minutes_dir()
            .join("dictation-focus-frontmost.enabled")
            .exists()
}

fn permission_restart_safety_from_snapshot(
    snapshot: PermissionRestartSnapshot,
) -> PermissionRestartSafety {
    let mut blockers = Vec::new();
    let mut confirmations = Vec::new();

    if snapshot.call_capture {
        blockers
            .push("Native call capture is recording. Stop the capture before restarting.".into());
    } else if snapshot.recording {
        blockers.push("A recording is active. Stop it before restarting.".into());
    }
    if snapshot.recording_starting {
        blockers.push("A recording is starting. Wait for it to settle before restarting.".into());
    }
    if snapshot.processing {
        blockers.push(
            "A recording is processing. Wait until processing finishes before restarting.".into(),
        );
    }
    if snapshot.live_transcript {
        blockers.push("Live transcript is active. Stop it before restarting.".into());
    }
    if snapshot.dictation {
        blockers.push("Dictation is active. Stop dictation before restarting.".into());
    }
    if snapshot.update_installing {
        blockers.push("An update install is running. Let it finish before restarting.".into());
    }
    if let Some(session_id) = snapshot.assistant_session {
        confirmations.push(format!(
            "Recall/assistant session '{}' will close when Minutes restarts.",
            session_id
        ));
    }

    if !blockers.is_empty() {
        PermissionRestartSafety {
            status: PermissionRestartStatus::Blocked,
            can_restart: false,
            requires_confirmation: false,
            detail: blockers.join(" "),
            blockers,
            confirmations,
        }
    } else if !confirmations.is_empty() {
        PermissionRestartSafety {
            status: PermissionRestartStatus::ConfirmationRequired,
            can_restart: true,
            requires_confirmation: true,
            detail: confirmations.join(" "),
            blockers,
            confirmations,
        }
    } else {
        PermissionRestartSafety {
            status: PermissionRestartStatus::Allowed,
            can_restart: true,
            requires_confirmation: false,
            blockers,
            confirmations,
            detail: "Minutes is idle and can restart now.".into(),
        }
    }
}

fn permission_restart_snapshot(state: &AppState) -> PermissionRestartSnapshot {
    let shared_processing = minutes_core::pid::read_processing_status();
    let active_jobs = minutes_core::jobs::active_jobs();
    let pid_status = minutes_core::pid::status();
    let processing = state.processing.load(Ordering::Relaxed)
        || shared_processing.processing
        || !active_jobs.is_empty();
    let recording =
        state.recording.load(Ordering::Relaxed) || (pid_status.recording && !processing);
    let call_capture = recording
        && (state
            .recording_started_by_call_detect
            .load(Ordering::Relaxed)
            || state
                .call_capture_health
                .lock()
                .ok()
                .and_then(|health| health.clone())
                .is_some());
    let assistant_session = state
        .pty_manager
        .lock()
        .ok()
        .and_then(|manager| manager.assistant_session_id());

    PermissionRestartSnapshot {
        recording,
        recording_starting: state.starting.load(Ordering::Relaxed),
        processing,
        live_transcript: state.live_transcript_active.load(Ordering::Relaxed),
        dictation: state.dictation_active.load(Ordering::Relaxed),
        update_installing: state.update_install_running.load(Ordering::SeqCst),
        call_capture,
        assistant_session,
    }
}

const PERMISSION_MONITOR_INTERVAL: Duration = Duration::from_secs(15);
const PERMISSION_MONITOR_LOSS_COOLDOWN_MS: u64 = 10_000;
const PERMISSION_MONITOR_WAKE_GRACE_MS: u64 = 20_000;
const PERMISSION_MONITOR_WAKE_GAP_MS: u64 = 45_000;

#[derive(Debug, Clone, PartialEq, Eq)]
struct PermissionMonitorRowState {
    kind: String,
    usable: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct PermissionMonitorSnapshot {
    signature: String,
    rows: Vec<PermissionMonitorRowState>,
}

#[derive(Debug, Clone)]
struct PendingPermissionLoss {
    snapshot: PermissionMonitorSnapshot,
    emit_after_ms: u64,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct PermissionMonitorDecision {
    reason: &'static str,
}

#[derive(Debug, Clone, Default)]
struct PermissionMonitorDedupe {
    last_emitted: Option<PermissionMonitorSnapshot>,
    pending_loss: Option<PendingPermissionLoss>,
    wake_grace_until_ms: u64,
    last_poll_ms: Option<u64>,
}

#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct PermissionMonitorEvent {
    reason: &'static str,
    rows: Vec<minutes_core::macos_permissions::MacPermissionRow>,
}

fn permission_monitor_snapshot_from_rows(
    rows: &[minutes_core::macos_permissions::MacPermissionRow],
) -> PermissionMonitorSnapshot {
    PermissionMonitorSnapshot {
        signature: serde_json::to_string(rows).unwrap_or_default(),
        rows: rows
            .iter()
            .map(|row| PermissionMonitorRowState {
                kind: format!("{:?}", row.kind),
                usable: row.status.is_granted() && row.runtime_usable,
            })
            .collect(),
    }
}

fn is_permission_loss(
    previous: &PermissionMonitorSnapshot,
    current: &PermissionMonitorSnapshot,
) -> bool {
    current.rows.iter().any(|current_row| {
        !current_row.usable
            && previous
                .rows
                .iter()
                .any(|previous_row| previous_row.kind == current_row.kind && previous_row.usable)
    })
}

impl PermissionMonitorDedupe {
    fn observe(
        &mut self,
        snapshot: PermissionMonitorSnapshot,
        now_ms: u64,
    ) -> Option<PermissionMonitorDecision> {
        if let Some(last_poll_ms) = self.last_poll_ms {
            if now_ms.saturating_sub(last_poll_ms) > PERMISSION_MONITOR_WAKE_GAP_MS {
                self.wake_grace_until_ms = now_ms.saturating_add(PERMISSION_MONITOR_WAKE_GRACE_MS);
            }
        }
        self.last_poll_ms = Some(now_ms);

        let Some(last_emitted) = self.last_emitted.clone() else {
            self.last_emitted = Some(snapshot);
            return None;
        };

        if snapshot.signature == last_emitted.signature {
            self.pending_loss = None;
            return None;
        }

        if is_permission_loss(&last_emitted, &snapshot) {
            let emit_after_ms = now_ms
                .saturating_add(PERMISSION_MONITOR_LOSS_COOLDOWN_MS)
                .max(self.wake_grace_until_ms);
            match &self.pending_loss {
                Some(pending)
                    if pending.snapshot.signature == snapshot.signature
                        && now_ms >= pending.emit_after_ms =>
                {
                    self.last_emitted = Some(snapshot);
                    self.pending_loss = None;
                    Some(PermissionMonitorDecision {
                        reason: "permission_loss",
                    })
                }
                Some(pending) if pending.snapshot.signature == snapshot.signature => None,
                _ => {
                    self.pending_loss = Some(PendingPermissionLoss {
                        snapshot,
                        emit_after_ms,
                    });
                    None
                }
            }
        } else {
            self.last_emitted = Some(snapshot);
            self.pending_loss = None;
            Some(PermissionMonitorDecision {
                reason: "permission_restored",
            })
        }
    }
}

pub fn spawn_permission_monitor(app: tauri::AppHandle) {
    std::thread::Builder::new()
        .name("permission-monitor".into())
        .spawn(move || {
            let started_at = Instant::now();
            let mut dedupe = PermissionMonitorDedupe::default();
            loop {
                let rows = minutes_core::macos_permissions::permission_rows();
                let snapshot = permission_monitor_snapshot_from_rows(&rows);
                let now_ms = started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
                if let Some(decision) = dedupe.observe(snapshot, now_ms) {
                    let _ = app.emit(
                        "permissions:changed",
                        PermissionMonitorEvent {
                            reason: decision.reason,
                            rows,
                        },
                    );
                }
                std::thread::sleep(PERMISSION_MONITOR_INTERVAL);
            }
        })
        .ok();
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum CallEndCountdownTerminalState {
    None = 0,
    UserCancelled = 1,
    RecordingStopped = 2,
    AutoStopFired = 3,
}

impl CallEndCountdownTerminalState {
    pub fn from_u8(value: u8) -> Self {
        match value {
            1 => Self::UserCancelled,
            2 => Self::RecordingStopped,
            3 => Self::AutoStopFired,
            _ => Self::None,
        }
    }
}

type ParakeetStatusView = minutes_core::transcription_coordinator::ParakeetBackendStatus;

fn parakeet_status_view(config: &Config) -> ParakeetStatusView {
    minutes_core::transcription_coordinator::parakeet_backend_status(config)
}

fn apple_speech_status_view() -> serde_json::Value {
    match minutes_core::apple_speech::probe_capabilities() {
        Ok(report) => serde_json::json!({
            "supported": report.runtime_supported,
            "selectable": report.runtime_supported
                && report.speech_transcriber.is_available.unwrap_or(false),
            "report": report,
        }),
        Err(error) => serde_json::json!({
            "supported": false,
            "selectable": false,
            "error": error.to_string(),
        }),
    }
}

fn live_transcript_fallback_order_view(config: &Config) -> Vec<String> {
    let resolved = config.effective_live_transcript_backend();
    let parakeet_ready = parakeet_status_view(config).ready;
    match resolved {
        "apple-speech" => {
            let mut order = vec!["apple-speech".to_string()];
            if parakeet_ready {
                order.push("parakeet".to_string());
            }
            order.push("whisper".to_string());
            order
        }
        "parakeet" => vec!["parakeet".to_string(), "whisper".to_string()],
        _ => vec!["whisper".to_string()],
    }
}

#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SurfaceReadinessView {
    configured_backend: String,
    resolved_backend: String,
    ready: bool,
    model_name: String,
    detail: String,
    next_action: String,
    fallback_order: Vec<String>,
}

fn whisper_model_file(config: &Config, model_name: &str) -> PathBuf {
    config
        .transcription
        .model_path
        .join(format!("ggml-{}.bin", model_name))
}

fn whisper_model_readiness(
    config: &Config,
    model_name: &str,
) -> (bool, String, std::path::PathBuf) {
    let selected_model = if model_name.trim().is_empty() {
        config.transcription.model.clone()
    } else {
        model_name.to_string()
    };
    let model_file = whisper_model_file(config, &selected_model);
    (model_file.exists(), selected_model, model_file)
}

fn apple_speech_selectable() -> bool {
    match minutes_core::apple_speech::probe_capabilities() {
        Ok(report) => {
            report.runtime_supported && report.speech_transcriber.is_available.unwrap_or(false)
        }
        Err(_) => false,
    }
}

fn batch_transcription_readiness_view(config: &Config) -> SurfaceReadinessView {
    if config.transcription.engine == "parakeet" {
        let status = parakeet_status_view(config);
        let detail = if status.ready {
            let tokenizer_label = status
                .tokenizer_label
                .clone()
                .unwrap_or_else(|| "unknown".to_string());
            format!(
                "Batch and recording transcription use Parakeet. Model: {}. Tokenizer: {}. Sidecar: {}.",
                status.model,
                tokenizer_label,
                status.sidecar
            )
        } else {
            format!(
                "Batch and recording transcription need Parakeet setup: {}. Run: {}",
                status.issues.join(", "),
                status.setup_command
            )
        };
        return SurfaceReadinessView {
            configured_backend: "parakeet".into(),
            resolved_backend: "parakeet".into(),
            ready: status.ready,
            model_name: status.model,
            detail,
            next_action: if status.ready {
                "none".into()
            } else {
                "setup-parakeet".into()
            },
            fallback_order: vec!["parakeet".into()],
        };
    }

    let (ready, model_name, model_file) =
        whisper_model_readiness(config, &config.transcription.model);
    SurfaceReadinessView {
        configured_backend: config.transcription.engine.clone(),
        resolved_backend: "whisper".into(),
        ready,
        model_name: model_name.clone(),
        detail: if ready {
            format!(
                "Batch and recording transcription use Whisper. {} is installed at {}.",
                model_name,
                model_file.display()
            )
        } else {
            format!(
                "Batch and recording transcription need a Whisper model. {} is missing at {}.",
                model_name,
                model_file.display()
            )
        },
        next_action: if ready {
            "none".into()
        } else {
            "download-model".into()
        },
        fallback_order: vec!["whisper".into()],
    }
}

fn standalone_live_readiness_view(config: &Config) -> SurfaceReadinessView {
    let configured_backend = config.standalone_live_backend_setting().to_string();
    let resolved_backend = config.effective_live_transcript_backend().to_string();
    let fallback_order = live_transcript_fallback_order_view(config);
    let parakeet = parakeet_status_view(config);
    let live_whisper_model = if config.live_transcript.model.trim().is_empty() {
        config.transcription.model.as_str()
    } else {
        config.live_transcript.model.as_str()
    };
    let (whisper_ready, whisper_model_name, whisper_model_file) =
        whisper_model_readiness(config, live_whisper_model);
    let apple_selectable = apple_speech_selectable();

    match resolved_backend.as_str() {
        "parakeet" => SurfaceReadinessView {
            configured_backend,
            resolved_backend,
            ready: parakeet.ready,
            model_name: parakeet.model.clone(),
            detail: if parakeet.ready {
                format!(
                    "Standalone live transcript uses Parakeet. Fallback order: {}.",
                    fallback_order.join(" -> ")
                )
            } else {
                format!(
                    "Standalone live transcript needs Parakeet setup: {}. Fallback order: {}.",
                    parakeet.issues.join(", "),
                    fallback_order.join(" -> ")
                )
            },
            next_action: if parakeet.ready {
                "none".into()
            } else {
                "setup-parakeet".into()
            },
            fallback_order,
        },
        "apple-speech" => {
            let ready = apple_selectable || parakeet.ready || whisper_ready;
            let (detail, next_action) = if apple_selectable {
                (
                    format!(
                        "Standalone live transcript can use Apple Speech directly. Fallback order: {}.",
                        fallback_order.join(" -> ")
                    ),
                    "none".into(),
                )
            } else if parakeet.ready {
                (
                    format!(
                        "Apple Speech is unavailable on this Mac, but standalone live transcript can run through Parakeet fallback. Fallback order: {}.",
                        fallback_order.join(" -> ")
                    ),
                    "none".into(),
                )
            } else if whisper_ready {
                (
                    format!(
                        "Apple Speech is unavailable on this Mac, but standalone live transcript can still run through Whisper fallback. Fallback order: {}.",
                        fallback_order.join(" -> ")
                    ),
                    "none".into(),
                )
            } else {
                (
                    format!(
                        "Apple Speech is unavailable on this Mac and no fallback backend is ready. Install a Whisper model at {} or set up Parakeet. Fallback order: {}.",
                        whisper_model_file.display(),
                        fallback_order.join(" -> ")
                    ),
                    "download-model".into(),
                )
            };
            SurfaceReadinessView {
                configured_backend,
                resolved_backend,
                ready,
                model_name: "apple-speech".into(),
                detail,
                next_action,
                fallback_order,
            }
        }
        _ => SurfaceReadinessView {
            configured_backend,
            resolved_backend,
            ready: whisper_ready,
            model_name: whisper_model_name.clone(),
            detail: if whisper_ready {
                format!(
                    "Standalone live transcript uses Whisper. {} is installed at {}.",
                    whisper_model_name,
                    whisper_model_file.display()
                )
            } else {
                format!(
                    "Standalone live transcript needs a Whisper model. {} is missing at {}.",
                    whisper_model_name,
                    whisper_model_file.display()
                )
            },
            next_action: if whisper_ready {
                "none".into()
            } else {
                "download-model".into()
            },
            fallback_order,
        },
    }
}

#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct TranscriptionSurfaceSetupView {
    pub surface: String,
    pub engine: String,
    pub model_name: String,
    pub has_model: bool,
    pub needs_setup: bool,
    pub parakeet: Option<ParakeetStatusView>,
    pub activation: ActivationStatusView,
}

fn transcription_surface_setup_view(
    config: &Config,
    surface: &str,
    readiness: &SurfaceReadinessView,
    progress: &ActivationProgress,
    has_saved_artifact: bool,
    recording: bool,
    processing: bool,
) -> TranscriptionSurfaceSetupView {
    let engine = readiness.resolved_backend.as_str();
    TranscriptionSurfaceSetupView {
        surface: surface.into(),
        engine: readiness.resolved_backend.clone(),
        model_name: readiness.model_name.clone(),
        has_model: readiness.ready,
        needs_setup: readiness.next_action != "none",
        parakeet: (engine == "parakeet").then(|| parakeet_status_view(config)),
        activation: activation_status_view(
            engine,
            progress,
            readiness.ready,
            has_saved_artifact,
            recording,
            processing,
        ),
    }
}

fn primary_setup_surface<'a>(
    batch: &'a TranscriptionSurfaceSetupView,
    standalone_live: &'a TranscriptionSurfaceSetupView,
) -> &'a TranscriptionSurfaceSetupView {
    if batch.needs_setup {
        batch
    } else if standalone_live.needs_setup {
        standalone_live
    } else {
        batch
    }
}

fn mark_model_ready_for_surface(
    config: &Config,
    readiness: &SurfaceReadinessView,
    activation_progress: &Arc<Mutex<ActivationProgress>>,
) {
    match readiness.resolved_backend.as_str() {
        "parakeet" => {
            let parakeet = parakeet_status_view(config);
            if parakeet.ready {
                if let Some(model_path) = parakeet.model_path.as_ref() {
                    mark_activation_model_ready(activation_progress, Path::new(model_path));
                }
            }
        }
        "whisper" => {
            let model_file = whisper_model_file(config, &readiness.model_name);
            if model_file.exists() {
                mark_activation_model_ready(activation_progress, &model_file);
            }
        }
        _ => {}
    }
}

/// Lifecycle state for the palette overlay window.
///
/// Transitions:
/// ```text
///     Closed ──hotkey──▶ Opening ──build_window──▶ Open
///     Open   ──hotkey──▶ Closing ──close──▶ Closed
///     Open   ──focus-lost──▶ Closing ──close──▶ Closed
///     Opening + hotkey  ==> ignored (mid-open race)
///     Closing + hotkey  ==> queue reopen; Closed triggers Opening again
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PaletteLifecycle {
    #[default]
    Closed,
    Opening,
    Open,
    Closing,
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct PendingUpdate {
    pub version: String,
    pub body: String,
    pub download_bytes: Option<u64>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
enum UpdatePhase {
    Available,
    Checking,
    Downloading,
    Verifying,
    Installing,
    Ready,
    Error,
}

impl UpdatePhase {
    fn as_str(self) -> &'static str {
        match self {
            Self::Available => "available",
            Self::Checking => "checking",
            Self::Downloading => "downloading",
            Self::Verifying => "verifying",
            Self::Installing => "installing",
            Self::Ready => "ready",
            Self::Error => "error",
        }
    }
}

#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateUiState {
    phase: UpdatePhase,
    version: Option<String>,
    total_bytes: Option<u64>,
    downloaded_bytes: u64,
    bytes_per_sec: Option<f64>,
    eta_seconds: Option<u64>,
    error_message: Option<String>,
    recoverable: bool,
    can_cancel: bool,
}

impl Default for UpdateUiState {
    fn default() -> Self {
        Self {
            phase: UpdatePhase::Available,
            version: None,
            total_bytes: None,
            downloaded_bytes: 0,
            bytes_per_sec: None,
            eta_seconds: None,
            error_message: None,
            recoverable: false,
            can_cancel: false,
        }
    }
}

impl UpdateUiState {
    fn available(version: impl Into<String>, total_bytes: Option<u64>) -> Self {
        Self {
            phase: UpdatePhase::Available,
            version: Some(version.into()),
            total_bytes,
            ..Self::default()
        }
    }

    fn checking(&self) -> Self {
        Self {
            phase: UpdatePhase::Checking,
            version: self.version.clone(),
            total_bytes: self.total_bytes,
            can_cancel: true,
            ..Self::default()
        }
    }

    fn downloading(&self, total_bytes: Option<u64>) -> Self {
        Self {
            phase: UpdatePhase::Downloading,
            version: self.version.clone(),
            total_bytes,
            can_cancel: true,
            ..Self::default()
        }
    }

    fn with_progress(
        &self,
        downloaded_bytes: u64,
        total_bytes: Option<u64>,
        bytes_per_sec: Option<f64>,
        eta_seconds: Option<u64>,
    ) -> Self {
        Self {
            phase: UpdatePhase::Downloading,
            version: self.version.clone(),
            total_bytes,
            downloaded_bytes,
            bytes_per_sec,
            eta_seconds,
            can_cancel: true,
            ..Self::default()
        }
    }

    fn verifying(&self, downloaded_bytes: u64, total_bytes: Option<u64>) -> Self {
        Self {
            phase: UpdatePhase::Verifying,
            version: self.version.clone(),
            total_bytes,
            downloaded_bytes,
            can_cancel: false,
            ..Self::default()
        }
    }

    fn installing(&self, downloaded_bytes: u64, total_bytes: Option<u64>) -> Self {
        Self {
            phase: UpdatePhase::Installing,
            version: self.version.clone(),
            total_bytes,
            downloaded_bytes,
            can_cancel: false,
            ..Self::default()
        }
    }

    fn ready(&self, downloaded_bytes: u64, total_bytes: Option<u64>) -> Self {
        Self {
            phase: UpdatePhase::Ready,
            version: self.version.clone(),
            total_bytes,
            downloaded_bytes,
            can_cancel: false,
            ..Self::default()
        }
    }

    fn failed(&self, message: impl Into<String>, recoverable: bool) -> Self {
        Self {
            phase: UpdatePhase::Error,
            version: self.version.clone(),
            total_bytes: self.total_bytes,
            downloaded_bytes: self.downloaded_bytes,
            bytes_per_sec: self.bytes_per_sec,
            eta_seconds: self.eta_seconds,
            error_message: Some(message.into()),
            recoverable,
            can_cancel: false,
        }
    }
}

#[derive(Debug)]
enum UpdateInstallError {
    Cancelled,
    Message(String),
}

impl From<String> for UpdateInstallError {
    fn from(value: String) -> Self {
        Self::Message(value)
    }
}

impl From<&str> for UpdateInstallError {
    fn from(value: &str) -> Self {
        Self::Message(value.to_string())
    }
}

#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MeetingPromptData {
    pub title: String,
    pub minutes_until: i64,
    pub url: Option<String>,
}

/// Returns the pending meeting-prompt payload for the given token (and clears
/// it). Called by the overlay window on load. Returning `None` means the
/// token was already consumed, the staging path failed, or the window was
/// opened without a matching token — the overlay should close rather than
/// render a phantom "Meeting" prompt with no context.
#[tauri::command]
pub fn cmd_get_meeting_prompt(
    token: u64,
    state: tauri::State<'_, AppState>,
) -> Option<MeetingPromptData> {
    match state.pending_meeting_prompts.lock() {
        Ok(mut map) => map.remove(&token),
        Err(e) => {
            eprintln!("[calendar] pending_meeting_prompts mutex poisoned: {}", e);
            None
        }
    }
}

#[tauri::command]
pub fn cmd_close_meeting_prompt(app: tauri::AppHandle) -> Result<(), String> {
    if let Some(win) = app.get_webview_window("meeting-prompt") {
        win.destroy().map_err(|e| e.to_string())?;
    }
    Ok(())
}

/// Surface a deferred update notification if one is pending and no session is active.
/// Call this after recording/live/dictation stops.
pub fn surface_deferred_update(app: &tauri::AppHandle) {
    let state = match app.try_state::<AppState>() {
        Some(s) => s,
        None => return,
    };
    if state.recording.load(Ordering::Relaxed)
        || state.starting.load(Ordering::Relaxed)
        || state.processing.load(Ordering::Relaxed)
        || state.live_transcript_active.load(Ordering::Relaxed)
        || state.dictation_active.load(Ordering::Relaxed)
    {
        return;
    }
    let pending = match state.pending_update.lock() {
        Ok(mut guard) => guard.take(),
        Err(_) => return,
    };
    if let Some(update) = pending {
        emit_update_ready(app, &update);
    }
}

fn emit_update_ready(app: &tauri::AppHandle, update: &PendingUpdate) {
    let _ = app.emit(
        "update-ready",
        serde_json::json!({
            "version": update.version,
            "body": update.body,
            "downloadBytes": update.download_bytes,
        }),
    );
}

fn set_update_ui_state(
    app: &tauri::AppHandle,
    state: &AppState,
    next: UpdateUiState,
) -> Result<(), String> {
    {
        let mut guard = state
            .update_install_state
            .lock()
            .map_err(|_| "update state lock poisoned".to_string())?;
        *guard = next.clone();
    }

    let _ = app.emit(
        "update://phase",
        serde_json::json!({
            "phase": next.phase.as_str(),
            "version": next.version,
            "totalBytes": next.total_bytes,
            "downloadedBytes": next.downloaded_bytes,
            "canCancel": next.can_cancel,
        }),
    );

    if next.phase == UpdatePhase::Downloading {
        let _ = app.emit(
            "update://progress",
            serde_json::json!({
                "downloadedBytes": next.downloaded_bytes,
                "totalBytes": next.total_bytes,
                "bytesPerSec": next.bytes_per_sec,
                "etaSeconds": next.eta_seconds,
            }),
        );
    }

    if next.phase == UpdatePhase::Error {
        let _ = app.emit(
            "update://error",
            serde_json::json!({
                "message": next.error_message,
                "recoverable": next.recoverable,
            }),
        );
    }

    Ok(())
}

pub(crate) async fn fetch_update_download_size(url: &reqwest::Url) -> Option<u64> {
    let client = reqwest::Client::builder()
        .user_agent("minutes-updater")
        .build()
        .ok()?;
    let response = client.head(url.clone()).send().await.ok()?;
    if !response.status().is_success() {
        return None;
    }
    response
        .headers()
        .get(CONTENT_LENGTH)
        .and_then(|value| value.to_str().ok())
        .and_then(|value| value.parse::<u64>().ok())
}

fn updater_pubkey() -> Result<String, String> {
    let config: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json"))
        .map_err(|e| format!("Failed to parse tauri.conf.json: {}", e))?;
    config
        .get("plugins")
        .and_then(|plugins| plugins.get("updater"))
        .and_then(|updater| updater.get("pubkey"))
        .and_then(|pubkey| pubkey.as_str())
        .map(|value| value.to_string())
        .ok_or_else(|| "Updater pubkey missing from tauri.conf.json".to_string())
}

fn verify_update_signature(
    bytes: &[u8],
    release_signature: &str,
    pub_key: &str,
) -> Result<(), String> {
    let pubkey_decoded = String::from_utf8(
        BASE64_STANDARD
            .decode(pub_key)
            .map_err(|e| format!("Failed to decode updater pubkey: {}", e))?,
    )
    .map_err(|e| format!("Failed to parse updater pubkey: {}", e))?;
    let signature_decoded = String::from_utf8(
        BASE64_STANDARD
            .decode(release_signature)
            .map_err(|e| format!("Failed to decode release signature: {}", e))?,
    )
    .map_err(|e| format!("Failed to parse release signature: {}", e))?;

    let public_key = PublicKey::decode(&pubkey_decoded)
        .map_err(|e| format!("Failed to load updater pubkey: {}", e))?;
    let signature = Signature::decode(&signature_decoded)
        .map_err(|e| format!("Failed to load release signature: {}", e))?;

    public_key
        .verify(bytes, &signature, true)
        .map_err(|e| format!("Signature verification failed: {}", e))?;
    Ok(())
}

async fn download_update_bytes(
    update: &tauri_plugin_updater::Update,
    cancel: &AtomicBool,
    mut on_progress: impl FnMut(u64, Option<u64>, Option<f64>, Option<u64>) + Send,
) -> Result<Vec<u8>, UpdateInstallError> {
    let response = reqwest::Client::builder()
        .user_agent("minutes-updater")
        .build()
        .map_err(|e| format!("Failed to build update client: {}", e))?
        .get(update.download_url.clone())
        .header(ACCEPT, "application/octet-stream")
        .send()
        .await
        .map_err(|e| format!("Update download failed: {}", e))?;

    if !response.status().is_success() {
        return Err(UpdateInstallError::Message(format!(
            "Update download failed with status {}",
            response.status()
        )));
    }

    let total_bytes = response.content_length();
    let mut downloaded_bytes = 0_u64;
    let started = Instant::now();
    let mut bytes = Vec::new();
    let mut stream = response.bytes_stream();

    while let Some(chunk) = stream.next().await {
        if cancel.load(Ordering::Relaxed) {
            return Err(UpdateInstallError::Cancelled);
        }
        let chunk = chunk.map_err(|e| format!("Update download failed: {}", e))?;
        downloaded_bytes += chunk.len() as u64;
        bytes.extend_from_slice(&chunk);
        let elapsed_secs = started.elapsed().as_secs_f64();
        let bytes_per_sec = if elapsed_secs > 0.0 {
            Some(downloaded_bytes as f64 / elapsed_secs)
        } else {
            None
        };
        let eta_seconds = match (total_bytes, bytes_per_sec) {
            (Some(total), Some(rate)) if rate > 0.0 && downloaded_bytes < total => {
                Some(((total - downloaded_bytes) as f64 / rate).ceil() as u64)
            }
            _ => None,
        };
        on_progress(downloaded_bytes, total_bytes, bytes_per_sec, eta_seconds);
    }

    if cancel.load(Ordering::Relaxed) {
        return Err(UpdateInstallError::Cancelled);
    }

    Ok(bytes)
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct MeetingSection {
    pub heading: String,
    pub content: String,
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct SpeakerAttributionView {
    pub speaker_label: String,
    pub name: String,
    pub confidence: String,
    pub source: String,
}

#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VocabularyRememberView {
    pub entry_id: String,
    pub canonical: String,
    pub already_exists: bool,
    pub note: String,
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct ActionItemView {
    pub assignee: String,
    pub task: String,
    pub due: Option<String>,
    pub status: String,
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct DecisionView {
    pub text: String,
    pub topic: Option<String>,
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct MeetingReferenceView {
    pub path: String,
    pub title: String,
    pub date: String,
    pub content_type: String,
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct RelatedCommitmentView {
    pub path: String,
    pub title: String,
    pub what: String,
    pub who: Option<String>,
    pub by_date: Option<String>,
}

struct RelatedContextView {
    related_people: Vec<String>,
    related_topics: Vec<String>,
    related_meetings: Vec<MeetingReferenceView>,
    related_commitments: Vec<RelatedCommitmentView>,
    adjacent_artifacts: Vec<RecentArtifactView>,
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct MeetingDetail {
    pub path: String,
    pub title: String,
    pub date: String,
    pub duration: String,
    pub content_type: String,
    pub status: Option<String>,
    pub context: Option<String>,
    pub attendees: Vec<String>,
    pub calendar_event: Option<String>,
    pub action_items: Vec<ActionItemView>,
    pub decisions: Vec<DecisionView>,
    pub related_people: Vec<String>,
    pub related_topics: Vec<String>,
    pub related_meetings: Vec<MeetingReferenceView>,
    pub related_commitments: Vec<RelatedCommitmentView>,
    pub adjacent_artifacts: Vec<RecentArtifactView>,
    pub sections: Vec<MeetingSection>,
    pub speaker_map: Vec<SpeakerAttributionView>,
    /// Capture policy from frontmatter ("none" for sensitive no-capture
    /// meetings); absent for normal captured meetings.
    pub capture: Option<String>,
    /// Sensitivity designation from frontmatter ("restricted"), if any.
    pub sensitivity: Option<String>,
    /// Debrief status from frontmatter ("pending"), if any.
    pub debrief: Option<String>,
}

#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ArtifactDraft {
    pub path: String,
    pub title: String,
    pub template_kind: String,
    pub content: String,
}

#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFileAccess {
    pub path: String,
    pub editable: bool,
    pub kind: String,
}

#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TextFileReview {
    pub available: bool,
    pub snapshot_label: Option<String>,
    pub before_preview: Option<String>,
    pub current_preview: Option<String>,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct RecentArtifactEntry {
    path: String,
    opened_at: String,
}

#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RecentArtifactView {
    pub path: String,
    pub filename: String,
    pub kind: String,
    pub editable: bool,
    pub opened_at: String,
    pub review_available: bool,
}

#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentView {
    pub path: String,
    pub filename: String,
    pub kind: String,
    pub mtime: i64,
    pub source: String,
    pub meeting_slug: Option<String>,
}

#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RecallWorkspaceState {
    pub recall_expanded: bool,
    pub recall_phase: String,
    pub recall_ratio: f64,
    pub current_meeting_path: Option<String>,
    pub open_artifact_path: Option<String>,
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct OutputNotice {
    pub kind: String,
    pub title: String,
    pub path: String,
    pub detail: String,
    #[serde(default, skip_serializing_if = "Option::is_none", rename = "jobId")]
    pub job_id: Option<String>,
}

#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ActivationProgress {
    pub desktop_opened_at: Option<String>,
    pub model_ready_at: Option<String>,
    pub first_recording_started_at: Option<String>,
    pub first_artifact_saved_at: Option<String>,
    pub first_artifact_path: Option<String>,
    pub next_step_nudge_shown_at: Option<String>,
    pub next_step_nudge_kind: Option<String>,
}

#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ActivationStatusView {
    pub phase: String,
    pub next_action: String,
    pub has_model: bool,
    pub has_saved_artifact: bool,
    pub first_artifact_path: Option<String>,
    pub milestones: ActivationProgress,
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct ReadinessItem {
    pub label: String,
    pub state: String,
    pub detail: String,
    pub optional: bool,
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct RecoveryItem {
    pub kind: String,
    pub title: String,
    pub path: String,
    pub detail: String,
    pub retry_type: String,
    pub modified_at: Option<String>,
}

#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RecoveryRetryFailure {
    pub path: String,
    pub error: String,
}

#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RecoveryRetryAllResult {
    pub queued: usize,
    pub failed: Vec<RecoveryRetryFailure>,
}

fn activation_state_path() -> PathBuf {
    Config::minutes_dir().join("activation-state.json")
}

fn recent_artifacts_state_path() -> PathBuf {
    Config::minutes_dir().join("recent-artifacts.json")
}

fn recall_workspace_state_path() -> PathBuf {
    Config::minutes_dir().join("recall-workspace.json")
}

fn now_rfc3339() -> String {
    chrono::Local::now().to_rfc3339()
}

fn system_time_to_rfc3339(value: SystemTime) -> Option<String> {
    let dt: chrono::DateTime<chrono::Local> = value.into();
    Some(dt.to_rfc3339())
}

fn path_timestamp(path: &Path) -> Option<String> {
    let metadata = std::fs::metadata(path).ok()?;
    metadata
        .created()
        .ok()
        .and_then(system_time_to_rfc3339)
        .or_else(|| metadata.modified().ok().and_then(system_time_to_rfc3339))
}

fn persist_activation_progress(progress: &ActivationProgress) {
    let path = activation_state_path();
    if let Some(parent) = path.parent() {
        if std::fs::create_dir_all(parent).is_err() {
            return;
        }
    }
    let Ok(json) = serde_json::to_string_pretty(progress) else {
        return;
    };
    let _ = std::fs::write(path, json);
}

fn load_recent_artifacts_from(path: &Path) -> Vec<RecentArtifactEntry> {
    std::fs::read_to_string(path)
        .ok()
        .and_then(|raw| serde_json::from_str::<Vec<RecentArtifactEntry>>(&raw).ok())
        .unwrap_or_default()
}

fn persist_recent_artifacts_to(path: &Path, entries: &[RecentArtifactEntry]) {
    if let Some(parent) = path.parent() {
        if std::fs::create_dir_all(parent).is_err() {
            return;
        }
    }
    let Ok(json) = serde_json::to_string_pretty(entries) else {
        return;
    };
    let _ = std::fs::write(path, json);
}

fn load_recall_workspace_state_from(path: &Path) -> RecallWorkspaceState {
    std::fs::read_to_string(path)
        .ok()
        .and_then(|raw| serde_json::from_str::<RecallWorkspaceState>(&raw).ok())
        .unwrap_or_else(|| RecallWorkspaceState {
            recall_expanded: false,
            recall_phase: "recall".into(),
            recall_ratio: 0.5,
            current_meeting_path: None,
            open_artifact_path: None,
        })
}

fn persist_recall_workspace_state_to(path: &Path, state: &RecallWorkspaceState) {
    if let Some(parent) = path.parent() {
        if std::fs::create_dir_all(parent).is_err() {
            return;
        }
    }
    let Ok(json) = serde_json::to_string_pretty(state) else {
        return;
    };
    let _ = std::fs::write(path, json);
}

fn record_recent_artifact_path(path: &Path) {
    const MAX_RECENT_ARTIFACTS: usize = 12;
    record_recent_artifact_path_with_limit(
        path,
        MAX_RECENT_ARTIFACTS,
        &recent_artifacts_state_path(),
    );
}

fn record_recent_artifact_path_with_limit(path: &Path, max_items: usize, state_path: &Path) {
    let canonical = match validate_text_file_path(path) {
        Ok(path) => path,
        Err(_) => return,
    };
    record_recent_artifact_canonical_with_limit(&canonical, max_items, state_path);
}

fn record_recent_artifact_canonical_with_limit(
    canonical: &Path,
    max_items: usize,
    state_path: &Path,
) {
    let canonical_string = canonical.display().to_string();
    let mut entries = load_recent_artifacts_from(state_path);
    entries.retain(|entry| entry.path != canonical_string);
    entries.insert(
        0,
        RecentArtifactEntry {
            path: canonical_string,
            opened_at: now_rfc3339(),
        },
    );
    entries.retain(|entry| Path::new(&entry.path).exists());
    entries.truncate(max_items);
    persist_recent_artifacts_to(state_path, &entries);
}

fn recent_artifact_views(
    config: &Config,
    limit: usize,
    exclude_path: Option<&Path>,
) -> Vec<RecentArtifactView> {
    let state_path = recent_artifacts_state_path();
    recent_artifact_views_from(config, limit, exclude_path, &state_path)
}

fn recent_artifact_views_from(
    config: &Config,
    limit: usize,
    exclude_path: Option<&Path>,
    state_path: &Path,
) -> Vec<RecentArtifactView> {
    let exclude = exclude_path.map(|path| path.display().to_string());
    let mut views = Vec::new();

    for entry in load_recent_artifacts_from(state_path).into_iter() {
        if views.len() >= limit {
            break;
        }
        if exclude.as_deref() == Some(entry.path.as_str()) {
            continue;
        }
        let canonical = match validate_text_file_path(Path::new(&entry.path)) {
            Ok(path) => path,
            Err(_) => continue,
        };
        let filename = canonical
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or("Artifact")
            .to_string();
        let kind = text_file_kind(&canonical).unwrap_or("text").to_string();
        let editable = is_editable_text_file_path(&canonical, config);
        let review_available = latest_snapshot_for_path(&canonical)
            .ok()
            .flatten()
            .is_some();

        views.push(RecentArtifactView {
            path: canonical.display().to_string(),
            filename,
            kind,
            editable,
            opened_at: entry.opened_at,
            review_available,
        });
    }

    views
}

fn file_mtime_ms(path: &Path) -> Option<i64> {
    let modified = std::fs::metadata(path).ok()?.modified().ok()?;
    let duration = modified
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default();
    Some(duration.as_millis().min(i64::MAX as u128) as i64)
}

fn filename_for_path(path: &Path) -> String {
    path.file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("Document")
        .to_string()
}

fn meeting_slug_for_document(path: &Path) -> String {
    path.file_stem()
        .and_then(|name| name.to_str())
        .unwrap_or("meeting")
        .to_string()
}

fn is_assistant_instruction_file(path: &Path) -> bool {
    matches!(
        path.file_name()
            .and_then(|name| name.to_str())
            .map(|name| name.to_ascii_uppercase())
            .as_deref(),
        Some("CLAUDE.MD" | "AGENTS.MD" | "MEMORY.MD")
    )
}

fn push_document_if_allowed(
    documents: &mut Vec<DocumentView>,
    seen: &mut std::collections::HashSet<PathBuf>,
    path: &Path,
    source: &str,
    meeting_slug: Option<String>,
) {
    let Ok(canonical) = validate_text_file_path(path) else {
        return;
    };
    if !seen.insert(canonical.clone()) {
        return;
    }
    let Some(mtime) = file_mtime_ms(&canonical) else {
        return;
    };
    documents.push(DocumentView {
        path: canonical.display().to_string(),
        filename: filename_for_path(&canonical),
        kind: text_file_kind(&canonical).unwrap_or("text").to_string(),
        mtime,
        source: source.to_string(),
        meeting_slug,
    });
}

fn push_assistant_document_if_allowed(
    documents: &mut Vec<DocumentView>,
    seen: &mut std::collections::HashSet<PathBuf>,
    path: &Path,
    allowed_roots: &[PathBuf],
) {
    let Ok(canonical) = validate_text_file_path(path) else {
        return;
    };
    if !allowed_roots.iter().any(|root| canonical.starts_with(root)) {
        return;
    }
    if !seen.insert(canonical.clone()) {
        return;
    }
    let Some(mtime) = file_mtime_ms(&canonical) else {
        return;
    };
    documents.push(DocumentView {
        path: canonical.display().to_string(),
        filename: filename_for_path(&canonical),
        kind: text_file_kind(&canonical).unwrap_or("text").to_string(),
        mtime,
        source: "assistant".to_string(),
        meeting_slug: None,
    });
}

fn is_plain_file_entry(path: &Path) -> bool {
    let Ok(metadata) = std::fs::symlink_metadata(path) else {
        return false;
    };
    let file_type = metadata.file_type();
    file_type.is_file() && !file_type.is_symlink()
}

fn append_assistant_documents(
    documents: &mut Vec<DocumentView>,
    seen: &mut std::collections::HashSet<PathBuf>,
    assistant_dir: &Path,
) {
    let Ok(assistant_root) = std::fs::canonicalize(assistant_dir) else {
        return;
    };
    if let Ok(entries) = std::fs::read_dir(assistant_dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if !is_plain_file_entry(&path) || is_assistant_instruction_file(&path) {
                continue;
            }
            let ext = path
                .extension()
                .and_then(|ext| ext.to_str())
                .map(|ext| ext.to_ascii_lowercase());
            if matches!(ext.as_deref(), Some("md" | "markdown" | "txt")) {
                push_assistant_document_if_allowed(
                    documents,
                    seen,
                    &path,
                    std::slice::from_ref(&assistant_root),
                );
            }
        }
    }

    let artifacts_dir = assistant_dir.join("artifacts");
    let Ok(artifact_metadata) = std::fs::symlink_metadata(&artifacts_dir) else {
        return;
    };
    if artifact_metadata.file_type().is_symlink() || !artifact_metadata.is_dir() {
        return;
    }
    let Ok(artifacts_root) = std::fs::canonicalize(&artifacts_dir) else {
        return;
    };
    let allowed_roots = [assistant_root, artifacts_root];
    if let Ok(entries) = std::fs::read_dir(&artifacts_dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if !is_plain_file_entry(&path) || is_assistant_instruction_file(&path) {
                continue;
            }
            let ext = path
                .extension()
                .and_then(|ext| ext.to_str())
                .map(|ext| ext.to_ascii_lowercase());
            if matches!(ext.as_deref(), Some("md" | "markdown" | "txt")) {
                push_assistant_document_if_allowed(documents, seen, &path, &allowed_roots);
            }
        }
    }
}

fn list_documents_for_roots(
    config: &Config,
    assistant_dir: &Path,
    limit: usize,
) -> Vec<DocumentView> {
    let state_path = recent_artifacts_state_path();
    list_documents_for_roots_with_recent_state(config, assistant_dir, limit, &state_path)
}

fn list_documents_for_roots_with_recent_state(
    config: &Config,
    assistant_dir: &Path,
    limit: usize,
    recent_state_path: &Path,
) -> Vec<DocumentView> {
    let mut documents = Vec::new();
    let mut seen = std::collections::HashSet::new();

    // Reuse the same recent artifact view path that powers the meeting detail
    // "Adjacent Artifacts" rows, then enrich it with mtime/source metadata.
    for artifact in recent_artifact_views_from(config, limit, None, recent_state_path) {
        let path = PathBuf::from(&artifact.path);
        push_document_if_allowed(
            &mut documents,
            &mut seen,
            &path,
            "meeting",
            Some(meeting_slug_for_document(&path)),
        );
    }

    append_assistant_documents(&mut documents, &mut seen, assistant_dir);
    documents.sort_by(|a, b| {
        b.mtime
            .cmp(&a.mtime)
            .then_with(|| a.filename.cmp(&b.filename))
    });
    documents.truncate(limit);
    documents
}

fn update_activation_progress<F>(state: &Arc<Mutex<ActivationProgress>>, mutate: F)
where
    F: FnOnce(&mut ActivationProgress) -> bool,
{
    let mut snapshot = None;
    if let Ok(mut progress) = state.lock() {
        if mutate(&mut progress) {
            snapshot = Some(progress.clone());
        }
    }
    if let Some(progress) = snapshot {
        persist_activation_progress(&progress);
    }
}

fn mark_activation_model_ready(state: &Arc<Mutex<ActivationProgress>>, model_file: &Path) {
    let inferred = path_timestamp(model_file).unwrap_or_else(now_rfc3339);
    update_activation_progress(state, |progress| {
        if progress.model_ready_at.is_none() {
            progress.model_ready_at = Some(inferred);
            return true;
        }
        false
    });
}

fn mark_activation_first_recording_started(state: &Arc<Mutex<ActivationProgress>>) {
    update_activation_progress(state, |progress| {
        if progress.first_recording_started_at.is_none() {
            progress.first_recording_started_at = Some(now_rfc3339());
            return true;
        }
        false
    });
}

fn mark_activation_first_artifact_saved(
    state: &Arc<Mutex<ActivationProgress>>,
    artifact_path: &Path,
) {
    let inferred = path_timestamp(artifact_path).unwrap_or_else(now_rfc3339);
    let path_string = artifact_path.display().to_string();
    update_activation_progress(state, |progress| {
        let mut changed = false;
        if progress.first_artifact_saved_at.is_none() {
            progress.first_artifact_saved_at = Some(inferred.clone());
            changed = true;
        }
        if progress.first_artifact_path.is_none() {
            progress.first_artifact_path = Some(path_string.clone());
            changed = true;
        }
        changed
    });
}

fn mark_activation_next_step_nudge_shown(
    state: &Arc<Mutex<ActivationProgress>>,
    kind: Option<&str>,
) {
    let kind = kind
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(String::from);
    update_activation_progress(state, |progress| {
        let mut changed = false;
        if progress.next_step_nudge_shown_at.is_none() {
            progress.next_step_nudge_shown_at = Some(now_rfc3339());
            changed = true;
        }
        if progress.next_step_nudge_kind.is_none() {
            if let Some(kind) = kind.clone() {
                progress.next_step_nudge_kind = Some(kind);
                changed = true;
            }
        }
        changed
    });
}

fn model_file_for_config(config: &Config) -> PathBuf {
    whisper_model_file(config, &config.transcription.model)
}

fn latest_saved_artifact_from_search(config: &Config) -> Option<PathBuf> {
    let filters = minutes_core::search::SearchFilters {
        content_type: None,
        since: None,
        attendee: None,
        intent_kind: None,
        owner: None,
        recorded_by: None,
    };
    minutes_core::search::search("", config, &filters)
        .ok()?
        .into_iter()
        .next()
        .map(|item| item.path)
}

fn backfill_activation_from_paths(
    progress: &mut ActivationProgress,
    model_file: &Path,
    latest_artifact: Option<&Path>,
) -> bool {
    let mut changed = false;
    if progress.model_ready_at.is_none() && model_file.exists() {
        progress.model_ready_at = Some(path_timestamp(model_file).unwrap_or_else(now_rfc3339));
        changed = true;
    }

    if progress.first_artifact_saved_at.is_none() {
        if let Some(path) = latest_artifact {
            progress.first_artifact_saved_at =
                Some(path_timestamp(path).unwrap_or_else(now_rfc3339));
            if progress.first_artifact_path.is_none() {
                progress.first_artifact_path = Some(path.display().to_string());
            }
            changed = true;
        }
    }
    changed
}

pub fn load_activation_progress(config: &Config) -> Arc<Mutex<ActivationProgress>> {
    let path = activation_state_path();
    let mut progress = std::fs::read_to_string(&path)
        .ok()
        .and_then(|raw| serde_json::from_str::<ActivationProgress>(&raw).ok())
        .unwrap_or_default();

    let mut changed = false;

    if progress.desktop_opened_at.is_none() {
        progress.desktop_opened_at = Some(now_rfc3339());
        changed = true;
    }

    let model_file = model_file_for_config(config);
    let latest_artifact = latest_saved_artifact_from_search(config);
    changed |=
        backfill_activation_from_paths(&mut progress, &model_file, latest_artifact.as_deref());

    if changed {
        persist_activation_progress(&progress);
    }

    Arc::new(Mutex::new(progress))
}

fn activation_phase(
    engine: &str,
    progress: &ActivationProgress,
    has_model: bool,
    has_saved_artifact: bool,
    recording: bool,
    processing: bool,
) -> (&'static str, &'static str) {
    if !has_model {
        return (
            "needs-model",
            if engine == "parakeet" {
                "setup-parakeet"
            } else {
                "download-model"
            },
        );
    }
    if progress.first_recording_started_at.is_none() {
        return ("ready-for-first-recording", "start-first-recording");
    }
    if !has_saved_artifact {
        if recording {
            return ("recording-first-artifact", "keep-recording");
        }
        if processing {
            return ("processing-first-artifact", "wait-for-first-artifact");
        }
        return ("ready-for-first-artifact", "start-first-recording");
    }
    if progress.next_step_nudge_shown_at.is_none() {
        return ("first-artifact-saved", "show-next-step");
    }
    ("activated", "explore-minutes")
}

fn activation_status_view(
    engine: &str,
    progress: &ActivationProgress,
    has_model: bool,
    has_saved_artifact: bool,
    recording: bool,
    processing: bool,
) -> ActivationStatusView {
    let (phase, next_action) = activation_phase(
        engine,
        progress,
        has_model,
        has_saved_artifact,
        recording,
        processing,
    );
    ActivationStatusView {
        phase: phase.into(),
        next_action: next_action.into(),
        has_model,
        has_saved_artifact,
        first_artifact_path: progress.first_artifact_path.clone(),
        milestones: progress.clone(),
    }
}

fn build_related_context(
    config: &Config,
    current_path: &Path,
    frontmatter: &minutes_core::markdown::Frontmatter,
) -> RelatedContextView {
    let mut related_people = frontmatter.attendees.clone();
    for person in &frontmatter.people {
        if !related_people.iter().any(|existing| existing == person) {
            related_people.push(person.clone());
        }
    }
    for entity in &frontmatter.entities.people {
        if !related_people
            .iter()
            .any(|existing| existing == &entity.label)
        {
            related_people.push(entity.label.clone());
        }
    }

    let mut related_topics = Vec::new();
    for decision in &frontmatter.decisions {
        if let Some(topic) = decision
            .topic
            .as_ref()
            .filter(|topic| !topic.trim().is_empty())
        {
            if !related_topics
                .iter()
                .any(|existing: &String| existing == topic)
            {
                related_topics.push(topic.clone());
            }
        }
    }

    let mut related_meetings = Vec::new();
    let mut related_meeting_paths = std::collections::HashSet::new();
    let mut related_commitments = Vec::new();

    for person in related_people.iter().take(3) {
        if let Ok(profile) = minutes_core::search::person_profile(config, person) {
            for meeting in profile.recent_meetings.into_iter().take(3) {
                let meeting_path = meeting.path.display().to_string();
                if meeting.path == current_path {
                    continue;
                }
                if related_meeting_paths.insert(meeting_path.clone()) {
                    related_meetings.push(MeetingReferenceView {
                        path: meeting_path,
                        title: meeting.title,
                        date: meeting.date,
                        content_type: meeting.content_type,
                    });
                }
            }

            for intent in profile.open_intents.into_iter().take(3) {
                related_commitments.push(RelatedCommitmentView {
                    path: intent.path.display().to_string(),
                    title: intent.title,
                    what: intent.what,
                    who: intent.who,
                    by_date: intent.by_date,
                });
            }
        }
    }

    for topic in related_topics.iter().take(2) {
        let filters = minutes_core::search::SearchFilters {
            content_type: None,
            since: None,
            attendee: None,
            intent_kind: None,
            owner: None,
            recorded_by: None,
        };
        if let Ok(report) = minutes_core::search::cross_meeting_research(topic, config, &filters) {
            for meeting in report.recent_meetings.into_iter().take(3) {
                let meeting_path = meeting.path.display().to_string();
                if meeting.path == current_path {
                    continue;
                }
                if related_meeting_paths.insert(meeting_path.clone()) {
                    related_meetings.push(MeetingReferenceView {
                        path: meeting_path,
                        title: meeting.title,
                        date: meeting.date,
                        content_type: meeting.content_type,
                    });
                }
            }

            for intent in report.related_open_intents.into_iter().take(3) {
                related_commitments.push(RelatedCommitmentView {
                    path: intent.path.display().to_string(),
                    title: intent.title,
                    what: intent.what,
                    who: intent.who,
                    by_date: intent.by_date,
                });
            }
        }
    }

    related_meetings.truncate(6);
    related_commitments.truncate(6);

    RelatedContextView {
        related_people,
        related_topics,
        related_meetings,
        related_commitments,
        adjacent_artifacts: recent_artifact_views(config, 4, Some(current_path)),
    }
}

#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProcessingJobView {
    pub id: String,
    pub title: String,
    pub mode: String,
    pub state: String,
    pub stage: Option<String>,
    pub stage_label: Option<String>,
    pub output_path: Option<String>,
    pub audio_path: String,
    pub error: Option<String>,
    pub created_at: String,
    pub started_at: Option<String>,
    pub finished_at: Option<String>,
    pub word_count: Option<usize>,
}

#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WeeklySummaryView {
    pub markdown: String,
}

#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProactiveContextBundleView {
    pub summary: String,
    pub markdown: String,
    pub recent_meeting_count: usize,
    pub recent_memo_count: usize,
    pub stale_commitment_count: usize,
    pub losing_touch_count: usize,
}

fn build_weekly_summary_markdown(
    meetings_count: usize,
    recent_titles: &str,
    decision_conflicts: &str,
    stale_commitments: &str,
    open_actions_block: &str,
) -> String {
    format!(
        "# Weekly Summary\n\n## Volume\n\n- {meetings_count} meeting or memo artifact(s) in the last 7 days.\n\n## Recent Meetings\n\n{recent_titles}\n\n## Decision Arcs\n\n{decision_conflicts}\n\n## Stale Commitments\n\n{stale_commitments}\n\n## Open Actions\n\n{open_actions_block}\n\n## Monday Brief\n\n- Confirm the highest-risk open commitment.\n- Review the most important decision conflict before the next meeting.\n- Turn the most important meeting into a durable artifact if it is still only in transcript form.\n"
    )
}

fn build_proactive_context_markdown(
    recent_meetings: &[String],
    recent_memos: &[String],
    stale_commitments: &[String],
    losing_touch: &[String],
) -> String {
    let meetings_block = if recent_meetings.is_empty() {
        "- No recent meetings.".to_string()
    } else {
        recent_meetings
            .iter()
            .map(|line| format!("- {line}"))
            .collect::<Vec<_>>()
            .join("\n")
    };
    let memos_block = if recent_memos.is_empty() {
        "- No recent memos.".to_string()
    } else {
        recent_memos
            .iter()
            .map(|line| format!("- {line}"))
            .collect::<Vec<_>>()
            .join("\n")
    };
    let stale_block = if stale_commitments.is_empty() {
        "- No stale commitments.".to_string()
    } else {
        stale_commitments
            .iter()
            .map(|line| format!("- {line}"))
            .collect::<Vec<_>>()
            .join("\n")
    };
    let touch_block = if losing_touch.is_empty() {
        "- No losing-touch alerts.".to_string()
    } else {
        losing_touch
            .iter()
            .map(|line| format!("- {line}"))
            .collect::<Vec<_>>()
            .join("\n")
    };

    format!(
        "# Proactive Context\n\n## Recent Meetings\n\n{meetings_block}\n\n## Recent Memos\n\n{memos_block}\n\n## Stale Commitments\n\n{stale_block}\n\n## Losing Touch\n\n{touch_block}\n"
    )
}

fn processing_job_title_fallback(mode: CaptureMode) -> &'static str {
    match mode {
        CaptureMode::Meeting => "Meeting recording",
        CaptureMode::QuickThought => "Quick thought",
        CaptureMode::Dictation => "Dictation",
        CaptureMode::LiveTranscript => "Live transcript",
    }
}

fn processing_job_view(job: minutes_core::jobs::ProcessingJob) -> ProcessingJobView {
    let mode = job.mode;
    let fallback_title = processing_job_title_fallback(mode);
    let stage_label = pipeline_stage_label(job.stage.as_deref(), Some(mode)).map(String::from);
    ProcessingJobView {
        id: job.id,
        title: job.title.unwrap_or_else(|| fallback_title.into()),
        mode: match mode {
            CaptureMode::Meeting => "meeting".into(),
            CaptureMode::QuickThought => "quick-thought".into(),
            CaptureMode::Dictation => "dictation".into(),
            CaptureMode::LiveTranscript => "live-transcript".into(),
        },
        state: match job.state {
            minutes_core::jobs::JobState::Queued => "queued".into(),
            minutes_core::jobs::JobState::Transcribing => "transcribing".into(),
            minutes_core::jobs::JobState::TranscriptOnly => "transcript-only".into(),
            minutes_core::jobs::JobState::Diarizing => "diarizing".into(),
            minutes_core::jobs::JobState::Summarizing => "summarizing".into(),
            minutes_core::jobs::JobState::Saving => "saving".into(),
            minutes_core::jobs::JobState::NeedsReview => "needs-review".into(),
            minutes_core::jobs::JobState::Complete => "complete".into(),
            minutes_core::jobs::JobState::Failed => "failed".into(),
        },
        stage: job.stage,
        stage_label,
        output_path: job.output_path,
        audio_path: job.audio_path,
        error: job.error,
        created_at: job.created_at.to_rfc3339(),
        started_at: job.started_at.map(|ts| ts.to_rfc3339()),
        finished_at: job.finished_at.map(|ts| ts.to_rfc3339()),
        word_count: job.word_count,
    }
}

fn artifact_slug(text: &str) -> String {
    let mut slug = String::new();
    let mut previous_dash = false;
    for ch in text.chars() {
        let lower = ch.to_ascii_lowercase();
        if lower.is_ascii_alphanumeric() {
            slug.push(lower);
            previous_dash = false;
        } else if !previous_dash {
            slug.push('-');
            previous_dash = true;
        }
    }
    slug.trim_matches('-').to_string()
}

fn text_file_kind(path: &Path) -> Option<&'static str> {
    match path
        .extension()
        .and_then(|ext| ext.to_str())
        .map(|ext| ext.to_ascii_lowercase())
        .as_deref()
    {
        Some("md") | Some("markdown") => Some("markdown"),
        Some("txt") => Some("text"),
        Some("json") => Some("json"),
        _ => None,
    }
}

fn resolve_unique_path(dir: &Path, stem: &str, extension: &str) -> PathBuf {
    let mut candidate = dir.join(format!("{stem}.{extension}"));
    let mut suffix = 2u32;
    while candidate.exists() {
        candidate = dir.join(format!("{stem}-{suffix}.{extension}"));
        suffix += 1;
    }
    candidate
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct HotkeyChoice {
    pub value: String,
    pub label: String,
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct HotkeySettings {
    pub enabled: bool,
    pub shortcut: String,
    pub choices: Vec<HotkeyChoice>,
}

#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DesktopCapabilities {
    pub platform: String,
    pub folder_reveal_label: String,
    pub supports_calendar_integration: bool,
    pub supports_call_detection: bool,
    pub supports_tray_artifact_copy: bool,
    pub supports_dictation_hotkey: bool,
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct TerminalInfo {
    pub title: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HotkeyCaptureStyle {
    Hold,
    Locked,
}

#[derive(Debug, Default)]
pub struct HotkeyRuntime {
    pub key_down: bool,
    pub key_down_started_at: Option<Instant>,
    pub active_capture: Option<HotkeyCaptureStyle>,
    pub recording_started_at: Option<Instant>,
    pub hold_generation: u64,
}

const HOTKEY_CHOICES: [(&str, &str); 3] = [
    ("CmdOrCtrl+Shift+M", "Cmd/Ctrl + Shift + M"),
    ("CmdOrCtrl+Shift+J", "Cmd/Ctrl + Shift + J"),
    ("CmdOrCtrl+Shift+T", "Cmd/Ctrl + Shift + T"),
];
const LIVE_SHORTCUT_CHOICES: [(&str, &str); 3] = [
    ("CmdOrCtrl+Shift+L", "Cmd/Ctrl + Shift + L"),
    ("CmdOrCtrl+Alt+L", "Cmd/Ctrl + Option/Alt + L"),
    ("CmdOrCtrl+Shift+T", "Cmd/Ctrl + Shift + T"),
];
const DICTATION_SHORTCUT_CHOICES: [(&str, &str); 3] = [
    ("CmdOrCtrl+Shift+Space", "Cmd/Ctrl + Shift + Space"),
    ("CmdOrCtrl+Alt+Space", "Cmd/Ctrl + Option/Alt + Space"),
    ("CmdOrCtrl+Shift+D", "Cmd/Ctrl + Shift + D"),
];
// Codex pass 3 + claude pass 3 P2: dropped `Cmd+Shift+P` from this
// dropdown because it actively conflicts with VS Code's Command
// Palette — offering it as a default-list choice would encourage
// users to break their IDE binding. `Cmd+Alt+Space` is also removed
// because it's the second slot in `DICTATION_SHORTCUT_CHOICES` and
// dual-claiming would silently fail one of the two registrations.
//
// Choices below are checked against `HOTKEY_CHOICES` and
// `DICTATION_SHORTCUT_CHOICES` so we don't reintroduce a collision in
// either direction. Users who want a non-default chord can edit
// `~/.config/minutes/config.toml` directly — the startup register path
// accepts arbitrary accelerator strings.
const PALETTE_SHORTCUT_CHOICES: [(&str, &str); 3] = [
    ("CmdOrCtrl+Shift+K", "Cmd/Ctrl + Shift + K"),
    ("CmdOrCtrl+Shift+O", "Cmd/Ctrl + Shift + O"),
    ("CmdOrCtrl+Shift+U", "Cmd/Ctrl + Shift + U"),
];
const HOTKEY_HOLD_THRESHOLD_MS: u64 = 300;
const HOTKEY_MIN_DURATION_MS: u64 = 400;

pub fn current_platform() -> &'static str {
    if cfg!(target_os = "macos") {
        "macos"
    } else if cfg!(target_os = "windows") {
        "windows"
    } else if cfg!(target_os = "linux") {
        "linux"
    } else {
        "other"
    }
}

pub fn supports_calendar_integration() -> bool {
    cfg!(target_os = "macos")
}

pub fn supports_call_detection() -> bool {
    cfg!(target_os = "macos")
}

pub fn supports_tray_artifact_copy() -> bool {
    cfg!(any(target_os = "macos", target_os = "windows"))
}

pub fn supports_dictation_hotkey() -> bool {
    cfg!(target_os = "macos")
}

pub fn folder_reveal_label() -> &'static str {
    if cfg!(target_os = "macos") {
        "Show in Finder"
    } else if cfg!(target_os = "windows") {
        "Show in Explorer"
    } else {
        "Show in Folder"
    }
}

fn desktop_context_limited(config: &Config, accessibility_trusted: bool) -> bool {
    #[cfg(target_os = "macos")]
    {
        (config.desktop_context.capture_window_titles
            || config.desktop_context.capture_browser_context)
            && !accessibility_trusted
    }

    #[cfg(not(target_os = "macos"))]
    {
        let _ = (config, accessibility_trusted);
        false
    }
}

fn desktop_context_state(
    config: &Config,
    capture_supported: bool,
    capture_active: bool,
) -> &'static str {
    if !config.desktop_context.enabled {
        "off"
    } else if !capture_supported {
        "unsupported"
    } else if capture_active {
        "recording"
    } else {
        "idle"
    }
}

pub fn default_hotkey_shortcut() -> &'static str {
    HOTKEY_CHOICES[0].0
}

pub fn default_dictation_shortcut() -> &'static str {
    DICTATION_SHORTCUT_CHOICES[0].0
}

pub fn default_palette_shortcut() -> &'static str {
    PALETTE_SHORTCUT_CHOICES[0].0
}

fn shortcut_choices(choices: &[(&str, &str)]) -> Vec<HotkeyChoice> {
    choices
        .iter()
        .map(|(value, label)| HotkeyChoice {
            value: (*value).to_string(),
            label: (*label).to_string(),
        })
        .collect()
}

fn hotkey_choices() -> Vec<HotkeyChoice> {
    shortcut_choices(&HOTKEY_CHOICES)
}

fn dictation_shortcut_choices() -> Vec<HotkeyChoice> {
    shortcut_choices(&DICTATION_SHORTCUT_CHOICES)
}

fn palette_shortcut_choices() -> Vec<HotkeyChoice> {
    shortcut_choices(&PALETTE_SHORTCUT_CHOICES)
}

fn live_shortcut_choices() -> Vec<HotkeyChoice> {
    shortcut_choices(&LIVE_SHORTCUT_CHOICES)
}

fn validate_shortcut(shortcut: &str, choices: &[(&str, &str)]) -> Result<String, String> {
    choices
        .iter()
        .find_map(|(value, _)| (*value == shortcut).then(|| (*value).to_string()))
        .ok_or_else(|| {
            format!(
                "Unsupported shortcut: {}. Choose one of: {}",
                shortcut,
                choices
                    .iter()
                    .map(|(_, label)| *label)
                    .collect::<Vec<_>>()
                    .join(", ")
            )
        })
}

fn validate_hotkey_shortcut(shortcut: &str) -> Result<String, String> {
    validate_shortcut(shortcut, &HOTKEY_CHOICES)
}

fn validate_dictation_shortcut(shortcut: &str) -> Result<String, String> {
    validate_shortcut(shortcut, &DICTATION_SHORTCUT_CHOICES)
}

fn validate_live_shortcut(shortcut: &str) -> Result<String, String> {
    validate_shortcut(shortcut, &LIVE_SHORTCUT_CHOICES)
}

fn validate_download_model_name(model: &str) -> Result<&str, String> {
    const ALLOWED_MODELS: [&str; 5] = ["tiny", "base", "small", "medium", "large-v3"];
    if ALLOWED_MODELS.contains(&model) {
        Ok(model)
    } else {
        Err(format!(
            "Unsupported model: {}. Choose one of: {}",
            model,
            ALLOWED_MODELS.join(", ")
        ))
    }
}

fn validate_palette_shortcut(shortcut: &str) -> Result<String, String> {
    validate_shortcut(shortcut, &PALETTE_SHORTCUT_CHOICES)
}

fn current_hotkey_settings(state: &AppState) -> HotkeySettings {
    let shortcut = state
        .global_hotkey_shortcut
        .lock()
        .ok()
        .map(|value| value.clone())
        .unwrap_or_else(|| default_hotkey_shortcut().to_string());
    HotkeySettings {
        enabled: state.global_hotkey_enabled.load(Ordering::Relaxed),
        shortcut,
        choices: hotkey_choices(),
    }
}

fn current_dictation_shortcut_settings(state: &AppState) -> HotkeySettings {
    let shortcut = state
        .dictation_shortcut
        .lock()
        .ok()
        .map(|value| value.clone())
        .unwrap_or_else(|| default_dictation_shortcut().to_string());
    HotkeySettings {
        enabled: state.dictation_shortcut_enabled.load(Ordering::Relaxed),
        shortcut,
        choices: dictation_shortcut_choices(),
    }
}

fn clear_hotkey_runtime(runtime: &Arc<Mutex<HotkeyRuntime>>) {
    if let Ok(mut current) = runtime.lock() {
        current.key_down = false;
        current.key_down_started_at = None;
        current.active_capture = None;
        current.recording_started_at = None;
    }
}

fn should_discard_hotkey_capture(started_at: Option<Instant>, now: Instant) -> bool {
    started_at
        .map(|started| now.duration_since(started).as_millis() < HOTKEY_MIN_DURATION_MS as u128)
        .unwrap_or(false)
}

fn reset_hotkey_capture_state(
    runtime: Option<&Arc<Mutex<HotkeyRuntime>>>,
    discard_short_hotkey_capture: Option<&Arc<AtomicBool>>,
) {
    if let Some(flag) = discard_short_hotkey_capture {
        flag.store(false, Ordering::Relaxed);
    }
    if let Some(runtime) = runtime {
        clear_hotkey_runtime(runtime);
    }
}

fn preserve_failed_capture(wav_path: &std::path::Path, config: &Config) -> Option<PathBuf> {
    let metadata = wav_path.metadata().ok()?;
    if metadata.len() == 0 {
        return None;
    }

    let dir = config.output_dir.join("failed-captures");
    std::fs::create_dir_all(&dir).ok()?;
    let dest = dir.join(format!(
        "{}-capture.wav",
        chrono::Local::now().format("%Y-%m-%d-%H%M%S")
    ));

    std::fs::copy(wav_path, &dest).ok()?;
    std::fs::remove_file(wav_path).ok();
    Some(dest)
}

fn preserve_failed_capture_path(path: &std::path::Path, config: &Config) -> Option<PathBuf> {
    let metadata = path.metadata().ok()?;
    if metadata.len() == 0 {
        return None;
    }

    let dir = config.output_dir.join("failed-captures");
    std::fs::create_dir_all(&dir).ok()?;
    let ext = path
        .extension()
        .and_then(|value| value.to_str())
        .filter(|value| !value.is_empty())
        .unwrap_or("bin");
    let new_basename = unique_failed_capture_basename(&dir, ext);
    let dest = dir.join(format!("{}.{}", new_basename, ext));

    // O_CREAT|O_EXCL on dest. If another process or a prior aborted run
    // raced to the same slot between `unique_failed_capture_basename` and
    // here, we'd rather preserve the existing artifact than silently
    // overwrite it (the previous overwrite-and-pray version was a pre-
    // existing data-loss bug now plugged). On failure, leave the source
    // intact — caller treats None as "couldn't preserve" and the original
    // .mov remains on disk for manual recovery.
    if copy_no_clobber_nofollow(path, &dest).is_err() {
        return None;
    }
    std::fs::remove_file(path).ok();

    // Native call capture writes lossless per-source PCM stems next to the
    // .mov (`<base>.voice.wav` / `<base>.system.wav`). When the .mov is
    // truncated (issue #216) the stems are the only intact artifact; rescue
    // them alongside the preserved path with matching new basenames so
    // `diarize::discover_stems` finds them on retry. Failures here are
    // best-effort — the .mov is already preserved.
    rescue_paired_stems(path, &dir, &new_basename);

    Some(dest)
}

#[derive(Debug, Clone)]
struct NativeCallProcessingInput {
    path: PathBuf,
    recovery_warning: Option<String>,
}

fn file_has_bytes(path: &Path) -> bool {
    path.metadata().is_ok_and(|metadata| metadata.len() > 0)
}

fn viable_native_call_stem(path: &Path) -> bool {
    path.metadata()
        .is_ok_and(|metadata| metadata.len() >= min_viable_stem_bytes(path))
}

fn native_call_recovery_health(
    message: impl Into<String>,
    source: minutes_core::diarize::CaptureSource,
) -> minutes_core::markdown::RecordingHealth {
    minutes_core::markdown::RecordingHealth {
        voice_stem_active_ratio: None,
        system_stem_active_ratio: None,
        system_dominant_ratio: None,
        capture_warnings: vec![minutes_core::markdown::CaptureWarning {
            kind: minutes_core::diarize::FailureKind::Other {
                code: "native-call-stem-recovery".into(),
            },
            source,
            message: message.into(),
            diagnostic_confidence: minutes_core::diarize::DiagnosticConfidence::Inferred,
        }],
        diarization_path: Some(minutes_core::markdown::DiarizationPath::None),
    }
}

fn native_call_processing_input(output_path: &Path) -> io::Result<NativeCallProcessingInput> {
    let primary_has_bytes = file_has_bytes(output_path);
    let stems = minutes_core::capture::stem_paths_for(output_path);
    let voice = stems.as_ref().map(|stems| stems.voice.as_path());
    let system = stems.as_ref().map(|stems| stems.system.as_path());
    let voice_viable = voice.is_some_and(viable_native_call_stem);
    let system_viable = system.is_some_and(viable_native_call_stem);

    if voice_viable && system_viable {
        if !primary_has_bytes {
            // `prepare_transcription_input` only needs the .mov path as the
            // stem-discovery anchor; tests already use a one-byte .mov stub.
            // If ScreenCaptureKit failed to materialize the container but the
            // PCM stems are good, create that anchor instead of stranding both
            // stems in native-captures.
            std::fs::write(output_path, b"minutes native-call stem anchor")?;
            return Ok(NativeCallProcessingInput {
                path: output_path.to_path_buf(),
                recovery_warning: Some(
                    "Native call capture did not produce a usable .mov container; processing recovered PCM stems instead.".into(),
                ),
            });
        }

        return Ok(NativeCallProcessingInput {
            path: output_path.to_path_buf(),
            recovery_warning: None,
        });
    }

    if voice_viable {
        let voice = voice.expect("voice path present when viable");
        return Ok(NativeCallProcessingInput {
            path: voice.to_path_buf(),
            recovery_warning: Some(
                "Native call capture did not produce a usable system-audio stem; processing the local microphone stem only.".into(),
            ),
        });
    }

    if system_viable {
        let system = system.expect("system path present when viable");
        return Ok(NativeCallProcessingInput {
            path: system.to_path_buf(),
            recovery_warning: Some(
                "Native call capture did not produce a usable microphone stem; processing the system-audio stem only.".into(),
            ),
        });
    }

    if primary_has_bytes {
        return Ok(NativeCallProcessingInput {
            path: output_path.to_path_buf(),
            recovery_warning: None,
        });
    }

    Err(io::Error::new(
        io::ErrorKind::NotFound,
        format!(
            "native call capture did not produce a usable .mov or PCM stem under {}",
            output_path
                .parent()
                .map(|path| path.display().to_string())
                .unwrap_or_else(|| "<unknown>".into())
        ),
    ))
}

#[allow(clippy::too_many_arguments)]
fn queue_native_call_capture_for_processing(
    mode: CaptureMode,
    requested_title: Option<String>,
    output_path: &Path,
    user_notes: Option<String>,
    pre_context: Option<String>,
    recording_started_at: chrono::DateTime<chrono::Local>,
    recording_finished_at: chrono::DateTime<chrono::Local>,
    context_session_id: Option<String>,
    calendar_event: Option<minutes_core::calendar::CalendarEvent>,
    extra_warning: Option<String>,
) -> Result<minutes_core::jobs::ProcessingJob, String> {
    let input = native_call_processing_input(output_path).map_err(|error| {
        format!("failed to prepare native call capture for processing: {error}")
    })?;
    let warning = match (extra_warning, input.recovery_warning) {
        (Some(extra), Some(recovery)) => Some(format!("{extra} {recovery}")),
        (Some(extra), None) => Some(extra),
        (None, Some(recovery)) => Some(recovery),
        (None, None) => None,
    };
    let source = if input
        .path
        .file_name()
        .and_then(|name| name.to_str())
        .is_some_and(|name| name.ends_with(".system.wav"))
    {
        minutes_core::diarize::CaptureSource::System
    } else if input
        .path
        .file_name()
        .and_then(|name| name.to_str())
        .is_some_and(|name| name.ends_with(".voice.wav"))
    {
        minutes_core::diarize::CaptureSource::Voice
    } else {
        minutes_core::diarize::CaptureSource::Both
    };
    let recording_health = warning.map(|message| native_call_recovery_health(message, source));
    let (consent, consent_notice) = if mode == CaptureMode::Meeting {
        minutes_core::notes::load_consent()
    } else {
        (None, None)
    };

    minutes_core::jobs::queue_live_capture_with_recording_health(
        mode,
        requested_title,
        &input.path,
        user_notes,
        pre_context,
        Some(recording_started_at),
        Some(recording_finished_at),
        context_session_id,
        calendar_event,
        None,
        consent,
        consent_notice,
        recording_health,
    )
    .map_err(|error| error.to_string())
}

/// Copy `src` to `dest` with two invariants beyond `std::fs::copy`:
///
/// 1. **No-clobber on dest** — open with `O_CREAT|O_EXCL` (`create_new` in Rust
///    std parlance) so a preexisting file at the destination is refused, not
///    overwritten. Closes the TOCTOU window between basename selection
///    (`unique_failed_capture_basename`) and the copy. Without this, a race
///    that landed two preserves on the same name silently destroyed the
///    earlier artifact.
///
/// 2. **No-follow on src** — open with `O_NOFOLLOW` on Unix so a symlink at
///    the source path is rejected instead of having its target's contents
///    materialized at `dest`. Closes the TOCTOU window between an earlier
///    `symlink_metadata()` check and the open. Defense-in-depth: the only
///    writer to `~/.minutes/native-captures/` is the Swift helper, but the
///    principled fix is to never deref symlinks during a recovery move.
///
/// On a mid-stream copy failure (disk full, EIO, etc.), the partially-written
/// destination file is removed so the caller's rollback only has to clean up
/// successful prior copies, not also reason about half-written ones.
fn copy_no_clobber_nofollow(src: &Path, dest: &Path) -> io::Result<u64> {
    #[cfg(unix)]
    let mut src_file = {
        use std::os::unix::fs::OpenOptionsExt;
        OpenOptions::new()
            .read(true)
            .custom_flags(libc::O_NOFOLLOW)
            .open(src)?
    };
    #[cfg(not(unix))]
    let mut src_file = std::fs::File::open(src)?;

    let mut dest_file = OpenOptions::new().write(true).create_new(true).open(dest)?;

    match io::copy(&mut src_file, &mut dest_file) {
        Ok(n) => Ok(n),
        Err(error) => {
            // Drop the half-written destination before returning so the
            // caller's rollback doesn't have to also reason about an
            // orphaned truncated file under the chosen basename.
            drop(dest_file);
            let _ = std::fs::remove_file(dest);
            Err(error)
        }
    }
}

/// Pick a basename for a failed-capture artifact that doesn't collide with
/// anything already present. Default precision is one second, which is fine
/// in practice (a single recording produces one preserved artifact), but
/// two failures landing in the same wall-clock second would silently
/// overwrite each other (`fs::copy` overwrites). Append `-2`, `-3`, ... if
/// any of the candidate slots — primary ext, `.voice.wav`, `.system.wav` —
/// already exist for the chosen basename.
fn unique_failed_capture_basename(dir: &Path, ext: &str) -> String {
    let ts = chrono::Local::now().format("%Y-%m-%d-%H%M%S").to_string();
    let primary = format!("{}-capture", ts);
    if basename_is_free(dir, &primary, ext) {
        return primary;
    }
    for n in 2..1000 {
        let candidate = format!("{}-capture-{}", ts, n);
        if basename_is_free(dir, &candidate, ext) {
            return candidate;
        }
    }
    // Pathological: 999 collisions in one second. Fall back to the primary
    // name — fs::copy will overwrite, but at this point something is very
    // wrong upstream and silent failure is no worse than crashing the
    // recording-stop path.
    primary
}

fn basename_is_free(dir: &Path, basename: &str, ext: &str) -> bool {
    !dir.join(format!("{}.{}", basename, ext)).exists()
        && !dir.join(format!("{}.voice.wav", basename)).exists()
        && !dir.join(format!("{}.system.wav", basename)).exists()
}

/// Audio duration (seconds) below which a stem is treated as an abort-at-start
/// fragment and skipped by rescue (#236). Read from the file's WAV header
/// `byte_rate` field so the threshold scales to whatever sample rate the
/// SCStream buffer produced rather than assuming 48 kHz.
const MIN_VIABLE_STEM_DURATION_SECS: f64 = 0.5;

/// Fallback `byte_rate` when the WAV header cannot be parsed (corrupt header,
/// truncated to less than 32 bytes, non-RIFF). 48 kHz float32 mono is what
/// SCStream produces in practice and what AVAudioFile defaults to in the
/// Swift helper. Used so a malformed-but-real-sized stem still gets a chance
/// to rescue against a reasonable threshold rather than always passing.
const FALLBACK_WAV_BYTE_RATE: u32 = 192_000;

/// Read the `byte_rate` field (bytes/sec of audio data) from a WAV header.
/// Returns None for non-RIFF files, files whose first chunk is not `fmt `
/// (e.g. BWF with leading JUNK/bext, RF64), headers shorter than 32 bytes,
/// any I/O error, or a header that decodes byte_rate as 0 (malformed).
///
/// Byte rate is at offset 28 of a standard RIFF/WAVE/PCM header (little
/// endian u32). For float WAVs this is `sample_rate * channels *
/// (bits_per_sample / 8)`. Reading from the header rather than recomputing
/// from sample_rate + format avoids assuming what the producer wrote.
///
/// We validate three layers before trusting offset 28:
/// 1. `RIFF` at 0..4 and `WAVE` at 8..12 — file is a RIFF/WAVE container.
/// 2. `fmt ` at 12..16 — the first chunk is the format chunk, not a
///    pre-format JUNK/bext chunk that would shift fmt elsewhere. Files
///    with non-standard chunk ordering fall back to the conservative
///    48 kHz default rather than silently using garbage at offset 28.
/// 3. `byte_rate > 0` — guards against malformed-but-RIFF-magic files
///    where the field is zero. A zero rate would set the size threshold
///    to zero and let every stem pass, defeating the abort-at-start
///    filter.
fn read_wav_byte_rate(path: &Path) -> Option<u32> {
    use std::io::Read;
    let mut file = std::fs::File::open(path).ok()?;
    let mut header = [0u8; 32];
    file.read_exact(&mut header).ok()?;
    if &header[0..4] != b"RIFF" || &header[8..12] != b"WAVE" {
        return None;
    }
    if &header[12..16] != b"fmt " {
        return None;
    }
    let byte_rate = u32::from_le_bytes([header[28], header[29], header[30], header[31]]);
    if byte_rate == 0 {
        return None;
    }
    Some(byte_rate)
}

/// Compute the minimum viable file size (bytes) for a stem at the given path
/// based on its WAV header's `byte_rate` field. Falls back to a 48 kHz
/// float32 mono rate when the header is unreadable so a malformed-but-real
/// stem still gets a fair threshold rather than always passing.
fn min_viable_stem_bytes(path: &Path) -> u64 {
    let byte_rate = read_wav_byte_rate(path).unwrap_or(FALLBACK_WAV_BYTE_RATE);
    ((byte_rate as f64) * MIN_VIABLE_STEM_DURATION_SECS) as u64
}

/// Move any sibling `<stem>.voice.wav` / `<stem>.system.wav` files (the
/// per-source PCM stems written by the native call capture helper) into
/// `dest_dir` with `<new_basename>.voice.wav` / `<new_basename>.system.wav`
/// names. No-op when the original path has no parent, no file stem, or no
/// matching stems exist.
///
/// Two-phase: copy every stem first; only after all copies succeed remove
/// the originals. If any copy fails, roll back partial copies and leave the
/// originals in place. This avoids the failure mode where voice copies
/// succeed and system fails (disk full, permission denied), since
/// `diarize::discover_stem_plan` rejects a `(voice=present, system=missing)`
/// pair (`SystemStemOnly` requires system to be the surviving one), so a
/// partial rescue would silently delete the only intact stem.
fn rescue_paired_stems(original: &std::path::Path, dest_dir: &Path, new_basename: &str) {
    let Some(parent) = original.parent() else {
        return;
    };
    let Some(stem) = original.file_stem().and_then(|s| s.to_str()) else {
        return;
    };

    // Phase 1: enumerate stems that exist and carry data. The suffix tag
    // travels alongside each plan entry so the asymmetry check below can
    // identify which side is missing without re-parsing path names.
    let mut plan: Vec<(PathBuf, (PathBuf, &str))> = Vec::new();
    for suffix in ["voice", "system"] {
        let src = parent.join(format!("{}.{}.wav", stem, suffix));
        // Use symlink_metadata so a sibling that is a symlink (pointing at
        // arbitrary content on disk) is not silently followed and copied
        // as if it were a real stem.
        let Ok(meta) = src.symlink_metadata() else {
            continue;
        };
        if !meta.file_type().is_file() {
            continue;
        }
        // Abort-at-start captures (issue #236) leave tiny orphan stems
        // (sjunnesson reported 19 KB and 38 KB pairs, ~0.1-0.2 s of
        // audio) next to 1.9 KB .mov stubs. The old `meta.len() == 0`
        // gate let these through, producing `failed-captures/` entries
        // the user can do nothing useful with. Skip stems below ~0.5 s
        // of audio. Threshold is derived per-file from the WAV header's
        // `byte_rate` field so it scales to whatever sample rate the
        // SCStream buffer actually produced (codex review caught the
        // earlier hard-coded 48 kHz assumption).
        let min_bytes = min_viable_stem_bytes(&src);
        if meta.len() < min_bytes {
            tracing::warn!(
                path = %src.display(),
                size_bytes = meta.len(),
                threshold_bytes = min_bytes,
                "rescue_paired_stems: skipping sub-threshold stem (abort-at-start fragment)"
            );
            continue;
        }
        let dest = dest_dir.join(format!("{}.{}.wav", new_basename, suffix));
        plan.push((src, (dest, suffix)));
    }

    // Discovery requires either both stems or system-only; voice-only is
    // explicitly rejected by `diarize::discover_stem_plan`. If voice made
    // it into the plan but system did not (absent, non-file, symlink, or
    // sub-threshold), drop voice too so we never produce a voice-only
    // rescue artifact. Codex review #236 caught the asymmetric-skip gap
    // introduced by the per-stem size filter — without this guard, a
    // sub-threshold system stem + healthy voice stem would silently
    // rescue voice alone and discover_stem_plan would reject it.
    let has_voice = plan.iter().any(|(_, (_, suffix))| *suffix == "voice");
    let has_system = plan.iter().any(|(_, (_, suffix))| *suffix == "system");
    if has_voice && !has_system {
        tracing::warn!(
            new_basename = new_basename,
            "rescue_paired_stems: dropping voice-only plan (system stem missing or sub-threshold); discover_stem_plan rejects voice-only"
        );
        return;
    }

    // Discard the suffix tag now that the asymmetry check has used it;
    // downstream phases only need the (src, dest) path pair.
    let plan: Vec<(PathBuf, PathBuf)> = plan.into_iter().map(|(s, (d, _))| (s, d)).collect();

    if plan.is_empty() {
        return;
    }

    // Phase 2: copy all. On failure, roll back and leave originals.
    // Uses `copy_no_clobber_nofollow` so each per-stem copy refuses to
    // overwrite a preexisting destination, refuses to follow a symlink at
    // the source, and removes its own half-written file on mid-stream
    // failure. Rollback only has to clean up SUCCESSFUL prior copies.
    let mut copied: Vec<PathBuf> = Vec::new();
    for (src, dest) in &plan {
        match copy_no_clobber_nofollow(src, dest) {
            Ok(_) => copied.push(dest.clone()),
            Err(_) => {
                for partial in &copied {
                    let _ = std::fs::remove_file(partial);
                }
                return;
            }
        }
    }

    // Phase 3: all copies succeeded; remove originals.
    for (src, _) in &plan {
        let _ = std::fs::remove_file(src);
    }
}

#[cfg(target_os = "macos")]
#[allow(clippy::too_many_arguments)]
fn start_native_call_recording(
    app_handle: &tauri::AppHandle,
    recording: &Arc<AtomicBool>,
    starting: &Arc<AtomicBool>,
    stop_flag: &Arc<AtomicBool>,
    processing: &Arc<AtomicBool>,
    processing_stage: &Arc<Mutex<Option<String>>>,
    latest_output: &Arc<Mutex<Option<OutputNotice>>>,
    activation_progress: &Arc<Mutex<ActivationProgress>>,
    call_capture_health: &Arc<Mutex<Option<crate::call_capture::CallSourceHealth>>>,
    completion_notifications_enabled: &Arc<AtomicBool>,
    hotkey_runtime: Option<&Arc<Mutex<HotkeyRuntime>>>,
    discard_short_hotkey_capture: Option<&Arc<AtomicBool>>,
    mode: CaptureMode,
    config: &Config,
    requested_title: Option<String>,
) -> Result<(), String> {
    minutes_core::pid::create().map_err(|error| error.to_string())?;
    // Re-check sensitive exclusivity with the PID (the atomic anchor) held;
    // closes the start/start interleaving in both directions (review F3).
    if let Err(error) = minutes_core::sensitive::ensure_inactive_for_recording() {
        minutes_core::pid::remove().ok();
        return Err(error.to_string());
    }
    let mut session = match call_capture::start_native_call_capture() {
        Ok(session) => session,
        Err(error) => {
            minutes_core::pid::remove().ok();
            return Err(error);
        }
    };
    let output_path = session.output_path().to_path_buf();
    let recording_started_at = chrono::Local::now();
    let context_session_id = minutes_core::desktop_context::maybe_start_capture_session(
        &config.desktop_context,
        mode,
        requested_title.clone(),
        recording_started_at,
    );

    starting.store(false, Ordering::Relaxed);
    recording.store(true, Ordering::Relaxed);
    stop_flag.store(false, Ordering::Relaxed);
    sync_processing_indicator(processing, processing_stage);
    set_latest_output(latest_output, None);
    if let Ok(mut health) = call_capture_health.lock() {
        *health = Some(session.source_health());
    }
    minutes_core::pid::write_recording_metadata_with_context(mode, context_session_id.as_deref())
        .ok();
    crate::sync_tray_state(app_handle);
    minutes_core::notes::save_recording_start().ok();
    maybe_save_and_show_recording_consent(app_handle, mode, config);
    minutes_core::events::append_event(minutes_core::events::recording_started_event(
        context_session_id.clone(),
        "capture",
        [
            "native-call.capture".to_string(),
            "screen-capture-kit".to_string(),
            format!("mode.{}", mode.noun().replace(' ', "-")),
        ],
    ));

    eprintln!(
        "[minutes] Native call capture started: {}",
        output_path.display()
    );

    let _desktop_context_collector = context_session_id.as_ref().and_then(|session_id| {
        match minutes_core::desktop_context::DesktopContextCollector::start(
            session_id.clone(),
            minutes_core::desktop_context::DesktopContextSessionKind::Recording,
            config.desktop_context.clone(),
        ) {
            Ok(collector) => Some(collector),
            Err(error) => {
                tracing::warn!(error = %error, mode = ?mode, "desktop context collector unavailable for native call recording");
                None
            }
        }
    });

    while !stop_flag.load(Ordering::Relaxed) {
        std::thread::sleep(Duration::from_millis(100));
        if let Ok(mut health) = call_capture_health.lock() {
            *health = Some(session.source_health());
        }
        if minutes_core::pid::check_and_clear_sentinel() {
            break;
        }
        if let Some(status) = session.try_wait()? {
            if !status.success() {
                let recording_finished_at = chrono::Local::now();
                let user_notes = minutes_core::notes::read_notes();
                let pre_context = minutes_core::notes::read_context();
                match queue_native_call_capture_for_processing(
                    mode,
                    requested_title.clone(),
                    &output_path,
                    user_notes,
                    pre_context,
                    recording_started_at,
                    recording_finished_at,
                    context_session_id.clone(),
                    None,
                    Some("Native call capture helper exited before normal stop.".into()),
                ) {
                    Ok(job) => {
                        processing.store(true, Ordering::Relaxed);
                        set_processing_stage(processing_stage, job.stage.as_deref());
                        minutes_core::pid::set_processing_status(
                            job.stage.as_deref(),
                            Some(mode),
                            job.title.as_deref(),
                            Some(&job.id),
                            minutes_core::jobs::active_job_count(),
                        )
                        .ok();
                        minutes_core::pid::remove().ok();
                        minutes_core::pid::clear_recording_metadata().ok();
                        minutes_core::notes::cleanup();
                        recording.store(false, Ordering::Relaxed);
                        starting.store(false, Ordering::Relaxed);
                        if let Ok(mut health) = call_capture_health.lock() {
                            *health = Some(session.source_health());
                        }
                        spawn_processing_worker(
                            app_handle.clone(),
                            processing.clone(),
                            processing_stage.clone(),
                            latest_output.clone(),
                            activation_progress.clone(),
                            completion_notifications_enabled.clone(),
                        );
                        sync_processing_indicator(processing, processing_stage);
                    }
                    Err(queue_error) => {
                        let preserved = preserve_failed_capture_path(&output_path, config);
                        if let Some(session_id) = context_session_id.as_deref() {
                            minutes_core::context_store::mark_capture_session_failed(
                                session_id,
                                Some(recording_finished_at),
                                &format!(
                                    "native call capture exited early; recovery queue failed: {}",
                                    queue_error
                                ),
                                preserved.as_deref(),
                            )
                            .ok();
                        }
                        minutes_core::pid::remove().ok();
                        minutes_core::pid::clear_recording_metadata().ok();
                        minutes_core::notes::cleanup();
                        recording.store(false, Ordering::Relaxed);
                        starting.store(false, Ordering::Relaxed);
                        if let Ok(mut health) = call_capture_health.lock() {
                            *health = None;
                        }
                        if let Some(saved) = preserved {
                            let notice = OutputNotice {
                                kind: "preserved-capture".into(),
                                title: "Native call capture failed".into(),
                                path: saved.display().to_string(),
                                detail: format!(
                                    "ScreenCaptureKit capture ended early. Recovery queue failed: {queue_error}"
                                ),
                                job_id: None,
                            };
                            set_latest_output(latest_output, Some(notice.clone()));
                            maybe_show_completion_notification(
                                app_handle,
                                completion_notifications_enabled,
                                &notice,
                            );
                        } else {
                            set_recording_error_notice(
                                latest_output,
                                "Native call capture failed",
                                format!(
                                    "ScreenCaptureKit capture ended early. Recovery queue failed: {queue_error}"
                                ),
                            );
                        }
                    }
                }
                reset_hotkey_capture_state(hotkey_runtime, discard_short_hotkey_capture);
                return Ok(());
            }
            break;
        }
    }

    if let Err(error) = session.stop() {
        let recording_finished_at = chrono::Local::now();
        let user_notes = minutes_core::notes::read_notes();
        let pre_context = minutes_core::notes::read_context();
        let queue_result = queue_native_call_capture_for_processing(
            mode,
            requested_title.clone(),
            &output_path,
            user_notes,
            pre_context,
            recording_started_at,
            recording_finished_at,
            context_session_id.clone(),
            None,
            Some(format!(
                "Native call capture finalization reported an error: {error}."
            )),
        );

        match queue_result {
            Ok(job) => {
                processing.store(true, Ordering::Relaxed);
                set_processing_stage(processing_stage, job.stage.as_deref());
                minutes_core::pid::set_processing_status(
                    job.stage.as_deref(),
                    Some(mode),
                    job.title.as_deref(),
                    Some(&job.id),
                    minutes_core::jobs::active_job_count(),
                )
                .ok();
                minutes_core::pid::remove().ok();
                minutes_core::pid::clear_recording_metadata().ok();
                minutes_core::notes::cleanup();
                starting.store(false, Ordering::Relaxed);
                recording.store(false, Ordering::Relaxed);
                if let Ok(mut health) = call_capture_health.lock() {
                    *health = Some(session.source_health());
                }
                spawn_processing_worker(
                    app_handle.clone(),
                    processing.clone(),
                    processing_stage.clone(),
                    latest_output.clone(),
                    activation_progress.clone(),
                    completion_notifications_enabled.clone(),
                );
                sync_processing_indicator(processing, processing_stage);
                reset_hotkey_capture_state(hotkey_runtime, discard_short_hotkey_capture);
                return Ok(());
            }
            Err(queue_error) => {
                let preserved = preserve_failed_capture_path(&output_path, config);
                if let Some(session_id) = context_session_id.as_deref() {
                    minutes_core::context_store::mark_capture_session_failed(
                        session_id,
                        Some(recording_finished_at),
                        &format!(
                            "stopping native call capture failed: {}; recovery queue failed: {}",
                            error, queue_error
                        ),
                        preserved.as_deref(),
                    )
                    .ok();
                }
                minutes_core::notes::cleanup();
                minutes_core::pid::remove().ok();
                minutes_core::pid::clear_recording_metadata().ok();
                processing.store(false, Ordering::Relaxed);
                set_processing_stage(processing_stage, None);
                starting.store(false, Ordering::Relaxed);
                recording.store(false, Ordering::Relaxed);
                if let Ok(mut health) = call_capture_health.lock() {
                    *health = None;
                }
                if let Some(saved) = preserved {
                    let notice = OutputNotice {
                        kind: "preserved-capture".into(),
                        title: "Native call capture preserved".into(),
                        path: saved.display().to_string(),
                        detail: format!(
                            "Stopping native call capture failed: {error}. Recovery queue failed: {queue_error}"
                        ),
                        job_id: None,
                    };
                    set_latest_output(latest_output, Some(notice.clone()));
                    maybe_show_completion_notification(
                        app_handle,
                        completion_notifications_enabled,
                        &notice,
                    );
                } else {
                    set_recording_error_notice(
                        latest_output,
                        "Native call capture failed",
                        format!(
                            "Stopping native call capture failed: {error}. Recovery queue failed: {queue_error}"
                        ),
                    );
                }
                reset_hotkey_capture_state(hotkey_runtime, discard_short_hotkey_capture);
                return Ok(());
            }
        }
    }

    recording.store(false, Ordering::Relaxed);
    if let Ok(mut health) = call_capture_health.lock() {
        *health = Some(session.source_health());
    }
    let should_discard = discard_short_hotkey_capture
        .as_ref()
        .map(|flag| flag.swap(false, Ordering::Relaxed))
        .unwrap_or(false);
    if should_discard {
        if output_path.exists() {
            std::fs::remove_file(&output_path).ok();
        }
        if let Some(session_id) = context_session_id.as_deref() {
            minutes_core::context_store::mark_capture_session_discarded(
                session_id,
                Some(chrono::Local::now()),
            )
            .ok();
        }
        minutes_core::notes::cleanup();
        minutes_core::pid::remove().ok();
        minutes_core::pid::clear_recording_metadata().ok();
        starting.store(false, Ordering::Relaxed);
        if let Ok(mut health) = call_capture_health.lock() {
            *health = None;
        }
        reset_hotkey_capture_state(hotkey_runtime, discard_short_hotkey_capture);
        return Ok(());
    }

    let recording_finished_at = chrono::Local::now();
    let user_notes = minutes_core::notes::read_notes();
    let pre_context = minutes_core::notes::read_context();
    // Don't block the stop path with a calendar query (can take 10s if Calendar.app hangs).
    // The pipeline already falls back to events_overlapping_now() during background processing.
    let calendar_event = None;

    match queue_native_call_capture_for_processing(
        mode,
        requested_title,
        &output_path,
        user_notes,
        pre_context,
        recording_started_at,
        recording_finished_at,
        context_session_id.clone(),
        calendar_event,
        None,
    ) {
        Ok(job) => {
            processing.store(true, Ordering::Relaxed);
            set_processing_stage(processing_stage, job.stage.as_deref());
            minutes_core::pid::set_processing_status(
                job.stage.as_deref(),
                Some(mode),
                job.title.as_deref(),
                Some(&job.id),
                minutes_core::jobs::active_job_count(),
            )
            .ok();
            minutes_core::pid::remove().ok();
            minutes_core::pid::clear_recording_metadata().ok();
            minutes_core::notes::cleanup();
            if let Ok(mut health) = call_capture_health.lock() {
                *health = Some(session.source_health());
            }
            spawn_processing_worker(
                app_handle.clone(),
                processing.clone(),
                processing_stage.clone(),
                latest_output.clone(),
                activation_progress.clone(),
                completion_notifications_enabled.clone(),
            );
            sync_processing_indicator(processing, processing_stage);
        }
        Err(error) => {
            let preserved = preserve_failed_capture_path(&output_path, config);
            if let Some(session_id) = context_session_id.as_deref() {
                minutes_core::context_store::mark_capture_session_failed(
                    session_id,
                    Some(recording_finished_at),
                    &format!("failed to queue native call capture: {}", error),
                    preserved.as_deref(),
                )
                .ok();
            }
            minutes_core::notes::cleanup();
            minutes_core::pid::remove().ok();
            minutes_core::pid::clear_recording_metadata().ok();
            processing.store(false, Ordering::Relaxed);
            set_processing_stage(processing_stage, None);
            if let Ok(mut health) = call_capture_health.lock() {
                *health = None;
            }
            if let Some(saved) = preserved {
                let notice = OutputNotice {
                    kind: "preserved-capture".into(),
                    title: "Native call capture preserved".into(),
                    path: saved.display().to_string(),
                    detail: format!(
                        "Failed to queue native call capture for processing: {}",
                        error
                    ),
                    job_id: None,
                };
                set_latest_output(latest_output, Some(notice.clone()));
                maybe_show_completion_notification(
                    app_handle,
                    completion_notifications_enabled,
                    &notice,
                );
            } else {
                set_recording_error_notice(
                    latest_output,
                    "Processing not started",
                    format!(
                        "Failed to queue native call capture for processing: {}",
                        error
                    ),
                );
            }
            starting.store(false, Ordering::Relaxed);
            reset_hotkey_capture_state(hotkey_runtime, discard_short_hotkey_capture);
            return Ok(());
        }
    }

    starting.store(false, Ordering::Relaxed);
    reset_hotkey_capture_state(hotkey_runtime, discard_short_hotkey_capture);
    Ok(())
}

pub fn recording_active(recording: &Arc<AtomicBool>) -> bool {
    recording.load(Ordering::Relaxed) || minutes_core::pid::status().recording
}

pub fn request_stop(
    recording: &Arc<AtomicBool>,
    stop_flag: &Arc<AtomicBool>,
) -> Result<(), String> {
    match minutes_core::pid::check_recording() {
        Ok(Some(pid)) => {
            if pid == std::process::id() {
                stop_flag.store(true, Ordering::Relaxed);
                recording.store(true, Ordering::Relaxed);
                Ok(())
            } else {
                minutes_core::pid::write_stop_sentinel().map_err(|e| e.to_string())?;

                #[cfg(unix)]
                {
                    if minutes_core::desktop_control::desktop_app_owns_pid(pid) {
                        eprintln!(
                            "recording PID {} is owned by the desktop app; using sentinel-only stop",
                            pid
                        );
                    } else {
                        let rc = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
                        if rc != 0 {
                            let err = std::io::Error::last_os_error();
                            eprintln!(
                                "SIGTERM failed (PID {}): {} — sentinel file will stop recording",
                                pid, err
                            );
                        }
                    }
                }

                Ok(())
            }
        }
        Ok(None) => {
            recording.store(false, Ordering::Relaxed);
            Err("Not recording".into())
        }
        Err(e) => Err(e.to_string()),
    }
}

fn wait_for_path_removal(path: &std::path::Path, timeout: Option<std::time::Duration>) -> bool {
    let start = std::time::Instant::now();
    while path.exists() {
        if let Some(timeout) = timeout {
            if start.elapsed() >= timeout {
                return false;
            }
        }
        std::thread::sleep(std::time::Duration::from_millis(250));
    }
    true
}

pub fn wait_for_recording_shutdown(timeout: std::time::Duration) -> bool {
    let pid_path = minutes_core::pid::pid_path();
    wait_for_path_removal(&pid_path, Some(timeout))
}

pub fn wait_for_recording_shutdown_forever() {
    let pid_path = minutes_core::pid::pid_path();
    let _ = wait_for_path_removal(&pid_path, None);
}

fn parse_capture_mode(mode: Option<&str>) -> Result<CaptureMode, String> {
    match mode.unwrap_or("meeting") {
        "meeting" => Ok(CaptureMode::Meeting),
        "quick-thought" => Ok(CaptureMode::QuickThought),
        other => Err(format!(
            "Unsupported recording mode: {}. Use 'meeting' or 'quick-thought'.",
            other
        )),
    }
}

fn parse_recording_intent(intent: Option<&str>) -> Result<Option<RecordingIntent>, String> {
    match intent.unwrap_or("auto") {
        "auto" => Ok(None),
        "memo" => Ok(Some(RecordingIntent::Memo)),
        "room" => Ok(Some(RecordingIntent::Room)),
        "call" => Ok(Some(RecordingIntent::Call)),
        other => Err(format!(
            "Unsupported recording intent: {}. Use auto, memo, room, or call.",
            other
        )),
    }
}

fn parse_optional_string_setting(value: &str) -> Option<String> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed.to_string())
    }
}

fn parse_optional_consent_basis_setting(value: &str) -> Result<Option<String>, String> {
    let Some(basis) = parse_optional_string_setting(value) else {
        return Ok(None);
    };
    basis
        .parse::<ConsentBasis>()
        .map_err(|error| error.to_string())?;
    Ok(Some(basis))
}

/// Parse a comma-separated setting string (e.g. `"foo, bar, baz"`) into a
/// `Vec<String>`. Trims whitespace around each entry, drops empties, preserves
/// input order. Case-sensitive — callers that need case-insensitive dedup
/// should use `IdentityConfig::all_user_aliases` downstream.
fn parse_comma_separated_setting(value: &str) -> Vec<String> {
    value
        .split(',')
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .collect()
}

fn call_detection_has_sentinel(config: &Config, sentinel: &str) -> bool {
    config.call_detection.apps.iter().any(|app| app == sentinel)
}

fn set_call_detection_sentinel(config: &mut Config, sentinel: &str, enabled: bool) {
    config.call_detection.apps.retain(|app| app != sentinel);
    if enabled {
        config.call_detection.apps.push(sentinel.to_string());
    }
}

fn configured_call_source(config: &Config) -> Option<&str> {
    config
        .recording
        .sources
        .as_ref()
        .and_then(|sources| sources.call.as_deref())
        .map(str::trim)
        .filter(|value| !value.is_empty())
}

fn arm_desktop_call_capture_route(
    config: &mut Config,
    requested_intent: Option<RecordingIntent>,
    loopback_device: Option<String>,
) -> bool {
    if requested_intent != Some(RecordingIntent::Call) || configured_call_source(config).is_some() {
        return false;
    }

    let Some(loopback_device) = loopback_device.map(|value| value.trim().to_string()) else {
        return false;
    };
    if loopback_device.is_empty() {
        return false;
    }

    let voice = config
        .recording
        .sources
        .as_ref()
        .and_then(|sources| sources.voice.clone())
        .or_else(|| config.recording.device.clone());
    config.recording.sources = Some(minutes_core::config::SourcesConfig {
        voice,
        call: Some(loopback_device),
    });
    config.recording.device = None;
    true
}

fn stage_label(stage: minutes_core::pipeline::PipelineStage, mode: CaptureMode) -> &'static str {
    match (stage, mode) {
        (minutes_core::pipeline::PipelineStage::Transcribing, CaptureMode::Meeting) => {
            "Transcribing meeting"
        }
        (minutes_core::pipeline::PipelineStage::Transcribing, CaptureMode::QuickThought) => {
            "Transcribing quick thought"
        }
        (minutes_core::pipeline::PipelineStage::Diarizing, _) => "Separating speakers",
        (minutes_core::pipeline::PipelineStage::Summarizing, CaptureMode::Meeting) => {
            "Generating meeting summary"
        }
        (minutes_core::pipeline::PipelineStage::Summarizing, CaptureMode::QuickThought) => {
            "Generating memo summary"
        }
        (minutes_core::pipeline::PipelineStage::Saving, CaptureMode::Meeting) => "Saving meeting",
        (minutes_core::pipeline::PipelineStage::Saving, CaptureMode::QuickThought) => {
            "Saving quick thought"
        }
        (minutes_core::pipeline::PipelineStage::Transcribing, CaptureMode::Dictation) => {
            "Transcribing dictation"
        }
        (minutes_core::pipeline::PipelineStage::Summarizing, CaptureMode::Dictation) => {
            "Generating dictation summary"
        }
        (minutes_core::pipeline::PipelineStage::Saving, CaptureMode::Dictation) => {
            "Saving dictation"
        }
        (_, CaptureMode::LiveTranscript) => "Processing live transcript",
    }
}

fn pipeline_stage_label(stage: Option<&str>, mode: Option<CaptureMode>) -> Option<&'static str> {
    let stage = stage?;
    let mode = mode?;
    let parsed = match stage {
        "transcribing" => minutes_core::pipeline::PipelineStage::Transcribing,
        "diarizing" => minutes_core::pipeline::PipelineStage::Diarizing,
        "summarizing" => minutes_core::pipeline::PipelineStage::Summarizing,
        "saving" => minutes_core::pipeline::PipelineStage::Saving,
        _ => return None,
    };
    Some(stage_label(parsed, mode))
}

fn set_processing_stage(stage: &Arc<Mutex<Option<String>>>, value: Option<&str>) {
    if let Ok(mut current) = stage.lock() {
        *current = value.map(String::from);
    }
}

fn set_latest_output(
    latest_output: &Arc<Mutex<Option<OutputNotice>>>,
    notice: Option<OutputNotice>,
) {
    if let Ok(mut current) = latest_output.lock() {
        *current = notice;
    }
}

fn output_error_notice(title: impl Into<String>, detail: impl Into<String>) -> OutputNotice {
    OutputNotice {
        kind: "error".into(),
        title: title.into(),
        path: String::new(),
        detail: detail.into(),
        job_id: None,
    }
}

fn recording_start_error_notice(detail: impl Into<String>) -> OutputNotice {
    output_error_notice("Recording not started", detail)
}

fn set_recording_start_error(
    latest_output: &Arc<Mutex<Option<OutputNotice>>>,
    detail: impl Into<String>,
) {
    let detail = detail.into();
    minutes_core::logging::log_error("desktop_recording_start", "", &detail);
    set_latest_output(latest_output, Some(recording_start_error_notice(detail)));
}

fn set_recording_error_notice(
    latest_output: &Arc<Mutex<Option<OutputNotice>>>,
    title: impl Into<String>,
    detail: impl Into<String>,
) {
    let detail = detail.into();
    minutes_core::logging::log_error("desktop_recording", "", &detail);
    set_latest_output(latest_output, Some(output_error_notice(title, detail)));
}

fn consent_mode_as_str(mode: ConsentMode) -> &'static str {
    match mode {
        ConsentMode::Off => "off",
        ConsentMode::Remind => "remind",
        ConsentMode::Require => "require",
    }
}

/// Result of a desktop recording start request.
#[derive(Debug, Clone, serde::Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum StartRecordingOutcome {
    /// Recording launch has been accepted.
    Started,
    /// The frontend must show the Require-mode confirmation dialog first.
    ConsentRequired {
        /// Disclosure text to show verbatim in the dialog.
        disclosure: String,
    },
}

fn desktop_recording_consent_basis(config: &Config) -> ConsentBasis {
    config
        .consent
        .default_basis
        .as_deref()
        .and_then(|basis| match basis.parse::<ConsentBasis>() {
            Ok(parsed) => Some(parsed),
            Err(error) => {
                tracing::warn!(
                    basis,
                    error = %error,
                    "invalid configured consent default_basis; recording as unattested"
                );
                None
            }
        })
        .unwrap_or(ConsentBasis::Unattested)
}

fn desktop_recording_consent_required(
    mode: CaptureMode,
    config: &Config,
    consent_confirmed: bool,
) -> Option<String> {
    if mode != CaptureMode::Meeting
        || config.consent.mode != ConsentMode::Require
        || consent_confirmed
    {
        return None;
    }

    let disclosure = config.consent.disclosure_script.trim();
    if disclosure.is_empty() {
        Some(Config::default().consent.disclosure_script)
    } else {
        Some(disclosure.to_string())
    }
}

fn show_user_notification_nonmodal(app_handle: &tauri::AppHandle, title: &str, body: &str) {
    #[cfg(target_os = "macos")]
    {
        let identifier = app_handle.config().identifier.as_str();
        let _ = notify_rust::set_application(identifier);

        let mut notification = notify_rust::Notification::new();
        notification.summary(title);
        notification.body(body);
        notification.auto_icon();

        if notification.show().is_ok() {
            return;
        }
    }

    if app_handle
        .notification()
        .builder()
        .title(title)
        .body(body)
        .show()
        .is_ok()
    {
        return;
    }

    tracing::warn!(title, "non-modal notification unavailable");
}

fn maybe_save_and_show_recording_consent(
    app_handle: &tauri::AppHandle,
    mode: CaptureMode,
    config: &Config,
) {
    minutes_core::notes::clear_consent();
    if mode != CaptureMode::Meeting {
        return;
    }

    let basis = desktop_recording_consent_basis(config);
    let disclosure = config.consent.disclosure_script.trim();
    let notice = if config.consent.mode == ConsentMode::Off {
        None
    } else {
        (!disclosure.is_empty()).then_some(disclosure)
    };

    if let Err(error) = minutes_core::notes::save_consent(Some(basis), notice) {
        minutes_core::notes::clear_consent();
        tracing::warn!(error = %error, "failed to save recording consent sidecar");
    }

    match config.consent.mode {
        ConsentMode::Off => {}
        ConsentMode::Remind => {
            let mut body =
                "Recording + transcribing locally - audio stays on your device.".to_string();
            if let Some(disclosure) = notice {
                body.push('\n');
                body.push_str(disclosure);
            }
            eprintln!("[minutes] {}", body.replace('\n', " "));
            show_user_notification_nonmodal(app_handle, "Recording disclosure", &body);
        }
        ConsentMode::Require => {
            if let Some(disclosure) = notice {
                eprintln!(
                    "[minutes] Recording + transcribing locally - audio stays on your device. {}",
                    disclosure
                );
            }
        }
    }
}

fn validate_recording_launch_state(state: &AppState) -> Result<(), String> {
    if recording_active(&state.recording) || state.starting.load(Ordering::Relaxed) {
        return Err("Already recording".into());
    }
    if state.live_transcript_active.load(Ordering::Relaxed) {
        return Err("Live transcript in progress — stop it first".into());
    }
    // Check both the in-process atomic and the cross-process PID file,
    // mirroring the live transcript path. `cmd_install_update` and the
    // palette dispatcher already treat the dictation PID as authoritative
    // for "another Minutes is dictating", so this gate stays consistent.
    if state.dictation_active.load(Ordering::Relaxed) || dictation_pid_active() {
        return Err("Dictation in progress — stop it first".into());
    }
    minutes_core::sensitive::ensure_inactive_for_recording().map_err(|error| error.to_string())?;
    Ok(())
}

fn reject_recording_launch(state: &AppState, error: String) -> String {
    set_recording_start_error(&state.latest_output, error.clone());
    error
}

fn reserve_recording_launch(state: &AppState) -> Result<(), String> {
    validate_recording_launch_state(state)?;
    state
        .starting
        .compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed)
        .map(|_| ())
        .map_err(|_| "Already recording".into())
}

fn prepare_cmd_recording_launch(state: &AppState, from_call_detect: bool) -> Result<(), String> {
    reserve_recording_launch(state)?;
    // Session-level flag that scopes the stop_when_call_ends auto-stop only
    // to recordings started via the call detection banner. Manual starts
    // never get auto-stopped, even when the config flag is on.
    state
        .recording_started_by_call_detect
        .store(from_call_detect, Ordering::Relaxed);
    // Starting a fresh recording always cancels any in-flight countdown so
    // the UI doesn't auto-stop a session the user has already moved past.
    reset_call_end_countdown(state);
    Ok(())
}

fn sync_processing_indicator(
    processing: &Arc<AtomicBool>,
    processing_stage: &Arc<Mutex<Option<String>>>,
) {
    let summary = minutes_core::jobs::processing_summary();
    processing.store(summary.is_some(), Ordering::Relaxed);
    set_processing_stage(
        processing_stage,
        summary.as_ref().and_then(|job| job.stage.as_deref()),
    );
}

fn output_notice_from_job(job: &minutes_core::jobs::ProcessingJob) -> Option<OutputNotice> {
    match job.state {
        minutes_core::jobs::JobState::NeedsReview => Some(OutputNotice {
            kind: "preserved-capture".into(),
            title: job
                .title
                .clone()
                .unwrap_or_else(|| "Recording needs review".into()),
            path: job.audio_path.clone(),
            detail: job.error.clone().unwrap_or_else(|| {
                "Transcript was marked as no speech. Raw capture preserved for retry.".into()
            }),
            job_id: Some(job.id.clone()),
        }),
        minutes_core::jobs::JobState::Complete => {
            job.output_path.as_ref().map(|path| OutputNotice {
                kind: "saved".into(),
                title: job
                    .title
                    .clone()
                    .unwrap_or_else(|| "Processed recording".into()),
                path: path.clone(),
                detail: "Saved meeting markdown".into(),
                job_id: None,
            })
        }
        minutes_core::jobs::JobState::Failed => {
            let path = job
                .output_path
                .clone()
                .unwrap_or_else(|| job.audio_path.clone());
            Some(OutputNotice {
                kind: "preserved-capture".into(),
                title: job
                    .title
                    .clone()
                    .unwrap_or_else(|| "Processing failed".into()),
                path,
                detail: job
                    .error
                    .clone()
                    .unwrap_or_else(|| "Processing failed, recoverable capture preserved.".into()),
                job_id: Some(job.id.clone()),
            })
        }
        _ => None,
    }
}

fn startup_retryable_output_notice_from_job(
    job: &minutes_core::jobs::ProcessingJob,
) -> Option<OutputNotice> {
    let mut notice = output_notice_from_job(job)?;
    notice.detail = match job.state {
        minutes_core::jobs::JobState::NeedsReview => {
            "Previous transcript needs review. Raw capture is preserved and can be retried.".into()
        }
        minutes_core::jobs::JobState::Failed => {
            "Previous processing failed. Raw capture is preserved and can be retried.".into()
        }
        _ => notice.detail,
    };
    Some(notice)
}

const STARTUP_RETRYABLE_NOTICE_WINDOW_DAYS: i64 = 3;

fn startup_retryable_notice_is_recent(job: &minutes_core::jobs::ProcessingJob) -> bool {
    let reference_time = job.finished_at.as_ref().unwrap_or(&job.created_at);
    *reference_time
        >= chrono::Local::now() - chrono::Duration::days(STARTUP_RETRYABLE_NOTICE_WINDOW_DAYS)
}

fn latest_retryable_output_notice() -> Option<OutputNotice> {
    // Include archive: retry-cap demotions in `list_jobs()` move Failed
    // jobs across the active/archive boundary (issue #229), so a
    // `list_jobs()`-only scan would silently miss them on startup.
    let mut jobs = minutes_core::jobs::display_jobs(None, true)
        .into_iter()
        .filter(|job| {
            matches!(
                job.state,
                minutes_core::jobs::JobState::Failed | minutes_core::jobs::JobState::NeedsReview
            ) && job.notice_dismissed_at.is_none()
                && startup_retryable_notice_is_recent(job)
                // Retry re-processes the raw capture, so a notice promising
                // "can be retried" is a lie once the wav has been cleaned up
                // (`remove_capture_artifacts`) — requeue_job would just fail
                // with "audio file missing".
                && Path::new(&job.audio_path).exists()
        })
        .collect::<Vec<_>>();

    jobs.sort_by(|a, b| {
        let a_time = a.finished_at.as_ref().unwrap_or(&a.created_at);
        let b_time = b.finished_at.as_ref().unwrap_or(&b.created_at);
        b_time.cmp(a_time)
    });

    jobs.into_iter()
        .next()
        .and_then(|job| startup_retryable_output_notice_from_job(&job))
}

fn job_id_from_notice(notice: &OutputNotice) -> Option<String> {
    if let Some(job_id) = notice.job_id.as_ref().filter(|id| !id.is_empty()) {
        return Some(job_id.clone());
    }

    let path = Path::new(&notice.path);
    let stem = path.file_stem().and_then(|stem| stem.to_str())?;
    if !stem.starts_with("job-") {
        return None;
    }

    let job = minutes_core::jobs::load_job(stem)?;
    let notice_path = path.to_string_lossy();
    let matches_job_path =
        job.audio_path == notice_path || job.output_path.as_deref() == Some(notice_path.as_ref());
    matches_job_path.then_some(job.id)
}

fn persist_output_notice_dismissal(notice: &OutputNotice) {
    let Some(job_id) = job_id_from_notice(notice) else {
        return;
    };

    if let Err(error) = minutes_core::jobs::dismiss_job_notice(&job_id) {
        eprintln!(
            "[minutes] failed to persist dismissed processing notice for {}: {}",
            job_id, error
        );
    }
}

pub fn seed_latest_retryable_output(latest_output: &Arc<Mutex<Option<OutputNotice>>>) {
    let should_seed = latest_output
        .lock()
        .ok()
        .is_some_and(|current| current.is_none());

    if should_seed {
        set_latest_output(latest_output, latest_retryable_output_notice());
    }
}

pub fn spawn_processing_worker(
    app_handle: tauri::AppHandle,
    processing: Arc<AtomicBool>,
    processing_stage: Arc<Mutex<Option<String>>>,
    latest_output: Arc<Mutex<Option<OutputNotice>>>,
    activation_progress: Arc<Mutex<ActivationProgress>>,
    completion_notifications_enabled: Arc<AtomicBool>,
) {
    std::thread::spawn(move || {
        if minutes_core::jobs::worker_active() {
            sync_processing_indicator(&processing, &processing_stage);
            return;
        }

        // Keep batch ASR/diarization work out of the GUI process. Long or
        // mostly-silent captures can stress native backends; the CLI already
        // uses this process boundary for queued jobs.
        let child_result = std::env::current_exe().and_then(|exe| {
            std::process::Command::new(exe)
                .arg("--process-queue-worker")
                .env(
                    "RUST_LOG",
                    std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
                )
                .stdin(std::process::Stdio::null())
                .stdout(std::process::Stdio::null())
                .stderr(std::process::Stdio::null())
                .spawn()
        });

        match child_result {
            Ok(mut child) => {
                let status = child.wait();
                if let Err(error) = status {
                    eprintln!("[minutes] processing worker wait failed: {}", error);
                }
            }
            Err(error) => {
                eprintln!("[minutes] failed to spawn processing worker: {}", error);
            }
        }

        sync_processing_indicator(&processing, &processing_stage);
        // The previous `display_jobs(Some(1), true).find(is_terminal)` had
        // two bugs: (a) the truncation hid the just-completed notification
        // whenever an active job existed, because the sort puts active <
        // terminal and `.find()` never reached the terminal record, and
        // (b) within the terminal bucket the sort was by `created_at` desc
        // rather than `finished_at`, so a long-running reprocess finishing
        // an old job would show the wrong notification. `latest_terminal_job`
        // scans only the archive dir (no active-dir disk work) and sorts by
        // `finished_at` with `created_at` as the fallback for older
        // records. Runs once per worker exit, not on the hot status poll.
        if let Some(job) = minutes_core::jobs::latest_terminal_job() {
            if let Some(notice) = output_notice_from_job(&job) {
                set_latest_output(&latest_output, Some(notice.clone()));
                if notice.kind == "saved" {
                    mark_activation_first_artifact_saved(
                        &activation_progress,
                        Path::new(&notice.path),
                    );
                }
                maybe_show_completion_notification(
                    &app_handle,
                    &completion_notifications_enabled,
                    &notice,
                );
            }
        }
    });
}

fn display_path(path: &str) -> String {
    if let Some(home) = dirs::home_dir() {
        let home_display = home.display().to_string();
        if let Some(stripped) = path.strip_prefix(&home_display) {
            return format!("~{}", stripped);
        }
    }
    path.to_string()
}

#[cfg(target_os = "macos")]
fn escape_applescript_literal(text: &str) -> String {
    text.replace('\\', "\\\\")
        .replace('"', "\\\"")
        .replace('\n', " ")
}

#[cfg(not(target_os = "macos"))]
fn escape_applescript_literal(text: &str) -> String {
    text.to_string()
}

pub fn open_target(app_handle: &tauri::AppHandle, target: &str) -> Result<(), String> {
    #[allow(deprecated)]
    app_handle
        .shell()
        .open(target.to_string(), None)
        .map_err(|e| e.to_string())
}

fn maybe_show_completion_notification(
    app_handle: &tauri::AppHandle,
    notifications_enabled: &Arc<AtomicBool>,
    notice: &OutputNotice,
) {
    if !notifications_enabled.load(Ordering::Relaxed) {
        return;
    }

    let should_notify = app_handle
        .get_webview_window("main")
        .map(|window| {
            let visible = window.is_visible().ok().unwrap_or(false);
            let focused = window.is_focused().ok().unwrap_or(false);
            !(visible && focused)
        })
        .unwrap_or(true);

    if !should_notify {
        return;
    }

    let body = format!("{} {}", notice.detail, display_path(&notice.path));
    show_user_notification(app_handle, &notice.title, &body);
}

pub fn show_user_notification(app_handle: &tauri::AppHandle, title: &str, body: &str) {
    #[cfg(target_os = "macos")]
    {
        let identifier = app_handle.config().identifier.as_str();
        let _ = notify_rust::set_application(identifier);

        let mut notification = notify_rust::Notification::new();
        notification.summary(title);
        notification.body(body);
        notification.auto_icon();

        if notification.show().is_ok() {
            return;
        }
    }

    let plugin_notification_result = app_handle
        .notification()
        .builder()
        .title(title)
        .body(body)
        .show();

    if plugin_notification_result.is_ok() {
        return;
    }

    #[cfg(target_os = "macos")]
    {
        let script = format!(
            "display notification \"{}\" with title \"Minutes\" subtitle \"{}\"",
            escape_applescript_literal(body),
            escape_applescript_literal(title)
        );

        if std::process::Command::new("osascript")
            .arg("-e")
            .arg(script)
            .spawn()
            .is_ok()
        {
            return;
        }
    }

    app_handle
        .dialog()
        .message(body.to_string())
        .title(title.to_string())
        .kind(MessageDialogKind::Info)
        .show(|_| {});
}

pub fn frontmost_application_name() -> Option<String> {
    #[cfg(target_os = "macos")]
    {
        let script = r#"tell application "System Events" to get name of first application process whose frontmost is true"#;
        let output = std::process::Command::new("osascript")
            .arg("-e")
            .arg(script)
            .output()
            .ok()?;
        if !output.status.success() {
            return None;
        }
        let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
        if name.is_empty() || name == "Minutes" {
            None
        } else {
            Some(name)
        }
    }

    #[cfg(not(target_os = "macos"))]
    {
        None
    }
}

fn latest_saved_artifact_path(
    latest_output: &Arc<Mutex<Option<OutputNotice>>>,
) -> Result<PathBuf, String> {
    if let Ok(current) = latest_output.lock() {
        if let Some(notice) = current.clone() {
            if notice.kind == "saved" && !notice.path.trim().is_empty() {
                let path = PathBuf::from(notice.path);
                if path.exists() {
                    return Ok(path);
                }
            }
        }
    }

    let config = Config::load();
    let filters = minutes_core::search::SearchFilters {
        content_type: None,
        since: None,
        attendee: None,
        intent_kind: None,
        owner: None,
        recorded_by: None,
    };
    let latest = minutes_core::search::search("", &config, &filters)
        .map_err(|e| e.to_string())?
        .into_iter()
        .next()
        .ok_or_else(|| "No saved meetings or memos yet.".to_string())?;
    Ok(latest.path)
}

fn extract_paste_text(content: &str, kind: &str) -> Result<String, String> {
    let (_, body) = minutes_core::markdown::split_frontmatter(content);
    let sections = parse_sections(body);
    let target_heading = match kind {
        "summary" => "Summary",
        "transcript" => "Transcript",
        other => {
            return Err(format!(
                "Unsupported paste payload: {}. Use 'summary' or 'transcript'.",
                other
            ));
        }
    };

    sections
        .into_iter()
        .find(|section| section.heading.eq_ignore_ascii_case(target_heading))
        .map(|section| section.content.trim().to_string())
        .filter(|text| !text.is_empty())
        .ok_or_else(|| format!("The latest artifact does not contain a {} section.", kind))
}

pub(crate) fn copy_to_clipboard(text: &str) -> Result<(), String> {
    #[cfg(target_os = "macos")]
    {
        use std::io::Write;

        let mut child = std::process::Command::new("pbcopy")
            .stdin(std::process::Stdio::piped())
            .spawn()
            .map_err(|e| format!("Could not start pbcopy: {}", e))?;

        if let Some(mut stdin) = child.stdin.take() {
            stdin
                .write_all(text.as_bytes())
                .map_err(|e| format!("Could not write to clipboard: {}", e))?;
        }

        let status = child
            .wait()
            .map_err(|e| format!("Could not finish clipboard write: {}", e))?;
        if status.success() {
            Ok(())
        } else {
            Err("pbcopy failed to update the clipboard.".into())
        }
    }

    #[cfg(not(target_os = "macos"))]
    {
        let _ = text;
        Err("Tray copy/paste automation is currently available on macOS only.".into())
    }
}

fn paste_into_application(app_name: &str) -> Result<(), String> {
    #[cfg(target_os = "macos")]
    {
        let script = format!(
            r#"tell application "{}" to activate
delay 0.15
tell application "System Events" to keystroke "v" using command down"#,
            escape_applescript_literal(app_name)
        );

        let output = std::process::Command::new("osascript")
            .arg("-e")
            .arg(script)
            .output()
            .map_err(|e| format!("Could not run paste automation: {}", e))?;

        if output.status.success() {
            Ok(())
        } else {
            let stderr = String::from_utf8_lossy(&output.stderr);
            Err(format!(
                "Paste automation failed{}. Minutes already copied the text to your clipboard.",
                if stderr.trim().is_empty() {
                    ".".to_string()
                } else {
                    format!(" ({})", stderr.trim())
                }
            ))
        }
    }

    #[cfg(not(target_os = "macos"))]
    {
        let _ = app_name;
        Err("Tray paste automation is currently available on macOS only.".into())
    }
}

pub fn paste_latest_artifact(
    latest_output: &Arc<Mutex<Option<OutputNotice>>>,
    kind: &str,
    target_app: Option<&str>,
) -> Result<String, String> {
    let path = latest_saved_artifact_path(latest_output)?;
    let content = std::fs::read_to_string(&path)
        .map_err(|e| format!("Could not read latest artifact {}: {}", path.display(), e))?;
    let payload = extract_paste_text(&content, kind)?;
    copy_to_clipboard(&payload)?;

    if let Some(app_name) = target_app.filter(|name| !name.trim().is_empty()) {
        paste_into_application(app_name)?;
        Ok(format!(
            "Copied the latest {} and pasted it into {}.",
            kind, app_name
        ))
    } else {
        Ok(format!(
            "Copied the latest {} to the clipboard. Switch to your app and paste.",
            kind
        ))
    }
}

fn parse_sections(body: &str) -> Vec<MeetingSection> {
    let mut sections = Vec::new();
    let mut current_heading: Option<String> = None;
    let mut current_lines: Vec<String> = Vec::new();

    for line in body.lines() {
        if let Some(heading) = line.strip_prefix("## ") {
            if let Some(existing_heading) = current_heading.take() {
                sections.push(MeetingSection {
                    heading: existing_heading,
                    content: current_lines.join("\n").trim().to_string(),
                });
            }
            current_heading = Some(heading.trim().to_string());
            current_lines.clear();
        } else if current_heading.is_some() {
            current_lines.push(line.to_string());
        }
    }

    if let Some(existing_heading) = current_heading.take() {
        sections.push(MeetingSection {
            heading: existing_heading,
            content: current_lines.join("\n").trim().to_string(),
        });
    }

    sections
}

fn find_section_content<'a>(sections: &'a [MeetingSection], heading: &str) -> Option<&'a str> {
    sections
        .iter()
        .find(|section| section.heading.eq_ignore_ascii_case(heading))
        .map(|section| section.content.as_str())
        .filter(|content| !content.trim().is_empty())
}

fn artifact_directory(config: &Config) -> Result<PathBuf, String> {
    let workspace = crate::context::create_workspace(config)?;
    let artifacts = workspace.join("artifacts");
    std::fs::create_dir_all(&artifacts).map_err(|e| {
        format!(
            "Failed to create artifact directory {}: {}",
            artifacts.display(),
            e
        )
    })?;
    Ok(artifacts)
}

fn is_editable_text_file_path(path: &Path, config: &Config) -> bool {
    let workspace = crate::context::workspace_dir();
    // Agent instruction files stay read-only in the viewer even though they
    // live under the writable workspace root: the app is for documents, and
    // silently editing the assistant's own instructions is a footgun.
    if path.starts_with(&workspace) {
        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
            if matches!(name, "CLAUDE.md" | "AGENTS.md" | "MEMORY.md") {
                return false;
            }
        }
    }
    let trusted_roots = [
        config.output_dir.clone(),
        workspace.clone(),
        workspace.join("artifacts"),
    ];
    trusted_roots.iter().any(|root| path.starts_with(root))
}

const MAX_ARTIFACT_SNAPSHOTS_PER_FILE: usize = 20;

fn snapshot_identity_for_path(path: &Path) -> (String, String) {
    let base = path
        .file_stem()
        .and_then(|name| name.to_str())
        .unwrap_or("artifact");
    let extension = path
        .extension()
        .and_then(|ext| ext.to_str())
        .unwrap_or("md")
        .to_ascii_lowercase();
    let mut hasher = DefaultHasher::new();
    path.to_string_lossy().hash(&mut hasher);
    let hash = format!("{:08x}", hasher.finish() as u32);
    (format!("{base}-{hash}"), extension)
}

fn prune_artifact_snapshots(
    snapshot_root: &Path,
    identity: &str,
    extension: &str,
) -> Result<(), String> {
    let matching = matching_snapshots(snapshot_root, identity, extension)?;
    if matching.len() <= MAX_ARTIFACT_SNAPSHOTS_PER_FILE {
        return Ok(());
    }

    let remove_count = matching.len() - MAX_ARTIFACT_SNAPSHOTS_PER_FILE;
    for path in matching.into_iter().take(remove_count) {
        std::fs::remove_file(&path)
            .map_err(|e| format!("Failed to prune old snapshot {}: {}", path.display(), e))?;
    }
    Ok(())
}

fn create_text_file_snapshot(path: &Path) -> Result<(), String> {
    if !path.exists() {
        return Ok(());
    }

    let original = std::fs::read_to_string(path)
        .map_err(|e| format!("Cannot snapshot {}: {}", path.display(), e))?;
    let snapshot_root = Config::minutes_dir().join("artifact-snapshots");
    std::fs::create_dir_all(&snapshot_root).map_err(|e| {
        format!(
            "Failed to create artifact snapshot directory {}: {}",
            snapshot_root.display(),
            e
        )
    })?;

    let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string();
    let (identity, extension) = snapshot_identity_for_path(path);
    let snapshot_path = snapshot_root.join(format!("{timestamp}-{identity}.{extension}"));
    std::fs::write(&snapshot_path, original).map_err(|e| {
        format!(
            "Failed to write artifact snapshot {}: {}",
            snapshot_path.display(),
            e
        )
    })?;
    prune_artifact_snapshots(&snapshot_root, &identity, &extension)
}

fn latest_snapshot_for_path(path: &Path) -> Result<Option<PathBuf>, String> {
    let snapshot_root = Config::minutes_dir().join("artifact-snapshots");
    if !snapshot_root.exists() {
        return Ok(None);
    }
    let (identity, extension) = snapshot_identity_for_path(path);
    let mut matching = matching_snapshots(&snapshot_root, &identity, &extension)?;
    Ok(matching.pop())
}

fn matching_snapshots(
    snapshot_root: &Path,
    identity: &str,
    extension: &str,
) -> Result<Vec<PathBuf>, String> {
    let suffix = format!("-{identity}.{extension}");
    let mut matching = std::fs::read_dir(snapshot_root)
        .map_err(|e| {
            format!(
                "Failed to read snapshot directory {}: {}",
                snapshot_root.display(),
                e
            )
        })?
        .filter_map(|entry| entry.ok())
        .map(|entry| entry.path())
        .filter(|candidate| {
            candidate
                .file_name()
                .and_then(|name| name.to_str())
                .is_some_and(|name| name.ends_with(&suffix))
        })
        .collect::<Vec<_>>();
    matching.sort();
    Ok(matching)
}

fn preview_text_for_review(text: &str, max_lines: usize, max_chars: usize) -> String {
    let mut joined = text.lines().take(max_lines).collect::<Vec<_>>().join("\n");
    if joined.chars().count() > max_chars {
        joined = joined.chars().take(max_chars).collect::<String>();
        joined.push_str("\n…");
    } else if text.lines().count() > max_lines {
        joined.push_str("\n…");
    }
    joined
}

fn review_preview_for_kind(kind: &str, text: &str, max_lines: usize, max_chars: usize) -> String {
    if kind == "json" {
        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(text) {
            if let Ok(pretty) = serde_json::to_string_pretty(&parsed) {
                return preview_text_for_review(&pretty, max_lines, max_chars);
            }
        }
    }
    preview_text_for_review(text, max_lines, max_chars)
}

fn write_text_file_atomic(path: &Path, content: &str) -> Result<(), String> {
    if content.len() > 1_048_576 {
        return Err("Refusing to save a text file larger than 1 MB.".into());
    }
    create_text_file_snapshot(path)?;
    let file_name = path
        .file_name()
        .and_then(|name| name.to_str())
        .ok_or_else(|| format!("Invalid file name: {}", path.display()))?;
    let temp_path = path.with_file_name(format!(".{}.tmp", file_name));
    std::fs::write(&temp_path, content)
        .map_err(|e| format!("Failed to write temp file {}: {}", temp_path.display(), e))?;
    std::fs::rename(&temp_path, path).map_err(|e| {
        format!(
            "Failed to atomically replace temp file {} with destination {}: {}",
            temp_path.display(),
            path.display(),
            e
        )
    })
}

fn meeting_section_bullets(content: Option<&str>, empty: &str) -> String {
    content
        .map(|text| {
            text.lines()
                .filter_map(|line| {
                    let trimmed = line.trim();
                    if trimmed.is_empty() {
                        None
                    } else {
                        Some(trimmed.to_string())
                    }
                })
                .collect::<Vec<_>>()
                .join("\n")
        })
        .filter(|text| !text.is_empty())
        .unwrap_or_else(|| empty.to_string())
}

fn build_artifact_template(
    frontmatter: &minutes_core::markdown::Frontmatter,
    sections: &[MeetingSection],
    meeting_path: &Path,
    kind: &str,
) -> Result<(String, String), String> {
    let meeting_title = frontmatter.title.trim();
    let slug = artifact_slug(meeting_title);
    let title = match kind {
        "follow-up-email" => format!("Follow-up Email - {}", meeting_title),
        "meeting-brief" => format!("Meeting Brief - {}", meeting_title),
        "debrief-memo" => format!("Debrief Memo - {}", meeting_title),
        "decision-memo" => format!("Decision Memo - {}", meeting_title),
        other => {
            return Err(format!(
            "Unknown artifact template '{}'. Use follow-up-email, meeting-brief, debrief-memo, or decision-memo.",
            other
        ))
        }
    };

    let summary = meeting_section_bullets(
        find_section_content(sections, "Summary"),
        "- Add a concise recap of what happened.\n- Pull the strongest 2-3 moments from the meeting.",
    );
    let decisions = if frontmatter.decisions.is_empty() {
        "- Add any decisions that should carry forward.".to_string()
    } else {
        frontmatter
            .decisions
            .iter()
            .map(|decision| format!("- {}", decision.text))
            .collect::<Vec<_>>()
            .join("\n")
    };
    let action_items = if frontmatter.action_items.is_empty() {
        "- Add the next actions and owners.".to_string()
    } else {
        frontmatter
            .action_items
            .iter()
            .map(|item| {
                let due = item
                    .due
                    .as_ref()
                    .map(|value| format!(" (due {})", value))
                    .unwrap_or_default();
                format!("- {}: {}{}", item.assignee, item.task, due)
            })
            .collect::<Vec<_>>()
            .join("\n")
    };
    let open_questions = meeting_section_bullets(
        find_section_content(sections, "Open Questions"),
        "- Add unresolved questions worth carrying into the next conversation.",
    );
    let attendees = if frontmatter.attendees.is_empty() {
        "_Add attendees if needed._".to_string()
    } else {
        frontmatter.attendees.join(", ")
    };

    let frontmatter_block = format!(
        "---\ntitle: {}\nartifact_type: {}\nsource_meeting: {}\nsource_title: {}\nsource_date: {}\nlinked_slug: {}\n---\n\n",
        title,
        kind,
        meeting_path.display(),
        meeting_title,
        frontmatter.date.to_rfc3339(),
        slug
    );

    let body = match kind {
        "follow-up-email" => format!(
            "# Subject\n\nFollow-up: {meeting_title}\n\n# Email Draft\n\nHi team,\n\nThanks again for the conversation today. Here is the clean follow-up from the meeting.\n\n## Key Points\n\n{summary}\n\n## Decisions\n\n{decisions}\n\n## Action Items\n\n{action_items}\n\n## Open Questions\n\n{open_questions}\n\nBest,\n\n[Your name]\n"
        ),
        "meeting-brief" => format!(
            "# Objective\n\nState what this next meeting needs to accomplish.\n\n## Context\n\n- Source meeting: [{meeting_title}]({})\n- Attendees: {attendees}\n\n## What Happened Last Time\n\n{summary}\n\n## Decisions Already Made\n\n{decisions}\n\n## Open Questions\n\n{open_questions}\n\n## Suggested Agenda\n\n- Start with the highest-stakes question\n- Confirm any blocked action items\n- End with explicit owners and dates\n\n## Notes\n\n- Add prep notes here.\n",
            meeting_path.display()
        ),
        "debrief-memo" => format!(
            "# Summary\n\n{summary}\n\n## Decisions\n\n{decisions}\n\n## Action Items\n\n{action_items}\n\n## Open Questions\n\n{open_questions}\n\n## Next Move\n\n- Write the next action the team should take from this conversation.\n"
        ),
        "decision-memo" => format!(
            "# Decision\n\nWrite the one decision this memo is locking in.\n\n## Why This Decision\n\n{summary}\n\n## Decision Details\n\n{decisions}\n\n## Implications\n\n- Add the operational, product, or relationship implications of this decision.\n\n## Action Items\n\n{action_items}\n\n## Open Questions / Risks\n\n{open_questions}\n"
        ),
        _ => unreachable!(),
    };

    Ok((title, format!("{frontmatter_block}{body}")))
}

fn model_status(config: &Config) -> ReadinessItem {
    let batch = batch_transcription_readiness_view(config);
    let live = standalone_live_readiness_view(config);
    ReadinessItem {
        label: "Speech backends".into(),
        state: if batch.ready && live.ready {
            "ready".into()
        } else {
            "attention".into()
        },
        detail: format!("Batch: {} Live: {}", batch.detail, live.detail),
        optional: false,
    }
}

fn audio_input_status() -> ReadinessItem {
    let devices = minutes_core::capture::list_input_devices();
    let has_devices = !devices.is_empty();

    ReadinessItem {
        label: "Audio input device".into(),
        state: if has_devices { "ready" } else { "attention" }.into(),
        detail: if has_devices {
            format!(
                "{} audio input device{} detected. Microphone permission is reported separately.",
                devices.len(),
                if devices.len() == 1 { "" } else { "s" }
            )
        } else {
            "No audio input devices detected. Check hardware and system audio settings.".into()
        },
        optional: false,
    }
}

fn call_capture_status() -> ReadinessItem {
    match call_capture::availability() {
        call_capture::CallCaptureAvailability::Available { backend } => ReadinessItem {
            label: "Call capture".into(),
            state: "ready".into(),
            detail: format!(
                "Native call capture backend is available via {}. Screen Recording permission is reported separately.",
                backend
            ),
            optional: true,
        },
        call_capture::CallCaptureAvailability::PermissionRequired { detail } => ReadinessItem {
            label: "Call capture".into(),
            state: "attention".into(),
            detail,
            optional: true,
        },
        call_capture::CallCaptureAvailability::Unavailable { detail } => ReadinessItem {
            label: "Call capture".into(),
            state: "attention".into(),
            detail,
            optional: true,
        },
        call_capture::CallCaptureAvailability::Unsupported { detail } => ReadinessItem {
            label: "Call capture".into(),
            state: "unsupported".into(),
            detail,
            optional: true,
        },
    }
}

fn calendar_status(config: &Config) -> ReadinessItem {
    if !config.calendar.enabled {
        return ReadinessItem {
            label: "Calendar suggestions".into(),
            state: "ready".into(),
            detail: "Calendar suggestions are disabled. No Calendar access check will run from Settings."
                .into(),
            optional: true,
        };
    }

    #[cfg(not(target_os = "macos"))]
    {
        return ReadinessItem {
            label: "Calendar suggestions".into(),
            state: "unsupported".into(),
            detail: "Calendar suggestions are currently available on macOS only.".into(),
            optional: true,
        };
    }

    #[cfg(target_os = "macos")]
    {
        // Non-prompting probe (#300): readiness must distinguish "no
        // meetings" from "access insufficient" or reminders die silently.
        use minutes_core::calendar::CalendarAccess;
        let access = minutes_core::calendar::calendar_access_status();
        let (state, detail): (&str, String) = match access {
            CalendarAccess::FullAccess => (
                "ready",
                "Calendar suggestions are enabled with Full Calendar access. Meeting reminders are active.".into(),
            ),
            CalendarAccess::WriteOnly => (
                "attention",
                "Calendar access is Add-Only, so Minutes cannot read upcoming meetings and reminders are OFF. Fix: System Settings > Privacy & Security > Calendars > Minutes > Full Calendar access.".into(),
            ),
            CalendarAccess::Denied | CalendarAccess::Restricted => (
                "attention",
                "Calendar access is denied, so meeting reminders are OFF. Fix: System Settings > Privacy & Security > Calendars > Minutes > Full Calendar access.".into(),
            ),
            CalendarAccess::NotDetermined => (
                "attention",
                "Calendar access has not been requested yet. The first calendar feature use will prompt; grant Full Calendar access to enable meeting reminders.".into(),
            ),
            CalendarAccess::Unknown => (
                "attention",
                "Calendar access state could not be determined (helper missing?). Meeting reminders may be off.".into(),
            ),
        };
        ReadinessItem {
            label: "Calendar suggestions".into(),
            state: state.into(),
            detail,
            optional: true,
        }
    }
}

fn watcher_status(config: &Config) -> ReadinessItem {
    let existing = config
        .watch
        .paths
        .iter()
        .filter(|path| path.exists())
        .count();
    let total = config.watch.paths.len();
    let state = if total > 0 && existing == total {
        "ready"
    } else {
        "attention"
    };

    let detail = if total == 0 {
        "No watch folders configured. Voice-memo ingestion is available but not set up.".into()
    } else if existing == total {
        format!(
            "{} watch folder{} ready for inbox processing.",
            total,
            if total == 1 { "" } else { "s" }
        )
    } else {
        format!(
            "{} of {} watch folders currently exist. Missing folders will prevent automatic inbox processing.",
            existing, total
        )
    };

    ReadinessItem {
        label: "Watcher folders".into(),
        state: state.into(),
        detail,
        optional: true,
    }
}

fn output_dir_status(config: &Config) -> ReadinessItem {
    let exists = config.output_dir.exists();
    ReadinessItem {
        label: "Meeting output folder".into(),
        state: if exists { "ready" } else { "attention" }.into(),
        detail: if exists {
            format!(
                "Meeting markdown is stored in {}.",
                config.output_dir.display()
            )
        } else {
            format!(
                "Output folder {} does not exist yet. Minutes will create it on demand.",
                config.output_dir.display()
            )
        },
        optional: false,
    }
}

fn vault_status(config: &Config) -> ReadinessItem {
    use minutes_core::vault;
    match vault::check_health(config) {
        vault::VaultStatus::NotConfigured => ReadinessItem {
            label: "Vault sync (Obsidian / Logseq)".into(),
            state: "attention".into(),
            detail: "Not configured. Use Settings > Set Up Vault to connect your vault.".into(),
            optional: true,
        },
        vault::VaultStatus::Healthy { strategy, path } => ReadinessItem {
            label: "Vault sync (Obsidian / Logseq)".into(),
            state: "ready".into(),
            detail: format!("Strategy: {}. Path: {}.", strategy, path.display()),
            optional: true,
        },
        vault::VaultStatus::BrokenSymlink { link_path, target } => ReadinessItem {
            label: "Vault sync (Obsidian / Logseq)".into(),
            state: "attention".into(),
            detail: format!(
                "Broken symlink at {} → {}. Re-run vault setup.",
                link_path.display(),
                target.display()
            ),
            optional: true,
        },
        vault::VaultStatus::PermissionDenied { path } => ReadinessItem {
            label: "Vault sync (Obsidian / Logseq)".into(),
            state: "attention".into(),
            detail: format!(
                "Permission denied: {}. Try Set Up Vault from the app.",
                path.display()
            ),
            optional: true,
        },
        vault::VaultStatus::MissingVaultDir { path } => ReadinessItem {
            label: "Vault sync (Obsidian / Logseq)".into(),
            state: "attention".into(),
            detail: format!("Vault directory missing: {}.", path.display()),
            optional: true,
        },
    }
}

// ── Vault Tauri commands ─────────────────────────────────────

#[tauri::command]
pub fn cmd_vault_status() -> serde_json::Value {
    let config = Config::load();
    let health = minutes_core::vault::check_health(&config);
    let (status, strategy, path, detail) = match health {
        minutes_core::vault::VaultStatus::NotConfigured => (
            "not_configured",
            "".into(),
            "".into(),
            "Not configured".into(),
        ),
        minutes_core::vault::VaultStatus::Healthy { strategy, path } => {
            let p = path.display().to_string();
            (
                "healthy",
                strategy,
                p.clone(),
                format!("Vault active at {}", p),
            )
        }
        minutes_core::vault::VaultStatus::BrokenSymlink { link_path, target } => (
            "broken",
            "symlink".into(),
            link_path.display().to_string(),
            format!("Broken symlink → {}", target.display()),
        ),
        minutes_core::vault::VaultStatus::PermissionDenied { path } => (
            "permission_denied",
            "".into(),
            path.display().to_string(),
            "Permission denied".into(),
        ),
        minutes_core::vault::VaultStatus::MissingVaultDir { path } => (
            "missing",
            "".into(),
            path.display().to_string(),
            "Vault directory missing".into(),
        ),
    };
    serde_json::json!({
        "status": status,
        "strategy": strategy,
        "path": path,
        "detail": detail,
        "enabled": config.vault.enabled,
    })
}

#[tauri::command]
pub fn cmd_vault_setup(path: String) -> Result<serde_json::Value, String> {
    let vault_path = std::path::PathBuf::from(&path);
    if !vault_path.exists() {
        return Err(format!("Path does not exist: {}", path));
    }

    let mut config = Config::load();
    let strategy = minutes_core::vault::recommend_strategy(&vault_path);

    // For symlink strategy, try to create the symlink
    if strategy == minutes_core::vault::VaultStrategy::Symlink {
        let link_path = vault_path.join(&config.vault.meetings_subdir);
        if let Err(e) = minutes_core::vault::create_symlink(&link_path, &config.output_dir) {
            // Fall back to copy if symlink fails
            eprintln!("[vault] symlink failed ({}), falling back to copy", e);
            config.vault.strategy = "copy".into();
        } else {
            config.vault.strategy = "symlink".into();
        }
    } else {
        config.vault.strategy = strategy.to_string();
    }

    config.vault.enabled = true;
    config.vault.path = vault_path;

    config
        .save()
        .map_err(|e| format!("Failed to save config: {}", e))?;

    let health = minutes_core::vault::check_health(&config);
    let status = match health {
        minutes_core::vault::VaultStatus::Healthy { strategy, path } => {
            format!("Vault configured ({}): {}", strategy, path.display())
        }
        _ => "Vault configured but health check shows issues. Check Readiness Center.".into(),
    };

    Ok(serde_json::json!({
        "status": "ok",
        "strategy": config.vault.strategy,
        "detail": status,
    }))
}

#[tauri::command]
pub fn cmd_vault_unlink() -> Result<String, String> {
    let mut config = Config::load();
    if !config.vault.enabled {
        return Ok("Vault is not configured.".into());
    }
    let old = config.vault.path.display().to_string();
    config.vault.enabled = false;
    config.vault.path = std::path::PathBuf::new();
    config.vault.strategy = "auto".into();
    config
        .save()
        .map_err(|e| format!("Failed to save config: {}", e))?;
    Ok(format!("Vault unlinked (was: {})", old))
}

fn is_hidden_or_system_file(path: &std::path::Path) -> bool {
    path.file_name()
        .and_then(|name| name.to_str())
        .map(|name| name.starts_with('.'))
        .unwrap_or(false)
}

fn recovery_title(path: &std::path::Path, fallback: &str) -> String {
    path.file_stem()
        .and_then(|stem| stem.to_str())
        .map(|stem| stem.replace('-', " "))
        .map(|stem| stem.trim().to_string())
        .filter(|stem| !stem.is_empty())
        .unwrap_or_else(|| fallback.to_string())
}

/// A dual-source capture writes `<name>.voice.wav` / `<name>.system.wav`
/// sidecars next to the primary `<name>.wav`. Those stems are processed as part
/// of the primary, never on their own, so they must not appear as independent
/// recovery items when their primary is present: otherwise "Retry all" would
/// enqueue them as standalone jobs that race (and break) the primary's job. An
/// orphaned stem with no primary still surfaces so the user can see it.
fn is_dual_source_stem_with_primary(path: &Path) -> bool {
    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
        return false;
    };
    let Some(dir) = path.parent() else {
        return false;
    };
    [".voice.wav", ".system.wav"].iter().any(|suffix| {
        name.strip_suffix(suffix)
            .map(|base| dir.join(format!("{}.wav", base)).exists())
            .unwrap_or(false)
    })
}

fn scan_recovery_items(config: &Config) -> Vec<RecoveryItem> {
    let mut found: Vec<(SystemTime, RecoveryItem)> = Vec::new();

    let current_wav = minutes_core::pid::current_wav_path();
    if current_wav.exists() && !minutes_core::pid::status().recording {
        if let Ok(metadata) = current_wav.metadata() {
            let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
            found.push((
                modified,
                RecoveryItem {
                    kind: "stale-recording".into(),
                    title: "Unprocessed live recording".into(),
                    path: current_wav.display().to_string(),
                    detail: "Minutes found an unfinished live capture that never made it through the pipeline.".into(),
                    retry_type: "meeting".into(),
                    modified_at: system_time_to_rfc3339(modified),
                },
            ));
        }
    }

    let failed_captures = config.output_dir.join("failed-captures");
    if let Ok(entries) = std::fs::read_dir(&failed_captures) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_file()
                && !is_hidden_or_system_file(&path)
                && !is_dual_source_stem_with_primary(&path)
            {
                let modified = entry
                    .metadata()
                    .ok()
                    .and_then(|m| m.modified().ok())
                    .unwrap_or(SystemTime::UNIX_EPOCH);
                found.push((
                    modified,
                    RecoveryItem {
                        kind: "preserved-capture".into(),
                        title: recovery_title(&path, "Preserved capture"),
                        path: path.display().to_string(),
                        detail:
                            "A live recording was preserved because capture or processing failed."
                                .into(),
                        retry_type: "meeting".into(),
                        modified_at: system_time_to_rfc3339(modified),
                    },
                ));
            }
        }
    }

    for watch_path in &config.watch.paths {
        let failed_dir = watch_path.join("failed");
        if let Ok(entries) = std::fs::read_dir(&failed_dir) {
            for entry in entries.flatten() {
                let path = entry.path();
                if path.is_file()
                    && !is_hidden_or_system_file(&path)
                    && !is_dual_source_stem_with_primary(&path)
                {
                    let modified = entry
                        .metadata()
                        .ok()
                        .and_then(|m| m.modified().ok())
                        .unwrap_or(SystemTime::UNIX_EPOCH);
                    found.push((
                        modified,
                        RecoveryItem {
                            kind: "watch-failed".into(),
                            title: recovery_title(&path, "Failed watched file"),
                            path: path.display().to_string(),
                            detail: "A watched audio file failed to process and is waiting for manual retry.".into(),
                            retry_type: config.watch.r#type.clone(),
                            modified_at: system_time_to_rfc3339(modified),
                        },
                    ));
                }
            }
        }
    }

    // Hide items that already have an active (queued or processing) job, so a
    // retried item disappears from the list without us moving its file out of
    // the recovery folder. A failed job is terminal (not active), so a failed
    // retry correctly re-surfaces the file here; a successful one is moved out
    // by the worker (`preserve_audio_alongside_output`) and is gone regardless.
    let active_paths: std::collections::HashSet<PathBuf> = minutes_core::jobs::active_jobs()
        .into_iter()
        .map(|job| PathBuf::from(job.audio_path))
        .collect();

    found.sort_by_key(|(modified, _)| Reverse(*modified));
    found
        .into_iter()
        .map(|(_, item)| item)
        .filter(|item| !active_paths.contains(&PathBuf::from(&item.path)))
        .collect()
}

/// Handles that `start_recording` clears at the end of a session. Keeps the
/// auto-stop state tied to a single recording: if the user started this
/// recording via the call detection banner, these flags live until the
/// recording ends; after that, a subsequent manual `minutes record` must not
/// be treated as call-detection-started.
#[derive(Clone)]
pub struct CallDetectSessionHandles {
    pub started_by_call_detect: Arc<AtomicBool>,
    pub countdown_cancel: Arc<AtomicBool>,
}

/// RAII guard that clears the call-detect session flags when dropped.
/// Used to keep every exit path in `start_recording` / `start_native_call_recording`
/// (including early returns on capture failure) from leaving stale state.
pub struct CallDetectSessionGuard {
    handles: CallDetectSessionHandles,
}

impl CallDetectSessionGuard {
    pub fn new(handles: CallDetectSessionHandles) -> Self {
        Self { handles }
    }
}

impl Drop for CallDetectSessionGuard {
    fn drop(&mut self) {
        self.handles
            .started_by_call_detect
            .store(false, Ordering::Relaxed);
        // Do not force `countdown_active=false` here. If the recording thread
        // exits while a call-end countdown is armed, the countdown thread must
        // observe cancellation and own the state transition; otherwise the
        // detector can see `call_ended` followed by a premature `cleared`.
        self.handles.countdown_cancel.store(true, Ordering::Relaxed);
    }
}

/// Start recording in a background thread.
#[allow(clippy::too_many_arguments)]
pub fn start_recording(
    app_handle: tauri::AppHandle,
    recording: Arc<AtomicBool>,
    starting: Arc<AtomicBool>,
    stop_flag: Arc<AtomicBool>,
    processing: Arc<AtomicBool>,
    processing_stage: Arc<Mutex<Option<String>>>,
    latest_output: Arc<Mutex<Option<OutputNotice>>>,
    activation_progress: Arc<Mutex<ActivationProgress>>,
    call_capture_health: Arc<Mutex<Option<crate::call_capture::CallSourceHealth>>>,
    completion_notifications_enabled: Arc<AtomicBool>,
    hotkey_runtime: Option<Arc<Mutex<HotkeyRuntime>>>,
    discard_short_hotkey_capture: Option<Arc<AtomicBool>>,
    call_detect_session: CallDetectSessionHandles,
    mode: CaptureMode,
    requested_intent: Option<RecordingIntent>,
    allow_degraded: bool,
    requested_title: Option<String>,
    language_override: Option<String>,
) {
    // Drop on any exit path (early returns, panic, normal exit) clears the
    // session flags so a subsequent manual recording isn't auto-stopped.
    let _session_guard = CallDetectSessionGuard::new(call_detect_session);
    let mut config = Config::load();
    if let Some(language) = language_override {
        config.transcription.language = Some(language);
    }
    // Re-validate the pinned input device at recording start so a
    // mid-session disconnect (Bluetooth headset turning off, USB device
    // unplugged) transparently falls back to the system default. Issue
    // #189: without this, preflight surfaces "device not found" and
    // some users press Cmd+Q out of frustration, which can trip a
    // separate destructor-during-exit() abort. In-memory only;
    // startup-side persistence stays in main.rs so users keep their
    // pin for when the device reconnects on a future launch.
    minutes_core::capture::auto_heal_missing_recording_device(&mut config);
    let desktop_call_loopback = if requested_intent == Some(RecordingIntent::Call) {
        minutes_core::capture::detect_loopback_device()
    } else {
        None
    };
    arm_desktop_call_capture_route(&mut config, requested_intent, desktop_call_loopback);
    let preflight = match minutes_core::capture::preflight_recording_with_native_call_capture(
        mode,
        requested_intent,
        allow_degraded,
        matches!(
            call_capture::availability(),
            call_capture::CallCaptureAvailability::Available { .. }
        ),
        &config,
    ) {
        Ok(preflight) => preflight,
        Err(error) => {
            eprintln!("Recording preflight failed: {}", error);
            set_recording_start_error(&latest_output, error.clone());
            show_user_notification(&app_handle, "Recording blocked", &error);
            starting.store(false, Ordering::Relaxed);
            recording.store(false, Ordering::Relaxed);
            reset_hotkey_capture_state(
                hotkey_runtime.as_ref(),
                discard_short_hotkey_capture.as_ref(),
            );
            return;
        }
    };
    let native_call_capture_available = preflight.intent == RecordingIntent::Call
        && matches!(
            call_capture::availability(),
            call_capture::CallCaptureAvailability::Available { .. }
        );
    if let Some(reason) = &preflight.blocking_reason {
        if !should_bypass_preflight_block_for_native_call_capture(
            &preflight,
            native_call_capture_available,
        ) {
            eprintln!("Recording preflight blocked: {}", reason);
            set_recording_start_error(&latest_output, reason.clone());
            show_user_notification(&app_handle, "Recording blocked", reason);
            starting.store(false, Ordering::Relaxed);
            recording.store(false, Ordering::Relaxed);
            reset_hotkey_capture_state(
                hotkey_runtime.as_ref(),
                discard_short_hotkey_capture.as_ref(),
            );
            return;
        }
    }
    for warning in &preflight.warnings {
        eprintln!("[minutes] {}", warning);
    }

    #[cfg(target_os = "macos")]
    if preflight.intent == RecordingIntent::Call && native_call_capture_available {
        match start_native_call_recording(
            &app_handle,
            &recording,
            &starting,
            &stop_flag,
            &processing,
            &processing_stage,
            &latest_output,
            &activation_progress,
            &call_capture_health,
            &completion_notifications_enabled,
            hotkey_runtime.as_ref(),
            discard_short_hotkey_capture.as_ref(),
            mode,
            &config,
            requested_title.clone(),
        ) {
            Ok(()) => {
                return;
            }
            Err(error) => {
                eprintln!("Native call recording unavailable, falling back: {}", error);
                minutes_core::logging::log_error(
                    "desktop_native_call_start",
                    "",
                    &format!("native call recording unavailable, falling back: {error}"),
                );
                if let Some(reason) = &preflight.blocking_reason {
                    set_recording_start_error(
                        &latest_output,
                        format!("{reason}\n\nNative call capture failed: {error}"),
                    );
                    show_user_notification(
                        &app_handle,
                        "Recording blocked",
                        &format!("{}\n\nNative call capture failed: {}", reason, error),
                    );
                    starting.store(false, Ordering::Relaxed);
                    recording.store(false, Ordering::Relaxed);
                    reset_hotkey_capture_state(
                        hotkey_runtime.as_ref(),
                        discard_short_hotkey_capture.as_ref(),
                    );
                    return;
                }
            }
        }
    }

    let wav_path = minutes_core::pid::current_wav_path();
    let recording_started_at = chrono::Local::now();

    if let Err(e) = minutes_core::pid::create() {
        eprintln!("Failed to create PID: {}", e);
        set_recording_start_error(&latest_output, format!("Could not start recording: {}", e));
        show_user_notification(
            &app_handle,
            "Recording",
            &format!("Could not start recording: {}", e),
        );
        starting.store(false, Ordering::Relaxed);
        recording.store(false, Ordering::Relaxed);
        reset_hotkey_capture_state(
            hotkey_runtime.as_ref(),
            discard_short_hotkey_capture.as_ref(),
        );
        return;
    }
    // Re-check sensitive exclusivity with the PID held (review F3).
    if let Err(error) = minutes_core::sensitive::ensure_inactive_for_recording() {
        minutes_core::pid::remove().ok();
        let message = error.to_string();
        set_recording_start_error(&latest_output, message.clone());
        show_user_notification(&app_handle, "Recording", &message);
        starting.store(false, Ordering::Relaxed);
        return;
    }
    starting.store(false, Ordering::Relaxed);
    recording.store(true, Ordering::Relaxed);
    stop_flag.store(false, Ordering::Relaxed);
    sync_processing_indicator(&processing, &processing_stage);
    set_latest_output(&latest_output, None);
    let context_session_id = minutes_core::desktop_context::maybe_start_capture_session(
        &config.desktop_context,
        mode,
        requested_title.clone(),
        recording_started_at,
    );
    minutes_core::pid::write_recording_metadata_with_context(mode, context_session_id.as_deref())
        .ok();
    crate::sync_tray_state(&app_handle);

    minutes_core::notes::save_recording_start().ok();
    maybe_save_and_show_recording_consent(&app_handle, mode, &config);
    eprintln!("{} started...", mode.noun());

    // Inject live transcript context into the assistant workspace so the Recall
    // panel (and any connected agent) can read the live JSONL during recording.
    if let Ok(workspace) = crate::context::create_workspace(&config) {
        update_assistant_live_context(&workspace, true);
    }

    let _desktop_context_collector = context_session_id.as_ref().and_then(|session_id| {
        match minutes_core::desktop_context::DesktopContextCollector::start(
            session_id.clone(),
            minutes_core::desktop_context::DesktopContextSessionKind::Recording,
            config.desktop_context.clone(),
        ) {
            Ok(collector) => Some(collector),
            Err(error) => {
                tracing::warn!(error = %error, mode = ?mode, "desktop context collector unavailable for recording session");
                None
            }
        }
    });

    let mut clear_processing_on_exit = true;
    match minutes_core::capture::record_to_wav_with_lifecycle(
        &wav_path,
        stop_flag,
        &config,
        Some(minutes_core::capture::RecordingStartedContext {
            session_id: context_session_id.clone(),
            source: "capture".into(),
            capabilities: vec![
                "audio.capture".into(),
                "live.utterance.final".into(),
                format!("mode.{}", mode.noun().replace(' ', "-")),
            ],
        }),
    ) {
        Ok(()) => {
            recording.store(false, Ordering::Relaxed);
            let should_discard = discard_short_hotkey_capture
                .as_ref()
                .map(|flag| flag.swap(false, Ordering::Relaxed))
                .unwrap_or(false);
            if should_discard {
                if wav_path.exists() {
                    std::fs::remove_file(&wav_path).ok();
                }
                if let Some(session_id) = context_session_id.as_deref() {
                    minutes_core::context_store::mark_capture_session_discarded(
                        session_id,
                        Some(chrono::Local::now()),
                    )
                    .ok();
                }
                eprintln!("Discarded short {} capture.", mode.noun());
            } else {
                let recording_finished_at = chrono::Local::now();
                let user_notes = minutes_core::notes::read_notes();
                let pre_context = minutes_core::notes::read_context();
                let (consent, consent_notice) = if mode == CaptureMode::Meeting {
                    minutes_core::notes::load_consent()
                } else {
                    (None, None)
                };
                // Don't block the stop path with a calendar query (can take 10s if Calendar.app hangs).
                // The pipeline already falls back to events_overlapping_now() during background processing.
                let calendar_event = None;

                match minutes_core::jobs::queue_live_capture_with_recording_health(
                    mode,
                    requested_title.clone(),
                    &wav_path,
                    user_notes,
                    pre_context,
                    Some(recording_started_at),
                    Some(recording_finished_at),
                    context_session_id.clone(),
                    calendar_event,
                    None,
                    consent,
                    consent_notice,
                    None,
                ) {
                    Ok(job) => {
                        processing.store(true, Ordering::Relaxed);
                        set_processing_stage(&processing_stage, job.stage.as_deref());
                        minutes_core::pid::set_processing_status(
                            job.stage.as_deref(),
                            Some(mode),
                            job.title.as_deref(),
                            Some(&job.id),
                            minutes_core::jobs::active_job_count(),
                        )
                        .ok();
                        minutes_core::pid::remove().ok();
                        minutes_core::pid::clear_recording_metadata().ok();
                        minutes_core::notes::cleanup();
                        clear_processing_on_exit = false;
                        spawn_processing_worker(
                            app_handle.clone(),
                            processing.clone(),
                            processing_stage.clone(),
                            latest_output.clone(),
                            activation_progress.clone(),
                            completion_notifications_enabled.clone(),
                        );
                    }
                    Err(e) => {
                        let preserved = preserve_failed_capture(&wav_path, &config);
                        if let Some(session_id) = context_session_id.as_deref() {
                            minutes_core::context_store::mark_capture_session_failed(
                                session_id,
                                Some(recording_finished_at),
                                &format!("failed to queue capture for processing: {}", e),
                                preserved.as_deref(),
                            )
                            .ok();
                        }
                        if let Some(saved) = preserved {
                            let notice = OutputNotice {
                                kind: "preserved-capture".into(),
                                title: "Raw capture preserved".into(),
                                path: saved.display().to_string(),
                                detail: format!(
                                    "Failed to queue background processing. Raw {} capture preserved.",
                                    mode.noun()
                                ),
                                job_id: None,
                            };
                            set_latest_output(&latest_output, Some(notice.clone()));
                            maybe_show_completion_notification(
                                &app_handle,
                                &completion_notifications_enabled,
                                &notice,
                            );
                            eprintln!(
                                "Queue error: {}. Raw audio preserved at {}",
                                e,
                                saved.display()
                            );
                        } else {
                            eprintln!("Queue error: {}", e);
                            set_recording_error_notice(
                                &latest_output,
                                "Processing not started",
                                format!("Recording finished, but queueing processing failed: {e}"),
                            );
                        }
                    }
                }
            }
        }
        Err(e) => {
            recording.store(false, Ordering::Relaxed);
            let preserved = preserve_failed_capture(&wav_path, &config);
            if let Some(session_id) = context_session_id.as_deref() {
                minutes_core::context_store::mark_capture_session_failed(
                    session_id,
                    Some(chrono::Local::now()),
                    &e.to_string(),
                    preserved.as_deref(),
                )
                .ok();
            }
            if let Some(saved) = preserved {
                let detail = match mode {
                    CaptureMode::Meeting => {
                        "Recording failed before processing, but the captured meeting audio was preserved."
                    }
                    CaptureMode::QuickThought => {
                        "Recording failed before processing, but the quick thought audio was preserved."
                    }
                    CaptureMode::Dictation => {
                        "Dictation failed, but the audio was preserved."
                    }
                    CaptureMode::LiveTranscript => {
                        "Live transcript failed, but the audio was preserved."
                    }
                };
                let notice = OutputNotice {
                    kind: "preserved-capture".into(),
                    title: "Partial capture preserved".into(),
                    path: saved.display().to_string(),
                    detail: detail.into(),
                    job_id: None,
                };
                set_latest_output(&latest_output, Some(notice.clone()));
                maybe_show_completion_notification(
                    &app_handle,
                    &completion_notifications_enabled,
                    &notice,
                );
                eprintln!(
                    "Capture error: {}. Partial audio preserved at {}",
                    e,
                    saved.display()
                );
            } else {
                eprintln!("Capture error: {}", e);
                set_recording_error_notice(
                    &latest_output,
                    "Recording failed",
                    format!("Recording failed before processing: {e}"),
                );
            }
        }
    }

    // Remove live transcript context from assistant workspace
    if let Ok(workspace) = crate::context::create_workspace(&config) {
        update_assistant_live_context(&workspace, false);
    }

    if clear_processing_on_exit {
        minutes_core::notes::cleanup();
        minutes_core::pid::remove().ok();
        processing.store(false, Ordering::Relaxed);
        set_processing_stage(&processing_stage, None);
        minutes_core::pid::clear_processing_status().ok();
        minutes_core::pid::clear_recording_metadata().ok();
    } else {
        sync_processing_indicator(&processing, &processing_stage);
    }
    starting.store(false, Ordering::Relaxed);
    recording.store(false, Ordering::Relaxed);
    reset_hotkey_capture_state(
        hotkey_runtime.as_ref(),
        discard_short_hotkey_capture.as_ref(),
    );
}

/// Result of an internal (non-JS) recording launch request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LaunchOutcome {
    /// The launch was accepted and capture is being reserved/spawned.
    Started,
    /// Require mode intercepted the launch: the blocking consent modal was
    /// shown in the main window and capture starts only after the user
    /// confirms there (review F1/F2).
    ConsentRequested,
}

#[allow(clippy::too_many_arguments)]
pub fn launch_recording(
    app: tauri::AppHandle,
    state: &AppState,
    mode: CaptureMode,
    requested_intent: Option<RecordingIntent>,
    allow_degraded: bool,
    requested_title: Option<String>,
    language_override: Option<String>,
    hotkey_runtime: Option<Arc<Mutex<HotkeyRuntime>>>,
    discard_short_hotkey_capture: Option<Arc<AtomicBool>>,
) -> Result<LaunchOutcome, String> {
    if mode == CaptureMode::Meeting {
        let config = Config::load();
        if let Some(disclosure) = desktop_recording_consent_required(mode, &config, false) {
            // Palette, tray, hotkey, and desktop-control starts end in the
            // same UI, so Require routes them to the same blocking modal as
            // the in-window button instead of erroring or bypassing
            // (spec Part A; review F1/F2). The frontend listener stashes the
            // pending args and re-invokes with consent confirmed.
            crate::show_main_window(&app);
            let _ = app.emit(
                "minutes://recording-consent-required",
                serde_json::json!({ "disclosure": disclosure }),
            );
            return Ok(LaunchOutcome::ConsentRequested);
        }
    }
    reserve_recording_launch(state).map_err(|error| reject_recording_launch(state, error))?;

    spawn_reserved_recording(
        app,
        state,
        mode,
        requested_intent,
        allow_degraded,
        requested_title,
        language_override,
        hotkey_runtime,
        discard_short_hotkey_capture,
    );

    Ok(LaunchOutcome::Started)
}

#[allow(clippy::too_many_arguments)]
fn spawn_reserved_recording(
    app: tauri::AppHandle,
    state: &AppState,
    mode: CaptureMode,
    requested_intent: Option<RecordingIntent>,
    allow_degraded: bool,
    requested_title: Option<String>,
    language_override: Option<String>,
    hotkey_runtime: Option<Arc<Mutex<HotkeyRuntime>>>,
    discard_short_hotkey_capture: Option<Arc<AtomicBool>>,
) {
    let rec = state.recording.clone();
    let starting = state.starting.clone();
    let stop = state.stop_flag.clone();
    let processing = state.processing.clone();
    let processing_stage = state.processing_stage.clone();
    let latest_output = state.latest_output.clone();
    let activation_progress = state.activation_progress.clone();
    let call_capture_health = state.call_capture_health.clone();
    let completion_notifications_enabled = state.completion_notifications_enabled.clone();
    let call_detect_session = CallDetectSessionHandles {
        started_by_call_detect: state.recording_started_by_call_detect.clone(),
        countdown_cancel: state.call_end_countdown_cancel.clone(),
    };
    let app_done = app.clone();
    mark_activation_first_recording_started(&activation_progress);

    std::thread::spawn(move || {
        start_recording(
            app,
            rec,
            starting,
            stop,
            processing,
            processing_stage,
            latest_output,
            activation_progress,
            call_capture_health,
            completion_notifications_enabled,
            hotkey_runtime,
            discard_short_hotkey_capture,
            call_detect_session,
            mode,
            requested_intent,
            allow_degraded,
            requested_title,
            language_override,
        );
        crate::sync_tray_state(&app_done);
    });
}

pub fn handle_desktop_control_request(
    app: tauri::AppHandle,
    state: &AppState,
    request: minutes_core::desktop_control::DesktopControlRequest,
) -> minutes_core::desktop_control::DesktopControlResponse {
    fn activation_detail(state: &AppState) -> String {
        state
            .latest_output
            .lock()
            .ok()
            .and_then(|notice| notice.clone())
            .map(|notice| notice.detail)
            .filter(|detail| !detail.trim().is_empty())
            .unwrap_or_else(|| {
                "Minutes desktop app did not confirm that recording became active.".into()
            })
    }

    let detail = match request.action {
        minutes_core::desktop_control::DesktopControlAction::StartRecording(payload) => {
            match launch_recording(
                app,
                state,
                payload.mode,
                payload.intent,
                payload.allow_degraded,
                payload.title,
                payload.language,
                None,
                None,
            ) {
                Ok(LaunchOutcome::ConsentRequested) => {
                    return minutes_core::desktop_control::DesktopControlResponse {
                        id: request.id,
                        handled_at: chrono::Local::now(),
                        accepted: true,
                        detail: "Require mode: confirmation shown in the Minutes window; recording starts after the user confirms.".into(),
                    };
                }
                Ok(LaunchOutcome::Started) => {
                    let start = Instant::now();
                    while start.elapsed() < Duration::from_secs(12) {
                        if recording_active(&state.recording) {
                            return minutes_core::desktop_control::DesktopControlResponse {
                                id: request.id,
                                handled_at: chrono::Local::now(),
                                accepted: true,
                                detail:
                                    "Recording request accepted by the running Minutes desktop app."
                                        .into(),
                            };
                        }
                        if !state.starting.load(Ordering::Relaxed) {
                            return minutes_core::desktop_control::DesktopControlResponse {
                                id: request.id,
                                handled_at: chrono::Local::now(),
                                accepted: false,
                                detail: activation_detail(state),
                            };
                        }
                        std::thread::sleep(Duration::from_millis(100));
                    }
                    return minutes_core::desktop_control::DesktopControlResponse {
                        id: request.id,
                        handled_at: chrono::Local::now(),
                        accepted: false,
                        detail: activation_detail(state),
                    };
                }
                Err(error) => error,
            }
        }
    };

    minutes_core::desktop_control::DesktopControlResponse {
        id: request.id,
        handled_at: chrono::Local::now(),
        accepted: false,
        detail,
    }
}

fn spawn_hotkey_recording(app: &tauri::AppHandle, style: HotkeyCaptureStyle) {
    let state = app.state::<AppState>();
    if let Ok(mut runtime) = state.hotkey_runtime.lock() {
        runtime.active_capture = Some(style);
        runtime.recording_started_at = Some(Instant::now());
    }
    state
        .discard_short_hotkey_capture
        .store(false, Ordering::Relaxed);
    let hotkey_runtime = state.hotkey_runtime.clone();
    let discard_short_hotkey_capture = state.discard_short_hotkey_capture.clone();
    let _ = launch_recording(
        app.clone(),
        &state,
        CaptureMode::QuickThought,
        Some(RecordingIntent::Memo),
        false,
        None,
        None,
        Some(hotkey_runtime),
        Some(discard_short_hotkey_capture),
    );
}

pub fn handle_global_hotkey_event(
    app: &tauri::AppHandle,
    shortcut_state: tauri_plugin_global_shortcut::ShortcutState,
) {
    let state = app.state::<AppState>();
    if !state.global_hotkey_enabled.load(Ordering::Relaxed) {
        return;
    }

    match shortcut_state {
        tauri_plugin_global_shortcut::ShortcutState::Pressed => {
            let generation = {
                let mut runtime = match state.hotkey_runtime.lock() {
                    Ok(runtime) => runtime,
                    Err(_) => return,
                };
                if runtime.key_down {
                    return;
                }
                runtime.key_down = true;
                runtime.key_down_started_at = Some(Instant::now());
                runtime.hold_generation = runtime.hold_generation.wrapping_add(1);
                runtime.hold_generation
            };

            let recording = state.recording.clone();
            let processing = state.processing.clone();
            let runtime = state.hotkey_runtime.clone();
            let app_handle = app.clone();
            std::thread::spawn(move || {
                std::thread::sleep(std::time::Duration::from_millis(HOTKEY_HOLD_THRESHOLD_MS));
                let should_start_hold = {
                    let runtime = match runtime.lock() {
                        Ok(runtime) => runtime,
                        Err(_) => return,
                    };
                    runtime.key_down
                        && runtime.hold_generation == generation
                        && runtime.active_capture.is_none()
                        && !recording.load(Ordering::Relaxed)
                        && !processing.load(Ordering::Relaxed)
                        && !minutes_core::pid::status().recording
                };
                if should_start_hold {
                    spawn_hotkey_recording(&app_handle, HotkeyCaptureStyle::Hold);
                }
            });
        }
        tauri_plugin_global_shortcut::ShortcutState::Released => {
            let now = Instant::now();
            let (active_capture, recording_started_at, was_short_tap) = {
                let mut runtime = match state.hotkey_runtime.lock() {
                    Ok(runtime) => runtime,
                    Err(_) => return,
                };
                let pressed_at = runtime.key_down_started_at;
                runtime.key_down = false;
                runtime.key_down_started_at = None;
                let was_short_tap = pressed_at
                    .map(|pressed| {
                        now.duration_since(pressed).as_millis() < HOTKEY_HOLD_THRESHOLD_MS as u128
                    })
                    .unwrap_or(false);
                (
                    runtime.active_capture,
                    runtime.recording_started_at,
                    was_short_tap,
                )
            };

            if let Some(_style) = active_capture {
                if should_discard_hotkey_capture(recording_started_at, now) {
                    state
                        .discard_short_hotkey_capture
                        .store(true, Ordering::Relaxed);
                }
                if let Ok(mut runtime) = state.hotkey_runtime.lock() {
                    runtime.active_capture = None;
                    runtime.recording_started_at = None;
                }
                if let Err(err) = request_stop(&state.recording, &state.stop_flag) {
                    show_user_notification(
                        app,
                        "Quick thought",
                        &format!("Could not stop recording: {}", err),
                    );
                }
                return;
            }

            if !was_short_tap {
                return;
            }

            if recording_active(&state.recording) {
                if let Err(err) = request_stop(&state.recording, &state.stop_flag) {
                    show_user_notification(
                        app,
                        "Quick thought",
                        &format!("Could not stop recording: {}", err),
                    );
                }
                return;
            }

            spawn_hotkey_recording(app, HotkeyCaptureStyle::Locked);
        }
    }
}

pub fn handle_dictation_shortcut_event(
    app: &tauri::AppHandle,
    shortcut_state: tauri_plugin_global_shortcut::ShortcutState,
) {
    let state = app.state::<AppState>();
    if !state.dictation_shortcut_enabled.load(Ordering::Relaxed) {
        return;
    }

    if shortcut_state != tauri_plugin_global_shortcut::ShortcutState::Pressed {
        return;
    }
    capture_pending_dictation_target(app);

    let shortcut = state
        .dictation_shortcut
        .lock()
        .ok()
        .map(|value| value.clone())
        .unwrap_or_else(|| default_dictation_shortcut().to_string());
    minutes_core::logging::append_log(&serde_json::json!({
        "ts": chrono::Local::now().to_rfc3339(),
        "level": "info",
        "step": "dictation_shortcut_event",
        "file": "",
        "extra": {
            "shortcut": shortcut,
            "state": "pressed",
        }
    }))
    .ok();

    if state.dictation_active.load(Ordering::Relaxed) {
        minutes_core::logging::append_log(&serde_json::json!({
            "ts": chrono::Local::now().to_rfc3339(),
            "level": "info",
            "step": "dictation_shortcut_action",
            "file": "",
            "extra": {
                "shortcut": shortcut,
                "action": "stop",
            }
        }))
        .ok();
        if let Ok(mut released_at) = state.dictation_release_started_at.lock() {
            *released_at = Some(Instant::now());
        }
        state.dictation_stop_flag.store(true, Ordering::Relaxed);
        return;
    }

    if let Err(error) = start_dictation_session(app, None) {
        minutes_core::logging::append_log(&serde_json::json!({
            "ts": chrono::Local::now().to_rfc3339(),
            "level": "error",
            "step": "dictation_shortcut_action",
            "file": "",
            "error": error,
            "extra": {
                "shortcut": shortcut,
                "action": "start_failed",
            }
        }))
        .ok();
        show_user_notification(app, "Dictation", &error);
    } else {
        minutes_core::logging::append_log(&serde_json::json!({
            "ts": chrono::Local::now().to_rfc3339(),
            "level": "info",
            "step": "dictation_shortcut_action",
            "file": "",
            "extra": {
                "shortcut": shortcut,
                "action": "start",
            }
        }))
        .ok();
    }
}

#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub fn cmd_start_recording(
    app: tauri::AppHandle,
    state: tauri::State<AppState>,
    mode: Option<String>,
    intent: Option<String>,
    allow_degraded: Option<bool>,
    title: Option<String>,
    language: Option<String>,
    source: Option<String>,
    consent_confirmed: Option<bool>,
) -> Result<StartRecordingOutcome, String> {
    let capture_mode = parse_capture_mode(mode.as_deref())?;
    let requested_intent = parse_recording_intent(intent.as_deref())?;
    let from_call_detect = source.as_deref() == Some("call_detect");
    let requested_intent = if from_call_detect && requested_intent.is_none() {
        Some(RecordingIntent::Call)
    } else {
        requested_intent
    };
    minutes_core::logging::log_step(
        "desktop_recording_start",
        "",
        0,
        serde_json::json!({
            "action": "requested",
            "mode": format!("{capture_mode:?}"),
            "intent": requested_intent.map(|intent| format!("{intent:?}")),
            "source": source,
            "recording_active": recording_active(&state.recording),
            "starting": state.starting.load(Ordering::Relaxed),
        }),
    );

    validate_recording_launch_state(&state)
        .map_err(|error| reject_recording_launch(&state, error))?;
    let config = Config::load();
    if let Some(disclosure) = desktop_recording_consent_required(
        capture_mode,
        &config,
        consent_confirmed.unwrap_or(false),
    ) {
        return Ok(StartRecordingOutcome::ConsentRequired { disclosure });
    }

    // Reserve the start BEFORE mutating any call-detect session atomics.
    // Otherwise a rejected call-detect start (live transcript/dictation already
    // active, stale starting flag, etc.) can cancel auto-stop state for a
    // recording that never actually launched.
    prepare_cmd_recording_launch(&state, from_call_detect)
        .map_err(|error| reject_recording_launch(&state, error))?;

    spawn_reserved_recording(
        app,
        &state,
        capture_mode,
        requested_intent,
        allow_degraded.unwrap_or(false),
        title,
        language,
        None,
        None,
    );
    Ok(StartRecordingOutcome::Started)
}

/// Reset countdown lifecycle state for a fresh recording/session boundary.
/// Used when a new recording starts so stale terminal markers from an older
/// call cannot masquerade as an explicit cancel on the next call end.
pub fn reset_call_end_countdown(state: &AppState) {
    state
        .call_end_countdown_terminal_state
        .store(CallEndCountdownTerminalState::None as u8, Ordering::Relaxed);
    state
        .call_end_countdown_cancel
        .store(true, Ordering::Relaxed);
    state
        .call_end_countdown_active
        .store(false, Ordering::Relaxed);
}

/// Explicit user cancellation of a call-end countdown via "Keep recording"
/// or "Stop now". This is a real terminal state and should not be re-armed
/// by the detector for the already-ended call session.
pub fn cancel_call_end_countdown_by_user(state: &AppState) {
    state.call_end_countdown_terminal_state.store(
        CallEndCountdownTerminalState::UserCancelled as u8,
        Ordering::Relaxed,
    );
    state
        .call_end_countdown_cancel
        .store(true, Ordering::Relaxed);
    state
        .call_end_countdown_active
        .store(false, Ordering::Relaxed);
}

#[tauri::command]
pub fn cmd_cancel_call_end_countdown(
    app: tauri::AppHandle,
    state: tauri::State<AppState>,
) -> Result<(), String> {
    cancel_call_end_countdown_by_user(&state);
    // Tell the UI to hide the banner immediately; the countdown thread will
    // observe the cancel flag on its next tick and exit without stopping.
    app.emit("call:end-countdown:cancelled", ()).ok();
    Ok(())
}

#[tauri::command]
pub fn cmd_stop_recording(state: tauri::State<AppState>) -> Result<(), String> {
    request_stop(&state.recording, &state.stop_flag)
}

#[tauri::command]
pub fn cmd_extend_recording() -> Result<(), String> {
    minutes_core::capture::write_extend_sentinel().map_err(|e| e.to_string())
}

#[tauri::command]
pub fn cmd_add_note(text: String) -> Result<String, String> {
    minutes_core::notes::add_note(&text)
}

/// Start a no-capture sensitive meeting from the desktop app.
#[tauri::command]
pub fn cmd_sensitive_start(title: Option<String>) -> Result<serde_json::Value, String> {
    let session =
        minutes_core::sensitive::start(title.as_deref()).map_err(|error| error.to_string())?;
    Ok(serde_json::json!({
        "id": session.id,
        "title": session.title,
        "startedAt": session.started_at.to_rfc3339(),
    }))
}

/// Stop the active desktop sensitive meeting and open the assistant debrief flow.
#[tauri::command]
pub fn cmd_sensitive_stop(
    app: tauri::AppHandle,
    state: tauri::State<AppState>,
) -> Result<serde_json::Value, String> {
    let config = Config::load();
    let result = minutes_core::sensitive::stop(None, &config).map_err(|error| error.to_string())?;
    let path = result.path.to_string_lossy().to_string();
    if let Err(error) = spawn_terminal(&app, &state.pty_manager, "meeting", Some(&path), None) {
        show_user_notification(&app, "Sensitive meeting saved", &error);
    } else if let Ok(mut manager) = state.pty_manager.lock() {
        if let Some(command) = manager.session_command(crate::pty::ASSISTANT_SESSION_ID) {
            let debrief_prompt = "Run /minutes-debrief for CURRENT_MEETING.md.";
            let input = if is_shell_command(&command) {
                format!("cat <<'__MINUTES__'\n{debrief_prompt}\n__MINUTES__\n")
            } else {
                format!("{debrief_prompt}\n")
            };
            let _ = manager.write_input(crate::pty::ASSISTANT_SESSION_ID, input.as_bytes());
        }
    }
    Ok(serde_json::json!({
        "path": path,
        "title": result.title,
        "debrief": "pending",
    }))
}

/// Toggle (or force-set) the Minutes-local mic mute for the active
/// dual-source recording. Returns the new muted state. System audio
/// keeps capturing; only the mic stream is silenced.
/// Hand an existing meeting to the assistant with a debrief prompt.
///
/// Used by the detail view's "Run debrief" action on no-capture sensitive
/// meetings whose frontmatter says `debrief: pending` (bead minutes-3yub.5);
/// mirrors the handoff `cmd_sensitive_stop` performs at stop time.
#[tauri::command]
pub fn cmd_run_meeting_debrief(
    app: tauri::AppHandle,
    state: tauri::State<AppState>,
    path: String,
) -> Result<(), String> {
    let config = Config::load();
    let meeting_path = std::path::PathBuf::from(&path);
    minutes_core::notes::validate_meeting_path(&meeting_path, &config.output_dir)?;
    spawn_terminal(&app, &state.pty_manager, "meeting", Some(&path), None)?;
    if let Ok(mut manager) = state.pty_manager.lock() {
        if let Some(command) = manager.session_command(crate::pty::ASSISTANT_SESSION_ID) {
            let debrief_prompt = "Run /minutes-debrief for CURRENT_MEETING.md.";
            let input = if is_shell_command(&command) {
                format!("cat <<'__MINUTES__'\n{debrief_prompt}\n__MINUTES__\n")
            } else {
                format!("{debrief_prompt}\n")
            };
            let _ = manager.write_input(crate::pty::ASSISTANT_SESSION_ID, input.as_bytes());
        }
    }
    Ok(())
}

#[tauri::command]
pub fn cmd_toggle_mic_mute(force_state: Option<bool>) -> bool {
    match force_state {
        Some(state) => minutes_core::streaming::set_mic_muted_with_sentinel(state),
        None => minutes_core::streaming::toggle_mic_mute_with_sentinel(),
    }
}

/// Returns the current mic-mute state as the Tauri process sees it.
#[tauri::command]
pub fn cmd_mic_mute_state() -> bool {
    minutes_core::streaming::is_mic_muted()
}

fn status_value(state: &AppState, include_readiness: bool) -> serde_json::Value {
    let recording = state.recording.load(Ordering::Relaxed);
    let starting = state.starting.load(Ordering::Relaxed);
    let shared_processing = minutes_core::pid::read_processing_status();
    // Scan ~/.minutes/jobs/ once per status call — `pid::status_with_active_jobs`
    // reuses this snapshot instead of triggering two more directory walks
    // (processing_summary + active_job_count) inside pid::status. This
    // matters at 1 Hz UI poll cadence when terminal jobs accumulate.
    let active_jobs = minutes_core::jobs::active_jobs();
    if active_jobs.is_empty() && !shared_processing.processing {
        state.processing.store(false, Ordering::Relaxed);
        set_processing_stage(&state.processing_stage, None);
    }
    let processing = state.processing.load(Ordering::Relaxed)
        || shared_processing.processing
        || !active_jobs.is_empty();
    let status = minutes_core::pid::status_with_active_jobs(&active_jobs);
    let local_processing_stage = state
        .processing_stage
        .lock()
        .ok()
        .and_then(|stage| stage.clone());
    let processing_stage = shared_processing
        .stage
        .clone()
        .or_else(|| active_jobs.first().and_then(|job| job.stage.clone()))
        .or(local_processing_stage);
    let processing_stage_label =
        pipeline_stage_label(processing_stage.as_deref(), status.recording_mode);
    let latest_output = state
        .latest_output
        .lock()
        .ok()
        .and_then(|notice| notice.clone());
    let call_capture_health = state
        .call_capture_health
        .lock()
        .ok()
        .and_then(|health| health.clone());
    let processing_jobs: Vec<ProcessingJobView> =
        active_jobs.into_iter().map(processing_job_view).collect();
    let update_state = state
        .update_install_state
        .lock()
        .ok()
        .map(|guard| guard.clone())
        .unwrap_or_default();
    let recording_active = recording || (status.recording && !processing);
    let sensitive_session = minutes_core::sensitive::active_session();

    // Get elapsed time if recording
    let elapsed = if recording_active {
        let start_path = minutes_core::notes::recording_start_path();
        if start_path.exists() {
            if let Ok(s) = std::fs::read_to_string(&start_path) {
                if let Ok(start) = s.trim().parse::<u64>() {
                    let now = std::time::SystemTime::now()
                        .duration_since(std::time::UNIX_EPOCH)
                        .unwrap_or_default()
                        .as_secs();
                    let e = now.saturating_sub(start);
                    Some(format!("{}:{:02}", e / 60, e % 60))
                } else {
                    None
                }
            } else {
                None
            }
        } else {
            None
        }
    } else {
        None
    };
    let sensitive_elapsed = sensitive_session.as_ref().map(|session| {
        let now = chrono::Local::now();
        let e = now
            .signed_duration_since(session.started_at)
            .num_seconds()
            .max(0) as u64;
        format!("{}:{:02}", e / 60, e % 60)
    });

    let audio_level = if recording_active {
        minutes_core::capture::audio_level()
    } else {
        0
    };

    let mut value = serde_json::json!({
        "recording": recording || (status.recording && !processing),
        "starting": starting,
        "processing": processing,
        "recordingMode": status.recording_mode,
        "processingStage": processing_stage,
        "processingStageLabel": processing_stage_label,
        "processingTitle": status.processing_title,
        "processingJobId": status.processing_job_id,
        "processingJobCount": status.processing_job_count,
        "processingJobs": processing_jobs,
        "updateState": update_state,
        "latestOutput": latest_output,
        "callCaptureHealth": call_capture_health,
        "pid": status.pid,
        "elapsed": elapsed,
        "sensitive": sensitive_session.as_ref().map(|session| serde_json::json!({
            "active": true,
            "id": session.id,
            "title": session.title,
            "startedAt": session.started_at.to_rfc3339(),
            "elapsed": sensitive_elapsed,
            "markerCount": session.markers.len(),
        })),
        "audioLevel": audio_level,
    });

    if include_readiness {
        let config = Config::load();
        let batch_readiness = batch_transcription_readiness_view(&config);
        let live_readiness = standalone_live_readiness_view(&config);
        mark_model_ready_for_surface(&config, &batch_readiness, &state.activation_progress);
        mark_model_ready_for_surface(&config, &live_readiness, &state.activation_progress);
        let activation_progress = state
            .activation_progress
            .lock()
            .ok()
            .map(|progress| progress.clone())
            .unwrap_or_default();
        let has_saved_artifact = activation_progress.first_artifact_saved_at.is_some();
        let batch_setup = transcription_surface_setup_view(
            &config,
            "batch",
            &batch_readiness,
            &activation_progress,
            has_saved_artifact,
            recording_active,
            processing,
        );
        let standalone_live_setup = transcription_surface_setup_view(
            &config,
            "standalone-live",
            &live_readiness,
            &activation_progress,
            has_saved_artifact,
            recording_active,
            processing,
        );
        let primary_setup = primary_setup_surface(&batch_setup, &standalone_live_setup);

        if let Some(object) = value.as_object_mut() {
            object.insert(
                "activation".into(),
                serde_json::to_value(primary_setup.activation.clone())
                    .unwrap_or(serde_json::Value::Null),
            );
            object.insert(
                "batch_transcription".into(),
                serde_json::to_value(batch_readiness).unwrap_or(serde_json::Value::Null),
            );
            object.insert(
                "standalone_live".into(),
                serde_json::to_value(live_readiness).unwrap_or(serde_json::Value::Null),
            );
            object.insert(
                "transcriptionSetup".into(),
                serde_json::json!({
                    "needsSetup": batch_setup.needs_setup || standalone_live_setup.needs_setup,
                    "batch": batch_setup,
                    "standaloneLive": standalone_live_setup,
                }),
            );
        }
    }

    value
}

#[tauri::command]
pub fn cmd_capture_status(state: tauri::State<AppState>) -> serde_json::Value {
    status_value(&state, false)
}

#[tauri::command]
pub fn cmd_status(state: tauri::State<AppState>) -> serde_json::Value {
    status_value(&state, true)
}

#[tauri::command]
pub fn cmd_processing_jobs(limit: Option<usize>) -> serde_json::Value {
    let jobs: Vec<ProcessingJobView> = minutes_core::jobs::display_jobs(limit, true)
        .into_iter()
        .map(processing_job_view)
        .collect();
    serde_json::to_value(jobs).unwrap_or(serde_json::json!([]))
}

#[tauri::command]
pub fn cmd_retry_processing_job(
    app: tauri::AppHandle,
    state: tauri::State<AppState>,
    job_id: String,
) -> Result<(), String> {
    let queued = minutes_core::jobs::requeue_job(&job_id)
        .map_err(|e| e.to_string())?
        .ok_or_else(|| format!("Processing job not found: {}", job_id))?;

    minutes_core::pid::set_processing_status(
        queued.stage.as_deref(),
        Some(queued.mode),
        queued.title.as_deref(),
        Some(&queued.id),
        minutes_core::jobs::active_job_count(),
    )
    .ok();
    sync_processing_indicator(&state.processing, &state.processing_stage);
    spawn_processing_worker(
        app,
        state.processing.clone(),
        state.processing_stage.clone(),
        state.latest_output.clone(),
        state.activation_progress.clone(),
        state.completion_notifications_enabled.clone(),
    );
    Ok(())
}

#[tauri::command]
pub fn cmd_weekly_summary() -> Result<WeeklySummaryView, String> {
    let config = Config::load();
    let since = (chrono::Local::now() - chrono::Duration::days(7)).to_rfc3339();
    let filters = minutes_core::search::SearchFilters {
        content_type: None,
        since: Some(since.clone()),
        attendee: None,
        intent_kind: None,
        owner: None,
        recorded_by: None,
    };

    let meetings =
        minutes_core::search::search("", &config, &filters).map_err(|e| e.to_string())?;
    let consistency =
        minutes_core::search::consistency_report(&config, None, 7).map_err(|e| e.to_string())?;
    let open_actions =
        minutes_core::search::find_open_actions(&config, None).map_err(|e| e.to_string())?;

    let meetings_count = meetings.len();
    let recent_titles = if meetings.is_empty() {
        "- No meetings or memos in the last 7 days.".to_string()
    } else {
        meetings
            .iter()
            .take(6)
            .map(|meeting| format!("- {} ({})", meeting.title, meeting.date))
            .collect::<Vec<_>>()
            .join("\n")
    };

    let decision_conflicts = if consistency.decision_conflicts.is_empty() {
        "- No conflicting decision arcs detected.".to_string()
    } else {
        consistency
            .decision_conflicts
            .iter()
            .take(5)
            .map(|conflict| format!("- {} -> {}", conflict.topic, conflict.latest.what))
            .collect::<Vec<_>>()
            .join("\n")
    };

    let stale_commitments = if consistency.stale_commitments.is_empty() {
        "- No stale commitments detected.".to_string()
    } else {
        consistency
            .stale_commitments
            .iter()
            .take(5)
            .map(|item| {
                format!(
                    "- {}{}",
                    item.entry.what,
                    item.entry
                        .who
                        .as_ref()
                        .map(|who| format!(" ({})", who))
                        .unwrap_or_default()
                )
            })
            .collect::<Vec<_>>()
            .join("\n")
    };

    let open_actions_block = if open_actions.is_empty() {
        "- No open action items found.".to_string()
    } else {
        open_actions
            .iter()
            .take(6)
            .map(|item| {
                format!(
                    "- {}: {}{}",
                    item.assignee,
                    item.task,
                    item.due
                        .as_ref()
                        .map(|due| format!(" (due {})", due))
                        .unwrap_or_default()
                )
            })
            .collect::<Vec<_>>()
            .join("\n")
    };

    let markdown = build_weekly_summary_markdown(
        meetings_count,
        &recent_titles,
        &decision_conflicts,
        &stale_commitments,
        &open_actions_block,
    );

    Ok(WeeklySummaryView { markdown })
}

#[tauri::command]
pub fn cmd_proactive_context_bundle() -> Result<ProactiveContextBundleView, String> {
    let config = Config::load();
    let since = (chrono::Local::now() - chrono::Duration::days(7)).to_rfc3339();
    let filters = minutes_core::search::SearchFilters {
        content_type: None,
        since: Some(since),
        attendee: None,
        intent_kind: None,
        owner: None,
        recorded_by: None,
    };

    let recent_results =
        minutes_core::search::search("", &config, &filters).map_err(|e| e.to_string())?;
    let recent_meetings: Vec<String> = recent_results
        .iter()
        .filter(|item| item.content_type != "memo")
        .take(4)
        .map(|item| format!("{} ({})", item.title, item.date))
        .collect();
    let recent_memos: Vec<String> = recent_results
        .iter()
        .filter(|item| item.content_type == "memo")
        .take(4)
        .map(|item| format!("{} ({})", item.title, item.date))
        .collect();

    let consistency =
        minutes_core::search::consistency_report(&config, None, 7).map_err(|e| e.to_string())?;
    let stale_commitments: Vec<String> = consistency
        .stale_commitments
        .iter()
        .take(4)
        .map(|item| {
            format!(
                "{}{}",
                item.entry.what,
                item.entry
                    .who
                    .as_ref()
                    .map(|who| format!(" ({who})"))
                    .unwrap_or_default()
            )
        })
        .collect();

    let losing_touch = minutes_core::graph::relationship_map(&config)
        .map(|people| {
            people
                .into_iter()
                .filter(|person| person.losing_touch)
                .take(4)
                .map(|person| {
                    format!(
                        "{} (last {}d ago)",
                        person.name,
                        person.days_since.round() as i64
                    )
                })
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();

    let summary = format!(
        "{} meetings · {} memos · {} stale commitments · {} losing-touch alerts",
        recent_meetings.len(),
        recent_memos.len(),
        stale_commitments.len(),
        losing_touch.len()
    );
    let markdown = build_proactive_context_markdown(
        &recent_meetings,
        &recent_memos,
        &stale_commitments,
        &losing_touch,
    );

    Ok(ProactiveContextBundleView {
        summary,
        markdown,
        recent_meeting_count: recent_meetings.len(),
        recent_memo_count: recent_memos.len(),
        stale_commitment_count: stale_commitments.len(),
        losing_touch_count: losing_touch.len(),
    })
}

/// Scan ~/.minutes/preps/ for existing prep files and return a set of
/// first-name slugs that have been prepped (for lifecycle badge display).
fn scan_prep_slugs() -> std::collections::HashSet<String> {
    let preps_dir = Config::minutes_dir().join("preps");
    let mut slugs = std::collections::HashSet::new();
    if let Ok(entries) = std::fs::read_dir(&preps_dir) {
        for entry in entries.flatten() {
            let name = entry.file_name().to_string_lossy().to_string();
            if name.ends_with(".prep.md") {
                // slug format: YYYY-MM-DD-{name}.prep.md → extract {name}
                if let Some(stem) = name.strip_suffix(".prep.md") {
                    // skip date prefix (11 chars: "YYYY-MM-DD-")
                    if stem.len() > 11 {
                        slugs.insert(stem[11..].to_lowercase());
                    }
                }
            }
        }
    }
    slugs
}

/// Check if a meeting's attendees include anyone with a matching prep file.
fn meeting_has_prep(attendees: &[String], prep_slugs: &std::collections::HashSet<String>) -> bool {
    attendees.iter().any(|name| {
        let first = name.split_whitespace().next().unwrap_or(name);
        prep_slugs.contains(&first.to_lowercase())
    })
}

#[tauri::command]
pub fn cmd_list_meetings(limit: Option<usize>) -> serde_json::Value {
    let config = Config::load();
    let prep_slugs = scan_prep_slugs();
    let filters = minutes_core::search::SearchFilters {
        content_type: None,
        since: None,
        attendee: None,
        intent_kind: None,
        owner: None,
        recorded_by: None,
    };
    match minutes_core::search::search("", &config, &filters) {
        Ok(results) => {
            let limited: Vec<_> = results.into_iter().take(limit.unwrap_or(20)).collect();
            let enriched: Vec<serde_json::Value> = limited
                .iter()
                .map(|r| {
                    let mut val = serde_json::to_value(r).unwrap_or(serde_json::json!({}));
                    // Read frontmatter to check for lifecycle badges
                    let badges = compute_lifecycle_badges(&r.path, &prep_slugs);
                    val["badges"] = serde_json::json!(badges);
                    val
                })
                .collect();
            serde_json::json!(enriched)
        }
        Err(_) => serde_json::json!([]),
    }
}

/// Compute lifecycle badge strings for a meeting artifact.
fn compute_lifecycle_badges(
    path: &std::path::Path,
    prep_slugs: &std::collections::HashSet<String>,
) -> Vec<String> {
    let mut badges = Vec::new();

    let content = match std::fs::read_to_string(path) {
        Ok(c) => c,
        Err(_) => return badges,
    };
    let (fm_str, body) = minutes_core::markdown::split_frontmatter(&content);
    let fm: Result<minutes_core::markdown::Frontmatter, _> =
        serde_yaml::from_str(&format!("---\n{}\n---", fm_str));

    if let Ok(fm) = fm {
        if meeting_has_prep(&fm.attendees, prep_slugs) {
            badges.push("prepped".into());
        }
        // "recorded" badge: all meetings/memos with transcripts are recorded
        if body.contains("## Transcript") || body.contains("## Summary") {
            badges.push("recorded".into());
        }
        // "debriefed" badge: has decisions or resolved intents (added by debrief)
        if !fm.decisions.is_empty() || fm.intents.iter().any(|i| i.status != "open") {
            badges.push("debriefed".into());
        }
    }

    badges
}

/// Tauri command that powers the in-app search bar.
///
/// Returns `Result<Vec<SearchResult>, String>` so failures (corrupted index,
/// disk-full, etc.) surface as JS Promise rejections that the frontend's
/// `catch` block handles by rendering an error banner. Previously the command
/// swallowed every error as `[]`, which made real failures look like "no
/// matches" and was a debugging footgun.
#[tauri::command]
pub fn cmd_search(query: String) -> Result<Vec<minutes_core::search::SearchResult>, String> {
    let config = Config::load();
    let filters = minutes_core::search::SearchFilters::default();
    minutes_core::search::search(&query, &config, &filters).map_err(|e| e.to_string())
}

#[tauri::command]
pub fn cmd_list_devices() -> serde_json::Value {
    let config = Config::load();
    let configured_device = config.recording.device.clone();
    let entries = minutes_core::capture::list_input_devices_detailed();
    // Back-compat: preserve the decorated label list for any caller that
    // still reads `devices`, while exposing structured entries so pickers
    // can store the canonical name instead of the label.
    let legacy_labels: Vec<String> = entries.iter().map(|e| e.label.clone()).collect();
    serde_json::json!({
        "devices": legacy_labels,
        "entries": entries,
        "configured_device": configured_device,
    })
}

#[tauri::command]
pub fn cmd_delete_meeting(
    app: tauri::AppHandle,
    path: String,
    with_audio: bool,
    force: bool,
) -> Result<String, String> {
    let md_path = std::path::PathBuf::from(&path);
    if !md_path.exists() {
        return Err(format!("File not found: {}", path));
    }

    let title = md_path
        .file_stem()
        .unwrap_or_default()
        .to_string_lossy()
        .to_string();

    let audio_artifacts = minutes_core::capture::meeting_audio_artifact_paths(&md_path);

    if force {
        delete_meeting_artifacts(&md_path, &audio_artifacts, with_audio)?;
        Ok(format!("Deleted: {}", title))
    } else {
        // Show native confirmation dialog and wait for user response
        let confirmed = app
            .dialog()
            .message(format!(
                "Archive \"{}\" and its audio recording?\nThey will be moved to the archive folder.",
                title
            ))
            .title("Archive Meeting")
            .kind(MessageDialogKind::Warning)
            .buttons(tauri_plugin_dialog::MessageDialogButtons::OkCancel)
            .blocking_show();

        if !confirmed {
            return Ok("Cancelled".into());
        }

        let config = Config::load();
        let archive_dir = config.output_dir.join("archive");
        std::fs::create_dir_all(&archive_dir).map_err(|e| e.to_string())?;

        archive_meeting_artifacts(&md_path, &archive_dir, &audio_artifacts, with_audio)?;
        Ok(format!("Archived: {}", title))
    }
}

fn delete_meeting_artifacts(
    md_path: &Path,
    audio_artifacts: &[PathBuf],
    with_audio: bool,
) -> Result<(), String> {
    std::fs::remove_file(md_path).map_err(|e| e.to_string())?;
    if with_audio {
        for path in audio_artifacts.iter().filter(|path| path.exists()) {
            std::fs::remove_file(path).map_err(|e| e.to_string())?;
        }
    }
    Ok(())
}

fn archive_meeting_artifacts(
    md_path: &Path,
    archive_dir: &Path,
    audio_artifacts: &[PathBuf],
    with_audio: bool,
) -> Result<(), String> {
    let dest_md = archive_dir.join(
        md_path
            .file_name()
            .ok_or_else(|| format!("missing filename for {}", md_path.display()))?,
    );
    std::fs::rename(md_path, &dest_md).map_err(|e| e.to_string())?;

    if with_audio {
        for path in audio_artifacts.iter().filter(|path| path.exists()) {
            let file_name = path
                .file_name()
                .ok_or_else(|| format!("missing filename for {}", path.display()))?;
            let dest_audio = archive_dir.join(file_name);
            std::fs::rename(path, &dest_audio).map_err(|e| e.to_string())?;
        }
    }

    Ok(())
}

#[tauri::command]
pub fn cmd_open_file(app: tauri::AppHandle, path: String) -> Result<(), String> {
    open_target(&app, &path)
}

fn validate_text_file_path(path: &Path) -> Result<PathBuf, String> {
    let canonical = std::fs::canonicalize(path)
        .map_err(|e| format!("Cannot resolve {}: {}", path.display(), e))?;
    let path_str = canonical.to_string_lossy();

    // Only allow reads under the user's home directory
    let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
    if !canonical.starts_with(&home) {
        return Err(format!(
            "Access denied: {} is outside home directory",
            path_str
        ));
    }

    let meta =
        std::fs::metadata(&canonical).map_err(|e| format!("Cannot stat {}: {}", path_str, e))?;
    if !meta.is_file() {
        return Err(format!("Not a file: {}", path_str));
    }

    // Cap at 1MB to prevent OOM on huge files
    if meta.len() > 1_048_576 {
        return Err(format!(
            "File too large: {} ({} bytes, max 1MB)",
            path_str,
            meta.len()
        ));
    }

    let extension = canonical
        .extension()
        .and_then(|ext| ext.to_str())
        .map(|ext| ext.to_ascii_lowercase())
        .ok_or_else(|| format!("Unsupported text file: {}", path_str))?;
    if !matches!(extension.as_str(), "md" | "markdown" | "txt" | "json") {
        return Err(format!(
            "Unsupported text file: {} (expected .md, .markdown, .txt, or .json)",
            path_str
        ));
    }

    Ok(canonical)
}

fn write_notice_prompt(command: &str, plain_text: &str) -> String {
    if is_shell_command(command) {
        format!("cat <<'__MINUTES__'\n{plain_text}\n__MINUTES__\n")
    } else {
        format!("{plain_text}\n")
    }
}

fn artifact_switch_prompt(command: &str, artifact_name: Option<&str>) -> String {
    let plain_text = match artifact_name {
        Some(name) => format!(
            "Minutes opened artifact {name}. Read CURRENT_ARTIFACT.md and your assistant instructions (CLAUDE.md or AGENTS.md). The user has this file open in the left pane and may want help editing it. If you update it on disk, the viewer will refresh live."
        ),
        None => "Minutes cleared the open artifact focus. Ignore CURRENT_ARTIFACT.md unless it reappears. If CURRENT_MEETING.md exists, prioritize it; otherwise continue in general assistant mode."
            .into(),
    };
    write_notice_prompt(command, &plain_text)
}

fn notify_assistant_artifact_focus(
    state: &tauri::State<AppState>,
    artifact_name: Option<&str>,
) -> Result<(), String> {
    let mut manager = state
        .pty_manager
        .lock()
        .map_err(|_| "PTY manager lock failed")?;
    if manager.assistant_session_id().is_some() {
        if let Some(command) = manager.session_command(crate::pty::ASSISTANT_SESSION_ID) {
            let prompt = artifact_switch_prompt(&command, artifact_name);
            manager.write_input(crate::pty::ASSISTANT_SESSION_ID, prompt.as_bytes())?;
        }
    }
    Ok(())
}

#[tauri::command]
pub fn cmd_read_text_file(path: String) -> Result<String, String> {
    let canonical = validate_text_file_path(Path::new(&path))?;
    let path_str = canonical.to_string_lossy();
    std::fs::read_to_string(&canonical).map_err(|e| format!("Cannot read {}: {}", path_str, e))
}

#[tauri::command]
pub fn cmd_get_text_file_access(path: String) -> Result<TextFileAccess, String> {
    let canonical = validate_text_file_path(Path::new(&path))?;
    let config = Config::load();
    Ok(TextFileAccess {
        path: canonical.display().to_string(),
        editable: is_editable_text_file_path(&canonical, &config),
        kind: text_file_kind(&canonical).unwrap_or("text").to_string(),
    })
}

#[tauri::command]
pub fn cmd_get_text_file_review(path: String) -> Result<TextFileReview, String> {
    let canonical = validate_text_file_path(Path::new(&path))?;
    let Some(snapshot) = latest_snapshot_for_path(&canonical)? else {
        return Ok(TextFileReview {
            available: false,
            snapshot_label: None,
            before_preview: None,
            current_preview: None,
        });
    };
    let before = std::fs::read_to_string(&snapshot)
        .map_err(|e| format!("Cannot read snapshot {}: {}", snapshot.display(), e))?;
    let current = std::fs::read_to_string(&canonical)
        .map_err(|e| format!("Cannot read {}: {}", canonical.display(), e))?;
    let kind = text_file_kind(&canonical).unwrap_or("text");
    let snapshot_label = snapshot
        .file_name()
        .and_then(|name| name.to_str())
        .map(|name| name.to_string());
    Ok(TextFileReview {
        available: true,
        snapshot_label,
        before_preview: Some(review_preview_for_kind(kind, &before, 80, 4000)),
        current_preview: Some(review_preview_for_kind(kind, &current, 80, 4000)),
    })
}

#[tauri::command]
pub fn cmd_recent_artifacts(limit: Option<usize>) -> serde_json::Value {
    let config = Config::load();
    let views = recent_artifact_views(&config, limit.unwrap_or(8), None);
    serde_json::to_value(views).unwrap_or_else(|_| serde_json::json!([]))
}

#[tauri::command]
pub fn cmd_list_documents(limit: Option<usize>) -> serde_json::Value {
    let config = Config::load();
    let workspace = crate::context::workspace_dir();
    let views = list_documents_for_roots(&config, &workspace, limit.unwrap_or(200).min(200));
    serde_json::to_value(views).unwrap_or_else(|_| serde_json::json!([]))
}

#[tauri::command]
pub fn cmd_get_recall_workspace_state() -> serde_json::Value {
    serde_json::to_value(load_recall_workspace_state_from(
        &recall_workspace_state_path(),
    ))
    .unwrap_or_else(|_| serde_json::json!({}))
}

#[tauri::command]
pub fn cmd_set_recall_workspace_state(
    recall_expanded: Option<bool>,
    recall_phase: Option<String>,
    recall_ratio: Option<f64>,
    current_meeting_path: Option<String>,
    open_artifact_path: Option<String>,
) -> Result<(), String> {
    let state_path = recall_workspace_state_path();
    let mut state = load_recall_workspace_state_from(&state_path);

    if let Some(value) = recall_expanded {
        state.recall_expanded = value;
    }
    if let Some(value) = recall_phase {
        state.recall_phase = if value.trim().is_empty() {
            "recall".into()
        } else {
            value
        };
    }
    if let Some(value) = recall_ratio {
        state.recall_ratio = value.clamp(0.25, 0.667);
    }
    if let Some(value) = current_meeting_path {
        state.current_meeting_path = if value.trim().is_empty() {
            None
        } else {
            Some(value)
        };
    }
    if let Some(value) = open_artifact_path {
        state.open_artifact_path = if value.trim().is_empty() {
            None
        } else {
            Some(value)
        };
    }

    persist_recall_workspace_state_to(&state_path, &state);
    Ok(())
}

#[tauri::command]
pub fn cmd_set_open_artifact(state: tauri::State<AppState>, path: String) -> Result<(), String> {
    let canonical = validate_text_file_path(Path::new(&path))?;
    record_recent_artifact_path(&canonical);
    let config = Config::load();
    let workspace = crate::context::create_workspace(&config)?;
    crate::context::write_assistant_context(&workspace, &config)?;
    crate::context::write_active_artifact_context(&workspace, &canonical)?;
    let artifact_name = canonical.file_name().and_then(|name| name.to_str());
    notify_assistant_artifact_focus(&state, artifact_name)
}

#[tauri::command]
pub fn cmd_clear_open_artifact(state: tauri::State<AppState>) -> Result<(), String> {
    let workspace = crate::context::workspace_dir();
    if workspace.exists() {
        crate::context::clear_active_artifact_context(&workspace)?;
    }
    notify_assistant_artifact_focus(&state, None)
}

#[tauri::command]
pub fn cmd_clear_latest_output(state: tauri::State<AppState>) {
    let dismissed_notice = state
        .latest_output
        .lock()
        .ok()
        .and_then(|current| current.clone());
    if let Some(notice) = dismissed_notice.as_ref() {
        persist_output_notice_dismissal(notice);
    }
    set_latest_output(&state.latest_output, None);
}

/// Persist the completion-notification preference to `config.toml`.
///
/// Extracted so the round-trip test can exercise the real save path without
/// constructing a `tauri::State` (which has no public test constructor). The
/// setter command below seeds AppState and then delegates here.
fn persist_completion_notifications(enabled: bool) -> Result<(), String> {
    let mut config = Config::load();
    config.notifications.completion_enabled = enabled;
    config
        .save()
        .map_err(|e| format!("Failed to save config: {}", e))
}

/// Persist the Quick-Thought global hotkey to `config.toml`.
///
/// Extracted (like `persist_completion_notifications`) so the round-trip test
/// can verify the on-disk write without a `tauri::AppHandle` / global-shortcut
/// manager. `cmd_set_global_hotkey` and the `quick_thought` slot of
/// `cmd_set_shortcut` both target the same `[global_hotkey]` fields.
fn persist_global_hotkey(enabled: bool, shortcut: &str) -> Result<(), String> {
    let mut config = Config::load();
    config.global_hotkey.shortcut_enabled = enabled;
    config.global_hotkey.shortcut = shortcut.to_string();
    config
        .save()
        .map_err(|e| format!("Failed to save config: {}", e))
}

#[tauri::command]
pub fn cmd_set_completion_notifications(
    state: tauri::State<AppState>,
    enabled: bool,
) -> Result<(), String> {
    state
        .completion_notifications_enabled
        .store(enabled, Ordering::Relaxed);

    persist_completion_notifications(enabled)?;

    Ok(())
}

#[tauri::command]
pub fn cmd_global_hotkey_settings(state: tauri::State<AppState>) -> HotkeySettings {
    current_hotkey_settings(&state)
}

#[tauri::command]
pub fn cmd_dictation_shortcut_settings(state: tauri::State<AppState>) -> HotkeySettings {
    current_dictation_shortcut_settings(&state)
}

#[tauri::command]
pub fn cmd_set_global_hotkey(
    app: tauri::AppHandle,
    state: tauri::State<AppState>,
    enabled: bool,
    shortcut: String,
) -> Result<HotkeySettings, String> {
    use tauri_plugin_global_shortcut::GlobalShortcutExt;

    let next_shortcut = validate_hotkey_shortcut(&shortcut)?;
    let previous = current_hotkey_settings(&state);
    let manager = app.global_shortcut();

    if previous.enabled {
        manager
            .unregister(previous.shortcut.as_str())
            .map_err(|e| format!("Could not unregister {}: {}", previous.shortcut, e))?;
    }

    if enabled {
        if let Err(e) = manager.register(next_shortcut.as_str()) {
            if previous.enabled {
                let _ = manager.register(previous.shortcut.as_str());
            }
            return Err(format!(
                "Could not register {}. Another app may already be using it. ({})",
                next_shortcut, e
            ));
        }
    }

    state
        .global_hotkey_enabled
        .store(enabled, Ordering::Relaxed);
    if let Ok(mut current) = state.global_hotkey_shortcut.lock() {
        *current = next_shortcut.clone();
    }

    persist_global_hotkey(enabled, &next_shortcut)?;

    Ok(current_hotkey_settings(&state))
}

#[tauri::command]
pub fn cmd_set_dictation_shortcut(
    app: tauri::AppHandle,
    state: tauri::State<AppState>,
    enabled: bool,
    shortcut: String,
) -> Result<HotkeySettings, String> {
    use tauri_plugin_global_shortcut::GlobalShortcutExt;

    let next_shortcut = validate_dictation_shortcut(&shortcut)?;
    let previous = current_dictation_shortcut_settings(&state);
    let manager = app.global_shortcut();
    let quick_thought_shortcut = current_hotkey_settings(&state).shortcut;

    if next_shortcut == quick_thought_shortcut {
        return Err(format!(
            "{} is already used by the quick-thought shortcut. Choose a different dictation shortcut.",
            next_shortcut
        ));
    }

    if previous.enabled {
        manager
            .unregister(previous.shortcut.as_str())
            .map_err(|e| format!("Could not unregister {}: {}", previous.shortcut, e))?;
    }

    if enabled {
        if let Err(e) = manager.register(next_shortcut.as_str()) {
            if previous.enabled {
                let _ = manager.register(previous.shortcut.as_str());
            }
            return Err(format!(
                "Could not register {}. Another app may already be using it. ({})",
                next_shortcut, e
            ));
        }
    }

    state
        .dictation_shortcut_enabled
        .store(enabled, Ordering::Relaxed);
    if let Ok(mut current) = state.dictation_shortcut.lock() {
        *current = next_shortcut.clone();
    }

    let mut config = Config::load();
    config.dictation.shortcut_enabled = enabled;
    config.dictation.shortcut = next_shortcut.clone();
    config
        .save()
        .map_err(|e| format!("Failed to save config: {}", e))?;

    // Preload model when user enables dictation for the first time
    if enabled {
        let preload_config = Config::load();
        std::thread::spawn(move || {
            minutes_core::dictation::preload_model(&preload_config).ok();
        });
    }

    Ok(current_dictation_shortcut_settings(&state))
}

#[tauri::command]
pub fn cmd_permission_center() -> serde_json::Value {
    let config = Config::load();
    let items = vec![
        model_status(&config),
        audio_input_status(),
        call_capture_status(),
        calendar_status(&config),
        watcher_status(&config),
        output_dir_status(&config),
        vault_status(&config),
    ];
    serde_json::to_value(items).unwrap_or(serde_json::json!([]))
}

#[tauri::command]
pub fn cmd_macos_permission_rows() -> serde_json::Value {
    serde_json::to_value(minutes_core::macos_permissions::permission_rows())
        .unwrap_or(serde_json::json!([]))
}

#[tauri::command]
pub fn cmd_permission_restart_safety(state: tauri::State<AppState>) -> PermissionRestartSafety {
    permission_restart_safety_from_snapshot(permission_restart_snapshot(&state))
}

#[tauri::command]
pub fn cmd_restart_for_permission(
    app: tauri::AppHandle,
    confirm_assistant: Option<bool>,
) -> Result<(), String> {
    let state = app.state::<AppState>();
    let safety = permission_restart_safety_from_snapshot(permission_restart_snapshot(&state));
    if !safety.blockers.is_empty() {
        return Err(safety.detail);
    }
    if safety.requires_confirmation && confirm_assistant != Some(true) {
        return Err(
            "Restart requires confirmation because an assistant session will close.".into(),
        );
    }
    app.restart();
    #[allow(unreachable_code)]
    Ok(())
}

#[tauri::command]
pub fn cmd_desktop_capabilities() -> DesktopCapabilities {
    DesktopCapabilities {
        platform: current_platform().into(),
        folder_reveal_label: folder_reveal_label().into(),
        supports_calendar_integration: supports_calendar_integration(),
        supports_call_detection: supports_call_detection(),
        supports_tray_artifact_copy: supports_tray_artifact_copy(),
        supports_dictation_hotkey: supports_dictation_hotkey(),
    }
}

#[tauri::command]
pub fn cmd_recovery_items() -> serde_json::Value {
    let config = Config::load();
    serde_json::to_value(scan_recovery_items(&config)).unwrap_or(serde_json::json!([]))
}

fn recovery_retry_mode(retry_type: &str) -> Result<CaptureMode, String> {
    match retry_type {
        "meeting" => Ok(CaptureMode::Meeting),
        "memo" => Ok(CaptureMode::QuickThought),
        other => Err(format!("Unsupported recovery type: {}", other)),
    }
}

#[tauri::command]
pub fn cmd_retry_all_recovery(
    app: tauri::AppHandle,
    state: tauri::State<AppState>,
) -> Result<RecoveryRetryAllResult, String> {
    if recording_active(&state.recording) || state.starting.load(Ordering::Relaxed) {
        return Err("Finish the current recording before retrying recovery items.".into());
    }

    let config = Config::load();
    let items = scan_recovery_items(&config);
    let mut queued = 0;
    let mut first_job = None;
    let mut failed = Vec::new();

    for item in items {
        let audio_path = PathBuf::from(&item.path);
        if !audio_path.exists() {
            failed.push(RecoveryRetryFailure {
                path: item.path,
                error: "Recovery item no longer exists.".into(),
            });
            continue;
        }

        let mode = match recovery_retry_mode(&item.retry_type) {
            Ok(mode) => mode,
            Err(error) => {
                failed.push(RecoveryRetryFailure {
                    path: item.path,
                    error,
                });
                continue;
            }
        };

        // Queue the recovery file IN PLACE. The worker processes it where it
        // lives; on success `preserve_audio_alongside_output` moves it out of
        // the recovery folder, and on failure the worker leaves it there so it
        // stays recoverable. Moving the file into the jobs dir first could
        // strand it (invisible to recovery scanning) if the app died mid-queue
        // or the job later failed, since a failed job never returns the file.
        // `scan_recovery_items` hides items that already have an active job, so
        // queued items still leave the list without the risky move.
        match minutes_core::jobs::enqueue_capture_job(
            mode, None, audio_path, None, None, None, None, None, None, None,
        ) {
            Ok(job) => {
                if first_job.is_none() {
                    first_job = Some(job.clone());
                }
                queued += 1;
            }
            Err(error) => {
                failed.push(RecoveryRetryFailure {
                    path: item.path,
                    error: error.to_string(),
                });
            }
        }
    }

    if let Some(job) = first_job {
        minutes_core::pid::set_processing_status(
            job.stage.as_deref(),
            Some(job.mode),
            job.title.as_deref(),
            Some(&job.id),
            minutes_core::jobs::active_job_count(),
        )
        .ok();
        sync_processing_indicator(&state.processing, &state.processing_stage);
        spawn_processing_worker(
            app,
            state.processing.clone(),
            state.processing_stage.clone(),
            state.latest_output.clone(),
            state.activation_progress.clone(),
            state.completion_notifications_enabled.clone(),
        );
    }

    Ok(RecoveryRetryAllResult { queued, failed })
}

#[tauri::command]
pub fn cmd_retry_recovery(
    state: tauri::State<AppState>,
    path: String,
    content_type: String,
) -> Result<(), String> {
    if recording_active(&state.recording) || state.processing.load(Ordering::Relaxed) {
        return Err("Finish the current recording before retrying recovery items.".into());
    }

    let audio_path = PathBuf::from(&path);
    if !audio_path.exists() {
        return Err(format!("Recovery item not found: {}", path));
    }

    // Don't run the pipeline in place on a file that "Retry all" already
    // queued: a stale single-retry click on the same item would otherwise
    // double-process it (or race the queued job's in-place source).
    if minutes_core::jobs::active_jobs()
        .iter()
        .any(|job| Path::new(&job.audio_path) == audio_path.as_path())
    {
        return Err("This recovery item is already queued for processing.".into());
    }

    let ct = match recovery_retry_mode(content_type.as_str())? {
        CaptureMode::Meeting => ContentType::Meeting,
        CaptureMode::QuickThought => ContentType::Memo,
        other => {
            return Err(format!(
                "Unsupported recovery mode for direct retry: {:?}",
                other
            ))
        }
    };

    // Run pipeline on a background thread so the UI stays responsive
    let processing = state.processing.clone();
    let processing_stage = state.processing_stage.clone();
    let latest_output = state.latest_output.clone();

    processing.store(true, Ordering::Relaxed);
    set_processing_stage(&processing_stage, Some("Preparing transcript..."));

    std::thread::spawn(move || {
        let config = Config::load();
        match minutes_core::pipeline::process_with_progress(
            &audio_path,
            ct,
            None,
            &config,
            |stage| {
                let label = match stage {
                    minutes_core::pipeline::PipelineStage::Transcribing => "Transcribing...",
                    minutes_core::pipeline::PipelineStage::Diarizing => "Identifying speakers...",
                    minutes_core::pipeline::PipelineStage::Summarizing => "Generating summary...",
                    minutes_core::pipeline::PipelineStage::Saving => "Saving...",
                };
                set_processing_stage(&processing_stage, Some(label));
                let _ = minutes_core::pid::set_processing_status(
                    Some(label),
                    Some(minutes_core::pid::CaptureMode::Meeting),
                    None,
                    None,
                    0,
                );
            },
        ) {
            Ok(result) => {
                let notice = OutputNotice {
                    kind: "saved".into(),
                    title: result.title.clone(),
                    path: result.path.display().to_string(),
                    detail: "Recovery item was processed successfully.".into(),
                    job_id: None,
                };
                set_latest_output(&latest_output, Some(notice));
                eprintln!("Recovery retry succeeded: {}", result.path.display());
            }
            Err(e) => {
                let notice = OutputNotice {
                    kind: "error".into(),
                    title: "Retry failed".into(),
                    path: audio_path.display().to_string(),
                    detail: format!("Recovery retry failed: {}", e),
                    job_id: None,
                };
                set_latest_output(&latest_output, Some(notice));
                eprintln!("Recovery retry failed: {}", e);
            }
        }
        processing.store(false, Ordering::Relaxed);
        set_processing_stage(&processing_stage, None);
        minutes_core::pid::clear_processing_status().ok();
    });

    Ok(())
}

#[tauri::command]
pub fn cmd_get_meeting_detail(path: String) -> Result<MeetingDetail, String> {
    let config = Config::load();
    let meeting_path = std::path::PathBuf::from(&path);
    minutes_core::notes::validate_meeting_path(&meeting_path, &config.output_dir)?;

    let content = std::fs::read_to_string(&meeting_path).map_err(|e| e.to_string())?;
    let (frontmatter_str, body) = minutes_core::markdown::split_frontmatter(&content);
    let mut frontmatter: minutes_core::markdown::Frontmatter =
        serde_yaml::from_str(frontmatter_str.trim()).map_err(|e| e.to_string())?;

    // Layer sidecar overlays over raw frontmatter so desktop reflects
    // confirmations written by the CLI, MCP tools, or other Minutes surfaces.
    match minutes_core::overlays::load_speaker_confirmations_for_meeting_at(
        &minutes_core::overlays::default_db_path(),
        &meeting_path,
    ) {
        Ok(confirmations) if !confirmations.is_empty() => {
            minutes_core::overlays::apply_speaker_confirmations(
                &mut frontmatter.speaker_map,
                &confirmations,
            );
        }
        Ok(_) => {}
        Err(e) => eprintln!(
            "[meeting_detail] overlay load failed (showing raw frontmatter): {}",
            e
        ),
    }

    let content_type = match frontmatter.r#type {
        ContentType::Meeting => "meeting",
        ContentType::Memo => "memo",
        ContentType::Dictation => "dictation",
    }
    .to_string();

    let status = frontmatter.status.map(|status| {
        match status {
            minutes_core::markdown::OutputStatus::Complete => "complete",
            minutes_core::markdown::OutputStatus::NoSpeech => "no-speech",
            minutes_core::markdown::OutputStatus::TranscriptOnly => "transcript-only",
            minutes_core::markdown::OutputStatus::Degraded => "degraded",
        }
        .to_string()
    });

    let speaker_map: Vec<SpeakerAttributionView> = frontmatter
        .speaker_map
        .iter()
        .map(|a| SpeakerAttributionView {
            speaker_label: a.speaker_label.clone(),
            name: a.name.clone(),
            confidence: format!("{:?}", a.confidence).to_lowercase(),
            source: format!("{:?}", a.source).to_lowercase(),
        })
        .collect();

    let action_items: Vec<ActionItemView> = frontmatter
        .action_items
        .iter()
        .map(|a| ActionItemView {
            assignee: a.assignee.clone(),
            task: a.task.clone(),
            due: a.due.clone(),
            status: a.status.clone(),
        })
        .collect();

    let decisions: Vec<DecisionView> = frontmatter
        .decisions
        .iter()
        .map(|d| DecisionView {
            text: d.text.clone(),
            topic: d.topic.clone(),
        })
        .collect();

    let related = build_related_context(&config, &meeting_path, &frontmatter);

    let capture_is_none = matches!(
        frontmatter.capture,
        Some(minutes_core::markdown::CapturePolicy::None)
    );
    Ok(MeetingDetail {
        path,
        title: frontmatter.title,
        date: frontmatter.date.to_rfc3339(),
        duration: frontmatter.duration,
        content_type,
        status,
        context: frontmatter.context,
        attendees: frontmatter.attendees,
        calendar_event: frontmatter.calendar_event,
        action_items,
        decisions,
        related_people: related.related_people,
        related_topics: related.related_topics,
        related_meetings: related.related_meetings,
        related_commitments: related.related_commitments,
        // Adjacent prep/brief artifacts are navigation noise on a no-capture
        // sensitive meeting; the card is about its own markers and debrief.
        adjacent_artifacts: if capture_is_none {
            Vec::new()
        } else {
            related.adjacent_artifacts
        },
        sections: parse_sections(body),
        speaker_map,
        capture: frontmatter.capture.map(|c| {
            match c {
                minutes_core::markdown::CapturePolicy::None => "none",
            }
            .to_string()
        }),
        sensitivity: frontmatter.sensitivity.map(|s| {
            match s {
                minutes_core::markdown::Sensitivity::Normal => "normal",
                minutes_core::markdown::Sensitivity::Restricted => "restricted",
            }
            .to_string()
        }),
        debrief: frontmatter.debrief.map(|d| {
            match d {
                minutes_core::markdown::DebriefStatus::Pending => "pending",
                minutes_core::markdown::DebriefStatus::Complete => "complete",
                minutes_core::markdown::DebriefStatus::NotApplicable => "not-applicable",
            }
            .to_string()
        }),
    })
}

#[tauri::command]
pub fn cmd_write_text_file(path: String, content: String) -> Result<String, String> {
    let canonical = validate_text_file_path(Path::new(&path))?;
    let config = Config::load();
    if !is_editable_text_file_path(&canonical, &config) {
        return Err(format!(
            "{} is view-only in Minutes. Make an editable copy first.",
            canonical.display()
        ));
    }
    let normalized = if text_file_kind(&canonical) == Some("json") {
        let parsed: serde_json::Value =
            serde_json::from_str(&content).map_err(|e| format!("Invalid JSON: {}", e))?;
        serde_json::to_string_pretty(&parsed)
            .map_err(|e| format!("Could not format JSON: {}", e))?
    } else {
        content
    };
    write_text_file_atomic(&canonical, &normalized)?;
    Ok(format!("Saved {}", canonical.display()))
}

#[tauri::command]
pub fn cmd_restore_text_file_snapshot(path: String) -> Result<String, String> {
    let canonical = validate_text_file_path(Path::new(&path))?;
    let config = Config::load();
    if !is_editable_text_file_path(&canonical, &config) {
        return Err(format!(
            "{} is view-only in Minutes. Make an editable copy first.",
            canonical.display()
        ));
    }
    let Some(snapshot) = latest_snapshot_for_path(&canonical)? else {
        return Err(format!(
            "No snapshot available yet for {}",
            canonical.display()
        ));
    };
    let content = std::fs::read_to_string(&snapshot)
        .map_err(|e| format!("Cannot read snapshot {}: {}", snapshot.display(), e))?;
    let file_name = canonical
        .file_name()
        .and_then(|name| name.to_str())
        .ok_or_else(|| format!("Invalid file name: {}", canonical.display()))?;
    let temp_path = canonical.with_file_name(format!(".{}.restore.tmp", file_name));
    std::fs::write(&temp_path, content).map_err(|e| {
        format!(
            "Failed to write restore temp file {}: {}",
            temp_path.display(),
            e
        )
    })?;
    std::fs::rename(&temp_path, &canonical).map_err(|e| {
        format!(
            "Failed to restore {} from snapshot {}: {}",
            canonical.display(),
            snapshot.display(),
            e
        )
    })?;
    Ok(format!(
        "Restored {} from {}",
        canonical.display(),
        snapshot.display()
    ))
}

#[tauri::command]
pub fn cmd_promote_text_file_to_artifact(path: String) -> Result<ArtifactDraft, String> {
    let canonical = validate_text_file_path(Path::new(&path))?;
    let config = Config::load();
    let artifacts_dir = artifact_directory(&config)?;
    let source_content = std::fs::read_to_string(&canonical)
        .map_err(|e| format!("Cannot read {}: {}", canonical.display(), e))?;
    let title = canonical
        .file_stem()
        .and_then(|name| name.to_str())
        .unwrap_or("Working Copy")
        .replace('-', " ");
    let stem = format!(
        "{}-working-copy-{}",
        chrono::Local::now().format("%Y-%m-%d"),
        artifact_slug(&title)
    );
    let extension = canonical
        .extension()
        .and_then(|ext| ext.to_str())
        .unwrap_or("md");
    let artifact_path = resolve_unique_path(&artifacts_dir, &stem, extension);
    write_text_file_atomic(&artifact_path, &source_content)?;
    Ok(ArtifactDraft {
        path: artifact_path.display().to_string(),
        title,
        template_kind: "working-copy".into(),
        content: source_content,
    })
}

#[tauri::command]
pub fn cmd_create_artifact_from_meeting(
    meeting_path: String,
    kind: String,
) -> Result<ArtifactDraft, String> {
    let config = Config::load();
    let meeting_path = PathBuf::from(&meeting_path);
    minutes_core::notes::validate_meeting_path(&meeting_path, &config.output_dir)?;

    let content = std::fs::read_to_string(&meeting_path)
        .map_err(|e| format!("Cannot read meeting: {}", e))?;
    let (frontmatter_str, body) = minutes_core::markdown::split_frontmatter(&content);
    let frontmatter: minutes_core::markdown::Frontmatter =
        serde_yaml::from_str(frontmatter_str.trim())
            .map_err(|e| format!("Bad frontmatter: {}", e))?;
    let sections = parse_sections(body);

    let artifacts_dir = artifact_directory(&config)?;
    let template_kind = kind.trim().to_ascii_lowercase();
    let (title, artifact_content) =
        build_artifact_template(&frontmatter, &sections, &meeting_path, &template_kind)?;
    let stem = format!(
        "{}-{}-{}",
        chrono::Local::now().format("%Y-%m-%d"),
        template_kind,
        artifact_slug(&frontmatter.title)
    );
    let artifact_path = resolve_unique_path(&artifacts_dir, &stem, "md");
    write_text_file_atomic(&artifact_path, &artifact_content)?;

    Ok(ArtifactDraft {
        path: artifact_path.display().to_string(),
        title,
        template_kind,
        content: artifact_content,
    })
}

#[tauri::command]
pub async fn cmd_list_voices() -> Result<serde_json::Value, String> {
    let conn = minutes_core::voice::open_db().map_err(|e| e.to_string())?;
    let profiles = minutes_core::voice::list_profiles(&conn).map_err(|e| e.to_string())?;
    serde_json::to_value(&profiles).map_err(|e| e.to_string())
}

#[tauri::command]
pub async fn cmd_confirm_speaker(
    meeting_path: String,
    speaker_label: String,
    name: String,
) -> Result<String, String> {
    let path = std::path::PathBuf::from(&meeting_path);
    if !path.exists() {
        return Err(format!("Meeting not found: {}", meeting_path));
    }

    let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
    let (fm_str, _body) = minutes_core::markdown::split_frontmatter(&content);
    if fm_str.is_empty() {
        return Err("Meeting has no frontmatter".into());
    }

    let frontmatter: minutes_core::markdown::Frontmatter =
        serde_yaml::from_str(fm_str).map_err(|e| e.to_string())?;

    let existing = frontmatter
        .speaker_map
        .iter()
        .find(|a| a.speaker_label == speaker_label);

    let previous_name = match existing {
        Some(attr) => attr.name.clone(),
        None => {
            return Err(format!(
                "Speaker '{}' not found in speaker_map",
                speaker_label
            ));
        }
    };

    minutes_core::overlays::write_speaker_confirmation(
        &path,
        &speaker_label,
        &name,
        Some(&previous_name),
        Some("desktop confirm"),
    )
    .map_err(|e| format!("Could not write speaker overlay: {}", e))?;

    // Refresh graph projection so other surfaces reflect the correction
    // immediately. Run on a blocking thread so we don't stall the async
    // Tauri runtime — graph rebuild walks every meeting file and can take
    // seconds on a large corpus. Failure is non-fatal because the overlay
    // already persisted and future rebuilds will pick it up.
    tauri::async_runtime::spawn_blocking(|| {
        let config = Config::load();
        if let Err(e) = minutes_core::graph::rebuild_index(&config) {
            eprintln!(
                "[confirm_speaker] overlay saved, but graph rebuild failed: {}",
                e
            );
        }
    });

    Ok(format!("Confirmed: {} = {}", speaker_label, name))
}

#[tauri::command]
pub fn cmd_remember_vocabulary_person(name: String) -> Result<VocabularyRememberView, String> {
    let canonical = name.trim();
    if !looks_like_rememberable_person_name(canonical) {
        return Err("Choose a specific person name before saving vocabulary.".into());
    }

    let path = minutes_core::vocabulary::default_path();
    let mut store = minutes_core::vocabulary::load().map_err(|e| e.to_string())?;
    let canonical_key = vocabulary_person_key(canonical);

    if let Some(existing) = store.entries.iter().find(|entry| {
        entry.kind == minutes_core::vocabulary::VocabularyKind::Person
            && entry
                .surface_forms()
                .iter()
                .any(|form| vocabulary_person_key(form) == canonical_key)
    }) {
        return Ok(VocabularyRememberView {
            entry_id: existing.id.clone(),
            canonical: existing.canonical.clone(),
            already_exists: true,
            note: format!(
                "{} is already in vocabulary. Future transcripts, search, and graph rebuilds can use it; existing raw transcripts stay unchanged.",
                existing.canonical
            ),
        });
    }

    let mut entry = minutes_core::vocabulary::VocabularyEntry::new(
        minutes_core::vocabulary::VocabularyKind::Person,
        canonical,
    );
    entry.priority = minutes_core::vocabulary::VocabularyPriority::High;
    entry.source = minutes_core::vocabulary::VocabularySource::SpeakerConfirmation;
    store.entries.push(entry);
    let normalized = store.normalized().map_err(|e| e.to_string())?;
    let saved_entry = normalized
        .entries
        .iter()
        .find(|entry| {
            entry.kind == minutes_core::vocabulary::VocabularyKind::Person
                && vocabulary_person_key(&entry.canonical) == canonical_key
        })
        .cloned()
        .ok_or_else(|| {
            "Saved vocabulary entry could not be found after normalization.".to_string()
        })?;

    minutes_core::vocabulary::save_at(&path, &normalized).map_err(|e| e.to_string())?;

    #[cfg(not(test))]
    {
        tauri::async_runtime::spawn_blocking(|| {
            let config = Config::load();
            if let Err(e) = minutes_core::graph::rebuild_index(&config) {
                eprintln!(
                    "[remember_vocabulary_person] vocabulary saved, but graph rebuild failed: {}",
                    e
                );
            }
        });
    }

    Ok(VocabularyRememberView {
        entry_id: saved_entry.id,
        canonical: saved_entry.canonical.clone(),
        already_exists: false,
        note: format!(
            "Saved {} to vocabulary. Future transcripts, search, and graph rebuilds can use it; existing raw transcripts stay unchanged.",
            saved_entry.canonical
        ),
    })
}

fn looks_like_rememberable_person_name(name: &str) -> bool {
    let key = vocabulary_person_key(name);
    if key.is_empty() {
        return false;
    }
    if key == "unknown" || key == "unknown speaker" {
        return false;
    }
    if key.starts_with("speaker ") || key.starts_with("speaker_") || key.starts_with("speaker-") {
        return false;
    }
    true
}

fn vocabulary_person_key(value: &str) -> String {
    value
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
        .trim()
        .to_ascii_lowercase()
}

#[tauri::command]
pub async fn cmd_upcoming_meetings() -> serde_json::Value {
    tauri::async_runtime::spawn_blocking(|| {
        let events = minutes_core::calendar::upcoming_events(120); // 2 hour lookahead
        serde_json::to_value(&events).unwrap_or(serde_json::json!([]))
    })
    .await
    .unwrap_or(serde_json::json!([]))
}

#[tauri::command]
pub fn cmd_needs_setup(state: tauri::State<AppState>) -> serde_json::Value {
    let config = Config::load();
    let batch_readiness = batch_transcription_readiness_view(&config);
    let live_readiness = standalone_live_readiness_view(&config);
    mark_model_ready_for_surface(&config, &batch_readiness, &state.activation_progress);
    mark_model_ready_for_surface(&config, &live_readiness, &state.activation_progress);

    let meetings_dir = config.output_dir.clone();
    let has_meetings_dir = meetings_dir.exists();
    let activation_progress = state
        .activation_progress
        .lock()
        .ok()
        .map(|progress| progress.clone())
        .unwrap_or_default();
    let has_saved_artifact = activation_progress.first_artifact_saved_at.is_some();
    let batch_setup = transcription_surface_setup_view(
        &config,
        "batch",
        &batch_readiness,
        &activation_progress,
        has_saved_artifact,
        false,
        false,
    );
    let standalone_live_setup = transcription_surface_setup_view(
        &config,
        "standalone-live",
        &live_readiness,
        &activation_progress,
        has_saved_artifact,
        false,
        false,
    );
    let primary_setup = primary_setup_surface(&batch_setup, &standalone_live_setup);

    serde_json::json!({
        "needsSetup": batch_setup.needs_setup || standalone_live_setup.needs_setup,
        "hasModel": primary_setup.has_model,
        "engine": primary_setup.engine.clone(),
        "modelName": primary_setup.model_name.clone(),
        "parakeet": primary_setup.parakeet.clone(),
        "batch_transcription": batch_readiness,
        "standalone_live": live_readiness,
        "transcriptionSetup": {
            "batch": batch_setup,
            "standaloneLive": standalone_live_setup,
        },
        "hasMeetingsDir": has_meetings_dir,
        "activation": primary_setup.activation.clone(),
    })
}

#[tauri::command]
pub async fn cmd_download_model(
    state: tauri::State<'_, AppState>,
    model: String,
) -> Result<String, String> {
    // Run in a blocking thread so the UI stays responsive during download
    let activation_progress = state.activation_progress.clone();
    tauri::async_runtime::spawn_blocking(move || {
        validate_download_model_name(&model)?;

        let config = Config::load();
        let model_dir = &config.transcription.model_path;
        let model_file = model_dir.join(format!("ggml-{}.bin", model));

        if model_file.exists() {
            mark_activation_model_ready(&activation_progress, &model_file);
            return Ok(format!("Model '{}' already downloaded", model));
        }

        std::fs::create_dir_all(model_dir).map_err(|e| e.to_string())?;

        let url = format!(
            "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-{}.bin",
            model
        );

        eprintln!("[minutes] Downloading model: {} from {}", model, url);

        let status = std::process::Command::new("curl")
            .args([
                "-L",
                "-o",
                &model_file.to_string_lossy(),
                &url,
                "--progress-bar",
            ])
            .status()
            .map_err(|e| format!("curl failed: {}", e))?;

        if !status.success() {
            return Err("Download failed".into());
        }

        let size = std::fs::metadata(&model_file)
            .map(|m| m.len() / (1024 * 1024))
            .unwrap_or(0);
        mark_activation_model_ready(&activation_progress, &model_file);

        Ok(format!("Downloaded '{}' model ({} MB)", model, size))
    })
    .await
    .map_err(|e| format!("Download task failed: {}", e))?
}

#[tauri::command]
pub fn cmd_mark_activation_nudge_shown(state: tauri::State<AppState>, kind: Option<String>) {
    mark_activation_next_step_nudge_shown(&state.activation_progress, kind.as_deref());
}

// ── Terminal / AI Assistant commands ──────────────────────────

fn meeting_title_from_path(path: &str) -> String {
    Path::new(path)
        .file_stem()
        .and_then(|stem| stem.to_str())
        .map(|stem| stem.replace('-', " "))
        .unwrap_or_else(|| "Meeting Discussion".into())
}

fn terminal_title_for_mode(mode: &str, meeting_path: Option<&str>) -> Result<String, String> {
    match mode {
        "assistant" => Ok("Minutes Assistant".into()),
        "meeting" => Ok(format!(
            "Discussing: {}",
            meeting_title_from_path(meeting_path.ok_or("meeting_path required for meeting mode")?)
        )),
        other => Err(format!(
            "Unknown mode: {}. Use 'meeting' or 'assistant'.",
            other
        )),
    }
}

fn sync_workspace_for_mode(
    workspace: &Path,
    config: &Config,
    mode: &str,
    meeting_path: Option<&str>,
) -> Result<(), String> {
    // write_assistant_context preserves live transcript markers if present (U2/T3)
    crate::context::write_assistant_context(workspace, config)?;

    match mode {
        "assistant" => crate::context::clear_active_meeting_context(workspace),
        "meeting" => {
            let path = meeting_path.ok_or("meeting_path required for meeting mode")?;
            let meeting = PathBuf::from(path);
            minutes_core::notes::validate_meeting_path(&meeting, &config.output_dir)?;
            crate::context::write_active_meeting_context(workspace, &meeting, config)
        }
        other => Err(format!(
            "Unknown mode: {}. Use 'meeting' or 'assistant'.",
            other
        )),
    }
}

fn is_shell_command(command: &str) -> bool {
    matches!(
        Path::new(command)
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or(command),
        "bash" | "zsh" | "sh" | "fish"
    )
}

fn is_approval_bypass_flag(arg: &str) -> bool {
    matches!(
        arg,
        "--dangerously-skip-permissions" | "--dangerously-bypass-approvals-and-sandbox"
    )
}

fn filtered_agent_args(agent_name: &str, args: &[String]) -> Vec<String> {
    let allowed_bypass_flag = match agent_name {
        "claude" => Some("--dangerously-skip-permissions"),
        "codex" => Some("--dangerously-bypass-approvals-and-sandbox"),
        _ => None,
    };

    args.iter()
        .filter(|arg| {
            !is_approval_bypass_flag(arg)
                || allowed_bypass_flag.is_some_and(|allowed| arg.as_str() == allowed)
        })
        .cloned()
        .collect()
}

fn context_switch_prompt(command: &str, mode: &str, title: &str) -> String {
    let plain_text = match mode {
        "meeting" => format!(
            "Minutes changed focus to {title}. Read CURRENT_MEETING.md and your assistant instructions (CLAUDE.md or AGENTS.md), then help with that meeting."
        ),
        _ => "Minutes cleared the active meeting focus. Resume general assistant mode and reread your assistant instructions (CLAUDE.md or AGENTS.md) if needed."
            .into(),
    };

    if is_shell_command(command) {
        format!("cat <<'__MINUTES__'\n{plain_text}\n__MINUTES__\n")
    } else {
        format!("{plain_text}\n")
    }
}

#[derive(Debug, Clone)]
pub struct AgentResolveError {
    pub name: String,
    pub unusable_candidates: Vec<PathBuf>,
}

impl AgentResolveError {
    fn user_message(&self) -> String {
        let install_hint = if self.name == "claude" {
            " Reinstall Claude Code, or choose another assistant in Settings."
        } else {
            " Reinstall that assistant CLI, or choose another assistant in Settings."
        };

        if let Some(path) = self.unusable_candidates.first() {
            format!(
                "Recall needs '{}', but the installed copy cannot be run.{} Minutes found the broken copy at {}.",
                self.name,
                install_hint,
                path.display()
            )
        } else {
            let install_hint = if self.name == "claude" {
                " Install Claude Code, or choose another assistant in Settings."
            } else {
                " Install it, or choose another assistant in Settings."
            };
            format!(
                "'{}' was not found on PATH or in common install locations.{} Settings file: {}",
                self.name,
                install_hint,
                user_config_path_for_display(),
            )
        }
    }
}

fn is_usable_agent_binary(path: &Path) -> bool {
    let Ok(metadata) = std::fs::metadata(path) else {
        return false;
    };
    if !metadata.is_file() {
        return false;
    }

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        metadata.permissions().mode() & 0o111 != 0
    }

    #[cfg(windows)]
    {
        true
    }

    #[cfg(not(any(unix, windows)))]
    {
        true
    }
}

fn remember_unusable_candidate(candidates: &mut Vec<PathBuf>, path: PathBuf) {
    if !candidates.iter().any(|existing| existing == &path) {
        candidates.push(path);
    }
}

/// Resolve an agent name or path to an executable.
///
/// Accepts either:
/// - A bare command name ("claude", "codex", "bash") — looked up via PATH
///   (with PATHEXT on Windows, so `claude.cmd` resolves from `claude`), then
///   searched in well-known install dirs as a fallback
/// - An absolute path ("/usr/local/bin/my-agent") — used directly if it exists
///
/// This is intentionally open: users can set `assistant.agent` to any binary
/// they want, including wrapper scripts or custom agent CLIs.
pub fn resolve_agent_binary(name: &str) -> Result<PathBuf, AgentResolveError> {
    let mut unusable_candidates = Vec::new();

    // If it's an absolute path, check it directly
    let as_path = PathBuf::from(name);
    if as_path.is_absolute() && as_path.exists() {
        if is_usable_agent_binary(&as_path) {
            return Ok(as_path);
        }
        remember_unusable_candidate(&mut unusable_candidates, as_path);
    }

    // PATH lookup (cross-platform). On Windows this respects PATHEXT and
    // resolves `claude` → `claude.cmd` / `claude.exe` correctly. GUI apps
    // launched from Finder/Explorer often have a minimal PATH, so the
    // fallback below catches common install dirs that aren't on PATH.
    if let Ok(path) = which::which(name) {
        if is_usable_agent_binary(&path) {
            return Ok(path);
        }
        remember_unusable_candidate(&mut unusable_candidates, path);
    }

    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
    let mut search_dirs: Vec<PathBuf> = vec![
        home.join(".cargo/bin"),
        home.join(".local/bin"),
        home.join(".opencode/bin"),
        home.join(".npm-global/bin"),
        PathBuf::from("/opt/homebrew/bin"),
        PathBuf::from("/usr/local/bin"),
        PathBuf::from("/usr/bin"),
        PathBuf::from("/bin"),
    ];
    if cfg!(windows) {
        // npm-global on Windows lands in %APPDATA%\npm by default, which
        // isn't always on PATH for GUI processes. LOCALAPPDATA covers a few
        // installer conventions (e.g., scoop, native installers).
        if let Some(appdata) = dirs::data_dir() {
            search_dirs.push(appdata.join("npm"));
        }
        if let Some(local) = dirs::data_local_dir() {
            search_dirs.push(local.join("npm"));
            search_dirs.push(local.join("Programs"));
        }
    }

    // On Windows, npm installs a bare extensionless shebang script alongside
    // the .cmd wrapper. Try .cmd/.exe first so we never hand a shebang script
    // to CreateProcessW (os error 193 "not a valid Win32 application").
    let exts: &[&str] = if cfg!(windows) {
        &["cmd", "exe", "bat", ""]
    } else {
        &[""]
    };
    for dir in &search_dirs {
        for ext in exts {
            let mut candidate = dir.join(name);
            if !ext.is_empty() {
                candidate.set_extension(ext);
            }
            if candidate.exists() {
                if is_usable_agent_binary(&candidate) {
                    return Ok(candidate);
                }
                remember_unusable_candidate(&mut unusable_candidates, candidate);
            }
        }
    }
    Err(AgentResolveError {
        name: name.into(),
        unusable_candidates,
    })
}

pub fn find_agent_binary(name: &str) -> Option<PathBuf> {
    resolve_agent_binary(name).ok()
}

/// Platform-correct path to the user's config file, used in error messages.
fn user_config_path_for_display() -> String {
    Config::config_path().display().to_string()
}

/// Shared spawn logic used by both cmd_spawn_terminal and the tray menu handler.
/// Returns (session_id, window_title) on success.
pub fn spawn_terminal(
    app: &tauri::AppHandle,
    pty_manager: &std::sync::Arc<Mutex<crate::pty::PtyManager>>,
    mode: &str,
    meeting_path: Option<&str>,
    agent_override: Option<&str>,
) -> Result<(String, String), String> {
    let config = Config::load();
    let title = terminal_title_for_mode(mode, meeting_path)?;
    let workspace = crate::context::create_workspace(&config)?;
    sync_workspace_for_mode(&workspace, &config, mode, meeting_path)?;

    let mut manager = pty_manager.lock().map_err(|_| "PTY manager lock failed")?;

    if manager.assistant_session_id().is_some() {
        manager.set_session_title(crate::pty::ASSISTANT_SESSION_ID, title.clone())?;
        // Only send a context switch prompt when actively switching to a
        // meeting (not when merely re-opening the panel in assistant mode,
        // which would inject unwanted text into Claude Code's input).
        if mode == "meeting" {
            if let Some(command) = manager.session_command(crate::pty::ASSISTANT_SESSION_ID) {
                let prompt = context_switch_prompt(&command, mode, &title);
                manager.write_input(crate::pty::ASSISTANT_SESSION_ID, prompt.as_bytes())?;
            }
        }
    } else {
        let agent_name = agent_override.unwrap_or(&config.assistant.agent);
        let agent_bin = resolve_agent_binary(agent_name).map_err(|err| err.user_message())?;

        let agent_args = filtered_agent_args(agent_name, &config.assistant.agent_args);

        manager.spawn(
            crate::pty::SpawnConfig {
                session_id: crate::pty::ASSISTANT_SESSION_ID.into(),
                app_handle: app.clone(),
                command: agent_bin.to_str().unwrap_or(agent_name).to_string(),
                args: agent_args,
                cwd: workspace.clone(),
                context_dir: workspace.clone(),
                title: title.clone(),
                target_window: "main".into(),
            },
            120,
            30,
        )?;
    }

    drop(manager);

    // Emit recall:expand event to the main window instead of opening a
    // separate terminal window. The JS in index.html handles the panel
    // expand animation and xterm.js initialisation.
    if app.get_webview_window("main").is_some() {
        crate::show_main_window(app);
        app.emit_to(
            "main",
            "recall:expand",
            serde_json::json!({ "title": title, "mode": mode }),
        )
        .ok();
    }

    Ok((crate::pty::ASSISTANT_SESSION_ID.into(), title))
}

#[tauri::command]
pub fn cmd_spawn_terminal(
    app: tauri::AppHandle,
    state: tauri::State<AppState>,
    mode: String,
    meeting_path: Option<String>,
    agent: Option<String>,
) -> Result<String, String> {
    let (session_id, _) = spawn_terminal(
        &app,
        &state.pty_manager,
        &mode,
        meeting_path.as_deref(),
        agent.as_deref(),
    )?;
    Ok(session_id)
}

#[tauri::command]
pub fn cmd_pty_input(
    state: tauri::State<AppState>,
    session_id: String,
    data: String,
) -> Result<(), String> {
    let mut manager = state.pty_manager.lock().map_err(|_| "Lock failed")?;
    manager.write_input(&session_id, data.as_bytes())
}

#[tauri::command]
pub fn cmd_pty_resize(
    state: tauri::State<AppState>,
    session_id: String,
    cols: u16,
    rows: u16,
) -> Result<(), String> {
    let manager = state.pty_manager.lock().map_err(|_| "Lock failed")?;
    manager.resize(&session_id, cols, rows)
}

#[tauri::command]
pub fn cmd_pty_kill(state: tauri::State<AppState>, session_id: String) -> Result<(), String> {
    let session = {
        let mut manager = state.pty_manager.lock().map_err(|_| "Lock failed")?;
        manager.take_session(&session_id)
    };
    if let Some(session) = session {
        crate::pty::kill_session(session);
    }
    Ok(())
}

/// Well-known agent CLIs to check for in cmd_list_agents.
const WELL_KNOWN_AGENTS: &[&str] = &["claude", "codex", "gemini", "opencode", "pi", "bash", "zsh"];

#[tauri::command]
pub fn cmd_list_agents() -> serde_json::Value {
    let agents: Vec<serde_json::Value> = WELL_KNOWN_AGENTS
        .iter()
        .filter_map(|name| {
            find_agent_binary(name).map(|path| {
                serde_json::json!({
                    "name": name,
                    "path": path.display().to_string(),
                })
            })
        })
        .collect();
    serde_json::json!(agents)
}

#[tauri::command]
pub fn cmd_terminal_info(state: tauri::State<AppState>, session_id: String) -> TerminalInfo {
    let title = state
        .pty_manager
        .lock()
        .ok()
        .and_then(|manager| manager.session_title(&session_id))
        .unwrap_or_else(|| "Minutes Assistant".into());
    TerminalInfo { title }
}

// ── Settings commands ─────────────────────────────────────────

#[tauri::command]
pub fn cmd_get_settings() -> serde_json::Value {
    let config = Config::load();
    let path = Config::config_path();
    let openai_compatible_secret_status =
        crate::secret_store::hydrate_openai_compatible_api_key_env();
    let recording_status = minutes_core::pid::status();
    let live_status = minutes_core::live_transcript::session_status();
    let desktop_context_supported = minutes_core::desktop_context::capture_supported();
    #[cfg(target_os = "macos")]
    let accessibility_trusted = minutes_core::hotkey_macos::is_accessibility_trusted();
    #[cfg(not(target_os = "macos"))]
    let accessibility_trusted = false;
    let desktop_context_filtered = !config.desktop_context.denied_apps.is_empty()
        || !config.desktop_context.allowed_apps.is_empty();
    let desktop_context_limited = desktop_context_limited(&config, accessibility_trusted);
    let desktop_context_state = desktop_context_state(
        &config,
        desktop_context_supported,
        recording_status.recording || live_status.active,
    );

    // Check env vars for API key status
    let anthropic_key_set = std::env::var("ANTHROPIC_API_KEY").is_ok();
    let openai_key_set = std::env::var("OPENAI_API_KEY").is_ok();
    let openai_compatible_api_key_env = config
        .summarization
        .openai_compatible_api_key_env
        .trim()
        .to_string();
    let openai_compatible_endpoint_is_local =
        minutes_core::config::openai_compatible_base_url_is_local(
            &config.summarization.openai_compatible_base_url,
        );
    let openai_compatible_key_set = if openai_compatible_api_key_env.is_empty() {
        if openai_compatible_endpoint_is_local {
            false
        } else {
            openai_compatible_secret_status.key_set
        }
    } else if openai_compatible_api_key_env == crate::secret_store::OPENAI_COMPATIBLE_API_KEY_ENV {
        openai_compatible_secret_status.key_set
    } else {
        std::env::var(&openai_compatible_api_key_env).is_ok()
    };

    // Check Ollama reachability
    let ollama_reachable = ureq::Agent::new_with_config(
        ureq::config::Config::builder()
            .timeout_global(Some(std::time::Duration::from_secs(2)))
            .build(),
    )
    .get(&format!("{}/api/tags", config.summarization.ollama_url))
    .call()
    .is_ok();

    // Check which whisper model is downloaded
    let model_path = config.transcription.model_path.clone();
    let downloaded_models: Vec<String> = ["tiny", "base", "small", "medium", "large-v3"]
        .iter()
        .filter(|m| {
            let pattern = format!("ggml-{}", m);
            model_path
                .read_dir()
                .into_iter()
                .flatten()
                .flatten()
                .any(|e| {
                    e.file_name()
                        .to_str()
                        .map(|n| n.contains(&pattern))
                        .unwrap_or(false)
                })
        })
        .map(|s| s.to_string())
        .collect();

    serde_json::json!({
        "config_path": path.display().to_string(),
        "recording": {
            "device": config.recording.device,
        },
        "transcription": {
            "engine": config.transcription.engine,
            "model": config.transcription.model,
            "downloaded_models": downloaded_models,
            "language": config.transcription.language,
            "parakeet_model": config.transcription.parakeet_model,
            "parakeet_binary": config.transcription.parakeet_binary,
            "parakeet_sidecar_enabled": match config.transcription.parakeet_sidecar_enabled {
                None => "auto",
                Some(true) => "true",
                Some(false) => "false",
            },
            "parakeet_compiled": cfg!(feature = "parakeet"),
            "parakeet_status": parakeet_status_view(&config),
            "apple_speech_status": apple_speech_status_view(),
        },
        "diarization": {
            "engine": config.diarization.engine,
        },
        "summarization": {
            "engine": config.summarization.engine,
            "agent_command": config.summarization.agent_command,
            "ollama_model": config.summarization.ollama_model,
            "ollama_url": config.summarization.ollama_url,
            "openai_compatible_base_url": config.summarization.openai_compatible_base_url,
            "openai_compatible_model": config.summarization.openai_compatible_model,
            "openai_compatible_api_key_env": config.summarization.openai_compatible_api_key_env,
            "openai_compatible_endpoint_is_local": openai_compatible_endpoint_is_local,
            "openai_compatible_key_set": openai_compatible_key_set,
            "openai_compatible_desktop_api_key_env": crate::secret_store::OPENAI_COMPATIBLE_API_KEY_ENV,
            "openai_compatible_keychain_supported": openai_compatible_secret_status.supported,
            "openai_compatible_keychain_key_set": openai_compatible_secret_status.stored_key_set,
            "openai_compatible_key_storage_label": openai_compatible_secret_status.storage_label,
            "openai_compatible_key_status_message": openai_compatible_secret_status.message,
            "anthropic_key_set": anthropic_key_set,
            "openai_key_set": openai_key_set,
            "ollama_reachable": ollama_reachable,
        },
        "screen_context": {
            "enabled": config.screen_context.enabled,
            "interval_secs": config.screen_context.interval_secs,
            "keep_after_summary": config.screen_context.keep_after_summary,
        },
        "desktop_context": {
            "enabled": config.desktop_context.enabled,
            "capture_window_titles": config.desktop_context.capture_window_titles,
            "capture_browser_context": config.desktop_context.capture_browser_context,
            "allowed_apps": config.desktop_context.allowed_apps,
            "denied_apps": config.desktop_context.denied_apps,
            "supported": desktop_context_supported,
            "state": desktop_context_state,
            "filtered": desktop_context_filtered,
            "limited": desktop_context_limited,
        },
        "privacy": {
            "hide_from_screen_share": config.privacy.hide_from_screen_share,
        },
        "consent": {
            "mode": consent_mode_as_str(config.consent.mode),
            "disclosure_script": config.consent.disclosure_script,
            "default_basis": config.consent.default_basis,
        },
        "assistant": {
            "agent": config.assistant.agent,
            "agent_args": config.assistant.agent_args,
        },
        "hooks": {
            "post_record": config.hooks.post_record,
        },
        "call_detection": {
            "enabled": config.call_detection.enabled,
            "poll_interval_secs": config.call_detection.poll_interval_secs,
            "cooldown_minutes": config.call_detection.cooldown_minutes,
            "apps": config.call_detection.apps,
            "google_meet_enabled": call_detection_has_sentinel(&config, "google-meet"),
            "teams_web_enabled": call_detection_has_sentinel(&config, "teams-web"),
            "stop_when_call_ends": config.call_detection.stop_when_call_ends,
            "call_end_stop_countdown_secs": config.call_detection.call_end_stop_countdown_secs,
        },
        "dictation": {
            "backend": config.dictation.backend,
            "model": config.dictation.model,
            "destination": config.dictation.destination,
            "accumulate": config.dictation.accumulate,
            "daily_note_log": config.dictation.daily_note_log,
            "cleanup_engine": config.dictation.cleanup_engine,
            "auto_paste": config.dictation.auto_paste,
            "silence_timeout_ms": config.dictation.silence_timeout_ms,
            "max_utterance_secs": config.dictation.max_utterance_secs,
            "shortcut_enabled": config.dictation.shortcut_enabled,
            "shortcut": config.dictation.shortcut,
            "hotkey_enabled": config.dictation.hotkey_enabled,
            "hotkey_keycode": config.dictation.hotkey_keycode,
        },
        "live_transcript": {
            "backend": config.standalone_live_backend_setting(),
            "resolved_backend": config.effective_live_transcript_backend(),
            "fallback_order": live_transcript_fallback_order_view(&config),
            "model": config.live_transcript.model,
            "max_utterance_secs": config.live_transcript.max_utterance_secs,
            "save_wav": config.live_transcript.save_wav,
            "shortcut_enabled": config.live_transcript.shortcut_enabled,
            "shortcut": config.live_transcript.shortcut,
        },
        "palette": {
            "shortcut_enabled": config.palette.shortcut_enabled,
            "shortcut": config.palette.shortcut,
        },
        "global_hotkey": {
            "shortcut_enabled": config.global_hotkey.shortcut_enabled,
            "shortcut": config.global_hotkey.shortcut,
        },
        "notifications": {
            "completion_enabled": config.notifications.completion_enabled,
        },
        "identity": {
            "name": config.identity.name,
            "email": config.identity.email,
            "emails": config.identity.emails,
            "aliases": config.identity.aliases,
        },
    })
}

#[tauri::command]
pub fn cmd_openai_compatible_secret_status() -> serde_json::Value {
    serde_json::to_value(crate::secret_store::hydrate_openai_compatible_api_key_env())
        .unwrap_or_else(|_| serde_json::json!({ "keySet": false }))
}

#[tauri::command]
pub fn cmd_set_openai_compatible_api_key(api_key: String) -> Result<serde_json::Value, String> {
    let api_key = api_key.trim().to_string();
    if api_key.is_empty() {
        return Err("Paste an API key first.".into());
    }

    crate::secret_store::save_openai_compatible_api_key(&api_key)?;
    std::env::set_var(crate::secret_store::OPENAI_COMPATIBLE_API_KEY_ENV, &api_key);

    let mut config = Config::load();
    if config.summarization.openai_compatible_api_key_env.trim()
        == crate::secret_store::OPENAI_COMPATIBLE_API_KEY_ENV
    {
        config.summarization.openai_compatible_api_key_env.clear();
        config
            .save()
            .map_err(|e| format!("Failed to save config: {}", e))?;
    }

    Ok(
        serde_json::to_value(crate::secret_store::openai_compatible_secret_status())
            .unwrap_or_else(|_| serde_json::json!({ "keySet": true })),
    )
}

#[tauri::command]
pub fn cmd_clear_openai_compatible_api_key() -> Result<serde_json::Value, String> {
    crate::secret_store::clear_openai_compatible_api_key()?;
    std::env::remove_var(crate::secret_store::OPENAI_COMPATIBLE_API_KEY_ENV);

    let mut config = Config::load();
    if config.summarization.openai_compatible_api_key_env.trim()
        == crate::secret_store::OPENAI_COMPATIBLE_API_KEY_ENV
    {
        config.summarization.openai_compatible_api_key_env.clear();
        config
            .save()
            .map_err(|e| format!("Failed to save config: {}", e))?;
    }

    Ok(
        serde_json::to_value(crate::secret_store::openai_compatible_secret_status())
            .unwrap_or_else(|_| serde_json::json!({ "keySet": false })),
    )
}

#[tauri::command]
pub async fn cmd_warm_parakeet() -> Result<serde_json::Value, String> {
    let config = Config::load();
    if config.transcription.engine != "parakeet"
        && config.effective_live_transcript_backend() != "parakeet"
    {
        return Ok(serde_json::json!({
            "status": "skipped",
            "reason": "parakeet not selected for batch or standalone live transcript",
        }));
    }
    #[cfg(feature = "parakeet")]
    {
        let stats = tauri::async_runtime::spawn_blocking(move || {
            minutes_core::transcription_coordinator::warmup_active_backend(&config)
        })
        .await
        .map_err(|error| format!("warmup task failed: {}", error))?
        .map_err(|error| error.to_string())?;

        Ok(serde_json::json!({
            "status": "ok",
            "backend_id": stats.backend_id,
            "model": stats.model,
            "elapsed_ms": stats.elapsed_ms,
            "used_gpu": stats.used_gpu,
        }))
    }
    #[cfg(not(feature = "parakeet"))]
    {
        Ok(serde_json::json!({
            "status": "skipped",
            "reason": "parakeet feature not compiled",
        }))
    }
}

#[tauri::command]
pub fn cmd_set_setting(section: String, key: String, value: String) -> Result<String, String> {
    let mut config = Config::load();

    match (section.as_str(), key.as_str()) {
        // Transcription
        ("transcription", "engine") => {
            if value == "apple-speech" {
                return Err(
                    "apple-speech is experimental and only applies to standalone live transcript today; configure it via CLI or the config file, not desktop settings".into(),
                );
            }
            if !["whisper", "parakeet"].contains(&value.as_str()) {
                return Err(format!(
                    "unknown transcription engine '{}'. Valid: whisper, parakeet",
                    value
                ));
            }
            config.transcription.engine = value.clone();
        }
        ("transcription", "model") => config.transcription.model = value.clone(),
        ("transcription", "parakeet_model") => {
            if !VALID_PARAKEET_MODELS.contains(&value.as_str()) {
                return Err(format!(
                    "unknown parakeet model '{}'. Valid: {}",
                    value,
                    VALID_PARAKEET_MODELS.join(", ")
                ));
            }
            config.transcription.parakeet_model = value.clone();
            config.transcription.parakeet_vocab = format!("{}.tokenizer.vocab", value);
        }
        ("transcription", "parakeet_sidecar_enabled") => {
            config.transcription.parakeet_sidecar_enabled = match value.as_str() {
                "auto" | "" => None,
                "true" | "on" => Some(true),
                "false" | "off" => Some(false),
                other => {
                    return Err(format!(
                        "unknown parakeet_sidecar_enabled '{}'. Valid: auto, on, off",
                        other
                    ))
                }
            };
        }
        ("transcription", "language") => {
            config.transcription.language = parse_optional_string_setting(&value);
        }

        // Recording
        ("recording", "device") => {
            config.recording.device =
                minutes_core::capture::canonicalize_input_device_setting(&value);
        }

        // Consent
        ("consent", "mode") => {
            let mode = match value.as_str() {
                "off" => ConsentMode::Off,
                "remind" => ConsentMode::Remind,
                "require" => ConsentMode::Require,
                other => {
                    return Err(format!(
                        "unknown consent mode '{}'. Valid: off, remind, require",
                        other
                    ))
                }
            };
            config.consent.mode = mode;
        }
        ("consent", "disclosure_script") => {
            config.consent.disclosure_script = value.clone();
        }
        ("consent", "default_basis") => {
            config.consent.default_basis = parse_optional_consent_basis_setting(&value)?;
        }

        // Diarization
        ("diarization", "engine") => config.diarization.engine = value.clone(),

        // Summarization
        ("summarization", "engine") => config.summarization.engine = value.clone(),
        ("summarization", "agent_command") => config.summarization.agent_command = value.clone(),
        ("summarization", "ollama_model") => config.summarization.ollama_model = value.clone(),
        ("summarization", "ollama_url") => config.summarization.ollama_url = value.clone(),
        ("summarization", "openai_compatible_base_url") => {
            config.summarization.openai_compatible_base_url = value.clone()
        }
        ("summarization", "openai_compatible_model") => {
            config.summarization.openai_compatible_model = value.clone()
        }
        ("summarization", "openai_compatible_api_key_env") => {
            config.summarization.openai_compatible_api_key_env = value.clone()
        }

        // Screen context
        ("screen_context", "enabled") => {
            config.screen_context.enabled = value == "true";
        }
        ("screen_context", "interval_secs") => {
            config.screen_context.interval_secs = value
                .parse()
                .map_err(|_| "interval_secs must be a number")?;
        }
        ("screen_context", "keep_after_summary") => {
            config.screen_context.keep_after_summary = value == "true";
        }

        // Desktop context
        ("desktop_context", "enabled") => {
            config.desktop_context.enabled = value == "true";
        }
        ("desktop_context", "capture_window_titles") => {
            config.desktop_context.capture_window_titles = value == "true";
        }
        ("desktop_context", "capture_browser_context") => {
            config.desktop_context.capture_browser_context = value == "true";
        }
        ("desktop_context", "allowed_apps") => {
            config.desktop_context.allowed_apps = parse_comma_separated_setting(&value);
        }
        ("desktop_context", "denied_apps") => {
            config.desktop_context.denied_apps = parse_comma_separated_setting(&value);
        }

        // Assistant
        ("assistant", "agent") => config.assistant.agent = value.clone(),
        ("assistant", "agent_args") => {
            config.assistant.agent_args = if value.trim().is_empty() {
                vec![]
            } else {
                value.split_whitespace().map(String::from).collect()
            };
        }

        // Call detection
        ("call_detection", "enabled") => {
            config.call_detection.enabled = value == "true";
        }
        ("call_detection", "poll_interval_secs") => {
            config.call_detection.poll_interval_secs = value
                .parse()
                .map_err(|_| "poll_interval_secs must be a number")?;
        }
        ("call_detection", "cooldown_minutes") => {
            config.call_detection.cooldown_minutes = value
                .parse()
                .map_err(|_| "cooldown_minutes must be a number")?;
        }
        ("call_detection", "google_meet_enabled") => {
            set_call_detection_sentinel(&mut config, "google-meet", value == "true");
        }
        ("call_detection", "teams_web_enabled") => {
            set_call_detection_sentinel(&mut config, "teams-web", value == "true");
        }
        ("call_detection", "stop_when_call_ends") => {
            config.call_detection.stop_when_call_ends = value == "true";
        }
        ("call_detection", "call_end_stop_countdown_secs") => {
            let parsed: u64 = value
                .parse()
                .map_err(|_| "call_end_stop_countdown_secs must be a number")?;
            // 1s minimum: the detector clamps with max(1) anyway, but reject 0
            // at the settings boundary so a misclick in the UI doesn't persist
            // a nonsensical "0s" countdown.
            if parsed == 0 {
                return Err("call_end_stop_countdown_secs must be at least 1".into());
            }
            config.call_detection.call_end_stop_countdown_secs = parsed;
        }

        // Dictation
        ("dictation", "backend") => {
            if !["whisper", "apple-speech", "parakeet"].contains(&value.as_str()) {
                return Err(format!(
                    "unknown dictation backend '{}'. Valid: whisper, apple-speech, parakeet",
                    value
                ));
            }
            config.dictation.backend = value.clone();
        }
        ("dictation", "model") => {
            config.dictation.model = value.clone();
            // Re-preload the new model in background so next dictation is instant
            let preload_config = config.clone();
            std::thread::spawn(move || {
                if let Err(e) = minutes_core::dictation::preload_model(&preload_config) {
                    eprintln!("[dictation] model re-preload failed: {}", e);
                }
            });
        }
        ("dictation", "daily_note_log") => {
            config.dictation.daily_note_log = value == "true";
        }
        ("dictation", "accumulate") => {
            config.dictation.accumulate = value == "true";
        }
        ("dictation", "silence_timeout_ms") => {
            config.dictation.silence_timeout_ms = value
                .parse()
                .map_err(|_| "silence_timeout_ms must be a number")?;
        }
        ("dictation", "destination") => config.dictation.destination = value.clone(),
        ("dictation", "auto_paste") => {
            config.dictation.auto_paste = value == "true";
        }
        ("dictation", "cleanup_engine") => config.dictation.cleanup_engine = value.clone(),
        ("dictation", "shortcut_enabled") => {
            config.dictation.shortcut_enabled = value == "true";
        }
        ("dictation", "shortcut") => config.dictation.shortcut = value.clone(),
        // Live transcript
        ("live_transcript", "backend") => {
            if !VALID_LIVE_TRANSCRIPT_BACKENDS.contains(&value.as_str()) {
                return Err(format!(
                    "unknown live transcript backend '{}'. Valid: {}",
                    value,
                    VALID_LIVE_TRANSCRIPT_BACKENDS.join(", ")
                ));
            }
            config.live_transcript.backend = value.clone();
        }
        ("live_transcript", "shortcut_enabled") => {
            config.live_transcript.shortcut_enabled = value == "true";
        }
        ("live_transcript", "shortcut") => {
            config.live_transcript.shortcut = value.clone();
        }

        // Hooks
        ("hooks", "post_record") => {
            config.hooks.post_record = parse_optional_string_setting(&value);
        }

        // Palette — persistence arms for the in-memory AppState writes
        // performed by `cmd_set_palette_shortcut`. Without these the
        // `.ok()`-wrapped `cmd_set_setting` calls at the bottom of that
        // handler silently swallow an "Unknown setting" error and the
        // user's rebind never lands on disk.
        ("palette", "shortcut_enabled") => {
            config.palette.shortcut_enabled = value == "true";
        }
        ("palette", "shortcut") => config.palette.shortcut = value.clone(),

        // Identity — name + email list + alias list. Used by the attribution
        // pipeline to fold calendar attendees arriving under different emails
        // or name spellings onto the canonical user entity. See
        // `IdentityConfig::all_user_aliases` at config.rs for the merge path.
        ("identity", "name") => {
            config.identity.name = parse_optional_string_setting(&value);
        }
        ("identity", "emails") => {
            config.identity.emails = parse_comma_separated_setting(&value);
        }
        ("identity", "aliases") => {
            config.identity.aliases = parse_comma_separated_setting(&value);
        }

        _ => return Err(format!("Unknown setting: {}.{}", section, key)),
    }

    config
        .save()
        .map_err(|e| format!("Failed to save config: {}", e))?;

    Ok(format!("Set {}.{} = {}", section, key, value))
}

#[tauri::command]
pub fn cmd_set_screen_share_hidden(
    app: tauri::AppHandle,
    state: tauri::State<AppState>,
    hidden: bool,
) -> Result<(), String> {
    let mut config = Config::load();
    config.privacy.hide_from_screen_share = hidden;
    config
        .save()
        .map_err(|e| format!("Failed to save config: {}", e))?;

    state.screen_share_hidden.store(hidden, Ordering::Relaxed);
    for (_, window) in app.webview_windows() {
        window.set_content_protected(hidden).ok();
    }

    Ok(())
}

#[tauri::command]
pub fn cmd_get_autostart(app: tauri::AppHandle) -> bool {
    use tauri_plugin_autostart::ManagerExt;
    app.autolaunch().is_enabled().unwrap_or(false)
}

#[tauri::command]
pub fn cmd_set_autostart(app: tauri::AppHandle, enabled: bool) -> Result<(), String> {
    use tauri_plugin_autostart::ManagerExt;
    let manager = app.autolaunch();
    if enabled {
        manager.enable().map_err(|e| e.to_string())
    } else {
        manager.disable().map_err(|e| e.to_string())
    }
}

#[tauri::command]
pub fn cmd_get_storage_stats() -> serde_json::Value {
    let config = Config::load();

    fn walk_size(path: &std::path::Path) -> (u64, usize) {
        let mut total_bytes = 0u64;
        let mut file_count = 0usize;
        for entry in walkdir::WalkDir::new(path).into_iter().flatten() {
            if entry.file_type().is_file() {
                total_bytes += entry.metadata().map(|m| m.len()).unwrap_or(0);
                file_count += 1;
            }
        }
        (total_bytes, file_count)
    }

    let meetings_dir = &config.output_dir;
    let memos_dir = config.output_dir.join("memos");
    let models_dir = &config.transcription.model_path;
    let screens_dir = Config::minutes_dir().join("screens");

    let (meetings_bytes, meetings_count) = walk_size(meetings_dir);
    let (memos_bytes, memos_count) = walk_size(&memos_dir);
    let (models_bytes, _) = walk_size(models_dir);
    let (screens_bytes, screens_count) = walk_size(&screens_dir);

    serde_json::json!({
        "meetings": { "bytes": meetings_bytes, "count": meetings_count },
        "memos": { "bytes": memos_bytes, "count": memos_count },
        "models": { "bytes": models_bytes },
        "screens": { "bytes": screens_bytes, "count": screens_count },
        "total_bytes": meetings_bytes + memos_bytes + models_bytes + screens_bytes,
    })
}

#[tauri::command]
pub fn cmd_open_meeting_url(app: tauri::AppHandle, url: String) -> Result<(), String> {
    open_target(&app, &url)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::path::Path;
    use std::sync::{Mutex, MutexGuard, OnceLock};
    use tempfile::TempDir;

    fn test_guard() -> MutexGuard<'static, ()> {
        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
        LOCK.get_or_init(|| Mutex::new(()))
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner())
    }

    fn with_temp_home<T>(f: impl FnOnce(&Path) -> T) -> T {
        let _guard = test_guard();
        let dir = std::env::temp_dir().join(format!(
            "minutes-app-test-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap_or_default()
                .as_nanos()
        ));
        std::fs::create_dir_all(&dir).unwrap();

        let original_home = std::env::var_os("HOME");
        let original_userprofile = std::env::var_os("USERPROFILE");
        std::env::set_var("HOME", &dir);
        std::env::set_var("USERPROFILE", &dir);

        let result = f(&dir);

        if let Some(home) = original_home {
            std::env::set_var("HOME", home);
        } else {
            std::env::remove_var("HOME");
        }
        if let Some(userprofile) = original_userprofile {
            std::env::set_var("USERPROFILE", userprofile);
        } else {
            std::env::remove_var("USERPROFILE");
        }

        std::fs::remove_dir_all(&dir).ok();
        result
    }

    fn test_app_state() -> AppState {
        AppState {
            recording: Arc::new(AtomicBool::new(false)),
            starting: Arc::new(AtomicBool::new(false)),
            stop_flag: Arc::new(AtomicBool::new(false)),
            processing: Arc::new(AtomicBool::new(false)),
            processing_stage: Arc::new(Mutex::new(None)),
            latest_output: Arc::new(Mutex::new(None)),
            activation_progress: Arc::new(Mutex::new(ActivationProgress::default())),
            call_capture_health: Arc::new(Mutex::new(None)),
            completion_notifications_enabled: Arc::new(AtomicBool::new(false)),
            screen_share_hidden: Arc::new(AtomicBool::new(false)),
            global_hotkey_enabled: Arc::new(AtomicBool::new(false)),
            global_hotkey_shortcut: Arc::new(Mutex::new("CmdOrCtrl+Shift+M".into())),
            hotkey_runtime: Arc::new(Mutex::new(HotkeyRuntime::default())),
            discard_short_hotkey_capture: Arc::new(AtomicBool::new(false)),
            pty_manager: Arc::new(Mutex::new(crate::pty::PtyManager::default())),
            dictation_active: Arc::new(AtomicBool::new(false)),
            dictation_stop_flag: Arc::new(AtomicBool::new(false)),
            dictation_focus_guard: Arc::new(Mutex::new(None)),
            pending_dictation_target: Arc::new(Mutex::new(None)),
            dictation_release_started_at: Arc::new(Mutex::new(None)),
            dictation_shortcut_enabled: Arc::new(AtomicBool::new(false)),
            dictation_shortcut: Arc::new(Mutex::new("CmdOrCtrl+Shift+Space".into())),
            live_transcript_active: Arc::new(AtomicBool::new(false)),
            live_transcript_stop_flag: Arc::new(AtomicBool::new(false)),
            live_shortcut_enabled: Arc::new(AtomicBool::new(false)),
            live_shortcut: Arc::new(Mutex::new("CmdOrCtrl+Shift+L".into())),
            pending_update: Arc::new(Mutex::new(None)),
            update_install_running: Arc::new(AtomicBool::new(false)),
            update_install_cancel: Arc::new(AtomicBool::new(false)),
            update_install_state: Arc::new(Mutex::new(UpdateUiState::default())),
            palette_shortcut_enabled: Arc::new(AtomicBool::new(false)),
            palette_shortcut: Arc::new(Mutex::new("CmdOrCtrl+Shift+K".into())),
            palette_lifecycle: Arc::new(Mutex::new(PaletteLifecycle::default())),
            palette_reopen_pending: Arc::new(AtomicBool::new(false)),
            pending_meeting_prompts: Arc::new(Mutex::new(HashMap::new())),
            recording_started_by_call_detect: Arc::new(AtomicBool::new(false)),
            call_end_countdown_cancel: Arc::new(AtomicBool::new(false)),
            call_end_countdown_active: Arc::new(AtomicBool::new(false)),
            call_end_countdown_terminal_state: Arc::new(AtomicU8::new(0)),
        }
    }

    #[test]
    fn capture_status_keeps_hot_poll_payload_lean() {
        with_temp_home(|_| {
            let state = test_app_state();
            let capture = status_value(&state, false);
            let full = status_value(&state, true);

            for key in [
                "recording",
                "starting",
                "processing",
                "recordingMode",
                "processingStage",
                "processingStageLabel",
                "processingTitle",
                "processingJobId",
                "processingJobCount",
                "processingJobs",
                "updateState",
                "latestOutput",
                "callCaptureHealth",
                "pid",
                "elapsed",
                "audioLevel",
            ] {
                assert!(capture.get(key).is_some(), "capture status missing {key}");
            }

            for key in [
                "activation",
                "batch_transcription",
                "standalone_live",
                "transcriptionSetup",
            ] {
                assert!(
                    capture.get(key).is_none(),
                    "capture status should not include slow readiness key {key}"
                );
                assert!(full.get(key).is_some(), "full status missing {key}");
            }
        });
    }

    #[test]
    fn permission_restart_safety_allows_idle_restart() {
        let safety = permission_restart_safety_from_snapshot(PermissionRestartSnapshot::default());

        assert_eq!(safety.status, PermissionRestartStatus::Allowed);
        assert!(safety.can_restart);
        assert!(!safety.requires_confirmation);
        assert!(safety.blockers.is_empty());
    }

    #[test]
    fn permission_restart_safety_blocks_active_work() {
        let safety = permission_restart_safety_from_snapshot(PermissionRestartSnapshot {
            recording: true,
            processing: true,
            live_transcript: true,
            dictation: true,
            update_installing: true,
            ..PermissionRestartSnapshot::default()
        });

        assert_eq!(safety.status, PermissionRestartStatus::Blocked);
        assert!(!safety.can_restart);
        assert!(!safety.requires_confirmation);
        assert!(safety
            .blockers
            .iter()
            .any(|item| item.contains("recording")));
        assert!(safety
            .blockers
            .iter()
            .any(|item| item.contains("processing")));
        assert!(safety
            .blockers
            .iter()
            .any(|item| item.contains("Live transcript")));
        assert!(safety
            .blockers
            .iter()
            .any(|item| item.contains("Dictation")));
        assert!(safety.blockers.iter().any(|item| item.contains("update")));
    }

    #[test]
    fn permission_restart_safety_requires_assistant_confirmation_only_when_idle() {
        let safety = permission_restart_safety_from_snapshot(PermissionRestartSnapshot {
            assistant_session: Some("assistant".into()),
            ..PermissionRestartSnapshot::default()
        });

        assert_eq!(safety.status, PermissionRestartStatus::ConfirmationRequired);
        assert!(safety.can_restart);
        assert!(safety.requires_confirmation);
        assert!(safety.blockers.is_empty());
        assert_eq!(safety.confirmations.len(), 1);
    }

    fn permission_monitor_snapshot_for_test(
        signature: &str,
        states: &[(&str, bool)],
    ) -> PermissionMonitorSnapshot {
        PermissionMonitorSnapshot {
            signature: signature.into(),
            rows: states
                .iter()
                .map(|(kind, usable)| PermissionMonitorRowState {
                    kind: (*kind).into(),
                    usable: *usable,
                })
                .collect(),
        }
    }

    #[test]
    fn permission_monitor_dedupes_identical_snapshots() {
        let mut monitor = PermissionMonitorDedupe::default();
        let ready = permission_monitor_snapshot_for_test("ready", &[("InputMonitoring", true)]);

        assert!(monitor.observe(ready.clone(), 0).is_none());
        assert!(monitor.observe(ready, 15_000).is_none());
    }

    #[test]
    fn permission_monitor_delays_transient_loss() {
        let mut monitor = PermissionMonitorDedupe::default();
        let ready = permission_monitor_snapshot_for_test("ready", &[("InputMonitoring", true)]);
        let denied = permission_monitor_snapshot_for_test("denied", &[("InputMonitoring", false)]);

        assert!(monitor.observe(ready.clone(), 0).is_none());
        assert!(monitor.observe(denied.clone(), 1_000).is_none());
        assert!(monitor.observe(denied, 10_999).is_none());
        assert!(monitor.observe(ready, 11_000).is_none());
    }

    #[test]
    fn permission_monitor_emits_restore_promptly_after_reported_loss() {
        let mut monitor = PermissionMonitorDedupe::default();
        let ready = permission_monitor_snapshot_for_test("ready", &[("InputMonitoring", true)]);
        let denied = permission_monitor_snapshot_for_test("denied", &[("InputMonitoring", false)]);

        assert!(monitor.observe(ready.clone(), 0).is_none());
        assert!(monitor.observe(denied.clone(), 1_000).is_none());
        assert_eq!(
            monitor
                .observe(denied, 11_000)
                .expect("persistent loss should emit")
                .reason,
            "permission_loss"
        );
        let decision = monitor
            .observe(ready, 11_000)
            .expect("restoration should emit immediately");
        assert_eq!(decision.reason, "permission_restored");
    }

    #[test]
    fn permission_monitor_emits_persistent_loss_after_cooldown() {
        let mut monitor = PermissionMonitorDedupe::default();
        let ready = permission_monitor_snapshot_for_test("ready", &[("Microphone", true)]);
        let denied = permission_monitor_snapshot_for_test("denied", &[("Microphone", false)]);

        assert!(monitor.observe(ready, 0).is_none());
        assert!(monitor.observe(denied.clone(), 1_000).is_none());
        let decision = monitor
            .observe(denied, 11_000)
            .expect("persistent loss should emit after cooldown");
        assert_eq!(decision.reason, "permission_loss");
    }

    #[test]
    fn permission_monitor_extends_loss_cooldown_after_wake_gap() {
        let mut monitor = PermissionMonitorDedupe::default();
        let ready = permission_monitor_snapshot_for_test("ready", &[("ScreenRecording", true)]);
        let denied = permission_monitor_snapshot_for_test("denied", &[("ScreenRecording", false)]);

        assert!(monitor.observe(ready, 0).is_none());
        assert!(monitor.observe(denied.clone(), 60_000).is_none());
        assert!(monitor.observe(denied.clone(), 75_000).is_none());
        let decision = monitor
            .observe(denied, 80_000)
            .expect("loss should emit after wake grace expires");
        assert_eq!(decision.reason, "permission_loss");
    }

    #[test]
    fn filtered_agent_args_drops_skip_permissions_for_unsupported_agents() {
        let args = vec![
            "--dangerously-skip-permissions".to_string(),
            "--model".to_string(),
            "openai/gpt-5.5".to_string(),
        ];

        assert_eq!(
            filtered_agent_args("opencode", &args),
            vec!["--model".to_string(), "openai/gpt-5.5".to_string()]
        );
        assert_eq!(filtered_agent_args("claude", &args), args);
    }

    #[test]
    fn filtered_agent_args_keeps_codex_specific_bypass_flag_only_for_codex() {
        let args = vec![
            "--dangerously-bypass-approvals-and-sandbox".to_string(),
            "--model".to_string(),
            "gpt-5-codex".to_string(),
        ];

        assert_eq!(filtered_agent_args("codex", &args), args);
        assert_eq!(
            filtered_agent_args("claude", &args),
            vec!["--model".to_string(), "gpt-5-codex".to_string()]
        );
        assert_eq!(
            filtered_agent_args("opencode", &args),
            vec!["--model".to_string(), "gpt-5-codex".to_string()]
        );
    }

    #[cfg(unix)]
    #[test]
    fn agent_binary_resolver_rejects_non_executable_absolute_path() {
        use std::os::unix::fs::PermissionsExt;

        let dir = TempDir::new().unwrap();
        let broken = dir.path().join("claude");
        fs::write(&broken, "").unwrap();
        fs::set_permissions(&broken, fs::Permissions::from_mode(0o644)).unwrap();

        let err = resolve_agent_binary(broken.to_str().unwrap()).unwrap_err();
        assert_eq!(err.unusable_candidates, vec![broken.clone()]);
        assert!(err.user_message().contains("installed copy cannot be run"));
        assert!(err.user_message().contains("choose another assistant"));
        assert!(find_agent_binary(broken.to_str().unwrap()).is_none());
    }

    #[cfg(unix)]
    #[test]
    fn agent_binary_resolver_accepts_executable_absolute_path() {
        use std::os::unix::fs::PermissionsExt;

        let dir = TempDir::new().unwrap();
        let executable = dir.path().join("custom-agent");
        fs::write(&executable, "#!/bin/sh\nexit 0\n").unwrap();
        fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap();

        assert_eq!(
            resolve_agent_binary(executable.to_str().unwrap()).unwrap(),
            executable
        );
    }

    #[test]
    fn assistant_switch_prompts_are_instruction_file_agnostic() {
        let meeting_prompt = context_switch_prompt("opencode", "meeting", "Discussing: Demo");
        assert!(meeting_prompt.contains("CURRENT_MEETING.md"));
        assert!(meeting_prompt.contains("CLAUDE.md or AGENTS.md"));
        assert!(!meeting_prompt.contains("Read CURRENT_MEETING.md and CLAUDE.md,"));

        let general_prompt = context_switch_prompt("opencode", "assistant", "Minutes Assistant");
        assert!(general_prompt.contains("CLAUDE.md or AGENTS.md"));

        let artifact_prompt = artifact_switch_prompt("opencode", Some("draft.md"));
        assert!(artifact_prompt.contains("CURRENT_ARTIFACT.md"));
        assert!(artifact_prompt.contains("CLAUDE.md or AGENTS.md"));
    }

    #[test]
    fn update_assistant_live_context_updates_both_instruction_files() {
        let temp = TempDir::new().unwrap();
        let workspace = temp.path();
        std::fs::write(workspace.join("CLAUDE.md"), "# Minutes Assistant\n").unwrap();
        std::fs::write(workspace.join("AGENTS.md"), "# Minutes Assistant\n").unwrap();

        update_assistant_live_context(workspace, true);

        let claude = std::fs::read_to_string(workspace.join("CLAUDE.md")).unwrap();
        let agents = std::fs::read_to_string(workspace.join("AGENTS.md")).unwrap();
        assert!(claude.contains("## Live Transcript Active"));
        assert!(agents.contains("## Live Transcript Active"));

        update_assistant_live_context(workspace, false);

        let claude = std::fs::read_to_string(workspace.join("CLAUDE.md")).unwrap();
        let agents = std::fs::read_to_string(workspace.join("AGENTS.md")).unwrap();
        assert!(!claude.contains("## Live Transcript Active"));
        assert!(!agents.contains("## Live Transcript Active"));
    }

    /// Arms that have no current caller but are deliberately kept pending a
    /// UI surface. Each entry is a documented audit finding (see the
    /// 2026-04-16 settings audit Table 1 — "fields with setter but no UI
    /// input"). Trim this list as UI is added; the guard will start
    /// catching *new* regressions as soon as any entry drops off.
    ///
    /// The alternative (removing the arm entirely) is fine when there's no
    /// concrete plan to expose the setting — users can still edit the
    /// config.toml field directly. Re-add the arm when the UI lands.
    const INTENTIONAL_ORPHANS: &[(&str, &str)] = &[
        // User-facing but no UI planned this sprint. Users on config.toml
        // can still toggle these; the cmd_set_setting arm is ready for a
        // future UI row.
        ("dictation", "accumulate"),
        ("dictation", "auto_paste"),
        ("dictation", "cleanup_engine"),
        ("dictation", "destination"),
        // NOTE: ("dictation", "shortcut_enabled") used to live here as a
        // vestigial arm (cmd_set_shortcut writes the field directly). It now
        // has a real caller via the central path — the round-trip persistence
        // test `dictation_shortcut_round_trips_to_config` exercises it — so it
        // dropped off the allowlist. The arm⇒caller guard's stale-orphan check
        // enforces this: leaving it here would fail the guard.
        // screen_context.keep_after_summary controls post-summary screenshot
        // retention. Low-traffic; TOML-only is fine.
        ("screen_context", "keep_after_summary"),
        // Parakeet sidecar auto-resolves from engine + binary presence since
        // #295; the arm accepts auto/on/off for power users but the resolved
        // state (not a toggle) is what Settings displays.
        ("transcription", "parakeet_sidecar_enabled"),
    ];

    /// CI guard — every `("section", "key")` arm in `cmd_set_setting` must be
    /// reachable from at least one call site (HTML invoke OR Rust internal
    /// call), OR listed in `INTENTIONAL_ORPHANS` above. This prevents a
    /// future repeat of the palette regression where arms existed in Rust
    /// but no frontend ever called them.
    ///
    /// If this test fails:
    ///   - Add the missing UI input (or Rust caller), OR
    ///   - Remove the orphaned arm AND its config field from config.rs, OR
    ///   - If it's a deliberate setter-without-UI from the audit, add it to
    ///     INTENTIONAL_ORPHANS with a comment explaining why.
    #[test]
    fn every_cmd_set_setting_arm_has_a_caller() {
        let manifest = env!("CARGO_MANIFEST_DIR"); // .../tauri/src-tauri
        let commands_path = format!("{}/src/commands.rs", manifest);
        let commands = std::fs::read_to_string(&commands_path).expect("failed to read commands.rs");

        let arms = extract_set_setting_arms(&commands);
        assert!(
            !arms.is_empty(),
            "found no cmd_set_setting arms — parser broken?"
        );

        // Aggregate all frontend + Rust source so an arm called from either
        // surface counts as reachable. Rust-internal call sites (e.g.
        // cmd_set_palette_shortcut → cmd_set_setting("palette", ...)) are
        // legitimate and shouldn't be flagged as dead.
        let mut haystack = String::new();
        let rust_root = format!("{}/src", manifest);
        for path in walk_with_extensions(&rust_root, &["rs"]) {
            let Ok(contents) = std::fs::read_to_string(&path) else {
                continue;
            };
            // commands.rs contains the match block that *defines* the arms —
            // if we included it verbatim, every arm would trivially satisfy
            // the proximity check. But commands.rs also contains legitimate
            // internal callers like cmd_set_palette_shortcut → cmd_set_setting.
            // Strip the match-block body only; keep the rest of the file.
            let filtered = if path.file_name().and_then(|n| n.to_str()) == Some("commands.rs") {
                strip_set_setting_match_block(&contents)
            } else {
                contents
            };
            haystack.push_str(&filtered);
            haystack.push('\n');
        }
        let html_root = format!("{}/../src", manifest);
        for path in walk_with_extensions(&html_root, &["html", "js"]) {
            if let Ok(contents) = std::fs::read_to_string(&path) {
                haystack.push_str(&contents);
                haystack.push('\n');
            }
        }

        let mut orphans: Vec<String> = arms
            .iter()
            .filter(|(s, k)| !arm_has_caller(&haystack, s, k))
            .filter(|(s, k)| {
                !INTENTIONAL_ORPHANS
                    .iter()
                    .any(|(ds, dk)| ds == s && dk == k)
            })
            .map(|(s, k)| format!("(\"{}\", \"{}\")", s, k))
            .collect();
        orphans.sort();

        // Flip side: fail if INTENTIONAL_ORPHANS lists an entry that DOES
        // have a caller now (stale allowlist). Forces the list to shrink
        // as UI lands.
        let mut stale: Vec<String> = INTENTIONAL_ORPHANS
            .iter()
            .filter(|(s, k)| arm_has_caller(&haystack, s, k))
            .filter(|(s, k)| arms.iter().any(|(a_s, a_k)| a_s == s && a_k == k))
            .map(|(s, k)| format!("(\"{}\", \"{}\")", s, k))
            .collect();
        stale.sort();

        assert!(
            orphans.is_empty(),
            "cmd_set_setting arms have no caller (dead code):\n  {}\n\n\
             Every (section, key) arm in commands.rs:cmd_set_setting must be \
             reachable from at least one HTML `invoke('cmd_set_setting', ...)` \
             call OR a Rust-internal `cmd_set_setting(...)` call in another \
             file. Options: add a caller, remove the arm, or document a \
             deferred intent in INTENTIONAL_ORPHANS above.",
            orphans.join("\n  ")
        );

        assert!(
            stale.is_empty(),
            "INTENTIONAL_ORPHANS contains arms that now have a caller:\n  {}\n\n\
             Remove these from the allowlist so the guard catches future \
             regressions on them.",
            stale.join("\n  ")
        );
    }

    /// Extract every *statically literal* `(section, key)` pair targeted by a
    /// `cmd_set_setting` call — both the HTML
    /// `invoke('cmd_set_setting', { section: 'X', key: 'Y', ... })` shape
    /// (single- or multi-line) and the Rust
    /// `cmd_set_setting("X".into(), "Y".into(), ...)` shape.
    ///
    /// Calls whose `key` (or `section`) is a non-literal expression — e.g. the
    /// `desktop_context` wrappers that pass a `key` *variable* — are skipped:
    /// they can't be resolved statically and are already covered by the
    /// arm⇒caller wrapper allowlist in `arm_has_caller`.
    /// Remove top-level `#[cfg(test)] mod ... { ... }` blocks from Rust source.
    /// Test modules in these files are top-level (their closing `}` sits at
    /// column 0), so a simple state machine that drops everything from the
    /// `#[cfg(test)]` attribute through the next column-0 `}` is sufficient.
    /// Used by the caller⇒arm guard so test scaffolding and the guard's own
    /// literal anchors aren't mistaken for shipping call sites.
    fn strip_cfg_test_modules(src: &str) -> String {
        let mut out = String::with_capacity(src.len());
        let mut in_test = false;
        let mut pending_cfg = false;
        for line in src.lines() {
            if in_test {
                if line == "}" {
                    in_test = false;
                }
                out.push('\n');
                continue;
            }
            let trimmed = line.trim();
            if trimmed == "#[cfg(test)]" {
                pending_cfg = true;
                out.push('\n');
                continue;
            }
            if pending_cfg {
                pending_cfg = false;
                if trimmed.starts_with("mod ") && trimmed.ends_with('{') {
                    in_test = true;
                    out.push('\n');
                    continue;
                }
                // `#[cfg(test)]` on a non-module item (e.g. a single test fn or
                // use); keep this line, it won't contain a real call site.
            }
            out.push_str(line);
            out.push('\n');
        }
        out
    }

    fn extract_set_setting_callers(haystack: &str) -> Vec<(String, String)> {
        let mut callers = Vec::new();
        // Build every anchor at runtime (split literals) so this scanner's own
        // source — the `anchors` array below — is not parsed as a real call
        // site when the guard scans this file.
        let rust_anchor = ["cmd_set_setting", "("].concat();
        // HTML/JS `invoke(...)` anchors, both quoting styles. The single-quoted
        // form is the legacy in-tree style; the double-quoted form catches a
        // future `invoke("cmd_set_setting", { section: "x", key: "y" })`, which
        // is otherwise invisible to caller⇒arm and would compile, ship, and
        // silently drop. The object-value parser already handles either quote.
        let html_anchor_single = ["'", "cmd_set_setting", "'"].concat();
        let html_anchor_double = ["\"", "cmd_set_setting", "\""].concat();
        let anchors = [
            rust_anchor.as_str(),
            html_anchor_single.as_str(),
            html_anchor_double.as_str(),
        ];
        for anchor in &anchors {
            let mut cursor = 0;
            while let Some(offset) = haystack[cursor..].find(anchor) {
                let abs = cursor + offset;
                // Skip anchors that sit *inside* a string literal (the char
                // right before the anchor is a quote), e.g. the `"cmd_set_setting("`
                // array element in this very function. Those aren't call sites.
                let preceded_by_quote = abs
                    .checked_sub(1)
                    .map(|i| matches!(haystack.as_bytes()[i], b'"' | b'\''))
                    .unwrap_or(false);
                if *anchor == rust_anchor.as_str() && preceded_by_quote {
                    cursor = abs + anchor.len();
                    continue;
                }
                let window_end = (abs + 320).min(haystack.len());
                let window = &haystack[abs..window_end];

                let pair = if *anchor == rust_anchor.as_str() {
                    // Rust: cmd_set_setting("section".into(), "key".into(), ..)
                    // First two quoted string literals after the paren.
                    let after = &window[anchor.len()..];
                    extract_first_two_double_quoted(after)
                } else {
                    // HTML: invoke('cmd_set_setting', { section: 'X', key: 'Y' })
                    let section = extract_object_literal_value(window, "section:");
                    let key = extract_object_literal_value(window, "key:");
                    match (section, key) {
                        (Some(s), Some(k)) => Some((s, k)),
                        _ => None,
                    }
                };

                if let Some(pair) = pair {
                    callers.push(pair);
                }
                cursor = abs + anchor.len();
            }
        }
        callers
    }

    /// Pull the first two `"..."`-quoted string literals out of a Rust slice.
    fn extract_first_two_double_quoted(s: &str) -> Option<(String, String)> {
        let (first, rest) = next_double_quoted(s)?;
        let (second, _) = next_double_quoted(rest)?;
        Some((first, second))
    }

    fn next_double_quoted(s: &str) -> Option<(String, &str)> {
        let start = s.find('"')?;
        let after = &s[start + 1..];
        let end = after.find('"')?;
        Some((after[..end].to_string(), &after[end + 1..]))
    }

    /// Resolve a `field: 'literal'` (or `field: "literal"`) value in a JS object
    /// literal window. Returns `None` if the value is a non-literal expression
    /// (variable / template / call), so dynamic callers are skipped rather than
    /// mis-parsed.
    fn extract_object_literal_value(window: &str, field: &str) -> Option<String> {
        let idx = window.find(field)?;
        let after = window[idx + field.len()..].trim_start();
        let mut chars = after.char_indices();
        let (_, quote) = chars.next()?;
        if quote != '\'' && quote != '"' {
            // Non-literal value (e.g. `key,` shorthand or an expression).
            return None;
        }
        let body = &after[quote.len_utf8()..];
        let end = body.find(quote)?;
        Some(body[..end].to_string())
    }

    /// Both `invoke('cmd_set_setting', …)` (single-quoted, legacy in-tree
    /// style) and `invoke("cmd_set_setting", …)` (double-quoted) call sites are
    /// visible to `extract_set_setting_callers`. The double-quoted form was a
    /// blind spot: `every_cmd_set_setting_caller_has_an_arm` would have missed
    /// a future double-quoted caller to a (section, key) with no arm, letting it
    /// compile, ship, and silently drop. The fixture anchors are assembled from
    /// split literals so this test's own source isn't parsed as a call site.
    #[test]
    fn extract_set_setting_callers_handles_both_quote_styles() {
        let invoke = "invoke(";
        let cmd = "cmd_set_setting";
        let single = format!(
            "{}'{}', {{ section: 'bogus_single', key: 'k1' }})",
            invoke, cmd
        );
        let double = format!(
            "{}\"{}\", {{ section: \"bogus_double\", key: \"k2\" }})",
            invoke, cmd
        );
        let haystack = format!("{}\n{}\n", single, double);

        let callers = extract_set_setting_callers(&haystack);
        assert!(
            callers.contains(&("bogus_single".to_string(), "k1".to_string())),
            "single-quoted caller not detected: {:?}",
            callers
        );
        assert!(
            callers.contains(&("bogus_double".to_string(), "k2".to_string())),
            "double-quoted caller not detected (blind spot the anchor fix closes): {:?}",
            callers
        );
    }

    /// CI guard (inverse direction): every `cmd_set_setting` *call site* — HTML
    /// `invoke` or Rust-internal — must target a `(section, key)` that actually
    /// has a match arm. This catches the recurring palette-style regression
    /// where a frontend call uses a section/key with no arm: it compiles,
    /// ships, and silently drops. `every_cmd_set_setting_arm_has_a_caller`
    /// proves arm⇒caller; this proves caller⇒arm.
    #[test]
    fn every_cmd_set_setting_caller_has_an_arm() {
        let manifest = env!("CARGO_MANIFEST_DIR");
        let commands_path = format!("{}/src/commands.rs", manifest);
        let commands = std::fs::read_to_string(&commands_path).expect("failed to read commands.rs");
        let arms = extract_set_setting_arms(&commands);
        assert!(!arms.is_empty(), "found no cmd_set_setting arms");
        let arm_set: std::collections::HashSet<(String, String)> = arms.into_iter().collect();

        let mut missing: Vec<String> = Vec::new();

        // Rust callers: scan every src/*.rs EXCEPT the test module's own
        // synthetic literals would otherwise self-match. We strip the match
        // block from commands.rs (the arms themselves are `("X", "Y") =>`, not
        // `cmd_set_setting("X", ...)` calls, so they're already ignored by the
        // anchor — but stripping keeps the window clean) and scan the rest.
        let rust_root = format!("{}/src", manifest);
        for path in walk_with_extensions(&rust_root, &["rs"]) {
            let Ok(contents) = std::fs::read_to_string(&path) else {
                continue;
            };
            // Strip test modules first: `#[cfg(test)]` code is scaffolding, not
            // a shipping call site, and the guard's own parser/assertion-string
            // literals would otherwise self-match. Then strip the match block in
            // commands.rs (arm definitions aren't callers).
            let no_tests = strip_cfg_test_modules(&contents);
            let filtered = if path.file_name().and_then(|n| n.to_str()) == Some("commands.rs") {
                strip_set_setting_match_block(&no_tests)
            } else {
                no_tests
            };
            for (s, k) in extract_set_setting_callers(&filtered) {
                if !arm_set.contains(&(s.clone(), k.clone())) {
                    missing.push(format!("Rust {}: (\"{}\", \"{}\")", path.display(), s, k));
                }
            }
        }

        // HTML/JS callers.
        let html_root = format!("{}/../src", manifest);
        for path in walk_with_extensions(&html_root, &["html", "js"]) {
            let Ok(contents) = std::fs::read_to_string(&path) else {
                continue;
            };
            for (s, k) in extract_set_setting_callers(&contents) {
                if !arm_set.contains(&(s.clone(), k.clone())) {
                    missing.push(format!("HTML {}: (\"{}\", \"{}\")", path.display(), s, k));
                }
            }
        }

        missing.sort();
        missing.dedup();
        assert!(
            missing.is_empty(),
            "cmd_set_setting call sites target a (section, key) with NO match arm \
             (would compile, ship, and silently drop):\n  {}\n\n\
             Add the arm in cmd_set_setting (and the config field), or fix the \
             caller's section/key.",
            missing.join("\n  ")
        );
    }

    /// Round-trip: `cmd_set_completion_notifications`' persistence step writes
    /// `notifications.completion_enabled` to config.toml and survives a fresh
    /// `Config::load()`. Would have caught confirmed drop #2.
    #[test]
    fn completion_notifications_round_trips_to_config() {
        with_temp_home(|_| {
            // Default is true; flip to false and confirm it lands on disk.
            persist_completion_notifications(false).unwrap();
            assert!(
                !Config::load().notifications.completion_enabled,
                "completion_enabled=false did not persist"
            );

            persist_completion_notifications(true).unwrap();
            assert!(
                Config::load().notifications.completion_enabled,
                "completion_enabled=true did not persist"
            );
        });
    }

    /// Round-trip: the Quick-Thought global hotkey persistence step (shared by
    /// `cmd_set_global_hotkey` and the `quick_thought` slot of
    /// `cmd_set_shortcut`) writes `global_hotkey.{shortcut_enabled,shortcut}`
    /// to config.toml. Would have caught confirmed drops #1 and #3.
    #[test]
    fn global_hotkey_round_trips_to_config() {
        with_temp_home(|_| {
            // Default is disabled with CmdOrCtrl+Shift+M.
            persist_global_hotkey(true, "CmdOrCtrl+Shift+J").unwrap();
            let loaded = Config::load();
            assert!(
                loaded.global_hotkey.shortcut_enabled,
                "global_hotkey.shortcut_enabled=true did not persist"
            );
            assert_eq!(
                loaded.global_hotkey.shortcut, "CmdOrCtrl+Shift+J",
                "global_hotkey.shortcut did not persist"
            );

            persist_global_hotkey(false, "CmdOrCtrl+Shift+T").unwrap();
            let loaded = Config::load();
            assert!(
                !loaded.global_hotkey.shortcut_enabled,
                "global_hotkey.shortcut_enabled=false did not persist"
            );
            assert_eq!(loaded.global_hotkey.shortcut, "CmdOrCtrl+Shift+T");
        });
    }

    /// Round-trip regression coverage: the dictation shortcut written through
    /// the `cmd_set_setting` central path persists. (`cmd_set_dictation_shortcut`
    /// and the Dictation slot of `cmd_set_shortcut` write the same fields via
    /// `Config::load()`→mutate→`save()`; this exercises the on-disk write of
    /// those fields without a `tauri::AppHandle`.)
    #[test]
    fn dictation_shortcut_round_trips_to_config() {
        with_temp_home(|_| {
            cmd_set_setting("dictation".into(), "shortcut_enabled".into(), "true".into()).unwrap();
            cmd_set_setting(
                "dictation".into(),
                "shortcut".into(),
                "CmdOrCtrl+Alt+Space".into(),
            )
            .unwrap();
            let loaded = Config::load();
            assert!(loaded.dictation.shortcut_enabled);
            assert_eq!(loaded.dictation.shortcut, "CmdOrCtrl+Alt+Space");
        });
    }

    /// Ban the `.ok()`/`let _ =` swallow on `cmd_set_setting`. A discarded
    /// Result hides an "Unknown setting" error and silently drops the write —
    /// the exact failure mode behind the palette/live regressions. Require `?`
    /// or an explicit `if let Err(e) = ... { log }` instead.
    #[test]
    fn cmd_set_setting_result_is_never_swallowed() {
        let manifest = env!("CARGO_MANIFEST_DIR");
        let rust_root = format!("{}/src", manifest);
        // Build the banned anchors at runtime (split literals) so this very
        // test's source — which must mention them to scan for them — does not
        // self-match. A bare contiguous `cmd_set_setting(` never appears in
        // this function's body.
        let call_anchor = ["cmd_set_setting", "("].concat();
        let discard_let = ["let _ = cmd_set", "_setting"].concat();
        let ok_tail = [".ok", "()"].concat();
        let mut offenders: Vec<String> = Vec::new();
        for path in walk_with_extensions(&rust_root, &["rs"]) {
            let Ok(contents) = std::fs::read_to_string(&path) else {
                continue;
            };
            let lines: Vec<&str> = contents.lines().collect();
            for (lineno, line) in lines.iter().enumerate() {
                let trimmed = line.trim_start();
                if trimmed.starts_with("//") {
                    continue;
                }

                // Shape A: `let _ = cmd_set_setting(...)`.
                if line.contains(&discard_let) {
                    offenders.push(format!("{}:{} {}", path.display(), lineno + 1, trimmed));
                    continue;
                }

                // Shape B (inline): `cmd_set_setting(...).ok()` on one line.
                if line.contains(&call_anchor) && line.contains(&ok_tail) {
                    offenders.push(format!("{}:{} {}", path.display(), lineno + 1, trimmed));
                    continue;
                }

                // Shape C (multi-line tail): a standalone `.ok()` line whose
                // statement opened with a `cmd_set_setting(` call. Look back a
                // few lines for the opener, stopping at a statement boundary
                // (`;` or `{`/`}`) so array literals like
                // `[cmd_set_setting(...), ...];` (terminated by `;`) don't match.
                if trimmed.starts_with(&ok_tail) {
                    for back in 1..=8usize {
                        let Some(prev_idx) = lineno.checked_sub(back) else {
                            break;
                        };
                        let prev = lines[prev_idx].trim();
                        if prev.contains(&call_anchor) {
                            offenders.push(format!(
                                "{}:{} {}",
                                path.display(),
                                prev_idx + 1,
                                lines[prev_idx].trim_start()
                            ));
                            break;
                        }
                        // Stop at a statement boundary that isn't the call we want.
                        if prev.ends_with(';') || prev.ends_with('{') || prev.ends_with('}') {
                            break;
                        }
                    }
                }
            }
        }
        assert!(
            offenders.is_empty(),
            "cmd_set_setting Result is swallowed (use `?` or `if let Err(e) = ..`):\n  {}",
            offenders.join("\n  ")
        );
    }

    /// Strip the body of the `match (section.as_str(), key.as_str()) { ... }`
    /// expression from commands.rs, replacing it with a blank region so the
    /// proximity-based caller check doesn't count the arm *definitions* as
    /// their own callers. Preserves the rest of the file so internal callers
    /// in commands.rs (e.g. cmd_set_palette_shortcut) still count.
    fn strip_set_setting_match_block(src: &str) -> String {
        // The match arms live inside `fn cmd_set_setting`, whose body
        // contains the literal function name as a lead-in. Without
        // stripping, every arm in the match body would appear within the
        // 260-char window of the `cmd_set_setting` identifier in its own
        // signature and count as its own caller.
        //
        // Stripping the match body is sufficient; INTENTIONAL_ORPHANS in
        // the tests module contains bare `("X", "Y")` tuples that are NOT
        // preceded by a `cmd_set_setting` token, so arm_has_caller's
        // anchored search already ignores them.
        let mut out = String::with_capacity(src.len());
        let mut in_match = false;
        for line in src.lines() {
            // Sentinel is the *full* match expression as it appears in the
            // real code: leading 4-space indent + trailing `{`. Tighter than
            // a substring search so this function's own test-code literals
            // (e.g. `line.contains("match (section.as_str(), key.as_str())")`
            // as a string argument) don't accidentally trip the sentinel.
            if line == "    match (section.as_str(), key.as_str()) {" {
                in_match = true;
                out.push('\n');
                continue;
            }
            if in_match && line.trim_start().starts_with("_ => return Err") {
                in_match = false;
                out.push('\n');
                continue;
            }
            if in_match {
                out.push('\n');
            } else {
                out.push_str(line);
                out.push('\n');
            }
        }
        out
    }

    /// Parse `(\"section\", \"key\")` tuples from the body of the
    /// `match (section.as_str(), key.as_str())` expression in cmd_set_setting.
    fn extract_set_setting_arms(src: &str) -> Vec<(String, String)> {
        let mut arms = Vec::new();
        let mut in_block = false;
        for line in src.lines() {
            // Match the actual match line exactly — see comment in
            // strip_set_setting_match_block for why the looser substring
            // search would self-trigger on this test's own source code.
            if line == "    match (section.as_str(), key.as_str()) {" {
                in_block = true;
                continue;
            }
            if in_block && line.trim_start().starts_with("_ => return Err") {
                break;
            }
            if !in_block {
                continue;
            }
            if let Some(arm) = parse_arm_tuple(line) {
                arms.push(arm);
            }
        }
        arms
    }

    fn parse_arm_tuple(line: &str) -> Option<(String, String)> {
        let trimmed = line.trim();
        let rest = trimmed.strip_prefix('(')?;
        let (section, rest) = extract_quoted(rest)?;
        let rest = rest.trim_start().strip_prefix(',')?.trim_start();
        let (key, rest) = extract_quoted(rest)?;
        let rest = rest.trim_start();
        if !rest.starts_with(')') {
            return None;
        }
        Some((section, key))
    }

    fn extract_quoted(s: &str) -> Option<(String, &str)> {
        let s = s.trim_start().strip_prefix('"')?;
        let end = s.find('"')?;
        Some((s[..end].to_string(), &s[end + 1..]))
    }

    /// Look for callers of `cmd_set_setting(section, key, ...)` in two
    /// shapes: the HTML `invoke('cmd_set_setting', { section: 'X', key: 'Y' })`
    /// pattern and the Rust `cmd_set_setting("X".into(), "Y".into(), ...)`
    /// pattern. Both anchor on `cmd_set_setting` as a lead-in, which avoids
    /// false positives from unrelated code that happens to mention the
    /// section/key literals (e.g. this guard's own INTENTIONAL_ORPHANS const).
    fn arm_has_caller(haystack: &str, section: &str, key: &str) -> bool {
        let section_patterns = [format!("\"{}\"", section), format!("'{}'", section)];
        let key_patterns = [format!("\"{}\"", key), format!("'{}'", key)];
        // Match two specific call shapes only, not any mention of the
        // function name. This avoids false positives from doc comments,
        // error-message strings, and the guard's own prose.
        //   Rust:  cmd_set_setting(
        //   HTML:  'cmd_set_setting'
        let anchors = ["cmd_set_setting(", "'cmd_set_setting'"];
        for anchor in &anchors {
            let mut cursor = 0;
            while let Some(offset) = haystack[cursor..].find(anchor) {
                let abs = cursor + offset;
                // 260-char window covers multi-line HTML invokes — the
                // real call shape spans about 5 lines (invoke + object
                // opening brace + section + key + value + closing). Avoid
                // using real section/key names in this comment; they
                // would self-match via the arm_has_caller scan.
                let window_end = (abs + 260).min(haystack.len());
                let window = &haystack[abs..window_end];
                let has_section = section_patterns.iter().any(|sp| window.contains(sp));
                let has_key = key_patterns.iter().any(|kp| window.contains(kp));
                if has_section && has_key {
                    return true;
                }
                cursor = abs + anchor.len();
            }
        }

        // Some UI surfaces intentionally route repeated settings through
        // helper wrappers rather than spelling out a literal invoke call for
        // every key. Keep this allowlist narrow and explicit so it still
        // proves a real caller exists, rather than downgrading the guard
        // into a generic string search.
        if section == "desktop_context" {
            let wrapper_anchors = ["wireDesktopContextToggle(", "wireDesktopContextList("];
            for anchor in &wrapper_anchors {
                let mut cursor = 0;
                while let Some(offset) = haystack[cursor..].find(anchor) {
                    let abs = cursor + offset;
                    let window_end = (abs + 180).min(haystack.len());
                    let window = &haystack[abs..window_end];
                    if key_patterns.iter().any(|pattern| window.contains(pattern)) {
                        return true;
                    }
                    cursor = abs + anchor.len();
                }
            }
        }

        false
    }

    fn walk_with_extensions(root: &str, exts: &[&str]) -> Vec<std::path::PathBuf> {
        walkdir::WalkDir::new(root)
            .into_iter()
            .filter_map(|e| e.ok())
            .filter(|e| {
                e.path()
                    .extension()
                    .and_then(|s| s.to_str())
                    .is_some_and(|ext| exts.contains(&ext))
            })
            .map(|e| e.path().to_path_buf())
            .collect()
    }

    #[test]
    fn preserve_failed_capture_moves_audio_into_failed_captures() {
        let dir = TempDir::new().unwrap();
        let config = Config {
            output_dir: dir.path().join("meetings"),
            ..Config::default()
        };
        let wav = dir.path().join("current.wav");
        std::fs::write(&wav, vec![1_u8; 256]).unwrap();

        let preserved = preserve_failed_capture(&wav, &config).unwrap();

        assert!(!wav.exists());
        assert!(preserved.exists());
        assert!(preserved.starts_with(config.output_dir.join("failed-captures")));
    }

    /// Regression for issue #216: when the native call helper's .mov is moved
    /// to failed-captures because it was truncated, the paired per-source PCM
    /// stems must travel with it (with matching new basenames) so that
    /// `diarize::discover_stems` finds them on retry.
    #[test]
    fn preserve_failed_capture_path_rescues_paired_stems() {
        let dir = TempDir::new().unwrap();
        let config = Config {
            output_dir: dir.path().join("meetings"),
            ..Config::default()
        };
        let native_captures = dir.path().join("native-captures");
        std::fs::create_dir_all(&native_captures).unwrap();
        let mov = native_captures.join("2026-05-11-103342-call.mov");
        std::fs::write(&mov, vec![1_u8; 1024]).unwrap();
        let voice_stem = native_captures.join("2026-05-11-103342-call.voice.wav");
        let system_stem = native_captures.join("2026-05-11-103342-call.system.wav");
        // Stems must be above MIN_VIABLE_STEM_BYTES to be rescued (#236).
        // Use 200 KB which represents ~1 s of audio, well above the cutoff.
        std::fs::write(&voice_stem, vec![2_u8; 200_000]).unwrap();
        std::fs::write(&system_stem, vec![3_u8; 200_000]).unwrap();

        let preserved = preserve_failed_capture_path(&mov, &config).expect("preserve");

        assert!(!mov.exists(), "original .mov should be moved");
        assert!(!voice_stem.exists(), "voice stem should be moved");
        assert!(!system_stem.exists(), "system stem should be moved");
        assert!(preserved.exists());
        assert_eq!(preserved.extension().and_then(|e| e.to_str()), Some("mov"));

        // discover_stems looks for `<basename>.voice.wav` next to the audio.
        // Verify the rescued stems use the SAME new basename as the .mov.
        let dest_stem = preserved
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap()
            .to_string();
        let dest_dir = preserved.parent().unwrap();
        assert!(
            dest_dir.join(format!("{}.voice.wav", dest_stem)).exists(),
            "voice stem should be rescued next to the .mov with matching basename"
        );
        assert!(
            dest_dir.join(format!("{}.system.wav", dest_stem)).exists(),
            "system stem should be rescued next to the .mov with matching basename"
        );
    }

    /// When the .mov has no paired stems (e.g. legacy capture path), preserve
    /// must still move the .mov successfully — stem rescue is best-effort.
    #[test]
    fn preserve_failed_capture_path_works_without_paired_stems() {
        let dir = TempDir::new().unwrap();
        let config = Config {
            output_dir: dir.path().join("meetings"),
            ..Config::default()
        };
        let mov = dir.path().join("2026-05-11-103342-call.mov");
        std::fs::write(&mov, vec![1_u8; 1024]).unwrap();

        let preserved = preserve_failed_capture_path(&mov, &config).expect("preserve");

        assert!(!mov.exists());
        assert!(preserved.exists());
    }

    /// If a destination slot somehow exists when copy_no_clobber_nofollow
    /// runs (TOCTOU race past unique_failed_capture_basename, or an
    /// aborted prior run left a file), we must refuse to overwrite.
    /// The preexisting artifact stays intact and the new preserve returns
    /// None so the caller leaves the source on disk for manual recovery.
    #[test]
    fn preserve_failed_capture_path_refuses_to_clobber_existing_dest() {
        let dir = TempDir::new().unwrap();
        let config = Config {
            output_dir: dir.path().join("meetings"),
            ..Config::default()
        };
        let failed_dir = config.output_dir.join("failed-captures");
        std::fs::create_dir_all(&failed_dir).unwrap();

        // Plant a file at EVERY basename slot unique_failed_capture_basename
        // would pick (primary + 999 fallbacks) — extreme but proves the
        // copy_no_clobber_nofollow contract: if there's no free slot AND no
        // basename-generation path leads to a free slot, preserve gives up
        // rather than overwriting. In practice we expect the basename
        // picker to find a free slot. This test forces the no-clobber path
        // to trigger by saturating slots.
        let ts = chrono::Local::now().format("%Y-%m-%d-%H%M%S").to_string();
        let primary = format!("{}-capture", ts);
        std::fs::write(failed_dir.join(format!("{}.mov", primary)), b"prior").unwrap();
        for n in 2..1000 {
            let alt = format!("{}-capture-{}", ts, n);
            std::fs::write(failed_dir.join(format!("{}.mov", alt)), b"prior").unwrap();
        }

        let mov = dir.path().join("call.mov");
        std::fs::write(&mov, vec![9_u8; 1024]).unwrap();

        let result = preserve_failed_capture_path(&mov, &config);

        assert!(
            result.is_none(),
            "preserve must give up rather than clobber an existing artifact"
        );
        assert!(
            mov.exists(),
            "source .mov must stay intact when preserve gives up"
        );
        // Confirm a planted file is unchanged.
        let planted = failed_dir.join(format!("{}.mov", primary));
        assert_eq!(std::fs::read(&planted).unwrap(), b"prior");
    }

    /// Same-second failures must not silently overwrite a previously
    /// preserved capture. `unique_failed_capture_basename` should pick a
    /// distinct name when the timestamped primary is already taken.
    #[test]
    fn preserve_failed_capture_path_avoids_same_second_collision() {
        let dir = TempDir::new().unwrap();
        let config = Config {
            output_dir: dir.path().join("meetings"),
            ..Config::default()
        };
        let native_captures = dir.path().join("native-captures");
        std::fs::create_dir_all(&native_captures).unwrap();

        let mov_a = native_captures.join("first-call.mov");
        std::fs::write(&mov_a, vec![1_u8; 1024]).unwrap();
        let preserved_a = preserve_failed_capture_path(&mov_a, &config).expect("preserve a");

        let mov_b = native_captures.join("second-call.mov");
        std::fs::write(&mov_b, vec![2_u8; 1024]).unwrap();
        let preserved_b = preserve_failed_capture_path(&mov_b, &config).expect("preserve b");

        assert_ne!(
            preserved_a, preserved_b,
            "two preserves in the same second must not produce the same path"
        );
        assert!(preserved_a.exists(), "first preserved file must survive");
        assert!(preserved_b.exists(), "second preserved file must exist");

        // Content sanity: A was 1s, B was 2s; both should be intact.
        let a = std::fs::read(&preserved_a).unwrap();
        let b = std::fs::read(&preserved_b).unwrap();
        assert_eq!(a[0], 1, "preserved A must keep its content");
        assert_eq!(
            b[0], 2,
            "preserved B must keep its content (not overwritten by A)"
        );
    }

    /// If one stem rescue fails partway through (system stem copy errors),
    /// the function must roll back the partial copy of the voice stem AND
    /// leave the originals in place. The opposite — deleting voice while
    /// leaving system orphaned — produces a `(voice=missing, system=present)`
    /// pair that `discover_stem_plan` rejects unless system exists, which
    /// silently breaks recovery.
    #[test]
    #[cfg(unix)]
    fn rescue_paired_stems_rolls_back_partial_copy() {
        let dir = TempDir::new().unwrap();
        let failed_dir = dir.path().join("failed-captures");
        std::fs::create_dir_all(&failed_dir).unwrap();
        let native_captures = dir.path().join("native-captures");
        std::fs::create_dir_all(&native_captures).unwrap();

        let mov = native_captures.join("call.mov");
        std::fs::write(&mov, vec![1_u8; 1024]).unwrap();
        let voice_stem = native_captures.join("call.voice.wav");
        let system_stem = native_captures.join("call.system.wav");
        // Use 200 KB stems to clear MIN_VIABLE_STEM_BYTES (#236).
        std::fs::write(&voice_stem, vec![2_u8; 200_000]).unwrap();
        std::fs::write(&system_stem, vec![3_u8; 200_000]).unwrap();

        // Force the second copy (system) to fail by pre-creating its
        // destination as a directory, which `fs::copy` cannot overwrite.
        // Phase order in `rescue_paired_stems` walks ["voice", "system"], so
        // voice copies successfully and then system fails and we roll back.
        let probe_basename = "rescue-rollback-probe";
        let system_dest_blocker = failed_dir.join(format!("{}.system.wav", probe_basename));
        std::fs::create_dir(&system_dest_blocker).expect("create blocker dir");

        rescue_paired_stems(&mov, &failed_dir, probe_basename);

        // Originals must still be on disk: nothing was committed.
        assert!(
            voice_stem.exists(),
            "voice original must remain when rescue rolls back"
        );
        assert!(
            system_stem.exists(),
            "system original must remain when rescue rolls back"
        );
        // The voice copy that succeeded in phase 2 must have been removed.
        let voice_partial = failed_dir.join(format!("{}.voice.wav", probe_basename));
        assert!(
            !voice_partial.exists(),
            "partial voice copy must be rolled back (was at {})",
            voice_partial.display()
        );
    }

    /// Symlink stems are not real audio data. Following them via
    /// `metadata()` would let an attacker (or a misconfigured environment)
    /// trick rescue into copying arbitrary file contents into
    /// `failed-captures/` and unlinking the symlink target's path entry.
    /// Use `symlink_metadata` and skip non-regular entries.
    #[test]
    #[cfg(unix)]
    fn rescue_paired_stems_skips_symlink_siblings() {
        let dir = TempDir::new().unwrap();
        let native_captures = dir.path().join("native-captures");
        std::fs::create_dir_all(&native_captures).unwrap();
        let failed_dir = dir.path().join("failed-captures");
        std::fs::create_dir_all(&failed_dir).unwrap();

        let mov = native_captures.join("call.mov");
        std::fs::write(&mov, vec![1_u8; 1024]).unwrap();

        // Symlink the voice stem to an arbitrary file outside native-captures.
        let outside_secret = dir.path().join("outside-secret.txt");
        std::fs::write(&outside_secret, b"sensitive contents").unwrap();
        let voice_link = native_captures.join("call.voice.wav");
        std::os::unix::fs::symlink(&outside_secret, &voice_link).unwrap();

        // Real system stem above MIN_VIABLE_STEM_BYTES so the function has
        // something to attempt (#236).
        let system_stem = native_captures.join("call.system.wav");
        std::fs::write(&system_stem, vec![3_u8; 200_000]).unwrap();

        rescue_paired_stems(&mov, &failed_dir, "rescued");

        // The symlinked voice stem MUST NOT be copied or unlinked.
        assert!(
            voice_link.exists(),
            "symlinked voice stem must be left alone"
        );
        assert!(
            outside_secret.exists(),
            "symlink target must not be touched"
        );
        let voice_dest = failed_dir.join("rescued.voice.wav");
        assert!(
            !voice_dest.exists(),
            "symlinked voice must NOT be materialized in failed-captures"
        );

        // The real system stem should still rescue successfully (single-stem
        // is a valid plan for diarize::discover_stem_plan).
        let system_dest = failed_dir.join("rescued.system.wav");
        assert!(
            system_dest.exists(),
            "real system stem should rescue independently"
        );
        assert!(
            !system_stem.exists(),
            "system original should be moved on successful rescue"
        );
    }

    /// Empty paired stems (zero-length placeholder files) should not be moved
    /// alongside; only stems with actual data count as rescuable.
    #[test]
    fn preserve_failed_capture_path_skips_empty_paired_stems() {
        let dir = TempDir::new().unwrap();
        let config = Config {
            output_dir: dir.path().join("meetings"),
            ..Config::default()
        };
        let native_captures = dir.path().join("native-captures");
        std::fs::create_dir_all(&native_captures).unwrap();
        let mov = native_captures.join("2026-05-11-103342-call.mov");
        std::fs::write(&mov, vec![1_u8; 1024]).unwrap();
        let voice_stem = native_captures.join("2026-05-11-103342-call.voice.wav");
        let system_stem = native_captures.join("2026-05-11-103342-call.system.wav");
        std::fs::write(&voice_stem, Vec::<u8>::new()).unwrap();
        std::fs::write(&system_stem, Vec::<u8>::new()).unwrap();

        let preserved = preserve_failed_capture_path(&mov, &config).expect("preserve");
        let dest_stem = preserved.file_stem().and_then(|s| s.to_str()).unwrap();
        let dest_dir = preserved.parent().unwrap();

        // Zero-length stems should be left alone — moving them would just
        // create misleading "stems exist" signals for discover_stems.
        assert!(voice_stem.exists());
        assert!(system_stem.exists());
        assert!(!dest_dir.join(format!("{}.voice.wav", dest_stem)).exists());
        assert!(!dest_dir.join(format!("{}.system.wav", dest_stem)).exists());
    }

    /// Abort-at-start native captures (#236) leave tiny orphan stems
    /// (sjunnesson observed 19 KB and 38 KB pairs) next to ~1.9 KB .mov
    /// stubs. Rescue must skip stems below MIN_VIABLE_STEM_BYTES so the
    /// user does not get useless `failed-captures/` entries with 0.1-0.2 s
    /// of garbage audio. The .mov itself still preserves.
    #[test]
    fn preserve_failed_capture_path_skips_subthreshold_paired_stems() {
        let dir = TempDir::new().unwrap();
        let config = Config {
            output_dir: dir.path().join("meetings"),
            ..Config::default()
        };
        let native_captures = dir.path().join("native-captures");
        std::fs::create_dir_all(&native_captures).unwrap();

        let mov = native_captures.join("2026-04-07-130235-capture.mov");
        std::fs::write(&mov, vec![1_u8; 1908]).unwrap();
        let voice_stem = native_captures.join("2026-04-07-130235-capture.voice.wav");
        let system_stem = native_captures.join("2026-04-07-130235-capture.system.wav");
        // Realistic abort-at-start sizes from sjunnesson's report on #236.
        std::fs::write(&voice_stem, vec![2_u8; 38_656]).unwrap();
        std::fs::write(&system_stem, vec![3_u8; 19_456]).unwrap();

        let preserved = preserve_failed_capture_path(&mov, &config).expect("preserve");
        let dest_stem = preserved.file_stem().and_then(|s| s.to_str()).unwrap();
        let dest_dir = preserved.parent().unwrap();

        // The .mov itself still preserves (it's the only failure signal).
        assert!(preserved.exists(), "the .mov stub must still be preserved");

        // Sub-threshold stems must stay in native-captures untouched, and
        // must NOT appear in failed-captures.
        assert!(
            voice_stem.exists(),
            "sub-threshold voice stem original must stay in place"
        );
        assert!(
            system_stem.exists(),
            "sub-threshold system stem original must stay in place"
        );
        assert!(
            !dest_dir.join(format!("{}.voice.wav", dest_stem)).exists(),
            "sub-threshold voice stem must NOT be moved to failed-captures"
        );
        assert!(
            !dest_dir.join(format!("{}.system.wav", dest_stem)).exists(),
            "sub-threshold system stem must NOT be moved to failed-captures"
        );
    }

    /// Helper: write a minimal RIFF/WAVE/fmt header carrying the given
    /// sample rate (float32 mono), then pad with zeros to the requested
    /// total file size. The rescue code only reads RIFF/WAVE/`fmt `
    /// markers plus `byte_rate` at offset 28, so the rest of the file
    /// does not need to be a valid PCM payload.
    ///
    /// Note: AVAudioFile on macOS may write WAVE_FORMAT_EXTENSIBLE
    /// (`0xFFFE`) for float32 mono in some cases, but byte_rate sits at
    /// the same absolute offset 28 either way. The chunk layout is
    /// chunk_id 4 plus chunk_size 4 plus format 2 plus channels 2 plus
    /// sample_rate 4, totaling 16 bytes after the RIFF header. This
    /// fixture uses tag `3` (IEEE float) for readability; the threshold
    /// logic is identical for both tags.
    #[allow(clippy::cast_possible_truncation, clippy::cast_lossless)]
    fn write_test_wav(path: &Path, sample_rate: u32, total_bytes: usize) {
        use std::io::Write;
        let channels: u16 = 1;
        let bits_per_sample: u16 = 32;
        let byte_rate: u32 = sample_rate * (channels as u32) * ((bits_per_sample / 8) as u32);
        let block_align: u16 = channels * (bits_per_sample / 8);

        let mut header = Vec::with_capacity(44);
        header.extend_from_slice(b"RIFF");
        header.extend_from_slice(&[0; 4]); // file size minus 8 (placeholder)
        header.extend_from_slice(b"WAVE");
        header.extend_from_slice(b"fmt ");
        header.extend_from_slice(&16u32.to_le_bytes());
        header.extend_from_slice(&3u16.to_le_bytes()); // float
        header.extend_from_slice(&channels.to_le_bytes());
        header.extend_from_slice(&sample_rate.to_le_bytes());
        header.extend_from_slice(&byte_rate.to_le_bytes());
        header.extend_from_slice(&block_align.to_le_bytes());
        header.extend_from_slice(&bits_per_sample.to_le_bytes());
        header.extend_from_slice(b"data");
        header.extend_from_slice(&[0; 4]); // data chunk size (placeholder)

        let mut file = std::fs::File::create(path).expect("create wav");
        file.write_all(&header).expect("write header");
        let pad = total_bytes.saturating_sub(header.len());
        if pad > 0 {
            file.write_all(&vec![0u8; pad]).expect("pad");
        }
    }

    #[test]
    fn native_call_processing_input_uses_voice_stem_when_system_missing() {
        let dir = TempDir::new().unwrap();
        let mov = dir.path().join("2026-05-19-120148-call.mov");
        std::fs::write(&mov, b"mov").unwrap();
        let voice = dir.path().join("2026-05-19-120148-call.voice.wav");
        write_test_wav(&voice, 48_000, 200_000);

        let input = native_call_processing_input(&mov).expect("processing input");

        assert_eq!(input.path, voice);
        assert!(
            input
                .recovery_warning
                .as_deref()
                .is_some_and(|warning| warning.contains("system-audio stem")),
            "warning should explain the missing system side"
        );
    }

    #[test]
    fn native_call_processing_input_creates_mov_anchor_for_paired_stems() {
        let dir = TempDir::new().unwrap();
        let mov = dir.path().join("2026-05-19-120148-call.mov");
        let voice = dir.path().join("2026-05-19-120148-call.voice.wav");
        let system = dir.path().join("2026-05-19-120148-call.system.wav");
        write_test_wav(&voice, 48_000, 200_000);
        write_test_wav(&system, 48_000, 200_000);

        let input = native_call_processing_input(&mov).expect("processing input");

        assert_eq!(input.path, mov);
        assert!(
            input.path.exists(),
            "missing .mov should be recreated as a stem anchor"
        );
        assert!(
            input
                .recovery_warning
                .as_deref()
                .is_some_and(|warning| warning.contains("recovered PCM stems")),
            "warning should explain stem-anchor recovery"
        );
    }

    /// Malformed WAV files (non-`fmt `-first chunk, zero byte_rate, or
    /// outright unreadable header) must fall back to the 48 kHz default
    /// threshold rather than letting every stem pass with a 0-byte cutoff
    /// or silently using garbage from offset 28. Codex round 2 review
    /// flagged both gaps; this test locks the fallback behavior in.
    #[test]
    fn read_wav_byte_rate_rejects_malformed_headers() {
        let dir = TempDir::new().unwrap();

        // Not a RIFF file at all.
        let plain = dir.path().join("not-a-wav.wav");
        std::fs::write(&plain, b"this is not wav content").unwrap();
        assert!(read_wav_byte_rate(&plain).is_none());

        // RIFF/WAVE but first chunk is JUNK, not `fmt ` (BWF-style).
        let bwf = dir.path().join("bwf.wav");
        let mut buf = Vec::with_capacity(32);
        buf.extend_from_slice(b"RIFF");
        buf.extend_from_slice(&[0; 4]);
        buf.extend_from_slice(b"WAVE");
        buf.extend_from_slice(b"JUNK");
        buf.extend_from_slice(&[0; 16]);
        std::fs::write(&bwf, &buf).unwrap();
        assert!(read_wav_byte_rate(&bwf).is_none());

        // RIFF/WAVE/fmt with explicit byte_rate = 0.
        let zero_rate = dir.path().join("zero-rate.wav");
        let mut buf = Vec::with_capacity(32);
        buf.extend_from_slice(b"RIFF");
        buf.extend_from_slice(&[0; 4]);
        buf.extend_from_slice(b"WAVE");
        buf.extend_from_slice(b"fmt ");
        buf.extend_from_slice(&16u32.to_le_bytes());
        buf.extend_from_slice(&3u16.to_le_bytes());
        buf.extend_from_slice(&1u16.to_le_bytes());
        buf.extend_from_slice(&48_000u32.to_le_bytes());
        buf.extend_from_slice(&0u32.to_le_bytes());
        std::fs::write(&zero_rate, &buf).unwrap();
        assert!(read_wav_byte_rate(&zero_rate).is_none());

        // Well-formed header — should return the byte_rate we wrote.
        let good = dir.path().join("good.wav");
        write_test_wav(&good, 48_000, 200_000);
        assert_eq!(read_wav_byte_rate(&good), Some(192_000));
    }

    /// At 16 kHz float32 mono, byte_rate is 64_000. A 0.5 s threshold is
    /// 32_000 bytes. A 40_000-byte stem at 16 kHz must rescue (above
    /// threshold for that rate), even though it falls below the 96 KB
    /// number that would apply at 48 kHz. Verifies the threshold scales
    /// per-file from the WAV header rather than assuming 48 kHz.
    #[test]
    fn rescue_paired_stems_threshold_scales_with_wav_byte_rate() {
        let dir = TempDir::new().unwrap();
        let failed_dir = dir.path().join("failed-captures");
        std::fs::create_dir_all(&failed_dir).unwrap();
        let native_captures = dir.path().join("native-captures");
        std::fs::create_dir_all(&native_captures).unwrap();

        let mov = native_captures.join("call.mov");
        std::fs::write(&mov, vec![1_u8; 1024]).unwrap();
        // 40_000 bytes at 16 kHz float32 mono = ~0.625 s of audio.
        // Above the 0.5 s threshold for that sample rate (32_000 byte_rate
        // * 0.5 = 32_000 byte minimum), below the 96_000 bytes that would
        // apply if we hardcoded 48 kHz.
        write_test_wav(&native_captures.join("call.voice.wav"), 16_000, 40_000);
        write_test_wav(&native_captures.join("call.system.wav"), 16_000, 40_000);

        rescue_paired_stems(&mov, &failed_dir, "rescued");

        assert!(
            failed_dir.join("rescued.voice.wav").exists(),
            "16 kHz stem above 0.5 s should rescue"
        );
        assert!(
            failed_dir.join("rescued.system.wav").exists(),
            "16 kHz stem above 0.5 s should rescue"
        );
    }

    /// Discovery rejects voice-only stem plans (`SystemStemOnly` is valid,
    /// `VoiceStemOnly` is not). If voice clears the threshold but system
    /// is sub-threshold, the rescue must drop voice too rather than
    /// produce a voice-only artifact that diarize cannot consume. Codex
    /// review on #236 caught this asymmetric-skip gap.
    #[test]
    fn rescue_paired_stems_drops_voice_when_system_subthreshold() {
        let dir = TempDir::new().unwrap();
        let failed_dir = dir.path().join("failed-captures");
        std::fs::create_dir_all(&failed_dir).unwrap();
        let native_captures = dir.path().join("native-captures");
        std::fs::create_dir_all(&native_captures).unwrap();

        let mov = native_captures.join("call.mov");
        std::fs::write(&mov, vec![1_u8; 1024]).unwrap();

        // Voice is healthy 1 s at 48 kHz, system is sub-threshold 0.1 s.
        let voice_stem = native_captures.join("call.voice.wav");
        let system_stem = native_captures.join("call.system.wav");
        write_test_wav(&voice_stem, 48_000, 200_000);
        write_test_wav(&system_stem, 48_000, 20_000);

        rescue_paired_stems(&mov, &failed_dir, "rescued");

        // Voice must NOT have moved alone (would be a VoiceStemOnly plan,
        // which discover_stem_plan rejects).
        assert!(
            voice_stem.exists(),
            "voice original must stay in place when system is sub-threshold"
        );
        assert!(
            !failed_dir.join("rescued.voice.wav").exists(),
            "voice must NOT rescue alone (would be invalid voice-only plan)"
        );
        // System sub-threshold stays in place too (skipped earlier).
        assert!(
            system_stem.exists(),
            "system sub-threshold stem must stay in place"
        );
        assert!(
            !failed_dir.join("rescued.system.wav").exists(),
            "system sub-threshold must not rescue"
        );
    }

    /// Inverse of the above: when voice is sub-threshold and system is
    /// healthy, system alone IS a valid plan (`SystemStemOnly`) and must
    /// still rescue. Verifies the asymmetric guard is one-directional.
    #[test]
    fn rescue_paired_stems_keeps_system_when_voice_subthreshold() {
        let dir = TempDir::new().unwrap();
        let failed_dir = dir.path().join("failed-captures");
        std::fs::create_dir_all(&failed_dir).unwrap();
        let native_captures = dir.path().join("native-captures");
        std::fs::create_dir_all(&native_captures).unwrap();

        let mov = native_captures.join("call.mov");
        std::fs::write(&mov, vec![1_u8; 1024]).unwrap();

        let voice_stem = native_captures.join("call.voice.wav");
        let system_stem = native_captures.join("call.system.wav");
        write_test_wav(&voice_stem, 48_000, 20_000);
        write_test_wav(&system_stem, 48_000, 200_000);

        rescue_paired_stems(&mov, &failed_dir, "rescued");

        // System rescues alone (valid SystemStemOnly).
        assert!(
            !system_stem.exists(),
            "healthy system stem must be moved on successful rescue"
        );
        assert!(
            failed_dir.join("rescued.system.wav").exists(),
            "healthy system stem must rescue when voice is sub-threshold"
        );
        // Voice sub-threshold stays in place.
        assert!(
            voice_stem.exists(),
            "voice sub-threshold stem must stay in place"
        );
        assert!(
            !failed_dir.join("rescued.voice.wav").exists(),
            "voice sub-threshold must not rescue"
        );
    }

    #[test]
    fn desktop_context_limited_matches_platform_behavior() {
        let mut config = Config::default();
        config.desktop_context.enabled = true;
        config.desktop_context.capture_window_titles = false;
        config.desktop_context.capture_browser_context = true;

        #[cfg(target_os = "macos")]
        {
            assert!(desktop_context_limited(&config, false));
            assert!(!desktop_context_limited(&config, true));
        }

        #[cfg(not(target_os = "macos"))]
        {
            assert!(!desktop_context_limited(&config, false));
            assert!(!desktop_context_limited(&config, true));
        }
    }

    #[test]
    fn desktop_context_state_reports_unsupported_before_idle_or_recording() {
        let mut config = Config::default();
        config.desktop_context.enabled = true;

        assert_eq!(desktop_context_state(&config, false, false), "unsupported");
        assert_eq!(desktop_context_state(&config, false, true), "unsupported");
        assert_eq!(desktop_context_state(&config, true, false), "idle");
        assert_eq!(desktop_context_state(&config, true, true), "recording");

        config.desktop_context.enabled = false;
        assert_eq!(desktop_context_state(&config, false, true), "off");
    }

    #[test]
    fn desktop_settings_reject_apple_speech_engine_selection() {
        with_temp_home(|_| {
            let error = cmd_set_setting(
                "transcription".into(),
                "engine".into(),
                "apple-speech".into(),
            )
            .unwrap_err();

            assert!(error.contains("standalone live transcript"));
        });
    }

    #[test]
    fn desktop_settings_accept_live_transcript_backend_selection() {
        with_temp_home(|_| {
            cmd_set_setting(
                "live_transcript".into(),
                "backend".into(),
                "apple-speech".into(),
            )
            .unwrap();

            let config = Config::load();
            assert_eq!(config.live_transcript.backend, "apple-speech");
        });
    }

    #[test]
    fn desktop_settings_reject_unknown_live_transcript_backend() {
        with_temp_home(|_| {
            let error = cmd_set_setting("live_transcript".into(), "backend".into(), "laser".into())
                .unwrap_err();

            assert!(error.contains("unknown live transcript backend"));
        });
    }

    #[test]
    fn desktop_settings_accept_dictation_backend_selection() {
        with_temp_home(|_| {
            cmd_set_setting("dictation".into(), "backend".into(), "apple-speech".into()).unwrap();

            let config = Config::load();
            assert_eq!(config.dictation.backend, "apple-speech");
            assert_eq!(config.transcription.engine, "whisper");

            cmd_set_setting("dictation".into(), "backend".into(), "parakeet".into()).unwrap();

            let config = Config::load();
            assert_eq!(config.dictation.backend, "parakeet");
            assert_eq!(config.transcription.engine, "whisper");
        });
    }

    #[test]
    fn desktop_settings_reject_unknown_dictation_backend() {
        with_temp_home(|_| {
            let error =
                cmd_set_setting("dictation".into(), "backend".into(), "laser".into()).unwrap_err();

            assert!(error.contains("unknown dictation backend"));
        });
    }

    #[test]
    fn desktop_settings_normalize_decorated_recording_device_name() {
        with_temp_home(|_| {
            cmd_set_setting(
                "recording".into(),
                "device".into(),
                "Ground Control (16000Hz, 1 ch)".into(),
            )
            .unwrap();

            let config = Config::load();
            assert_eq!(config.recording.device.as_deref(), Some("Ground Control"));
        });
    }

    #[test]
    fn primary_setup_surface_switches_to_live_parakeet_when_batch_is_ready() {
        let dir = TempDir::new().unwrap();
        let mut config = Config::default();
        config.transcription.engine = "whisper".into();
        config.transcription.model_path = dir.path().to_path_buf();
        config.live_transcript.backend = "parakeet".into();

        let whisper_model = model_file_for_config(&config);
        std::fs::create_dir_all(whisper_model.parent().unwrap()).unwrap();
        std::fs::write(&whisper_model, b"model").unwrap();

        let batch_readiness = batch_transcription_readiness_view(&config);
        let live_readiness = standalone_live_readiness_view(&config);
        let progress = ActivationProgress::default();
        let batch_setup = transcription_surface_setup_view(
            &config,
            "batch",
            &batch_readiness,
            &progress,
            false,
            false,
            false,
        );
        let standalone_live_setup = transcription_surface_setup_view(
            &config,
            "standalone-live",
            &live_readiness,
            &progress,
            false,
            false,
            false,
        );
        let primary = primary_setup_surface(&batch_setup, &standalone_live_setup);

        assert!(!batch_setup.needs_setup);
        assert!(standalone_live_setup.needs_setup);
        assert_eq!(primary.engine, "parakeet");
        assert_eq!(primary.activation.next_action, "setup-parakeet");
    }

    #[test]
    fn primary_setup_surface_keeps_batch_when_batch_needs_setup() {
        let dir = TempDir::new().unwrap();
        let mut config = Config::default();
        config.transcription.model_path = dir.path().to_path_buf();
        let batch_readiness = batch_transcription_readiness_view(&config);
        let live_readiness = standalone_live_readiness_view(&config);
        let progress = ActivationProgress::default();
        let batch_setup = transcription_surface_setup_view(
            &config,
            "batch",
            &batch_readiness,
            &progress,
            false,
            false,
            false,
        );
        let standalone_live_setup = transcription_surface_setup_view(
            &config,
            "standalone-live",
            &live_readiness,
            &progress,
            false,
            false,
            false,
        );
        let primary = primary_setup_surface(&batch_setup, &standalone_live_setup);

        assert!(batch_setup.needs_setup);
        assert_eq!(primary.engine, batch_setup.engine);
        assert_eq!(primary.surface, "batch");
    }

    #[test]
    fn standalone_live_readiness_uses_live_whisper_model_name() {
        let dir = TempDir::new().unwrap();
        let mut config = Config::default();
        config.transcription.model_path = dir.path().to_path_buf();
        config.transcription.model = "base".into();
        config.live_transcript.backend = "whisper".into();
        config.live_transcript.model = "small".into();

        let batch_model = whisper_model_file(&config, &config.transcription.model);
        std::fs::create_dir_all(batch_model.parent().unwrap()).unwrap();
        std::fs::write(&batch_model, b"base-model").unwrap();

        let live = standalone_live_readiness_view(&config);

        assert_eq!(live.model_name, "small");
        assert!(!live.ready);
        assert!(live.detail.contains("ggml-small.bin"));
    }

    #[test]
    fn delete_meeting_artifacts_removes_all_audio_sidecars() {
        let dir = TempDir::new().unwrap();
        let md = dir.path().join("2026-04-01-artifacts.md");
        std::fs::write(&md, "---\ntitle: Test\n---\n").unwrap();
        let artifacts = minutes_core::capture::meeting_audio_artifact_paths(&md);
        for path in &artifacts {
            std::fs::write(path, "artifact").unwrap();
        }

        delete_meeting_artifacts(&md, &artifacts, true).unwrap();

        assert!(!md.exists());
        for path in &artifacts {
            assert!(!path.exists(), "{} should be removed", path.display());
        }
    }

    #[test]
    fn archive_meeting_artifacts_moves_all_audio_sidecars() {
        let dir = TempDir::new().unwrap();
        let archive_dir = dir.path().join("archive");
        std::fs::create_dir_all(&archive_dir).unwrap();
        let md = dir.path().join("2026-04-01-artifacts.md");
        std::fs::write(&md, "---\ntitle: Test\n---\n").unwrap();
        let artifacts = minutes_core::capture::meeting_audio_artifact_paths(&md);
        for path in &artifacts {
            std::fs::write(path, "artifact").unwrap();
        }

        archive_meeting_artifacts(&md, &archive_dir, &artifacts, true).unwrap();

        assert!(!md.exists());
        assert!(archive_dir.join("2026-04-01-artifacts.md").exists());
        for path in &artifacts {
            assert!(!path.exists(), "{} should be moved", path.display());
            assert!(
                archive_dir.join(path.file_name().unwrap()).exists(),
                "{} should be archived",
                path.display()
            );
        }
    }

    #[test]
    fn wait_for_path_removal_returns_false_after_timeout() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("still-there.pid");
        std::fs::write(&path, "123").unwrap();

        let removed = wait_for_path_removal(&path, Some(std::time::Duration::from_millis(50)));

        assert!(!removed);
        assert!(path.exists());
    }

    #[test]
    fn wait_for_path_removal_returns_true_when_file_disappears() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("gone-soon.pid");
        std::fs::write(&path, "123").unwrap();

        let path_for_thread = path.clone();
        std::thread::spawn(move || {
            std::thread::sleep(std::time::Duration::from_millis(50));
            std::fs::remove_file(path_for_thread).unwrap();
        });

        let removed = wait_for_path_removal(&path, Some(std::time::Duration::from_secs(1)));

        assert!(removed);
        assert!(!path.exists());
    }

    #[test]
    fn stage_label_maps_pipeline_stage_to_user_facing_copy() {
        assert_eq!(
            stage_label(
                minutes_core::pipeline::PipelineStage::Transcribing,
                CaptureMode::QuickThought
            ),
            "Transcribing quick thought"
        );
        assert_eq!(
            stage_label(
                minutes_core::pipeline::PipelineStage::Saving,
                CaptureMode::Meeting
            ),
            "Saving meeting"
        );
    }

    #[test]
    fn processing_job_view_includes_user_facing_stage_label() {
        let job = minutes_core::jobs::ProcessingJob {
            id: "job-summary".into(),
            title: Some("Design review".into()),
            mode: CaptureMode::Meeting,
            content_type: ContentType::Meeting,
            state: minutes_core::jobs::JobState::Summarizing,
            stage: Some("summarizing".into()),
            output_path: Some("/tmp/design-review.md".into()),
            audio_path: "/tmp/design-review.wav".into(),
            error: None,
            created_at: chrono::Local::now(),
            started_at: Some(chrono::Local::now()),
            finished_at: None,
            notice_dismissed_at: None,
            recording_started_at: None,
            recording_finished_at: None,
            context_session_id: None,
            user_notes: None,
            pre_context: None,
            calendar_event: None,
            template_slug: None,
            word_count: None,
            owner_pid: None,
            retry_count: 0,
            consent: None,
            consent_notice: None,
            recording_health: None,
        };

        let view = processing_job_view(job);

        assert_eq!(view.state, "summarizing");
        assert_eq!(view.stage.as_deref(), Some("summarizing"));
        assert_eq!(
            view.stage_label.as_deref(),
            Some("Generating meeting summary")
        );
    }

    #[test]
    fn parse_optional_string_setting_preserves_auto_detect_state() {
        assert_eq!(parse_optional_string_setting(""), None);
        assert_eq!(parse_optional_string_setting("   "), None);
        assert_eq!(parse_optional_string_setting("en"), Some("en".to_string()));
        assert_eq!(
            parse_optional_string_setting(" es "),
            Some("es".to_string())
        );
    }

    #[test]
    fn desktop_require_consent_returns_confirmation_until_confirmed() {
        let mut config = Config::default();
        config.consent.mode = ConsentMode::Require;
        config.consent.disclosure_script = "Please confirm before recording.".into();

        assert_eq!(
            desktop_recording_consent_required(CaptureMode::Meeting, &config, false).as_deref(),
            Some("Please confirm before recording.")
        );
        assert_eq!(
            desktop_recording_consent_required(CaptureMode::Meeting, &config, true),
            None
        );
        assert_eq!(
            desktop_recording_consent_required(CaptureMode::QuickThought, &config, false),
            None
        );
    }

    #[test]
    fn parse_comma_separated_setting_splits_trims_and_drops_empties() {
        assert_eq!(parse_comma_separated_setting(""), Vec::<String>::new());
        assert_eq!(parse_comma_separated_setting("   "), Vec::<String>::new());
        assert_eq!(parse_comma_separated_setting(","), Vec::<String>::new());
        assert_eq!(
            parse_comma_separated_setting("a@x.com"),
            vec!["a@x.com".to_string()]
        );
        assert_eq!(
            parse_comma_separated_setting("a@x.com, b@y.com"),
            vec!["a@x.com".to_string(), "b@y.com".to_string()]
        );
        assert_eq!(
            parse_comma_separated_setting("  a , ,   b  "),
            vec!["a".to_string(), "b".to_string()]
        );
    }

    #[test]
    fn call_detection_sentinel_toggle_is_idempotent() {
        let mut config = Config::default();
        assert!(!call_detection_has_sentinel(&config, "google-meet"));

        set_call_detection_sentinel(&mut config, "google-meet", true);
        assert!(call_detection_has_sentinel(&config, "google-meet"));

        set_call_detection_sentinel(&mut config, "google-meet", true);
        assert_eq!(
            config
                .call_detection
                .apps
                .iter()
                .filter(|app| app.as_str() == "google-meet")
                .count(),
            1
        );

        set_call_detection_sentinel(&mut config, "google-meet", false);
        assert!(!call_detection_has_sentinel(&config, "google-meet"));
    }

    #[test]
    fn recording_start_error_notice_has_no_openable_path() {
        let notice = recording_start_error_notice("ScreenCaptureKit tap unavailable");

        assert_eq!(notice.kind, "error");
        assert_eq!(notice.title, "Recording not started");
        assert_eq!(notice.path, "");
        assert_eq!(notice.detail, "ScreenCaptureKit tap unavailable");
    }

    #[test]
    fn rejected_call_detect_start_does_not_mutate_auto_stop_state() {
        with_temp_home(|_| {
            let state = test_app_state();
            state.live_transcript_active.store(true, Ordering::Relaxed);
            state
                .call_end_countdown_active
                .store(true, Ordering::Relaxed);
            state
                .call_end_countdown_cancel
                .store(false, Ordering::Relaxed);
            state.call_end_countdown_terminal_state.store(
                CallEndCountdownTerminalState::UserCancelled as u8,
                Ordering::Relaxed,
            );

            let result = prepare_cmd_recording_launch(&state, true);

            assert_eq!(
                result.unwrap_err(),
                "Live transcript in progress — stop it first"
            );
            assert!(!state.starting.load(Ordering::Relaxed));
            assert!(!state
                .recording_started_by_call_detect
                .load(Ordering::Relaxed));
            assert!(state.call_end_countdown_active.load(Ordering::Relaxed));
            assert!(!state.call_end_countdown_cancel.load(Ordering::Relaxed));
            assert_eq!(
                CallEndCountdownTerminalState::from_u8(
                    state
                        .call_end_countdown_terminal_state
                        .load(Ordering::Relaxed)
                ),
                CallEndCountdownTerminalState::UserCancelled
            );
        });
    }

    #[test]
    fn call_detect_start_mutates_auto_stop_state_only_after_reservation() {
        with_temp_home(|_| {
            let state = test_app_state();
            state
                .call_end_countdown_active
                .store(true, Ordering::Relaxed);
            state
                .call_end_countdown_cancel
                .store(false, Ordering::Relaxed);
            state.call_end_countdown_terminal_state.store(
                CallEndCountdownTerminalState::UserCancelled as u8,
                Ordering::Relaxed,
            );

            prepare_cmd_recording_launch(&state, true).unwrap();

            assert!(state.starting.load(Ordering::Relaxed));
            assert!(state
                .recording_started_by_call_detect
                .load(Ordering::Relaxed));
            assert!(!state.call_end_countdown_active.load(Ordering::Relaxed));
            assert!(state.call_end_countdown_cancel.load(Ordering::Relaxed));
            assert_eq!(
                CallEndCountdownTerminalState::from_u8(
                    state
                        .call_end_countdown_terminal_state
                        .load(Ordering::Relaxed)
                ),
                CallEndCountdownTerminalState::None
            );

            state.starting.store(false, Ordering::Relaxed);
        });
    }

    #[test]
    fn rejected_recording_launch_sets_visible_error_notice() {
        let state = test_app_state();

        let error = reject_recording_launch(&state, "Dictation in progress — stop it first".into());

        assert_eq!(error, "Dictation in progress — stop it first");
        let notice = state.latest_output.lock().unwrap().clone().unwrap();
        assert_eq!(notice.kind, "error");
        assert_eq!(notice.title, "Recording not started");
        assert_eq!(notice.path, "");
        assert_eq!(notice.detail, "Dictation in progress — stop it first");
    }

    #[test]
    fn call_detection_teams_web_sentinel_toggle_is_independent() {
        let mut config = Config::default();
        assert!(!call_detection_has_sentinel(&config, "teams-web"));
        assert!(!call_detection_has_sentinel(&config, "google-meet"));

        set_call_detection_sentinel(&mut config, "teams-web", true);
        set_call_detection_sentinel(&mut config, "google-meet", true);
        assert!(call_detection_has_sentinel(&config, "teams-web"));
        assert!(call_detection_has_sentinel(&config, "google-meet"));

        set_call_detection_sentinel(&mut config, "teams-web", false);
        assert!(!call_detection_has_sentinel(&config, "teams-web"));
        assert!(call_detection_has_sentinel(&config, "google-meet"));
    }

    #[test]
    fn desktop_call_capture_route_preserves_selected_mic_and_sets_loopback() {
        let mut config = Config::default();
        config.recording.device = Some("MacBook Pro Microphone".into());

        let armed = arm_desktop_call_capture_route(
            &mut config,
            Some(RecordingIntent::Call),
            Some("MMAudio Device".into()),
        );

        assert!(armed);
        assert!(config.recording.device.is_none());
        let sources = config.recording.sources.unwrap();
        assert_eq!(sources.voice.as_deref(), Some("MacBook Pro Microphone"));
        assert_eq!(sources.call.as_deref(), Some("MMAudio Device"));
    }

    #[test]
    fn desktop_call_capture_route_keeps_existing_call_source() {
        let mut config = Config::default();
        config.recording.device = Some("MacBook Pro Microphone".into());
        config.recording.sources = Some(minutes_core::config::SourcesConfig {
            voice: Some("Studio Mic".into()),
            call: Some("BlackHole 2ch".into()),
        });

        let armed = arm_desktop_call_capture_route(
            &mut config,
            Some(RecordingIntent::Call),
            Some("MMAudio Device".into()),
        );

        assert!(!armed);
        assert_eq!(
            config.recording.device.as_deref(),
            Some("MacBook Pro Microphone")
        );
        let sources = config.recording.sources.unwrap();
        assert_eq!(sources.voice.as_deref(), Some("Studio Mic"));
        assert_eq!(sources.call.as_deref(), Some("BlackHole 2ch"));
    }

    #[test]
    fn set_latest_output_replaces_previous_notice() {
        let latest_output = Arc::new(Mutex::new(None));
        set_latest_output(
            &latest_output,
            Some(OutputNotice {
                kind: "saved".into(),
                title: "Demo".into(),
                path: "/tmp/demo.md".into(),
                detail: "Saved".into(),
                job_id: None,
            }),
        );

        let current = latest_output.lock().unwrap().clone().unwrap();
        assert_eq!(current.title, "Demo");
        assert_eq!(current.path, "/tmp/demo.md");
    }

    #[test]
    fn activation_phase_guides_new_user_to_download_model_first() {
        let progress = ActivationProgress::default();
        let (phase, action) = activation_phase("whisper", &progress, false, false, false, false);

        assert_eq!(phase, "needs-model");
        assert_eq!(action, "download-model");
    }

    #[test]
    fn activation_phase_guides_parakeet_user_to_setup_flow_first() {
        let progress = ActivationProgress::default();
        let (phase, action) = activation_phase("parakeet", &progress, false, false, false, false);

        assert_eq!(phase, "needs-model");
        assert_eq!(action, "setup-parakeet");
    }

    #[test]
    fn activation_phase_guides_user_to_first_recording_after_model_ready() {
        let progress = ActivationProgress {
            model_ready_at: Some("2026-04-09T12:00:00-07:00".into()),
            ..ActivationProgress::default()
        };
        let (phase, action) = activation_phase("whisper", &progress, true, false, false, false);

        assert_eq!(phase, "ready-for-first-recording");
        assert_eq!(action, "start-first-recording");
    }

    #[test]
    fn activation_phase_reports_processing_until_first_artifact_finishes() {
        let progress = ActivationProgress {
            model_ready_at: Some("2026-04-09T12:00:00-07:00".into()),
            first_recording_started_at: Some("2026-04-09T12:01:00-07:00".into()),
            ..ActivationProgress::default()
        };
        let (phase, action) = activation_phase("whisper", &progress, true, false, false, true);

        assert_eq!(phase, "processing-first-artifact");
        assert_eq!(action, "wait-for-first-artifact");
    }

    #[test]
    fn activation_phase_requires_next_step_nudge_after_first_artifact() {
        let progress = ActivationProgress {
            model_ready_at: Some("2026-04-09T12:00:00-07:00".into()),
            first_recording_started_at: Some("2026-04-09T12:01:00-07:00".into()),
            first_artifact_saved_at: Some("2026-04-09T12:02:00-07:00".into()),
            first_artifact_path: Some("/tmp/demo.md".into()),
            ..ActivationProgress::default()
        };
        let (phase, action) = activation_phase("whisper", &progress, true, true, false, false);

        assert_eq!(phase, "first-artifact-saved");
        assert_eq!(action, "show-next-step");
    }

    #[test]
    fn parakeet_status_reports_missing_assets() {
        let dir = TempDir::new().unwrap();
        let mut config = Config::default();
        config.transcription.engine = "parakeet".into();
        config.transcription.model_path = dir.path().to_path_buf();

        let status = parakeet_status_view(&config);
        assert!(!status.ready);
        assert!(!status.model_found);
        assert!(!status.tokenizer_found);
        assert!(
            status
                .issues
                .iter()
                .any(|issue| issue.contains("model assets")),
            "expected missing model issue, got {:?}",
            status.issues
        );
    }

    #[test]
    fn parakeet_status_reports_installed_metadata_across_build_flavors() {
        let dir = TempDir::new().unwrap();
        let mut config = Config::default();
        config.transcription.engine = "parakeet".into();
        config.transcription.model_path = dir.path().to_path_buf();
        config.transcription.parakeet_model = "tdt-ctc-110m".into();
        let binary = if cfg!(windows) {
            which::which("cmd").unwrap()
        } else {
            which::which("sh").unwrap()
        };
        config.transcription.parakeet_binary = binary.display().to_string();

        let install_dir = minutes_core::parakeet::install_dir(&config, "tdt-ctc-110m");
        std::fs::create_dir_all(&install_dir).unwrap();
        let model = install_dir.join("tdt-ctc-110m.safetensors");
        let tokenizer = install_dir.join("tdt-ctc-110m.tokenizer.vocab");
        std::fs::write(&model, b"model").unwrap();
        std::fs::write(&tokenizer, b"tokenizer").unwrap();
        minutes_core::parakeet::write_install_metadata(&config, "tdt-ctc-110m", &model, &tokenizer)
            .unwrap();

        let status = parakeet_status_view(&config);
        assert!(status.model_found);
        assert!(status.tokenizer_found);
        assert!(status.metadata.is_some());
        assert_eq!(
            status.tokenizer_label.as_deref(),
            Some("tdt-ctc-110m.tokenizer.vocab")
        );
        if cfg!(feature = "parakeet") {
            assert!(status.ready);
        } else {
            assert!(!status.ready);
            assert!(
                status
                    .issues
                    .iter()
                    .any(|issue| issue.contains("not compiled into this build")),
                "expected missing compile support issue, got {:?}",
                status.issues
            );
        }
    }

    #[test]
    fn backfill_activation_from_paths_populates_missing_model_and_artifact_milestones() {
        let temp = TempDir::new().unwrap();
        let model = temp.path().join("ggml-small.bin");
        let artifact = temp.path().join("2026-04-09-demo.md");
        std::fs::write(&model, "model").unwrap();
        std::fs::write(&artifact, "---\ntitle: Demo\n---\n").unwrap();

        let mut progress = ActivationProgress::default();
        let changed = backfill_activation_from_paths(&mut progress, &model, Some(&artifact));
        let expected = artifact.display().to_string();

        assert!(changed);
        assert!(progress.model_ready_at.is_some());
        assert!(progress.first_artifact_saved_at.is_some());
        assert_eq!(
            progress.first_artifact_path.as_deref(),
            Some(expected.as_str())
        );
    }

    #[test]
    fn build_artifact_template_includes_meeting_metadata() {
        let fm = minutes_core::markdown::Frontmatter {
            title: "Pricing Review".into(),
            r#type: ContentType::Meeting,
            date: chrono::Local::now(),
            duration: "30m".into(),
            source: None,
            status: Some(minutes_core::markdown::OutputStatus::Complete),
            tags: vec![],
            attendees: vec!["Mat".into(), "Alex".into()],
            attendees_raw: None,
            calendar_event: None,
            people: vec![],
            entities: minutes_core::markdown::EntityLinks::default(),
            device: None,
            captured_at: None,
            context: None,
            action_items: vec![minutes_core::markdown::ActionItem {
                assignee: "Mat".into(),
                task: "Send follow-up".into(),
                due: Some("Friday".into()),
                status: "open".into(),
            }],
            decisions: vec![minutes_core::markdown::Decision {
                text: "Ship the new pricing page".into(),
                topic: Some("pricing".into()),
                authority: None,
                supersedes: None,
            }],
            intents: vec![],
            recorded_by: None,
            capture: None,
            sensitivity: None,
            debrief: None,
            visibility: None,
            speaker_map: vec![],
            name_corrections: Vec::new(),
            speaker_mapping: None,
            template: None,
            filter_diagnosis: None,
            consent: None,
            consent_notice: None,
            recording_health: None,
            processing_warnings: Vec::new(),
        };
        let sections = vec![MeetingSection {
            heading: "Summary".into(),
            content: "- We aligned on pricing changes.".into(),
        }];

        let (title, body) = build_artifact_template(
            &fm,
            &sections,
            Path::new("/tmp/pricing-review.md"),
            "debrief-memo",
        )
        .unwrap();

        assert!(title.contains("Pricing Review"));
        assert!(body.contains("source_meeting: /tmp/pricing-review.md"));
        assert!(body.contains("Ship the new pricing page"));
        assert!(body.contains("Mat: Send follow-up"));
    }

    #[test]
    fn build_decision_memo_template_includes_decision_sections() {
        let fm = minutes_core::markdown::Frontmatter {
            title: "Pricing Review".into(),
            r#type: ContentType::Meeting,
            date: chrono::Local::now(),
            duration: "30m".into(),
            source: None,
            status: Some(minutes_core::markdown::OutputStatus::Complete),
            tags: vec![],
            attendees: vec!["Mat".into(), "Alex".into()],
            attendees_raw: None,
            calendar_event: None,
            people: vec![],
            entities: minutes_core::markdown::EntityLinks::default(),
            device: None,
            captured_at: None,
            context: None,
            action_items: vec![minutes_core::markdown::ActionItem {
                assignee: "Mat".into(),
                task: "Send follow-up".into(),
                due: Some("Friday".into()),
                status: "open".into(),
            }],
            decisions: vec![minutes_core::markdown::Decision {
                text: "Ship the new pricing page".into(),
                topic: Some("pricing".into()),
                authority: None,
                supersedes: None,
            }],
            intents: vec![],
            recorded_by: None,
            capture: None,
            sensitivity: None,
            debrief: None,
            visibility: None,
            speaker_map: vec![],
            name_corrections: Vec::new(),
            speaker_mapping: None,
            template: None,
            filter_diagnosis: None,
            consent: None,
            consent_notice: None,
            recording_health: None,
            processing_warnings: Vec::new(),
        };
        let sections = vec![MeetingSection {
            heading: "Summary".into(),
            content: "- We aligned on pricing changes.".into(),
        }];

        let (title, body) = build_artifact_template(
            &fm,
            &sections,
            Path::new("/tmp/pricing-review.md"),
            "decision-memo",
        )
        .unwrap();

        assert!(title.contains("Decision Memo"));
        assert!(body.contains("# Decision"));
        assert!(body.contains("## Decision Details"));
        assert!(body.contains("## Implications"));
        assert!(body.contains("Ship the new pricing page"));
    }

    #[test]
    fn starter_artifact_pack_templates_all_build() {
        let fm = minutes_core::markdown::Frontmatter {
            title: "Pricing Review".into(),
            r#type: ContentType::Meeting,
            date: chrono::Local::now(),
            duration: "30m".into(),
            source: None,
            status: Some(minutes_core::markdown::OutputStatus::Complete),
            tags: vec![],
            attendees: vec!["Mat".into(), "Alex".into()],
            attendees_raw: None,
            calendar_event: None,
            people: vec![],
            entities: minutes_core::markdown::EntityLinks::default(),
            device: None,
            captured_at: None,
            context: None,
            action_items: vec![],
            decisions: vec![],
            intents: vec![],
            recorded_by: None,
            capture: None,
            sensitivity: None,
            debrief: None,
            visibility: None,
            speaker_map: vec![],
            name_corrections: Vec::new(),
            speaker_mapping: None,
            template: None,
            filter_diagnosis: None,
            consent: None,
            consent_notice: None,
            recording_health: None,
            processing_warnings: Vec::new(),
        };
        let sections = vec![MeetingSection {
            heading: "Summary".into(),
            content: "- We aligned on pricing changes.".into(),
        }];

        for kind in [
            "debrief-memo",
            "follow-up-email",
            "meeting-brief",
            "decision-memo",
        ] {
            let (_title, body) =
                build_artifact_template(&fm, &sections, Path::new("/tmp/pricing-review.md"), kind)
                    .unwrap_or_else(|error| panic!("template {kind} failed: {error}"));
            assert!(body.contains("source_meeting: /tmp/pricing-review.md"));
        }
    }

    #[test]
    fn record_recent_artifact_path_keeps_latest_entry_first_and_deduplicated() {
        let temp = TempDir::new().unwrap();
        let a = temp.path().join("artifact-a.md");
        let b = temp.path().join("artifact-b.md");
        std::fs::write(&a, "# A").unwrap();
        std::fs::write(&b, "# B").unwrap();
        let state_path = temp.path().join(".minutes/recent-artifacts.json");

        record_recent_artifact_canonical_with_limit(&a, 4, &state_path);
        record_recent_artifact_canonical_with_limit(&b, 4, &state_path);
        record_recent_artifact_canonical_with_limit(&a, 4, &state_path);

        let entries = load_recent_artifacts_from(&state_path);
        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0].path, a.display().to_string());
        assert_eq!(entries[1].path, b.display().to_string());
    }

    #[test]
    fn record_recent_artifact_path_prunes_to_limit() {
        let temp = TempDir::new().unwrap();
        let state_path = temp.path().join(".minutes/recent-artifacts.json");

        for idx in 0..5 {
            let path = temp.path().join(format!("artifact-{idx}.md"));
            std::fs::write(&path, format!("# {idx}")).unwrap();
            record_recent_artifact_canonical_with_limit(&path, 3, &state_path);
        }

        let entries = load_recent_artifacts_from(&state_path);
        assert_eq!(entries.len(), 3);
        assert!(entries[0].path.ends_with("artifact-4.md"));
        assert!(entries[2].path.ends_with("artifact-2.md"));
    }

    #[test]
    fn recall_workspace_state_round_trips() {
        let temp = TempDir::new().unwrap();
        let state_path = temp.path().join(".minutes/recall-workspace.json");
        let state = RecallWorkspaceState {
            recall_expanded: true,
            recall_phase: "debrief".into(),
            recall_ratio: 0.61,
            current_meeting_path: Some("/tmp/meeting.md".into()),
            open_artifact_path: Some("/tmp/artifact.md".into()),
        };

        persist_recall_workspace_state_to(&state_path, &state);
        let restored = load_recall_workspace_state_from(&state_path);

        assert_eq!(restored, state);
    }

    #[test]
    fn recall_workspace_state_defaults_when_missing() {
        let temp = TempDir::new().unwrap();
        let state_path = temp.path().join(".minutes/missing.json");

        let restored = load_recall_workspace_state_from(&state_path);

        assert!(!restored.recall_expanded);
        assert_eq!(restored.recall_phase, "recall");
        assert_eq!(restored.recall_ratio, 0.5);
        assert_eq!(restored.current_meeting_path, None);
        assert_eq!(restored.open_artifact_path, None);
    }

    #[test]
    fn build_related_context_collects_people_topics_meetings_and_commitments() {
        let temp = TempDir::new().unwrap();
        let meetings = temp.path().join("meetings");
        std::fs::create_dir_all(&meetings).unwrap();
        let current = meetings.join("2026-03-17-pricing-review.md");
        let followup = meetings.join("2026-03-20-follow-up.md");

        std::fs::write(
            &current,
            "---\ntitle: Pricing Review\ntype: meeting\ndate: 2026-03-17T12:00:00-07:00\nduration: 42m\nstatus: complete\nattendees: [Alex]\npeople: []\naction_items: []\ndecisions:\n  - text: Launch pricing at monthly billing per month\n    topic: pricing\nintents: []\n---\n\n## Transcript\n\nWe discussed pricing.\n",
        )
        .unwrap();
        std::fs::write(
            &followup,
            "---\ntitle: Follow-up\ntype: meeting\ndate: 2026-03-20T12:00:00-07:00\nduration: 30m\nstatus: complete\nattendees: [Alex]\npeople: []\naction_items: []\ndecisions: []\nintents:\n  - kind: commitment\n    what: Share revised pricing model\n    who: Alex\n    status: open\n    by_date: Tuesday\n---\n\n## Transcript\n\nWe followed up on pricing.\n",
        )
        .unwrap();

        let config = Config {
            output_dir: meetings.clone(),
            ..Config::default()
        };
        let frontmatter: minutes_core::markdown::Frontmatter = serde_yaml::from_str(
            "title: Pricing Review\ntype: meeting\ndate: 2026-03-17T12:00:00-07:00\nduration: 42m\nstatus: complete\nattendees: [Alex]\npeople: []\naction_items: []\ndecisions:\n  - text: Launch pricing at monthly billing per month\n    topic: pricing\nintents: []\n",
        )
        .unwrap();

        let related = build_related_context(&config, &current, &frontmatter);

        assert!(related.related_people.iter().any(|person| person == "Alex"));
        assert!(related
            .related_topics
            .iter()
            .any(|topic| topic == "pricing"));
        assert!(related
            .related_meetings
            .iter()
            .any(|meeting| meeting.title == "Follow-up"));
        assert!(related
            .related_commitments
            .iter()
            .any(|commitment| commitment.what.contains("Share revised pricing model")));
    }

    #[test]
    fn build_related_context_links_memo_to_related_meeting() {
        let temp = TempDir::new().unwrap();
        let meetings = temp.path().join("meetings");
        let memos = meetings.join("memos");
        std::fs::create_dir_all(&memos).unwrap();
        let current = memos.join("2026-03-19-pricing-idea.md");
        let related_meeting = meetings.join("2026-03-20-pricing-review.md");

        std::fs::write(
            &current,
            "---\ntitle: Pricing Idea\ntype: memo\ndate: 2026-03-19T12:00:00-07:00\nduration: 2m\nstatus: complete\ntags:\n  - memo\n  - topic:pricing-strategy\nattendees: [Alex]\npeople: [Alex]\naction_items: []\ndecisions:\n  - text: Explore premium annual billing\n    topic: pricing strategy\nintents: []\n---\n\n## Transcript\n\nVoice memo about pricing.\n",
        )
        .unwrap();
        std::fs::write(
            &related_meeting,
            "---\ntitle: Pricing Review\ntype: meeting\ndate: 2026-03-20T12:00:00-07:00\nduration: 30m\nstatus: complete\nattendees: [Alex]\npeople: []\naction_items: []\ndecisions:\n  - text: Launch pricing at monthly billing per month\n    topic: pricing strategy\nintents: []\n---\n\n## Transcript\n\nWe discussed pricing.\n",
        )
        .unwrap();

        let config = Config {
            output_dir: meetings.clone(),
            ..Config::default()
        };
        let frontmatter: minutes_core::markdown::Frontmatter = serde_yaml::from_str(
            "title: Pricing Idea\ntype: memo\ndate: 2026-03-19T12:00:00-07:00\nduration: 2m\nstatus: complete\ntags:\n  - memo\n  - topic:pricing-strategy\nattendees: [Alex]\npeople: [Alex]\naction_items: []\ndecisions:\n  - text: Explore premium annual billing\n    topic: pricing strategy\nintents: []\n",
        )
        .unwrap();

        let related = build_related_context(&config, &current, &frontmatter);

        assert!(related
            .related_meetings
            .iter()
            .any(|meeting| meeting.title == "Pricing Review"));
        assert!(related
            .related_topics
            .iter()
            .any(|topic| topic == "pricing strategy"));
    }

    #[test]
    fn build_weekly_summary_markdown_includes_core_sections() {
        let markdown = build_weekly_summary_markdown(
            3,
            "- Pricing Review\n- Follow-up",
            "- pricing -> Launch monthly billing",
            "- Share revised pricing model (Alex)",
            "- Alex: Send updated doc",
        );

        assert!(markdown.contains("# Weekly Summary"));
        assert!(markdown.contains("## Recent Meetings"));
        assert!(markdown.contains("## Decision Arcs"));
        assert!(markdown.contains("## Stale Commitments"));
        assert!(markdown.contains("## Open Actions"));
        assert!(markdown.contains("## Monday Brief"));
    }

    #[test]
    fn text_file_kind_detects_json() {
        assert_eq!(text_file_kind(Path::new("/tmp/test.json")), Some("json"));
        assert_eq!(text_file_kind(Path::new("/tmp/test.md")), Some("markdown"));
        assert_eq!(text_file_kind(Path::new("/tmp/test.txt")), Some("text"));
    }

    #[test]
    fn list_documents_merges_assistant_and_meeting_sources_by_recency() {
        let home = dirs::home_dir().expect("home dir");
        let temp = tempfile::Builder::new()
            .prefix("minutes-documents-test-")
            .tempdir_in(home)
            .unwrap();
        let assistant = temp.path().join("assistant");
        let assistant_artifacts = assistant.join("artifacts");
        let meetings = temp.path().join("meetings");
        let state_path = temp.path().join("recent-artifacts.json");
        std::fs::create_dir_all(&assistant_artifacts).unwrap();
        std::fs::create_dir_all(&meetings).unwrap();

        let older = assistant.join("prep.md");
        std::fs::write(&older, "# Prep").unwrap();
        std::thread::sleep(std::time::Duration::from_millis(5));
        std::fs::write(assistant.join("CLAUDE.md"), "# Instructions").unwrap();
        std::thread::sleep(std::time::Duration::from_millis(5));
        let nested = assistant_artifacts.join("debrief.txt");
        std::fs::write(&nested, "Debrief").unwrap();
        std::thread::sleep(std::time::Duration::from_millis(5));
        let meeting = meetings.join("2026-07-02-product-review.md");
        std::fs::write(&meeting, "# Meeting artifact").unwrap();
        let outside = temp.path().join("outside-secret.md");
        std::fs::write(&outside, "# Outside").unwrap();
        #[cfg(unix)]
        {
            std::os::unix::fs::symlink(&outside, assistant.join("outside-secret.md")).unwrap();
            std::os::unix::fs::symlink(&outside, assistant_artifacts.join("linked-secret.md"))
                .unwrap();
        }

        let config = Config {
            output_dir: meetings.clone(),
            ..Config::default()
        };
        record_recent_artifact_canonical_with_limit(&meeting, 8, &state_path);

        let docs =
            list_documents_for_roots_with_recent_state(&config, &assistant, 200, &state_path);
        let names: Vec<_> = docs.iter().map(|doc| doc.filename.as_str()).collect();

        assert_eq!(names.len(), 3);
        assert_eq!(names[0], "2026-07-02-product-review.md");
        assert!(names.contains(&"prep.md"));
        assert!(names.contains(&"debrief.txt"));
        assert!(!names.contains(&"CLAUDE.md"));
        assert!(!names.contains(&"outside-secret.md"));
        assert!(!names.contains(&"linked-secret.md"));

        let capped =
            list_documents_for_roots_with_recent_state(&config, &assistant, 2, &state_path);
        let capped_names: Vec<_> = capped.iter().map(|doc| doc.filename.as_str()).collect();
        assert_eq!(
            capped_names,
            vec!["2026-07-02-product-review.md", "debrief.txt"]
        );

        let meeting_doc = docs
            .iter()
            .find(|doc| doc.filename == "2026-07-02-product-review.md")
            .unwrap();
        assert_eq!(meeting_doc.source, "meeting");
        assert_eq!(
            meeting_doc.meeting_slug.as_deref(),
            Some("2026-07-02-product-review")
        );
        assert_eq!(meeting_doc.kind, "markdown");

        let assistant_doc = docs.iter().find(|doc| doc.filename == "prep.md").unwrap();
        assert_eq!(assistant_doc.source, "assistant");
        assert_eq!(assistant_doc.meeting_slug, None);

        #[cfg(unix)]
        {
            use std::os::unix::ffi::OsStringExt;

            let non_utf8_name = std::ffi::OsString::from_vec(b"nonutf-\xFF.md".to_vec());
            let non_utf8_path = assistant.join(non_utf8_name);
            match std::fs::write(&non_utf8_path, "# Non UTF-8") {
                Ok(()) => {
                    let docs = list_documents_for_roots_with_recent_state(
                        &config,
                        &assistant,
                        200,
                        &state_path,
                    );
                    assert!(docs
                        .iter()
                        .any(|doc| doc.filename == "Document" && doc.source == "assistant"));
                }
                Err(_) => {
                    assert_eq!(filename_for_path(&non_utf8_path), "Document");
                }
            }
        }
    }

    #[test]
    fn prune_artifact_snapshots_keeps_latest_per_file_identity() {
        let temp = TempDir::new().unwrap();
        for idx in 0..25 {
            let path = temp
                .path()
                .join(format!("20260409-120{idx:02}-artifact-abcd1234.md"));
            std::fs::write(path, "snapshot").unwrap();
        }

        prune_artifact_snapshots(temp.path(), "artifact-abcd1234", "md").unwrap();

        let remaining = std::fs::read_dir(temp.path()).unwrap().count();
        assert_eq!(remaining, MAX_ARTIFACT_SNAPSHOTS_PER_FILE);
    }

    #[test]
    fn latest_snapshot_for_path_returns_newest_snapshot() {
        let temp = TempDir::new().unwrap();
        let path = temp.path().join("artifact.md");
        let snapshot_root = temp.path().join("snapshots");
        std::fs::create_dir_all(&snapshot_root).unwrap();
        let (identity, extension) = snapshot_identity_for_path(&path);
        let older = snapshot_root.join(format!("20260409-120000-{identity}.{extension}"));
        let newer = snapshot_root.join(format!("20260409-120100-{identity}.{extension}"));
        std::fs::write(&older, "old").unwrap();
        std::fs::write(&newer, "new").unwrap();

        let mut matching = matching_snapshots(&snapshot_root, &identity, &extension).unwrap();
        assert_eq!(matching.pop().unwrap(), newer);
    }

    #[test]
    fn review_preview_for_kind_pretty_prints_json() {
        let preview = review_preview_for_kind("json", "{\"b\":2,\"a\":1}", 80, 4000);
        assert!(preview.contains("\"a\": 1"));
        assert!(preview.contains("\"b\": 2"));
        assert!(preview.contains('\n'));
    }

    #[test]
    fn needs_review_jobs_surface_as_preserved_capture_notices() {
        let job = minutes_core::jobs::ProcessingJob {
            id: "job-review".into(),
            title: Some("Interview".into()),
            mode: CaptureMode::Meeting,
            content_type: ContentType::Meeting,
            state: minutes_core::jobs::JobState::NeedsReview,
            stage: minutes_core::jobs::JobState::NeedsReview.default_stage(),
            output_path: Some("/tmp/interview.md".into()),
            audio_path: "/tmp/interview.wav".into(),
            error: Some("silence strip removed ALL audio".into()),
            created_at: chrono::Local::now(),
            started_at: None,
            finished_at: Some(chrono::Local::now()),
            notice_dismissed_at: None,
            recording_started_at: None,
            recording_finished_at: None,
            context_session_id: None,
            user_notes: None,
            pre_context: None,
            calendar_event: None,
            template_slug: None,
            word_count: Some(0),
            owner_pid: None,
            retry_count: 0,
            consent: None,
            consent_notice: None,
            recording_health: None,
        };

        let notice = output_notice_from_job(&job).expect("needs-review notice");
        assert_eq!(notice.kind, "preserved-capture");
        assert_eq!(notice.path, "/tmp/interview.wav");
        assert!(notice.detail.contains("silence strip"));
    }

    #[test]
    fn startup_retryable_notices_do_not_surface_stale_failure_details() {
        let job = minutes_core::jobs::ProcessingJob {
            id: "job-failed".into(),
            title: Some("Legacy Method Agent Capacity".into()),
            mode: CaptureMode::Meeting,
            content_type: ContentType::Meeting,
            state: minutes_core::jobs::JobState::Failed,
            stage: minutes_core::jobs::JobState::Failed.default_stage(),
            output_path: None,
            audio_path: "/tmp/capture.wav".into(),
            error: Some("engine 'parakeet' not compiled in".into()),
            created_at: chrono::Local::now(),
            started_at: None,
            finished_at: Some(chrono::Local::now()),
            notice_dismissed_at: None,
            recording_started_at: None,
            recording_finished_at: None,
            context_session_id: None,
            user_notes: None,
            pre_context: None,
            calendar_event: None,
            template_slug: None,
            word_count: Some(0),
            owner_pid: None,
            retry_count: 0,
            consent: None,
            consent_notice: None,
            recording_health: None,
        };

        let live_notice = output_notice_from_job(&job).expect("live failed notice");
        assert!(live_notice.detail.contains("parakeet"));

        let startup_notice =
            startup_retryable_output_notice_from_job(&job).expect("startup retryable notice");
        assert_eq!(startup_notice.kind, "preserved-capture");
        assert_eq!(startup_notice.path, "/tmp/capture.wav");
        assert_eq!(
            startup_notice.detail,
            "Previous processing failed. Raw capture is preserved and can be retried."
        );
        assert!(!startup_notice.detail.contains("parakeet"));
    }

    #[test]
    fn latest_retryable_notice_skips_dismissed_jobs_after_restart() {
        with_temp_home(|_| {
            let now = chrono::Local::now();
            let dismissed_job = minutes_core::jobs::ProcessingJob {
                id: "job-dismissed".into(),
                title: Some("Already handled".into()),
                mode: CaptureMode::Meeting,
                content_type: ContentType::Meeting,
                state: minutes_core::jobs::JobState::Failed,
                stage: minutes_core::jobs::JobState::Failed.default_stage(),
                output_path: None,
                audio_path: "/tmp/dismissed.wav".into(),
                error: Some("old failure".into()),
                created_at: now - chrono::Duration::minutes(1),
                started_at: None,
                finished_at: Some(now),
                notice_dismissed_at: Some(now),
                recording_started_at: None,
                recording_finished_at: None,
                context_session_id: None,
                user_notes: None,
                pre_context: None,
                calendar_event: None,
                template_slug: None,
                word_count: Some(0),
                owner_pid: None,
                retry_count: 0,
                consent: None,
                consent_notice: None,
                recording_health: None,
            };

            minutes_core::jobs::write_job(&dismissed_job).unwrap();
            assert!(
                latest_retryable_output_notice().is_none(),
                "dismissed startup notices should stay hidden after restart"
            );
        });
    }

    #[test]
    fn latest_retryable_notice_falls_back_to_older_undismissed_job() {
        with_temp_home(|dir| {
            let now = chrono::Local::now();
            let older_wav = dir.join("old-visible.wav");
            std::fs::write(&older_wav, b"riff").unwrap();
            let dismissed_job = minutes_core::jobs::ProcessingJob {
                id: "job-new-dismissed".into(),
                title: Some("New dismissed".into()),
                mode: CaptureMode::Meeting,
                content_type: ContentType::Meeting,
                state: minutes_core::jobs::JobState::Failed,
                stage: minutes_core::jobs::JobState::Failed.default_stage(),
                output_path: None,
                audio_path: "/tmp/new-dismissed.wav".into(),
                error: Some("new failure".into()),
                created_at: now - chrono::Duration::minutes(1),
                started_at: None,
                finished_at: Some(now),
                notice_dismissed_at: Some(now),
                recording_started_at: None,
                recording_finished_at: None,
                context_session_id: None,
                user_notes: None,
                pre_context: None,
                calendar_event: None,
                template_slug: None,
                word_count: Some(0),
                owner_pid: None,
                retry_count: 0,
                consent: None,
                consent_notice: None,
                recording_health: None,
            };
            let older_job = minutes_core::jobs::ProcessingJob {
                id: "job-old-visible".into(),
                title: Some("Still retryable".into()),
                mode: CaptureMode::Meeting,
                content_type: ContentType::Meeting,
                state: minutes_core::jobs::JobState::Failed,
                stage: minutes_core::jobs::JobState::Failed.default_stage(),
                output_path: None,
                audio_path: older_wav.display().to_string(),
                error: Some("older failure".into()),
                created_at: now - chrono::Duration::minutes(10),
                started_at: None,
                finished_at: Some(now - chrono::Duration::minutes(9)),
                notice_dismissed_at: None,
                recording_started_at: None,
                recording_finished_at: None,
                context_session_id: None,
                user_notes: None,
                pre_context: None,
                calendar_event: None,
                template_slug: None,
                word_count: Some(0),
                owner_pid: None,
                retry_count: 0,
                consent: None,
                consent_notice: None,
                recording_health: None,
            };

            minutes_core::jobs::write_job(&dismissed_job).unwrap();
            minutes_core::jobs::write_job(&older_job).unwrap();

            let notice = latest_retryable_output_notice().expect("older retryable notice");
            assert_eq!(notice.job_id.as_deref(), Some("job-old-visible"));
            assert_eq!(notice.path, older_wav.display().to_string());
        });
    }

    #[test]
    fn latest_retryable_notice_skips_jobs_whose_capture_was_deleted() {
        with_temp_home(|dir| {
            let now = chrono::Local::now();
            let missing_wav = dir.join("vanished.wav");
            let job = minutes_core::jobs::ProcessingJob {
                id: "job-capture-gone".into(),
                title: Some("consent-e2e-check".into()),
                mode: CaptureMode::Meeting,
                content_type: ContentType::Meeting,
                state: minutes_core::jobs::JobState::Failed,
                stage: minutes_core::jobs::JobState::Failed.default_stage(),
                output_path: None,
                audio_path: missing_wav.display().to_string(),
                error: Some("engine 'parakeet' not compiled in".into()),
                created_at: now - chrono::Duration::minutes(1),
                started_at: None,
                finished_at: Some(now),
                notice_dismissed_at: None,
                recording_started_at: None,
                recording_finished_at: None,
                context_session_id: None,
                user_notes: None,
                pre_context: None,
                calendar_event: None,
                template_slug: None,
                word_count: Some(0),
                owner_pid: None,
                retry_count: 0,
                consent: None,
                consent_notice: None,
                recording_health: None,
            };
            minutes_core::jobs::write_job(&job).unwrap();

            assert!(
                latest_retryable_output_notice().is_none(),
                "a notice promising retry must not surface once the raw capture is gone"
            );
        });
    }

    #[test]
    fn latest_retryable_notice_ignores_stale_historical_failures() {
        with_temp_home(|_| {
            let old = chrono::Local::now() - chrono::Duration::days(10);
            let job = minutes_core::jobs::ProcessingJob {
                id: "job-old-undismissed".into(),
                title: Some("Old undismissed".into()),
                mode: CaptureMode::Meeting,
                content_type: ContentType::Meeting,
                state: minutes_core::jobs::JobState::Failed,
                stage: minutes_core::jobs::JobState::Failed.default_stage(),
                output_path: None,
                audio_path: "/tmp/old-undismissed.wav".into(),
                error: Some("historical failure".into()),
                created_at: old,
                started_at: None,
                finished_at: Some(old),
                notice_dismissed_at: None,
                recording_started_at: None,
                recording_finished_at: None,
                context_session_id: None,
                user_notes: None,
                pre_context: None,
                calendar_event: None,
                template_slug: None,
                word_count: Some(0),
                owner_pid: None,
                retry_count: 0,
                consent: None,
                consent_notice: None,
                recording_health: None,
            };
            minutes_core::jobs::write_job(&job).unwrap();

            assert!(
                latest_retryable_output_notice().is_none(),
                "old pre-marker failed jobs should stay in recovery/history, not nag on every startup"
            );
        });
    }

    #[test]
    fn dismiss_notice_resolves_job_from_audio_path_without_job_id() {
        with_temp_home(|dir| {
            let now = chrono::Local::now();
            let audio_path = dir.join("job-path-fallback.wav");
            fs::write(&audio_path, b"fake-wav").unwrap();
            let job = minutes_core::jobs::ProcessingJob {
                id: "job-path-fallback".into(),
                title: Some("Path fallback".into()),
                mode: CaptureMode::Meeting,
                content_type: ContentType::Meeting,
                state: minutes_core::jobs::JobState::Failed,
                stage: minutes_core::jobs::JobState::Failed.default_stage(),
                output_path: None,
                audio_path: audio_path.display().to_string(),
                error: Some("old failure".into()),
                created_at: now,
                started_at: None,
                finished_at: Some(now),
                notice_dismissed_at: None,
                recording_started_at: None,
                recording_finished_at: None,
                context_session_id: None,
                user_notes: None,
                pre_context: None,
                calendar_event: None,
                template_slug: None,
                word_count: Some(0),
                owner_pid: None,
                retry_count: 0,
                consent: None,
                consent_notice: None,
                recording_health: None,
            };
            minutes_core::jobs::write_job(&job).unwrap();

            let notice = OutputNotice {
                kind: "preserved-capture".into(),
                title: "Processing failed".into(),
                path: audio_path.display().to_string(),
                detail: "Previous processing failed".into(),
                job_id: None,
            };

            assert_eq!(
                job_id_from_notice(&notice).as_deref(),
                Some("job-path-fallback")
            );
            persist_output_notice_dismissal(&notice);

            let dismissed = minutes_core::jobs::load_job("job-path-fallback").unwrap();
            assert!(dismissed.notice_dismissed_at.is_some());
            assert!(
                latest_retryable_output_notice().is_none(),
                "path-only dismissed notices should not reappear after restart"
            );
        });
    }

    #[test]
    fn stale_retry_notice_does_not_mark_queued_job_dismissed() {
        with_temp_home(|dir| {
            let now = chrono::Local::now();
            let audio_path = dir.join("job-retry-ordering.wav");
            fs::write(&audio_path, b"fake-wav").unwrap();
            let job = minutes_core::jobs::ProcessingJob {
                id: "job-retry-ordering".into(),
                title: Some("Retry ordering".into()),
                mode: CaptureMode::Meeting,
                content_type: ContentType::Meeting,
                state: minutes_core::jobs::JobState::Failed,
                stage: minutes_core::jobs::JobState::Failed.default_stage(),
                output_path: None,
                audio_path: audio_path.display().to_string(),
                error: Some("old failure".into()),
                created_at: now,
                started_at: None,
                finished_at: Some(now),
                notice_dismissed_at: None,
                recording_started_at: None,
                recording_finished_at: None,
                context_session_id: None,
                user_notes: None,
                pre_context: None,
                calendar_event: None,
                template_slug: None,
                word_count: Some(0),
                owner_pid: None,
                retry_count: 0,
                consent: None,
                consent_notice: None,
                recording_health: None,
            };
            minutes_core::jobs::write_job(&job).unwrap();
            let stale_notice = output_notice_from_job(&job).unwrap();

            let requeued = minutes_core::jobs::requeue_job(&job.id).unwrap().unwrap();
            assert_eq!(requeued.state, minutes_core::jobs::JobState::Queued);

            persist_output_notice_dismissal(&stale_notice);

            let loaded = minutes_core::jobs::load_job(&job.id).unwrap();
            assert_eq!(loaded.state, minutes_core::jobs::JobState::Queued);
            assert!(
                loaded.notice_dismissed_at.is_none(),
                "retry start should hide the old notice locally, not persist a dismissal"
            );
        });
    }

    #[test]
    fn desktop_capabilities_align_with_helper_flags() {
        let caps = cmd_desktop_capabilities();

        assert_eq!(caps.platform, current_platform());
        assert_eq!(caps.folder_reveal_label, folder_reveal_label());
        assert_eq!(
            caps.supports_calendar_integration,
            supports_calendar_integration()
        );
        assert_eq!(caps.supports_call_detection, supports_call_detection());
        assert_eq!(
            caps.supports_tray_artifact_copy,
            supports_tray_artifact_copy()
        );
        assert_eq!(caps.supports_dictation_hotkey, supports_dictation_hotkey());
    }

    #[test]
    fn scan_recovery_items_finds_failed_capture_and_watch_file() {
        with_temp_home(|home| {
            let watch_dir = home.join("watch");
            let failed_dir = watch_dir.join("failed");
            let output_dir = home.join("meetings");
            let failed_captures = output_dir.join("failed-captures");
            std::fs::create_dir_all(&failed_dir).unwrap();
            std::fs::create_dir_all(&failed_captures).unwrap();

            let failed_watch = failed_dir.join("idea.m4a");
            let failed_capture = failed_captures.join("capture.wav");
            std::fs::write(&failed_watch, "watch").unwrap();
            std::fs::write(&failed_capture, "capture").unwrap();

            let config = Config {
                output_dir: output_dir.clone(),
                watch: minutes_core::config::WatchConfig {
                    paths: vec![watch_dir],
                    ..Config::default().watch
                },
                ..Config::default()
            };

            let items = scan_recovery_items(&config);
            assert_eq!(items.len(), 2);
            assert!(items.iter().any(|item| item.kind == "watch-failed"));
            assert!(items.iter().any(|item| item.kind == "preserved-capture"));
        });
    }

    #[test]
    fn model_status_reports_missing_model() {
        let dir = tempfile::TempDir::new().unwrap();
        let config = Config {
            transcription: minutes_core::config::TranscriptionConfig {
                model: "small".into(),
                model_path: dir.path().join("models"),
                min_words: 3,
                language: Some("en".into()),
                vad_model: "silero-v6.2.0".into(),
                noise_reduction: false,
                ..minutes_core::config::TranscriptionConfig::default()
            },
            ..Config::default()
        };

        let status = model_status(&config);
        assert_eq!(status.label, "Speech backends");
        assert_eq!(status.state, "attention");
    }

    #[test]
    fn display_path_rewrites_home_prefix() {
        let home = dirs::home_dir().unwrap();
        let path = home.join("meetings/demo.md");
        let displayed = display_path(&path.display().to_string());
        assert!(displayed.starts_with("~/"));
    }

    #[test]
    fn parse_sections_preserves_top_level_order() {
        let body = "## Summary\n\nHello\n\n## Notes\n\n- One\n\n## Transcript\n\n[0:00] Hi\n";
        let sections = parse_sections(body);

        assert_eq!(sections.len(), 3);
        assert_eq!(sections[0].heading, "Summary");
        assert_eq!(sections[1].heading, "Notes");
        assert_eq!(sections[2].heading, "Transcript");
        assert!(sections[2].content.contains("[0:00] Hi"));
    }

    #[test]
    fn validate_hotkey_shortcut_accepts_known_values() {
        assert_eq!(
            validate_hotkey_shortcut("CmdOrCtrl+Shift+M").unwrap(),
            "CmdOrCtrl+Shift+M"
        );
    }

    #[test]
    fn validate_hotkey_shortcut_rejects_unknown_values() {
        assert!(validate_hotkey_shortcut("CmdOrCtrl+Shift+P").is_err());
    }

    #[test]
    fn validate_palette_shortcut_accepts_default_choices() {
        assert_eq!(
            validate_palette_shortcut("CmdOrCtrl+Shift+K").unwrap(),
            "CmdOrCtrl+Shift+K"
        );
        assert_eq!(
            validate_palette_shortcut("CmdOrCtrl+Shift+O").unwrap(),
            "CmdOrCtrl+Shift+O"
        );
        assert_eq!(
            validate_palette_shortcut("CmdOrCtrl+Shift+U").unwrap(),
            "CmdOrCtrl+Shift+U"
        );
    }

    #[test]
    fn validate_palette_shortcut_rejects_unknown() {
        assert!(validate_palette_shortcut("CmdOrCtrl+Shift+Z").is_err());
        assert!(validate_palette_shortcut("nonsense").is_err());
        // Codex pass 3: P (VS Code Command Palette conflict) and
        // Alt+Space (collides with DICTATION_SHORTCUT_CHOICES) were
        // dropped on purpose. Both should be rejected.
        assert!(validate_palette_shortcut("CmdOrCtrl+Shift+P").is_err());
        assert!(validate_palette_shortcut("CmdOrCtrl+Alt+Space").is_err());
    }

    #[test]
    fn validate_live_shortcut_accepts_known_values() {
        assert_eq!(
            validate_live_shortcut("CmdOrCtrl+Shift+L").unwrap(),
            "CmdOrCtrl+Shift+L"
        );
        assert_eq!(
            validate_live_shortcut("CmdOrCtrl+Alt+L").unwrap(),
            "CmdOrCtrl+Alt+L"
        );
    }

    #[test]
    fn validate_live_shortcut_rejects_unknown_values() {
        assert!(validate_live_shortcut("CmdOrCtrl+Shift+M").is_err());
        assert!(validate_live_shortcut("nonsense").is_err());
    }

    #[test]
    fn validate_download_model_name_rejects_path_like_input() {
        assert!(validate_download_model_name("../../.ssh/evil").is_err());
        assert!(validate_download_model_name("tiny").is_ok());
    }

    #[test]
    fn palette_shortcut_choices_do_not_collide_with_other_minutes_choices() {
        use std::collections::HashSet;
        let palette: HashSet<&str> = PALETTE_SHORTCUT_CHOICES.iter().map(|(v, _)| *v).collect();
        let hotkey: HashSet<&str> = HOTKEY_CHOICES.iter().map(|(v, _)| *v).collect();
        let dictation: HashSet<&str> = DICTATION_SHORTCUT_CHOICES.iter().map(|(v, _)| *v).collect();
        for chord in &palette {
            assert!(
                !hotkey.contains(chord),
                "{} appears in both PALETTE_SHORTCUT_CHOICES and HOTKEY_CHOICES",
                chord
            );
            assert!(
                !dictation.contains(chord),
                "{} appears in both PALETTE_SHORTCUT_CHOICES and DICTATION_SHORTCUT_CHOICES",
                chord
            );
        }
    }

    #[test]
    fn shortcut_collision_error_ignores_disabled_shortcuts() {
        let in_use = [
            ("dictation", false, Some("CmdOrCtrl+Shift+K".to_string())),
            (
                "live transcript",
                true,
                Some("CmdOrCtrl+Shift+O".to_string()),
            ),
        ];

        assert!(shortcut_collision_error("CmdOrCtrl+Shift+K", &in_use).is_ok());
        assert!(shortcut_collision_error("CmdOrCtrl+Shift+O", &in_use)
            .unwrap_err()
            .contains("live transcript"));
    }

    #[test]
    fn humanize_shortcut_renders_modifiers_as_glyphs() {
        assert_eq!(humanize_shortcut("CmdOrCtrl+Shift+K"), "⌘⇧K");
        assert_eq!(humanize_shortcut("CmdOrCtrl+Alt+Space"), "⌘⌥Space");
        assert_eq!(humanize_shortcut("CmdOrCtrl+Shift+O"), "⌘⇧O");
        // Unknown pieces fall through verbatim.
        assert_eq!(
            humanize_shortcut("CmdOrCtrl+Shift+Backspace"),
            "⌘⇧Backspace"
        );
    }

    #[test]
    fn short_hotkey_capture_is_discarded() {
        let started = Instant::now() - std::time::Duration::from_millis(200);
        assert!(should_discard_hotkey_capture(Some(started), Instant::now()));
    }

    #[test]
    fn long_hotkey_capture_is_kept() {
        let started = Instant::now() - std::time::Duration::from_millis(450);
        assert!(!should_discard_hotkey_capture(
            Some(started),
            Instant::now()
        ));
    }

    #[test]
    fn reset_hotkey_capture_state_clears_runtime_and_discard_flag() {
        let runtime = Arc::new(Mutex::new(HotkeyRuntime {
            key_down: true,
            key_down_started_at: Some(Instant::now()),
            active_capture: Some(HotkeyCaptureStyle::Locked),
            recording_started_at: Some(Instant::now()),
            hold_generation: 9,
        }));
        let discard = Arc::new(AtomicBool::new(true));

        reset_hotkey_capture_state(Some(&runtime), Some(&discard));

        let current = runtime.lock().unwrap();
        assert!(!current.key_down);
        assert!(current.key_down_started_at.is_none());
        assert!(current.active_capture.is_none());
        assert!(current.recording_started_at.is_none());
        assert!(!discard.load(Ordering::Relaxed));
    }

    #[test]
    fn extract_paste_text_returns_summary_section() {
        let content = "---\ntitle: Demo\n---\n\n## Summary\n\nShort summary.\n\n## Transcript\n\nFull transcript.\n";
        let summary = extract_paste_text(content, "summary").unwrap();
        assert_eq!(summary, "Short summary.");
    }

    #[test]
    fn extract_paste_text_rejects_missing_summary() {
        let content = "---\ntitle: Demo\n---\n\n## Transcript\n\nFull transcript.\n";
        assert!(extract_paste_text(content, "summary").is_err());
    }

    /// Core cohesion invariant for 8zgi.2: a speaker confirmation written to
    /// the sidecar overlay store (by the CLI, the desktop app, or any other
    /// surface) must surface in the meeting detail view without mutating the
    /// raw markdown on disk.
    #[test]
    fn meeting_detail_reflects_speaker_overlay_confirmations() {
        with_temp_home(|home| {
            // Config::load resolves output_dir to $HOME/meetings when no
            // config file exists, which is what we want for this isolated run.
            let meetings_dir = home.join("meetings");
            std::fs::create_dir_all(&meetings_dir).unwrap();

            let meeting_path = meetings_dir.join("2026-04-24-cohesion-check.md");
            let raw_markdown = concat!(
                "---\n",
                "title: Cohesion Check\n",
                "type: meeting\n",
                "date: 2026-04-24T10:00:00-07:00\n",
                "duration: 15m\n",
                "tags: []\n",
                "attendees: []\n",
                "people: []\n",
                "action_items: []\n",
                "decisions: []\n",
                "intents: []\n",
                "speaker_map:\n",
                "  - speaker_label: SPEAKER_0\n",
                "    name: Speaker 0\n",
                "    confidence: medium\n",
                "    source: llm\n",
                "---\n\n",
                "## Transcript\n\n",
                "SPEAKER_0: hello there\n",
            );
            std::fs::write(&meeting_path, raw_markdown).unwrap();
            let hash_before = hash_file_bytes(&meeting_path);

            // Simulate a CLI-initiated or desktop-initiated confirmation by
            // writing directly to the overlay store at the HOME-rooted path.
            let overlay_db = minutes_core::overlays::default_db_path();
            minutes_core::overlays::write_speaker_confirmation_at(
                &overlay_db,
                &meeting_path,
                "SPEAKER_0",
                "Alex Kim",
                Some("Speaker 0"),
                Some("overlay test"),
            )
            .unwrap();

            let detail =
                cmd_get_meeting_detail(meeting_path.to_string_lossy().to_string()).unwrap();

            let alex = detail
                .speaker_map
                .iter()
                .find(|attr| attr.speaker_label == "SPEAKER_0")
                .expect("SPEAKER_0 must appear in meeting detail");
            assert_eq!(
                alex.name, "Alex Kim",
                "overlay confirmation must surface in meeting detail speaker_map"
            );
            assert_eq!(
                alex.confidence, "high",
                "overlay confirmations carry high confidence"
            );
            assert_eq!(
                alex.source, "manual",
                "overlay confirmations attribute the source as manual"
            );

            let hash_after = hash_file_bytes(&meeting_path);
            assert_eq!(
                hash_before, hash_after,
                "overlay write must not mutate the raw meeting markdown"
            );
        });
    }

    #[test]
    fn rememberable_person_name_rejects_generic_speaker_labels() {
        assert!(looks_like_rememberable_person_name("Alex Kim"));
        assert!(!looks_like_rememberable_person_name(""));
        assert!(!looks_like_rememberable_person_name("Unknown Speaker"));
        assert!(!looks_like_rememberable_person_name("SPEAKER_0"));
        assert!(!looks_like_rememberable_person_name("speaker-0"));
        assert!(!looks_like_rememberable_person_name("Speaker 1"));
    }

    #[test]
    fn remember_vocabulary_person_writes_user_vocabulary_without_duplicates() {
        with_temp_home(|home| {
            let saved = cmd_remember_vocabulary_person("Alex Kim".into()).unwrap();
            assert_eq!(saved.canonical, "Alex Kim");
            assert!(!saved.already_exists);
            assert!(saved
                .note
                .contains("existing raw transcripts stay unchanged"));

            let vocabulary_path = home.join(".minutes").join("vocabulary.toml");
            let store = minutes_core::vocabulary::load_at(&vocabulary_path).unwrap();
            assert_eq!(store.entries.len(), 1);
            assert_eq!(store.entries[0].canonical, "Alex Kim");
            assert_eq!(
                store.entries[0].kind,
                minutes_core::vocabulary::VocabularyKind::Person
            );
            assert_eq!(
                store.entries[0].source,
                minutes_core::vocabulary::VocabularySource::SpeakerConfirmation
            );

            let duplicate = cmd_remember_vocabulary_person(" Alex Kim ".into()).unwrap();
            assert!(duplicate.already_exists);
            let store = minutes_core::vocabulary::load_at(&vocabulary_path).unwrap();
            assert_eq!(store.entries.len(), 1);
        });
    }

    fn hash_file_bytes(path: &Path) -> u64 {
        let bytes = std::fs::read(path).expect("meeting file must exist");
        let mut hasher = DefaultHasher::new();
        bytes.hash(&mut hasher);
        hasher.finish()
    }
}

// ── Dictation commands ──────────────────────────────────────

#[tauri::command]
pub fn cmd_start_dictation(
    app: tauri::AppHandle,
    _state: tauri::State<AppState>,
) -> Result<String, String> {
    start_dictation_session(&app, None)
}

#[tauri::command]
pub fn cmd_recent_dictations(
    limit: Option<usize>,
) -> Result<Vec<minutes_core::dictation_memory::DictationMemoryRecord>, String> {
    minutes_core::dictation_memory::load_recent(limit.unwrap_or(6).clamp(1, 25))
        .map_err(|error| format!("Could not load recent dictations: {error}"))
}

#[tauri::command]
pub fn cmd_copy_dictation(
    id: String,
) -> Result<crate::text_insertion::TextInsertionResult, String> {
    let record = minutes_core::dictation_memory::find_record(&id)
        .map_err(|error| format!("Could not load dictation history: {error}"))?
        .ok_or_else(|| "Dictation was not found in local history.".to_string())?;
    Ok(crate::text_insertion::insert_text(
        crate::text_insertion::TextInsertionRequest {
            text: record.cleaned_text,
            mode: crate::text_insertion::TextInsertionMode::CopyOnly,
            restore_clipboard: false,
            clipboard_snapshot: None,
            expected_target: None,
        },
    ))
}

#[tauri::command]
pub fn cmd_repaste_dictation(
    id: String,
) -> Result<crate::text_insertion::TextInsertionResult, String> {
    let record = minutes_core::dictation_memory::find_record(&id)
        .map_err(|error| format!("Could not load dictation history: {error}"))?
        .ok_or_else(|| "Dictation was not found in local history.".to_string())?;
    let config = Config::load();
    let clipboard_snapshot = if config.dictation.auto_paste_restore {
        crate::text_insertion::read_clipboard().ok()
    } else {
        None
    };
    Ok(crate::text_insertion::insert_text(
        crate::text_insertion::TextInsertionRequest {
            text: record.cleaned_text,
            mode: crate::text_insertion::TextInsertionMode::BestEffortVerified,
            restore_clipboard: config.dictation.auto_paste_restore,
            clipboard_snapshot,
            expected_target: None,
        },
    ))
}

#[tauri::command]
pub fn cmd_stop_dictation(state: tauri::State<AppState>) -> Result<String, String> {
    if state.dictation_active.load(Ordering::Relaxed) {
        if let Ok(mut released_at) = state.dictation_release_started_at.lock() {
            *released_at = Some(Instant::now());
        }
        state.dictation_stop_flag.store(true, Ordering::Relaxed);
        return Ok("Dictation stop requested".into());
    }
    if dictation_pid_active() {
        return Err("Dictation is running in another Minutes process.".into());
    }
    Err("Dictation is not active".into())
}

fn show_dictation_overlay(app: &tauri::AppHandle) {
    use tauri::WebviewUrl;

    // Destroy existing overlay synchronously before rebuilding the same label.
    // `close()` only queues a close-request event; a stale transparent WebView
    // can otherwise overlap the new overlay during AppKit/WebKit frame updates.
    if let Some(win) = app.get_webview_window("dictation-overlay") {
        win.destroy().ok();
    }

    // Position: bottom-center HUD, anchored to the current monitor work area.
    let width = 320.0;
    let height = 88.0;
    let inset_y = 16.0;

    let monitor = app
        .get_webview_window("main")
        .and_then(|window| window.current_monitor().ok().flatten())
        .or_else(|| {
            app.get_webview_window("main")
                .and_then(|window| window.primary_monitor().ok().flatten())
        });

    let (x, y) = if let Some(monitor) = monitor {
        let scale = monitor.scale_factor();
        let work_area = monitor.work_area();
        let work_x = work_area.position.x as f64 / scale;
        let work_y = work_area.position.y as f64 / scale;
        let work_width = work_area.size.width as f64 / scale;
        let work_height = work_area.size.height as f64 / scale;
        (
            work_x + (work_width - width) / 2.0,
            work_y + work_height - height - inset_y,
        )
    } else {
        ((1440.0 - width) / 2.0, 900.0 - height - inset_y)
    };

    match tauri::WebviewWindowBuilder::new(
        app,
        "dictation-overlay",
        WebviewUrl::App("dictation-overlay.html".into()),
    )
    .title("Dictation")
    .inner_size(width, height)
    .position(x, y)
    .resizable(false)
    .decorations(false)
    .transparent(true)
    .shadow(false)
    .content_protected(Config::load().privacy.hide_from_screen_share)
    .always_on_top(true)
    .focused(false)
    .skip_taskbar(true)
    .build()
    {
        Ok(_) => eprintln!("[dictation] overlay shown"),
        Err(e) => eprintln!("[dictation] overlay failed: {}", e),
    }
}

// ── Live transcript commands ─────────────────────────────────

/// RAII guard that resets the live_transcript_active flag on drop (even on
/// panic) and then re-syncs the tray. The tray sync MUST run after the flag
/// flips back to false or `sync_tray_state` would still derive `Live` and
/// leave the menu reflecting an inactive session. Owning the `AppHandle`
/// here keeps the ordering correct in one place.
struct LiveActiveGuard {
    active: Arc<AtomicBool>,
    app: tauri::AppHandle,
}
impl Drop for LiveActiveGuard {
    fn drop(&mut self) {
        self.active.store(false, Ordering::SeqCst);
        crate::sync_tray_state(&self.app);
    }
}

/// Shared live transcript session runner. Spawned on a background thread by both
/// cmd_start_live_transcript and handle_live_shortcut_event.
fn run_live_session(app: tauri::AppHandle, active: Arc<AtomicBool>, stop_flag: Arc<AtomicBool>) {
    let _guard = LiveActiveGuard {
        active,
        app: app.clone(),
    };

    let mut config = Config::load();
    // Re-validate the pinned input device for mid-session disconnects
    // (#189). In-memory only; startup-side persistence is in main.rs.
    minutes_core::capture::auto_heal_missing_recording_device(&mut config);

    if let Ok(workspace) = crate::context::create_workspace(&config) {
        update_assistant_live_context(&workspace, true);
    }

    let live_context_session_id =
        minutes_core::desktop_context::maybe_start_live_transcript_session(
            &config.desktop_context,
            chrono::Local::now(),
        );

    let _desktop_context_collector = live_context_session_id.as_ref().and_then(|session_id| {
        match minutes_core::desktop_context::DesktopContextCollector::start(
            session_id.clone(),
            minutes_core::desktop_context::DesktopContextSessionKind::LiveTranscript,
            config.desktop_context.clone(),
        ) {
            Ok(collector) => Some(collector),
            Err(error) => {
                tracing::warn!(error = %error, "desktop context collector unavailable for live transcript session");
                None
            }
        }
    });

    // The live flag was set by `try_acquire_live` before run_live_session
    // was spawned; sync the tray to reflect the just-started session.
    crate::sync_tray_state(&app);

    let result = minutes_core::live_transcript::run(
        stop_flag.clone(),
        &config,
        live_context_session_id.clone(),
    );

    stop_flag.store(false, Ordering::Relaxed);

    if let Ok(workspace) = crate::context::create_workspace(&config) {
        update_assistant_live_context(&workspace, false);
    }

    match result {
        Ok((lines, duration, _path)) => {
            eprintln!(
                "[live-transcript] ended: {} lines in {:.0}s",
                lines, duration
            );
            if let Some(win) = app.get_webview_window("main") {
                win.emit(
                    "live-transcript:stopped",
                    serde_json::json!({ "lines": lines, "duration_secs": duration }),
                )
                .ok();
            }
        }
        Err(e) => {
            eprintln!("[live-transcript] error: {}", e);
            if let Some(win) = app.get_webview_window("main") {
                win.emit(
                    "live-transcript:error",
                    serde_json::json!({ "error": e.to_string() }),
                )
                .ok();
            }
        }
    }

    // Tray sync fires from `LiveActiveGuard`'s Drop once this function
    // returns and the guard sets `live_transcript_active` to false. Calling
    // sync_tray_state here would still see the flag as true and re-render
    // the menu in Live mode.
}

/// Try to acquire the live transcript state. Returns Err with a message on conflict.
/// RAII guard that resets the dictation_active flag on drop (even on panic)
/// and re-syncs the tray. Same shape as `LiveActiveGuard` — owning the
/// `AppHandle` keeps the flag-flip-then-sync ordering correct in one place.
struct DictationActiveGuard {
    active: Arc<AtomicBool>,
    app: tauri::AppHandle,
}
impl Drop for DictationActiveGuard {
    fn drop(&mut self) {
        self.active.store(false, Ordering::SeqCst);
        crate::sync_tray_state(&self.app);
    }
}

/// Try to acquire the dictation state. Mirrors `try_acquire_live`: gates
/// against recording / live / dictation, uses `compare_exchange` to close
/// the load→store TOCTOU window in the old code (`load` at the top of
/// `start_dictation_session`, `store` after overlay setup), and rolls back
/// the flag on subsequent failure cases.
fn try_acquire_dictation(state: &AppState) -> Result<(), String> {
    if recording_active(&state.recording) {
        return Err("Recording in progress — stop recording before dictating".into());
    }
    if state.live_transcript_active.load(Ordering::Relaxed) {
        return Err("Live transcript in progress — stop it before dictating".into());
    }
    if state
        .dictation_active
        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
        .is_err()
    {
        return Err("Dictation is already in progress.".into());
    }
    if dictation_pid_active() {
        state.dictation_active.store(false, Ordering::SeqCst);
        return Err("Dictation is running in another Minutes process.".into());
    }
    Ok(())
}

fn try_acquire_live(state: &AppState) -> Result<(), String> {
    if state
        .live_transcript_active
        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
        .is_err()
    {
        return Err("Live transcript already active".into());
    }
    if recording_active(&state.recording) {
        state.live_transcript_active.store(false, Ordering::SeqCst);
        return Err("Recording already in progress — it already includes a live transcript".into());
    }
    if state.dictation_active.load(Ordering::Relaxed) {
        state.live_transcript_active.store(false, Ordering::SeqCst);
        return Err("Dictation in progress — stop dictation first".into());
    }
    Ok(())
}

#[tauri::command]
pub fn cmd_start_live_transcript(
    app: tauri::AppHandle,
    state: tauri::State<AppState>,
) -> Result<(), String> {
    try_acquire_live(&state)?;

    let permission_preflight = minutes_core::capture::preflight_microphone_only();
    if let Some(reason) = permission_preflight.blocking_reason {
        state.live_transcript_active.store(false, Ordering::SeqCst);
        return Err(reason);
    }
    for warning in permission_preflight.warnings {
        eprintln!("[live-transcript] {}", warning);
        app.emit("live-transcript:warning", warning.as_str()).ok();
    }

    let active = state.live_transcript_active.clone();
    let stop_flag = state.live_transcript_stop_flag.clone();
    stop_flag.store(false, Ordering::Relaxed);

    let app_clone = app.clone();
    std::thread::spawn(move || run_live_session(app_clone, active, stop_flag));

    if let Some(win) = app.get_webview_window("main") {
        win.emit("live-transcript:started", ()).ok();
    }

    Ok(())
}

#[tauri::command]
pub fn cmd_stop_live_transcript(state: tauri::State<AppState>) -> Result<(), String> {
    if state.live_transcript_active.load(Ordering::Relaxed) {
        state
            .live_transcript_stop_flag
            .store(true, Ordering::Relaxed);
        return Ok(());
    }
    // Check for external live transcript (started from CLI). `inspect_pid_file`
    // so a session holding the PID under a mandatory Windows lock is detected; the
    // stop sentinel (polled inline by the live loop) stops it on any platform, and
    // the Unix SIGTERM is sent only when the PID is readable. See #258.
    let lt_pid = minutes_core::pid::live_transcript_pid_path();
    let lt_state = minutes_core::pid::inspect_pid_file(&lt_pid);
    if lt_state.is_active() {
        minutes_core::pid::write_stop_sentinel()
            .map_err(|e| format!("failed to write stop sentinel: {}", e))?;
        #[cfg(unix)]
        if let Some(pid) = lt_state.pid() {
            unsafe {
                libc::kill(pid as i32, libc::SIGTERM);
            }
        }
        return Ok(());
    }
    Err("No live transcript session active".into())
}

#[tauri::command]
pub fn cmd_live_transcript_status(state: tauri::State<AppState>) -> serde_json::Value {
    let in_app_active = state.live_transcript_active.load(Ordering::Relaxed);
    let status = minutes_core::live_transcript::session_status();
    let audio_level = if in_app_active {
        minutes_core::streaming::stream_audio_level()
    } else {
        0
    };
    serde_json::json!({
        "active": in_app_active || status.active,
        "line_count": status.line_count,
        "duration_secs": status.duration_secs,
        "audioLevel": audio_level,
        "source": status.source,
        "diagnostic": status.diagnostic,
    })
}

/// Update the assistant instruction files to mention (or un-mention)
/// the live transcript. This makes any agent aware of the live JSONL file
/// without requiring MCP.
pub fn handle_live_shortcut_event(
    app: &tauri::AppHandle,
    shortcut_state: tauri_plugin_global_shortcut::ShortcutState,
) {
    let state = app.state::<AppState>();
    if !state.live_shortcut_enabled.load(Ordering::Relaxed) {
        return;
    }
    if shortcut_state != tauri_plugin_global_shortcut::ShortcutState::Pressed {
        return;
    }

    // Toggle: if active, stop. If idle, start.
    if state.live_transcript_active.load(Ordering::Relaxed) {
        state
            .live_transcript_stop_flag
            .store(true, Ordering::Relaxed);
    } else if try_acquire_live(&state).is_ok() {
        let active = state.live_transcript_active.clone();
        let stop_flag = state.live_transcript_stop_flag.clone();
        stop_flag.store(false, Ordering::Relaxed);
        let app_clone = app.clone();
        std::thread::spawn(move || run_live_session(app_clone, active, stop_flag));
        if let Some(win) = app.get_webview_window("main") {
            win.emit("live-transcript:started", ()).ok();
        }
    }
    // else: conflicting mode, silently ignore (shortcut is best-effort)
}

#[tauri::command]
pub fn cmd_live_shortcut_settings(state: tauri::State<AppState>) -> HotkeySettings {
    let enabled = state.live_shortcut_enabled.load(Ordering::Relaxed);
    let shortcut = state
        .live_shortcut
        .lock()
        .map(|s| s.clone())
        .unwrap_or_else(|_| "CmdOrCtrl+Shift+L".into());
    HotkeySettings {
        enabled,
        shortcut,
        choices: live_shortcut_choices(),
    }
}

#[tauri::command]
pub fn cmd_set_live_shortcut(
    app: tauri::AppHandle,
    state: tauri::State<AppState>,
    enabled: bool,
    shortcut: String,
) -> Result<HotkeySettings, String> {
    use tauri_plugin_global_shortcut::GlobalShortcutExt;

    let next_shortcut = validate_live_shortcut(&shortcut)?;
    let previous = cmd_live_shortcut_settings(state.clone());
    let manager = app.global_shortcut();

    if previous.enabled {
        manager
            .unregister(previous.shortcut.as_str())
            .map_err(|e| format!("Could not unregister {}: {}", previous.shortcut, e))?;
    }

    if enabled {
        if let Err(e) = manager.register(next_shortcut.as_str()) {
            if previous.enabled {
                let _ = manager.register(previous.shortcut.as_str());
            }
            return Err(format!(
                "Could not register {}. Another app may already be using it. ({})",
                next_shortcut, e
            ));
        }
    }

    state
        .live_shortcut_enabled
        .store(enabled, Ordering::Relaxed);
    if let Ok(mut current) = state.live_shortcut.lock() {
        *current = next_shortcut.clone();
    }

    // Persist to config.toml
    cmd_set_setting(
        "live_transcript".into(),
        "shortcut_enabled".into(),
        enabled.to_string(),
    )?;
    cmd_set_setting("live_transcript".into(), "shortcut".into(), next_shortcut)?;

    Ok(cmd_live_shortcut_settings(state))
}

#[tauri::command]
pub fn cmd_palette_settings(state: tauri::State<AppState>) -> HotkeySettings {
    let enabled = state.palette_shortcut_enabled.load(Ordering::Relaxed);
    let shortcut = state
        .palette_shortcut
        .lock()
        .map(|s| s.clone())
        .unwrap_or_else(|_| default_palette_shortcut().to_string());
    HotkeySettings {
        enabled,
        shortcut,
        choices: palette_shortcut_choices(),
    }
}

/// Reject a palette shortcut that collides with another Minutes
/// shortcut. The other dropdowns (quick-thought hotkey, dictation,
/// live transcript) all hand-out chord strings; if the user picks the
/// same chord for two of them, the second `register` call will
/// silently fail at the OS level and one of the two features stops
/// working with no surfaced error. This helper turns that into a
/// clear up-front rejection.
///
/// Codex pass 3 + claude pass 3 P2.
fn ensure_no_palette_shortcut_collision(state: &AppState, candidate: &str) -> Result<(), String> {
    let in_use = [
        (
            "dictation",
            state.dictation_shortcut_enabled.load(Ordering::Relaxed),
            state.dictation_shortcut.lock().ok().map(|s| s.clone()),
        ),
        (
            "live transcript",
            state.live_shortcut_enabled.load(Ordering::Relaxed),
            state.live_shortcut.lock().ok().map(|s| s.clone()),
        ),
        (
            "quick thought hotkey",
            state.global_hotkey_enabled.load(Ordering::Relaxed),
            state.global_hotkey_shortcut.lock().ok().map(|s| s.clone()),
        ),
    ];
    shortcut_collision_error(candidate, &in_use)
}

fn shortcut_collision_error(
    candidate: &str,
    in_use: &[(&str, bool, Option<String>)],
) -> Result<(), String> {
    for (name, enabled, value) in in_use {
        if *enabled && value.as_deref().is_some_and(|other| other == candidate) {
            return Err(format!(
                "{} is already used by the {} shortcut",
                candidate, name
            ));
        }
    }
    Ok(())
}

#[tauri::command]
pub fn cmd_set_palette_shortcut(
    app: tauri::AppHandle,
    state: tauri::State<AppState>,
    enabled: bool,
    shortcut: String,
) -> Result<HotkeySettings, String> {
    use tauri_plugin_global_shortcut::GlobalShortcutExt;

    let next_shortcut = validate_palette_shortcut(&shortcut)?;
    if enabled {
        ensure_no_palette_shortcut_collision(&state, &next_shortcut)?;
    }
    let previous = cmd_palette_settings(state.clone());
    let manager = app.global_shortcut();

    if previous.enabled {
        // Codex pass 3 P2: treat unregister failure as fatal. The
        // previous code logged-and-continued, which left the OLD
        // chord still registered AND the new chord registered on top
        // of it. Subsequent presses of the old chord no longer
        // matched `palette_shortcut_id` (state was already updated)
        // and fell through to `handle_global_hotkey_event` — i.e.
        // the wrong feature fired. Better to refuse the rebind than
        // to leave the routing inconsistent.
        if let Err(e) = manager.unregister(previous.shortcut.as_str()) {
            return Err(format!(
                "Could not unregister previous palette shortcut {}: {}",
                previous.shortcut, e
            ));
        }
    }

    if enabled {
        if let Err(e) = manager.register(next_shortcut.as_str()) {
            // The new shortcut won't register — try to restore the
            // previous one so the user keeps a working palette
            // toggle. If the rollback ALSO fails, force-disable the
            // palette shortcut so the in-memory state matches the
            // empty OS registration. Claude pass 3 P2 #8: silent
            // dead palette is the worst failure mode.
            let mut rollback_failed = false;
            if previous.enabled {
                if let Err(rollback_err) = manager.register(previous.shortcut.as_str()) {
                    eprintln!(
                        "[palette-shortcut] rollback re-register of {} failed: {}",
                        previous.shortcut, rollback_err
                    );
                    rollback_failed = true;
                }
            }
            if rollback_failed {
                state
                    .palette_shortcut_enabled
                    .store(false, Ordering::Relaxed);
                // We're already returning an error to the user; if persisting
                // the force-disabled state ALSO fails, log it rather than
                // swallow (the original registration error is what the user
                // needs to see, so don't override the return value here).
                if let Err(persist_err) =
                    cmd_set_setting("palette".into(), "shortcut_enabled".into(), "false".into())
                {
                    eprintln!(
                        "[palette-shortcut] failed to persist force-disabled state: {}",
                        persist_err
                    );
                }
                return Err(format!(
                    "Could not register {} and could not restore the previous shortcut. \
                     Palette shortcut is now disabled — set a different binding from \
                     Settings to re-enable.",
                    next_shortcut
                ));
            }
            return Err(format!(
                "Could not register {}. Another app may already be using it. ({})",
                next_shortcut, e
            ));
        }
    }

    state
        .palette_shortcut_enabled
        .store(enabled, Ordering::Relaxed);
    if let Ok(mut current) = state.palette_shortcut.lock() {
        *current = next_shortcut.clone();
    }

    // Persist to config.toml so the next launch picks up the user's
    // choice without re-running the migration.
    cmd_set_setting(
        "palette".into(),
        "shortcut_enabled".into(),
        enabled.to_string(),
    )?;
    cmd_set_setting("palette".into(), "shortcut".into(), next_shortcut)?;

    Ok(cmd_palette_settings(state))
}

/// Marker file used to track whether the palette first-run notice has
/// been shown to the user. Stored as a sibling to `palette.json` in
/// `~/.minutes/` so it survives config rewrites and works across
/// processes (CLI vs desktop) without a config schema dance.
fn palette_first_run_marker() -> PathBuf {
    Config::minutes_dir().join("palette_first_run_shown")
}

/// Fire a one-shot system notification announcing the new command
/// palette. Called from `main.rs::setup` after the palette shortcut
/// is registered. The marker file ensures this only happens once per
/// machine, even across reinstalls — the only way to re-trigger it is
/// to delete the marker file manually.
///
/// **Why this exists**: the upgrade migration used to default the
/// shortcut to OFF specifically to avoid hijacking VS Code's
/// `Delete Line` and JetBrains' `Push...` chords without consent.
/// That made the feature undiscoverable. The current design defaults
/// ON for both fresh installs and upgrades, but fires this
/// notification on the first launch so users with a real conflict
/// hear about it immediately and can disable from the settings UI in
/// one click. See PLAN.md.command-palette-slice-2 D10 (post-fix).
pub fn maybe_show_palette_first_run_notice(app: &tauri::AppHandle) {
    let marker = palette_first_run_marker();
    if marker.exists() {
        return;
    }

    let state = app.state::<AppState>();
    if !state.palette_shortcut_enabled.load(Ordering::Relaxed) {
        // The user (or some other process) already opted out before
        // the notice ran. Don't show it.
        return;
    }
    let shortcut = state
        .palette_shortcut
        .lock()
        .map(|s| s.clone())
        .unwrap_or_else(|_| default_palette_shortcut().to_string());

    let body = format!(
        "Press {} to open the new command palette. \
         Disable in Settings if it conflicts with your other apps.",
        humanize_shortcut(&shortcut)
    );

    // Dispatch the notification FIRST. The marker is only written on
    // successful delivery so the next launch retries if delivery
    // failed (notification permission denied, Notification Center
    // unhealthy, etc.). Codex pass 3 P1 + Claude pass 3 P1 #4: the
    // earlier marker-before-show ordering meant a single failed
    // dispatch permanently suppressed the only consent surface for
    // the upgrade-on default. Retrying on every launch is mildly
    // annoying but strictly better than silently hijacking a chord
    // the user can't recover from.
    let delivery_result = app
        .notification()
        .builder()
        .title("Minutes command palette")
        .body(body)
        .show();

    match delivery_result {
        Ok(_) => {
            if let Some(parent) = marker.parent() {
                let _ = std::fs::create_dir_all(parent);
            }
            if let Err(e) = std::fs::write(&marker, "shown\n") {
                eprintln!(
                    "[palette] could not write first-run marker {}: {}",
                    marker.display(),
                    e
                );
            }
        }
        Err(e) => {
            // Don't write the marker. The fallback consent surface is
            // the visible "Minutes Palette" branding inside the
            // overlay itself plus the dedicated Settings row under
            // Shortcuts → Command palette. A user who hits ⌘⇧K
            // expecting VS Code's Delete Line will at least see
            // "Minutes Palette" in the overlay header and can find
            // the toggle in Settings → Shortcuts → Command palette.
            eprintln!(
                "[palette] first-run notification failed: {} (will retry on next launch)",
                e
            );
        }
    }
}

/// Render an Accelerator-style shortcut string ("CmdOrCtrl+Shift+K")
/// as a more readable form ("⌘⇧K"). Used in the first-run notice so
/// the user can mentally match it to the symbol they'd hit on the
/// keyboard.
fn humanize_shortcut(shortcut: &str) -> String {
    shortcut
        .split('+')
        .map(|piece| match piece {
            "CmdOrCtrl" | "Cmd" | "Command" | "Meta" => "⌘".to_string(),
            "Shift" => "⇧".to_string(),
            "Alt" | "Option" | "Opt" => "⌥".to_string(),
            "Ctrl" | "Control" => "⌃".to_string(),
            "Space" => "Space".to_string(),
            other => other.to_string(),
        })
        .collect::<Vec<_>>()
        .join("")
}

fn update_assistant_live_context_file(path: &std::path::Path, live_active: bool) {
    let existing = std::fs::read_to_string(path).unwrap_or_default();
    let marker_start = "<!-- LIVE_TRANSCRIPT_START -->";
    let marker_end = "<!-- LIVE_TRANSCRIPT_END -->";

    // Remove any existing live transcript section (T4: validate marker order)
    let cleaned = if let (Some(start), Some(end)) =
        (existing.find(marker_start), existing.find(marker_end))
    {
        if start < end {
            let end_pos = end + marker_end.len();
            format!("{}{}", &existing[..start], &existing[end_pos..])
        } else {
            // Markers out of order (corrupt file). Remove both markers individually.
            existing.replace(marker_start, "").replace(marker_end, "")
        }
    } else {
        // Remove any orphaned single marker
        existing.replace(marker_start, "").replace(marker_end, "")
    };

    let updated = if live_active {
        let jsonl_path = minutes_core::pid::live_transcript_jsonl_path();
        let section = format!(
            "\n{marker_start}\n\
            ## Live Transcript Active\n\
            \n\
            A live meeting transcript is being recorded right now.\n\
            \n\
            **JSONL file:** `{path}`\n\
            \n\
            Each line is a JSON object with: `line` (sequence number), `ts` (wall clock), \
            `offset_ms` (ms since session start), `duration_ms`, `text`, `speaker` (null for now).\n\
            \n\
            To read the latest utterances:\n\
            - **File:** `cat {path} | tail -5` (last 5 utterances)\n\
            - **CLI:** `minutes transcript --since 5m` (last 5 minutes)\n\
            - **MCP:** Use `read_live_transcript` tool with `since: \"5m\"`\n\
            \n\
            The user may ask for coaching during the meeting. Read the recent transcript \
            to understand what's being discussed, then provide tactical advice.\n\
            {marker_end}\n",
            marker_start = marker_start,
            marker_end = marker_end,
            path = jsonl_path.display(),
        );
        format!("{}{}", cleaned.trim_end(), section)
    } else {
        cleaned
    };

    // Atomic write: write to temp file then rename (T7)
    let content = updated.trim_end().to_string() + "\n";
    let tmp = path.with_extension("md.tmp");
    if std::fs::write(&tmp, &content).is_ok() {
        std::fs::rename(&tmp, path).ok();
    }
}

fn update_assistant_live_context(workspace: &std::path::Path, live_active: bool) {
    for file_name in crate::context::ASSISTANT_INSTRUCTION_FILES {
        update_assistant_live_context_file(&workspace.join(file_name), live_active);
    }
}

pub(crate) fn dictation_pid_active() -> bool {
    minutes_core::pid::check_pid_file(&minutes_core::pid::dictation_pid_path())
        .ok()
        .flatten()
        .is_some()
}

fn dictation_record_engine_id(config: &Config) -> String {
    match config.dictation.backend.as_str() {
        "whisper" | "" => format!("whisper:{}", config.dictation.model),
        backend => backend.to_string(),
    }
}

fn dictation_record_engine_descriptor(config: &Config) -> Option<String> {
    match config.dictation.backend.as_str() {
        "whisper" | "" => Some(config.dictation.model.clone()),
        backend => Some(backend.to_string()),
    }
}

pub fn capture_pending_dictation_target(app: &tauri::AppHandle) {
    let Some(state) = app.try_state::<AppState>() else {
        return;
    };
    let capture_start = Instant::now();
    let target_context = crate::text_insertion::capture_active_target_context();
    dictation_focus_debug(
        "pending_target_captured",
        target_context.as_ref(),
        None,
        Some(format!("capture_ms={}", capture_start.elapsed().as_millis()).as_str()),
    );
    let pending = PendingDictationTarget {
        captured_at: Instant::now(),
        target_context,
    };
    let store_result = state.pending_dictation_target.lock();
    match store_result {
        Ok(mut guard) => *guard = Some(pending),
        Err(poisoned) => *poisoned.into_inner() = Some(pending),
    }
}

fn take_pending_dictation_target(
    state: &AppState,
) -> Option<crate::text_insertion::ActiveTargetContext> {
    const PENDING_TARGET_MAX_AGE: Duration = Duration::from_secs(5);
    let pending = match state.pending_dictation_target.lock() {
        Ok(mut guard) => guard.take(),
        Err(poisoned) => poisoned.into_inner().take(),
    }?;

    let age = pending.captured_at.elapsed();
    if age <= PENDING_TARGET_MAX_AGE {
        dictation_focus_debug(
            "pending_target_used",
            pending.target_context.as_ref(),
            None,
            Some(format!("age_ms={}", age.as_millis()).as_str()),
        );
        return pending.target_context;
    }

    dictation_focus_debug(
        "pending_target_expired",
        pending.target_context.as_ref(),
        None,
        Some(format!("age_ms={}", age.as_millis()).as_str()),
    );
    None
}

fn dictation_insertion_memory(
    insertion: &crate::text_insertion::TextInsertionResult,
) -> minutes_core::dictation_memory::DictationInsertionMemory {
    minutes_core::dictation_memory::DictationInsertionMemory {
        outcome: insertion.outcome.as_str().into(),
        method: insertion.method.as_str().into(),
        verified: insertion.verified,
        clipboard_restored: insertion.clipboard_restored,
        message: insertion.message.clone(),
    }
}

fn dictation_target_context(
    insertion: &crate::text_insertion::TextInsertionResult,
) -> Option<minutes_core::dictation_memory::DictationTargetContext> {
    insertion.target_context.as_ref().map(|context| {
        minutes_core::dictation_memory::DictationTargetContext {
            platform: context.platform.clone(),
            app_name: context.app_name.clone(),
        }
    })
}

fn record_dictation_memory(
    config: &Config,
    result: &minutes_core::dictation::DictationResult,
    insertion: &crate::text_insertion::TextInsertionResult,
) {
    let record = minutes_core::dictation_memory::DictationMemoryRecord::new(
        minutes_core::dictation_memory::DictationMemoryInput {
            raw_text: result.raw_text.clone(),
            cleaned_text: result.text.clone(),
            duration_secs: result.duration_secs,
            engine_id: dictation_record_engine_id(config),
            engine_descriptor_version: dictation_record_engine_descriptor(config),
            vocabulary_mode: None,
            vocabulary_used: Vec::new(),
            destination: result.destination.clone(),
            insertion: dictation_insertion_memory(insertion),
            target_context: dictation_target_context(insertion),
            file_path: result.file_path.clone(),
            daily_note_appended: result.daily_note_appended,
        },
    );
    if let Err(error) = minutes_core::dictation_memory::append_record(record) {
        tracing::warn!(error = %error, "failed to persist dictation memory record");
    }
}

fn dictation_should_insert(config: &Config) -> bool {
    match config.dictation.destination.as_str() {
        "insert" => true,
        "" | "clipboard" => false,
        _ => config.dictation.auto_paste,
    }
}

fn dictation_insert_fallback_message(config: &Config) -> Option<&'static str> {
    if dictation_should_insert(config) && !crate::text_insertion::can_insert_into_apps() {
        Some(crate::text_insertion::insertion_permission_fallback_message())
    } else {
        None
    }
}

fn take_dictation_release_started_at(state: &AppState) -> Option<Instant> {
    state
        .dictation_release_started_at
        .lock()
        .ok()
        .and_then(|mut released_at| released_at.take())
}

fn log_dictation_insert(
    result: &minutes_core::dictation::DictationResult,
    insertion: &crate::text_insertion::TextInsertionResult,
    release_started_at: Option<Instant>,
    insert_started_at: Instant,
) {
    let release_to_inserted_ms = release_started_at.map(|started| started.elapsed().as_millis());
    let duration_ms =
        release_to_inserted_ms.unwrap_or_else(|| insert_started_at.elapsed().as_millis());
    let file_label = result
        .file_path
        .as_ref()
        .map(|path| path.display().to_string())
        .unwrap_or_default();
    minutes_core::logging::log_step(
        "dictation_insert",
        &file_label,
        duration_ms as u64,
        serde_json::json!({
            "release_to_inserted_ms": release_to_inserted_ms,
            "insert_operation_ms": insert_started_at.elapsed().as_millis(),
            "destination": result.destination,
            "outcome": insertion.outcome.as_str(),
            "method": insertion.method.as_str(),
            "verified": insertion.verified,
            "clipboard_restored": insertion.clipboard_restored,
        }),
    );
}

fn restore_dictation_target_focus(
    target_context: &Option<crate::text_insertion::ActiveTargetContext>,
) {
    let Some(context) = target_context else {
        dictation_focus_debug(
            "restore_target_focus_skipped",
            None,
            None,
            Some("no captured target"),
        );
        return;
    };
    if let Err(error) = crate::text_insertion::restore_target_focus(context) {
        tracing::debug!(error = %error, "could not restore focus to dictation target");
        dictation_focus_debug(
            "restore_target_focus_failed",
            Some(context),
            None,
            Some(error.as_str()),
        );
        return;
    }

    // Give the window server a beat before clipboard paste automation or overlay
    // dismissal; otherwise macOS can keep Minutes as the active app.
    std::thread::sleep(Duration::from_millis(120));
    dictation_focus_debug("restore_target_focus_ok", Some(context), None, None);
}

fn hide_main_window_for_external_dictation(
    app: &tauri::AppHandle,
    target_context: &Option<crate::text_insertion::ActiveTargetContext>,
) -> bool {
    let target_bundle_id = target_context
        .as_ref()
        .and_then(|context| context.bundle_id.as_deref());
    if target_bundle_id == Some(app.config().identifier.as_str()) {
        return false;
    }

    let Some(window) = app.get_webview_window("main") else {
        return false;
    };
    if !window.is_visible().ok().unwrap_or(false) {
        return false;
    }
    if window.is_focused().ok().unwrap_or(false) {
        return false;
    }

    match window.hide() {
        Ok(()) => {
            dictation_focus_debug(
                "main_window_hidden",
                target_context.as_ref(),
                Some(true),
                None,
            );
            true
        }
        Err(error) => {
            tracing::debug!(error = %error, "could not hide main window during dictation");
            dictation_focus_debug(
                "main_window_hide_failed",
                target_context.as_ref(),
                Some(false),
                Some(error.to_string().as_str()),
            );
            false
        }
    }
}

fn finish_dictation_overlay_lifecycle(app: &tauri::AppHandle, guard: Option<DictationFocusGuard>) {
    dictation_focus_debug(
        "finish_overlay_lifecycle_start",
        guard
            .as_ref()
            .and_then(|guard| guard.target_context.as_ref()),
        guard.as_ref().map(|guard| guard.main_window_hidden),
        None,
    );
    if let Some(window) = app.get_webview_window("dictation-overlay") {
        if let Err(error) = window.destroy() {
            tracing::debug!(error = %error, "could not close dictation overlay");
            dictation_focus_debug(
                "overlay_close_failed",
                guard
                    .as_ref()
                    .and_then(|guard| guard.target_context.as_ref()),
                guard.as_ref().map(|guard| guard.main_window_hidden),
                Some(error.to_string().as_str()),
            );
        }
    }

    std::thread::sleep(Duration::from_millis(100));

    let Some(guard) = guard else {
        return;
    };

    if guard.main_window_hidden {
        dictation_focus_debug(
            "main_window_restore_deferred",
            guard.target_context.as_ref(),
            Some(true),
            Some("left hidden to avoid activating Minutes after external dictation"),
        );
    }

    restore_dictation_target_focus(&guard.target_context);
    dictation_focus_debug(
        "finish_overlay_lifecycle_done",
        guard.target_context.as_ref(),
        Some(guard.main_window_hidden),
        None,
    );
}

#[tauri::command]
pub fn cmd_dismiss_dictation_overlay(
    app: tauri::AppHandle,
    state: tauri::State<AppState>,
) -> Result<(), String> {
    let guard = match state.dictation_focus_guard.lock() {
        Ok(mut guard) => guard.take(),
        Err(poisoned) => poisoned.into_inner().take(),
    };
    finish_dictation_overlay_lifecycle(&app, guard);
    Ok(())
}

/// Public entry point for the shortcut manager to start a dictation session.
pub fn start_dictation_session_public(
    app: &tauri::AppHandle,
    capture_style: Option<HotkeyCaptureStyle>,
) -> Result<(), String> {
    start_dictation_session(app, capture_style).map(|_| ())
}

fn start_dictation_session(
    app: &tauri::AppHandle,
    capture_style: Option<HotkeyCaptureStyle>,
) -> Result<String, String> {
    let state = app.state::<AppState>();

    // Acquire BEFORE any side effects (overlay, focus capture, emits). The
    // previous load→store gap could let two starts race past the load and
    // both fall into overlay setup. `try_acquire_dictation` uses
    // compare_exchange and also gates against recording / live transcript.
    try_acquire_dictation(&state)?;
    // From here on, any early exit must drop `tray_guard` so the flag
    // resets and the tray re-syncs. The guard is moved into the spawned
    // thread once we get there; if any panic happens between here and the
    // spawn it'll drop on the local stack and clean up.
    let tray_guard = DictationActiveGuard {
        active: Arc::clone(&state.dictation_active),
        app: app.clone(),
    };

    let permission_preflight = minutes_core::capture::preflight_microphone_only();
    if let Some(reason) = permission_preflight.blocking_reason {
        return Err(reason);
    }
    for warning in permission_preflight.warnings {
        eprintln!("[dictation] {}", warning);
        app.emit("dictation:warning", warning.as_str()).ok();
    }

    let dictation_target_context = take_pending_dictation_target(&state)
        .or_else(crate::text_insertion::capture_active_target_context);
    dictation_focus_debug(
        "session_start_target_captured",
        dictation_target_context.as_ref(),
        None,
        None,
    );
    let main_window_hidden =
        hide_main_window_for_external_dictation(app, &dictation_target_context);
    let focus_guard = DictationFocusGuard {
        target_context: dictation_target_context.clone(),
        main_window_hidden,
    };
    match state.dictation_focus_guard.lock() {
        Ok(mut guard) => *guard = Some(focus_guard.clone()),
        Err(poisoned) => *poisoned.into_inner() = Some(focus_guard.clone()),
    }
    show_dictation_overlay(app);
    dictation_focus_debug(
        "overlay_shown",
        dictation_target_context.as_ref(),
        Some(main_window_hidden),
        None,
    );
    restore_dictation_target_focus(&dictation_target_context);
    app.emit("dictation:state", "loading").ok();

    state.dictation_stop_flag.store(false, Ordering::Relaxed);
    if let Ok(mut released_at) = state.dictation_release_started_at.lock() {
        *released_at = None;
    }
    // dictation_active is already true from try_acquire_dictation; sync the
    // tray so the menu reflects the just-started session.
    crate::sync_tray_state(app);

    let _ = capture_style;

    let app_clone = app.clone();
    let stop_flag = Arc::clone(&state.dictation_stop_flag);
    let final_output_emitted = Arc::new(AtomicBool::new(false));
    let dictation_target_context_for_thread = dictation_target_context.clone();
    std::thread::spawn(move || {
        // Move the tray guard into the thread. When this closure exits
        // (normal return, error, or panic) the guard drops, which stores
        // `dictation_active = false` and re-syncs the tray (idle).
        let _tray_guard = tray_guard;
        let mut config = Config::load();
        // Re-validate the pinned input device for mid-session
        // disconnects (#189). In-memory only; startup-side persistence
        // is in main.rs.
        minutes_core::capture::auto_heal_missing_recording_device(&mut config);
        let insert_available_at_start = dictation_insert_fallback_message(&config).is_none();
        let insert_fallback_message_for_events =
            dictation_insert_fallback_message(&config).map(str::to_string);
        let insert_fallback_emitted = Arc::new(AtomicBool::new(false));
        let app_for_events = app_clone.clone();
        let app_for_results = app_clone.clone();
        let config_for_results = config.clone();
        let final_output_for_results = Arc::clone(&final_output_emitted);
        let dictation_target_context_for_results = dictation_target_context_for_thread.clone();

        let result = minutes_core::dictation::run(
            stop_flag,
            &config,
            move |event| {
                use minutes_core::dictation::DictationEvent;
                let state_str = match &event {
                    DictationEvent::Listening => "listening",
                    DictationEvent::Accumulating => "accumulating",
                    DictationEvent::Processing => "processing",
                    DictationEvent::PartialText(_) => "partial",
                    DictationEvent::AudioLevel(_) => "",
                    DictationEvent::SilenceCountdown { .. } => "",
                    DictationEvent::Success => "success",
                    DictationEvent::Error => "error",
                    DictationEvent::Cancelled => "cancelled",
                    DictationEvent::Yielded => "yielded",
                };
                if !state_str.is_empty() {
                    if matches!(&event, DictationEvent::Listening) {
                        if let Some(message) = insert_fallback_message_for_events.as_ref() {
                            if !insert_fallback_emitted.swap(true, Ordering::Relaxed) {
                                app_for_events
                                    .emit(
                                        "dictation:insertion",
                                        serde_json::json!({
                                            "message": message,
                                            "earlyFallback": true,
                                        }),
                                    )
                                    .ok();
                            }
                        }
                    }
                    app_for_events.emit("dictation:state", state_str).ok();
                }

                if let DictationEvent::PartialText(text) = &event {
                    app_for_events.emit("dictation:partial", text.as_str()).ok();
                }

                if let DictationEvent::AudioLevel(level) = &event {
                    app_for_events.emit("dictation:level", level).ok();
                }

                if let DictationEvent::SilenceCountdown {
                    total_ms,
                    remaining_ms,
                } = &event
                {
                    app_for_events
                        .emit(
                            "dictation:silence",
                            serde_json::json!({
                                "total_ms": total_ms,
                                "remaining_ms": remaining_ms,
                            }),
                        )
                        .ok();
                }
            },
            move |result| {
                final_output_for_results.store(true, Ordering::Relaxed);
                app_for_results.emit("dictation:result", &result.text).ok();
                let insert_started_at = Instant::now();
                let release_started_at = app_for_results
                    .try_state::<AppState>()
                    .and_then(|state| take_dictation_release_started_at(&state));
                if dictation_should_insert(&config_for_results) && insert_available_at_start {
                    app_for_results.emit("dictation:state", "inserting").ok();
                    dictation_focus_debug(
                        "before_insert_restore",
                        dictation_target_context_for_results.as_ref(),
                        None,
                        None,
                    );
                    restore_dictation_target_focus(&dictation_target_context_for_results);
                    let insertion = crate::text_insertion::insert_text(
                        crate::text_insertion::TextInsertionRequest {
                            text: result.text.clone(),
                            mode: crate::text_insertion::TextInsertionMode::BestEffortVerified,
                            restore_clipboard: config_for_results.dictation.auto_paste_restore,
                            clipboard_snapshot: None,
                            expected_target: dictation_target_context_for_results.clone(),
                        },
                    );
                    app_for_results.emit("dictation:insertion", &insertion).ok();
                    app_for_results
                        .emit("dictation:state", insertion.overlay_state())
                        .ok();
                    log_dictation_insert(
                        &result,
                        &insertion,
                        release_started_at,
                        insert_started_at,
                    );
                    record_dictation_memory(&config_for_results, &result, &insertion);
                } else {
                    dictation_focus_debug(
                        "before_copy_restore",
                        dictation_target_context_for_results.as_ref(),
                        None,
                        None,
                    );
                    restore_dictation_target_focus(&dictation_target_context_for_results);
                    let insertion = crate::text_insertion::insert_text(
                        crate::text_insertion::TextInsertionRequest {
                            text: result.text.clone(),
                            mode: crate::text_insertion::TextInsertionMode::CopyOnly,
                            restore_clipboard: false,
                            clipboard_snapshot: None,
                            expected_target: None,
                        },
                    );
                    app_for_results.emit("dictation:insertion", &insertion).ok();
                    app_for_results
                        .emit("dictation:state", insertion.overlay_state())
                        .ok();
                    log_dictation_insert(
                        &result,
                        &insertion,
                        release_started_at,
                        insert_started_at,
                    );
                    record_dictation_memory(&config_for_results, &result, &insertion);
                }
            },
        );

        // dictation_active is flipped to false (and the tray re-syncs)
        // when `_tray_guard` drops on closure exit. See `DictationActiveGuard`.
        match result {
            Ok(()) => {
                // Session ended normally (silence timeout or yield).
                // Dismiss overlay if it wasn't already dismissed by a terminal event.
                if !final_output_emitted.load(Ordering::Relaxed) {
                    app_clone.emit("dictation:state", "cancelled").ok();
                    let guard = app_clone
                        .state::<AppState>()
                        .dictation_focus_guard
                        .lock()
                        .map(|mut guard| guard.take())
                        .unwrap_or_else(|poisoned| poisoned.into_inner().take());
                    if guard.is_some() {
                        finish_dictation_overlay_lifecycle(&app_clone, guard);
                    } else {
                        dictation_focus_debug(
                            "cancel_cleanup_already_consumed",
                            None,
                            None,
                            Some("overlay dismiss command already handled cleanup"),
                        );
                    }
                }
            }
            Err(e) => {
                eprintln!("[dictation] error: {}", e);
                app_clone.emit("dictation:state", "error").ok();
            }
        }
    });

    Ok("Dictation started".into())
}

// ── Unified Shortcut Commands ────────────────────────────────

#[tauri::command]
pub fn cmd_set_shortcut(
    app: tauri::AppHandle,
    slot: String,
    enabled: bool,
    shortcut: String,
    keycode: i64,
) -> Result<crate::shortcut_manager::ShortcutStatus, String> {
    use crate::shortcut_manager::{ShortcutManager, ShortcutSlot};

    let slot = ShortcutSlot::from_str(&slot)?;

    // Validate shortcut string
    if shortcut.len() > 50 {
        return Err("Shortcut string too long (max 50 characters)".into());
    }
    if !shortcut.is_empty()
        && !shortcut
            .chars()
            .all(|c| c.is_alphanumeric() || "+_ ".contains(c))
    {
        return Err(format!("Invalid characters in shortcut: {}", shortcut));
    }

    // Validate keycode range
    if !(-1..=255).contains(&keycode) {
        return Err(format!("Invalid keycode: {}", keycode));
    }

    // Acquire lock, perform registration/unregistration, then DROP before file I/O.
    let status = {
        let mgr_state = app.state::<std::sync::Arc<std::sync::Mutex<ShortcutManager>>>();
        let mut mgr = mgr_state
            .lock()
            .map_err(|_| "Shortcut manager lock poisoned".to_string())?;

        if enabled {
            mgr.register(slot, shortcut.clone(), keycode, &app)?
        } else {
            mgr.unregister(slot, &app)?;
            let mut s = mgr.build_status(slot);
            // Preserve the shortcut choice in status even when disabling
            if !shortcut.is_empty() {
                s.shortcut = shortcut.clone();
                s.keycode = keycode;
            }
            s
        }
    }; // lock dropped here

    if enabled {
        // Persist to config (no lock held)
        let mut config = Config::load();
        match slot {
            ShortcutSlot::Dictation => {
                config.dictation.shortcut_enabled = true;
                config.dictation.shortcut = status.shortcut.clone();
                let backend = crate::shortcut_manager::classify_shortcut(keycode);
                if backend == crate::shortcut_manager::ShortcutBackend::Native {
                    config.dictation.hotkey_enabled = true;
                    config.dictation.hotkey_keycode = keycode;
                } else {
                    config.dictation.hotkey_enabled = false;
                }
            }
            ShortcutSlot::QuickThought => {
                config.global_hotkey.shortcut_enabled = true;
                config.global_hotkey.shortcut = status.shortcut.clone();
            }
        }
        config
            .save()
            .map_err(|e| format!("Failed to save config: {}", e))?;

        // Preload model when dictation is first enabled
        if matches!(slot, ShortcutSlot::Dictation) {
            let config = Config::load();
            std::thread::spawn(move || {
                minutes_core::dictation::preload_model(&config).ok();
            });
        }

        Ok(status)
    } else {
        // Persist disabled state but keep the shortcut/keycode for later re-enable
        let mut config = Config::load();
        match slot {
            ShortcutSlot::Dictation => {
                config.dictation.shortcut_enabled = false;
                config.dictation.hotkey_enabled = false;
                if !shortcut.is_empty() {
                    let backend = crate::shortcut_manager::classify_shortcut(keycode);
                    if backend == crate::shortcut_manager::ShortcutBackend::Native {
                        config.dictation.hotkey_keycode = keycode;
                    } else {
                        config.dictation.shortcut = shortcut;
                    }
                }
            }
            ShortcutSlot::QuickThought => {
                config.global_hotkey.shortcut_enabled = false;
                if !shortcut.is_empty() {
                    config.global_hotkey.shortcut = shortcut;
                }
            }
        }
        config
            .save()
            .map_err(|e| format!("Failed to save config: {}", e))?;

        Ok(status)
    }
}

#[tauri::command]
pub fn cmd_shortcut_status(
    app: tauri::AppHandle,
    slot: String,
) -> Result<crate::shortcut_manager::ShortcutStatus, String> {
    use crate::shortcut_manager::{ShortcutManager, ShortcutSlot};

    let slot = ShortcutSlot::from_str(&slot)?;
    let mgr_state = app.state::<std::sync::Arc<std::sync::Mutex<ShortcutManager>>>();
    let mgr = mgr_state
        .lock()
        .map_err(|_| "Shortcut manager lock poisoned".to_string())?;
    Ok(mgr.build_status(slot))
}

#[tauri::command]
pub fn cmd_suspend_shortcut(app: tauri::AppHandle, slot: String) -> Result<(), String> {
    use crate::shortcut_manager::{ShortcutManager, ShortcutSlot};
    let slot = ShortcutSlot::from_str(&slot)?;
    let mgr_state = app.state::<std::sync::Arc<std::sync::Mutex<ShortcutManager>>>();
    let mut mgr = mgr_state
        .lock()
        .map_err(|_| "Shortcut manager lock poisoned".to_string())?;
    mgr.unregister(slot, &app)?;
    Ok(())
}

#[tauri::command]
pub fn cmd_probe_shortcut(keycode: i64) -> serde_json::Value {
    let backend = crate::shortcut_manager::classify_shortcut(keycode);
    let needs_native = backend == crate::shortcut_manager::ShortcutBackend::Native;

    let permission_granted = if needs_native {
        #[cfg(target_os = "macos")]
        {
            minutes_core::hotkey_macos::is_input_monitoring_granted()
        }
        #[cfg(not(target_os = "macos"))]
        {
            false
        }
    } else {
        true // Standard backend needs no permission
    };

    serde_json::json!({
        "keycode": keycode,
        "backend": if needs_native { "native" } else { "standard" },
        "needs_permission": needs_native && !permission_granted,
        "permission_granted": permission_granted,
        "supported": !needs_native || cfg!(target_os = "macos"),
    })
}

#[tauri::command]
pub async fn cmd_install_update(app: tauri::AppHandle) -> Result<serde_json::Value, String> {
    use tauri_plugin_updater::UpdaterExt;

    let state = app.state::<AppState>();
    if state.recording.load(Ordering::Relaxed) {
        return Err("Cannot update while recording. Stop the recording first.".into());
    }
    if state.starting.load(Ordering::Relaxed) {
        return Err("Recording is starting. Wait a moment and try again.".into());
    }
    if state.processing.load(Ordering::Relaxed) {
        return Err("Processing a recording. Wait until it finishes.".into());
    }
    if state.live_transcript_active.load(Ordering::Relaxed) {
        return Err("Cannot update during live transcription. Stop it first.".into());
    }
    if state.dictation_active.load(Ordering::Relaxed) {
        return Err("Cannot update during dictation. Stop it first.".into());
    }
    // §7: catch CLI-driven recordings the app didn't start. The flock-backed
    // PID files are the canonical source of truth for cross-process exclusion.
    #[cfg(target_os = "macos")]
    if crate::cli_setup::cli_recording_active() {
        return Err("A CLI recording is active. Stop it (`minutes stop`) before updating.".into());
    }

    if state
        .update_install_running
        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
        .is_err()
    {
        return Err("An update is already in progress.".into());
    }
    state.update_install_cancel.store(false, Ordering::SeqCst);

    let initial_pending = state
        .pending_update
        .lock()
        .ok()
        .and_then(|guard| guard.clone());

    let initial_ui = initial_pending
        .as_ref()
        .map(|pending| UpdateUiState::available(pending.version.clone(), pending.download_bytes))
        .unwrap_or_default()
        .checking();
    let _ = set_update_ui_state(&app, &state, initial_ui);

    let app_handle = app.clone();
    tauri::async_runtime::spawn(async move {
        let state = app_handle.state::<AppState>();
        let result = async {
            let updater = app_handle
                .updater()
                .map_err(|e| UpdateInstallError::Message(e.to_string()))?;
            let update = updater
                .check()
                .await
                .map_err(|e| UpdateInstallError::Message(e.to_string()))?
                .ok_or_else(|| UpdateInstallError::Message("No update available.".into()))?;

            let version = update.version.clone();
            let pending = PendingUpdate {
                version: version.clone(),
                body: update.body.clone().unwrap_or_default(),
                download_bytes: fetch_update_download_size(&update.download_url).await,
            };
            if let Ok(mut guard) = state.pending_update.lock() {
                *guard = Some(pending.clone());
            }
            emit_update_ready(&app_handle, &pending);

            let downloading = UpdateUiState::available(version.clone(), pending.download_bytes)
                .downloading(pending.download_bytes);
            let _ = set_update_ui_state(&app_handle, &state, downloading.clone());

            let bytes = download_update_bytes(
                &update,
                &state.update_install_cancel,
                |downloaded_bytes, total_bytes, bytes_per_sec, eta_seconds| {
                    let progress_state =
                        UpdateUiState::available(version.clone(), pending.download_bytes)
                            .with_progress(
                                downloaded_bytes,
                                total_bytes.or(pending.download_bytes),
                                bytes_per_sec,
                                eta_seconds,
                            );
                    let _ = set_update_ui_state(&app_handle, &state, progress_state);
                },
            )
            .await?;

            let total_bytes = pending.download_bytes.or(Some(bytes.len() as u64));
            let _ = set_update_ui_state(
                &app_handle,
                &state,
                UpdateUiState::available(version.clone(), total_bytes)
                    .verifying(bytes.len() as u64, total_bytes),
            );
            let pubkey = updater_pubkey().map_err(UpdateInstallError::Message)?;
            verify_update_signature(&bytes, &update.signature, &pubkey)
                .map_err(UpdateInstallError::Message)?;

            let _ = set_update_ui_state(
                &app_handle,
                &state,
                UpdateUiState::available(version.clone(), total_bytes)
                    .installing(bytes.len() as u64, total_bytes),
            );
            update.install(&bytes).map_err(|e| {
                UpdateInstallError::Message(format!("Update install failed: {}", e))
            })?;

            if let Ok(mut pending) = state.pending_update.lock() {
                *pending = None;
            }

            let _ = set_update_ui_state(
                &app_handle,
                &state,
                UpdateUiState::available(version.clone(), total_bytes)
                    .ready(bytes.len() as u64, total_bytes),
            );
            eprintln!("[updater] v{} installed, restarting", version);
            std::thread::sleep(Duration::from_millis(700));
            app_handle.restart();
            #[allow(unreachable_code)]
            Ok::<(), UpdateInstallError>(())
        }
        .await;

        if let Err(error) = result {
            match error {
                UpdateInstallError::Cancelled => {
                    if let Ok(mut guard) = state.update_install_state.lock() {
                        *guard = UpdateUiState::default();
                    }
                    if let Ok(guard) = state.pending_update.lock() {
                        if let Some(pending) = guard.as_ref() {
                            emit_update_ready(&app_handle, pending);
                        }
                    }
                }
                UpdateInstallError::Message(message) => {
                    let current = state
                        .update_install_state
                        .lock()
                        .ok()
                        .map(|guard| guard.clone())
                        .unwrap_or_default();
                    let _ = set_update_ui_state(&app_handle, &state, current.failed(message, true));
                }
            }
        }

        state.update_install_cancel.store(false, Ordering::SeqCst);
        state.update_install_running.store(false, Ordering::SeqCst);
    });

    Ok(serde_json::json!({"started": true}))
}

#[tauri::command]
pub fn cmd_cancel_update_install(app: tauri::AppHandle) -> Result<(), String> {
    let state = app.state::<AppState>();
    if !state.update_install_running.load(Ordering::SeqCst) {
        return Err("No update is currently in progress.".into());
    }
    let can_cancel = state
        .update_install_state
        .lock()
        .map_err(|_| "update state lock poisoned".to_string())?
        .can_cancel;
    if !can_cancel {
        return Err("Update can no longer be canceled.".into());
    }
    state.update_install_cancel.store(true, Ordering::SeqCst);
    Ok(())
}

#[tauri::command]
pub fn cmd_debug_simulate_update(app: tauri::AppHandle, scenario: String) -> Result<(), String> {
    if !app.config().identifier.contains(".dev") {
        return Err("Debug updater simulation is only available in Minutes Dev.app.".into());
    }
    debug_emit_update_state(&app, &scenario)
}

pub fn debug_emit_update_state(app: &tauri::AppHandle, scenario: &str) -> Result<(), String> {
    let state = app.state::<AppState>();
    let version = state
        .pending_update
        .lock()
        .ok()
        .and_then(|guard| guard.as_ref().map(|pending| pending.version.clone()))
        .unwrap_or_else(|| "0.0.0-dev".to_string());
    let available_version = version.clone();
    let total = Some(48 * 1024 * 1024_u64);
    let next = match scenario {
        "available" => UpdateUiState::available(available_version.clone(), total),
        "checking" => UpdateUiState::available(version, total).checking(),
        "downloading" => UpdateUiState::available(version, total).with_progress(
            12 * 1024 * 1024,
            total,
            Some(1.4 * 1024.0 * 1024.0),
            Some(26),
        ),
        "verifying" => UpdateUiState::available(version, total).verifying(48 * 1024 * 1024, total),
        "installing" => {
            UpdateUiState::available(version, total).installing(48 * 1024 * 1024, total)
        }
        "ready" => UpdateUiState::available(version, total).ready(48 * 1024 * 1024, total),
        "error" => UpdateUiState::available(version, total).failed(
            "Update download stalled. Check your connection and try again.",
            true,
        ),
        _ => return Err("Unknown debug scenario.".into()),
    };
    if scenario == "available" {
        emit_update_ready(
            app,
            &PendingUpdate {
                version: available_version,
                body: String::new(),
                download_bytes: total,
            },
        );
    }
    set_update_ui_state(app, &state, next)
}

// ─────────────────────────────────────────────────────────────────────
// What's New (post-update release notes)
// ─────────────────────────────────────────────────────────────────────

fn github_release_url_for_version(version: &str) -> String {
    format!(
        "https://github.com/silverstein/minutes/releases/tag/v{}",
        version
    )
}

const UPDATER_LATEST_JSON_URL: &str =
    "https://github.com/silverstein/minutes/releases/latest/download/latest.json";

fn github_release_body_from_json(text: &str) -> Option<String> {
    serde_json::from_str::<serde_json::Value>(text)
        .ok()
        .and_then(|v| v.get("body").and_then(|b| b.as_str()).map(String::from))
        .filter(|body| !body.trim().is_empty())
}

fn updater_notes_from_manifest_json(text: &str, version: &str) -> Option<String> {
    serde_json::from_str::<serde_json::Value>(text)
        .ok()
        .and_then(|v| {
            let manifest_version = v.get("version").and_then(|value| value.as_str())?;
            if manifest_version != version {
                return None;
            }
            v.get("notes")
                .and_then(|notes| notes.as_str())
                .map(String::from)
        })
        .filter(|body| !body.trim().is_empty())
}

fn fetch_github_release_notes(version: &str) -> Option<String> {
    let tag = format!("v{}", version);
    let url = format!(
        "https://api.github.com/repos/silverstein/minutes/releases/tags/{}",
        tag
    );

    match ureq::get(&url)
        .header("Accept", "application/vnd.github.v3+json")
        .header("User-Agent", "minutes-desktop")
        .call()
    {
        Ok(response) => response
            .into_body()
            .read_to_string()
            .ok()
            .and_then(|text| github_release_body_from_json(&text)),
        Err(_) => None,
    }
}

fn fetch_updater_manifest_notes(version: &str) -> Option<String> {
    match ureq::get(UPDATER_LATEST_JSON_URL)
        .header("Accept", "application/json")
        .header("User-Agent", "minutes-desktop")
        .call()
    {
        Ok(response) => response
            .into_body()
            .read_to_string()
            .ok()
            .and_then(|text| updater_notes_from_manifest_json(&text, version)),
        Err(_) => None,
    }
}

fn release_notes_for_version(version: &str) -> String {
    fetch_github_release_notes(version)
        .or_else(|| fetch_updater_manifest_notes(version))
        .unwrap_or_default()
}

/// Check whether the user should see "What's New" after an update.
///
/// Reads `~/.minutes/whats-new.json` for `last_seen_version`, compares it
/// to the running app version. If different, fetches the matching GitHub
/// release notes and returns them. Offline or 404 → still shows the
/// version bump, just without a body.
#[tauri::command]
pub async fn cmd_check_whats_new(app: tauri::AppHandle) -> Result<serde_json::Value, String> {
    let current = app.config().version.clone().unwrap_or_default();
    if current.is_empty() {
        return Ok(serde_json::json!({ "show": false }));
    }

    let state_path = Config::minutes_dir().join("whats-new.json");
    let last_seen = std::fs::read_to_string(&state_path)
        .ok()
        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
        .and_then(|v| {
            v.get("last_seen_version")
                .and_then(|v| v.as_str())
                .map(String::from)
        })
        .unwrap_or_default();

    if last_seen == current {
        return Ok(serde_json::json!({ "show": false }));
    }

    // First launch ever → record version, don't show notes
    if last_seen.is_empty() {
        let payload = serde_json::json!({ "last_seen_version": current });
        let _ = std::fs::write(
            &state_path,
            serde_json::to_string_pretty(&payload).unwrap_or_default(),
        );
        return Ok(serde_json::json!({ "show": false }));
    }

    // Version changed → fetch release notes. Prefer the GitHub release body,
    // then fall back to the updater manifest so post-update notes do not go
    // blank when the GitHub API is temporarily unavailable.
    let body = release_notes_for_version(&current);
    let release_url = github_release_url_for_version(&current);

    Ok(serde_json::json!({
        "show": true,
        "version": current,
        "previousVersion": last_seen,
        "body": body,
        "url": release_url,
    }))
}

/// Fetch the current version's release notes for a manual "What's New" view.
#[tauri::command]
pub async fn cmd_get_whats_new(app: tauri::AppHandle) -> Result<serde_json::Value, String> {
    let current = app.config().version.clone().unwrap_or_default();
    if current.is_empty() {
        return Err("App version is unavailable.".into());
    }

    let state_path = Config::minutes_dir().join("whats-new.json");
    let last_seen = std::fs::read_to_string(&state_path)
        .ok()
        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
        .and_then(|v| {
            v.get("last_seen_version")
                .and_then(|v| v.as_str())
                .map(String::from)
        })
        .unwrap_or_default();

    Ok(serde_json::json!({
        "show": true,
        "version": current,
        "previousVersion": last_seen,
        "body": release_notes_for_version(&current),
        "url": github_release_url_for_version(&current),
    }))
}

/// Mark the current version as seen so the modal won't show again.
#[tauri::command]
pub async fn cmd_dismiss_whats_new(app: tauri::AppHandle) -> Result<(), String> {
    let current = app.config().version.clone().unwrap_or_default();
    let state_path = Config::minutes_dir().join("whats-new.json");
    let payload = serde_json::json!({ "last_seen_version": current });
    std::fs::write(
        &state_path,
        serde_json::to_string_pretty(&payload).unwrap_or_default(),
    )
    .map_err(|e| e.to_string())
}

// ─────────────────────────────────────────────────────────────────────
// Command palette window management
// ─────────────────────────────────────────────────────────────────────

/// Global-shortcut handler for the palette toggle (`⌘⇧K` by default).
///
/// Reacts to `Pressed` only. The palette is a toggle on press, not a
/// hold-to-talk, so `Released` is ignored. Routes through the
/// lifecycle-aware `toggle_palette_window` helper to survive fast
/// double-press races.
pub fn handle_palette_shortcut_event(
    app: &tauri::AppHandle,
    shortcut_state: tauri_plugin_global_shortcut::ShortcutState,
) {
    if shortcut_state != tauri_plugin_global_shortcut::ShortcutState::Pressed {
        return;
    }
    let state = app.state::<AppState>();
    if !state.palette_shortcut_enabled.load(Ordering::Relaxed) {
        return;
    }
    toggle_palette_window(app);
}

/// Toggle the palette overlay window based on the current lifecycle state.
///
/// The state machine:
/// - `Closed`  → `Opening` → build window → `Open`
/// - `Open`    → `Closing` → destroy window → `Closed`
/// - `Opening` → ignore (duplicate press mid-create)
/// - `Closing` → queue a reopen; when destroy completes, transition
///   `Closed → Opening` immediately
///
/// All transitions happen under a `Mutex`. The window is destroyed
/// via `WebviewWindow::destroy` (not `close`) so the tear-down is
/// synchronous: codex pass 3 caught that `close()` only enqueues a
/// `RunEvent::CloseRequested` message which the runtime processes on
/// its own schedule, leaving a brief window where the OLD instance is
/// still live and a reopen race could attach to a window that is
/// about to disappear. `destroy()` skips the close-request event and
/// removes the window immediately.
/// In-window accelerator: plain Cmd+K opens the palette when the main
/// Minutes window already has focus (bead minutes-s5fb). The GLOBAL
/// binding stays Cmd+Shift+K; a global plain Cmd+K would shadow the
/// palette key of half the apps on the machine.
#[tauri::command]
pub fn cmd_toggle_palette(app: tauri::AppHandle) {
    toggle_palette_window(&app);
}

pub fn toggle_palette_window(app: &tauri::AppHandle) {
    let state = app.state::<AppState>();

    let transition: Option<PaletteTransition> = {
        let mut lifecycle = lock_or_recover(&state.palette_lifecycle);
        match *lifecycle {
            PaletteLifecycle::Closed => {
                *lifecycle = PaletteLifecycle::Opening;
                Some(PaletteTransition::Open)
            }
            PaletteLifecycle::Open => {
                *lifecycle = PaletteLifecycle::Closing;
                Some(PaletteTransition::Close)
            }
            PaletteLifecycle::Opening => None,
            PaletteLifecycle::Closing => {
                state.palette_reopen_pending.store(true, Ordering::Relaxed);
                None
            }
        }
    };

    match transition {
        Some(PaletteTransition::Open) => create_or_show_palette_window(app),
        Some(PaletteTransition::Close) => close_palette_window(app),
        None => {}
    }
}

#[derive(Debug)]
enum PaletteTransition {
    Open,
    Close,
}

/// Lock helper that recovers from a poisoned `PaletteLifecycle` mutex
/// instead of dropping the hotkey on the floor. Codex pass 3 P2:
/// `finalize_palette_open` and the close path were silently strand
/// the state machine in `Opening` if any prior call panicked while
/// holding the lock. Recovering the inner guard via `into_inner()`
/// keeps the palette responsive even after a transient poison.
fn lock_or_recover<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
    match mutex.lock() {
        Ok(guard) => guard,
        Err(poisoned) => {
            eprintln!("[palette] lifecycle mutex was poisoned; recovering");
            poisoned.into_inner()
        }
    }
}

/// Destroy the palette window synchronously and drain any queued
/// reopen request. Both `palette_close` (the webview's Esc key and
/// focus-lost paths) and the shortcut-toggle close path funnel
/// through here. Idempotent — safe to call when no palette window
/// exists.
pub fn close_palette_window(app: &tauri::AppHandle) {
    if let Some(win) = app.get_webview_window("palette") {
        // `destroy()` is the synchronous tear-down. `close()` only
        // enqueues a CloseRequested event which the runtime processes
        // later, leaving the old window briefly alive — that's the
        // race codex pass 3 caught. `destroy()` removes the window
        // immediately so the next `get_webview_window("palette")`
        // returns None.
        if let Err(e) = win.destroy() {
            eprintln!("[palette] failed to destroy palette window: {}", e);
        }
    }

    let reopen = {
        let state = app.state::<AppState>();
        let mut lifecycle = lock_or_recover(&state.palette_lifecycle);
        *lifecycle = PaletteLifecycle::Closed;
        state.palette_reopen_pending.swap(false, Ordering::Relaxed)
    };

    if reopen {
        let state = app.state::<AppState>();
        let should_reopen = {
            let mut lifecycle = lock_or_recover(&state.palette_lifecycle);
            if *lifecycle == PaletteLifecycle::Closed {
                *lifecycle = PaletteLifecycle::Opening;
                true
            } else {
                false
            }
        };
        if should_reopen {
            create_or_show_palette_window(app);
        }
    }
}

/// Public Tauri command wrapping [`close_palette_window`]. Called from
/// the palette frontend's Esc and focus-lost handlers so the state
/// machine stays consistent no matter which event triggered the close.
#[tauri::command]
pub fn palette_close(app: tauri::AppHandle) {
    close_palette_window(&app);
}

fn create_or_show_palette_window(app: &tauri::AppHandle) {
    // Wrap the entire create-or-show path in `catch_unwind` so a panic
    // inside `WebviewWindowBuilder::build()` (or any of the helper
    // calls below) cannot leave `palette_lifecycle` stuck in `Opening`
    // forever. This was codex pass 2 P2 #5: the only reset path used
    // to be the explicit `Err` arm after `.build()`, so an unwinding
    // panic would skip the reset and the user could never reopen the
    // palette without restarting the app.
    //
    // **Honest caveat** (codex pass 3 P2): `AssertUnwindSafe` here is
    // not a magic recovery story — `AppHandle` contains internal
    // Arcs/Mutexes managed by Tauri, and a panic inside `build()`
    // could leave Tauri's `WindowManager` in an inconsistent state.
    // The catch_unwind only ensures our `palette_lifecycle` flag
    // resets so the user can press the hotkey again. The "right" fix
    // is to never panic in there, which is a deeper Tauri-runtime
    // concern. We accept this trade-off because the alternative —
    // stranding the user with a wedged hotkey — is strictly worse.
    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
        create_or_show_palette_window_inner(app)
    }));
    if let Err(panic) = result {
        eprintln!("[palette] window creation panicked: {:?}", panic);
        let state = app.state::<AppState>();
        let mut lifecycle = lock_or_recover(&state.palette_lifecycle);
        *lifecycle = PaletteLifecycle::Closed;
    }
}

fn create_or_show_palette_window_inner(app: &tauri::AppHandle) {
    use tauri::WebviewUrl;

    // Singleton: a stale window from a previous toggle should be reused,
    // not duplicated. `get_webview_window` is cheap.
    if let Some(win) = app.get_webview_window("palette") {
        // The lifecycle says we are opening, but a window already exists.
        // Show + focus it instead of spawning a duplicate.
        if let Err(e) = win.show() {
            eprintln!("[palette] show failed: {}", e);
        }
        if let Err(e) = win.set_focus() {
            eprintln!("[palette] focus failed: {}", e);
        }
        finalize_palette_open(app);
        return;
    }

    // Position: center of the primary monitor. Tauri's `center()` builder
    // option handles multi-monitor setups correctly.
    let width = 640.0_f64;
    let height = 420.0_f64;

    let build_result = tauri::WebviewWindowBuilder::new(
        app,
        "palette",
        WebviewUrl::App("palette/index.html".into()),
    )
    .title("Minutes Palette")
    .inner_size(width, height)
    .resizable(false)
    .decorations(false)
    .transparent(true)
    .shadow(true)
    .always_on_top(true)
    .center()
    .focused(true)
    .skip_taskbar(true)
    .content_protected(true)
    .build();

    match build_result {
        Ok(_) => finalize_palette_open(app),
        Err(e) => {
            eprintln!("[palette] failed to build palette window: {}", e);
            let state = app.state::<AppState>();
            let mut lifecycle = lock_or_recover(&state.palette_lifecycle);
            *lifecycle = PaletteLifecycle::Closed;
        }
    }
}

fn finalize_palette_open(app: &tauri::AppHandle) {
    let state = app.state::<AppState>();
    let mut lifecycle = lock_or_recover(&state.palette_lifecycle);
    *lifecycle = PaletteLifecycle::Open;

    // Capability smoke test was a D4 dev affordance — kept on debug
    // builds only so prod users don't see the green indicator and so
    // we don't ship dev cruft. Codex pass 3 P3 + claude P3 #18 + #20
    // both flagged this as ship-noise.
    #[cfg(debug_assertions)]
    {
        let app_clone = app.clone();
        std::thread::spawn(move || {
            std::thread::sleep(std::time::Duration::from_millis(120));
            if let Err(e) =
                app_clone.emit_to("palette", "palette:ping", serde_json::json!({ "ok": true }))
            {
                eprintln!("[palette] palette:ping emit failed: {}", e);
            }
        });
    }
}

/// Read the assistant workspace's `CURRENT_MEETING.md` breadcrumb and
/// return the absolute path of the meeting the user is currently
/// discussing. Returns `None` if the file is missing, unreadable, or
/// does not reference a resolvable meeting path.
///
/// The palette webview calls this right before `palette_list` and
/// `palette_execute` so `PaletteUiContext.current_meeting` can be
/// populated for meeting-scoped commands (copy markdown, rename, etc.).
///
/// **Side-effect-free**: this command intentionally does NOT call
/// `crate::context::create_workspace` because that function does
/// `create_dir_all`, creates a `meetings` symlink, and runs `git init`.
/// Just opening the palette must not mutate `~/.minutes/assistant`.
/// Instead we use `workspace_dir()` (a pure path computation) and only
/// read the marker file if the workspace already exists. See codex
/// pass 2 P2 #3.
#[tauri::command]
pub fn palette_current_meeting() -> Option<PathBuf> {
    // Prefer the desktop workspace state first. That is the meeting the
    // user is actively looking at in the detail view, even if Recall was
    // previously focused on a different meeting.
    if let Some(candidate) = recall_workspace_current_meeting() {
        return Some(candidate);
    }

    let workspace_root = crate::context::workspace_dir();
    let marker = workspace_root.join(crate::context::ACTIVE_MEETING_FILE);

    // CURRENT_MEETING.md stores a link or raw path to the current meeting
    // markdown. Accepted forms (pick the first matching line):
    //   1. Markdown link: `[title](/abs/path.md)`
    //   2. Bare path line: `/abs/path.md`
    //   3. `path: /abs/path.md` frontmatter-ish line
    // Anything else → `None`.
    if workspace_root.exists() {
        if let Ok(contents) = std::fs::read_to_string(&marker) {
            for line in contents.lines() {
                let trimmed = line.trim();
                if trimmed.is_empty() || trimmed.starts_with('#') {
                    continue;
                }
                if let Some(path) = extract_current_meeting_path(trimmed) {
                    let candidate = PathBuf::from(path);
                    if candidate.exists() && candidate.is_file() {
                        return Some(candidate);
                    }
                }
            }
        }
    }

    None
}

fn recall_workspace_current_meeting() -> Option<PathBuf> {
    let state = load_recall_workspace_state_from(&recall_workspace_state_path());
    let candidate = state.current_meeting_path.as_ref().map(PathBuf::from)?;
    let config = Config::load();
    if candidate.exists()
        && candidate.is_file()
        && minutes_core::notes::validate_meeting_path(&candidate, &config.output_dir).is_ok()
    {
        return Some(candidate);
    }

    None
}

/// Parse a single line of `CURRENT_MEETING.md` looking for a path. Kept
/// private and tested directly so the accepted forms are documented.
fn extract_current_meeting_path(line: &str) -> Option<&str> {
    // Markdown link form: `[label](path)`
    if let Some(start) = line.find("](") {
        let rest = &line[start + 2..];
        if let Some(end) = rest.find(')') {
            let path = &rest[..end];
            if path.ends_with(".md") {
                return Some(path);
            }
        }
    }
    // `path: /abs/path.md` form
    if let Some(rest) = line.strip_prefix("path:") {
        let trimmed = rest.trim().trim_matches('"').trim_matches('\'');
        if trimmed.ends_with(".md") {
            return Some(trimmed);
        }
    }
    // Bare path form
    if line.ends_with(".md") && line.starts_with('/') {
        return Some(line);
    }
    None
}

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

    #[test]
    fn extracts_markdown_link_path() {
        assert_eq!(
            extract_current_meeting_path("[Team Sync](/Users/x/meetings/2026-04-07-team-sync.md)"),
            Some("/Users/x/meetings/2026-04-07-team-sync.md")
        );
    }

    #[test]
    fn extracts_path_prefix_form() {
        assert_eq!(
            extract_current_meeting_path("path: /Users/x/meetings/call.md"),
            Some("/Users/x/meetings/call.md")
        );
        assert_eq!(
            extract_current_meeting_path(r#"path: "/Users/x/meetings/call.md""#),
            Some("/Users/x/meetings/call.md")
        );
    }

    #[test]
    fn extracts_bare_absolute_path() {
        assert_eq!(
            extract_current_meeting_path("/Users/x/meetings/call.md"),
            Some("/Users/x/meetings/call.md")
        );
    }

    #[test]
    fn rejects_non_md_and_relative_paths() {
        assert_eq!(extract_current_meeting_path("relative/path.md"), None);
        assert_eq!(extract_current_meeting_path("/abs/path.txt"), None);
        assert_eq!(extract_current_meeting_path("just a sentence"), None);
    }
}

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

    #[test]
    fn release_notes_parser_reads_github_body() {
        let body = github_release_body_from_json(
            r##"{"tag_name":"v0.16.0","body":"# Minutes 0.16.0\n\nBig release."}"##,
        );

        assert_eq!(body.as_deref(), Some("# Minutes 0.16.0\n\nBig release."));
    }

    #[test]
    fn release_notes_parser_rejects_empty_github_body() {
        assert_eq!(github_release_body_from_json(r#"{"body":"   "}"#), None);
        assert_eq!(github_release_body_from_json(r#"{"name":"v0.16.0"}"#), None);
    }

    #[test]
    fn updater_manifest_notes_require_matching_version() {
        let manifest = r##"{
          "version": "0.16.0",
          "notes": "# Minutes 0.16.0\n\nRelease notes from latest.json",
          "platforms": {
            "darwin-aarch64": {
              "signature": "sig",
              "url": "https://example.com/Minutes.app.tar.gz"
            }
          }
        }"##;

        assert_eq!(
            updater_notes_from_manifest_json(manifest, "0.16.0").as_deref(),
            Some("# Minutes 0.16.0\n\nRelease notes from latest.json")
        );
        assert_eq!(updater_notes_from_manifest_json(manifest, "0.15.0"), None);
    }

    #[test]
    fn update_ui_state_tracks_phase_transitions() {
        let available = UpdateUiState::available("0.12.0", Some(48 * 1024 * 1024));
        assert_eq!(available.phase, UpdatePhase::Available);
        assert_eq!(available.version.as_deref(), Some("0.12.0"));
        assert!(!available.can_cancel);

        let checking = available.checking();
        assert_eq!(checking.phase, UpdatePhase::Checking);
        assert!(checking.can_cancel);

        let downloading = checking.with_progress(
            12 * 1024 * 1024,
            Some(48 * 1024 * 1024),
            Some(1.5 * 1024.0 * 1024.0),
            Some(24),
        );
        assert_eq!(downloading.phase, UpdatePhase::Downloading);
        assert_eq!(downloading.downloaded_bytes, 12 * 1024 * 1024);
        assert!(downloading.can_cancel);

        let verifying = downloading.verifying(48 * 1024 * 1024, Some(48 * 1024 * 1024));
        assert_eq!(verifying.phase, UpdatePhase::Verifying);
        assert!(!verifying.can_cancel);

        let installing = verifying.installing(48 * 1024 * 1024, Some(48 * 1024 * 1024));
        assert_eq!(installing.phase, UpdatePhase::Installing);
        assert!(!installing.can_cancel);

        let ready = installing.ready(48 * 1024 * 1024, Some(48 * 1024 * 1024));
        assert_eq!(ready.phase, UpdatePhase::Ready);
        assert!(!ready.can_cancel);
    }

    #[test]
    fn update_ui_state_failure_preserves_context() {
        let base = UpdateUiState::available("0.12.0", Some(1024)).with_progress(
            256,
            Some(1024),
            Some(128.0),
            Some(6),
        );
        let failed = base.failed("network stalled", true);

        assert_eq!(failed.phase, UpdatePhase::Error);
        assert_eq!(failed.version.as_deref(), Some("0.12.0"));
        assert_eq!(failed.total_bytes, Some(1024));
        assert_eq!(failed.downloaded_bytes, 256);
        assert_eq!(failed.error_message.as_deref(), Some("network stalled"));
        assert!(failed.recoverable);
        assert!(!failed.can_cancel);
    }
}
