// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>

use smithay_client_toolkit::
{
	compositor::CompositorState,
	output::OutputState,
	registry::RegistryState,
	seat::SeatState,
	shell::
	{
		WaylandSurface,
		wlr_layer::LayerShell,
		xdg::
		{
			XdgShell,
			window::WindowDecorations,
		},
	},
	shm::Shm,
	subcompositor::SubcompositorState,
};
use smithay_client_toolkit::reexports::client::Connection;
use smithay_client_toolkit::reexports::calloop::EventLoop;
use smithay_client_toolkit::reexports::calloop_wayland_source::WaylandSource;
use calloop::timer::{ Timer, TimeoutAction };
use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_manager_v3::ZwpTextInputManagerV3;

use crate::app::{ App, InvalidationScope };
use crate::types::Point;

use super::{ AppData, LayerConfig, SurfaceFocus, SurfaceKind, SurfaceState };
use super::error::RunError;
use super::frame::draw_frame;
use super::invalidation::apply_invalidation;
use super::overlays_reconcile::reconcile_overlays;

/// Run the application, panicking on init failure. Thin wrapper over
/// [`try_run`] kept for backwards-compatibility — embedders that need
/// to recover from a missing compositor or a stripped-down driver
/// should call [`try_run`] instead.
pub( crate ) fn run<A: App>( app: A )
{
	if let Err( e ) = try_run( app )
	{
		panic!( "ltk::run failed during init: {e}" );
	}
}

/// Run the application, returning a typed error on init failure.
/// The dispatch loop's runtime errors still panic — they are non-
/// recoverable once the surface is on screen, and the surface state
/// machine cannot be unwound cleanly from this entry point.
pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
{
	let conn = Connection::connect_to_env()
		.map_err( |e| RunError::NoWaylandConnection( format!( "{e}" ) ) )?;
	let ( globals, queue ) =
		smithay_client_toolkit::reexports::client::globals::registry_queue_init( &conn )
			.map_err( |e| RunError::RegistryInit( format!( "{e:?}" ) ) )?;
	let qh = queue.handle();

	let mut event_loop: EventLoop<AppData<A>> = EventLoop::try_new()
		.map_err( |e| RunError::EventLoop( format!( "EventLoop::try_new: {e}" ) ) )?;
	WaylandSource::new( conn.clone(), queue )
		.insert( event_loop.handle() )
		.map_err( |e| RunError::EventLoop( format!( "WaylandSource::insert: {e:?}" ) ) )?;

	let compositor = CompositorState::bind( &globals, &qh )
		.map_err( |e| RunError::MissingProtocol { name: "wl_compositor", detail: format!( "{e:?}" ) } )?;
	let shm        = Shm::bind( &globals, &qh )
		.map_err( |e| RunError::MissingProtocol { name: "wl_shm",        detail: format!( "{e:?}" ) } )?;

	// Optional: compositors lacking wl_subcompositor leave this None and
	// App::subsurfaces() silently degrades to no subsurfaces.
	let subcompositor = SubcompositorState::bind( compositor.wl_compositor().clone(), &globals, &qh ).ok();

	// Try to bring EGL up. On failure (no libEGL, no compatible config,
	// LTK_FORCE_SOFTWARE=1, ES2/ES3 context creation refused…) we log the
	// reason and every surface falls back to the SHM path.
	let egl_context = match crate::egl_context::EglContext::new( &conn )
	{
		Ok( ctx )    => Some( std::sync::Arc::new( ctx ) ),
		Err( reason ) =>
		{
			crate::egl_context::log_software_fallback( &reason );
			None
		}
	};
	crate::render::set_software_render( egl_context.is_none() );

	// Bind layer-shell up front. Both the main surface (when requested via
	// ShellMode::Layer) and every overlay returned by App::overlays() share
	// this single binding.
	let layer_shell_opt: Option<LayerShell> = LayerShell::bind( &globals, &qh ).ok();

	// Backwards compatibility: window_config() overrides shell_mode()
	let force_window = app.window_config()
		.map( |( t, id )| ( t.to_string(), id.to_string() ) );

	let bind_xdg = |globals: &smithay_client_toolkit::reexports::client::globals::GlobalList, qh: &smithay_client_toolkit::reexports::client::QueueHandle<AppData<A>>|
		-> Result<XdgShell, RunError>
	{
		XdgShell::bind( globals, qh )
			.map_err( |e| RunError::MissingProtocol { name: "xdg_wm_base", detail: format!( "{e:?}" ) } )
	};

	// Skipped when going fullscreen: Mutter rejects the fullscreen
	// request if a min_size constrains it.
	let apply_size_hint = |window: &smithay_client_toolkit::shell::xdg::window::Window|
	{
		if app.start_fullscreen() { return; }
		match app.window_size_hint()
		{
			Some( ( w, h ) ) =>
			{
				window.set_min_size( Some( ( w, h ) ) );
				window.set_max_size( Some( ( w, h ) ) );
			}
			None =>
			{
				window.set_min_size( Some( ( 360, 480 ) ) );
			}
		}
	};

	let apply_fullscreen = |window: &smithay_client_toolkit::shell::xdg::window::Window|
	{
		if app.start_fullscreen()
		{
			window.set_fullscreen( None );
		}
	};

	let ( surface_kind, xdg_shell ) = if let Some( ( ref title, ref app_id ) ) = force_window
	{
		let xdg     = bind_xdg( &globals, &qh )?;
		let surface = compositor.create_surface( &qh );
		let window  = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
		window.set_title( title.as_str() );
		window.set_app_id( app_id.as_str() );
		apply_size_hint( &window );
		apply_fullscreen( &window );
		window.commit();
		( SurfaceKind::Window( window ), Some( xdg ) )
	} else {
		// Use shell_mode() to determine surface type
		use crate::app::ShellMode;
		match app.shell_mode()
		{
			ShellMode::SessionLock => {
				// Lock surface is created in SessionLockHandler::locked once the
				// compositor grants the lock; until then a placeholder.
				( SurfaceKind::PendingLock, None )
			}
			ShellMode::Window => {
				let xdg     = bind_xdg( &globals, &qh )?;
				let surface = compositor.create_surface( &qh );
				let window  = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
				window.set_title( "ltk" );
				window.set_app_id( "ltk" );
				apply_size_hint( &window );
				window.commit();
				( SurfaceKind::Window( window ), Some( xdg ) )
			}
			ShellMode::Layer( layer ) => {
				if layer_shell_opt.is_some()
				{
					// Defer surface creation until new_output fires: if we create the layer
					// surface before the compositor has any output ready (e.g. sway startup),
					// the compositor cannot assign it and logs an error.
					let cfg = LayerConfig {
						layer:              layer.to_wlr_layer(),
						exclusive_zone:     app.exclusive_zone(),
						anchor:             app.layer_anchor(),
						size:               app.layer_size(),
						keyboard_exclusive: app.keyboard_exclusive(),
						namespace:          "ltk-sctk",
					};
					( SurfaceKind::Pending( cfg ), None )
				} else {
					eprintln!( "ltk: wlr-layer-shell not available, falling back to xdg window" );
					let xdg     = bind_xdg( &globals, &qh )?;
					let surface = compositor.create_surface( &qh );
					let window  = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
					window.set_title( "ltk" );
					window.set_app_id( "ltk" );
					apply_size_hint( &window );
					window.commit();
					( SurfaceKind::Window( window ), Some( xdg ) )
				}
			}
		}
	};

	// Bind the session-lock manager and request the lock. We don't keep the
	// `SessionLockState` (the manager) around: it has no `Drop`, so the
	// manager object persists in the connection, and the lock lifecycle runs
	// entirely off the returned `SessionLock` + its surfaces.
	let session_lock =
		if force_window.is_none() && matches!( app.shell_mode(), crate::app::ShellMode::SessionLock )
		{
			smithay_client_toolkit::session_lock::SessionLockState::new( &globals, &qh )
				.lock( &qh )
				.ok()
		} else {
			None
		};

	let text_input_manager: Option<ZwpTextInputManagerV3> = globals.bind( &qh, 1..=1, () ).ok();
	// wl_data_device_manager: optional. Required for cross-application
	// copy/paste; absence means the clipboard stays process-local.
	let data_device_manager =
		smithay_client_toolkit::data_device_manager::DataDeviceManagerState::bind( &globals, &qh ).ok();
	let ( clipboard_inbox_tx, clipboard_inbox_rx ) = super::data_device::clipboard_inbox();
	let ( drop_inbox_tx,      drop_inbox_rx      ) = super::data_device::drop_inbox();
	// AT-SPI2 / accessibility. `try_new` returns `None` when the
	// platform adapter cannot be created (no daemon on the bus,
	// headless CI, etc.) — the runtime then runs with no
	// accessibility tree, which is the previous behaviour.
	let a11y_app_name = "ltk-app";
	let a11y_app_id   = "net.liberux.ltk";
	let a11y = crate::a11y::A11yState::try_new( a11y_app_name, a11y_app_id );
	// xdg-activation-v1: optional. Compositors that don't carry the
	// global leave `activation_state` as `None` and the inbound /
	// outbound activation paths silently degrade to no-ops.
	let activation_state =
		smithay_client_toolkit::activation::ActivationState::bind( &globals, &qh ).ok();
	// `$XDG_ACTIVATION_TOKEN` is the cross-desktop convention for an
	// incoming activation token (the launcher that spawned us set it
	// in the new process's environment). Honoured exactly once, after
	// the main surface receives its first configure — that is the
	// earliest moment we can call `xdg_activation_v1.activate`.
	let activation_token_pending = std::env::var( "XDG_ACTIVATION_TOKEN" ).ok().filter( |t| !t.is_empty() );
	// Avoid the token leaking into any child process the app spawns —
	// it is single-use and would be invalid the second time anyway.
	if activation_token_pending.is_some()
	{
		// SAFETY: removing an env var is sound only when no other thread
		// is reading the environment concurrently. We are still in the
		// init phase before `set_channel_sender`, so the app has had
		// no opportunity to spawn worker threads yet.
		unsafe { std::env::remove_var( "XDG_ACTIVATION_TOKEN" ); }
	}

	// `ext-foreign-toplevel-list-v1`. SCTK's `ForeignToplevelList` handles the
	// bind + dispatch routing; absence of the global is fine, the inner
	// `GlobalProxy` then yields no toplevels and `App::on_toplevel_event` never
	// fires. The list is built unconditionally so apps that override the
	// callback always see a consistent stream when the compositor does carry
	// the protocol.
	let foreign_toplevel_list = smithay_client_toolkit::foreign_toplevel_list::ForeignToplevelList::new( &globals, &qh );

	let debug_layout = std::env::var( "LTK_DEBUG_LAYOUT" ).is_ok();

	let titlebar_height = if force_window.is_some() { 36.0 } else { 0.0 };
	let titlebar_title  = force_window.map( |( t, _ )| t ).unwrap_or_default();

	let pending_fullscreen      = app.start_fullscreen();
	let pending_size_hint_unpin = !app.start_fullscreen() && app.window_size_hint().is_some() && app.window_resizable();

	let mut data = AppData
	{
		app,
		registry_state:     RegistryState::new( &globals ),
		seat_state:         SeatState::new( &globals, &qh ),
		output_state:       OutputState::new( &globals, &qh ),
		compositor_state:   compositor,
		subcompositor,
		shm,
		session_lock,
		egl_context,
		xdg_shell,
		layer_shell:        layer_shell_opt,
		keyboard:           None,
		pointer:            None,
		touch:              None,
		pointer_pos:        Point::default(),
		cursor_shape_manager:
			smithay_client_toolkit::seat::pointer::cursor_shape::CursorShapeManager::bind( &globals, &qh ).ok(),
		cursor_shape_device:        None,
		last_pointer_enter_serial:  0,
		current_cursor_shape:       None,
		text_input_manager,
		text_input:         None,
		text_input_secure:  false,
		activation_state,
		activation_token_pending,
		data_device_manager,
		data_device:        None,
		clipboard_source:   None,
		clipboard_inbox_tx,
		clipboard_inbox_rx,
		drop_position: None,
		drop_mime:     None,
		drop_inbox_tx,
		drop_inbox_rx,
		a11y,
		foreign_toplevel_list,
		shift_pressed:      false,
		ctrl_pressed:       false,
		loop_handle:        event_loop.handle(),
		compositor_repeat_rate:  0,
		compositor_repeat_delay: 0,
		key_repeat:         None,
		button_repeat:      None,
		clipboard:          String::new(),
		last_press_time:    None,
		last_press_pos:     None,
		debug_layout,
		pending_msgs:       Vec::new(),
		pending_drag_inits: Vec::new(),
		tooltip_pending:    None,
		tooltip_visible:    None,
		qh:                 qh.clone(),
		last_pointer_serial: 0,
		last_input_serial:   0,
		exit_requested:      false,
		pending_fullscreen,
		pending_size_hint_unpin,
		main:                SurfaceState::<A::Message>::new( surface_kind, titlebar_height, titlebar_title ),
		overlays:            std::collections::HashMap::new(),
		secondaries:         Vec::new(),
		main_output:         None,
		subsurfaces:         std::collections::HashMap::new(),
		subsurface_gles_canvas: None,
		pointer_focus:       SurfaceFocus::Main,
		keyboard_focus:      SurfaceFocus::Main,
		touch_focus:         std::collections::HashMap::new(),
		cached_view:            None,
		cached_overlays: None,
		view_dirty:      true,
		overlays_dirty:  true,
		first_frame_committed: false,
		focus_retry: None,
		perf: super::perf::PerfState::new(),
	};

	// Register a calloop channel so the app can send messages from any thread.
	// Messages sent through the Sender wake the event loop immediately.
	{
		let ( sender, channel ) = calloop::channel::channel::<A::Message>();
		event_loop.handle()
			.insert_source(
				channel,
				|event, _, data: &mut AppData<A>|
				{
					if let calloop::channel::Event::Msg( msg ) = event
					{
						// Just queue the message — the run loop will run
						// `App::invalidate_after` and apply the resulting
						// scope, which is what decides which surfaces (if
						// any) actually need to redraw.
						data.pending_msgs.push( msg );
					}
				},
			)
			.map_err( |e| RunError::EventLoop( format!( "channel insert_source: {e:?}" ) ) )?;
		data.app.set_channel_sender( sender );
	}

	// Follow the desktop's accessibility text scale: a watcher thread reads
	// `org.gnome.desktop.interface text-scaling-factor` and streams changes
	// here; applying the factor and repainting is all it takes because font
	// resolution multiplies by `text_scale()` at paint time.
	{
		let ( tx, channel ) = calloop::channel::channel::<f32>();
		event_loop.handle()
			.insert_source(
				channel,
				|event, _, data: &mut AppData<A>|
				{
					if let calloop::channel::Event::Msg( factor ) = event
					{
						if ( factor - crate::types::text_scale() ).abs() < f32::EPSILON { return; }
						crate::types::set_text_scale( factor );
						data.dirty_caches();
						data.main.request_redraw();
						for ss in data.overlays.values_mut()
						{
							ss.request_redraw();
						}
					}
				},
			)
			.map_err( |e| RunError::EventLoop( format!( "text-scale insert_source: {e:?}" ) ) )?;
		super::text_scale::spawn_watcher( tx );
	}

	// Register a periodic timer if the app wants one (e.g. clock tick every second).
	// The timer fires independently of Wayland events, waking the event loop on schedule.
	if let Some( dur ) = data.app.poll_interval()
	{
		data.perf.warn_poll_interval( dur );
		event_loop.handle()
			.insert_source(
				Timer::from_duration( dur ),
				|_, _, data: &mut AppData<A>|
				{
					let msgs = data.app.poll_external();
					data.pending_msgs.extend( msgs );
					let next = data.app.poll_interval()
						.unwrap_or( std::time::Duration::from_secs( 1 ) );
					TimeoutAction::ToDuration( next )
				},
			)
			.map_err( |e| RunError::EventLoop( format!( "poll timer insert_source: {e:?}" ) ) )?;
	}

	while !data.exit_requested
	{
		// Sleep until something interesting fires:
		//   * Wayland event (input, configure, frame callback, …)
		//   * calloop timer (poll_external)
		//   * calloop channel (App::set_channel_sender)
		// Pacing is driven by `wl_surface.frame` callbacks, so an idle /
		// off-screen / VRR display blocks indefinitely instead of polling
		// at a fixed rate. The one exception is a pending long-press:
		// cap the wait at its deadline so a perfectly still press still
		// fires on time.
		let timeout = match ( data.next_long_press_wakeup(), data.next_tooltip_wakeup() )
		{
			( Some( a ), Some( b ) ) => Some( a.min( b ) ),
			( a, b )                 => a.or( b ),
		};
		match event_loop.dispatch( timeout, &mut data )
		{
			Ok( () ) => {},
			Err( calloop::Error::IoError( ref e ) )
				if matches!( e.kind(), std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset ) =>
			{
				// On a fatal `wl_display.error` the compositor closes the
				// socket; the backend has the typed error stashed but the
				// dispatch result only carries the resulting `BrokenPipe`.
				if let Some( pe ) = conn.protocol_error()
				{
					eprintln!(
						"ltk: wayland protocol error — interface={} object_id={} code={}: {}",
						pe.object_interface, pe.object_id, pe.code, pe.message
					);
				}
				else
				{
					eprintln!( "ltk: wayland connection lost ({e}); exiting" );
				}
				data.exit_requested = true;
				continue;
			}
			Err( calloop::Error::OtherError( ref e ) ) =>
			{
				// wayland-client surfaces the closed-socket condition as an
				// OtherError wrapping a WaylandError::Io(BrokenPipe) — one
				// level deeper than a direct io::Error. Walk the source()
				// chain so we catch it regardless of how many wrapper types
				// sit between calloop and the raw io::Error.
				let mut src: Option<&dyn std::error::Error> = Some( e.as_ref() );
				let mut is_closed = false;
				while let Some( err ) = src
				{
					if let Some( io ) = err.downcast_ref::<std::io::Error>()
					{
						if matches!( io.kind(),
							std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset )
						{
							is_closed = true;
							break;
						}
					}
					src = err.source();
				}
				if is_closed
				{
					eprintln!( "ltk: wayland connection lost; exiting" );
					data.exit_requested = true;
					continue;
				}
				panic!( "dispatch: {e:?}" );
			}
			Err( e ) => panic!( "dispatch: {e:?}" ),
		}
		// Any surface whose press has now crossed `long_press_duration`
		// emits its stored message and flips into drag mode for the rest
		// of the gesture.
		data.check_long_press_deadlines();
		data.check_tooltip_deadline();

		// Poll external messages (immediate async results, e.g. PAM auth channel)
		let ext: Vec<_> = data.app.poll_external();
		data.pending_msgs.extend( ext );

		// Issue any xdg-activation tokens the app asked for. The tag rides
		// in `RequestData::app_id` and comes back through
		// `ActivationHandler::new_token` (handlers.rs) as the token. When the
		// compositor never advertised the global, answer with an empty token
		// so the caller still proceeds (falling back to its own matching).
		for tag in data.app.take_activation_requests()
		{
			match data.activation_state
			{
				Some( ref activation ) =>
				{
					let req = smithay_client_toolkit::activation::RequestData {
						app_id:          Some( tag ),
						seat_and_serial: None,
						surface:         data.main.surface.try_wl_surface().cloned(),
					};
					activation.request_token::<AppData<A>>( &data.qh, req );
				}
				None =>
				{
					if let Some( msg ) = data.app.on_activation_token( tag, String::new() )
					{
						data.pending_msgs.push( msg );
					}
				}
			}
		}

		// Drain any inbound clipboard payloads delivered by the
		// data-device worker thread. Latest writer wins; the channel
		// is unbounded but selection payloads are small (capped at
		// 16 MiB inside the worker, see `data_device::DataDeviceHandler::selection`).
		while let Ok( text ) = data.clipboard_inbox_rx.try_recv()
		{
			data.clipboard = text;
		}
		while let Ok( p ) = data.drop_inbox_rx.try_recv()
		{
			data.app.on_drop_received( p.x as f32, p.y as f32, &p.mime, &p.text );
		}

		// Process pending messages, folding their per-message invalidation
		// scopes into a single decision before applying it.
		let msgs: Vec<_> = data.pending_msgs.drain( .. ).collect();
		let had_msgs = !msgs.is_empty();
		if had_msgs
		{
			let mut scope = InvalidationScope::Only( Vec::new() );
			for msg in msgs
			{
				scope = scope.union( data.app.invalidate_after( &msg ) );
				data.app.update( msg );
			}
			apply_invalidation( &mut data, scope );
			// `update()` may have flipped the busy / loading flag
			// the app reads from inside `cursor_override`. Re-sync
			// the pointer cursor so the change propagates without
			// waiting for the next motion event.
			let pf = data.pointer_focus;
			data.dispatch_cursor_shape( pf );
		}

		// App asked to exit (e.g. the lockscreen authenticated). For a
		// session-lock surface, unlock first — exiting without unlocking
		// leaves the compositor's lock in place by design.
		if data.app.requested_exit()
		{
			if let Some( lock ) = data.session_lock.take()
			{
				lock.unlock();
				let _ = conn.roundtrip();
			}
			data.exit_requested = true;
		}

		// Seed `on_drag_move` with the long-press origin for any drag that
		// just started. Must run *after* `update()` so the app's drag state
		// has already been set up by the paired long-press message — the
		// coords would otherwise hit a dragging_app=None shell and be lost.
		let drag_inits: Vec<_> = data.pending_drag_inits.drain( .. ).collect();
		if !drag_inits.is_empty()
		{
			for origin in drag_inits
			{
				data.app.on_drag_move( origin.x, origin.y );
			}
			data.view_dirty     = true;
			data.overlays_dirty = true;
			data.main.request_redraw();
		}
		// After update() the app state is the source of truth — discard any
		// pending text values so that the next keystroke reads the fresh state
		// instead of a stale pre-update buffer (e.g. password cleared on auth failure).
		if !data.main.pending_text_values.is_empty()
		{
			data.main.pending_text_values.clear();
		}
		for ss in data.overlays.values_mut()
		{
			ss.pending_text_values.clear();
		}

		// Reconcile the overlay set against the app's current specs: drop any
		// overlays whose id disappeared, create new ones for ids that just
		// appeared. Specs are re-queried next frame for drawing.
		reconcile_overlays( &mut data );

		// Draw any surface that's configured, dirty, and not already waiting
		// on a frame callback. The compositor decides our cadence — when no
		// surface qualifies we just loop back to `dispatch(None)` and sleep.
		let any_drawable = ( data.main.configured && data.main.needs_redraw && !data.main.frame_pending )
			|| data.overlays.values().any( |ss| ss.configured && ss.needs_redraw && !ss.frame_pending )
			|| data.secondaries.iter().any( |( _, ss )| ss.configured && ss.needs_redraw && !ss.frame_pending );
		if any_drawable
		{
			// Rebuild while motion is in progress and on the first frame
			// after it ends, so the settle frame paints at full quality
			// instead of freezing one step short of the target.
			// `wants_low_quality_paint` is the source of truth so
			// finger-tracked drags get the same treatment. Slider /
			// scroll drags are owned by the runtime gesture machine,
			// not by `App::update`, so OR with the gesture state to
			// pick up that motion signal too.
			let runtime_slider_motion =
				data.main.gesture.dragging_slider.is_some()
				|| data.overlays.values().any( |ss| ss.gesture.dragging_slider.is_some() );
			if runtime_slider_motion
			{
				data.view_dirty     = true;
				data.overlays_dirty = true;
			}
			if data.view_dirty
			{
				data.cached_view = Some( data.app.view() );
				data.view_dirty  = false;
			}
			if data.overlays_dirty
			{
				let mut specs = data.app.overlays();
				if let Some( ts ) = data.tooltip_overlay() { specs.push( ts ); }
				data.cached_overlays = Some( specs );
				data.overlays_dirty  = false;
			}
			let first_commit_now = draw_frame( &mut data );
			if first_commit_now
			{
				data.app.on_first_frame_committed();
			}

			// Push the freshly-laid-out widget tree to AccessKit. The
			// closure only runs when an AT client is actually
			// listening (the adapter no-ops `update_if_active`
			// otherwise), so the build cost is paid on demand. Main
			// surface only — overlays would require per-surface
			// adapters, which the AT-SPI2 model is not built for.
			let mut a11y_taken = data.a11y.take();
			if let Some( ref mut a ) = a11y_taken
			{
				let main_w = data.main.width  as f32;
				let main_h = data.main.height as f32;
				let mut overlay_meta: Vec<( u8, crate::app::OverlayId, f32, f32 )> = Vec::new();
				let mut next_id: u8 = 1;
				for ( id, ss ) in data.overlays.iter()
				{
					if next_id == 0 { break; }
					let ( ox, oy ) = data.surface_offset_for( super::SurfaceFocus::Overlay( *id ) );
					let ( w, h )   = ( ss.width as f32, ss.height as f32 );
					if w <= 0.0 || h <= 0.0 || ( ox + w <= 0.0 ) || ( oy + h <= 0.0 ) || ox >= main_w || oy >= main_h { continue; }
					overlay_meta.push( ( next_id, *id, ox, oy ) );
					next_id = next_id.saturating_add( 1 );
				}
				let kb_focus_id = match data.keyboard_focus
				{
					super::SurfaceFocus::Main => 0u8,
					super::SurfaceFocus::Overlay( id ) =>
						overlay_meta.iter().find( |( _, oid, _, _ )| *oid == id ).map( |( i, _, _, _ )| *i ).unwrap_or( 0 ),
				};
				let mut surfaces: Vec<crate::a11y::tree::SurfaceView<A::Message>> = Vec::new();
				surfaces.push( crate::a11y::tree::SurfaceView
				{
					focus_id:            0,
					is_main:             true,
					label:               None,
					widget_rects:        &data.main.frame.widget_rects,
					extras:              &data.main.frame.accessible_extras,
					focused_idx:         data.main.focused_idx,
					pressed_idx:         data.main.gesture.pressed_idx,
					pending_text_values: &data.main.pending_text_values,
					cursor_state:        &data.main.frame.cursor_state,
					width:               main_w,
					height:              main_h,
					offset_x:            0.0,
					offset_y:            0.0,
				} );
				for ( fid, id, ox, oy ) in &overlay_meta
				{
					let Some( ss ) = data.overlays.get( id ) else { continue };
					surfaces.push( crate::a11y::tree::SurfaceView
					{
						focus_id:            *fid,
						is_main:             false,
						label:               None,
						widget_rects:        &ss.frame.widget_rects,
						extras:              &ss.frame.accessible_extras,
						focused_idx:         ss.focused_idx,
						pressed_idx:         ss.gesture.pressed_idx,
						pending_text_values: &ss.pending_text_values,
						cursor_state:        &ss.frame.cursor_state,
						width:               ss.width as f32,
						height:              ss.height as f32,
						offset_x:            *ox,
						offset_y:            *oy,
					} );
				}
				let app_name = "ltk-app";
				a.update( || crate::a11y::tree::build_tree( &surfaces, kb_focus_id, app_name ) );
			}
			data.a11y = a11y_taken;
		}

		// Reconcile + reposition input-transparent subsurfaces every
		// iteration, decoupled from the main redraw cadence: a finger drag
		// emits motion events faster than frame callbacks clear
		// `frame_pending`, so gating this on a main draw would pin the
		// subsurface between frames. A position-only change is just
		// set_position + a bare parent commit; no-ops when nothing moved.
		super::subsurface::reconcile_subsurfaces( &mut data );

		// Drain inbound AT-SPI2 actions (Orca pressing a button,
		// switch-control focusing a node, etc.) and translate each
		// into a synthetic press / focus on the matching widget.
		// Actions for widgets that disappeared between dispatch and
		// drain are silently dropped — the action loop is best-effort.
		let a11y_requests: Vec<accesskit::ActionRequest> =
			if let Some( ref mut a ) = data.a11y { a.action_rx.try_iter().collect() } else { Vec::new() };
		if !a11y_requests.is_empty()
		{
			let qh = data.qh.clone();
			let overlay_keys: Vec<crate::app::OverlayId> = data.overlays.keys().copied().collect();
			for req in a11y_requests
			{
				let Some( r ) = crate::a11y::tree::parse_id( req.target ) else { continue };
				if r.kind != 0 { continue; }
				let idx = r.idx as usize;
				let focus_target = if r.focus == 0
				{
					SurfaceFocus::Main
				}
				else
				{
					let oi = ( r.focus as usize ).saturating_sub( 1 );
					let Some( id ) = overlay_keys.get( oi ).copied() else { continue };
					SurfaceFocus::Overlay( id )
				};
				let widget = match focus_target
				{
					SurfaceFocus::Main          => data.main.frame.widget_rects.iter().find( |w| w.flat_idx == idx ).cloned(),
					SurfaceFocus::Overlay( id ) => data.overlays.get( &id ).and_then( |ss| ss.frame.widget_rects.iter().find( |w| w.flat_idx == idx ).cloned() ),
				};
				match req.action
				{
					accesskit::Action::Click =>
					{
						if let Some( msg ) = widget.as_ref().and_then( |w| w.handlers.press_msg() )
						{
							data.pending_msgs.push( msg );
						}
					}
					accesskit::Action::Focus =>
					{
						data.set_focus( focus_target, Some( idx ), &qh );
					}
					accesskit::Action::SetValue =>
					{
						if let Some( w ) = widget
						{
							match ( &w.handlers, req.data )
							{
								( crate::widget::WidgetHandlers::Slider { .. }, Some( accesskit::ActionData::NumericValue( v ) ) ) =>
								{
									if let Some( msg ) = w.handlers.slider_change_msg( v.clamp( 0.0, 1.0 ) as f32 )
									{
										data.pending_msgs.push( msg );
									}
								}
								( crate::widget::WidgetHandlers::TextEdit { .. }, Some( accesskit::ActionData::Value( s ) ) ) =>
								{
									if let Some( msg ) = w.handlers.text_change_msg( &s )
									{
										data.pending_msgs.push( msg );
									}
								}
								_ => {}
							}
						}
					}
					accesskit::Action::Increment | accesskit::Action::Decrement =>
					{
						if let Some( w ) = widget
						{
							if let crate::widget::WidgetHandlers::Slider { value, .. } = &w.handlers
							{
								let step = 0.05f32;
								let delta = if matches!( req.action, accesskit::Action::Increment ) { step } else { -step };
								let v = ( *value + delta ).clamp( 0.0, 1.0 );
								if let Some( msg ) = w.handlers.slider_change_msg( v )
								{
									data.pending_msgs.push( msg );
								}
							}
						}
					}
					accesskit::Action::ScrollUp | accesskit::Action::ScrollDown
						| accesskit::Action::ScrollLeft | accesskit::Action::ScrollRight =>
					{
						const STEP: f32 = 80.0;
						let ( dx, dy ) = match req.action
						{
							accesskit::Action::ScrollLeft  => ( -STEP, 0.0 ),
							accesskit::Action::ScrollRight => (  STEP, 0.0 ),
							accesskit::Action::ScrollUp    => ( 0.0, -STEP ),
							accesskit::Action::ScrollDown  => ( 0.0,  STEP ),
							_ => ( 0.0, 0.0 ),
						};
						let ss = match focus_target
						{
							SurfaceFocus::Main          => Some( &mut data.main ),
							SurfaceFocus::Overlay( id ) => data.overlays.get_mut( &id ),
						};
						if let Some( ss ) = ss
						{
							if let Some( ( _, _, _ ) ) = ss.frame.scroll_rects.last().copied()
							{
								let scroll_idx = ss.frame.scroll_rects.last().unwrap().1;
								let entry = ss.frame.scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
								entry.0 = ( entry.0 + dx ).max( 0.0 );
								entry.1 = ( entry.1 + dy ).max( 0.0 );
								ss.request_redraw();
							}
						}
					}
					accesskit::Action::ScrollIntoView =>
					{
						let Some( w ) = widget else { continue };
						let ss = match focus_target
						{
							SurfaceFocus::Main          => Some( &mut data.main ),
							SurfaceFocus::Overlay( id ) => data.overlays.get_mut( &id ),
						};
						if let Some( ss ) = ss
						{
							let target = w.rect;
							let probe = crate::types::Point { x: target.x + 1.0, y: target.y + 1.0 };
							let container = ss.frame.scroll_rects.iter().rev()
								.find( |( r, _, _ )| r.contains( probe ) )
								.copied();
							if let Some( ( r, idx, _ ) ) = container
							{
								let entry = ss.frame.scroll_offsets.entry( idx ).or_insert( ( 0.0, 0.0 ) );
								if target.y < r.y           { entry.1 -= r.y - target.y; }
								if target.y + target.height > r.y + r.height
								{
									entry.1 += ( target.y + target.height ) - ( r.y + r.height );
								}
								if target.x < r.x           { entry.0 -= r.x - target.x; }
								if target.x + target.width  > r.x + r.width
								{
									entry.0 += ( target.x + target.width ) - ( r.x + r.width );
								}
								entry.0 = entry.0.max( 0.0 );
								entry.1 = entry.1.max( 0.0 );
								ss.request_redraw();
							}
						}
					}
					_ => {}
				}
			}
		}

		// Focus the widget with the requested WidgetId if the app requests it.
		// Walks main + every overlay because the targeted widget can live
		// on any of them (a search field on a launcher overlay, a text
		// edit on a dialog modal, …).
		let focus_id = data.app.take_focus_request().or_else( || data.focus_retry.take() );
		if let Some( id ) = focus_id
		{
			let mut hit: Option<( SurfaceFocus, usize )> = data.main.frame.widget_rects.iter()
				.find( |w| w.id == Some( id ) )
				.map( |w| ( SurfaceFocus::Main, w.flat_idx ) );
			if hit.is_none()
			{
				for ( ov_id, surf ) in &data.overlays
				{
					if let Some( w ) = surf.frame.widget_rects.iter().find( |w| w.id == Some( id ) )
					{
						hit = Some( ( SurfaceFocus::Overlay( *ov_id ), w.flat_idx ) );
						break;
					}
				}
			}
			if let Some( ( focus, flat_idx ) ) = hit
			{
				let qh = data.qh.clone();
				data.set_focus( focus, Some( flat_idx ), &qh );
				match focus
				{
					SurfaceFocus::Main         => data.main.request_redraw(),
					SurfaceFocus::Overlay( id ) =>
					{
						if let Some( s ) = data.overlays.get_mut( &id )
						{
							s.request_redraw();
						}
					}
				}
			} else {
				data.focus_retry  = Some( id );
				data.view_dirty   = true;
				data.main.request_redraw();
			}
		}
	}

	Ok( () )
}
