mod analyze;
mod helpers;
mod output;
mod request;
mod scroll;
mod tabs;

use std::time::Instant;

use crate::model::app_state::AppState;
use crate::services::AppServices;
use crate::update::action::Action;
use crate::update::dispatch_result::DispatchResult;

pub fn dispatch_explain(
    state: &mut AppState,
    action: &Action,
    now: Instant,
    services: &AppServices,
) -> DispatchResult {
    request::reduce_request(state, action, now, services)
        .or_else(|| analyze::reduce_analyze(state, action, now, services))
        .or_else(|| output::reduce_output(state, action, now))
        .or_else(|| scroll::reduce_scroll(state, action, now))
        .or_else(|| tabs::reduce_tabs(state, action, now, services))
}

#[cfg(test)]
fn reduce_explain_with_services(
    state: &mut AppState,
    action: &Action,
    now: Instant,
    services: &AppServices,
) -> DispatchResult {
    dispatch_explain(state, action, now, services)
}

#[cfg(test)]
fn reduce_explain(state: &mut AppState, action: &Action, now: Instant) -> DispatchResult {
    dispatch_explain(state, action, now, &crate::services::AppServices::stub())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cmd::effect::Effect;
    use crate::model::shared::db_capabilities::DbCapabilities;
    use crate::model::shared::input_mode::InputMode;
    use crate::model::shared::inspector_tab::InspectorTab;
    use crate::model::sql_editor::modal::{SqlModalStatus, SqlModalTab};
    use crate::services::AppServices;
    use crate::update::action::{ScrollAmount, ScrollDirection, ScrollTarget};
    use std::time::Instant;

    fn sql_modal_state() -> AppState {
        let mut state = AppState::new("test".to_string());
        state.modal.set_mode(InputMode::SqlModal);
        state
    }

    fn services_without_explain() -> AppServices {
        let mut services = AppServices::stub();
        services.db_capabilities = DbCapabilities::new(false, vec![InspectorTab::Info]);
        services
    }

    mod explain_request {
        use super::*;

        #[test]
        fn empty_query_is_noop() {
            let mut state = sql_modal_state();
            state.sql_modal.editor.set_content("  ".to_string());
            state.session.dsn = Some("dsn://test".to_string());

            let effects = reduce_explain(&mut state, &Action::ExplainRequest, Instant::now())
                .into_effects()
                .expect("reducer should handle action");

            assert!(effects.is_empty());
        }

        #[test]
        fn no_dsn_is_noop() {
            let mut state = sql_modal_state();
            state.sql_modal.editor.set_content("SELECT 1".to_string());

            let effects = reduce_explain(&mut state, &Action::ExplainRequest, Instant::now())
                .into_effects()
                .expect("reducer should handle action");

            assert!(effects.is_empty());
        }

        #[test]
        fn running_is_noop() {
            let mut state = sql_modal_state();
            state.sql_modal.editor.set_content("SELECT 1".to_string());
            state.session.dsn = Some("dsn://test".to_string());
            state.sql_modal.set_status_for_test(SqlModalStatus::Running);

            let effects = reduce_explain(&mut state, &Action::ExplainRequest, Instant::now())
                .into_effects()
                .expect("reducer should handle action");

            assert!(effects.is_empty());
        }

        #[test]
        fn unsupported_database_sets_error_without_effects() {
            let mut state = sql_modal_state();
            state.sql_modal.editor.set_content("SELECT 1".to_string());
            state.session.dsn = Some("dsn://test".to_string());

            let effects = reduce_explain_with_services(
                &mut state,
                &Action::ExplainRequest,
                Instant::now(),
                &services_without_explain(),
            )
            .unwrap();

            assert!(effects.is_empty());
            assert_eq!(
                state.explain.error.as_deref(),
                Some("EXPLAIN is unavailable for this database")
            );
            assert_eq!(state.sql_modal.active_tab(), SqlModalTab::Sql);
        }

        #[test]
        fn multi_statement_sets_error_and_switches_to_plan_tab() {
            let mut state = sql_modal_state();
            state
                .sql_modal
                .editor
                .set_content("SELECT 1; DELETE FROM users".to_string());
            state.session.dsn = Some("dsn://test".to_string());

            let effects = reduce_explain(&mut state, &Action::ExplainRequest, Instant::now())
                .into_effects()
                .expect("reducer should handle action");

            assert!(effects.is_empty());
            assert_eq!(
                state.explain.error.as_deref(),
                Some("EXPLAIN does not support multiple statements")
            );
            assert_eq!(state.sql_modal.active_tab(), SqlModalTab::Plan);
        }

        #[test]
        fn starts_query_timer() {
            let mut state = sql_modal_state();
            state.sql_modal.editor.set_content("SELECT 1".to_string());
            state.session.dsn = Some("dsn://test".to_string());

            reduce_explain(&mut state, &Action::ExplainRequest, Instant::now());

            assert!(state.query.is_running());
            assert!(state.query.start_time().is_some());
        }

        #[test]
        fn emits_execute_explain_effect() {
            let mut state = sql_modal_state();
            state.sql_modal.editor.set_content("SELECT 1".to_string());
            state.session.dsn = Some("dsn://test".to_string());

            let effects = reduce_explain(&mut state, &Action::ExplainRequest, Instant::now())
                .into_effects()
                .expect("reducer should handle action");

            assert_eq!(effects.len(), 1);
            assert!(matches!(
                &effects[0],
                Effect::ExecuteExplain {
                    query,
                    is_analyze: false,
                    read_only: true,
                    ..
                } if query == "EXPLAIN SELECT 1"
            ));
            assert_eq!(*state.sql_modal.status(), SqlModalStatus::Running);
            assert_eq!(state.sql_modal.active_tab(), SqlModalTab::Plan);
        }
    }

    mod explain_analyze_request {
        use super::*;

        #[test]
        fn empty_query_is_noop() {
            let mut state = sql_modal_state();
            state.session.dsn = Some("dsn://test".to_string());

            let effects =
                reduce_explain(&mut state, &Action::ExplainAnalyzeRequest, Instant::now())
                    .into_effects()
                    .expect("reducer should handle action");

            assert!(effects.is_empty());
        }

        #[test]
        fn multi_statement_sets_error_and_switches_to_plan_tab() {
            let mut state = sql_modal_state();
            state
                .sql_modal
                .editor
                .set_content("SELECT 1; DELETE FROM users".to_string());
            state.session.dsn = Some("dsn://test".to_string());

            let effects =
                reduce_explain(&mut state, &Action::ExplainAnalyzeRequest, Instant::now())
                    .into_effects()
                    .expect("reducer should handle action");

            assert!(effects.is_empty());
            assert_eq!(
                state.explain.error.as_deref(),
                Some("EXPLAIN ANALYZE does not support multiple statements")
            );
            assert_eq!(state.sql_modal.active_tab(), SqlModalTab::Plan);
            assert!(state.confirm_dialog.intent().is_none());
        }

        #[test]
        fn unsupported_database_sets_error_without_effects() {
            let mut state = sql_modal_state();
            state.sql_modal.editor.set_content("SELECT 1".to_string());
            state.session.dsn = Some("dsn://test".to_string());

            let effects = reduce_explain_with_services(
                &mut state,
                &Action::ExplainAnalyzeRequest,
                Instant::now(),
                &services_without_explain(),
            )
            .unwrap();

            assert!(effects.is_empty());
            assert_eq!(
                state.explain.error.as_deref(),
                Some("EXPLAIN is unavailable for this database")
            );
            assert_eq!(state.sql_modal.active_tab(), SqlModalTab::Sql);
        }

        #[test]
        fn select_executes_immediately_without_confirm() {
            let mut state = sql_modal_state();
            state.sql_modal.editor.set_content("SELECT 1".to_string());
            state.session.dsn = Some("dsn://test".to_string());

            let effects =
                reduce_explain(&mut state, &Action::ExplainAnalyzeRequest, Instant::now())
                    .into_effects()
                    .expect("reducer should handle action");

            assert_eq!(effects.len(), 1);
            assert!(matches!(
                &effects[0],
                Effect::ExecuteExplain {
                    is_analyze: true,
                    ..
                }
            ));
            assert_eq!(*state.sql_modal.status(), SqlModalStatus::Running);
        }

        #[test]
        fn insert_executes_immediately() {
            let mut state = sql_modal_state();
            state
                .sql_modal
                .editor
                .set_content("INSERT INTO users VALUES (1)".to_string());
            state.session.dsn = Some("dsn://test".to_string());

            let effects =
                reduce_explain(&mut state, &Action::ExplainAnalyzeRequest, Instant::now())
                    .into_effects()
                    .expect("reducer should handle action");

            assert_eq!(*state.sql_modal.status(), SqlModalStatus::Running);
            assert_eq!(effects.len(), 1);
            assert!(matches!(
                &effects[0],
                Effect::ExecuteExplain {
                    is_analyze: true,
                    ..
                }
            ));
        }

        #[test]
        fn update_with_where_executes_immediately() {
            let mut state = sql_modal_state();
            state
                .sql_modal
                .editor
                .set_content("UPDATE users SET name='x' WHERE id=1".to_string());
            state.session.dsn = Some("dsn://test".to_string());

            let effects =
                reduce_explain(&mut state, &Action::ExplainAnalyzeRequest, Instant::now())
                    .into_effects()
                    .expect("reducer should handle action");

            assert_eq!(*state.sql_modal.status(), SqlModalStatus::Running);
            assert_eq!(effects.len(), 1);
            assert!(matches!(
                &effects[0],
                Effect::ExecuteExplain {
                    is_analyze: true,
                    ..
                }
            ));
        }

        #[test]
        fn delete_without_where_shows_high_confirm() {
            let mut state = sql_modal_state();
            state
                .sql_modal
                .editor
                .set_content("DELETE FROM users".to_string());
            state.session.dsn = Some("dsn://test".to_string());

            reduce_explain(&mut state, &Action::ExplainAnalyzeRequest, Instant::now());

            assert!(matches!(
                state.sql_modal.status(),
                SqlModalStatus::ConfirmingAnalyzeHigh { .. }
            ));
        }

        #[test]
        fn unsupported_statement_shows_risk_acknowledge() {
            use crate::policy::write::sql_risk::AcknowledgeReason;
            let mut state = sql_modal_state();
            state.sql_modal.editor.set_content(
                "MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN DELETE".to_string(),
            );
            state.session.dsn = Some("dsn://test".to_string());

            reduce_explain(&mut state, &Action::ExplainAnalyzeRequest, Instant::now());

            assert!(matches!(
                state.sql_modal.status(),
                SqlModalStatus::ConfirmingAnalyzeRisk {
                    reason: AcknowledgeReason::UnknownRisk,
                    ..
                }
            ));
        }

        #[test]
        fn delete_with_where_executes_immediately() {
            let mut state = sql_modal_state();
            state
                .sql_modal
                .editor
                .set_content("DELETE FROM users WHERE id=1".to_string());
            state.session.dsn = Some("dsn://test".to_string());

            let effects =
                reduce_explain(&mut state, &Action::ExplainAnalyzeRequest, Instant::now())
                    .into_effects()
                    .expect("reducer should handle action");

            assert_eq!(*state.sql_modal.status(), SqlModalStatus::Running);
            assert_eq!(effects.len(), 1);
            assert!(matches!(
                &effects[0],
                Effect::ExecuteExplain {
                    is_analyze: true,
                    ..
                }
            ));
        }

        #[test]
        fn drop_shows_high_confirm() {
            let mut state = sql_modal_state();
            state
                .sql_modal
                .editor
                .set_content("DROP TABLE users".to_string());
            state.session.dsn = Some("dsn://test".to_string());

            reduce_explain(&mut state, &Action::ExplainAnalyzeRequest, Instant::now());

            assert!(matches!(
                state.sql_modal.status(),
                SqlModalStatus::ConfirmingAnalyzeHigh { .. }
            ));
        }

        #[test]
        fn truncate_shows_high_confirm() {
            let mut state = sql_modal_state();
            state
                .sql_modal
                .editor
                .set_content("TRUNCATE users".to_string());
            state.session.dsn = Some("dsn://test".to_string());

            reduce_explain(&mut state, &Action::ExplainAnalyzeRequest, Instant::now());

            assert!(matches!(
                state.sql_modal.status(),
                SqlModalStatus::ConfirmingAnalyzeHigh { .. }
            ));
        }
    }

    mod read_only_analyze {
        use super::*;

        #[test]
        fn read_only_blocks_dml_analyze() {
            let mut state = sql_modal_state();
            state
                .sql_modal
                .editor
                .set_content("DELETE FROM users WHERE id=1".to_string());
            state.session.dsn = Some("dsn://test".to_string());
            state.session.read_only = true;

            reduce_explain(&mut state, &Action::ExplainAnalyzeRequest, Instant::now());

            assert!(state.explain.error.is_some());
            assert!(
                state
                    .explain
                    .error
                    .as_deref()
                    .unwrap()
                    .contains("Read-only")
            );
            assert_eq!(state.sql_modal.active_tab(), SqlModalTab::Plan);
            assert!(state.confirm_dialog.intent().is_none());
        }

        #[test]
        fn read_only_allows_select_analyze() {
            let mut state = sql_modal_state();
            state
                .sql_modal
                .editor
                .set_content("SELECT * FROM users".to_string());
            state.session.dsn = Some("dsn://test".to_string());
            state.session.read_only = true;

            let effects =
                reduce_explain(&mut state, &Action::ExplainAnalyzeRequest, Instant::now())
                    .into_effects()
                    .expect("reducer should handle action");

            assert!(state.explain.error.is_none());
            assert_eq!(effects.len(), 1);
            assert!(matches!(
                &effects[0],
                Effect::ExecuteExplain {
                    is_analyze: true,
                    ..
                }
            ));
        }

        #[test]
        fn read_only_blocks_insert_analyze() {
            let mut state = sql_modal_state();
            state
                .sql_modal
                .editor
                .set_content("INSERT INTO users VALUES (1)".to_string());
            state.session.dsn = Some("dsn://test".to_string());
            state.session.read_only = true;

            reduce_explain(&mut state, &Action::ExplainAnalyzeRequest, Instant::now());

            assert!(
                state
                    .explain
                    .error
                    .as_deref()
                    .unwrap()
                    .contains("Read-only")
            );
        }
    }

    mod analyze_confirm_cancel {
        use super::*;
        use crate::model::shared::text_input::TextInputState;

        #[test]
        fn confirm_from_high_with_matching_table_emits_effect() {
            let mut state = sql_modal_state();
            state.session.dsn = Some("dsn://test".to_string());
            let mut input = crate::model::shared::text_input::TextInputState::default();
            for c in "users".chars() {
                input.insert_char(c);
            }
            state
                .sql_modal
                .set_status_for_test(SqlModalStatus::ConfirmingAnalyzeHigh {
                    query: "DELETE FROM users".to_string(),
                    input,
                    target_name: "users".to_string(),
                });

            let effects =
                reduce_explain(&mut state, &Action::ExplainAnalyzeConfirm, Instant::now())
                    .into_effects()
                    .expect("reducer should handle action");

            assert_eq!(effects.len(), 1);
            assert_eq!(*state.sql_modal.status(), SqlModalStatus::Running);
        }

        #[test]
        fn confirm_from_high_with_mismatch_is_noop() {
            let mut state = sql_modal_state();
            state.session.dsn = Some("dsn://test".to_string());
            let mut input = crate::model::shared::text_input::TextInputState::default();
            input.insert_char('x');
            state
                .sql_modal
                .set_status_for_test(SqlModalStatus::ConfirmingAnalyzeHigh {
                    query: "DELETE FROM users".to_string(),
                    input,
                    target_name: "users".to_string(),
                });

            let effects =
                reduce_explain(&mut state, &Action::ExplainAnalyzeConfirm, Instant::now())
                    .into_effects()
                    .expect("reducer should handle action");

            assert!(effects.is_empty());
            assert!(matches!(
                state.sql_modal.status(),
                SqlModalStatus::ConfirmingAnalyzeHigh { .. }
            ));
        }

        #[test]
        fn cancel_from_high_resets_to_normal() {
            let mut state = sql_modal_state();
            state
                .sql_modal
                .set_status_for_test(SqlModalStatus::ConfirmingAnalyzeHigh {
                    query: "DROP TABLE users".to_string(),
                    input: TextInputState::default(),
                    target_name: "users".to_string(),
                });

            reduce_explain(&mut state, &Action::ExplainAnalyzeCancel, Instant::now());

            assert_eq!(*state.sql_modal.status(), SqlModalStatus::Normal);
        }

        #[test]
        fn confirm_from_risk_acknowledge_emits_effect() {
            use crate::policy::write::sql_risk::AcknowledgeReason;
            let mut state = sql_modal_state();
            state.session.dsn = Some("dsn://test".to_string());
            state
                .sql_modal
                .set_status_for_test(SqlModalStatus::ConfirmingAnalyzeRisk {
                    query: "MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN DELETE"
                        .to_string(),
                    reason: AcknowledgeReason::UnknownRisk,
                });

            let effects =
                reduce_explain(&mut state, &Action::ExplainAnalyzeConfirm, Instant::now())
                    .into_effects()
                    .expect("reducer should handle action");

            assert_eq!(effects.len(), 1);
            assert_eq!(*state.sql_modal.status(), SqlModalStatus::Running);
        }

        #[test]
        fn cancel_from_risk_acknowledge_resets_to_normal() {
            use crate::policy::write::sql_risk::AcknowledgeReason;
            let mut state = sql_modal_state();
            state
                .sql_modal
                .set_status_for_test(SqlModalStatus::ConfirmingAnalyzeRisk {
                    query: "GRANT SELECT ON users TO role1".to_string(),
                    reason: AcknowledgeReason::UnknownRisk,
                });

            reduce_explain(&mut state, &Action::ExplainAnalyzeCancel, Instant::now());

            assert_eq!(*state.sql_modal.status(), SqlModalStatus::Normal);
        }
    }

    mod explain_completed {
        use super::*;

        #[test]
        fn sets_plan_and_switches_to_plan_tab() {
            let mut state = sql_modal_state();
            state.session.dsn = Some("dsn://test".to_string());
            let _ = state.query.begin_running(Instant::now());
            state.sql_modal.set_status_for_test(SqlModalStatus::Running);

            reduce_explain(
                &mut state,
                &Action::ExplainCompleted {
                    dsn: "dsn://test".to_string(),
                    run_id: 1,
                    query: "SELECT 1".to_string(),
                    plan_text: "Seq Scan".to_string(),
                    is_analyze: false,
                    execution_time_ms: 42,
                },
                Instant::now(),
            );

            assert_eq!(state.explain.plan_text.as_deref(), Some("Seq Scan"));
            assert_eq!(*state.sql_modal.status(), SqlModalStatus::Normal);
            assert_eq!(state.sql_modal.active_tab(), SqlModalTab::Plan);
            assert!(!state.query.is_running());
        }

        #[test]
        fn mismatched_dsn_does_not_replace_plan() {
            let mut state = sql_modal_state();
            state.session.dsn = Some("dsn://current".to_string());
            let _ = state.query.begin_running(Instant::now());
            state.sql_modal.set_status_for_test(SqlModalStatus::Running);
            state
                .explain
                .set_plan("Original".to_string(), false, 10, "SELECT old");

            reduce_explain(
                &mut state,
                &Action::ExplainCompleted {
                    dsn: "dsn://stale".to_string(),
                    run_id: 1,
                    query: "SELECT stale".to_string(),
                    plan_text: "Stale".to_string(),
                    is_analyze: false,
                    execution_time_ms: 42,
                },
                Instant::now(),
            );

            assert_eq!(state.explain.plan_text.as_deref(), Some("Original"));
            assert_eq!(
                state.explain.plan_query_snippet.as_deref(),
                Some("SELECT old")
            );
            assert_eq!(*state.sql_modal.status(), SqlModalStatus::Running);
        }
    }

    mod explain_failed {
        use super::*;
        use crate::ports::outbound::DbOperationError;

        #[test]
        fn sets_error_and_switches_to_plan_tab() {
            let mut state = sql_modal_state();
            state.session.dsn = Some("dsn://test".to_string());
            let _ = state.query.begin_running(Instant::now());
            state.sql_modal.set_status_for_test(SqlModalStatus::Running);

            reduce_explain(
                &mut state,
                &Action::ExplainFailed {
                    dsn: "dsn://test".to_string(),
                    run_id: 1,
                    error: DbOperationError::QueryFailed("syntax error".to_string()),
                },
                Instant::now(),
            );

            assert_eq!(
                state.explain.error.as_deref(),
                Some("Query failed: syntax error. Review the database error details and SQL.")
            );
            assert_eq!(*state.sql_modal.status(), SqlModalStatus::Normal);
            assert_eq!(state.sql_modal.active_tab(), SqlModalTab::Plan);
            assert!(!state.query.is_running());
        }

        #[test]
        fn mismatched_dsn_does_not_replace_plan_with_error() {
            let mut state = sql_modal_state();
            state.session.dsn = Some("dsn://current".to_string());
            let _ = state.query.begin_running(Instant::now());
            state.sql_modal.set_status_for_test(SqlModalStatus::Running);
            state
                .explain
                .set_plan("Original".to_string(), false, 10, "SELECT old");

            reduce_explain(
                &mut state,
                &Action::ExplainFailed {
                    dsn: "dsn://stale".to_string(),
                    run_id: 1,
                    error: DbOperationError::QueryFailed("syntax error".to_string()),
                },
                Instant::now(),
            );

            assert_eq!(state.explain.plan_text.as_deref(), Some("Original"));
            assert_eq!(state.explain.error, None);
            assert_eq!(*state.sql_modal.status(), SqlModalStatus::Running);
        }
    }

    mod compare_workflow {
        use super::*;

        #[test]
        fn two_explains_auto_advance_returns_comparable_slots() {
            let mut state = sql_modal_state();
            state.sql_modal.editor.set_content("SELECT 1".to_string());
            state.session.dsn = Some("dsn://test".to_string());
            let now = Instant::now();

            // Step 1: First EXPLAIN
            reduce_explain(&mut state, &Action::ExplainRequest, now);
            reduce_explain(
                &mut state,
                &Action::ExplainCompleted {
                    dsn: "dsn://test".to_string(),
                    run_id: 1,
                    query: "SELECT 1".to_string(),
                    plan_text: "Seq Scan  (cost=0.00..100.00 rows=10 width=32)".to_string(),
                    is_analyze: false,
                    execution_time_ms: 42,
                },
                now,
            );
            assert!(state.explain.right.is_some());
            assert!(state.explain.left.is_none());

            // Step 2: Second EXPLAIN — auto-advance moves right→left
            state.sql_modal.editor.set_content("SELECT 2".to_string());
            reduce_explain(&mut state, &Action::ExplainRequest, now);
            reduce_explain(
                &mut state,
                &Action::ExplainCompleted {
                    dsn: "dsn://test".to_string(),
                    run_id: 2,
                    query: "SELECT 2".to_string(),
                    plan_text: "Index Scan  (cost=0.00..5.00 rows=1 width=32)".to_string(),
                    is_analyze: false,
                    execution_time_ms: 5,
                },
                now,
            );

            assert!(state.explain.left.is_some());
            assert!(state.explain.right.is_some());
            assert_eq!(
                state.explain.left.as_ref().unwrap().plan.total_cost,
                Some(100.0)
            );
            assert_eq!(
                state.explain.right.as_ref().unwrap().plan.total_cost,
                Some(5.0)
            );
        }
    }

    mod scroll {
        use super::*;

        #[test]
        fn plan_scroll_up_saturates_at_zero() {
            let mut state = sql_modal_state();
            state.explain.scroll_offset = 0;

            reduce_explain(
                &mut state,
                &Action::Scroll {
                    target: ScrollTarget::ExplainPlan,
                    direction: ScrollDirection::Up,
                    amount: ScrollAmount::Line,
                },
                Instant::now(),
            );

            assert_eq!(state.explain.scroll_offset, 0);
        }

        #[test]
        fn plan_scroll_down_increments() {
            let mut state = sql_modal_state();
            state.ui.terminal_height = 24;
            let long_plan = (0..20)
                .map(|i| format!("line{i}"))
                .collect::<Vec<_>>()
                .join("\n");
            state.explain.set_plan(long_plan, false, 0, "Q1");

            reduce_explain(
                &mut state,
                &Action::Scroll {
                    target: ScrollTarget::ExplainPlan,
                    direction: ScrollDirection::Down,
                    amount: ScrollAmount::Line,
                },
                Instant::now(),
            );

            assert_eq!(state.explain.scroll_offset, 1);
        }

        #[test]
        fn plan_scroll_down_clamps_at_max() {
            let mut state = sql_modal_state();
            state.ui.terminal_height = 24;
            let long_plan = (0..20)
                .map(|i| format!("line{i}"))
                .collect::<Vec<_>>()
                .join("\n");
            state.explain.set_plan(long_plan, false, 0, "Q1");
            let modal_inner = crate::model::explain_context::ExplainContext::modal_inner_height(
                state.ui.terminal_height,
            );
            let max = state.explain.line_count().saturating_sub(modal_inner);
            state.explain.scroll_offset = max;

            reduce_explain(
                &mut state,
                &Action::Scroll {
                    target: ScrollTarget::ExplainPlan,
                    direction: ScrollDirection::Down,
                    amount: ScrollAmount::Line,
                },
                Instant::now(),
            );

            assert_eq!(state.explain.scroll_offset, max);
        }

        #[test]
        fn compare_plan_scroll_up_saturates_at_zero() {
            let mut state = sql_modal_state();
            state.explain.compare_scroll_offset = 0;

            reduce_explain(
                &mut state,
                &Action::Scroll {
                    target: ScrollTarget::ExplainCompare,
                    direction: ScrollDirection::Up,
                    amount: ScrollAmount::Line,
                },
                Instant::now(),
            );

            assert_eq!(state.explain.compare_scroll_offset, 0);
        }

        #[test]
        fn compare_plan_scroll_down_increments() {
            let mut state = sql_modal_state();
            let long_plan = (0..20)
                .map(|i| format!("  ->  Node{i}  (cost=0.00..{i}.00 rows=1 width=32)"))
                .collect::<Vec<_>>()
                .join("\n");
            state.explain.set_plan(long_plan.clone(), false, 0, "Q1");
            state.explain.set_plan(long_plan, false, 0, "Q2");

            reduce_explain(
                &mut state,
                &Action::Scroll {
                    target: ScrollTarget::ExplainCompare,
                    direction: ScrollDirection::Down,
                    amount: ScrollAmount::Line,
                },
                Instant::now(),
            );

            assert_eq!(state.explain.compare_scroll_offset, 1);
        }

        #[test]
        fn compare_scroll_down_stops_at_max() {
            let mut state = sql_modal_state();
            state.ui.terminal_height = 24;
            let long_plan = (0..20)
                .map(|i| format!("  ->  Node{i}  (cost=0.00..{i}.00 rows=1 width=32)"))
                .collect::<Vec<_>>()
                .join("\n");
            state.explain.set_plan(long_plan.clone(), false, 0, "Q1");
            state.explain.set_plan(long_plan, false, 0, "Q2");

            let max = state.explain.compare_max_scroll(state.ui.terminal_height);

            // Scroll to max
            for _ in 0..max + 5 {
                reduce_explain(
                    &mut state,
                    &Action::Scroll {
                        target: ScrollTarget::ExplainCompare,
                        direction: ScrollDirection::Down,
                        amount: ScrollAmount::Line,
                    },
                    Instant::now(),
                );
            }

            assert_eq!(state.explain.compare_scroll_offset, max);

            // k should immediately scroll back
            reduce_explain(
                &mut state,
                &Action::Scroll {
                    target: ScrollTarget::ExplainCompare,
                    direction: ScrollDirection::Up,
                    amount: ScrollAmount::Line,
                },
                Instant::now(),
            );
            assert_eq!(state.explain.compare_scroll_offset, max.saturating_sub(1));
        }

        #[test]
        fn right_only_plan_scroll_down_increments() {
            let mut state = sql_modal_state();
            state.ui.terminal_height = 24;
            let long_plan = (0..20)
                .map(|i| format!("  ->  Node{i}  (cost=0.00..{i}.00 rows=1 width=32)"))
                .collect::<Vec<_>>()
                .join("\n");
            state.explain.set_plan(long_plan, false, 0, "Q1");

            reduce_explain(
                &mut state,
                &Action::Scroll {
                    target: ScrollTarget::ExplainCompare,
                    direction: ScrollDirection::Down,
                    amount: ScrollAmount::Line,
                },
                Instant::now(),
            );

            assert_eq!(state.explain.compare_scroll_offset, 1);
        }

        #[test]
        fn compare_scroll_down_clamps_without_content() {
            let mut state = sql_modal_state();

            reduce_explain(
                &mut state,
                &Action::Scroll {
                    target: ScrollTarget::ExplainCompare,
                    direction: ScrollDirection::Down,
                    amount: ScrollAmount::Line,
                },
                Instant::now(),
            );

            assert_eq!(state.explain.compare_scroll_offset, 0);
        }
    }

    mod tab_switch {
        use super::*;

        #[test]
        fn next_tab_switches_sql_to_plan() {
            let mut state = sql_modal_state();
            state.sql_modal.set_active_tab(SqlModalTab::Sql);

            reduce_explain(&mut state, &Action::SqlModalNextTab, Instant::now());

            assert_eq!(state.sql_modal.active_tab(), SqlModalTab::Plan);
        }

        #[test]
        fn next_tab_switches_plan_to_compare() {
            let mut state = sql_modal_state();
            state.sql_modal.set_active_tab(SqlModalTab::Plan);

            reduce_explain(&mut state, &Action::SqlModalNextTab, Instant::now());

            assert_eq!(state.sql_modal.active_tab(), SqlModalTab::Compare);
        }

        #[test]
        fn next_tab_switches_compare_to_sql() {
            let mut state = sql_modal_state();
            state.sql_modal.set_active_tab(SqlModalTab::Compare);

            reduce_explain(&mut state, &Action::SqlModalNextTab, Instant::now());

            assert_eq!(state.sql_modal.active_tab(), SqlModalTab::Sql);
        }

        #[test]
        fn next_tab_stays_on_sql_when_explain_is_unsupported() {
            let mut state = sql_modal_state();
            state.sql_modal.set_active_tab(SqlModalTab::Sql);

            reduce_explain_with_services(
                &mut state,
                &Action::SqlModalNextTab,
                Instant::now(),
                &services_without_explain(),
            );

            assert_eq!(state.sql_modal.active_tab(), SqlModalTab::Sql);
        }

        #[test]
        fn next_tab_normalizes_stale_plan_to_sql_when_explain_is_unsupported() {
            let mut state = sql_modal_state();
            state.sql_modal.set_active_tab(SqlModalTab::Plan);

            reduce_explain_with_services(
                &mut state,
                &Action::SqlModalNextTab,
                Instant::now(),
                &services_without_explain(),
            );

            assert_eq!(state.sql_modal.active_tab(), SqlModalTab::Sql);
        }

        #[test]
        fn prev_tab_switches_sql_to_compare() {
            let mut state = sql_modal_state();
            state.sql_modal.set_active_tab(SqlModalTab::Sql);

            reduce_explain(&mut state, &Action::SqlModalPrevTab, Instant::now());

            assert_eq!(state.sql_modal.active_tab(), SqlModalTab::Compare);
        }

        #[test]
        fn prev_tab_switches_compare_to_plan() {
            let mut state = sql_modal_state();
            state.sql_modal.set_active_tab(SqlModalTab::Compare);

            reduce_explain(&mut state, &Action::SqlModalPrevTab, Instant::now());

            assert_eq!(state.sql_modal.active_tab(), SqlModalTab::Plan);
        }

        #[test]
        fn prev_tab_switches_plan_to_sql() {
            let mut state = sql_modal_state();
            state.sql_modal.set_active_tab(SqlModalTab::Plan);

            reduce_explain(&mut state, &Action::SqlModalPrevTab, Instant::now());

            assert_eq!(state.sql_modal.active_tab(), SqlModalTab::Sql);
        }

        #[test]
        fn prev_tab_stays_on_sql_when_explain_is_unsupported() {
            let mut state = sql_modal_state();
            state.sql_modal.set_active_tab(SqlModalTab::Sql);

            reduce_explain_with_services(
                &mut state,
                &Action::SqlModalPrevTab,
                Instant::now(),
                &services_without_explain(),
            );

            assert_eq!(state.sql_modal.active_tab(), SqlModalTab::Sql);
        }

        #[test]
        fn prev_tab_normalizes_stale_compare_to_sql_when_explain_is_unsupported() {
            let mut state = sql_modal_state();
            state.sql_modal.set_active_tab(SqlModalTab::Compare);

            reduce_explain_with_services(
                &mut state,
                &Action::SqlModalPrevTab,
                Instant::now(),
                &services_without_explain(),
            );

            assert_eq!(state.sql_modal.active_tab(), SqlModalTab::Sql);
        }
    }
}
