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

use crate::a11y::{AccessNode, Role};
use crate::frame::{FrameCtx, InteractDeclaration};
use crate::hit_test::Sense;
use crate::input::{KeyCode, ModifierMask, 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::keys::{TakeKey, take_key};
use super::paint::{GlyphMark, LabelText, WidgetPaint};
use super::scrollbar::{row_window, wheel_scroll, window_scrollbar};
use super::visuals::push_focus_ring;

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum SortDirection {
    Ascending,
    Descending,
}

impl SortDirection {
    #[must_use]
    pub const fn flip(self) -> Self {
        match self {
            Self::Ascending => Self::Descending,
            Self::Descending => Self::Ascending,
        }
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct TableSort {
    pub column: WidgetId,
    pub direction: SortDirection,
}

#[derive(Clone, Debug, PartialEq)]
pub struct ListItem {
    pub id: WidgetId,
    pub label: LabelText,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ListSelectionMode {
    Single,
    Multi,
}

#[derive(Clone, Debug, Default, PartialEq)]
pub struct ListViewState {
    pub selection: BTreeSet<WidgetId>,
    pub focused: Option<WidgetId>,
    pub scroll_offset: LayoutPx,
}

#[derive(Debug, PartialEq)]
pub struct ListView<'a, 'state> {
    pub id: WidgetId,
    pub rect: LayoutRect,
    pub label: StringKey,
    pub items: &'a [ListItem],
    pub state: &'state mut ListViewState,
    pub mode: ListSelectionMode,
    pub row_height: LayoutPx,
}

impl<'a, 'state> ListView<'a, 'state> {
    #[must_use]
    pub const fn new(
        id: WidgetId,
        rect: LayoutRect,
        label: StringKey,
        items: &'a [ListItem],
        state: &'state mut ListViewState,
    ) -> Self {
        Self {
            id,
            rect,
            label,
            items,
            state,
            mode: ListSelectionMode::Single,
            row_height: LayoutPx::new(22.0),
        }
    }

    #[must_use]
    pub const fn mode(self, mode: ListSelectionMode) -> Self {
        Self { mode, ..self }
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct ListViewResponse {
    pub activated: Option<WidgetId>,
    pub opened: Option<WidgetId>,
    pub paint: Vec<WidgetPaint>,
}

#[must_use]
pub fn show_list_view(ctx: &mut FrameCtx<'_>, view: ListView<'_, '_>) -> ListViewResponse {
    let ListView {
        id,
        rect,
        label,
        items,
        state,
        mode,
        row_height,
    } = view;
    ctx.a11y
        .push(id, rect, AccessNode::new(Role::ListBox).with_label(label));
    let mut paint = vec![WidgetPaint::Surface {
        rect,
        fill: ctx.theme().colors.surface(crate::theme::SurfaceLevel::L0),
        border: None,
        radius: ctx.theme().radius.none,
        elevation: None,
    }];
    let requested = wheel_scroll(ctx, rect, state.scroll_offset);
    let window = row_window(rect, items.len(), row_height, requested);
    state.scroll_offset = window.offset;
    let content_rect = window.content_rect;
    let entry_id = state
        .focused
        .filter(|f| items.iter().any(|i| i.id == *f))
        .or_else(|| items.first().map(|i| i.id));
    if let Some(id) = entry_id {
        ctx.focus.register_tab_stop(id);
    }
    let mut activated: Option<WidgetId> = None;
    let mut opened: Option<WidgetId> = None;
    items
        .iter()
        .enumerate()
        .skip(window.first_row)
        .take(window.last_row - window.first_row)
        .for_each(|(idx, item)| {
            let row_rect = list_row_rect(content_rect, idx - window.first_row, row_height);
            let selected = state.selection.contains(&item.id);
            let interaction = ctx.interact(
                InteractDeclaration::new(item.id, row_rect, Sense::INTERACTIVE)
                    .focusable(false)
                    .active(selected)
                    .a11y(
                        AccessNode::new(Role::ListBoxOption)
                            .with_label_text(item.label.clone())
                            .with_selected(selected),
                    ),
            );
            let live_focused = ctx.is_focused(item.id);
            if interaction.click() {
                apply_list_selection(state, item.id, ctx.input.modifiers, mode);
                state.focused = Some(item.id);
                ctx.focus.request_focus(item.id);
                if activated.is_none() {
                    activated = Some(item.id);
                }
            }
            if interaction.double_click() && opened.is_none() {
                opened = Some(item.id);
            }
            let fill = list_row_fill(ctx, &interaction, state.selection.contains(&item.id));
            paint.push(WidgetPaint::Surface {
                rect: row_rect,
                fill,
                border: None,
                radius: ctx.theme().radius.none,
                elevation: None,
            });
            paint.push(WidgetPaint::Label {
                rect: row_rect,
                text: item.label.clone(),
                color: ctx.theme().colors.text_primary(),
                role: ctx.theme().typography.body,
            });
            push_focus_ring(
                ctx,
                &mut paint,
                row_rect,
                ctx.theme().radius.none,
                live_focused,
            );
        });
    let (bar_paint, next_offset) = window_scrollbar(ctx, id, label, &window, state.scroll_offset);
    state.scroll_offset = next_offset;
    paint.extend(bar_paint);
    handle_list_keyboard(ctx, items, state);
    ListViewResponse {
        activated,
        opened,
        paint,
    }
}

fn list_row_rect(view: LayoutRect, idx: usize, row_height: LayoutPx) -> LayoutRect {
    #[allow(
        clippy::cast_precision_loss,
        reason = "list row index fits f32 mantissa"
    )]
    let i = idx as f32;
    LayoutRect::new(
        LayoutPos::new(
            view.origin.x,
            LayoutPx::new(view.origin.y.value() + i * row_height.value()),
        ),
        LayoutSize::new(view.size.width, row_height),
    )
}

fn list_row_fill(
    ctx: &FrameCtx<'_>,
    interaction: &crate::hit_test::Interaction,
    selected: bool,
) -> Color {
    let neutral = ctx.theme().colors.neutral;
    if selected {
        ctx.theme().colors.accent.step(Step12::HOVER_BG)
    } else if interaction.hover() {
        neutral.step(Step12::HOVER_BG)
    } else {
        Color::TRANSPARENT
    }
}

fn apply_list_selection(
    state: &mut ListViewState,
    id: WidgetId,
    modifiers: ModifierMask,
    mode: ListSelectionMode,
) {
    match mode {
        ListSelectionMode::Single => {
            state.selection = BTreeSet::from([id]);
        }
        ListSelectionMode::Multi => {
            if modifiers.contains(ModifierMask::CTRL) {
                if !state.selection.insert(id) {
                    state.selection.remove(&id);
                }
            } else {
                state.selection = BTreeSet::from([id]);
            }
        }
    }
}

fn handle_list_keyboard(ctx: &mut FrameCtx<'_>, items: &[ListItem], state: &mut ListViewState) {
    let in_focus = ctx
        .focus
        .focused()
        .is_some_and(|f| items.iter().any(|i| i.id == f));
    if !in_focus {
        return;
    }
    let event = take_key(
        ctx.input,
        &[
            TakeKey::named(NamedKey::ArrowUp),
            TakeKey::named(NamedKey::ArrowDown),
            TakeKey::named(NamedKey::Home),
            TakeKey::named(NamedKey::End),
        ],
    );
    let Some(event) = event else { return };
    let focused = ctx.focus.focused();
    let current = focused.and_then(|f| items.iter().position(|i| i.id == f));
    let target = match event.code {
        KeyCode::Named(NamedKey::ArrowDown) => current.and_then(|i| items.get(i + 1)),
        KeyCode::Named(NamedKey::ArrowUp) => {
            current.and_then(|i| if i == 0 { None } else { items.get(i - 1) })
        }
        KeyCode::Named(NamedKey::Home) => items.first(),
        KeyCode::Named(NamedKey::End) => items.last(),
        _ => None,
    };
    if let Some(target) = target {
        ctx.focus.request_focus(target.id);
        state.focused = Some(target.id);
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct TableColumn {
    pub id: WidgetId,
    pub label: StringKey,
    pub width: LayoutPx,
    pub sortable: bool,
    pub min_width: LayoutPx,
}

impl TableColumn {
    #[must_use]
    pub const fn new(id: WidgetId, label: StringKey, width: LayoutPx) -> Self {
        Self {
            id,
            label,
            width,
            sortable: true,
            min_width: LayoutPx::new(40.0),
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TableRow<const N: usize> {
    pub id: WidgetId,
    pub cells: [StringKey; N],
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct ResizeAnchor {
    pub column: WidgetId,
    pub start_width: LayoutPx,
}

#[derive(Clone, Debug, Default, PartialEq)]
pub struct TableState {
    pub sort: Option<TableSort>,
    pub selection: BTreeSet<WidgetId>,
    pub focused: Option<WidgetId>,
    pub column_widths: BTreeMap<WidgetId, LayoutPx>,
    pub resizing: Option<ResizeAnchor>,
}

#[derive(Debug, PartialEq)]
pub struct Table<'a, 'state, const N: usize> {
    pub id: WidgetId,
    pub rect: LayoutRect,
    pub label: StringKey,
    pub columns: &'a [TableColumn; N],
    pub rows: &'a [TableRow<N>],
    pub state: &'state mut TableState,
    pub header_height: LayoutPx,
    pub row_height: LayoutPx,
    pub mode: ListSelectionMode,
}

impl<'a, 'state, const N: usize> Table<'a, 'state, N> {
    #[must_use]
    pub const fn new(
        id: WidgetId,
        rect: LayoutRect,
        label: StringKey,
        columns: &'a [TableColumn; N],
        rows: &'a [TableRow<N>],
        state: &'state mut TableState,
    ) -> Self {
        Self {
            id,
            rect,
            label,
            columns,
            rows,
            state,
            header_height: LayoutPx::new(24.0),
            row_height: LayoutPx::new(22.0),
            mode: ListSelectionMode::Single,
        }
    }

    #[must_use]
    pub const fn mode(self, mode: ListSelectionMode) -> Self {
        Self { mode, ..self }
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct TableResponse {
    pub activated_row: Option<WidgetId>,
    pub sort_changed: Option<TableSort>,
    pub column_resized: Option<(WidgetId, LayoutPx)>,
    pub paint: Vec<WidgetPaint>,
}

const RESIZE_HANDLE_PX: f32 = 6.0;

#[must_use]
pub fn show_table<const N: usize>(
    ctx: &mut FrameCtx<'_>,
    table: Table<'_, '_, N>,
) -> TableResponse {
    let Table {
        id,
        rect,
        label,
        columns,
        rows,
        state,
        header_height,
        row_height,
        mode,
    } = table;
    ctx.a11y
        .push(id, rect, AccessNode::new(Role::Table).with_label(label));
    let (column_widths, column_x) = compute_column_layout(columns, state, rect);
    let mut paint = vec![WidgetPaint::Surface {
        rect,
        fill: ctx.theme().colors.surface(crate::theme::SurfaceLevel::L0),
        border: None,
        radius: ctx.theme().radius.none,
        elevation: None,
    }];
    let mut sort_changed: Option<TableSort> = None;
    let mut column_resized: Option<(WidgetId, LayoutPx)> = None;
    paint.extend(draw_headers(
        ctx,
        HeadersArgs {
            columns,
            column_widths: &column_widths,
            column_x: &column_x,
            row_y: rect.origin.y,
            header_height,
            state,
        },
        &mut sort_changed,
        &mut column_resized,
    ));
    let entry_id = state
        .focused
        .filter(|f| rows.iter().any(|r| r.id == *f))
        .or_else(|| rows.first().map(|r| r.id));
    if let Some(id) = entry_id {
        ctx.focus.register_tab_stop(id);
    }
    let mut activated_row: Option<WidgetId> = None;
    rows.iter().enumerate().for_each(|(row_idx, row)| {
        paint.extend(draw_row(
            ctx,
            RowArgs {
                row,
                row_idx,
                rect,
                header_height,
                row_height,
                column_widths: &column_widths,
                column_x: &column_x,
                state,
                mode,
            },
            &mut activated_row,
        ));
    });
    prune_column_widths(state, columns);
    TableResponse {
        activated_row,
        sort_changed,
        column_resized,
        paint,
    }
}

fn prune_column_widths<const N: usize>(state: &mut TableState, columns: &[TableColumn; N]) {
    let live: BTreeSet<WidgetId> = columns.iter().map(|c| c.id).collect();
    state.column_widths.retain(|k, _| live.contains(k));
}

fn compute_column_layout<const N: usize>(
    columns: &[TableColumn; N],
    state: &TableState,
    rect: LayoutRect,
) -> (Vec<LayoutPx>, Vec<LayoutPx>) {
    let widths: Vec<LayoutPx> = columns
        .iter()
        .map(|c| state.column_widths.get(&c.id).copied().unwrap_or(c.width))
        .collect();
    let xs: Vec<LayoutPx> = widths
        .iter()
        .scan(rect.origin.x, |x, w| {
            let cur = *x;
            *x = LayoutPx::new(x.value() + w.value());
            Some(cur)
        })
        .collect();
    (widths, xs)
}

struct HeadersArgs<'a, const N: usize> {
    columns: &'a [TableColumn; N],
    column_widths: &'a [LayoutPx],
    column_x: &'a [LayoutPx],
    row_y: LayoutPx,
    header_height: LayoutPx,
    state: &'a mut TableState,
}

fn draw_headers<const N: usize>(
    ctx: &mut FrameCtx<'_>,
    args: HeadersArgs<'_, N>,
    sort_changed: &mut Option<TableSort>,
    column_resized: &mut Option<(WidgetId, LayoutPx)>,
) -> Vec<WidgetPaint> {
    let HeadersArgs {
        columns,
        column_widths,
        column_x,
        row_y,
        header_height,
        state,
    } = args;
    let mut paint = Vec::new();
    columns
        .iter()
        .zip(column_widths.iter())
        .zip(column_x.iter())
        .for_each(|((column, width), x)| {
            let header_rect = LayoutRect::new(
                LayoutPos::new(*x, row_y),
                LayoutSize::new(*width, header_height),
            );
            paint.extend(draw_header_cell(
                ctx,
                column,
                header_rect,
                state,
                sort_changed,
                column_resized,
            ));
        });
    paint
}

struct RowArgs<'a, const N: usize> {
    row: &'a TableRow<N>,
    row_idx: usize,
    rect: LayoutRect,
    header_height: LayoutPx,
    row_height: LayoutPx,
    column_widths: &'a [LayoutPx],
    column_x: &'a [LayoutPx],
    state: &'a mut TableState,
    mode: ListSelectionMode,
}

fn draw_row<const N: usize>(
    ctx: &mut FrameCtx<'_>,
    args: RowArgs<'_, N>,
    activated_row: &mut Option<WidgetId>,
) -> Vec<WidgetPaint> {
    let RowArgs {
        row,
        row_idx,
        rect,
        header_height,
        row_height,
        column_widths,
        column_x,
        state,
        mode,
    } = args;
    let row_y = rect.origin.y.value() + header_height.value() + row_idx_to_y(row_idx, row_height);
    let row_rect = LayoutRect::new(
        LayoutPos::new(rect.origin.x, LayoutPx::new(row_y)),
        LayoutSize::new(rect.size.width, row_height),
    );
    let selected = state.selection.contains(&row.id);
    let label = row
        .cells
        .first()
        .copied()
        .unwrap_or(StringKey::new("table.row"));
    let interaction = ctx.interact(
        InteractDeclaration::new(row.id, row_rect, Sense::INTERACTIVE)
            .focusable(false)
            .active(selected)
            .a11y(
                AccessNode::new(Role::Row)
                    .with_label(label)
                    .with_selected(selected),
            ),
    );
    let live_focused = ctx.is_focused(row.id);
    if interaction.click() {
        apply_table_selection(state, row.id, ctx.input.modifiers, mode);
        state.focused = Some(row.id);
        ctx.focus.request_focus(row.id);
        if activated_row.is_none() {
            *activated_row = Some(row.id);
        }
    }
    let fill = list_row_fill(ctx, &interaction, state.selection.contains(&row.id));
    let mut paint = vec![WidgetPaint::Surface {
        rect: row_rect,
        fill,
        border: None,
        radius: ctx.theme().radius.none,
        elevation: None,
    }];
    column_widths
        .iter()
        .zip(column_x.iter())
        .zip(row.cells.iter())
        .for_each(|((width, x), text)| {
            paint.push(WidgetPaint::Label {
                rect: LayoutRect::new(
                    LayoutPos::new(*x, LayoutPx::new(row_y)),
                    LayoutSize::new(*width, row_height),
                ),
                text: LabelText::Key(*text),
                color: ctx.theme().colors.text_primary(),
                role: ctx.theme().typography.body,
            });
        });
    push_focus_ring(
        ctx,
        &mut paint,
        row_rect,
        ctx.theme().radius.none,
        live_focused,
    );
    paint
}

fn row_idx_to_y(row_idx: usize, row_height: LayoutPx) -> f32 {
    #[allow(
        clippy::cast_precision_loss,
        reason = "table row index fits f32 mantissa"
    )]
    let i = row_idx as f32;
    i * row_height.value()
}

fn draw_header_cell(
    ctx: &mut FrameCtx<'_>,
    column: &TableColumn,
    header_rect: LayoutRect,
    state: &mut TableState,
    sort_changed: &mut Option<TableSort>,
    column_resized: &mut Option<(WidgetId, LayoutPx)>,
) -> Vec<WidgetPaint> {
    let mut paint = Vec::new();
    let header_id = column.id;
    let resize_id = column.id.child(WidgetKey::new("resize"));
    let resize_rect = LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(
                header_rect.origin.x.value() + header_rect.size.width.value() - RESIZE_HANDLE_PX,
            ),
            header_rect.origin.y,
        ),
        LayoutSize::new(LayoutPx::new(RESIZE_HANDLE_PX), header_rect.size.height),
    );
    let header_interaction = ctx.interact(
        InteractDeclaration::new(header_id, header_rect, Sense::INTERACTIVE)
            .focusable(false)
            .a11y(AccessNode::new(Role::ColumnHeader).with_label(column.label)),
    );
    let resize_interaction = ctx.interact(
        InteractDeclaration::new(resize_id, resize_rect, Sense::DRAGGABLE)
            .focusable(false)
            .a11y(
                AccessNode::new(Role::Splitter).with_label(StringKey::new("table.column.resize")),
            ),
    );
    if header_interaction.click() && column.sortable {
        let next = match state.sort {
            Some(prev) if prev.column == column.id => TableSort {
                column: column.id,
                direction: prev.direction.flip(),
            },
            _ => TableSort {
                column: column.id,
                direction: SortDirection::Ascending,
            },
        };
        state.sort = Some(next);
        if sort_changed.is_none() {
            *sort_changed = Some(next);
        }
    }
    if resize_interaction.drag_start() {
        state.resizing = Some(ResizeAnchor {
            column: column.id,
            start_width: header_rect.size.width,
        });
    }
    if let Some(anchor) = state.resizing
        && anchor.column == column.id
        && (resize_interaction.pressed() || resize_interaction.drag_start())
    {
        let new_width = LayoutPx::new(
            (anchor.start_width.value() + resize_interaction.drag_delta.dx.value())
                .max(column.min_width.value()),
        );
        state.column_widths.insert(column.id, new_width);
        if column_resized.is_none() {
            *column_resized = Some((column.id, new_width));
        }
    }
    if resize_interaction.drag_release() {
        state.resizing = None;
    }
    paint.push(WidgetPaint::Surface {
        rect: header_rect,
        fill: if header_interaction.hover() {
            ctx.theme().colors.neutral.step(Step12::HOVER_BG)
        } else {
            ctx.theme().colors.neutral.step(Step12::SUBTLE_BG)
        },
        border: Some(Border {
            width: StrokeWidth::HAIRLINE,
            color: ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER),
        }),
        radius: ctx.theme().radius.none,
        elevation: None,
    });
    paint.push(WidgetPaint::Label {
        rect: header_rect,
        text: LabelText::Key(column.label),
        color: ctx.theme().colors.text_primary(),
        role: ctx.theme().typography.label,
    });
    if let Some(sort) = state.sort
        && sort.column == column.id
    {
        paint.push(WidgetPaint::Mark {
            rect: sort_indicator_rect(header_rect),
            kind: match sort.direction {
                SortDirection::Ascending => GlyphMark::SortAscending,
                SortDirection::Descending => GlyphMark::SortDescending,
            },
            color: ctx.theme().colors.text_secondary(),
        });
    }
    paint
}

fn sort_indicator_rect(header: LayoutRect) -> LayoutRect {
    let size = 12.0;
    let pad = (header.size.height.value() - size) / 2.0;
    LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(header.origin.x.value() + header.size.width.value() - size - 8.0),
            LayoutPx::new(header.origin.y.value() + pad),
        ),
        LayoutSize::new(LayoutPx::new(size), LayoutPx::new(size)),
    )
}

fn apply_table_selection(
    state: &mut TableState,
    id: WidgetId,
    modifiers: ModifierMask,
    mode: ListSelectionMode,
) {
    match mode {
        ListSelectionMode::Single => {
            state.selection = BTreeSet::from([id]);
        }
        ListSelectionMode::Multi => {
            if modifiers.contains(ModifierMask::CTRL) {
                if !state.selection.insert(id) {
                    state.selection.remove(&id);
                }
            } else {
                state.selection = BTreeSet::from([id]);
            }
        }
    }
}

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

    use super::{
        LabelText, ListItem, ListView, ListViewState, SortDirection, Table, TableColumn, TableRow,
        TableState, show_list_view, show_table,
    };
    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 list_id() -> WidgetId {
        WidgetId::ROOT.child(WidgetKey::new("list"))
    }

    fn list_items(count: u64) -> Vec<ListItem> {
        (0..count)
            .map(|i| ListItem {
                id: list_id().child_indexed(WidgetKey::new("item"), i),
                label: LabelText::Key(StringKey::new("list.item")),
            })
            .collect()
    }

    fn render_list(
        items: &[ListItem],
        state: &mut ListViewState,
        focus: &mut FocusManager,
        snap: &mut InputSnapshot,
        prev: &HitState,
    ) -> (super::ListViewResponse, HitState) {
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut hits = HitFrame::new();
        let rect = LayoutRect::new(
            LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
            LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(400.0)),
        );
        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_list_view(
                &mut ctx,
                ListView::new(list_id(), rect, StringKey::new("test.list"), items, state),
            )
        };
        let next = resolve(prev, &hits, snap, focus.focused());
        (response, next)
    }

    fn render_list_at(
        items: &[ListItem],
        state: &mut ListViewState,
        scroll_y: f32,
        pointer: Option<LayoutPos>,
    ) -> (Vec<super::WidgetPaint>, bool) {
        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.scroll_y = scroll_y;
        snap.pointer = pointer.map(PointerSample::new);
        let rect = LayoutRect::new(
            LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
            LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(400.0)),
        );
        let scrollbar_id = list_id().child(WidgetKey::new("scrollbar"));
        let mut shaper = bone_text::Shaper::new();
        let mut a11y = crate::a11y::AccessTreeBuilder::new();
        let paint = {
            let mut ctx = FrameCtx::new(
                theme,
                &mut snap,
                &mut focus,
                &table,
                StringTable::empty(),
                &mut hits,
                &prev,
                &mut a11y,
                &mut shaper,
            );
            show_list_view(
                &mut ctx,
                ListView::new(list_id(), rect, StringKey::new("test.list"), items, state),
            )
            .paint
        };
        (paint, a11y.contains(scrollbar_id))
    }

    fn count_labels(paint: &[super::WidgetPaint]) -> usize {
        paint
            .iter()
            .filter(|p| matches!(p, super::WidgetPaint::Label { .. }))
            .count()
    }

    #[test]
    fn tall_list_culls_rows_and_shows_a_scrollbar() {
        let items = list_items(40);
        let mut state = ListViewState::default();
        let (paint, has_bar) = render_list_at(&items, &mut state, 0.0, None);
        assert!(has_bar, "an overflowing list shows a scrollbar");
        assert_eq!(
            count_labels(&paint),
            18,
            "only the rows that fit the 400 px viewport paint",
        );
    }

    #[test]
    fn list_that_fits_shows_no_scrollbar() {
        let items = list_items(3);
        let mut state = ListViewState::default();
        let (paint, has_bar) = render_list_at(&items, &mut state, 0.0, None);
        assert!(!has_bar, "a list that fits its viewport shows no scrollbar");
        assert_eq!(count_labels(&paint), 3);
    }

    #[test]
    fn wheel_over_the_list_scrolls_it() {
        let items = list_items(40);
        let mut state = ListViewState::default();
        let _ = render_list_at(
            &items,
            &mut state,
            100.0,
            Some(LayoutPos::new(LayoutPx::new(100.0), LayoutPx::new(100.0))),
        );
        assert!(
            state.scroll_offset.value() > 0.0,
            "a wheel notch over the list advances the offset",
        );
    }

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

    #[test]
    fn click_list_item_selects() {
        let items = list_items(3);
        let mut state = ListViewState::default();
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(33.0));
        let mut last: Option<super::ListViewResponse> = None;
        [press(click_pos), release(click_pos), idle(click_pos)]
            .into_iter()
            .for_each(|mut snap| {
                let (response, next) =
                    render_list(&items, &mut state, &mut focus, &mut snap, &prev);
                last = Some(response);
                prev = next;
            });
        let Some(response) = last else {
            panic!("response missing")
        };
        assert_eq!(response.activated, Some(items[1].id));
        assert!(state.selection.contains(&items[1].id));
    }

    #[test]
    fn arrow_down_moves_focus_in_list() {
        let items = list_items(3);
        let mut state = ListViewState::default();
        let mut focus = FocusManager::new();
        focus.register_focusable(items[0].id);
        focus.request_focus(items[0].id);
        focus.end_frame();
        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_list(&items, &mut state, &mut focus, &mut snap, &prev);
        assert_eq!(state.focused, Some(items[1].id));
    }

    #[test]
    fn double_click_list_item_sets_opened() {
        let items = list_items(3);
        let mut state = ListViewState::default();
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(33.0));
        let mut last: Option<super::ListViewResponse> = None;
        let frames = [
            press(click_pos),
            release(click_pos),
            press(click_pos),
            release(click_pos),
            idle(click_pos),
        ];
        frames.into_iter().for_each(|mut snap| {
            let (response, next) = render_list(&items, &mut state, &mut focus, &mut snap, &prev);
            last = Some(response);
            prev = next;
        });
        let Some(response) = last else {
            panic!("response missing")
        };
        assert_eq!(response.opened, Some(items[1].id));
    }

    #[test]
    fn single_click_list_item_does_not_set_opened() {
        let items = list_items(3);
        let mut state = ListViewState::default();
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(33.0));
        let mut last: Option<super::ListViewResponse> = None;
        [press(click_pos), release(click_pos), idle(click_pos)]
            .into_iter()
            .for_each(|mut snap| {
                let (response, next) =
                    render_list(&items, &mut state, &mut focus, &mut snap, &prev);
                last = Some(response);
                prev = next;
            });
        let Some(response) = last else {
            panic!("response missing")
        };
        assert!(response.opened.is_none());
    }

    #[test]
    fn list_view_registers_single_tab_stop() {
        let items = list_items(4);
        let mut state = ListViewState::default();
        let mut focus = FocusManager::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        let prev = HitState::new();
        let _ = render_list(&items, &mut state, &mut focus, &mut snap, &prev);
        let stops: Vec<WidgetId> = focus.tab_stops().iter().map(|(id, _)| *id).collect();
        assert_eq!(stops, vec![items[0].id]);
    }

    fn table_id() -> WidgetId {
        WidgetId::ROOT.child(WidgetKey::new("table"))
    }

    fn columns() -> [TableColumn; 2] {
        [
            TableColumn::new(
                table_id().child(WidgetKey::new("name")),
                StringKey::new("col.name"),
                LayoutPx::new(120.0),
            ),
            TableColumn::new(
                table_id().child(WidgetKey::new("value")),
                StringKey::new("col.value"),
                LayoutPx::new(80.0),
            ),
        ]
    }

    fn rows() -> Vec<TableRow<2>> {
        vec![
            TableRow {
                id: table_id().child_indexed(WidgetKey::new("row"), 0),
                cells: [
                    StringKey::new("row.first"),
                    StringKey::new("row.first.value"),
                ],
            },
            TableRow {
                id: table_id().child_indexed(WidgetKey::new("row"), 1),
                cells: [
                    StringKey::new("row.second"),
                    StringKey::new("row.second.value"),
                ],
            },
        ]
    }

    fn render_table(
        cols: &[TableColumn; 2],
        rows_data: &[TableRow<2>],
        state: &mut TableState,
        focus: &mut FocusManager,
        snap: &mut InputSnapshot,
        prev: &HitState,
    ) -> (super::TableResponse, HitState) {
        let theme = Arc::new(Theme::light());
        let table_keys = HotkeyTable::new();
        let mut hits = HitFrame::new();
        let rect = LayoutRect::new(
            LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
            LayoutSize::new(LayoutPx::new(220.0), LayoutPx::new(200.0)),
        );
        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_keys,
                StringTable::empty(),
                &mut hits,
                prev,
                &mut a11y,
                &mut shaper,
            );
            show_table(
                &mut ctx,
                Table::new(
                    table_id(),
                    rect,
                    StringKey::new("test.table"),
                    cols,
                    rows_data,
                    state,
                ),
            )
        };
        let next = resolve(prev, &hits, snap, focus.focused());
        (response, next)
    }

    #[test]
    fn click_header_sets_sort_to_ascending_then_descending() {
        let cols = columns();
        let rows_data = rows();
        let mut state = TableState::default();
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let header_pos = LayoutPos::new(LayoutPx::new(50.0), LayoutPx::new(12.0));
        let mut last: Option<super::TableResponse> = None;
        [press(header_pos), release(header_pos), idle(header_pos)]
            .into_iter()
            .for_each(|mut snap| {
                let (response, next) =
                    render_table(&cols, &rows_data, &mut state, &mut focus, &mut snap, &prev);
                last = Some(response);
                prev = next;
            });
        assert_eq!(
            state.sort.map(|s| s.direction),
            Some(SortDirection::Ascending),
            "first click ascending",
        );
        prev = HitState::new();
        last = None;
        [press(header_pos), release(header_pos), idle(header_pos)]
            .into_iter()
            .for_each(|mut snap| {
                let (response, next) =
                    render_table(&cols, &rows_data, &mut state, &mut focus, &mut snap, &prev);
                last = Some(response);
                prev = next;
            });
        assert_eq!(
            state.sort.map(|s| s.direction),
            Some(SortDirection::Descending),
            "second click descending",
        );
    }

    #[test]
    fn click_row_activates_and_selects() {
        let cols = columns();
        let rows_data = rows();
        let mut state = TableState::default();
        let mut focus = FocusManager::new();
        let mut prev = HitState::new();
        let row_pos = LayoutPos::new(LayoutPx::new(50.0), LayoutPx::new(36.0));
        let mut last: Option<super::TableResponse> = None;
        [press(row_pos), release(row_pos), idle(row_pos)]
            .into_iter()
            .for_each(|mut snap| {
                let (response, next) =
                    render_table(&cols, &rows_data, &mut state, &mut focus, &mut snap, &prev);
                last = Some(response);
                prev = next;
            });
        let Some(response) = last else {
            panic!("response missing")
        };
        assert_eq!(response.activated_row, Some(rows_data[0].id));
        assert!(state.selection.contains(&rows_data[0].id));
    }

    #[test]
    fn table_registers_single_row_tab_stop() {
        let cols = columns();
        let rows_data = rows();
        let mut state = TableState::default();
        let mut focus = FocusManager::new();
        let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
        let prev = HitState::new();
        let _ = render_table(&cols, &rows_data, &mut state, &mut focus, &mut snap, &prev);
        let row_stops: Vec<WidgetId> = focus
            .tab_stops()
            .iter()
            .map(|(id, _)| *id)
            .filter(|id| rows_data.iter().any(|r| r.id == *id))
            .collect();
        assert_eq!(row_stops, vec![rows_data[0].id]);
    }
}
