//! Normal mode handler: scrolling, jumping, yanking, mode transitions.

use log::debug;
use std::path::Path;

use crate::url::LinkTarget;

use super::effect::ExitReason;
use super::input_history::ScrollDirection;
use super::keymap::Action;
use super::layout::{ScrollState, visual_line_offset};
use super::mode_command::CommandState;
use super::mode_grep::{LastSearch, SearchDirection};
use super::mode_inline_search::InlineSearchState;
use super::mode_toc::{TocState, collect_headings};
use super::mode_url::{UrlPickerEntry, UrlPickerState, collect_all_url_entries};
use super::query::DocumentQuery;
use super::{Effect, ViewerMode};

/// Produce the effect(s) to open a classified link target.
///
/// Local markdown links are resolved against `current_file` and become
/// `Exit(Navigate)`; everything else becomes `OpenExternalUrl`.
pub(super) fn open_link_target(target: &LinkTarget, current_file: Option<&Path>) -> Vec<Effect> {
    match target {
        LinkTarget::LocalMarkdown(rel) => {
            match current_file.and_then(|f| crate::url::resolve_link_path(rel, f)) {
                Some(path) => vec![Effect::Exit(ExitReason::Navigate { path })],
                None => vec![Effect::OpenExternalUrl(rel.clone())],
            }
        }
        LinkTarget::ExternalUrl(url) => vec![Effect::OpenExternalUrl(url.clone())],
    }
}

pub(super) struct NormalCtx<'a> {
    pub scroll: &'a ScrollState,
    pub doc: &'a DocumentQuery<'a>,
    pub max_scroll: u32,
    pub scroll_step: u32,
    pub half_page: u32,
    pub last_search: &'a mut Option<LastSearch>,
    pub current_file: Option<&'a Path>,
    pub current_scale: f64,
}

/// Discrete zoom levels — `+`/`-` cycle through these.
/// Chrome-style asymmetric curve: finer steps near 100%, coarser at extremes.
/// Rationale and trial history: docs/design/zoom.md § "Preset Curve Design".
const ZOOM_PRESETS: &[f64] = &[0.50, 0.67, 0.85, 1.00, 1.10, 1.25, 1.50, 2.00];

/// Find the next preset in `dir` direction (1 = up, -1 = down).
/// If `current` is between presets, snaps to nearest in the chosen direction.
/// Returns current value if already at the edge.
fn next_zoom_preset(current: f64, dir: i32) -> f64 {
    debug_assert!(dir == 1 || dir == -1);
    // ±1e-9 excludes the current value so an exact-preset match advances rather than stalling
    if dir > 0 {
        ZOOM_PRESETS
            .iter()
            .copied()
            .find(|&p| p > current + 1e-9)
            .unwrap_or(current)
    } else {
        ZOOM_PRESETS
            .iter()
            .rev()
            .copied()
            .find(|&p| p < current - 1e-9)
            .unwrap_or(current)
    }
}

fn format_zoom(scale: f64) -> String {
    format!("zoom: {}%", (scale * 100.0).round() as i32)
}

fn zoom_effects(current: f64, target: f64) -> Vec<Effect> {
    if (target - current).abs() < 1e-9 {
        let label = if ZOOM_PRESETS
            .last()
            .is_some_and(|&m| (target - m).abs() < 1e-9)
        {
            "max"
        } else if ZOOM_PRESETS
            .first()
            .is_some_and(|&m| (target - m).abs() < 1e-9)
        {
            "min"
        } else {
            "no change"
        };
        vec![
            Effect::Flash(format!("{} ({label})", format_zoom(target))),
            Effect::RedrawStatusBar,
        ]
    } else {
        vec![Effect::Exit(ExitReason::SetScale {
            old: current,
            new: target,
            flash: Some(format_zoom(target)),
        })]
    }
}

pub(super) fn handle(action: Action, ctx: &mut NormalCtx) -> Vec<Effect> {
    match action {
        Action::Quit => vec![Effect::Exit(ExitReason::Quit)],

        Action::CancelInput => vec![Effect::RedrawStatusBar],

        Action::Digit => vec![Effect::RedrawStatusBar],

        Action::ZoomIn => zoom_effects(ctx.current_scale, next_zoom_preset(ctx.current_scale, 1)),
        Action::ZoomOut => zoom_effects(ctx.current_scale, next_zoom_preset(ctx.current_scale, -1)),
        Action::ZoomReset => zoom_effects(ctx.current_scale, 1.0),

        // Accumulating scrolls stack onto the derived target (anchor +
        // Σ history.delta_px), not `y_offset`, so that rapid keypresses
        // during an in-flight animation add up correctly (otherwise each
        // new press anchors off the lagged render position and the total
        // travel under-shoots the intent).
        Action::ScrollDown(count) => {
            let cur = ctx.scroll.derived_target(ctx.max_scroll);
            let y = (cur + count * ctx.scroll_step).min(ctx.max_scroll);
            let delta = y as i32 - cur as i32;
            debug!(
                "scroll down: target {cur} → {y} (count={count}, step={}, max={}, delta={delta})",
                ctx.scroll_step, ctx.max_scroll
            );
            vec![Effect::ScrollImpulse {
                delta_px: delta,
                direction: ScrollDirection::Down,
            }]
        }
        Action::ScrollUp(count) => {
            let cur = ctx.scroll.derived_target(ctx.max_scroll);
            let y = cur.saturating_sub(count * ctx.scroll_step);
            let delta = y as i32 - cur as i32;
            debug!(
                "scroll up: target {cur} → {y} (count={count}, step={}, max={}, delta={delta})",
                ctx.scroll_step, ctx.max_scroll
            );
            vec![Effect::ScrollImpulse {
                delta_px: delta,
                direction: ScrollDirection::Up,
            }]
        }
        Action::HalfPageDown(count) => {
            let cur = ctx.scroll.derived_target(ctx.max_scroll);
            let y = (cur + count * ctx.half_page).min(ctx.max_scroll);
            let delta = y as i32 - cur as i32;
            debug!(
                "scroll half-down: target {cur} → {y} (count={count}, half={}, max={}, delta={delta})",
                ctx.half_page, ctx.max_scroll
            );
            vec![Effect::ScrollImpulse {
                delta_px: delta,
                direction: ScrollDirection::Down,
            }]
        }
        Action::HalfPageUp(count) => {
            let cur = ctx.scroll.derived_target(ctx.max_scroll);
            let y = cur.saturating_sub(count * ctx.half_page);
            let delta = y as i32 - cur as i32;
            debug!(
                "scroll half-up: target {cur} → {y} (count={count}, half={}, max={}, delta={delta})",
                ctx.half_page, ctx.max_scroll
            );
            vec![Effect::ScrollImpulse {
                delta_px: delta,
                direction: ScrollDirection::Up,
            }]
        }

        Action::JumpToTop => {
            debug!("scroll top: y_offset {} → 0", ctx.scroll.y_offset);
            vec![Effect::ScrollAnchor(0)]
        }
        Action::JumpToBottom => {
            debug!(
                "scroll bottom: y_offset {} → {} (max={})",
                ctx.scroll.y_offset, ctx.max_scroll, ctx.max_scroll
            );
            vec![Effect::ScrollAnchor(ctx.max_scroll)]
        }
        Action::JumpToLine(n) => {
            let y = visual_line_offset(ctx.doc.visual_lines, ctx.max_scroll, n);
            debug!("jump to line {n}: y_offset {} → {}", ctx.scroll.y_offset, y);
            vec![Effect::ScrollAnchor(y)]
        }

        Action::EnterInlineSearch => {
            let is = InlineSearchState::new(ctx.scroll.y_offset, SearchDirection::Forward);
            vec![Effect::SetMode(ViewerMode::InlineSearch(is))]
        }

        Action::EnterBackwardSearch => {
            let is = InlineSearchState::new(ctx.scroll.y_offset, SearchDirection::Backward);
            vec![Effect::SetMode(ViewerMode::InlineSearch(is))]
        }

        Action::EnterCommand => {
            let cs = CommandState {
                input: String::new(),
            };
            vec![Effect::SetMode(ViewerMode::Command(cs))]
        }

        Action::SearchNextMatch => navigate_search(ctx, KeyDirection::Next),
        Action::SearchPrevMatch => navigate_search(ctx, KeyDirection::Prev),

        Action::YankExactPrompt => {
            vec![
                Effect::Flash("Type Ny to yank line N".into()),
                Effect::RedrawStatusBar,
            ]
        }
        Action::YankExact(n) => yank_and_flash(
            ctx,
            n,
            |doc, idx| doc.yank_exact(idx),
            |n, lc| format!("Yanked L{n} ({lc} line{})", if lc > 1 { "s" } else { "" }),
        ),

        Action::YankBlockPrompt => {
            vec![
                Effect::Flash("Type NY to yank block N".into()),
                Effect::RedrawStatusBar,
            ]
        }
        Action::YankBlock(n) => yank_and_flash(
            ctx,
            n,
            |doc, idx| doc.yank_lines(idx, idx),
            |n, lc| format!("Yanked L{n} block ({lc} lines)"),
        ),

        Action::OpenUrlPrompt => {
            vec![
                Effect::Flash("Type No to open URL on line N".into()),
                Effect::RedrawStatusBar,
            ]
        }
        Action::OpenUrl(n) => open_url(ctx, n),

        Action::GoBack => vec![Effect::GoBack],

        Action::EnterToc => {
            let entries = collect_headings(ctx.doc);
            if entries.is_empty() {
                vec![
                    Effect::Flash("No headings in document".into()),
                    Effect::RedrawStatusBar,
                ]
            } else {
                vec![
                    Effect::DeletePlacements,
                    Effect::SetMode(ViewerMode::Toc(TocState::new(entries))),
                ]
            }
        }

        Action::EnterUrlPicker => {
            let entries = collect_all_url_entries(ctx.doc);
            if entries.is_empty() {
                vec![
                    Effect::Flash("No URLs in document".into()),
                    Effect::RedrawStatusBar,
                ]
            } else {
                vec![
                    Effect::DeletePlacements,
                    Effect::SetMode(ViewerMode::UrlPicker(UrlPickerState::new(entries))),
                ]
            }
        }
    }
}

enum KeyDirection {
    Next,
    Prev,
}

fn navigate_search(ctx: &mut NormalCtx, key_dir: KeyDirection) -> Vec<Effect> {
    let Some(ls) = ctx.last_search.as_mut() else {
        return vec![
            Effect::Flash("No search results".into()),
            Effect::RedrawStatusBar,
        ];
    };
    // Compose key direction with saved search direction:
    // n + Forward = next, n + Backward = prev
    // N + Forward = prev, N + Backward = next
    let forward = matches!(
        (&key_dir, ls.direction),
        (KeyDirection::Next, SearchDirection::Forward)
            | (KeyDirection::Prev, SearchDirection::Backward)
    );
    if forward {
        ls.advance_next();
    } else {
        ls.advance_prev();
    }
    let Some(vl_idx) = ls.current_visual_line_idx() else {
        return vec![];
    };
    let line_num = (vl_idx + 1) as u32;
    let y = visual_line_offset(ctx.doc.visual_lines, ctx.max_scroll, line_num);
    let flash = format!("match {}/{}", ls.current_idx + 1, ls.matches.len());
    vec![
        Effect::ShowHighlights,
        Effect::InvalidateOverlays,
        Effect::ScrollAnchor(y),
        Effect::Flash(flash),
    ]
}

/// Shared yank logic: bounds check, extract text, build effects.
///
/// `extract` performs the actual text extraction (yank_exact or yank_lines).
/// `format_msg` builds the success flash message from (line_num, line_count).
fn yank_and_flash(
    ctx: &NormalCtx,
    line_num: u32,
    extract: impl FnOnce(&DocumentQuery, usize) -> String,
    format_msg: impl FnOnce(u32, usize) -> String,
) -> Vec<Effect> {
    let vl_idx = (line_num as usize).saturating_sub(1);
    if vl_idx >= ctx.doc.visual_lines.len() {
        return vec![
            Effect::Flash(format!(
                "Line {line_num} out of range (max {})",
                ctx.doc.visual_lines.len()
            )),
            Effect::RedrawStatusBar,
        ];
    }
    let text = extract(ctx.doc, vl_idx);
    if text.is_empty() {
        return vec![
            Effect::Flash(format!("L{line_num}: no source mapping")),
            Effect::RedrawStatusBar,
        ];
    }
    let line_count = text.lines().count();
    debug!("yank L{line_num}: {} bytes, {line_count} lines", text.len());
    vec![
        Effect::Yank(text),
        Effect::Flash(format_msg(line_num, line_count)),
        Effect::RedrawStatusBar,
    ]
}

/// Open URL(s) found on the given visual line.
///
/// If exactly one URL, opens it directly. If multiple, enters URL picker
/// with only the URLs from that line.
fn open_url(ctx: &NormalCtx, line_num: u32) -> Vec<Effect> {
    let vl_idx = (line_num as usize).saturating_sub(1);
    if vl_idx >= ctx.doc.visual_lines.len() {
        return vec![
            Effect::Flash(format!(
                "Line {line_num} out of range (max {})",
                ctx.doc.visual_lines.len()
            )),
            Effect::RedrawStatusBar,
        ];
    }
    if ctx.doc.visual_lines[vl_idx].md_block_range.is_none() {
        return vec![
            Effect::Flash(format!("L{line_num}: no source mapping")),
            Effect::RedrawStatusBar,
        ];
    }
    let urls = ctx.doc.extract_urls(vl_idx);
    if urls.is_empty() {
        return vec![
            Effect::Flash(format!("L{line_num}: no URL found")),
            Effect::RedrawStatusBar,
        ];
    }
    if urls.len() == 1 {
        let display = urls[0].target.display_url().to_string();
        debug!("open_url L{line_num}: {display}");
        let mut effects = open_link_target(&urls[0].target, ctx.current_file);
        effects.push(Effect::Flash(format!("Opening {display}")));
        effects.push(Effect::RedrawStatusBar);
        effects
    } else {
        debug!("open_url L{line_num}: {} URLs, entering picker", urls.len());
        let entries: Vec<UrlPickerEntry> = urls
            .into_iter()
            .map(|u| UrlPickerEntry {
                target: u.target,
                text: u.text,
                visual_line: line_num as usize,
            })
            .collect();
        vec![
            Effect::DeletePlacements,
            Effect::SetMode(ViewerMode::UrlPicker(UrlPickerState::new(entries))),
        ]
    }
}

#[cfg(test)]
mod tests {
    use super::super::query::test_helpers::empty_ci;
    use super::super::scroll_animator::ScrollAnimator;
    use super::*;
    use crate::frame::VisualLine;

    fn make_vl(y_px: u32) -> VisualLine {
        VisualLine {
            y_pt: 0.0,
            y_px,
            md_block_range: None,
            md_offset: None,
            diff_status: None,
        }
    }

    fn make_ctx<'a>(
        scroll: &'a ScrollState,
        doc: &'a DocumentQuery<'a>,
        last_search: &'a mut Option<LastSearch>,
    ) -> NormalCtx<'a> {
        NormalCtx {
            scroll,
            doc,
            max_scroll: 1000,
            scroll_step: 30,
            half_page: 200,
            last_search,
            current_file: None,
            current_scale: 1.0,
        }
    }

    fn make_state(y_offset: u32) -> ScrollState {
        ScrollState::new(
            y_offset,
            2000,
            800,
            600,
            crate::config::ScrollAnimation::ExpDecay,
        )
    }

    #[test]
    fn scroll_accumulates_onto_target_not_render_position() {
        // Simulate mid-animation: render position (y_offset / animator current)
        // lags behind the derived target.  A new ScrollDown must stack onto
        // the derived target (anchor + Σ history.delta_px), not onto
        // y_offset, so rapid keypresses accumulate correctly.
        let mut state = make_state(0);
        // Build the derived target = 100 by anchoring at 0 and pushing
        // a 100-px Down impulse — this models a mid-animation state.
        state.anchor = 0.0;
        let _ = state.input_history.record(ScrollDirection::Down, 100);
        state.animator = ScrollAnimator::new_exp_decay(40.0);
        state.y_offset = 40;
        let vls = vec![make_vl(0)];
        let ci = empty_ci();
        let doc = DocumentQuery::new("", &vls, &ci, 0);
        let mut ls = None;
        let mut ctx = make_ctx(&state, &doc, &mut ls);
        ctx.scroll_step = 50;
        let effects = handle(Action::ScrollDown(1), &mut ctx);
        // Expected: 100 (target) + 50 (step) = 150. NOT 40 (render) + 50 = 90.
        // Incremental scrolls emit ScrollImpulse with the signed delta
        // and direction; the apply layer pushes to history.
        assert!(matches!(
            effects[0],
            Effect::ScrollImpulse {
                delta_px: 50,
                direction: ScrollDirection::Down,
            }
        ));
    }

    #[test]
    fn scroll_down_clamps_to_max() {
        let state = make_state(990);
        let vls = vec![make_vl(0)];
        let ci = empty_ci();
        let doc = DocumentQuery::new("", &vls, &ci, 0);
        let mut ls = None;
        let mut ctx = make_ctx(&state, &doc, &mut ls);
        ctx.max_scroll = 1000;
        let effects = handle(Action::ScrollDown(1), &mut ctx);
        // Clamped to 1000; the achieved delta (10) goes into the impulse.
        assert!(matches!(
            effects[0],
            Effect::ScrollImpulse {
                delta_px: 10,
                direction: ScrollDirection::Down,
            }
        ));
    }

    #[test]
    fn scroll_up_clamps_to_zero() {
        let state = make_state(10);
        let vls = vec![make_vl(0)];
        let ci = empty_ci();
        let doc = DocumentQuery::new("", &vls, &ci, 0);
        let mut ls = None;
        let mut ctx = make_ctx(&state, &doc, &mut ls);
        let effects = handle(Action::ScrollUp(1), &mut ctx);
        // Clamped; negative delta reflects upward direction.
        assert!(matches!(
            effects[0],
            Effect::ScrollImpulse {
                delta_px: -10,
                direction: ScrollDirection::Up,
            }
        ));
    }

    #[test]
    fn half_page_down() {
        let state = make_state(0);
        let vls = vec![make_vl(0)];
        let ci = empty_ci();
        let doc = DocumentQuery::new("", &vls, &ci, 0);
        let mut ls = None;
        let mut ctx = make_ctx(&state, &doc, &mut ls);
        let effects = handle(Action::HalfPageDown(1), &mut ctx);
        assert!(matches!(
            effects[0],
            Effect::ScrollImpulse {
                delta_px: 200,
                direction: ScrollDirection::Down,
            }
        ));
    }

    #[test]
    fn half_page_up() {
        let state = make_state(500);
        let vls = vec![make_vl(0)];
        let ci = empty_ci();
        let doc = DocumentQuery::new("", &vls, &ci, 0);
        let mut ls = None;
        let mut ctx = make_ctx(&state, &doc, &mut ls);
        let effects = handle(Action::HalfPageUp(2), &mut ctx);
        assert!(matches!(
            effects[0],
            Effect::ScrollImpulse {
                delta_px: -400,
                direction: ScrollDirection::Up,
            }
        ));
    }

    #[test]
    fn jump_to_top() {
        let state = make_state(500);
        let vls = vec![make_vl(0)];
        let ci = empty_ci();
        let doc = DocumentQuery::new("", &vls, &ci, 0);
        let mut ls = None;
        let mut ctx = make_ctx(&state, &doc, &mut ls);
        let effects = handle(Action::JumpToTop, &mut ctx);
        assert!(matches!(effects[0], Effect::ScrollAnchor(0)));
    }

    #[test]
    fn jump_to_bottom() {
        let state = make_state(0);
        let vls = vec![make_vl(0)];
        let ci = empty_ci();
        let doc = DocumentQuery::new("", &vls, &ci, 0);
        let mut ls = None;
        let mut ctx = make_ctx(&state, &doc, &mut ls);
        let effects = handle(Action::JumpToBottom, &mut ctx);
        assert!(matches!(effects[0], Effect::ScrollAnchor(1000)));
    }

    #[test]
    fn quit_returns_exit() {
        let state = make_state(0);
        let vls = vec![make_vl(0)];
        let ci = empty_ci();
        let doc = DocumentQuery::new("", &vls, &ci, 0);
        let mut ls = None;
        let mut ctx = make_ctx(&state, &doc, &mut ls);
        let effects = handle(Action::Quit, &mut ctx);
        assert!(matches!(effects[0], Effect::Exit(ExitReason::Quit)));
    }

    #[test]
    fn enter_inline_search_sets_mode_and_saves_scroll() {
        let state = make_state(42);
        let vls = vec![make_vl(0)];
        let ci = empty_ci();
        let doc = DocumentQuery::new("", &vls, &ci, 0);
        let mut ls = None;
        let mut ctx = make_ctx(&state, &doc, &mut ls);
        let effects = handle(Action::EnterInlineSearch, &mut ctx);
        assert_eq!(effects.len(), 1);
        assert!(matches!(
            effects[0],
            Effect::SetMode(ViewerMode::InlineSearch(_))
        ));
    }

    #[test]
    fn yank_out_of_range_flashes_error() {
        let state = make_state(0);
        let vls = vec![make_vl(0)];
        let ci = empty_ci();
        let doc = DocumentQuery::new("hello", &vls, &ci, 0);
        let mut ls = None;
        let mut ctx = make_ctx(&state, &doc, &mut ls);
        let effects = handle(Action::YankExact(99), &mut ctx);
        assert!(matches!(&effects[0], Effect::Flash(msg) if msg.contains("out of range")));
    }

    #[test]
    fn open_url_no_source_mapping_flashes_error() {
        let state = make_state(0);
        let vls = vec![make_vl(0)]; // no md_line_range
        let ci = empty_ci();
        let doc = DocumentQuery::new("hello", &vls, &ci, 0);
        let mut ls = None;
        let mut ctx = make_ctx(&state, &doc, &mut ls);
        let effects = handle(Action::OpenUrl(1), &mut ctx);
        assert!(matches!(&effects[0], Effect::Flash(msg) if msg.contains("no source mapping")));
    }

    #[test]
    fn search_next_without_results_flashes() {
        let state = make_state(0);
        let vls = vec![make_vl(0)];
        let ci = empty_ci();
        let doc = DocumentQuery::new("", &vls, &ci, 0);
        let mut ls = None;
        let mut ctx = make_ctx(&state, &doc, &mut ls);
        let effects = handle(Action::SearchNextMatch, &mut ctx);
        assert!(matches!(&effects[0], Effect::Flash(msg) if msg.contains("No search results")));
    }

    #[test]
    fn go_back_returns_effect() {
        let state = make_state(0);
        let vls = vec![make_vl(0)];
        let ci = empty_ci();
        let doc = DocumentQuery::new("", &vls, &ci, 0);
        let mut ls = None;
        let mut ctx = make_ctx(&state, &doc, &mut ls);
        let effects = handle(Action::GoBack, &mut ctx);
        assert!(matches!(effects[0], Effect::GoBack));
    }

    // --- next_zoom_preset ---

    #[test]
    fn next_zoom_preset_exact_up() {
        assert_eq!(next_zoom_preset(1.0, 1), 1.10);
    }

    #[test]
    fn next_zoom_preset_exact_down() {
        assert_eq!(next_zoom_preset(1.0, -1), 0.85);
    }

    #[test]
    fn next_zoom_preset_top_edge_clamp() {
        assert_eq!(next_zoom_preset(2.0, 1), 2.0);
    }

    #[test]
    fn next_zoom_preset_bottom_edge_clamp() {
        assert_eq!(next_zoom_preset(0.5, -1), 0.5);
    }

    #[test]
    fn next_zoom_preset_off_preset_snap_up() {
        assert_eq!(next_zoom_preset(0.7, 1), 0.85);
    }

    #[test]
    fn next_zoom_preset_off_preset_snap_down() {
        assert_eq!(next_zoom_preset(0.7, -1), 0.67);
    }

    // --- zoom_effects ---

    #[test]
    fn zoom_effects_no_change_flashes() {
        let effects = zoom_effects(1.0, 1.0);
        assert_eq!(effects.len(), 2);
        assert!(matches!(
            &effects[0],
            Effect::Flash(msg) if msg.contains("no change") && msg.contains("100%")
        ));
        assert!(matches!(&effects[1], Effect::RedrawStatusBar));
    }

    #[test]
    fn zoom_effects_at_max_flashes_max() {
        let effects = zoom_effects(2.0, 2.0);
        assert_eq!(effects.len(), 2);
        assert!(matches!(
            &effects[0],
            Effect::Flash(msg) if msg.contains("max") && msg.contains("200%")
        ));
        assert!(matches!(&effects[1], Effect::RedrawStatusBar));
    }

    #[test]
    fn zoom_effects_at_min_flashes_min() {
        let effects = zoom_effects(0.5, 0.5);
        assert_eq!(effects.len(), 2);
        assert!(matches!(
            &effects[0],
            Effect::Flash(msg) if msg.contains("min") && msg.contains("50%")
        ));
        assert!(matches!(&effects[1], Effect::RedrawStatusBar));
    }

    #[test]
    fn zoom_effects_change_emits_exit_with_flash() {
        let effects = zoom_effects(1.0, 1.25);
        assert_eq!(effects.len(), 1);
        assert!(matches!(
            &effects[0],
            Effect::Exit(ExitReason::SetScale { old, new, flash })
                if (*old - 1.0).abs() < 1e-9
                && (*new - 1.25).abs() < 1e-9
                && flash.as_deref() == Some("zoom: 125%")
        ));
    }
}
