Skip to main content

lib/bb/tui/panels/parameters.ex

defmodule BB.TUI.Panels.Parameters do
  @moduledoc """
  Parameters panel — displays robot parameters grouped by path.

  Renders a tab strip in the title when remote bridges have been
  discovered. The `Local` tab shows parameters from `state.parameters.list`
  (with schema metadata from `state.parameters.metadata`). Bridge tabs
  show entries from `state.parameters.remote[bridge_name]`, which the
  app populates by calling `BB.Parameter.list_remote/2` whenever the
  user switches to that tab.

  Pure function — takes state, returns a widget struct.
  """

  alias BB.TUI.State
  alias BB.TUI.Theme
  alias ExRatatui.Style
  alias ExRatatui.Text.Line
  alias ExRatatui.Text.Span
  alias ExRatatui.Widgets.Block
  alias ExRatatui.Widgets.Table

  @doc """
  Renders the parameters panel as a Table widget. Columns depend on the
  selected tab: the local tab shows `Parameter | Value | Type`, while a
  bridge tab shows `Parameter | Value | Type` populated from the remote
  fetch result.

  ## Examples

      iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{list: [{[:speed], 100}, {[:controller, :kp], 0.5}]}}
      iex> %ExRatatui.Widgets.Table{header: header} = BB.TUI.Panels.Parameters.render(state, false)
      iex> header
      ["Parameter", "Value", "Type"]

      iex> state = %BB.TUI.State{parameters: %BB.TUI.State.Parameters{list: []}}
      iex> %ExRatatui.Widgets.Table{rows: rows} = BB.TUI.Panels.Parameters.render(state, false)
      iex> rows
      [["No parameters defined", "", ""]]
  """
  @spec render(State.t(), boolean()) :: struct()
  def render(state, focused?) do
    tab = State.selected_parameter_tab(state)
    {rows, count} = rows_and_count(state, tab)

    %Table{
      rows: rows,
      header: ["Parameter", "Value", "Type"],
      widths: [
        {:percentage, 45},
        {:percentage, 30},
        {:percentage, 25}
      ],
      selected: if(focused? and selectable_rows?(rows), do: state.parameters.selected),
      highlight_style: Theme.highlight_style(),
      highlight_symbol: "\u{25B6} ",
      block: %Block{
        title: title_line(state.parameters.tabs, state.parameters.tab_selected, count),
        borders: [:all],
        border_type: :rounded,
        border_style: Theme.border_style(focused?)
      }
    }
  end

  defp rows_and_count(state, :local) do
    case state.parameters.list do
      [] ->
        {[["No parameters defined", "", ""]], 0}

      params ->
        sorted = Enum.sort_by(params, fn {path, _} -> path end)

        rows =
          Enum.map(sorted, fn {path, value} ->
            [
              format_path(path),
              format_value(value) <> edit_hint(value),
              format_type(state.parameters.metadata[path])
            ]
          end)

        {rows, length(sorted)}
    end
  end

  defp rows_and_count(state, {:bridge, name}) do
    case state.parameters.remote[name] do
      nil ->
        {[["Loading…", "", ""]], 0}

      {:error, reason} ->
        {[["Error: #{inspect(reason)}", "", ""]], 0}

      [] ->
        {[["No remote parameters", "", ""]], 0}

      params when is_list(params) ->
        sorted = Enum.sort_by(params, &State.remote_param_id/1)
        rows = Enum.map(sorted, &remote_row/1)
        {rows, length(sorted)}
    end
  end

  defp remote_row(param) do
    [
      State.remote_param_id(param),
      param |> Map.get(:value) |> format_value() |> append_edit_hint(param),
      format_remote_type(param)
    ]
  end

  defp append_edit_hint(value_str, %{value: v}) when is_number(v) or is_boolean(v),
    do: value_str <> edit_hint(v)

  defp append_edit_hint(value_str, _), do: value_str

  defp format_remote_type(%{type: type}) when is_atom(type) and not is_nil(type),
    do: inspect(type)

  defp format_remote_type(%{type: type}) when is_binary(type), do: type
  defp format_remote_type(_), do: "—"

  defp selectable_rows?([[only_label | _]])
       when only_label in ["No parameters defined", "Loading…", "No remote parameters"],
       do: false

  defp selectable_rows?([[label | _]]) when is_binary(label) do
    not String.starts_with?(label, "Error: ")
  end

  defp selectable_rows?(_), do: true

  @doc """
  Formats a parameter's Spark-declared type for the Type column.

  Returns `"—"` when no schema metadata is present. Atom types render as
  `":float"`; option-tagged types like `{:integer, [min: 0, max: 100]}`
  render as their head atom — the bounds belong in the (future) edit
  popup, not in a one-line table cell.

  ## Examples

      iex> BB.TUI.Panels.Parameters.format_type(nil)
      "—"

      iex> BB.TUI.Panels.Parameters.format_type(%{type: nil})
      "—"

      iex> BB.TUI.Panels.Parameters.format_type(%{type: :float})
      ":float"

      iex> BB.TUI.Panels.Parameters.format_type(%{type: {:integer, [min: 0, max: 100]}})
      ":integer"

      iex> BB.TUI.Panels.Parameters.format_type(%{type: {:custom, MyMod, :validate, []}})
      "{:custom, MyMod, :validate, []}"

      iex> BB.TUI.Panels.Parameters.format_type(%{})
      "—"
  """
  @spec format_type(map() | nil) :: String.t()
  def format_type(nil), do: "—"
  def format_type(%{type: nil}), do: "—"
  def format_type(%{type: type}) when is_atom(type), do: inspect(type)
  def format_type(%{type: {head, opts}}) when is_atom(head) and is_list(opts), do: inspect(head)
  def format_type(%{type: other}), do: inspect(other)
  def format_type(_), do: "—"

  @doc """
  Returns an edit hint suffix indicating how a parameter can be edited.

  ## Examples

      iex> BB.TUI.Panels.Parameters.edit_hint(42)
      " [h/l]"

      iex> BB.TUI.Panels.Parameters.edit_hint(3.14)
      " [h/l]"

      iex> BB.TUI.Panels.Parameters.edit_hint(true)
      " [enter]"

      iex> BB.TUI.Panels.Parameters.edit_hint(:fast)
      ""
  """
  @spec edit_hint(term()) :: String.t()
  def edit_hint(val) when is_number(val), do: " [h/l]"
  def edit_hint(val) when is_boolean(val), do: " [enter]"
  def edit_hint(_val), do: ""

  @doc """
  Builds the panel title as a rich `%Line{}` carrying the tab strip.

  Single-tab (local-only) state renders as ` Parameters (N) ` with a
  bold-cyan count. Multi-tab state renders ` Parameters · Local | mavlink ` etc.,
  with the active tab bold-cyan. The `t` shortcut to cycle tabs is
  surfaced in the bottom status bar.

  ## Examples

      iex> %ExRatatui.Text.Line{spans: spans} =
      ...>   BB.TUI.Panels.Parameters.title_line([:local], 0, 5)
      iex> Enum.map_join(spans, "", & &1.content)
      "  5  Parameters (5) "

      iex> %ExRatatui.Text.Line{spans: spans} =
      ...>   BB.TUI.Panels.Parameters.title_line([:local], 0, 0)
      iex> Enum.map_join(spans, "", & &1.content)
      "  5  Parameters "

      iex> %ExRatatui.Text.Line{spans: spans} =
      ...>   BB.TUI.Panels.Parameters.title_line([:local, {:bridge, :mavlink}], 1, 12)
      iex> Enum.map_join(spans, "", & &1.content)
      "  5  Parameters · Local | mavlink (12) "
  """
  @spec title_line([atom() | {:bridge, atom()}], non_neg_integer(), non_neg_integer()) :: Line.t()
  def title_line([:local], _idx, 0) do
    %Line{spans: badge() ++ [%Span{content: "Parameters ", style: Theme.panel_title_style()}]}
  end

  def title_line([:local], _idx, count) do
    %Line{
      spans:
        badge() ++
          [
            %Span{content: "Parameters (", style: Theme.panel_title_style()},
            %Span{
              content: Integer.to_string(count),
              style: %Style{fg: Theme.cyan(), modifiers: [:bold]}
            },
            %Span{content: ") ", style: Theme.panel_title_style()}
          ]
    }
  end

  def title_line(tabs, idx, count) do
    tab_spans =
      tabs
      |> Enum.with_index()
      |> Enum.flat_map(fn {tab, i} -> tab_span(tab, i == idx, count) end)
      |> drop_trailing_separator()

    %Line{
      spans:
        badge() ++
          [%Span{content: "Parameters · ", style: Theme.panel_title_style()}] ++
          tab_spans ++ [%Span{content: " ", style: %Style{}}]
    }
  end

  defp badge, do: Theme.panel_badge_spans(State.panel_number(:parameters))

  defp tab_span(:local, active?, count), do: labeled_span("Local", active?, count) ++ separator()

  defp tab_span({:bridge, name}, active?, count),
    do: labeled_span(Atom.to_string(name), active?, count) ++ separator()

  defp labeled_span(label, true, count) when count > 0 do
    [
      %Span{
        content: "#{label} (#{count})",
        style: %Style{fg: Theme.cyan(), modifiers: [:bold]}
      }
    ]
  end

  defp labeled_span(label, true, _count) do
    [
      %Span{
        content: label,
        style: %Style{fg: Theme.cyan(), modifiers: [:bold]}
      }
    ]
  end

  defp labeled_span(label, false, _count) do
    [%Span{content: label, style: %Style{fg: Theme.dim_text()}}]
  end

  defp separator, do: [%Span{content: " | ", style: %Style{fg: Theme.dim_text()}}]

  # tab_span/3 always emits a `" | "` separator after the tab label, so
  # the last entry in the flat-mapped span list is guaranteed to be a
  # separator we can drop unconditionally.
  defp drop_trailing_separator(spans), do: Enum.drop(spans, -1)

  @doc """
  Formats a parameter path list as a dot-separated string.

  ## Examples

      iex> BB.TUI.Panels.Parameters.format_path([:controller, :kp])
      "controller.kp"

      iex> BB.TUI.Panels.Parameters.format_path([:speed])
      "speed"
  """
  @spec format_path(list()) :: String.t()
  def format_path(path) when is_list(path) do
    Enum.map_join(path, ".", &to_string/1)
  end

  @doc """
  Formats a parameter value for display.

  ## Examples

      iex> BB.TUI.Panels.Parameters.format_value(42)
      "42"

      iex> BB.TUI.Panels.Parameters.format_value(3.14159)
      "3.142"

      iex> BB.TUI.Panels.Parameters.format_value(true)
      "true"

      iex> BB.TUI.Panels.Parameters.format_value(:fast)
      ":fast"
  """
  @spec format_value(term()) :: String.t()
  def format_value(val) when is_float(val) do
    :erlang.float_to_binary(val, decimals: 3)
  end

  def format_value(val) when is_integer(val), do: Integer.to_string(val)
  def format_value(val) when is_boolean(val), do: to_string(val)
  def format_value(val) when is_atom(val), do: inspect(val)
  def format_value(val), do: inspect(val, pretty: false, limit: 30)
end