// Pure reducer: state transitions only, no I/O.
// The reducer MUST NOT:
// - Call `Instant::now()` (time is passed as `now` parameter)
// - Perform I/O operations
// - Spawn async tasks

use std::time::Instant;

use super::{
    dispatch_connection, dispatch_er, dispatch_explain, dispatch_metadata, dispatch_modal,
    dispatch_navigation, dispatch_query, dispatch_result, dispatch_sql_modal,
};
use crate::catalog::HelpDocument;
use crate::cmd::effect::Effect;
use crate::domain::TableSummary;
use crate::model::app_state::AppState;
use crate::model::shared::focused_pane::FocusedPane;
use crate::model::shared::input_mode::InputMode;
use crate::model::shared::key_sequence::KeySequenceState;
use crate::services::AppServices;
use crate::update::action::{Action, TableTarget};

pub fn reduce(
    state: &mut AppState,
    action: Action,
    now: Instant,
    services: &AppServices,
) -> Vec<Effect> {
    // Mark dirty for all state-changing actions (except None and Render)
    let should_mark_dirty = !matches!(action, Action::None | Action::Render);

    let effects = reduce_inner(state, action, now, services);

    if should_mark_dirty {
        state.mark_dirty();
    }

    effects
}

fn reduce_inner(
    state: &mut AppState,
    action: Action,
    now: Instant,
    services: &AppServices,
) -> Vec<Effect> {
    state.result_interaction.clear_operator_pending();

    if let Some(effects) = dispatch_connection(state, &action, now, services)
        .or_else(|| dispatch_modal(state, &action, now))
        // dispatch_result must precede dispatch_query: passthrough actions (e.g. ResultNextPage)
        // reset view state here and return Pass, relying on dispatch_query for the page change.
        .or_else(|| dispatch_result(state, &action, services, now))
        .or_else(|| dispatch_navigation(state, &action, services, now))
        .or_else(|| dispatch_sql_modal(state, &action, now, services))
        .or_else(|| dispatch_explain(state, &action, now, services))
        .or_else(|| dispatch_metadata(state, &action, now))
        .or_else(|| dispatch_er(state, &action, now))
        .or_else(|| dispatch_query(state, &action, now, services))
        .into_effects()
    {
        return effects;
    }

    match action {
        Action::BeginKeySequence(prefix) => {
            state.ui.key_sequence = KeySequenceState::WaitingSecondKey(prefix);
            vec![]
        }
        Action::CancelKeySequence => {
            state.ui.key_sequence = KeySequenceState::Idle;
            vec![]
        }
        Action::Quit => {
            state.should_quit = true;
            vec![]
        }
        Action::Resize(w, h) => {
            state.ui.terminal_width = w;
            state.ui.terminal_height = h;
            let document = HelpDocument::from_state(state);
            state
                .ui
                .clamp_help_offsets(document.line_count(), document.content_width());
            vec![]
        }
        Action::Render => {
            vec![Effect::Render]
        }

        Action::ConfirmSelection => {
            if state.modal.active_mode() == InputMode::TablePicker {
                let table = state
                    .filtered_tables()
                    .get(state.ui.table_picker.selected())
                    .copied()
                    .cloned();
                if let Some(table) = table {
                    state.modal.set_mode(InputMode::Normal);
                    return select_table(state, &table);
                }
            } else if state.modal.active_mode() == InputMode::Normal {
                if state.connection_error.error_info.is_some() {
                    state.modal.replace_mode(InputMode::ConnectionError);
                    return vec![];
                }
                if state.ui.focused_pane != FocusedPane::Explorer {
                    return vec![];
                }
                let table = state
                    .tables()
                    .get(state.ui.explorer_selected)
                    .copied()
                    .cloned();
                if let Some(table) = table {
                    return select_table(state, &table);
                }
            } else if state.modal.active_mode() == InputMode::CommandPalette {
                use crate::update::input::palette::palette_action_for_index;

                let cmd_action = palette_action_for_index(
                    state.ui.table_picker.selected(),
                    state.settings.saved_keymap_preset(),
                );
                state.modal.set_mode(InputMode::Normal);
                return reduce(state, cmd_action, now, services);
            }

            vec![]
        }

        // Invalid target/direction/amount combo — catch call-site bugs in debug builds
        Action::Scroll { .. }
        | Action::ScrollToCursor { .. }
        | Action::TextInput { .. }
        | Action::TextBackspace { .. }
        | Action::TextDelete { .. }
        | Action::TextMoveCursor { .. }
        | Action::Select(_)
        | Action::ListSelect { .. } => {
            debug_assert!(false, "unhandled parametric action: {action:?}");
            vec![]
        }

        // Handled by sub-reducers
        _ => vec![],
    }
}

fn select_table(state: &mut AppState, table: &TableSummary) -> Vec<Effect> {
    let generation =
        state
            .session
            .select_table(&table.schema, &table.name, &mut state.query.pagination);
    state.result_interaction.reset_interaction();

    let schema = table.schema.clone();
    let table_name = table.name.clone();

    let mut effects = Vec::new();
    if let Some(dsn) = state.session.dsn.clone() {
        let run_id = state.session.begin_table_detail_run();
        effects.push(Effect::FetchTableDetail {
            dsn,
            schema: schema.clone(),
            table: table_name.clone(),
            generation,
            run_id,
        });
    }
    effects.push(Effect::DispatchActions(vec![Action::ExecutePreview(
        TableTarget {
            schema,
            table: table_name,
            generation,
        },
    )]));
    effects
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use super::*;
    use crate::ports::outbound::DbOperationError;
    use crate::ports::outbound::connection_store::ConnectionStoreError;
    use crate::update::action::ModalKind;
    use crate::update::action::{ConnectionSaveError, ConnectionTarget};
    use crate::update::action::{InputTarget, SelectMotion};

    fn create_test_state() -> AppState {
        AppState::new("test_project".to_string())
    }

    mod pure_actions {
        use super::*;
        use rstest::rstest;

        #[test]
        fn quit_sets_should_quit_and_returns_no_effects() {
            let mut state = create_test_state();
            let now = Instant::now();

            let effects = reduce(&mut state, Action::Quit, now, &AppServices::stub());

            assert!(state.should_quit);
            assert!(effects.is_empty());
        }

        #[test]
        fn toggle_focus_returns_no_effects() {
            let mut state = create_test_state();
            let now = Instant::now();

            let effects = reduce(&mut state, Action::ToggleFocus, now, &AppServices::stub());

            assert!(state.ui.is_focus_mode());
            assert!(effects.is_empty());
        }

        #[test]
        fn resize_updates_terminal_size_and_clamps_help_offsets() {
            let mut state = create_test_state();
            state.ui.terminal_width = 20;
            state.ui.terminal_height = 10;
            state.ui.help.set_scroll_offset(usize::MAX);
            state.ui.help.set_horizontal_offset(usize::MAX);
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::Resize(100, 50),
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.ui.terminal_width, 100);
            assert_eq!(state.ui.terminal_height, 50);
            let document = HelpDocument::from_state(&state);
            assert_eq!(
                state.ui.help.scroll_offset(),
                state
                    .ui
                    .help_max_scroll(document.line_count(), document.content_width())
            );
            assert_eq!(
                state.ui.help.horizontal_offset(),
                state
                    .ui
                    .help_max_horizontal_scroll(document.line_count(), document.content_width())
            );
            assert!(effects.is_empty());
        }

        #[test]
        fn render_returns_render_effect() {
            let mut state = create_test_state();
            let now = Instant::now();

            let effects = reduce(&mut state, Action::Render, now, &AppServices::stub());

            assert_eq!(effects.len(), 1);
            assert!(matches!(effects[0], Effect::Render));
        }

        #[rstest]
        #[case(Action::Select(SelectMotion::First))]
        #[case(Action::Select(SelectMotion::Last))]
        #[case(Action::Select(SelectMotion::Next))]
        #[case(Action::Select(SelectMotion::Previous))]
        fn selection_on_empty_tables_keeps_none(#[case] action: Action) {
            let mut state = create_test_state();
            state.ui.focused_pane = FocusedPane::Explorer;
            state.ui.explorer_selected = 0;
            let now = Instant::now();

            reduce(&mut state, action, now, &AppServices::stub());

            assert_eq!(state.ui.explorer_selected, 0);
        }
    }

    mod scroll_actions {
        use super::*;
        use crate::update::action::{ScrollAmount, ScrollDirection, ScrollTarget};
        use rstest::rstest;

        #[test]
        fn result_scroll_up_decrements_offset() {
            let mut state = create_test_state();
            state.result_interaction.scroll_offset = 5;
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::Scroll {
                    target: ScrollTarget::Result,
                    direction: ScrollDirection::Up,
                    amount: ScrollAmount::Line,
                },
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.result_interaction.scroll_offset, 4);
            assert!(effects.is_empty());
        }

        #[test]
        fn result_scroll_up_saturates_at_zero() {
            let mut state = create_test_state();
            state.result_interaction.scroll_offset = 0;
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::Scroll {
                    target: ScrollTarget::Result,
                    direction: ScrollDirection::Up,
                    amount: ScrollAmount::Line,
                },
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.result_interaction.scroll_offset, 0);
            assert!(effects.is_empty());
        }

        #[test]
        fn result_scroll_top_resets_to_zero() {
            let mut state = create_test_state();
            state.result_interaction.scroll_offset = 10;
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::Scroll {
                    target: ScrollTarget::Result,
                    direction: ScrollDirection::Up,
                    amount: ScrollAmount::ToStart,
                },
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.result_interaction.scroll_offset, 0);
            assert!(effects.is_empty());
        }

        #[rstest]
        #[case(ScrollTarget::Result, ScrollDirection::Down, ScrollAmount::Line)]
        #[case(ScrollTarget::Result, ScrollDirection::Up, ScrollAmount::HalfPage)]
        #[case(ScrollTarget::Result, ScrollDirection::Left, ScrollAmount::Line)]
        #[case(ScrollTarget::Result, ScrollDirection::Right, ScrollAmount::Line)]
        #[case(ScrollTarget::Result, ScrollDirection::Up, ScrollAmount::ToStart)]
        #[case(ScrollTarget::Result, ScrollDirection::Down, ScrollAmount::FullPage)]
        #[case(ScrollTarget::Inspector, ScrollDirection::Down, ScrollAmount::Line)]
        #[case(ScrollTarget::Inspector, ScrollDirection::Up, ScrollAmount::Line)]
        #[case(ScrollTarget::Help, ScrollDirection::Up, ScrollAmount::Line)]
        #[case(ScrollTarget::Help, ScrollDirection::Down, ScrollAmount::Line)]
        #[case(ScrollTarget::Help, ScrollDirection::Up, ScrollAmount::ToStart)]
        #[case(ScrollTarget::Help, ScrollDirection::Down, ScrollAmount::ToEnd)]
        #[case(ScrollTarget::Help, ScrollDirection::Up, ScrollAmount::HalfPage)]
        #[case(ScrollTarget::Help, ScrollDirection::Down, ScrollAmount::FullPage)]
        #[case(
            ScrollTarget::ConnectionError,
            ScrollDirection::Down,
            ScrollAmount::Line
        )]
        #[case(ScrollTarget::ExplainPlan, ScrollDirection::Down, ScrollAmount::Line)]
        #[case(ScrollTarget::ExplainPlan, ScrollDirection::Up, ScrollAmount::Line)]
        #[case(
            ScrollTarget::ExplainCompare,
            ScrollDirection::Down,
            ScrollAmount::Line
        )]
        #[case(ScrollTarget::ExplainCompare, ScrollDirection::Up, ScrollAmount::Line)]
        #[case(
            ScrollTarget::ExplainConfirm,
            ScrollDirection::Down,
            ScrollAmount::Line
        )]
        #[case(ScrollTarget::ExplainConfirm, ScrollDirection::Up, ScrollAmount::Line)]
        #[case(ScrollTarget::ConfirmDialog, ScrollDirection::Down, ScrollAmount::Line)]
        #[case(ScrollTarget::ConfirmDialog, ScrollDirection::Up, ScrollAmount::Line)]
        #[case(ScrollTarget::Explorer, ScrollDirection::Left, ScrollAmount::Line)]
        #[case(ScrollTarget::Explorer, ScrollDirection::Right, ScrollAmount::Line)]
        fn scroll_reduce_never_returns_effects(
            #[case] target: ScrollTarget,
            #[case] direction: ScrollDirection,
            #[case] amount: ScrollAmount,
        ) {
            let mut state = create_test_state();
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::Scroll {
                    target,
                    direction,
                    amount,
                },
                now,
                &AppServices::stub(),
            );

            assert!(
                effects.is_empty(),
                "scroll reduce must return empty effects for coalescing safety"
            );
        }

        fn help_metrics(state: &AppState) -> (usize, usize) {
            let document = HelpDocument::from_state(state);
            (document.line_count(), document.content_width())
        }

        fn help_max_scroll(state: &AppState) -> usize {
            let (line_count, content_width) = help_metrics(state);
            state.ui.help_max_scroll(line_count, content_width)
        }

        fn help_max_horizontal_scroll(state: &AppState) -> usize {
            let (line_count, content_width) = help_metrics(state);
            state
                .ui
                .help_max_horizontal_scroll(line_count, content_width)
        }

        #[test]
        fn help_scroll_top_resets_offset_to_zero() {
            let mut state = create_test_state();
            state.ui.help.set_scroll_offset(8);
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::Scroll {
                    target: ScrollTarget::Help,
                    direction: ScrollDirection::Up,
                    amount: ScrollAmount::ToStart,
                },
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.ui.help.scroll_offset(), 0);
            assert!(effects.is_empty());
        }

        #[test]
        fn help_scroll_bottom_moves_to_max_scroll() {
            let mut state = create_test_state();
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::Scroll {
                    target: ScrollTarget::Help,
                    direction: ScrollDirection::Down,
                    amount: ScrollAmount::ToEnd,
                },
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.ui.help.scroll_offset(), help_max_scroll(&state));
            assert!(effects.is_empty());
        }

        #[test]
        fn help_half_page_scroll_uses_half_of_visible_rows() {
            let mut state = create_test_state();
            state.ui.terminal_height = 24;
            state.ui.help.set_scroll_offset(1);
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::Scroll {
                    target: ScrollTarget::Help,
                    direction: ScrollDirection::Down,
                    amount: ScrollAmount::HalfPage,
                },
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.ui.help.scroll_offset(), 9);
            assert!(effects.is_empty());
        }

        #[test]
        fn help_full_page_scroll_uses_visible_rows() {
            let mut state = create_test_state();
            state.ui.terminal_height = 24;
            state.ui.help.set_scroll_offset(2);
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::Scroll {
                    target: ScrollTarget::Help,
                    direction: ScrollDirection::Down,
                    amount: ScrollAmount::FullPage,
                },
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.ui.help.scroll_offset(), 18);
            assert!(effects.is_empty());
        }

        #[test]
        fn help_page_scroll_saturates_at_bounds() {
            let mut state = create_test_state();
            state.ui.help.set_scroll_offset(help_max_scroll(&state));
            let now = Instant::now();

            reduce(
                &mut state,
                Action::Scroll {
                    target: ScrollTarget::Help,
                    direction: ScrollDirection::Down,
                    amount: ScrollAmount::FullPage,
                },
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.ui.help.scroll_offset(), help_max_scroll(&state));

            reduce(
                &mut state,
                Action::Scroll {
                    target: ScrollTarget::Help,
                    direction: ScrollDirection::Up,
                    amount: ScrollAmount::FullPage,
                },
                now,
                &AppServices::stub(),
            );

            reduce(
                &mut state,
                Action::Scroll {
                    target: ScrollTarget::Help,
                    direction: ScrollDirection::Up,
                    amount: ScrollAmount::ToStart,
                },
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.ui.help.scroll_offset(), 0);
        }

        #[test]
        fn help_horizontal_scroll_saturates_at_bounds() {
            let mut state = create_test_state();
            state.ui.terminal_width = 40;
            state
                .ui
                .help
                .set_horizontal_offset(help_max_horizontal_scroll(&state));
            let now = Instant::now();

            reduce(
                &mut state,
                Action::Scroll {
                    target: ScrollTarget::Help,
                    direction: ScrollDirection::Right,
                    amount: ScrollAmount::Line,
                },
                now,
                &AppServices::stub(),
            );

            assert_eq!(
                state.ui.help.horizontal_offset(),
                help_max_horizontal_scroll(&state)
            );

            reduce(
                &mut state,
                Action::Scroll {
                    target: ScrollTarget::Help,
                    direction: ScrollDirection::Left,
                    amount: ScrollAmount::Line,
                },
                now,
                &AppServices::stub(),
            );

            assert_eq!(
                state.ui.help.horizontal_offset(),
                help_max_horizontal_scroll(&state).saturating_sub(1)
            );
        }

        #[test]
        fn help_close_resets_vertical_and_horizontal_offsets() {
            let mut state = create_test_state();
            state.modal.set_mode(InputMode::Help);
            state.ui.help.set_scroll_offset(3);
            state.ui.help.set_horizontal_offset(4);
            let now = Instant::now();

            reduce(
                &mut state,
                Action::CloseModal(ModalKind::Help),
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.ui.help.scroll_offset(), 0);
            assert_eq!(state.ui.help.horizontal_offset(), 0);
        }
    }

    mod modal_toggles {
        use super::*;

        #[test]
        fn open_table_picker_sets_mode_and_clears_filter() {
            let mut state = create_test_state();
            state.ui.table_picker.insert_filter_str("test");
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::OpenModal(ModalKind::TablePicker),
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.input_mode(), InputMode::TablePicker);
            assert!(state.ui.table_picker.filter_input().content().is_empty());
            assert_eq!(state.ui.table_picker.selected(), 0);
            assert!(effects.is_empty());
        }

        #[test]
        fn close_table_picker_returns_to_normal() {
            let mut state = create_test_state();
            state.modal.set_mode(InputMode::TablePicker);
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::CloseModal(ModalKind::TablePicker),
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.input_mode(), InputMode::Normal);
            assert!(effects.is_empty());
        }

        #[test]
        fn open_help_toggles_help_mode() {
            let mut state = create_test_state();
            let now = Instant::now();

            // First open
            let effects = reduce(
                &mut state,
                Action::ToggleModal(ModalKind::Help),
                now,
                &AppServices::stub(),
            );
            assert_eq!(state.input_mode(), InputMode::Help);
            assert!(effects.is_empty());

            // Toggle back to normal
            let effects = reduce(
                &mut state,
                Action::ToggleModal(ModalKind::Help),
                now,
                &AppServices::stub(),
            );
            assert_eq!(state.input_mode(), InputMode::Normal);
            assert!(effects.is_empty());
        }

        #[test]
        fn close_help_resets_scroll_offset() {
            let mut state = create_test_state();
            state.modal.set_mode(InputMode::Help);
            state.ui.help.set_scroll_offset(12);
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::CloseModal(ModalKind::Help),
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.input_mode(), InputMode::Normal);
            assert_eq!(state.ui.help.scroll_offset(), 0);
            assert!(effects.is_empty());
        }

        #[test]
        fn close_help_returns_to_origin_mode() {
            let mut state = create_test_state();
            state.modal.set_mode(InputMode::SqlModal);
            let now = Instant::now();

            reduce(
                &mut state,
                Action::ToggleModal(ModalKind::Help),
                now,
                &AppServices::stub(),
            );
            let effects = reduce(
                &mut state,
                Action::CloseModal(ModalKind::Help),
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.input_mode(), InputMode::SqlModal);
            assert!(effects.is_empty());
        }
    }

    mod sql_modal_debounce {
        use super::*;
        use crate::model::shared::text_input::TextInputLike;
        use crate::model::sql_editor::modal::SqlModalStatus;
        use std::time::Duration;

        #[test]
        fn sql_modal_input_sets_debounce_state() {
            let mut state = create_test_state();
            state.modal.set_mode(InputMode::SqlModal);
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::TextInput {
                    target: InputTarget::SqlModal,
                    ch: 'a',
                },
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.sql_modal.editor.content(), "a");
            assert_eq!(state.sql_modal.editor.cursor(), 1);
            assert!(effects.is_empty());
            assert!(state.sql_modal.completion_debounce().is_some());
        }

        #[test]
        fn sql_modal_backspace_sets_debounce_state() {
            let mut state = create_test_state();
            state.sql_modal.editor.set_content("ab".to_string());
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::TextBackspace {
                    target: InputTarget::SqlModal,
                },
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.sql_modal.editor.content(), "a");
            assert_eq!(state.sql_modal.editor.cursor(), 1);
            assert!(effects.is_empty());
            assert!(state.sql_modal.completion_debounce().is_some());
        }

        #[test]
        fn debounce_state_uses_provided_now() {
            let mut state = create_test_state();
            let now = Instant::now();

            reduce(
                &mut state,
                Action::TextInput {
                    target: InputTarget::SqlModal,
                    ch: 'x',
                },
                now,
                &AppServices::stub(),
            );

            let expected = now + Duration::from_millis(100);
            assert_eq!(state.sql_modal.completion_debounce(), Some(expected));
        }

        #[test]
        fn text_input_preserves_visible_completion_popup() {
            let mut state = create_test_state();
            state.modal.set_mode(InputMode::SqlModal);
            state.sql_modal.set_status_for_test(SqlModalStatus::Editing);
            state.sql_modal.completion_mut_for_test().visible = true;
            let now = Instant::now();

            reduce(
                &mut state,
                Action::TextInput {
                    target: InputTarget::SqlModal,
                    ch: 'x',
                },
                now,
                &AppServices::stub(),
            );

            assert!(state.sql_modal.completion().visible);
            assert_eq!(
                state.sql_modal.completion_debounce(),
                Some(now + Duration::from_millis(100))
            );
        }
    }

    mod completion_ui {
        use super::*;
        use crate::model::shared::text_input::TextInputLike;
        use crate::model::sql_editor::completion::{CompletionCandidate, CompletionKind};

        fn make_candidate(text: &str) -> CompletionCandidate {
            CompletionCandidate {
                text: text.to_string(),
                kind: CompletionKind::Table,
                score: 0,
            }
        }

        #[test]
        fn completion_next_wraps_around() {
            let mut state = create_test_state();
            state.sql_modal.completion_mut_for_test().candidates =
                vec![make_candidate("a"), make_candidate("b")];
            state.sql_modal.completion_mut_for_test().selected_index = 1;
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::CompletionNext,
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.sql_modal.completion().selected_index, 0);
            assert!(effects.is_empty());
        }

        #[test]
        fn completion_prev_wraps_around() {
            let mut state = create_test_state();
            state.sql_modal.completion_mut_for_test().candidates =
                vec![make_candidate("a"), make_candidate("b")];
            state.sql_modal.completion_mut_for_test().selected_index = 0;
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::CompletionPrev,
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.sql_modal.completion().selected_index, 1);
            assert!(effects.is_empty());
        }

        #[test]
        fn completion_accept_dismisses_when_cursor_precedes_trigger() {
            let mut state = create_test_state();
            state.modal.set_mode(InputMode::SqlModal);
            state
                .sql_modal
                .editor
                .set_content_with_cursor("SELECT ".to_string(), 0);
            state
                .sql_modal
                .apply_completion_update(&[make_candidate("users")], 7, true);
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::CompletionAccept,
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.sql_modal.editor.content(), "SELECT ");
            assert_eq!(state.sql_modal.editor.cursor(), 0);
            assert!(!state.sql_modal.completion().visible);
            assert!(effects.is_empty());
        }
    }

    mod response_handlers {
        use super::*;
        use crate::domain::{DatabaseMetadata, MetadataState, TableSummary};
        use crate::model::connection::error::ConnectionErrorInfo;

        fn metadata_loaded_action(state: &mut AppState, metadata: DatabaseMetadata) -> Action {
            state.session.dsn = Some("postgres://localhost/test".to_string());
            let run_id = state.session.begin_metadata_refresh();
            Action::MetadataLoaded {
                dsn: "postgres://localhost/test".to_string(),
                run_id,
                metadata: Arc::new(metadata),
            }
        }

        fn metadata_failed_action(state: &mut AppState, error: DbOperationError) -> Action {
            state.session.dsn = Some("postgres://localhost/test".to_string());
            let run_id = state.session.begin_metadata_refresh();
            Action::MetadataFailed {
                dsn: "postgres://localhost/test".to_string(),
                run_id,
                error,
            }
        }

        #[test]
        fn metadata_loaded_with_empty_tables_selects_none() {
            let mut state = create_test_state();
            state.ui.explorer_selected = 5;
            let metadata = DatabaseMetadata {
                database_name: "test".to_string(),
                schemas: vec![],
                table_summaries: vec![],
            };
            let now = Instant::now();
            let action = metadata_loaded_action(&mut state, metadata);

            reduce(&mut state, action, now, &AppServices::stub());

            assert!(state.session.metadata().is_some());
            assert_eq!(state.ui.explorer_selected, 0);
        }

        #[test]
        fn metadata_loaded_with_tables_selects_first() {
            let mut state = create_test_state();
            state.ui.explorer_selected = 3;
            let metadata = DatabaseMetadata {
                database_name: "test".to_string(),
                schemas: vec![],
                table_summaries: vec![TableSummary::new(
                    "public".to_string(),
                    "users".to_string(),
                    None,
                    false,
                )],
            };
            let now = Instant::now();
            let action = metadata_loaded_action(&mut state, metadata);

            reduce(&mut state, action, now, &AppServices::stub());

            assert!(state.session.metadata().is_some());
            assert_eq!(state.ui.explorer_selected, 0);
        }

        #[test]
        fn metadata_failed_opens_error_modal_automatically() {
            let mut state = create_test_state();
            let now = Instant::now();
            let action = metadata_failed_action(
                &mut state,
                DbOperationError::ConnectionFailed("psql: error: connection refused".to_string()),
            );

            let effects = reduce(&mut state, action, now, &AppServices::stub());

            assert!(matches!(
                state.session.metadata_state(),
                MetadataState::Error(_)
            ));
            assert_eq!(state.input_mode(), InputMode::ConnectionError);
            assert!(state.connection_error.error_info.is_some());
            assert!(effects.is_empty());
        }

        #[test]
        fn enter_with_error_info_opens_modal() {
            let mut state = create_test_state();
            state
                .connection_error
                .set_error(ConnectionErrorInfo::new("error"));
            state.ui.focused_pane = FocusedPane::Result; // Any pane works
            let now = Instant::now();

            reduce(
                &mut state,
                Action::ConfirmSelection,
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.input_mode(), InputMode::ConnectionError);
        }
    }

    mod connection_error_actions {
        use super::*;
        use crate::domain::MetadataState;
        use crate::model::connection::error::{ConnectionErrorInfo, ConnectionErrorKind};

        fn state_with_error() -> AppState {
            let mut state = create_test_state();
            let info = ConnectionErrorInfo::with_kind(
                ConnectionErrorKind::HostUnreachable,
                "psql: error: could not translate host",
            );
            state.connection_error.set_error(info);
            state.modal.set_mode(InputMode::ConnectionError);
            state
        }

        #[test]
        fn close_keeps_error_info_for_reopen() {
            let mut state = state_with_error();
            state.connection_error.details_expanded = true;
            state.connection_error.scroll_offset = 5;
            let now = Instant::now();

            reduce(
                &mut state,
                Action::CloseConnectionError,
                now,
                &AppServices::stub(),
            );

            // error_info is kept so Enter can re-open modal
            assert!(state.connection_error.error_info.is_some());
            assert_eq!(state.input_mode(), InputMode::Normal);
            // UI state is reset
            assert!(!state.connection_error.details_expanded);
            assert_eq!(state.connection_error.scroll_offset, 0);
        }

        #[test]
        fn close_clears_copied_feedback() {
            let mut state = state_with_error();
            let now = Instant::now();
            state.connection_error.mark_copied_at(now);
            assert!(state.connection_error.is_copied_visible_at(now));

            reduce(
                &mut state,
                Action::CloseConnectionError,
                now,
                &AppServices::stub(),
            );

            // Copied feedback is cleared on close
            assert!(!state.connection_error.is_copied_visible_at(now));
        }

        #[test]
        fn reopen_modal_after_close_shows_same_error() {
            let mut state = state_with_error();
            state
                .session
                .set_metadata_state(MetadataState::Error("error".to_string()));
            state.ui.focused_pane = FocusedPane::Explorer;
            let now = Instant::now();

            // Close modal
            reduce(
                &mut state,
                Action::CloseConnectionError,
                now,
                &AppServices::stub(),
            );
            assert_eq!(state.input_mode(), InputMode::Normal);

            // Re-open with Enter
            reduce(
                &mut state,
                Action::ConfirmSelection,
                now,
                &AppServices::stub(),
            );
            assert_eq!(state.input_mode(), InputMode::ConnectionError);
            assert!(state.connection_error.error_info.is_some());
        }

        #[test]
        fn toggle_details_flips_expanded_state() {
            let mut state = state_with_error();
            let now = Instant::now();
            assert!(!state.connection_error.details_expanded);

            reduce(
                &mut state,
                Action::ToggleConnectionErrorDetails,
                now,
                &AppServices::stub(),
            );
            assert!(state.connection_error.details_expanded);

            reduce(
                &mut state,
                Action::ToggleConnectionErrorDetails,
                now,
                &AppServices::stub(),
            );
            assert!(!state.connection_error.details_expanded);
        }

        #[test]
        fn copy_returns_clipboard_effect() {
            let mut state = state_with_error();
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::CopyConnectionError,
                now,
                &AppServices::stub(),
            );

            assert_eq!(effects.len(), 1);
            assert!(matches!(effects[0], Effect::CopyToClipboard { .. }));
        }

        #[test]
        fn copied_marks_feedback_visible() {
            let mut state = state_with_error();
            let now = Instant::now();

            reduce(
                &mut state,
                Action::ConnectionErrorCopied,
                now,
                &AppServices::stub(),
            );

            assert!(state.connection_error.is_copied_visible_at(now));
        }
    }

    mod confirm_selection_safety {
        use super::*;
        use crate::domain::{DatabaseMetadata, Table, TableSummary};

        fn stale_table_detail() -> Table {
            Table {
                schema: "public".to_string(),
                name: "old_table".to_string(),
                owner: None,
                columns: vec![],
                primary_key: None,
                foreign_keys: vec![],
                indexes: vec![],
                rls: None,
                triggers: vec![],
                row_count_estimate: None,
                comment: None,
            }
        }

        fn users_metadata() -> Arc<DatabaseMetadata> {
            Arc::new(DatabaseMetadata {
                database_name: "test".to_string(),
                schemas: vec![],
                table_summaries: vec![TableSummary::new(
                    "public".to_string(),
                    "users".to_string(),
                    Some(100),
                    false,
                )],
            })
        }

        #[test]
        fn confirm_selection_in_normal_mode_clears_stale_table_detail() {
            let now = Instant::now();
            let mut state = create_test_state();
            state.session.set_metadata(Some(users_metadata()));
            state
                .session
                .set_table_detail_raw(Some(stale_table_detail()));
            state.modal.set_mode(InputMode::Normal);
            state.ui.focused_pane = FocusedPane::Explorer;
            state.ui.set_explorer_selection(Some(0));

            reduce(
                &mut state,
                Action::ConfirmSelection,
                now,
                &AppServices::stub(),
            );

            assert!(state.session.table_detail().is_none());
        }

        #[test]
        fn confirm_selection_in_table_picker_mode_clears_stale_table_detail() {
            let now = Instant::now();
            let mut state = create_test_state();
            state.session.set_metadata(Some(users_metadata()));
            state
                .session
                .set_table_detail_raw(Some(stale_table_detail()));
            state.modal.set_mode(InputMode::TablePicker);
            state.ui.table_picker.set_selection(0);

            reduce(
                &mut state,
                Action::ConfirmSelection,
                now,
                &AppServices::stub(),
            );

            assert!(state.session.table_detail().is_none());
        }
    }

    mod effect_producing_actions {
        use super::*;
        use crate::domain::{DatabaseMetadata, MetadataState};

        #[test]
        fn load_metadata_with_dsn_returns_fetch_effect() {
            let mut state = create_test_state();
            state.session.dsn = Some("postgres://localhost/test".to_string());
            let now = Instant::now();

            let effects = reduce(&mut state, Action::LoadMetadata, now, &AppServices::stub());

            assert_eq!(effects.len(), 1);
            assert!(matches!(effects[0], Effect::FetchMetadata { .. }));
            assert!(matches!(
                state.session.metadata_state(),
                MetadataState::Loading
            ));
        }

        #[test]
        fn load_metadata_without_dsn_returns_no_effects() {
            let mut state = create_test_state();
            state.session.dsn = None;
            let now = Instant::now();

            let effects = reduce(&mut state, Action::LoadMetadata, now, &AppServices::stub());

            assert!(effects.is_empty());
        }

        #[test]
        fn reload_metadata_returns_sequence_effect() {
            let mut state = create_test_state();
            state.session.dsn = Some("postgres://localhost/test".to_string());
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::ReloadMetadata,
                now,
                &AppServices::stub(),
            );

            assert_eq!(effects.len(), 1);
            assert!(matches!(effects[0], Effect::Sequence(_)));

            if let Effect::Sequence(seq) = &effects[0] {
                assert_eq!(seq.len(), 3);
                assert!(matches!(seq[0], Effect::CacheInvalidate { .. }));
                assert!(matches!(seq[1], Effect::ClearCompletionEngineCache));
                assert!(matches!(seq[2], Effect::FetchMetadata { .. }));
            }
        }

        #[test]
        fn reload_metadata_sets_is_reloading_flag() {
            let mut state = create_test_state();
            state.session.dsn = Some("postgres://localhost/test".to_string());
            let now = Instant::now();

            reduce(
                &mut state,
                Action::ReloadMetadata,
                now,
                &AppServices::stub(),
            );

            assert!(state.session.is_reloading);
        }

        #[test]
        fn reload_then_metadata_loaded_shows_reloaded_message() {
            let mut state = create_test_state();
            state.session.dsn = Some("postgres://localhost/test".to_string());
            let now = Instant::now();

            // Trigger reload
            reduce(
                &mut state,
                Action::ReloadMetadata,
                now,
                &AppServices::stub(),
            );
            assert!(state.session.is_reloading);

            // Metadata loaded
            let metadata = DatabaseMetadata {
                database_name: "test".to_string(),
                schemas: vec![],
                table_summaries: vec![],
            };
            let action = Action::MetadataLoaded {
                dsn: "postgres://localhost/test".to_string(),
                run_id: 1,
                metadata: Arc::new(metadata),
            };
            reduce(&mut state, action, now, &AppServices::stub());

            // Check reloading flag is cleared and message is shown
            assert!(!state.session.is_reloading);
            assert_eq!(state.messages.last_success, Some("Reloaded!".to_string()));
        }

        #[test]
        fn execute_adhoc_with_dsn_returns_effect() {
            let mut state = create_test_state();
            state.session.dsn = Some("postgres://localhost/test".to_string());
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::ExecuteAdhoc("SELECT 1".to_string()),
                now,
                &AppServices::stub(),
            );

            assert_eq!(effects.len(), 1);
            assert!(matches!(effects[0], Effect::ExecuteAdhoc { .. }));
        }
    }

    mod er_diagram {
        use super::*;
        use crate::domain::DatabaseMetadata;
        use crate::model::er_state::ErStatus;

        #[test]
        fn er_open_while_rendering_returns_no_effects() {
            let mut state = create_test_state();
            state.er_preparation.status = ErStatus::Rendering;
            let now = Instant::now();

            let effects = reduce(&mut state, Action::ErOpenDiagram, now, &AppServices::stub());

            assert!(effects.is_empty());
        }

        #[test]
        fn always_emits_smart_refresh_even_with_pending_tables() {
            let mut state = create_test_state();
            state.session.dsn = Some("postgres://localhost/test".to_string());
            state.session.set_metadata(Some(Arc::new(DatabaseMetadata {
                database_name: "test".to_string(),
                schemas: vec![],
                table_summaries: vec![],
            })));
            let _ = state.sql_modal.begin_prefetch();
            state
                .er_preparation
                .pending_tables
                .insert("public.users".to_string());
            let now = Instant::now();

            let effects = reduce(&mut state, Action::ErOpenDiagram, now, &AppServices::stub());

            assert_eq!(state.er_preparation.status, ErStatus::Waiting);
            assert!(!state.sql_modal.is_prefetch_started());
            assert_eq!(effects.len(), 1);
            assert!(matches!(&effects[0], Effect::SmartErRefresh { .. }));
        }

        #[test]
        fn prefetch_started_true_emits_smart_refresh() {
            let mut state = create_test_state();
            state.session.dsn = Some("postgres://localhost/test".to_string());
            state.session.set_metadata(Some(Arc::new(DatabaseMetadata {
                database_name: "test".to_string(),
                schemas: vec![],
                table_summaries: vec![],
            })));
            let _ = state.sql_modal.begin_prefetch();
            let now = Instant::now();

            let effects = reduce(&mut state, Action::ErOpenDiagram, now, &AppServices::stub());

            assert!(!state.sql_modal.is_prefetch_started());
            assert_eq!(effects.len(), 1);
            assert!(matches!(&effects[0], Effect::SmartErRefresh { .. }));
        }

        #[test]
        fn no_prefetch_emits_smart_refresh() {
            let mut state = create_test_state();
            state.session.dsn = Some("postgres://localhost/test".to_string());
            state.session.set_metadata(Some(Arc::new(DatabaseMetadata {
                database_name: "test".to_string(),
                schemas: vec![],
                table_summaries: vec![],
            })));
            let now = Instant::now();

            let effects = reduce(&mut state, Action::ErOpenDiagram, now, &AppServices::stub());

            assert_eq!(state.er_preparation.status, ErStatus::Waiting);
            assert_eq!(effects.len(), 1);
            assert!(matches!(&effects[0], Effect::SmartErRefresh { .. }));
        }

        #[test]
        fn no_metadata_returns_error() {
            let mut state = create_test_state();
            let _ = state.sql_modal.begin_prefetch();
            let now = Instant::now();

            let effects = reduce(&mut state, Action::ErOpenDiagram, now, &AppServices::stub());

            assert!(state.messages.last_error.is_some());
            assert!(effects.is_empty());
        }

        #[test]
        fn metadata_failed_resets_er_waiting_to_idle() {
            let mut state = create_test_state();
            state.er_preparation.status = ErStatus::Waiting;
            let now = Instant::now();
            state.session.dsn = Some("postgres://localhost/test".to_string());
            let run_id = state.session.begin_metadata_refresh();
            let action = Action::MetadataFailed {
                dsn: "postgres://localhost/test".to_string(),
                run_id,
                error: DbOperationError::ConnectionFailed("connection refused".to_string()),
            };

            reduce(&mut state, action, now, &AppServices::stub());

            assert_eq!(state.er_preparation.status, ErStatus::Idle);
        }
    }

    mod table_detail_cached {
        use super::*;
        use crate::domain::Table;

        fn make_test_table() -> Box<Table> {
            Box::new(Table {
                schema: "public".to_string(),
                name: "users".to_string(),
                owner: None,
                columns: vec![],
                primary_key: None,
                indexes: vec![],
                foreign_keys: vec![],
                rls: None,
                triggers: vec![],
                row_count_estimate: None,
                comment: None,
            })
        }

        #[test]
        fn emits_cache_effect() {
            let mut state = create_test_state();
            state.session.dsn = Some("postgres://localhost/test".to_string());
            let run_id = state.sql_modal.begin_prefetch();
            state
                .sql_modal
                .prefetching_tables
                .insert("public.users".to_string());
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::TableDetailCached {
                    dsn: "postgres://localhost/test".to_string(),
                    run_id,
                    schema: "public".to_string(),
                    table: "users".to_string(),
                    detail: make_test_table(),
                },
                now,
                &AppServices::stub(),
            );

            assert!(!effects.is_empty());
            assert!(matches!(
                effects[0],
                Effect::CacheTableInCompletionEngine { .. }
            ));
            assert!(!state.sql_modal.prefetching_tables.contains("public.users"));
        }

        #[test]
        fn with_queue_returns_process_effect() {
            let mut state = create_test_state();
            state.session.dsn = Some("postgres://localhost/test".to_string());
            let run_id = state.sql_modal.begin_prefetch();
            state
                .sql_modal
                .prefetch_queue
                .push_back("public.orders".to_string());
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::TableDetailCached {
                    dsn: "postgres://localhost/test".to_string(),
                    run_id,
                    schema: "public".to_string(),
                    table: "users".to_string(),
                    detail: make_test_table(),
                },
                now,
                &AppServices::stub(),
            );

            assert!(
                effects
                    .iter()
                    .any(|e| matches!(e, Effect::ProcessPrefetchQueue { .. }))
            );
        }
    }

    mod connection_setup_validation {
        use crate::model::connection::setup::{ConnectionField, ConnectionSetupState};
        use crate::model::shared::text_input::TextInputState;
        use crate::update::helpers::{validate_all, validate_field};
        use rstest::rstest;

        fn setup_state() -> ConnectionSetupState {
            ConnectionSetupState::default()
        }

        #[rstest]
        #[case(ConnectionField::Host, "", true)]
        #[case(ConnectionField::Host, "  ", true)]
        #[case(ConnectionField::Host, "localhost", false)]
        #[case(ConnectionField::Database, "", true)]
        #[case(ConnectionField::Database, "mydb", false)]
        #[case(ConnectionField::User, "", true)]
        #[case(ConnectionField::User, "postgres", false)]
        fn required_field_validation(
            #[case] field: ConnectionField,
            #[case] value: &str,
            #[case] has_error: bool,
        ) {
            let mut state = setup_state();
            match field {
                ConnectionField::Host => state.host.set_content(value.to_string()),
                ConnectionField::Database => state.database.set_content(value.to_string()),
                ConnectionField::User => state.user.set_content(value.to_string()),
                _ => {}
            }

            validate_field(&mut state, field);

            assert_eq!(state.validation_errors.contains_key(&field), has_error);
        }

        #[rstest]
        #[case("")]
        #[case("abc")]
        fn port_validation_invalid_format(#[case] value: &str) {
            let mut state = setup_state();
            state.port.set_content(value.to_string());

            validate_field(&mut state, ConnectionField::Port);

            assert!(state.validation_errors.contains_key(&ConnectionField::Port));
        }

        #[rstest]
        #[case("0")]
        #[case("65536")]
        #[case("99999")]
        fn port_validation_out_of_range(#[case] value: &str) {
            let mut state = setup_state();
            state.port.set_content(value.to_string());

            validate_field(&mut state, ConnectionField::Port);

            assert!(state.validation_errors.contains_key(&ConnectionField::Port));
        }

        #[rstest]
        #[case("1")]
        #[case("5432")]
        #[case("65535")]
        fn port_validation_valid_range(#[case] value: &str) {
            let mut state = setup_state();
            state.port.set_content(value.to_string());

            validate_field(&mut state, ConnectionField::Port);

            assert!(!state.validation_errors.contains_key(&ConnectionField::Port));
        }

        #[rstest]
        #[case(ConnectionField::Password)]
        #[case(ConnectionField::SslMode)]
        fn optional_fields_never_error(#[case] field: ConnectionField) {
            let mut state = setup_state();
            state.password = TextInputState::default();

            validate_field(&mut state, field);

            assert!(!state.validation_errors.contains_key(&field));
        }

        #[test]
        fn validate_all_checks_all_required_fields() {
            let mut state = setup_state();
            state.host = TextInputState::default();
            state.port.set_content("invalid".to_string());
            state.database = TextInputState::default();
            state.user = TextInputState::default();

            validate_all(&mut state);

            assert!(state.validation_errors.contains_key(&ConnectionField::Host));
            assert!(state.validation_errors.contains_key(&ConnectionField::Port));
            assert!(
                state
                    .validation_errors
                    .contains_key(&ConnectionField::Database)
            );
            assert!(state.validation_errors.contains_key(&ConnectionField::User));
            assert!(
                !state
                    .validation_errors
                    .contains_key(&ConnectionField::Password)
            );
            assert!(
                !state
                    .validation_errors
                    .contains_key(&ConnectionField::SslMode)
            );
        }
    }

    mod connection_setup_transitions {
        use super::*;
        use crate::domain::ConnectionId;

        #[test]
        fn save_completed_sets_dsn_and_returns_fetch_effect() {
            let mut state = create_test_state();
            state.modal.set_mode(InputMode::ConnectionSetup);
            state.connection_setup.is_first_run = true;
            state
                .connection_setup
                .host
                .set_content("db.example.com".to_string());
            state.connection_setup.port.set_content("5432".to_string());
            state
                .connection_setup
                .database
                .set_content("mydb".to_string());
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::ConnectionSaveCompleted(ConnectionTarget {
                    id: ConnectionId::new(),
                    dsn: "postgres://db.example.com/mydb".to_string(),
                    name: "Test Connection".to_string(),
                }),
                now,
                &AppServices::stub(),
            );

            assert!(!state.connection_setup.is_first_run);
            assert_eq!(
                state.session.dsn,
                Some("postgres://db.example.com/mydb".to_string())
            );
            assert_eq!(
                state.session.active_connection_name,
                Some("Test Connection".to_string())
            );
            assert_eq!(state.input_mode(), InputMode::Normal);
            assert_eq!(effects.len(), 1);
            assert!(matches!(effects[0], Effect::FetchMetadata { .. }));
        }

        #[test]
        fn save_failed_sets_error_message() {
            let mut state = create_test_state();
            state.modal.set_mode(InputMode::ConnectionSetup);
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::ConnectionSaveFailed(ConnectionSaveError::Store(ConnectionStoreError::Io(
                    Arc::new(std::io::Error::other("Write error")),
                ))),
                now,
                &AppServices::stub(),
            );

            assert!(state.messages.last_error.is_some());
            assert!(effects.is_empty());
        }

        #[test]
        fn cancel_on_first_run_opens_confirm_dialog() {
            let mut state = create_test_state();
            state.modal.set_mode(InputMode::ConnectionSetup);
            state.connection_setup.is_first_run = true;
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::ConnectionSetupCancel,
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.input_mode(), InputMode::ConfirmDialog);
            assert!(matches!(
                state.confirm_dialog.intent(),
                Some(&crate::model::shared::confirm_dialog::ConfirmIntent::QuitNoConnection)
            ));
            assert!(effects.is_empty());
        }

        #[test]
        fn cancel_after_save_returns_to_normal_and_dispatches_try_connect() {
            let mut state = create_test_state();
            state.modal.set_mode(InputMode::ConnectionSetup);
            state.connection_setup.is_first_run = false;
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::ConnectionSetupCancel,
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.input_mode(), InputMode::Normal);
            assert_eq!(effects.len(), 1);
            assert!(matches!(effects[0], Effect::DispatchActions(_)));
        }
    }

    mod confirm_dialog_transitions {
        use super::*;
        use crate::model::shared::confirm_dialog::ConfirmIntent;

        #[test]
        fn confirm_quit_no_connection_sets_should_quit() {
            let mut state = create_test_state();
            state.modal.set_mode(InputMode::ConfirmDialog);
            state
                .confirm_dialog
                .open("", "", ConfirmIntent::QuitNoConnection);
            let now = Instant::now();

            reduce(
                &mut state,
                Action::ConfirmDialogConfirm,
                now,
                &AppServices::stub(),
            );

            assert!(state.should_quit);
            assert!(state.confirm_dialog.intent().is_none());
        }

        #[test]
        fn cancel_quit_no_connection_restores_connection_setup_synchronously() {
            let mut state = create_test_state();
            state.modal.set_mode(InputMode::ConfirmDialog);
            state
                .confirm_dialog
                .open("", "", ConfirmIntent::QuitNoConnection);
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::ConfirmDialogCancel,
                now,
                &AppServices::stub(),
            );

            assert!(state.confirm_dialog.intent().is_none());
            assert_eq!(state.input_mode(), InputMode::ConnectionSetup);
            assert!(effects.is_empty());
        }

        #[test]
        fn confirm_delete_write_then_success_preserves_delete_context() {
            use crate::policy::write::write_guardrails::{
                GuardrailDecision, RiskLevel, TargetSummary, WriteOperation, WritePreview,
            };

            let mut state = create_test_state();
            state.session.dsn = Some("postgres://localhost/test".to_string());
            state.modal.set_mode(InputMode::ConfirmDialog);

            let delete_sql = "DELETE FROM \"public\".\"users\"\nWHERE \"id\" = '2';".to_string();
            state.result_interaction.set_write_preview(WritePreview {
                operation: WriteOperation::Delete,
                sql: delete_sql.clone(),
                target_summary: TargetSummary {
                    schema: "public".to_string(),
                    table: "users".to_string(),
                    key_values: vec![("id".to_string(), "2".to_string())],
                },
                diff: vec![],
                guardrail: GuardrailDecision {
                    risk_level: RiskLevel::Low,
                    blocked: false,
                    reason: None,
                    target_summary: None,
                },
            });
            state.query.set_delete_refresh_target(0, Some(499), 1);
            state.confirm_dialog.open(
                "",
                "",
                ConfirmIntent::ExecuteWrite {
                    sql: delete_sql,
                    blocked: false,
                },
            );

            let now = Instant::now();
            let effects = reduce(
                &mut state,
                Action::ConfirmDialogConfirm,
                now,
                &AppServices::stub(),
            );

            // Preview must survive confirm for ExecuteWriteSucceeded to detect Delete
            assert!(state.result_interaction.pending_write_preview().is_some());
            assert!(state.query.pending_delete_refresh_target().is_some());
            assert_eq!(effects.len(), 1);
            assert!(matches!(&effects[0], Effect::ExecuteWrite { .. }));

            // Simulate success
            let effects = reduce(
                &mut state,
                Action::ExecuteWriteSucceeded {
                    dsn: "postgres://localhost/test".to_string(),
                    run_id: 1,
                    affected_rows: 1,
                },
                now,
                &AppServices::stub(),
            );

            // After success, preview is cleaned up and delete message is shown
            assert!(state.result_interaction.pending_write_preview().is_none());
            assert!(
                state
                    .messages
                    .last_success
                    .as_deref()
                    .unwrap()
                    .contains("Deleted")
            );
            assert_eq!(effects.len(), 1);
            assert!(matches!(&effects[0], Effect::ExecutePreview { .. }));
        }

        #[test]
        fn confirm_delete_write_then_failure_returns_to_normal() {
            use crate::policy::write::write_guardrails::{
                GuardrailDecision, RiskLevel, TargetSummary, WriteOperation, WritePreview,
            };

            let mut state = create_test_state();
            state.session.dsn = Some("postgres://localhost/test".to_string());
            state.modal.set_mode(InputMode::ConfirmDialog);

            state.result_interaction.set_write_preview(WritePreview {
                operation: WriteOperation::Delete,
                sql: "DELETE FROM t WHERE id='1'".to_string(),
                target_summary: TargetSummary {
                    schema: "public".to_string(),
                    table: "t".to_string(),
                    key_values: vec![],
                },
                diff: vec![],
                guardrail: GuardrailDecision {
                    risk_level: RiskLevel::Low,
                    blocked: false,
                    reason: None,
                    target_summary: None,
                },
            });
            state.confirm_dialog.open(
                "",
                "",
                ConfirmIntent::ExecuteWrite {
                    sql: "DELETE FROM t WHERE id='1'".to_string(),
                    blocked: false,
                },
            );

            let now = Instant::now();
            reduce(
                &mut state,
                Action::ConfirmDialogConfirm,
                now,
                &AppServices::stub(),
            );

            // Simulate failure — must detect Delete and return to Normal (not CellEdit)
            reduce(
                &mut state,
                Action::ExecuteWriteFailed {
                    dsn: "postgres://localhost/test".to_string(),
                    run_id: 1,
                    error: DbOperationError::QueryFailed("connection lost".to_string()),
                },
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.input_mode(), InputMode::Normal);
            assert!(state.result_interaction.pending_write_preview().is_none());
        }
    }

    mod connection_state_tests {
        use super::*;
        use crate::domain::{ConnectionId, DatabaseMetadata, MetadataState};
        use crate::model::connection::state::ConnectionState;

        #[test]
        fn try_connect_with_dsn_starts_connecting() {
            let mut state = create_test_state();
            state.session.dsn = Some("postgres://localhost/test".to_string());
            state
                .session
                .set_connection_state(ConnectionState::NotConnected);
            state.modal.set_mode(InputMode::Normal);
            let now = Instant::now();

            let effects = reduce(&mut state, Action::TryConnect, now, &AppServices::stub());

            assert!(state.session.connection_state().is_connecting());
            assert!(matches!(
                state.session.metadata_state(),
                MetadataState::Loading
            ));
            assert_eq!(effects.len(), 1);
            assert!(matches!(effects[0], Effect::FetchMetadata { .. }));
        }

        #[test]
        fn try_connect_without_dsn_does_nothing() {
            let mut state = create_test_state();
            state.session.dsn = None;
            state
                .session
                .set_connection_state(ConnectionState::NotConnected);
            state.modal.set_mode(InputMode::Normal);
            let now = Instant::now();

            let effects = reduce(&mut state, Action::TryConnect, now, &AppServices::stub());

            assert!(state.session.connection_state().is_not_connected());
            assert!(effects.is_empty());
        }

        #[test]
        fn try_connect_when_already_connecting_is_noop() {
            let mut state = create_test_state();
            state.session.dsn = Some("postgres://localhost/test".to_string());
            state
                .session
                .set_connection_state(ConnectionState::Connecting);
            state.modal.set_mode(InputMode::Normal);
            let now = Instant::now();

            let effects = reduce(&mut state, Action::TryConnect, now, &AppServices::stub());

            assert!(state.session.connection_state().is_connecting());
            assert!(effects.is_empty());
        }

        #[test]
        fn try_connect_when_already_connected_is_noop() {
            let mut state = create_test_state();
            state.session.dsn = Some("postgres://localhost/test".to_string());
            state
                .session
                .set_connection_state(ConnectionState::Connected);
            state.modal.set_mode(InputMode::Normal);
            let now = Instant::now();

            let effects = reduce(&mut state, Action::TryConnect, now, &AppServices::stub());

            assert!(state.session.connection_state().is_connected());
            assert!(effects.is_empty());
        }

        #[test]
        fn try_connect_when_not_in_normal_mode_is_noop() {
            let mut state = create_test_state();
            state.session.dsn = Some("postgres://localhost/test".to_string());
            state
                .session
                .set_connection_state(ConnectionState::NotConnected);
            state.modal.set_mode(InputMode::ConnectionSetup);
            let now = Instant::now();

            let effects = reduce(&mut state, Action::TryConnect, now, &AppServices::stub());

            assert!(state.session.connection_state().is_not_connected());
            assert!(effects.is_empty());
        }

        #[test]
        fn metadata_loaded_sets_connected() {
            let mut state = create_test_state();
            state
                .session
                .set_connection_state(ConnectionState::Connecting);
            state.session.dsn = Some("postgres://localhost/test".to_string());
            let run_id = state.session.begin_metadata_refresh();
            let metadata = DatabaseMetadata {
                database_name: "test".to_string(),
                schemas: vec![],
                table_summaries: vec![],
            };
            let now = Instant::now();

            reduce(
                &mut state,
                Action::MetadataLoaded {
                    dsn: "postgres://localhost/test".to_string(),
                    run_id,
                    metadata: Arc::new(metadata),
                },
                now,
                &AppServices::stub(),
            );

            assert!(state.session.connection_state().is_connected());
            assert!(matches!(
                state.session.metadata_state(),
                MetadataState::Loaded
            ));
        }

        #[test]
        fn metadata_failed_sets_failed() {
            let mut state = create_test_state();
            state
                .session
                .set_connection_state(ConnectionState::Connecting);
            state.session.dsn = Some("postgres://localhost/test".to_string());
            let run_id = state.session.begin_metadata_refresh();
            let now = Instant::now();

            reduce(
                &mut state,
                Action::MetadataFailed {
                    dsn: "postgres://localhost/test".to_string(),
                    run_id,
                    error: DbOperationError::ConnectionFailed("connection refused".to_string()),
                },
                now,
                &AppServices::stub(),
            );

            assert!(state.session.connection_state().is_failed());
            assert!(matches!(
                state.session.metadata_state(),
                MetadataState::Error(_)
            ));
        }

        #[test]
        fn metadata_failed_preserves_connected_state() {
            // When already connected, metadata failure should preserve connection state
            // (metadata-only failure, e.g., permission denied on schema)
            let mut state = create_test_state();
            state
                .session
                .set_connection_state(ConnectionState::Connected);
            state.session.dsn = Some("postgres://localhost/test".to_string());
            state.session.set_metadata_state(MetadataState::Loaded);
            let run_id = state.session.begin_metadata_refresh();
            let now = Instant::now();

            reduce(
                &mut state,
                Action::MetadataFailed {
                    dsn: "postgres://localhost/test".to_string(),
                    run_id,
                    error: DbOperationError::QueryFailed("permission denied".to_string()),
                },
                now,
                &AppServices::stub(),
            );

            // Connection state should remain Connected
            assert!(state.session.connection_state().is_connected());
            // But metadata state should be Error
            assert!(matches!(
                state.session.metadata_state(),
                MetadataState::Error(_)
            ));
        }

        #[test]
        fn reenter_connection_setup_resets_all_states() {
            let mut state = create_test_state();
            state.session.set_connection_state(ConnectionState::Failed);
            state
                .session
                .set_metadata_state(MetadataState::Error("error".to_string()));
            state.modal.set_mode(InputMode::ConnectionError);
            let now = Instant::now();

            reduce(
                &mut state,
                Action::ReenterConnectionSetup,
                now,
                &AppServices::stub(),
            );

            assert!(state.session.connection_state().is_not_connected());
            assert!(matches!(
                state.session.metadata_state(),
                MetadataState::NotLoaded
            ));
            assert_eq!(state.input_mode(), InputMode::ConnectionSetup);
        }

        #[test]
        fn reenter_connection_setup_preserves_form_values() {
            let mut state = create_test_state();
            state
                .connection_setup
                .host
                .set_content("custom-host".to_string());
            state.connection_setup.port.set_content("5433".to_string());
            state
                .connection_setup
                .database
                .set_content("mydb".to_string());
            state.connection_setup.user.set_content("admin".to_string());
            state
                .connection_setup
                .password
                .set_content("secret".to_string());
            state.session.set_connection_state(ConnectionState::Failed);
            let now = Instant::now();

            reduce(
                &mut state,
                Action::ReenterConnectionSetup,
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.connection_setup.host.content(), "custom-host");
            assert_eq!(state.connection_setup.port.content(), "5433");
            assert_eq!(state.connection_setup.database.content(), "mydb");
            assert_eq!(state.connection_setup.user.content(), "admin");
            assert_eq!(state.connection_setup.password.content(), "secret");
        }

        #[test]
        fn connection_save_completed_sets_connecting_and_loading() {
            let mut state = create_test_state();
            state
                .session
                .set_connection_state(ConnectionState::NotConnected);
            state.session.set_metadata_state(MetadataState::NotLoaded);
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::ConnectionSaveCompleted(ConnectionTarget {
                    id: ConnectionId::new(),
                    dsn: "postgres://localhost/test".to_string(),
                    name: "Test".to_string(),
                }),
                now,
                &AppServices::stub(),
            );

            assert!(state.session.connection_state().is_connecting());
            assert!(matches!(
                state.session.metadata_state(),
                MetadataState::Loading
            ));
            assert_eq!(effects.len(), 1);
            assert!(matches!(effects[0], Effect::FetchMetadata { .. }));
        }

        #[test]
        fn switch_connection_saves_current_and_fetches_new() {
            let mut state = create_test_state();
            let conn_a = ConnectionId::new();
            let conn_b = ConnectionId::new();

            state.session.active_connection_id = Some(conn_a.clone());
            state
                .session
                .set_connection_state(ConnectionState::Connected);
            state.ui.explorer_selected = 5;
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::SwitchConnection(ConnectionTarget {
                    id: conn_b.clone(),
                    dsn: "postgres://localhost/other".to_string(),
                    name: "Other".to_string(),
                }),
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.session.active_connection_id, Some(conn_b));
            assert!(state.session.connection_state().is_connecting());
            assert!(state.connection_caches.get(&conn_a).is_some());
            assert_eq!(
                state
                    .connection_caches
                    .get(&conn_a)
                    .unwrap()
                    .explorer_selected,
                5
            );
            assert_eq!(effects.len(), 2);
        }

        #[test]
        fn switch_connection_restores_from_cache() {
            use crate::model::shared::inspector_tab::InspectorTab;

            let mut state = create_test_state();
            let conn_a = ConnectionId::new();
            let conn_b = ConnectionId::new();

            state.session.active_connection_id = Some(conn_a);
            state
                .session
                .set_connection_state(ConnectionState::Connected);
            state.ui.explorer_selected = 3;

            let cached = crate::model::connection::cache::ConnectionCache {
                explorer_selected: 10,
                inspector_tab: InspectorTab::Indexes,
                metadata: Some(Arc::new(DatabaseMetadata {
                    database_name: "cached_db".to_string(),
                    schemas: vec![],
                    table_summaries: vec![],
                })),
                ..Default::default()
            };
            state.connection_caches.save(&conn_b, cached);
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::SwitchConnection(ConnectionTarget {
                    id: conn_b.clone(),
                    dsn: "postgres://localhost/cached".to_string(),
                    name: "Cached".to_string(),
                }),
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.session.active_connection_id, Some(conn_b));
            assert!(state.session.connection_state().is_connected());
            assert_eq!(state.ui.explorer_selected, 10);
            assert_eq!(state.ui.inspector_tab, InspectorTab::Indexes);
            assert_eq!(
                state.session.metadata().as_ref().unwrap().database_name,
                "cached_db"
            );
            assert_eq!(effects.len(), 1);
        }
    }

    mod er_table_picker {
        use super::*;
        use crate::domain::{DatabaseMetadata, TableSummary};

        fn state_with_metadata() -> AppState {
            let mut state = create_test_state();
            state.session.set_metadata(Some(Arc::new(DatabaseMetadata {
                database_name: "test".to_string(),
                schemas: vec![],
                table_summaries: vec![
                    TableSummary::new("public".to_string(), "users".to_string(), None, false),
                    TableSummary::new("public".to_string(), "posts".to_string(), None, false),
                ],
            })));
            state
        }

        #[test]
        fn open_clears_selections_and_filter() {
            let mut state = state_with_metadata();
            state.ui.er_picker.insert_filter_str("old");
            state
                .ui
                .er_selected_tables
                .insert("public.users".to_string());
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::OpenModal(ModalKind::ErTablePicker),
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.input_mode(), InputMode::ErTablePicker);
            assert!(state.ui.er_picker.filter_input().content().is_empty());
            assert!(state.ui.er_selected_tables.is_empty());
            assert!(effects.is_empty());
        }

        #[test]
        fn open_without_metadata_sets_pending() {
            let mut state = create_test_state();
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::OpenModal(ModalKind::ErTablePicker),
                now,
                &AppServices::stub(),
            );

            assert!(state.ui.pending_er_picker);
            assert!(state.messages.last_success.is_some());
            assert_ne!(state.input_mode(), InputMode::ErTablePicker);
            assert!(effects.is_empty());
        }

        fn sample_metadata() -> Arc<DatabaseMetadata> {
            Arc::new(DatabaseMetadata {
                database_name: "test_db".to_string(),
                schemas: vec![],
                table_summaries: vec![TableSummary::new(
                    "public".to_string(),
                    "users".to_string(),
                    Some(100),
                    false,
                )],
            })
        }

        fn metadata_loaded_action(state: &mut AppState) -> Action {
            state.session.dsn = Some("postgres://localhost/test".to_string());
            let run_id = state.session.begin_metadata_refresh();
            Action::MetadataLoaded {
                dsn: "postgres://localhost/test".to_string(),
                run_id,
                metadata: sample_metadata(),
            }
        }

        fn has_open_er_dispatch(effects: &[Effect]) -> bool {
            effects.iter().any(|e| {
                matches!(e, Effect::DispatchActions(actions)
                    if actions.iter().any(|a| matches!(a, Action::OpenModal(ModalKind::ErTablePicker))))
            })
        }

        #[test]
        fn metadata_loaded_with_pending_dispatches_open() {
            let mut state = create_test_state();
            state.ui.pending_er_picker = true;
            state.modal.set_mode(InputMode::Normal);
            let now = Instant::now();
            let action = metadata_loaded_action(&mut state);

            let effects = reduce(&mut state, action, now, &AppServices::stub());

            assert!(!state.ui.pending_er_picker);
            assert!(has_open_er_dispatch(&effects));
        }

        #[test]
        fn metadata_loaded_without_pending_does_not_dispatch_open() {
            let mut state = create_test_state();
            state.ui.pending_er_picker = false;
            let now = Instant::now();
            let action = metadata_loaded_action(&mut state);

            let effects = reduce(&mut state, action, now, &AppServices::stub());

            assert!(!has_open_er_dispatch(&effects));
        }

        #[test]
        fn metadata_loaded_with_pending_but_non_normal_mode_discards() {
            let mut state = create_test_state();
            state.ui.pending_er_picker = true;
            state.modal.set_mode(InputMode::SqlModal);
            let now = Instant::now();
            let action = metadata_loaded_action(&mut state);

            let effects = reduce(&mut state, action, now, &AppServices::stub());

            assert!(!state.ui.pending_er_picker);
            assert!(!has_open_er_dispatch(&effects));
        }

        #[test]
        fn close_er_table_picker_returns_to_normal() {
            let mut state = state_with_metadata();
            state.modal.set_mode(InputMode::ErTablePicker);

            state.ui.er_picker.insert_filter_str("test");
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::CloseModal(ModalKind::ErTablePicker),
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.input_mode(), InputMode::Normal);
            assert!(state.ui.er_picker.filter_input().content().is_empty());
            assert!(effects.is_empty());
        }

        #[test]
        fn confirm_with_selected_tables_sets_target_and_returns_dispatch() {
            let mut state = state_with_metadata();
            state.modal.set_mode(InputMode::ErTablePicker);

            state
                .ui
                .er_selected_tables
                .insert("public.users".to_string());
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::ErConfirmSelection,
                now,
                &AppServices::stub(),
            );

            assert_eq!(
                state.er_preparation.target_tables,
                vec!["public.users".to_string()]
            );
            assert_eq!(state.input_mode(), InputMode::Normal);
            assert_eq!(effects.len(), 1);
            assert!(matches!(effects[0], Effect::DispatchActions(_)));
        }

        #[test]
        fn confirm_with_no_selection_returns_error() {
            let mut state = state_with_metadata();
            state.modal.set_mode(InputMode::ErTablePicker);

            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::ErConfirmSelection,
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.input_mode(), InputMode::ErTablePicker);
            assert!(state.messages.last_error.is_some());
            assert!(effects.is_empty());
        }

        #[test]
        fn target_tables_survive_er_open() {
            let mut state = state_with_metadata();
            state.session.dsn = Some("postgres://localhost/test".to_string());
            let _ = state.sql_modal.begin_prefetch();
            state.er_preparation.target_tables = vec!["public.users".to_string()];
            let now = Instant::now();

            let effects = reduce(&mut state, Action::ErOpenDiagram, now, &AppServices::stub());

            assert_eq!(effects.len(), 1);
            assert!(matches!(&effects[0], Effect::SmartErRefresh { .. }));
            assert_eq!(
                state.er_preparation.target_tables,
                vec!["public.users".to_string()]
            );
        }

        #[test]
        fn prefetch_complete_dispatches_er_generate() {
            use crate::model::er_state::ErStatus;

            let mut state = state_with_metadata();
            state.session.dsn = Some("postgres://localhost/test".to_string());
            let run_id = state.sql_modal.begin_prefetch();
            state.er_preparation.status = ErStatus::Waiting;
            state.er_preparation.total_tables = 1;
            state.er_preparation.fk_expanded = true;
            state
                .er_preparation
                .pending_tables
                .insert("public.users".to_string());
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::TableDetailAlreadyCached {
                    dsn: "postgres://localhost/test".to_string(),
                    run_id,
                    schema: "public".to_string(),
                    table: "users".to_string(),
                },
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.er_preparation.status, ErStatus::Idle);
            assert!(effects.iter().any(|e| {
                matches!(e, Effect::DispatchActions(actions)
                    if actions.iter().any(|a| matches!(a, Action::ErGenerateFromCache)))
            }));
        }

        #[test]
        fn prefetch_complete_with_failures_does_not_auto_open() {
            use crate::model::er_state::ErStatus;

            let mut state = state_with_metadata();
            state.session.dsn = Some("postgres://localhost/test".to_string());
            let run_id = state.sql_modal.begin_prefetch();
            state.er_preparation.status = ErStatus::Waiting;
            state.er_preparation.total_tables = 2;
            state.er_preparation.fk_expanded = true;
            state
                .er_preparation
                .failed_tables
                .insert("public.posts".to_string(), "timeout".to_string());
            state
                .er_preparation
                .pending_tables
                .insert("public.users".to_string());
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::TableDetailAlreadyCached {
                    dsn: "postgres://localhost/test".to_string(),
                    run_id,
                    schema: "public".to_string(),
                    table: "users".to_string(),
                },
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.er_preparation.status, ErStatus::Idle);
            assert!(!effects.iter().any(|e| {
                matches!(e, Effect::DispatchActions(actions)
                    if actions.iter().any(|a| matches!(a, Action::ErOpenDiagram)))
            }));
            assert!(state.messages.last_error.is_some());
        }
    }

    mod pagination_integration {
        use super::*;
        use crate::domain::{DatabaseMetadata, QueryResult, QuerySource, TableSummary};
        use crate::model::browse::query_execution::PREVIEW_PAGE_SIZE;
        use std::sync::Arc;

        fn state_after_confirm_and_complete() -> (AppState, Instant) {
            let mut state = create_test_state();
            state.session.dsn = Some("postgres://localhost/test".to_string());
            let now = Instant::now();

            // Load metadata with a table
            let metadata = DatabaseMetadata {
                database_name: "test".to_string(),
                schemas: vec![],
                table_summaries: vec![TableSummary::new(
                    "public".to_string(),
                    "users".to_string(),
                    Some(1200),
                    false,
                )],
            };
            let run_id = state.session.begin_metadata_refresh();
            reduce(
                &mut state,
                Action::MetadataLoaded {
                    dsn: "postgres://localhost/test".to_string(),
                    run_id,
                    metadata: Arc::new(metadata),
                },
                now,
                &AppServices::stub(),
            );

            // ConfirmSelection from Normal mode (explorer focused)
            state.modal.set_mode(InputMode::Normal);
            state.ui.focused_pane = FocusedPane::Explorer;
            state.ui.explorer_selected = 0;
            let effects = reduce(
                &mut state,
                Action::ConfirmSelection,
                now,
                &AppServices::stub(),
            );

            // Extract the dispatched ExecutePreview action and run it
            let dispatch_actions: Vec<Action> = effects
                .into_iter()
                .filter_map(|e| match e {
                    Effect::DispatchActions(actions) => Some(actions),
                    _ => None,
                })
                .flatten()
                .collect();
            for action in dispatch_actions {
                reduce(&mut state, action, now, &AppServices::stub());
            }

            // Simulate QueryCompleted with a full page of results
            let current_gen = state.session.selection_generation();
            let result = Arc::new(QueryResult::success(
                String::new(),
                vec!["id".to_string()],
                vec![vec!["1".to_string()]; PREVIEW_PAGE_SIZE],
                10,
                QuerySource::Preview,
            ));
            let run_id = state.query.begin_running(now);
            reduce(
                &mut state,
                Action::QueryCompleted {
                    dsn: "postgres://localhost/test".to_string(),
                    run_id,
                    result,
                    generation: current_gen,
                    target_page: Some(0),
                },
                now,
                &AppServices::stub(),
            );

            (state, now)
        }

        #[test]
        fn confirm_selection_initializes_pagination_via_dispatch() {
            let (state, _now) = state_after_confirm_and_complete();

            assert_eq!(state.query.pagination.schema, "public");
            assert_eq!(state.query.pagination.table, "users");
            assert_eq!(state.query.pagination.total_rows_estimate, Some(1200));
            assert_eq!(state.query.pagination.current_page, 0);
            assert!(!state.query.pagination.reached_end);
        }

        #[test]
        fn next_page_after_confirm_emits_correct_offset() {
            let (mut state, now) = state_after_confirm_and_complete();

            let effects = reduce(
                &mut state,
                Action::ResultNextPage,
                now,
                &AppServices::stub(),
            );

            let preview_effect = effects
                .iter()
                .find(|e| matches!(e, Effect::ExecutePreview { .. }));
            assert!(preview_effect.is_some());
            if let Some(Effect::ExecutePreview {
                offset,
                target_page,
                schema,
                table,
                ..
            }) = preview_effect
            {
                assert_eq!(*offset, PREVIEW_PAGE_SIZE);
                assert_eq!(*target_page, 1);
                assert_eq!(schema, "public");
                assert_eq!(table, "users");
            }
        }

        #[test]
        fn prev_page_after_confirm_flows_through_result_to_query() {
            let (mut state, now) = state_after_confirm_and_complete();
            state.query.pagination.current_page = 1;
            state.query.pagination.reached_end = true;

            let effects = reduce(
                &mut state,
                Action::ResultPrevPage,
                now,
                &AppServices::stub(),
            );

            let preview_effect = effects
                .iter()
                .find(|e| matches!(e, Effect::ExecutePreview { .. }));
            assert!(preview_effect.is_some());
            if let Some(Effect::ExecutePreview {
                offset,
                target_page,
                schema,
                table,
                ..
            }) = preview_effect
            {
                assert_eq!(*offset, 0);
                assert_eq!(*target_page, 0);
                assert_eq!(schema, "public");
                assert_eq!(table, "users");
            }
            assert!(!state.query.pagination.reached_end);
        }
    }

    mod command_palette {
        use super::*;
        use crate::model::shared::settings::KeymapPreset;
        use crate::update::input::palette::palette_commands;
        use rstest::rstest;

        fn state_in_palette_mode(preset: KeymapPreset) -> AppState {
            let mut state = create_test_state();
            state.settings.load_keymap_preset(preset);
            state.modal.set_mode(InputMode::CommandPalette);

            state
        }

        fn palette_index_of(state: &AppState, target: impl Fn(&Action) -> bool) -> usize {
            palette_commands(state.settings.saved_keymap_preset())
                .enumerate()
                .find(|(_, kb)| target(&kb.action))
                .map(|(i, _)| i)
                .expect("action must exist in palette")
        }

        fn same_palette_action(left: &Action, right: &Action) -> bool {
            match (left, right) {
                (Action::OpenModal(a), Action::OpenModal(b))
                | (Action::CloseModal(a), Action::CloseModal(b))
                | (Action::ToggleModal(a), Action::ToggleModal(b)) => a == b,
                _ => std::mem::discriminant(left) == std::mem::discriminant(right),
            }
        }

        #[rstest]
        #[case(
            KeymapPreset::Default,
            Action::ToggleModal(ModalKind::Help),
            InputMode::Help
        )]
        #[case(
            KeymapPreset::Default,
            Action::OpenModal(ModalKind::TablePicker),
            InputMode::TablePicker
        )]
        #[case(
            KeymapPreset::Default,
            Action::OpenModal(ModalKind::SqlModal),
            InputMode::SqlModal
        )]
        #[case(
            KeymapPreset::Ide,
            Action::ToggleModal(ModalKind::Help),
            InputMode::Help
        )]
        #[case(
            KeymapPreset::Ide,
            Action::OpenModal(ModalKind::TablePicker),
            InputMode::TablePicker
        )]
        #[case(
            KeymapPreset::Ide,
            Action::OpenModal(ModalKind::SqlModal),
            InputMode::SqlModal
        )]
        fn confirm_selection_applies_sub_action(
            #[case] preset: KeymapPreset,
            #[case] target_action: Action,
            #[case] expected_mode: InputMode,
        ) {
            let mut state = state_in_palette_mode(preset);
            let entry_index = palette_index_of(&state, |a| same_palette_action(a, &target_action));
            state.ui.table_picker.set_selection(entry_index);
            let now = Instant::now();

            reduce(
                &mut state,
                Action::ConfirmSelection,
                now,
                &AppServices::stub(),
            );

            assert_eq!(state.input_mode(), expected_mode);
        }

        #[test]
        fn confirm_selection_with_reload_emits_sequence_effect() {
            let mut state = state_in_palette_mode(KeymapPreset::Ide);
            let entry_index = palette_index_of(&state, |a| matches!(a, Action::ReloadMetadata));
            state.session.dsn = Some("postgres://localhost/test".to_string());
            state.ui.table_picker.set_selection(entry_index);
            let now = Instant::now();

            let effects = reduce(
                &mut state,
                Action::ConfirmSelection,
                now,
                &AppServices::stub(),
            );

            assert!(
                effects.iter().any(|e| matches!(e, Effect::Sequence(_))),
                "expected Sequence effect for ReloadMetadata, got {effects:?}"
            );
        }

        #[test]
        fn confirm_selection_open_connection_selector_closes_palette() {
            let mut state = state_in_palette_mode(KeymapPreset::Ide);
            let entry_index = palette_index_of(&state, |a| {
                matches!(a, Action::OpenModal(ModalKind::ConnectionSelector))
            });

            state.ui.table_picker.set_selection(entry_index);
            let now = Instant::now();

            reduce(
                &mut state,
                Action::ConfirmSelection,
                now,
                &AppServices::stub(),
            );

            assert_ne!(
                state.input_mode(),
                InputMode::CommandPalette,
                "palette must be closed after confirm"
            );
        }
    }

    mod operator_pending {
        use super::*;

        #[test]
        fn yank_pending_reset_on_non_yank_action() {
            let mut state = create_test_state();
            state.result_interaction.start_yank_operator();
            let now = Instant::now();

            reduce(
                &mut state,
                Action::Select(SelectMotion::Next),
                now,
                &AppServices::stub(),
            );

            assert!(!state.result_interaction.is_yank_operator_pending());
        }

        #[test]
        fn y_then_d_cancels_yank_starts_delete() {
            let mut state = create_test_state();
            state.ui.focused_pane = FocusedPane::Result;
            state.result_interaction.activate_cell(0, 0);
            let now = Instant::now();

            reduce(
                &mut state,
                Action::ResultRowYankOperatorPending,
                now,
                &AppServices::stub(),
            );
            assert!(state.result_interaction.is_yank_operator_pending());
            assert!(!state.result_interaction.is_delete_operator_pending());

            reduce(
                &mut state,
                Action::ResultDeleteOperatorPending,
                now,
                &AppServices::stub(),
            );
            assert!(!state.result_interaction.is_yank_operator_pending());
            assert!(state.result_interaction.is_delete_operator_pending());
        }

        #[test]
        fn d_then_y_cancels_delete_starts_yank() {
            let mut state = create_test_state();
            state.ui.focused_pane = FocusedPane::Result;
            state.result_interaction.activate_cell(0, 0);
            let now = Instant::now();

            reduce(
                &mut state,
                Action::ResultDeleteOperatorPending,
                now,
                &AppServices::stub(),
            );
            assert!(state.result_interaction.is_delete_operator_pending());
            assert!(!state.result_interaction.is_yank_operator_pending());

            reduce(
                &mut state,
                Action::ResultRowYankOperatorPending,
                now,
                &AppServices::stub(),
            );
            assert!(!state.result_interaction.is_delete_operator_pending());
            assert!(state.result_interaction.is_yank_operator_pending());
        }
    }
}
