//! The egui application: config panel, record/stop controls, case list.
//!
//! State transitions are driven by:
//! - user clicks (new/continue/stop/open/save-config/dismiss-error)
//! - recorder events (ffmpeg finalizing → RecorderEvent::Finished)
//! - upload events (terminal failure → AppState::Error)
//!
//! Connectivity and upload-progress indicators live in the footer; they
//! never block the main state machine. See
//! `berpr-fe-den-plan-lively-journal.md` for the design rationale.

use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};

use crate::case_store::{CaseMarker, CaseStore};
use crate::case_update::{PutOnelinerError, put_oneliner};
use crate::footer_status::{FooterStatus, pick_footer_status};
use crate::server_sync::{ServerSync, SyncConfig, WorkerSnapshot, WorkerState};
use crate::snapshot_cache::SnapshotCache;
use crate::startup::{DEFAULT_MARKER_RETENTION, DEFAULT_ORPHAN_AUDIO_RETENTION, run_startup_cleanup};
use crate::upload::{PendingUpload, UploadEvent, write_sidecar};
use doctate_common::join_url;
use doctate_common::oneliners::OnelinerState;
use doctate_common::timestamp::{extract_hhmm, now_rfc3339, recorded_at_to_filename_stem};
use eframe::egui;
use tokio::runtime::Runtime;
use tokio::sync::{mpsc, watch};
use tracing::{error, info, warn};
use uuid::Uuid;

use crate::config::Config;
use crate::recorder::{Recorder, RecorderEvent};
use crate::state::AppState;

pub struct DoctateApp {
    runtime: Arc<Runtime>,
    config: Option<Config>,
    pending_dir: PathBuf,
    state: AppState,

    server_url_input: String,
    api_key_input: String,

    http_client: Arc<reqwest::Client>,
    case_store: Arc<CaseStore>,
    snapshot_rx: watch::Receiver<Arc<Vec<CaseMarker>>>,
    snapshot_cache: Arc<SnapshotCache>,

    recorder: Option<Recorder>,
    recorder_events: Option<mpsc::UnboundedReceiver<RecorderEvent>>,

    server_sync: Option<ServerSync>,
    worker_rx: Option<watch::Receiver<WorkerSnapshot>>,
    upload_events: Option<mpsc::UnboundedReceiver<UploadEvent>>,

    /// Context for an in-flight ffmpeg flush: we left `AppState::Recording`
    /// for `AppState::Idle` on Stop (so the UI stays responsive), but we
    /// still need `case_id` + `recorded_at` when the recorder emits
    /// `Finished`. `None` when no flush is in progress.
    finalizing: Option<RecordingContext>,

    /// Active inline tap-to-edit on a oneliner row. `None` means
    /// every row renders in read mode.
    oneliner_edit: Option<OnelinerEditState>,
    /// Channel for results coming back from background `put_oneliner`
    /// spawns. The receiver is drained at the start of every frame.
    save_result_tx: mpsc::UnboundedSender<SaveResult>,
    save_result_rx: mpsc::UnboundedReceiver<SaveResult>,
    /// Toast banner shown until the user dismisses it; populated when
    /// a `put_oneliner` returns an error.
    last_save_error: Option<String>,
}

/// Enough recording identity to finalize a pending flush or emit an
/// upload event once the recorder signals `Finished`.
#[derive(Debug, Clone)]
struct RecordingContext {
    case_id: Uuid,
    recorded_at: String,
}

/// Per-case oneliner edit buffer. Only one case is in edit mode at a
/// time; clicking another case (or pressing Esc) clears this. The
/// `focus_pending` flag drives a one-shot `request_focus()` on the
/// first render after the edit starts, so the user lands in the
/// TextEdit without a second click.
struct OnelinerEditState {
    case_id: Uuid,
    draft: String,
    focus_pending: bool,
}

/// Result of a background `put_oneliner` request, posted back to the
/// UI thread via mpsc so the next `update()` frame can surface
/// success/failure to the user.
struct SaveResult {
    case_id: Uuid,
    outcome: Result<doctate_common::oneliners::OnelinerState, PutOnelinerError>,
}

enum UiAction {
    SaveConfig,
    StartNew,
    Continue(Uuid),
    OpenWeb(Uuid),
    Stop,
    DismissError,
    StartOnelinerEdit { case_id: Uuid, current_text: String },
    CancelOnelinerEdit,
    SaveOneliner { case_id: Uuid, text: String },
    DismissSaveError,
}

impl DoctateApp {
    pub fn new(
        runtime: Arc<Runtime>,
        pending_dir: PathBuf,
        cases_dir: PathBuf,
        snapshot_cache_path: PathBuf,
    ) -> Self {
        let config = match crate::config_path::load() {
            Ok(Some(c)) => Some(c),
            Ok(None) => None,
            Err(e) => {
                warn!(error = %e, "config load failed");
                None
            }
        };

        let server_url_input = config
            .as_ref()
            .map(|c| c.server_url.clone())
            .unwrap_or_default();
        let api_key_input = config
            .as_ref()
            .map(|c| c.api_key.clone())
            .unwrap_or_default();

        let (store, snapshot_rx) = runtime
            .block_on(async { CaseStore::open(cases_dir).await })
            .expect("open case store");
        let case_store = Arc::new(store);
        let snapshot_cache = Arc::new(SnapshotCache::new(snapshot_cache_path));
        let http_client = Arc::new(reqwest::Client::new());

        // Startup cleanup — one-shot, before the worker spawns.
        {
            let store = case_store.clone();
            let pending = pending_dir.clone();
            runtime.block_on(async move {
                run_startup_cleanup(
                    &store,
                    &pending,
                    DEFAULT_MARKER_RETENTION,
                    DEFAULT_ORPHAN_AUDIO_RETENTION,
                )
                .await;
            });
        }

        let (state, server_sync, worker_rx, upload_events) = if let Some(cfg) = &config {
            let (sync, events, rx) = spawn_worker(
                &runtime,
                cfg.clone(),
                pending_dir.clone(),
                http_client.clone(),
                case_store.clone(),
                snapshot_cache.clone(),
            );
            (AppState::Idle, Some(sync), Some(rx), Some(events))
        } else {
            (AppState::NotConfigured, None, None, None)
        };

        let (save_result_tx, save_result_rx) = mpsc::unbounded_channel();

        Self {
            runtime,
            config,
            pending_dir,
            state,
            server_url_input,
            api_key_input,
            http_client,
            case_store,
            snapshot_rx,
            snapshot_cache,
            recorder: None,
            recorder_events: None,
            server_sync,
            worker_rx,
            upload_events,
            finalizing: None,
            oneliner_edit: None,
            save_result_tx,
            save_result_rx,
            last_save_error: None,
        }
    }

    fn on_save_config(&mut self) {
        let cfg = Config {
            server_url: self.server_url_input.trim().to_string(),
            api_key: self.api_key_input.trim().to_string(),
            oneliner_poll_interval_seconds: self
                .config
                .as_ref()
                .and_then(|c| c.oneliner_poll_interval_seconds),
        };
        if let Err(e) = crate::config_path::save(&cfg) {
            error!(error = %e, "config save failed");
            self.state = AppState::Error {
                message: format!("Speichern: {e}"),
            };
            return;
        }
        info!("config saved");

        let store = self.case_store.clone();
        let cache = self.snapshot_cache.clone();
        self.runtime.block_on(async move {
            if let Err(e) = store.clear_all().await {
                warn!(error = %e, "case store clear failed on config change");
            }
            if let Err(e) = cache.clear().await {
                warn!(error = %e, "snapshot cache clear failed on config change");
            }
        });

        // Drop old worker explicitly so its abort fires before new spawn.
        self.server_sync = None;
        self.worker_rx = None;
        self.upload_events = None;

        let (sync, events, rx) = spawn_worker(
            &self.runtime,
            cfg.clone(),
            self.pending_dir.clone(),
            self.http_client.clone(),
            self.case_store.clone(),
            self.snapshot_cache.clone(),
        );
        self.server_sync = Some(sync);
        self.worker_rx = Some(rx);
        self.upload_events = Some(events);
        self.config = Some(cfg);
        self.state = AppState::Idle;
        self.finalizing = None;
    }

    fn on_start_new(&mut self) {
        self.start_recording_for(Uuid::new_v4(), /* is_continue */ false);
    }

    fn on_continue(&mut self, case_id: Uuid) {
        self.start_recording_for(case_id, /* is_continue */ true);
    }

    fn start_recording_for(&mut self, case_id: Uuid, is_continue: bool) {
        // Two-part guard: user-visible state AND no lingering recorder
        // from a previous stop that is still flushing.
        if !matches!(self.state, AppState::Idle) || self.recorder.is_some() {
            return;
        }
        if let Err(e) = std::fs::create_dir_all(&self.pending_dir) {
            self.state = AppState::Error {
                message: format!("Pending-Verzeichnis: {e}"),
            };
            return;
        }

        let recorded_at = now_rfc3339();
        let stem = format!("{}_{}", case_id, recorded_at_to_filename_stem(&recorded_at));
        let output_path = self.pending_dir.join(format!("{stem}.m4a"));

        // Persist the case marker before recording so the UI list
        // reflects the new case immediately.
        let store = self.case_store.clone();
        let now = time::OffsetDateTime::now_utc();
        self.runtime.block_on(async move {
            let res = if is_continue {
                store.mark_activity(case_id, now).await.map(|_| ())
            } else {
                store.create_local(case_id, now).await.map(|_| ())
            };
            if let Err(e) = res {
                warn!(error = %e, "case store write failed");
            }
        });

        let _guard = self.runtime.enter();
        match Recorder::start(output_path) {
            Ok((recorder, events)) => {
                self.recorder = Some(recorder);
                self.recorder_events = Some(events);
                self.state = AppState::Recording {
                    case_id,
                    recorded_at,
                    started_at: Instant::now(),
                };
            }
            Err(e) => {
                self.state = AppState::Error {
                    message: format!("Aufnahme starten: {e}"),
                };
            }
        }
    }

    fn on_stop(&mut self) {
        let AppState::Recording {
            case_id,
            recorded_at,
            ..
        } = &self.state
        else {
            return;
        };
        let case_id = *case_id;
        let recorded_at = recorded_at.clone();
        if let Some(rec) = self.recorder.take() {
            rec.stop();
        }
        // UI is ready for the next recording immediately — the finalizing
        // ffmpeg flush shows up as a footer spinner, not a blocking state.
        self.finalizing = Some(RecordingContext {
            case_id,
            recorded_at,
        });
        self.state = AppState::Idle;
    }

    fn on_open_web(&self, case_id: Uuid) {
        let Some(cfg) = &self.config else {
            return;
        };
        let server_url = cfg.server_url.clone();
        let api_key = cfg.api_key.clone();
        let http = self.http_client.clone();
        let return_to = format!("/web/cases/{case_id}");
        let fallback_url = join_url(&server_url, &return_to);

        // Fire-and-forget on the runtime. Blocking the egui UI thread on
        // an HTTP round-trip would freeze the window for ~100–500ms.
        self.runtime.spawn(async move {
            let url =
                match crate::magic_link::build_magic_url(&http, &server_url, &api_key, &return_to)
                    .await
                {
                    Ok(u) => u,
                    Err(e) => {
                        warn!(error = %e, "magic-link request failed; falling back to plain URL");
                        fallback_url
                    }
                };
            if let Err(e) = webbrowser::open(&url) {
                warn!(url = %url, error = %e, "open browser failed");
            }
        });
    }

    fn drain_recorder_events(&mut self) {
        let Some(rx) = self.recorder_events.as_mut() else {
            return;
        };
        let mut events = Vec::new();
        while let Ok(event) = rx.try_recv() {
            events.push(event);
        }
        for event in events {
            match event {
                RecorderEvent::Started => {}
                RecorderEvent::Finished { output } => {
                    self.handle_recording_finished(output);
                }
                RecorderEvent::Failed { error: err } => {
                    error!(error = %err, "recorder failed");
                    self.finalizing = None;
                    self.state = AppState::Error {
                        message: format!("Aufnahme: {err}"),
                    };
                    self.recorder_events = None;
                    self.recorder = None;
                }
            }
        }
    }

    fn handle_recording_finished(&mut self, output: PathBuf) {
        // Prefer the explicit finalizing context (Stop-flow). Fall back
        // to AppState::Recording (recorder terminated before Stop — rare
        // but possible).
        let ctx = if let Some(ctx) = self.finalizing.take() {
            ctx
        } else if let AppState::Recording {
            case_id,
            recorded_at,
            ..
        } = &self.state
        {
            let ctx = RecordingContext {
                case_id: *case_id,
                recorded_at: recorded_at.clone(),
            };
            self.state = AppState::Idle;
            ctx
        } else {
            warn!(state = ?self.state, "recorder finished in unexpected state");
            self.recorder_events = None;
            self.recorder = None;
            return;
        };

        let RecordingContext {
            case_id,
            recorded_at,
        } = ctx;

        let store = self.case_store.clone();
        let now = time::OffsetDateTime::now_utc();
        self.runtime.block_on(async move {
            if let Err(e) = store.mark_activity(case_id, now).await {
                warn!(error = %e, "mark_activity on recording finished failed");
            }
        });

        let upload = PendingUpload {
            case_id,
            recorded_at,
            file: output,
        };

        if let Err(e) = write_sidecar(&upload) {
            self.state = AppState::Error {
                message: format!("Sidecar schreiben: {e}"),
            };
            self.recorder_events = None;
            self.recorder = None;
            return;
        }

        // Kick the worker so it picks up the fresh file without waiting
        // for the next poll-tick.
        if let Some(sync) = &self.server_sync {
            sync.notify();
        } else {
            warn!("no server_sync active — upload stays on disk until next launch");
        }

        self.recorder_events = None;
        self.recorder = None;
    }

    fn drain_upload_events(&mut self) {
        let Some(rx) = self.upload_events.as_mut() else {
            return;
        };
        let mut events = Vec::new();
        while let Ok(event) = rx.try_recv() {
            events.push(event);
        }
        for event in events {
            match event {
                UploadEvent::Started(case_id) => {
                    info!(case_id = %case_id, "upload attempt started");
                }
                UploadEvent::Succeeded { case_id, status } => {
                    info!(case_id = %case_id, status = ?status, "upload succeeded");
                }
                UploadEvent::Failed {
                    case_id,
                    reason,
                    will_retry,
                } => {
                    warn!(case_id = %case_id, reason = %reason, will_retry, "upload failed");
                    if !will_retry {
                        // Terminal = operator action needed. Block UI.
                        self.state = AppState::Error { message: reason };
                    }
                }
            }
        }
    }

    fn render_controls(&mut self, ui: &mut egui::Ui) -> Option<UiAction> {
        match &self.state {
            AppState::NotConfigured => {
                ui.label("Server URL:");
                ui.add(egui::TextEdit::singleline(&mut self.server_url_input).desired_width(280.0));
                ui.add_space(4.0);
                ui.label("API Key:");
                ui.add(
                    egui::TextEdit::singleline(&mut self.api_key_input)
                        .desired_width(280.0)
                        .password(true),
                );
                ui.add_space(8.0);
                if ui.button("Speichern").clicked() {
                    return Some(UiAction::SaveConfig);
                }
                None
            }
            AppState::Idle => {
                if ui.button("● Neu").clicked() {
                    return Some(UiAction::StartNew);
                }
                None
            }
            AppState::Recording { started_at, .. } => {
                let elapsed = started_at.elapsed();
                ui.label(format!(
                    "● REC  {:02}:{:02}",
                    elapsed.as_secs() / 60,
                    elapsed.as_secs() % 60
                ));
                ui.add_space(4.0);
                if ui.button("■ Stop").clicked() {
                    return Some(UiAction::Stop);
                }
                None
            }
            AppState::Error { message } => {
                ui.colored_label(egui::Color32::RED, message);
                ui.add_space(8.0);
                if ui.button("OK").clicked() {
                    return Some(UiAction::DismissError);
                }
                None
            }
        }
    }

    fn render_case_list(&mut self, ui: &mut egui::Ui) -> Option<UiAction> {
        let snapshot = self.snapshot_rx.borrow().clone();
        // Snapshot is already server-filtered — the server's `window_hours`
        // per user (users.toml) is the single source of truth.
        let visible: Vec<&CaseMarker> = snapshot.iter().collect();

        if visible.is_empty() {
            ui.add_space(8.0);
            ui.colored_label(egui::Color32::GRAY, "Noch keine Fälle heute.");
            return None;
        }

        // Pending-upload lookup for the badge. Cheap clone (Arc) from the
        // worker's last published snapshot; empty set if no worker yet.
        let pending_ids = self
            .worker_rx
            .as_ref()
            .map(|rx| rx.borrow().pending_case_ids.clone())
            .unwrap_or_default();

        let mut action = None;
        // Ready-for-new means the user-intent state is Idle AND no ffmpeg
        // instance is still flushing. Upload-queue depth does NOT gate.
        let idle = matches!(self.state, AppState::Idle) && self.recorder.is_none();

        egui::ScrollArea::vertical().show(ui, |ui| {
            for marker in visible {
                ui.separator();
                ui.add_space(4.0);

                let time_str = extract_hhmm(&marker.last_activity_at);
                let has_pending = pending_ids.contains(&marker.case_id);
                let in_edit_mode = self
                    .oneliner_edit
                    .as_ref()
                    .is_some_and(|e| e.case_id == marker.case_id);

                if in_edit_mode {
                    // Inline edit: TextEdit + ✓/✗. Enter saves, Esc cancels.
                    let edit = self.oneliner_edit.as_mut().unwrap();
                    ui.horizontal(|ui| {
                        ui.label(format!("{time_str} ·"));
                        let response = ui
                            .add(egui::TextEdit::singleline(&mut edit.draft).desired_width(280.0));
                        if edit.focus_pending {
                            response.request_focus();
                            edit.focus_pending = false;
                        }
                        let enter_pressed =
                            response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
                        let save_clicked = ui.button("✓").clicked();
                        let cancel_clicked = ui.button("✗").clicked();
                        let esc_pressed = ui.input(|i| i.key_pressed(egui::Key::Escape));

                        if save_clicked || enter_pressed {
                            action = Some(UiAction::SaveOneliner {
                                case_id: marker.case_id,
                                text: edit.draft.clone(),
                            });
                        } else if cancel_clicked || esc_pressed {
                            action = Some(UiAction::CancelOnelinerEdit);
                        }
                    });
                } else {
                    // Read mode: clickable label, plus a green pencil
                    // for Manual variant so the doctor sees their
                    // override is the source of truth here.
                    let oneliner_text = match &marker.oneliner {
                        Some(
                            OnelinerState::Ready { text, .. } | OnelinerState::Manual { text, .. },
                        ) => text.clone(),
                        Some(OnelinerState::Empty { .. }) => "∅".to_owned(),
                        Some(OnelinerState::Error { .. }) => "⚠".to_owned(),
                        None => "⏳".to_owned(),
                    };
                    let line = if has_pending {
                        format!("⬆ {time_str} · {oneliner_text}")
                    } else {
                        format!("{time_str} · {oneliner_text}")
                    };
                    ui.horizontal(|ui| {
                        let label = if has_pending {
                            egui::Label::new(
                                egui::RichText::new(line)
                                    .color(egui::Color32::from_rgb(200, 140, 0)),
                            )
                            .sense(egui::Sense::click())
                        } else {
                            egui::Label::new(line).sense(egui::Sense::click())
                        };
                        let resp = ui.add(label);
                        if matches!(marker.oneliner, Some(OnelinerState::Manual { .. })) {
                            // Person-glyph marks doctor-authored titles.
                            // Replaces an earlier pencil icon: the pencil
                            // suggested an edit affordance, but on this row
                            // the marker is informational only — clicking
                            // the title text already opens the editor.
                            ui.colored_label(egui::Color32::GRAY, "👤")
                                .on_hover_text("manuell bearbeitet");
                        }
                        if resp.clicked() {
                            // Pre-fill the editor with the visible text
                            // (Ready/Manual). For Empty/Error/None start
                            // with an empty buffer so the doctor types
                            // from scratch.
                            let current = match &marker.oneliner {
                                Some(
                                    OnelinerState::Ready { text, .. }
                                    | OnelinerState::Manual { text, .. },
                                ) => text.clone(),
                                _ => String::new(),
                            };
                            action = Some(UiAction::StartOnelinerEdit {
                                case_id: marker.case_id,
                                current_text: current,
                            });
                        }
                    });
                }

                ui.horizontal(|ui| {
                    if ui
                        .add_enabled(idle, egui::Button::new("▶ Fortsetzen"))
                        .clicked()
                    {
                        action = Some(UiAction::Continue(marker.case_id));
                    }
                    if ui.button("🌐 Öffnen").clicked() {
                        action = Some(UiAction::OpenWeb(marker.case_id));
                    }
                });
                ui.add_space(4.0);
            }
        });
        action
    }

    fn render_footer_status(&self, ui: &mut egui::Ui) {
        let Some(worker_rx) = &self.worker_rx else {
            ui.colored_label(egui::Color32::GRAY, "—");
            return;
        };
        let snap = worker_rx.borrow().clone();
        let last_success_age = snap.last_success.map(|i| i.elapsed());
        let last_failure_age = snap.last_failure.map(|i| i.elapsed());
        let threshold = self
            .config
            .as_ref()
            .map(|c| std::cmp::max(3 * c.poll_interval(), Duration::from_secs(30)))
            .unwrap_or(Duration::from_secs(30));

        let status = pick_footer_status(
            self.finalizing.is_some(),
            snap.state,
            snap.queue_len,
            last_success_age,
            last_failure_age,
            threshold,
        );

        match status {
            FooterStatus::Finalizing => {
                ui.horizontal(|ui| {
                    ui.spinner();
                    ui.label("Speichere Aufnahme…");
                });
            }
            FooterStatus::Uploading { count } => {
                ui.horizontal(|ui| {
                    ui.spinner();
                    if count <= 1 {
                        ui.label("Uploading…");
                    } else {
                        ui.label(format!("Uploading ({count} offen)…"));
                    }
                });
            }
            FooterStatus::Synchronizing => {
                ui.horizontal(|ui| {
                    ui.spinner();
                    ui.label("Synchronisiere…");
                });
            }
            FooterStatus::NeverConnected => {
                ui.colored_label(egui::Color32::GRAY, "⚪ noch keine Verbindung");
            }
            FooterStatus::Offline => {
                ui.colored_label(egui::Color32::DARK_RED, "🔴 Offline");
            }
            FooterStatus::Online => {
                ui.colored_label(egui::Color32::DARK_GREEN, "🟢 Online");
            }
        }
    }

    fn handle_action(&mut self, action: UiAction) {
        match action {
            UiAction::SaveConfig => self.on_save_config(),
            UiAction::StartNew => self.on_start_new(),
            UiAction::Continue(id) => self.on_continue(id),
            UiAction::OpenWeb(id) => self.on_open_web(id),
            UiAction::Stop => self.on_stop(),
            UiAction::DismissError => self.state = AppState::Idle,
            UiAction::StartOnelinerEdit {
                case_id,
                current_text,
            } => {
                self.oneliner_edit = Some(OnelinerEditState {
                    case_id,
                    draft: current_text,
                    focus_pending: true,
                });
            }
            UiAction::CancelOnelinerEdit => {
                self.oneliner_edit = None;
            }
            UiAction::SaveOneliner { case_id, text } => {
                self.on_save_oneliner(case_id, text);
            }
            UiAction::DismissSaveError => {
                self.last_save_error = None;
            }
        }
    }

    /// Optimistic write + background PUT. Local state is updated
    /// immediately so the new text appears on the next frame; the
    /// server round-trip runs in a spawned task and reports back via
    /// `save_result_tx`.
    fn on_save_oneliner(&mut self, case_id: Uuid, text: String) {
        let trimmed = text.trim().to_owned();
        if trimmed.is_empty() {
            // Empty payload would be rejected by the server (400) and
            // is also a no-op on the local cache. Treat as cancel.
            self.oneliner_edit = None;
            return;
        }

        let local_state = OnelinerState::Manual {
            text: trimmed.clone(),
            set_at: now_rfc3339(),
        };
        let store = self.case_store.clone();
        let local_clone = local_state.clone();
        // Block briefly on the local write — single tokio::fs::write +
        // map insert, microseconds in practice. Keeps the UI strictly
        // monotonic: by the next render frame the new text is visible.
        self.runtime.block_on(async move {
            if let Err(e) = store.set_oneliner_local(case_id, local_clone).await {
                warn!(case_id = %case_id, error = %e, "local set_oneliner failed");
            }
        });
        self.oneliner_edit = None;

        // Background PUT. On failure the next drain_save_results
        // surfaces the error; on success no further work is needed
        // because the local state is already correct.
        let Some(cfg) = self.config.clone() else {
            warn!("save_oneliner without config — should not happen");
            return;
        };
        let http = self.http_client.clone();
        let tx = self.save_result_tx.clone();
        self.runtime.spawn(async move {
            let outcome =
                put_oneliner(&http, &cfg.server_url, &cfg.api_key, case_id, &trimmed).await;
            let _ = tx.send(SaveResult { case_id, outcome });
        });
    }

    fn drain_save_results(&mut self) {
        while let Ok(result) = self.save_result_rx.try_recv() {
            match result.outcome {
                Ok(_) => {
                    info!(case_id = %result.case_id, "oneliner save succeeded");
                }
                Err(e) => {
                    error!(case_id = %result.case_id, error = ?e, "oneliner save failed");
                    self.last_save_error = Some(format!(
                        "Oneliner für Fall {} konnte nicht gespeichert werden: {}",
                        short_case_id(&result.case_id),
                        user_error_message(&e),
                    ));
                }
            }
        }
    }

    fn worker_is_active(&self) -> bool {
        let Some(rx) = &self.worker_rx else {
            return false;
        };
        let snap = rx.borrow();
        snap.state != WorkerState::Idle || snap.queue_len > 0
    }
}

fn spawn_worker(
    runtime: &Runtime,
    config: Config,
    pending_dir: PathBuf,
    http: Arc<reqwest::Client>,
    case_store: Arc<CaseStore>,
    snapshot_cache: Arc<SnapshotCache>,
) -> (
    ServerSync,
    mpsc::UnboundedReceiver<UploadEvent>,
    watch::Receiver<WorkerSnapshot>,
) {
    let _guard = runtime.enter();
    let poll_interval = config.poll_interval();
    let sync_config = SyncConfig {
        server_url: config.server_url,
        api_key: config.api_key,
        poll_interval,
    };
    let (sync, events_rx) =
        ServerSync::spawn(http, sync_config, case_store, snapshot_cache, pending_dir);
    let snapshot_rx = sync.snapshot();
    (sync, events_rx, snapshot_rx)
}

/// Short prefix of a `Uuid` for human-readable error messages.
fn short_case_id(id: &Uuid) -> String {
    id.to_string().chars().take(8).collect()
}

/// Map a `PutOnelinerError` to a German user-facing one-liner.
fn user_error_message(e: &PutOnelinerError) -> String {
    match e {
        PutOnelinerError::Terminal { status, .. } => {
            format!("Server-Fehler {status} — bitte Konfiguration prüfen.")
        }
        PutOnelinerError::Transient(_) => "Netzwerk-Fehler — bitte erneut versuchen.".to_string(),
        PutOnelinerError::Parse(_) => "Antwort vom Server unverständlich.".to_string(),
    }
}

impl eframe::App for DoctateApp {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        self.drain_recorder_events();
        self.drain_upload_events();
        self.drain_save_results();

        let mut pending_action: Option<UiAction> = None;

        // Toast banner for the last save failure. Sits above the
        // header so the user can still navigate the rest of the app.
        if let Some(msg) = self.last_save_error.clone() {
            egui::TopBottomPanel::top("save-error-banner").show(ctx, |ui| {
                ui.add_space(4.0);
                ui.horizontal(|ui| {
                    ui.colored_label(egui::Color32::DARK_RED, "⚠");
                    ui.label(msg);
                    if ui.button("✕").clicked() {
                        pending_action = Some(UiAction::DismissSaveError);
                    }
                });
                ui.add_space(4.0);
            });
        }

        egui::TopBottomPanel::top("header").show(ctx, |ui| {
            ui.add_space(4.0);
            ui.vertical_centered(|ui| {
                ui.heading("doctate");
                ui.label(self.state.label());
            });
            ui.add_space(4.0);
        });

        egui::TopBottomPanel::bottom("footer").show(ctx, |ui| {
            ui.add_space(4.0);
            ui.vertical_centered(|ui| {
                self.render_footer_status(ui);
            });
            ui.add_space(4.0);
        });

        egui::CentralPanel::default().show(ctx, |ui| {
            ui.vertical_centered(|ui| {
                ui.add_space(8.0);
                if let Some(a) = self.render_controls(ui) {
                    pending_action = Some(a);
                }
                ui.add_space(8.0);
            });
            if !matches!(self.state, AppState::NotConfigured)
                && let Some(a) = self.render_case_list(ui)
            {
                pending_action = Some(a);
            }
        });

        if let Some(action) = pending_action {
            self.handle_action(action);
        }

        // Repaint cadence: fast while visibly active (spinner, REC-timer),
        // slow (1 Hz) in the quiet steady state.
        let active_spinner = self.finalizing.is_some() || self.worker_is_active();
        let interval = match self.state {
            AppState::Recording { .. } => Duration::from_millis(250),
            _ if active_spinner => Duration::from_millis(100),
            _ => Duration::from_secs(1),
        };
        ctx.request_repaint_after(interval);
    }
}
