use std::collections::{BTreeMap, BTreeSet};

use serde::{Deserialize, Serialize};

use crate::input::{
    ClickCount, DoubleClickWindow, FrameInstant, InputSnapshot, PointerButton, PointerButtonMask,
};
use crate::layout::{LayoutOffset, LayoutPos, LayoutRect};
use crate::widget_id::WidgetId;

#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Sense(u8);

impl Sense {
    pub const HOVER: Self = Self(1 << 0);
    pub const PRESS: Self = Self(1 << 1);
    pub const DRAG: Self = Self(1 << 2);
    pub const INTERACTIVE: Self = Self(Self::HOVER.0 | Self::PRESS.0);
    pub const DRAGGABLE: Self = Self(Self::HOVER.0 | Self::PRESS.0 | Self::DRAG.0);

    #[must_use]
    pub const fn contains(self, other: Self) -> bool {
        (self.0 & other.0) == other.0
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ZLayer(u32);

impl ZLayer {
    pub const BASE: Self = Self(0);
    pub const POPUP: Self = Self(100);

    #[must_use]
    pub const fn new(value: u32) -> Self {
        Self(value)
    }

    #[must_use]
    pub const fn get(self) -> u32 {
        self.0
    }
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct HitItem {
    pub id: WidgetId,
    pub rect: LayoutRect,
    pub sense: Sense,
    pub z: ZLayer,
    pub disabled: bool,
    pub active: bool,
}

#[derive(Clone, Debug, Default, PartialEq)]
pub struct HitFrame {
    items: Vec<HitItem>,
    seen: BTreeSet<WidgetId>,
}

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

    pub fn push(&mut self, item: HitItem) {
        assert!(
            !item.sense.contains(Sense::DRAG) || item.sense.contains(Sense::PRESS),
            "Sense::DRAG implies Sense::PRESS",
        );
        assert!(
            !item.sense.contains(Sense::PRESS) || item.sense.contains(Sense::HOVER),
            "Sense::PRESS implies Sense::HOVER",
        );
        assert!(
            self.seen.insert(item.id),
            "duplicate WidgetId in HitFrame: {:?}",
            item.id,
        );
        self.items.push(item);
    }

    #[must_use]
    pub fn items(&self) -> &[HitItem] {
        &self.items
    }

    pub fn clear(&mut self) {
        self.items.clear();
        self.seen.clear();
    }

    fn topmost(&self, pos: LayoutPos, required: Sense) -> Option<&HitItem> {
        self.items
            .iter()
            .enumerate()
            .filter(|(_, item)| item.sense.contains(required) && item.rect.contains(pos))
            .max_by_key(|(idx, item)| (item.z, *idx))
            .map(|(_, item)| item)
    }

    fn lookup(&self, id: WidgetId) -> Option<&HitItem> {
        self.items.iter().find(|item| item.id == id)
    }
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct PointerCapture {
    pub id: WidgetId,
    pub button: PointerButton,
}

#[derive(Clone, Debug, Default, PartialEq)]
pub struct PointerCaptureStack {
    captures: Vec<PointerCapture>,
}

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

    #[must_use]
    pub fn top(&self) -> Option<PointerCapture> {
        self.captures.last().copied()
    }

    pub fn push(&mut self, capture: PointerCapture) {
        self.captures.push(capture);
    }

    pub fn release_button(&mut self, button: PointerButton) {
        self.captures.retain(|c| c.button != button);
    }

    #[must_use]
    pub fn captures(&self) -> &[PointerCapture] {
        &self.captures
    }

    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.captures.is_empty()
    }
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct PressedRecord {
    pub id: WidgetId,
    pub button: PointerButton,
    pub origin: LayoutPos,
    pub started_at: FrameInstant,
    pub drag_active: bool,
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct ClickRecord {
    pub id: WidgetId,
    pub at: FrameInstant,
    pub count: ClickCount,
}

#[derive(
    Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize,
)]
#[serde(transparent)]
pub struct InteractionState(u16);

impl InteractionState {
    pub const NONE: Self = Self(0);
    pub const HOVER: Self = Self(1 << 0);
    pub const PRESSED: Self = Self(1 << 1);
    pub const FOCUSED: Self = Self(1 << 2);
    pub const DISABLED: Self = Self(1 << 3);
    pub const ACTIVE: Self = Self(1 << 4);
    pub const CLICK: Self = Self(1 << 5);
    pub const DOUBLE_CLICK: Self = Self(1 << 6);
    pub const DRAG_START: Self = Self(1 << 7);
    pub const DRAG_RELEASE: Self = Self(1 << 8);

    #[must_use]
    pub const fn contains(self, other: Self) -> bool {
        (self.0 & other.0) == other.0
    }

    #[must_use]
    pub const fn with(self, flag: Self, on: bool) -> Self {
        if on {
            Self(self.0 | flag.0)
        } else {
            Self(self.0 & !flag.0)
        }
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Default)]
pub struct Interaction {
    pub state: InteractionState,
    pub drag_delta: LayoutOffset,
    pub pressed_buttons: PointerButtonMask,
    pub click_button: Option<PointerButton>,
    pub drag_button: Option<PointerButton>,
}

impl Interaction {
    #[must_use]
    pub const fn idle() -> Self {
        Self {
            state: InteractionState::NONE,
            drag_delta: LayoutOffset::ZERO,
            pressed_buttons: PointerButtonMask::EMPTY,
            click_button: None,
            drag_button: None,
        }
    }

    #[must_use]
    pub const fn hover(self) -> bool {
        self.state.contains(InteractionState::HOVER)
    }

    #[must_use]
    pub const fn pressed(self) -> bool {
        self.state.contains(InteractionState::PRESSED)
    }

    #[must_use]
    pub const fn focused(self) -> bool {
        self.state.contains(InteractionState::FOCUSED)
    }

    #[must_use]
    pub const fn disabled(self) -> bool {
        self.state.contains(InteractionState::DISABLED)
    }

    #[must_use]
    pub const fn active(self) -> bool {
        self.state.contains(InteractionState::ACTIVE)
    }

    #[must_use]
    pub const fn click(self) -> bool {
        self.state.contains(InteractionState::CLICK)
    }

    #[must_use]
    pub const fn double_click(self) -> bool {
        self.state.contains(InteractionState::DOUBLE_CLICK)
    }

    #[must_use]
    pub const fn drag_start(self) -> bool {
        self.state.contains(InteractionState::DRAG_START)
    }

    #[must_use]
    pub const fn drag_release(self) -> bool {
        self.state.contains(InteractionState::DRAG_RELEASE)
    }
}

#[derive(Clone, Debug, Default, PartialEq)]
pub struct HitState {
    pub hovered: Option<WidgetId>,
    pub pressed: BTreeMap<PointerButton, PressedRecord>,
    pub last_click: BTreeMap<PointerButton, ClickRecord>,
    pub captures: PointerCaptureStack,
    pub interactions: BTreeMap<WidgetId, Interaction>,
    pub last_pointer: Option<LayoutPos>,
}

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

    #[must_use]
    pub fn interaction(&self, id: WidgetId) -> Interaction {
        self.interactions
            .get(&id)
            .copied()
            .unwrap_or(Interaction::idle())
    }
}

#[derive(Copy, Clone, Debug, PartialEq)]
struct ButtonEvent {
    id: WidgetId,
    button: PointerButton,
}

#[derive(Copy, Clone, Debug, PartialEq)]
struct ClickEvent {
    id: WidgetId,
    button: PointerButton,
    count: ClickCount,
}

#[derive(Default)]
struct Phase {
    pressed: BTreeMap<PointerButton, PressedRecord>,
    captures: PointerCaptureStack,
    last_click: BTreeMap<PointerButton, ClickRecord>,
    drag_starts: Vec<ButtonEvent>,
    drag_releases: Vec<ButtonEvent>,
    clicks: Vec<ClickEvent>,
    release_records: Vec<PressedRecord>,
}

#[must_use]
pub fn resolve(
    prev: &HitState,
    frame: &HitFrame,
    input: &InputSnapshot,
    focused: Option<WidgetId>,
) -> HitState {
    let pointer = input.pointer.map(|p| p.position);
    let phase = Phase {
        pressed: prev.pressed.clone(),
        captures: prev.captures.clone(),
        last_click: prev.last_click.clone(),
        ..Phase::default()
    };
    let phase = apply_press(phase, frame, input, pointer);
    let phase = apply_drag_start(phase, input, frame, pointer);
    let phase = apply_release(phase, frame, input, pointer);

    let routed_id = route_pointer(&phase.captures, frame, pointer);
    let sticky_pointer = pointer.or(prev.last_pointer);
    let interactions = build_interactions(
        frame,
        &InteractionInputs {
            hovered: routed_id,
            focused,
            pressed: &phase.pressed,
            release_records: &phase.release_records,
            pointer: sticky_pointer,
            drag_starts: &phase.drag_starts,
            drag_releases: &phase.drag_releases,
            clicks: &phase.clicks,
        },
    );

    HitState {
        hovered: routed_id,
        pressed: phase.pressed,
        last_click: phase.last_click,
        captures: phase.captures,
        interactions,
        last_pointer: sticky_pointer,
    }
}

fn route_pointer(
    captures: &PointerCaptureStack,
    frame: &HitFrame,
    pointer: Option<LayoutPos>,
) -> Option<WidgetId> {
    if let Some(cap) = captures.top()
        && frame.lookup(cap.id).is_some()
    {
        return Some(cap.id);
    }
    pointer
        .and_then(|p| frame.topmost(p, Sense::HOVER))
        .map(|item| item.id)
}

fn apply_press(
    phase: Phase,
    frame: &HitFrame,
    input: &InputSnapshot,
    pointer: Option<LayoutPos>,
) -> Phase {
    PointerButton::ALL
        .iter()
        .copied()
        .filter(|button| input.buttons_pressed.contains(*button))
        .fold(phase, |mut phase, button| {
            let target = pointer
                .and_then(|p| frame.topmost(p, Sense::PRESS))
                .filter(|item| !item.disabled);
            if let Some(item) = target
                && let Some(p) = pointer
            {
                phase.pressed.insert(
                    button,
                    PressedRecord {
                        id: item.id,
                        button,
                        origin: p,
                        started_at: input.frame,
                        drag_active: false,
                    },
                );
                if item.sense.contains(Sense::DRAG) {
                    phase.captures.push(PointerCapture {
                        id: item.id,
                        button,
                    });
                }
            }
            phase
        })
}

fn apply_drag_start(
    phase: Phase,
    input: &InputSnapshot,
    frame: &HitFrame,
    pointer: Option<LayoutPos>,
) -> Phase {
    let candidates: Vec<PressedRecord> = phase
        .pressed
        .values()
        .filter(|rec| !rec.drag_active)
        .filter(|rec| {
            frame
                .lookup(rec.id)
                .is_some_and(|item| item.sense.contains(Sense::DRAG) && !item.disabled)
        })
        .copied()
        .collect();
    candidates.into_iter().fold(phase, |mut phase, rec| {
        let Some(p) = pointer else { return phase };
        let offset = LayoutOffset::between(rec.origin, p);
        if !input.drag_threshold.exceeded_by(offset) {
            return phase;
        }
        if let Some(record) = phase.pressed.get_mut(&rec.button) {
            record.drag_active = true;
        }
        let already_captured = phase
            .captures
            .captures()
            .iter()
            .any(|c| c.id == rec.id && c.button == rec.button);
        if !already_captured {
            phase.captures.push(PointerCapture {
                id: rec.id,
                button: rec.button,
            });
        }
        phase.drag_starts.push(ButtonEvent {
            id: rec.id,
            button: rec.button,
        });
        phase
    })
}

fn apply_release(
    phase: Phase,
    frame: &HitFrame,
    input: &InputSnapshot,
    pointer: Option<LayoutPos>,
) -> Phase {
    PointerButton::ALL
        .iter()
        .copied()
        .filter(|button| input.buttons_released.contains(*button))
        .fold(phase, |mut phase, button| {
            if let Some(rec) = phase.pressed.remove(&button) {
                phase.release_records.push(rec);
                if rec.drag_active {
                    phase.drag_releases.push(ButtonEvent { id: rec.id, button });
                } else {
                    let pointer_over = pointer.is_some_and(|p| {
                        frame
                            .lookup(rec.id)
                            .is_some_and(|item| item.rect.contains(p))
                    });
                    if pointer_over {
                        let count = next_click_count(
                            phase.last_click.get(&button).copied(),
                            rec.id,
                            input.frame,
                            input.double_click_window,
                        );
                        phase.last_click.insert(
                            button,
                            ClickRecord {
                                id: rec.id,
                                at: input.frame,
                                count,
                            },
                        );
                        phase.clicks.push(ClickEvent {
                            id: rec.id,
                            button,
                            count,
                        });
                    }
                }
            }
            phase.captures.release_button(button);
            phase
        })
}

struct InteractionInputs<'a> {
    hovered: Option<WidgetId>,
    focused: Option<WidgetId>,
    pressed: &'a BTreeMap<PointerButton, PressedRecord>,
    release_records: &'a [PressedRecord],
    pointer: Option<LayoutPos>,
    drag_starts: &'a [ButtonEvent],
    drag_releases: &'a [ButtonEvent],
    clicks: &'a [ClickEvent],
}

fn build_interactions(
    frame: &HitFrame,
    inputs: &InteractionInputs<'_>,
) -> BTreeMap<WidgetId, Interaction> {
    frame
        .items()
        .iter()
        .map(|item| (item.id, interaction_for(item, inputs)))
        .collect()
}

fn interaction_for(item: &HitItem, inputs: &InteractionInputs<'_>) -> Interaction {
    let pressed_buttons = inputs
        .pressed
        .values()
        .filter(|rec| rec.id == item.id)
        .fold(PointerButtonMask::EMPTY, |mask, rec| mask.with(rec.button));
    let drag_record = inputs
        .pressed
        .values()
        .find(|rec| rec.id == item.id && rec.drag_active)
        .copied()
        .or_else(|| {
            inputs
                .release_records
                .iter()
                .find(|rec| rec.id == item.id && rec.drag_active)
                .copied()
        });
    let drag_delta = match (drag_record, inputs.pointer) {
        (Some(rec), Some(p)) => LayoutOffset::between(rec.origin, p),
        _ => LayoutOffset::ZERO,
    };
    let drag_start_event = inputs.drag_starts.iter().find(|ev| ev.id == item.id);
    let drag_button = drag_record
        .map(|rec| rec.button)
        .or_else(|| drag_start_event.map(|ev| ev.button));
    let click_match = inputs.clicks.iter().find(|ev| ev.id == item.id);
    let state = InteractionState::NONE
        .with(InteractionState::HOVER, inputs.hovered == Some(item.id))
        .with(InteractionState::FOCUSED, inputs.focused == Some(item.id))
        .with(InteractionState::PRESSED, !pressed_buttons.is_empty())
        .with(InteractionState::DISABLED, item.disabled)
        .with(InteractionState::ACTIVE, item.active)
        .with(InteractionState::CLICK, click_match.is_some())
        .with(
            InteractionState::DOUBLE_CLICK,
            click_match.is_some_and(|ev| ev.count.is_double()),
        )
        .with(InteractionState::DRAG_START, drag_start_event.is_some())
        .with(
            InteractionState::DRAG_RELEASE,
            inputs.drag_releases.iter().any(|ev| ev.id == item.id),
        );
    Interaction {
        state,
        drag_delta,
        pressed_buttons,
        click_button: click_match.map(|ev| ev.button),
        drag_button,
    }
}

fn next_click_count(
    last: Option<ClickRecord>,
    id: WidgetId,
    now: FrameInstant,
    window: DoubleClickWindow,
) -> ClickCount {
    match last {
        Some(prev) if prev.id == id && window.contains(now.since(prev.at)) => prev.count.next(),
        _ => ClickCount::SINGLE,
    }
}

#[cfg(test)]
mod tests {
    use core::time::Duration;

    use super::{HitFrame, HitItem, HitState, Interaction, Sense, ZLayer, resolve};
    use crate::input::{
        DoubleClickWindow, DragThreshold, FrameInstant, InputSnapshot, PointerButton,
        PointerButtonMask, PointerSample,
    };
    use crate::layout::{LayoutOffset, LayoutPos, LayoutPx, LayoutRect, LayoutSize};
    use crate::widget_id::{WidgetId, WidgetKey};

    fn id(key: &'static str) -> WidgetId {
        WidgetId::ROOT.child(WidgetKey::new(key))
    }

    fn rect(x: f32, y: f32, w: f32, h: f32) -> LayoutRect {
        LayoutRect::new(
            LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)),
            LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h)),
        )
    }

    fn pos(x: f32, y: f32) -> LayoutPos {
        LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y))
    }

    fn item(id: WidgetId, rect: LayoutRect, sense: Sense) -> HitItem {
        HitItem {
            id,
            rect,
            sense,
            z: ZLayer::BASE,
            disabled: false,
            active: false,
        }
    }

    fn snap_at(pointer: LayoutPos, frame_ms: u64) -> InputSnapshot {
        let mut s =
            InputSnapshot::idle(FrameInstant::from_duration(Duration::from_millis(frame_ms)));
        s.pointer = Some(PointerSample::new(pointer));
        s
    }

    fn press_snap(pointer: LayoutPos, frame_ms: u64, button: PointerButton) -> InputSnapshot {
        let mut s = snap_at(pointer, frame_ms);
        s.buttons_pressed = PointerButtonMask::just(button);
        s
    }

    fn release_snap(pointer: LayoutPos, frame_ms: u64, button: PointerButton) -> InputSnapshot {
        let mut s = snap_at(pointer, frame_ms);
        s.buttons_released = PointerButtonMask::just(button);
        s
    }

    #[test]
    fn hover_routes_to_topmost_item() {
        let mut frame = HitFrame::new();
        frame.push(item(
            id("under"),
            rect(0.0, 0.0, 100.0, 100.0),
            Sense::HOVER,
        ));
        frame.push(HitItem {
            z: ZLayer::new(10),
            ..item(id("over"), rect(20.0, 20.0, 40.0, 40.0), Sense::HOVER)
        });
        let state = resolve(&HitState::new(), &frame, &snap_at(pos(30.0, 30.0), 0), None);
        assert_eq!(state.hovered, Some(id("over")));
    }

    #[test]
    fn click_fires_on_press_and_release_inside() {
        let mut frame = HitFrame::new();
        frame.push(item(
            id("btn"),
            rect(0.0, 0.0, 50.0, 50.0),
            Sense::INTERACTIVE,
        ));
        let press = press_snap(pos(10.0, 10.0), 0, PointerButton::Primary);
        let after_press = resolve(&HitState::new(), &frame, &press, None);
        assert!(after_press.interaction(id("btn")).pressed());
        assert!(!after_press.interaction(id("btn")).click());

        let release = release_snap(pos(15.0, 15.0), 50, PointerButton::Primary);
        let after_release = resolve(&after_press, &frame, &release, None);
        let i = after_release.interaction(id("btn"));
        assert!(i.click());
        assert!(!i.pressed());
        assert_eq!(i.click_button, Some(PointerButton::Primary));
    }

    #[test]
    fn release_outside_does_not_fire_click() {
        let mut frame = HitFrame::new();
        frame.push(item(
            id("btn"),
            rect(0.0, 0.0, 50.0, 50.0),
            Sense::INTERACTIVE,
        ));
        let press = press_snap(pos(10.0, 10.0), 0, PointerButton::Primary);
        let after_press = resolve(&HitState::new(), &frame, &press, None);

        let release = release_snap(pos(200.0, 200.0), 50, PointerButton::Primary);
        let after_release = resolve(&after_press, &frame, &release, None);
        assert!(!after_release.interaction(id("btn")).click());
    }

    #[test]
    fn double_click_fires_within_window() {
        let mut frame = HitFrame::new();
        frame.push(item(
            id("btn"),
            rect(0.0, 0.0, 50.0, 50.0),
            Sense::INTERACTIVE,
        ));
        let s1 = resolve(
            &HitState::new(),
            &frame,
            &press_snap(pos(5.0, 5.0), 0, PointerButton::Primary),
            None,
        );
        let s2 = resolve(
            &s1,
            &frame,
            &release_snap(pos(5.0, 5.0), 30, PointerButton::Primary),
            None,
        );
        assert!(s2.interaction(id("btn")).click());
        assert!(!s2.interaction(id("btn")).double_click());

        let s3 = resolve(
            &s2,
            &frame,
            &press_snap(pos(5.0, 5.0), 60, PointerButton::Primary),
            None,
        );
        let s4 = resolve(
            &s3,
            &frame,
            &release_snap(pos(5.0, 5.0), 90, PointerButton::Primary),
            None,
        );
        let i = s4.interaction(id("btn"));
        assert!(i.click());
        assert!(i.double_click());
    }

    #[test]
    fn triple_click_keeps_double_click_flag() {
        let mut frame = HitFrame::new();
        frame.push(item(
            id("btn"),
            rect(0.0, 0.0, 50.0, 50.0),
            Sense::INTERACTIVE,
        ));
        let states = (0..3).fold(HitState::new(), |state, n| {
            let press_ms = n * 60;
            let release_ms = press_ms + 30;
            let s = resolve(
                &state,
                &frame,
                &press_snap(pos(5.0, 5.0), press_ms, PointerButton::Primary),
                None,
            );
            resolve(
                &s,
                &frame,
                &release_snap(pos(5.0, 5.0), release_ms, PointerButton::Primary),
                None,
            )
        });
        let Some(last) = states.last_click.get(&PointerButton::Primary).copied() else {
            panic!("triple-click leaves a click record");
        };
        assert_eq!(last.count.get(), 3);
        assert!(last.count.is_double());
    }

    #[test]
    fn drag_capture_routes_motion_outside_bounds() {
        let mut frame = HitFrame::new();
        frame.push(item(
            id("handle"),
            rect(0.0, 0.0, 20.0, 20.0),
            Sense::DRAGGABLE,
        ));
        let s1 = resolve(
            &HitState::new(),
            &frame,
            &press_snap(pos(5.0, 5.0), 0, PointerButton::Primary),
            None,
        );
        assert_eq!(s1.captures.top().map(|c| c.id), Some(id("handle")));

        let motion = snap_at(pos(200.0, 200.0), 16);
        let s2 = resolve(&s1, &frame, &motion, None);
        let i = s2.interaction(id("handle"));
        assert!(i.drag_start());
        assert_ne!(i.drag_delta, LayoutOffset::ZERO);
        assert_eq!(i.drag_button, Some(PointerButton::Primary));
        assert_eq!(s2.hovered, Some(id("handle")));
    }

    #[test]
    fn drag_release_clears_capture_and_skips_click() {
        let mut frame = HitFrame::new();
        frame.push(item(
            id("handle"),
            rect(0.0, 0.0, 20.0, 20.0),
            Sense::DRAGGABLE,
        ));
        let s1 = resolve(
            &HitState::new(),
            &frame,
            &press_snap(pos(5.0, 5.0), 0, PointerButton::Primary),
            None,
        );
        let motion = snap_at(pos(200.0, 200.0), 16);
        let s2 = resolve(&s1, &frame, &motion, None);
        let s3 = resolve(
            &s2,
            &frame,
            &release_snap(pos(220.0, 220.0), 32, PointerButton::Primary),
            None,
        );
        let i = s3.interaction(id("handle"));
        assert!(i.drag_release());
        assert!(!i.click());
        assert!(s3.captures.is_empty());
    }

    #[test]
    fn disabled_item_does_not_press() {
        let mut frame = HitFrame::new();
        frame.push(HitItem {
            disabled: true,
            ..item(id("btn"), rect(0.0, 0.0, 50.0, 50.0), Sense::INTERACTIVE)
        });
        let press = press_snap(pos(10.0, 10.0), 0, PointerButton::Primary);
        let s = resolve(&HitState::new(), &frame, &press, None);
        let i = s.interaction(id("btn"));
        assert!(!i.pressed());
        assert!(i.disabled());
    }

    #[test]
    fn focused_id_threads_into_interaction() {
        let mut frame = HitFrame::new();
        frame.push(item(id("btn"), rect(0.0, 0.0, 50.0, 50.0), Sense::HOVER));
        let s = resolve(
            &HitState::new(),
            &frame,
            &snap_at(pos(10.0, 10.0), 0),
            Some(id("btn")),
        );
        assert!(s.interaction(id("btn")).focused());
    }

    #[test]
    fn active_flag_threads_from_hit_item() {
        let mut frame = HitFrame::new();
        frame.push(HitItem {
            active: true,
            ..item(id("toggle"), rect(0.0, 0.0, 50.0, 50.0), Sense::HOVER)
        });
        let s = resolve(&HitState::new(), &frame, &snap_at(pos(10.0, 10.0), 0), None);
        assert!(s.interaction(id("toggle")).active());
    }

    #[test]
    fn interaction_idle_default() {
        let i = Interaction::idle();
        assert!(!i.hover());
        assert!(!i.click());
        assert_eq!(i.drag_delta, LayoutOffset::ZERO);
        assert!(i.pressed_buttons.is_empty());
        assert_eq!(i.click_button, None);
        assert_eq!(i.drag_button, None);
    }

    #[test]
    fn pressed_buttons_records_each_button_held() {
        let mut frame = HitFrame::new();
        frame.push(item(
            id("btn"),
            rect(0.0, 0.0, 50.0, 50.0),
            Sense::INTERACTIVE,
        ));
        let mut snap = snap_at(pos(10.0, 10.0), 0);
        snap.buttons_pressed =
            PointerButtonMask::just(PointerButton::Primary).with(PointerButton::Secondary);
        let s = resolve(&HitState::new(), &frame, &snap, None);
        let i = s.interaction(id("btn"));
        assert!(i.pressed_buttons.contains(PointerButton::Primary));
        assert!(i.pressed_buttons.contains(PointerButton::Secondary));
        assert!(!i.pressed_buttons.contains(PointerButton::Middle));
    }

    #[test]
    #[should_panic(expected = "Sense::PRESS implies Sense::HOVER")]
    fn press_without_hover_panics_on_push() {
        let mut frame = HitFrame::new();
        frame.push(item(id("rogue"), rect(0.0, 0.0, 10.0, 10.0), Sense::PRESS));
    }

    #[test]
    #[should_panic(expected = "duplicate WidgetId in HitFrame")]
    fn duplicate_widget_id_in_frame_panics() {
        let mut frame = HitFrame::new();
        frame.push(item(id("dup"), rect(0.0, 0.0, 10.0, 10.0), Sense::HOVER));
        frame.push(item(id("dup"), rect(20.0, 0.0, 10.0, 10.0), Sense::HOVER));
    }

    #[test]
    fn release_button_clears_all_captures_for_that_button() {
        let mut stack = super::PointerCaptureStack::new();
        stack.push(super::PointerCapture {
            id: id("ghost"),
            button: PointerButton::Primary,
        });
        stack.push(super::PointerCapture {
            id: id("active"),
            button: PointerButton::Primary,
        });
        stack.push(super::PointerCapture {
            id: id("middle"),
            button: PointerButton::Middle,
        });
        stack.release_button(PointerButton::Primary);
        assert_eq!(stack.captures().len(), 1);
        assert_eq!(stack.captures()[0].button, PointerButton::Middle);
    }

    #[test]
    fn drag_start_skipped_when_widget_disables_mid_press() {
        let id_h = id("handle");
        let mut frame = HitFrame::new();
        frame.push(item(id_h, rect(0.0, 0.0, 20.0, 20.0), Sense::DRAGGABLE));
        let s1 = resolve(
            &HitState::new(),
            &frame,
            &press_snap(pos(5.0, 5.0), 0, PointerButton::Primary),
            None,
        );
        let mut disabled_frame = HitFrame::new();
        disabled_frame.push(HitItem {
            disabled: true,
            ..item(id_h, rect(0.0, 0.0, 20.0, 20.0), Sense::DRAGGABLE)
        });
        let s2 = resolve(&s1, &disabled_frame, &snap_at(pos(80.0, 80.0), 16), None);
        assert!(!s2.interaction(id_h).drag_start());
    }

    #[test]
    fn double_click_window_default() {
        assert_eq!(
            DoubleClickWindow::DEFAULT.duration(),
            Duration::from_millis(400)
        );
    }

    #[test]
    fn drag_threshold_default_4_px() {
        assert_eq!(DragThreshold::DEFAULT.px(), LayoutPx::new(4.0));
    }

    #[test]
    fn secondary_button_press_release_fires_click() {
        let mut frame = HitFrame::new();
        frame.push(item(
            id("btn"),
            rect(0.0, 0.0, 50.0, 50.0),
            Sense::INTERACTIVE,
        ));
        let s1 = resolve(
            &HitState::new(),
            &frame,
            &press_snap(pos(10.0, 10.0), 0, PointerButton::Secondary),
            None,
        );
        assert!(s1.interaction(id("btn")).pressed());
        let s2 = resolve(
            &s1,
            &frame,
            &release_snap(pos(10.0, 10.0), 30, PointerButton::Secondary),
            None,
        );
        let i = s2.interaction(id("btn"));
        assert!(i.click());
        assert_eq!(i.click_button, Some(PointerButton::Secondary));
    }

    #[test]
    fn two_buttons_pressed_in_same_snapshot() {
        let mut frame = HitFrame::new();
        frame.push(item(
            id("btn"),
            rect(0.0, 0.0, 50.0, 50.0),
            Sense::INTERACTIVE,
        ));
        let mut snap = snap_at(pos(10.0, 10.0), 0);
        snap.buttons_pressed =
            PointerButtonMask::just(PointerButton::Primary).with(PointerButton::Secondary);
        let s = resolve(&HitState::new(), &frame, &snap, None);
        assert_eq!(s.pressed.len(), 2);
        assert!(s.pressed.contains_key(&PointerButton::Primary));
        assert!(s.pressed.contains_key(&PointerButton::Secondary));
        assert!(s.interaction(id("btn")).pressed());
    }

    #[test]
    fn middle_button_drag_records_drag_button() {
        let mut frame = HitFrame::new();
        frame.push(item(
            id("viewport"),
            rect(0.0, 0.0, 200.0, 200.0),
            Sense::DRAGGABLE,
        ));
        let s1 = resolve(
            &HitState::new(),
            &frame,
            &press_snap(pos(50.0, 50.0), 0, PointerButton::Middle),
            None,
        );
        let motion = snap_at(pos(120.0, 120.0), 16);
        let s2 = resolve(&s1, &frame, &motion, None);
        let i = s2.interaction(id("viewport"));
        assert!(i.drag_start());
        assert_eq!(i.drag_button, Some(PointerButton::Middle));
    }

    #[test]
    #[should_panic(expected = "Sense::DRAG implies Sense::PRESS")]
    fn drag_without_press_panics_on_push() {
        let mut frame = HitFrame::new();
        frame.push(item(id("rogue"), rect(0.0, 0.0, 10.0, 10.0), Sense::DRAG));
    }

    #[test]
    fn drag_delta_falls_back_to_last_pointer_when_pointer_drops() {
        let mut frame = HitFrame::new();
        frame.push(item(
            id("handle"),
            rect(0.0, 0.0, 20.0, 20.0),
            Sense::DRAGGABLE,
        ));
        let s1 = resolve(
            &HitState::new(),
            &frame,
            &press_snap(pos(5.0, 5.0), 0, PointerButton::Primary),
            None,
        );
        let s2 = resolve(&s1, &frame, &snap_at(pos(80.0, 80.0), 16), None);
        assert!(s2.interaction(id("handle")).drag_start());

        let mut idle = InputSnapshot::idle(FrameInstant::from_duration(Duration::from_millis(32)));
        idle.pointer = None;
        let s3 = resolve(&s2, &frame, &idle, None);
        let i = s3.interaction(id("handle"));
        assert_ne!(i.drag_delta, LayoutOffset::ZERO);
        assert_eq!(s3.last_pointer, Some(pos(80.0, 80.0)));
    }

    #[test]
    fn ghost_capture_falls_through_to_position_routing() {
        let mut frame = HitFrame::new();
        frame.push(item(
            id("handle"),
            rect(0.0, 0.0, 20.0, 20.0),
            Sense::DRAGGABLE,
        ));
        let s1 = resolve(
            &HitState::new(),
            &frame,
            &press_snap(pos(5.0, 5.0), 0, PointerButton::Primary),
            None,
        );
        assert_eq!(s1.captures.top().map(|c| c.id), Some(id("handle")));

        let mut next_frame = HitFrame::new();
        next_frame.push(item(
            id("other"),
            rect(50.0, 50.0, 20.0, 20.0),
            Sense::HOVER,
        ));
        let s2 = resolve(&s1, &next_frame, &snap_at(pos(55.0, 55.0), 16), None);
        assert_eq!(s2.hovered, Some(id("other")));
    }

    #[test]
    fn release_without_press_clears_stale_capture() {
        let mut frame = HitFrame::new();
        frame.push(item(
            id("handle"),
            rect(0.0, 0.0, 20.0, 20.0),
            Sense::DRAGGABLE,
        ));
        let mut prev = HitState::new();
        prev.captures.push(super::PointerCapture {
            id: id("handle"),
            button: PointerButton::Primary,
        });
        let s = resolve(
            &prev,
            &frame,
            &release_snap(pos(5.0, 5.0), 0, PointerButton::Primary),
            None,
        );
        assert!(s.captures.is_empty());
    }
}
