Skip to main content

lib/phoenix_gen_api_tui/state.ex

defmodule PhoenixGenApiTui.State do
  @moduledoc """
  State struct and pure navigation logic for the PhoenixGenApi TUI explorer.

  All state transitions are pure functions: `handle_key(state, key) -> state`.
  No side effects, no processes — just data transformation.
  """

  require Logger

  alias PhoenixGenApiTui.Format
  alias PhoenixGenApiTui.Introspection.ServiceInfo
  alias PhoenixGenApiTui.Telemetry

  defstruct [
    :introspection,
    :current_service,
    :current_function,
    :search_input,
    current_tab: :functions,
    nav_selected: 0,
    detail_selected: 0,
    focus: :nav,
    nav_stack: [],
    show_help: false,
    detail_overlay: nil,
    searching: false,
    search_query: "",
    last_error: nil,
    notice: nil
  ]

  @type tab ::
          :functions
          | :call_flows
          | :cluster
          | :health
          | :stats
          | :rate_limits
          | :errors
          | :system
          | :service_config

  @type t :: %__MODULE__{
          introspection: PhoenixGenApiTui.Introspection.t(),
          current_service: ServiceInfo.t() | nil,
          current_function: struct() | nil,
          current_tab: tab(),
          nav_selected: non_neg_integer(),
          detail_selected: non_neg_integer(),
          focus: :nav | :detail,
          nav_stack: [{atom(), tab()}],
          show_help: boolean(),
          detail_overlay: struct() | nil,
          search_input: reference() | nil,
          searching: boolean(),
          search_query: String.t(),
          last_error: String.t() | nil,
          notice: String.t() | nil
        }

  @tabs [
    :functions,
    :call_flows,
    :cluster,
    :health,
    :stats,
    :rate_limits,
    :errors,
    :system,
    :service_config
  ]

  @doc """
  Creates a new state from introspection data.

  ## Examples

      iex> introspection = %PhoenixGenApiTui.Introspection{
      ...>   services: [%PhoenixGenApiTui.Introspection.ServiceInfo{name: MyApp.Services.UserService, functions: []}],
      ...>   call_flows: [], cluster_view: %{}, health_check: %{}, statistics: %{}, rate_limits: %{},
      ...>   status: :ok, loaded_at: 0
      ...> }
      iex> state = PhoenixGenApiTui.State.new(introspection)
      iex> state.current_service.name
      MyApp.Services.UserService
      iex> state.current_tab
      :functions

      iex> state = PhoenixGenApiTui.State.new(%PhoenixGenApiTui.Introspection{services: [], call_flows: [], cluster_view: %{}, health_check: %{}, statistics: %{}, rate_limits: %{}})
      iex> state.current_service
      nil
  """
  @spec new(PhoenixGenApiTui.Introspection.t()) :: t()
  def new(%{services: []} = introspection) do
    %__MODULE__{introspection: introspection, current_service: nil, current_function: nil}
  end

  def new(introspection) do
    service = hd(introspection.services)

    %__MODULE__{
      introspection: introspection,
      current_service: service,
      current_function: List.first(service.functions)
    }
  end

  @doc """
  Refreshes the introspection data attached to the state.
  Returns a new state with updated data, preserving navigation context where possible.
  """
  @spec refresh(t()) :: t()
  def refresh(%__MODULE__{introspection: intro, current_service: current_svc} = state) do
    # Always force fresh data on explicit user refresh
    Telemetry.execute([:phoenix_gen_api_tui, :refresh, :start], %{}, %{})
    # Preserve the same node target (local or remote) on refresh
    intro_opts = if intro.node, do: [node: intro.node], else: []
    new_intro = PhoenixGenApiTui.Introspection.load!(intro_opts)
    Telemetry.execute([:phoenix_gen_api_tui, :refresh, :stop], %{}, %{status: new_intro.status})

    # Try to preserve the current service selection
    current_service =
      if current_svc do
        case Enum.find(new_intro.services, &(&1.name == current_svc.name)) do
          nil -> List.first(new_intro.services)
          found -> found
        end
      else
        List.first(new_intro.services)
      end

    %{
      state
      | introspection: new_intro,
        current_service: current_service,
        current_function: current_service && List.first(current_service.functions),
        notice: "Data refreshed"
    }
    |> reconcile_nav_selected()
  end

  @doc """
  Clears any notice or error message in the state.
  """
  @spec clear_notice(t()) :: t()
  def clear_notice(state), do: %{state | notice: nil, last_error: nil}

  # ── Navigation Items ────────────────────────────────────────

  @doc """
  Returns the list of items shown in the navigation panel.

  This is a flat list of services with their function counts.
  Supports fuzzy substring matching when searching.
  """
  @spec nav_items(t()) :: [ServiceInfo.t()]
  def nav_items(%__MODULE__{introspection: introspection} = state) do
    query = state.search_query
    all_services = get_all_services(introspection)

    if query == "" do
      all_services
    else
      fuzzy_filter(all_services, query)
    end
  end

  # Collects services from current introspection plus all connected nodes.
  defp get_all_services(introspection) do
    local_services = introspection.services
    cluster = introspection.cluster_view
    node_services = get_node_services(cluster[:phoenix_gen_api_services])
    connected_nodes = List.wrap(cluster[:connected])
    current_node = introspection.node

    # Gather service names from all connected nodes
    remote_service_names =
      connected_nodes
      |> Enum.flat_map(&(node_services[&1] || []))
      |> Enum.uniq()

    # Gather service names from current node
    current_service_names =
      current_node
      |> then(&(node_services[&1] || []))

    all_service_names =
      (current_service_names ++ remote_service_names ++ Enum.map(local_services, & &1.name))
      |> Enum.uniq()

    # Build ServiceInfo for each unique service name
    Enum.map(all_service_names, fn name ->
      existing = Enum.find(local_services, &(&1.name == name))

      if existing do
        existing
      else
        %ServiceInfo{name: name, functions: [], function_count: 0}
      end
    end)
  end

  defp get_node_services(services) when is_map(services), do: services
  defp get_node_services(_), do: %{}

  @doc """
  Returns the cached search query (updated on search input changes).
  """
  @spec search_query(t()) :: String.t()
  def search_query(%__MODULE__{search_query: q}), do: q

  # Fuzzy filter: matches characters in order, case-insensitive
  # Uses charlist traversal for O(n) single-pass matching.
  defp fuzzy_filter(services, query) do
    query_chars = String.downcase(query) |> String.to_charlist()

    Enum.filter(services, fn service ->
      name = service.name |> to_string() |> String.downcase() |> String.to_charlist()
      fuzzy_match?(name, query_chars)
    end)
  end

  # Single-pass charlist matching: O(n) where n = length of name
  defp fuzzy_match?(_name, []), do: true
  defp fuzzy_match?([], _query), do: false
  defp fuzzy_match?([h | name_rest], [h | query_rest]), do: fuzzy_match?(name_rest, query_rest)
  defp fuzzy_match?([_ | name_rest], query), do: fuzzy_match?(name_rest, query)

  @doc """
  Returns the items for the currently active detail tab.
  """
  @spec detail_items(t()) :: [term()]
  def detail_items(%__MODULE__{current_tab: :functions, current_service: nil}), do: []

  def detail_items(%__MODULE__{current_tab: :call_flows} = state),
    do: state.introspection.call_flows

  def detail_items(%__MODULE__{current_tab: :cluster}), do: []
  def detail_items(%__MODULE__{current_tab: :health}), do: []
  def detail_items(%__MODULE__{current_tab: :stats}), do: []
  def detail_items(%__MODULE__{current_tab: :rate_limits}), do: []

  def detail_items(%__MODULE__{current_tab: :errors} = state),
    do: state.introspection.failed_configs || []

  def detail_items(%__MODULE__{current_tab: :system} = state),
    do: state.introspection.system_info || []

  def detail_items(%__MODULE__{current_tab: :service_config}) do
    try do
      PhoenixGenApi.ConfigPuller.get_services()
      |> Map.values()
    catch
      :exit, _ -> []
    end
  end

  def detail_items(%__MODULE__{current_tab: :functions, current_service: service}) do
    service.functions
  end

  @doc """
  Returns the breadcrumb trail for the header.
  """
  @spec breadcrumb(t()) :: String.t()
  def breadcrumb(%__MODULE__{nav_stack: [], current_service: nil}), do: ""

  def breadcrumb(%__MODULE__{nav_stack: [], current_service: service}) do
    Format.short_name(service.name)
  end

  def breadcrumb(%__MODULE__{nav_stack: stack, current_service: service}) do
    trail =
      stack
      |> Enum.reverse()
      |> Enum.map(fn {service_name, _tab} -> Format.short_name(service_name) end)

    current = if service, do: Format.short_name(service.name), else: ""
    Enum.join(trail ++ [current], " > ")
  end

  # ── Key Handling ─────────────────────────────────────────────

  @doc """
  Processes a key press and returns the updated state.

  Handles vim-style navigation (`j`/`k`/`h`/`l`), tab switching (`tab`, `1`-`6`),
  enter/esc for selection and back-navigation, `?` for help, and `/` for search.
  """
  @spec handle_key(t(), String.t()) :: t()
  # Help overlay — any key closes it
  def handle_key(%__MODULE__{show_help: true} = state, _key) do
    %{state | show_help: false}
  end

  # Detail overlay — esc closes, other keys ignored
  def handle_key(%__MODULE__{detail_overlay: overlay} = state, "esc") when not is_nil(overlay) do
    %{state | detail_overlay: nil}
  end

  def handle_key(%__MODULE__{detail_overlay: overlay} = state, _key) when not is_nil(overlay) do
    state
  end

  # Search mode — handle input, enter, and esc
  def handle_key(%__MODULE__{searching: true} = state, "esc") do
    clear_search(state)
  end

  def handle_key(%__MODULE__{searching: true} = state, "enter") do
    %{state | searching: false, focus: :nav, nav_selected: 0}
  end

  def handle_key(%__MODULE__{searching: true, search_input: ref} = state, key) do
    ExRatatui.text_input_handle_key(ref, key)
    new_query = ExRatatui.text_input_get_value(ref)
    %{state | nav_selected: 0, search_query: new_query}
  end

  # Single-key commands
  def handle_key(state, "?"), do: %{state | show_help: true}

  def handle_key(%__MODULE__{search_input: ref} = state, "/") when not is_nil(ref),
    do: %{state | searching: true, focus: :nav}

  def handle_key(state, "r"), do: refresh(state)
  def handle_key(state, "tab"), do: next_tab(state)
  def handle_key(state, "enter"), do: handle_enter(state)
  def handle_key(state, "esc"), do: pop_nav_stack(state)

  # Vim navigation
  def handle_key(state, key) when key in ["j", "down"], do: move_selection(state, :down)
  def handle_key(state, key) when key in ["k", "up"], do: move_selection(state, :up)
  def handle_key(state, key) when key in ["h", "left"], do: %{state | focus: :nav}
  def handle_key(state, key) when key in ["l", "right"], do: %{state | focus: :detail}

  # Number keys for tab selection
  def handle_key(state, key) when key in ["1", "2", "3", "4", "5", "6", "7", "8", "9"] do
    idx = String.to_integer(key) - 1
    tab = Enum.at(@tabs, idx)
    switch_tab(state, tab)
  end

  # Fallback — ignore unknown keys
  def handle_key(state, _key), do: state

  # ── State Transitions ───────────────────────────────────────

  defp move_selection(%{focus: :nav} = state, direction) do
    items = nav_items(state)
    max_idx = max(length(items) - 1, 0)

    new_selected =
      case direction do
        :down -> min(state.nav_selected + 1, max_idx)
        :up -> max(state.nav_selected - 1, 0)
      end

    %{state | nav_selected: new_selected}
  end

  defp move_selection(%{focus: :detail} = state, direction) do
    items = detail_items(state)
    max_idx = max(length(items) - 1, 0)

    new_selected =
      case direction do
        :down -> min(state.detail_selected + 1, max_idx)
        :up -> max(state.detail_selected - 1, 0)
      end

    %{state | detail_selected: new_selected}
  end

  defp move_selection(state, _direction), do: state

  defp handle_enter(%{focus: :nav} = state) do
    items = nav_items(state)

    case Enum.at(items, state.nav_selected) do
      %ServiceInfo{} = service ->
        select_service(state, service)

      nil ->
        state
    end
  end

  defp handle_enter(%{focus: :detail, current_tab: :functions} = state) do
    case Enum.at(state.current_service.functions, state.detail_selected) do
      fun when not is_nil(fun) ->
        %{state | detail_overlay: fun}

      nil ->
        state
    end
  end

  defp handle_enter(%{focus: :detail, current_tab: :service_config} = state) do
    pulled_services = PhoenixGenApi.ConfigPuller.get_services() |> Map.values()
    service = Enum.at(pulled_services, state.detail_selected)
    if service, do: %{state | detail_overlay: service}, else: state
  catch
    :exit, _ -> state
  end

  defp handle_enter(state), do: state

  defp select_service(state, service) do
    %{
      state
      | current_service: service,
        current_function: List.first(service.functions),
        detail_selected: 0
    }
    |> reconcile_nav_selected()
  end

  defp pop_nav_stack(%{nav_stack: []} = state), do: state

  defp pop_nav_stack(%{nav_stack: [{service_name, tab} | rest]} = state) do
    service = Enum.find(state.introspection.services, &(&1.name == service_name))

    %{
      state
      | nav_stack: rest,
        current_service: service,
        current_function: service && List.first(service.functions),
        current_tab: tab,
        detail_selected: 0
    }
    |> reconcile_nav_selected()
  end

  defp next_tab(state) do
    current_idx = Enum.find_index(@tabs, &(&1 == state.current_tab))
    next_idx = rem(current_idx + 1, length(@tabs))
    switch_tab(state, Enum.at(@tabs, next_idx))
  end

  defp switch_tab(state, tab) when tab in @tabs do
    %{state | current_tab: tab, detail_selected: 0}
  end

  defp switch_tab(state, tab) do
    Logger.warning("Attempted to switch to unknown tab: #{inspect(tab)}")
    state
  end

  defp clear_search(%__MODULE__{search_input: ref} = state) when not is_nil(ref) do
    ExRatatui.text_input_set_value(ref, "")
    %{state | searching: false, nav_selected: 0, search_query: ""}
  end

  defp clear_search(state), do: %{state | searching: false, search_query: ""}

  # Expanding a different service (or popping the nav stack) reshapes the visible
  # list, so a previously valid `nav_selected` can fall off the shorter list.
  # Clamp it back into range.
  defp reconcile_nav_selected(state) do
    max_idx = max(length(nav_items(state)) - 1, 0)
    %{state | nav_selected: min(state.nav_selected, max_idx)}
  end
end