//! The app's confirmation prompts, as free render functions plus the
//! `*_ui` drivers `update()` calls in its documented order.

use super::*;

use crate::ui_util::{centered_modal, hint};

/// A button (or Esc/backdrop click) in the unsaved-changes modal.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum UnsavedChoice {
    Apply,
    Discard,
    Cancel,
}

impl QuickSearchApp {
    pub(super) fn rebuild_prompt_ui(&mut self, ctx: &egui::Context) {
        let Some(changes) = &self.rebuild_prompt else {
            return;
        };
        let changes = changes.clone();
        let mut close = false;
        // Not `centered_modal`: the columns below need `default_width`.
        egui::Window::new("Rebuild index?")
            .collapsible(false)
            .resizable(false)
            .default_width(560.0)
            .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
            .show(ctx, |ui| {
                ui.label("These settings differ from what the index was built with:");
                ui.add_space(4.0);
                if changes.is_empty() {
                    ui.monospace("the index cannot be read with the new settings");
                }
                for change in &changes {
                    ui.strong(format!("{}:", change.key));
                    ui.columns(2, |cols| {
                        cols[0].label(hint("index was built with"));
                        cols[0].monospace(display_value(&change.stored));
                        cols[1].label(hint("config now says"));
                        cols[1].monospace(display_value(&change.current));
                    });
                    ui.add_space(6.0);
                }
                ui.label(hint(
                    "Unlike folders, filters and hidden files — which are applied \
                     to the existing index in place — these cannot be, so the \
                     index has to be built again. Until it is, existing entries \
                     keep the old settings.",
                ));
                ui.horizontal(|ui| {
                    if ui.button("Rebuild now").clicked() {
                        self.backend.rebuild_index();
                        close = true;
                    }
                    if ui.button("Later").clicked() {
                        close = true;
                    }
                });
            });
        if close {
            self.rebuild_prompt = None;
        }
    }

    pub(super) fn nested_prompt_ui(&mut self, ctx: &egui::Context) {
        let Some(pairs) = &self.nested_prompt else {
            return;
        };
        let pairs = pairs.clone();
        let close = centered_modal(ctx, "Indexed folders may not be nested", |ui| {
            ui.label(
                "Each root is indexed by its own worker pool, so one root \
                 may not contain another. Fix the folder list below:",
            );
            for (child, parent) in &pairs {
                ui.monospace(format!("{}  ⊂  {}", child, parent));
            }
            ui.label(hint(
                "Indexing stays paused until the overlap is removed and \
                 the list is applied.",
            ));
            ui.button("Fix folders").clicked()
        });
        if close == Some(true) {
            self.nested_prompt = None;
            self.switch_tab(ctx, Tab::Manage);
        }
    }

    /// Tell the user their index is being replaced because it belongs to an
    /// older version. The button starts the rebuild rather than merely
    /// dismissing — in manual mode nothing else would.
    pub(super) fn stale_index_prompt_ui(&mut self, ctx: &egui::Context) {
        if !self.stale_index_prompt {
            return;
        }
        if stale_index_window(ctx, self.key_source) {
            self.stale_index_prompt = false;
            self.backend.rebuild_index();
            self.dups.state = DupState::NotLoaded;
        }
    }

    /// Tell the user their settings have not reached the index. Work still
    /// owed means a pass was abandoned or the config was edited while the
    /// app was closed. Hidden while a run or reconcile is in progress.
    pub(super) fn reconcile_owed_ui(&mut self, ctx: &egui::Context) {
        if !self.reconcile_owed {
            return;
        }
        let state = self.backend.coordinator.state();
        // A completed run reconciles from the same record and stamps it; a
        // run the user stops does not move this.
        if state.last_full_index > self.reconcile_owed_since {
            self.reconcile_owed = false;
            return;
        }
        if !matches!(
            state.activity,
            IndexingStatus::Idle | IndexingStatus::Error(_)
        ) || state.reconcile.is_some()
        {
            return;
        }
        match reconcile_owed_banner(ctx) {
            None => {}
            Some(ReconcileOwedChoice::StartIndexing) => {
                self.backend.coordinator.reindex_now();
                // Not cleared here: the run that finishes clears it, and one
                // that is stopped half-way leaves the reminder standing.
                ctx.request_repaint_after(Duration::from_millis(100));
            }
            Some(ReconcileOwedChoice::Dismiss) => self.reconcile_owed = false,
        }
    }

    pub(super) fn watch_cap_prompt_ui(&mut self, ctx: &egui::Context) {
        let Some(reason) = &self.watch_cap_prompt else {
            return;
        };
        let reason = reason.clone();
        let close = centered_modal(ctx, "Live index updating is disabled", |ui| {
            ui.set_max_width(420.0);
            match &reason {
                WatchError::TooManyDirectories { cap, .. } => {
                    ui.label(format!(
                        "Your indexed folders contain more than {} directories. The \
                         system limits how many folders can be watched for changes at \
                         once, so QuickSearch cannot update the index as files change.",
                        group_thousands(*cap as u64),
                    ));
                }
                WatchError::KernelLimit { registered } => {
                    ui.label(format!(
                        "The system ran out of folder watches after {} directories, so \
                         QuickSearch cannot update the index as files change.",
                        group_thousands(*registered as u64),
                    ));
                }
                // Not reachable through this modal today: an overflow leaves
                // the watcher running and schedules a full run instead of
                // disabling live updates, so it never becomes a
                // `WatcherStatus::Disabled`. Spelled out rather than folded
                // into a catch-all so that a future variant is a compile
                // error here, which is how this arm came to be written.
                WatchError::Overflowed => {
                    ui.label(
                        "The system dropped some change notifications, so the index \
                         is being rebuilt to catch up.",
                    );
                }
                WatchError::Other(msg) => {
                    ui.label(format!("Live updates are unavailable: {}", msg));
                }
            }
            ui.add_space(4.0);
            ui.label(format!(
                "The index is rebuilt every {} instead. Searches keep working; \
                 recent changes may take that long to appear.",
                fmt_interval(self.cfg.indexing.reindex_interval_minutes),
            ));
            ui.label(hint(
                "To restore live updates, index fewer folders or exclude large \
                 subfolders under Filters on the Manage Index tab.",
            ));
            ui.button("OK").clicked()
        });
        if close == Some(true) {
            self.watch_cap_prompt = None;
            for root in &self.cfg.paths.indexing_paths {
                if !self.cfg.ui.watch_cap_warned_roots.contains(root) {
                    self.cfg.ui.watch_cap_warned_roots.push(root.clone());
                }
            }
            if let Err(e) = self.cfg.save() {
                self.config_error = Some(e);
            }
        }
    }

    /// The first-start tour. Dismissal is written straight to the config, the
    /// way the fuzzy default is — not through the Settings draft, which this
    /// has nothing to do with.
    pub(super) fn tutorial_ui(&mut self, ctx: &egui::Context) {
        let Some(tour) = &mut self.tutorial else {
            return;
        };
        let roots = self.cfg.paths.indexing_paths.clone();
        if tour.ui(ctx, &roots) {
            self.tutorial = None;
            self.cfg.ui.tutorial_seen = Some(true);
            if let Err(e) = self.cfg.save() {
                self.config_error = Some(e);
            }
        }
    }

    /// Re-open the tour from the Help tab. Nothing is written until it is
    /// dismissed again, so a re-read costs the config nothing.
    pub(crate) fn show_tutorial(&mut self) {
        self.tutorial = Some(crate::tutorial::Tutorial::new());
    }

    pub(super) fn clear_prompt_ui(&mut self, ctx: &egui::Context) {
        if !self.clear_prompt {
            return;
        }
        let close = centered_modal(ctx, "Clear index?", |ui| {
            ui.label("This deletes the search index database. Your files are not touched.");
            ui.label(hint(
                "Indexing switches to manual until you start it again or return \
                 to automatic mode.",
            ));
            ui.horizontal(|ui| {
                let delete = egui::RichText::new("Delete index").color(ui.visuals().error_fg_color);
                if ui.button(delete).clicked() {
                    // Manual first, and persisted: automatic mode must not
                    // resurrect what was just deleted, nor the next launch
                    // undo the stop.
                    self.set_index_mode(false);
                    self.backend.clear_index();
                    self.dups.state = DupState::NotLoaded;
                    return true;
                }
                ui.button("Cancel").clicked()
            })
            .inner
        });
        if close == Some(true) {
            self.clear_prompt = false;
        }
    }

    /// Drive the unsaved-changes guard. Each frame the pending intent picks
    /// the editor to ask about; Apply and Discard clean one editor and the
    /// next frame moves on or falls through to the navigation. Cancel —
    /// button, Esc, or a backdrop click — drops the intent.
    pub(super) fn unsaved_prompt_ui(&mut self, ctx: &egui::Context) {
        let Some(intent) = self.pending_nav else {
            return;
        };
        let dirty = (self.manage.is_dirty(), self.settings.is_dirty(&self.cfg));
        // `self.tab` is the tab being left: the switch itself is what the
        // intent is holding back.
        let Some(source) = guard_source(intent, self.tab, dirty.0, dirty.1) else {
            // Inside the guard: the Discard-then-quit path sets
            // `quit_confirmed` and never returns to the close-request check,
            // so a warning living only there would be skipped.
            if quit_needs_reconcile_warning(intent, self.backend.coordinator.reconciling()) {
                // Repaint so a reconcile that ends takes the modal with it.
                ctx.request_repaint_after(Duration::from_millis(250));
                match reconcile_quit_modal(ctx) {
                    None => return,
                    Some(false) => {
                        self.pending_nav = None;
                        return;
                    }
                    Some(true) => {}
                }
            }
            return self.complete_nav(ctx, intent);
        };
        match unsaved_changes_modal(ctx, source) {
            None => {}
            Some(UnsavedChoice::Cancel) => self.pending_nav = None,
            Some(UnsavedChoice::Discard) => match source {
                UnsavedSource::Manage => self.manage.discard(),
                UnsavedSource::Settings => self.settings.discard(),
            },
            Some(UnsavedChoice::Apply) => {
                let ok = match source {
                    UnsavedSource::Manage => match self.manage.take_apply_config(&self.cfg) {
                        Some(cfg) => {
                            let ok = self.apply_new_config(ctx, cfg);
                            if ok {
                                self.manage.mark_applied();
                            }
                            ok
                        }
                        None => true,
                    },
                    UnsavedSource::Settings => match self.settings.draft_config() {
                        Some(cfg) => {
                            let ok = self.apply_new_config(ctx, cfg);
                            if ok {
                                self.settings.discard();
                            }
                            ok
                        }
                        None => true,
                    },
                };
                if !ok {
                    // Rejected (nested roots): stay put, keep the staged
                    // edits; the error banner explains what to fix.
                    self.pending_nav = None;
                }
            }
        }
    }

    /// Perform a navigation the guard has cleared.
    pub(super) fn complete_nav(&mut self, ctx: &egui::Context, intent: NavIntent) {
        self.pending_nav = None;
        match intent {
            NavIntent::SwitchTab(tab) => self.switch_tab(ctx, tab),
            NavIntent::Quit => {
                self.quit_confirmed = true;
                ctx.send_viewport_cmd(egui::ViewportCommand::Close);
            }
        }
    }
}

/// Body of the unsaved-changes guard; `Some(choice)` when the user decided
/// this frame. Esc and a click on the backdrop count as Cancel.
///
/// Unlike the centered `egui::Window` the other prompts use, `egui::Modal`'s
/// backdrop blocks input to everything behind it — a click landing on the
/// tab strip would re-trigger or bypass the guard.
fn unsaved_changes_modal(ctx: &egui::Context, source: UnsavedSource) -> Option<UnsavedChoice> {
    let mut choice = None;
    let modal = egui::Modal::new(egui::Id::new("unsaved-guard")).show(ctx, |ui| {
        ui.set_max_width(420.0);
        ui.heading("Unsaved changes");
        ui.label(match source {
            UnsavedSource::Manage => "The Manage Index tab has edits that have not been applied.",
            UnsavedSource::Settings => "The Settings tab has edits that have not been applied.",
        });
        ui.add_space(6.0);
        ui.horizontal(|ui| {
            if ui
                .add(crate::ui_util::bordered_button(
                    "Apply & Save",
                    palette(ui.visuals().dark_mode).blue,
                ))
                .clicked()
            {
                choice = Some(UnsavedChoice::Apply);
            }
            if ui
                .button(egui::RichText::new("Discard changes").color(ui.visuals().error_fg_color))
                .clicked()
            {
                choice = Some(UnsavedChoice::Discard);
            }
            if ui.button("Cancel").clicked() {
                choice = Some(UnsavedChoice::Cancel);
            }
        });
    });
    if choice.is_none() && modal.should_close() {
        choice = Some(UnsavedChoice::Cancel);
    }
    choice
}

/// What the user chose in the "settings not applied yet" banner.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum ReconcileOwedChoice {
    StartIndexing,
    Dismiss,
}

/// The banner's body, as a top panel under the config-error one.
fn reconcile_owed_banner(ctx: &egui::Context) -> Option<ReconcileOwedChoice> {
    let mut choice = None;
    egui::TopBottomPanel::top("reconcile-owed").show(ctx, |ui| {
        ui.horizontal(|ui| {
            ui.colored_label(
                ui.visuals().warn_fg_color,
                "Your indexing settings have not been applied to the index yet.",
            );
            if ui.small_button("Start indexing now").clicked() {
                choice = Some(ReconcileOwedChoice::StartIndexing);
            }
            if ui.small_button("Dismiss").clicked() {
                choice = Some(ReconcileOwedChoice::Dismiss);
            }
        });
    });
    choice
}

/// Body of the quit-during-a-reconcile guard; `Some(true)` to quit anyway,
/// `Some(false)` to stay. Esc and a backdrop click count as staying.
fn reconcile_quit_modal(ctx: &egui::Context) -> Option<bool> {
    let mut choice = None;
    let modal = egui::Modal::new(egui::Id::new("reconcile-quit-guard")).show(ctx, |ui| {
        ui.set_max_width(460.0);
        ui.heading("Settings are still being applied");
        ui.label(
            "QuickSearch is still applying your indexing settings to the index. If you \
             quit now it stops part-way, and entries you excluded can still turn up in \
             search results.",
        );
        ui.add_space(4.0);
        ui.label(
            "Nothing is lost: the next indexing run picks the work up again. In manual \
             mode, choose \"Start indexing now\" on the Manage Index tab after the next \
             launch.",
        );
        ui.add_space(6.0);
        ui.horizontal(|ui| {
            if ui
                .button(egui::RichText::new("Quit anyway").color(ui.visuals().error_fg_color))
                .clicked()
            {
                choice = Some(true);
            }
            if ui
                .add(crate::ui_util::bordered_button(
                    "Cancel",
                    palette(ui.visuals().dark_mode).blue,
                ))
                .clicked()
            {
                choice = Some(false);
            }
        });
    });
    if choice.is_none() && modal.should_close() {
        choice = Some(false);
    }
    choice
}

/// The stale-index window's body. Returns whether the user asked for the
/// rebuild.
fn stale_index_window(ctx: &egui::Context, key_source: KeySource) -> bool {
    centered_modal(ctx, "Index reset for this version", |ui| {
        ui.set_max_width(440.0);
        ui.label(
            "Your search index was created by an older version of QuickSearch, \
             which this version cannot read. It is being reset and rebuilt from \
             scratch. Don't worry, this is very Quick!",
        );
        let reassurance = match key_source {
            KeySource::Unprotected => None,
            KeySource::Prompt => Some(
                "The rebuilt index is encrypted with the password you just \
                 entered, so it stays password protected.",
            ),
            KeySource::Keychain => Some(
                "The rebuilt index is encrypted with the key remembered on \
                 this device, so it stays password protected.",
            ),
        };
        if let Some(text) = reassurance {
            ui.add_space(4.0);
            ui.label(text);
        }
        ui.add_space(4.0);
        ui.label(hint(
            "Your files are not touched. Searches return incomplete results \
             until the rebuild finishes; progress is on the Manage Index tab.",
        ));
        ui.add_space(4.0);
        ui.button("Rebuild now").clicked()
    })
    .unwrap_or(false)
}

/// A stored/current config value for the rebuild prompt; list values are
/// already newline-joined and render as-is, empty means unset.
fn display_value(value: &str) -> String {
    if value.trim().is_empty() {
        "(none)".to_string()
    } else {
        value.to_string()
    }
}

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

    fn frame(ctx: &egui::Context, source: KeySource, events: Vec<egui::Event>) -> bool {
        let input = crate::test_ui::raw_input(SCREEN, events);
        let mut clicked = false;
        let _ = ctx.run(input, |ctx| clicked = stale_index_window(ctx, source));
        clicked
    }

    use crate::test_ui::click_at;

    /// The viewport every modal in this module is centred in.
    const SCREEN: egui::Vec2 = egui::vec2(1000.0, 700.0);

    /// The stale-index modal renders under every key source and its one
    /// button reports the click that starts the rebuild.
    #[test]
    fn the_stale_index_modal_renders_and_its_button_fires() {
        for source in [
            KeySource::Unprotected,
            KeySource::Prompt,
            KeySource::Keychain,
        ] {
            let ctx = crate::test_ui::ctx();
            assert!(
                !frame(&ctx, source, Vec::new()),
                "an untouched frame must not request a rebuild"
            );

            // Sweep for the button: the window's height depends on which
            // sentence is shown.
            let mut fired = None;
            'sweep: for y in (230..480).step_by(3) {
                for x in (250..760).step_by(6) {
                    let pos = egui::pos2(x as f32, y as f32);
                    if frame(&ctx, source, click_at(pos)) {
                        fired = Some(pos);
                        break 'sweep;
                    }
                }
            }
            assert!(
                fired.is_some(),
                "no clickable Rebuild button for {source:?}"
            );
        }
    }

    fn modal_frame(
        ctx: &egui::Context,
        source: UnsavedSource,
        events: Vec<egui::Event>,
    ) -> Option<UnsavedChoice> {
        let input = crate::test_ui::raw_input(SCREEN, events);
        let mut choice = None;
        let _ = ctx.run(input, |ctx| choice = unsaved_changes_modal(ctx, source));
        choice
    }

    /// Every way out of the guard reports the right choice: all three
    /// buttons fire, Esc cancels, and an untouched frame decides nothing.
    #[test]
    fn the_unsaved_modal_reports_each_choice() {
        for source in [UnsavedSource::Manage, UnsavedSource::Settings] {
            let ctx = crate::test_ui::ctx();
            assert_eq!(
                modal_frame(&ctx, source, Vec::new()),
                None,
                "an untouched frame must not decide"
            );

            let mut seen = std::collections::HashSet::new();
            for y in (250..450).step_by(3) {
                for x in (250..760).step_by(6) {
                    let pos = egui::pos2(x as f32, y as f32);
                    if let Some(choice) = modal_frame(&ctx, source, click_at(pos)) {
                        seen.insert(choice);
                    }
                }
            }
            for expected in [
                UnsavedChoice::Apply,
                UnsavedChoice::Discard,
                UnsavedChoice::Cancel,
            ] {
                assert!(
                    seen.contains(&expected),
                    "{expected:?} never fired ({source:?})"
                );
            }

            let esc = modal_frame(
                &ctx,
                source,
                vec![egui::Event::Key {
                    key: egui::Key::Escape,
                    physical_key: None,
                    pressed: true,
                    repeat: false,
                    modifiers: egui::Modifiers::NONE,
                }],
            );
            assert_eq!(esc, Some(UnsavedChoice::Cancel), "Esc must cancel");
        }
    }

    fn reconcile_modal_frame(ctx: &egui::Context, events: Vec<egui::Event>) -> Option<bool> {
        let input = crate::test_ui::raw_input(SCREEN, events);
        let mut choice = None;
        let _ = ctx.run(input, |ctx| choice = reconcile_quit_modal(ctx));
        choice
    }

    /// Both ways out of the quit warning work, and neither is the default.
    #[test]
    fn the_quit_warning_reports_both_answers() {
        let ctx = crate::test_ui::ctx();
        assert_eq!(
            reconcile_modal_frame(&ctx, Vec::new()),
            None,
            "an untouched frame must not decide"
        );

        let mut seen = std::collections::HashSet::new();
        for y in (250..450).step_by(3) {
            for x in (250..760).step_by(6) {
                if let Some(choice) =
                    reconcile_modal_frame(&ctx, click_at(egui::pos2(x as f32, y as f32)))
                {
                    seen.insert(choice);
                }
            }
        }
        assert!(seen.contains(&true), "\"Quit anyway\" never fired");
        assert!(seen.contains(&false), "Cancel never fired");

        let esc = reconcile_modal_frame(
            &ctx,
            vec![egui::Event::Key {
                key: egui::Key::Escape,
                physical_key: None,
                pressed: true,
                repeat: false,
                modifiers: egui::Modifiers::NONE,
            }],
        );
        assert_eq!(esc, Some(false), "Esc must keep the app open");
    }

    fn banner_frame(ctx: &egui::Context, events: Vec<egui::Event>) -> Option<ReconcileOwedChoice> {
        let input = crate::test_ui::raw_input(SCREEN, events);
        let mut choice = None;
        let _ = ctx.run(input, |ctx| choice = reconcile_owed_banner(ctx));
        choice
    }

    /// Both banner buttons report their clicks.
    #[test]
    fn the_reconcile_banner_reports_both_buttons() {
        let ctx = crate::test_ui::ctx();
        assert_eq!(banner_frame(&ctx, Vec::new()), None);

        let mut seen = std::collections::HashSet::new();
        // A top panel, so it sits in the first rows of the window.
        for y in (0..60).step_by(2) {
            for x in (0..1000).step_by(4) {
                if let Some(choice) = banner_frame(&ctx, click_at(egui::pos2(x as f32, y as f32))) {
                    seen.insert(choice);
                }
            }
        }
        assert!(
            seen.contains(&ReconcileOwedChoice::StartIndexing),
            "\"Start indexing now\" never fired"
        );
        assert!(
            seen.contains(&ReconcileOwedChoice::Dismiss),
            "Dismiss never fired"
        );
    }
}
