Skip to main content

lib/bb/tui/state.ex

defmodule BB.TUI.State do
  @moduledoc """
  State struct and pure update functions for the BB TUI dashboard.

  All state transitions are pure functions — no side effects, no process
  communication. The `BB.TUI.App` module handles IO and delegates here
  for state changes.

  High-rate-stream controls live in the `BB.TUI.State.Throttle` substruct
  (`throttle.debounce_ms`/`throttle.last_seen` back `append_event/3`'s log
  debouncing; `throttle.render_pending?`/`throttle.flush_ms` drive
  `BB.TUI.App`'s coalesced sensor re-render). See `BB.TUI.App` for the flow.

  ## Examples

      iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_panel: :safety, show_help?: false}}
      iex> state.ui.active_panel
      :safety
  """

  alias BB.TUI.State.Commands
  alias BB.TUI.State.Events
  alias BB.TUI.State.Joints
  alias BB.TUI.State.Parameters
  alias BB.TUI.State.Power
  alias BB.TUI.State.Safety
  alias BB.TUI.State.Throttle
  alias BB.TUI.State.UI
  alias BB.TUI.State.Viz
  alias BB.TUI.Viz.RobotScene
  alias ExRatatui.ThreeD.Camera

  @max_events 100

  defstruct [
    :robot,
    :robot_struct,
    node: nil,
    commands: %Commands{},
    parameters: %Parameters{},
    ui: %UI{},
    events: %Events{},
    joints: %Joints{},
    safety: %Safety{},
    power: %Power{},
    renderers: %{},
    observed: %{},
    throttle: %Throttle{},
    viz: %Viz{}
  ]

  @type t :: %__MODULE__{
          robot: module(),
          robot_struct: term(),
          node: node() | nil,
          commands: Commands.t(),
          parameters: Parameters.t(),
          ui: UI.t(),
          events: Events.t(),
          joints: Joints.t(),
          safety: Safety.t(),
          power: Power.t(),
          renderers: %{optional([atom()]) => module()},
          observed: %{optional(term()) => %{display: map(), meta: map()}},
          throttle: Throttle.t(),
          viz: Viz.t()
        }

  @panels [:safety, :commands, :joints, :events, :parameters]

  @doc """
  Returns the ordered list of panel names for tab cycling.

  ## Examples

      iex> BB.TUI.State.panels()
      [:safety, :commands, :joints, :events, :parameters]
  """
  @spec panels() :: [atom()]
  def panels, do: @panels

  @tabs [:control, :visualization]

  @doc """
  Returns the ordered list of top-level tabs.

  ## Examples

      iex> BB.TUI.State.tabs()
      [:control, :visualization]
  """
  @spec tabs() :: [atom()]
  def tabs, do: @tabs

  @doc """
  Switches to the next top-level tab, wrapping around.

  ## Examples

      iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_tab: :control}}
      iex> BB.TUI.State.next_tab(state).ui.active_tab
      :visualization

      iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_tab: :visualization}}
      iex> BB.TUI.State.next_tab(state).ui.active_tab
      :control
  """
  @spec next_tab(t()) :: t()
  def next_tab(%__MODULE__{ui: %{active_tab: current}} = state) do
    idx = Enum.find_index(@tabs, &(&1 == current)) || 0
    next = Enum.at(@tabs, rem(idx + 1, length(@tabs)))
    %{state | ui: %{state.ui | active_tab: next}}
  end

  @doc """
  Switches to the previous top-level tab, wrapping around.

  ## Examples

      iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_tab: :control}}
      iex> BB.TUI.State.prev_tab(state).ui.active_tab
      :visualization
  """
  @spec prev_tab(t()) :: t()
  def prev_tab(%__MODULE__{ui: %{active_tab: current}} = state) do
    count = length(@tabs)
    idx = Enum.find_index(@tabs, &(&1 == current)) || 0
    prev = Enum.at(@tabs, rem(idx - 1 + count, count))
    %{state | ui: %{state.ui | active_tab: prev}}
  end

  @doc """
  Returns the visualization-tab camera, defaulting when unset.
  """
  @spec viz_camera(t()) :: Camera.t()
  def viz_camera(%__MODULE__{viz: %{camera: nil}}), do: RobotScene.default_camera()
  def viz_camera(%__MODULE__{viz: %{camera: camera}}), do: camera

  @doc """
  Orbits the visualization camera by `yaw`/`pitch` deltas (radians).
  """
  @spec orbit_camera(t(), number(), number()) :: t()
  def orbit_camera(%__MODULE__{} = state, yaw, pitch) do
    %{state | viz: %{state.viz | camera: Camera.orbit(viz_camera(state), yaw, pitch)}}
  end

  @doc """
  Zooms the visualization camera; a positive `delta` moves farther from the robot.
  """
  @spec zoom_camera(t(), number()) :: t()
  def zoom_camera(%__MODULE__{} = state, delta) do
    %{state | viz: %{state.viz | camera: Camera.zoom(viz_camera(state), delta)}}
  end

  @doc """
  Resets the visualization camera to the default framing.
  """
  @spec reset_camera(t()) :: t()
  def reset_camera(%__MODULE__{} = state) do
    %{state | viz: %{state.viz | camera: RobotScene.default_camera()}}
  end

  @render_modes [:auto, :kitty, :sixel, :iterm2, :half_block, :braille, :ascii]

  @doc """
  Returns the ordered list of `Viewport3D` render modes.

  ## Examples

      iex> BB.TUI.State.render_modes()
      [:auto, :kitty, :sixel, :iterm2, :half_block, :braille, :ascii]
  """
  @spec render_modes() :: [atom()]
  def render_modes, do: @render_modes

  @doc """
  Returns the visualization render mode.
  """
  @spec viz_render_mode(t()) :: atom()
  def viz_render_mode(%__MODULE__{viz: %{render_mode: mode}}), do: mode

  @doc """
  Cycles the visualization render mode to the next one in order, wrapping around.

  ## Examples

      iex> state = %BB.TUI.State{viz: %BB.TUI.State.Viz{render_mode: :auto}}
      iex> BB.TUI.State.cycle_render_mode(state).viz.render_mode
      :kitty

      iex> state = %BB.TUI.State{viz: %BB.TUI.State.Viz{render_mode: :ascii}}
      iex> BB.TUI.State.cycle_render_mode(state).viz.render_mode
      :auto
  """
  @spec cycle_render_mode(t()) :: t()
  def cycle_render_mode(%__MODULE__{viz: %{render_mode: current}} = state) do
    idx = Enum.find_index(@render_modes, &(&1 == current)) || 0
    next = Enum.at(@render_modes, rem(idx + 1, length(@render_modes)))
    %{state | viz: %{state.viz | render_mode: next}}
  end

  @doc """
  Cycles the active panel to the next one in order.

  When `active_panel` is unknown (e.g. set out-of-band to a stale
  value), resets to the first panel.

  ## Examples

      iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_panel: :safety}}
      iex> BB.TUI.State.cycle_panel(state).ui.active_panel
      :commands

      iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_panel: :parameters}}
      iex> BB.TUI.State.cycle_panel(state).ui.active_panel
      :safety

      iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_panel: :unknown}}
      iex> BB.TUI.State.cycle_panel(state).ui.active_panel
      :safety
  """
  @spec cycle_panel(t()) :: t()
  def cycle_panel(%__MODULE__{ui: %{active_panel: current}} = state) when current in @panels do
    idx = Enum.find_index(@panels, &(&1 == current))
    next = Enum.at(@panels, rem(idx + 1, length(@panels)))
    %{state | ui: %{state.ui | active_panel: next}}
  end

  def cycle_panel(%__MODULE__{} = state) do
    %{state | ui: %{state.ui | active_panel: hd(@panels)}}
  end

  @doc """
  Cycles the active panel to the previous one in order (Shift+Tab).

  When `active_panel` is unknown, resets to the last panel so a stale
  state still lands somewhere navigable.

  ## Examples

      iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_panel: :commands}}
      iex> BB.TUI.State.cycle_panel_back(state).ui.active_panel
      :safety

      iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_panel: :safety}}
      iex> BB.TUI.State.cycle_panel_back(state).ui.active_panel
      :parameters

      iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_panel: :unknown}}
      iex> BB.TUI.State.cycle_panel_back(state).ui.active_panel
      :parameters
  """
  @spec cycle_panel_back(t()) :: t()
  def cycle_panel_back(%__MODULE__{ui: %{active_panel: current}} = state)
      when current in @panels do
    count = length(@panels)
    idx = Enum.find_index(@panels, &(&1 == current))
    prev = Enum.at(@panels, rem(idx - 1 + count, count))
    %{state | ui: %{state.ui | active_panel: prev}}
  end

  def cycle_panel_back(%__MODULE__{} = state) do
    %{state | ui: %{state.ui | active_panel: List.last(@panels)}}
  end

  @doc """
  Returns the 1-based number of a panel, suitable for number-key jump
  hints in panel titles and help text. Returns `nil` for unknown
  panels.

  ## Examples

      iex> BB.TUI.State.panel_number(:safety)
      1

      iex> BB.TUI.State.panel_number(:parameters)
      5

      iex> BB.TUI.State.panel_number(:unknown)
      nil
  """
  @spec panel_number(atom()) :: pos_integer() | nil
  def panel_number(panel) when panel in @panels do
    Enum.find_index(@panels, &(&1 == panel)) + 1
  end

  def panel_number(_), do: nil

  @doc """
  Returns the panel atom at a 1-based index, or `nil` when the index is
  out of range. Mirror of `panel_number/1`, used by the number-key
  jump handler.

  ## Examples

      iex> BB.TUI.State.panel_at(1)
      :safety

      iex> BB.TUI.State.panel_at(5)
      :parameters

      iex> BB.TUI.State.panel_at(9)
      nil
  """
  @spec panel_at(pos_integer()) :: atom() | nil
  def panel_at(n) when is_integer(n) and n >= 1 and n <= length(@panels) do
    Enum.at(@panels, n - 1)
  end

  def panel_at(_), do: nil

  @doc """
  Jumps directly to the named panel, leaving everything else
  unchanged. A no-op when the target isn't a known panel — so a
  stray key never silently parks the dashboard in an unreachable
  state.

  ## Examples

      iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_panel: :safety}}
      iex> BB.TUI.State.jump_to_panel(state, :events).ui.active_panel
      :events

      iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{active_panel: :safety}}
      iex> BB.TUI.State.jump_to_panel(state, :unknown).ui.active_panel
      :safety
  """
  @spec jump_to_panel(t(), atom()) :: t()
  def jump_to_panel(%__MODULE__{} = state, panel) when panel in @panels do
    %{state | ui: %{state.ui | active_panel: panel}}
  end

  def jump_to_panel(%__MODULE__{} = state, _panel), do: state

  @doc """
  Toggles the help overlay.

  ## Examples

      iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{show_help?: false}}
      iex> BB.TUI.State.toggle_help(state).ui.show_help?
      true

      iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{show_help?: true}}
      iex> BB.TUI.State.toggle_help(state).ui.show_help?
      false
  """
  @spec toggle_help(t()) :: t()
  def toggle_help(%__MODULE__{ui: ui} = state) do
    %{state | ui: %{ui | show_help?: !ui.show_help?, help_scroll_offset: 0}}
  end

  @doc """
  Scrolls the help popup down by one line.

  ## Examples

      iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{show_help?: true, help_scroll_offset: 0}}
      iex> BB.TUI.State.scroll_help_down(state).ui.help_scroll_offset
      1
  """
  @spec scroll_help_down(t()) :: t()
  def scroll_help_down(%__MODULE__{ui: %{help_scroll_offset: offset} = ui} = state) do
    %{state | ui: %{ui | help_scroll_offset: offset + 1}}
  end

  @doc """
  Scrolls the help popup up by one line.

  ## Examples

      iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{show_help?: true, help_scroll_offset: 0}}
      iex> BB.TUI.State.scroll_help_up(state).ui.help_scroll_offset
      0

      iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{show_help?: true, help_scroll_offset: 5}}
      iex> BB.TUI.State.scroll_help_up(state).ui.help_scroll_offset
      4
  """
  @spec scroll_help_up(t()) :: t()
  def scroll_help_up(%__MODULE__{ui: %{help_scroll_offset: offset} = ui} = state) do
    %{state | ui: %{ui | help_scroll_offset: max(offset - 1, 0)}}
  end

  @doc """
  Shows the force disarm confirmation popup.

  ## Examples

      iex> BB.TUI.State.show_force_disarm(%BB.TUI.State{}).safety.confirm_force_disarm?
      true
  """
  @spec show_force_disarm(t()) :: t()
  def show_force_disarm(%__MODULE__{} = state) do
    put_in(state.safety.confirm_force_disarm?, true)
  end

  @doc """
  Dismisses the force disarm confirmation popup.

  ## Examples

      iex> state = %BB.TUI.State{safety: %BB.TUI.State.Safety{confirm_force_disarm?: true}}
      iex> BB.TUI.State.dismiss_force_disarm(state).safety.confirm_force_disarm?
      false
  """
  @spec dismiss_force_disarm(t()) :: t()
  def dismiss_force_disarm(%__MODULE__{} = state) do
    put_in(state.safety.confirm_force_disarm?, false)
  end

  @doc """
  Updates safety and runtime state from a state machine message.

  ## Examples

      iex> state = BB.TUI.State.update_safety(%BB.TUI.State{}, :armed, :idle)
      iex> {state.safety.state, state.safety.runtime}
      {:armed, :idle}
  """
  @spec update_safety(t(), atom(), atom()) :: t()
  def update_safety(%__MODULE__{} = state, safety_state, runtime_state) do
    %{state | safety: %{state.safety | state: safety_state, runtime: runtime_state}}
  end

  @doc """
  Updates joint positions from a sensor message.

  Only updates joints that exist in the current state; unknown joint names
  are silently ignored.

  ## Examples

      iex> entries = %{shoulder: %{joint: %{}, position: 0.0}}
      iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: entries}}
      iex> BB.TUI.State.update_positions(state, %{shoulder: 42.0}).joints.entries.shoulder.position
      42.0
  """
  @spec update_positions(t(), %{atom() => float()}) :: t()
  def update_positions(%__MODULE__{joints: %{entries: entries}} = state, new_positions) do
    updated =
      Map.new(entries, fn {name, joint_data} ->
        case Map.fetch(new_positions, name) do
          {:ok, position} -> {name, %{joint_data | position: position}}
          :error -> {name, joint_data}
        end
      end)

    %{state | joints: %{state.joints | entries: updated}}
  end

  @doc """
  Records the latest battery or power telemetry from a sensor payload.

  Recognizes `BB.Message.Sensor.BatteryState` and `BB.Message.Sensor.PowerState`
  payloads and stashes the freshest of each in `state.power`; any other payload
  passes through untouched. `BB.TUI.App` calls this for every `[:sensor | _]`
  message, and the status bar renders the result.

  ## Examples

      iex> battery = %BB.Message.Sensor.BatteryState{voltage: 12.0, percentage: 0.8}
      iex> BB.TUI.State.update_power(%BB.TUI.State{}, battery).power.battery.percentage
      0.8

      iex> reading = %BB.Message.Sensor.PowerState{voltage: 11.5, current: 2.0}
      iex> BB.TUI.State.update_power(%BB.TUI.State{}, reading).power.power.voltage
      11.5

      iex> BB.TUI.State.update_power(%BB.TUI.State{}, %{names: [:a], positions: [0.0]}).power
      %BB.TUI.State.Power{}
  """
  @spec update_power(t(), term()) :: t()
  def update_power(%__MODULE__{power: power} = state, %BB.Message.Sensor.BatteryState{} = battery) do
    %{state | power: %{power | battery: battery}}
  end

  def update_power(%__MODULE__{power: power} = state, %BB.Message.Sensor.PowerState{} = reading) do
    %{state | power: %{power | power: reading}}
  end

  def update_power(%__MODULE__{} = state, _payload), do: state

  # ── Consumer-renderer seam ──────────────────────────────────

  @doc """
  Returns the renderer module registered for `path`, or `nil`.

  Registered renderers are keyed by a path *prefix*; a message's `path` matches
  a prefix when the prefix is a leading sublist of the path (`[:demo]` matches
  `[:demo]`, `[:demo, :imu]`, `[:demo, :imu, 1]`, …). When several prefixes
  match, the **longest** wins — a routing-table style most-specific match — so a
  consumer can register a broad `[:demo]` renderer and override a narrower
  `[:demo, :raw]` one. Returns `nil` when no prefix matches, so the caller falls
  through to the built-in handling.

  ## Examples

      iex> state = %BB.TUI.State{renderers: %{[:demo] => A, [:demo, :raw] => B}}
      iex> BB.TUI.State.renderer_for(state, [:demo, :imu])
      A

      iex> state = %BB.TUI.State{renderers: %{[:demo] => A, [:demo, :raw] => B}}
      iex> BB.TUI.State.renderer_for(state, [:demo, :raw, 1])
      B

      iex> state = %BB.TUI.State{renderers: %{[:demo] => A}}
      iex> BB.TUI.State.renderer_for(state, [:other])
      nil

      iex> BB.TUI.State.renderer_for(%BB.TUI.State{}, [:demo])
      nil
  """
  @spec renderer_for(t(), [atom()]) :: module() | nil
  def renderer_for(%__MODULE__{renderers: renderers}, path) when map_size(renderers) > 0 do
    case matching_renderers(renderers, path) do
      [] -> nil
      matches -> matches |> Enum.max_by(fn {prefix, _mod} -> length(prefix) end) |> elem(1)
    end
  end

  def renderer_for(%__MODULE__{}, _path), do: nil

  defp matching_renderers(renderers, path) do
    Enum.filter(renderers, fn {prefix, _mod} -> List.starts_with?(path, prefix) end)
  end

  @doc """
  Records the latest observed entry for a renderer-supplied `slot_key`.

  The dashboard keeps only the freshest `%{display: ..., meta: ...}` per
  `slot_key` (a faster slot overwrites the previous reading rather than
  accumulating; the event log carries the history). The status bar reads
  `state.observed` to surface the freshest slot at a glance.

  `bb_tui` never inspects `display` or `meta` beyond the generic keys the status
  bar reads (`display.label`, `meta.seq`, `meta.freshness`) — both are supplied
  verbatim by the consumer's renderer.

  ## Examples

      iex> state = BB.TUI.State.put_observed(%BB.TUI.State{}, {:wheels, :imu},
      ...>   %{display: %{label: "imu"}, meta: %{seq: 1, freshness: :fresh}})
      iex> state.observed
      %{{:wheels, :imu} => %{display: %{label: "imu"}, meta: %{seq: 1, freshness: :fresh}}}
  """
  @spec put_observed(t(), term(), %{display: map(), meta: map()}) :: t()
  def put_observed(%__MODULE__{observed: observed} = state, slot_key, entry) do
    %{state | observed: Map.put(observed, slot_key, entry)}
  end

  @doc """
  Updates parameters from a parameter list.

  `BB.Parameter.list/2` returns `{path, metadata}` tuples where metadata
  is a map carrying `:value` plus schema-derived fields like `:type`,
  `:doc`, and `:default`. The plain value is mirrored into
  `state.parameters.list` so navigation code keeps working with simple
  `{path, value}` tuples, while the rest of the metadata is stashed in
  `state.parameters.metadata` keyed by path. Plain-value inputs (no
  metadata map) leave the metadata side-channel untouched for that path.

  ## Examples

      iex> next = BB.TUI.State.update_parameters(%BB.TUI.State{}, [{[:speed], %{value: 100, type: :integer, doc: "rpm"}}])
      iex> next.parameters.list
      [{[:speed], 100}]
      iex> next.parameters.metadata
      %{[:speed] => %{type: :integer, doc: "rpm", default: nil}}

      iex> BB.TUI.State.update_parameters(%BB.TUI.State{}, [{[:speed], 42}]).parameters.list
      [{[:speed], 42}]
  """
  @spec update_parameters(t(), [{list(), term()}]) :: t()
  def update_parameters(%__MODULE__{parameters: parameters} = state, new_parameters) do
    {params, metadata} =
      Enum.map_reduce(new_parameters, %{}, fn
        {path, %{value: value} = meta}, acc ->
          {{path, value}, Map.put(acc, path, extract_metadata(meta))}

        {path, value}, acc ->
          {{path, value}, acc}
      end)

    %{state | parameters: %{parameters | list: params, metadata: metadata}}
  end

  defp extract_metadata(meta) do
    %{
      type: Map.get(meta, :type),
      doc: Map.get(meta, :doc),
      default: Map.get(meta, :default)
    }
  end

  @doc """
  Appends an event to the event list, capping at #{@max_events}.

  Events are prepended (newest first) and the list is trimmed to
  #{@max_events} entries. When events are paused, the event is dropped.

  Under high-rate streams, a repeat of the same `{path, payload-type}` seen
  within `throttle.debounce_ms` (default 1s) is dropped so a fast sensor
  cannot flood the log; distinct paths or payload types always pass through.
  A debounce window of `0` disables this.
  """
  @spec append_event(t(), list(), term()) :: t()
  def append_event(%__MODULE__{events: %{paused?: true}} = state, _path, _message), do: state

  def append_event(
        %__MODULE__{events: %{list: list} = events, throttle: throttle} = state,
        path,
        message
      ) do
    key = event_debounce_key(path, message)
    now = System.monotonic_time(:millisecond)

    if event_debounced?(throttle.last_seen, key, now, throttle.debounce_ms) do
      state
    else
      event = {event_timestamp(message), path, message}

      %{
        state
        | events: %{events | list: Enum.take([event | list], @max_events)},
          throttle: %{throttle | last_seen: Map.put(throttle.last_seen, key, now)}
      }
    end
  end

  @doc false
  # Pure debounce predicate, split out so the time-dependent window logic can
  # be unit-tested with explicit timestamps. `append_event/3` calls it with
  # `System.monotonic_time(:millisecond)`.
  @spec event_debounced?(map(), {list(), term()}, integer(), non_neg_integer()) :: boolean()
  def event_debounced?(last_seen, key, now, window_ms) do
    case Map.get(last_seen, key) do
      nil -> false
      last -> now - last < window_ms
    end
  end

  @doc false
  # Debounce identity for an event: the publish path plus the payload's struct
  # module (or `:map` for a plain map, or the bare term otherwise). Keying on
  # type — not value — means a high-rate sensor emitting the same struct on the
  # same path collapses to one log row per window, while distinct paths/types
  # always pass through.
  @spec event_debounce_key(list(), term()) :: {list(), term()}
  def event_debounce_key(path, message), do: {path, payload_type(message)}

  defp payload_type(%{payload: payload}), do: payload_type(payload)
  defp payload_type(%_struct{} = value), do: value.__struct__
  defp payload_type(value) when is_map(value), do: :map
  defp payload_type(value), do: value

  # Prefer the wall_time carried on %BB.Message{} so timestamps in the
  # events panel reflect when the publisher fired, not when this process
  # observed the message. Plain-map payloads (e.g. ad-hoc `BB.publish/3`
  # without `BB.Message.new/2`) fall back to `DateTime.utc_now/0` and
  # therefore look fresh on every reconnect — use BB.Message.new/2 to
  # preserve causality across distribution.
  defp event_timestamp(%BB.Message{wall_time: wall_time}) when is_integer(wall_time) do
    DateTime.from_unix!(wall_time, :nanosecond)
  end

  defp event_timestamp(_message), do: DateTime.utc_now()

  @doc """
  Scrolls the event panel down (newer events).

  ## Examples

      iex> list = [{~U[2026-01-01 00:00:00Z], [:test], %{}}]
      iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{list: list, scroll_offset: 0}}
      iex> BB.TUI.State.scroll_down(state).events.scroll_offset
      0
  """
  @spec scroll_down(t()) :: t()
  def scroll_down(%__MODULE__{events: %{scroll_offset: offset, list: list} = events} = state) do
    max_offset = max(length(list) - 1, 0)
    %{state | events: %{events | scroll_offset: min(offset + 1, max_offset)}}
  end

  @doc """
  Scrolls the event panel up (older events).

  ## Examples

      iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{scroll_offset: 0}}
      iex> BB.TUI.State.scroll_up(state).events.scroll_offset
      0

      iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{scroll_offset: 5}}
      iex> BB.TUI.State.scroll_up(state).events.scroll_offset
      4
  """
  @spec scroll_up(t()) :: t()
  def scroll_up(%__MODULE__{events: %{scroll_offset: offset} = events} = state) do
    %{state | events: %{events | scroll_offset: max(offset - 1, 0)}}
  end

  @doc """
  Increments the throbber animation step.

  ## Examples

      iex> state = %BB.TUI.State{ui: %BB.TUI.State.UI{throbber_step: 3}}
      iex> BB.TUI.State.tick_throbber(state).ui.throbber_step
      4
  """
  @spec tick_throbber(t()) :: t()
  def tick_throbber(%__MODULE__{ui: %{throbber_step: step} = ui} = state) do
    %{state | ui: %{ui | throbber_step: step + 1}}
  end

  @doc """
  Flags that sensor-driven state changed and a coalesced re-render is due.

  The reducer returns `render?: false` for sensor messages and sets this
  flag; `BB.TUI.App`'s subscriptions callback then arms the one-shot
  `:sensor_flush` tick that performs the single batched render.

      iex> BB.TUI.State.mark_render_pending(%BB.TUI.State{}).throttle.render_pending?
      true
  """
  @spec mark_render_pending(t()) :: t()
  def mark_render_pending(%__MODULE__{} = state), do: put_in(state.throttle.render_pending?, true)

  @doc """
  Clears the pending-render flag once the coalesced frame has been rendered.

      iex> state = %BB.TUI.State{throttle: %BB.TUI.State.Throttle{render_pending?: true}}
      iex> BB.TUI.State.clear_render_pending(state).throttle.render_pending?
      false
  """
  @spec clear_render_pending(t()) :: t()
  def clear_render_pending(%__MODULE__{} = state),
    do: put_in(state.throttle.render_pending?, false)

  @doc """
  Toggles the event stream pause state.

  ## Examples

      iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{paused?: false}}
      iex> BB.TUI.State.toggle_events_pause(state).events.paused?
      true

      iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{paused?: true}}
      iex> BB.TUI.State.toggle_events_pause(state).events.paused?
      false
  """
  @spec toggle_events_pause(t()) :: t()
  def toggle_events_pause(%__MODULE__{events: events} = state) do
    %{state | events: %{events | paused?: !events.paused?}}
  end

  @doc """
  Clears all events and resets scroll offset.

  ## Examples

      iex> list = [{~U[2026-01-01 00:00:00Z], [:test], %{}}]
      iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{list: list, scroll_offset: 5}}
      iex> new_state = BB.TUI.State.clear_events(state)
      iex> {new_state.events.list, new_state.events.scroll_offset}
      {[], 0}
  """
  @spec clear_events(t()) :: t()
  def clear_events(%__MODULE__{events: events} = state) do
    %{
      state
      | events: %{events | list: [], scroll_offset: 0},
        throttle: %{state.throttle | last_seen: %{}}
    }
  end

  @doc """
  Toggles the event detail popup for the currently selected event.

  ## Examples

      iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{show_detail?: false}}
      iex> BB.TUI.State.toggle_event_detail(state).events.show_detail?
      true
  """
  @spec toggle_event_detail(t()) :: t()
  def toggle_event_detail(%__MODULE__{events: events} = state) do
    %{state | events: %{events | show_detail?: !events.show_detail?}}
  end

  @doc """
  Dismisses the event detail popup.

  ## Examples

      iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{show_detail?: true}}
      iex> BB.TUI.State.dismiss_event_detail(state).events.show_detail?
      false
  """
  @spec dismiss_event_detail(t()) :: t()
  def dismiss_event_detail(%__MODULE__{events: events} = state) do
    %{state | events: %{events | show_detail?: false}}
  end

  @doc """
  Returns the currently selected event, or nil if no events.

  ## Examples

      iex> list = [{~U[2026-01-01 00:00:00Z], [:test], %{payload: :ok}}]
      iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{list: list, scroll_offset: 0}}
      iex> {_, [:test], _} = BB.TUI.State.selected_event(state)

      iex> state = %BB.TUI.State{events: %BB.TUI.State.Events{list: [], scroll_offset: 0}}
      iex> BB.TUI.State.selected_event(state)
      nil
  """
  @spec selected_event(t()) :: {DateTime.t(), list(), term()} | nil
  def selected_event(%__MODULE__{events: %{list: list, scroll_offset: offset}}) do
    Enum.at(list, offset)
  end

  @doc """
  Selects the next command in the list.

  ## Examples

      iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{selected: 0, available: [%{name: :a}, %{name: :b}]}}
      iex> BB.TUI.State.select_next_command(state).commands.selected
      1

      iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{selected: 1, available: [%{name: :a}, %{name: :b}]}}
      iex> BB.TUI.State.select_next_command(state).commands.selected
      1
  """
  @spec select_next_command(t()) :: t()
  def select_next_command(
        %__MODULE__{commands: %{selected: idx, available: cmds} = commands} = state
      ) do
    max_idx = max(length(cmds) - 1, 0)
    %{state | commands: %{commands | selected: min(idx + 1, max_idx)}}
  end

  @doc """
  Selects the previous command in the list.

  ## Examples

      iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{selected: 1}}
      iex> BB.TUI.State.select_prev_command(state).commands.selected
      0

      iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{selected: 0}}
      iex> BB.TUI.State.select_prev_command(state).commands.selected
      0
  """
  @spec select_prev_command(t()) :: t()
  def select_prev_command(%__MODULE__{commands: %{selected: idx} = commands} = state) do
    %{state | commands: %{commands | selected: max(idx - 1, 0)}}
  end

  @doc """
  Returns the currently selected command map, or `nil`.

  ## Examples

      iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{available: [%{name: :a}, %{name: :b}], selected: 1}}
      iex> BB.TUI.State.selected_command(state)
      %{name: :b}

      iex> BB.TUI.State.selected_command(%BB.TUI.State{commands: %BB.TUI.State.Commands{available: []}})
      nil
  """
  @spec selected_command(t()) :: map() | nil
  def selected_command(%__MODULE__{commands: %{available: cmds, selected: idx}}) do
    Enum.at(cmds, idx)
  end

  @doc """
  Enters argument-edit mode for the selected command, if it has arguments.

  No-op when the selected command has no arguments — argument-less
  commands execute directly on Enter.

  ## Examples

      iex> cmd = %{name: :move, arguments: [%{name: :angle, type: "float", default: 0.0}]}
      iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{available: [cmd], selected: 0}}
      iex> BB.TUI.State.enter_command_edit_mode(state).commands.edit_mode?
      true

      iex> cmd = %{name: :home, arguments: []}
      iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{available: [cmd], selected: 0}}
      iex> BB.TUI.State.enter_command_edit_mode(state).commands.edit_mode?
      false
  """
  @spec enter_command_edit_mode(t()) :: t()
  def enter_command_edit_mode(%__MODULE__{commands: commands} = state) do
    case selected_command(state) do
      %{arguments: [_ | _]} -> %{state | commands: %{commands | edit_mode?: true, focused_arg: 0}}
      _ -> state
    end
  end

  @doc """
  Exits argument-edit mode. Keeps `commands.form_values` intact.
  """
  @spec exit_command_edit_mode(t()) :: t()
  def exit_command_edit_mode(%__MODULE__{commands: commands} = state) do
    %{state | commands: %{commands | edit_mode?: false}}
  end

  @doc """
  Focuses the next argument field, wrapping at the end.
  """
  @spec focus_next_arg(t()) :: t()
  def focus_next_arg(%__MODULE__{commands: %{focused_arg: idx} = commands} = state) do
    case selected_command(state) do
      %{arguments: args} when args != [] ->
        %{state | commands: %{commands | focused_arg: rem(idx + 1, length(args))}}

      _ ->
        state
    end
  end

  @doc """
  Focuses the previous argument field, wrapping at the start.
  """
  @spec focus_prev_arg(t()) :: t()
  def focus_prev_arg(%__MODULE__{commands: %{focused_arg: idx} = commands} = state) do
    case selected_command(state) do
      %{arguments: args} when args != [] ->
        count = length(args)
        %{state | commands: %{commands | focused_arg: rem(idx - 1 + count, count)}}

      _ ->
        state
    end
  end

  @doc """
  Returns the current string value for an argument, falling back to the
  argument's `:default` (rendered as a string).
  """
  @spec arg_value(t(), atom(), map()) :: String.t()
  def arg_value(%__MODULE__{commands: %{form_values: form}}, cmd_name, %{
        name: name,
        default: default
      }) do
    case form |> Map.get(cmd_name, %{}) |> Map.fetch(name) do
      {:ok, value} -> value
      :error -> default_to_string(default)
    end
  end

  @doc """
  Appends a character to the focused argument's value.
  """
  @spec append_to_focused_arg(t(), String.t()) :: t()
  def append_to_focused_arg(%__MODULE__{commands: %{edit_mode?: false}} = state, _char), do: state

  def append_to_focused_arg(%__MODULE__{} = state, char) do
    update_focused_arg(state, fn current -> current <> char end)
  end

  @doc """
  Deletes the last character from the focused argument's value.
  """
  @spec backspace_focused_arg(t()) :: t()
  def backspace_focused_arg(%__MODULE__{commands: %{edit_mode?: false}} = state), do: state

  def backspace_focused_arg(%__MODULE__{} = state) do
    update_focused_arg(state, fn
      "" -> ""
      str -> String.slice(str, 0, String.length(str) - 1)
    end)
  end

  @doc """
  Returns the currently-focused command argument map, or `nil` when the
  selected command has no arguments.

  ## Examples

      iex> cmd = %{name: :move, arguments: [%{name: :angle}, %{name: :side}]}
      iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{available: [cmd], selected: 0, focused_arg: 1}}
      iex> BB.TUI.State.focused_arg(state)
      %{name: :side}

      iex> BB.TUI.State.focused_arg(%BB.TUI.State{commands: %BB.TUI.State.Commands{available: []}})
      nil
  """
  @spec focused_arg(t()) :: map() | nil
  def focused_arg(%__MODULE__{commands: %{focused_arg: idx}} = state) do
    case selected_command(state) do
      %{arguments: [_ | _] = args} -> Enum.at(args, idx)
      _ -> nil
    end
  end

  @doc """
  Returns the enum-value list for the focused argument when the arg is
  enum-typed (`{:in, [...]}` in the underlying Spark schema), otherwise
  `nil`.

  ## Examples

      iex> cmd = %{name: :move, arguments: [%{name: :side, enum_values: [:left, :right]}]}
      iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{available: [cmd], selected: 0, focused_arg: 0}}
      iex> BB.TUI.State.focused_arg_enum_values(state)
      [:left, :right]

      iex> cmd = %{name: :move, arguments: [%{name: :angle, enum_values: nil}]}
      iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{available: [cmd], selected: 0, focused_arg: 0}}
      iex> BB.TUI.State.focused_arg_enum_values(state)
      nil
  """
  @spec focused_arg_enum_values(t()) :: [atom()] | nil
  def focused_arg_enum_values(%__MODULE__{} = state) do
    case focused_arg(state) do
      %{enum_values: [_ | _] = values} -> values
      _ -> nil
    end
  end

  @doc """
  Cycles the focused argument to the next (or previous) value in its
  enum list. A no-op when not in edit mode or when the focused arg
  isn't enum-typed.

  Stores the chosen value as the leading-colon atom literal (`":foo"`)
  so `parsed_args_for_selected/1` decodes it back to `:foo` when the
  command executes.
  """
  @spec cycle_focused_enum(t(), :next | :prev) :: t()
  def cycle_focused_enum(%__MODULE__{commands: %{edit_mode?: false}} = state, _direction),
    do: state

  def cycle_focused_enum(%__MODULE__{} = state, direction) do
    case focused_arg(state) do
      %{enum_values: [_ | _] = values} = arg ->
        cmd_name = selected_command(state).name
        current = parse_value(arg_value(state, cmd_name, arg))
        next_value = cycle_enum_value(values, current, direction)
        update_focused_arg(state, fn _ -> ":" <> Atom.to_string(next_value) end)

      _ ->
        state
    end
  end

  defp cycle_enum_value(values, current, direction) do
    count = length(values)
    idx = Enum.find_index(values, &(&1 == current)) || 0
    shift = if direction == :next, do: 1, else: -1
    Enum.at(values, rem(idx + shift + count, count))
  end

  defp update_focused_arg(
         %__MODULE__{commands: %{focused_arg: idx, form_values: form} = commands} = state,
         fun
       ) do
    case selected_command(state) do
      %{name: cmd_name, arguments: args} when args != [] ->
        arg = Enum.at(args, idx)
        current = arg_value(state, cmd_name, arg)
        per_command = form |> Map.get(cmd_name, %{}) |> Map.put(arg.name, fun.(current))
        %{state | commands: %{commands | form_values: Map.put(form, cmd_name, per_command)}}

      _ ->
        state
    end
  end

  defp default_to_string(nil), do: ""
  defp default_to_string(value) when is_binary(value), do: value
  defp default_to_string(value) when is_atom(value), do: ":" <> Atom.to_string(value)
  defp default_to_string(value), do: to_string(value)

  @doc """
  Returns the form values for the selected command, parsed by type.

  Mirrors `BB.LiveView.Components.Command`'s `parse_value/1`:
  `"true"`/`"false"` → boolean, `":foo"` → atom, numeric → number,
  else string.

  Falls back to `arg.default` for arguments the user has not touched.

  ## Examples

      iex> cmd = %{
      ...>   name: :move,
      ...>   arguments: [
      ...>     %{name: :angle, type: "float", default: 1.5},
      ...>     %{name: :side, type: "atom", default: :left}
      ...>   ]
      ...> }
      iex> state = %BB.TUI.State{
      ...>   commands: %BB.TUI.State.Commands{
      ...>     available: [cmd],
      ...>     selected: 0,
      ...>     form_values: %{move: %{angle: "2.5"}}
      ...>   }
      ...> }
      iex> BB.TUI.State.parsed_args_for_selected(state)
      %{angle: 2.5, side: :left}
  """
  @spec parsed_args_for_selected(t()) :: map()
  def parsed_args_for_selected(%__MODULE__{} = state) do
    case selected_command(state) do
      %{name: cmd_name, arguments: args} ->
        Map.new(args, fn arg ->
          {arg.name, parse_value(arg_value(state, cmd_name, arg))}
        end)

      _ ->
        %{}
    end
  end

  defp parse_value("true"), do: true
  defp parse_value("false"), do: false

  defp parse_value(":" <> rest = value) when byte_size(rest) > 0 do
    String.to_existing_atom(rest)
  rescue
    ArgumentError -> value
  end

  defp parse_value(value) when is_binary(value) do
    case Integer.parse(value) do
      {int, ""} ->
        int

      _ ->
        case Float.parse(value) do
          {float, ""} -> float
          _ -> value
        end
    end
  end

  @doc """
  Sets the command execution result.

  ## Examples

      iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{result: nil, executing: self()}}
      iex> new_state = BB.TUI.State.set_command_result(state, {:ok, :done})
      iex> {new_state.commands.result, new_state.commands.executing}
      {{:ok, :done}, nil}
  """
  @spec set_command_result(t(), {:ok, term()} | {:error, term()}) :: t()
  def set_command_result(%__MODULE__{commands: commands} = state, result) do
    %{state | commands: %{commands | result: result, executing: nil}}
  end

  @doc """
  Marks a command as currently executing.

  ## Examples

      iex> state = %BB.TUI.State{commands: %BB.TUI.State.Commands{executing: nil, result: {:ok, :old}}}
      iex> pid = self()
      iex> new_state = BB.TUI.State.start_command(state, pid)
      iex> {new_state.commands.executing, new_state.commands.result}
      {pid, nil}
  """
  @spec start_command(t(), term()) :: t()
  def start_command(%__MODULE__{commands: commands} = state, marker) do
    %{state | commands: %{commands | executing: marker, result: nil}}
  end

  # ── Joint control ──────────────────────────────────────────

  @doc """
  Returns sorted joint names, matching the render order of the joints panel.

  ## Examples

      iex> entries = %{elbow: %{joint: %{}, position: 0.0}, shoulder: %{joint: %{}, position: 0.0}}
      iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: entries}}
      iex> BB.TUI.State.sorted_joint_names(state)
      [:elbow, :shoulder]
  """
  @spec sorted_joint_names(t()) :: [atom()]
  def sorted_joint_names(%__MODULE__{joints: %{entries: entries}}) do
    entries |> Map.keys() |> Enum.sort()
  end

  @doc """
  Returns the name of the currently selected joint, or nil if no joints exist.

  ## Examples

      iex> entries = %{elbow: %{joint: %{}, position: 0.0}, shoulder: %{joint: %{}, position: 0.0}}
      iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: entries, selected: 1}}
      iex> BB.TUI.State.selected_joint_name(state)
      :shoulder

      iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: %{}, selected: 0}}
      iex> BB.TUI.State.selected_joint_name(state)
      nil
  """
  @spec selected_joint_name(t()) :: atom() | nil
  def selected_joint_name(%__MODULE__{} = state) do
    Enum.at(sorted_joint_names(state), state.joints.selected)
  end

  @doc """
  Selects the next joint in the sorted list.

  ## Examples

      iex> entries = %{a: %{joint: %{}, position: 0.0}, b: %{joint: %{}, position: 0.0}}
      iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: entries, selected: 0}}
      iex> BB.TUI.State.select_next_joint(state).joints.selected
      1

      iex> entries = %{a: %{joint: %{}, position: 0.0}, b: %{joint: %{}, position: 0.0}}
      iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: entries, selected: 1}}
      iex> BB.TUI.State.select_next_joint(state).joints.selected
      1
  """
  @spec select_next_joint(t()) :: t()
  def select_next_joint(%__MODULE__{joints: %{entries: entries, selected: idx}} = state) do
    max_idx = max(map_size(entries) - 1, 0)
    %{state | joints: %{state.joints | selected: min(idx + 1, max_idx)}}
  end

  @doc """
  Selects the previous joint in the sorted list.

  ## Examples

      iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: %{a: %{joint: %{}, position: 0.0}}, selected: 1}}
      iex> BB.TUI.State.select_prev_joint(state).joints.selected
      0

      iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: %{a: %{joint: %{}, position: 0.0}}, selected: 0}}
      iex> BB.TUI.State.select_prev_joint(state).joints.selected
      0
  """
  @spec select_prev_joint(t()) :: t()
  def select_prev_joint(%__MODULE__{joints: %{selected: idx}} = state) do
    %{state | joints: %{state.joints | selected: max(idx - 1, 0)}}
  end

  @doc """
  Updates the position of a specific joint in state.

  ## Examples

      iex> entries = %{shoulder: %{joint: %{}, position: 0.0, target: nil}}
      iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: entries}}
      iex> BB.TUI.State.set_joint_position(state, :shoulder, 1.5).joints.entries.shoulder.position
      1.5
  """
  @spec set_joint_position(t(), atom(), float()) :: t()
  def set_joint_position(%__MODULE__{joints: %{entries: entries}} = state, name, position) do
    case Map.fetch(entries, name) do
      {:ok, joint_data} ->
        %{
          state
          | joints: %{
              state.joints
              | entries: Map.put(entries, name, %{joint_data | position: position})
            }
        }

      :error ->
        state
    end
  end

  @doc """
  Records the last-commanded target position for a joint. The panel
  renders it as a secondary marker on the position bar so the operator
  can see what the joint is moving toward. Pass `nil` to clear the
  target (e.g. when the joint has reached it).

  ## Examples

      iex> entries = %{shoulder: %{joint: %{}, position: 0.0, target: nil}}
      iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: entries}}
      iex> BB.TUI.State.set_joint_target(state, :shoulder, 1.5).joints.entries.shoulder.target
      1.5

      iex> state = %BB.TUI.State{joints: %BB.TUI.State.Joints{entries: %{}}}
      iex> BB.TUI.State.set_joint_target(state, :missing, 1.5).joints.entries
      %{}
  """
  @spec set_joint_target(t(), atom(), float() | nil) :: t()
  def set_joint_target(%__MODULE__{joints: %{entries: entries}} = state, name, target) do
    case Map.fetch(entries, name) do
      {:ok, joint_data} ->
        %{
          state
          | joints: %{
              state.joints
              | entries: Map.put(entries, name, Map.put(joint_data, :target, target))
            }
        }

      :error ->
        state
    end
  end

  @doc """
  Computes the step size for a joint based on its limits.

  Returns `(upper - lower) / 100` for joints with limits, or a default
  step of `π/50` (~3.6°) for unlimited joints.

  ## Examples

      iex> BB.TUI.State.joint_step(%{limits: %{lower: -1.0, upper: 1.0}})
      0.02

      iex> BB.TUI.State.joint_step(%{type: :continuous})
      :math.pi() / 50
  """
  @spec joint_step(map()) :: float()
  def joint_step(joint) do
    case joint_limits(joint) do
      {lower, upper} when upper > lower -> (upper - lower) / 100
      _ -> :math.pi() / 50
    end
  end

  @doc """
  Clamps a position value within a joint's limits.

  Returns the position unchanged if the joint has no limits.

  ## Examples

      iex> BB.TUI.State.clamp_position(2.0, %{limits: %{lower: -1.0, upper: 1.0}})
      1.0

      iex> BB.TUI.State.clamp_position(-2.0, %{limits: %{lower: -1.0, upper: 1.0}})
      -1.0

      iex> BB.TUI.State.clamp_position(99.0, %{type: :continuous})
      99.0
  """
  @spec clamp_position(float(), map()) :: float()
  def clamp_position(pos, joint) do
    case joint_limits(joint) do
      {lower, upper} -> max(lower, min(upper, pos))
      _ -> pos
    end
  end

  # ── Parameter navigation ───────────────────────────────────

  @doc """
  Selects the next parameter in the sorted list.

  ## Examples

      iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{list: [{[:a], 1}, {[:b], 2}], selected: 0}}
      iex> BB.TUI.State.select_next_param(state).parameters.selected
      1

      iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{list: [{[:a], 1}, {[:b], 2}], selected: 1}}
      iex> BB.TUI.State.select_next_param(state).parameters.selected
      1
  """
  @spec select_next_param(t()) :: t()
  def select_next_param(%__MODULE__{parameters: %{selected: idx, list: list} = params} = state) do
    max_idx = max(length(list) - 1, 0)
    %{state | parameters: %{params | selected: min(idx + 1, max_idx)}}
  end

  @doc """
  Selects the previous parameter in the sorted list.

  ## Examples

      iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{list: [{[:a], 1}], selected: 1}}
      iex> BB.TUI.State.select_prev_param(state).parameters.selected
      0

      iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{list: [{[:a], 1}], selected: 0}}
      iex> BB.TUI.State.select_prev_param(state).parameters.selected
      0
  """
  @spec select_prev_param(t()) :: t()
  def select_prev_param(%__MODULE__{parameters: %{selected: idx} = params} = state) do
    %{state | parameters: %{params | selected: max(idx - 1, 0)}}
  end

  @doc """
  Returns the currently selected parameter as `{path, value}`, or nil.

  Parameters are sorted by path to match the render order.

  ## Examples

      iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{list: [{[:b], 2}, {[:a], 1}], selected: 0}}
      iex> BB.TUI.State.selected_param(state)
      {[:a], 1}

      iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{list: [], selected: 0}}
      iex> BB.TUI.State.selected_param(state)
      nil
  """
  @spec selected_param(t()) :: {list(), term()} | nil
  def selected_param(%__MODULE__{parameters: %{list: list, selected: idx}}) do
    list
    |> Enum.sort_by(fn {path, _} -> path end)
    |> Enum.at(idx)
  end

  @doc """
  Replaces the discovered parameter tabs and resets the selected tab.

  Always keeps `:local` at the head, so cycling never lands in a state
  where no local-parameter view is reachable.

  ## Examples

      iex> next = BB.TUI.State.set_parameter_tabs(%BB.TUI.State{}, [%{name: :mavlink}])
      iex> next.parameters.tabs
      [:local, {:bridge, :mavlink}]
      iex> next.parameters.tab_selected
      0
  """
  @spec set_parameter_tabs(t(), [map()]) :: t()
  def set_parameter_tabs(%__MODULE__{parameters: params} = state, bridges) do
    tabs = [:local | Enum.map(bridges, fn %{name: name} -> {:bridge, name} end)]
    %{state | parameters: %{params | tabs: tabs, tab_selected: 0, selected: 0}}
  end

  @doc """
  Returns the currently selected parameter tab.

  ## Examples

      iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{tabs: [:local, {:bridge, :mavlink}], tab_selected: 1}}
      iex> BB.TUI.State.selected_parameter_tab(state)
      {:bridge, :mavlink}

      iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{tabs: [:local], tab_selected: 0}}
      iex> BB.TUI.State.selected_parameter_tab(state)
      :local
  """
  @spec selected_parameter_tab(t()) :: :local | {:bridge, atom()}
  def selected_parameter_tab(%__MODULE__{parameters: %{tabs: tabs, tab_selected: idx}}) do
    Enum.at(tabs, idx, :local)
  end

  @doc """
  Cycles to the next parameter tab, wrapping back to `:local`.

  Resets `param_selected` so the new tab starts at the first row.

  ## Examples

      iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{tabs: [:local, {:bridge, :mavlink}], tab_selected: 0, selected: 3}}
      iex> next = BB.TUI.State.cycle_parameter_tab(state)
      iex> next.parameters.tab_selected
      1
      iex> next.parameters.selected
      0

      iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{tabs: [:local, {:bridge, :mavlink}], tab_selected: 1}}
      iex> BB.TUI.State.cycle_parameter_tab(state).parameters.tab_selected
      0

      iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{tabs: [:local], tab_selected: 0}}
      iex> BB.TUI.State.cycle_parameter_tab(state).parameters.tab_selected
      0
  """
  @spec cycle_parameter_tab(t()) :: t()
  def cycle_parameter_tab(%__MODULE__{parameters: %{tabs: tabs} = params} = state)
      when length(tabs) <= 1 do
    %{state | parameters: %{params | tab_selected: 0, selected: 0}}
  end

  def cycle_parameter_tab(%__MODULE__{parameters: params} = state) do
    next = rem(params.tab_selected + 1, length(params.tabs))
    %{state | parameters: %{params | tab_selected: next, selected: 0}}
  end

  @doc """
  Stores the latest remote-parameter snapshot for a bridge.

  ## Examples

      iex> next = BB.TUI.State.put_remote_parameters(%BB.TUI.State{}, :mavlink, [%{id: "PITCH_P", value: 0.1}])
      iex> next.parameters.remote
      %{mavlink: [%{id: "PITCH_P", value: 0.1}]}
  """
  @spec put_remote_parameters(t(), atom(), [map()] | {:error, term()}) :: t()
  def put_remote_parameters(
        %__MODULE__{parameters: %{remote: existing} = params} = state,
        bridge_name,
        payload
      ) do
    %{state | parameters: %{params | remote: Map.put(existing, bridge_name, payload)}}
  end

  @doc """
  Returns the sort key used when rendering a remote parameter row.

  Bridges typically use string ids (`"PITCH_P"`), but some (`BB.Bridge`
  implementations are free to use atoms) return atom ids. Both
  normalize to a binary so the panel and the navigation index agree on
  ordering.

  ## Examples

      iex> BB.TUI.State.remote_param_id(%{id: "PITCH_P"})
      "PITCH_P"

      iex> BB.TUI.State.remote_param_id(%{id: :gain})
      "gain"

      iex> BB.TUI.State.remote_param_id(%{})
      ""
  """
  @spec remote_param_id(map()) :: String.t()
  def remote_param_id(%{id: id}) when is_binary(id), do: id
  def remote_param_id(%{id: id}), do: to_string(id)
  def remote_param_id(_), do: ""

  @doc """
  Returns the currently-focused remote parameter for the selected
  bridge tab, or `nil` when the active tab is `:local`, the bridge has
  no fetched list yet, or the fetch errored.

  Sort order matches the panel's render (`Enum.sort_by(remote_param_id/1)`).

  ## Examples

      iex> remote = [%{id: "ROLL_P", value: 0.0}, %{id: "PITCH_P", value: 0.1}]
      iex> state = %BB.TUI.State{
      ...>   parameters: %BB.TUI.State.Parameters{
      ...>     tabs: [:local, {:bridge, :mavlink}],
      ...>     tab_selected: 1,
      ...>     remote: %{mavlink: remote},
      ...>     selected: 0
      ...>   }
      ...> }
      iex> BB.TUI.State.selected_remote_param(state)
      %{id: "PITCH_P", value: 0.1}

      iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{tabs: [:local], tab_selected: 0}}
      iex> BB.TUI.State.selected_remote_param(state)
      nil

      iex> state = %BB.TUI.State{
      ...>   parameters: %BB.TUI.State.Parameters{
      ...>     tabs: [:local, {:bridge, :mavlink}],
      ...>     tab_selected: 1,
      ...>     remote: %{mavlink: {:error, :nodedown}}
      ...>   }
      ...> }
      iex> BB.TUI.State.selected_remote_param(state)
      nil
  """
  @spec selected_remote_param(t()) :: map() | nil
  def selected_remote_param(%__MODULE__{parameters: params} = state) do
    case selected_parameter_tab(state) do
      {:bridge, name} ->
        case Map.get(params.remote, name) do
          list when is_list(list) ->
            list
            |> Enum.sort_by(&remote_param_id/1)
            |> Enum.at(params.selected)

          _ ->
            nil
        end

      _ ->
        nil
    end
  end

  @doc """
  Returns `{min, max}` bounds for a remote parameter when the bridge
  carries them as flat `:min` / `:max` keys (matching `bb_liveview`'s
  shape), otherwise `nil`. Either bound may be `nil` to leave that side
  open.

  ## Examples

      iex> BB.TUI.State.remote_param_bounds(%{id: "X", value: 1, min: 0, max: 100})
      {0, 100}

      iex> BB.TUI.State.remote_param_bounds(%{id: "X", value: 1, min: 0})
      {0, nil}

      iex> BB.TUI.State.remote_param_bounds(%{id: "X", value: 1})
      nil
  """
  @spec remote_param_bounds(map()) :: {number() | nil, number() | nil} | nil
  def remote_param_bounds(%{} = param) do
    case {Map.get(param, :min), Map.get(param, :max)} do
      {nil, nil} -> nil
      bounds -> bounds
    end
  end

  @doc """
  Returns `{min, max}` bounds for the parameter at `path` when the
  Spark-style metadata declares them, otherwise `nil`.

  Looks at `state.parameters.metadata[path].type` for the standard
  `{head, opts}` shape used by `Spark.Options` and extracts the
  `:min` / `:max` keyword values. Either bound may be absent (returned
  as `nil`); both absent collapses to `nil` (no bounds).

  ## Examples

      iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{metadata: %{[:speed] => %{type: {:integer, [min: 0, max: 100]}}}}}
      iex> BB.TUI.State.parameter_bounds(state, [:speed])
      {0, 100}

      iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{metadata: %{[:gain] => %{type: {:float, [min: 0.0]}}}}}
      iex> BB.TUI.State.parameter_bounds(state, [:gain])
      {0.0, nil}

      iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{metadata: %{[:speed] => %{type: :integer}}}}
      iex> BB.TUI.State.parameter_bounds(state, [:speed])
      nil

      iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{metadata: %{[:speed] => %{type: {:integer, [doc: "rpm"]}}}}}
      iex> BB.TUI.State.parameter_bounds(state, [:speed])
      nil

      iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{metadata: %{}}}
      iex> BB.TUI.State.parameter_bounds(state, [:unknown])
      nil
  """
  @spec parameter_bounds(t(), list()) :: {number() | nil, number() | nil} | nil
  def parameter_bounds(%__MODULE__{parameters: %{metadata: meta}}, path) do
    case meta[path] do
      %{type: {head, opts}} when is_atom(head) and is_list(opts) ->
        case {Keyword.get(opts, :min), Keyword.get(opts, :max)} do
          {nil, nil} -> nil
          bounds -> bounds
        end

      _ ->
        nil
    end
  end

  @doc """
  Clamps a numeric value into `{min, max}` bounds. Either bound may be
  `nil` to leave that side open. A `nil` bounds tuple returns `value`
  unchanged.

  ## Examples

      iex> BB.TUI.State.clamp_to_bounds(5, {0, 10})
      5

      iex> BB.TUI.State.clamp_to_bounds(-3, {0, 10})
      0

      iex> BB.TUI.State.clamp_to_bounds(99, {0, 10})
      10

      iex> BB.TUI.State.clamp_to_bounds(99, {nil, 10})
      10

      iex> BB.TUI.State.clamp_to_bounds(-3, {0, nil})
      0

      iex> BB.TUI.State.clamp_to_bounds(7, nil)
      7
  """
  @spec clamp_to_bounds(number(), {number() | nil, number() | nil} | nil) :: number()
  def clamp_to_bounds(value, nil), do: value

  def clamp_to_bounds(value, {min, max}) do
    value
    |> apply_lower(min)
    |> apply_upper(max)
  end

  defp apply_lower(value, nil), do: value
  defp apply_lower(value, min) when value < min, do: min
  defp apply_lower(value, _min), do: value

  defp apply_upper(value, nil), do: value
  defp apply_upper(value, max) when value > max, do: max
  defp apply_upper(value, _max), do: value

  # ── Joint limit proximity ────────────────────────────────────

  @warning_threshold 0.15
  @danger_threshold 0.05

  @doc """
  Returns the proximity of a joint position to its nearest limit.

  Returns `:danger` when within #{@danger_threshold * 100}% of a limit,
  `:warning` when within #{@warning_threshold * 100}% of a limit,
  or `:normal` otherwise.

  Joints without limits always return `:normal`.

  ## Examples

      iex> joint = %{limits: %{lower: -1.0, upper: 1.0}}
      iex> BB.TUI.State.limit_proximity(0.0, joint)
      :normal

      iex> joint = %{limits: %{lower: -1.0, upper: 1.0}}
      iex> BB.TUI.State.limit_proximity(0.75, joint)
      :warning

      iex> joint = %{limits: %{lower: -1.0, upper: 1.0}}
      iex> BB.TUI.State.limit_proximity(0.96, joint)
      :danger

      iex> joint = %{limits: %{lower: -1.0, upper: 1.0}}
      iex> BB.TUI.State.limit_proximity(-0.96, joint)
      :danger

      iex> BB.TUI.State.limit_proximity(99.0, %{type: :continuous})
      :normal
  """
  @spec limit_proximity(number() | nil, map()) :: :normal | :warning | :danger
  def limit_proximity(nil, _joint), do: :normal

  def limit_proximity(pos, joint) do
    case joint_limits(joint) do
      {lower, upper} when upper > lower ->
        range = upper - lower
        dist_to_nearest = min((pos - lower) / range, (upper - pos) / range)

        cond do
          dist_to_nearest <= @danger_threshold -> :danger
          dist_to_nearest <= @warning_threshold -> :warning
          true -> :normal
        end

      _ ->
        :normal
    end
  end

  @doc false
  @spec joint_limits(map()) :: {number(), number()} | nil
  def joint_limits(%{limits: %{lower: lower, upper: upper}})
      when not is_nil(lower) and not is_nil(upper),
      do: {lower, upper}

  def joint_limits(_), do: nil
end