use bone_text::{ShapedLine, ShapedText, SourceByteIndex};

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

use super::paint::{LabelText, WidgetPaint};
use super::visuals::{FieldVisuals, SurfaceVisuals, TextVisuals, push_focus_ring};

const CARET_WIDTH_PX: f32 = 1.0;
const CARET_VPAD_PX: f32 = 3.0;

pub trait Clipboard {
    fn read(&self) -> Option<String>;
    fn write(&mut self, text: String);
}

#[derive(Default, Clone, Debug, PartialEq, Eq)]
pub struct MemoryClipboard(Option<String>);

impl Clipboard for MemoryClipboard {
    fn read(&self) -> Option<String> {
        self.0.clone()
    }
    fn write(&mut self, text: String) {
        self.0 = Some(text);
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct TextInputState {
    pub text: String,
    pub selection: Selection,
    pub was_focused: bool,
    pub drag_anchor: Option<SourceByteIndex>,
    pub scroll_x: f32,
}

impl Default for TextInputState {
    fn default() -> Self {
        Self {
            text: String::new(),
            selection: Selection::caret_at(SourceByteIndex::new(0)),
            was_focused: false,
            drag_anchor: None,
            scroll_x: 0.0,
        }
    }
}

impl TextInputState {
    #[must_use]
    pub fn from_text<S: Into<String>>(text: S) -> Self {
        let text = text.into();
        let len = text.len();
        Self {
            selection: Selection::caret_at(SourceByteIndex::new(len)),
            text,
            was_focused: false,
            drag_anchor: None,
            scroll_x: 0.0,
        }
    }
}

pub trait TextInputValidation {
    type Error: Clone + PartialEq;

    fn validate(&self, text: &str) -> Result<(), Self::Error>;
}

#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct AlwaysValid;

impl TextInputValidation for AlwaysValid {
    type Error = core::convert::Infallible;

    fn validate(&self, _text: &str) -> Result<(), Self::Error> {
        Ok(())
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum TextInputAction {
    Insert,
    DeleteBack,
    DeleteForward,
    Cut,
    Copy,
    Paste,
    Move,
    SelectAll,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TextInputEdit {
    pub action: TextInputAction,
    pub before_text: String,
    pub after_text: String,
}

#[derive(Clone, Debug, PartialEq)]
pub struct TextInputResponse<E> {
    pub interaction: Interaction,
    pub edits: Vec<TextInputEdit>,
    pub error: Option<E>,
    pub paint: Vec<WidgetPaint>,
}

pub struct TextInput<'state, V> {
    pub id: WidgetId,
    pub rect: LayoutRect,
    pub placeholder: StringKey,
    pub state: &'state mut TextInputState,
    pub disabled: bool,
    pub validator: V,
}

#[must_use]
pub fn show_text_input<V: TextInputValidation, C: Clipboard + ?Sized>(
    ctx: &mut FrameCtx<'_>,
    input: TextInput<'_, V>,
    clipboard: &mut C,
) -> TextInputResponse<V::Error> {
    let TextInput {
        id,
        rect,
        placeholder,
        state,
        disabled,
        validator,
    } = input;
    clamp_selection_to_text(state);
    let interactive = !disabled;
    let interaction = ctx.interact(
        InteractDeclaration::new(id, rect, Sense::DRAGGABLE)
            .focusable(interactive)
            .disabled(!interactive)
            .a11y(
                AccessNode::new(Role::TextInput)
                    .with_label(placeholder)
                    .with_disabled(!interactive),
            ),
    );
    let live_focused = ctx.is_focused(id);
    let role = ctx.theme().typography.body;
    let mut edits = Vec::new();
    if interactive && live_focused {
        edits.extend(drain_edits(ctx, state, clipboard));
    }
    let shaped = ctx.shaper.shape(&state.text, request_for(role, None));
    if interactive {
        let pointer = ctx.input.pointer.as_ref().map(|p| p.position);
        apply_pointer_selection(state, rect, &shaped, pointer, interaction);
    }
    let error = validator.validate(&state.text).err();
    update_scroll_x(state, rect, &shaped);
    let paint = build_paint(
        ctx,
        PaintInputs {
            rect,
            placeholder,
            state,
            disabled,
            shaped: &shaped,
        },
        interaction,
        live_focused,
        error.is_some(),
    );
    state.was_focused = live_focused;
    TextInputResponse {
        interaction,
        edits,
        error,
        paint,
    }
}

fn apply_pointer_selection(
    state: &mut TextInputState,
    rect: LayoutRect,
    shaped: &ShapedText,
    pointer: Option<LayoutPos>,
    interaction: Interaction,
) {
    let primary_held =
        interaction.pressed() && interaction.pressed_buttons.contains(PointerButton::Primary);
    if !primary_held {
        state.drag_anchor = None;
        return;
    }
    let Some(byte) = pointer_byte(pointer, rect, shaped, &state.text, state.scroll_x) else {
        return;
    };
    let idx = SourceByteIndex::new(byte);
    match state.drag_anchor {
        None => {
            state.drag_anchor = Some(idx);
            state.selection = Selection::caret_at(idx);
        }
        Some(anchor) => {
            state.selection = Selection::ranged(anchor, idx);
        }
    }
}

fn pointer_byte(
    pointer: Option<LayoutPos>,
    rect: LayoutRect,
    shaped: &ShapedText,
    text: &str,
    scroll_x: f32,
) -> Option<usize> {
    let pointer = pointer?;
    let line = shaped.lines.first()?;
    let visible = line.visible_advance_px();
    let start_x = effective_start_x(rect, visible, scroll_x);
    let local_x = pointer.x.value() - start_x;
    Some(byte_at_x(line, text.len(), local_x))
}

fn effective_start_x(rect: LayoutRect, visible: f32, scroll_x: f32) -> f32 {
    let rw = rect.size.width.value();
    let rx = rect.origin.x.value();
    if visible > rw {
        rx - scroll_x
    } else {
        rx + (rw - visible) * 0.5
    }
}

fn update_scroll_x(state: &mut TextInputState, rect: LayoutRect, shaped: &ShapedText) {
    let Some(line) = shaped.lines.first() else {
        state.scroll_x = 0.0;
        return;
    };
    let visible = line.visible_advance_px();
    let rw = rect.size.width.value();
    if visible <= rw {
        state.scroll_x = 0.0;
        return;
    }
    let max_scroll = (visible - rw).max(0.0);
    let prev = state.scroll_x.clamp(0.0, max_scroll);
    let caret_local = caret_x_at_byte(line, state.text.len(), state.selection.caret().value());
    state.scroll_x = if caret_local < prev {
        caret_local.max(0.0)
    } else if caret_local > prev + rw {
        (caret_local - rw).clamp(0.0, max_scroll)
    } else {
        prev
    };
}

fn byte_at_x(line: &ShapedLine, text_len: usize, target_x: f32) -> usize {
    if target_x <= 0.0 {
        return 0;
    }
    line.runs
        .iter()
        .flat_map(|run| {
            run.glyphs
                .iter()
                .map(move |g| (g.cluster, run.origin_x_px + g.x_px, g.advance_px))
        })
        .find_map(|(cluster, x, adv)| (target_x < x + adv * 0.5).then_some(cluster.value()))
        .unwrap_or(text_len)
}

fn caret_x_at_byte(line: &ShapedLine, text_len: usize, byte: usize) -> f32 {
    if byte == 0 {
        return 0.0;
    }
    if byte >= text_len {
        return line.visible_advance_px();
    }
    line.runs
        .iter()
        .flat_map(|run| {
            run.glyphs
                .iter()
                .map(move |g| (g.cluster.value(), run.origin_x_px + g.x_px))
        })
        .find(|(cluster, _)| *cluster >= byte)
        .map_or_else(|| line.visible_advance_px(), |(_, x)| x)
}

fn caret_rect(
    rect: LayoutRect,
    shaped: &ShapedText,
    text_len: usize,
    caret_byte: usize,
    scroll_x: f32,
) -> LayoutRect {
    let visible = shaped
        .lines
        .first()
        .map_or(0.0, ShapedLine::visible_advance_px);
    let start_x = effective_start_x(rect, visible, scroll_x);
    let caret_local = shaped
        .lines
        .first()
        .map_or(0.0, |line| caret_x_at_byte(line, text_len, caret_byte));
    let h = (rect.size.height.value() - 2.0 * CARET_VPAD_PX).max(1.0);
    LayoutRect::new(
        LayoutPos::new(
            LayoutPx::saturating(start_x + caret_local),
            LayoutPx::saturating(rect.origin.y.value() + CARET_VPAD_PX),
        ),
        LayoutSize::new(LayoutPx::new(CARET_WIDTH_PX), LayoutPx::new(h)),
    )
}

fn selection_rect(
    rect: LayoutRect,
    shaped: &ShapedText,
    text_len: usize,
    min_byte: usize,
    max_byte: usize,
    scroll_x: f32,
) -> Option<LayoutRect> {
    if max_byte <= min_byte {
        return None;
    }
    let line = shaped.lines.first()?;
    let visible = line.visible_advance_px();
    let start_x = effective_start_x(rect, visible, scroll_x);
    let min_x = start_x + caret_x_at_byte(line, text_len, min_byte);
    let max_x = start_x + caret_x_at_byte(line, text_len, max_byte);
    let width = (max_x - min_x).max(0.0);
    if width <= 0.0 {
        return None;
    }
    let visible_left = rect.origin.x.value();
    let visible_right = visible_left + rect.size.width.value();
    let clipped_min = min_x.max(visible_left);
    let clipped_max = max_x.min(visible_right);
    let clipped_width = clipped_max - clipped_min;
    if clipped_width <= 0.0 {
        return None;
    }
    let h = (rect.size.height.value() - 2.0 * CARET_VPAD_PX).max(1.0);
    Some(LayoutRect::new(
        LayoutPos::new(
            LayoutPx::saturating(clipped_min),
            LayoutPx::saturating(rect.origin.y.value() + CARET_VPAD_PX),
        ),
        LayoutSize::new(LayoutPx::new(clipped_width), LayoutPx::new(h)),
    ))
}

fn drain_edits<C: Clipboard + ?Sized>(
    ctx: &mut FrameCtx<'_>,
    state: &mut TextInputState,
    clipboard: &mut C,
) -> Vec<TextInputEdit> {
    let mut edits = Vec::new();
    let pending = core::mem::take(&mut ctx.input.keys_pressed);
    let unconsumed = pending.into_iter().fold(Vec::new(), |mut acc, event| {
        let classified = classify(event);
        if matches!(classified, ClassifiedKey::PassThrough) {
            acc.push(event);
            return acc;
        }
        let before = state.text.clone();
        match perform(classified, state, clipboard) {
            Some(action) if always_emit(action) || before != state.text => {
                edits.push(TextInputEdit {
                    action,
                    before_text: before,
                    after_text: state.text.clone(),
                });
            }
            Some(_) => {}
            None => acc.push(event),
        }
        acc
    });
    ctx.input.keys_pressed = unconsumed;

    let committed = core::mem::take(&mut ctx.input.text_committed);
    if !committed.is_empty() {
        let before = state.text.clone();
        insert_text(state, &committed);
        edits.push(TextInputEdit {
            action: TextInputAction::Insert,
            before_text: before,
            after_text: state.text.clone(),
        });
    }

    edits
}

fn perform<C: Clipboard + ?Sized>(
    classified: ClassifiedKey,
    state: &mut TextInputState,
    clipboard: &mut C,
) -> Option<TextInputAction> {
    Some(match classified {
        ClassifiedKey::Backspace => {
            delete_back(state);
            TextInputAction::DeleteBack
        }
        ClassifiedKey::Delete => {
            delete_forward(state);
            TextInputAction::DeleteForward
        }
        ClassifiedKey::Move(motion) => {
            state.selection = state.selection.apply(&state.text, motion);
            TextInputAction::Move
        }
        ClassifiedKey::SelectAll => {
            state.selection = Selection::ranged(
                SourceByteIndex::new(0),
                SourceByteIndex::new(state.text.len()),
            );
            TextInputAction::SelectAll
        }
        ClassifiedKey::Copy => {
            let slice = selected_text(state)?;
            clipboard.write(slice);
            TextInputAction::Copy
        }
        ClassifiedKey::Cut => {
            let slice = selected_text(state)?;
            clipboard.write(slice);
            delete_selection(state);
            TextInputAction::Cut
        }
        ClassifiedKey::Paste => {
            let text = clipboard.read()?;
            insert_text(state, &text);
            TextInputAction::Paste
        }
        ClassifiedKey::PassThrough => unreachable!("filtered above"),
    })
}

const fn always_emit(action: TextInputAction) -> bool {
    matches!(
        action,
        TextInputAction::Move
            | TextInputAction::SelectAll
            | TextInputAction::Copy
            | TextInputAction::Cut
            | TextInputAction::Paste,
    )
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum ClassifiedKey {
    Backspace,
    Delete,
    Move(SelectionAction),
    SelectAll,
    Copy,
    Cut,
    Paste,
    PassThrough,
}

fn classify(event: KeyEvent) -> ClassifiedKey {
    let no_alt = !event.modifiers.contains(ModifierMask::ALT);
    let no_meta = !event.modifiers.contains(ModifierMask::META);
    let ctrl = event.modifiers.contains(ModifierMask::CTRL);
    let plain = event.modifiers == ModifierMask::NONE;
    if let Some(action) = SelectionAction::from_key(event) {
        return ClassifiedKey::Move(action);
    }
    match event.code {
        KeyCode::Named(NamedKey::Backspace) if plain => ClassifiedKey::Backspace,
        KeyCode::Named(NamedKey::Delete) if plain => ClassifiedKey::Delete,
        KeyCode::Char(c) if ctrl && no_alt && no_meta && c.get() == 'a' => ClassifiedKey::SelectAll,
        KeyCode::Char(c) if ctrl && no_alt && no_meta && c.get() == 'c' => ClassifiedKey::Copy,
        KeyCode::Char(c) if ctrl && no_alt && no_meta && c.get() == 'x' => ClassifiedKey::Cut,
        KeyCode::Char(c) if ctrl && no_alt && no_meta && c.get() == 'v' => ClassifiedKey::Paste,
        _ => ClassifiedKey::PassThrough,
    }
}

fn insert_text(state: &mut TextInputState, text: &str) {
    let min = state.selection.min().value();
    let max = state.selection.max().value();
    state.text.replace_range(min..max, text);
    let next = SourceByteIndex::new(min + text.len());
    state.selection = Selection::caret_at(next);
}

fn delete_back(state: &mut TextInputState) {
    if !state.selection.is_empty() {
        delete_selection(state);
        return;
    }
    let caret = state.selection.caret().value();
    if caret == 0 {
        return;
    }
    let extended = state.selection.apply(
        &state.text,
        SelectionAction::Extend(crate::text::CaretMove::PrevGrapheme),
    );
    state.selection = extended;
    delete_selection(state);
}

fn delete_forward(state: &mut TextInputState) {
    if !state.selection.is_empty() {
        delete_selection(state);
        return;
    }
    if state.selection.caret().value() >= state.text.len() {
        return;
    }
    let extended = state.selection.apply(
        &state.text,
        SelectionAction::Extend(crate::text::CaretMove::NextGrapheme),
    );
    state.selection = extended;
    delete_selection(state);
}

fn delete_selection(state: &mut TextInputState) {
    let min = state.selection.min().value();
    let max = state.selection.max().value();
    state.text.replace_range(min..max, "");
    state.selection = Selection::caret_at(SourceByteIndex::new(min));
}

fn selected_text(state: &TextInputState) -> Option<String> {
    if state.selection.is_empty() {
        return None;
    }
    let min = state.selection.min().value();
    let max = state.selection.max().value();
    Some(state.text[min..max].to_owned())
}

fn clamp_selection_to_text(state: &mut TextInputState) {
    let anchor = clamp_byte_to_boundary(&state.text, state.selection.anchor().value());
    let caret = clamp_byte_to_boundary(&state.text, state.selection.caret().value());
    if anchor != state.selection.anchor().value() || caret != state.selection.caret().value() {
        state.selection =
            Selection::ranged(SourceByteIndex::new(anchor), SourceByteIndex::new(caret));
    }
}

fn clamp_byte_to_boundary(text: &str, byte: usize) -> usize {
    let bounded = byte.min(text.len());
    if text.is_char_boundary(bounded) {
        return bounded;
    }
    (0..bounded)
        .rev()
        .find(|&i| text.is_char_boundary(i))
        .unwrap_or(0)
}

#[derive(Copy, Clone)]
struct PaintInputs<'a> {
    rect: LayoutRect,
    placeholder: StringKey,
    state: &'a TextInputState,
    disabled: bool,
    shaped: &'a ShapedText,
}

fn build_paint(
    ctx: &FrameCtx<'_>,
    inputs: PaintInputs<'_>,
    interaction: Interaction,
    live_focused: bool,
    has_error: bool,
) -> Vec<WidgetPaint> {
    let PaintInputs {
        rect,
        placeholder,
        state,
        disabled,
        shaped,
    } = inputs;
    let visuals = field_visuals(ctx, disabled, interaction, has_error);
    let (label, label_color) = if state.text.is_empty() {
        (LabelText::Key(placeholder), visuals.placeholder)
    } else {
        (LabelText::Owned(state.text.clone()), visuals.text.color)
    };
    let visible = shaped
        .lines
        .first()
        .map_or(0.0, ShapedLine::visible_advance_px);
    let label_rect = if visible > rect.size.width.value() {
        LayoutRect::new(
            LayoutPos::new(
                LayoutPx::saturating(rect.origin.x.value() - state.scroll_x),
                rect.origin.y,
            ),
            rect.size,
        )
    } else {
        rect
    };
    let mut paint = vec![
        WidgetPaint::Surface {
            rect,
            fill: visuals.surface.fill,
            border: visuals.surface.border,
            radius: visuals.surface.radius,
            elevation: None,
        },
        WidgetPaint::Label {
            rect: label_rect,
            text: label,
            color: label_color,
            role: visuals.text.role,
        },
    ];
    let text_len = state.text.len();
    if let Some(sel) = selection_rect(
        rect,
        shaped,
        text_len,
        state.selection.min().value(),
        state.selection.max().value(),
        state.scroll_x,
    ) {
        paint.push(WidgetPaint::SelectionHighlight {
            rect: sel,
            color: visuals.selection,
        });
    }
    if live_focused && !disabled {
        let caret = caret_rect(
            rect,
            shaped,
            text_len,
            state.selection.caret().value(),
            state.scroll_x,
        );
        paint.push(WidgetPaint::Caret {
            rect: caret,
            color: visuals.caret,
        });
    }
    push_focus_ring(ctx, &mut paint, rect, visuals.surface.radius, live_focused);
    paint
}

fn field_visuals(
    ctx: &FrameCtx<'_>,
    disabled: bool,
    interaction: Interaction,
    has_error: bool,
) -> FieldVisuals {
    let neutral = ctx.theme().colors.neutral;
    let danger = ctx.theme().colors.danger;
    let radius = ctx.theme().radius.sm;
    let hovered = interaction.hover();
    let focused = interaction.focused();
    let fill = if disabled {
        neutral.step(Step12::SUBTLE_BG)
    } else {
        neutral.step(Step12::APP_BG)
    };
    let border_color = if has_error {
        danger.step(Step12::SOLID)
    } else if focused {
        ctx.theme().colors.accent_solid()
    } else if hovered {
        neutral.step(Step12::HOVER_BORDER)
    } else {
        neutral.step(Step12::BORDER)
    };
    let surface = SurfaceVisuals {
        fill,
        border: Some(Border {
            width: StrokeWidth::HAIRLINE,
            color: border_color,
        }),
        radius,
        elevation: None,
    };
    let text_color = if disabled {
        ctx.theme().colors.text_disabled()
    } else {
        ctx.theme().colors.text_primary()
    };
    FieldVisuals {
        surface,
        text: TextVisuals {
            color: text_color,
            role: ctx.theme().typography.body,
        },
        placeholder: ctx.theme().colors.text_secondary(),
        caret: ctx.theme().colors.text_primary(),
        selection: ctx.theme().cad.selection_primary.with_alpha(0.35),
    }
}

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

    use super::{
        AlwaysValid, Clipboard, MemoryClipboard, TextInput, TextInputAction, TextInputState,
        TextInputValidation, show_text_input,
    };
    use bone_text::SourceByteIndex;

    use crate::focus::FocusManager;
    use crate::frame::FrameCtx;
    use crate::hit_test::{HitFrame, HitState};
    use crate::hotkey::HotkeyTable;
    use crate::input::{
        FrameInstant, InputSnapshot, KeyChar, KeyCode, KeyEvent, ModifierMask, NamedKey,
    };
    use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
    use crate::strings::StringKey;
    use crate::strings::StringTable;
    use crate::text::Selection;
    use crate::theme::Theme;
    use crate::widget_id::{WidgetId, WidgetKey};

    const PLACEHOLDER: StringKey = StringKey::new("text.placeholder");

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

    fn id_widget() -> WidgetId {
        WidgetId::ROOT.child(WidgetKey::new("textinput"))
    }

    fn focused_with(
        state_text: &str,
        events: Vec<KeyEvent>,
    ) -> (TextInputState, FocusManager, InputSnapshot) {
        let state = TextInputState::from_text(state_text);
        let mut focus = FocusManager::new();
        focus.register_focusable(id_widget());
        focus.request_focus(id_widget());
        focus.end_frame();
        let mut input = InputSnapshot::idle(FrameInstant::ZERO);
        input.keys_pressed = events;
        (state, focus, input)
    }

    fn run<C: Clipboard, V: TextInputValidation>(
        state: &mut TextInputState,
        focus: &mut FocusManager,
        input: &mut InputSnapshot,
        clipboard: &mut C,
        validator: V,
    ) -> super::TextInputResponse<V::Error> {
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut hits = HitFrame::new();
        let prev = HitState::new();
        let widget = TextInput {
            id: id_widget(),
            rect: rect(),
            placeholder: PLACEHOLDER,
            state,
            disabled: false,
            validator,
        };
        let mut a11y = crate::a11y::AccessTreeBuilder::new();
        let mut shaper = bone_text::Shaper::new();
        let mut ctx = FrameCtx::new(
            theme,
            input,
            focus,
            &table,
            StringTable::empty(),
            &mut hits,
            &prev,
            &mut a11y,
            &mut shaper,
        );
        show_text_input(&mut ctx, widget, clipboard)
    }

    fn ctrl_key(c: char) -> KeyEvent {
        KeyEvent::new(KeyCode::Char(KeyChar::from_char(c)), ModifierMask::CTRL)
    }

    #[test]
    fn typing_inserts_at_caret() {
        let (mut state, mut focus, mut input) = focused_with("ab", vec![]);
        input.text_committed = "c".to_owned();
        let mut clipboard = MemoryClipboard::default();
        let _ = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        assert_eq!(state.text, "abc");
        assert_eq!(state.selection.caret().value(), 3);
    }

    #[test]
    fn backspace_deletes_grapheme_when_no_selection() {
        let (mut state, mut focus, mut input) = focused_with(
            "abc",
            vec![KeyEvent::new(
                KeyCode::Named(NamedKey::Backspace),
                ModifierMask::NONE,
            )],
        );
        let mut clipboard = MemoryClipboard::default();
        let _ = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        assert_eq!(state.text, "ab");
    }

    #[test]
    fn delete_removes_grapheme_after_caret() {
        let (mut state, mut focus, mut input) = focused_with(
            "abc",
            vec![KeyEvent::new(
                KeyCode::Named(NamedKey::Delete),
                ModifierMask::NONE,
            )],
        );
        state.selection = Selection::caret_at(SourceByteIndex::new(1));
        let mut clipboard = MemoryClipboard::default();
        let _ = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        assert_eq!(state.text, "ac");
    }

    #[test]
    fn cut_copies_selection_and_removes_it() {
        let (mut state, mut focus, mut input) = focused_with("hello", vec![ctrl_key('x')]);
        state.selection = Selection::ranged(SourceByteIndex::new(1), SourceByteIndex::new(4));
        let mut clipboard = MemoryClipboard::default();
        let response = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        assert_eq!(state.text, "ho");
        assert_eq!(clipboard.read(), Some("ell".to_owned()));
        assert!(
            response
                .edits
                .iter()
                .any(|e| e.action == TextInputAction::Cut)
        );
        assert!(input.keys_pressed.is_empty(), "Cut chord drained");
    }

    #[test]
    fn copy_passes_through_when_no_selection() {
        let (mut state, mut focus, mut input) = focused_with("hello", vec![ctrl_key('c')]);
        let mut clipboard = MemoryClipboard::default();
        let _ = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        assert_eq!(
            input.keys_pressed,
            vec![ctrl_key('c')],
            "Copy with no selection preserves chord for outer handler",
        );
    }

    #[test]
    fn cut_passes_through_when_no_selection() {
        let (mut state, mut focus, mut input) = focused_with("hello", vec![ctrl_key('x')]);
        let mut clipboard = MemoryClipboard::default();
        let response = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        assert_eq!(state.text, "hello");
        assert!(response.edits.is_empty());
        assert_eq!(input.keys_pressed, vec![ctrl_key('x')]);
    }

    #[test]
    fn paste_passes_through_when_clipboard_empty() {
        let (mut state, mut focus, mut input) = focused_with("", vec![ctrl_key('v')]);
        let mut clipboard = MemoryClipboard::default();
        let _ = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        assert_eq!(input.keys_pressed, vec![ctrl_key('v')]);
    }

    #[test]
    fn paste_drains_chord_when_clipboard_has_content() {
        let (mut state, mut focus, mut input) = focused_with("", vec![ctrl_key('v')]);
        let mut clipboard = MemoryClipboard::default();
        clipboard.write("x".to_owned());
        let _ = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        assert!(
            input.keys_pressed.is_empty(),
            "Paste with content drains chord"
        );
    }

    #[test]
    fn copy_does_not_modify_text() {
        let (mut state, mut focus, mut input) = focused_with("hello", vec![ctrl_key('c')]);
        state.selection = Selection::ranged(SourceByteIndex::new(1), SourceByteIndex::new(4));
        let mut clipboard = MemoryClipboard::default();
        let _ = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        assert_eq!(state.text, "hello");
        assert_eq!(clipboard.read(), Some("ell".to_owned()));
    }

    #[test]
    fn paste_inserts_clipboard_text() {
        let (mut state, mut focus, mut input) = focused_with("ho", vec![ctrl_key('v')]);
        state.selection = Selection::caret_at(SourceByteIndex::new(1));
        let mut clipboard = MemoryClipboard::default();
        clipboard.write("ell".to_owned());
        let _ = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        assert_eq!(state.text, "hello");
        assert_eq!(state.selection.caret().value(), 4);
    }

    #[test]
    fn select_all_then_typing_replaces() {
        let (mut state, mut focus, mut input) = focused_with("abc", vec![ctrl_key('a')]);
        let mut clipboard = MemoryClipboard::default();
        let _ = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        assert_eq!(state.selection.min().value(), 0);
        assert_eq!(state.selection.max().value(), 3);

        let (_, _, mut input2) = focused_with("dummy", vec![]);
        input2.text_committed = "Z".to_owned();
        let _ = run(
            &mut state,
            &mut focus,
            &mut input2,
            &mut clipboard,
            AlwaysValid,
        );
        assert_eq!(state.text, "Z");
    }

    #[test]
    fn arrow_keys_move_caret_without_changing_text() {
        let (mut state, mut focus, mut input) = focused_with(
            "abc",
            vec![KeyEvent::new(
                KeyCode::Named(NamedKey::ArrowLeft),
                ModifierMask::NONE,
            )],
        );
        let mut clipboard = MemoryClipboard::default();
        let _ = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        assert_eq!(state.text, "abc");
        assert_eq!(state.selection.caret().value(), 2);
    }

    #[test]
    fn validator_surfaces_typed_error() {
        struct LimitFour;
        #[derive(Clone, Debug, PartialEq, Eq)]
        enum Err {
            TooLong,
        }
        impl TextInputValidation for LimitFour {
            type Error = Err;
            fn validate(&self, text: &str) -> Result<(), Err> {
                if text.len() > 4 {
                    Err(Err::TooLong)
                } else {
                    Ok(())
                }
            }
        }
        let (mut state, mut focus, mut input) = focused_with("abcde", vec![]);
        let mut clipboard = MemoryClipboard::default();
        let response = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            LimitFour,
        );
        assert_eq!(response.error, Some(Err::TooLong));
    }

    #[test]
    fn unfocused_input_does_not_consume_keys() {
        let mut state = TextInputState::from_text("ab");
        let mut focus = FocusManager::new();
        let mut input = InputSnapshot::idle(FrameInstant::ZERO);
        let event = KeyEvent::new(KeyCode::Char(KeyChar::from_char('c')), ModifierMask::NONE);
        input.keys_pressed.push(event);
        let mut clipboard = MemoryClipboard::default();
        let _ = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        assert_eq!(state.text, "ab");
        assert_eq!(input.keys_pressed, vec![event]);
    }

    #[test]
    fn ime_committed_text_inserts_into_buffer() {
        let (mut state, mut focus, mut input) = focused_with("é", vec![]);
        state.selection = Selection::caret_at(SourceByteIndex::new("é".len()));
        input.text_committed = "あ".to_owned();
        let mut clipboard = MemoryClipboard::default();
        let _ = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        assert_eq!(state.text, "éあ");
    }

    #[test]
    fn printable_keystroke_does_not_insert_via_keys_pressed() {
        let (mut state, mut focus, mut input) = focused_with(
            "",
            vec![KeyEvent::new(
                KeyCode::Char(KeyChar::from_char('a')),
                ModifierMask::NONE,
            )],
        );
        let mut clipboard = MemoryClipboard::default();
        let _ = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        assert_eq!(
            state.text, "",
            "printable input is the platform's text_committed responsibility, not keys_pressed",
        );
    }

    #[test]
    fn paint_carries_user_text_as_owned_label() {
        let (mut state, mut focus, mut input) = focused_with("hello", vec![]);
        let mut clipboard = MemoryClipboard::default();
        let response = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        let owned_text = response.paint.iter().find_map(|p| match p {
            super::WidgetPaint::Label {
                text: super::LabelText::Owned(s),
                ..
            } => Some(s.clone()),
            _ => None,
        });
        assert_eq!(owned_text.as_deref(), Some("hello"));
    }

    #[test]
    fn empty_input_paints_placeholder_key() {
        let (mut state, mut focus, mut input) = focused_with("", vec![]);
        let mut clipboard = MemoryClipboard::default();
        let response = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        let key = response.paint.iter().find_map(|p| match p {
            super::WidgetPaint::Label {
                text: super::LabelText::Key(k),
                ..
            } => Some(*k),
            _ => None,
        });
        assert_eq!(key, Some(PLACEHOLDER));
    }

    fn caret_paint_for(text: &str, byte: usize) -> super::WidgetPaint {
        let (mut state, mut focus, mut input) = focused_with(text, vec![]);
        state.selection = Selection::caret_at(SourceByteIndex::new(byte));
        let mut clipboard = MemoryClipboard::default();
        let response = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        let Some(caret) = response
            .paint
            .iter()
            .find(|p| matches!(p, super::WidgetPaint::Caret { .. }))
            .cloned()
        else {
            panic!("focused input must paint a caret");
        };
        caret
    }

    #[test]
    fn caret_rect_advances_with_byte_offset() {
        let super::WidgetPaint::Caret { rect: at_zero, .. } = caret_paint_for("abc", 0) else {
            panic!("caret variant expected");
        };
        let super::WidgetPaint::Caret { rect: at_two, .. } = caret_paint_for("abc", 2) else {
            panic!("caret variant expected");
        };
        let super::WidgetPaint::Caret { rect: at_end, .. } = caret_paint_for("abc", 3) else {
            panic!("caret variant expected");
        };
        assert!(
            at_zero.origin.x.value() < at_two.origin.x.value(),
            "caret at 0 must sit left of caret at 2",
        );
        assert!(
            at_two.origin.x.value() < at_end.origin.x.value(),
            "caret at 2 must sit left of caret at end",
        );
        assert!(
            (at_zero.size.width.value() - super::CARET_WIDTH_PX).abs() < f32::EPSILON,
            "caret bar is one pixel wide",
        );
    }

    #[test]
    fn selection_rect_spans_only_selected_glyphs() {
        let (mut state, mut focus, mut input) = focused_with("hello", vec![]);
        state.selection = Selection::ranged(SourceByteIndex::new(1), SourceByteIndex::new(4));
        let mut clipboard = MemoryClipboard::default();
        let response = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        let Some(selection) = response.paint.iter().find_map(|p| match p {
            super::WidgetPaint::SelectionHighlight { rect, .. } => Some(*rect),
            _ => None,
        }) else {
            panic!("non-empty selection must emit a highlight");
        };
        let widget = rect();
        assert!(
            selection.size.width.value() > 0.0,
            "selection rect must have positive width",
        );
        assert!(
            selection.size.width.value() < widget.size.width.value(),
            "selection rect cannot exceed input width for substring",
        );
        assert!(
            selection.origin.x.value() >= widget.origin.x.value(),
            "selection cannot start before input rect",
        );
    }

    #[test]
    fn empty_selection_emits_no_highlight() {
        let (mut state, mut focus, mut input) = focused_with("hello", vec![]);
        state.selection = Selection::caret_at(SourceByteIndex::new(2));
        let mut clipboard = MemoryClipboard::default();
        let response = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        let any_selection = response
            .paint
            .iter()
            .any(|p| matches!(p, super::WidgetPaint::SelectionHighlight { .. }));
        assert!(
            !any_selection,
            "caret-only selection must not paint a highlight"
        );
    }

    fn run_pointer_drag(
        state: &mut TextInputState,
        widget_rect: LayoutRect,
        positions: &[(f32, bool)],
    ) {
        use crate::hit_test::{HitFrame, HitState, Sense, resolve};
        use crate::input::{PointerButton, PointerButtonMask, PointerSample};
        let mut focus = FocusManager::new();
        focus.register_focusable(id_widget());
        focus.end_frame();
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut shaper = bone_text::Shaper::new();
        let mut hit_state = HitState::new();
        for (x, primary_pressed) in positions {
            let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
            snap.pointer = Some(PointerSample::new(LayoutPos::new(
                LayoutPx::new(*x),
                LayoutPx::new(widget_rect.origin.y.value() + widget_rect.size.height.value() * 0.5),
            )));
            if *primary_pressed {
                snap.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
            } else {
                snap.buttons_released = PointerButtonMask::just(PointerButton::Primary);
            }
            let mut hits = HitFrame::new();
            hits.push(crate::hit_test::HitItem {
                id: id_widget(),
                rect: widget_rect,
                sense: Sense::DRAGGABLE,
                z: crate::hit_test::ZLayer::BASE,
                disabled: false,
                active: false,
            });
            let new_state = resolve(&hit_state, &hits, &snap, focus.focused());
            let mut clipboard = MemoryClipboard::default();
            let mut a11y = crate::a11y::AccessTreeBuilder::new();
            let mut frame_hits = HitFrame::new();
            let widget = TextInput {
                id: id_widget(),
                rect: widget_rect,
                placeholder: PLACEHOLDER,
                state: &mut *state,
                disabled: false,
                validator: AlwaysValid,
            };
            let mut snap_for_ctx = snap;
            {
                let mut ctx = FrameCtx::new(
                    Arc::clone(&theme),
                    &mut snap_for_ctx,
                    &mut focus,
                    &table,
                    StringTable::empty(),
                    &mut frame_hits,
                    &new_state,
                    &mut a11y,
                    &mut shaper,
                );
                let _ = show_text_input(&mut ctx, widget, &mut clipboard);
            }
            hit_state = new_state;
        }
    }

    #[test]
    fn press_far_left_places_caret_at_start() {
        let widget_rect = LayoutRect::new(
            LayoutPos::new(LayoutPx::new(20.0), LayoutPx::new(0.0)),
            LayoutSize::new(LayoutPx::new(160.0), LayoutPx::new(24.0)),
        );
        let mut state = TextInputState::from_text("hello");
        state.selection = Selection::caret_at(SourceByteIndex::new(5));
        run_pointer_drag(&mut state, widget_rect, &[(22.0, true)]);
        assert_eq!(
            state.selection.caret().value(),
            0,
            "press far-left of text origin must place caret at byte 0",
        );
    }

    #[test]
    fn press_then_drag_extends_selection() {
        let widget_rect = LayoutRect::new(
            LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)),
            LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(24.0)),
        );
        let mut state = TextInputState::from_text("hello world");
        state.selection = Selection::caret_at(SourceByteIndex::new(0));
        run_pointer_drag(
            &mut state,
            widget_rect,
            &[(0.0, true), (200.0, true), (200.0, false)],
        );
        assert_eq!(state.selection.min().value(), 0);
        assert_eq!(state.selection.max().value(), state.text.len());
        assert!(state.drag_anchor.is_none(), "release clears drag anchor");
    }

    fn run_with_state(
        state: &mut TextInputState,
        widget_rect: LayoutRect,
    ) -> super::TextInputResponse<core::convert::Infallible> {
        use crate::hit_test::{HitFrame, HitState};
        let mut focus = FocusManager::new();
        focus.register_focusable(id_widget());
        focus.request_focus(id_widget());
        focus.end_frame();
        let theme = Arc::new(Theme::light());
        let table = HotkeyTable::new();
        let mut hits = HitFrame::new();
        let prev = HitState::new();
        let mut input = InputSnapshot::idle(FrameInstant::ZERO);
        let mut clipboard = MemoryClipboard::default();
        let mut a11y = crate::a11y::AccessTreeBuilder::new();
        let mut shaper = bone_text::Shaper::new();
        let widget = TextInput {
            id: id_widget(),
            rect: widget_rect,
            placeholder: PLACEHOLDER,
            state: &mut *state,
            disabled: false,
            validator: AlwaysValid,
        };
        let mut ctx = FrameCtx::new(
            theme,
            &mut input,
            &mut focus,
            &table,
            StringTable::empty(),
            &mut hits,
            &prev,
            &mut a11y,
            &mut shaper,
        );
        show_text_input(&mut ctx, widget, &mut clipboard)
    }

    #[test]
    fn scroll_x_stays_zero_when_text_fits() {
        let widget_rect = LayoutRect::new(
            LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
            LayoutSize::new(LayoutPx::new(400.0), LayoutPx::new(24.0)),
        );
        let mut state = TextInputState::from_text("hi");
        state.selection = Selection::caret_at(SourceByteIndex::new(2));
        let _ = run_with_state(&mut state, widget_rect);
        assert!(
            state.scroll_x.abs() < f32::EPSILON,
            "short text in wide rect must not scroll, got {}",
            state.scroll_x,
        );
    }

    #[test]
    fn scroll_x_advances_to_keep_caret_visible_at_end_of_long_text() {
        let widget_rect = LayoutRect::new(
            LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
            LayoutSize::new(LayoutPx::new(40.0), LayoutPx::new(24.0)),
        );
        let mut state = TextInputState::from_text("the quick brown fox jumps over the lazy dog");
        state.selection = Selection::caret_at(SourceByteIndex::new(state.text.len()));
        let _ = run_with_state(&mut state, widget_rect);
        assert!(
            state.scroll_x > 0.0,
            "long text with caret at end must scroll, got {}",
            state.scroll_x,
        );
    }

    #[test]
    fn scroll_x_resets_when_caret_returns_to_start() {
        let widget_rect = LayoutRect::new(
            LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
            LayoutSize::new(LayoutPx::new(40.0), LayoutPx::new(24.0)),
        );
        let mut state = TextInputState::from_text("the quick brown fox jumps over the lazy dog");
        state.selection = Selection::caret_at(SourceByteIndex::new(state.text.len()));
        let _ = run_with_state(&mut state, widget_rect);
        let scrolled = state.scroll_x;
        assert!(scrolled > 0.0);
        state.selection = Selection::caret_at(SourceByteIndex::new(0));
        let _ = run_with_state(&mut state, widget_rect);
        assert!(
            state.scroll_x < scrolled,
            "caret at start must scroll back, was {} now {}",
            scrolled,
            state.scroll_x,
        );
    }

    #[test]
    fn caret_rect_stays_inside_widget_rect_when_text_overflows() {
        let widget_rect = LayoutRect::new(
            LayoutPos::new(LayoutPx::new(10.0), LayoutPx::ZERO),
            LayoutSize::new(LayoutPx::new(40.0), LayoutPx::new(24.0)),
        );
        let mut state = TextInputState::from_text("the quick brown fox jumps over the lazy dog");
        state.selection = Selection::caret_at(SourceByteIndex::new(state.text.len()));
        let response = run_with_state(&mut state, widget_rect);
        let Some(caret) = response.paint.iter().find_map(|p| match p {
            super::WidgetPaint::Caret { rect, .. } => Some(*rect),
            _ => None,
        }) else {
            panic!("focused input must paint a caret");
        };
        let left = widget_rect.origin.x.value();
        let right = left + widget_rect.size.width.value();
        assert!(
            caret.origin.x.value() >= left - 0.5 && caret.origin.x.value() <= right + 0.5,
            "caret x {} must lie in [{}, {}]",
            caret.origin.x.value(),
            left,
            right,
        );
    }

    #[test]
    fn drag_after_release_does_not_extend_selection() {
        let widget_rect = LayoutRect::new(
            LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)),
            LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(24.0)),
        );
        let mut state = TextInputState::from_text("hello world");
        state.selection = Selection::caret_at(SourceByteIndex::new(0));
        run_pointer_drag(&mut state, widget_rect, &[(20.0, true), (60.0, false)]);
        let after_release = state.selection;
        run_pointer_drag(&mut state, widget_rect, &[(180.0, false)]);
        assert_eq!(
            state.selection, after_release,
            "pointer movement after release does not change selection",
        );
    }

    #[test]
    fn out_of_bounds_selection_snaps_to_text_length_before_editing() {
        let (mut state, mut focus, mut input) = focused_with("ab", vec![]);
        state.selection = Selection::caret_at(SourceByteIndex::new(99));
        input.text_committed = "c".to_owned();
        let mut clipboard = MemoryClipboard::default();
        let _ = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        assert_eq!(state.text, "abc");
        assert_eq!(state.selection.caret().value(), 3);
    }

    #[test]
    fn mid_codepoint_selection_snaps_to_grapheme_boundary_before_editing() {
        let (mut state, mut focus, mut input) = focused_with("é", vec![]);
        state.selection = Selection::caret_at(SourceByteIndex::new(1));
        input.text_committed = "x".to_owned();
        let mut clipboard = MemoryClipboard::default();
        let _ = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        assert_eq!(state.text, "xé");
        assert_eq!(state.selection.caret().value(), "x".len());
    }

    #[test]
    fn delete_back_with_invalid_selection_does_not_panic() {
        let (mut state, mut focus, mut input) = focused_with(
            "abc",
            vec![KeyEvent::new(
                KeyCode::Named(NamedKey::Backspace),
                ModifierMask::NONE,
            )],
        );
        state.selection = Selection::ranged(SourceByteIndex::new(2), SourceByteIndex::new(99));
        let mut clipboard = MemoryClipboard::default();
        let _ = run(
            &mut state,
            &mut focus,
            &mut input,
            &mut clipboard,
            AlwaysValid,
        );
        assert_eq!(state.text, "ab");
    }
}
