//! Top-level application state and message dispatch.

use std::path::PathBuf;
use std::sync::Arc;

use iced::{
    Element, Event, Length, Point, Size, Subscription, Task, mouse,
};

use crate::codec::{DecodeError, DecodedImage, FormatRegistry};
use crate::config::{self, Keymap, KeymapAction};
use crate::nav::Navigator;
use crate::settings::Settings;
use crate::ui::about_dialog::{AboutDialog, AboutMessage};
use crate::ui::context_menu::{ContextMenu, ContextMenuMessage, ContextMenuItem};
use crate::ui::error_modal::ErrorModal;
use crate::ui::exif_panel::{PropertiesPanel, PropertiesMessage};
use crate::ui::export_dialog::{ExportDialog, ExportMessage, run_ffmpeg_export};
use crate::ui::image_view::{ImageMessage, ImageView};
use crate::ui::sidebar::{Sidebar, SidebarMessage};
use crate::ui::status_bar::StatusBar;
use crate::ui::thumbnail_bar::{ThumbnailBar, ThumbnailMessage, THUMB_SIZE, thumb_scroll_id};
use crate::ui::toolbar::{ToolbarMessage, view as toolbar_view};

const TOOLBAR_H: f32 = 34.0;
const STATUS_H: f32 = 22.0;
const THUMB_BAR_H: f32 = 80.0;

#[derive(Debug, Clone)]
pub enum Message {
    Toolbar(ToolbarMessage),
    Thumbnail(ThumbnailMessage),
    ContextMenu(ContextMenuMessage),
    About(AboutMessage),
    Sidebar(SidebarMessage),
    Properties(PropertiesMessage),
    Export(ExportMessage),
    SlideshowTick,
    RandomSameFolder,
    RandomFolderTree,
    ScrollToCurrentThumbnail,
    FolderSelected(Option<PathBuf>),
    ImageDecoded(Result<DecodedImage, DecodeError>),
    ThumbnailsLoaded(Vec<(usize, iced::widget::image::Handle)>),
    OpenFolder,
    Navigate(i32),
    JumpTo(usize),
    Event(Event),
    DismissContextMenu,
    DismissError,
    ActionCompleted(String),
    TrashCompleted(Result<PathBuf, String>),
    WallpaperSet(Result<(), String>),
}

pub struct Viewer {
    pub codec: Arc<FormatRegistry>,
    pub keymap: Keymap,
    pub settings: Settings,
    /// Warnings emitted while loading `keymap.toml` (invalid bindings,
    /// parse errors, etc.). Intended to be surfaced in a future "Config
    /// Health" UI; currently only stored, not displayed.
    #[allow(dead_code)]
    pub config_warnings: Vec<String>,
    pub navigator: Navigator,
    pub image_view: ImageView,
    pub status_bar: StatusBar,
    pub thumbnail_bar: ThumbnailBar,
    pub context_menu: ContextMenu,
    pub error_modal: ErrorModal,
    pub about_dialog: AboutDialog,
    pub sidebar: Sidebar,
    pub properties_panel: PropertiesPanel,
    pub export_dialog: ExportDialog,
    pub slideshow_active: bool,
    pub loading: bool,
    pub fullscreen: bool,
    pub window_size: Size,
    pub cursor: Option<Point>,
    pub toast: Option<String>,
}

impl Default for Viewer {
    fn default() -> Self {
        let (keymap, warnings) = config::load_keymap();
        let (settings, settings_warnings) = Settings::load();
        let mut all_warnings = warnings;
        all_warnings.extend(settings_warnings);
        Self {
            codec: Arc::new(FormatRegistry::new()),
            keymap,
            settings,
            config_warnings: all_warnings,
            navigator: Navigator::new(),
            image_view: ImageView::default(),
            status_bar: StatusBar::default(),
            thumbnail_bar: ThumbnailBar::new(),
            context_menu: ContextMenu::new(),
            error_modal: ErrorModal::new(),
            about_dialog: AboutDialog::new(),
            sidebar: Sidebar::new(),
            properties_panel: PropertiesPanel::new(),
            export_dialog: ExportDialog::new(),
            slideshow_active: false,
            loading: false,
            fullscreen: false,
            window_size: Size::new(1200.0, 800.0),
            cursor: None,
            toast: None,
        }
    }
}

/// What the image area viewport size is (window minus chrome).
fn image_viewport(window: Size, fullscreen: bool, sidebar_width: f32) -> Size {
    if fullscreen {
        window
    } else {
        Size::new(
            window.width - sidebar_width,
            window.height - TOOLBAR_H - STATUS_H - THUMB_BAR_H,
        )
    }
}

impl Viewer {
    /// Central message dispatcher: applies `message` to `self` and returns a
    /// `Task<Message>` describing any async follow-up work (e.g. decoding the
    /// next image, opening a file dialog). This is the only entry point that
    /// mutates viewer state — iced calls it once per event.
    pub fn update(&mut self, message: Message) -> Task<Message> {
        match message {
            Message::OpenFolder => {
                self.status_bar.filename = "Opening dialog…".into();
                Task::perform(
                    async {
                        rfd::AsyncFileDialog::new()
                            .set_title("Pick a folder of images")
                            .pick_folder()
                            .await
                            .map(|h| h.path().to_path_buf())
                    },
                    |path| Message::FolderSelected(path),
                )
            }

            Message::FolderSelected(Some(path)) => {
                let images = crate::nav::scan_folder(&path, &self.codec);
                self.sidebar.refresh(&path, &self.codec);
                if images.is_empty() {
                    self.status_bar.filename =
                        "No supported images in that folder".into();
                    self.status_bar.total = 0;
                    return Task::none();
                }
                self.navigator.set_images(images.clone());
                self.thumbnail_bar.set_paths(images, 0);
                self.load_current()
            }

            Message::FolderSelected(None) => {
                if self.navigator.is_empty() {
                    self.status_bar.filename = "Cancelled".into();
                }
                Task::none()
            }

            Message::Navigate(delta) => {
                if self.navigator.is_empty() {
                    return Task::none();
                }
                self.navigator.navigate(delta);
                self.thumbnail_bar
                    .set_current(self.navigator.current_index());
                if self.settings.smooth_scroll_thumbnails {
                    Task::batch([
                        self.load_current(),
                        self.scroll_to_thumbnail(),
                    ])
                } else {
                    self.load_current()
                }
            }

            Message::JumpTo(index) => {
                if self.navigator.jump_to(index) {
                    self.thumbnail_bar
                        .set_current(self.navigator.current_index());
                    if self.settings.smooth_scroll_thumbnails {
                        Task::batch([
                            self.load_current(),
                            self.scroll_to_thumbnail(),
                        ])
                    } else {
                        self.load_current()
                    }
                } else {
                    Task::none()
                }
            }

            Message::ImageDecoded(Ok(img)) => {
                self.loading = false;
                let default_zoom = match self.settings.default_zoom_mode.as_str() {
                    "ActualSize" => crate::ui::image_view::ZoomMode::ActualSize,
                    _ => crate::ui::image_view::ZoomMode::FitToWindow,
                };
                self.image_view.set_image(Arc::new(img), default_zoom);
                self.update_status_bar();
                // Request thumbnails for the new position.
                self.request_thumbnails()
            }

            Message::ImageDecoded(Err(e)) => {
                self.loading = false;
                self.error_modal.show(e);
                self.status_bar.filename = "Error".into();
                Task::none()
            }

            Message::ThumbnailsLoaded(results) => {
                results.into_iter().for_each(|(index, handle)| {
                    self.thumbnail_bar.insert_thumbnail(index, handle);
                });
                // Request more if needed.
                self.request_thumbnails()
            }

            Message::Toolbar(msg) => match msg {
                ToolbarMessage::Prev => self.update(Message::Navigate(-1)),
                ToolbarMessage::Next => self.update(Message::Navigate(1)),
                ToolbarMessage::OpenFolder => self.update(Message::OpenFolder),
                ToolbarMessage::FitToWindow => {
                    self.image_view.fit_to_window();
                    self.update_status_bar();
                    Task::none()
                }
                ToolbarMessage::ActualSize => {
                    self.image_view.actual_size();
                    self.update_status_bar();
                    Task::none()
                }
                ToolbarMessage::ZoomIn => {
                    self.image_view.zoom_in();
                    self.update_status_bar();
                    Task::none()
                }
                ToolbarMessage::ZoomOut => {
                    self.image_view.zoom_out();
                    self.update_status_bar();
                    Task::none()
                }
                ToolbarMessage::RotateCw => {
                    self.image_view.rotate_cw();
                    self.update_status_bar();
                    Task::none()
                }
                ToolbarMessage::RotateCcw => {
                    self.image_view.rotate_ccw();
                    self.update_status_bar();
                    Task::none()
                }
                ToolbarMessage::ToggleFullscreen => {
                    self.fullscreen = !self.fullscreen;
                    Task::none()
                }
                ToolbarMessage::About => {
                    self.about_dialog.show();
                    Task::none()
                }
                ToolbarMessage::Shuffle => {
                    self.update(Message::RandomSameFolder)
                }
            },

            Message::Thumbnail(msg) => match msg {
                ThumbnailMessage::Clicked(index) => {
                    self.update(Message::JumpTo(index))
                }
            },

            Message::ContextMenu(msg) => match msg {
                ContextMenuMessage::Show { x, y } => {
                    self.context_menu.show(x, y, self.window_size.width, self.window_size.height);
                    Task::none()
                }
                ContextMenuMessage::Selected(item) => {
                    self.context_menu.dismiss();
                    self.execute_context_action(item)
                }
            },

            Message::Event(event) => self.handle_event(event),

            Message::DismissContextMenu => {
                self.context_menu.dismiss();
                Task::none()
            }

            Message::DismissError => {
                self.error_modal.dismiss();
                Task::none()
            }

            Message::ActionCompleted(msg) => {
                self.toast = Some(msg);
                Task::none()
            }

            Message::TrashCompleted(Ok(path)) => {
                // Remove from navigator, load next.
                let Some(idx) = self.navigator.images().iter().position(|p| *p == path) else {
                    self.toast = Some("Moved to trash".into());
                    return Task::none();
                };
                let images: Vec<PathBuf> = self
                    .navigator
                    .images()
                    .iter()
                    .filter(|p| **p != path)
                    .cloned()
                    .collect();
                let new_idx = if images.is_empty() {
                    0
                } else if idx < images.len() {
                    idx
                } else {
                    images.len() - 1
                };
                self.navigator.set_images(images.clone());
                self.navigator.jump_to(new_idx);
                self.thumbnail_bar.set_paths(images, new_idx);
                if !self.navigator.is_empty() {
                    return self.load_current();
                }
                self.image_view.clear();
                self.status_bar = StatusBar::default();
                self.status_bar.filename = "No images".into();
                self.toast = Some("Moved to trash".into());
                Task::none()
            }

            Message::TrashCompleted(Err(e)) => {
                self.toast = Some(format!("Trash failed: {e}"));
                Task::none()
            }

            Message::WallpaperSet(Ok(())) => {
                self.toast = Some("Wallpaper set".into());
                Task::none()
            }
            Message::WallpaperSet(Err(e)) => {
                self.toast = Some(format!("Wallpaper failed: {e}"));
                Task::none()
            }

            Message::About(msg) => match msg {
                AboutMessage::Dismiss => {
                    self.about_dialog.dismiss();
                    Task::none()
                }
                AboutMessage::OpenWebsite => {
                    // Linux-only: relies on xdg-open being present on PATH.
                    let url = "http://git.dcos.net/dcosnet/marten";
                    let _ = std::process::Command::new("xdg-open")
                        .arg(url)
                        .spawn();
                    Task::none()
                }
            },

            Message::Sidebar(msg) => match msg {
                SidebarMessage::Toggle => {
                    self.sidebar.toggle();
                    Task::none()
                }
                SidebarMessage::FolderSelected(path) => {
                    let images = crate::nav::scan_folder(&path, &self.codec);
                    if images.is_empty() {
                        self.status_bar.filename = "No supported images in that folder".into();
                        self.status_bar.total = 0;
                        return Task::none();
                    }
                    self.navigator.set_images(images.clone());
                    self.thumbnail_bar.set_paths(images, 0);
                    self.sidebar.refresh(&path, &self.codec);
                    self.load_current()
                }
            },

            Message::Properties(msg) => match msg {
                PropertiesMessage::Dismiss => {
                    self.properties_panel.dismiss();
                    Task::none()
                }
            },

            Message::SlideshowTick => {
                if self.slideshow_active && !self.loading {
                    self.update(Message::Navigate(1))
                } else {
                    Task::none()
                }
            }

            Message::RandomSameFolder => {
                if self.navigator.random_same_folder() {
                    self.thumbnail_bar
                        .set_current(self.navigator.current_index());
                    Task::batch([
                        self.load_current(),
                        self.scroll_to_thumbnail(),
                    ])
                } else {
                    Task::none()
                }
            }

            Message::RandomFolderTree => {
                let root = self
                    .navigator
                    .current_path()
                    .and_then(|p| p.parent())
                    .map(|p| p.to_path_buf());

                let root = match root {
                    Some(r) => r,
                    None => return Task::none(),
                };

                let tree_images =
                    crate::nav::folder::walk_folder_tree(&root, &self.codec);
                if tree_images.is_empty() {
                    self.toast = Some("No images found in folder tree".into());
                    return Task::none();
                }

                let switched_folder = self.navigator.random_from_tree(&tree_images);
                if let Some(folder) = switched_folder {
                    self.sidebar.refresh(&folder, &self.codec);
                    self.thumbnail_bar
                        .set_paths(self.navigator.images().to_vec(), self.navigator.current_index());
                } else {
                    self.thumbnail_bar
                        .set_current(self.navigator.current_index());
                }
                Task::batch([
                    self.load_current(),
                    self.scroll_to_thumbnail(),
                ])
            }

            Message::ScrollToCurrentThumbnail => {
                self.scroll_to_thumbnail()
            }

            Message::Export(msg) => self.handle_export_message(msg),
        }
    }

    /// Build the root UI element for the current frame. Composes the toolbar,
    /// image area, thumbnail bar, and status bar, then layers any active
    /// overlays (context menu, error modal, about dialog, toast) on top via
    /// `iced::widget::stack`.
    pub fn view(&self) -> Element<'_, Message> {
        let viewport = image_viewport(self.window_size, self.fullscreen, self.sidebar.width());

        // Main content column.
        // v0.2 layout (gpicview-style): thumbnails on top, image in middle,
        // toolbar + status bar at bottom.
        let toolbar_el: Element<'_, Message> = if self.fullscreen {
            iced::widget::text("").into()
        } else {
            toolbar_view(self.navigator.is_empty()).map(Message::Toolbar)
        };

        let image_el: Element<'_, Message> = {
            let img: Element<'_, ImageMessage> = self.image_view.view(viewport);
            img.map(|msg| match msg {
                ImageMessage::RightClicked => {
                    let pos = self.cursor.unwrap_or(Point::new(0.0, 0.0));
                    Message::ContextMenu(ContextMenuMessage::Show { x: pos.x, y: pos.y })
                }
            })
        };

        let thumb_el: Element<'_, Message> = if self.fullscreen {
            iced::widget::text("").into()
        } else {
            self.thumbnail_bar.view().map(Message::Thumbnail)
        };

        let status_el: Element<'_, Message> = if self.fullscreen {
            iced::widget::text("").into()
        } else {
            // StatusMessage is an empty enum (the status bar is display-only),
            // so this map closure never runs — it only serves to satisfy the
            // type system by lifting `Element<StatusMessage>` to `Element<Message>`.
            crate::ui::status_bar::view(&self.status_bar)
                .map(|_| Message::DismissContextMenu)
                .into()
        };

        // gpicview-style: thumbnail bar on TOP, toolbar on BOTTOM.
        // v0.3: optional sidebar on LEFT (toggled via Tab).
        let main_column = iced::widget::column![
            thumb_el,
            iced::widget::container(image_el)
                .width(Length::Fill)
                .height(Length::Fill),
            status_el,
            toolbar_el,
        ]
        .width(Length::Fill)
        .height(Length::Fill);

        // Compose sidebar + main content horizontally.
        let main_content: Element<'_, Message> = if self.sidebar.visible && !self.fullscreen {
            iced::widget::row![
                self.sidebar.view().map(Message::Sidebar),
                main_column,
            ]
            .width(Length::Fill)
            .height(Length::Fill)
            .into()
        } else {
            main_column.into()
        };

        // Layer overlays using iced::widget::stack.
        let mut layers: Vec<Element<'_, Message>> = vec![main_content.into()];

        // Context menu overlay.
        if self.context_menu.visible {
            let backdrop = iced::widget::mouse_area(
                self.context_menu.overlay_view().map(Message::ContextMenu),
            )
            .on_press(Message::DismissContextMenu);
            layers.push(backdrop.into());
        }

        // Error modal overlay (highest priority).
        if let Some(modal) = self.error_modal.view() {
            let backdrop = iced::widget::mouse_area(
                modal.map(|_| Message::DismissError),
            )
            .on_press(Message::DismissError);
            layers.push(backdrop.into());
        }

        // About dialog overlay.
        // Note: does NOT dismiss on backdrop click — only via × button or Escape.
        if let Some(about) = self.about_dialog.view() {
            layers.push(about.map(Message::About).into());
        }

        // Properties panel overlay.
        if let Some(props) = self.properties_panel.view() {
            let backdrop = iced::widget::mouse_area(
                props.map(Message::Properties),
            )
            .on_press(Message::Properties(PropertiesMessage::Dismiss));
            layers.push(backdrop.into());
        }

        // Export dialog overlay.
        if let Some(export) = self.export_dialog.view() {
            layers.push(export.map(Message::Export).into());
        }

        // Fullscreen exit hint.
        if self.fullscreen && self.settings.show_fullscreen_hint {
            let hint = iced::widget::container(
                iced::widget::text("Press F11 to exit fullscreen")
                    .color(crate::ui::theme::TEXT_SECONDARY)
                    .size(11),
            )
            .padding(iced::Padding {
                top: 6.0,
                right: 14.0,
                bottom: 6.0,
                left: 14.0,
            })
            .style(|_| iced::widget::container::Style {
                background: Some(
                    iced::Color::from_rgba(0.086, 0.086, 0.102, 0.85).into(),
                ),
                border: iced::Border {
                    color: crate::ui::theme::BG_CHROME_BORDER,
                    width: 1.0,
                    radius: 4.0.into(),
                },
                ..Default::default()
            });

            let positioned = iced::widget::container(hint)
                .width(Length::Fill)
                .height(Length::Fill)
                .padding(iced::Padding {
                    top: 12.0,
                    right: 0.0,
                    bottom: 0.0,
                    left: 0.0,
                })
                .align_x(iced::Alignment::Center);
            layers.push(positioned.into());
        }

        // Toast overlay (if any).
        if let Some(toast) = &self.toast {
            let toast_el = iced::widget::container(
                iced::widget::text(toast.clone())
                    .color(crate::ui::theme::TEXT_PRIMARY)
                    .size(12),
            )
            .padding(iced::Padding {
                top: 8.0,
                right: 16.0,
                bottom: 8.0,
                left: 16.0,
            })
            .style(|_| iced::widget::container::Style {
                background: Some(crate::ui::theme::BG_CHROME.into()),
                border: iced::Border {
                    color: crate::ui::theme::BG_CHROME_BORDER,
                    width: 1.0,
                    radius: 6.0.into(),
                },
                ..Default::default()
            });

            let positioned = iced::widget::container(toast_el)
                .width(Length::Fill)
                .height(Length::Fill)
                .padding(iced::Padding {
                    top: 44.0,
                    right: 0.0,
                    bottom: 0.0,
                    left: 0.0,
                })
                .align_x(iced::Alignment::Center);

            layers.push(positioned.into());
        }

        iced::widget::stack(layers).into()
    }

    /// Subscribe to global window events (keyboard, mouse, resize). iced
    /// forwards each event as `Message::Event`.
    pub fn subscription(&self) -> Subscription<Message> {
        let event_sub = iced::event::listen().map(Message::Event);
        if self.slideshow_active {
            let interval_secs = self.settings.slideshow_interval_secs.max(0.1);
            let slideshow_sub = iced::time::every(std::time::Duration::from_secs_f32(interval_secs))
                .map(|_| Message::SlideshowTick);
            Subscription::batch([event_sub, slideshow_sub])
        } else {
            event_sub
        }
    }

    // ── Internal helpers ──────────────────────────────────────────────

    /// Scroll the thumbnail strip so the current image is centered.
    /// Emulates a video editor's timeline playhead behavior: as the user
    /// navigates, the strip auto-scrolls to keep the current thumbnail visible.
    fn scroll_to_thumbnail(&self) -> Task<Message> {
        let thumb_entry_width = THUMB_SIZE + 2.0 + 2.0; // image + border + spacing
        let current_offset = self.thumbnail_bar.current as f32 * thumb_entry_width;
        let viewport_w = self.window_size.width - self.sidebar.width();
        let center_offset = (current_offset - viewport_w / 2.0).max(0.0);
        iced::widget::scrollable::scroll_to(
            thumb_scroll_id(),
            iced::widget::scrollable::AbsoluteOffset {
                x: center_offset,
                y: 0.0,
            },
        )
    }

    /// Handle export dialog messages: file selection, codec changes,
    /// and the actual ffmpeg export in a background thread.
    fn handle_export_message(&mut self, msg: ExportMessage) -> Task<Message> {
        match msg {
            ExportMessage::SelectAudio => {
                Task::perform(
                    async {
                        rfd::AsyncFileDialog::new()
                            .set_title("Select audio file")
                            .add_filter("Audio", &["mp3", "wav", "ogg", "flac", "aac", "m4a"])
                            .pick_file()
                            .await
                            .map(|h| h.path().to_path_buf())
                    },
                    |path| Message::Export(ExportMessage::AudioSelected(path)),
                )
            }
            ExportMessage::AudioSelected(path) => {
                self.export_dialog.audio_path = path;
                Task::none()
            }
            ExportMessage::SelectOutput => {
                Task::perform(
                    async {
                        rfd::AsyncFileDialog::new()
                            .set_title("Select output video file")
                            .add_filter("WebM", &["webm"])
                            .set_file_name("slideshow.webm")
                            .save_file()
                            .await
                            .map(|h| h.path().to_path_buf())
                    },
                    |path| Message::Export(ExportMessage::OutputSelected(path)),
                )
            }
            ExportMessage::OutputSelected(path) => {
                self.export_dialog.output_path = path;
                Task::none()
            }
            ExportMessage::CodecChanged(codec) => {
                self.export_dialog.codec = codec;
                Task::none()
            }
            ExportMessage::DurationChanged(s) => {
                self.export_dialog.duration_per_image = s;
                Task::none()
            }
            ExportMessage::StartExport => {
                let images: Vec<PathBuf> = self.navigator.images().to_vec();
                let audio = match &self.export_dialog.audio_path {
                    Some(p) => p.clone(),
                    None => return Task::none(),
                };
                let output = match &self.export_dialog.output_path {
                    Some(p) => p.clone(),
                    None => return Task::none(),
                };
                let codec = self.export_dialog.codec;
                let duration: f32 = self
                    .export_dialog
                    .duration_per_image
                    .parse()
                    .unwrap_or(3.0);

                self.export_dialog.exporting = true;
                self.export_dialog.result_message = None;

                Task::perform(
                    async move {
                        tokio::task::spawn_blocking(move || {
                            run_ffmpeg_export(&images, &audio, &output, codec, duration)
                        })
                        .await
                        .map_err(|e| e.to_string())?
                    },
                    |res| Message::Export(ExportMessage::ExportCompleted(res)),
                )
            }
            ExportMessage::ExportCompleted(result) => {
                self.export_dialog.exporting = false;
                match result {
                    Ok(()) => {
                        self.export_dialog.result_message =
                            Some("Export complete!".to_string());
                        self.export_dialog.result_is_error = false;
                    }
                    Err(e) => {
                        self.export_dialog.result_message = Some(e);
                        self.export_dialog.result_is_error = true;
                    }
                }
                Task::none()
            }
            ExportMessage::Dismiss => {
                self.export_dialog.dismiss();
                Task::none()
            }
        }
    }

    fn load_current(&mut self) -> Task<Message> {
        let path = match self.navigator.current_path() {
            Some(p) => p.to_path_buf(),
            None => return Task::none(),
        };
        self.loading = true;
        self.status_bar.filename = format!(
            "Loading {}",
            path.file_name()
                .map(|s| s.to_string_lossy())
                .unwrap_or_default()
        );

        let registry = self.codec.clone();
        Task::perform(
            async move {
                tokio::task::spawn_blocking(move || registry.decode(&path))
                    .await
                    .map_err(|e| DecodeError::Decode(e.to_string()))?
            },
            |res| Message::ImageDecoded(res),
        )
    }

    fn request_thumbnails(&mut self) -> Task<Message> {
        let window = self.settings.thumbnail_cache_window;
        let needed = self.thumbnail_bar.needed_thumbnails(window);
        if needed.is_empty() {
            return Task::none();
        }
        let paths: Vec<(usize, PathBuf)> = needed
            .iter()
            .filter_map(|&i| {
                self.navigator
                    .images()
                    .get(i)
                    .map(|p| (i, p.clone()))
            })
            .collect();
        if paths.is_empty() {
            return Task::none();
        }

        let registry = self.codec.clone();
        Task::perform(
            async move {
                let max_size = THUMB_SIZE as u32;
                tokio::task::spawn_blocking(move || {
                    paths
                        .iter()
                        .filter_map(|(idx, path)| {
                            let decoded = registry.decode(path).ok()?;
                            let img = image::RgbaImage::from_raw(
                                decoded.width,
                                decoded.height,
                                decoded.pixels,
                            )?;
                            let thumb = image::imageops::resize(
                                &img,
                                max_size,
                                max_size,
                                image::imageops::FilterType::Nearest,
                            );
                            let handle = iced::widget::image::Handle::from_rgba(
                                thumb.width(),
                                thumb.height(),
                                thumb.into_raw(),
                            );
                            Some((*idx, handle))
                        })
                        .collect()
                })
                .await
                .unwrap_or_default()
            },
            |results| Message::ThumbnailsLoaded(results),
        )
    }

    fn handle_event(&mut self, event: Event) -> Task<Message> {
        match event {
            Event::Keyboard(iced::keyboard::Event::KeyPressed { key, modifiers, .. }) => {
                // Escape dismisses overlays first.
                if matches!(key, iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape)) {
                    if self.export_dialog.visible {
                        return self.update(Message::Export(ExportMessage::Dismiss));
                    }
                    if self.properties_panel.visible {
                        return self.update(Message::Properties(PropertiesMessage::Dismiss));
                    }
                    if self.about_dialog.visible {
                        return self.update(Message::About(AboutMessage::Dismiss));
                    }
                    if self.error_modal.visible {
                        return self.update(Message::DismissError);
                    }
                    if self.context_menu.visible {
                        return self.update(Message::DismissContextMenu);
                    }
                    if self.slideshow_active {
                        self.slideshow_active = false;
                        self.toast = Some("Slideshow stopped".into());
                        return Task::none();
                    }
                }

                self.dispatch_key(key, modifiers)
            }
            Event::Keyboard(iced::keyboard::Event::KeyReleased { .. }) => Task::none(),
            Event::Mouse(mouse::Event::CursorMoved { position }) => {
                self.cursor = Some(position);
                Task::none()
            }
            // Right-click context menu positioning is handled by the
            // mouse_area wrapper around the image widget in `view()`,
            // which captures the cursor position at press time. No
            // work to do here for raw button events.
            Event::Mouse(mouse::Event::ButtonPressed(_)) => Task::none(),
            Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
                // Only navigate on scroll when in fit mode.
                // In zoom mode, the scrollable widget handles panning.
                if !self.image_view.zoom.is_fit() {
                    return Task::none();
                }
                let dy = match delta {
                    mouse::ScrollDelta::Lines { y, .. } => y * 16.0,
                    mouse::ScrollDelta::Pixels { y, .. } => y,
                };
                if dy.abs() <= 0.5 {
                    return Task::none();
                }
                let nav_delta = if dy > 0.0 { -1 } else { 1 };
                self.update(Message::Navigate(nav_delta))
            }
            Event::Window(iced::window::Event::Resized(size)) => {
                self.window_size = size;
                Task::none()
            }
            _ => Task::none(),
        }
    }

    fn dispatch_key(&mut self, key: iced::keyboard::Key, modifiers: iced::keyboard::Modifiers) -> Task<Message> {
        // Static lookup table for named keys → keymap string. Character keys
        // use the character verbatim. Anything else (e.g. Unnamed) is ignored.
        use iced::keyboard::key::Named as N;
        const NAMED_KEYS: &[(N, &str)] = &[
            (N::ArrowLeft, "Left"),
            (N::ArrowRight, "Right"),
            (N::ArrowUp, "Up"),
            (N::ArrowDown, "Down"),
            (N::F1, "F1"),
            (N::F2, "F2"),
            (N::F3, "F3"),
            (N::F4, "F4"),
            (N::F5, "F5"),
            (N::F6, "F6"),
            (N::F7, "F7"),
            (N::F8, "F8"),
            (N::F9, "F9"),
            (N::F10, "F10"),
            (N::F11, "F11"),
            (N::F12, "F12"),
            (N::Escape, "Escape"),
            (N::Space, "Space"),
            (N::Enter, "Enter"),
            (N::Tab, "Tab"),
            (N::Backspace, "Backspace"),
            (N::Delete, "Delete"),
            (N::Home, "Home"),
            (N::End, "End"),
            (N::PageUp, "PageUp"),
            (N::PageDown, "PageDown"),
        ];

        let key_str = match &key {
            iced::keyboard::Key::Named(named) => {
                match NAMED_KEYS.iter().find(|(n, _)| n == named) {
                    Some((_, s)) => *s,
                    None => return Task::none(),
                }
            }
            iced::keyboard::Key::Character(c) => c.as_str(),
            _ => return Task::none(),
        };

        let ctrl = modifiers.control();
        let shift = modifiers.shift();
        let alt = modifiers.alt();
        let logo = modifiers.logo();

        let matches_binding = |binding: &str| -> bool {
            let parsed = match config::parse_binding(binding) {
                Some(p) => p,
                None => return false,
            };
            parsed.mods.ctrl == ctrl
                && parsed.mods.shift == shift
                && parsed.mods.alt == alt
                && parsed.mods.logo == logo
                && parsed.key == key_str
        };

        const ACTIONS: &[KeymapAction] = &[
            KeymapAction::NextPhoto,
            KeymapAction::PrevPhoto,
            KeymapAction::FirstPhoto,
            KeymapAction::LastPhoto,
            KeymapAction::ZoomIn,
            KeymapAction::ZoomOut,
            KeymapAction::FitToWindow,
            KeymapAction::ActualSize,
            KeymapAction::ToggleFullscreen,
            KeymapAction::ToggleChrome,
            KeymapAction::OpenFolder,
            KeymapAction::Quit,
            KeymapAction::RotateCw,
            KeymapAction::RotateCcw,
            KeymapAction::Properties,
            KeymapAction::CopyToPictures,
            KeymapAction::DeletePermanently,
            KeymapAction::About,
            KeymapAction::ToggleSidebar,
            KeymapAction::ToggleSlideshow,
            KeymapAction::RandomSameFolder,
            KeymapAction::RandomFolderTree,
        ];

        ACTIONS
            .iter()
            .find(|action| self.keymap.bindings_for(**action).iter().any(|b| matches_binding(b)))
            .map(|action| self.dispatch_action(*action))
            .unwrap_or_else(Task::none)
    }

    fn dispatch_action(&mut self, action: KeymapAction) -> Task<Message> {
        match action {
            KeymapAction::NextPhoto => self.update(Message::Navigate(1)),
            KeymapAction::PrevPhoto => self.update(Message::Navigate(-1)),
            KeymapAction::FirstPhoto => {
                self.navigator.first();
                self.thumbnail_bar
                    .set_current(self.navigator.current_index());
                self.load_current()
            }
            KeymapAction::LastPhoto => {
                self.navigator.last();
                self.thumbnail_bar
                    .set_current(self.navigator.current_index());
                self.load_current()
            }
            KeymapAction::ZoomIn => {
                self.image_view.zoom_in();
                self.update_status_bar();
                Task::none()
            }
            KeymapAction::ZoomOut => {
                self.image_view.zoom_out();
                self.update_status_bar();
                Task::none()
            }
            KeymapAction::FitToWindow => {
                self.image_view.fit_to_window();
                self.update_status_bar();
                Task::none()
            }
            KeymapAction::ActualSize => {
                self.image_view.actual_size();
                self.update_status_bar();
                Task::none()
            }
            KeymapAction::ToggleFullscreen => {
                self.fullscreen = !self.fullscreen;
                Task::none()
            }
            KeymapAction::ToggleChrome => {
                self.fullscreen = !self.fullscreen;
                Task::none()
            }
            KeymapAction::OpenFolder => self.update(Message::OpenFolder),
            KeymapAction::Quit => iced::exit(),
            KeymapAction::RotateCw => {
                self.image_view.rotate_cw();
                self.update_status_bar();
                Task::none()
            }
            KeymapAction::RotateCcw => {
                self.image_view.rotate_ccw();
                self.update_status_bar();
                Task::none()
            }
            KeymapAction::Properties => {
                self.execute_context_action(ContextMenuItem::Properties)
            }
            KeymapAction::CopyToPictures => {
                self.execute_context_action(ContextMenuItem::CopyToPictures)
            }
            KeymapAction::DeletePermanently => {
                self.execute_context_action(ContextMenuItem::DeletePermanently)
            }
            KeymapAction::About => {
                self.about_dialog.show();
                Task::none()
            }
            KeymapAction::ToggleSidebar => {
                self.sidebar.toggle();
                Task::none()
            }
            KeymapAction::ToggleSlideshow => {
                self.slideshow_active = !self.slideshow_active;
                if self.slideshow_active {
                    self.toast = Some("Slideshow started".into());
                } else {
                    self.toast = Some("Slideshow stopped".into());
                }
                Task::none()
            }
            KeymapAction::RandomSameFolder => {
                self.update(Message::RandomSameFolder)
            }
            KeymapAction::RandomFolderTree => {
                self.update(Message::RandomFolderTree)
            }
        }
    }

    fn execute_context_action(&mut self, item: ContextMenuItem) -> Task<Message> {
        match item {
            ContextMenuItem::OpenWith => {
                let Some(path) = self.navigator.current_path().map(|p| p.to_path_buf()) else {
                    return Task::none();
                };
                // Linux-only: relies on xdg-open being present on PATH.
                Task::perform(
                    async move {
                        let _ = std::process::Command::new("xdg-open")
                            .arg(&path)
                            .spawn();
                    },
                    |_| Message::ActionCompleted("Opened externally".into()),
                )
            }
            ContextMenuItem::CopyPath => {
                let Some(path) = self.navigator.current_path() else {
                    return Task::none();
                };
                let path_str = path.display().to_string();
                if let Ok(mut clipboard) = arboard::Clipboard::new() {
                    let _ = clipboard.set_text(path_str);
                }
                self.toast = Some("Path copied".into());
                Task::none()
            }
            ContextMenuItem::CopyImage => {
                let Some(img) = &self.image_view.original else {
                    return Task::none();
                };
                let rgba = img::image_data_to_arboard(img);
                if let Ok(mut clipboard) = arboard::Clipboard::new() {
                    let _ = clipboard.set_image(rgba);
                }
                self.toast = Some("Image copied".into());
                Task::none()
            }
            ContextMenuItem::RotateCw => {
                self.image_view.rotate_cw();
                self.update_status_bar();
                Task::none()
            }
            ContextMenuItem::RotateCcw => {
                self.image_view.rotate_ccw();
                self.update_status_bar();
                Task::none()
            }
            ContextMenuItem::SetAsWallpaper => {
                let Some(path) = self.navigator.current_path().map(|p| p.to_path_buf()) else {
                    return Task::none();
                };
                Task::perform(
                    async move {
                        // Linux-only: gsettings (GNOME) tried first, then feh
                        // as an X11 fallback. Neither is a portable solution.
                        let uri = format!("file://{}", path.display());
                        let gsettings_result = std::process::Command::new("gsettings")
                            .args(["set", "org.gnome.desktop.background", "picture-uri", &uri])
                            .output();
                        match gsettings_result {
                            Ok(output) if output.status.success() => Ok(()),
                            _ => {
                                // Fallback: try feh (X11).
                                let feh_result = std::process::Command::new("feh")
                                    .args(["--bg-fill", &path.display().to_string()])
                                    .output();
                                match feh_result {
                                    Ok(o) if o.status.success() => Ok(()),
                                    Ok(o) => Err(format!("feh failed: {}", String::from_utf8_lossy(&o.stderr))),
                                    Err(_) => Err("Neither gsettings nor feh available".into()),
                                }
                            }
                        }
                    },
                    |res| Message::WallpaperSet(res),
                )
            }
            ContextMenuItem::MoveToTrash => {
                let Some(path) = self.navigator.current_path().map(|p| p.to_path_buf()) else {
                    return Task::none();
                };
                let path_clone = path.clone();
                Task::perform(
                    async move {
                        trash::delete(&path_clone)
                            .map(|_| path_clone)
                            .map_err(|e| e.to_string())
                    },
                    |res| Message::TrashCompleted(res),
                )
            }
            ContextMenuItem::DeletePermanently => {
                let Some(path) = self.navigator.current_path().map(|p| p.to_path_buf()) else {
                    return Task::none();
                };
                let path_clone = path.clone();
                Task::perform(
                    async move {
                        std::fs::remove_file(&path_clone)
                            .map(|_| path_clone)
                            .map_err(|e| e.to_string())
                    },
                    // Routes through the same TrashCompleted handler because the
                    // post-delete UI work (navigator update, next-image load) is
                    // identical for trash and permanent delete.
                    |res| Message::TrashCompleted(res),
                )
            }
            ContextMenuItem::CopyToPictures => {
                let Some(path) = self.navigator.current_path().map(|p| p.to_path_buf()) else {
                    return Task::none();
                };
                let path_clone = path.clone();
                Task::perform(
                    async move {
                        // Determine destination: ~/Pictures
                        let home = dirs::home_dir()
                            .ok_or_else(|| "Could not find home directory".to_string())?;
                        let pictures_dir = home.join("Pictures");
                        // Create ~/Pictures if it doesn't exist.
                        if !pictures_dir.exists() {
                            std::fs::create_dir_all(&pictures_dir)
                                .map_err(|e| e.to_string())?;
                        }
                        let filename = path_clone
                            .file_name()
                            .ok_or_else(|| "Invalid filename".to_string())?;
                        let dest = pictures_dir.join(filename);
                        // Filename collision suffixing: find the first
                        // counter value whose destination does not exist.
                        // Implemented as an unbounded loop because no finite
                        // iterator bound is correct — a folder could in
                        // principle contain thousands of `image_N` copies.
                        let dest = if !dest.exists() {
                            dest
                        } else {
                            let stem = path_clone
                                .file_stem()
                                .and_then(|s| s.to_str())
                                .unwrap_or("image");
                            let ext = path_clone
                                .extension()
                                .and_then(|s| s.to_str())
                                .unwrap_or("");
                            let mut counter = 1;
                            loop {
                                let candidate = if ext.is_empty() {
                                    pictures_dir.join(format!("{}_{}", stem, counter))
                                } else {
                                    pictures_dir.join(format!("{}_{}.{}", stem, counter, ext))
                                };
                                if !candidate.exists() {
                                    break candidate;
                                }
                                counter += 1;
                            }
                        };
                        std::fs::copy(&path_clone, &dest)
                            .map_err(|e| e.to_string())?;
                        Ok::<String, String>(dest.to_string_lossy().to_string())
                    },
                    |res: Result<String, String>| match res {
                        Ok(dest_path) => Message::ActionCompleted(
                            format!("Copied to {}", dest_path)
                        ),
                        Err(e) => Message::ActionCompleted(format!("Copy failed: {}", e)),
                    },
                )
            }
            ContextMenuItem::Properties => {
                let Some(img) = &self.image_view.original else {
                    return Task::none();
                };
                let Some(path) = self.navigator.current_path() else {
                    return Task::none();
                };
                self.properties_panel.show(img, path);
                Task::none()
            }
            ContextMenuItem::About => {
                self.about_dialog.show();
                Task::none()
            }
            ContextMenuItem::ExportToVideo => {
                let folder = self
                    .navigator
                    .current_path()
                    .and_then(|p| p.parent())
                    .map(|p| p.to_path_buf());
                if let Some(folder) = folder {
                    self.export_dialog
                        .show(folder, self.navigator.len());
                }
                Task::none()
            }
        }
    }

    fn update_status_bar(&mut self) {
        if let Some(path) = self.navigator.current_path() {
            self.status_bar.filename = path
                .file_name()
                .map(|s| s.to_string_lossy().to_string())
                .unwrap_or_default();
        }
        self.status_bar.current_index = self.navigator.current_index();
        self.status_bar.total = self.navigator.len();
        if let Some(img) = &self.image_view.original {
            let (w, h) = self.image_view.displayed_dimensions().unwrap_or((img.width, img.height));
            self.status_bar.width = w;
            self.status_bar.height = h;
            self.status_bar.format = img.format.to_string();
        }
        self.status_bar.zoom_pct = self.image_view.zoom.pct();
    }
}

// Helper module for arboard conversion.
mod img {
    use crate::codec::DecodedImage;

    pub fn image_data_to_arboard(img: &DecodedImage) -> arboard::ImageData<'_> {
        arboard::ImageData {
            width: img.width as usize,
            height: img.height as usize,
            bytes: std::borrow::Cow::Borrowed(&img.pixels),
        }
    }
}
