use crate::a11y::{AccessNode, Role};
use crate::focus::FocusScopeKind;
use crate::frame::FrameCtx;
use crate::input::NamedKey;
use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
use crate::strings::StringKey;
use crate::theme::{Border, Color, Step12, StrokeWidth};
use crate::widget_id::{WidgetId, WidgetKey};

use super::button::{Button, ButtonState, ButtonVariant, show_button};
use super::keys::{TakeKey, take_key};
use super::paint::{LabelText, WidgetPaint};

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct Modal {
    pub id: WidgetId,
    pub viewport: LayoutRect,
    pub size: LayoutSize,
    pub label: StringKey,
}

impl Modal {
    #[must_use]
    pub const fn new(
        id: WidgetId,
        viewport: LayoutRect,
        size: LayoutSize,
        label: StringKey,
    ) -> Self {
        Self {
            id,
            viewport,
            size,
            label,
        }
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct ModalResponse {
    pub body_rect: LayoutRect,
    pub dismissed: bool,
    pub paint: Vec<WidgetPaint>,
}

#[must_use]
pub fn show_modal<F, R>(ctx: &mut FrameCtx<'_>, modal: Modal, body: F) -> (ModalResponse, R)
where
    F: FnOnce(&mut FrameCtx<'_>, LayoutRect, &mut Vec<WidgetPaint>) -> R,
{
    ctx.focus.push_scope(FocusScopeKind::Modal);
    let mut paint = vec![WidgetPaint::Surface {
        rect: modal.viewport,
        fill: Color::TRANSPARENT.with_alpha(0.45),
        border: None,
        radius: ctx.theme().radius.none,
        elevation: None,
    }];
    let scrim_id = modal.id.child(WidgetKey::new("scrim"));
    ctx.block_pointer(scrim_id, modal.viewport);
    let body_rect = center_rect(modal.viewport, modal.size);
    paint.push(WidgetPaint::Surface {
        rect: body_rect,
        fill: ctx.theme().colors.surface(crate::theme::SurfaceLevel::L1),
        border: Some(Border {
            width: StrokeWidth::HAIRLINE,
            color: ctx.theme().colors.neutral.step(Step12::BORDER),
        }),
        radius: ctx.theme().radius.md,
        elevation: Some(ctx.theme().elevation.level1),
    });
    ctx.a11y.push(
        modal.id,
        body_rect,
        AccessNode::new(Role::Dialog).with_label(modal.label),
    );
    let dismissed = take_key(ctx.input, &[TakeKey::named(NamedKey::Escape)]).is_some();
    let extras = body(ctx, body_rect, &mut paint);
    ctx.focus.pop_scope();
    (
        ModalResponse {
            body_rect,
            dismissed,
            paint,
        },
        extras,
    )
}

fn center_rect(viewport: LayoutRect, size: LayoutSize) -> LayoutRect {
    let cx = viewport.origin.x.value() + viewport.size.width.value() / 2.0;
    let cy = viewport.origin.y.value() + viewport.size.height.value() / 2.0;
    LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(cx - size.width.value() / 2.0),
            LayoutPx::new(cy - size.height.value() / 2.0),
        ),
        size,
    )
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct DialogButton {
    pub id: WidgetId,
    pub label: StringKey,
    pub variant: ButtonVariant,
    pub disabled: bool,
}

impl DialogButton {
    #[must_use]
    pub const fn primary(id: WidgetId, label: StringKey) -> Self {
        Self {
            id,
            label,
            variant: ButtonVariant::Primary,
            disabled: false,
        }
    }

    #[must_use]
    pub const fn secondary(id: WidgetId, label: StringKey) -> Self {
        Self {
            id,
            label,
            variant: ButtonVariant::Secondary,
            disabled: false,
        }
    }

    #[must_use]
    pub const fn destructive(id: WidgetId, label: StringKey) -> Self {
        Self {
            id,
            label,
            variant: ButtonVariant::Destructive,
            disabled: false,
        }
    }
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct Dialog<'a> {
    pub id: WidgetId,
    pub viewport: LayoutRect,
    pub size: LayoutSize,
    pub title: StringKey,
    pub buttons: &'a [DialogButton],
}

impl<'a> Dialog<'a> {
    #[must_use]
    pub const fn new(
        id: WidgetId,
        viewport: LayoutRect,
        size: LayoutSize,
        title: StringKey,
        buttons: &'a [DialogButton],
    ) -> Self {
        Self {
            id,
            viewport,
            size,
            title,
            buttons,
        }
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct DialogResponse {
    pub body_rect: LayoutRect,
    pub activated: Option<WidgetId>,
    pub dismissed: bool,
    pub paint: Vec<WidgetPaint>,
}

const DIALOG_TITLE_HEIGHT: f32 = 44.0;
const DIALOG_BUTTON_HEIGHT: f32 = 32.0;
const DIALOG_BUTTON_GAP: f32 = 8.0;
const DIALOG_BUTTON_WIDTH: f32 = 96.0;
const DIALOG_PADDING: f32 = 16.0;

#[must_use]
pub fn show_dialog<F, R>(ctx: &mut FrameCtx<'_>, dialog: Dialog<'_>, body: F) -> (DialogResponse, R)
where
    F: FnOnce(&mut FrameCtx<'_>, LayoutRect, &mut Vec<WidgetPaint>) -> R,
{
    let surface = center_rect(dialog.viewport, dialog.size);
    let title_rect = title_rect_of(surface);
    let body_rect = body_rect_of(surface);
    let strip_rect = button_strip_rect_of(surface);
    let buttons = dialog.buttons;
    let title = dialog.title;
    let modal = Modal::new(dialog.id, dialog.viewport, dialog.size, dialog.title);
    let (modal_response, (activated, extras)) = show_modal(ctx, modal, |ctx, _surface, paint| {
        paint.push(WidgetPaint::Label {
            rect: title_label_rect(title_rect),
            text: LabelText::Key(title),
            color: ctx.theme().colors.text_primary(),
            role: ctx.theme().typography.heading,
        });
        paint.push(WidgetPaint::Surface {
            rect: divider_rect(title_rect),
            fill: ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER),
            border: None,
            radius: ctx.theme().radius.none,
            elevation: None,
        });
        let extras = body(ctx, body_rect, paint);
        let activated = render_button_strip(ctx, buttons, strip_rect, paint);
        seed_dialog_focus(ctx, buttons);
        (activated, extras)
    });
    (
        DialogResponse {
            body_rect,
            activated,
            dismissed: modal_response.dismissed,
            paint: modal_response.paint,
        },
        extras,
    )
}

fn title_rect_of(surface: LayoutRect) -> LayoutRect {
    LayoutRect::new(
        surface.origin,
        LayoutSize::new(surface.size.width, LayoutPx::new(DIALOG_TITLE_HEIGHT)),
    )
}

fn title_label_rect(title: LayoutRect) -> LayoutRect {
    LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(title.origin.x.value() + DIALOG_PADDING),
            title.origin.y,
        ),
        LayoutSize::new(
            LayoutPx::saturating_nonneg(title.size.width.value() - 2.0 * DIALOG_PADDING),
            title.size.height,
        ),
    )
}

fn body_rect_of(surface: LayoutRect) -> LayoutRect {
    LayoutRect::new(
        LayoutPos::new(
            surface.origin.x,
            LayoutPx::new(surface.origin.y.value() + DIALOG_TITLE_HEIGHT),
        ),
        LayoutSize::new(
            surface.size.width,
            LayoutPx::saturating_nonneg(
                surface.size.height.value()
                    - DIALOG_TITLE_HEIGHT
                    - DIALOG_BUTTON_HEIGHT
                    - 2.0 * DIALOG_PADDING,
            ),
        ),
    )
}

fn button_strip_rect_of(surface: LayoutRect) -> LayoutRect {
    LayoutRect::new(
        LayoutPos::new(
            surface.origin.x,
            LayoutPx::new(
                surface.origin.y.value() + surface.size.height.value()
                    - DIALOG_BUTTON_HEIGHT
                    - DIALOG_PADDING,
            ),
        ),
        LayoutSize::new(surface.size.width, LayoutPx::new(DIALOG_BUTTON_HEIGHT)),
    )
}

fn divider_rect(title: LayoutRect) -> LayoutRect {
    LayoutRect::new(
        LayoutPos::new(
            title.origin.x,
            LayoutPx::new(title.origin.y.value() + title.size.height.value() - 1.0),
        ),
        LayoutSize::new(title.size.width, LayoutPx::new(1.0)),
    )
}

fn seed_dialog_focus(ctx: &mut FrameCtx<'_>, buttons: &[DialogButton]) {
    let scope = ctx.focus.current_scope();
    let in_scope = ctx.focus.focused().is_some_and(|f| {
        ctx.focus
            .tab_stops()
            .iter()
            .any(|(id, s)| *s == scope && *id == f)
    });
    if in_scope {
        return;
    }
    if let Some(target) = buttons.iter().find(|b| !b.disabled).map(|b| b.id) {
        ctx.focus.request_focus(target);
    }
}

fn render_button_strip(
    ctx: &mut FrameCtx<'_>,
    buttons: &[DialogButton],
    strip_rect: LayoutRect,
    paint: &mut Vec<WidgetPaint>,
) -> Option<WidgetId> {
    if buttons.is_empty() {
        return None;
    }
    let count = buttons.len();
    #[allow(
        clippy::cast_precision_loss,
        reason = "dialog button counts fit in f32 mantissa"
    )]
    let total_width = count as f32 * (DIALOG_BUTTON_WIDTH + DIALOG_BUTTON_GAP) - DIALOG_BUTTON_GAP;
    let strip_x =
        strip_rect.origin.x.value() + strip_rect.size.width.value() - total_width - DIALOG_PADDING;
    let mut activated: Option<WidgetId> = None;
    buttons.iter().enumerate().for_each(|(idx, button)| {
        #[allow(
            clippy::cast_precision_loss,
            reason = "dialog button index fits f32 mantissa"
        )]
        let i = idx as f32;
        let rect = LayoutRect::new(
            LayoutPos::new(
                LayoutPx::new(strip_x + i * (DIALOG_BUTTON_WIDTH + DIALOG_BUTTON_GAP)),
                strip_rect.origin.y,
            ),
            LayoutSize::new(
                LayoutPx::new(DIALOG_BUTTON_WIDTH),
                LayoutPx::new(DIALOG_BUTTON_HEIGHT),
            ),
        );
        let state = if button.disabled {
            ButtonState::Disabled
        } else {
            ButtonState::Idle
        };
        let response = show_button(
            ctx,
            Button::new(button.id, rect, button.label, button.variant).with_state(state),
        );
        paint.extend(response.paint);
        if response.activated && activated.is_none() {
            activated = Some(button.id);
        }
    });
    activated
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ConfirmationOutcome {
    Confirm,
    Cancel,
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct ConfirmationDialog {
    pub id: WidgetId,
    pub viewport: LayoutRect,
    pub size: LayoutSize,
    pub title: StringKey,
    pub message: StringKey,
    pub confirm_label: StringKey,
    pub cancel_label: StringKey,
    pub destructive: bool,
}

#[derive(Clone, Debug, PartialEq)]
pub struct ConfirmationResponse {
    pub outcome: Option<ConfirmationOutcome>,
    pub paint: Vec<WidgetPaint>,
}

#[must_use]
pub fn show_confirmation(
    ctx: &mut FrameCtx<'_>,
    dialog: ConfirmationDialog,
) -> ConfirmationResponse {
    let confirm_id = dialog.id.child(WidgetKey::new("confirm"));
    let cancel_id = dialog.id.child(WidgetKey::new("cancel"));
    let confirm = if dialog.destructive {
        DialogButton::destructive(confirm_id, dialog.confirm_label)
    } else {
        DialogButton::primary(confirm_id, dialog.confirm_label)
    };
    let cancel = DialogButton::secondary(cancel_id, dialog.cancel_label);
    let buttons = [cancel, confirm];
    let message = dialog.message;
    let (response, ()) = show_dialog(
        ctx,
        Dialog::new(
            dialog.id,
            dialog.viewport,
            dialog.size,
            dialog.title,
            &buttons,
        ),
        |ctx, body_rect, paint| {
            paint.push(WidgetPaint::Label {
                rect: body_rect,
                text: LabelText::Key(message),
                color: ctx.theme().colors.text_primary(),
                role: ctx.theme().typography.body,
            });
        },
    );
    let outcome = match (response.dismissed, response.activated) {
        (true, _) => Some(ConfirmationOutcome::Cancel),
        (_, Some(id)) if id == confirm_id => Some(ConfirmationOutcome::Confirm),
        (_, Some(id)) if id == cancel_id => Some(ConfirmationOutcome::Cancel),
        _ => None,
    };
    ConfirmationResponse {
        outcome,
        paint: response.paint,
    }
}

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

    use super::{
        ConfirmationDialog, ConfirmationOutcome, Dialog, DialogButton, Modal, show_confirmation,
        show_dialog, show_modal,
    };
    use crate::focus::FocusManager;
    use crate::frame::FrameCtx;
    use crate::hit_test::{HitFrame, HitState, resolve};
    use crate::hotkey::HotkeyTable;
    use crate::input::{
        FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton,
        PointerButtonMask, PointerSample,
    };
    use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
    use crate::strings::{StringKey, StringTable};
    use crate::theme::Theme;
    use crate::widget_id::{WidgetId, WidgetKey};

    fn modal_id() -> WidgetId {
        WidgetId::ROOT.child(WidgetKey::new("modal"))
    }

    fn viewport() -> LayoutRect {
        LayoutRect::new(
            LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
            LayoutSize::new(LayoutPx::new(800.0), LayoutPx::new(600.0)),
        )
    }

    fn run_dialog(
        focus: &mut FocusManager,
        snap: &mut InputSnapshot,
        prev: &HitState,
        buttons: &[DialogButton],
    ) -> (super::DialogResponse, HitState) {
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut hits = HitFrame::new();
        let response = {
            let mut shaper = bone_text::Shaper::new();
            let mut a11y = crate::a11y::AccessTreeBuilder::new();
            let mut ctx = FrameCtx::new(
                theme,
                snap,
                focus,
                &table,
                StringTable::empty(),
                &mut hits,
                prev,
                &mut a11y,
                &mut shaper,
            );
            let (response, ()) = show_dialog(
                &mut ctx,
                Dialog::new(
                    modal_id(),
                    viewport(),
                    LayoutSize::new(LayoutPx::new(400.0), LayoutPx::new(240.0)),
                    StringKey::new("dialog.title"),
                    buttons,
                ),
                |_ctx, _body_rect, _paint| {},
            );
            response
        };
        let next = resolve(prev, &hits, snap, focus.focused());
        (response, next)
    }

    #[test]
    fn escape_dismisses_modal() {
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut focus = FocusManager::new();
        let mut hits = HitFrame::new();
        let prev = HitState::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::Escape),
            ModifierMask::NONE,
        ));
        let response = {
            let mut shaper = bone_text::Shaper::new();
            let mut a11y = crate::a11y::AccessTreeBuilder::new();
            let mut ctx = FrameCtx::new(
                theme,
                &mut snap,
                &mut focus,
                &table,
                StringTable::empty(),
                &mut hits,
                &prev,
                &mut a11y,
                &mut shaper,
            );
            let (response, ()) = show_modal(
                &mut ctx,
                Modal::new(
                    modal_id(),
                    viewport(),
                    LayoutSize::new(LayoutPx::new(300.0), LayoutPx::new(200.0)),
                    StringKey::new("test.modal"),
                ),
                |_ctx, _body_rect, _paint| {},
            );
            response
        };
        assert!(response.dismissed);
    }

    #[test]
    fn dialog_centers_body_rect() {
        let buttons: Vec<DialogButton> = Vec::new();
        let mut focus = FocusManager::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        let prev = HitState::new();
        let (response, _) = run_dialog(&mut focus, &mut snap, &prev, &buttons);
        let cx = response.body_rect.origin.x.value() + response.body_rect.size.width.value() / 2.0;
        assert!((cx - 400.0).abs() <= 1.0);
    }

    #[test]
    fn modal_scrim_blocks_pointer_hits_to_chrome_below() {
        use crate::frame::InteractDeclaration;
        use crate::hit_test::Sense;

        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut focus = FocusManager::new();
        let mut hits = HitFrame::new();
        let prev = HitState::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        let chrome_id = WidgetId::ROOT.child(WidgetKey::new("chrome.button"));
        let chrome_rect = LayoutRect::new(
            LayoutPos::new(LayoutPx::new(10.0), LayoutPx::new(10.0)),
            LayoutSize::new(LayoutPx::new(80.0), LayoutPx::new(24.0)),
        );
        snap.pointer = Some(PointerSample::new(LayoutPos::new(
            LayoutPx::new(20.0),
            LayoutPx::new(20.0),
        )));
        snap.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
        snap.buttons_released = PointerButtonMask::just(PointerButton::Primary);
        {
            let mut shaper = bone_text::Shaper::new();
            let mut a11y = crate::a11y::AccessTreeBuilder::new();
            let mut ctx = FrameCtx::new(
                theme,
                &mut snap,
                &mut focus,
                &table,
                StringTable::empty(),
                &mut hits,
                &prev,
                &mut a11y,
                &mut shaper,
            );
            ctx.interact(InteractDeclaration::new(
                chrome_id,
                chrome_rect,
                Sense::INTERACTIVE,
            ));
            let (_response, ()) = show_modal(
                &mut ctx,
                Modal::new(
                    modal_id(),
                    viewport(),
                    LayoutSize::new(LayoutPx::new(300.0), LayoutPx::new(200.0)),
                    StringKey::new("test.modal"),
                ),
                |_ctx, _body_rect, _paint| {},
            );
        }
        let resolved = resolve(&prev, &hits, &snap, focus.focused());
        assert!(
            !resolved.interaction(chrome_id).click(),
            "modal scrim must absorb pointer clicks aimed at chrome below the modal",
        );
    }

    #[test]
    fn show_modal_pops_scope_so_later_widgets_are_not_trapped() {
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut focus = FocusManager::new();
        let mut hits = HitFrame::new();
        let prev = HitState::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        {
            let mut shaper = bone_text::Shaper::new();
            let mut a11y = crate::a11y::AccessTreeBuilder::new();
            let mut ctx = FrameCtx::new(
                theme,
                &mut snap,
                &mut focus,
                &table,
                StringTable::empty(),
                &mut hits,
                &prev,
                &mut a11y,
                &mut shaper,
            );
            let (_response, ()) = show_modal(
                &mut ctx,
                Modal::new(
                    modal_id(),
                    viewport(),
                    LayoutSize::new(LayoutPx::new(300.0), LayoutPx::new(200.0)),
                    StringKey::new("test.modal"),
                ),
                |_ctx, _body_rect, _paint| {},
            );
            assert_eq!(
                ctx.focus.current_scope(),
                crate::focus::FocusScopeId::ROOT,
                "modal scope must be popped before show_modal returns",
            );
        }
    }

    #[test]
    fn enter_on_focused_button_emits_activation() {
        let confirm_id = modal_id().child(WidgetKey::new("confirm"));
        let cancel_id = modal_id().child(WidgetKey::new("cancel"));
        let buttons = [
            DialogButton::primary(confirm_id, StringKey::new("dialog.ok")),
            DialogButton::secondary(cancel_id, StringKey::new("dialog.cancel")),
        ];
        let mut focus = FocusManager::new();
        let prev = HitState::new();
        let mut warm = InputSnapshot::idle(FrameInstant::ZERO);
        focus.request_focus(confirm_id);
        let _ = run_dialog(&mut focus, &mut warm, &prev, &buttons);
        assert_eq!(focus.focused(), Some(confirm_id));

        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::Enter),
            ModifierMask::NONE,
        ));
        let (response, _) = run_dialog(&mut focus, &mut snap, &prev, &buttons);
        assert_eq!(response.activated, Some(confirm_id));
    }

    fn run_confirmation(
        focus: &mut FocusManager,
        snap: &mut InputSnapshot,
        prev: &HitState,
        destructive: bool,
    ) -> (super::ConfirmationResponse, HitState) {
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut hits = HitFrame::new();
        let response = {
            let mut shaper = bone_text::Shaper::new();
            let mut a11y = crate::a11y::AccessTreeBuilder::new();
            let mut ctx = FrameCtx::new(
                theme,
                snap,
                focus,
                &table,
                StringTable::empty(),
                &mut hits,
                prev,
                &mut a11y,
                &mut shaper,
            );
            show_confirmation(
                &mut ctx,
                ConfirmationDialog {
                    id: modal_id(),
                    viewport: viewport(),
                    size: LayoutSize::new(LayoutPx::new(400.0), LayoutPx::new(240.0)),
                    title: StringKey::new("dialog.title"),
                    message: StringKey::new("dialog.message"),
                    confirm_label: StringKey::new("dialog.ok"),
                    cancel_label: StringKey::new("dialog.cancel"),
                    destructive,
                },
            )
        };
        let next = resolve(prev, &hits, snap, focus.focused());
        (response, next)
    }

    #[test]
    fn enter_on_seeded_cancel_button_emits_cancel_outcome() {
        let cancel_id = modal_id().child(WidgetKey::new("cancel"));
        let mut focus = FocusManager::new();
        let prev = HitState::new();
        let mut warm = InputSnapshot::idle(FrameInstant::ZERO);
        let _ = run_confirmation(&mut focus, &mut warm, &prev, false);
        assert_eq!(
            focus.focused(),
            Some(cancel_id),
            "confirmation seeds focus on cancel button (buttons[0])",
        );

        let mut enter = InputSnapshot::idle(FrameInstant::ZERO);
        enter.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::Enter),
            ModifierMask::NONE,
        ));
        let (response, _) = run_confirmation(&mut focus, &mut enter, &prev, false);
        assert_eq!(response.outcome, Some(ConfirmationOutcome::Cancel));
    }

    #[test]
    fn escape_on_open_confirmation_emits_cancel_outcome() {
        let mut focus = FocusManager::new();
        let prev = HitState::new();
        let mut warm = InputSnapshot::idle(FrameInstant::ZERO);
        let _ = run_confirmation(&mut focus, &mut warm, &prev, false);

        let mut esc = InputSnapshot::idle(FrameInstant::ZERO);
        esc.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::Escape),
            ModifierMask::NONE,
        ));
        let (response, _) = run_confirmation(&mut focus, &mut esc, &prev, false);
        assert_eq!(response.outcome, Some(ConfirmationOutcome::Cancel));
    }

    #[test]
    fn click_confirm_button_emits_confirm_outcome() {
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let dialog_size = LayoutSize::new(LayoutPx::new(400.0), LayoutPx::new(240.0));
        let dialog_x = (800.0 - 400.0) / 2.0;
        let dialog_y = (600.0 - 240.0) / 2.0;
        let confirm_x = dialog_x + 400.0 - 16.0 - 96.0 / 2.0;
        let confirm_y = dialog_y + 240.0 - 16.0 - 32.0 / 2.0;
        let click_pos = LayoutPos::new(LayoutPx::new(confirm_x), LayoutPx::new(confirm_y));
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut last: Option<super::ConfirmationResponse> = None;
        [
            press_at(click_pos),
            release_at(click_pos),
            idle_at(click_pos),
        ]
        .into_iter()
        .for_each(|mut snap| {
            let mut hits = HitFrame::new();
            let response = {
                let mut shaper = bone_text::Shaper::new();
                let mut a11y = crate::a11y::AccessTreeBuilder::new();
                let mut ctx = FrameCtx::new(
                    theme.clone(),
                    &mut snap,
                    &mut focus,
                    &table,
                    StringTable::empty(),
                    &mut hits,
                    &prev,
                    &mut a11y,
                    &mut shaper,
                );
                show_confirmation(
                    &mut ctx,
                    ConfirmationDialog {
                        id: modal_id(),
                        viewport: viewport(),
                        size: dialog_size,
                        title: StringKey::new("dialog.title"),
                        message: StringKey::new("dialog.message"),
                        confirm_label: StringKey::new("dialog.ok"),
                        cancel_label: StringKey::new("dialog.cancel"),
                        destructive: false,
                    },
                )
            };
            last = Some(response);
            prev = resolve(&prev, &hits, &snap, focus.focused());
        });
        let Some(response) = last else {
            panic!("response missing")
        };
        assert_eq!(response.outcome, Some(ConfirmationOutcome::Confirm));
    }

    fn press_at(pos: LayoutPos) -> InputSnapshot {
        let mut s = InputSnapshot::idle(FrameInstant::ZERO);
        s.pointer = Some(PointerSample::new(pos));
        s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
        s
    }

    fn release_at(pos: LayoutPos) -> InputSnapshot {
        let mut s = InputSnapshot::idle(FrameInstant::ZERO);
        s.pointer = Some(PointerSample::new(pos));
        s.buttons_released = PointerButtonMask::just(PointerButton::Primary);
        s
    }

    fn idle_at(pos: LayoutPos) -> InputSnapshot {
        let mut s = InputSnapshot::idle(FrameInstant::ZERO);
        s.pointer = Some(PointerSample::new(pos));
        s
    }
}
