use core::time::Duration;

use crate::a11y::{AccessNode, Role};
use crate::frame::{FrameCtx, InteractDeclaration};
use crate::hit_test::{Interaction, Sense, ZLayer};
use crate::input::{FrameInstant, KeyCode, KeyEvent, ModifierMask, NamedKey};
use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
use crate::strings::StringKey;
use crate::theme::{Border, Step12, StrokeWidth};
use crate::widget_id::{WidgetId, WidgetKey};

use super::keys::{TakeKey, take_key};
use super::paint::{GlyphMark, LabelText, WidgetPaint};
use super::visuals::push_focus_ring;

const TYPEAHEAD_RESET: Duration = Duration::from_millis(750);

#[derive(Clone, Debug, PartialEq)]
pub struct DropdownItem<T: Clone + PartialEq> {
    pub value: T,
    pub label: StringKey,
}

#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DropdownState {
    pub open: bool,
    pub highlighted: Option<usize>,
    pub filter: String,
    pub typeahead_last: Option<FrameInstant>,
    pub focus_seated: bool,
    pub last_hovered: Option<usize>,
}

impl DropdownState {
    #[must_use]
    pub fn closed() -> Self {
        Self::default()
    }

    fn clear_typeahead(&mut self) {
        self.filter.clear();
        self.typeahead_last = None;
    }
}

pub struct Dropdown<'state, T: Clone + PartialEq> {
    pub id: WidgetId,
    pub trigger_rect: LayoutRect,
    pub item_height: LayoutPx,
    pub items: Vec<DropdownItem<T>>,
    pub selected: Option<T>,
    pub placeholder: StringKey,
    pub state: &'state mut DropdownState,
    pub disabled: bool,
}

impl<'state, T: Clone + PartialEq> Dropdown<'state, T> {
    #[must_use]
    pub fn new(
        id: WidgetId,
        trigger_rect: LayoutRect,
        item_height: LayoutPx,
        items: Vec<DropdownItem<T>>,
        selected: Option<T>,
        placeholder: StringKey,
        state: &'state mut DropdownState,
    ) -> Self {
        Self {
            id,
            trigger_rect,
            item_height,
            items,
            selected,
            placeholder,
            state,
            disabled: false,
        }
    }

    #[must_use]
    pub fn disabled(self, disabled: bool) -> Self {
        Self { disabled, ..self }
    }
}

fn popup_rect_below(trigger: LayoutRect, item_height: LayoutPx, count: usize) -> LayoutRect {
    #[allow(clippy::cast_precision_loss, reason = "dropdown item counts are small")]
    let count_f32 = count as f32;
    LayoutRect::new(
        LayoutPos::new(
            trigger.origin.x,
            LayoutPx::new(trigger.origin.y.value() + trigger.size.height.value()),
        ),
        LayoutSize::new(
            trigger.size.width,
            LayoutPx::new(item_height.value() * count_f32),
        ),
    )
}

#[derive(Clone, Debug, PartialEq)]
pub struct DropdownResponse<T: Clone + PartialEq> {
    pub interaction: Interaction,
    pub selected: Option<T>,
    pub changed: bool,
    pub paint: Vec<WidgetPaint>,
}

#[must_use]
pub fn show_dropdown<T: Clone + PartialEq>(
    ctx: &mut FrameCtx<'_>,
    dropdown: Dropdown<'_, T>,
) -> DropdownResponse<T> {
    let Dropdown {
        id,
        trigger_rect,
        item_height,
        items,
        selected: initial_selected,
        placeholder,
        state,
        disabled,
    } = dropdown;
    let interactive = !disabled;
    let popup_rect = popup_rect_below(trigger_rect, item_height, items.len());
    let trigger_interaction = ctx.interact(
        InteractDeclaration::new(id, trigger_rect, Sense::INTERACTIVE)
            .focusable(interactive)
            .disabled(!interactive)
            .active(state.open)
            .a11y(
                AccessNode::new(Role::ComboBox)
                    .with_label(placeholder)
                    .with_disabled(!interactive)
                    .with_expanded(state.open),
            ),
    );
    let live_focused = ctx.is_focused(id);
    if state.open && state.focus_seated && !live_focused {
        state.open = false;
    }
    if state.open && pointer_pressed_outside(ctx.input, trigger_rect, popup_rect) {
        state.open = false;
    }
    let pointer_toggled = interactive && trigger_interaction.click();
    let keyboard_opened = interactive && live_focused && !state.open && {
        take_key(
            ctx.input,
            &[
                TakeKey::named(NamedKey::Enter),
                TakeKey::named(NamedKey::Space),
                TakeKey::named(NamedKey::ArrowDown),
            ],
        )
        .is_some()
    };
    if pointer_toggled {
        state.open = !state.open;
    } else if keyboard_opened {
        state.open = true;
    }
    if state.open && live_focused {
        state.focus_seated = true;
    }
    if state.open && state.highlighted.is_none() {
        state.highlighted = current_index(&items, initial_selected.as_ref())
            .or_else(|| (!items.is_empty()).then_some(0));
    }
    if !state.open {
        state.clear_typeahead();
        state.focus_seated = false;
        state.last_hovered = None;
    }
    let mut selected = initial_selected.clone();
    let mut changed = false;
    let mut paint = trigger_paint(
        ctx,
        trigger_rect,
        disabled,
        trigger_label(&items, initial_selected.as_ref(), placeholder),
        trigger_interaction,
        live_focused,
    );
    if state.open && interactive {
        render_open_popup(
            ctx,
            PopupInputs {
                id,
                popup_rect,
                item_height,
                label: placeholder,
                items: &items,
                initial_selected: initial_selected.as_ref(),
                state,
                live_focused,
            },
            &mut selected,
            &mut changed,
            &mut paint,
        );
    }
    DropdownResponse {
        interaction: trigger_interaction,
        selected,
        changed,
        paint,
    }
}

struct PopupInputs<'a, T: Clone + PartialEq> {
    id: WidgetId,
    popup_rect: LayoutRect,
    item_height: LayoutPx,
    label: StringKey,
    items: &'a [DropdownItem<T>],
    initial_selected: Option<&'a T>,
    state: &'a mut DropdownState,
    live_focused: bool,
}

fn render_open_popup<T: Clone + PartialEq>(
    ctx: &mut FrameCtx<'_>,
    inputs: PopupInputs<'_, T>,
    selected: &mut Option<T>,
    changed: &mut bool,
    paint: &mut Vec<WidgetPaint>,
) {
    let PopupInputs {
        id,
        popup_rect,
        item_height,
        label,
        items,
        initial_selected,
        state,
        live_focused,
    } = inputs;
    if live_focused {
        if let Some(action) = take_keyboard_action(ctx) {
            let page_size = visible_item_count(popup_rect, item_height);
            apply_keyboard_action(action, items, page_size, state, selected, changed);
        }
        typeahead_step(ctx, items, state);
    }
    ctx.a11y.push(
        id.child(WidgetKey::new("popup")),
        popup_rect,
        AccessNode::new(Role::ListBox).with_label(label),
    );
    let mut popup_paints: Vec<WidgetPaint> = Vec::new();
    popup_paints.push(WidgetPaint::Surface {
        rect: popup_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.sm,
        elevation: Some(ctx.theme().elevation.level1),
    });
    let prev_hovered = state.last_hovered;
    let mut current_hovered: Option<usize> = None;
    items.iter().enumerate().for_each(|(index, item)| {
        let item_rect = item_rect(popup_rect, item_height, index);
        let item_id = id.child_indexed(WidgetKey::new("item"), index as u64);
        let item_interaction = ctx.interact(
            InteractDeclaration::new(item_id, item_rect, Sense::INTERACTIVE)
                .at_z(ZLayer::POPUP)
                .active(Some(&item.value) == initial_selected)
                .a11y(
                    AccessNode::new(Role::ListBoxOption)
                        .with_label(item.label)
                        .with_selected(Some(&item.value) == initial_selected),
                ),
        );
        if item_interaction.hover() {
            current_hovered = Some(index);
            if prev_hovered != Some(index) {
                state.highlighted = Some(index);
            }
        }
        if item_interaction.click() {
            let next = Some(item.value.clone());
            *changed = next != *selected;
            *selected = next;
            state.open = false;
        }
        let highlighted = state.highlighted == Some(index);
        popup_paints.push(WidgetPaint::Surface {
            rect: item_rect,
            fill: if highlighted {
                ctx.theme().colors.accent.step(Step12::HOVER_BG)
            } else if Some(&item.value) == initial_selected {
                ctx.theme().colors.neutral.step(Step12::SELECTED_BG)
            } else {
                ctx.theme().colors.surface(crate::theme::SurfaceLevel::L1)
            },
            border: None,
            radius: ctx.theme().radius.none,
            elevation: None,
        });
        popup_paints.push(WidgetPaint::Label {
            rect: item_rect,
            text: LabelText::Key(item.label),
            color: ctx.theme().colors.text_primary(),
            role: ctx.theme().typography.body,
        });
    });
    state.last_hovered = current_hovered;
    paint.push(WidgetPaint::Popup {
        paints: popup_paints,
    });
}

fn pointer_pressed_outside(
    input: &crate::input::InputSnapshot,
    trigger: LayoutRect,
    popup: LayoutRect,
) -> bool {
    if input.buttons_pressed.is_empty() {
        return false;
    }
    let Some(sample) = input.pointer else {
        return false;
    };
    !trigger.contains(sample.position) && !popup.contains(sample.position)
}

fn apply_keyboard_action<T: Clone + PartialEq>(
    action: KeyboardAction,
    items: &[DropdownItem<T>],
    page_size: usize,
    state: &mut DropdownState,
    selected: &mut Option<T>,
    changed: &mut bool,
) {
    state.clear_typeahead();
    let count = items.len();
    match action {
        KeyboardAction::Up => {
            state.highlighted = move_highlight(state.highlighted, count, HighlightStep::Prev);
        }
        KeyboardAction::Down => {
            state.highlighted = move_highlight(state.highlighted, count, HighlightStep::Next);
        }
        KeyboardAction::PageUp => {
            state.highlighted = page_highlight(state.highlighted, count, page_size, false);
        }
        KeyboardAction::PageDown => {
            state.highlighted = page_highlight(state.highlighted, count, page_size, true);
        }
        KeyboardAction::Home => {
            if count > 0 {
                state.highlighted = Some(0);
            }
        }
        KeyboardAction::End => {
            if count > 0 {
                state.highlighted = Some(count - 1);
            }
        }
        KeyboardAction::Enter => {
            if let Some(idx) = state.highlighted
                && let Some(item) = items.get(idx)
            {
                let next = Some(item.value.clone());
                *changed = next != *selected;
                *selected = next;
                state.open = false;
            }
        }
        KeyboardAction::Escape => {
            state.open = false;
        }
    }
}

fn page_highlight(
    current: Option<usize>,
    count: usize,
    page: usize,
    forward: bool,
) -> Option<usize> {
    if count == 0 {
        return None;
    }
    let step = page.max(1);
    let idx = current.unwrap_or(if forward { 0 } else { count - 1 });
    let next = if forward {
        (idx + step).min(count - 1)
    } else {
        idx.saturating_sub(step)
    };
    Some(next)
}

fn trigger_label<T: Clone + PartialEq>(
    items: &[DropdownItem<T>],
    selected: Option<&T>,
    placeholder: StringKey,
) -> StringKey {
    selected
        .and_then(|sel| items.iter().find(|i| &i.value == sel).map(|i| i.label))
        .unwrap_or(placeholder)
}

fn typeahead_step<T: Clone + PartialEq>(
    ctx: &mut FrameCtx<'_>,
    items: &[DropdownItem<T>],
    state: &mut DropdownState,
) {
    let now = ctx.input.frame;
    let pending = core::mem::take(&mut ctx.input.keys_pressed);
    let unconsumed = pending.into_iter().fold(Vec::new(), |mut acc, event| {
        let Some(typed) = printable_char(event) else {
            acc.push(event);
            return acc;
        };
        if state
            .typeahead_last
            .is_some_and(|t| now.since(t) > TYPEAHEAD_RESET)
        {
            state.filter.clear();
        }
        state.filter.push(typed);
        state.typeahead_last = Some(now);
        let needle = state.filter.to_lowercase();
        let matched = items.iter().position(|item| {
            ctx.strings.contains(item.label)
                && ctx
                    .strings
                    .resolve(item.label)
                    .to_lowercase()
                    .starts_with(&needle)
        });
        if let Some(idx) = matched {
            state.highlighted = Some(idx);
        }
        acc
    });
    ctx.input.keys_pressed = unconsumed;
}

fn printable_char(event: KeyEvent) -> Option<char> {
    if event.modifiers != ModifierMask::NONE && event.modifiers != ModifierMask::SHIFT {
        return None;
    }
    match event.code {
        KeyCode::Char(c) => Some(c.get()),
        KeyCode::Named(NamedKey::Space) => Some(' '),
        KeyCode::Named(_) => None,
    }
}

fn visible_item_count(popup: LayoutRect, item_height: LayoutPx) -> usize {
    let h = item_height.value();
    if h <= 0.0 {
        return 0;
    }
    #[allow(
        clippy::cast_possible_truncation,
        clippy::cast_sign_loss,
        reason = "popup heights yield small non-negative usize"
    )]
    let count = (popup.size.height.value() / h).floor() as usize;
    count
}

fn item_rect(popup: LayoutRect, item_height: LayoutPx, index: usize) -> LayoutRect {
    #[allow(
        clippy::cast_precision_loss,
        reason = "dropdown indices fit in f32 mantissa"
    )]
    let index_f32 = index as f32;
    let y = popup.origin.y.value() + index_f32 * item_height.value();
    LayoutRect::new(
        LayoutPos::new(popup.origin.x, LayoutPx::new(y)),
        LayoutSize::new(popup.size.width, item_height),
    )
}

fn current_index<T: Clone + PartialEq>(
    items: &[DropdownItem<T>],
    selected: Option<&T>,
) -> Option<usize> {
    selected.and_then(|sel| items.iter().position(|item| &item.value == sel))
}

#[derive(Copy, Clone, Debug)]
enum HighlightStep {
    Prev,
    Next,
}

fn move_highlight(current: Option<usize>, count: usize, step: HighlightStep) -> Option<usize> {
    if count == 0 {
        return None;
    }
    let next = match (current, step) {
        (None, HighlightStep::Next) => 0,
        (None, HighlightStep::Prev) => count - 1,
        (Some(idx), HighlightStep::Next) => (idx + 1) % count,
        (Some(idx), HighlightStep::Prev) => (idx + count - 1) % count,
    };
    Some(next)
}

#[derive(Copy, Clone, Debug)]
enum KeyboardAction {
    Up,
    Down,
    PageUp,
    PageDown,
    Home,
    End,
    Enter,
    Escape,
}

fn take_keyboard_action(ctx: &mut FrameCtx<'_>) -> Option<KeyboardAction> {
    let event = take_key(
        ctx.input,
        &[
            TakeKey::named(NamedKey::ArrowUp),
            TakeKey::named(NamedKey::ArrowDown),
            TakeKey::named(NamedKey::PageUp),
            TakeKey::named(NamedKey::PageDown),
            TakeKey::named(NamedKey::Home),
            TakeKey::named(NamedKey::End),
            TakeKey::named(NamedKey::Enter),
            TakeKey::named(NamedKey::Escape),
        ],
    )?;
    let action = match event.code {
        KeyCode::Named(NamedKey::ArrowUp) => KeyboardAction::Up,
        KeyCode::Named(NamedKey::ArrowDown) => KeyboardAction::Down,
        KeyCode::Named(NamedKey::PageUp) => KeyboardAction::PageUp,
        KeyCode::Named(NamedKey::PageDown) => KeyboardAction::PageDown,
        KeyCode::Named(NamedKey::Home) => KeyboardAction::Home,
        KeyCode::Named(NamedKey::End) => KeyboardAction::End,
        KeyCode::Named(NamedKey::Enter) => KeyboardAction::Enter,
        KeyCode::Named(NamedKey::Escape) => KeyboardAction::Escape,
        _ => unreachable!("take_key only returns the listed candidates"),
    };
    Some(action)
}

fn trigger_paint(
    ctx: &FrameCtx<'_>,
    trigger_rect: LayoutRect,
    disabled: bool,
    label_text: StringKey,
    interaction: Interaction,
    live_focused: bool,
) -> Vec<WidgetPaint> {
    let neutral = ctx.theme().colors.neutral;
    let radius = ctx.theme().radius.sm;
    let hovered = interaction.hover();
    let pressed = interaction.pressed();
    let fill = if disabled {
        neutral.step(Step12::SUBTLE_BG)
    } else if pressed {
        neutral.step(Step12::SELECTED_BG)
    } else if hovered {
        neutral.step(Step12::HOVER_BG)
    } else {
        neutral.step(Step12::ELEMENT_BG)
    };
    let border = Border {
        width: StrokeWidth::HAIRLINE,
        color: neutral.step(if hovered {
            Step12::HOVER_BORDER
        } else {
            Step12::BORDER
        }),
    };
    let chevron_width = trigger_rect
        .size
        .height
        .value()
        .min(trigger_rect.size.width.value());
    let label_width = (trigger_rect.size.width.value() - chevron_width).max(0.0);
    let label_rect = LayoutRect::new(
        trigger_rect.origin,
        LayoutSize::new(LayoutPx::new(label_width), trigger_rect.size.height),
    );
    let chevron_rect = LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(trigger_rect.origin.x.value() + label_width),
            trigger_rect.origin.y,
        ),
        LayoutSize::new(LayoutPx::new(chevron_width), trigger_rect.size.height),
    );
    let mut paint = vec![
        WidgetPaint::Surface {
            rect: trigger_rect,
            fill,
            border: Some(border),
            radius,
            elevation: None,
        },
        WidgetPaint::Label {
            rect: label_rect,
            text: LabelText::Key(label_text),
            color: if disabled {
                ctx.theme().colors.text_disabled()
            } else {
                ctx.theme().colors.text_primary()
            },
            role: ctx.theme().typography.body,
        },
        WidgetPaint::Mark {
            rect: chevron_rect,
            kind: GlyphMark::Chevron,
            color: ctx.theme().colors.text_secondary(),
        },
    ];
    push_focus_ring(ctx, &mut paint, trigger_rect, radius, live_focused);
    paint
}

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

    use super::{Dropdown, DropdownItem, DropdownState, show_dropdown};
    use crate::focus::FocusManager;
    use crate::frame::FrameCtx;
    use crate::hit_test::{HitFrame, HitState, resolve};
    use crate::hotkey::HotkeyTable;
    use crate::input::{
        FrameInstant, InputSnapshot, KeyChar, KeyCode, KeyEvent, ModifierMask, NamedKey,
        PointerButton, PointerButtonMask, PointerSample,
    };
    use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
    use crate::strings::StringKey;
    use crate::strings::StringTable;
    use crate::theme::Theme;
    use crate::widget_id::{WidgetId, WidgetKey};

    #[derive(Copy, Clone, Debug, PartialEq, Eq)]
    enum Choice {
        Apple,
        Banana,
        Cherry,
    }

    const PLACEHOLDER: StringKey = StringKey::new("dropdown.choose");

    fn dropdown_id() -> WidgetId {
        WidgetId::ROOT.child(WidgetKey::new("dropdown"))
    }

    fn trigger_rect() -> LayoutRect {
        LayoutRect::new(
            LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
            LayoutSize::new(LayoutPx::new(160.0), LayoutPx::new(28.0)),
        )
    }

    const ITEM_HEIGHT: LayoutPx = LayoutPx::new(24.0);

    fn items() -> Vec<DropdownItem<Choice>> {
        vec![
            DropdownItem {
                value: Choice::Apple,
                label: StringKey::new("fruit.apple"),
            },
            DropdownItem {
                value: Choice::Banana,
                label: StringKey::new("fruit.banana"),
            },
            DropdownItem {
                value: Choice::Cherry,
                label: StringKey::new("fruit.cherry"),
            },
        ]
    }

    fn render_with(
        state: &mut DropdownState,
        selected: Option<Choice>,
        focus: &mut FocusManager,
        snap: &mut InputSnapshot,
        prev: &HitState,
    ) -> (super::DropdownResponse<Choice>, HitState, HitFrame) {
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut hits = HitFrame::new();
        let widget = Dropdown::new(
            dropdown_id(),
            trigger_rect(),
            ITEM_HEIGHT,
            items(),
            selected,
            PLACEHOLDER,
            state,
        );
        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_dropdown(&mut ctx, widget)
        };
        let next_state = resolve(prev, &hits, snap, focus.focused());
        (response, next_state, hits)
    }

    fn pointer_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 pointer_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 pointer_idle_at(pos: LayoutPos) -> InputSnapshot {
        let mut s = InputSnapshot::idle(FrameInstant::ZERO);
        s.pointer = Some(PointerSample::new(pos));
        s
    }

    fn click_at(
        state: &mut DropdownState,
        selected: &mut Option<Choice>,
        focus: &mut FocusManager,
        prev: &mut HitState,
        pos: LayoutPos,
    ) {
        [
            pointer_press_at(pos),
            pointer_release_at(pos),
            pointer_idle_at(pos),
        ]
        .into_iter()
        .for_each(|mut snap| {
            let (response, next, _) = render_with(state, *selected, focus, &mut snap, prev);
            if response.changed {
                *selected = response.selected;
            }
            *prev = next;
        });
    }

    fn focused_setup() -> FocusManager {
        let mut focus = FocusManager::new();
        focus.register_focusable(dropdown_id());
        focus.request_focus(dropdown_id());
        focus.end_frame();
        focus
    }

    fn open_state(highlighted: Option<usize>) -> DropdownState {
        DropdownState {
            open: true,
            highlighted,
            filter: String::new(),
            typeahead_last: None,
            focus_seated: true,
            last_hovered: None,
        }
    }

    #[test]
    fn click_trigger_opens_dropdown() {
        let mut state = DropdownState::closed();
        let mut selected = None;
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        click_at(
            &mut state,
            &mut selected,
            &mut focus,
            &mut prev,
            LayoutPos::new(LayoutPx::new(10.0), LayoutPx::new(10.0)),
        );
        assert!(state.open, "first click opens");
        click_at(
            &mut state,
            &mut selected,
            &mut focus,
            &mut prev,
            LayoutPos::new(LayoutPx::new(10.0), LayoutPx::new(10.0)),
        );
        assert!(!state.open, "second click closes");
    }

    #[test]
    fn click_item_selects_and_closes() {
        let mut state = open_state(Some(0));
        let mut selected = None;
        let mut focus = focused_setup();
        let mut prev = HitState::new();
        let item_pos = LayoutPos::new(LayoutPx::new(20.0), LayoutPx::new(28.0 + 12.0));
        click_at(&mut state, &mut selected, &mut focus, &mut prev, item_pos);
        assert_eq!(selected, Some(Choice::Apple));
        assert!(!state.open);
    }

    #[test]
    fn arrow_down_then_enter_picks_next_item() {
        let mut state = open_state(Some(0));
        let selected = None;
        let mut focus = focused_setup();
        let prev = HitState::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::ArrowDown),
            ModifierMask::NONE,
        ));
        let (_, _, _) = render_with(&mut state, selected, &mut focus, &mut snap, &prev);
        assert_eq!(state.highlighted, Some(1));

        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::Enter),
            ModifierMask::NONE,
        ));
        let (response, _, _) = render_with(&mut state, selected, &mut focus, &mut snap, &prev);
        assert_eq!(response.selected, Some(Choice::Banana));
        assert!(!state.open);
    }

    #[test]
    fn escape_closes_without_changing_selection() {
        let mut state = open_state(Some(2));
        let selected = Some(Choice::Apple);
        let mut focus = focused_setup();
        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, _, _) = render_with(&mut state, selected, &mut focus, &mut snap, &prev);
        assert_eq!(response.selected, selected);
        assert!(!response.changed);
        assert!(!state.open);
    }

    #[test]
    fn home_end_jumps_to_extremes() {
        let mut state = open_state(Some(1));
        let mut focus = focused_setup();
        let prev = HitState::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::Home),
            ModifierMask::NONE,
        ));
        let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev);
        assert_eq!(state.highlighted, Some(0));

        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::End),
            ModifierMask::NONE,
        ));
        let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev);
        assert_eq!(state.highlighted, Some(2));
    }

    #[test]
    fn arrow_keys_wrap_around_list() {
        let mut state = open_state(Some(2));
        let mut focus = focused_setup();
        let prev = HitState::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::ArrowDown),
            ModifierMask::NONE,
        ));
        let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev);
        assert_eq!(state.highlighted, Some(0));
    }

    #[test]
    fn focused_closed_dropdown_opens_on_enter() {
        let mut state = DropdownState::closed();
        let mut focus = focused_setup();
        let prev = HitState::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::Enter),
            ModifierMask::NONE,
        ));
        let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev);
        assert!(state.open, "Enter on focused trigger opens");
        assert!(snap.keys_pressed.is_empty(), "Enter consumed");
    }

    #[test]
    fn focused_closed_dropdown_opens_on_arrow_down() {
        let mut state = DropdownState::closed();
        let mut focus = focused_setup();
        let prev = HitState::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::ArrowDown),
            ModifierMask::NONE,
        ));
        let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev);
        assert!(state.open);
    }

    #[test]
    fn open_dropdown_auto_closes_when_focus_leaves() {
        let mut state = open_state(Some(0));
        let mut focus = FocusManager::new();
        let prev = HitState::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev);
        assert!(!state.open, "open + seated + no live focus closes");
    }

    fn typeahead_render_with(
        state: &mut DropdownState,
        focus: &mut FocusManager,
        snap: &mut InputSnapshot,
        prev: &HitState,
        strings: &StringTable,
    ) -> super::DropdownResponse<Choice> {
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut hits = HitFrame::new();
        let widget = Dropdown::new(
            dropdown_id(),
            trigger_rect(),
            ITEM_HEIGHT,
            items(),
            None,
            PLACEHOLDER,
            state,
        );
        let mut shaper = bone_text::Shaper::new();
        let mut a11y = crate::a11y::AccessTreeBuilder::new();
        let mut ctx = FrameCtx::new(
            theme,
            snap,
            focus,
            &table,
            strings,
            &mut hits,
            prev,
            &mut a11y,
            &mut shaper,
        );
        show_dropdown(&mut ctx, widget)
    }

    #[test]
    fn typeahead_highlights_first_matching_label() {
        let mut state = open_state(Some(0));
        let mut focus = focused_setup();
        let prev = HitState::new();
        let strings = StringTable::from_entries([
            (StringKey::new("fruit.apple"), "Apple".to_owned()),
            (StringKey::new("fruit.banana"), "Banana".to_owned()),
            (StringKey::new("fruit.cherry"), "Cherry".to_owned()),
        ]);
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.keys_pressed.push(KeyEvent::new(
            KeyCode::Char(KeyChar::from_char('b')),
            ModifierMask::NONE,
        ));
        let _ = typeahead_render_with(&mut state, &mut focus, &mut snap, &prev, &strings);
        assert_eq!(state.highlighted, Some(1));
        assert_eq!(state.filter, "b");
    }

    #[test]
    fn typeahead_filter_clears_on_navigation() {
        let mut state = open_state(Some(0));
        state.filter = "ban".to_owned();
        let mut focus = focused_setup();
        let prev = HitState::new();
        let strings = StringTable::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::ArrowDown),
            ModifierMask::NONE,
        ));
        let _ = typeahead_render_with(&mut state, &mut focus, &mut snap, &prev, &strings);
        assert!(state.filter.is_empty(), "navigation clears filter");
    }

    #[test]
    fn pointer_hover_over_item_updates_highlight() {
        let mut state = open_state(Some(0));
        let mut focus = focused_setup();
        let mut prev = HitState::new();
        let item_pos = LayoutPos::new(LayoutPx::new(20.0), LayoutPx::new(28.0 + 24.0 + 12.0));
        (0..2).for_each(|_| {
            let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
            snap.pointer = Some(PointerSample::new(item_pos));
            let (_, next, _) = render_with(&mut state, None, &mut focus, &mut snap, &prev);
            prev = next;
        });
        assert_eq!(
            state.highlighted,
            Some(1),
            "hover over second item bumps highlight",
        );
    }

    #[test]
    fn keyboard_nav_holds_against_stationary_pointer() {
        let mut state = open_state(Some(0));
        let mut focus = focused_setup();
        let mut prev = HitState::new();
        let item1_pos = LayoutPos::new(LayoutPx::new(20.0), LayoutPx::new(28.0 + 24.0 + 12.0));
        (0..2).for_each(|_| {
            let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
            snap.pointer = Some(PointerSample::new(item1_pos));
            let (_, next, _) = render_with(&mut state, None, &mut focus, &mut snap, &prev);
            prev = next;
        });
        assert_eq!(state.highlighted, Some(1), "hover seeded highlight");

        let mut nav_snap = InputSnapshot::idle(FrameInstant::ZERO);
        nav_snap.pointer = Some(PointerSample::new(item1_pos));
        nav_snap.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::ArrowDown),
            ModifierMask::NONE,
        ));
        let (_, after_nav, _) = render_with(&mut state, None, &mut focus, &mut nav_snap, &prev);
        prev = after_nav;
        assert_eq!(
            state.highlighted,
            Some(2),
            "ArrowDown wins because hovered item did not change",
        );

        let mut idle_snap = InputSnapshot::idle(FrameInstant::ZERO);
        idle_snap.pointer = Some(PointerSample::new(item1_pos));
        let _ = render_with(&mut state, None, &mut focus, &mut idle_snap, &prev);
        assert_eq!(
            state.highlighted,
            Some(2),
            "stationary pointer cannot reclaim highlight from keyboard",
        );
    }

    #[test]
    fn outside_pointer_press_closes_open_popup() {
        let mut state = open_state(Some(0));
        let mut focus = focused_setup();
        let prev = HitState::new();
        let mut snap = pointer_press_at(LayoutPos::new(LayoutPx::new(500.0), LayoutPx::new(500.0)));
        let (_, _, _) = render_with(&mut state, None, &mut focus, &mut snap, &prev);
        assert!(!state.open, "click outside closes");
    }

    #[test]
    fn outside_press_with_no_pointer_does_not_close() {
        let mut state = open_state(Some(0));
        let mut focus = focused_setup();
        let prev = HitState::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.buttons_pressed =
            crate::input::PointerButtonMask::just(crate::input::PointerButton::Primary);
        let (_, _, _) = render_with(&mut state, None, &mut focus, &mut snap, &prev);
        assert!(state.open, "no pointer sample = no outside-click signal");
    }

    #[test]
    fn typeahead_resets_after_quiet_window() {
        let mut state = open_state(Some(0));
        let mut focus = focused_setup();
        let prev = HitState::new();
        let strings = StringTable::from_entries([
            (StringKey::new("fruit.apple"), "Apple".to_owned()),
            (StringKey::new("fruit.banana"), "Banana".to_owned()),
            (StringKey::new("fruit.cherry"), "Cherry".to_owned()),
        ]);
        let mut snap = InputSnapshot::idle(FrameInstant::from_duration(
            core::time::Duration::from_millis(100),
        ));
        snap.keys_pressed.push(KeyEvent::new(
            KeyCode::Char(KeyChar::from_char('a')),
            ModifierMask::NONE,
        ));
        let _ = typeahead_render_with(&mut state, &mut focus, &mut snap, &prev, &strings);
        assert_eq!(state.filter, "a");

        let mut snap2 = InputSnapshot::idle(FrameInstant::from_duration(
            core::time::Duration::from_millis(100 + 800),
        ));
        snap2.keys_pressed.push(KeyEvent::new(
            KeyCode::Char(KeyChar::from_char('b')),
            ModifierMask::NONE,
        ));
        let _ = typeahead_render_with(&mut state, &mut focus, &mut snap2, &prev, &strings);
        assert_eq!(state.filter, "b", "quiet window reset filter to fresh char");
        assert_eq!(state.highlighted, Some(1));
    }

    #[test]
    fn page_down_jumps_by_visible_count() {
        let mut state = open_state(Some(0));
        let mut focus = focused_setup();
        let prev = HitState::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::PageDown),
            ModifierMask::NONE,
        ));
        let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev);
        assert_eq!(
            state.highlighted,
            Some(2),
            "PageDown jumps to last when within visible count"
        );
    }

    #[test]
    fn page_up_clamps_at_first() {
        let mut state = open_state(Some(1));
        let mut focus = focused_setup();
        let prev = HitState::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.keys_pressed.push(KeyEvent::new(
            KeyCode::Named(NamedKey::PageUp),
            ModifierMask::NONE,
        ));
        let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev);
        assert_eq!(state.highlighted, Some(0));
    }

    #[test]
    fn popup_rect_below_anchors_under_trigger_and_sizes_to_items() {
        let trigger = trigger_rect();
        let popup = super::popup_rect_below(trigger, ITEM_HEIGHT, 4);
        assert_eq!(popup.origin.x, trigger.origin.x);
        assert!(
            (popup.origin.y.value() - (trigger.origin.y.value() + trigger.size.height.value()))
                .abs()
                < 1e-6,
            "popup y must sit at trigger bottom"
        );
        assert_eq!(popup.size.width, trigger.size.width);
        assert!(
            (popup.size.height.value() - 96.0).abs() < 1e-6,
            "4 items × 24px = 96px"
        );
    }

    #[test]
    fn typeahead_skips_unresolved_keys() {
        let mut state = open_state(Some(0));
        let mut focus = focused_setup();
        let prev = HitState::new();
        let strings = StringTable::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        snap.keys_pressed.push(KeyEvent::new(
            KeyCode::Char(KeyChar::from_char('f')),
            ModifierMask::NONE,
        ));
        let _ = typeahead_render_with(&mut state, &mut focus, &mut snap, &prev, &strings);
        assert_eq!(
            state.highlighted,
            Some(0),
            "highlight unchanged when no string entries match",
        );
    }

    #[test]
    fn disabled_dropdown_swallows_clicks() {
        let mut state = DropdownState::closed();
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        [
            pointer_press_at(LayoutPos::new(LayoutPx::new(10.0), LayoutPx::new(10.0))),
            pointer_release_at(LayoutPos::new(LayoutPx::new(10.0), LayoutPx::new(10.0))),
        ]
        .into_iter()
        .for_each(|mut snap| {
            let mut hits = HitFrame::new();
            let widget = Dropdown::new(
                dropdown_id(),
                trigger_rect(),
                ITEM_HEIGHT,
                items(),
                None,
                PLACEHOLDER,
                &mut state,
            )
            .disabled(true);
            let _ = {
                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_dropdown(&mut ctx, widget)
            };
            prev = resolve(&prev, &hits, &snap, focus.focused());
        });
        assert!(!state.open);
    }

    #[test]
    fn open_popup_wins_hit_over_sibling_pushed_after_at_base_z() {
        use crate::frame::InteractDeclaration;
        use crate::hit_test::{HitItem, Sense, ZLayer};
        let mut state = open_state(Some(0));
        let mut focus = focused_setup();
        let prev = HitState::new();
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut hits = HitFrame::new();
        let item_pos = LayoutPos::new(LayoutPx::new(20.0), LayoutPx::new(28.0 + 12.0));
        let mut snap = pointer_press_at(item_pos);
        let widget = Dropdown::new(
            dropdown_id(),
            trigger_rect(),
            ITEM_HEIGHT,
            items(),
            None,
            PLACEHOLDER,
            &mut state,
        );
        let sibling_id = WidgetId::ROOT.child(WidgetKey::new("sibling_under_popup"));
        {
            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 _ = show_dropdown(&mut ctx, widget);
            let _ = ctx.interact(InteractDeclaration::new(
                sibling_id,
                LayoutRect::new(
                    LayoutPos::new(LayoutPx::ZERO, LayoutPx::new(28.0)),
                    LayoutSize::new(LayoutPx::new(160.0), LayoutPx::new(72.0)),
                ),
                Sense::INTERACTIVE,
            ));
        }
        let item_at_idx_0 = dropdown_id().child_indexed(WidgetKey::new("item"), 0);
        let Some(popup_item) = hits.items().iter().find(|h| h.id == item_at_idx_0) else {
            panic!("popup item must be registered in hit frame");
        };
        assert_eq!(popup_item.z, ZLayer::POPUP);
        let next = resolve(&prev, &hits, &snap, focus.focused());
        let hit_id = hits
            .items()
            .iter()
            .filter(|item: &&HitItem| {
                item.sense.contains(Sense::HOVER) && item.rect.contains(item_pos)
            })
            .max_by_key(|item: &&HitItem| item.z)
            .map(|item: &HitItem| item.id);
        assert_eq!(
            hit_id,
            Some(item_at_idx_0),
            "popup item must beat later sibling at base z when both contain pointer",
        );
        assert!(
            next.interaction(item_at_idx_0).pressed(),
            "press routes to popup item, not sibling",
        );
        assert!(
            !next.interaction(sibling_id).pressed(),
            "sibling did not consume press despite later push order",
        );
    }
}
