/* start_screen.rs
 *
 * Copyright 2026 Christian Kruse
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * SPDX-License-Identifier: AGPL-3.0-or-later
 */

use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};

use adw::prelude::*;
use gettextrs::gettext;
use relm4::Sender;
use relm4::gtk::glib;
use relm4::prelude::*;

use crate::clone_dialog::{CloneDialog, CloneDialogInit, CloneDialogOutput};
use crate::clone_progress_dialog::{
    CloneProgressDialog, CloneProgressDialogInit, CloneProgressDialogMsg, CloneProgressDialogOutput,
};
use crate::confirm_dialog::{ConfirmDialog, ConfirmDialogInit, ConfirmDialogOutput};
use crate::git::{self, CloneProgress, QuickStatus};
use crate::repo_row::{RepoRow, RepoRowInit, RepoRowInput, RepoRowOutput};
use crate::repository::{self, Repository, RepositoryStore};
use crate::text_prompt_dialog::{TextPromptDialog, TextPromptDialogInit, TextPromptDialogOutput};
use crate::{portal, preferences, ui_helpers};

/*
 * Cross-window pub/sub for store mutations.
 *
 * Each StartScreen instance subscribes its input sender on init and gets a
 * stable subscription id back, used both to unsubscribe on shutdown and to
 * exclude itself from its own broadcasts (the originating window already
 * handled the mutation locally and doesn't need to round-trip through the bus).
 *
 */
static SUBSCRIBERS: Mutex<Option<HashMap<u64, Sender<StartScreenMsg>>>> = Mutex::new(None);
static NEXT_SUBSCRIPTION_ID: AtomicU64 = AtomicU64::new(0);

fn with_subscribers<R>(f: impl FnOnce(&mut HashMap<u64, Sender<StartScreenMsg>>) -> R) -> R {
    let mut guard = SUBSCRIBERS.lock().unwrap();
    let map = guard.get_or_insert_with(HashMap::new);
    f(map)
}

fn subscribe(sender: Sender<StartScreenMsg>) -> u64 {
    let id = NEXT_SUBSCRIPTION_ID.fetch_add(1, Ordering::Relaxed);

    with_subscribers(|subs| {
        subs.insert(id, sender);
    });

    id
}

fn unsubscribe(id: u64) {
    with_subscribers(|subs| {
        subs.remove(&id);
    });
}

pub fn broadcast_repo_opened(id: String, ts: i64, except: Option<u64>) {
    with_subscribers(|subs| {
        subs.retain(|sub_id, sender| {
            if Some(*sub_id) == except {
                return true;
            }

            sender.send(StartScreenMsg::RepoOpened(id.clone(), ts)).is_ok()
        });
    });
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortMode {
    Manual,
    Name,
    LastOpened,
}

impl SortMode {
    fn from_settings(value: &str) -> Self {
        match value {
            "name" => Self::Name,
            "last-opened" => Self::LastOpened,
            _ => Self::Manual,
        }
    }
}

enum StackPage {
    Empty,
    List,
}

impl StackPage {
    fn as_str(&self) -> &'static str {
        match self {
            Self::Empty => "empty",
            Self::List => "list",
        }
    }

    fn for_store(store: &RepositoryStore) -> Self {
        if store.is_empty() { Self::Empty } else { Self::List }
    }
}

pub struct StartScreenInit {
    pub folder_select_supported: bool,
}

pub struct StartScreen {
    repositories: RepositoryStore,
    repo_factory: FactoryVecDeque<RepoRow>,
    favorites_factory: FactoryVecDeque<RepoRow>,
    removed_repos: Vec<(usize, Repository)>,
    search_text: String,
    sort_mode: SortMode,
    status_cache: HashMap<String, Option<QuickStatus>>,
    status_epoch: u64,
    toast_overlay: adw::ToastOverlay,
    has_repos: bool,
    subscription_id: u64,
    search_debounce: Option<glib::SourceId>,
    confirm_dialog: Option<Controller<ConfirmDialog>>,
    clone_dialog: Option<Controller<CloneDialog>>,
    clone_progress_dialog: Option<Controller<CloneProgressDialog>>,
    clone_cancel_sender: Option<std::sync::mpsc::Sender<()>>,
    clone_cancelled: bool,
    folder_select_supported: bool,
    text_prompt_dialog: Option<Controller<TextPromptDialog>>,
}

#[derive(Debug)]
pub enum StartScreenOutput {
    OpenRepository { path: PathBuf, id: String, name: String },
    HasReposChanged(bool),
}

#[derive(Debug)]
pub enum StartScreenMsg {
    AddRepository,
    InitRepository,
    RepositorySelected(PathBuf),
    BulkRepositoriesSelected(Vec<PathBuf>),
    AddConfirmed(PathBuf),
    RemoveRepository(String),
    RenameRepository(String, String),
    UndoRemove,
    OpenRepository {
        path: PathBuf,
        id: String,
        name: String,
    },
    OpenPath(PathBuf),
    ToggleFavorite(String),
    MoveRepo {
        dragged_id: String,
        target_id: String,
        before: bool,
    },
    FocusSearch,
    SearchChanged(String),
    SearchDebounceFired,
    SortModeChanged(SortMode),
    Rebuild,
    Reload,
    RepoOpenedLocal {
        id: String,
        ts: i64,
    },
    RepoOpened(String, i64),
    CloneRepository,
    CloneConfirmed {
        url: String,
        destination: PathBuf,
    },
    CloneProgress(CloneProgress),
    CloneWorkerDone(PathBuf),
    CloneWorkerFailed(String),
    CloneFinished(PathBuf),
    CloneFailed(String),
    CloneCancelled,
    CloneDialogDismissed,
    ConfirmDialogDismissed,
    InitRepoConfirmed(PathBuf),
    StatusLoaded(u64, String, QuickStatus),
}

impl StartScreen {
    fn forward_row(output: RepoRowOutput) -> StartScreenMsg {
        match output {
            RepoRowOutput::Activated { path, id, name } => StartScreenMsg::OpenRepository { path, id, name },

            RepoRowOutput::Remove(id) => StartScreenMsg::RemoveRepository(id),

            RepoRowOutput::Rename(id, name) => StartScreenMsg::RenameRepository(id, name),

            RepoRowOutput::ToggleFavorite(id) => StartScreenMsg::ToggleFavorite(id),

            RepoRowOutput::Reorder {
                dragged_id,
                target_id,
                before,
            } => StartScreenMsg::MoveRepo {
                dragged_id,
                target_id,
                before,
            },
        }
    }
}

#[relm4::component(pub)]
impl Component for StartScreen {
    type Init = StartScreenInit;
    type Input = StartScreenMsg;
    type Output = StartScreenOutput;
    type CommandOutput = StartScreenMsg;

    view! {
        #[name = "toast_overlay"]
        adw::ToastOverlay {
            #[name = "stack"]
            gtk::Stack {
                add_named[Some("empty")] = &adw::StatusPage {
                    set_icon_name: Some("de.wwwtech.gitte"),
                    set_title: &gettext("Welcome to Gitte"),
                    set_description: Some(&gettext("Ook? Add or initialize a Git repository to get started.")),

                    #[wrap(Some)]
                    set_child = &gtk::Box {
                        set_orientation: gtk::Orientation::Horizontal,
                        add_css_class: "gap-l",
                        set_halign: gtk::Align::Center,

                        gtk::Button {
                            set_label: &gettext("Add Repository…"),
                            add_css_class: "pill",
                            add_css_class: "suggested-action",
                            connect_clicked => StartScreenMsg::AddRepository,
                        },

                        gtk::Button {
                            set_label: &gettext("Init Repository…"),
                            add_css_class: "pill",
                            connect_clicked => StartScreenMsg::InitRepository,
                        },

                        gtk::Button {
                            set_label: &gettext("Clone Repository…"),
                            add_css_class: "pill",
                            connect_clicked => StartScreenMsg::CloneRepository,
                        },
                    },
                },

                add_named[Some("list")] = &gtk::Box {
                    set_orientation: gtk::Orientation::Vertical,

                    #[name = "search_bar"]
                    gtk::SearchBar {
                        set_search_mode: false,

                        #[wrap(Some)]
                        #[name = "search_entry"]
                        set_child = &gtk::SearchEntry {
                            set_placeholder_text: Some(&gettext("Search repositories")),
                            update_property: &[gtk::accessible::Property::Label(&gettext("Search repositories"))],
                            set_width_chars: 32,
                            connect_search_changed[sender] => move |entry| {
                                sender.input(StartScreenMsg::SearchChanged(entry.text().to_string()));
                            },
                        },
                    },

                    gtk::ScrolledWindow {
                        set_vexpand: true,

                        adw::Clamp {
                            set_maximum_size: 720,
                            add_css_class: "mt-xl",
                            add_css_class: "mb-xl",

                            gtk::Box {
                                set_orientation: gtk::Orientation::Vertical,
                                add_css_class: "gap-l",

                                gtk::Box {
                                    set_orientation: gtk::Orientation::Horizontal,
                                    add_css_class: "gap-s",

                                    gtk::Label {
                                        set_label: &gettext("Repositories"),
                                        add_css_class: "title-4",
                                        set_halign: gtk::Align::Start,
                                        set_hexpand: true,
                                    },

                                    gtk::ToggleButton {
                                        set_icon_name: "system-search-symbolic",
                                        set_tooltip_text: Some(&gettext("Search")),
                                        add_css_class: "flat",
                                        #[watch]
                                        set_active: search_bar.is_search_mode(),
                                        connect_toggled[search_bar] => move |btn| {
                                            search_bar.set_search_mode(btn.is_active());
                                        },
                                    },

                                    gtk::MenuButton {
                                        set_icon_name: "view-sort-descending-symbolic",
                                        set_tooltip_text: Some(&gettext("Sort")),
                                        add_css_class: "flat",
                                        set_menu_model: Some(&crate::menus::sort_menu()),
                                    },
                                },

                                #[name = "favorites_label"]
                                gtk::Label {
                                    set_label: &gettext("Favorites"),
                                    add_css_class: "heading",
                                    add_css_class: "dim-label",
                                    set_halign: gtk::Align::Start,
                                    #[watch]
                                    set_visible: model.show_favorites(),
                                },

                                #[local_ref]
                                favorites_list -> gtk::ListBox {
                                    set_selection_mode: gtk::SelectionMode::None,
                                    add_css_class: "boxed-list",
                                    #[watch]
                                    set_visible: model.show_favorites(),
                                },

                                #[name = "all_label"]
                                gtk::Label {
                                    set_label: &gettext("All Repositories"),
                                    add_css_class: "heading",
                                    add_css_class: "dim-label",
                                    set_halign: gtk::Align::Start,
                                    #[watch]
                                    set_visible: model.show_all_label(),
                                },

                                #[local_ref]
                                repo_list -> gtk::ListBox {
                                    set_selection_mode: gtk::SelectionMode::None,
                                    add_css_class: "boxed-list",
                                    #[watch]
                                    set_visible: !model.repo_factory.is_empty(),
                                },

                                #[name = "empty_filter_label"]
                                gtk::Label {
                                    set_label: &gettext("Ook? No repositories match your search."),
                                    add_css_class: "dim-label",
                                    set_halign: gtk::Align::Center,
                                    add_css_class: "mt-l",
                                    #[watch]
                                    set_visible: model.repo_factory.is_empty()
                                        && model.favorites_factory.is_empty()
                                        && !model.search_text.is_empty(),
                                },

                                gtk::ListBox {
                                    set_selection_mode: gtk::SelectionMode::None,
                                    add_css_class: "boxed-list",

                                    adw::ActionRow {
                                        set_title: &gettext("Add Repository…"),
                                        set_activatable: true,
                                        add_prefix = &gtk::Image {
                                            set_icon_name: Some("list-add-symbolic"),
                                        },
                                        connect_activated => StartScreenMsg::AddRepository,
                                    },

                                    adw::ActionRow {
                                        set_title: &gettext("Init Repository…"),
                                        set_activatable: true,
                                        add_prefix = &gtk::Image {
                                            set_icon_name: Some("folder-new-symbolic"),
                                        },
                                        connect_activated => StartScreenMsg::InitRepository,
                                    },

                                    adw::ActionRow {
                                        set_title: &gettext("Clone Repository…"),
                                        set_activatable: true,
                                        add_prefix = &gtk::Image {
                                            set_icon_name: Some("folder-download-symbolic"),
                                        },
                                        connect_activated => StartScreenMsg::CloneRepository,
                                    },
                                },
                            },
                        },
                    },
                },
            },
        }
    }

    fn init(init: Self::Init, root: Self::Root, sender: ComponentSender<Self>) -> ComponentParts<Self> {
        let (repositories, load_warning) = repository::load_repositories();

        let settings = preferences::app_settings();
        let sort_mode = SortMode::from_settings(&settings.string("start-screen-sort"));

        let repo_factory = FactoryVecDeque::builder()
            .launch(gtk::ListBox::default())
            .forward(sender.input_sender(), Self::forward_row);

        let favorites_factory = FactoryVecDeque::builder()
            .launch(gtk::ListBox::default())
            .forward(sender.input_sender(), Self::forward_row);

        let toast_overlay = adw::ToastOverlay::new();
        if let Some(warning) = load_warning {
            toast_overlay.add_toast(adw::Toast::new(&warning));
        }

        let subscription_id = subscribe(sender.input_sender().clone());

        let has_repos = !repositories.is_empty();
        let model = StartScreen {
            repositories,
            repo_factory,
            favorites_factory,
            removed_repos: vec![],
            search_text: String::new(),
            sort_mode,
            status_cache: HashMap::new(),
            status_epoch: 0,
            toast_overlay: toast_overlay.clone(),
            has_repos,
            subscription_id,
            search_debounce: None,
            confirm_dialog: None,
            clone_dialog: None,
            clone_progress_dialog: None,
            clone_cancel_sender: None,
            clone_cancelled: false,
            folder_select_supported: init.folder_select_supported,
            text_prompt_dialog: None,
        };

        let repo_list = model.repo_factory.widget();
        let favorites_list = model.favorites_factory.widget();
        let widgets = view_output!();

        let sort_action = settings.create_action("start-screen-sort");
        let action_group = gtk::gio::SimpleActionGroup::new();
        action_group.add_action(&sort_action);
        root.insert_action_group("start", Some(&action_group));

        let s = sender.clone();
        settings.connect_changed(Some("start-screen-sort"), move |gs, _| {
            let mode = SortMode::from_settings(&gs.string("start-screen-sort"));
            s.input(StartScreenMsg::SortModeChanged(mode));
        });

        let initial_search = settings.boolean("start-screen-search-visible");
        widgets.search_bar.set_search_mode(initial_search);
        let gs_clone = settings.clone();
        widgets
            .search_bar
            .connect_search_mode_enabled_notify(move |bar: &gtk::SearchBar| {
                let _ = gs_clone.set_boolean("start-screen-search-visible", bar.is_search_mode());
            });

        widgets
            .stack
            .set_visible_child_name(StackPage::for_store(&model.repositories).as_str());

        let _ = sender.output(StartScreenOutput::HasReposChanged(model.has_repos));

        sender.input(StartScreenMsg::Rebuild);

        ComponentParts { model, widgets }
    }

    fn update_with_view(
        &mut self,
        widgets: &mut Self::Widgets,
        msg: Self::Input,
        sender: ComponentSender<Self>,
        root: &Self::Root,
    ) {
        match msg {
            StartScreenMsg::AddRepository => self.handle_add_repository(root, &sender),

            StartScreenMsg::InitRepository => self.handle_init_repository(root, &sender),

            StartScreenMsg::RepositorySelected(path) => self.handle_repository_selected(path, root, &sender),

            StartScreenMsg::BulkRepositoriesSelected(paths) => {
                self.handle_bulk_repositories_selected(paths, root, &sender);
            }

            StartScreenMsg::CloneRepository => self.handle_clone_repository(root, &sender),

            StartScreenMsg::CloneConfirmed { url, destination } => {
                self.clone_dialog = None;

                let window = root.ancestor(gtk::Window::static_type()).and_downcast::<gtk::Window>();
                let dest_clone = destination.clone();

                self.clone_progress_dialog = Some(
                    CloneProgressDialog::builder()
                        .launch(CloneProgressDialogInit {
                            parent_window: window,
                            destination: dest_clone,
                        })
                        .forward(sender.input_sender(), |output| match output {
                            CloneProgressDialogOutput::Succeeded(path) => StartScreenMsg::CloneFinished(path),
                            CloneProgressDialogOutput::Failed(msg) => StartScreenMsg::CloneFailed(msg),
                            CloneProgressDialogOutput::Cancelled => StartScreenMsg::CloneCancelled,
                        }),
                );

                let (cancel_tx, cancel_rx) = std::sync::mpsc::channel::<()>();
                self.clone_cancel_sender = Some(cancel_tx);

                let input_sender = sender.input_sender().clone();
                let git_command = preferences::git_command();
                sender.spawn_oneshot_command(move || {
                    let mut last_progress_update = std::time::Instant::now();
                    let result = git::clone_repository(&url, &destination, &git_command, move |progress| {
                        if last_progress_update.elapsed() >= std::time::Duration::from_millis(100) {
                            let _ = input_sender.send(StartScreenMsg::CloneProgress(progress));
                            last_progress_update = std::time::Instant::now();
                        }

                        cancel_rx.try_recv().is_err()
                    });

                    match result {
                        Ok(()) => StartScreenMsg::CloneWorkerDone(destination),

                        Err(msg) => {
                            let _ = std::fs::remove_dir_all(&destination);
                            StartScreenMsg::CloneWorkerFailed(msg)
                        }
                    }
                });
            }

            StartScreenMsg::CloneProgress(progress) => {
                if let Some(ref dialog) = self.clone_progress_dialog {
                    dialog.emit(CloneProgressDialogMsg::UpdateProgress(progress));
                }
            }

            StartScreenMsg::CloneWorkerDone(path) => {
                self.clone_cancel_sender = None;

                if let Some(ref dialog) = self.clone_progress_dialog {
                    dialog.emit(CloneProgressDialogMsg::Finished);
                } else {
                    sender.input(StartScreenMsg::AddConfirmed(path));
                }
            }

            StartScreenMsg::CloneWorkerFailed(msg) => {
                self.clone_cancel_sender = None;

                if self.clone_cancelled {
                    self.clone_cancelled = false;
                } else if let Some(ref dialog) = self.clone_progress_dialog {
                    dialog.emit(CloneProgressDialogMsg::Failed(msg));
                } else {
                    tracing::error!("git clone failed: {msg}");
                    let window = root.ancestor(gtk::Window::static_type()).and_downcast::<gtk::Window>();
                    ui_helpers::show_error_dialog(window.as_ref(), &gettext("Clone Failed"), &msg);
                }
            }

            StartScreenMsg::CloneFinished(path) => {
                self.clone_progress_dialog = None;
                sender.input(StartScreenMsg::AddConfirmed(path));
            }

            StartScreenMsg::CloneFailed(msg) => {
                self.clone_progress_dialog = None;
                tracing::error!("git clone failed: {msg}");
                let window = root.ancestor(gtk::Window::static_type()).and_downcast::<gtk::Window>();

                ui_helpers::show_error_dialog(window.as_ref(), &gettext("Clone Failed"), &msg);
            }

            StartScreenMsg::CloneCancelled => {
                self.clone_progress_dialog = None;
                self.clone_cancelled = true;

                if let Some(cancel_tx) = self.clone_cancel_sender.take() {
                    let _ = cancel_tx.send(());
                }
            }

            StartScreenMsg::CloneDialogDismissed => {
                self.clone_dialog = None;
            }

            StartScreenMsg::ConfirmDialogDismissed => {
                self.confirm_dialog = None;
            }

            StartScreenMsg::InitRepoConfirmed(path) => {
                self.confirm_dialog = None;

                match git::init_repository(&path) {
                    Ok(()) => sender.input(StartScreenMsg::AddConfirmed(path)),
                    Err(msg) => {
                        tracing::error!("git init failed: {msg}");
                        let window = root.ancestor(gtk::Window::static_type()).and_downcast::<gtk::Window>();
                        ui_helpers::show_error_dialog(window.as_ref(), &gettext("Git Initialization Failed"), &msg);
                    }
                }
            }

            StartScreenMsg::AddConfirmed(path) => {
                self.handle_add_confirmed(path);
                self.sync_has_repos(&sender);
                sender.input(StartScreenMsg::Rebuild);
            }

            StartScreenMsg::RemoveRepository(id) => {
                self.handle_remove_repository(&id, &sender);
                self.sync_has_repos(&sender);
                sender.input(StartScreenMsg::Rebuild);
            }

            StartScreenMsg::RenameRepository(id, name) => {
                self.handle_rename_repository(&id, name);
                sender.input(StartScreenMsg::Rebuild);
            }

            StartScreenMsg::UndoRemove => {
                self.handle_undo_remove();
                self.sync_has_repos(&sender);
                sender.input(StartScreenMsg::Rebuild);
            }

            StartScreenMsg::OpenRepository { path, id, name } => {
                sender.output(StartScreenOutput::OpenRepository { path, id, name }).ok();
            }

            StartScreenMsg::OpenPath(path) => self.handle_open_path(path, root, &sender),

            StartScreenMsg::ToggleFavorite(id) => {
                self.handle_toggle_favorite(&id);
                sender.input(StartScreenMsg::Rebuild);
            }

            StartScreenMsg::MoveRepo {
                dragged_id,
                target_id,
                before,
            } => {
                self.handle_move_repo(&dragged_id, &target_id, before);
                sender.input(StartScreenMsg::Rebuild);
            }

            StartScreenMsg::FocusSearch => {
                widgets.search_bar.set_search_mode(true);
                widgets.search_entry.grab_focus();
            }

            StartScreenMsg::SearchChanged(text) => {
                self.search_text = text;
                self.schedule_search_rebuild(&sender);
            }

            StartScreenMsg::SearchDebounceFired => {
                self.search_debounce = None;
                self.rebuild_lists(&sender);
            }

            StartScreenMsg::SortModeChanged(mode) => {
                if self.sort_mode != mode {
                    self.sort_mode = mode;
                    sender.input(StartScreenMsg::Rebuild);
                }
            }

            StartScreenMsg::Rebuild => {
                self.rebuild_lists(&sender);
            }

            StartScreenMsg::Reload => {
                let (store, warning) = repository::load_repositories();
                self.repositories = store;
                if let Some(w) = warning {
                    self.toast_overlay.add_toast(adw::Toast::new(&w));
                }
                self.status_cache.clear();
                self.status_epoch = self.status_epoch.wrapping_add(1);
                self.sync_has_repos(&sender);
                sender.input(StartScreenMsg::Rebuild);
            }

            StartScreenMsg::RepoOpenedLocal { id, ts } => {
                if self.repositories.set_last_opened(&id, ts) {
                    self.repositories.save_or_warn();
                    broadcast_repo_opened(id, ts, Some(self.subscription_id));
                    sender.input(StartScreenMsg::Rebuild);
                }
            }

            StartScreenMsg::RepoOpened(id, ts) => {
                if self.repositories.set_last_opened(&id, ts) {
                    sender.input(StartScreenMsg::Rebuild);
                }
            }

            StartScreenMsg::StatusLoaded(epoch, id, status) => {
                if epoch == self.status_epoch && self.repositories.index_of_id(&id).is_some() {
                    self.status_cache.insert(id.clone(), Some(status.clone()));
                    self.dispatch_status_to_rows(&id, status);
                }
            }
        }

        widgets
            .stack
            .set_visible_child_name(StackPage::for_store(&self.repositories).as_str());

        self.update_view(widgets, sender);
    }

    fn shutdown(&mut self, _widgets: &mut Self::Widgets, _output: relm4::Sender<Self::Output>) {
        unsubscribe(self.subscription_id);
        if let Some(source) = self.search_debounce.take() {
            source.remove();
        }
    }

    fn update_cmd(&mut self, msg: Self::CommandOutput, sender: ComponentSender<Self>, _root: &Self::Root) {
        sender.input(msg);
    }
}

impl StartScreen {
    fn show_favorites(&self) -> bool {
        !self.favorites_factory.is_empty()
    }

    fn show_all_label(&self) -> bool {
        !self.favorites_factory.is_empty() && !self.repo_factory.is_empty()
    }

    fn sync_has_repos(&mut self, sender: &ComponentSender<Self>) {
        let has = !self.repositories.is_empty();
        if self.has_repos != has {
            self.has_repos = has;
            let _ = sender.output(StartScreenOutput::HasReposChanged(has));
        }
    }

    fn schedule_search_rebuild(&mut self, sender: &ComponentSender<Self>) {
        if let Some(source) = self.search_debounce.take() {
            source.remove();
        }

        let s = sender.clone();
        let id = glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || {
            s.input(StartScreenMsg::SearchDebounceFired);
        });
        self.search_debounce = Some(id);
    }

    fn rebuild_lists(&mut self, sender: &ComponentSender<Self>) {
        let manual = self.sort_mode == SortMode::Manual;
        let search = self.search_text.trim().to_lowercase();

        let mut filtered: Vec<&Repository> = self
            .repositories
            .entries
            .iter()
            .filter(|r| {
                if search.is_empty() {
                    return true;
                }

                r.name.to_lowercase().contains(&search) || r.path.to_string_lossy().to_lowercase().contains(&search)
            })
            .collect();

        match self.sort_mode {
            SortMode::Manual => {
                // already in correct order
            }

            SortMode::Name => {
                filtered.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
            }

            SortMode::LastOpened => {
                filtered.sort_by(|a, b| b.last_opened.cmp(&a.last_opened));
            }
        }

        let (favorites, regular): (Vec<&Repository>, Vec<&Repository>) = filtered.into_iter().partition(|r| r.favorite);

        {
            let mut guard = self.favorites_factory.guard();
            guard.clear();

            for repo in &favorites {
                guard.push_back(RepoRowInit {
                    repository: (*repo).clone(),
                    draggable: false,
                });
            }
        }

        {
            let mut guard = self.repo_factory.guard();
            guard.clear();

            for repo in &regular {
                guard.push_back(RepoRowInit {
                    repository: (*repo).clone(),
                    draggable: manual && search.is_empty(),
                });
            }
        }

        let to_load: Vec<(String, PathBuf)> = self
            .repositories
            .entries
            .iter()
            .filter(|r| !self.status_cache.contains_key(&r.id))
            .map(|r| (r.id.clone(), r.path.clone()))
            .collect();

        let epoch = self.status_epoch;
        for (id, path) in to_load {
            self.status_cache.insert(id.clone(), None);
            sender.spawn_oneshot_command(move || StartScreenMsg::StatusLoaded(epoch, id, git::quick_status(&path)));
        }

        let cached: Vec<(String, QuickStatus)> = self
            .status_cache
            .iter()
            .filter_map(|(id, st)| st.as_ref().map(|s| (id.clone(), s.clone())))
            .collect();

        for (id, status) in cached {
            self.dispatch_status_to_rows(&id, status);
        }
    }

    fn dispatch_status_to_rows(&mut self, id: &str, status: QuickStatus) {
        for i in 0..self.favorites_factory.len() {
            if let Some(row) = self.favorites_factory.get(i)
                && row.id() == id
            {
                self.favorites_factory.send(i, RepoRowInput::SetStatus(status.clone()));
                return;
            }
        }

        for i in 0..self.repo_factory.len() {
            if let Some(row) = self.repo_factory.get(i)
                && row.id() == id
            {
                self.repo_factory.send(i, RepoRowInput::SetStatus(status));
                return;
            }
        }
    }

    fn handle_add_repository(&mut self, root: &adw::ToastOverlay, sender: &ComponentSender<Self>) {
        let window = root.ancestor(gtk::Window::static_type()).and_downcast::<gtk::Window>();

        if !self.folder_select_supported {
            self.text_prompt_dialog = Some(
                TextPromptDialog::builder()
                    .launch(TextPromptDialogInit {
                        heading: gettext("Add Repository"),
                        body: gettext("Enter the absolute path to the Git repository."),
                        placeholder: "/path/to/repository".to_string(),
                        initial: String::new(),
                        confirm_label: gettext("Add"),
                        parent_window: window,
                        validator: Some(|s| std::path::Path::new(s.trim()).is_dir()),
                        notice: Some(portal::fallback_notice()),
                    })
                    .forward(sender.input_sender(), |output| match output {
                        TextPromptDialogOutput::Confirmed(text) => {
                            StartScreenMsg::RepositorySelected(PathBuf::from(text.trim()))
                        }

                        TextPromptDialogOutput::Cancelled => StartScreenMsg::ConfirmDialogDismissed,
                    }),
            );

            return;
        }

        let dialog = gtk::FileDialog::builder()
            .title(gettext("Select Git Repositories"))
            .modal(true)
            .build();

        let sender = sender.clone();
        dialog.select_multiple_folders(window.as_ref(), None::<&gtk::gio::Cancellable>, move |result| match result {
            Ok(list) => {
                let mut paths: Vec<PathBuf> = Vec::with_capacity(list.n_items() as usize);
                for i in 0..list.n_items() {
                    if let Some(file) = list.item(i).and_downcast::<gtk::gio::File>()
                        && let Some(path) = file.path()
                    {
                        paths.push(path);
                    }
                }

                match paths.len() {
                    0 => {}

                    1 => {
                        if let Some(path) = paths.pop() {
                            sender.input(StartScreenMsg::RepositorySelected(path));
                        }
                    }

                    _ => sender.input(StartScreenMsg::BulkRepositoriesSelected(paths)),
                }
            }

            Err(err) if err.matches(gtk::DialogError::Dismissed) => {}

            Err(err) => {
                tracing::error!("File dialog error: {err}");
            }
        });
    }

    fn handle_bulk_repositories_selected(
        &mut self,
        paths: Vec<PathBuf>,
        _root: &adw::ToastOverlay,
        sender: &ComponentSender<Self>,
    ) {
        let mut added = 0usize;
        let mut skipped_duplicate = 0usize;
        let mut skipped_non_git = 0usize;

        for path in paths {
            if self.repositories.contains_path(&path) {
                skipped_duplicate += 1;
                continue;
            }

            if git::is_git_repository(&path) {
                self.handle_add_confirmed(path);
                added += 1;
            } else {
                skipped_non_git += 1;
            }
        }

        if added > 0 {
            self.sync_has_repos(sender);
            sender.input(StartScreenMsg::Rebuild);
        }

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

        if added > 0 {
            parts.push(
                gettextrs::ngettext("%d repository added", "%d repositories added", added as u32)
                    .replace("%d", &added.to_string()),
            );
        }

        if skipped_non_git > 0 {
            parts.push(
                gettextrs::ngettext(
                    "%d folder skipped (not a Git repository)",
                    "%d folders skipped (not Git repositories)",
                    skipped_non_git as u32,
                )
                .replace("%d", &skipped_non_git.to_string()),
            );
        }

        if skipped_duplicate > 0 {
            parts.push(
                gettextrs::ngettext("%d already in list", "%d already in list", skipped_duplicate as u32)
                    .replace("%d", &skipped_duplicate.to_string()),
            );
        }

        if !parts.is_empty() {
            self.toast_overlay.add_toast(adw::Toast::new(&parts.join(" · ")));
        }
    }

    fn handle_init_repository(&mut self, root: &adw::ToastOverlay, sender: &ComponentSender<Self>) {
        let window = root.ancestor(gtk::Window::static_type()).and_downcast::<gtk::Window>();

        if !self.folder_select_supported {
            self.text_prompt_dialog = Some(
                TextPromptDialog::builder()
                    .launch(TextPromptDialogInit {
                        heading: gettext("Initialize Repository"),
                        body: gettext(
                            "Enter the absolute path where the Git repository should be initialized. The directory \
                             may exist, but it must not already be a Git repository.",
                        ),
                        placeholder: "/path/to/new-repository".to_string(),
                        initial: String::new(),
                        confirm_label: gettext("Initialize"),
                        parent_window: window,
                        validator: Some(|s| {
                            let p = std::path::Path::new(s.trim());
                            if !p.is_absolute() {
                                return false;
                            }
                            if p.exists() {
                                p.is_dir() && !p.join(".git").exists()
                            } else {
                                p.parent().is_some_and(std::path::Path::exists)
                            }
                        }),
                        notice: Some(portal::fallback_notice()),
                    })
                    .forward(sender.input_sender(), |output| match output {
                        TextPromptDialogOutput::Confirmed(text) => {
                            StartScreenMsg::InitRepoConfirmed(PathBuf::from(text.trim()))
                        }

                        TextPromptDialogOutput::Cancelled => StartScreenMsg::ConfirmDialogDismissed,
                    }),
            );

            return;
        }

        let dialog = gtk::FileDialog::builder()
            .title(gettext("Initialize a Git Repository"))
            .modal(true)
            .build();

        let sender = sender.clone();
        let win = window.clone();

        dialog.select_folder(window.as_ref(), None::<&gtk::gio::Cancellable>, move |result| match result {
            Ok(file) => {
                if let Some(path) = file.path() {
                    match git::init_repository(&path) {
                        Ok(()) => sender.input(StartScreenMsg::AddConfirmed(path)),

                        Err(msg) => {
                            tracing::error!("git init failed: {msg}");
                            ui_helpers::show_error_dialog(win.as_ref(), &gettext("Git Initialization Failed"), &msg);
                        }
                    }
                }
            }

            Err(err) if err.matches(gtk::DialogError::Dismissed) => {}

            Err(err) => {
                tracing::error!("File dialog error: {err}");
            }
        });
    }

    fn handle_repository_selected(&mut self, path: PathBuf, root: &adw::ToastOverlay, sender: &ComponentSender<Self>) {
        if self.repositories.contains_path(&path) {
            tracing::info!("Repository already added: {}", path.to_string_lossy());
            return;
        }

        if git::is_git_repository(&path) {
            sender.input(StartScreenMsg::AddConfirmed(path));
        } else {
            let window = root.ancestor(gtk::Window::static_type()).and_downcast::<gtk::Window>();
            let p = path.clone();

            self.confirm_dialog = Some(
                ConfirmDialog::builder()
                    .launch(ConfirmDialogInit {
                        heading: gettext("Not a Git Repository"),
                        body: gettext("The selected folder is not a Git repository. Would you like to initialize one?"),
                        action_label: gettext("Initialize"),
                        appearance: adw::ResponseAppearance::Suggested,
                        parent_window: window,
                    })
                    .forward(sender.input_sender(), move |output| match output {
                        ConfirmDialogOutput::Confirmed => StartScreenMsg::InitRepoConfirmed(p.clone()),
                        ConfirmDialogOutput::Cancelled => StartScreenMsg::ConfirmDialogDismissed,
                    }),
            );
        }
    }

    fn handle_clone_repository(&mut self, root: &adw::ToastOverlay, sender: &ComponentSender<Self>) {
        let window = root.ancestor(gtk::Window::static_type()).and_downcast::<gtk::Window>();

        self.clone_dialog = Some(
            CloneDialog::builder()
                .launch(CloneDialogInit {
                    parent_window: window,
                    folder_select_supported: self.folder_select_supported,
                })
                .forward(sender.input_sender(), |output| match output {
                    CloneDialogOutput::Cancelled => StartScreenMsg::CloneDialogDismissed,

                    CloneDialogOutput::Confirmed { url, destination } => {
                        StartScreenMsg::CloneConfirmed { url, destination }
                    }
                }),
        );
    }

    fn handle_add_confirmed(&mut self, path: PathBuf) {
        let name = repository::display_name(&path);

        let repo = Repository {
            id: uuid::Uuid::new_v4().to_string(),
            name,
            path,
            favorite: false,
            last_opened: None,
        };
        self.repositories.add(repo);
    }

    fn handle_remove_repository(&mut self, id: &str, sender: &ComponentSender<Self>) {
        let Some(store_idx) = self.repositories.index_of_id(id) else {
            return;
        };

        let removed = self.repositories.remove(store_idx);
        self.removed_repos.push((store_idx, removed));
        self.status_cache.remove(id);

        let toast = adw::Toast::builder()
            .title(gettext("Repository removed"))
            .button_label(gettext("Undo"))
            .build();

        let s = sender.clone();
        toast.connect_button_clicked(move |_| {
            s.input(StartScreenMsg::UndoRemove);
        });

        self.toast_overlay.add_toast(toast);
    }

    fn handle_rename_repository(&mut self, id: &str, name: String) {
        let Some(store_idx) = self.repositories.index_of_id(id) else {
            return;
        };

        self.repositories.rename(store_idx, name);
    }

    fn handle_toggle_favorite(&mut self, id: &str) {
        let Some(store_idx) = self.repositories.index_of_id(id) else {
            return;
        };

        let current = self.repositories.entries[store_idx].favorite;
        self.repositories.set_favorite(store_idx, !current);
    }

    fn handle_move_repo(&mut self, dragged_id: &str, target_id: &str, before: bool) {
        if self.sort_mode != SortMode::Manual {
            return;
        }

        let Some(from) = self.repositories.index_of_id(dragged_id) else {
            return;
        };

        let Some(target) = self.repositories.index_of_id(target_id) else {
            return;
        };

        let mut to = if before { target } else { target + 1 };
        if from < to {
            to -= 1;
        }

        self.repositories.move_repo(from, to);
    }

    fn handle_undo_remove(&mut self) {
        if let Some((idx, repo)) = self.removed_repos.pop() {
            self.repositories.insert(idx, repo);
        }
    }

    fn handle_open_path(&mut self, path: PathBuf, _root: &adw::ToastOverlay, sender: &ComponentSender<Self>) {
        let path = path.canonicalize().unwrap_or(path);

        if let Some(repo) = self.repositories.entries.iter().find(|r| r.path == path) {
            let id = repo.id.clone();
            let name = repo.name.clone();
            sender.output(StartScreenOutput::OpenRepository { path, id, name }).ok();

            return;
        }

        if !git::is_git_repository(&path) {
            self.toast_overlay.add_toast(adw::Toast::new(
                &gettext("Path is not a Git repository").replace("%s", &path.to_string_lossy()),
            ));

            return;
        }

        self.handle_add_confirmed(path.clone());
        self.sync_has_repos(sender);
        sender.input(StartScreenMsg::Rebuild);

        if let Some(repo) = self.repositories.entries.iter().find(|r| r.path == path) {
            let id = repo.id.clone();
            let name = repo.name.clone();
            sender.output(StartScreenOutput::OpenRepository { path, id, name }).ok();
        }
    }
}
